diff --git a/studio/backend/core/inference/api_monitor.py b/studio/backend/core/inference/api_monitor.py index 670f370003..e4aad4ade2 100644 --- a/studio/backend/core/inference/api_monitor.py +++ b/studio/backend/core/inference/api_monitor.py @@ -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, diff --git a/studio/backend/picker/schemas.py b/studio/backend/picker/schemas.py index b4f956188f..c43d769817 100644 --- a/studio/backend/picker/schemas.py +++ b/studio/backend/picker/schemas.py @@ -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 diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 4bb0edcceb..eda92b21f1 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -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]]: diff --git a/studio/backend/routes/settings.py b/studio/backend/routes/settings.py index 2dd0cabbe7..07a751cf61 100644 --- a/studio/backend/routes/settings.py +++ b/studio/backend/routes/settings.py @@ -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, diff --git a/studio/backend/tests/test_openai_auto_switch.py b/studio/backend/tests/test_openai_auto_switch.py index d1173585b0..e6ce241d79 100644 --- a/studio/backend/tests/test_openai_auto_switch.py +++ b/studio/backend/tests/test_openai_auto_switch.py @@ -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 diff --git a/studio/backend/utils/openai_auto_switch_settings.py b/studio/backend/utils/openai_auto_switch_settings.py index 7f6b64eca4..ba63c47212 100644 --- a/studio/backend/utils/openai_auto_switch_settings.py +++ b/studio/backend/utils/openai_auto_switch_settings.py @@ -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 diff --git a/studio/frontend/src/features/api-monitor/api-monitor-page.tsx b/studio/frontend/src/features/api-monitor/api-monitor-page.tsx index fa5934ab5d..50b27672c5 100644 --- a/studio/frontend/src/features/api-monitor/api-monitor-page.tsx +++ b/studio/frontend/src/features/api-monitor/api-monitor-page.tsx @@ -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("all"); const [query, setQuery] = useState(""); const [selectedId, setSelectedId] = useState(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" diff --git a/studio/frontend/src/features/api-monitor/overlay-store.ts b/studio/frontend/src/features/api-monitor/overlay-store.ts index 07ad4edd31..a2d1bd7631 100644 --- a/studio/frontend/src/features/api-monitor/overlay-store.ts +++ b/studio/frontend/src/features/api-monitor/overlay-store.ts @@ -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()( { 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, diff --git a/studio/frontend/src/features/api-monitor/use-api-monitor.ts b/studio/frontend/src/features/api-monitor/use-api-monitor.ts index 20c3915ee3..84be9e3084 100644 --- a/studio/frontend/src/features/api-monitor/use-api-monitor.ts +++ b/studio/frontend/src/features/api-monitor/use-api-monitor.ts @@ -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), ); }); } diff --git a/studio/frontend/src/features/hub/hub-page.tsx b/studio/frontend/src/features/hub/hub-page.tsx index e853ea0e5c..8175774689 100644 --- a/studio/frontend/src/features/hub/hub-page.tsx +++ b/studio/frontend/src/features/hub/hub-page.tsx @@ -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( - () => { - 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(() => { + 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( () => ({