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:
parent
b5cada1028
commit
79e8c7356e
10 changed files with 391 additions and 91 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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]]:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
() => ({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue