From 84bf2744f89715d4938a890eda3d4e917f969d51 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Wed, 29 Jul 2026 04:14:13 +0000 Subject: [PATCH] Key a standalone GGUF's settings the same way on every surface llama_cpp falls back to _extract_quant_label(gguf_path) when a load names no variant, and /status echoes that as gguf_variant, so the sidebar wrote settings under :Q4_K_M while the Hub row, the picker and the backfill used the bare path. The auto-switch lookup reads target_id before target_id:file_variant, so the bare entry always won and a sidebar edit could never reach an API load. The sidebar now nulls the variant for the settings identity, the same rule settingsGgufVariantForRow applies to a Hub row, sharing one definition. The displayed label still carries the quant. --- .../src/features/hub/lib/model-identity.ts | 10 ++++ .../components/sidebar-model-config.tsx | 21 +++++++-- .../model-config/model-identity.ts | 1 + tests/studio/test_model_picker_contracts.py | 46 +++++++++++++++++-- 4 files changed, 70 insertions(+), 8 deletions(-) diff --git a/studio/frontend/src/features/hub/lib/model-identity.ts b/studio/frontend/src/features/hub/lib/model-identity.ts index 3a00f4166e..890b321127 100644 --- a/studio/frontend/src/features/hub/lib/model-identity.ts +++ b/studio/frontend/src/features/hub/lib/model-identity.ts @@ -188,3 +188,13 @@ const NATIVE_FILE_LABEL_RE = /^[^/\\]+\.gguf$/i; export function isNativeFileLabel(modelId: string | null | undefined): boolean { return modelId != null && NATIVE_FILE_LABEL_RE.test(modelId); } + +// A scanned standalone .gguf, keyed by its on-disk path. Its settings identity +// carries no variant: it has no quant to choose between, while the loader and the +// inventory both label it from its filename, so adopting that label would key one +// file's config two ways. settings-identity.ts applies the same rule to a Hub row. +export function isStandaloneGgufPath( + modelId: string | null | undefined, +): boolean { + return modelId != null && modelId.toLowerCase().endsWith(".gguf"); +} diff --git a/studio/frontend/src/features/model-picker/components/sidebar-model-config.tsx b/studio/frontend/src/features/model-picker/components/sidebar-model-config.tsx index e80a4e4fbb..3a978f5747 100644 --- a/studio/frontend/src/features/model-picker/components/sidebar-model-config.tsx +++ b/studio/frontend/src/features/model-picker/components/sidebar-model-config.tsx @@ -3,7 +3,10 @@ import { useMemo } from "react"; import { modelConfigInstanceKey } from "../model-config/config-signature"; -import { isOllamaLinkPath } from "../model-config/model-identity"; +import { + isOllamaLinkPath, + isStandaloneGgufPath, +} from "../model-config/model-identity"; import type { PerModelConfig } from "../model-config/per-model-config"; import { ModelConfigPage } from "./model-config-page"; import type { ModelPickTarget } from "./model-selector/types"; @@ -38,12 +41,20 @@ export function SidebarModelConfig({ loadedConfig, onReload, }: SidebarModelConfigProps) { + // A standalone .gguf has no quant to choose between, but the loader labels it + // from its filename (llama_cpp falls back to _extract_quant_label when the load + // named no variant) and /status echoes that as gguf_variant. Keying settings by + // it would write ":Q4_K_M" while the Hub row, the picker and the backfill + // all use the bare path, so the same file would carry two configs. The + // auto-switch lookup reads the bare path first, so the sidebar's entry would + // never be the one an API load applies. Same rule as settingsGgufVariantForRow. + const settingsGgufVariant = isStandaloneGgufPath(modelId) ? null : ggufVariant; const target = useMemo(() => { const leaf = leafName(modelId); return { id: modelId, displayName: ggufVariant ? `${leaf} · ${ggufVariant}` : leaf, - ggufVariant, + ggufVariant: settingsGgufVariant, isGguf, // An Ollama blob loads through a link dir the auto-switch resolver skips, // so its settings must not be mirrored as if the API could load it. @@ -51,17 +62,17 @@ export function SidebarModelConfig({ meta: { source: "local", isLora: false, - ggufVariant: ggufVariant ?? undefined, + ggufVariant: settingsGgufVariant ?? undefined, isGguf, isDownloaded: true, contextLength: nativeContextLength, }, }; - }, [modelId, ggufVariant, isGguf, nativeContextLength]); + }, [modelId, ggufVariant, settingsGgufVariant, isGguf, nativeContextLength]); return ( :Q4_K_M" while the Hub row, the picker and + the backfill all wrote the bare path. The auto-switch lookup reads the bare + path BEFORE ":", so the sidebar's entry was never the one + an API load applied: the user edited settings that could not take effect.""" + sidebar = " ".join(_read("features/model-picker/components/sidebar-model-config.tsx").split()) + # Nulled for the settings identity, and used for every field that keys it. + assert ( + "const settingsGgufVariant = isStandaloneGgufPath(modelId) ? null : ggufVariant;" + in sidebar + ) + assert "ggufVariant: settingsGgufVariant," in sidebar + assert "ggufVariant: settingsGgufVariant ?? undefined," in sidebar + # The label still shows the quant; only the identity drops it. + assert "displayName: ggufVariant ? `${leaf} · ${ggufVariant}` : leaf," in sidebar + + # One rule, one definition: the Hub row applies the same test. + identity = _read("features/hub/lib/model-identity.ts") + assert "export function isStandaloneGgufPath(" in identity + assert 'modelId.toLowerCase().endsWith(".gguf")' in identity + row_identity = _read("features/hub/inventory/settings-identity.ts") + assert 'row.path.toLowerCase().endsWith(".gguf")' in row_identity + + # The precedence that makes the bare path the one that wins. + route = (WORKDIR / "studio" / "backend" / "routes" / "inference.py").read_text( + encoding = "utf-8", + ) + assert 'f"{target_id}:{file_variant}" if file_variant else None,' in route + bare = route.index("\n target_id,\n") + labelled = route.index('f"{target_id}:{file_variant}"') + assert bare < labelled, "the bare path must be read before the filename label"