From 359c0ce881c0d0cfed6bbcb7ed8f0729dfdc3782 Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com> Date: Wed, 9 Sep 2026 03:14:22 +0000 Subject: [PATCH] fix(ui): omit GPU recommendations that do not fit When no sampled candidate fits GPU memory, ranking falls back to the oversized pool and labels its first model Best fit. Keep GPU picks within the existing 95% budget and hide the section when no candidate qualifies. Remove static GPU starter picks so Home cannot reintroduce the same error. Add browser regressions for both sections and document the empty result. CPU fallback behavior stays unchanged. Assisted-by: Codex:gpt-6 [Codex] Signed-off-by: Ettore Di Giacinto --- .../e2e/models-recommended-panel.spec.js | 45 +++++++++++++ .../react-ui/src/components/StarterModels.jsx | 67 +++---------------- .../src/hooks/useRecommendedModels.js | 6 +- docs/content/getting-started/models.md | 2 + 4 files changed, 61 insertions(+), 59 deletions(-) diff --git a/core/http/react-ui/e2e/models-recommended-panel.spec.js b/core/http/react-ui/e2e/models-recommended-panel.spec.js index 2c729b652..00d839780 100644 --- a/core/http/react-ui/e2e/models-recommended-panel.spec.js +++ b/core/http/react-ui/e2e/models-recommended-panel.spec.js @@ -103,3 +103,48 @@ test.describe("Models gallery - recommended panel prominence", () => { await expect(grid(page).locator(".lane__tag--evidence")).toHaveCount(1); }); }); + +// Start with a fitting model so absence assertions cannot pass during loading. +// Then change the polled hardware budget while keeping the same gallery. +for (const view of ["models", "home"]) { + test(`${view} removes GPU recommendations when no candidate fits`, async ({ page }) => { + await mockGallery(page, 0); + await page.route("**/v1/models", (route) => + route.fulfill({ json: { data: [] } }), + ); + const gib = 1024 ** 3; + let budget = 24 * gib; + await page.route("**/api/resources", (route) => + route.fulfill({ json: { + type: "gpu", + aggregate: { total_memory: budget, gpu_count: 1 }, + gpus: [{ vendor: "nvidia", total_memory: budget }], + } }), + ); + await page.route("**/api/models/estimate/*", (route) => + route.fulfill({ json: { + sizeBytes: 17.4 * gib, + sizeDisplay: "17.4 GB", + estimates: { 4096: { vramBytes: 18.4 * gib, vramDisplay: "18.4 GB" } }, + } }), + ); + await page.goto(view === "models" ? "/app/models" : "/app/"); + const section = view === "models" ? panel(page) : page.locator(".home-starters"); + await expect(section).toBeVisible(); + await expect(section).toContainText("tiny-chat"); + + // Wait for BOTH recommendation estimates, not the hook's loading render + // or the gallery rail's separate context-size requests. + const estimatesFinished = REC_MODELS.map(model => page.waitForResponse(response => { + const url = new URL(response.url()); + return url.pathname.endsWith('/api/models/estimate/' + model.name) && + url.searchParams.get('contexts') === '4096' && response.status() === 200; + }).then(response => response.finished())); + budget = 12 * gib; + await Promise.all(estimatesFinished); + await page.evaluate(() => new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + )); + await expect(section).toHaveCount(0, { timeout: 15_000 }); + }); +} diff --git a/core/http/react-ui/src/components/StarterModels.jsx b/core/http/react-ui/src/components/StarterModels.jsx index d5f8122b6..abeabfa5c 100644 --- a/core/http/react-ui/src/components/StarterModels.jsx +++ b/core/http/react-ui/src/components/StarterModels.jsx @@ -3,63 +3,17 @@ import { useTranslation } from 'react-i18next' import { modelsApi } from '../utils/api' import { useRecommendedModels, isNvfp4Name } from '../hooks/useRecommendedModels' -// Static fallback used only when the live gallery / estimates can't be reached -// (offline, trimmed gallery). The hook is the primary, data-driven path; these -// are real gallery names kept as a safety net so onboarding never shows nothing. -// Gemma picks use the QAT (quantization-aware-trained) Q4 builds. NVIDIA boxes -// get NVFP4 + MTP variants at the mid/large tiers (see NVIDIA below). -const BASE = { - cpu: [ - { name: 'gemma-4-e2b-it-qat-q4_0', size: '~1.5 GB' }, - { name: 'qwen3.5-4b-claude-4.6-opus-reasoning-distilled', size: '~2.5 GB' }, - { name: 'gemma-4-e4b-it-qat-q4_0', size: '~3 GB' }, - { name: 'lfm2.5-1.2b-instruct', size: '~0.8 GB' }, - ], - 'gpu-small': [ - { name: 'gemma-4-e4b-it-qat-q4_0', size: '~3 GB' }, - { name: 'lfm2.5-8b-a1b', size: '~5 GB' }, - { name: 'qwen3.5-9b', size: '~5.5 GB' }, - { name: 'gemma-4-12b-it-qat-q4_0', size: '~7 GB' }, - ], - 'gpu-mid': [ - { name: 'qwen3.6-27b', size: '~16 GB' }, - { name: 'qwen3.6-27b-mtp-pi-tune', size: '~16 GB' }, - { name: 'gemma-4-26b-a4b-it-qat-q4_0', size: '~16 GB' }, - { name: 'qwen3.5-27b', size: '~16 GB' }, - ], - 'gpu-large': [ - { name: 'qwen3.6-35b-a3b-apex', size: '~20 GB' }, - { name: 'qwen3.6-35b-a3b-claude-4.6-opus-reasoning-distilled', size: '~20 GB' }, - { name: 'gemma-4-31b-it-qat-q4_0', size: '~18 GB' }, - { name: 'qwen3.5-35b-a3b-apex', size: '~20 GB' }, - ], -} - -// NVIDIA-only overrides: NVFP4 is a Blackwell-optimised 4-bit format paired with -// MTP (multi-token prediction) for speed. Only the mid/large tiers have these. -const NVIDIA = { - 'gpu-mid': [ - { name: 'qwen3.6-27b-nvfp4-mtp', size: '~14 GB' }, - { name: 'qwen3.6-27b-mtp-pi-tune', size: '~16 GB' }, - { name: 'gemma-4-26b-a4b-it-qat-q4_0', size: '~16 GB' }, - { name: 'qwen3.6-27b', size: '~16 GB' }, - ], - 'gpu-large': [ - { name: 'qwen3.6-35b-a3b-nvfp4-mtp', size: '~18 GB' }, - { name: 'qwen3.6-27b-nvfp4-mtp', size: '~14 GB' }, - { name: 'qwen3.6-35b-a3b-apex', size: '~20 GB' }, - { name: 'gemma-4-31b-it-qat-q4_0', size: '~18 GB' }, - ], -} - -function fallbackFor(tierId, isNvidia) { - if (isNvidia && NVIDIA[tierId]) return NVIDIA[tierId] - return BASE[tierId] || BASE.cpu -} +// Offline CPU suggestions do not claim a measured GPU fit. +const CPU_FALLBACK = [ + { name: 'gemma-4-e2b-it-qat-q4_0', size: '~1.5 GB' }, + { name: 'qwen3.5-4b-claude-4.6-opus-reasoning-distilled', size: '~2.5 GB' }, + { name: 'gemma-4-e4b-it-qat-q4_0', size: '~3 GB' }, + { name: 'lfm2.5-1.2b-instruct', size: '~0.8 GB' }, +] export default function StarterModels({ addToast, onInstallStarted }) { const { t } = useTranslation('home') - const { recommended, tier, isNvidia, loading } = useRecommendedModels({ count: 4 }) + const { recommended, tier, loading } = useRecommendedModels({ count: 4 }) const [installing, setInstalling] = useState(() => new Set()) // While the hardware probe + gallery query are in flight, render nothing @@ -67,10 +21,11 @@ export default function StarterModels({ addToast, onInstallStarted }) { if (loading) return null // Prefer live recommendations; fall back to the static list only when the - // gallery yielded nothing. + // gallery yielded nothing on a CPU host. Static GPU picks have no measured + // fit and must not replace an empty set of fitting recommendations. const items = (recommended && recommended.length > 0) ? recommended.map(r => ({ name: r.name, size: r.sizeDisplay })) - : fallbackFor(tier.id, isNvidia) + : tier.id === 'cpu' ? CPU_FALLBACK : [] if (items.length === 0) return null diff --git a/core/http/react-ui/src/hooks/useRecommendedModels.js b/core/http/react-ui/src/hooks/useRecommendedModels.js index c970c7625..4436ce705 100644 --- a/core/http/react-ui/src/hooks/useRecommendedModels.js +++ b/core/http/react-ui/src/hooks/useRecommendedModels.js @@ -53,16 +53,16 @@ function rank(candidates, tier, count, isNvidia) { } const limit = tier.vram * 0.95 const fits = pool.filter(c => c.vramBytes != null && c.vramBytes <= limit) - const base = fits.length > 0 ? fits : pool // tiny GPU where nothing fits → fall through to smallest const byPreference = (a, b) => { // On NVIDIA, surface NVFP4 first; then largest-that-fits (best quality). if (isNvidia) { const an = isNvfp4Name(a.name), bn = isNvfp4Name(b.name) if (an !== bn) return an ? -1 : 1 } - return fits.length > 0 ? b.sizeBytes - a.sizeBytes : a.sizeBytes - b.sizeBytes + return b.sizeBytes - a.sizeBytes } - return [...base].sort(byPreference).slice(0, count) + // An oversized or unestimated model cannot be labelled a hardware fit. + return [...fits].sort(byPreference).slice(0, count) } export function useRecommendedModels({ count = 4, candidatePool = 10 } = {}) { diff --git a/docs/content/getting-started/models.md b/docs/content/getting-started/models.md index b34440079..9c6ce32f7 100644 --- a/docs/content/getting-started/models.md +++ b/docs/content/getting-started/models.md @@ -19,6 +19,8 @@ This section covers everything you need to know about installing and configuring The Model Gallery is the simplest way to install models. It provides pre-configured models ready to use. +GPU recommendations require a memory estimate within 95% of the detected model memory budget at a 4096-token context. If none of the sampled candidates fit, the recommendation section is hidden. You can still browse the gallery and check individual models at your intended context size. The Home page also omits static GPU suggestions when no fitting recommendation is available. + ### Via WebUI 1. Open the LocalAI WebUI at `http://localhost:8080`