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 <path>: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.
This commit is contained in:
danielhanchen 2026-07-29 04:14:13 +00:00
commit 84bf2744f8
4 changed files with 70 additions and 8 deletions

View file

@ -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");
}

View file

@ -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 "<path>: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<ModelPickTarget>(() => {
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 (
<ModelConfigPage
key={modelConfigInstanceKey(modelId, ggufVariant, loadedConfig)}
key={modelConfigInstanceKey(modelId, settingsGgufVariant, loadedConfig)}
target={target}
onRun={onReload}
loadedConfig={loadedConfig}

View file

@ -12,6 +12,7 @@ import {
export {
isNativeFileLabel,
isOllamaLinkPath,
isStandaloneGgufPath,
normalizeGgufVariantIdentity,
normalizeModelIdentity,
} from "@/features/hub/lib/model-identity";

View file

@ -690,7 +690,7 @@ def test_parallel_slots_setting_wired_end_to_end():
signature = _read("features/model-picker/model-config/config-signature.ts")
assert 'config.nParallel ?? "",' in signature
sidebar = " ".join(_read("features/model-picker/components/sidebar-model-config.tsx").split())
assert "key={modelConfigInstanceKey(modelId, ggufVariant, loadedConfig)}" in sidebar
assert "key={modelConfigInstanceKey(modelId, settingsGgufVariant, loadedConfig)}" in sidebar
def test_parallel_slots_reach_an_api_load_through_the_server_mirror():
@ -1429,9 +1429,13 @@ def test_the_hub_settings_editor_reseeds_when_the_live_config_lands():
running with something else, which Apply then wrote back over it."""
view = " ".join(_read("features/hub/catalog/hub-model-settings-view.tsx").split())
assert "key={modelConfigInstanceKey( target.id, target.ggufVariant, loadedConfig, )}" in view
# Same key the sidebar entry uses; that parity is the point.
# Same key the sidebar entry uses; that parity is the point. It keys by the
# settings variant, which is the loader's filename label nulled out for a
# standalone .gguf, so both surfaces name one config per file.
sidebar = " ".join(_read("features/model-picker/components/sidebar-model-config.tsx").split())
assert "key={modelConfigInstanceKey(modelId, ggufVariant, loadedConfig)}" in sidebar
assert (
"key={modelConfigInstanceKey(modelId, settingsGgufVariant, loadedConfig)}" in sidebar
)
signature = " ".join(_read("features/model-picker/model-config/config-signature.ts").split())
# "No live config yet" has to be its own value: that transition is exactly
@ -1490,3 +1494,39 @@ def test_a_standalone_gguf_is_resident_despite_its_derived_quant():
assert (
"self._hf_variant = _extract_quant_label(gguf_path)" in backend
), "the derived label this accounts for"
def test_a_standalone_gguf_has_one_settings_identity_everywhere():
"""A loose .gguf has no quant to choose between, but llama_cpp falls back to
_extract_quant_label(gguf_path) when a load names no variant, and /status
echoes that as gguf_variant. The sidebar took it straight from the store, so
an edit there landed under "<path>: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 "<path>:<file_variant>", 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"