Require the scanner's own label before folding a .gguf colon suffix

The frontend splitter accepted any suffix after a .gguf head, while the backend
requires that suffix to be the label the scanner derives from the filename. A
colon is legal in a POSIX filename and POSIX is case sensitive, so
"/models/llama.gguf:Bar.gguf" and "/models/llama.gguf:bar.gguf" are two real,
distinct files. The one-time backfill folded both onto one override key, since
the variant half is stored lowercased, and then re-read the local configs by
that key, so the first entry was sent twice, the second file's context and KV
cache settings never left the browser, and the done flag was set anyway.

splitQuantSuffix now ports extract_quant_label for a bare filename, shard suffix
and float-precision fallback included, and takes the suffix only when it equals
that label. Checked against the backend over twenty-nine keys with identical
answers on both sides, up from twelve, and the backfill now migrates both files
with their own settings.

The identity helpers come straight from features/hub/lib/model-identity rather
than the hub barrel, which also re-exports the download manager and its React
components. Same bindings, and it puts the module within reach of a test.

The 27 new frontend assertions cover the split case by case against the
backend's answers, and pin the storage rule the backfill's re-read depends on:
two spellings of one repo id or one Windows path keep a single record, a POSIX
path keeps two, and importing the legacy load settings never adds a duplicate.
This commit is contained in:
danielhanchen 2026-07-29 01:14:13 +00:00
commit a06b2a5422
5 changed files with 311 additions and 6 deletions

View file

