Split a quant suffix the way the backend does, and gate the detail card too
The backfill took a key's identity by splitting on its last colon, so anything else that ends in one was read as a quant separator. A Windows path made "C:\models\foo.gguf" into model "C" with variant "\models\foo.gguf", and an ordinary colon inside a POSIX filename folded "/models/foo:Bar.gguf" and "/models/foo:bar.gguf" onto one key, so whichever of the two was already on the server made the other look migrated and left its API loads on defaults. splitQuantSuffix now mirrors split_quant_suffix on the backend: the suffix has to be a known quant label, with or without a bits-per-weight modifier, or the head has to be a .gguf carrying a stem label. Checked against the backend over twelve keys, including every case above, with identical answers on both sides. Settings also opens from the on-device detail card, and that constructor never set apiLoadable, so an Ollama model reached the server mirror and the "API loads use these settings" line from that entry point even though the auto-switch resolver skips Ollama's scanner. It now reads the same source the row menu does.
This commit is contained in:
parent
50ed0e55d8
commit
7340d90163
4 changed files with 93 additions and 7 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue