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