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:
parent
84a7f048e5
commit
cb93f5e4d1
10 changed files with 88 additions and 25 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue