Fetch staged GGUF checkpoints as scoped jobs, and stop calling diffusion models unsupported

A GGUF entry went out as a full snapshot, whose ignore list drops *.gguf: the job
finished at once having fetched only docs, and the repo landed on device unloadable.
Every entry is scoped now. The Hub also no longer tags image/video models as
unsupported (they run on their own pages), and those pickers name what they select.
This commit is contained in:
Unsloth 2026-07-26 04:32:48 -07:00
commit cb93f5e4d1
10 changed files with 88 additions and 25 deletions

View file

@ -249,7 +249,7 @@ export const ModelCard = memo(function ModelCard({
}),
[isDataset, row.id, row.result, deviceType],
);
const unsupported = support?.status === "unsupported";
const unsupported = support?.status === "unsupported" && !support?.supportedIn;
const partial = row.isAvailableOnDevice && row.isPartialOnDevice;
const onDevice = row.isAvailableOnDevice && !row.isPartialOnDevice;
const topCapability = row.capabilities[0] ?? null;

View file

@ -280,7 +280,12 @@ function ModelStatusChips({
unslothSupport: UnslothSupport;
vramInfo: VramInfo;
}) {
const showUnsupported = !isDataset && unslothSupport.status === "unsupported";
// The Images/Video pages run these, so they are not "unsupported" to a user even
// though chat cannot load them.
const showUnsupported =
!isDataset &&
unslothSupport.status === "unsupported" &&
!unslothSupport.supportedIn;
// The format-unsupported chip already explains itself; this one covers the
// supported-format model a chat-only host still can't run.
const showChatOnly = !isDataset && !isGguf && chatOnly && !showUnsupported;
@ -694,7 +699,7 @@ export const ModelInspector = memo(function ModelInspector({
gpuGb={gpuGb}
systemRamGb={systemRamGb}
unsupportedReason={
unslothSupport.status === "unsupported"
unslothSupport.status === "unsupported" && !unslothSupport.supportedIn
? (unslothSupport.reason ?? "Unsupported format")
: null
}

View file

@ -449,7 +449,7 @@ export const DiscoverModelRow = memo(function DiscoverModelRow({
}),
[isDataset, row.id, row.result, deviceType],
);
const unsupported = support?.status === "unsupported";
const unsupported = support?.status === "unsupported" && !support?.supportedIn;
const handleClick = useCallback(() => onSelect(row.id), [onSelect, row.id]);
const partialRepoId =
row.isAvailableOnDevice && row.isPartialOnDevice
@ -593,16 +593,16 @@ export const InventoryRow = memo(function InventoryRow({
const rowTagsSignature = row.tags?.join("\u0001") ?? "";
const unsupported = useMemo(() => {
if (isDataset) return false;
return (
classifyUnslothSupport({
modelId: rowModelId,
pipelineTag: row.pipelineTag,
tags: rowTagsSignature ? rowTagsSignature.split("\u0001") : undefined,
libraryName: row.libraryName,
quantMethod: row.quantMethod,
deviceType,
}).status === "unsupported"
);
const classified = classifyUnslothSupport({
modelId: rowModelId,
pipelineTag: row.pipelineTag,
tags: rowTagsSignature ? rowTagsSignature.split("\u0001") : undefined,
libraryName: row.libraryName,
quantMethod: row.quantMethod,
deviceType,
});
// Images/Video run these, so they are not unsupported to a user.
return classified.status === "unsupported" && !classified.supportedIn;
}, [
isDataset,
rowModelId,

View file

@ -576,7 +576,7 @@ function useResultRowModel(
const taskLabel = isDataset
? null
: formatPipelineTag(row.result.pipelineTag);
const unsupported = support?.status === "unsupported";
const unsupported = support?.status === "unsupported" && !support?.supportedIn;
return {
support,
unsupported,

View file

@ -15,10 +15,9 @@ export interface StagedDownloadEntry {
repoId: string;
files: string[];
bytes: number;
/** Set when this entry is a single-file GGUF checkpoint rather than a scoped subset. */
/** Set when this entry is a single-file GGUF checkpoint. Informational: it is fetched
* as a scoped job like every other entry. */
ggufFilename?: string | null;
/** Quant label for a GGUF entry, so the job keys like any other variant download. */
variant?: string | null;
}
/**
@ -42,10 +41,11 @@ export function useStagedDownload({
const [queue, setQueue] = useState<StagedDownloadEntry[] | null>(null);
const current = queue?.[0] ?? null;
// A GGUF entry is a normal variant download; a scoped entry keys itself under "@scope".
const activeVariant = current
? (current.variant ?? (current.ggufFilename ? null : scopedVariant(scopeId)))
: null;
// Every entry is scoped, including a GGUF checkpoint. A plain snapshot job would be the
// wrong tool for it: the Hub's snapshot ignore list drops *.gguf, so the job would finish
// at once having fetched everything EXCEPT the weights, and the repo would land on device
// unloadable.
const activeVariant = current ? scopedVariant(scopeId) : null;
const advance = useCallback(() => {
setQueue((rest) => {
@ -78,8 +78,8 @@ export function useStagedDownload({
repoId: current.repoId,
variant: activeVariant,
expectedBytes: current.bytes,
scopeId: current.ggufFilename ? null : scopeId,
files: current.ggufFilename ? undefined : current.files,
scopeId,
files: current.files,
});
if (!active) return;
if (outcome === "started") {

View file

@ -126,6 +126,35 @@ export type UnslothSupportStatus = "supported" | "unsupported";
export interface UnslothSupport {
status: UnslothSupportStatus;
reason: string | null;
/**
* Set when Studio runs this model on a dedicated page rather than in chat. The status
* stays "unsupported" because the chat pickers gate on it, but the UI must not call the
* model unsupported: the Images and Video pages load it.
*/
supportedIn?: "images" | "video";
}
// Generation tasks the Images / Video pages handle. Mirrors IMAGE_GEN_TASKS and the video
// picker's tasks; image-to-video is included for LTX-2.3, whose HF pipeline tag is that.
const IMAGE_PAGE_TASKS: ReadonlySet<string> = new Set([
"text-to-image",
"image-to-image",
"image-text-to-image",
]);
const VIDEO_PAGE_TASKS: ReadonlySet<string> = new Set([
"text-to-video",
"image-to-video",
]);
/** Which Studio page runs this pipeline task, if any. */
export function studioPageForTask(
pipelineTag?: string | null,
): "images" | "video" | undefined {
const tag = pipelineTag?.toLowerCase().trim();
if (!tag) return undefined;
if (IMAGE_PAGE_TASKS.has(tag)) return "images";
if (VIDEO_PAGE_TASKS.has(tag)) return "video";
return undefined;
}
export function excludedFormatTagsForDevice(
@ -214,6 +243,9 @@ export function classifyUnslothSupport({
return {
status: "unsupported",
reason: `Pipeline task: ${pipeline}.`,
// Not chat-loadable, but the Images/Video pages run it, so the UI must not
// present it as unsupported.
supportedIn: studioPageForTask(pipeline),
};
}
for (const tag of lowerTags) {

View file

@ -2390,6 +2390,7 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
className="!h-[34px]"
task={IMAGE_GEN_TASKS}
catalog={IMAGE_CATALOG}
placeholder="Select image model"
open={active && selectorOpen}
onOpenChange={(o) => setSelectorOpen(active && o)}
/>

View file

@ -155,6 +155,9 @@ interface ModelSelectorProps {
* artifact repos into one row with a format second level and device-aware
* routing. Undefined (chat) changes nothing. */
catalog?: CatalogGroup[];
/** Trigger text when nothing is loaded. Defaults to "Select model"; task pages name
* what they pick so it reads as separate from the chat model. */
placeholder?: string;
}
function ModelSelectorTrigger({
@ -166,6 +169,9 @@ function ModelSelectorTrigger({
className,
dataTour,
onEject,
// Task pages name what they pick ("Select image model"), so it is clear the choice is
// separate from the chat model.
placeholder = "Select model",
}: {
currentModel?: ModelOption;
isLoaded: boolean;
@ -175,6 +181,7 @@ function ModelSelectorTrigger({
className?: string;
dataTour?: string;
onEject?: () => void;
placeholder?: string;
}) {
return (
<PopoverTrigger asChild={true}>
@ -241,7 +248,7 @@ function ModelSelectorTrigger({
) : null}
<span className="flex min-w-0 flex-1 items-baseline">
<span className="min-w-0 flex flex-1 items-baseline truncate font-heading text-ui-16 font-medium leading-tight text-black dark:text-white">
{currentModel?.name ?? "Select model"}
{currentModel?.name ?? placeholder}
{showCloudIndicator ? (
<HugeiconsIcon
icon={CloudIcon}
@ -690,6 +697,7 @@ export function ModelSelector({
showCloudIndicator = false,
task,
catalog,
placeholder,
}: ModelSelectorProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const open = controlledOpen ?? uncontrolledOpen;
@ -796,6 +804,7 @@ export function ModelSelector({
className={className}
dataTour={triggerDataTour}
onEject={onEject ? handleEject : undefined}
placeholder={placeholder}
/>
<ModelSelectorContent
open={open}

View file

@ -1462,6 +1462,7 @@ export function VideoPage({ active = true }: { active?: boolean }) {
className="!h-[34px]"
task={VIDEO_GEN_TASKS}
catalog={VIDEO_CATALOG}
placeholder="Select video model"
open={active && selectorOpen}
onOpenChange={(o) => setSelectorOpen(active && o)}
/>

View file

@ -622,3 +622,18 @@ def test_diffusion_pages_stage_downloads_through_the_manager():
assert "isDownloaded !== false" in body, f"{rel}: cached picks would re-stage"
# A missing plan must still load rather than dead-end.
assert "catch" in body, f"{rel}: no fallback when the plan is unavailable"
def test_staged_downloads_always_scope_their_files():
"""Every staged entry must go out as a scoped job carrying its file list, GGUF
checkpoints included. A plain snapshot job drops *.gguf via the Hub's ignore list, so
it would finish instantly having fetched everything except the weights and leave the
repo on device unloadable."""
src = _read("features/hub/download-manager/use-staged-download.ts")
start = re.search(r"downloadManager\.requestStart\(\{.*?\}\);", src, re.S)
assert start, "requestStart call not found"
body = start.group(0)
# Unconditional: no branch may send a null scope or omit the files.
assert "scopeId," in body and "files: current.files," in body
assert "? null" not in body and "? undefined" not in body
assert "const activeVariant = current ? scopedVariant(scopeId) : null;" in src