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 764e823e53..e32f8918b2 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 @@ -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 = 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; } diff --git a/studio/frontend/tests/bundler-resolver.mjs b/studio/frontend/tests/bundler-resolver.mjs new file mode 100644 index 0000000000..7987b8544c --- /dev/null +++ b/studio/frontend/tests/bundler-resolver.mjs @@ -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); +} diff --git a/studio/frontend/tests/per-model-config-storage-identity.test.ts b/studio/frontend/tests/per-model-config-storage-identity.test.ts new file mode 100644 index 0000000000..a8015d0f0d --- /dev/null +++ b/studio/frontend/tests/per-model-config-storage-identity.test.ts @@ -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(); +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, + ); +}); diff --git a/studio/frontend/tests/quant-suffix-split.test.ts b/studio/frontend/tests/quant-suffix-split.test.ts new file mode 100644 index 0000000000..55c1bc84f3 --- /dev/null +++ b/studio/frontend/tests/quant-suffix-split.test.ts @@ -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)); +}); diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index bdc90d7ad4..2d776ad265 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -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():