Fix root-only pipeline index detection, seed range overflow, local safetensors loads

Require the model_index.json to sit at the snapshot ROOT before flagging a
cached repo as pipeline-loadable: CachedFileInfo.file_name is the basename,
so the previous name match also claimed nested copies (subdir/model_index.json)
and the picker then sent a from_pretrained load that fails only after the GPU
handoff. Scope by file_path against the revision's snapshot_path.

Validate the maximum derived seed before the multi-run image loop: an explicit
seed near 2**53-1 plus the per-run offset (base + i*batchSize) exceeded the
backend cap and 422'd a later run after earlier images had already generated.

Route local single-file .safetensors picks on the Images and Video pages
through the single_file load path (parent dir + basename), matching the local
GGUF branch: the pipeline route rejects a bare file with no model_index.json,
and only after evicting the resident model.
This commit is contained in:
Daniel Han 2026-07-10 17:01:24 +00:00
commit 39b256f8c9
4 changed files with 96 additions and 4 deletions

View file

@ -3386,13 +3386,23 @@ async def list_cached_gguf(current_subject: str = Depends(get_current_subject)):
def _repo_has_pipeline_index(repo_info) -> bool:
"""Whether the cached snapshot carries a model_index.json, i.e. is loadable as a
full diffusers pipeline (from_pretrained). Single-file / ComfyUI checkpoints ship
none and need a checkpoint filename + from_single_file instead."""
"""Whether the cached snapshot carries a ROOT model_index.json, i.e. is loadable
as a full diffusers pipeline (from_pretrained reads only the repo root). A nested
subdir/model_index.json does not count: loading the repo root still fails, so the
row must keep its single_file flag. CachedFileInfo.file_name is the basename, so
a name match alone would also claim nested copies -- scope by file_path when the
scan provides it."""
try:
for rev in repo_info.revisions:
snapshot = getattr(rev, "snapshot_path", None)
for f in rev.files:
if f.file_name == "model_index.json" or f.file_name.endswith("/model_index.json"):
name = str(getattr(f, "file_name", "") or "")
path = getattr(f, "file_path", None)
if path is not None and snapshot is not None:
p = Path(path)
if p.name == "model_index.json" and p.parent == Path(snapshot):
return True
elif name == "model_index.json":
return True
except Exception:
pass

View file

@ -1078,6 +1078,33 @@ def test_cached_repo_partial_scopes_probe_to_snapshot_dir(monkeypatch):
assert models_route._cached_repo_partial("Org/Repo", snapshot_dir) is False
def test_repo_has_pipeline_index_requires_root_model_index(tmp_path):
# Only a ROOT model_index.json makes a repo pipeline-loadable: from_pretrained
# reads the repo root, so a nested subdir/model_index.json must NOT clear the
# single_file flag. CachedFileInfo.file_name is the basename, so the helper has
# to scope by file_path/snapshot_path -- a name-only match would claim both.
snap = tmp_path / "snapshots" / "abc"
nested = SimpleNamespace(
file_name = "model_index.json",
file_path = snap / "prior" / "model_index.json",
)
repo_nested = SimpleNamespace(
repo_id = "unsloth/nested-index",
revisions = [SimpleNamespace(files = [nested], snapshot_path = snap)],
)
assert models_route._repo_has_pipeline_index(repo_nested) is False
root = SimpleNamespace(
file_name = "model_index.json",
file_path = snap / "model_index.json",
)
repo_root = SimpleNamespace(
repo_id = "unsloth/root-index",
revisions = [SimpleNamespace(files = [root], snapshot_path = snap)],
)
assert models_route._repo_has_pipeline_index(repo_root) is True
def test_list_cached_models_flags_single_file_diffusion_repos(monkeypatch, tmp_path):
# A diffusion-tagged repo with NO top-level model_index.json is a single-file
# checkpoint: the task pickers must not offer it as a pipeline load (from_pretrained

View file

@ -1660,6 +1660,29 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
});
return;
}
// A direct local single-file .safetensors pick (custom folder / on-device file)
// must load via from_single_file: the pipeline route rejects a bare file (no
// model_index.json) and only after evicting the resident model. Split into
// (parent dir, basename) exactly like the local GGUF branch above.
if (meta.source === "local" && id.toLowerCase().endsWith(".safetensors")) {
const norm = id.replace(/\\/g, "/");
const slash = norm.lastIndexOf("/");
const filename = slash >= 0 ? norm.slice(slash + 1) : norm;
const dir = slash >= 0 ? norm.slice(0, slash) : ".";
const prevQuant = quant;
quantRevert.current = { prev: prevQuant };
setQuant(filename);
const dsf = defaultsFor(id);
setSteps(dsf.steps);
setGuidance(dsf.guidance);
void handleLoad(dir, { kind: "single_file", filename }).then((started) => {
if (!started) {
setQuant(prevQuant);
quantRevert.current = null;
}
});
return;
}
// Otherwise treat it as a full diffusers repo (safetensors / bnb-4bit). The backend
// infers the family + base repo from the id and gates loads to unsloth/* repos or
// on-device paths, so only attempt those; other Hub orgs can't be assembled here.
@ -1839,6 +1862,15 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
const runs = Number.isFinite(count) && count >= 1 ? Math.floor(count) : 1;
if (runs !== count) setCount(runs);
// An explicit seed near the 2**53-1 backend cap can overflow once the per-run
// offset (base + i*batchSize) and the engine's in-batch +j offsets are added,
// 422ing a later run AFTER earlier images already generated. Fail before any
// GPU work. Subtraction keeps the comparison exact where the sum would round.
if (baseSeed > Number.MAX_SAFE_INTEGER - (runs * batchSize - 1)) {
toast.error("Seed too large for this run count and batch size; use a smaller seed");
return;
}
setBusy("generating");
setGenDone(0);
setGenStep(null);

View file

@ -1044,6 +1044,29 @@ export function VideoPage({ active = true }: { active?: boolean }) {
});
return;
}
// A direct local single-file .safetensors pick must load via from_single_file:
// the pipeline route rejects a bare file (no model_index.json) and only after
// evicting the resident model. Split into (parent dir, basename) exactly like
// the local GGUF branch above.
if (meta.source === "local" && id.toLowerCase().endsWith(".safetensors")) {
const norm = id.replace(/\\/g, "/");
const slash = norm.lastIndexOf("/");
const filename = slash >= 0 ? norm.slice(slash + 1) : norm;
const dir = slash >= 0 ? norm.slice(0, slash) : ".";
const prevQuant = quant;
quantRevert.current = { prev: prevQuant };
setQuant(filename);
const dsf = defaultsFor(id);
setSteps(dsf.steps);
setGuidance(dsf.guidance);
void handleLoad(dir, { kind: "single_file", filename }).then((started) => {
if (!started) {
setQuant(prevQuant);
quantRevert.current = null;
}
});
return;
}
// Otherwise treat it as a full diffusers repo. The backend gates loads to unsloth/*
// repos, the family bases, or on-device paths, so only attempt those.
if (meta.source !== "local" && !id.toLowerCase().startsWith("unsloth/")) {