studio: harden per-model settings against review findings and fuzzing

Review findings (PR #7473):
- Look up overrides under the concrete load path with its quant, not just the
  advertised repo id, so local folders and non-active HF caches are found.
- Carry bare-repo launch flags into the first per-quant save. Auto-switch
  prefers the qualified entry, so without this the flags were silently dropped
  and no UI could show or restore them. The bare id is only derived when the
  suffix looks like a quant, so a Windows drive letter is not split.
- Drop a saved gpu_ids pin that no longer resolves instead of 400ing the whole
  load. A pin outlives the machine it was made on.
- Build the displayed API base from getApiBase() on desktop; the Tauri webview
  origin is not the API server.
- Keep a partial download's isDownloaded when opening settings, so the loader
  still reports download progress.
- Only prefer the loaded quant when the loaded model is this row. Q4_K_M exists
  in most repos, so an unguarded match targeted the wrong variant.

Found by simulation:
- A lone surrogate in a chat template raised UnicodeEncodeError on the byte
  check, an unhandled 500. Now a validation error, in all three call sites.
- _bounded_int accepted bools as GPU ids, truncated fractional floats, and
  raised OverflowError on Infinity, which json.loads accepts.
- api_monitor stored a non-string model verbatim; the monitor page then threw
  on toLowerCase and rendered nothing. Coerced at the boundary and the filter
  no longer trusts network data.
- The overlay store now uses storage that cannot throw. Safari private mode and
  blocked-cookie origins make localStorage throw on access, which broke the
  opt-out toggle.
This commit is contained in:
Unsloth 2026-07-26 07:35:11 -07:00
commit 79e8c7356e
10 changed files with 391 additions and 91 deletions

View file

@ -113,7 +113,9 @@ class ApiMonitor:
id = f"apireq_{uuid.uuid4().hex[:12]}",
endpoint = endpoint,
method = method,
model = model or "default",
# str(): a raw JSON body can carry any type here, and the field is
# rendered in the UI, where a non-string breaks the whole monitor.
model = str(model) if model else "default",
prompt = _trim(prompt, _MAX_PROMPT_CHARS),
status = "running",
started_at = now,

View file

@ -11,13 +11,29 @@ from pydantic import BaseModel, Field, field_validator
MAX_CHAT_TEMPLATE_BYTES = 65_536
def chat_template_byte_length(value: str) -> Optional[int]:
"""UTF-8 length, or None if the string cannot be encoded at all.
JSON can carry an unpaired surrogate, as a truncated emoji paste produces.
json decodes it fine and .encode("utf-8") then raises. Callers treat None as
"reject": such a template can never render.
"""
try:
return len(value.encode("utf-8"))
except UnicodeEncodeError:
return None
class ValidateChatTemplateRequest(BaseModel):
template: str = Field(default = "")
@field_validator("template")
@classmethod
def _enforce_template_size(cls, value: str) -> str:
if len(value.encode("utf-8")) > MAX_CHAT_TEMPLATE_BYTES:
size = chat_template_byte_length(value)
if size is None:
raise ValueError("Chat template contains unpaired surrogate characters.")
if size > MAX_CHAT_TEMPLATE_BYTES:
raise ValueError(f"Chat template exceeds the {MAX_CHAT_TEMPLATE_BYTES}-byte limit.")
return value

View file

@ -3722,12 +3722,13 @@ async def _maybe_auto_switch_model(
# speculative decoding, chat template and GPU placement, not
# just the two legacy flags. Look the config up under the
# variant-qualified id first (two quants of one repo can carry
# different configs), then the bare advertised id, then the
# concrete load path, so a config saved under any of the names
# this model is known by is found.
# different configs), then the bare ids. Both the advertised
# repo id and the concrete load path are tried: a local folder
# or a non-active HF cache is configured against its path.
override = {}
for override_key in (
f"{override_id}:{variant}" if variant else None,
f"{target_id}:{variant}" if variant else None,
override_id,
target_id,
):
@ -3745,6 +3746,19 @@ async def _maybe_auto_switch_model(
is_gguf = bool(variant) or target_id.lower().endswith(".gguf"),
)
)
saved_gpu_ids = load_kwargs.get("gpu_ids")
if saved_gpu_ids and not _override_gpu_ids_still_resolve(
saved_gpu_ids
):
# A pin saved before a GPU was removed, before a
# visibility-mask change, or on another host. Dropping the
# one dead field beats 400ing the whole load.
load_kwargs.pop("gpu_ids", None)
logger.warning(
"Dropping saved gpu_ids %s for %s: not available here.",
saved_gpu_ids,
override_id,
)
# Reuse the load impl so its dedup, tensor fallback, and threading
# apply. Call the impl directly: we already hold the lifecycle gate
# the /load route would otherwise take, so the route would deadlock.
@ -3965,6 +3979,26 @@ def _classify_diffusion_gguf(config: ModelConfig) -> Optional[bool]:
return True if name_says_diffusion else None
def _override_gpu_ids_still_resolve(gpu_ids: List[int]) -> bool:
"""Whether a per-model GPU pin is usable on this machine right now.
normalize_model_override cannot know the device list, so it stores whatever
was valid where the config was written. This is the load-time reconciliation.
"""
try:
from utils.hardware import DeviceType, get_device
from utils.hardware.hardware import resolve_requested_gpu_ids
is_vulkan = LlamaCppBackend._is_vulkan_backend()
if get_device() == DeviceType.XPU and not is_vulkan:
# gpu_ids is rejected outright on XPU.
return False
resolve_requested_gpu_ids(gpu_ids, is_vulkan = is_vulkan)
return True
except Exception:
return False
async def _resolve_gguf_gpu_ids_for_request(
config: ModelConfig, gpu_ids: Optional[List[int]]
) -> Optional[List[int]]:

View file

@ -34,7 +34,7 @@ from utils.helper_precache_settings import (
helper_model_disabled_by_env,
set_helper_precache_enabled,
)
from picker.schemas import MAX_CHAT_TEMPLATE_BYTES
from picker.schemas import MAX_CHAT_TEMPLATE_BYTES, chat_template_byte_length
from utils.coding_agents import CODING_AGENTS, detect_installed_coding_agents
from utils.openai_auto_switch_settings import (
DEFAULT_AUTO_UNLOAD_KEEP_KV,
@ -166,7 +166,10 @@ class ModelOverridePayload(BaseModel):
# template is accepted or rejected identically on both paths.
if value is None:
return None
if len(value.encode("utf-8")) > MAX_CHAT_TEMPLATE_BYTES:
size = chat_template_byte_length(value)
if size is None:
raise ValueError("Chat template contains unpaired surrogate characters.")
if size > MAX_CHAT_TEMPLATE_BYTES:
raise ValueError(f"Chat template exceeds the {MAX_CHAT_TEMPLATE_BYTES}-byte limit.")
return value
@ -322,6 +325,21 @@ def get_openai_auto_switch_overrides(
return ModelOverridesResponse(overrides = get_model_overrides())
# A quant suffix, as modelOverrideKey builds it: no path separator and short.
# Guards against splitting "C:\\models\\x.gguf", where the colon is a drive letter.
_MAX_VARIANT_SUFFIX_LEN = 64
def _bare_model_id(model_id: str) -> Optional[str]:
"""``repo`` for a ``repo:QUANT`` key, or None when there is no quant suffix."""
head, sep, tail = model_id.rpartition(":")
if not sep or not head or not tail:
return None
if len(tail) > _MAX_VARIANT_SUFFIX_LEN or "/" in tail or "\\" in tail:
return None
return head
@router.put("/openai-auto-switch/overrides", response_model = ModelOverridesResponse)
def update_openai_auto_switch_override(
payload: ModelOverridePayload, current_subject: str = Depends(get_current_subject)
@ -343,6 +361,13 @@ def update_openai_auto_switch_override(
}
if requested_extra_args is None and not is_removal:
requested_extra_args = get_model_override(payload.model_id).get("llama_extra_args")
if requested_extra_args is None:
# First per-quant save for a model whose flags were stored under the
# bare repo id. Auto-switch prefers the qualified entry, so without
# this the flags are silently dropped and no UI can restore them.
bare_id = _bare_model_id(payload.model_id)
if bare_id:
requested_extra_args = get_model_override(bare_id).get("llama_extra_args")
extra_args = validate_extra_args(requested_extra_args)
set_model_override(
payload.model_id,

View file

@ -3982,3 +3982,158 @@ def test_override_route_preserves_launch_flags_across_a_settings_only_update(mon
"tester",
)
assert "unsloth/B-GGUF" not in gone.overrides
def test_override_found_under_a_concrete_path_with_variant(monkeypatch):
# A local folder or non-active HF cache resolves to a public repo id plus a
# concrete path. Settings saved against the path must still be found.
backend = _FakeBackend(None)
rec = _LoadRecorder(backend)
_wire(
monkeypatch,
enabled = True,
resolves_to = ("/models/local/Qwen3-8B-Q4_K_M.gguf", "Q4_K_M", "unsloth/Qwen3-8B-GGUF"),
backend = backend,
recorder = rec,
)
stored = {"/models/local/Qwen3-8B-Q4_K_M.gguf:Q4_K_M": {"max_seq_length": 8192}}
monkeypatch.setattr(settings, "get_model_override", lambda mid: stored.get(mid, {}))
_run_hook("unsloth/Qwen3-8B-GGUF")
assert rec.calls[0].max_seq_length == 8192
def test_repo_qualified_override_beats_path_qualified(monkeypatch):
# Ordering is most specific first, and the public repo id is the name the
# user configured against in the picker.
backend = _FakeBackend(None)
rec = _LoadRecorder(backend)
_wire(
monkeypatch,
enabled = True,
resolves_to = ("/models/local/x.gguf", "Q4_K_M", "unsloth/B-GGUF"),
backend = backend,
recorder = rec,
)
stored = {
"unsloth/B-GGUF:Q4_K_M": {"max_seq_length": 8192},
"/models/local/x.gguf:Q4_K_M": {"max_seq_length": 1024},
}
monkeypatch.setattr(settings, "get_model_override", lambda mid: stored.get(mid, {}))
_run_hook("unsloth/B-GGUF")
assert rec.calls[0].max_seq_length == 8192
def test_first_quant_save_keeps_legacy_bare_repo_launch_flags(monkeypatch):
# Flags were stored under the bare repo id before per-quant settings existed.
# The first save from the settings page writes repo:QUANT, and auto-switch
# then prefers that entry, so the flags must come with it or they are
# silently disabled with no UI able to show or restore them.
import routes.settings as settings_route
_mock_override_store(monkeypatch)
settings.set_model_override("unsloth/B-GGUF", llama_extra_args = ["--flash-attn"])
resp = settings_route.update_openai_auto_switch_override(
settings_route.ModelOverridePayload(
model_id = "unsloth/B-GGUF:Q4_K_M", max_seq_length = 4096
),
"tester",
)
entry = resp.overrides["unsloth/B-GGUF:Q4_K_M"]
assert entry["max_seq_length"] == 4096
assert entry["llama_extra_args"] == ["--flash-attn"]
def test_bare_repo_carry_over_does_not_split_a_windows_path(monkeypatch):
# "C:\models\x.gguf" has a colon that is not a variant separator. Splitting
# naively would look up "C" and, worse, could graft another model's flags on.
import routes.settings as settings_route
_mock_override_store(monkeypatch)
settings.set_model_override("C", llama_extra_args = ["--flash-attn"])
resp = settings_route.update_openai_auto_switch_override(
settings_route.ModelOverridePayload(
model_id = r"C:\models\x.gguf", max_seq_length = 4096
),
"tester",
)
assert "llama_extra_args" not in resp.overrides[r"C:\models\x.gguf"]
def test_windows_path_with_quant_still_carries_over(monkeypatch):
import routes.settings as settings_route
_mock_override_store(monkeypatch)
settings.set_model_override(r"C:\models\x.gguf", llama_extra_args = ["--flash-attn"])
resp = settings_route.update_openai_auto_switch_override(
settings_route.ModelOverridePayload(
model_id = r"C:\models\x.gguf:Q4_K_M", max_seq_length = 4096
),
"tester",
)
assert resp.overrides[r"C:\models\x.gguf:Q4_K_M"]["llama_extra_args"] == ["--flash-attn"]
def test_stale_gpu_ids_are_dropped_not_fatal(monkeypatch):
# A pin saved on a two-GPU box, replayed on a one-GPU box. Before this the
# whole load 400d; the contract is that one dead field degrades to defaults.
backend = _FakeBackend(None)
rec = _LoadRecorder(backend)
_wire(
monkeypatch,
enabled = True,
resolves_to = ("unsloth/B-GGUF", "Q4_K_M", "unsloth/B-GGUF"),
backend = backend,
recorder = rec,
)
monkeypatch.setattr(
settings,
"get_model_override",
lambda mid: {"gpu_ids": [0, 1], "max_seq_length": 4096},
)
monkeypatch.setattr(
inference_route, "_override_gpu_ids_still_resolve", lambda ids: False
)
_run_hook("unsloth/B-GGUF")
req = rec.calls[0]
assert not req.gpu_ids
# The rest of the config still applies.
assert req.max_seq_length == 4096
def test_usable_gpu_ids_are_kept(monkeypatch):
backend = _FakeBackend(None)
rec = _LoadRecorder(backend)
_wire(
monkeypatch,
enabled = True,
resolves_to = ("unsloth/B-GGUF", "Q4_K_M", "unsloth/B-GGUF"),
backend = backend,
recorder = rec,
)
monkeypatch.setattr(
settings, "get_model_override", lambda mid: {"gpu_ids": [0, 1]}
)
monkeypatch.setattr(
inference_route, "_override_gpu_ids_still_resolve", lambda ids: True
)
_run_hook("unsloth/B-GGUF")
assert rec.calls[0].gpu_ids == [0, 1]
def test_override_gpu_ids_probe_never_raises(monkeypatch):
# The probe runs on the load path, so any hardware error must read as
# "unusable" rather than escaping as a 500.
import utils.hardware.hardware as hw
def boom(*args, **kwargs):
raise RuntimeError("driver exploded")
monkeypatch.setattr(hw, "resolve_requested_gpu_ids", boom)
assert inference_route._override_gpu_ids_still_resolve([0]) is False

View file

@ -266,9 +266,18 @@ def _clean_str(value: Any, allowed: frozenset[str]) -> Optional[str]:
def _bounded_int(value: Any, *, minimum: int, maximum: int) -> Optional[int]:
# bool is a subclass of int, so `gpu_ids: [true, false]` would otherwise pin
# the model to GPUs 1 and 0.
if isinstance(value, bool):
return None
# int(1.5) is 1, which would silently turn a fractional context into a
# useless one. Only exact integers count.
if isinstance(value, float) and not value.is_integer():
return None
try:
parsed = int(value)
except (TypeError, ValueError):
except (TypeError, ValueError, OverflowError):
# OverflowError is float("inf"), which json.loads accepts as `Infinity`.
return None
if parsed < minimum or parsed > maximum:
return None
@ -315,7 +324,13 @@ def normalize_model_override(payload: dict[str, Any]) -> dict[str, Any]:
template = payload.get("chat_template_override")
if isinstance(template, str) and template.strip():
if len(template.encode("utf-8")) <= MAX_CHAT_TEMPLATE_OVERRIDE_BYTES:
# JSON can carry lone surrogates, which encode() rejects outright. Such a
# template can never render, so it is dropped like any other bad field.
try:
template_bytes = len(template.encode("utf-8"))
except UnicodeEncodeError:
template_bytes = MAX_CHAT_TEMPLATE_OVERRIDE_BYTES + 1
if template_bytes <= MAX_CHAT_TEMPLATE_OVERRIDE_BYTES:
entry["chat_template_override"] = template
# Only "manual" is a real override: persisting "auto" would pin the model and

View file

@ -18,8 +18,10 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { usePlatformStore } from "@/config/env";
import type { ApiMonitorEntry } from "@/features/chat/types/api";
import { useSettingsDialogStore } from "@/features/settings";
import { getApiBase, isTauri } from "@/lib/api-base";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { Tick02Icon } from "@/lib/tick-icon";
import { cn } from "@/lib/utils";
@ -462,6 +464,7 @@ export function ApiMonitorPage(): ReactElement {
loadingDetails,
requestDetail,
} = useApiMonitor();
const serverUrl = usePlatformStore((s) => s.serverUrl);
const [statusFilter, setStatusFilter] = useState<MonitorStatusFilter>("all");
const [query, setQuery] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
@ -497,8 +500,10 @@ export function ApiMonitorPage(): ReactElement {
requestDetail(selectedId_);
}, [selectedId_, selectedUpdatedAt, selectedIsMissing, requestDetail]);
const baseUrl =
typeof window === "undefined" ? "" : `${window.location.origin}/v1`;
// The desktop webview's origin is tauri://, not the API server, and the
// packaged app picks its port dynamically. Same source as the Agents tab.
const origin = typeof window === "undefined" ? "" : window.location.origin;
const baseUrl = `${isTauri ? (serverUrl ?? getApiBase()) : origin}/v1`;
const serverStatus = data?.status ?? "idle";
const statusCopy =
serverStatus === "generating"

View file

@ -2,7 +2,39 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { createJSONStorage, persist } from "zustand/middleware";
/**
* localStorage that cannot throw.
*
* Safari private browsing, Firefox with the origin's cookies blocked, and an
* opaque origin in a webview all make `window.localStorage` throw on access
* rather than return null. Losing the preference there is fine; taking the
* whole panel down with it is not.
*/
const safeStorage = {
getItem: (name: string): string | null => {
try {
return window.localStorage.getItem(name);
} catch {
return null;
}
},
setItem: (name: string, value: string): void => {
try {
window.localStorage.setItem(name, value);
} catch {
// Quota exceeded or storage denied. The preference stays session-only.
}
},
removeItem: (name: string): void => {
try {
window.localStorage.removeItem(name);
} catch {
// Same.
}
},
};
interface ApiMonitorOverlayState {
/** Whether the floating panel is on screen right now. Session state. */
@ -33,7 +65,11 @@ export const useApiMonitorOverlayStore = create<ApiMonitorOverlayState>()(
{
name: "unsloth_api_monitor_overlay",
version: 1,
storage: createJSONStorage(() => safeStorage),
partialize: (state) => ({ autoOpen: state.autoOpen }),
// Without this a version bump discards the payload, quietly handing the
// popup back to someone who had turned it off.
migrate: (persisted) => persisted,
// Explicit merge so an older stored payload cannot resurrect `isOpen`.
merge: (persisted, current) => ({
...current,

View file

@ -136,12 +136,19 @@ export function filterEntries(
}
// Search the fields a debugging session actually keys off: which model,
// which endpoint, and the previews/error text visible in the row.
return (
entry.model.toLowerCase().includes(needle) ||
entry.endpoint.toLowerCase().includes(needle) ||
entry.prompt_preview.toLowerCase().includes(needle) ||
entry.reply_preview.toLowerCase().includes(needle) ||
(entry.error ?? "").toLowerCase().includes(needle)
//
// Coerced, not trusted: these arrive over the network, and one malformed
// entry throwing here would blank the whole log.
return [
entry.model,
entry.endpoint,
entry.prompt_preview,
entry.reply_preview,
entry.error,
].some((field) =>
String(field ?? "")
.toLowerCase()
.includes(needle),
);
});
}

View file

@ -2,10 +2,6 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { usePlatformStore } from "@/config/env";
import {
isChannelEntryFresh,
useHubFeedStore,
} from "./stores/hub-feed-store";
import {
getInferenceStatus,
isExternalModelId,
@ -13,25 +9,17 @@ import {
useChatModelRuntime,
useChatRuntimeStore,
} from "@/features/chat";
import { useHubInventory } from "./inventory";
import type {
HfModelSearchChannel,
HfSortDirection,
HfSortKey,
} from "./hooks/use-hub-model-search";
import { useOnlineStatus } from "@/features/hub";
import { useHubInfiniteScroll } from "@/features/hub";
import { ggufVariantsMatch, modelIdsMatch } from "./lib/model-identity";
import { hfApiToken, useHfTokenStore } from "./stores/hf-token-store";
import {
type ModelPickTarget,
type PerModelConfig,
applyModelLoadConfigToRuntime,
applyPerModelConfigToRuntime,
currentRuntimePerModelConfig,
hfModelFitsDevice,
resolveInitialConfig,
useActiveModelConfig,
type ModelPickTarget,
type PerModelConfig,
} from "@/features/model-picker";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useGpuInfo } from "@/hooks/use-gpu-info";
@ -47,8 +35,8 @@ import {
} from "react";
import { ExternalLinkConfirmDialog } from "./catalog/external-link-confirm-dialog";
import { HubDetailView } from "./catalog/hub-detail-view";
import { HubModelSettingsView } from "./catalog/hub-model-settings-view";
import { HubFeed } from "./catalog/hub-feed";
import { HubModelSettingsView } from "./catalog/hub-model-settings-view";
import { HubTopBar } from "./catalog/hub-top-bar";
import {
ModelsCatalog,
@ -72,8 +60,14 @@ import { useDiscoverSearch } from "./hooks/use-discover-search";
import { useFeedWriteBack } from "./hooks/use-feed-write-back";
import { useHiddenEmbeddingModelIds } from "./hooks/use-hidden-embedding-models";
import { useHubFeed } from "./hooks/use-hub-feed";
import type {
HfModelSearchChannel,
HfSortDirection,
HfSortKey,
} from "./hooks/use-hub-model-search";
import { useHubModelVram } from "./hooks/use-hub-model-vram";
import { useModelsSelection } from "./hooks/use-models-selection";
import { useHubInventory } from "./inventory";
import {
CHANNEL_TO_SECTION,
type ChannelId,
@ -88,6 +82,7 @@ import {
isHiddenModelId,
} from "./lib/hidden-models";
import { inventoryRowMatches, tokenizeQuery } from "./lib/inventory-search";
import { ggufVariantsMatch, modelIdsMatch } from "./lib/model-identity";
import {
type ModelTypeFilter,
matchesModelType,
@ -101,6 +96,8 @@ import {
matchesCapability,
matchesFormat,
} from "./lib/view-models";
import { hfApiToken, useHfTokenStore } from "./stores/hf-token-store";
import { isChannelEntryFresh, useHubFeedStore } from "./stores/hub-feed-store";
import type {
CachedInventoryRow,
CapabilityFilter,
@ -669,11 +666,7 @@ export function ModelsPage() {
const visibleResults =
results.length === 0 &&
liveListChannel &&
isChannelEntryFresh(
cachedListEntry,
liveListChannel.id,
tokenFingerprint,
)
isChannelEntryFresh(cachedListEntry, liveListChannel.id, tokenFingerprint)
? (cachedListEntry?.results ?? results)
: results;
@ -1237,6 +1230,15 @@ export function ModelsPage() {
async (row: CachedInventoryRow | LocalInventoryRow) => {
// loadId is what the loader accepts; repoId is only a display/API alias.
const id = row.loadId;
// Whether the loaded model is this row, under any of the names it goes by.
// Gates the "prefer the loaded quant" hint below.
const rowAliases =
row.kind === "local"
? [id, row.repoId, row.path]
: [id, row.repoId, row.cachePath];
const rowIsActive = rowAliases.some((alias) =>
modelIdsMatch(alias, activeCheckpoint),
);
// Cached repo rows never carry a quant: the inventory emits one row per
// repo with format_variant null (see cache_inventory.py). Opening settings
// with a null variant would key the saved config to `repo::` while the
@ -1250,15 +1252,20 @@ export function ModelsPage() {
try {
const res = await listGgufVariants(repoId, hfApiToken(hfToken), {
preferLocalCache: true,
localPath: row.kind === "local" ? row.path : (row.cachePath ?? null),
localPath:
row.kind === "local" ? row.path : (row.cachePath ?? null),
});
const downloaded = res.variants.filter((v) => v.downloaded);
ggufVariant =
// Prefer the loaded quant, then the repo default, then whatever is
// on disk, mirroring LocalOnDeviceCard's selectedQuant.
downloaded.find((v) =>
ggufVariantsMatch(v.quant, activeGgufVariant),
)?.quant ??
// on disk, mirroring LocalOnDeviceCard's selectedQuant. Only when
// this row is the loaded model: Q4_K_M exists in most repos, so an
// unguarded match would target the wrong quant of the wrong model.
(rowIsActive
? downloaded.find((v) =>
ggufVariantsMatch(v.quant, activeGgufVariant),
)?.quant
: undefined) ??
downloaded.find((v) =>
ggufVariantsMatch(v.quant, res.default_variant),
)?.quant ??
@ -1291,7 +1298,7 @@ export function ModelsPage() {
},
});
},
[activeGgufVariant, hfToken],
[activeCheckpoint, activeGgufVariant, hfToken],
);
// Applying from the settings page loads the model with exactly those settings.
// ModelConfigPage has already persisted them (locally and, when "remember" is
@ -1310,7 +1317,9 @@ export function ModelsPage() {
source: "local",
ggufVariant: target.ggufVariant ?? undefined,
isGguf: target.isGguf,
isDownloaded: true,
// A partial row opens settings too; claiming complete would skip the
// loader's download-progress reporting.
isDownloaded: target.meta.isDownloaded,
isLora: target.meta.isLora,
keepSpeculative: true,
forceReload: true,
@ -1424,45 +1433,13 @@ export function ModelsPage() {
],
);
const catalogState = useMemo<ModelsCatalogState>(
() => {
const typeFilterActive =
!isDatasetMode && inventoryTypeFilter !== "all";
return {
tab,
discoverRows: listRows,
cachedRows: filteredCachedRows,
localRows: filteredLocalRows,
selectedId,
isLoading,
downloadedReady,
inventoryError,
inventoryWarning,
query,
activeCheckpoint,
activeGgufVariant,
searchError,
online,
isDataset: isDatasetMode,
inventoryTokens,
scannedCount,
loadingIntentCount: discoverFetchIntent,
hasMore,
manualFetchAvailable: discoverManualFetchAvailable,
hasActiveFilters:
!isFeedMode &&
(deferredFormatFilter !== "all" ||
deferredCapabilityFilter !== "all" ||
(tab === "downloaded" && typeFilterActive)),
typeFilterActive,
};
},
[
const catalogState = useMemo<ModelsCatalogState>(() => {
const typeFilterActive = !isDatasetMode && inventoryTypeFilter !== "all";
return {
tab,
isFeedMode,
listRows,
filteredCachedRows,
filteredLocalRows,
discoverRows: listRows,
cachedRows: filteredCachedRows,
localRows: filteredLocalRows,
selectedId,
isLoading,
downloadedReady,
@ -1473,17 +1450,45 @@ export function ModelsPage() {
activeGgufVariant,
searchError,
online,
isDatasetMode,
isDataset: isDatasetMode,
inventoryTokens,
scannedCount,
discoverFetchIntent,
loadingIntentCount: discoverFetchIntent,
hasMore,
discoverManualFetchAvailable,
deferredFormatFilter,
deferredCapabilityFilter,
inventoryTypeFilter,
],
);
manualFetchAvailable: discoverManualFetchAvailable,
hasActiveFilters:
!isFeedMode &&
(deferredFormatFilter !== "all" ||
deferredCapabilityFilter !== "all" ||
(tab === "downloaded" && typeFilterActive)),
typeFilterActive,
};
}, [
tab,
isFeedMode,
listRows,
filteredCachedRows,
filteredLocalRows,
selectedId,
isLoading,
downloadedReady,
inventoryError,
inventoryWarning,
query,
activeCheckpoint,
activeGgufVariant,
searchError,
online,
isDatasetMode,
inventoryTokens,
scannedCount,
discoverFetchIntent,
hasMore,
discoverManualFetchAvailable,
deferredFormatFilter,
deferredCapabilityFilter,
inventoryTypeFilter,
]);
const catalogPagination = useMemo<ModelsCatalogPagination>(
() => ({