@ -1,16 +1,19 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
// Straight from the module rather than the hub barrel, which also re-exports the
// download manager and its React components: these are pure string helpers, and
// pulling the barrel in puts every one of those in the way of loading them.
import {
normalizeGgufVariantIdentity,
normalizeModelIdentity,
} from "@/features/hub";
} from "@/features/hub/lib/model-identity";
export {
isOllamaLinkPath,
normalizeGgufVariantIdentity,
normalizeModelIdentity,
} from "@/features/hub";
} from "@/features/hub/lib/model-identity";
const MODEL_STORAGE_KEY_PREFIX = "v2:";
@ -72,9 +75,51 @@ export function ggufVariantFromStorageKey(key: string): string | null {
// Mirrors split_quant_suffix in studio/backend/utils/openai_auto_switch_settings.py.
// The bpw modifier ("IQ4_XS-3.53bpw") is optional: the backend label helpers disagree.
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;
// One source for the anchored test and the scan below, so they cannot drift apart.
// Mirrors _GGUF_QUANT_RE in studio/backend/hub/utils/gguf.py.
const QUANT_TOKEN_SOURCE =
"(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)";
const KNOWN_QUANT = new RegExp(`^${QUANT_TOKEN_SOURCE}$`, "i");
const QUANT_TOKEN = new RegExp(QUANT_TOKEN_SOURCE, "gi");
const MAX_QUANT_SUFFIX_LEN = 64;
// Mirrors _GGUF_SPLIT_SUFFIX_RE in studio/backend/hub/utils/gguf.py.
const GGUF_SPLIT_SUFFIX = /-[0-9]{3,}-of-[0-9]{3,}/gi;
const BACKSLASHES = /\\/g;
// A float precision only labels a file when nothing sharper does, matching the
// backend's _select_quant_match.
const FLOAT_PRECISION_QUANTS: ReadonlySet<string> = new Set([
"BF16",
"F16",
"F32",
]);
/** Mirrors _gguf_stem in studio/backend/hub/utils/gguf.py, for a bare filename. */
function ggufStem(filename: string): string {
const dot = filename.lastIndexOf(".");
const withoutExtension = dot >= 0 ? filename.slice(0, dot) : filename;
return withoutExtension.replace(GGUF_SPLIT_SUFFIX, "").trim();
}
/**
* Mirrors extract_quant_label in studio/backend/hub/utils/gguf.py, for a bare
* filename. The parent-directory pass there cannot fire on a basename, so this
* is the stem's own quant token or, failing that, the stem itself.
*/
function ggufQuantLabel(filename: string): string {
const stem = ggufStem(filename);
let fallback: RegExpExecArray | null = null;
for (const match of stem.matchAll(QUANT_TOKEN)) {
if (FLOAT_PRECISION_QUANTS.has(match[2].toUpperCase())) {
fallback ??= match;
continue;
}
return `${match[1] ?? ""}${match[2]}`;
}
if (fallback) {
return `${fallback[1] ?? ""}${fallback[2]}`;
}
return stem || "gguf";
}
/**
* `[head, quant]` for a `head:QUANT` key, or null when the colon is not one.
@ -100,5 +145,15 @@ export function splitQuantSuffix(value: string): [string, string] | null {
}
// A .gguf with no recognizable quant is labelled by its stem, so
// "/models/CustomModel.gguf:custommodel" exists; a non-.gguf head is a plain colon.
return head.toLowerCase().endsWith(".gguf") ? [head, tail] : null;
if (!head.toLowerCase().endsWith(".gguf")) {
return null;
}
// The suffix has to be that exact label, as the backend requires. A colon is legal
// in a POSIX filename, so "/models/llama.gguf:Bar.gguf" and its lowercase sibling
// are two real files: reading the suffix as a variant folds them onto one key and
// strands one file's settings, since the variant half is stored lowercased.
const filename = head.replace(BACKSLASHES, "/").split("/").pop() ?? head;
return tail.toLowerCase() === ggufQuantLabel(filename).toLowerCase()
? [head, tail]
: null;
}

View file

@ -0,0 +1,36 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
// The two resolution rules vite and tsconfig's "bundler" mode give the app that
// bare node does not have: the "@/*" path alias, and a relative import written
// without its extension. Register this from a test that needs to import a src
// module using either, which is otherwise unreachable from the test runner.
import { existsSync } from "node:fs";
import { fileURLToPath, pathToFileURL } from "node:url";
const SRC = fileURLToPath(new URL("../src/", import.meta.url));
function firstExisting(base) {
for (const candidate of [`${base}.ts`, `${base}/index.ts`, base]) {
if (existsSync(candidate)) {
return pathToFileURL(candidate).href;
}
}
return null;
}
export function resolve(specifier, context, next) {
if (specifier.startsWith("@/")) {
const resolved = firstExisting(SRC + specifier.slice(2));
return next(resolved ?? specifier, context);
}
if (specifier.startsWith(".") && context.parentURL?.startsWith("file:")) {
const resolved = firstExisting(
fileURLToPath(new URL(specifier, context.parentURL)),
);
if (resolved) {
return next(resolved, context);
}
}
return next(specifier, context);
}

View file

@ -0,0 +1,111 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import assert from "node:assert/strict";
import { register } from "node:module";
import test from "node:test";
register("./bundler-resolver.mjs", import.meta.url);
const store = new Map<string, string>();
const storage = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
removeItem: (key: string) => {
store.delete(key);
},
};
Object.assign(globalThis, {
window: { localStorage: storage },
localStorage: storage,
});
const REPO_KEY = 'v2:["unsloth/repo-gguf","q4_k_m"]';
// The legacy import of unsloth_load_settings runs once, on the first read after
// load, so it has to be staged before the module is imported.
store.set(
"unsloth_model_configs",
JSON.stringify({ [REPO_KEY]: { version: 1, maxSeqLength: 32768 } }),
);
store.set(
"unsloth_load_settings",
JSON.stringify({ "Unsloth/Repo-GGUF::Q4_K_M": { contextLength: 8192 } }),
);
const { listPerModelConfigs, resolveInitialConfig, savePerModelConfig } =
await import("../src/features/model-picker/model-config/per-model-config.ts");
function config(maxSeqLength: number, kvCacheDtype: string | null = null) {
return {
customContextLength: null,
maxSeqLength,
kvCacheDtype,
speculativeType: null,
specDraftNMax: null,
tensorParallel: false,
chatTemplateOverride: null,
};
}
function storedKeys(): string[] {
return Object.keys(
JSON.parse(storage.getItem("unsloth_model_configs") ?? "{}"),
);
}
// The one-time backfill re-reads listPerModelConfigs() to pick up a save that
// landed while the override fetch was in flight, and matches on the folded
// identity. That is only unambiguous because storage holds one record per model,
// so these pin that rule rather than the backfill.
test("importing the legacy load settings never doubles up a model", () => {
// The typed casing in unsloth_load_settings names the model the v2 record
// already holds, so the import has to leave it alone rather than add a second
// record the picker would prefer and the backfill would not.
assert.deepEqual(listPerModelConfigs().length, 1);
assert.deepEqual(storedKeys(), [REPO_KEY]);
assert.equal(
resolveInitialConfig("unsloth/repo-gguf", "q4_k_m").config
.customContextLength,
null,
);
});
test("two spellings of one model id keep a single stored record", () => {
store.clear();
savePerModelConfig("Unsloth/Repo-GGUF", "Q4_K_M", config(4096));
savePerModelConfig("unsloth/repo-gguf", "q4_k_m", config(32768, "q8_0"));
assert.deepEqual(storedKeys(), [REPO_KEY]);
const listed = listPerModelConfigs();
assert.equal(listed.length, 1);
assert.equal(listed[0]?.config.maxSeqLength, 32768);
// What the picker applies and the only thing the backfill can see agree.
assert.equal(
resolveInitialConfig("Unsloth/Repo-GGUF", "Q4_K_M").config.maxSeqLength,
32768,
);
});
test("two spellings of one Windows path keep a single stored record", () => {
store.clear();
savePerModelConfig("C:\\Models\\Foo.gguf", null, config(4096));
savePerModelConfig("c:/models/foo.gguf", null, config(32768, "q8_0"));
assert.deepEqual(storedKeys(), ['v2:["c:/models/foo.gguf",""]']);
assert.equal(listPerModelConfigs().length, 1);
});
test("a POSIX path is case sensitive, so its two spellings stay separate", () => {
store.clear();
savePerModelConfig("/models/Foo.gguf", null, config(4096));
savePerModelConfig("/models/foo.gguf", null, config(32768, "q8_0"));
assert.equal(storedKeys().length, 2);
assert.equal(
resolveInitialConfig("/models/Foo.gguf", null).config.maxSeqLength,
4096,
);
});

View file

@ -0,0 +1,83 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import assert from "node:assert/strict";
import { register } from "node:module";
import test from "node:test";
// The module under test resolves the way vite and tsconfig resolve, not the way
// bare node does.
register("./bundler-resolver.mjs", import.meta.url);
const { modelStorageKey, splitQuantSuffix } = await import(
"../src/features/model-picker/model-config/model-identity.ts"
);
// Every answer below is the one split_quant_suffix in
// studio/backend/utils/openai_auto_switch_settings.py gives for the same key. The
// backfill folds a stored key with this before comparing it against the server's,
// so a suffix this splits and the backend does not collapses two models onto one
// key on the browser side only.
const CASES: [string, [string, string] | null][] = [
// A known quant label, with and without the optional bpw modifier.
["org/Repo-GGUF:Q4_K_M", ["org/Repo-GGUF", "Q4_K_M"]],
["org/Repo-GGUF:IQ4_XS-3.53bpw", ["org/Repo-GGUF", "IQ4_XS-3.53bpw"]],
["org/Repo-GGUF:UD-Q4_K_XL", ["org/Repo-GGUF", "UD-Q4_K_XL"]],
// A .gguf with no quant token in its name is labelled by its stem, and storage
// lowercases the label while the scanner keeps the filename's casing.
["/models/CustomModel.gguf:custommodel", ["/models/CustomModel.gguf", "custommodel"]],
["/models/CustomModel.gguf:CustomModel", ["/models/CustomModel.gguf", "CustomModel"]],
["C:\\models\\CustomModel.gguf:custommodel", ["C:\\models\\CustomModel.gguf", "custommodel"]],
// A shard suffix is not part of the label.
[
"/models/Custom-00001-of-00003.gguf:custom",
["/models/Custom-00001-of-00003.gguf", "custom"],
],
["/models/Custom-00001-of-00003.gguf:custom-00001-of-00003", null],
// An extensionless .gguf still has a label.
["/models/.gguf:gguf", ["/models/.gguf", "gguf"]],
// A quant token inside the filename wins over the stem.
["/models/tinyllama-Q4_K_M.gguf:q4_k_m", ["/models/tinyllama-Q4_K_M.gguf", "q4_k_m"]],
["/models/tinyllama-Q4_K_M.gguf:tinyllama-q4_k_m", null],
// Only the basename is labelled, never the directories above it.
[
"/models/dir/CustomModel.gguf:custommodel",
["/models/dir/CustomModel.gguf", "custommodel"],
],
["/models/dir/CustomModel.gguf:dir/custommodel", null],
// A colon is legal in a POSIX filename. Neither of these is a variant, and
// reading them as one folds two real files onto a single key.
["/models/foo:Bar.gguf", null],
["/models/foo:bar.gguf", null],
["/models/llama.gguf:Bar.gguf", null],
["/models/llama.gguf:bar.gguf", null],
["/models/CustomModel.gguf:othermodel", null],
["/models/model.gguf:notalabel", null],
["/models/plain.gguf:plain:extra", null],
// A Windows drive letter is not a separator either.
["C:\\models\\foo.gguf", null],
["C:/models/foo.gguf", null],
// Nothing to split.
["org/Repo-GGUF", null],
["/models/foo.gguf", null],
["org/Repo:", null],
[":Q4_K_M", null],
];
test("splitQuantSuffix answers exactly as the backend's split_quant_suffix", () => {
for (const [value, expected] of CASES) {
assert.deepEqual(splitQuantSuffix(value), expected, value);
}
});
test("a .gguf filename carrying a colon is not folded into a variant", () => {
// Two real, distinct files: POSIX allows a colon in a name and is case
// sensitive, so the one-time backfill has to keep their settings apart. The
// variant half of an override key is stored lowercased, so folding these makes
// one key and strands whichever file the backfill reaches second.
const upper = "/models/llama.gguf:Bar.gguf";
const lower = "/models/llama.gguf:bar.gguf";
assert.equal(splitQuantSuffix(upper), null);
assert.equal(splitQuantSuffix(lower), null);
assert.notEqual(modelStorageKey(upper, null), modelStorageKey(lower, null));
});

View file

@ -894,7 +894,12 @@ def test_backfill_splits_a_quant_suffix_the_way_the_backend_does():
# 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
assert 'if (!head.toLowerCase().endsWith(".gguf")) { return null; }' in identity
# A .gguf head is not enough on its own: the suffix has to be the label the
# scanner derives from that filename, or a name that itself contains ".gguf:"
# ("/models/llama.gguf:Bar.gguf" and its lowercase sibling, two real POSIX
# files) folds onto one key and one file's settings never migrate.
assert "tail.toLowerCase() === ggufQuantLabel(filename).toLowerCase()" in identity
migrate = " ".join(_read("features/model-picker/api/migrate-model-overrides.ts").split())
assert "const split = splitQuantSuffix(key);" in migrate
@ -905,12 +910,27 @@ def test_backfill_splits_a_quant_suffix_the_way_the_backend_does():
).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
assert "extract_quant_label(filename).casefold()" in backend, "the label rule"
# Both sides accept the same quant vocabulary; the regex lives with the loader.
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
# The label helpers the .gguf branch leans on are ported too, shard suffix and
# float-precision fallback included, or the two sides label a filename apart.
gguf = (WORKDIR / "studio" / "backend" / "hub" / "utils" / "gguf.py").read_text(
encoding = "utf-8"
)
assert "def extract_quant_label(" in gguf and "def _gguf_stem(" in gguf
assert "function ggufQuantLabel(" in identity and "function ggufStem(" in identity
assert "_GGUF_SPLIT_SUFFIX_RE" in gguf and "GGUF_SPLIT_SUFFIX" in identity
assert "_FLOAT_PRECISION_QUANTS" in gguf and "FLOAT_PRECISION_QUANTS" in identity
# The executable half of this contract, checked case by case against the
# answers split_quant_suffix gives.
assert (
WORKDIR / "studio" / "frontend" / "tests" / "quant-suffix-split.test.ts"
).is_file()
def test_the_detail_card_also_gates_ollama_out_of_the_api_promise():