From 36268b844462396eff2349c311dcf5d9b9bc33b9 Mon Sep 17 00:00:00 2001 From: shimmyshimmer Date: Thu, 23 Jul 2026 16:21:08 -0700 Subject: [PATCH 01/11] Studio: auto-load only on-device models on send; never download the hard-coded default Fixes #7374. Pressing Send with no model loaded could miss local models (models dir, LM Studio, custom scan folders, inactive HF caches) and then download unsloth/Qwen3.5-4B-MTP-GGUF from Hugging Face without consent. - autoLoadSmallestModel is now autoLoadOnDeviceModel: adopts the server active model, then the remembered on-device model, then the smallest complete chat-capable on-device model (GGUF first), across managed HF caches and the unified backend local inventory. - Inventory failures fail closed instead of reading as an empty cache. - The remote Qwen fallback is removed from the send path; with no valid candidate the user is asked to select or explicitly download a model. - last-local-model-load records loadId/inventoryId/source with backward compatible v1 parsing; indexed local loads are remembered while native path lease picks stay excluded. - New source-contract tests in tests/studio/test_model_picker_contracts.py. --- .../src/features/chat/api/chat-adapter.ts | 490 ++++++++++++------ .../chat/hooks/use-chat-model-runtime.ts | 28 +- .../frontend/src/features/chat/types/api.ts | 2 + .../chat/utils/last-local-model-load.ts | 86 ++- tests/studio/test_model_picker_contracts.py | 177 ++++++- 5 files changed, 599 insertions(+), 184 deletions(-) diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index d4861e8a3a0..4eea97db4cd 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -2,6 +2,18 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { getAuthToken } from "@/features/auth"; +import { + type CachedGgufRepo, + type CachedModelRepo, + type LocalModelInfo, + listCachedGguf, + listCachedModels, + listLocalModels, +} from "@/features/hub/inventory/api"; +import { + ensureHiddenModelMatchers, + isHiddenModelId, +} from "@/features/hub/lib/hidden-models"; import { resolveInitialConfig } from "@/features/model-picker"; import { projectHasSources } from "@/features/rag/api/rag-api"; import { apiUrl } from "@/lib/api-base"; @@ -77,9 +89,12 @@ import { updateStoredChatThread, } from "../utils/chat-history-storage"; import { + isManagedCacheSource, readLastLocalModelLoad, recordLastLocalModelLoad, type LastLocalModelKind, + type LastLocalModelLoad, + type LastLocalModelSource, } from "../utils/last-local-model-load"; import { getImageInputUnavailableReason } from "../utils/image-input-support"; import { @@ -89,8 +104,6 @@ import { import { resolveLoadMaxSeqLength } from "../presets/preset-policy"; import { generateAudio, - listCachedGguf, - listCachedModels, listGgufVariants, loadModel, streamChatCompletions, @@ -1391,11 +1404,6 @@ function waitForModelReady(abortSignal?: AbortSignal): Promise { }); } -/** - * Auto-load the smallest downloaded model when the user chats without - * selecting one. Prefers GGUF (smallest cached variant), then smallest - * cached safetensors model. - */ // Cap cascade so broken cached repos can't spam /api/inference/load. const MAX_AUTO_LOAD_ATTEMPTS = 3; const BIG_ENDIAN_GGUF_FILENAME_RE = /(^|[-_])be(?:[._-]|$)/gi; @@ -1409,6 +1417,8 @@ type AutoLoadCandidate = { ggufVariant: string | null; maxSeqLength: number; successLabel: string; + inventoryId?: string | null; + source: LastLocalModelSource; }; function autoLoadCandidateKey( @@ -1427,6 +1437,95 @@ function findCachedRepo( return repos.find((repo) => repo.repo_id.toLowerCase() === normalized); } +/** + * Managed-cache rows eligible for background auto-load: complete, not + * hidden infrastructure, and not declared non-chat by the backend. + */ +function isAutoLoadableCachedRepo(repo: { + repo_id: string; + partial?: boolean; + capabilities?: { can_chat?: boolean } | null; +}): boolean { + if (repo.partial) return false; + if (repo.capabilities?.can_chat === false) return false; + return !isHiddenModelId(repo.repo_id); +} + +// Same on-device scan sources the unified picker exposes +// (use-chat-picker-inventory's PICKER_LOCAL_SOURCES). hf_cache rows are +// covered by the cached lists; ollama links are not directly loadable. +const AUTO_LOAD_LOCAL_SOURCES: ReadonlySet = new Set([ + "models_dir", + "lmstudio", + "custom", +]); + +/** + * Backend-indexed local rows eligible for background auto-load: same policy + * as the on-device picker (complete, chat-capable, not hidden infra), plus + * no variant requirement, since a background load cannot ask for a quant. + */ +function isAutoLoadableLocalRow(row: LocalModelInfo): boolean { + if (!AUTO_LOAD_LOCAL_SOURCES.has(row.source)) return false; + if (row.capabilities?.can_chat !== true) return false; + if (row.partial) return false; + if (isHiddenModelId(row.model_id, row.id, row.path)) return false; + if ( + row.model_format === "gguf" && + hasBigEndianGgufMarker(row.path, row.format_variant) + ) { + return false; + } + return true; +} + +function localRowLoadTarget(row: LocalModelInfo): string { + return row.load_id || row.id; +} + +function localRowToCandidate( + row: LocalModelInfo, + ggufVariant: string | null = null, +): AutoLoadCandidate { + const isGguf = row.model_format === "gguf"; + return { + id: row.id, + loadId: localRowLoadTarget(row), + kind: isGguf ? "gguf" : "model", + // The backend load target identifies the GGUF itself; no Hub quant is + // required (a remembered quant is passed through for multi-quant dirs). + ggufVariant: isGguf ? ggufVariant : null, + maxSeqLength: isGguf ? 0 : 4096, + successLabel: `Loaded ${row.display_name || row.id}`, + inventoryId: row.inventory_id ?? null, + source: AUTO_LOAD_LOCAL_SOURCES.has(row.source) + ? (row.source as LastLocalModelSource) + : "local", + }; +} + +/** Resolve a remembered local model against current backend inventory. */ +function matchesRememberedLocalRow( + row: LocalModelInfo, + remembered: LastLocalModelLoad, +): boolean { + if ( + remembered.inventoryId && + row.inventory_id && + row.inventory_id.toLowerCase() === remembered.inventoryId.toLowerCase() + ) { + return true; + } + const targets = new Set( + [remembered.loadId, remembered.id] + .filter((value): value is string => Boolean(value)) + .map((value) => value.toLowerCase()), + ); + return [row.load_id, row.id, row.path, row.model_id].some( + (value) => !!value && targets.has(value.toLowerCase()), + ); +} + function hasBigEndianGgufMarker(filename: string, quant?: string | null): boolean { const normalized = filename.replace(/\\/g, "/").toLowerCase(); const separatorIndex = normalized.lastIndexOf("/"); @@ -1463,9 +1562,19 @@ function isAutoLoadableGgufVariant(variant: GgufVariantDetail | null): boolean { return !hasBigEndianGgufMarker(filename, variant.quant); } -async function autoLoadSmallestModel(): Promise<{ +/** + * Auto-load a model already on this device when the user chats without + * selecting one: adopt the server-active model, then the last successfully + * loaded on-device model (managed HF caches, models dir, LM Studio, custom + * scan folders), then the smallest complete chat-capable on-device model + * (GGUF first, then safetensors). Never downloads: with no valid on-device + * candidate the caller shows the actionable "no model" error instead. + */ +async function autoLoadOnDeviceModel(): Promise<{ loaded: boolean; blockedByTrustRemoteCode: boolean; + /** True when an inventory failure was already surfaced to the user. */ + inventoryErrorSurfaced?: boolean; }> { if (await tryAdoptServerActiveModel()) { return { loaded: true, blockedByTrustRemoteCode: false }; @@ -1479,7 +1588,7 @@ async function autoLoadSmallestModel(): Promise<{ const toastId = toast("Loading a model…", { description: lastLoaded ? "Loading last used model." - : "Auto-selecting the smallest downloaded model.", + : "Auto-selecting the smallest on-device model.", duration: 5000, closeButton: true, }); @@ -1736,21 +1845,97 @@ async function autoLoadSmallestModel(): Promise<{ id: candidate.id, kind: candidate.kind, ggufVariant: candidate.ggufVariant, + loadId: candidate.loadId ?? null, + inventoryId: candidate.inventoryId ?? null, + source: candidate.source, }); } toast.success(candidate.successLabel, { id: toastId }); return true; } + // An inventory failure is NOT an empty inventory: surface it and stop the + // automatic selection path instead of concluding nothing is on device. + let allGgufRepos: CachedGgufRepo[]; + let allModelRepos: CachedModelRepo[]; + let allLocalRows: LocalModelInfo[]; try { - const [ggufRepos, modelRepos] = await Promise.all([ - listCachedGguf().catch(() => []), - listCachedModels().catch(() => []), + // Dynamic hidden-model matchers are best-effort; the static needles + // still filter the built-in infra models when the fetch fails. + await ensureHiddenModelMatchers().catch(() => undefined); + const [cachedGguf, cachedModels, localList] = await Promise.all([ + listCachedGguf(hfToken), + listCachedModels(hfToken), + listLocalModels(), ]); + allGgufRepos = cachedGguf; + allModelRepos = cachedModels; + allLocalRows = localList.models; + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Could not read the on-device model inventory."; + toast.error("Couldn't check on-device models", { + id: toastId, + description: message, + }); + return { + loaded: false, + blockedByTrustRemoteCode: false, + inventoryErrorSurfaced: true, + }; + } + const ggufRepos = allGgufRepos.filter(isAutoLoadableCachedRepo); + const modelRepos = allModelRepos.filter(isAutoLoadableCachedRepo); + const localRows = allLocalRows.filter(isAutoLoadableLocalRow); + // Dedupe candidates that appear in both the cached and the local + // inventory (e.g. a custom scan folder pointing into an HF cache). + const seenLoadTargets = new Set(); + const markSeen = (...values: (string | null | undefined)[]): void => { + for (const value of values) { + if (value) seenLoadTargets.add(value.toLowerCase()); + } + }; + const isSeen = (...values: (string | null | undefined)[]): boolean => + values.some((value) => !!value && seenLoadTargets.has(value.toLowerCase())); + + try { if (lastLoaded) { - if (lastLoaded.kind === "gguf") { + if (!isManagedCacheSource(lastLoaded.source)) { + const row = localRows.find((candidateRow) => + matchesRememberedLocalRow(candidateRow, lastLoaded), + ); + if (row) { + markSeen(row.load_id, row.id, row.path, row.model_id); + try { + toast("Loading last used model…", { + id: toastId, + description: row.display_name || row.id, + duration: 5000, + }); + if ( + await loadAutoLoadCandidate( + localRowToCandidate(row, lastLoaded.ggufVariant), + ) + ) { + return { loaded: true, blockedByTrustRemoteCode: false }; + } + } catch { + hadNonTrustFailure = true; + skippedAutoLoadCandidates.add( + autoLoadCandidateKey( + row.model_format === "gguf" ? "gguf" : "model", + row.id, + lastLoaded.ggufVariant, + ), + ); + } + } + } else if (lastLoaded.kind === "gguf") { const repo = findCachedRepo(ggufRepos, lastLoaded.id); if (repo && lastLoaded.ggufVariant) { + markSeen(repo.repo_id, repo.load_id, repo.cache_path); try { const variants = await listGgufVariants(repo.repo_id, undefined, { preferLocalCache: true, @@ -1759,6 +1944,7 @@ async function autoLoadSmallestModel(): Promise<{ const variant = variants.variants.find( (entry) => entry.downloaded && + !entry.partial && entry.quant?.toLowerCase() === lastLoaded.ggufVariant?.toLowerCase() && isAutoLoadableGgufVariant(entry), @@ -1777,6 +1963,8 @@ async function autoLoadSmallestModel(): Promise<{ ggufVariant: variant.quant, maxSeqLength: 0, successLabel: `Loaded ${repo.repo_id} (${variant.quant})`, + inventoryId: repo.inventory_id ?? null, + source: "hf_cache", }) ) { return { loaded: true, blockedByTrustRemoteCode: false }; @@ -1792,6 +1980,7 @@ async function autoLoadSmallestModel(): Promise<{ } else { const repo = findCachedRepo(modelRepos, lastLoaded.id); if (repo) { + markSeen(repo.repo_id, repo.load_id, repo.cache_path); try { toast("Loading last used model…", { id: toastId, @@ -1806,6 +1995,8 @@ async function autoLoadSmallestModel(): Promise<{ ggufVariant: null, maxSeqLength: store.params.maxSeqLength, successLabel: `Loaded ${repo.repo_id}`, + inventoryId: repo.inventory_id ?? null, + source: "hf_cache", }) ) { return { loaded: true, blockedByTrustRemoteCode: false }; @@ -1820,23 +2011,70 @@ async function autoLoadSmallestModel(): Promise<{ } toast("Loading a model…", { id: toastId, - description: "Auto-selecting the smallest downloaded model.", + description: "Auto-selecting the smallest on-device model.", duration: 5000, }); } - // GGUF first: smallest-total-size repo, then its smallest variant. - if (ggufRepos.length > 0) { - const sorted = [...ggufRepos].sort((a, b) => a.size_bytes - b.size_bytes); - for (const repo of sorted) { - if (loadAttempts >= MAX_AUTO_LOAD_ATTEMPTS) break; + // Deterministic on-device fallback: complete/loadable GGUF models first, + // then complete/loadable non-GGUF models, smallest first within each + // group, merging managed-cache repos with backend-indexed local rows. + type FallbackCandidate = + | { type: "cached-gguf"; repo: CachedGgufRepo; sizeBytes: number } + | { type: "cached-model"; repo: CachedModelRepo; sizeBytes: number } + | { type: "local"; row: LocalModelInfo; sizeBytes: number }; + // Unknown sizes (0) sort last so a sizeless row can't shadow a real one. + const sizeOrUnknown = (bytes?: number | null): number => + bytes && bytes > 0 ? bytes : Number.MAX_SAFE_INTEGER; + const bySizeAsc = (a: FallbackCandidate, b: FallbackCandidate): number => + a.sizeBytes - b.sizeBytes; + // Background loads cannot ask which quant/variant to use. + const cascadeLocalRows = localRows.filter( + (row) => row.capabilities?.requires_variant !== true, + ); + const ggufGroup: FallbackCandidate[] = [ + ...ggufRepos.map((repo) => ({ + type: "cached-gguf" as const, + repo, + sizeBytes: sizeOrUnknown(repo.size_bytes), + })), + ...cascadeLocalRows + .filter((row) => row.model_format === "gguf") + .map((row) => ({ + type: "local" as const, + row, + sizeBytes: sizeOrUnknown(row.size_bytes), + })), + ].sort(bySizeAsc); + const modelGroup: FallbackCandidate[] = [ + ...modelRepos.map((repo) => ({ + type: "cached-model" as const, + repo, + sizeBytes: sizeOrUnknown(repo.size_bytes), + })), + ...cascadeLocalRows + .filter((row) => row.model_format !== "gguf") + .map((row) => ({ + type: "local" as const, + row, + sizeBytes: sizeOrUnknown(row.size_bytes), + })), + ].sort(bySizeAsc); + + for (const candidate of [...ggufGroup, ...modelGroup]) { + if (loadAttempts >= MAX_AUTO_LOAD_ATTEMPTS) break; + if (candidate.type === "cached-gguf") { + const repo = candidate.repo; + markSeen(repo.repo_id, repo.load_id, repo.cache_path); try { const variants = await listGgufVariants(repo.repo_id, undefined, { preferLocalCache: true, localPath: repo.cache_path, }); const downloaded = variants.variants - .filter((v) => v.downloaded && isAutoLoadableGgufVariant(v)) + .filter( + (v) => v.downloaded && !v.partial && isAutoLoadableGgufVariant(v), + ) .sort((a, b) => a.size_bytes - b.size_bytes); if (downloaded.length > 0) { const variant = downloaded[0]; @@ -1855,6 +2093,8 @@ async function autoLoadSmallestModel(): Promise<{ ggufVariant: variant.quant, maxSeqLength: 0, successLabel: `Loaded ${repo.repo_id} (${variant.quant})`, + inventoryId: repo.inventory_id ?? null, + source: "hf_cache", }) ) { return { loaded: true, blockedByTrustRemoteCode: false }; @@ -1862,26 +2102,20 @@ async function autoLoadSmallestModel(): Promise<{ } } catch { hadNonTrustFailure = true; - continue; } + continue; } - } - - // Fall back to safetensors models. - if (modelRepos.length > 0) { - const sorted = [...modelRepos].sort( - (a, b) => a.size_bytes - b.size_bytes, - ); - for (const repo of sorted) { - if (loadAttempts >= MAX_AUTO_LOAD_ATTEMPTS) break; + if (candidate.type === "cached-model") { + const repo = candidate.repo; + markSeen(repo.repo_id, repo.load_id, repo.cache_path); + if ( + skippedAutoLoadCandidates.has( + autoLoadCandidateKey("model", repo.repo_id), + ) + ) { + continue; + } try { - if ( - skippedAutoLoadCandidates.has( - autoLoadCandidateKey("model", repo.repo_id), - ) - ) { - continue; - } if ( await loadAutoLoadCandidate({ id: repo.repo_id, @@ -1890,141 +2124,52 @@ async function autoLoadSmallestModel(): Promise<{ ggufVariant: null, maxSeqLength: 4096, successLabel: `Loaded ${repo.repo_id}`, + inventoryId: repo.inventory_id ?? null, + source: "hf_cache", }) ) { return { loaded: true, blockedByTrustRemoteCode: false }; } } catch { hadNonTrustFailure = true; - continue; } + continue; } - } - - // Cap also gates the default download, so total /api/inference/load - // budget across cached + fallback is MAX_AUTO_LOAD_ATTEMPTS, not +1. - if (loadAttempts >= MAX_AUTO_LOAD_ATTEMPTS) { - toast.dismiss(toastId); - return { - loaded: false, - blockedByTrustRemoteCode: - blockedByTrustRemoteCode && !hadNonTrustFailure, - }; - } - - // No cached models — try downloading a small default GGUF. - toast("Downloading a small model…", { - id: toastId, - description: - "No downloaded models found. Fetching Qwen3.5-4B-MTP (UD-Q4_K_XL).", - duration: 30000, - }); - try { - const rt = useChatRuntimeStore.getState(); + const row = candidate.row; + if (isSeen(row.load_id, row.id, row.path, row.model_id)) { + continue; + } + const localCandidate = localRowToCandidate(row); if ( - !(await canAutoLoad({ - model_path: "unsloth/Qwen3.5-4B-MTP-GGUF", - max_seq_length: 0, - is_lora: false, - gguf_variant: "UD-Q4_K_XL", - // The same live-store GPU pick the load below sends (a fresh default - // model has no remembered settings to prefer). - gpu_ids: rt.selectedGpuIds ?? undefined, - gpu_memory_mode: rt.gpuMemoryMode, - })) + skippedAutoLoadCandidates.has( + autoLoadCandidateKey( + localCandidate.kind, + localCandidate.id, + localCandidate.ggufVariant, + ), + ) ) { - toast.dismiss(toastId); - return { loaded: false, blockedByTrustRemoteCode }; + continue; } - loadAttempts += 1; - const loadResp = await loadModel({ - model_path: "unsloth/Qwen3.5-4B-MTP-GGUF", - hf_token: hfToken, - // Model default under both modes: Auto layers + no pin means - // resolveFitMaxSeqLength returns 0 for every mode (the canAutoLoad - // preflight above sends the same). - max_seq_length: 0, - load_in_4bit: true, - is_lora: false, - gguf_variant: "UD-Q4_K_XL", - trust_remote_code: trustRemoteCode, - speculative_type: specSettings.speculativeType, - spec_draft_n_max: specSettings.specDraftNMax, - // GPU Memory mode is a standing preference, so honor it on auto-load. - // The layer/MoE/split knobs and the context pin are per-model: the live - // store may hold edits drafted for a staged pick, and a fresh default - // model has no remembered settings, so those stay at their defaults like - // the cached-candidate path. The GPU pick deliberately differs (it's the - // picker's current on-screen selection, which the canAutoLoad preflight - // above already committed to). - gpu_memory_mode: rt.gpuMemoryMode, - gpu_layers: GPU_LAYERS_AUTO, - n_cpu_moe: 0, - gpu_ids: rt.selectedGpuIds ?? undefined, - }); - saveSpeculativeType(specSettings.speculativeType); - persistGpuMemoryModeOnLoad(loadResp, rt.gpuMemoryMode); - useChatRuntimeStore - .getState() - .setCheckpoint("unsloth/Qwen3.5-4B-MTP-GGUF", "UD-Q4_K_XL"); - const store = useChatRuntimeStore.getState(); - store.setModelRequiresTrustRemoteCode( - loadResp.requires_trust_remote_code ?? false, - ); - store.setParams({ - ...store.params, - maxTokens: loadResp.context_length ?? 131072, - }); - const defaultModel: ChatModelSummary = { - id: "unsloth/Qwen3.5-4B-MTP-GGUF", - name: loadResp.display_name ?? "Qwen3.5-4B-MTP-GGUF", - isVision: loadResp.is_vision ?? false, - isLora: false, - isGguf: true, - }; - if (!store.models.some((m) => m.id === "unsloth/Qwen3.5-4B-MTP-GGUF")) { - store.setModels([...store.models, defaultModel]); + markSeen(row.load_id, row.id, row.path, row.model_id); + try { + if (await loadAutoLoadCandidate(localCandidate)) { + return { loaded: true, blockedByTrustRemoteCode: false }; + } + } catch { + hadNonTrustFailure = true; } - useChatRuntimeStore.setState({ - ggufContextLength: loadResp.context_length ?? 131072, - ggufMaxContextLength: - loadResp.max_context_length ?? loadResp.context_length ?? 131072, - supportsReasoning: loadResp.supports_reasoning ?? false, - reasoningAlwaysOn: loadResp.reasoning_always_on ?? false, - reasoningEnabled: loadResp.supports_reasoning ?? false, - ...reasoningCapsFromLoad(loadResp), - supportsPreserveThinking: loadResp.supports_preserve_thinking ?? false, - supportsTools: loadResp.supports_tools ?? false, - ...resolveToolsEnabledOnLoad(loadResp.supports_tools ?? false), - kvCacheDtype: loadResp.cache_type_kv ?? null, - loadedKvCacheDtype: loadResp.cache_type_kv ?? null, - tensorParallel: loadResp.tensor_parallel ?? false, - loadedTensorParallel: loadResp.tensor_parallel ?? false, - ...loadedGpuMemoryFields(loadResp), - // Drives the GPU Memory controls' diffusion gate; set alongside the - // GPU fields on every load path so the gate can't read stale. - loadedIsDiffusion: loadResp.is_diffusion ?? false, - defaultChatTemplate: loadResp.chat_template ?? null, - chatTemplateOverride: null, - loadedIsMultimodal: isMultimodalResponse(loadResp), - ...resolveLoadedSpeculativeSettings(loadResp), - }); - recordLastLocalModelLoad({ - id: "unsloth/Qwen3.5-4B-MTP-GGUF", - kind: "gguf", - ggufVariant: "UD-Q4_K_XL", - }); - toast.success("Loaded Qwen3.5-4B-MTP (UD-Q4_K_XL)", { id: toastId }); - return { loaded: true, blockedByTrustRemoteCode: false }; - } catch { - toast.dismiss(toastId); - hadNonTrustFailure = true; - return { - loaded: false, - blockedByTrustRemoteCode: - blockedByTrustRemoteCode && !hadNonTrustFailure, - }; } + + // No auto-loadable on-device model (or the attempt cap was hit). Never + // fall back to a remote download from the send path: the caller shows + // the actionable "no model" error and any download stays an explicit + // user action. + toast.dismiss(toastId); + return { + loaded: false, + blockedByTrustRemoteCode: blockedByTrustRemoteCode && !hadNonTrustFailure, + }; } catch { toast.dismiss(toastId); hadNonTrustFailure = true; @@ -2101,24 +2246,27 @@ export function createOpenAIStreamAdapter( // Prefer a model already loaded by the CLI/API before auto-loading. let loaded: boolean; let blockedByTrustRemoteCode: boolean; + let inventoryErrorSurfaced: boolean | undefined; try { - ({ loaded, blockedByTrustRemoteCode } = - await autoLoadSmallestModel()); + ({ loaded, blockedByTrustRemoteCode, inventoryErrorSurfaced } = + await autoLoadOnDeviceModel()); } catch (error) { clearSelectedImageEditReference(); throw error; } if (!loaded) { - toast.error( - blockedByTrustRemoteCode - ? "This model needs custom code approval" - : "No model loaded", - { - description: blockedByTrustRemoteCode - ? "Select it from the top bar to review and approve its custom code, or pick another model." - : "Pick a model in the top bar, then retry.", - }, - ); + if (!inventoryErrorSurfaced) { + toast.error( + blockedByTrustRemoteCode + ? "This model needs custom code approval" + : "No model loaded", + { + description: blockedByTrustRemoteCode + ? "Select it from the top bar to review and approve its custom code, or pick another model." + : "Select a model in the top bar, or download one from the Hub, then retry.", + }, + ); + } clearSelectedImageEditReference(); throw new Error("Load a model first."); } diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index 76a310ac33c..3e9ae4bbc78 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -1044,21 +1044,39 @@ export function useChatModelRuntime() { } } await refresh({ signal: abortCtrl.signal }); + // Native-picked files are never remembered: their access depends + // on a signed, expiring path lease that a raw remembered path + // would bypass. Backend-indexed local rows (models dir, LM + // Studio, custom scan folders; selection source "local") are + // remembered and re-resolved through inventory on auto-load. + // Other arbitrary paths keep the isLocalModelPath protection. + const indexedLocalSelection = + typeof selection !== "string" && selection.source === "local"; if ( !isLora && !(loadResponse.is_lora ?? false) && !nativePathToken && - !isLocalModelPath(modelId) && - !isExternalModelId(modelId) + !isExternalModelId(modelId) && + (indexedLocalSelection || !isLocalModelPath(modelId)) ) { - if (loadResponse.is_gguf || isGguf || ggufVariant) { + const kind = + loadResponse.is_gguf || isGguf || ggufVariant + ? ("gguf" as const) + : ("model" as const); + if (isLocalModelPath(modelId)) { recordLastLocalModelLoad({ id: modelId, - kind: "gguf", + kind, ggufVariant: ggufVariant ?? null, + loadId: modelId, + source: "local", }); } else { - recordLastLocalModelLoad({ id: modelId, kind: "model" }); + recordLastLocalModelLoad({ + id: modelId, + kind, + ggufVariant: ggufVariant ?? null, + }); } } } catch (error) { diff --git a/studio/frontend/src/features/chat/types/api.ts b/studio/frontend/src/features/chat/types/api.ts index c9c06834c1d..0d0b92de674 100644 --- a/studio/frontend/src/features/chat/types/api.ts +++ b/studio/frontend/src/features/chat/types/api.ts @@ -115,6 +115,8 @@ export interface GgufVariantDetail { download_size_bytes?: number; downloaded?: boolean; update_available?: boolean; + /** True while an in-progress (.incomplete) blob exists for this variant. */ + partial?: boolean; } export interface GgufVariantsResponse { diff --git a/studio/frontend/src/features/chat/utils/last-local-model-load.ts b/studio/frontend/src/features/chat/utils/last-local-model-load.ts index 099386fbc74..eab93eb52fe 100644 --- a/studio/frontend/src/features/chat/utils/last-local-model-load.ts +++ b/studio/frontend/src/features/chat/utils/last-local-model-load.ts @@ -3,13 +3,46 @@ export type LastLocalModelKind = "gguf" | "model"; +/** + * Where the remembered model came from: + * - "hf_cache": a managed Hugging Face cache repo (active or inactive cache), + * resolved through the cached-gguf / cached-models inventory. + * - "models_dir" / "lmstudio" / "custom": a backend-indexed local inventory + * row, resolved through the /api/hub/local inventory. + * - "local": an indexed local row whose exact scan source was not known at + * record time (interactive picker loads); resolved like the other local + * sources. + * + * Native file-picker selections are never recorded here: their access depends + * on a signed, expiring native path lease that must not be bypassed by a raw + * remembered path (the caller skips recording when a lease token is present). + */ +export type LastLocalModelSource = + | "hf_cache" + | "models_dir" + | "lmstudio" + | "custom" + | "local"; + export type LastLocalModelLoad = { + /** Display / repository identity (HF repo id, or a local path for indexed rows). */ id: string; kind: LastLocalModelKind; + /** Managed-cache GGUF quant. Null is valid when the load target itself + * identifies the actual GGUF (a local file or directory). */ ggufVariant: string | null; + /** Backend-provided load target when it differs from `id` (e.g. an + * inactive-cache row's load_id or an indexed local path). */ + loadId: string | null; + /** Stable backend inventory row identity, when available. */ + inventoryId: string | null; + source: LastLocalModelSource; loadedAt: number; }; +// Kept at v1: new fields are parsed backward-compatibly, so existing records +// (which predate loadId/inventoryId/source) keep resolving as managed-cache +// entries without a migration pass. const STORAGE_KEY = "unsloth.last-local-model-load.v1"; function storage(): Storage | null { @@ -24,6 +57,28 @@ function isLastLocalModelKind(value: unknown): value is LastLocalModelKind { return value === "gguf" || value === "model"; } +const LOCAL_MODEL_SOURCES: readonly LastLocalModelSource[] = [ + "hf_cache", + "models_dir", + "lmstudio", + "custom", + "local", +]; + +function isLastLocalModelSource( + value: unknown, +): value is LastLocalModelSource { + return LOCAL_MODEL_SOURCES.includes(value as LastLocalModelSource); +} + +export function isManagedCacheSource(source: LastLocalModelSource): boolean { + return source === "hf_cache"; +} + +function normalizeOptionalString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value : null; +} + export function readLastLocalModelLoad(): LastLocalModelLoad | null { try { const raw = storage()?.getItem(STORAGE_KEY); @@ -39,17 +94,25 @@ export function readLastLocalModelLoad(): LastLocalModelLoad | null { ) { return null; } - if ( - parsed.kind === "gguf" && - (typeof parsed.ggufVariant !== "string" || !parsed.ggufVariant.trim()) - ) { + // Legacy v1 records carry no source; they were only ever written for + // managed-cache repos. + const source = isLastLocalModelSource(parsed.source) + ? parsed.source + : "hf_cache"; + const ggufVariant = normalizeOptionalString(parsed.ggufVariant); + // A managed-cache GGUF loads by repo + quant, so the quant is required. + // An indexed local GGUF's load target identifies the file itself, so a + // null variant stays valid. + if (parsed.kind === "gguf" && source === "hf_cache" && !ggufVariant) { return null; } return { id: parsed.id, kind: parsed.kind, - ggufVariant: - typeof parsed.ggufVariant === "string" ? parsed.ggufVariant : null, + ggufVariant, + loadId: normalizeOptionalString(parsed.loadId), + inventoryId: normalizeOptionalString(parsed.inventoryId), + source, loadedAt: parsed.loadedAt, }; } catch { @@ -61,22 +124,31 @@ export function recordLastLocalModelLoad(input: { id: string; kind: LastLocalModelKind; ggufVariant?: string | null; + loadId?: string | null; + inventoryId?: string | null; + source?: LastLocalModelSource; }): void { const id = input.id.trim(); if (!id) { return; } + const source = input.source ?? "hf_cache"; const ggufVariant = input.ggufVariant?.trim() || null; - if (input.kind === "gguf" && !ggufVariant) { + if (input.kind === "gguf" && source === "hf_cache" && !ggufVariant) { return; } try { + // Only inventory identity is stored: never tokens, native path leases, or + // security approvals. storage()?.setItem( STORAGE_KEY, JSON.stringify({ id, kind: input.kind, ggufVariant: input.kind === "gguf" ? ggufVariant : null, + loadId: input.loadId?.trim() || null, + inventoryId: input.inventoryId?.trim() || null, + source, loadedAt: Date.now(), } satisfies LastLocalModelLoad), ); diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index baf0fdf1bff..13aead7e3c1 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -265,7 +265,7 @@ def test_chat_autoload_scopes_variant_lookup_to_cached_repo_path(): """Autoload must probe the exact cache row it will load, including rows retained from a previously selected Hugging Face cache.""" src = _read("features/chat/api/chat-adapter.ts") - auto_load = src.split("async function autoLoadSmallestModel", 1)[1] + auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1] assert auto_load.count("preferLocalCache: true") >= 2 assert auto_load.count("localPath: repo.cache_path") >= 2 @@ -442,3 +442,178 @@ def test_legacy_migration_is_idempotent_and_non_destructive(): # Layer 3: non-overwriting merge skips an existing (or default) key, so even a # forced re-run cannot duplicate or clobber a user's config. assert "if (isDefaultConfig(migrated) || Object.hasOwn(map, key)) {" in src + + +# --------------------------------------------------------------------------- +# Send-with-no-model auto-load (issue #7374): on-device discovery must cover +# every picker inventory source, the remembered model must survive local +# (non-cache) loads, and the send path must never start a remote download. +# --------------------------------------------------------------------------- + + +def _autoload_section() -> str: + src = _read("features/chat/api/chat-adapter.ts") + return src.split("async function autoLoadOnDeviceModel", 1)[1] + + +def test_send_path_cannot_reach_hardcoded_default_download(): + """Pressing Send with no model loaded must never fetch the hard-coded + default repo from Hugging Face (the unconsented download in the bug + report). Any recommended download must stay an explicit user action.""" + src = _read("features/chat/api/chat-adapter.ts") + assert "Qwen3.5-4B-MTP-GGUF" not in src + assert "Downloading a small model" not in src + assert "No downloaded models found" not in src + # The old entry point must not linger anywhere. + assert "autoLoadSmallestModel" not in src + # The renamed entry point runs exactly once per send, so the submitted + # prompt executes exactly once after a successful load. + assert src.count("await autoLoadOnDeviceModel())") == 1 + + +def test_autoload_no_model_error_is_actionable(): + """With no valid on-device candidate the user is told to select or + explicitly download a model instead of getting a silent remote load.""" + src = _read("features/chat/api/chat-adapter.ts") + assert ( + "Select a model in the top bar, or download one from the Hub, then retry." + in src + ) + + +def test_autoload_inventory_failure_is_not_empty_inventory(): + """A failed cached/local inventory request must stop the automatic + selection path, not be swallowed into an empty list that used to fall + through to the remote default download.""" + src = _read("features/chat/api/chat-adapter.ts") + assert ".catch(() => [])" not in src + auto_load = _autoload_section() + assert "inventoryErrorSurfaced: true" in auto_load + # All three inventory sources are queried together and fail closed. + for needle in ( + "listCachedGguf(hfToken)", + "listCachedModels(hfToken)", + "listLocalModels()", + ): + assert needle in auto_load, needle + + +def test_autoload_uses_unified_backend_inventory(): + """Auto-load must consume the same non-React backend inventory the + unified picker uses (no second frontend filesystem scanner), covering + the models dir, LM Studio dirs, and custom scan folders.""" + src = _read("features/chat/api/chat-adapter.ts") + assert re.search( + r'import \{[^}]*listLocalModels[^}]*\} from "@/features/hub/inventory/api"', + src, + re.S, + ) + sources = re.search( + r"const AUTO_LOAD_LOCAL_SOURCES[^;]*;", src, re.S + ) + assert sources, "AUTO_LOAD_LOCAL_SOURCES not found" + for source in ('"models_dir"', '"lmstudio"', '"custom"'): + assert source in sources.group(0), source + + +def test_autoload_filters_match_picker_policy(): + """Only complete, chat-capable, non-hidden rows may auto-load: partial + downloads, weightless/non-chat folders, and infrastructure models are + excluded with the same policy the picker applies.""" + src = _read("features/chat/api/chat-adapter.ts") + local_fn = src.split("function isAutoLoadableLocalRow", 1)[1] + local_fn = local_fn.split("\nfunction ", 1)[0] + assert "row.capabilities?.can_chat !== true" in local_fn + assert "row.partial" in local_fn + assert "isHiddenModelId(row.model_id, row.id, row.path)" in local_fn + assert "hasBigEndianGgufMarker(row.path, row.format_variant)" in local_fn + cached_fn = src.split("function isAutoLoadableCachedRepo", 1)[1] + cached_fn = cached_fn.split("\nconst ", 1)[0] + assert "repo.partial" in cached_fn + assert "repo.capabilities?.can_chat === false" in cached_fn + assert "isHiddenModelId(repo.repo_id)" in cached_fn + + +def test_autoload_local_rows_load_via_backend_target(): + """Indexed local rows (models dir, LM Studio, custom scan folders) must + load through the backend-provided target and record their stable + inventory identity, never a reconstructed path or synthetic variant.""" + src = _read("features/chat/api/chat-adapter.ts") + assert "function localRowLoadTarget" in src + assert "row.load_id || row.id" in src + candidate_fn = src.split("function localRowToCandidate", 1)[1] + candidate_fn = candidate_fn.split("\n/**", 1)[0] + assert "loadId: localRowLoadTarget(row)" in candidate_fn + assert "inventoryId: row.inventory_id ?? null" in candidate_fn + # Inactive-cache rows keep loading by their backend load_id. + auto_load = _autoload_section() + assert auto_load.count("loadId: repo.load_id") >= 3 + + +def test_autoload_remembers_last_model_across_all_sources(): + """The remembered model resolves against managed caches AND the indexed + local inventory; a stale entry only falls through to other on-device + candidates (there is no remote branch left to reach).""" + auto_load = _autoload_section() + assert "isManagedCacheSource(lastLoaded.source)" in auto_load + assert "matchesRememberedLocalRow(candidateRow, lastLoaded)" in auto_load + assert "localRowToCandidate(row, lastLoaded.ggufVariant)" in auto_load + # Managed-cache candidates record their provenance for later resolution. + assert auto_load.count('source: "hf_cache"') >= 4 + + +def test_autoload_deduplicates_cached_and_local_candidates(): + """A model visible in both the cached lists and the local inventory + (e.g. a custom scan folder pointing into an HF cache) must not be tried + twice.""" + auto_load = _autoload_section() + assert "const seenLoadTargets = new Set()" in auto_load + assert "markSeen(repo.repo_id, repo.load_id, repo.cache_path)" in auto_load + assert "isSeen(row.load_id, row.id, row.path, row.model_id)" in auto_load + + +def test_autoload_trust_guard_still_blocks_background_loads(): + """A model needing custom-code approval or a security review is never + silently auto-loaded, and a blocked candidate can only cascade to other + on-device candidates.""" + auto_load = _autoload_section() + assert "validation.requires_trust_remote_code" in auto_load + assert "validation.requires_security_review" in auto_load + assert "MAX_AUTO_LOAD_ATTEMPTS" in auto_load + + +def test_remembered_model_record_supports_local_sources(): + """last-local-model-load must represent managed-cache models AND + backend-indexed local models: a local GGUF is valid with a null variant, + legacy v1 records keep resolving as managed-cache entries, and no + secrets (tokens/leases) are ever persisted.""" + src = _read("features/chat/utils/last-local-model-load.ts") + # Same storage key: v1 records parse backward-compatibly, no migration. + assert 'const STORAGE_KEY = "unsloth.last-local-model-load.v1";' in src + # Legacy records carry no source and default to the managed cache. + assert 'isLastLocalModelSource(parsed.source)' in src + assert ': "hf_cache";' in src + # The GGUF-variant requirement is scoped to managed-cache records; a + # local GGUF's load target identifies the file, so null stays valid. + assert src.count('source === "hf_cache" && !ggufVariant') == 2 + # Indexed local scan sources are representable. + for source in ('"models_dir"', '"lmstudio"', '"custom"'): + assert source in src, source + # Identity only: never tokens, native path leases, or approvals. + assert "nativePath" not in src + assert "hfToken" not in src and "hf_token" not in src + assert "fingerprint" not in src + + +def test_interactive_local_loads_are_remembered_without_lease_bypass(): + """A successful interactive load of a backend-indexed local model + (picker source "local") must be remembered so auto-load can reuse it, + while native-picked files (signed, expiring path lease) and other + arbitrary paths must never be recorded.""" + src = _read("features/chat/hooks/use-chat-model-runtime.ts") + record_block = src.split("const indexedLocalSelection", 1)[1] + record_block = record_block.split("} catch (error) {", 1)[0] + assert 'selection.source === "local"' in src + assert "!nativePathToken &&" in record_block + assert "(indexedLocalSelection || !isLocalModelPath(modelId))" in record_block + assert 'source: "local",' in record_block From 939ea605ad898f0a2150bfda6ea2e0bc281d5f2c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:22:48 +0000 Subject: [PATCH 02/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/studio/test_model_picker_contracts.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 13aead7e3c1..ed7b45c648e 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -475,10 +475,7 @@ def test_autoload_no_model_error_is_actionable(): """With no valid on-device candidate the user is told to select or explicitly download a model instead of getting a silent remote load.""" src = _read("features/chat/api/chat-adapter.ts") - assert ( - "Select a model in the top bar, or download one from the Hub, then retry." - in src - ) + assert "Select a model in the top bar, or download one from the Hub, then retry." in src def test_autoload_inventory_failure_is_not_empty_inventory(): @@ -508,9 +505,7 @@ def test_autoload_uses_unified_backend_inventory(): src, re.S, ) - sources = re.search( - r"const AUTO_LOAD_LOCAL_SOURCES[^;]*;", src, re.S - ) + sources = re.search(r"const AUTO_LOAD_LOCAL_SOURCES[^;]*;", src, re.S) assert sources, "AUTO_LOAD_LOCAL_SOURCES not found" for source in ('"models_dir"', '"lmstudio"', '"custom"'): assert source in sources.group(0), source @@ -591,7 +586,7 @@ def test_remembered_model_record_supports_local_sources(): # Same storage key: v1 records parse backward-compatibly, no migration. assert 'const STORAGE_KEY = "unsloth.last-local-model-load.v1";' in src # Legacy records carry no source and default to the managed cache. - assert 'isLastLocalModelSource(parsed.source)' in src + assert "isLastLocalModelSource(parsed.source)" in src assert ': "hf_cache";' in src # The GGUF-variant requirement is scoped to managed-cache records; a # local GGUF's load target identifies the file, so null stays valid. From 5d06d64a263cfb14edb417821eed566d4024692f Mon Sep 17 00:00:00 2001 From: shimmyshimmer Date: Thu, 23 Jul 2026 16:45:56 -0700 Subject: [PATCH 03/11] Studio: export autoLoadOnDeviceModel for tests Allows behavioral simulations to drive the real auto-load implementation directly instead of asserting on source text only. No runtime change. --- studio/frontend/src/features/chat/api/chat-adapter.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 4eea97db4cd..dfed8caa592 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -1569,8 +1569,10 @@ function isAutoLoadableGgufVariant(variant: GgufVariantDetail | null): boolean { * scan folders), then the smallest complete chat-capable on-device model * (GGUF first, then safetensors). Never downloads: with no valid on-device * candidate the caller shows the actionable "no model" error instead. + * + * Exported for tests. */ -async function autoLoadOnDeviceModel(): Promise<{ +export async function autoLoadOnDeviceModel(): Promise<{ loaded: boolean; blockedByTrustRemoteCode: boolean; /** True when an inventory failure was already surfaced to the user. */ From c4f182a581c5ac3b8e106bed21b6bd42babf2fb9 Mon Sep 17 00:00:00 2001 From: shimmyshimmer Date: Thu, 23 Jul 2026 17:29:57 -0700 Subject: [PATCH 04/11] Studio autoload: resolve dir-GGUF variants, match remembered kind, skip adapters Review follow-ups, all verified against the backend inventory services: - Directory-based local GGUFs (LM Studio, models dir, custom folders) are flagged requires_variant by the backend, so the fallback now resolves the smallest complete quant through the same variants API the picker card uses instead of dropping every directory row. - A folder holding both GGUF and safetensors weights yields two rows with the same load target; remembered-model matching now requires the row format to agree with the remembered kind. - Adapter rows are chat-capable but load by resolving their base model, which for a Hub-id base would start an implicit remote fetch; adapters are excluded from background auto-load. Contract tests extended to pin all three behaviors. --- .../src/features/chat/api/chat-adapter.ts | 101 +++++++++++++----- tests/studio/test_model_picker_contracts.py | 37 ++++++- 2 files changed, 112 insertions(+), 26 deletions(-) diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index dfed8caa592..2666d069283 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -1469,6 +1469,10 @@ function isAutoLoadableLocalRow(row: LocalModelInfo): boolean { if (!AUTO_LOAD_LOCAL_SOURCES.has(row.source)) return false; if (row.capabilities?.can_chat !== true) return false; if (row.partial) return false; + // Adapters are chat-capable but load by resolving their base model, which + // for a Hub-id base can trigger the implicit remote fetch a background + // auto-load must never start. Adapters stay interactive-only. + if (row.model_format === "adapter") return false; if (isHiddenModelId(row.model_id, row.id, row.path)) return false; if ( row.model_format === "gguf" && @@ -1504,11 +1508,50 @@ function localRowToCandidate( }; } +/** + * Build a loadable candidate for a backend-indexed local row. Directory-based + * GGUFs (LM Studio, models dir, custom folders) are flagged requires_variant + * by the backend, so without a remembered quant the folder is scanned through + * the same variants API the picker card uses and the smallest complete, + * auto-loadable quant is chosen. Returns null when no quant can be resolved. + */ +async function resolveLocalRowCandidate( + row: LocalModelInfo, + rememberedVariant: string | null = null, +): Promise { + const isGguf = row.model_format === "gguf"; + if (row.capabilities?.requires_variant === true) { + // Only GGUF folders have an automatic quant resolution path. + if (!isGguf) return null; + if (!rememberedVariant) { + const variants = await listGgufVariants(row.model_id || row.id, undefined, { + preferLocalCache: true, + localPath: row.path, + }); + const downloaded = variants.variants + .filter( + (entry) => + entry.downloaded && !entry.partial && isAutoLoadableGgufVariant(entry), + ) + .sort((a, b) => a.size_bytes - b.size_bytes); + if (downloaded.length === 0) return null; + return localRowToCandidate(row, downloaded[0].quant); + } + } + return localRowToCandidate(row, isGguf ? rememberedVariant : null); +} + /** Resolve a remembered local model against current backend inventory. */ function matchesRememberedLocalRow( row: LocalModelInfo, remembered: LastLocalModelLoad, ): boolean { + // A folder holding both GGUF and safetensors weights yields two rows with + // the same path/load target, so the format must agree with the remembered + // kind before identifier matching can accept the row. + if ((row.model_format === "gguf") !== (remembered.kind === "gguf")) { + return false; + } if ( remembered.inventoryId && row.inventory_id && @@ -1911,17 +1954,19 @@ export async function autoLoadOnDeviceModel(): Promise<{ if (row) { markSeen(row.load_id, row.id, row.path, row.model_id); try { - toast("Loading last used model…", { - id: toastId, - description: row.display_name || row.id, - duration: 5000, - }); - if ( - await loadAutoLoadCandidate( - localRowToCandidate(row, lastLoaded.ggufVariant), - ) - ) { - return { loaded: true, blockedByTrustRemoteCode: false }; + const rememberedCandidate = await resolveLocalRowCandidate( + row, + lastLoaded.ggufVariant, + ); + if (rememberedCandidate) { + toast("Loading last used model…", { + id: toastId, + description: row.display_name || row.id, + duration: 5000, + }); + if (await loadAutoLoadCandidate(rememberedCandidate)) { + return { loaded: true, blockedByTrustRemoteCode: false }; + } } } catch { hadNonTrustFailure = true; @@ -2030,9 +2075,12 @@ export async function autoLoadOnDeviceModel(): Promise<{ bytes && bytes > 0 ? bytes : Number.MAX_SAFE_INTEGER; const bySizeAsc = (a: FallbackCandidate, b: FallbackCandidate): number => a.sizeBytes - b.sizeBytes; - // Background loads cannot ask which quant/variant to use. + // Directory-based GGUF rows resolve a quant automatically below; only + // non-GGUF variant-requiring rows have no background resolution path. const cascadeLocalRows = localRows.filter( - (row) => row.capabilities?.requires_variant !== true, + (row) => + row.model_format === "gguf" || + row.capabilities?.requires_variant !== true, ); const ggufGroup: FallbackCandidate[] = [ ...ggufRepos.map((repo) => ({ @@ -2141,20 +2189,23 @@ export async function autoLoadOnDeviceModel(): Promise<{ if (isSeen(row.load_id, row.id, row.path, row.model_id)) { continue; } - const localCandidate = localRowToCandidate(row); - if ( - skippedAutoLoadCandidates.has( - autoLoadCandidateKey( - localCandidate.kind, - localCandidate.id, - localCandidate.ggufVariant, - ), - ) - ) { - continue; - } markSeen(row.load_id, row.id, row.path, row.model_id); try { + const localCandidate = await resolveLocalRowCandidate(row); + if (!localCandidate) { + continue; + } + if ( + skippedAutoLoadCandidates.has( + autoLoadCandidateKey( + localCandidate.kind, + localCandidate.id, + localCandidate.ggufVariant, + ), + ) + ) { + continue; + } if (await loadAutoLoadCandidate(localCandidate)) { return { loaded: true, blockedByTrustRemoteCode: false }; } diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index ed7b45c648e..39e06643f13 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -520,6 +520,9 @@ def test_autoload_filters_match_picker_policy(): local_fn = local_fn.split("\nfunction ", 1)[0] assert "row.capabilities?.can_chat !== true" in local_fn assert "row.partial" in local_fn + # Adapters resolve their base model on load; a Hub-id base would start + # the implicit remote fetch a background auto-load must never trigger. + assert 'row.model_format === "adapter"' in local_fn assert "isHiddenModelId(row.model_id, row.id, row.path)" in local_fn assert "hasBigEndianGgufMarker(row.path, row.format_variant)" in local_fn cached_fn = src.split("function isAutoLoadableCachedRepo", 1)[1] @@ -552,7 +555,8 @@ def test_autoload_remembers_last_model_across_all_sources(): auto_load = _autoload_section() assert "isManagedCacheSource(lastLoaded.source)" in auto_load assert "matchesRememberedLocalRow(candidateRow, lastLoaded)" in auto_load - assert "localRowToCandidate(row, lastLoaded.ggufVariant)" in auto_load + assert "await resolveLocalRowCandidate(" in auto_load + assert "lastLoaded.ggufVariant," in auto_load # Managed-cache candidates record their provenance for later resolution. assert auto_load.count('source: "hf_cache"') >= 4 @@ -612,3 +616,34 @@ def test_interactive_local_loads_are_remembered_without_lease_bypass(): assert "!nativePathToken &&" in record_block assert "(indexedLocalSelection || !isLocalModelPath(modelId))" in record_block assert 'source: "local",' in record_block + + +def test_remembered_local_row_match_requires_kind_agreement(): + """A folder holding both GGUF and safetensors weights yields two inventory + rows with the same path/load target, so the remembered kind must gate the + identifier match or a remembered safetensors load can resolve to the GGUF + row (and vice versa).""" + src = _read("features/chat/api/chat-adapter.ts") + match_fn = src.split("function matchesRememberedLocalRow", 1)[1] + match_fn = match_fn.split("\nasync function ", 1)[0].split("\nfunction ", 1)[0] + assert '(row.model_format === "gguf") !== (remembered.kind === "gguf")' in match_fn + + +def test_directory_gguf_rows_resolve_variant_like_picker(): + """Directory-based local GGUFs (LM Studio, models dir, custom folders) are + flagged requires_variant by the backend, so the fallback must resolve a + quant through the variants API (as the picker card does) instead of + silently dropping every directory row; non-GGUF variant-requiring rows + have no background resolution and stay excluded.""" + src = _read("features/chat/api/chat-adapter.ts") + resolve_fn = src.split("async function resolveLocalRowCandidate", 1)[1] + resolve_fn = resolve_fn.split("\nfunction ", 1)[0] + assert 'row.capabilities?.requires_variant === true' in resolve_fn + assert "if (!isGguf) return null;" in resolve_fn + assert "listGgufVariants(row.model_id || row.id" in resolve_fn + assert "localPath: row.path" in resolve_fn + assert "entry.downloaded && !entry.partial && isAutoLoadableGgufVariant(entry)" in resolve_fn + # The cascade must keep directory GGUF rows as candidates. + auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1] + assert 'row.model_format === "gguf" ||' in auto_load + assert "await resolveLocalRowCandidate(row)" in auto_load From db7855538ac8d62d94a44e59799f93db45367db8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:32:33 +0000 Subject: [PATCH 05/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/studio/test_model_picker_contracts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 39e06643f13..4e898b8fb99 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -638,7 +638,7 @@ def test_directory_gguf_rows_resolve_variant_like_picker(): src = _read("features/chat/api/chat-adapter.ts") resolve_fn = src.split("async function resolveLocalRowCandidate", 1)[1] resolve_fn = resolve_fn.split("\nfunction ", 1)[0] - assert 'row.capabilities?.requires_variant === true' in resolve_fn + assert "row.capabilities?.requires_variant === true" in resolve_fn assert "if (!isGguf) return null;" in resolve_fn assert "listGgufVariants(row.model_id || row.id" in resolve_fn assert "localPath: row.path" in resolve_fn From bf46d8bb6059ac70aef93e47205da668102b9ea1 Mon Sep 17 00:00:00 2001 From: shimmyshimmer Date: Thu, 23 Jul 2026 18:25:46 -0700 Subject: [PATCH 06/11] Studio autoload: folder quant fallback, scan own path, skip cached adapters Second round of review follow-ups, verified against the backend services: - A failed remembered local quant now excludes only that exact candidate key instead of marking the whole row seen, so another complete quant in the same folder can still load (mirrors the managed-cache remembered path). - Quant resolution for a local GGUF folder scans the folder itself via a local-path repo id; the cache-first prefer_local_cache flow could return a cache quant missing from the folder when the row also has a Hub model_id. - Cached adapter repos are chat-capable in the cached inventory and resolve a base model on load, so they are excluded from background auto-load like local adapter rows. Contract tests extended for all three. --- .../src/features/chat/api/chat-adapter.ts | 27 ++++++++++++++----- tests/studio/test_model_picker_contracts.py | 25 ++++++++++++++++- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 2666d069283..2b32ac89da8 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -55,6 +55,7 @@ import { type PendingImageEditReference, type RagAutoInject, GPU_LAYERS_AUTO, + isLocalModelPath, loadedGpuMemoryFields, reconcilePersistedGpuIds, resolveLoadedSpeculativeSettings, @@ -1439,14 +1440,18 @@ function findCachedRepo( /** * Managed-cache rows eligible for background auto-load: complete, not - * hidden infrastructure, and not declared non-chat by the backend. + * hidden infrastructure, not declared non-chat by the backend, and not an + * adapter (loading an adapter resolves its base model, which for an + * uncached Hub base would start an implicit remote fetch). */ function isAutoLoadableCachedRepo(repo: { repo_id: string; partial?: boolean; + model_format?: string | null; capabilities?: { can_chat?: boolean } | null; }): boolean { if (repo.partial) return false; + if (repo.model_format === "adapter") return false; if (repo.capabilities?.can_chat === false) return false; return !isHiddenModelId(repo.repo_id); } @@ -1524,7 +1529,12 @@ async function resolveLocalRowCandidate( // Only GGUF folders have an automatic quant resolution path. if (!isGguf) return null; if (!rememberedVariant) { - const variants = await listGgufVariants(row.model_id || row.id, undefined, { + // Scan the folder the row will actually load from: the cache-first + // prefer_local_cache flow could return a quant present in the HF + // cache but missing at row's own path. A local-path repo id routes + // the backend straight to the filesystem scan of that folder. + const variantScanTarget = isLocalModelPath(row.id) ? row.id : row.path; + const variants = await listGgufVariants(variantScanTarget, undefined, { preferLocalCache: true, localPath: row.path, }); @@ -1952,9 +1962,13 @@ export async function autoLoadOnDeviceModel(): Promise<{ matchesRememberedLocalRow(candidateRow, lastLoaded), ); if (row) { - markSeen(row.load_id, row.id, row.path, row.model_id); + // Not marked seen here: if this exact quant fails, the fallback + // loop may still pick another complete quant from the same folder + // (only the failed candidate key below is excluded, mirroring the + // managed-cache remembered path). + let rememberedCandidate: AutoLoadCandidate | null = null; try { - const rememberedCandidate = await resolveLocalRowCandidate( + rememberedCandidate = await resolveLocalRowCandidate( row, lastLoaded.ggufVariant, ); @@ -1972,9 +1986,10 @@ export async function autoLoadOnDeviceModel(): Promise<{ hadNonTrustFailure = true; skippedAutoLoadCandidates.add( autoLoadCandidateKey( - row.model_format === "gguf" ? "gguf" : "model", + rememberedCandidate?.kind ?? + (row.model_format === "gguf" ? "gguf" : "model"), row.id, - lastLoaded.ggufVariant, + rememberedCandidate?.ggufVariant ?? lastLoaded.ggufVariant, ), ); } diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 4e898b8fb99..ab6bbb7eddc 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -528,6 +528,9 @@ def test_autoload_filters_match_picker_policy(): cached_fn = src.split("function isAutoLoadableCachedRepo", 1)[1] cached_fn = cached_fn.split("\nconst ", 1)[0] assert "repo.partial" in cached_fn + # Cached adapter repos are chat-capable too and resolve a base model on + # load, so they must be excluded exactly like local adapter rows. + assert 'repo.model_format === "adapter"' in cached_fn assert "repo.capabilities?.can_chat === false" in cached_fn assert "isHiddenModelId(repo.repo_id)" in cached_fn @@ -640,10 +643,30 @@ def test_directory_gguf_rows_resolve_variant_like_picker(): resolve_fn = resolve_fn.split("\nfunction ", 1)[0] assert "row.capabilities?.requires_variant === true" in resolve_fn assert "if (!isGguf) return null;" in resolve_fn - assert "listGgufVariants(row.model_id || row.id" in resolve_fn + # Quants must be resolved from the folder the row will load from, not + # from a same-id HF cache repo whose quants may be absent locally. + assert ( + "const variantScanTarget = isLocalModelPath(row.id) ? row.id : row.path;" + in resolve_fn + ) + assert "listGgufVariants(variantScanTarget" in resolve_fn assert "localPath: row.path" in resolve_fn assert "entry.downloaded && !entry.partial && isAutoLoadableGgufVariant(entry)" in resolve_fn # The cascade must keep directory GGUF rows as candidates. auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1] assert 'row.model_format === "gguf" ||' in auto_load assert "await resolveLocalRowCandidate(row)" in auto_load + + +def test_remembered_local_failure_does_not_block_folder_fallback(): + """A failed remembered local quant must exclude only that exact candidate + key, not mark the whole row as seen; otherwise a folder with another + complete quant can never fall back and Send falsely reports no model.""" + src = _read("features/chat/api/chat-adapter.ts") + auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1] + remembered_block = auto_load.split("isManagedCacheSource(lastLoaded.source)", 1)[1] + remembered_block = remembered_block.split('} else if (lastLoaded.kind === "gguf")', 1)[0] + assert "markSeen(" not in remembered_block, ( + "remembered-local retry must not pre-mark the row as deduped" + ) + assert "rememberedCandidate?.ggufVariant ?? lastLoaded.ggufVariant" in remembered_block From 44964067ab85285a1409a80eecd86d0cb9c043c9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:28:42 +0000 Subject: [PATCH 07/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/studio/test_model_picker_contracts.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index ab6bbb7eddc..4e420b57044 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -645,10 +645,7 @@ def test_directory_gguf_rows_resolve_variant_like_picker(): assert "if (!isGguf) return null;" in resolve_fn # Quants must be resolved from the folder the row will load from, not # from a same-id HF cache repo whose quants may be absent locally. - assert ( - "const variantScanTarget = isLocalModelPath(row.id) ? row.id : row.path;" - in resolve_fn - ) + assert "const variantScanTarget = isLocalModelPath(row.id) ? row.id : row.path;" in resolve_fn assert "listGgufVariants(variantScanTarget" in resolve_fn assert "localPath: row.path" in resolve_fn assert "entry.downloaded && !entry.partial && isAutoLoadableGgufVariant(entry)" in resolve_fn @@ -666,7 +663,7 @@ def test_remembered_local_failure_does_not_block_folder_fallback(): auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1] remembered_block = auto_load.split("isManagedCacheSource(lastLoaded.source)", 1)[1] remembered_block = remembered_block.split('} else if (lastLoaded.kind === "gguf")', 1)[0] - assert "markSeen(" not in remembered_block, ( - "remembered-local retry must not pre-mark the row as deduped" - ) + assert ( + "markSeen(" not in remembered_block + ), "remembered-local retry must not pre-mark the row as deduped" assert "rememberedCandidate?.ggufVariant ?? lastLoaded.ggufVariant" in remembered_block From 5aa018370680845f306478115146aeb86138b422 Mon Sep 17 00:00:00 2001 From: shimmyshimmer Date: Thu, 23 Jul 2026 18:58:24 -0700 Subject: [PATCH 08/11] Studio autoload: retry next folder quant after a skip; dedupe by load target only Third round of review follow-ups: - Quant resolution for a local GGUF folder now returns the smallest quant that is not already in the skipped set, so one corrupt or blocked file cannot sink a folder that still has other complete quants. - The cached/local dedupe keys on actual load targets and on-disk paths only. A local copy that merely shares a repo model_id is a distinct set of files and stays available when the cached copy fails or has no usable quant. Contract tests updated and extended for both. --- .../src/features/chat/api/chat-adapter.ts | 35 +++++++++++++------ tests/studio/test_model_picker_contracts.py | 31 ++++++++++++---- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 2b32ac89da8..66205419046 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -1523,6 +1523,7 @@ function localRowToCandidate( async function resolveLocalRowCandidate( row: LocalModelInfo, rememberedVariant: string | null = null, + isSkippedCandidate?: (candidate: AutoLoadCandidate) => boolean, ): Promise { const isGguf = row.model_format === "gguf"; if (row.capabilities?.requires_variant === true) { @@ -1544,8 +1545,14 @@ async function resolveLocalRowCandidate( entry.downloaded && !entry.partial && isAutoLoadableGgufVariant(entry), ) .sort((a, b) => a.size_bytes - b.size_bytes); - if (downloaded.length === 0) return null; - return localRowToCandidate(row, downloaded[0].quant); + // Smallest first, skipping quants that already failed or were + // blocked, so one bad file cannot sink a folder with other quants. + for (const entry of downloaded) { + const candidate = localRowToCandidate(row, entry.quant); + if (isSkippedCandidate?.(candidate)) continue; + return candidate; + } + return null; } } return localRowToCandidate(row, isGguf ? rememberedVariant : null); @@ -1944,8 +1951,10 @@ export async function autoLoadOnDeviceModel(): Promise<{ const ggufRepos = allGgufRepos.filter(isAutoLoadableCachedRepo); const modelRepos = allModelRepos.filter(isAutoLoadableCachedRepo); const localRows = allLocalRows.filter(isAutoLoadableLocalRow); - // Dedupe candidates that appear in both the cached and the local - // inventory (e.g. a custom scan folder pointing into an HF cache). + // Dedupe candidates that resolve to the SAME load target (e.g. a custom + // scan folder pointing into an HF cache). Keyed on load targets and + // on-disk paths only: a shared model_id does not mean the same files, and + // a distinct local copy must stay available when the cached copy fails. const seenLoadTargets = new Set(); const markSeen = (...values: (string | null | undefined)[]): void => { for (const value of values) { @@ -1997,7 +2006,7 @@ export async function autoLoadOnDeviceModel(): Promise<{ } else if (lastLoaded.kind === "gguf") { const repo = findCachedRepo(ggufRepos, lastLoaded.id); if (repo && lastLoaded.ggufVariant) { - markSeen(repo.repo_id, repo.load_id, repo.cache_path); + markSeen(repo.load_id || repo.repo_id, repo.cache_path); try { const variants = await listGgufVariants(repo.repo_id, undefined, { preferLocalCache: true, @@ -2042,7 +2051,7 @@ export async function autoLoadOnDeviceModel(): Promise<{ } else { const repo = findCachedRepo(modelRepos, lastLoaded.id); if (repo) { - markSeen(repo.repo_id, repo.load_id, repo.cache_path); + markSeen(repo.load_id || repo.repo_id, repo.cache_path); try { toast("Loading last used model…", { id: toastId, @@ -2130,7 +2139,7 @@ export async function autoLoadOnDeviceModel(): Promise<{ if (loadAttempts >= MAX_AUTO_LOAD_ATTEMPTS) break; if (candidate.type === "cached-gguf") { const repo = candidate.repo; - markSeen(repo.repo_id, repo.load_id, repo.cache_path); + markSeen(repo.load_id || repo.repo_id, repo.cache_path); try { const variants = await listGgufVariants(repo.repo_id, undefined, { preferLocalCache: true, @@ -2172,7 +2181,7 @@ export async function autoLoadOnDeviceModel(): Promise<{ } if (candidate.type === "cached-model") { const repo = candidate.repo; - markSeen(repo.repo_id, repo.load_id, repo.cache_path); + markSeen(repo.load_id || repo.repo_id, repo.cache_path); if ( skippedAutoLoadCandidates.has( autoLoadCandidateKey("model", repo.repo_id), @@ -2201,12 +2210,16 @@ export async function autoLoadOnDeviceModel(): Promise<{ continue; } const row = candidate.row; - if (isSeen(row.load_id, row.id, row.path, row.model_id)) { + if (isSeen(row.load_id, row.id, row.path)) { continue; } - markSeen(row.load_id, row.id, row.path, row.model_id); + markSeen(row.load_id, row.id, row.path); try { - const localCandidate = await resolveLocalRowCandidate(row); + const localCandidate = await resolveLocalRowCandidate(row, null, (c) => + skippedAutoLoadCandidates.has( + autoLoadCandidateKey(c.kind, c.id, c.ggufVariant), + ), + ); if (!localCandidate) { continue; } diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 4e420b57044..1c18ca39ead 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -565,13 +565,32 @@ def test_autoload_remembers_last_model_across_all_sources(): def test_autoload_deduplicates_cached_and_local_candidates(): - """A model visible in both the cached lists and the local inventory - (e.g. a custom scan folder pointing into an HF cache) must not be tried - twice.""" + """Candidates resolving to the same load target (e.g. a custom scan + folder pointing into an HF cache) must not be tried twice, but the + dedupe must key on actual load targets/paths only: a local copy that + merely shares a repo model_id is a distinct set of files and must stay + available when the cached copy fails or has no usable quant.""" auto_load = _autoload_section() assert "const seenLoadTargets = new Set()" in auto_load - assert "markSeen(repo.repo_id, repo.load_id, repo.cache_path)" in auto_load - assert "isSeen(row.load_id, row.id, row.path, row.model_id)" in auto_load + assert "markSeen(repo.load_id || repo.repo_id, repo.cache_path)" in auto_load + assert "isSeen(row.load_id, row.id, row.path)" in auto_load + # The repo-id-based dedupe that shadowed distinct local copies is gone. + assert "isSeen(row.load_id, row.id, row.path, row.model_id)" not in auto_load + assert "markSeen(repo.repo_id," not in auto_load + + +def test_local_quant_resolution_skips_failed_quants(): + """When a folder's smallest quant already failed or was blocked, the + resolver must return the next complete quant instead of abandoning the + whole folder (which made Send falsely report no model).""" + src = _read("features/chat/api/chat-adapter.ts") + resolve_fn = src.split("async function resolveLocalRowCandidate", 1)[1] + resolve_fn = resolve_fn.split("\nfunction ", 1)[0] + assert "for (const entry of downloaded)" in resolve_fn + assert "if (isSkippedCandidate?.(candidate)) continue;" in resolve_fn + # The fallback loop feeds the skip set into resolution. + auto_load = _autoload_section() + assert "await resolveLocalRowCandidate(row, null, (c) =>" in auto_load def test_autoload_trust_guard_still_blocks_background_loads(): @@ -652,7 +671,7 @@ def test_directory_gguf_rows_resolve_variant_like_picker(): # The cascade must keep directory GGUF rows as candidates. auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1] assert 'row.model_format === "gguf" ||' in auto_load - assert "await resolveLocalRowCandidate(row)" in auto_load + assert "await resolveLocalRowCandidate(row, null, (c) =>" in auto_load def test_remembered_local_failure_does_not_block_folder_fallback(): From a928900a2e42df984108ba0eead4c24d1573b1ae Mon Sep 17 00:00:00 2001 From: shimmyshimmer Date: Thu, 23 Jul 2026 20:01:23 -0700 Subject: [PATCH 09/11] Studio autoload: order folders by resolved quant size; kind-scoped dedupe keys Fourth round of review follow-ups: - Local candidates are now resolved before the fallback groups are sorted, and multi-quant GGUF folders order by the resolved quant's own size. The backend row size sums every quant in a folder, so a folder holding a 2 GB and an 8 GB quant previously sorted after a 4 GB single-quant model even though its 2 GB quant was the smallest loadable artifact. - The cached/local dedupe keys now carry the model kind. A folder emitting both GGUF and safetensors rows shares one path while holding two different models, so a failing GGUF row no longer shadows its loadable safetensors sibling. Contract tests updated and extended for both. --- .../src/features/chat/api/chat-adapter.ts | 159 +++++++++++------- tests/studio/test_model_picker_contracts.py | 30 +++- 2 files changed, 124 insertions(+), 65 deletions(-) diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 66205419046..095f74dc36e 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -1520,11 +1520,24 @@ function localRowToCandidate( * the same variants API the picker card uses and the smallest complete, * auto-loadable quant is chosen. Returns null when no quant can be resolved. */ +// Unknown sizes (0) sort last so a sizeless row can't shadow a real one. +function sizeOrUnknownBytes(bytes?: number | null): number { + return bytes && bytes > 0 ? bytes : Number.MAX_SAFE_INTEGER; +} + +type ResolvedLocalCandidate = { + candidate: AutoLoadCandidate; + /** Size of what would actually load: the resolved quant's own size for a + * multi-quant folder (whose row size_bytes SUMS every quant), else the + * row size. Orders the smallest-first cascade. */ + sizeBytes: number; +}; + async function resolveLocalRowCandidate( row: LocalModelInfo, rememberedVariant: string | null = null, isSkippedCandidate?: (candidate: AutoLoadCandidate) => boolean, -): Promise { +): Promise { const isGguf = row.model_format === "gguf"; if (row.capabilities?.requires_variant === true) { // Only GGUF folders have an automatic quant resolution path. @@ -1550,12 +1563,15 @@ async function resolveLocalRowCandidate( for (const entry of downloaded) { const candidate = localRowToCandidate(row, entry.quant); if (isSkippedCandidate?.(candidate)) continue; - return candidate; + return { candidate, sizeBytes: sizeOrUnknownBytes(entry.size_bytes) }; } return null; } } - return localRowToCandidate(row, isGguf ? rememberedVariant : null); + return { + candidate: localRowToCandidate(row, isGguf ? rememberedVariant : null), + sizeBytes: sizeOrUnknownBytes(row.size_bytes), + }; } /** Resolve a remembered local model against current backend inventory. */ @@ -1952,17 +1968,27 @@ export async function autoLoadOnDeviceModel(): Promise<{ const modelRepos = allModelRepos.filter(isAutoLoadableCachedRepo); const localRows = allLocalRows.filter(isAutoLoadableLocalRow); // Dedupe candidates that resolve to the SAME load target (e.g. a custom - // scan folder pointing into an HF cache). Keyed on load targets and - // on-disk paths only: a shared model_id does not mean the same files, and - // a distinct local copy must stay available when the cached copy fails. + // scan folder pointing into an HF cache). Keyed on kind + load target / + // on-disk path: a shared model_id does not mean the same files (a distinct + // local copy stays available when the cached copy fails), and a folder + // emitting both GGUF and safetensors rows shares a path while holding two + // different models, so the format is part of the key. const seenLoadTargets = new Set(); - const markSeen = (...values: (string | null | undefined)[]): void => { + const markSeen = ( + kind: LastLocalModelKind, + ...values: (string | null | undefined)[] + ): void => { for (const value of values) { - if (value) seenLoadTargets.add(value.toLowerCase()); + if (value) seenLoadTargets.add(`${kind}:${value.toLowerCase()}`); } }; - const isSeen = (...values: (string | null | undefined)[]): boolean => - values.some((value) => !!value && seenLoadTargets.has(value.toLowerCase())); + const isSeen = ( + kind: LastLocalModelKind, + ...values: (string | null | undefined)[] + ): boolean => + values.some( + (value) => !!value && seenLoadTargets.has(`${kind}:${value.toLowerCase()}`), + ); try { if (lastLoaded) { @@ -1977,10 +2003,9 @@ export async function autoLoadOnDeviceModel(): Promise<{ // managed-cache remembered path). let rememberedCandidate: AutoLoadCandidate | null = null; try { - rememberedCandidate = await resolveLocalRowCandidate( - row, - lastLoaded.ggufVariant, - ); + rememberedCandidate = + (await resolveLocalRowCandidate(row, lastLoaded.ggufVariant)) + ?.candidate ?? null; if (rememberedCandidate) { toast("Loading last used model…", { id: toastId, @@ -2006,7 +2031,7 @@ export async function autoLoadOnDeviceModel(): Promise<{ } else if (lastLoaded.kind === "gguf") { const repo = findCachedRepo(ggufRepos, lastLoaded.id); if (repo && lastLoaded.ggufVariant) { - markSeen(repo.load_id || repo.repo_id, repo.cache_path); + markSeen("gguf", repo.load_id || repo.repo_id, repo.cache_path); try { const variants = await listGgufVariants(repo.repo_id, undefined, { preferLocalCache: true, @@ -2051,7 +2076,7 @@ export async function autoLoadOnDeviceModel(): Promise<{ } else { const repo = findCachedRepo(modelRepos, lastLoaded.id); if (repo) { - markSeen(repo.load_id || repo.repo_id, repo.cache_path); + markSeen("model", repo.load_id || repo.repo_id, repo.cache_path); try { toast("Loading last used model…", { id: toastId, @@ -2093,53 +2118,80 @@ export async function autoLoadOnDeviceModel(): Promise<{ type FallbackCandidate = | { type: "cached-gguf"; repo: CachedGgufRepo; sizeBytes: number } | { type: "cached-model"; repo: CachedModelRepo; sizeBytes: number } - | { type: "local"; row: LocalModelInfo; sizeBytes: number }; - // Unknown sizes (0) sort last so a sizeless row can't shadow a real one. - const sizeOrUnknown = (bytes?: number | null): number => - bytes && bytes > 0 ? bytes : Number.MAX_SAFE_INTEGER; + | { + type: "local"; + row: LocalModelInfo; + candidate: AutoLoadCandidate; + sizeBytes: number; + }; const bySizeAsc = (a: FallbackCandidate, b: FallbackCandidate): number => a.sizeBytes - b.sizeBytes; - // Directory-based GGUF rows resolve a quant automatically below; only - // non-GGUF variant-requiring rows have no background resolution path. + const isSkippedAutoLoadCandidate = (c: AutoLoadCandidate): boolean => + skippedAutoLoadCandidates.has( + autoLoadCandidateKey(c.kind, c.id, c.ggufVariant), + ); + // Directory-based GGUF rows resolve a quant automatically; only non-GGUF + // variant-requiring rows have no background resolution path. const cascadeLocalRows = localRows.filter( (row) => row.model_format === "gguf" || row.capabilities?.requires_variant !== true, ); + // Resolve local candidates BEFORE ordering: a multi-quant folder's row + // size_bytes sums every quant in it, so the cascade must order on the + // resolved quant's own size or a folder with a small quant would lose to + // a larger single-quant model. + const localEntries = ( + await Promise.all( + cascadeLocalRows.map( + async (row): Promise => { + try { + const resolved = await resolveLocalRowCandidate( + row, + null, + isSkippedAutoLoadCandidate, + ); + if (!resolved) return null; + return { + type: "local" as const, + row, + candidate: resolved.candidate, + sizeBytes: resolved.sizeBytes, + }; + } catch { + hadNonTrustFailure = true; + return null; + } + }, + ), + ) + ).filter((entry): entry is FallbackCandidate => entry !== null); const ggufGroup: FallbackCandidate[] = [ ...ggufRepos.map((repo) => ({ type: "cached-gguf" as const, repo, - sizeBytes: sizeOrUnknown(repo.size_bytes), + sizeBytes: sizeOrUnknownBytes(repo.size_bytes), })), - ...cascadeLocalRows - .filter((row) => row.model_format === "gguf") - .map((row) => ({ - type: "local" as const, - row, - sizeBytes: sizeOrUnknown(row.size_bytes), - })), + ...localEntries.filter( + (entry) => entry.type === "local" && entry.candidate.kind === "gguf", + ), ].sort(bySizeAsc); const modelGroup: FallbackCandidate[] = [ ...modelRepos.map((repo) => ({ type: "cached-model" as const, repo, - sizeBytes: sizeOrUnknown(repo.size_bytes), + sizeBytes: sizeOrUnknownBytes(repo.size_bytes), })), - ...cascadeLocalRows - .filter((row) => row.model_format !== "gguf") - .map((row) => ({ - type: "local" as const, - row, - sizeBytes: sizeOrUnknown(row.size_bytes), - })), + ...localEntries.filter( + (entry) => entry.type === "local" && entry.candidate.kind === "model", + ), ].sort(bySizeAsc); for (const candidate of [...ggufGroup, ...modelGroup]) { if (loadAttempts >= MAX_AUTO_LOAD_ATTEMPTS) break; if (candidate.type === "cached-gguf") { const repo = candidate.repo; - markSeen(repo.load_id || repo.repo_id, repo.cache_path); + markSeen("gguf", repo.load_id || repo.repo_id, repo.cache_path); try { const variants = await listGgufVariants(repo.repo_id, undefined, { preferLocalCache: true, @@ -2181,7 +2233,7 @@ export async function autoLoadOnDeviceModel(): Promise<{ } if (candidate.type === "cached-model") { const repo = candidate.repo; - markSeen(repo.load_id || repo.repo_id, repo.cache_path); + markSeen("model", repo.load_id || repo.repo_id, repo.cache_path); if ( skippedAutoLoadCandidates.has( autoLoadCandidateKey("model", repo.repo_id), @@ -2210,30 +2262,15 @@ export async function autoLoadOnDeviceModel(): Promise<{ continue; } const row = candidate.row; - if (isSeen(row.load_id, row.id, row.path)) { + const localCandidate = candidate.candidate; + if (isSeen(localCandidate.kind, row.load_id, row.id, row.path)) { + continue; + } + markSeen(localCandidate.kind, row.load_id, row.id, row.path); + if (isSkippedAutoLoadCandidate(localCandidate)) { continue; } - markSeen(row.load_id, row.id, row.path); try { - const localCandidate = await resolveLocalRowCandidate(row, null, (c) => - skippedAutoLoadCandidates.has( - autoLoadCandidateKey(c.kind, c.id, c.ggufVariant), - ), - ); - if (!localCandidate) { - continue; - } - if ( - skippedAutoLoadCandidates.has( - autoLoadCandidateKey( - localCandidate.kind, - localCandidate.id, - localCandidate.ggufVariant, - ), - ) - ) { - continue; - } if (await loadAutoLoadCandidate(localCandidate)) { return { loaded: true, blockedByTrustRemoteCode: false }; } diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 1c18ca39ead..f1c7037e1a7 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -572,8 +572,12 @@ def test_autoload_deduplicates_cached_and_local_candidates(): available when the cached copy fails or has no usable quant.""" auto_load = _autoload_section() assert "const seenLoadTargets = new Set()" in auto_load - assert "markSeen(repo.load_id || repo.repo_id, repo.cache_path)" in auto_load - assert "isSeen(row.load_id, row.id, row.path)" in auto_load + # Keys carry the model kind: a folder emitting both GGUF and safetensors + # rows shares a path while holding two different models. + assert "seenLoadTargets.add(`${kind}:${value.toLowerCase()}`)" in auto_load + assert 'markSeen("gguf", repo.load_id || repo.repo_id, repo.cache_path)' in auto_load + assert 'markSeen("model", repo.load_id || repo.repo_id, repo.cache_path)' in auto_load + assert "isSeen(localCandidate.kind, row.load_id, row.id, row.path)" in auto_load # The repo-id-based dedupe that shadowed distinct local copies is gone. assert "isSeen(row.load_id, row.id, row.path, row.model_id)" not in auto_load assert "markSeen(repo.repo_id," not in auto_load @@ -590,7 +594,7 @@ def test_local_quant_resolution_skips_failed_quants(): assert "if (isSkippedCandidate?.(candidate)) continue;" in resolve_fn # The fallback loop feeds the skip set into resolution. auto_load = _autoload_section() - assert "await resolveLocalRowCandidate(row, null, (c) =>" in auto_load + assert "isSkippedAutoLoadCandidate," in auto_load def test_autoload_trust_guard_still_blocks_background_loads(): @@ -671,7 +675,7 @@ def test_directory_gguf_rows_resolve_variant_like_picker(): # The cascade must keep directory GGUF rows as candidates. auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1] assert 'row.model_format === "gguf" ||' in auto_load - assert "await resolveLocalRowCandidate(row, null, (c) =>" in auto_load + assert "await resolveLocalRowCandidate(" in auto_load def test_remembered_local_failure_does_not_block_folder_fallback(): @@ -686,3 +690,21 @@ def test_remembered_local_failure_does_not_block_folder_fallback(): "markSeen(" not in remembered_block ), "remembered-local retry must not pre-mark the row as deduped" assert "rememberedCandidate?.ggufVariant ?? lastLoaded.ggufVariant" in remembered_block + + +def test_local_fallback_orders_by_resolved_quant_size(): + """A GGUF folder row's size_bytes sums every quant in the folder, so the + smallest-first cascade must order local candidates by the resolved + quant's own size; otherwise a folder with a small quant loses to a + larger single-quant model.""" + src = _read("features/chat/api/chat-adapter.ts") + resolve_fn = src.split("async function resolveLocalRowCandidate", 1)[1] + resolve_fn = resolve_fn.split("\nfunction ", 1)[0] + assert "sizeBytes: sizeOrUnknownBytes(entry.size_bytes)" in resolve_fn + auto_load = _autoload_section() + # Local candidates are resolved BEFORE the groups are sorted. + assert "const localEntries = (" in auto_load + assert auto_load.index("const localEntries = (") < auto_load.index( + "const ggufGroup: FallbackCandidate[]" + ) + assert "sizeBytes: resolved.sizeBytes" in auto_load From 08cb60c6e1607f06888d1150dfcfddf95b3f135c Mon Sep 17 00:00:00 2001 From: shimmyshimmer Date: Thu, 23 Jul 2026 22:03:56 -0700 Subject: [PATCH 10/11] Studio autoload: retry the folder's next quant when a load itself fails Fifth round of review follow-ups. A resolved quant that passed validation could still fail /api/inference/load (corrupt file, llama.cpp startup error); the cascade then abandoned the whole row because only validation blocks recorded a skip key. The fallback now marks the failed quant skipped and resolves the folder's next complete quant before moving on, bounded by the existing attempt cap. Single-candidate rows resolve to null once skipped, so the retry loop terminates. Contract test and simulation added for the failure-then-retry path. --- .../src/features/chat/api/chat-adapter.ts | 46 +++++++++++++++---- tests/studio/test_model_picker_contracts.py | 21 +++++++++ 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 095f74dc36e..41d92f2df05 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -1568,8 +1568,12 @@ async function resolveLocalRowCandidate( return null; } } + const candidate = localRowToCandidate(row, isGguf ? rememberedVariant : null); + // Single-candidate rows resolve to null once their candidate is skipped, + // so the retry loop in the fallback terminates instead of re-attempting. + if (isSkippedCandidate?.(candidate)) return null; return { - candidate: localRowToCandidate(row, isGguf ? rememberedVariant : null), + candidate, sizeBytes: sizeOrUnknownBytes(row.size_bytes), }; } @@ -2262,20 +2266,42 @@ export async function autoLoadOnDeviceModel(): Promise<{ continue; } const row = candidate.row; - const localCandidate = candidate.candidate; + let localCandidate: AutoLoadCandidate | null = candidate.candidate; if (isSeen(localCandidate.kind, row.load_id, row.id, row.path)) { continue; } markSeen(localCandidate.kind, row.load_id, row.id, row.path); - if (isSkippedAutoLoadCandidate(localCandidate)) { - continue; - } - try { - if (await loadAutoLoadCandidate(localCandidate)) { - return { loaded: true, blockedByTrustRemoteCode: false }; + // Try the row's quants smallest-first: a failed LOAD (not just a + // blocked validation) marks that quant skipped and the folder's next + // complete quant is resolved and tried, so one corrupt file cannot + // abandon a folder that still holds a loadable quant. The attempt cap + // still bounds total /load calls; single-candidate rows resolve to + // null once skipped, terminating the loop. + while (localCandidate && loadAttempts < MAX_AUTO_LOAD_ATTEMPTS) { + if (!isSkippedAutoLoadCandidate(localCandidate)) { + try { + if (await loadAutoLoadCandidate(localCandidate)) { + return { loaded: true, blockedByTrustRemoteCode: false }; + } + } catch { + hadNonTrustFailure = true; + skippedAutoLoadCandidates.add( + autoLoadCandidateKey( + localCandidate.kind, + localCandidate.id, + localCandidate.ggufVariant, + ), + ); + } + } + try { + localCandidate = + (await resolveLocalRowCandidate(row, null, isSkippedAutoLoadCandidate)) + ?.candidate ?? null; + } catch { + hadNonTrustFailure = true; + break; } - } catch { - hadNonTrustFailure = true; } } diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index f1c7037e1a7..c5fb84e50fb 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -708,3 +708,24 @@ def test_local_fallback_orders_by_resolved_quant_size(): "const ggufGroup: FallbackCandidate[]" ) assert "sizeBytes: resolved.sizeBytes" in auto_load + + +def test_cascade_retries_next_quant_after_load_failure(): + """A failed /api/inference/load (not just a blocked validation) must mark + that quant skipped and try the folder's next complete quant before the + row is abandoned; single-candidate rows resolve to null once skipped so + the retry loop terminates, and the attempt cap bounds total loads.""" + src = _read("features/chat/api/chat-adapter.ts") + auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1] + assert ( + "while (localCandidate && loadAttempts < MAX_AUTO_LOAD_ATTEMPTS)" in auto_load + ) + # The cascade catch records the failed quant, unlike the old generic flag. + local_loop = auto_load.split( + "while (localCandidate && loadAttempts < MAX_AUTO_LOAD_ATTEMPTS)", 1 + )[1].split("\n }", 1)[0] + assert "skippedAutoLoadCandidates.add(" in local_loop + # Termination guard: a skipped single candidate resolves to null. + resolve_fn = src.split("async function resolveLocalRowCandidate", 1)[1] + resolve_fn = resolve_fn.split("\nfunction ", 1)[0] + assert "if (isSkippedCandidate?.(candidate)) return null;" in resolve_fn From 69c614e2e804d6f0564467acf3b67c973f76f44f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 05:04:39 +0000 Subject: [PATCH 11/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/studio/test_model_picker_contracts.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index c5fb84e50fb..06295a161f3 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -717,9 +717,7 @@ def test_cascade_retries_next_quant_after_load_failure(): the retry loop terminates, and the attempt cap bounds total loads.""" src = _read("features/chat/api/chat-adapter.ts") auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1] - assert ( - "while (localCandidate && loadAttempts < MAX_AUTO_LOAD_ATTEMPTS)" in auto_load - ) + assert "while (localCandidate && loadAttempts < MAX_AUTO_LOAD_ATTEMPTS)" in auto_load # The cascade catch records the failed quant, unlike the old generic flag. local_loop = auto_load.split( "while (localCandidate && loadAttempts < MAX_AUTO_LOAD_ATTEMPTS)", 1