Tighten comments in the image stack routes

This commit is contained in:
Daniel Han 2026-07-12 12:00:44 +00:00
commit 3a6a007038
3 changed files with 281 additions and 336 deletions

View file

@ -3756,8 +3756,8 @@ def _guard_chat_load_against_training(
if not llm_active:
# An SDXL LoRA trainer runs in its own subprocess and its VRAM can't be cheaply
# fit-checked here, so refuse the chat load outright while one is active rather
# than risk OOMing the run. Symmetric with the image-load guard.
# fit-checked here, so refuse the chat load while one is active rather than risk
# OOMing the run. Symmetric with the image-load guard.
if _diffusion_training_active():
raise HTTPException(
status_code = 409,
@ -3913,9 +3913,9 @@ async def _load_model_impl(request: LoadRequest, fastapi_request: Request, curre
# Reclaim the GPU for chat (evicting a resident Images/Video pipeline) only once the
# load is known viable: the already-loaded fast paths below re-assert CHAT ownership
# themselves, and the real handoff is deferred past identifier / gpu_ids / training-memory
# validation so a doomed chat load (bad id, unsupported gpu_ids on GGUF, or a training
# 409) can't evict a working image/video model and then error. Mirrors the image/video
# loaders, which validate before acquire_for.
# validation so a doomed load (bad id, unsupported gpu_ids on GGUF, training 409) can't
# evict a working image/video model and then error. Mirrors the image/video loaders,
# which validate before acquire_for.
from core.inference.gpu_arbiter import acquire_for, CHAT
# ── Already-loaded check: skip reload if the exact model is active ──
@ -3951,9 +3951,9 @@ async def _load_model_impl(request: LoadRequest, fastapi_request: Request, curre
_gguf_audio = getattr(llama_backend, "_audio_type", None)
_gguf_is_audio = getattr(llama_backend, "_is_audio", False)
# The requested GGUF chat model is already resident: assert CHAT ownership (a
# no-op when it already holds it) so a drifted arbiter owner is corrected. This
# is a guaranteed-success path, not a doomed load, so evicting here is correct.
# Requested GGUF chat model already resident: assert CHAT ownership (no-op when
# held) to correct a drifted arbiter owner. Guaranteed-success path, so evicting
# here is correct.
await asyncio.to_thread(acquire_for, CHAT)
return LoadResponse(
status = "already_loaded",
@ -4007,9 +4007,9 @@ async def _load_model_impl(request: LoadRequest, fastapi_request: Request, curre
_sf_flags = _detect_safetensors_features(backend, _chat_template)
_sf_supports_reasoning = _sf_flags["supports_reasoning"]
_sf_reasoning_style = _sf_flags["reasoning_style"]
# The requested chat model is already resident: assert CHAT ownership (no-op when
# it already holds it) to correct a drifted arbiter owner. Guaranteed-success
# path, not a doomed load, so evicting here is correct.
# Requested chat model already resident: assert CHAT ownership (no-op when held)
# to correct a drifted arbiter owner. Guaranteed-success path, so evicting here
# is correct.
await asyncio.to_thread(acquire_for, CHAT)
return LoadResponse(
status = "already_loaded",
@ -4094,11 +4094,10 @@ async def _load_model_impl(request: LoadRequest, fastapi_request: Request, curre
n_parallel = getattr(fastapi_request.app.state, "llama_parallel_slots", 1),
)
# The load is now known viable (valid identifier, gpu_ids ok, fits alongside any active
# Load now known viable (valid identifier, gpu_ids ok, fits alongside any active
# training): reclaim the GPU for chat, evicting a resident Images/Video pipeline. Doing
# this only here -- not before the validation above -- is what keeps a doomed chat load
# from evicting a working image/video model and then erroring. No-op when chat already
# owns the GPU.
# this only here -- not before the validation above -- keeps a doomed load from evicting
# a working image/video model and then erroring. No-op when chat already owns the GPU.
await asyncio.to_thread(acquire_for, CHAT)
# ── GGUF path: load via llama-server ──────────────────────
@ -14236,10 +14235,9 @@ async def _openai_passthrough_non_streaming_upstream(
# ──────────────────────────────────────────────────────────────────────────
# Diffusion (local text-to-image)
#
# Studio-only routes (studio_router is not mounted under /v1). The diffusion
# backend runs in-process and is synchronous, so the blocking load/generate/
# unload calls are offloaded with asyncio.to_thread to keep the event loop free.
# This is the single error boundary: backend methods raise, we map to HTTP here.
# Studio-only routes (studio_router is not mounted under /v1). The diffusion backend
# runs in-process and synchronously, so blocking load/generate/unload calls are
# offloaded with asyncio.to_thread. Single error boundary: backend raises, we map to HTTP.
# ──────────────────────────────────────────────────────────────────────────
@ -14265,9 +14263,9 @@ def _guard_diffusion_load_against_training() -> None:
except Exception as e:
logger.warning("Could not check training state for image-load guard: %s", e)
return
# An SDXL LoRA trainer runs in its own subprocess on the same GPU, so an image
# load must be refused while one is active too -- otherwise the resident pipeline
# competes with the trainer for VRAM. Symmetric with the diffusion-start interlock.
# An SDXL LoRA trainer runs in its own subprocess on the same GPU, so an image load
# must be refused while one is active too, or the pipeline contends with the trainer
# for VRAM. Symmetric with the diffusion-start interlock.
if not llm_active and not _diffusion_training_active():
return
raise HTTPException(
@ -14303,19 +14301,18 @@ async def load_diffusion_model(
# engine selection, and the load all agree. A bad explicit kind raises here -> 400.
kind = resolve_model_kind(request.gguf_filename, request.model_kind)
# A local On-Device pick can be a bare single-file .safetensors directory (no
# model_index.json): the scanner advertises it as a text-to-image model, but the local
# picker starts it as a pipeline with no filename, so a pipeline load would 400 on the
# missing model_index.json and the advertised model is unusable. If the directory holds
# exactly one checkpoint, reinterpret the pick as a single_file load of it (the only
# loadable shape for that dir), so validation, engine selection, and the load all agree.
# model_index.json): the scanner advertises it as text-to-image, but the picker starts
# it as a pipeline with no filename, so a pipeline load would 400 on the missing
# model_index.json. If the directory holds exactly one checkpoint, reinterpret the pick
# as a single_file load of it (its only loadable shape), so all three paths agree.
if kind == "pipeline" and not request.gguf_filename:
sole = await asyncio.to_thread(resolve_local_single_file, request.model_path)
if sole is not None:
request.gguf_filename = sole
kind = resolve_model_kind(sole)
# Validate cheaply BEFORE touching the GPU: an unloadable pick (bad family,
# missing local GGUF, a non-unsloth non-GGUF repo) must not evict a working chat
# model and then 400. The validated family also drives engine selection below.
# Validate cheaply BEFORE touching the GPU: an unloadable pick (bad family, missing
# local GGUF, non-unsloth non-GGUF repo) must not evict a working chat model and then
# 400. The validated family also drives engine selection below.
fam = await asyncio.to_thread(
backend.validate_load_request,
request.model_path,
@ -14329,18 +14326,16 @@ async def load_diffusion_model(
# same via _guard_chat_load_against_training; this is its image sibling.
_guard_diffusion_load_against_training()
# Pick the engine for this host (diffusers on GPU, native sd.cpp with no GPU),
# installing the sd-cli binary if needed -- all BEFORE evicting chat, so a
# native fallback never strands a half-loaded state. Non-GGUF kinds force diffusers.
# installing the sd-cli binary if needed -- all BEFORE evicting chat, so a native
# fallback never strands a half-loaded state. Non-GGUF kinds force diffusers.
engine = await asyncio.to_thread(
select_and_activate_engine, fam, hf_token = request.hf_token, model_kind = kind
)
# Take the GPU from the chat backend only when this load will actually use it,
# which is exactly the resolved device being non-CPU. diffusers on an accelerator
# and a force-native sd.cpp load on CUDA/XPU/MPS both resolve to that device; a
# native sd.cpp load on a pure-CPU host does not. Crucially, a CPU-only host with
# no usable sd-cli falls back to diffusers ON CPU -- that also never touches GPU
# memory, so keying off the engine name (not the device) would wrongly evict a
# resident chat model for a load that cannot use the GPU. Gate on the device.
# Take the GPU from chat only when this load will actually use it, i.e. the resolved
# device is non-CPU. diffusers on an accelerator and a force-native sd.cpp load on
# CUDA/XPU/MPS both resolve to a device; a native sd.cpp load on a pure-CPU host, and a
# CPU-only host falling back to diffusers ON CPU, do not. So gate on the device, not the
# engine name -- else we'd evict a resident chat model for a load that can't use the GPU.
device = await asyncio.to_thread(lambda: resolve_diffusion_device_target().device)
needs_gpu = device != "cpu"
if needs_gpu:
@ -14350,10 +14345,9 @@ async def load_diffusion_model(
else:
# A CPU-only native load never touches the GPU, so it neither acquires nor is
# tracked by the arbiter. But switching here FROM a previous diffusers/GPU load
# (select_and_activate_engine unloaded it above) leaves DIFFUSION still marked
# as the arbiter owner; a later chat acquire would then "evict" this CPU model
# for no reason. Release that stale ownership -- release() is owner-guarded, so
# it is a no-op when diffusion never owned the GPU.
# leaves DIFFUSION still marked as arbiter owner, so a later chat acquire would
# "evict" this CPU model for no reason. Release that stale ownership -- release()
# is owner-guarded, so it's a no-op when diffusion never owned the GPU.
await asyncio.to_thread(release, DIFFUSION)
status_dict = await asyncio.to_thread(
engine.begin_load,
@ -14429,13 +14423,11 @@ async def generate_diffusion_image(
# doesn't support) — a 400 with the reason, not a generic 500.
raise HTTPException(status_code = 400, detail = str(exc))
except RuntimeError as exc:
# Only "no model loaded" / user-cancelled are client-state (409); both engines
# raise these two EXACT messages. The native sd.cpp engine also raises
# RuntimeError for execution failures (nonzero exit, timeout, missing output)
# whose text can embed the raw sd-cli tail (local paths / argv) -- those are
# server errors (500) returned as a fixed literal, never echoed. Match the
# sentinels exactly, not as a substring, so an sd-cli failure that merely
# contains "cancelled" can't misroute to 409 and leak that output.
# Only "no model loaded" / user-cancelled are client-state (409); both engines raise
# these two EXACT messages. The native sd.cpp engine also raises RuntimeError for
# execution failures whose text can embed the raw sd-cli tail (local paths / argv) --
# those are 500s returned as a fixed literal, never echoed. Match the sentinels exactly
# (not as substrings) so an sd-cli failure containing "cancelled" can't misroute to 409.
msg = str(exc)
if msg in (DIFFUSION_NOT_LOADED_MSG, DIFFUSION_CANCELLED_MSG):
raise HTTPException(status_code = 409, detail = msg)
@ -14445,9 +14437,9 @@ async def generate_diffusion_image(
logger.error("diffusion.generate_failed: %s", exc, exc_info = True)
raise HTTPException(status_code = 500, detail = "Image generation failed.")
# Persist each image with its full recipe embedded. The diffusers batch shares
# one seed (drawn sequentially from one generator); the native sd.cpp batch uses a
# distinct seed per image and returns them in ``seeds`` so each is reproducible.
# Persist each image with its full recipe embedded. The diffusers batch shares one seed
# (drawn sequentially from one generator); the native sd.cpp batch uses a distinct seed
# per image, returned in ``seeds`` so each is reproducible.
created_at = time.time()
per_image_seeds = result.get("seeds")
@ -14465,21 +14457,20 @@ async def generate_diffusion_image(
{
"prompt": request.prompt,
"negative_prompt": request.negative_prompt,
# Persist the ACTUAL output size, not the request sliders: Transform/
# Inpaint/Edit derive it from the uploaded image, Extend grows the
# canvas, and Upscale resizes it, so request.width/height would record
# (and later restore) the wrong dimensions for those workflows. For
# plain txt2img the image size equals the sliders anyway.
# Persist the ACTUAL output size, not the request sliders:
# Transform/Inpaint/Edit derive it from the uploaded image, Extend grows
# the canvas, Upscale resizes it, so request.width/height would record the
# wrong dims. For plain txt2img the size equals the sliders anyway.
"width": getattr(image, "width", None) or request.width,
"height": getattr(image, "height", None) or request.height,
"steps": request.steps,
"guidance": request.guidance,
"seed": seed,
# Position within the batch: shared timestamp, so the export
# filename needs this to stay unique.
# Position within the batch (shared timestamp), so the export filename
# stays unique.
"batch_index": index,
# The batch shares one seed, so reproducing image batch_index>0
# needs the original batch_size: persist it so restore can replay.
# The batch shares one seed, so reproducing a batch_index>0 image needs
# the original batch_size: persist it so restore can replay.
"batch_size": request.batch_size,
"model": result.get("repo_id"),
"loras": (
@ -14488,9 +14479,8 @@ async def generate_diffusion_image(
"controlnet": (
f"{request.controlnet.id}:{request.controlnet.control_type}:"
f"{request.controlnet.strength:g}"
# strength 0 is treated as disabled and skipped before loading /
# conditioning, so the image is unconditioned; don't claim a
# ControlNet was applied in the recipe/metadata.
# strength 0 is disabled and skipped before loading/conditioning,
# so don't claim a ControlNet was applied in the recipe/metadata.
if request.controlnet and request.controlnet.strength > 0
else None
),
@ -14524,11 +14514,9 @@ async def list_gallery_images(
# Fetch one extra to learn whether more remain, without a second scan.
records = await asyncio.to_thread(image_gallery.list_images, limit + 1, offset)
has_more = len(records) > limit
# Build the response per record and drop any that fail schema validation: a PNG
# whose recipe chunk has all required keys but a wrong value type (e.g. a
# hand-dropped or corrupted file) passes the presence-only read but would raise
# inside GalleryImage(**r). Skipping it keeps one bad file from 500-ing the whole
# gallery listing.
# Drop records that fail schema validation: a PNG whose recipe chunk has all keys but a
# wrong value type (hand-dropped or corrupted) passes the presence-only read yet raises
# inside GalleryImage(**r). Skipping it keeps one bad file from 500-ing the listing.
images = []
for r in records[:limit]:
try:
@ -14581,11 +14569,10 @@ async def unload_diffusion_model(current_subject: str = Depends(get_current_subj
status_dict = await asyncio.to_thread(get_active_diffusion_engine().unload)
# Drop DIFFUSION ownership only if nothing is resident AND no new load is in flight: a
# concurrent /images/load that re-acquired DIFFUSION while this (slow) unload ran must keep
# ownership, or a later chat load would see no owner, skip eviction, and OOM against the newly
# ownership, or a later chat load would see no owner, skip eviction, and OOM the newly
# resident pipeline. An in-flight load has is_loaded False for its whole download/finalize
# window, so gate on loading_repo_ids() too (both engines expose it), not just the committed
# state. release() is owner-guarded and identity-less, so an unconditional release here would
# clear the newer load's claim.
# window, so gate on loading_repo_ids() too, not just committed state. release() is
# owner-guarded and identity-less, so an unconditional release would clear the newer claim.
engine = get_active_diffusion_engine()
if not engine.loading_repo_ids() and not engine.is_loaded:
release(DIFFUSION)
@ -14623,20 +14610,17 @@ async def diffusion_generate_progress(current_subject: str = Depends(get_current
# ──────────────────────────────────────────────────────────────────────────
# 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).
# The inference router is mounted at both /api/inference and /v1, so this also answers
# /v1/images/generations for off-the-shelf OpenAI clients, mapping CreateImageRequest onto
# the in-process diffusion backend. Studio's Image tab uses the richer /images/generate
# 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).
# ──────────────────────────────────────────────────────────────────────────
# 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.
# 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
@ -14702,9 +14686,8 @@ async def openai_image_generations(
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.
# Use the active engine (diffusers OR native sd.cpp on a no-GPU host), the same accessor
# /images/generate uses, so a native-engine model isn't wrongly reported unloaded here.
backend = get_active_diffusion_engine()
status = backend.status()
if not status.get("loaded"):
@ -14712,9 +14695,8 @@ async def openai_image_generations(
# isn't loaded; the global handler turns this into the OpenAI envelope.
raise HTTPException(status_code = 503, detail = _NO_IMAGE_MODEL_MSG)
# An edit-only model (Qwen-Image-Edit, FLUX Kontext) needs an input image this API
# cannot supply; refuse up front with a 400 instead of letting the backend's
# ValueError surface as a sanitized 500.
# An edit-only model (Qwen-Image-Edit, FLUX Kontext) needs an input image this API can't
# supply; refuse up front with a 400 rather than let the backend ValueError become a 500.
workflows = status.get("workflows") or []
if workflows and "txt2img" not in workflows:
raise HTTPException(
@ -14741,10 +14723,9 @@ async def openai_image_generations(
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.
# A RuntimeError with the model now unloaded means it was evicted between the readiness
# check and the call (a transient race): 503. Every other failure (CUDA OOM, a diffusers
# shape/device error) is a real 500 whose 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)
@ -14752,8 +14733,8 @@ async def openai_image_generations(
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.
# Persist each image with its full recipe, like /images/generate, so response_format=url
# links resolve and the images show up in the gallery.
recipe = {
"prompt": body.prompt,
"negative_prompt": None,
@ -14762,14 +14743,14 @@ async def openai_image_generations(
"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.
# original batch_size to replay (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.
# 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]:

View file

@ -28,18 +28,15 @@ class CachedModelRepo(BaseModel):
repo_id: str
size_bytes: int
last_modified: Optional[float] = None
# "text-to-image" for cached diffusers image repos; response_model would silently
# drop the value the handler sets, letting image-only repos pass the chat picker's
# task gate.
# "text-to-image" for cached diffusers image repos; declared here or response_model
# drops it, letting image-only repos pass the chat picker's task gate.
task: Optional[str] = None
# True when the snapshot is incomplete (a cancelled/partial download left only some
# weights). The picker must not treat a partial base repo as a usable download, or an
# On Device click routes to a fresh multi-GB re-download instead of the complete GGUF.
# True when the snapshot is incomplete (cancelled/partial download): the picker must
# not treat it as usable, or an On Device click re-downloads the full GGUF.
partial: Optional[bool] = None
# True for a diffusion-tagged repo with NO top-level model_index.json: a single-file
# checkpoint that needs from_single_file + a filename. The task-scoped pickers must not
# offer it as a pipeline load (from_pretrained on it fails after the GPU handoff)
# unless the curated catalog carries its artifact.
# checkpoint needing from_single_file + a filename. Pickers must not offer it as a
# pipeline load (from_pretrained fails) unless the curated catalog carries its artifact.
single_file: Optional[bool] = None
@ -919,7 +916,7 @@ async def list_local_models(
try:
models = collect_local_models(models_root)
# Tag each model with its task so the Images picker can filter to diffusion
# (GGUF by architecture; local diffusers checkpoints by pipeline / family).
# (GGUF by architecture; local checkpoints by pipeline / family).
models = [m.model_copy(update = {"task": _local_model_task(m)}) for m in models]
return LocalModelListResponse(
@ -3153,16 +3150,14 @@ def _repo_gguf_last_modified(repo_info) -> float:
return latest
# GGUF general.architecture values that denote a diffusion (image) model;
# everything else is treated as a text model. Lets the Images picker show only
# image GGUFs in its On Device list.
# GGUF general.architecture values that denote a diffusion (image) model (everything
# else is text); lets the Images picker show only image GGUFs in its On Device list.
_DIFFUSION_GGUF_ARCHS = frozenset(
{
# ONLY the families the diffusion backend can actually assemble (see
# diffusion_families._FAMILIES). Other on-device diffusion archs (SD1/2/3,
# SDXL, PixArt, Lumina2, AuraFlow, Wan, HunyuanVideo, ...) would pass this
# Images-picker filter and then fail validate_load with a 400, so they are
# deliberately excluded until the backend supports them.
# ONLY the families the diffusion backend can assemble (see
# diffusion_families._FAMILIES). Other diffusion archs (SD1/2/3, SDXL,
# PixArt, Lumina2, AuraFlow, Wan, HunyuanVideo, ...) would pass this filter
# then 400 in validate_load, so they stay excluded until the backend supports them.
"flux", # flux.1
"flux2", # flux.2-klein
"qwen_image", # qwen-image
@ -3172,13 +3167,11 @@ _DIFFUSION_GGUF_ARCHS = frozenset(
}
)
# Known diffusion / image-video GGUF archs the backend can NOT assemble yet. These
# are the GGUF general.architecture values llama.cpp also has no architecture for,
# kept in sync with core.inference.llama_cpp.LlamaCppBackend._DIFFUSION_ARCHES
# (minus the loadable set above). Tagging them with a dedicated, non-loadable task
# keeps them OUT of the chat picker -- loading one as a chat model dies with
# "unknown model architecture" -- while also keeping them out of the Images picker
# (the task is not an IMAGE_GEN_TASK), where they would 400 in validate_load.
# Diffusion / image-video GGUF archs the backend can NOT assemble yet (llama.cpp also
# lacks an architecture for them); kept in sync with
# core.inference.llama_cpp.LlamaCppBackend._DIFFUSION_ARCHES minus the loadable set above.
# A dedicated non-loadable task keeps them out of the chat picker (they die with
# "unknown model architecture") and out of Images (not an IMAGE_GEN_TASK; would 400).
_UNSUPPORTED_DIFFUSION_GGUF_ARCHS = frozenset(
{
"sd1",
@ -3218,15 +3211,13 @@ def _arch_to_task(arch: Optional[str], name_hints: tuple[Optional[str], ...] = (
if a in _DIFFUSION_GGUF_ARCHS:
return "text-to-image"
if a in _VIDEO_GGUF_ARCHS:
# Advertise as loadable video only when a VideoFamily actually resolves. Some archs map
# straight from the arch (ltxv); others are ambiguous at the arch level -- bare "wan"
# covers both the single-DiT TI2V-5B (GGUF-loadable) and the dual-expert A14B MoE whose
# single file the loader refuses -- so when the bare arch does not resolve, fall back to
# the repo/file names like the loader's own detect_video_family does (each tried
# separately, since it matches on name segments not substrings), and surface only a
# non-MoE (loadable) match. Without a name we cannot disambiguate, so a bare-arch Wan
# GGUF (which the loader also cannot resolve) stays in the unsupported bucket rather than
# advertising a GGUF that would 400 on load.
# Advertise as loadable video only when a VideoFamily resolves. Some archs map
# straight from the arch (ltxv); bare "wan" is ambiguous -- it covers both the
# GGUF-loadable single-DiT TI2V-5B and the dual-expert A14B MoE the loader refuses --
# so when the bare arch doesn't resolve, fall back to repo/file names (each tried
# separately, matching name segments not substrings) like the loader's own
# detect_video_family, surfacing only a non-MoE match. Without a name we can't
# disambiguate, so a bare-arch Wan GGUF stays unsupported rather than 400ing on load.
from core.inference.video_families import detect_video_family
fam = detect_video_family("", override = a)
@ -3239,8 +3230,8 @@ def _arch_to_task(arch: Optional[str], name_hints: tuple[Optional[str], ...] = (
if fam is not None and not getattr(fam, "is_moe", False):
return _VIDEO_GEN_TASK
return _UNSUPPORTED_DIFFUSION_TASK
# A diffusion arch the backend can't assemble: hide it from chat (it would die
# in llama.cpp) without surfacing it in Images (it would 400 in validate_load).
# A diffusion arch the backend can't assemble: hide from chat (dies in llama.cpp)
# without surfacing in Images (would 400 in validate_load).
if a in _UNSUPPORTED_DIFFUSION_GGUF_ARCHS:
return _UNSUPPORTED_DIFFUSION_TASK
return "text-generation"
@ -3289,11 +3280,10 @@ def _local_model_task(model: "LocalModelInfo") -> Optional[str]:
pass
return None
if _local_is_diffusers(model):
# A local diffusers pipeline can be a VIDEO family (LTX / Wan / Hunyuan), not just an
# image one. Tag it text-to-video so it surfaces in the Video On-Device picker instead
# of the Images picker (where the image loader would reject it), mirroring the
# cached-repo _cached_repo_task. Gated on _local_is_diffusers, so only a real loadable
# pipeline dir (model_index.json) or a name-matched checkpoint reaches this check.
# A local diffusers pipeline can be a VIDEO family (LTX / Wan / Hunyuan), not just
# image. Tag it text-to-video so it surfaces in the Video On-Device picker instead of
# Images (which would reject it), mirroring _cached_repo_task. Gated on
# _local_is_diffusers, so only a real pipeline dir or name-matched checkpoint reaches here.
try:
from core.inference.video import _is_trusted_video_repo
from core.inference.video_families import detect_video_family
@ -3458,9 +3448,8 @@ def _cached_repo_task(repo_info) -> Optional[str]:
from core.inference.video import _is_trusted_video_repo
from core.inference.video_families import detect_video_family
# Both gates: a detected video family (so unsloth image repos don't
# match) AND the load path's own trust rule (so an untrusted video repo
# isn't advertised as loadable).
# Both gates: a detected video family (so image repos don't match) AND the
# load path's trust rule (so an untrusted video repo isn't advertised as loadable).
if detect_video_family(repo_id) is not None and _is_trusted_video_repo(repo_id):
return _VIDEO_GEN_TASK
except Exception:
@ -3511,10 +3500,9 @@ async def list_cached_models(
key = repo_id.lower()
existing = seen_lower.get(key)
is_partial = _cached_repo_partial(repo_id, Path(repo_info.repo_path))
# Prefer the most COMPLETE snapshot, then the largest. The picker drops partial
# rows, so a partial copy in one cache root must not shadow a smaller COMPLETE
# copy in another (that would make a usable model vanish from On Device).
# Completeness wins outright; size only breaks ties among equal completeness.
# Prefer the most COMPLETE snapshot, then largest. The picker drops partial
# rows, so a partial copy in one cache root must not shadow a smaller complete
# copy in another (size only breaks ties among equal completeness).
if existing is None or (not is_partial, total_size) > (
not bool(existing.get("partial")),
existing["size_bytes"],
@ -3527,8 +3515,8 @@ async def list_cached_models(
if is_partial:
row["partial"] = True
# Flag diffusion repos with no pipeline index: loadable only via
# from_single_file with a checkpoint filename, so the pickers must
# not offer them as pipeline loads unless the catalog carries them.
# from_single_file, so pickers must not offer them as pipeline
# loads unless the catalog carries them.
if row["task"] is not None and not _repo_has_pipeline_index(repo_info):
row["single_file"] = True
# Keep the newest timestamp across duplicate caches;
@ -3610,13 +3598,12 @@ async def delete_cached_model(
except Exception:
pass
# Also refuse if the diffusion (Images) backend has this repo loaded; its
# delete guard is otherwise chat-only, so its GGUF could be removed from
# under a live pipeline. Repo-level match, like the chat guards above.
# Also refuse if the Images backend has this repo loaded (guards above are
# chat-only), or its GGUF could be removed from under a live pipeline.
try:
# The ACTIVE engine (diffusers or native sd_cpp): on a native selection the
# diffusers singleton reports unloaded while sd-cli still generates from the
# cached GGUF, so checking it alone would let the files be deleted mid-use.
# cached GGUF, so checking it alone would let files be deleted mid-use.
from core.inference.diffusion_engine_router import get_active_diffusion_engine
engine = get_active_diffusion_engine()
@ -3628,11 +3615,11 @@ async def delete_cached_model(
status_code = 400,
detail = "Unload the model before deleting",
)
# The native sd.cpp one-shot engine re-reads its companion VAE / text-encoder files
# from the HF cache on every generation, so deleting a companion repo (e.g.
# comfyanonymous/flux_text_encoders) while a native GGUF is loaded would brick the
# next generation. status().repo_id only covers the main GGUF, so also refuse the
# committed companion repos the loaded engine reads from disk.
# The native sd.cpp engine re-reads companion VAE / text-encoder files from the HF
# cache every generation, so deleting a companion repo (e.g.
# comfyanonymous/flux_text_encoders) while a native GGUF is loaded bricks the next
# generation. status().repo_id covers only the main GGUF, so also refuse the
# committed companion repos the engine reads from disk.
for lid in getattr(engine, "loaded_repo_ids", tuple)():
if _loaded_id_matches_repo(str(lid).lower(), repo_id):
raise HTTPException(
@ -3640,8 +3627,8 @@ async def delete_cached_model(
detail = "Unload the model before deleting",
)
# Also refuse while a background image load is DOWNLOADING this repo (or its
# companion base): status().loaded is still False in that window, but deleting
# would remove blobs from under the in-flight download/assembly.
# companion base): status().loaded is still False then, but deleting would
# remove blobs from under the in-flight download/assembly.
loading_ids = getattr(engine, "loading_repo_ids", tuple)()
for lid in loading_ids:
lid = str(lid).lower()
@ -3656,9 +3643,9 @@ async def delete_cached_model(
pass
# And refuse if the Video backend has this repo loaded or is downloading it: cached non-GGUF
# video repos now surface in the Video On-Device picker with the normal delete action, but the
# guards above only cover chat + the Images engine, so without this a loaded/loading Wan / LTX /
# Hunyuan pipeline could have its HF snapshot removed from under it. Mirror the Images guard.
# video repos now surface in the Video picker with a delete action, but the guards above cover
# only chat + Images, so without this a loaded/loading Wan / LTX / Hunyuan pipeline could lose
# its HF snapshot from under it. Mirror the Images guard.
try:
from core.inference.video import get_video_backend
@ -3672,8 +3659,8 @@ async def delete_cached_model(
detail = "Unload the model before deleting",
)
# Also refuse while a background VIDEO load is DOWNLOADING this repo (or its companion
# base): status().loaded is still False in that window, but deleting would remove blobs
# from under the in-flight download/assembly -- same as the Images guard above.
# base): status().loaded is still False then, but deleting would remove blobs from under
# the in-flight download/assembly -- same as the Images guard above.
for lid in getattr(video_backend, "loading_repo_ids", tuple)():
lid = str(lid).lower()
if _loaded_id_matches_repo(lid, repo_id):

View file

@ -212,9 +212,9 @@ async def start_training(
error = "Training already active",
)
# A diffusion (SDXL) LoRA job runs in its own subprocess on the same GPU, so an
# LLM start must also refuse while one is active -- otherwise the two trainers
# contend for VRAM and both fail. Symmetric with the check in start_diffusion_training.
# A diffusion (SDXL) LoRA job runs in its own subprocess on the same GPU, so an LLM
# start must refuse while one is active, or the two trainers contend for VRAM and both
# fail. Symmetric with the check in start_diffusion_training.
if _diffusion_training_active():
return TrainingJobResponse(
job_id = "",
@ -436,21 +436,20 @@ async def start_training(
logger.warning("Could not shut down export subprocess: %s", e)
try:
# A resident or in-flight diffusion (Images) pipeline also holds
# GPU memory the training run needs, and it can't be cheaply sized,
# so tear it down unconditionally like the export subprocess above
# (the chat block below fit-checks; diffusion can't). unload() is a
# no-op when nothing is loaded and also preempts an in-flight load;
# release the arbiter so it doesn't think the gone pipeline owns
# the GPU. Must precede the chat block, which early-returns.
# A resident or in-flight Images pipeline also holds GPU memory the run needs
# and can't be cheaply sized, so tear it down unconditionally like the export
# subprocess above (the chat block below fit-checks; diffusion can't). unload()
# is a no-op when nothing is loaded and preempts an in-flight load; release the
# arbiter so it doesn't think the gone pipeline owns the GPU. Must precede the
# chat block, which early-returns.
from core.inference import gpu_arbiter
from core.inference.diffusion_engine_router import (
get_active_diffusion_engine,
)
# The ACTIVE engine, not the diffusers singleton: on a native
# (sd_cpp) selection the diffusers backend reports unloaded while
# the native engine still holds model state / a live generation.
# The ACTIVE engine, not the diffusers singleton: on a native (sd_cpp)
# selection the diffusers backend reports unloaded while the native engine
# still holds model state / a live generation.
diffusion = get_active_diffusion_engine()
if diffusion.is_loaded:
logger.info(
@ -462,12 +461,11 @@ async def start_training(
logger.warning("Could not unload diffusion model for training: %s", e)
try:
# A resident or in-flight Video pipeline holds GPU memory the training run
# needs too, and it loads under the VIDEO arbiter owner the diffusion teardown
# above never touches. Tear it down the same way (unload is a no-op when nothing
# is loaded and preempts an in-flight load) and release VIDEO, so starting
# training while a generated-video session is resident can't OOM the run. Must
# precede the chat block, which early-returns.
# A resident or in-flight Video pipeline holds GPU memory the run needs too, and
# loads under the VIDEO arbiter owner the diffusion teardown above never touches.
# Tear it down the same way (unload no-ops when nothing is loaded, preempts an
# in-flight load) and release VIDEO, so a resident video session can't OOM the
# run. Must precede the chat block, which early-returns.
from core.inference import gpu_arbiter
from core.inference.video import get_video_backend
@ -524,12 +522,10 @@ async def start_training(
logger.warning("Chat/training VRAM coordination failed; proceeding: %s", e)
# The hook runs only once start guards pass -> VRAM freed iff training starts.
# Offloaded to a worker thread: the hook's diffusion/video unload() waits on the
# engines' generation locks until an in-flight denoise step reaches its cancel
# callback (and the export subprocess teardown can take seconds), which would
# otherwise block the event loop and freeze every concurrent status/cancel/UI
# request -- the same reason start_diffusion_training runs
# _free_gpu_for_diffusion_training via asyncio.to_thread. Overlapping starts are
# Offloaded to a worker thread: the hook's diffusion/video unload() waits on the engines'
# generation locks until an in-flight denoise step hits its cancel callback (and the
# export subprocess teardown can take seconds), which would otherwise block the event
# loop and freeze every concurrent status/cancel/UI request. Overlapping starts are
# serialized by the backend's own start-in-progress guard.
success = await asyncio.to_thread(
backend.start_training,
@ -1125,10 +1121,10 @@ async def stream_training_progress(
# ── Diffusion (SDXL) LoRA training ────────────────────────────────────────────
# A separate, lightweight job path from the LLM training endpoints above: diffusion
# runs are driven by DiffusionTrainingService (its own subprocess + event pump), not
# the LLM TrainingBackend, so the two never contend and diffusion never triggers LLM
# lifecycle (DB run rows, plots, transfer-to-chat-inference).
# A separate, lightweight job path from the LLM endpoints above: diffusion runs are driven
# by DiffusionTrainingService (its own subprocess + event pump), not the LLM TrainingBackend,
# so the two never contend and diffusion never triggers LLM lifecycle (DB run rows, plots,
# transfer-to-chat-inference).
def _diffusion_training_active() -> bool:
@ -1164,10 +1160,9 @@ def _free_gpu_for_diffusion_training() -> None:
from core.inference import gpu_arbiter
from core.inference.diffusion_engine_router import get_active_diffusion_engine
# The ACTIVE engine, not the diffusers singleton: on a native (sd_cpp)
# selection the diffusers backend reports unloaded while the resident
# sd-server still holds the GPU, so unloading only the singleton is a no-op.
# Mirrors the LLM training start path.
# The ACTIVE engine, not the diffusers singleton: on a native (sd_cpp) selection the
# diffusers backend reports unloaded while the resident sd-server still holds the GPU,
# so unloading only the singleton is a no-op. Mirrors the LLM training start path.
diffusion = get_active_diffusion_engine()
if diffusion.is_loaded:
logger.info("Unloading resident Images pipeline to free GPU memory for training")
@ -1177,9 +1172,9 @@ def _free_gpu_for_diffusion_training() -> None:
logger.warning("Could not unload Images pipeline for diffusion training: %s", e)
try:
# A resident Video pipeline loads under the VIDEO arbiter owner, which the Images
# teardown above does not free; unload it too (no-op when nothing is loaded) and release
# VIDEO so a generated-video session left resident can't OOM the diffusion trainer.
# A resident Video pipeline loads under the VIDEO arbiter owner the Images teardown
# above doesn't free; unload it too (no-op when nothing is loaded) and release VIDEO
# so a resident video session can't OOM the diffusion trainer.
from core.inference import gpu_arbiter
from core.inference.video import get_video_backend
@ -1192,9 +1187,9 @@ def _free_gpu_for_diffusion_training() -> None:
logger.warning("Could not unload Video pipeline for diffusion training: %s", e)
try:
# The SDXL trainer's footprint can't be cheaply sized against a resident chat
# model, so free chat unconditionally (same conservative choice the LLM path
# makes for an in-flight chat load) rather than risk an OOM.
# The SDXL trainer's footprint can't be cheaply sized against a resident chat model,
# so free chat unconditionally (like the LLM path does for an in-flight load) rather
# than risk an OOM.
from routes.training_vram import free_chat_models_for_training, summarize_resident_chat
if summarize_resident_chat()["any"]:
freed = free_chat_models_for_training(reason = "diffusion training starting")
@ -1273,10 +1268,9 @@ async def start_diffusion_training(
"""Start an SDXL LoRA training job from an image + caption dataset."""
from core.training.diffusion_training_service import get_diffusion_training_service
# When Studio is driven as an inference API (API-key auth), refuse to start training
# while a request is in flight: _free_gpu_for_diffusion_training() below unloads the
# chat backends to reclaim VRAM, which would kill the stream. Mirrors start_training so
# a diffusion start cannot silently drop an active API inference request.
# Under API-key auth, refuse to start training while a request is in flight:
# _free_gpu_for_diffusion_training() below unloads the chat backends, killing the stream.
# Mirrors start_training so a diffusion start can't silently drop an active API request.
if via_api_key is True:
from core.inference.llama_keepwarm import other_inference_request_count
if (
@ -1292,8 +1286,8 @@ async def start_diffusion_training(
),
)
# Interlock: refuse while an LLM training run holds the GPU (symmetric with the
# diffusion check in start_training), so the two trainers never contend for VRAM.
# Interlock: refuse while an LLM training run holds the GPU (symmetric with the diffusion
# check in start_training), so the two trainers never contend for VRAM.
try:
if get_training_backend().is_training_active():
raise HTTPException(
@ -1308,9 +1302,9 @@ async def start_diffusion_training(
except Exception: # noqa: BLE001 -- backend import/health issue must not block a start
pass
# Resolve + contain the dataset and output paths BEFORE spawning, so Studio-relative
# names ("uploads/my-images") work and absolute paths stay under a Studio root -- the
# trainer subprocess otherwise resolves them relative to its own cwd.
# Resolve + contain the dataset and output paths BEFORE spawning, so Studio-relative names
# ("uploads/my-images") work and absolute paths stay under a Studio root -- the trainer
# subprocess otherwise resolves them relative to its own cwd.
config = body.model_dump()
try:
from utils.paths import resolve_output_dir
@ -1319,9 +1313,9 @@ async def start_diffusion_training(
except ValueError as e:
raise HTTPException(status_code = 400, detail = str(e))
# Validate the config BEFORE freeing resident GPU workloads, so a start that is
# then refused (bad numbers, a non-SDXL base model) never tears down the user's
# loaded chat/Images model. service.start() re-runs this cheaply before spawn.
# Validate the config BEFORE freeing resident GPU workloads, so a start then refused (bad
# numbers, non-SDXL base) never tears down the user's chat/Images model. service.start()
# re-runs this cheaply before spawn.
from core.training.diffusion_lora_trainer import _config_from_dict
try:
@ -1329,11 +1323,11 @@ async def start_diffusion_training(
except ValueError as e:
raise HTTPException(status_code = 400, detail = str(e))
# Preflight the requested DiT precision BEFORE freeing GPU residents: the DiT trainer's own
# checks (a bf16-capable GPU is required; an explicit int8 needs a functional torchao) fire
# only in the child, AFTER _free_gpu_for_diffusion_training() already evicted the user's
# chat/Images model. Fail fast (400) so a pre-Ampere GPU (T4 / V100 / RTX 20xx) or a
# stub-torchao host never tears down resident models for a run that cannot start.
# Preflight the requested DiT precision BEFORE freeing GPU residents: the trainer's own
# checks (bf16-capable GPU required; explicit int8 needs a functional torchao) fire only in
# the child, AFTER _free_gpu_for_diffusion_training() evicted the user's model. Fail fast
# (400) so a pre-Ampere GPU (T4 / V100 / RTX 20xx) or stub-torchao host never tears down
# residents for a run that cannot start.
from core.training.diffusion_train_common import training_precision_preflight_error
_precision_reason = training_precision_preflight_error(
@ -1343,8 +1337,8 @@ async def start_diffusion_training(
raise HTTPException(status_code = 400, detail = _precision_reason)
# Run the trainers' trust gate here too (both assert the same predicate before
# from_pretrained), so an untrusted/typoed base 400s BEFORE freeing GPU residents
# instead of tearing down the user's chat/Images model and failing in the child.
# from_pretrained), so an untrusted/typoed base 400s BEFORE freeing GPU residents rather
# than tearing down the user's model and failing in the child.
from core.training.diffusion_train_common import _assert_trusted_base_model
try:
@ -1352,19 +1346,18 @@ async def start_diffusion_training(
except ValueError as e:
raise HTTPException(status_code = 400, detail = str(e))
# Preflight access to a gated base repo with the user's token BEFORE freeing GPU
# residents, so a missing/insufficient token fails fast (400) without tearing down the
# user's loaded chat/Images model, and never surfaces as a confusing mid-load 401.
# Offloaded to a worker thread: it does a blocking urlopen HEAD (up to a 5s timeout) to
# Hugging Face, which would otherwise stall the event loop and every concurrent
# status/progress/cancel request, as the filesystem preflight just below already does.
# Preflight access to a gated base repo with the user's token BEFORE freeing GPU residents,
# so a missing/insufficient token fails fast (400) without tearing down the user's model, and
# never surfaces as a confusing mid-load 401. Offloaded to a worker thread: it does a blocking
# urlopen HEAD (5s timeout) to HF, which would otherwise stall the event loop and every
# concurrent status/progress/cancel request (as the filesystem preflight below also does).
await asyncio.to_thread(
_preflight_gated_base, config.get("base_model", ""), config.get("hf_token")
)
# Preflight the dataset too: a missing/empty/uncaptionable data_dir otherwise
# fails inside the spawned trainer AFTER the user's chat/Images model was
# evicted. Same discovery the trainer runs, so the two cannot disagree.
# Preflight the dataset too: a missing/empty/uncaptionable data_dir otherwise fails inside
# the spawned trainer AFTER the user's model was evicted. Same discovery the trainer runs,
# so the two cannot disagree.
from core.training import diffusion_train_common as _dtc
try:
@ -1373,32 +1366,29 @@ async def start_diffusion_training(
config["data_dir"],
instance_prompt = config.get("instance_prompt") or None,
caption_column = config.get("caption_column") or "text",
# Decode-probe every image now (cheap PIL header check) so a corrupt / zero-byte
# upload is rejected with a 400 BEFORE _free_gpu_for_diffusion_training() tears down
# the user's resident models, instead of crashing the spawned trainer post-eviction.
# Decode-probe every image now (cheap PIL header check) so a corrupt/zero-byte upload
# 400s BEFORE _free_gpu_for_diffusion_training() tears down the user's models, rather
# than crashing the spawned trainer post-eviction.
verify_images = True,
)
except (FileNotFoundError, ValueError) as e:
raise HTTPException(status_code = 400, detail = str(e))
service = get_diffusion_training_service()
# Reserve the training slot BEFORE freeing residents: is_active() otherwise flips true only
# at service.start(), after the free below, so a concurrent /images/load or /video/load would
# pass its training guard during the free-then-spawn window, acquire the GPU, and double-
# allocate VRAM against the trainer. reserve() is a compare-and-set: a second overlapping
# /diffusion/start raises RuntimeError (-> 409) here, before it frees anything, so two starts
# never both tear down residents and race to start(). unreserve() runs in the finally ONLY
# when THIS request acquired the reservation, so a rejected second request never clears the
# first request's claim.
# Reserve the training slot BEFORE freeing residents: is_active() otherwise flips true only at
# service.start(), after the free, so a concurrent /images/load or /video/load would pass its
# training guard during the free-then-spawn window and double-allocate VRAM. reserve() is a
# compare-and-set: a second overlapping /diffusion/start raises RuntimeError (-> 409) before
# freeing anything, so two starts never both tear down residents. unreserve() runs in the
# finally ONLY when THIS request reserved, so a rejected second request can't clear the claim.
reserved = False
try:
service.reserve()
reserved = True
# Free resident GPU workloads (export / Images pipeline / chat) before the trainer
# loads its own pipeline. Offload the blocking teardown (engine unload waits on the
# generation locks; the export subprocess join can take seconds) to a worker thread so
# the event loop stays free for concurrent status/progress/cancel requests, as the
# inference routes do for their blocking load/unload calls.
# Free resident GPU workloads (export / Images pipeline / chat) before the trainer loads
# its own pipeline. Offload the blocking teardown (engine unload waits on generation
# locks; export subprocess join can take seconds) to a worker thread so the event loop
# stays free for concurrent status/progress/cancel requests.
await asyncio.to_thread(_free_gpu_for_diffusion_training)
job_id = service.start(config)
except ValueError as e:
@ -1417,9 +1407,9 @@ async def start_diffusion_training(
log = logger,
)
finally:
# On success the now-live proc keeps is_active() true; on any failure this clears the
# reservation so training is not left permanently "active". Only the request that actually
# reserved clears it, so a rejected overlapping start does not drop the winner's claim.
# On success the now-live proc keeps is_active() true; on failure this clears the
# reservation so training isn't left permanently "active". Only the request that reserved
# clears it, so a rejected overlapping start doesn't drop the winner's claim.
if reserved:
service.unreserve()
return DiffusionTrainingStartResponse(job_id = job_id, status = "running")
@ -1466,9 +1456,9 @@ async def list_diffusion_training_runs(
summaries: list[DiffusionTrainingRunSummary] = []
for r in list_diffusion_runs(limit = limit):
# list_diffusion_runs already skips non-dict / missing-id records, but a record with
# a wrong-typed field (e.g. a non-numeric avg_loss) would still raise here; catch it
# per record so one bad file never breaks the whole Previous runs panel.
# list_diffusion_runs already skips non-dict / missing-id records, but a wrong-typed
# field (e.g. a non-numeric avg_loss) would still raise here; catch it per record so
# one bad file never breaks the whole Previous runs panel.
try:
summaries.append(DiffusionTrainingRunSummary(**r))
except ValidationError:
@ -1485,17 +1475,16 @@ async def get_diffusion_training_run(
from core.training.diffusion_training_service import get_diffusion_run
rec = get_diffusion_run(job_id)
# A valid-JSON file that is not an object (a truncated / hand-edited [] record) would make
# A valid-JSON file that is not an object (a truncated / hand-edited [] record) makes
# DiffusionTrainingRunDetail(**rec) raise TypeError -- not the ValidationError caught below
# -- and 500 the endpoint. Treat any non-dict record as absent, matching the list route's
# shape check.
# -- and 500 the endpoint. Treat any non-dict record as absent, like the list route.
if not isinstance(rec, dict):
raise HTTPException(status_code = 404, detail = "No such training run.")
try:
return DiffusionTrainingRunDetail(**rec)
except ValidationError:
# A malformed on-disk record (hand-edited / older shape) should read as absent
# rather than 500 the endpoint, mirroring how the list route skips bad records.
# A malformed on-disk record (hand-edited / older shape) reads as absent rather than
# 500 the endpoint, like the list route skips bad records.
raise HTTPException(status_code = 404, detail = "No such training run.")
@ -1535,8 +1524,8 @@ def _resolve_dataset_caption(
def _diffusion_dataset_summary(folder: Path) -> DiffusionDatasetSummary:
# Count an image as captioned only when it resolves to a NON-EMPTY caption via the same
# sidecar > metadata precedence the trainer uses -- an empty tombstone sidecar shadows a
# metadata row and makes the trainer skip the image, so counting it here would over-report
# caption_count and mislabel an effectively-uncaptioned dataset as captioned.
# metadata row and makes the trainer skip the image, so counting it would over-report
# caption_count and mislabel an uncaptioned dataset as captioned.
meta_captions = _load_metadata_captions(folder)
images = captions = 0
for f in folder.iterdir():
@ -1562,8 +1551,8 @@ async def diffusion_training_info(current_subject: str = Depends(get_current_sub
root = datasets_root()
found: list[DiffusionDatasetSummary] = []
try:
# Skip hidden dirs: they are never user datasets, and an in-progress example
# import stages into a dot-prefixed sibling that must not surface as a dataset.
# Skip hidden dirs: never user datasets, and an in-progress example import stages
# into a dot-prefixed sibling that must not surface as a dataset.
children = sorted(
p for p in root.iterdir() if p.is_dir() and not p.name.startswith(".")
)
@ -1635,17 +1624,17 @@ async def upload_diffusion_dataset(
total_bytes = 0
uploaded = 0
allowed = _DIFFUSION_DATASET_IMAGE_EXTS | _DIFFUSION_DATASET_TEXT_EXTS
# Validate every filename up front so a valid image ahead of a bad one is not left
# written on disk when the 400 fires -- make the upload all-or-nothing.
# Validate every filename up front so a valid image ahead of a bad one isn't left on disk
# when the 400 fires -- make the upload all-or-nothing.
names: list[str] = []
for f in files:
# Normalise to a safe basename. Path.name does not split on a backslash on POSIX, so a
# Windows client that sends a backslash path in the multipart filename would otherwise be
# stored verbatim; fold backslashes to forward slashes first so the true basename is
# taken for both separators. The read/caption/delete endpoints run the stored name through
# _safe_dataset_image_path (rejects "\\" / ".." / path chars), so a name that still holds
# ".." here would list an image the labeling grid can never preview, caption, or delete --
# reject it now instead of persisting an unmanageable orphan.
# Normalise to a safe basename. Path.name doesn't split on a backslash on POSIX, so a
# Windows client sending a backslash path in the multipart filename would be stored
# verbatim; fold backslashes to forward slashes first so the true basename is taken for
# both separators. The read/caption/delete endpoints run the stored name through
# _safe_dataset_image_path (rejects "\\" / ".." / path chars), so a name still holding
# ".." here would list an image the grid can never preview, caption, or delete -- reject
# it now instead of persisting an unmanageable orphan.
filename = Path((f.filename or "").replace("\\", "/")).name.strip().replace("\x00", "")
ext = Path(filename).suffix.lower()
if not filename or ".." in filename or ext not in allowed:
@ -1654,15 +1643,13 @@ async def upload_diffusion_dataset(
status_code = 400,
detail = f"Unsupported file '{f.filename}'. Allowed: {exts}",
)
# Reject an EXACT duplicate name within THIS batch (two cat.png dragged from
# different folders, or an API client repeating a part). The same-name exemption
# below exists for SEPARATE repeat uploads, where re-sending a name is a
# deliberate overwrite of the file on disk; inside one batch the two parts are
# distinct files staged to the same destination on EVERY filesystem, so the later
# tmp.replace(dest) in the commit loop would silently discard the earlier one
# while `uploaded` still counts both. Exact match only: a case VARIANT pair
# (pic.png vs Pic.png) stays exempt like the stem guard documents -- one file /
# an overwrite on case-insensitive filesystems, two files on Linux.
# Reject an EXACT duplicate name within THIS batch (two cat.png from different folders,
# or an API client repeating a part). The same-name exemption below is for SEPARATE
# repeat uploads, a deliberate overwrite of the file on disk; inside one batch the two
# parts are distinct files staged to the same destination on EVERY filesystem, so the
# later tmp.replace(dest) would silently discard the earlier one while `uploaded` counts
# both. Exact match only: a case VARIANT pair (pic.png vs Pic.png) stays exempt per the
# stem guard -- one file / overwrite on case-insensitive filesystems, two on Linux.
fname_cf = filename.casefold()
if filename in names:
raise HTTPException(
@ -1673,25 +1660,22 @@ async def upload_diffusion_dataset(
"uploading."
),
)
# Reject a second IMAGE that shares this one's stem but differs by extension (sample.png
# vs sample.jpg): both resolve to the same <stem>.txt caption sidecar (the kohya/diffusers
# convention the reader, editor, and delete paths all use), so keeping both would silently
# share -- and corrupt -- one caption during training. Check both files already on disk
# (uploads accumulate) and earlier images validated in THIS batch (nothing is on disk yet
# in this up-front pass). Re-uploading the exact same name (same stem AND extension) stays
# an overwrite; caption/text files are exempt (sample.txt for sample.png is intended).
# Reject a second IMAGE sharing this stem but differing by extension (sample.png vs
# sample.jpg): both resolve to the same <stem>.txt sidecar (the kohya/diffusers
# convention the reader, editor, and delete paths use), so keeping both would silently
# share -- and corrupt -- one caption. Check files already on disk (uploads accumulate)
# and earlier images in THIS batch. Re-uploading the exact same name (stem AND extension)
# stays an overwrite; caption/text files are exempt (sample.txt for sample.png is fine).
if ext in _DIFFUSION_DATASET_IMAGE_EXTS:
stem = Path(filename).stem
# Compare stems (and the same-name guard) case-insensitively: on Windows/macOS
# (case-insensitive filesystems) two images whose stems differ only by case
# (sample.png vs Sample.jpg) resolve to the SAME <stem>.txt caption sidecar, so a
# case-sensitive check would let both through and silently share -- and corrupt --
# one caption. A same-name case variant is exempt ONLY when its stem also differs
# in case (sample.png vs Sample.png): one file / an overwrite on case-insensitive
# filesystems, and on Linux the two files write SEPARATE sidecars (sample.txt vs
# Sample.txt). An EXTENSION-case variant (cat.PNG vs cat.png) has exactly equal
# stems, so on Linux both files land and both resolve to ONE cat.txt -- the very
# collision this guard exists for -- and is rejected like any other stem clash.
# Compare stems (and the same-name guard) case-insensitively: on case-insensitive
# filesystems (Windows/macOS) two images whose stems differ only by case (sample.png
# vs Sample.jpg) resolve to the SAME <stem>.txt sidecar, so a case-sensitive check
# would let both share -- and corrupt -- one caption. A same-name case variant is
# exempt ONLY when its stem also differs in case (sample.png vs Sample.png): one file /
# overwrite on case-insensitive filesystems, SEPARATE sidecars on Linux. An
# EXTENSION-case variant (cat.PNG vs cat.png) has equal stems, so on Linux both land
# and resolve to ONE cat.txt -- the collision this guard exists for -- and is rejected.
stem_cf = stem.casefold()
def _shares_sidecar(other_name: str) -> bool:
@ -1703,8 +1687,7 @@ async def upload_diffusion_dataset(
):
return False
# A casefold-equal full name is exempt unless the stems match EXACTLY
# (extension-case variants collide on one sidecar on case-sensitive
# filesystems).
# (extension-case variants collide on one sidecar on case-sensitive FS).
return other.stem == stem or other_name.casefold() != fname_cf
clash = next(
@ -1723,10 +1706,9 @@ async def upload_diffusion_dataset(
),
)
names.append(filename)
# Stage each file to a temp name and only move it into place once the whole batch is
# written, so a mid-batch failure (size limit, disk error, disconnect) leaves the
# dataset untouched -- including any pre-existing file that shares a name, which a
# direct write would have truncated (repeat uploads into the same name accumulate).
# Stage each file to a temp name and move it into place only once the whole batch is written,
# so a mid-batch failure (size limit, disk error, disconnect) leaves the dataset untouched --
# including any pre-existing same-name file a direct write would have truncated.
staged: list[tuple[Path, Path]] = [] # (temp, final)
committed = False
try:
@ -1773,7 +1755,7 @@ async def upload_diffusion_dataset(
# ── Dataset labeling (per-image caption editing) + one-click example imports ──
# Thumbnails live in a hidden subdir so they never appear in dataset listings or the
# trainer's own image discovery (both scan only top-level files).
# trainer's image discovery (both scan only top-level files).
_THUMBS_DIRNAME = ".thumbs"
_MAX_CAPTION_CHARS = 2000
@ -1855,9 +1837,8 @@ def _image_record(
caption = None
break
if caption is None:
# Basename first, then the relative path as written in the jsonl (as_posix so a
# Windows backslash path still matches forward-slash keys) -- the same lookup
# order discover_image_caption_pairs uses.
# Basename first, then the relative path as written in the jsonl (as_posix so a Windows
# backslash path still matches forward-slash keys) -- discover_image_caption_pairs's order.
meta = meta_captions.get(image_path.name)
if meta is None:
try:
@ -1932,10 +1913,9 @@ async def get_diffusion_dataset_image(
thumbs_dir = folder / _THUMBS_DIRNAME
thumbs_dir.mkdir(exist_ok = True)
# Key on the full filename (stem + extension), not the stem: two images that
# share a stem but differ by extension (sample.png / sample.jpg) would otherwise
# collide on one cache file, and an mtime-newer cache built for the first would
# be served for the second, showing the wrong image in the labeling grid.
# Key on the full filename (stem + extension), not the stem: two images sharing a stem
# but differing by extension (sample.png / sample.jpg) would otherwise collide on one
# cache file, and an mtime-newer cache for the first would be served for the second.
thumb_path = thumbs_dir / f"{image_path.name}_{size}.jpg"
src_mtime = image_path.stat().st_mtime
if thumb_path.is_file() and thumb_path.stat().st_mtime >= src_mtime:
@ -1983,11 +1963,10 @@ async def set_diffusion_dataset_caption(
sidecar.write_text(caption, encoding = "utf-8")
image_path.with_suffix(".caption").unlink(missing_ok = True)
return _image_record(folder, image_path, _load_metadata_captions(folder))
# Blank must actually clear. Unlinking alone would resurface this image's
# metadata.jsonl / captions.jsonl caption (the fallback source), so when one
# exists write an EMPTY sidecar instead: both the record reader and the
# trainer's discovery treat an existing sidecar as authoritative even when
# empty, which makes it a tombstone. No metadata caption -> plain cleanup.
# Blank must actually clear. Unlinking alone would resurface this image's metadata.jsonl
# / captions.jsonl caption (the fallback), so when one exists write an EMPTY sidecar
# instead: both the reader and the trainer's discovery treat an existing sidecar as
# authoritative even when empty, a tombstone. No metadata caption -> plain cleanup.
meta = _load_metadata_captions(folder)
try:
rel = image_path.relative_to(folder).as_posix()
@ -2021,9 +2000,8 @@ async def delete_diffusion_dataset_image(
image_path.with_suffix(ext).unlink(missing_ok = True)
thumbs_dir = folder / _THUMBS_DIRNAME
if thumbs_dir.is_dir():
# Thumbs are keyed on the full filename (stem + extension), so match that
# here too; a stem-only glob would leave this image's thumbs behind and
# could delete a same-stem sibling's (sample.png vs sample.jpg).
# Thumbs are keyed on the full filename (stem + extension), so match that here too;
# a stem-only glob would strand this image's thumbs or delete a same-stem sibling's.
for t in thumbs_dir.glob(f"{image_path.name}_*.jpg"):
t.unlink(missing_ok = True)
return {"deleted": image_path.name}
@ -2034,7 +2012,7 @@ async def delete_diffusion_dataset_image(
# Curated, license-labelled example datasets for one-click import. ``loader`` picks the
# materialization strategy: "hf_dataset" streams rows from datasets.load_dataset (image +
# optional caption column); "imagefolder_jsonl" snapshot-downloads a dataset repo whose
# captions live in a *.jsonl (file_name/text) rather than a standard metadata.jsonl.
# captions live in a *.jsonl (file_name/text) not a standard metadata.jsonl.
_DATASET_EXAMPLES: list[dict] = [
{
"id": "dreambooth-dog",
@ -2091,8 +2069,8 @@ _DATASET_EXAMPLES: list[dict] = [
),
"license": "CC0 (Smithsonian Open Access)",
"image_cap": 100,
# The metadata columns are species names / boilerplate alt-text, not text-to-image
# captions, so train it as a subject set with the trigger prompt instead.
# The metadata columns are species names / boilerplate alt-text, not captions, so train
# it as a subject set with the trigger prompt instead.
"suggested_trigger": "a photo of a sks butterfly",
"loader": "hf_dataset",
"caption_column": None,
@ -2285,12 +2263,12 @@ async def import_diffusion_dataset_example(
if existing.image_count == 0:
cap = int(entry["image_cap"])
# Materialize into a private staging dir and promote into the dataset folder only
# after the whole import succeeds. A materialize that fails partway (a transient
# fetch/copy error after writing some images) then leaves only the staging dir,
# never a half-filled dataset -- otherwise the image_count>0 idempotency check
# above would treat that partial result as complete on the next retry (imported=0)
# and strand the user with a truncated dataset (there is no dataset-delete flow).
# Staged as a hidden sibling on the same filesystem so promotion is an atomic rename.
# after the whole import succeeds. A partial materialize (a transient fetch/copy
# error after some images) then leaves only the staging dir, never a half-filled
# dataset -- otherwise the image_count>0 idempotency check above would treat that
# partial as complete on retry (imported=0) and strand a truncated dataset (there is
# no dataset-delete flow). Staged as a hidden same-filesystem sibling so promotion is
# an atomic rename.
staging = Path(tempfile.mkdtemp(dir = folder.parent, prefix = f".{folder.name}.import-"))
try:
try:
@ -2311,11 +2289,10 @@ async def import_diffusion_dataset_example(
detail = f"No images found in '{entry['repo']}'.",
)
# Promote the fully-materialized staging dir as a UNIT. A per-file move loop is
# not atomic: a hard process death (SIGKILL / OOM / power loss) between two moves
# would leave the folder with SOME images, and the image_count>0 idempotency check
# above would then accept that truncated dataset as complete on the next retry. The
# folder was created empty on this path (it only runs when it holds no images), so
# a single same-filesystem directory rename is atomic. If the folder holds
# not atomic: a hard process death (SIGKILL / OOM / power loss) mid-loop would
# leave SOME images, which the image_count>0 idempotency check above would accept
# as complete on retry. The folder was created empty here (runs only when it holds
# no images), so a single same-filesystem rename is atomic. If the folder holds
# unrelated non-image files (rmdir refuses), fall back to a per-file move rather
# than abort -- the common fresh-import path stays atomic.
try: