Fix model picker per-model config regressions

- Stop reloading the already loaded model on re-pick
- Hide infra models from the chat picker
- Detect vision support on cached GGUF repos
- Honor saved maxSeqLength on auto load
- Restore default chat template for local GGUFs
- Warn on save failure and revert config on cancel
- Refetch picker inventory on open
- Persist read only per model config safely
This commit is contained in:
sneakr 2026-07-11 15:53:26 +02:00
commit f4838782cb
16 changed files with 213 additions and 135 deletions

View file

@ -31,6 +31,7 @@ from hub.services.models.common import (
_is_checkpoint_weight_name,
_is_gguf_filename,
_is_main_gguf_filename,
_is_mmproj_filename,
_is_transformers_safetensors_weight_name,
_local_inventory_id,
_prefer_complete_larger,
@ -143,6 +144,14 @@ def _repo_gguf_last_modified(repo_info) -> float:
return latest
def _repo_has_mmproj(repo_info) -> bool:
return any(
_is_mmproj_filename(f.file_name)
for revision in repo_info.revisions
for f in revision.files
)
def _cached_repo_file_name(file_obj) -> str:
file_path = getattr(file_obj, "file_path", None)
if file_path:
@ -270,6 +279,8 @@ def _scan_cached_gguf() -> list[dict]:
requires_variant = True,
)
)
if _repo_has_mmproj(repo_info):
row["capabilities"]["supports_vision"] = True
if _prefer_cache_row(row, existing):
seen_lower[key] = row
elif last_modified > existing.get("last_modified", 0.0):

View file

@ -17,6 +17,7 @@ from hub.services.models.folder_browser import (
_build_browse_allowlist,
_is_path_inside_allowlist,
)
from hub.utils.gguf import iter_hf_cache_snapshots
from utils.models.gguf_metadata import read_gguf_chat_template
from utils.models.model_config import (
_extract_quant_label,
@ -25,7 +26,6 @@ from utils.models.model_config import (
_is_mtp_drafter,
)
from utils.paths.path_utils import (
get_cache_path,
is_local_path,
normalize_path,
resolve_cached_repo_id_case,
@ -210,18 +210,6 @@ def _chat_template_from_dir(dir_path: Path, gguf_variant: Optional[str] = None)
return _chat_template_from_tokenizer_dir(dir_path) or from_gguf()
def _snapshots_newest_first(snapshots_dir: Path) -> list[Path]:
dirs_with_mtime: list[tuple[float, Path]] = []
for entry in snapshots_dir.iterdir():
try:
if entry.is_dir():
dirs_with_mtime.append((entry.stat().st_mtime, entry))
except OSError:
continue
dirs_with_mtime.sort(key = lambda item: item[0], reverse = True)
return [entry for _, entry in dirs_with_mtime]
def read_default_chat_template(
model_name: str,
hf_token: Optional[str] = None,
@ -250,14 +238,10 @@ def read_default_chat_template(
resolved = resolve_cached_repo_id_case(name)
try:
repo_dir = get_cache_path(resolved)
if repo_dir is not None and repo_dir.exists():
snapshots_dir = repo_dir / "snapshots"
if snapshots_dir.exists():
for snapshot in _snapshots_newest_first(snapshots_dir):
template = _chat_template_from_dir(snapshot, gguf_variant)
if template:
return template
for snapshot in iter_hf_cache_snapshots(resolved):
template = _chat_template_from_dir(snapshot, gguf_variant)
if template:
return template
except Exception as exc:
logger.debug("Could not read cached chat template for %s: %s", resolved, exc)

View file

@ -1,7 +1,16 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
from picker.service import _find_gguf_in_dir, _iter_ggufs
import json
from picker.service import (
_chat_template_from_dir,
_chat_template_from_tokenizer_config,
_chat_template_from_tokenizer_dir,
_find_gguf_in_dir,
_iter_ggufs,
validate_chat_template,
)
def test_iter_ggufs_skips_gguf_companions(tmp_path):
@ -46,3 +55,68 @@ def test_find_gguf_in_dir_matches_bpw_variant_base_label(tmp_path):
assert _find_gguf_in_dir(tmp_path, "IQ4_XS") == target
assert _find_gguf_in_dir(tmp_path, "IQ4_XS-3.53bpw") == target
assert _find_gguf_in_dir(tmp_path, "Q4_K") is None
def test_validate_chat_template_accepts_valid_and_empty():
assert validate_chat_template("{{ messages[0].content }}").valid is True
assert validate_chat_template("").valid is True
assert validate_chat_template(" ").valid is True
def test_validate_chat_template_reports_syntax_error_with_line():
result = validate_chat_template("{% if %}{% endif %}")
assert result.valid is False
assert result.error is not None
assert result.error.startswith("Line ")
def test_chat_template_from_tokenizer_config_reads_string():
assert _chat_template_from_tokenizer_config({"chat_template": "HELLO"}) == "HELLO"
assert _chat_template_from_tokenizer_config({"chat_template": " "}) is None
assert _chat_template_from_tokenizer_config({}) is None
def test_chat_template_from_tokenizer_config_prefers_named_default():
config = {
"chat_template": [
{"name": "tool_use", "template": "TOOL"},
{"name": "default", "template": "DEFAULT"},
]
}
assert _chat_template_from_tokenizer_config(config) == "DEFAULT"
def test_chat_template_from_tokenizer_config_falls_back_to_first_entry():
config = {
"chat_template": [
{"name": "tool_use", "template": "TOOL"},
{"name": "other", "template": "OTHER"},
]
}
assert _chat_template_from_tokenizer_config(config) == "TOOL"
def test_chat_template_from_tokenizer_dir_prefers_jinja_file(tmp_path):
(tmp_path / "chat_template.jinja").write_text("FROM_JINJA", encoding="utf-8")
(tmp_path / "tokenizer_config.json").write_text(
json.dumps({"chat_template": "FROM_CONFIG"}), encoding="utf-8"
)
assert _chat_template_from_tokenizer_dir(tmp_path) == "FROM_JINJA"
def test_chat_template_from_tokenizer_dir_reads_tokenizer_config(tmp_path):
(tmp_path / "tokenizer_config.json").write_text(
json.dumps({"chat_template": "FROM_CONFIG"}), encoding="utf-8"
)
assert _chat_template_from_tokenizer_dir(tmp_path) == "FROM_CONFIG"
def test_chat_template_from_dir_without_variant_prefers_tokenizer(tmp_path):
(tmp_path / "tokenizer_config.json").write_text(
json.dumps({"chat_template": "FROM_CONFIG"}), encoding="utf-8"
)
assert _chat_template_from_dir(tmp_path) == "FROM_CONFIG"
def test_chat_template_from_dir_returns_none_when_absent(tmp_path):
assert _chat_template_from_dir(tmp_path) is None

View file

@ -1441,7 +1441,7 @@ async function autoLoadSmallestModel(): Promise<{
ggufContextLength: null,
currentCheckpoint: currentStore.params.checkpoint,
activeGgufVariant: currentStore.activeGgufVariant,
maxSeqLength: candidate.maxSeqLength,
maxSeqLength: config.maxSeqLength ?? candidate.maxSeqLength,
presetSource: currentStore.activePresetSource,
});
const effectiveSpeculativeType =
@ -1489,6 +1489,9 @@ async function autoLoadSmallestModel(): Promise<{
);
store.setParams({
...store.params,
...(candidate.kind === "gguf"
? {}
: { maxSeqLength: effectiveMaxSeqLength }),
maxTokens:
candidate.kind === "gguf"
? loadResp.context_length ?? 131072

View file

@ -11,7 +11,6 @@ import {
ModelSelector,
type ModelSelectorChangeMeta,
type PerModelConfig,
perModelConfigsEqual,
resolveInitialConfig,
SidebarModelConfig,
} from "@/features/model-picker";
@ -1887,20 +1886,7 @@ export function ChatPage({
const isSameLoadedModel =
value === currentCheckpoint &&
(meta?.ggufVariant ?? null) === (currentVariant ?? null);
const metaIsGguf =
meta?.isGguf === true ||
meta?.ggufVariant != null ||
value.toLowerCase().endsWith(".gguf");
if (
isSameLoadedModel &&
(!meta?.config ||
perModelConfigsEqual(
meta.config,
currentRuntimePerModelConfig({
includeMaxSeqLength: !metaIsGguf,
}),
))
) {
if (isSameLoadedModel && !meta?.forceReload) {
return;
}
if (meta?.source === "external" || isExternalModelId(value)) {
@ -2103,6 +2089,7 @@ export function ChatPage({
isGguf: activeModelIsGguf,
isDownloaded: true,
config,
forceReload: true,
});
},
[
@ -2731,7 +2718,7 @@ export function ChatPage({
params={inferenceParams}
onParamsChange={setInferenceParams}
modelConfig={
view.mode !== "compare" && activeModelConfig ? (
view.mode !== "compare" && activeModelConfig && !modelLoading ? (
<SidebarModelConfig
modelId={inferenceParams.checkpoint}
ggufVariant={activeGgufVariant ?? null}

View file

@ -1179,10 +1179,10 @@ export function useChatModelRuntime() {
resetLoadingUi();
}
} catch (error) {
if (abortCtrl.signal.aborted) return; // User cancelled, nothing to report
if (typeof selection !== "string" && selection.previousConfig) {
applyPerModelConfigToRuntime(selection.previousConfig);
}
if (abortCtrl.signal.aborted) return; // User cancelled, nothing to report
resetLoadingUi();
const message =
error instanceof Error ? error.message : "Failed to load model";

View file

@ -1,32 +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
import { authFetch } from "@/features/auth";
import { readFastApiError } from "@/lib/format-fastapi-error";
async function parseJsonOrThrow<T>(response: Response): Promise<T> {
if (!response.ok) {
throw new Error(await readFastApiError(response));
}
return response.json();
}
import { getModelConfig } from "@/features/training";
export async function fetchModelMaxPositionEmbeddings(
modelName: string,
hfToken?: string | null,
signal?: AbortSignal,
): Promise<number | null> {
const query = hfToken?.trim()
? `?hf_token=${encodeURIComponent(hfToken.trim())}`
: "";
const response = await authFetch(
`/api/models/config/${encodeURIComponent(modelName)}${query}`,
{ signal },
const config = await getModelConfig(
modelName,
signal,
hfToken?.trim() || undefined,
);
const data = await parseJsonOrThrow<{ max_position_embeddings?: unknown }>(
response,
);
const value = data.max_position_embeddings;
const value = config.max_position_embeddings;
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.floor(value)
: null;

View file

@ -30,11 +30,13 @@ import {
import { perModelConfigsEqual } from "../model-config/apply-per-model-config";
import {
DEFAULT_PER_MODEL_CONFIG,
KV_CACHE_DTYPES,
MAX_SEQ_LENGTH_MAX,
MAX_SEQ_LENGTH_MIN,
MAX_SEQ_LENGTH_STEP,
MTP_SPECULATIVE_TYPES,
type PerModelConfig,
SPECULATIVE_TYPES,
deletePerModelConfig,
isDefaultConfig,
normalizeMaxSeqLength,
@ -53,6 +55,16 @@ const CONTROL_SURFACE =
const SELECT_TRIGGER_CLASS = `grid h-8 min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 ${CONTROL_SURFACE} pl-3 pr-2 py-0 text-[13px]! font-medium text-nav-fg focus-visible:ring-0 focus-visible:border-transparent [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate [&>svg]:shrink-0`;
const NUMBER_INPUT_CLASS = `h-8 w-[92px] ${CONTROL_SURFACE} pl-3 pr-2 py-0 text-right text-[13px] font-medium text-nav-fg outline-none focus-visible:ring-0`;
const KV_CACHE_DTYPE_DEFAULT = "f16";
const SPECULATIVE_TYPE_LABELS: Record<(typeof SPECULATIVE_TYPES)[number], string> =
{
auto: "Auto",
mtp: "MTP",
ngram: "Ngram",
"mtp+ngram": "MTP+Ngram",
off: "Off",
};
function hasNonDefaultAdvanced(config: PerModelConfig): boolean {
return (
config.kvCacheDtype != null ||
@ -182,9 +194,9 @@ function GgufAdvancedSettings({
</InfoHint>
</div>
<Select
value={config.kvCacheDtype ?? "f16"}
value={config.kvCacheDtype ?? KV_CACHE_DTYPE_DEFAULT}
onValueChange={(v) =>
update({ kvCacheDtype: v === "f16" ? null : v })
update({ kvCacheDtype: v === KV_CACHE_DTYPE_DEFAULT ? null : v })
}
>
<SelectTrigger
@ -196,11 +208,14 @@ function GgufAdvancedSettings({
<SelectValue />
</SelectTrigger>
<SelectContent className="menu-soft-surface ring-0 border-0 rounded-lg">
<SelectItem value="f16">f16</SelectItem>
<SelectItem value="bf16">bf16</SelectItem>
<SelectItem value="q8_0">q8_0</SelectItem>
<SelectItem value="q5_1">q5_1</SelectItem>
<SelectItem value="q4_1">q4_1</SelectItem>
<SelectItem value={KV_CACHE_DTYPE_DEFAULT}>
{KV_CACHE_DTYPE_DEFAULT}
</SelectItem>
{KV_CACHE_DTYPES.map((dtype) => (
<SelectItem key={dtype} value={dtype}>
{dtype}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
@ -232,11 +247,11 @@ function GgufAdvancedSettings({
<SelectValue />
</SelectTrigger>
<SelectContent className="menu-soft-surface ring-0 border-0 rounded-lg">
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="mtp">MTP</SelectItem>
<SelectItem value="ngram">Ngram</SelectItem>
<SelectItem value="mtp+ngram">MTP+Ngram</SelectItem>
<SelectItem value="off">Off</SelectItem>
{SPECULATIVE_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{SPECULATIVE_TYPE_LABELS[type]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
@ -317,6 +332,12 @@ export function ModelConfigPage({
const isActiveModel = loadedConfig != null;
const runtimeMaxSeqLength = useChatRuntimeStore((s) => s.params.maxSeqLength);
const hfToken = useChatRuntimeStore((s) => s.hfToken);
const loadedDefaultChatTemplate = useChatRuntimeStore(
(s) => s.defaultChatTemplate,
);
const loadedMaxContextLength = useChatRuntimeStore(
(s) => s.ggufMaxContextLength,
);
const [initialMaxSeqLength] = useState(
() => normalizeMaxSeqLength(runtimeMaxSeqLength) ?? 4096,
);
@ -353,6 +374,14 @@ export function ModelConfigPage({
target.id,
!target.isGguf,
);
const hasLoadedDefaultTemplate =
isActiveModel && loadedDefaultChatTemplate != null;
const resolvedDefaultTemplate = hasLoadedDefaultTemplate
? loadedDefaultChatTemplate
: templateDefaults.template;
const resolvedDefaultLoading = hasLoadedDefaultTemplate
? false
: templateDefaults.loading;
const update = (patch: Partial<PerModelConfig>) =>
setConfig((current) => ({ ...current, ...patch }));
@ -454,7 +483,7 @@ export function ModelConfigPage({
customContextLength:
contextBaseline == null && config.customContextLength == null
? null
: resolveCustomContextLength(contextValue, nativeContextLength),
: resolveCustomContextLength(contextValue, contextBaseline),
}
: {
...config,
@ -472,20 +501,21 @@ export function ModelConfigPage({
const handleRun = () => {
const defaultConfig = isDefaultConfig(runtimeConfig);
let saveFailed = false;
if (remember) {
const saved = savePerModelConfig(
saveFailed = !savePerModelConfig(
target.id,
target.ggufVariant,
runtimeConfig,
);
if (!saved) {
toast.error("Couldn't save settings for this model.");
return;
}
} else {
deletePerModelConfig(target.id, target.ggufVariant);
}
if (persistenceOnly) {
if (saveFailed) {
toast.error("Couldn't save settings for this model.");
return;
}
const nextRemember = remember && !defaultConfig;
setSavedRemember(nextRemember);
setRemember(nextRemember);
@ -498,6 +528,9 @@ export function ModelConfigPage({
);
return;
}
if (saveFailed) {
toast.error("Couldn't save these settings, loading with them anyway.");
}
onRun(runtimeConfig);
};
@ -566,6 +599,15 @@ export function ModelConfigPage({
aria-label="Context Length"
/>
) : null}
{isActiveModel &&
loadedMaxContextLength != null &&
contextValue > loadedMaxContextLength && (
<p className="text-[11px] text-amber-500">
Exceeds estimated VRAM capacity (
{loadedMaxContextLength.toLocaleString()} tokens). The model
may use system RAM.
</p>
)}
</div>
{showAdvanced && (
@ -679,8 +721,8 @@ export function ModelConfigPage({
open={templateOpen}
onOpenChange={setTemplateOpen}
value={config.chatTemplateOverride}
defaultTemplate={templateDefaults.template}
defaultLoading={templateDefaults.loading}
defaultTemplate={resolvedDefaultTemplate}
defaultLoading={resolvedDefaultLoading}
readOnly={!target.isGguf}
onSave={(override) => update({ chatTemplateOverride: override })}
/>

View file

@ -542,6 +542,7 @@ function ModelSelectorContent({
onSelect(visibleConfigTarget.id, {
...visibleConfigTarget.meta,
config,
forceReload: true,
})
}
loadedConfig={

View file

@ -1477,7 +1477,8 @@ export function HubModelPicker({
}, []);
const pickerInventory = useChatPickerInventory({ enabled: true });
const { cachedGguf, cachedModels, cachedReady } = pickerInventory;
const { cachedGguf, cachedModels, cachedReady, refreshInventory } =
pickerInventory;
const lmStudioModels = useMemo(
() =>
sortLmStudio(
@ -1658,6 +1659,10 @@ export function HubModelPicker({
.catch(() => {});
}, [refreshScanFolders]);
useEffect(() => {
void refreshInventory();
}, [refreshInventory]);
// Hide downloaded models from the recommended list. Case-insensitive
// since the HF cache lowercases repo IDs.
const downloadedSet = useMemo(() => {

View file

@ -38,6 +38,7 @@ export interface ModelSelectorChangeMeta {
* Studio). Marks it as a GGUF source for the deferred-load staging flow. */
isGguf?: boolean;
config?: PerModelConfig;
forceReload?: boolean;
}
export interface ModelPickTarget {

View file

@ -1,7 +1,7 @@
// 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 { looksLikeLocalPath, useHfTokenStore } from "@/features/hub";
import { useHfTokenStore } from "@/features/hub";
import { useEffect, useState } from "react";
import { fetchModelMaxPositionEmbeddings } from "../api/model-metadata";
import { fetchDefaultChatTemplate } from "../api/templates";
@ -69,7 +69,7 @@ export function useDefaultChatTemplate(
if (controller.signal.aborted) {
return;
}
if (!(template === null && looksLikeLocalPath(modelId))) {
if (template !== null) {
cacheTemplate(cacheKey, template);
}
setFetched({

View file

@ -10,6 +10,7 @@ import {
type CachedInventoryRow,
type LocalInventoryRow,
type LocalSource,
isHiddenModelId,
useHubInventory,
} from "@/features/hub";
import { useMemo } from "react";
@ -74,21 +75,35 @@ export function useChatPickerInventory(
const cachedGguf = useMemo(
() =>
inventory.cachedRows
.filter((row) => row.modelFormat === "gguf" && isCompleteCachedRow(row))
.filter(
(row) =>
row.modelFormat === "gguf" &&
isCompleteCachedRow(row) &&
!isHiddenModelId(row.repoId),
)
.map(toCachedGgufRepo),
[inventory.cachedRows],
);
const cachedModels = useMemo(
() =>
inventory.cachedRows
.filter((row) => row.modelFormat !== "gguf" && isCompleteCachedRow(row))
.filter(
(row) =>
row.modelFormat !== "gguf" &&
isCompleteCachedRow(row) &&
!isHiddenModelId(row.repoId),
)
.map(toCachedModelRepo),
[inventory.cachedRows],
);
const localModels = useMemo(
() =>
inventory.localRows
.filter((row) => PICKER_LOCAL_SOURCES.has(row.source))
.filter(
(row) =>
PICKER_LOCAL_SOURCES.has(row.source) &&
!isHiddenModelId(row.modelId, row.repoId, row.path),
)
.map(toLocalModelInfo),
[inventory.localRows],
);

View file

@ -18,10 +18,11 @@ function cleanTemplate(value: string | null | undefined): string | null {
export function applyPerModelConfigToRuntime(config: PerModelConfig): void {
const maxSeqLength = normalizeMaxSeqLength(config.maxSeqLength);
useChatRuntimeStore.setState((state) => ({
...(maxSeqLength == null
? {}
: { params: { ...state.params, maxSeqLength } }),
const store = useChatRuntimeStore.getState();
if (maxSeqLength != null && maxSeqLength !== store.params.maxSeqLength) {
store.setParams({ ...store.params, maxSeqLength });
}
useChatRuntimeStore.setState({
customContextLength: config.customContextLength ?? null,
kvCacheDtype: config.kvCacheDtype ?? null,
speculativeType:
@ -30,7 +31,7 @@ export function applyPerModelConfigToRuntime(config: PerModelConfig): void {
specDraftNMax: config.specDraftNMax ?? null,
tensorParallel: config.tensorParallel ?? false,
chatTemplateOverride: cleanTemplate(config.chatTemplateOverride),
}));
});
}
export function applyModelLoadConfigToRuntime(

View file

@ -53,7 +53,7 @@ const LEGACY_STORAGE_KEY = "unsloth_load_settings";
const LEGACY_MIGRATION_FLAG = "unsloth_model_configs_migrated";
const STORAGE_SCHEMA_VERSION = 1;
const MAX_ENTRIES = 500;
export const MAX_PER_MODEL_CONFIG_STORAGE_BYTES = 1024 * 1024;
const MAX_PER_MODEL_CONFIG_STORAGE_BYTES = 1024 * 1024;
export const MAX_CHAT_TEMPLATE_BYTES = 65_536;
type StoredPerModelConfig = PerModelConfig & {
@ -149,17 +149,6 @@ function deleteOldestEvictableEntry(
return null;
}
function isMostRecentEntry(map: StoredMap, key: string): boolean {
const keys = Object.keys(map);
return keys.length > 0 && keys[keys.length - 1] === key;
}
function touchEntry(map: StoredMap, key: string): void {
const value = map[key];
delete map[key];
map[key] = value;
}
function enforceStorageBudget(map: StoredMap, protectedKey?: string): boolean {
let entryCount = Object.keys(map).length;
while (entryCount > MAX_ENTRIES) {
@ -255,9 +244,12 @@ function migrateLegacyLoadSettingsOnce(): void {
if (localStorage.getItem(LEGACY_MIGRATION_FLAG)) {
return;
}
const legacy = JSON.parse(
localStorage.getItem(LEGACY_STORAGE_KEY) ?? "null",
);
let legacy: unknown = null;
try {
legacy = JSON.parse(localStorage.getItem(LEGACY_STORAGE_KEY) ?? "null");
} catch {
legacy = null;
}
if (!legacy || typeof legacy !== "object" || Array.isArray(legacy)) {
localStorage.setItem(LEGACY_MIGRATION_FLAG, "1");
return;
@ -270,8 +262,6 @@ function migrateLegacyLoadSettingsOnce(): void {
enforceStorageBudget(map);
if (writeMap(map)) {
localStorage.setItem(LEGACY_MIGRATION_FLAG, "1");
} else {
legacyMigrationChecked = false;
}
} catch (err) {
console.warn("Failed to migrate legacy load settings:", err);
@ -472,36 +462,13 @@ function deleteConfigEntriesForModelVariant(
return changed;
}
function loadPerModelConfigInternal(
function loadPerModelConfig(
modelId: string,
ggufVariant: string | null | undefined,
touch: boolean,
ggufVariant?: string | null,
): PerModelConfig | null {
const map = readMap();
const key = findConfigKeyForModelVariant(map, modelId, ggufVariant);
if (!key) {
return null;
}
const config = normalize(map[key]);
if (touch && !isMostRecentEntry(map, key)) {
touchEntry(map, key);
writeMap(map);
}
return config;
}
export function loadPerModelConfig(
modelId: string,
ggufVariant?: string | null,
): PerModelConfig | null {
return loadPerModelConfigInternal(modelId, ggufVariant, true);
}
export function hasPerModelConfig(
modelId: string,
ggufVariant?: string | null,
): boolean {
return loadPerModelConfigInternal(modelId, ggufVariant, false) != null;
return key ? normalize(map[key]) : null;
}
export function isDefaultConfig(config: PerModelConfig): boolean {

View file

@ -24,8 +24,8 @@ export { HfDatasetSubsetSplitSelectors } from "./components/hf-dataset-subset-sp
export { useDatasetPreviewDialogStore } from "./stores/dataset-preview-dialog-store";
export { listLocalDatasets, uploadTrainingDataset } from "./api/datasets-api";
export type { LocalDatasetInfo } from "./types/datasets";
export { listLocalModels } from "./api/models-api";
export type { LocalModelInfo } from "./api/models-api";
export { getModelConfig, listLocalModels } from "./api/models-api";
export type { LocalModelInfo, ModelConfigResponse } from "./api/models-api";
export type {
TrainingPhase,
TrainingViewData,