From 5e3ab0a8c4aa0ef676d22ef21417f5e1f6323c5c Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 9 Jul 2026 09:04:16 +0000 Subject: [PATCH] images/video: apply the fit-on-device toggle to catalog group rows The Recommended list's fit-on-device toggle filtered the live Hub rows and the flat curated rows, but the canonical catalog GROUP rows (Images / Video pages) were gated only by the format filter. A bare click on a filtered list could then still start an OOM load the toggle was meant to hide (LTX-2 base at 90 GB, the Wan2.2-A14B MoE at 114 GB, both bf16-only with no GGUF fallback). Add catalogGroupFitsDevice: a group stays visible when at least one artifact can actually run here (already downloaded, a GGUF whose quant ladder self-fits, or a sized artifact within 0.7*GPU + 0.7*RAM), mirroring the Recommended fit predicate across a group's formats. Gate the search (matchedCatalogGroups) and the Recommended-section catalog rows (render + roving keys) on it. Node-native catalog:check assertions cover the over-budget, GGUF-fallback, downloaded, unknown -budget, and datacenter-budget cases. --- .../model-selector/model-catalog.check.ts | 44 +++++++++++++++++++ .../model-selector/model-catalog.ts | 28 ++++++++++++ .../assistant-ui/model-selector/pickers.tsx | 41 +++++++++++++---- 3 files changed, 104 insertions(+), 9 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/model-selector/model-catalog.check.ts b/studio/frontend/src/components/assistant-ui/model-selector/model-catalog.check.ts index 8d9678d712..dccfe16cb1 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/model-catalog.check.ts +++ b/studio/frontend/src/components/assistant-ui/model-selector/model-catalog.check.ts @@ -13,6 +13,7 @@ import { IMAGE_CATALOG, VIDEO_CATALOG, canonicalKeyFor, + catalogGroupFitsDevice, catalogToModelOptions, classifyGgufFit, groupForRepoId, @@ -455,6 +456,49 @@ assert.equal( assert.equal(loadSpecFor("Tongyi-MAI/Z-Image-Turbo", IMAGE_CATALOG)?.kind, "pipeline"); assert.equal(loadSpecFor("Qwen/Qwen-Image-2512", IMAGE_CATALOG)?.kind, "pipeline"); +// ── catalogGroupFitsDevice (the fit-on-device toggle for catalog rows) ───────── + +const wanA14b = groupForRepoId("Wan-AI/Wan2.2-T2V-A14B-Diffusers", VIDEO_CATALOG); +const ltxBase = groupForRepoId("Lightricks/LTX-2", VIDEO_CATALOG); +const hunyuanFit = groupForRepoId( + "hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v", + VIDEO_CATALOG, +); +assert.ok(wanA14b && ltxBase && hunyuanFit && ltxGroup); +const consumer = { gpuGb: 24, systemRamGb: 64 }; // budget 61.6 GB +// A bare-bf16 group over budget is hidden: this is the OOM the toggle must catch. +assert.equal(catalogGroupFitsDevice(wanA14b, consumer, notDownloaded), false); // 114 GB +assert.equal(catalogGroupFitsDevice(ltxBase, consumer, notDownloaded), false); // 90 GB +// A sized bf16 group that fits the budget stays visible (Hunyuan 40/52 GB <= 61.6). +assert.equal(catalogGroupFitsDevice(hunyuanFit, consumer, notDownloaded), true); +// But on a tiny device even those are hidden. +assert.equal( + catalogGroupFitsDevice(hunyuanFit, { gpuGb: 8, systemRamGb: 8 }, notDownloaded), + false, +); +// A GGUF in the group is always runnable (its quant ladder self-fits + offloads), +// so LTX-2.3 stays visible even on a tiny card despite its 90 GB BF16 sibling. +assert.equal( + catalogGroupFitsDevice(ltxGroup, { gpuGb: 4, systemRamGb: 4 }, notDownloaded), + true, +); +// An already-downloaded artifact keeps its group visible regardless of budget. +assert.equal( + catalogGroupFitsDevice( + wanA14b, + { gpuGb: 8, systemRamGb: 8 }, + (id) => id === "Wan-AI/Wan2.2-T2V-A14B-Diffusers", + ), + true, +); +// Unknown device budget keeps everything (we cannot tell), even a 114 GB group. +assert.equal(catalogGroupFitsDevice(wanA14b, { gpuGb: 0, systemRamGb: 0 }, notDownloaded), true); +// On a B200-class budget the large bf16 groups fit and stay visible. +assert.equal( + catalogGroupFitsDevice(wanA14b, { gpuGb: 192, systemRamGb: 256 }, notDownloaded), + true, +); + // ── groupMatchesQuery ────────────────────────────────────────────────────────── assert.ok(groupMatchesQuery(qwenGroup, "qwen")); diff --git a/studio/frontend/src/components/assistant-ui/model-selector/model-catalog.ts b/studio/frontend/src/components/assistant-ui/model-selector/model-catalog.ts index 20a273628a..5fc3a0a7c5 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/model-catalog.ts +++ b/studio/frontend/src/components/assistant-ui/model-selector/model-catalog.ts @@ -675,3 +675,31 @@ export function pickDefaultArtifact( (a, b) => (a.approxSizeGb ?? Infinity) - (b.approxSizeGb ?? Infinity), )[0]; } + +/** Whether the "fit on device" toggle should keep a catalog group. A group stays + * visible when at least one artifact can actually run here: one already on disk + * (the user has it), a GGUF (its per-quant ladder self-fits and the backend GGUF + * path plans its own offload), or a sized artifact whose curated footprint is + * within the 0.7*GPU + 0.7*RAM budget. A group of only over-budget (or unsized) + * full-precision artifacts -- e.g. the LTX-2 base at 90 GB or Wan2.2-A14B at + * 114 GB on a consumer card -- is hidden, so a bare click on a fit-filtered list + * can no longer start an OOM load the toggle was meant to hide. An unknown device + * budget keeps everything (we cannot tell). Mirrors fitsResident/the Recommended + * fit predicate, extended across a group's formats. */ +export function catalogGroupFitsDevice( + group: CatalogGroup, + budget: DeviceBudget, + isDownloaded: (repoId: string) => boolean, +): boolean { + const budgetGb = + Math.max(0, budget.gpuGb || 0) * 0.7 + + Math.max(0, budget.systemRamGb || 0) * 0.7; + if (budgetGb <= 0) return true; + return group.artifacts.some((a) => { + if (isDownloaded(a.repoId)) return true; + // A GGUF's quant ladder self-fits (llama-server offloads), so it is always a + // runnable fallback -- matching pickDefaultArtifact returning it on any budget. + if (a.format === "gguf") return true; + return a.approxSizeGb !== undefined && a.approxSizeGb <= budgetGb; + }); +} diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index b0e2c03033..767e586a07 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -116,6 +116,7 @@ import { type CatalogGroup, type ModelArtifact, artifactForRepoId, + catalogGroupFitsDevice, groupForRepoId, groupMatchesQuery, pickDefaultArtifact, @@ -2435,6 +2436,27 @@ export function HubModelPicker({ return map; }, [results, recommendedSearch.results]); + // The fit-on-device toggle hides a catalog group with nothing runnable here, + // exactly as it hides an over-budget Recommended row -- otherwise a bare click + // on a fit-filtered list could still start a 90-114 GB OOM load (LTX-2 base, + // Wan2.2-A14B). Off = every group passes. + const catalogGroupPassesFit = useCallback( + (g: CatalogGroup) => + !fitOnDeviceOnly || + catalogGroupFitsDevice(g, deviceBudget, isRepoDownloaded), + [fitOnDeviceOnly, deviceBudget, isRepoDownloaded], + ); + + // Curated catalog rows for the Recommended section (no query): format toggle + + // the same fit gate as the live Recommended rows. Shared by the render and the + // roving-key list so navigation matches what is on screen. + const recommendedCatalogGroups = useMemo(() => { + if (!catalog) return []; + return catalog.filter( + (g) => catalogGroupMatchesFormat(g, formatFilter) && catalogGroupPassesFit(g), + ); + }, [catalog, formatFilter, catalogGroupPassesFit]); + // Recommended models that match the current search query // Catalog groups matching a typed query (old ids, format tokens, quant names // all match); rendered as canonical rows above the remaining search results. @@ -2443,9 +2465,10 @@ export function HubModelPicker({ return catalog.filter( (g) => groupMatchesQuery(g, debouncedQuery.trim()) && - catalogGroupMatchesFormat(g, formatFilter), + catalogGroupMatchesFormat(g, formatFilter) && + catalogGroupPassesFit(g), ); - }, [catalog, showHfSection, debouncedQuery, formatFilter]); + }, [catalog, showHfSection, debouncedQuery, formatFilter, catalogGroupPassesFit]); const filteredRecommendedIds = useMemo(() => { if (!showHfSection) return []; @@ -2684,9 +2707,9 @@ export function HubModelPicker({ // flat curated safetensors rows. if (catalog) { keys.push( - ...catalog - .filter((g) => catalogGroupMatchesFormat(g, formatFilter)) - .map((g) => makeModelOptionKey("catalog-group", g.canonicalId)), + ...recommendedCatalogGroups.map((g) => + makeModelOptionKey("catalog-group", g.canonicalId), + ), ); } else { keys.push( @@ -2713,9 +2736,9 @@ export function HubModelPicker({ fineTunedRows, fineTunedCollapsed, filteredRecommendedIds, - formatFilter, hfIds, matchedCatalogGroups, + recommendedCatalogGroups, sortedLmStudio, lmStudioCollapsed, recommendedRows, @@ -4138,9 +4161,9 @@ export function HubModelPicker({ listing's task tags. Without one (legacy), the flat curated safetensors rows. */} {catalog - ? catalog - .filter((g) => catalogGroupMatchesFormat(g, formatFilter)) - .map((g) => renderCatalogGroupRow(g, "catalog-group")) + ? recommendedCatalogGroups.map((g) => + renderCatalogGroupRow(g, "catalog-group"), + ) : curatedSafetensorsRows.map((m) => { const optionKey = makeModelOptionKey( "curated-safetensors",