From bf46d8bb6059ac70aef93e47205da668102b9ea1 Mon Sep 17 00:00:00 2001 From: shimmyshimmer Date: Thu, 23 Jul 2026 18:25:46 -0700 Subject: [PATCH] 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 2666d06928..2b32ac89da 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 4e898b8fb9..ab6bbb7edd 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