Skip to content

Commit c4f182a

Browse files
committed
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.
1 parent 5d06d64 commit c4f182a

2 files changed

Lines changed: 112 additions & 26 deletions

File tree

studio/frontend/src/features/chat/api/chat-adapter.ts

Lines changed: 76 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1469,6 +1469,10 @@ function isAutoLoadableLocalRow(row: LocalModelInfo): boolean {
14691469
if (!AUTO_LOAD_LOCAL_SOURCES.has(row.source)) return false;
14701470
if (row.capabilities?.can_chat !== true) return false;
14711471
if (row.partial) return false;
1472+
// Adapters are chat-capable but load by resolving their base model, which
1473+
// for a Hub-id base can trigger the implicit remote fetch a background
1474+
// auto-load must never start. Adapters stay interactive-only.
1475+
if (row.model_format === "adapter") return false;
14721476
if (isHiddenModelId(row.model_id, row.id, row.path)) return false;
14731477
if (
14741478
row.model_format === "gguf" &&
@@ -1504,11 +1508,50 @@ function localRowToCandidate(
15041508
};
15051509
}
15061510

1511+
/**
1512+
* Build a loadable candidate for a backend-indexed local row. Directory-based
1513+
* GGUFs (LM Studio, models dir, custom folders) are flagged requires_variant
1514+
* by the backend, so without a remembered quant the folder is scanned through
1515+
* the same variants API the picker card uses and the smallest complete,
1516+
* auto-loadable quant is chosen. Returns null when no quant can be resolved.
1517+
*/
1518+
async function resolveLocalRowCandidate(
1519+
row: LocalModelInfo,
1520+
rememberedVariant: string | null = null,
1521+
): Promise<AutoLoadCandidate | null> {
1522+
const isGguf = row.model_format === "gguf";
1523+
if (row.capabilities?.requires_variant === true) {
1524+
// Only GGUF folders have an automatic quant resolution path.
1525+
if (!isGguf) return null;
1526+
if (!rememberedVariant) {
1527+
const variants = await listGgufVariants(row.model_id || row.id, undefined, {
1528+
preferLocalCache: true,
1529+
localPath: row.path,
1530+
});
1531+
const downloaded = variants.variants
1532+
.filter(
1533+
(entry) =>
1534+
entry.downloaded && !entry.partial && isAutoLoadableGgufVariant(entry),
1535+
)
1536+
.sort((a, b) => a.size_bytes - b.size_bytes);
1537+
if (downloaded.length === 0) return null;
1538+
return localRowToCandidate(row, downloaded[0].quant);
1539+
}
1540+
}
1541+
return localRowToCandidate(row, isGguf ? rememberedVariant : null);
1542+
}
1543+
15071544
/** Resolve a remembered local model against current backend inventory. */
15081545
function matchesRememberedLocalRow(
15091546
row: LocalModelInfo,
15101547
remembered: LastLocalModelLoad,
15111548
): boolean {
1549+
// A folder holding both GGUF and safetensors weights yields two rows with
1550+
// the same path/load target, so the format must agree with the remembered
1551+
// kind before identifier matching can accept the row.
1552+
if ((row.model_format === "gguf") !== (remembered.kind === "gguf")) {
1553+
return false;
1554+
}
15121555
if (
15131556
remembered.inventoryId &&
15141557
row.inventory_id &&
@@ -1911,17 +1954,19 @@ export async function autoLoadOnDeviceModel(): Promise<{
19111954
if (row) {
19121955
markSeen(row.load_id, row.id, row.path, row.model_id);
19131956
try {
1914-
toast("Loading last used model…", {
1915-
id: toastId,
1916-
description: row.display_name || row.id,
1917-
duration: 5000,
1918-
});
1919-
if (
1920-
await loadAutoLoadCandidate(
1921-
localRowToCandidate(row, lastLoaded.ggufVariant),
1922-
)
1923-
) {
1924-
return { loaded: true, blockedByTrustRemoteCode: false };
1957+
const rememberedCandidate = await resolveLocalRowCandidate(
1958+
row,
1959+
lastLoaded.ggufVariant,
1960+
);
1961+
if (rememberedCandidate) {
1962+
toast("Loading last used model…", {
1963+
id: toastId,
1964+
description: row.display_name || row.id,
1965+
duration: 5000,
1966+
});
1967+
if (await loadAutoLoadCandidate(rememberedCandidate)) {
1968+
return { loaded: true, blockedByTrustRemoteCode: false };
1969+
}
19251970
}
19261971
} catch {
19271972
hadNonTrustFailure = true;
@@ -2030,9 +2075,12 @@ export async function autoLoadOnDeviceModel(): Promise<{
20302075
bytes && bytes > 0 ? bytes : Number.MAX_SAFE_INTEGER;
20312076
const bySizeAsc = (a: FallbackCandidate, b: FallbackCandidate): number =>
20322077
a.sizeBytes - b.sizeBytes;
2033-
// Background loads cannot ask which quant/variant to use.
2078+
// Directory-based GGUF rows resolve a quant automatically below; only
2079+
// non-GGUF variant-requiring rows have no background resolution path.
20342080
const cascadeLocalRows = localRows.filter(
2035-
(row) => row.capabilities?.requires_variant !== true,
2081+
(row) =>
2082+
row.model_format === "gguf" ||
2083+
row.capabilities?.requires_variant !== true,
20362084
);
20372085
const ggufGroup: FallbackCandidate[] = [
20382086
...ggufRepos.map((repo) => ({
@@ -2141,20 +2189,23 @@ export async function autoLoadOnDeviceModel(): Promise<{
21412189
if (isSeen(row.load_id, row.id, row.path, row.model_id)) {
21422190
continue;
21432191
}
2144-
const localCandidate = localRowToCandidate(row);
2145-
if (
2146-
skippedAutoLoadCandidates.has(
2147-
autoLoadCandidateKey(
2148-
localCandidate.kind,
2149-
localCandidate.id,
2150-
localCandidate.ggufVariant,
2151-
),
2152-
)
2153-
) {
2154-
continue;
2155-
}
21562192
markSeen(row.load_id, row.id, row.path, row.model_id);
21572193
try {
2194+
const localCandidate = await resolveLocalRowCandidate(row);
2195+
if (!localCandidate) {
2196+
continue;
2197+
}
2198+
if (
2199+
skippedAutoLoadCandidates.has(
2200+
autoLoadCandidateKey(
2201+
localCandidate.kind,
2202+
localCandidate.id,
2203+
localCandidate.ggufVariant,
2204+
),
2205+
)
2206+
) {
2207+
continue;
2208+
}
21582209
if (await loadAutoLoadCandidate(localCandidate)) {
21592210
return { loaded: true, blockedByTrustRemoteCode: false };
21602211
}

tests/studio/test_model_picker_contracts.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,9 @@ def test_autoload_filters_match_picker_policy():
520520
local_fn = local_fn.split("\nfunction ", 1)[0]
521521
assert "row.capabilities?.can_chat !== true" in local_fn
522522
assert "row.partial" in local_fn
523+
# Adapters resolve their base model on load; a Hub-id base would start
524+
# the implicit remote fetch a background auto-load must never trigger.
525+
assert 'row.model_format === "adapter"' in local_fn
523526
assert "isHiddenModelId(row.model_id, row.id, row.path)" in local_fn
524527
assert "hasBigEndianGgufMarker(row.path, row.format_variant)" in local_fn
525528
cached_fn = src.split("function isAutoLoadableCachedRepo", 1)[1]
@@ -552,7 +555,8 @@ def test_autoload_remembers_last_model_across_all_sources():
552555
auto_load = _autoload_section()
553556
assert "isManagedCacheSource(lastLoaded.source)" in auto_load
554557
assert "matchesRememberedLocalRow(candidateRow, lastLoaded)" in auto_load
555-
assert "localRowToCandidate(row, lastLoaded.ggufVariant)" in auto_load
558+
assert "await resolveLocalRowCandidate(" in auto_load
559+
assert "lastLoaded.ggufVariant," in auto_load
556560
# Managed-cache candidates record their provenance for later resolution.
557561
assert auto_load.count('source: "hf_cache"') >= 4
558562

@@ -612,3 +616,34 @@ def test_interactive_local_loads_are_remembered_without_lease_bypass():
612616
assert "!nativePathToken &&" in record_block
613617
assert "(indexedLocalSelection || !isLocalModelPath(modelId))" in record_block
614618
assert 'source: "local",' in record_block
619+
620+
621+
def test_remembered_local_row_match_requires_kind_agreement():
622+
"""A folder holding both GGUF and safetensors weights yields two inventory
623+
rows with the same path/load target, so the remembered kind must gate the
624+
identifier match or a remembered safetensors load can resolve to the GGUF
625+
row (and vice versa)."""
626+
src = _read("features/chat/api/chat-adapter.ts")
627+
match_fn = src.split("function matchesRememberedLocalRow", 1)[1]
628+
match_fn = match_fn.split("\nasync function ", 1)[0].split("\nfunction ", 1)[0]
629+
assert '(row.model_format === "gguf") !== (remembered.kind === "gguf")' in match_fn
630+
631+
632+
def test_directory_gguf_rows_resolve_variant_like_picker():
633+
"""Directory-based local GGUFs (LM Studio, models dir, custom folders) are
634+
flagged requires_variant by the backend, so the fallback must resolve a
635+
quant through the variants API (as the picker card does) instead of
636+
silently dropping every directory row; non-GGUF variant-requiring rows
637+
have no background resolution and stay excluded."""
638+
src = _read("features/chat/api/chat-adapter.ts")
639+
resolve_fn = src.split("async function resolveLocalRowCandidate", 1)[1]
640+
resolve_fn = resolve_fn.split("\nfunction ", 1)[0]
641+
assert 'row.capabilities?.requires_variant === true' in resolve_fn
642+
assert "if (!isGguf) return null;" in resolve_fn
643+
assert "listGgufVariants(row.model_id || row.id" in resolve_fn
644+
assert "localPath: row.path" in resolve_fn
645+
assert "entry.downloaded && !entry.partial && isAutoLoadableGgufVariant(entry)" in resolve_fn
646+
# The cascade must keep directory GGUF rows as candidates.
647+
auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1]
648+
assert 'row.model_format === "gguf" ||' in auto_load
649+
assert "await resolveLocalRowCandidate(row)" in auto_load

0 commit comments

Comments
 (0)