diff --git a/studio/frontend/src/features/hub/hub-page.tsx b/studio/frontend/src/features/hub/hub-page.tsx index 879d96f641..2417bec0d9 100644 --- a/studio/frontend/src/features/hub/hub-page.tsx +++ b/studio/frontend/src/features/hub/hub-page.tsx @@ -1414,6 +1414,9 @@ export function ModelsPage() { displayName: ggufVariant ? `${leaf} ยท ${ggufVariant}` : leaf, ggufVariant, isGguf: selectedModel.isGguf, + apiLoadable: + selectedModel.isGguf && + selectedModel.localSource !== LOCAL_MODEL_SOURCE.OLLAMA, meta: { source: "local", isLora: selectedModel.modelFormat === "adapter", diff --git a/studio/frontend/src/features/model-picker/api/migrate-model-overrides.ts b/studio/frontend/src/features/model-picker/api/migrate-model-overrides.ts index e9466de36d..6b26c424cc 100644 --- a/studio/frontend/src/features/model-picker/api/migrate-model-overrides.ts +++ b/studio/frontend/src/features/model-picker/api/migrate-model-overrides.ts @@ -10,6 +10,7 @@ import { normalizeGgufVariantIdentity, normalizeModelIdentity, + splitQuantSuffix, } from "../model-config/model-identity"; import { isDefaultConfig, @@ -47,17 +48,18 @@ function markRan(): void { * row was written, so an old install has keys like `Unsloth/Repo-GGUF:Q4_K_M` while * this browser only produces the folded form. The backend resolves both to one * model, so an exact lookup would report "not on the server" and let the backfill - * overwrite it. Variants never contain a colon, so the last one splits the key; a - * repo id folds and a POSIX path deliberately does not. + * overwrite it. The split is quant-aware like the backend's: a repo id folds and a + * POSIX path deliberately does not, and an ordinary colon inside a filename, or a + * Windows drive letter, is not a separator at all. */ function normalizedOverrideKey(key: string): string { - const separator = key.lastIndexOf(":"); - if (separator < 0) { + const split = splitQuantSuffix(key); + if (!split) { return modelOverrideKey(normalizeModelIdentity(key)); } return modelOverrideKey( - normalizeModelIdentity(key.slice(0, separator)), - normalizeGgufVariantIdentity(key.slice(separator + 1)), + normalizeModelIdentity(split[0]), + normalizeGgufVariantIdentity(split[1]), ); } diff --git a/studio/frontend/src/features/model-picker/model-config/model-identity.ts b/studio/frontend/src/features/model-picker/model-config/model-identity.ts index 0caa7c1312..5848ba6921 100644 --- a/studio/frontend/src/features/model-picker/model-config/model-identity.ts +++ b/studio/frontend/src/features/model-picker/model-config/model-identity.ts @@ -67,3 +67,41 @@ export function ggufVariantFromStorageKey(key: string): string | null { const separator = key.lastIndexOf("::"); return separator >= 0 ? key.slice(separator + 2) : null; } + +// Mirrors split_quant_suffix in studio/backend/utils/openai_auto_switch_settings.py. +// A quant label may carry a bits-per-weight modifier ("IQ4_XS-3.53bpw"), and the +// two backend label helpers disagree on whether to keep it, so both forms parse. +const BPW_SUFFIX = /-[0-9]+(?:\.[0-9]+)?bpw$/i; +const KNOWN_QUANT = + /^(UD-)?(MXFP[0-9]+(?:_[A-Z0-9]+)*|IQ[0-9]+_[A-Z]+(?:_[A-Z0-9]+)?|TQ[0-9]+_[0-9]+|Q[0-9]+_K_[A-Z]+|Q[0-9]+_[0-9]+|Q[0-9]+_K|BF16|F16|F32)$/i; +const MAX_QUANT_SUFFIX_LEN = 64; + +/** + * `[head, quant]` for a `head:QUANT` key, or null when the colon is not one. + * + * The suffix has to look like a real quant, so an ordinary colon inside a POSIX + * filename is left alone ("/models/foo:bar.gguf" is one valid filename) and a + * Windows drive letter is never mistaken for a model id. + */ +export function splitQuantSuffix(value: string): [string, string] | null { + const separator = value.lastIndexOf(":"); + if (separator <= 0 || separator === value.length - 1) { + return null; + } + const head = value.slice(0, separator); + const tail = value.slice(separator + 1); + if (tail.includes("/") || tail.includes("\\")) { + return null; + } + if ( + tail.length <= MAX_QUANT_SUFFIX_LEN && + KNOWN_QUANT.test(tail.replace(BPW_SUFFIX, "")) + ) { + return [head, tail]; + } + // A .gguf with no recognizable quant token is labelled by its stem, so keys + // like "/models/CustomModel.gguf:custommodel" exist. Requiring the head to be + // a .gguf keeps an ordinary colon out: "/models/foo:bar.gguf" splits to a head + // that is not one. + return head.toLowerCase().endsWith(".gguf") ? [head, tail] : null; +} diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 51f571013f..854476d5ef 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -699,7 +699,7 @@ def test_backfill_compares_server_keys_by_normalized_identity(): assert "if (known.has(key)) { continue; }" in src # A variant never holds a colon, so the last one splits the key; the first would # cut the drive letter off every Windows path id. - assert 'key.lastIndexOf(":")' in src + assert "const split = splitQuantSuffix(key);" in src # Repo ids fold and POSIX paths do not, which is what these do. assert "normalizeModelIdentity(" in src and "normalizeGgufVariantIdentity(" in src @@ -871,3 +871,46 @@ def test_cached_repo_settings_are_keyed_by_the_repo_id(): WORKDIR / "studio" / "backend" / "hub" / "tests" / "test_model_services.py" ).read_text(encoding = "utf-8") assert 'fields["load_id"] == str(snapshot)' in backend, "the rule this mirrors" + + +def test_backfill_splits_a_quant_suffix_the_way_the_backend_does(): + """The backfill compared server keys under an identity taken by splitting on + the last colon, so a Windows drive letter and an ordinary colon inside a POSIX + filename were read as quant separators: `/models/foo:Bar.gguf` and + `/models/foo:bar.gguf` folded to one key, and whichever was already on the + server made the other look migrated.""" + identity = " ".join( + _read("features/model-picker/model-config/model-identity.ts").split() + ) + assert "export function splitQuantSuffix(" in identity + # The two rules that keep a path out: no separator in the tail, and a head + # that is not a .gguf cannot carry a free-form label. + assert 'if (tail.includes("/") || tail.includes("\\\\"))' in identity + assert 'head.toLowerCase().endsWith(".gguf") ? [head, tail] : null' in identity + + migrate = " ".join(_read("features/model-picker/api/migrate-model-overrides.ts").split()) + assert "const split = splitQuantSuffix(key);" in migrate + assert 'key.lastIndexOf(":")' not in migrate, "the unconditional split is gone" + + backend = ( + WORKDIR / "studio" / "backend" / "utils" / "openai_auto_switch_settings.py" + ).read_text(encoding = "utf-8") + assert "def split_quant_suffix(" in backend, "the rule this mirrors" + assert "_BPW_SUFFIX" in backend and "bpw" in identity + # Both sides accept the same quant vocabulary. The regex itself lives with + # the loader that reads the filenames. + quants = ( + WORKDIR / "studio" / "backend" / "core" / "inference" / "llama_cpp.py" + ).read_text(encoding = "utf-8") + for token in ("MXFP", "IQ", "TQ", "BF16", "F16", "F32"): + assert token in quants and token in identity, token + + +def test_the_detail_card_also_gates_ollama_out_of_the_api_promise(): + """Settings opens from two places in the Hub. The row menu gated Ollama out of + the server mirror and the "API loads use these" copy; the detail card did not, + so the same model made the same false promise from the other entry point.""" + hub = " ".join(_read("features/hub/hub-page.tsx").split()) + assert hub.count("LOCAL_MODEL_SOURCE.OLLAMA") == 2 + assert "selectedModel.localSource !== LOCAL_MODEL_SOURCE.OLLAMA" in hub + assert 'row.source !== LOCAL_MODEL_SOURCE.OLLAMA' in hub