diff --git a/.github/workflows/studio-ui-smoke.yml b/.github/workflows/studio-ui-smoke.yml
index 30280c281e..0ad55ebd6d 100644
--- a/.github/workflows/studio-ui-smoke.yml
+++ b/.github/workflows/studio-ui-smoke.yml
@@ -237,6 +237,54 @@ jobs:
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
sleep 2
+ # Model-picker per-model-config regression (PR #7207 re-land of #6647).
+ # Fourth Unsloth on its own port; loads the tiny GGUF and drives the
+ # picker's run-settings surface: Context Length persists across a reload,
+ # Reset clears the stored override (never pins it), and the infra models
+ # (RAG embedder + llama.cpp probe) stay hidden from the picker.
+ - name: Reset auth + boot Unsloth for model-config tests (port 18898)
+ run: |
+ unsloth studio reset-password
+ mkdir -p logs
+ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18898 \
+ > logs/studio_modelcfg.log 2>&1 &
+ echo "STUDIO_MODELCFG_PID=$!" >> "$GITHUB_ENV"
+
+ - name: Wait for /api/health on 18898
+ run: |
+ for i in $(seq 1 180); do
+ if curl -fs "http://127.0.0.1:18898/api/health" > /tmp/health4.json; then
+ jq -e '.status == "healthy"' /tmp/health4.json && break
+ fi
+ sleep 1
+ done
+ jq -e '.status == "healthy"' /tmp/health4.json
+
+ - name: Pass bootstrap pw for model-config test
+ run: |
+ NEW="CIModelCfg-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
+ echo "::add-mask::$NEW"
+ echo "STUDIO_MODELCFG_NEW_PW=$NEW" >> "$GITHUB_ENV"
+
+ - name: Drive model-picker per-model-config with Playwright
+ env:
+ BASE_URL: http://127.0.0.1:18898
+ STUDIO_NEW_PW: ${{ env.STUDIO_MODELCFG_NEW_PW }}
+ PW_ART_DIR: logs/playwright_modelcfg
+ STUDIO_UI_STRICT: '1'
+ GGUF_REPO: ${{ env.GGUF_REPO }}
+ GGUF_VARIANT: ${{ env.GGUF_VARIANT }}
+ STUDIO_MODEL_HINT: gemma-3-270m
+ run: |
+ mkdir -p logs/playwright_modelcfg
+ python tests/studio/playwright_model_config.py
+
+ - name: Stop fourth Unsloth
+ if: always()
+ run: |
+ kill "${STUDIO_MODELCFG_PID}" 2>/dev/null || true
+ sleep 2
+
# IME + multilingual paste regression (issue #5318 / PR #5327).
# Third Unsloth on its own port so a hang here cannot poison the
# earlier UI tests. No GGUF -- the bug surface is the composer.
@@ -297,12 +345,14 @@ jobs:
path: |
logs/studio.log
logs/studio_extra.log
+ logs/studio_modelcfg.log
logs/studio_ime.log
logs/install.log
logs/server-logs/
logs/playwright
logs/playwright-permissions-*
logs/playwright_extra
+ logs/playwright_modelcfg
logs/playwright_ime
logs/studio-permissions-*.log
retention-days: 7
diff --git a/studio/backend/hub/routes/inventory.py b/studio/backend/hub/routes/inventory.py
index 4b6c179a2b..1ffadf0544 100644
--- a/studio/backend/hub/routes/inventory.py
+++ b/studio/backend/hub/routes/inventory.py
@@ -28,6 +28,7 @@ from hub.schemas.inventory import (
CachedModelsResponse,
DeleteCachedModelResponse,
GgufVariantsResponse,
+ HiddenModelsResponse,
LocalModelListResponse,
ModelsFolderResponse,
RecommendedFoldersResponse,
@@ -214,6 +215,16 @@ async def list_cached_models(
return await cache_inventory.list_cached_models_response(hf_token)
+@router.get("/hidden-models", response_model = HiddenModelsResponse)
+async def list_hidden_models(current_subject: str = Depends(get_current_subject)):
+ import asyncio
+
+ from routes.models import hidden_model_matchers
+
+ needles, exact_ids, exact_paths = await asyncio.to_thread(hidden_model_matchers)
+ return HiddenModelsResponse(needles = needles, exact_ids = exact_ids, exact_paths = exact_paths)
+
+
@router.delete(
"/delete-cached",
response_model = DeleteCachedModelResponse,
diff --git a/studio/backend/hub/schemas/inventory.py b/studio/backend/hub/schemas/inventory.py
index ef95efe2f2..19d6da3e11 100644
--- a/studio/backend/hub/schemas/inventory.py
+++ b/studio/backend/hub/schemas/inventory.py
@@ -160,6 +160,7 @@ class CachedRepoBase(BaseModel):
repo_id: str
size_bytes: int = 0
cache_path: Optional[str] = None
+ last_modified: Optional[float] = None
partial: bool = False
partial_transport: Optional[str] = None
inventory_id: Optional[str] = None
@@ -189,6 +190,12 @@ class CachedModelsResponse(BaseModel):
cached: List[CachedModelRepo] = Field(default_factory = list)
+class HiddenModelsResponse(BaseModel):
+ needles: List[str] = Field(default_factory = list)
+ exact_ids: List[str] = Field(default_factory = list)
+ exact_paths: List[str] = Field(default_factory = list)
+
+
class AddScanFolderRequest(BaseModel):
"""Request body for adding a custom scan folder."""
diff --git a/studio/backend/hub/services/models/cache_inventory.py b/studio/backend/hub/services/models/cache_inventory.py
index 54a25482f2..c1b864bb63 100644
--- a/studio/backend/hub/services/models/cache_inventory.py
+++ b/studio/backend/hub/services/models/cache_inventory.py
@@ -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,
@@ -132,6 +133,39 @@ def _repo_has_gguf_files(repo_info) -> bool:
return _repo_gguf_size_bytes(repo_info) > 0
+def _blob_mtime(file_obj) -> float:
+ ts = getattr(file_obj, "blob_last_modified", None)
+ if isinstance(ts, (int, float)) and ts > 0:
+ return float(ts)
+ blob_path = getattr(file_obj, "blob_path", None)
+ if blob_path:
+ try:
+ return float(Path(blob_path).stat().st_mtime)
+ except OSError:
+ pass
+ return 0.0
+
+
+def _repo_gguf_last_modified(repo_info) -> float:
+ latest = 0.0
+ for revision in repo_info.revisions:
+ for f in revision.files:
+ if _is_main_gguf_filename(f.file_name):
+ latest = max(latest, _blob_mtime(f))
+ return latest
+
+
+def _repo_has_mmproj(repo_info) -> bool:
+ # An mmproj file only makes a repo vision-capable when it is an actual GGUF
+ # projector; a non-GGUF sidecar (e.g. mmproj_config.json) does not, and the
+ # runtime's projector detection is GGUF-only.
+ return any(
+ _is_gguf_filename(f.file_name) and _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:
@@ -291,6 +325,7 @@ def _scan_cached_gguf() -> list[dict]:
continue
key = repo_id.lower()
existing = seen_lower.get(key)
+ last_modified = _repo_gguf_last_modified(repo_info)
row = {
"repo_id": repo_id,
"size_bytes": max(total_size, variant_state_size),
@@ -300,6 +335,9 @@ def _scan_cached_gguf() -> list[dict]:
# per-variant detail lives on GgufVariantDetail.
"partial_transport": None,
}
+ last_modified = max(last_modified, (existing or {}).get("last_modified", 0.0))
+ if last_modified > 0:
+ row["last_modified"] = last_modified
row.update(
_cache_inventory_fields(
repo_id,
@@ -308,11 +346,20 @@ def _scan_cached_gguf() -> list[dict]:
requires_variant = True,
)
)
+ if _repo_has_mmproj(repo_info):
+ row["capabilities"]["supports_vision"] = True
# Visible infra variants remain management-only.
if is_hidden_infra:
row["capabilities"]["can_chat"] = False
if _prefer_cache_row(row, existing):
+ if existing and existing["capabilities"].get("supports_vision"):
+ row["capabilities"]["supports_vision"] = True
seen_lower[key] = row
+ else:
+ if last_modified > existing.get("last_modified", 0.0):
+ existing["last_modified"] = last_modified
+ if row["capabilities"].get("supports_vision"):
+ existing["capabilities"]["supports_vision"] = True
except Exception as e:
repo_label = getattr(repo_info, "repo_id", "")
logger.warning(f"Skipping cached GGUF repo {repo_label}: {e}")
@@ -340,13 +387,14 @@ class _CachedNonGgufPayload(NamedTuple):
size_bytes: int
has_runnable_weights: bool
model_format: ModelFormat
+ last_modified: float
def _repo_non_gguf_model_payload(repo_info) -> _CachedNonGgufPayload:
- all_weight_blobs: dict[str, int] = {}
- adapter_blobs: dict[str, int] = {}
- safetensors_blobs: dict[str, int] = {}
- checkpoint_blobs: dict[str, int] = {}
+ all_weight_blobs: dict[str, tuple[int, float]] = {}
+ adapter_blobs: dict[str, tuple[int, float]] = {}
+ safetensors_blobs: dict[str, tuple[int, float]] = {}
+ checkpoint_blobs: dict[str, tuple[int, float]] = {}
has_config = False
has_adapter_config = False
has_adapter_weights = False
@@ -354,12 +402,15 @@ def _repo_non_gguf_model_payload(repo_info) -> _CachedNonGgufPayload:
has_transformers_safetensors = False
has_checkpoint = False
- def _record_blob(target: dict[str, int], file_obj, rev_id: str, file_name: str) -> None:
+ def _record_blob(
+ target: dict[str, tuple[int, float]], file_obj, rev_id: str, file_name: str
+ ) -> None:
blob_path = getattr(file_obj, "blob_path", None)
size = int(file_obj.size_on_disk or 0)
key = str(blob_path) if blob_path else f"{rev_id}:{file_name}"
- target[key] = size
- all_weight_blobs[key] = size
+ value = (size, _blob_mtime(file_obj))
+ target[key] = value
+ all_weight_blobs[key] = value
for revision in repo_info.revisions:
rev_id = getattr(revision, "commit_hash", None) or str(id(revision))
@@ -403,18 +454,19 @@ def _repo_non_gguf_model_payload(repo_info) -> _CachedNonGgufPayload:
or "unknown"
)
if model_format == "adapter":
- size_bytes = sum(adapter_blobs.values())
+ selected_blobs = adapter_blobs
elif model_format == "safetensors":
- size_bytes = sum(safetensors_blobs.values())
+ selected_blobs = safetensors_blobs
elif model_format == "checkpoint":
- size_bytes = sum(checkpoint_blobs.values())
+ selected_blobs = checkpoint_blobs
else:
- size_bytes = sum(all_weight_blobs.values())
+ selected_blobs = all_weight_blobs
return _CachedNonGgufPayload(
- size_bytes = size_bytes,
+ size_bytes = sum(size for size, _mtime in selected_blobs.values()),
has_runnable_weights = model_format != "unknown",
model_format = model_format,
+ last_modified = max((mtime for _size, mtime in selected_blobs.values()), default = 0.0),
)
@@ -544,6 +596,12 @@ def _scan_cached_models() -> list[dict]:
),
**_cached_model_local_metadata(repo_path),
}
+ last_modified = max(
+ payload.last_modified,
+ (existing or {}).get("last_modified", 0.0),
+ )
+ if last_modified > 0:
+ row["last_modified"] = last_modified
row.update(
_cache_inventory_fields(
repo_id,
@@ -553,6 +611,8 @@ def _scan_cached_models() -> list[dict]:
)
if _prefer_cache_row(row, existing):
seen_lower[key] = row
+ elif last_modified > existing.get("last_modified", 0.0):
+ existing["last_modified"] = last_modified
except Exception as e:
repo_label = getattr(repo_info, "repo_id", "")
logger.warning(f"Skipping cached model repo {repo_label}: {e}")
diff --git a/studio/backend/main.py b/studio/backend/main.py
index 48675b9539..3f244dc22e 100644
--- a/studio/backend/main.py
+++ b/studio/backend/main.py
@@ -315,6 +315,7 @@ from hub.routes import (
datasets_router as hub_datasets_router,
token_router as hub_token_router,
)
+from picker.routes import templates_router as picker_templates_router
from hub.schemas.downloads import TransportCapabilities
from hub.utils.download_registry import (
get_download_transport_capabilities,
@@ -764,6 +765,7 @@ _BODY_PROTECTED_PREFIXES = (
"/v1/completions",
"/p/",
"/api/inference",
+ "/api/picker",
"/api/data-recipe",
"/api/datasets",
"/api/hub",
@@ -995,6 +997,7 @@ app.include_router(rag_router, prefix = "/api/rag", tags = ["rag"])
app.include_router(training_history_router, prefix = "/api/train", tags = ["training-history"])
app.include_router(hub_inventory_router, prefix = "/api/hub", tags = ["hub"])
app.include_router(hub_datasets_router, prefix = "/api/hub/datasets", tags = ["hub"])
+app.include_router(picker_templates_router, prefix = "/api/picker", tags = ["picker"])
app.include_router(hub_token_router, prefix = "/api/hub", tags = ["hub"])
# Re-wrap client-error responses on the /v1/* surface into OpenAI/Anthropic
diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py
index d51d35189b..580a74dddf 100644
--- a/studio/backend/models/inference.py
+++ b/studio/backend/models/inference.py
@@ -18,6 +18,8 @@ from pydantic import (
model_validator,
)
+from picker.schemas import MAX_CHAT_TEMPLATE_BYTES
+
class LoadRequest(BaseModel):
"""Request to load a model for inference"""
@@ -54,8 +56,16 @@ class LoadRequest(BaseModel):
@field_validator("chat_template_override")
@classmethod
def normalize_blank_chat_template_override(cls, value: Optional[str]) -> Optional[str]:
- if value is not None and value.strip() == "":
+ if value is None:
return None
+ # Char count is a lower bound on UTF-8 byte length: reject an oversized
+ # template before spending work encoding it.
+ if len(value) > MAX_CHAT_TEMPLATE_BYTES:
+ raise ValueError(f"Chat template exceeds the {MAX_CHAT_TEMPLATE_BYTES}-byte limit.")
+ if value.strip() == "":
+ return None
+ if len(value.encode("utf-8")) > MAX_CHAT_TEMPLATE_BYTES:
+ raise ValueError(f"Chat template exceeds the {MAX_CHAT_TEMPLATE_BYTES}-byte limit.")
return value
cache_type_kv: Optional[str] = Field(
@@ -206,6 +216,13 @@ class ValidateModelRequest(BaseModel):
description = "Also read the native context length from the local GGUF header. "
"Opt-in so the normal load preflight doesn't pay for a cache scan it doesn't need.",
)
+ include_chat_template: bool = Field(
+ False,
+ description = "Also read the embedded chat template from the local GGUF header, so a "
+ "native (picked / drag-drop) file's default template can be shown before it is loaded. "
+ "Opt-in and, like include_context_length, a metadata-only probe that skips the training "
+ "guard. Only the leased file's own embedded template is read, never sibling sidecars.",
+ )
class TransformersUpgradeInfo(BaseModel):
@@ -266,6 +283,11 @@ class ValidateModelResponse(BaseModel):
description = "MoE expert-layer count (the manual --n-cpu-moe ceiling), read from the GGUF "
"header alongside context_length; 0 for dense models, None when not read.",
)
+ chat_template: Optional[str] = Field(
+ None,
+ description = "Embedded GGUF chat template, read from the header when include_chat_template "
+ "is set (native lease-backed picks); None for non-GGUF, over-cap, or not-read templates.",
+ )
# Additive fields; the consuming consent dialog ships in a follow-up frontend PR.
requires_transformers_upgrade: bool = Field(
False,
diff --git a/studio/backend/picker/__init__.py b/studio/backend/picker/__init__.py
new file mode 100644
index 0000000000..32014236c6
--- /dev/null
+++ b/studio/backend/picker/__init__.py
@@ -0,0 +1,2 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
diff --git a/studio/backend/picker/routes/__init__.py b/studio/backend/picker/routes/__init__.py
new file mode 100644
index 0000000000..c0e988c8bb
--- /dev/null
+++ b/studio/backend/picker/routes/__init__.py
@@ -0,0 +1,6 @@
+# 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 .templates import router as templates_router
+
+__all__ = ["templates_router"]
diff --git a/studio/backend/picker/routes/templates.py b/studio/backend/picker/routes/templates.py
new file mode 100644
index 0000000000..02b8bf7184
--- /dev/null
+++ b/studio/backend/picker/routes/templates.py
@@ -0,0 +1,45 @@
+# 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 __future__ import annotations
+
+import asyncio
+from typing import Optional
+
+from fastapi import APIRouter, Body, Depends, Query
+
+from auth.authentication import get_current_subject
+from hub.dependencies import get_hf_token
+
+from ..schemas import (
+ MAX_CHAT_TEMPLATE_BYTES,
+ ModelTemplateResponse,
+ ValidateChatTemplateRequest,
+ ValidateChatTemplateResponse,
+)
+from ..service import read_default_chat_template, validate_chat_template
+
+router = APIRouter()
+
+
+@router.post("/validate-chat-template", response_model = ValidateChatTemplateResponse)
+async def validate_chat_template_route(
+ body: ValidateChatTemplateRequest = Body(...),
+ current_subject: str = Depends(get_current_subject),
+) -> ValidateChatTemplateResponse:
+ return await asyncio.to_thread(validate_chat_template, body.template)
+
+
+@router.get("/chat-template/{model_name:path}", response_model = ModelTemplateResponse)
+async def get_default_chat_template_route(
+ model_name: str,
+ gguf_variant: Optional[str] = Query(None),
+ hf_token: Optional[str] = Depends(get_hf_token),
+ current_subject: str = Depends(get_current_subject),
+) -> ModelTemplateResponse:
+ template = await asyncio.to_thread(
+ read_default_chat_template, model_name, hf_token, gguf_variant
+ )
+ if template is not None and len(template.encode("utf-8")) > MAX_CHAT_TEMPLATE_BYTES:
+ template = None
+ return ModelTemplateResponse(model_name = model_name, chat_template = template)
diff --git a/studio/backend/picker/schemas.py b/studio/backend/picker/schemas.py
new file mode 100644
index 0000000000..b4f956188f
--- /dev/null
+++ b/studio/backend/picker/schemas.py
@@ -0,0 +1,32 @@
+# 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 typing import Optional
+
+from pydantic import BaseModel, Field, field_validator
+
+# Mirror the frontend's 64 KiB chat-template contract (per-model-config.ts) at
+# the API boundary so a direct caller cannot make Jinja parse an oversized
+# template. MaxBodyMiddleware only caps the whole request body, not this field.
+MAX_CHAT_TEMPLATE_BYTES = 65_536
+
+
+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:
+ raise ValueError(f"Chat template exceeds the {MAX_CHAT_TEMPLATE_BYTES}-byte limit.")
+ return value
+
+
+class ValidateChatTemplateResponse(BaseModel):
+ valid: bool
+ error: Optional[str] = None
+
+
+class ModelTemplateResponse(BaseModel):
+ model_name: str
+ chat_template: Optional[str] = None
diff --git a/studio/backend/picker/service.py b/studio/backend/picker/service.py
new file mode 100644
index 0000000000..13065b2920
--- /dev/null
+++ b/studio/backend/picker/service.py
@@ -0,0 +1,426 @@
+# 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 __future__ import annotations
+
+import json
+import logging
+import os
+import re
+from pathlib import Path
+from typing import Optional
+
+from hub.services.models.folder_browser import (
+ _build_browse_allowlist,
+ _is_path_inside_allowlist,
+)
+from hub.utils.gguf import extract_quant_label, iter_hf_cache_snapshots
+from utils.models.gguf_metadata import read_gguf_chat_template
+from utils.models.model_config import (
+ _extract_quant_label,
+ _is_big_endian_gguf_path,
+ _is_mmproj,
+ _is_mtp_drafter,
+)
+from utils.paths.path_utils import (
+ is_local_path,
+ normalize_path,
+ resolve_cached_repo_id_case,
+)
+
+from .schemas import MAX_CHAT_TEMPLATE_BYTES, ValidateChatTemplateResponse
+
+logger = logging.getLogger(__name__)
+
+_VALID_REPO_ID = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
+
+
+def _is_valid_repo_id(repo_id: str) -> bool:
+ return bool(_VALID_REPO_ID.fullmatch(repo_id))
+
+
+_TOKENIZER_CONFIG_PATHS = ("tokenizer_config.json", "LLM/tokenizer_config.json")
+_JINJA_TEMPLATE_PATHS = ("chat_template.jinja", "LLM/chat_template.jinja")
+_PROCESSOR_TEMPLATE_PATHS = ("chat_template.json", "LLM/chat_template.json")
+
+# Cap sidecar reads so a malformed or hostile metadata file cannot exhaust memory
+# before its template is size-checked. The JSON envelope may exceed a bare template
+# (it carries other tokenizer metadata); the extracted template is still bounded by
+# MAX_CHAT_TEMPLATE_BYTES downstream.
+MAX_TEMPLATE_METADATA_BYTES = 4 * 1024 * 1024
+
+
+def _read_bounded_text(path: Path, limit: int) -> Optional[str]:
+ """Read at most `limit` bytes of UTF-8 text; None if larger or unreadable."""
+ try:
+ with path.open("rb") as f:
+ data = f.read(limit + 1)
+ except OSError:
+ return None
+ if len(data) > limit:
+ return None
+ try:
+ return data.decode("utf-8")
+ except UnicodeError:
+ return None
+
+
+def _leaf_inside_allowlist(path: Path, allow_roots: Optional[list[Path]]) -> bool:
+ # Block symlinked children from escaping the validated directory (realpath-checked).
+ # None = trusted caller (HF cache / remote download).
+ return allow_roots is None or _is_path_inside_allowlist(path, allow_roots)
+
+
+def validate_chat_template(template: str) -> ValidateChatTemplateResponse:
+ text = (template or "").strip()
+ if not text:
+ return ValidateChatTemplateResponse(valid = True, error = None)
+ # Import Jinja lazily: optional at runtime (e.g. GGUF-only installs), so a
+ # missing dependency must not crash API startup.
+ try:
+ from jinja2 import TemplateError
+ from jinja2.ext import Extension
+ from jinja2.sandbox import ImmutableSandboxedEnvironment
+ except ImportError:
+ return ValidateChatTemplateResponse(valid = True, error = None)
+
+ class _GenerationTag(Extension):
+ # Accept Transformers' {% generation %} assistant-mask tag so a pasted HF
+ # chat template validates (we only parse it).
+ tags = {"generation"}
+
+ def parse(self, parser):
+ next(parser.stream)
+ return parser.parse_statements(["name:endgeneration"], drop_needle = True)
+
+ try:
+ env = ImmutableSandboxedEnvironment(
+ trim_blocks = True,
+ lstrip_blocks = True,
+ extensions = ["jinja2.ext.loopcontrols", _GenerationTag],
+ )
+ env.parse(text)
+ return ValidateChatTemplateResponse(valid = True, error = None)
+ except TemplateError as exc:
+ message = getattr(exc, "message", None) or str(exc)
+ lineno = getattr(exc, "lineno", None)
+ if lineno:
+ message = f"Line {lineno}: {message}"
+ return ValidateChatTemplateResponse(valid = False, error = message)
+ except Exception as exc:
+ return ValidateChatTemplateResponse(valid = False, error = str(exc))
+
+
+def _chat_template_from_tokenizer_config(config: dict) -> Optional[str]:
+ if not isinstance(config, dict):
+ return None
+ raw = config.get("chat_template")
+ if isinstance(raw, str) and raw.strip():
+ return raw
+ if isinstance(raw, list):
+ fallback: Optional[str] = None
+ for entry in raw:
+ if not isinstance(entry, dict):
+ continue
+ template = entry.get("template")
+ if not isinstance(template, str):
+ continue
+ if entry.get("name") == "default":
+ return template
+ if fallback is None:
+ fallback = template
+ return fallback
+ return None
+
+
+def _chat_template_from_jinja_file(
+ dir_path: Path, allow_roots: Optional[list[Path]] = None
+) -> Optional[str]:
+ for rel in _JINJA_TEMPLATE_PATHS:
+ template_file = dir_path / rel
+ if not template_file.exists() or not _leaf_inside_allowlist(template_file, allow_roots):
+ continue
+ try:
+ if template_file.stat().st_size > MAX_CHAT_TEMPLATE_BYTES:
+ continue
+ template = template_file.read_text(encoding = "utf-8")
+ except Exception:
+ continue
+ if template.strip():
+ return template
+ return None
+
+
+def _chat_template_from_processor_payload(payload: object) -> Optional[str]:
+ # processor chat_template.json may be the template string itself or a
+ # {name: template} map, not only a tokenizer_config-shaped object.
+ if isinstance(payload, str):
+ return payload if payload.strip() else None
+ template = _chat_template_from_tokenizer_config(payload) # type: ignore[arg-type]
+ if template:
+ return template
+ if isinstance(payload, dict):
+ # Named-template map: prefer "default", else the first non-empty entry
+ # (mirrors the tokenizer-config list fallback).
+ default = payload.get("default")
+ if isinstance(default, str) and default.strip():
+ return default
+ for value in payload.values():
+ if isinstance(value, str) and value.strip():
+ return value
+ return None
+
+
+def _chat_template_from_processor_json(
+ dir_path: Path, allow_roots: Optional[list[Path]] = None
+) -> Optional[str]:
+ for rel in _PROCESSOR_TEMPLATE_PATHS:
+ config_file = dir_path / rel
+ if not config_file.exists() or not _leaf_inside_allowlist(config_file, allow_roots):
+ continue
+ raw = _read_bounded_text(config_file, MAX_TEMPLATE_METADATA_BYTES)
+ if raw is None:
+ continue
+ try:
+ payload = json.loads(raw)
+ except Exception:
+ continue
+ template = _chat_template_from_processor_payload(payload)
+ if template:
+ return template
+ return None
+
+
+def _chat_template_from_tokenizer_dir(
+ dir_path: Path, allow_roots: Optional[list[Path]] = None
+) -> Optional[str]:
+ jinja = _chat_template_from_jinja_file(dir_path, allow_roots)
+ if jinja:
+ return jinja
+ for rel in _TOKENIZER_CONFIG_PATHS:
+ config_file = dir_path / rel
+ if not config_file.exists() or not _leaf_inside_allowlist(config_file, allow_roots):
+ continue
+ raw = _read_bounded_text(config_file, MAX_TEMPLATE_METADATA_BYTES)
+ if raw is None:
+ continue
+ try:
+ config = json.loads(raw)
+ except Exception:
+ continue
+ template = _chat_template_from_tokenizer_config(config)
+ if template:
+ return template
+ return _chat_template_from_processor_json(dir_path, allow_roots)
+
+
+_GGUF_SCAN_MAX_DEPTH = 2
+
+
+def _iter_ggufs(dir_path: Path) -> list[Path]:
+ if dir_path == dir_path.parent:
+ return []
+ root = str(dir_path)
+ found: list[Path] = []
+ for current, dirs, files in os.walk(root, followlinks = False):
+ rel = os.path.relpath(current, root)
+ depth = 0 if rel == os.curdir else rel.count(os.sep) + 1
+ if depth >= _GGUF_SCAN_MAX_DEPTH:
+ dirs[:] = []
+ for name in files:
+ if not name.lower().endswith(".gguf") or _is_mmproj(name):
+ continue
+ path = Path(current) / name
+ try:
+ rel = path.relative_to(dir_path).as_posix()
+ except ValueError:
+ rel = name
+ quant = _extract_quant_label(rel)
+ if _is_mtp_drafter(rel) or _is_big_endian_gguf_path(rel, quant):
+ continue
+ found.append(path)
+ return found
+
+
+def _variant_matches(relative_path: str, needle: str) -> bool:
+ quant = _extract_quant_label(relative_path).lower()
+ if quant == needle:
+ return True
+ if extract_quant_label(relative_path).lower() == needle:
+ return True
+ prefix = f"{needle}-"
+ if not quant.startswith(prefix):
+ return False
+ suffix = quant[len(prefix) :]
+ if not suffix.endswith("bpw"):
+ return False
+ value = suffix[:-3]
+ return bool(value) and value.replace(".", "", 1).isdigit()
+
+
+_GGUF_SPLIT_INDEX_RE = re.compile(r"-(\d{3,})-of-\d{3,}$", re.IGNORECASE)
+
+
+def _is_nonfirst_gguf_split(path: Path) -> bool:
+ match = _GGUF_SPLIT_INDEX_RE.search(path.stem)
+ return match is not None and int(match.group(1)) != 1
+
+
+def _find_gguf_in_dir(dir_path: Path, gguf_variant: Optional[str]) -> Optional[Path]:
+ try:
+ ggufs = sorted(_iter_ggufs(dir_path))
+ except OSError:
+ return None
+ if not ggufs:
+ return None
+ needle = (gguf_variant or "").strip().lower()
+ if needle:
+ for path in ggufs:
+ try:
+ relative = path.relative_to(dir_path).as_posix()
+ except ValueError:
+ relative = path.name
+ if _variant_matches(relative, needle):
+ return path
+ return None
+ candidates = [path for path in ggufs if not _is_nonfirst_gguf_split(path)] or ggufs
+ try:
+ return max(candidates, key = lambda path: path.stat().st_size)
+ except OSError:
+ return candidates[0]
+
+
+def _chat_template_from_dir(
+ dir_path: Path,
+ gguf_variant: Optional[str] = None,
+ allow_roots: Optional[list[Path]] = None,
+) -> Optional[str]:
+ def from_gguf() -> Optional[str]:
+ gguf = _find_gguf_in_dir(dir_path, gguf_variant)
+ if gguf is None or not _leaf_inside_allowlist(gguf, allow_roots):
+ return None
+ return read_gguf_chat_template(str(gguf))
+
+ # Sidecar tokenizer files (chat_template.jinja / tokenizer_config.json) are the
+ # author's maintained template and supersede the GGUF's possibly-stale embedded
+ # copy. The variant only picks the GGUF fallback, so tokenizer-first precedence
+ # holds whether or not a variant is given.
+ return _chat_template_from_tokenizer_dir(dir_path, allow_roots) or from_gguf()
+
+
+def read_default_chat_template(
+ model_name: str,
+ hf_token: Optional[str] = None,
+ gguf_variant: Optional[str] = None,
+) -> Optional[str]:
+ if not isinstance(model_name, str) or not model_name.strip():
+ return None
+ name = model_name.strip()
+
+ if is_local_path(name):
+ try:
+ target = Path(normalize_path(name)).expanduser()
+ allow_roots = _build_browse_allowlist()
+ if not _is_path_inside_allowlist(target, allow_roots):
+ logger.debug("Refused chat template read outside allowed folders: %s", name)
+ return None
+ if name.lower().endswith(".gguf"):
+ # Prefer a maintained sidecar next to the file over the GGUF's
+ # embedded copy (tokenizer-first precedence, as elsewhere).
+ sidecar = _chat_template_from_tokenizer_dir(target.parent, allow_roots)
+ if sidecar:
+ return sidecar
+ return read_gguf_chat_template(str(target))
+ return _chat_template_from_dir(target, gguf_variant, allow_roots)
+ except Exception as exc:
+ logger.debug("Could not read local chat template for %s: %s", name, exc)
+ return None
+
+ if not _is_valid_repo_id(name):
+ return None
+
+ resolved = resolve_cached_repo_id_case(name)
+
+ try:
+ # Resolve within each cached revision, newest first. A revision's sidecar
+ # supersedes its own embedded GGUF copy, but must not override a newer
+ # revision, so precedence stays per-snapshot rather than global.
+ 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)
+
+ try:
+ from huggingface_hub import HfApi, hf_hub_download
+
+ _api = HfApi()
+
+ def _remote_exceeds_cap(rel: str) -> bool:
+ # Best-effort: skip the download when the remote's advertised size
+ # exceeds the cap, so a maliciously large sidecar is never fetched.
+ try:
+ infos = _api.get_paths_info(resolved, [rel], repo_type = "model", token = hf_token)
+ except Exception:
+ return False
+ for info in infos:
+ size = getattr(info, "size", None)
+ if (
+ getattr(info, "path", None) == rel
+ and isinstance(size, int)
+ and size > MAX_TEMPLATE_METADATA_BYTES
+ ):
+ return True
+ return False
+
+ def _download_text(rel: str) -> Optional[str]:
+ if _remote_exceeds_cap(rel):
+ return None
+ try:
+ path = hf_hub_download(resolved, rel, token = hf_token)
+ return _read_bounded_text(Path(path), MAX_TEMPLATE_METADATA_BYTES)
+ except Exception:
+ return None
+
+ for rel in _JINJA_TEMPLATE_PATHS:
+ template = _download_text(rel)
+ if not template or not template.strip():
+ continue
+ # A raw Jinja sidecar is the whole template, so it must fit the route's
+ # response cap (the local path skips oversized .jinja too). Download stays
+ # bounded at MAX_TEMPLATE_METADATA_BYTES so a large JSON embedding a small
+ # template still extracts below, but an over-cap Jinja is dropped so the
+ # search falls through to the tokenizer/processor template.
+ if len(template.encode("utf-8")) > MAX_CHAT_TEMPLATE_BYTES:
+ continue
+ return template
+
+ for rel in _TOKENIZER_CONFIG_PATHS:
+ raw = _download_text(rel)
+ if not raw:
+ continue
+ try:
+ config = json.loads(raw)
+ except Exception:
+ continue
+ template = _chat_template_from_tokenizer_config(config)
+ if template:
+ return template
+
+ for rel in _PROCESSOR_TEMPLATE_PATHS:
+ raw = _download_text(rel)
+ if not raw:
+ continue
+ try:
+ payload = json.loads(raw)
+ except Exception:
+ continue
+ template = _chat_template_from_processor_payload(payload)
+ if template:
+ return template
+
+ return None
+ except Exception as exc:
+ logger.debug("Could not fetch chat template for %s: %s", resolved, exc)
+ return None
diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py
index afd942e9a5..d3e588bb0b 100644
--- a/studio/backend/routes/inference.py
+++ b/studio/backend/routes/inference.py
@@ -5144,10 +5144,10 @@ async def validate_model(
latest_tier_active_for, config.identifier, request.hf_token
):
effective_load_in_4bit = False
- # A metadata-only probe just reads the GGUF header and allocates no VRAM,
- # so it must not be refused by the training guard. Real loads validate
- # without include_context_length and /load applies the guard again.
- if not request.include_context_length:
+ # A metadata-only probe reads the GGUF header and allocates no VRAM, so the
+ # training guard must not refuse it. Real loads omit include_context_length /
+ # include_chat_template, and /load applies the guard again.
+ if not (request.include_context_length or request.include_chat_template):
# Match /load's inherited llama.cpp extras and parallel slot count so
# validation cannot pass a smaller estimate than the subsequent load.
effective_extra_args = _resolve_inherited_extra_args(
@@ -5189,9 +5189,15 @@ async def validate_model(
context_length: Optional[int] = None
layer_count: Optional[int] = None
moe_layer_count: Optional[int] = None
- if request.include_context_length and is_gguf:
+ chat_template: Optional[str] = None
+ # Both header probes read the same local GGUF, so resolve it once.
+ if (request.include_context_length or request.include_chat_template) and is_gguf:
from hub.utils.gguf import resolve_local_gguf_path
- from utils.models.gguf_metadata import read_gguf_staged_dims
+ from picker.schemas import MAX_CHAT_TEMPLATE_BYTES
+ from utils.models.gguf_metadata import (
+ read_gguf_chat_template,
+ read_gguf_staged_dims,
+ )
# Best-effort: a header-read failure must never fail validation of an
# otherwise-valid model (the outer except turns it into a 400).
@@ -5207,13 +5213,24 @@ async def validate_model(
model_identifier, request.gguf_variant
)
if local_gguf:
- # Header walk reads tokenizer arrays for dense models (tens of
- # ms); keep it off the event loop.
- dims = await asyncio.to_thread(read_gguf_staged_dims, local_gguf)
- if dims:
- context_length = dims["context_length"]
- layer_count = dims["layer_count"]
- moe_layer_count = dims["moe_layer_count"]
+ if request.include_context_length:
+ # Header walk reads tokenizer arrays (tens of ms); keep it
+ # off the event loop.
+ dims = await asyncio.to_thread(read_gguf_staged_dims, local_gguf)
+ if dims:
+ context_length = dims["context_length"]
+ layer_count = dims["layer_count"]
+ moe_layer_count = dims["moe_layer_count"]
+ if request.include_chat_template:
+ # Read only the leased GGUF's own embedded template (the copy
+ # llama.cpp loads), never a sibling sidecar: the native grant
+ # authorizes just this path, so neighbours would be scope escalation.
+ raw_template = await asyncio.to_thread(read_gguf_chat_template, local_gguf)
+ if (
+ raw_template is not None
+ and len(raw_template.encode("utf-8")) <= MAX_CHAT_TEMPLATE_BYTES
+ ):
+ chat_template = raw_template
except Exception as e:
logger.debug("Header probe failed for %s: %s", model_log_label, e)
@@ -5232,6 +5249,7 @@ async def validate_model(
context_length = context_length,
layer_count = layer_count,
moe_layer_count = moe_layer_count,
+ chat_template = chat_template,
requires_transformers_upgrade = transformers_upgrade is not None,
transformers_upgrade = transformers_upgrade,
)
diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py
index 0806c2f513..a5ce1a72f0 100644
--- a/studio/backend/routes/models.py
+++ b/studio/backend/routes/models.py
@@ -60,13 +60,52 @@ def _safe_is_dir(path) -> bool:
# Shared with the hub inventory scans; keep the private aliases so existing
-# importers (core.inference.local_model_resolver, tests) stay valid.
+# importers stay valid. ``_HF_REPO_ID_RE`` is the Hub repo id shape ("owner/name");
+# anything else is treated as a local filesystem path.
from utils.hidden_models import (
+ _HF_REPO_ID_RE,
+ _existing_resolved_path,
_safe_resolve,
is_hidden_model as _is_hidden_model,
)
+def hidden_model_matchers() -> tuple[list[str], list[str], list[str]]:
+ """Substring needles, exact repo ids, and exact resolved paths identifying
+ infra models (the RAG embedder and the llama.cpp install validation probe)
+ that pickers hide. Served by the ``/api/hub/hidden-models`` endpoint. A
+ configured HF-repo embedder is published as its exact lowercased repo id
+ (mirroring ``utils.hidden_models.is_hidden_model``) and a local-path
+ embedder as its exact resolved path only: a generic basename like "model"
+ must not substring-hide unrelated chat models."""
+ from core.rag import config as rag_config
+
+ needles = [
+ # The validation probe's repo and its exact filename. The filename carries
+ # .gguf so it won't hide unrelated repos like ``user/stories260K-finetune-GGUF``.
+ "ggml-org/models",
+ "stories260k.gguf",
+ ]
+ exact_ids: list[str] = []
+ exact_paths: list[str] = []
+ for model in (
+ rag_config.effective_embedding_model(),
+ rag_config.effective_gguf_repo(),
+ ):
+ # Resolve an existing local path before the repo-id regex: a local embedder
+ # shaped like "models/embedder" is an exact path, not a Hub repo id.
+ existing_path = _existing_resolved_path(model)
+ if existing_path:
+ exact_paths.append(existing_path.lower())
+ elif _HF_REPO_ID_RE.match(model):
+ exact_ids.append(model.lower())
+ else:
+ resolved = _safe_resolve(Path(model).expanduser())
+ if resolved:
+ exact_paths.append(resolved.lower())
+ return needles, exact_ids, exact_paths
+
+
backend_path = Path(__file__).parent.parent.parent
if str(backend_path) not in sys.path:
sys.path.insert(0, str(backend_path))
@@ -91,6 +130,7 @@ try:
_pick_best_gguf,
_extract_quant_label,
_is_big_endian_gguf_path,
+ _is_mtp_drafter,
is_audio_input_type,
)
from core.inference import get_inference_backend
@@ -123,6 +163,7 @@ except ImportError:
_pick_best_gguf,
_extract_quant_label,
_is_big_endian_gguf_path,
+ _is_mtp_drafter,
is_audio_input_type,
)
from core.inference import get_inference_backend
@@ -803,7 +844,7 @@ def collect_local_models(models_root: Path) -> List[LocalModelInfo]:
models = sorted(
deduped.values(),
- key = lambda item: (item.updated_at or 0),
+ key = lambda item: item.updated_at or 0,
reverse = True,
)
return [m for m in models if not _is_hidden_model(m.id, m.model_id, m.path)]
@@ -1750,9 +1791,11 @@ def _get_model_size_bytes(model_name: str, hf_token: Optional[str] = None) -> Op
async def get_model_config(
model_name: str,
hf_token: Optional[str] = Query(None),
+ header_hf_token: Optional[str] = Depends(get_hf_token),
current_subject: str = Depends(get_current_subject),
):
"""Get configuration for a specific model (wraps load_model_defaults)."""
+ hf_token = _normalize_hf_token(header_hf_token) or _normalize_hf_token(hf_token)
try:
if not is_local_path(model_name):
resolved = resolve_cached_repo_id_case(model_name)
@@ -2471,6 +2514,7 @@ async def get_lora_base_model(lora_path: str, current_subject: str = Depends(get
async def check_vision_model(
model_name: str,
hf_token: Optional[str] = Query(None),
+ header_hf_token: Optional[str] = Depends(get_hf_token),
current_subject: str = Depends(get_current_subject),
):
"""
@@ -2478,6 +2522,7 @@ async def check_vision_model(
This endpoint wraps the backend is_vision_model function.
"""
+ hf_token = _normalize_hf_token(header_hf_token) or _normalize_hf_token(hf_token)
try:
logger.info(f"Checking if vision model: {model_name}")
# Authenticate so a gated/private VLM classifies correctly (else 404 -> non-vision).
@@ -2503,6 +2548,7 @@ async def check_vision_model(
async def check_embedding_model(
model_name: str,
hf_token: Optional[str] = Query(None),
+ header_hf_token: Optional[str] = Depends(get_hf_token),
current_subject: str = Depends(get_current_subject),
):
"""
@@ -2510,6 +2556,7 @@ async def check_embedding_model(
This endpoint wraps the backend is_embedding_model function.
"""
+ hf_token = _normalize_hf_token(header_hf_token) or _normalize_hf_token(hf_token)
try:
logger.info(f"Checking if embedding model: {model_name}")
is_embedding = is_embedding_model(model_name, hf_token = hf_token)
@@ -2573,12 +2620,6 @@ def _resolve_quant_gguf(repo_id: str, quant: str, is_local: bool) -> tuple[Optio
Q8_0 weights). Never raises.
"""
try:
- from utils.models.model_config import (
- _extract_quant_label,
- _is_big_endian_gguf_path,
- _is_mtp_drafter,
- )
-
if is_local:
roots = [Path(repo_id)]
else:
@@ -2595,25 +2636,19 @@ def _resolve_quant_gguf(repo_id: str, quant: str, is_local: bool) -> tuple[Optio
if snaps.is_dir():
roots.extend(s for s in snaps.iterdir() if s.is_dir())
- want = quant.lower().replace("-", "").replace("_", "")
+ want = _normalized_quant_label(quant)
best_total = 0
best_first: Optional[str] = None
for root in roots:
matches: list[tuple[str, Path]] = []
total = 0
for f in _iter_gguf_paths(root):
- if _is_mmproj_filename(f.name):
- continue
try:
rel = f.relative_to(root).as_posix()
except ValueError:
rel = f.name
- if _is_mtp_drafter(rel):
- continue
- q = _extract_quant_label(rel)
- if _is_big_endian_gguf_path(rel, q):
- continue
- if q.lower().replace("-", "").replace("_", "") != want:
+ q = _main_variant_gguf_label(rel)
+ if q is None or _normalized_quant_label(q) != want:
continue
try:
total += f.stat().st_size
@@ -3035,6 +3070,22 @@ def _is_main_gguf_filename(name: str) -> bool:
return _is_gguf_filename(name) and not _is_mmproj_filename(name)
+def _main_variant_gguf_label(rel_path: str) -> Optional[str]:
+ name = rel_path.rsplit("/", 1)[-1]
+ if not _is_main_gguf_filename(name):
+ return None
+ if _is_mtp_drafter(rel_path):
+ return None
+ label = _extract_quant_label(rel_path)
+ if _is_big_endian_gguf_path(rel_path, label):
+ return None
+ return label
+
+
+def _normalized_quant_label(label: str) -> str:
+ return label.lower().replace("-", "").replace("_", "")
+
+
def _repo_has_mmproj(repo_info) -> bool:
"""True if the repo ships a GGUF vision adapter (mmproj), so it can
take image inputs. Cheap: scans already-listed file names only."""
@@ -3362,6 +3413,170 @@ async def delete_cached_model(
)
+def _resolve_cached_model_path(repo_id: str, variant: Optional[str]) -> Path:
+ """Absolute path of a cached repo (newest snapshot dir) or, with *variant*,
+ that quant's main GGUF file (first split of a sharded quant). Paths come
+ from the HF cache scan only, so callers can't probe arbitrary paths."""
+ cache_scans = _all_hf_cache_scans()
+
+ matching_repos = []
+ for hf_cache in cache_scans:
+ for repo_info in hf_cache.repos:
+ if repo_info.repo_type != "model":
+ continue
+ if repo_info.repo_id.lower() == repo_id.lower():
+ matching_repos.append(repo_info)
+ if not matching_repos:
+ raise HTTPException(status_code = 404, detail = "Model not found in cache")
+
+ if variant:
+ want = _normalized_quant_label(variant)
+ candidate_revisions = sorted(
+ (rev for repo_info in matching_repos for rev in repo_info.revisions),
+ key = lambda rev: getattr(rev, "last_modified", 0) or 0,
+ reverse = True,
+ )
+ for rev in candidate_revisions:
+ snapshot = getattr(rev, "snapshot_path", None)
+ matches = []
+ for f in rev.files:
+ p = Path(f.file_path)
+ rel = f.file_name
+ if snapshot:
+ try:
+ rel = p.relative_to(snapshot).as_posix()
+ except ValueError:
+ pass
+ label = _main_variant_gguf_label(rel)
+ if label is None or _normalized_quant_label(label) != want:
+ continue
+ if p.exists() or p.is_symlink():
+ matches.append((rel, p))
+ if matches:
+ # Path-sorted so a sharded quant deterministically yields its first split.
+ return sorted(matches, key = lambda m: m[0].lower())[0][1]
+ raise HTTPException(
+ status_code = 404,
+ detail = f"Variant {variant} not found in cache for {repo_id}",
+ )
+
+ def repo_size(repo_info) -> int:
+ gguf_size = _repo_gguf_size_bytes(repo_info)
+ if gguf_size > 0:
+ return gguf_size
+ return sum(
+ (getattr(f, "size_on_disk", None) or 0)
+ for rev in repo_info.revisions
+ for f in rev.files
+ )
+
+ def repo_last_modified(repo_info) -> float:
+ return max(
+ (getattr(rev, "last_modified", 0) or 0 for rev in repo_info.revisions),
+ default = 0,
+ )
+
+ target_repo = max(
+ matching_repos,
+ key = lambda repo_info: (repo_size(repo_info), repo_last_modified(repo_info)),
+ )
+
+ # Whole repo: the newest revision's snapshot dir holds the visible files.
+ revisions = sorted(
+ (rev for rev in target_repo.revisions if getattr(rev, "snapshot_path", None)),
+ key = lambda rev: getattr(rev, "last_modified", 0) or 0,
+ reverse = True,
+ )
+ for rev in revisions:
+ p = Path(rev.snapshot_path)
+ if p.exists():
+ return p
+ p = Path(target_repo.repo_path)
+ if p.exists():
+ return p
+ raise HTTPException(status_code = 404, detail = "Cached model path not found")
+
+
+def _wsl_reveal_in_explorer(path: Path) -> bool:
+ import subprocess
+
+ from utils.paths.path_utils import _IS_WSL
+
+ if not _IS_WSL:
+ return False
+ try:
+ windows_path = subprocess.run(
+ ["wslpath", "-w", str(path)],
+ capture_output = True,
+ text = True,
+ check = True,
+ timeout = 10,
+ ).stdout.strip()
+ if not windows_path:
+ return False
+ argument = f"/select,{windows_path}" if path.is_file() else windows_path
+ subprocess.Popen(["explorer.exe", argument])
+ return True
+ except (OSError, subprocess.SubprocessError):
+ return False
+
+
+def _reveal_in_file_manager(path: Path) -> None:
+ """Open the OS file manager with *path* selected (best effort per platform)."""
+ import subprocess
+
+ target = str(path)
+ if sys.platform == "darwin":
+ cmd = ["open", "-R", target] if path.is_file() else ["open", target]
+ subprocess.Popen(cmd)
+ elif os.name == "nt":
+ if path.is_file():
+ subprocess.Popen(["explorer", f"/select,{target}"])
+ else:
+ os.startfile(target) # noqa: S606 - local user's own file manager
+ elif not _wsl_reveal_in_explorer(path):
+ # No cross-desktop "select file" standard on Linux; open the directory.
+ directory = target if path.is_dir() else str(path.parent)
+ subprocess.Popen(["xdg-open", directory])
+
+
+class CachedModelPathResponse(BaseModel):
+ path: str
+ is_dir: bool
+
+
+@router.get("/cached-model-path", response_model = CachedModelPathResponse)
+async def get_cached_model_path(
+ repo_id: str = Query(..., description = "HuggingFace repo ID"),
+ variant: str = Query("", description = "Quantization variant (empty for whole repo)"),
+ current_subject: str = Depends(get_current_subject),
+):
+ """Absolute on-disk path of a cached repo or one of its GGUF variants."""
+ if not _is_valid_repo_id(repo_id):
+ raise HTTPException(status_code = 400, detail = "Invalid repo_id format")
+ path = await asyncio.to_thread(_resolve_cached_model_path, repo_id, variant.strip() or None)
+ return {"path": str(path), "is_dir": path.is_dir()}
+
+
+@router.post("/reveal-cached-model")
+async def reveal_cached_model(
+ repo_id: str = Body(...),
+ variant: Optional[str] = Body(None),
+ current_subject: str = Depends(get_current_subject),
+):
+ """Reveal a cached repo (or one GGUF variant's file) in the OS file manager."""
+ if not _is_valid_repo_id(repo_id):
+ raise HTTPException(status_code = 400, detail = "Invalid repo_id format")
+ variant = (variant or "").strip() or None
+ path = await asyncio.to_thread(_resolve_cached_model_path, repo_id, variant)
+ try:
+ await asyncio.to_thread(_reveal_in_file_manager, path)
+ except Exception as e:
+ logger.error(f"Failed to reveal {path}: {e}")
+ raise HTTPException(status_code = 500, detail = "Failed to open file manager")
+ return {"status": "ok", "path": str(path)}
+
+
@router.get("/checkpoints", response_model = CheckpointListResponse)
async def list_checkpoints(
outputs_dir: str = Query(
diff --git a/studio/backend/tests/test_chat_load_during_training.py b/studio/backend/tests/test_chat_load_during_training.py
index 7daa4224aa..a5fd71b6a0 100644
--- a/studio/backend/tests/test_chat_load_during_training.py
+++ b/studio/backend/tests/test_chat_load_during_training.py
@@ -801,6 +801,80 @@ class TestValidateRefusesDuringTraining(unittest.TestCase):
asyncio.run(self.route.validate_model(request, current_subject = "u"))
self.assertEqual(guard_called, [])
+ def _validate_gguf_template(
+ self,
+ *,
+ template,
+ canonical_path = "/picked/model.gguf",
+ ):
+ # Drive validate_model for a native lease-backed GGUF template probe and
+ # capture what the embedded-template reader was called with.
+ from models.inference import ValidateModelRequest
+
+ request = ValidateModelRequest(
+ model_path = "model.gguf",
+ gguf_variant = "Q4_K_M",
+ native_path_lease = "signed-lease",
+ include_chat_template = True,
+ )
+ cfg = SimpleNamespace(
+ identifier = canonical_path,
+ display_name = "model.gguf",
+ is_gguf = True,
+ is_lora = False,
+ is_vision = False,
+ gguf_file = canonical_path,
+ path = None,
+ base_model = None,
+ )
+ import utils.models.gguf_metadata as gguf_meta
+
+ seen = {}
+
+ def _fake_read(path):
+ seen["path"] = path
+ return template
+
+ guard_called = []
+ with (
+ patch.object(
+ self.route,
+ "_resolve_model_identifier_for_request",
+ return_value = (canonical_path, "model.gguf", True),
+ ),
+ patch.object(self.route.ModelConfig, "from_identifier", return_value = cfg),
+ patch.object(self.route, "load_inference_config", return_value = {}),
+ patch.object(gguf_meta, "read_gguf_chat_template", _fake_read),
+ patch.object(
+ self.route,
+ "_guard_chat_load_against_training",
+ lambda *a, **kw: guard_called.append(True),
+ ),
+ ):
+ resp = asyncio.run(self.route.validate_model(request, current_subject = "u"))
+ return resp, seen, guard_called
+
+ def test_include_chat_template_reads_leased_gguf_embedded_template(self):
+ # The picker chat-template GET has no lease plumbing, so a native picked
+ # GGUF surfaces its default template through this lease-aware probe: the
+ # embedded template is read from the granted canonical path and returned.
+ resp, seen, _ = self._validate_gguf_template(template = "{{ messages }}")
+ self.assertEqual(resp.chat_template, "{{ messages }}")
+ # Read strictly the leased file's own embedded template, never a sibling
+ # sidecar: the grant authorizes just this one path.
+ self.assertEqual(seen["path"], "/picked/model.gguf")
+
+ def test_include_chat_template_skips_training_guard(self):
+ # A template-only probe allocates no VRAM, so like include_context_length
+ # it must not be refused by the training guard.
+ _, _, guard_called = self._validate_gguf_template(template = "{{ messages }}")
+ self.assertEqual(guard_called, [])
+
+ def test_include_chat_template_over_cap_is_dropped(self):
+ from picker.schemas import MAX_CHAT_TEMPLATE_BYTES
+ resp, _, _ = self._validate_gguf_template(template = "a" * (MAX_CHAT_TEMPLATE_BYTES + 1))
+ self.assertIsNone(resp.chat_template)
+
# ── _estimate_gguf_required_gb (sizes the same weights the loader loads) ──────
diff --git a/studio/backend/tests/test_export_absolute_paths.py b/studio/backend/tests/test_export_absolute_paths.py
index 761ea08e3f..5097f9f53a 100644
--- a/studio/backend/tests/test_export_absolute_paths.py
+++ b/studio/backend/tests/test_export_absolute_paths.py
@@ -158,6 +158,7 @@ def _install_lightweight_backend_stubs(monkeypatch):
utils_model_config._pick_best_gguf = lambda variants: variants[0] if variants else None
utils_model_config._extract_quant_label = lambda value: value
utils_model_config._is_big_endian_gguf_path = lambda *args, **kwargs: False
+ utils_model_config._is_mtp_drafter = lambda *args, **kwargs: False
utils_model_config.is_audio_input_type = lambda *args, **kwargs: None
monkeypatch.setitem(
sys.modules,
diff --git a/studio/backend/tests/test_model_picker_regression.py b/studio/backend/tests/test_model_picker_regression.py
new file mode 100644
index 0000000000..f38a4d0b8d
--- /dev/null
+++ b/studio/backend/tests/test_model_picker_regression.py
@@ -0,0 +1,232 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Regression guards for the model-picker per-model-config feature (the set of
+bugs that got the predecessor PR reverted). Pure-function / validation checks
+only, so they run on CPU in the backend pytest job with no model download.
+
+Covers, at the backend layer:
+ - infra-model hiding: the RAG embedder (bge-small-en-v1.5) and the llama.cpp
+ install-validation probe (ggml-org/models / stories260K) stay hidden, while
+ normal chat repos are not hidden;
+ - the HF token is honored from the dedicated header with the query string as a
+ fallback, never the other way around;
+ - the chat-template byte caps reject oversized overrides (both the char-count
+ fast path and the UTF-8 byte path) and the sidecar reader is size-bounded.
+"""
+
+from __future__ import annotations
+
+import sys
+import types
+
+import pytest
+
+# Keep this test runnable without the optional structlog dependency (mirrors
+# tests/test_cached_gguf_routes.py), since importing routes.models pulls it in.
+if "structlog" not in sys.modules:
+
+ class _DummyLogger:
+ def __getattr__(self, _name):
+ return lambda *args, **kwargs: None
+
+ sys.modules["structlog"] = types.SimpleNamespace(
+ BoundLogger = _DummyLogger,
+ get_logger = lambda *args, **kwargs: _DummyLogger(),
+ )
+
+import routes.models as models_route
+from core.rag import config as rag_config
+from hub.dependencies import get_hf_token
+from models.inference import LoadRequest
+from picker.schemas import MAX_CHAT_TEMPLATE_BYTES
+from picker.service import _read_bounded_text
+from utils.hidden_models import is_hidden_model
+
+
+@pytest.fixture(autouse = True)
+def _pin_default_embedder(monkeypatch):
+ """Pin the effective embedder to Studio's static default so hiding is
+ deterministic and cannot depend on ambient RAG config / env."""
+ default = "unsloth/bge-small-en-v1.5"
+ monkeypatch.setattr(rag_config, "EMBEDDING_MODEL", default, raising = False)
+ monkeypatch.setattr(rag_config, "effective_embedding_model", lambda: default)
+ monkeypatch.setattr(rag_config, "effective_gguf_repo", lambda: default)
+ monkeypatch.setattr(rag_config, "default_gguf_repo", lambda: default)
+
+
+# --------------------------------------------------------------------------- #
+# Infra-model hiding (the "infra models resurfaced in the picker" regression) #
+# --------------------------------------------------------------------------- #
+
+
+@pytest.mark.parametrize(
+ "value",
+ [
+ "ggml-org/models", # the probe repo id
+ "unsloth/bge-small-en-v1.5", # the RAG embedder repo
+ "unsloth/bge-small-en-v1.5-GGUF", # its GGUF companion
+ "/root/.cache/huggingface/hub/x/stories260K.gguf", # probe on disk
+ "/root/.cache/x/Stories260K.GGUF", # case-insensitive
+ r"C:\\models\\stories260K.gguf", # windows-style path
+ "/opt/models/bge-small-en-v1.5", # embedder basename folder
+ "/opt/models/bge-small-en-v1.5-Q8_0.gguf", # suffixed local weight
+ ],
+)
+def test_infra_models_are_hidden(value):
+ assert is_hidden_model(value) is True
+
+
+@pytest.mark.parametrize(
+ "value",
+ [
+ "unsloth/gemma-3-270m-it-GGUF", # a normal small chat GGUF
+ "unsloth/Qwen3-0.6B", # a normal non-GGUF chat model
+ "user/stories260K-finetune-GGUF", # repo id merely contains "stories260k"
+ "user/model-chat", # generic repo must not be hidden
+ "meta-llama/Llama-3.1-8B-Instruct",
+ ],
+)
+def test_normal_models_are_not_hidden(value):
+ assert is_hidden_model(value) is False
+
+
+def test_is_hidden_model_ignores_empty_values():
+ assert is_hidden_model(None) is False
+ assert is_hidden_model("") is False
+ assert is_hidden_model(None, "", "unsloth/gemma-3-270m-it-GGUF") is False
+
+
+def test_hidden_model_matchers_expose_probe_needles():
+ needles, exact_ids, _exact_paths = models_route.hidden_model_matchers()
+ lowered = [n.lower() for n in needles]
+ assert "ggml-org/models" in lowered
+ assert "stories260k.gguf" in lowered
+ # The configured embedder is exposed as an exact repo id, never as a
+ # basename needle that would substring-hide unrelated chat models.
+ assert "bge-small-en-v1.5" not in lowered
+ assert "unsloth/bge-small-en-v1.5" in exact_ids
+
+
+def test_hidden_model_matchers_custom_repo_publishes_exact_ids(monkeypatch):
+ monkeypatch.setattr(rag_config, "effective_embedding_model", lambda: "org/model")
+ monkeypatch.setattr(rag_config, "effective_gguf_repo", lambda: "org/model-GGUF")
+ needles, exact_ids, exact_paths = models_route.hidden_model_matchers()
+ assert needles == ["ggml-org/models", "stories260k.gguf"]
+ assert "org/model" in exact_ids
+ assert "org/model-gguf" in exact_ids
+ assert exact_paths == []
+
+
+def test_hidden_model_matchers_local_owner_name_path_is_exact_path(monkeypatch, tmp_path):
+ # A local embedder shaped like owner/name that exists on disk must be an
+ # exact resolved path, not a Hub repo id (mirroring is_hidden_model), so the
+ # local row stays hidden instead of showing as a chat model.
+ (tmp_path / "models" / "embedder").mkdir(parents = True)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(rag_config, "effective_embedding_model", lambda: "models/embedder")
+ monkeypatch.setattr(rag_config, "effective_gguf_repo", lambda: "ggml-org/models")
+ _needles, exact_ids, exact_paths = models_route.hidden_model_matchers()
+ resolved = str((tmp_path / "models" / "embedder").resolve()).lower()
+ assert resolved in exact_paths
+ assert "models/embedder" not in exact_ids
+
+
+# --------------------------------------------------------------------------- #
+# HF token via header, query string only as a fallback (the token-leak fix) #
+# --------------------------------------------------------------------------- #
+
+
+def test_get_hf_token_strips_and_returns():
+ assert get_hf_token(" hf_abc ") == "hf_abc"
+
+
+@pytest.mark.parametrize("value", [None, "", " ", "\n\t"])
+def test_get_hf_token_blank_is_none(value):
+ assert get_hf_token(value) is None
+
+
+@pytest.mark.parametrize(
+ "value,expected",
+ [(" hf_x ", "hf_x"), ("", None), (" ", None), (None, None), (1234, None)],
+)
+def test_normalize_hf_token(value, expected):
+ assert models_route._normalize_hf_token(value) == expected
+
+
+def test_header_token_wins_over_query():
+ header, query = "hf_header", "hf_query"
+ resolved = models_route._normalize_hf_token(header) or models_route._normalize_hf_token(query)
+ assert resolved == "hf_header"
+
+
+def test_query_token_is_fallback_when_header_absent():
+ resolved = models_route._normalize_hf_token(None) or models_route._normalize_hf_token(
+ "hf_query"
+ )
+ assert resolved == "hf_query"
+
+
+# --------------------------------------------------------------------------- #
+# Chat-template byte caps (the unbounded-template hardening) #
+# --------------------------------------------------------------------------- #
+
+
+def _load_request(**overrides):
+ data = {"model_path": "unsloth/test-model-GGUF", "gguf_variant": "Q4_K_M"}
+ data.update(overrides)
+ return LoadRequest.model_validate(data)
+
+
+def test_blank_chat_template_override_normalizes_to_none():
+ assert _load_request(chat_template_override = " \n\t").chat_template_override is None
+
+
+def test_nonblank_chat_template_override_preserved_verbatim():
+ template = " {{ messages }} "
+ assert _load_request(chat_template_override = template).chat_template_override == template
+
+
+def test_chat_template_at_byte_limit_is_accepted():
+ template = "a" * MAX_CHAT_TEMPLATE_BYTES # exactly the limit, 1 byte/char
+ assert (
+ len(_load_request(chat_template_override = template).chat_template_override)
+ == MAX_CHAT_TEMPLATE_BYTES
+ )
+
+
+def test_chat_template_over_char_limit_is_rejected():
+ with pytest.raises(Exception): # pydantic ValidationError wrapping ValueError
+ _load_request(chat_template_override = "a" * (MAX_CHAT_TEMPLATE_BYTES + 1))
+
+
+def test_chat_template_over_byte_limit_is_rejected():
+ # Char count stays under the limit but UTF-8 bytes exceed it (3 bytes/char),
+ # so only the byte-count branch can catch this.
+ multibyte = "€" * (MAX_CHAT_TEMPLATE_BYTES // 2) # euro sign, 3 bytes each
+ assert len(multibyte) <= MAX_CHAT_TEMPLATE_BYTES
+ assert len(multibyte.encode("utf-8")) > MAX_CHAT_TEMPLATE_BYTES
+ with pytest.raises(Exception):
+ _load_request(chat_template_override = multibyte)
+
+
+def test_read_bounded_text_reads_within_limit(tmp_path):
+ p = tmp_path / "t.json"
+ p.write_text("hello", encoding = "utf-8")
+ assert _read_bounded_text(p, 16) == "hello"
+
+
+def test_read_bounded_text_rejects_over_limit(tmp_path):
+ p = tmp_path / "big.json"
+ p.write_bytes(b"x" * 100)
+ assert _read_bounded_text(p, 50) is None
+
+
+def test_read_bounded_text_at_limit_is_read(tmp_path):
+ p = tmp_path / "exact.json"
+ p.write_bytes(b"x" * 50)
+ assert _read_bounded_text(p, 50) == "x" * 50
+
+
+def test_read_bounded_text_missing_file_is_none(tmp_path):
+ assert _read_bounded_text(tmp_path / "nope.json", 50) is None
diff --git a/studio/backend/tests/test_model_update_robustness.py b/studio/backend/tests/test_model_update_robustness.py
index edf55812e2..7d98766616 100644
--- a/studio/backend/tests/test_model_update_robustness.py
+++ b/studio/backend/tests/test_model_update_robustness.py
@@ -314,6 +314,7 @@ def test_cached_model_scan_keeps_local_safetensors_repo(monkeypatch, tmp_path):
file_name = "model.safetensors",
size_on_disk = 100,
blob_path = str(repo_path / "blobs" / "modelsha"),
+ blob_last_modified = 3_000.0,
),
]
)
@@ -336,6 +337,51 @@ def test_cached_model_scan_keeps_local_safetensors_repo(monkeypatch, tmp_path):
assert rows[0]["repo_id"] == "Org/SafeTensorRepo"
assert rows[0]["model_format"] == "safetensors"
assert rows[0]["size_bytes"] == 100
+ assert rows[0]["last_modified"] == 3_000.0
+
+
+def test_cached_gguf_scan_keeps_download_timestamp(monkeypatch, tmp_path):
+ repo_path = tmp_path / "models--Org--GgufRepo"
+ repo = SimpleNamespace(
+ repo_id = "Org/GgufRepo",
+ repo_type = "model",
+ repo_path = repo_path,
+ revisions = [
+ SimpleNamespace(
+ files = [
+ SimpleNamespace(
+ file_name = "model-Q4_K_M.gguf",
+ size_on_disk = 100,
+ blob_path = None,
+ blob_last_modified = 5_000.0,
+ ),
+ ]
+ )
+ ],
+ )
+ monkeypatch.setattr(
+ CI,
+ "all_hf_cache_scans",
+ lambda: [SimpleNamespace(repos = [repo])],
+ )
+ monkeypatch.setattr(
+ CI.hf_cache_scan,
+ "is_gguf_repo_partial",
+ lambda *args, **kwargs: False,
+ )
+ monkeypatch.setattr(
+ CI,
+ "_gguf_variant_state_summary",
+ lambda _repo_id: (False, 0),
+ )
+
+ rows = CI._scan_cached_gguf()
+
+ assert len(rows) == 1
+ assert rows[0]["repo_id"] == "Org/GgufRepo"
+ assert rows[0]["model_format"] == "gguf"
+ assert rows[0]["size_bytes"] == 100
+ assert rows[0]["last_modified"] == 5_000.0
# ── hf_hub_download_with_xet_fallback force_download bypass (X2/F2) ───
@@ -636,3 +682,18 @@ def test_reclaim_replaced_gguf_variant_keeps_no_symlink_current_file(monkeypatch
assert snap.exists() is True # the current file must survive
assert result["removed_snapshots"] == 0
assert result["deleted_blobs"] == 0
+
+
+def _mmproj_repo(*file_names: str):
+ return SimpleNamespace(
+ revisions = [SimpleNamespace(files = [SimpleNamespace(file_name = n) for n in file_names])]
+ )
+
+
+def test_repo_has_mmproj_requires_gguf_projector():
+ # A non-GGUF sidecar whose name merely contains "mmproj" must NOT mark the
+ # repo vision-capable; the runtime's projector detection is GGUF-only.
+ assert CI._repo_has_mmproj(_mmproj_repo("model-Q4_K_M.gguf", "mmproj_config.json")) is False
+ assert CI._repo_has_mmproj(_mmproj_repo("model-Q4_K_M.gguf", "README-mmproj.md")) is False
+ # A real GGUF projector still marks the repo vision-capable.
+ assert CI._repo_has_mmproj(_mmproj_repo("model-Q4_K_M.gguf", "mmproj-F16.gguf")) is True
diff --git a/studio/backend/tests/test_picker_service.py b/studio/backend/tests/test_picker_service.py
new file mode 100644
index 0000000000..be7ea18f03
--- /dev/null
+++ b/studio/backend/tests/test_picker_service.py
@@ -0,0 +1,266 @@
+# 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 json
+from types import SimpleNamespace
+
+from picker.service import (
+ MAX_TEMPLATE_METADATA_BYTES,
+ _chat_template_from_dir,
+ _chat_template_from_processor_json,
+ _chat_template_from_tokenizer_config,
+ _chat_template_from_tokenizer_dir,
+ _find_gguf_in_dir,
+ _iter_ggufs,
+ read_default_chat_template,
+ validate_chat_template,
+)
+
+
+def test_iter_ggufs_skips_gguf_companions(tmp_path):
+ mtp_dir = tmp_path / "MTP"
+ mtp_dir.mkdir()
+ main = tmp_path / "model-Q8_0.gguf"
+ main.write_bytes(b"")
+ (tmp_path / "mmproj-F16.gguf").write_bytes(b"")
+ (tmp_path / "mtp-model-Q8_0.gguf").write_bytes(b"")
+ (mtp_dir / "model-Q8_0-MTP.gguf").write_bytes(b"")
+ (tmp_path / "model-Q8_0-be.gguf").write_bytes(b"")
+
+ assert _iter_ggufs(tmp_path) == [main]
+
+
+def test_find_gguf_in_dir_matches_quant_label(tmp_path):
+ mtp_dir = tmp_path / "MTP"
+ mtp_dir.mkdir()
+ main = tmp_path / "model-Q8_0.gguf"
+ main.write_bytes(b"")
+ (mtp_dir / "model-Q8_0-MTP.gguf").write_bytes(b"")
+ (tmp_path / "model-Q4_K_M.gguf").write_bytes(b"")
+
+ assert _find_gguf_in_dir(tmp_path, "Q8_0") == main
+ assert _find_gguf_in_dir(tmp_path, "Q4_K") is None
+
+
+def test_find_gguf_in_dir_without_variant_prefers_largest_model(tmp_path):
+ smaller = tmp_path / "a-model-Q4_K_M.gguf"
+ larger = tmp_path / "z-model-Q8_0.gguf"
+ smaller.write_bytes(b"0")
+ larger.write_bytes(b"00")
+
+ assert _find_gguf_in_dir(tmp_path, None) == larger
+
+
+def test_find_gguf_in_dir_without_variant_prefers_first_split(tmp_path):
+ first = tmp_path / "model-Q4_K_M-00001-of-00003.gguf"
+ second = tmp_path / "model-Q4_K_M-00002-of-00003.gguf"
+ third = tmp_path / "model-Q4_K_M-00003-of-00003.gguf"
+ first.write_bytes(b"0")
+ second.write_bytes(b"000")
+ third.write_bytes(b"00")
+
+ assert _find_gguf_in_dir(tmp_path, None) == first
+
+ first.unlink()
+ assert _find_gguf_in_dir(tmp_path, None) == second
+
+
+def test_find_gguf_in_dir_matches_bpw_variant_base_label(tmp_path):
+ target = tmp_path / "model-IQ4_XS-3.53bpw.gguf"
+ target.write_bytes(b"")
+ (tmp_path / "model-Q4_K_M.gguf").write_bytes(b"")
+
+ 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_with_variant_still_prefers_tokenizer(tmp_path, monkeypatch):
+ (tmp_path / "tokenizer_config.json").write_text(
+ json.dumps({"chat_template": "FROM_CONFIG"}), encoding = "utf-8"
+ )
+ (tmp_path / "model-Q4_K_M.gguf").write_bytes(b"")
+ monkeypatch.setattr("picker.service.read_gguf_chat_template", lambda _path: "FROM_GGUF")
+ # Selecting a variant must not flip precedence to the embedded GGUF template.
+ assert _chat_template_from_dir(tmp_path, "Q4_K_M") == "FROM_CONFIG"
+
+
+def test_chat_template_from_dir_with_variant_falls_back_to_gguf(tmp_path, monkeypatch):
+ (tmp_path / "model-Q4_K_M.gguf").write_bytes(b"")
+ monkeypatch.setattr("picker.service.read_gguf_chat_template", lambda _path: "FROM_GGUF")
+ # With no tokenizer sidecar, the embedded GGUF template is still the fallback.
+ assert _chat_template_from_dir(tmp_path, "Q4_K_M") == "FROM_GGUF"
+
+
+def test_chat_template_from_dir_returns_none_when_absent(tmp_path):
+ assert _chat_template_from_dir(tmp_path) is None
+
+
+def test_read_default_chat_template_direct_gguf_prefers_sidecar(tmp_path, monkeypatch):
+ gguf = tmp_path / "model-Q4_K_M.gguf"
+ gguf.write_bytes(b"")
+ (tmp_path / "tokenizer_config.json").write_text(
+ json.dumps({"chat_template": "FROM_CONFIG"}), encoding = "utf-8"
+ )
+ monkeypatch.setattr("picker.service._build_browse_allowlist", lambda: [tmp_path])
+ monkeypatch.setattr("picker.service.read_gguf_chat_template", lambda _path: "FROM_GGUF")
+ # A directly selected .gguf must prefer a maintained sidecar over its embedded copy.
+ assert read_default_chat_template(str(gguf)) == "FROM_CONFIG"
+
+
+def test_read_default_chat_template_direct_gguf_falls_back_to_embedded(tmp_path, monkeypatch):
+ gguf = tmp_path / "model-Q4_K_M.gguf"
+ gguf.write_bytes(b"")
+ monkeypatch.setattr("picker.service._build_browse_allowlist", lambda: [tmp_path])
+ monkeypatch.setattr("picker.service.read_gguf_chat_template", lambda _path: "FROM_GGUF")
+ # With no sidecar next to the file, the embedded GGUF template is the fallback.
+ assert read_default_chat_template(str(gguf)) == "FROM_GGUF"
+
+
+def test_tokenizer_config_over_size_limit_is_skipped_not_parsed(tmp_path):
+ # An oversized tokenizer_config.json must be skipped before json.loads so a
+ # hostile sidecar cannot exhaust memory.
+ padding = "x" * (MAX_TEMPLATE_METADATA_BYTES + 1024)
+ (tmp_path / "tokenizer_config.json").write_text(
+ json.dumps({"chat_template": "HELLO", "_pad": padding}), encoding = "utf-8"
+ )
+ assert _chat_template_from_tokenizer_dir(tmp_path) is None
+
+
+def test_processor_json_over_size_limit_is_skipped_not_parsed(tmp_path):
+ padding = "x" * (MAX_TEMPLATE_METADATA_BYTES + 1024)
+ (tmp_path / "chat_template.json").write_text(
+ json.dumps({"default": "HELLO", "_pad": padding}), encoding = "utf-8"
+ )
+ assert _chat_template_from_processor_json(tmp_path) is None
+
+
+def test_tokenizer_config_at_size_limit_is_still_read(tmp_path):
+ # A normal-sized config is unaffected by the bound (regression guard).
+ (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_remote_template_over_size_limit_is_skipped_before_download(monkeypatch):
+ # An uncached Hub repo whose template exceeds the cap must be skipped via the
+ # remote size pre-check, never downloaded.
+ import huggingface_hub
+
+ monkeypatch.setattr("picker.service.resolve_cached_repo_id_case", lambda name: name)
+ monkeypatch.setattr("picker.service.iter_hf_cache_snapshots", lambda resolved: [])
+
+ def _fail_download(*args, **kwargs):
+ raise AssertionError("oversized remote template must not be downloaded")
+
+ def _fake_get_paths_info(self, repo_id, paths, **kwargs):
+ return [SimpleNamespace(path = p, size = MAX_TEMPLATE_METADATA_BYTES + 1) for p in paths]
+
+ monkeypatch.setattr(huggingface_hub, "hf_hub_download", _fail_download)
+ monkeypatch.setattr(huggingface_hub.HfApi, "get_paths_info", _fake_get_paths_info)
+
+ assert read_default_chat_template("org/oversized-model") is None
+
+
+def test_remote_oversized_jinja_falls_through_to_tokenizer_template(tmp_path, monkeypatch):
+ # A raw chat_template.jinja between the response cap (MAX_CHAT_TEMPLATE_BYTES)
+ # and the download bound (MAX_TEMPLATE_METADATA_BYTES) must not be returned: the
+ # route drops it, so the remote path must skip the oversized Jinja and fall
+ # through to the smaller tokenizer_config.json.
+ import huggingface_hub
+ from picker.schemas import MAX_CHAT_TEMPLATE_BYTES
+
+ big_jinja = tmp_path / "chat_template.jinja"
+ big_jinja.write_text("{{ x }}" * (MAX_CHAT_TEMPLATE_BYTES // 4), encoding = "utf-8")
+ assert MAX_CHAT_TEMPLATE_BYTES < big_jinja.stat().st_size < MAX_TEMPLATE_METADATA_BYTES
+ tokenizer_config = tmp_path / "tokenizer_config.json"
+ tokenizer_config.write_text(json.dumps({"chat_template": "SMALL_TEMPLATE"}), encoding = "utf-8")
+ files = {
+ "chat_template.jinja": big_jinja,
+ "tokenizer_config.json": tokenizer_config,
+ }
+
+ monkeypatch.setattr("picker.service.resolve_cached_repo_id_case", lambda name: name)
+ monkeypatch.setattr("picker.service.iter_hf_cache_snapshots", lambda resolved: [])
+
+ def _fake_download(repo_id, rel, **kwargs):
+ target = files.get(rel)
+ if target is None:
+ raise FileNotFoundError(rel)
+ return str(target)
+
+ def _fake_get_paths_info(self, repo_id, paths, **kwargs):
+ return [
+ SimpleNamespace(
+ path = p,
+ size = files[p].stat().st_size if p in files else 0,
+ )
+ for p in paths
+ ]
+
+ monkeypatch.setattr(huggingface_hub, "hf_hub_download", _fake_download)
+ monkeypatch.setattr(huggingface_hub.HfApi, "get_paths_info", _fake_get_paths_info)
+
+ assert read_default_chat_template("org/big-jinja-model") == "SMALL_TEMPLATE"
diff --git a/studio/backend/utils/models/gguf_metadata.py b/studio/backend/utils/models/gguf_metadata.py
index 50b3cd3513..749f2c9234 100644
--- a/studio/backend/utils/models/gguf_metadata.py
+++ b/studio/backend/utils/models/gguf_metadata.py
@@ -50,10 +50,14 @@ _CACHE_MAX_ENTRIES = 4096
# keyed by (file cache key, wanted key). None = key absent / file unreadable.
_BOOL_CACHE: Dict[Tuple[_CacheKey, str], Optional[bool]] = {}
+_STRING_CACHE: Dict[Tuple[_CacheKey, str], Optional[str]] = {}
+
# GGUF header dims for the staged/deferred-load UI: context_length, layer_count
# (block_count), and moe_layer_count (block_count minus leading dense layers; 0
# if not MoE). One cached pass fills all three so the staged sheet can size every
-# slider before the model loads. None = unreadable / not a GGUF.
+# slider before the model loads. None = unreadable / not a GGUF. The native
+# training context length (``{arch}.context_length``) the UI shows before a model
+# loads is read from here via read_gguf_context_length.
_DIMS_CACHE: Dict[_CacheKey, Optional[Dict[str, Optional[int]]]] = {}
@@ -408,6 +412,83 @@ def _read_gguf_bool(path: str, wanted_key: str) -> Optional[bool]:
return result
+def _parse_gguf_string(path: str, wanted_key: str) -> Optional[str]:
+ try:
+ with open(path, "rb") as f:
+ head = f.read(24)
+ if len(head) < 24:
+ return None
+ magic, _version, _tcount, kv_count = struct.unpack(" 1 << 20:
+ break
+ kbytes = f.read(klen)
+ if len(kbytes) < klen:
+ break
+ key = kbytes.decode("utf-8", "replace")
+ vt_bytes = f.read(4)
+ if len(vt_bytes) < 4:
+ break
+ vtype = struct.unpack(" 1 << 22:
+ break
+ sbytes = f.read(slen)
+ if len(sbytes) < slen:
+ break
+ return sbytes.decode("utf-8", "replace")
+ if not _skip_gguf_value(f, vtype):
+ break
+ except (struct.error, UnicodeDecodeError):
+ break
+ except OSError as e:
+ logger.debug(f"_parse_gguf_string: cannot open {path}: {e}")
+ return None
+ except Exception as e:
+ logger.debug(f"_parse_gguf_string: parse failure on {path}: {e}")
+ return None
+ return None
+
+
+def _read_gguf_string(path: str, wanted_key: str) -> Optional[str]:
+ fkey = _cache_key(path)
+ if fkey is None:
+ return None
+ ckey = (fkey, wanted_key)
+ with _CACHE_LOCK:
+ if ckey in _STRING_CACHE:
+ return _STRING_CACHE[ckey]
+ result = _parse_gguf_string(path, wanted_key)
+ with _CACHE_LOCK:
+ while len(_STRING_CACHE) >= _CACHE_MAX_ENTRIES:
+ try:
+ _STRING_CACHE.pop(next(iter(_STRING_CACHE)))
+ except StopIteration:
+ break
+ _STRING_CACHE[ckey] = result
+ return result
+
+
+def read_gguf_chat_template(path: str) -> Optional[str]:
+ template = _read_gguf_string(path, "tokenizer.chat_template")
+ if isinstance(template, str) and template.strip():
+ return template
+ return None
+
+
def read_mmproj_audio_capability(path: str) -> Optional[bool]:
"""``clip.has_audio_encoder`` from an mmproj GGUF (e.g. Gemma 4's
gemma4ua): ``True``/``False`` if present, ``None`` if absent/unreadable.
diff --git a/studio/frontend/src/app/routes/__root.tsx b/studio/frontend/src/app/routes/__root.tsx
index 57e890dd5a..7137fd6f96 100644
--- a/studio/frontend/src/app/routes/__root.tsx
+++ b/studio/frontend/src/app/routes/__root.tsx
@@ -196,9 +196,6 @@ function RootLayout() {
chatRuntime.setActiveThreadId(null);
chatRuntime.setActiveProjectId(null);
chatRuntime.setIncognito(false);
- // Detach the staging UI but keep any in-flight download running, like Hub.
- if (chatRuntime.pendingSelection)
- chatRuntime.abandonStagedModel({ keepDownload: true });
void navigate({
to: "/chat",
search: { new: crypto.randomUUID() },
@@ -221,10 +218,6 @@ function RootLayout() {
chatRuntime.setActiveProjectId(null);
chatRuntime.setActiveThreadId(null);
chatRuntime.setIncognito(false);
- // Leaving chat must not kill an in-flight download: detach the staging UI
- // but keep the transfer running in the manager, like a Hub download.
- if (chatRuntime.pendingSelection)
- chatRuntime.abandonStagedModel({ keepDownload: true });
}, [isChatRoute]);
return (
diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx
index 8eab03133b..293971904f 100644
--- a/studio/frontend/src/components/app-sidebar.tsx
+++ b/studio/frontend/src/components/app-sidebar.tsx
@@ -1011,28 +1011,6 @@ export function AppSidebar() {
- {isPinned ? (
-
-
-
-
-
- Unpin
-
-
- ) : null}
);
}
diff --git a/studio/frontend/src/components/assistant-ui/model-selector/remembered-load-settings.ts b/studio/frontend/src/components/assistant-ui/model-selector/remembered-load-settings.ts
deleted file mode 100644
index 08492ab480..0000000000
--- a/studio/frontend/src/components/assistant-ui/model-selector/remembered-load-settings.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
-
-// Per-model pre-load inference settings, persisted in localStorage so the load
-// dialog can offer "Remember settings for ". GGUF picks only: every
-// field is a llama.cpp load knob, so all save/restore call sites gate on
-// GGUF-ness (a non-GGUF blob would only snapshot leftover standing values).
-
-const KEY = "unsloth_load_settings";
-
-export interface RememberedLoadSettings {
- contextLength: number | null;
- kvCacheDtype: string | null;
- speculativeType: string | null;
- specDraftNMax: number | null;
- tensorParallel: boolean;
- // GPU Memory controls. Optional so an older blob (which lacked them) still
- // parses, leaving the live knobs untouched on apply. The mode is kept with the
- // manual knobs (gpuLayers/nCpuMoe are ignored outside Manual mode). A null
- // selectedGpuIds is meaningful (all GPUs), so it's distinguished from absent.
- // The per-GPU split ratio is deliberately NOT remembered: it's positionally
- // bound to the exact GPU set/order and unvalidated, so it would mismatch.
- gpuMemoryMode?: "auto" | "manual";
- gpuLayers?: number;
- nCpuMoe?: number;
- selectedGpuIds?: number[] | null;
-}
-
-// Storage key for a pick's remembered settings, scoped per quant (the VRAM-budget
-// knobs differ per quant). An HF repo collapses its GGUF variants into one `id`,
-// so fold the variant in. Local .gguf paths are already file-specific; native
-// drag-drop files key by display label, so same-named files share an entry.
-export function rememberedLoadSettingsKey(selection: {
- id: string;
- ggufVariant?: string | null;
-}): string {
- return selection.ggufVariant
- ? `${selection.id}::${selection.ggufVariant}`
- : selection.id;
-}
-
-function readAll(): Record {
- try {
- return JSON.parse(localStorage.getItem(KEY) ?? "{}");
- } catch {
- return {};
- }
-}
-
-function writeAll(all: Record) {
- try {
- localStorage.setItem(KEY, JSON.stringify(all));
- } catch {
- // Ignore quota / unavailable storage.
- }
-}
-
-export function loadRememberedLoadSettings(
- key: string,
-): RememberedLoadSettings | null {
- return readAll()[key] ?? null;
-}
-
-export function saveRememberedLoadSettings(
- key: string,
- settings: RememberedLoadSettings,
-) {
- const all = readAll();
- all[key] = settings;
- writeAll(all);
-}
-
-export function clearRememberedLoadSettings(key: string) {
- const all = readAll();
- if (key in all) {
- delete all[key];
- writeAll(all);
- }
-}
diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts
index 7083f02288..b0127b5e40 100644
--- a/studio/frontend/src/features/chat/api/chat-adapter.ts
+++ b/studio/frontend/src/features/chat/api/chat-adapter.ts
@@ -2,10 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { getAuthToken } from "@/features/auth";
-import {
- loadRememberedLoadSettings,
- rememberedLoadSettingsKey,
-} from "@/components/assistant-ui/model-selector/remembered-load-settings";
+import { resolveInitialConfig } from "@/features/model-picker";
import { projectHasSources } from "@/features/rag/api/rag-api";
import { apiUrl } from "@/lib/api-base";
import { parseParamCountB } from "@/lib/model-size";
@@ -46,7 +43,7 @@ import {
type PendingImageEditReference,
type RagAutoInject,
GPU_LAYERS_AUTO,
- loadedGpuMemoryFieldsUnlessStaged,
+ loadedGpuMemoryFields,
reconcilePersistedGpuIds,
resolveLoadedSpeculativeSettings,
resolveSpeculativeSettingsForLoad,
@@ -1533,65 +1530,56 @@ async function autoLoadSmallestModel(): Promise<{
return false;
}
const currentStore = useChatRuntimeStore.getState();
- // Blobs are saved for GGUF picks only (the sheet gates on it), so don't
- // let a legacy non-GGUF blob feed a stale context/spec choice into a
- // safetensors auto-load.
- const remembered =
- candidate.kind === "gguf"
- ? loadRememberedLoadSettings(
- rememberedLoadSettingsKey({
- id: candidate.id,
- ggufVariant: candidate.ggufVariant,
- }),
- )
- : null;
+ const { config } = resolveInitialConfig(candidate.id, candidate.ggufVariant);
const effectiveMaxSeqLength = resolveLoadMaxSeqLength({
modelId: candidate.id,
ggufVariant: candidate.ggufVariant,
isGguf: candidate.kind === "gguf",
- customContextLength: remembered?.contextLength ?? null,
+ customContextLength: config.customContextLength,
ggufContextLength: null,
currentCheckpoint: currentStore.params.checkpoint,
activeGgufVariant: currentStore.activeGgufVariant,
- maxSeqLength: candidate.maxSeqLength,
+ maxSeqLength: config.maxSeqLength ?? candidate.maxSeqLength,
presetSource: currentStore.activePresetSource,
});
- // The GPU knobs are per-model, so read them from the same remembered
- // settings that fed effectiveMaxSeqLength -- on a background auto-load the
- // live store holds session defaults, not the saved Manual mode / layer pin /
- // GPU pick. Absent fields fall back like applyRememberedLoadSettings: the
- // mode to the store (a persisted standing preference), the per-model knobs to
- // their defaults. The saved GPU pick is reconciled against the GPUs present
- // now, like the interactive restore.
+ // The GPU knobs are per-model, so read them from the same per-model config
+ // that fed effectiveMaxSeqLength -- on a background auto-load the live store
+ // holds session defaults, not the saved Manual mode / layer pin / GPU pick.
+ // Absent fields fall back like the interactive restore: the mode to the store
+ // (a persisted standing preference), the per-model knobs to their defaults.
+ // The saved GPU pick is reconciled against the GPUs present now.
const effectiveGpuMemoryMode =
- remembered?.gpuMemoryMode ?? currentStore.gpuMemoryMode;
- const effectiveGpuLayers = remembered?.gpuLayers ?? GPU_LAYERS_AUTO;
- const effectiveNCpuMoe = remembered?.nCpuMoe ?? 0;
- if (remembered?.selectedGpuIds != null) {
+ config.gpuMemoryMode ?? currentStore.gpuMemoryMode;
+ const effectiveGpuLayers = config.gpuLayers ?? GPU_LAYERS_AUTO;
+ const effectiveNCpuMoe = config.nCpuMoe ?? 0;
+ if (config.selectedGpuIds != null) {
// Warm the device cache first: on a cold cache the reconcile passes the
// saved pick through unvalidated, and a stale cross-host pick then fails
// the load with the picker hidden.
await ensureGpuDeviceCache();
}
const effectiveGpuIds =
- remembered?.selectedGpuIds !== undefined
- ? reconcilePersistedGpuIds(remembered.selectedGpuIds)
+ config.selectedGpuIds !== undefined
+ ? reconcilePersistedGpuIds(config.selectedGpuIds)
: null;
// Under Manual GPU memory + Auto layers, llama.cpp's --fit owns context
// sizing, so send 0 (or the pinned length). GGUF-only; a no-op otherwise.
- // The context pin is per-model too, so it comes from remembered settings,
- // not the live store.
+ // The context pin is per-model too, so it comes from the saved config, not
+ // the live store.
const fitMaxSeqLength = resolveFitMaxSeqLength(
candidate.kind === "gguf",
effectiveGpuMemoryMode,
effectiveGpuLayers,
- remembered?.contextLength ?? null,
+ config.customContextLength ?? null,
effectiveMaxSeqLength,
);
const effectiveSpeculativeType =
- remembered?.speculativeType ?? specSettings.speculativeType;
+ config.speculativeType ?? specSettings.speculativeType;
const effectiveSpecDraftNMax =
- remembered?.specDraftNMax ?? specSettings.specDraftNMax;
+ config.specDraftNMax ?? specSettings.specDraftNMax;
+ const effectiveChatTemplateOverride = config.chatTemplateOverride?.trim()
+ ? config.chatTemplateOverride
+ : null;
if (
!(await canAutoLoad({
model_path: candidate.id,
@@ -1621,10 +1609,11 @@ async function autoLoadSmallestModel(): Promise<{
is_lora: false,
gguf_variant: candidate.ggufVariant,
trust_remote_code: trustRemoteCode,
- cache_type_kv: remembered?.kvCacheDtype ?? null,
+ chat_template_override: effectiveChatTemplateOverride,
+ cache_type_kv: config.kvCacheDtype,
speculative_type: effectiveSpeculativeType,
spec_draft_n_max: effectiveSpecDraftNMax,
- tensor_parallel: remembered?.tensorParallel ?? false,
+ tensor_parallel: config.tensorParallel,
// GGUF-only: the safetensors fallback loads via HF auto-placement (no
// explicit pins). The split ratio is deliberately never remembered
// (positionally bound to an exact GPU set), so auto-load leaves llama.cpp's
@@ -1638,7 +1627,12 @@ async function autoLoadSmallestModel(): Promise<{
}
: {}),
});
- saveSpeculativeType(effectiveSpeculativeType);
+ // Only persist the global preference when the value came from the global
+ // settings. A per-model config's choice must stay load-local, or autoloading
+ // a remembered model on startup would rewrite the global default.
+ if (config.speculativeType == null) {
+ saveSpeculativeType(effectiveSpeculativeType);
+ }
// Self-gates on is_gguf (skips diffusion), so persists only for a real GGUF load.
persistGpuMemoryModeOnLoad(loadResp, effectiveGpuMemoryMode);
useChatRuntimeStore
@@ -1650,6 +1644,9 @@ async function autoLoadSmallestModel(): Promise<{
);
store.setParams({
...store.params,
+ ...(candidate.kind === "gguf"
+ ? {}
+ : { maxSeqLength: effectiveMaxSeqLength }),
maxTokens:
candidate.kind === "gguf"
? loadResp.context_length ?? 131072
@@ -1676,7 +1673,7 @@ async function autoLoadSmallestModel(): Promise<{
const keepCustomCtx = resolveManualAutoCtxPin(
effectiveGpuMemoryMode,
effectiveGpuLayers,
- remembered?.contextLength ?? null,
+ config.customContextLength ?? null,
);
useChatRuntimeStore.setState({
ggufContextLength: loadResp.context_length ?? 131072,
@@ -1694,13 +1691,14 @@ async function autoLoadSmallestModel(): Promise<{
loadedKvCacheDtype: loadResp.cache_type_kv ?? null,
tensorParallel: loadResp.tensor_parallel ?? false,
loadedTensorParallel: loadResp.tensor_parallel ?? false,
- ...loadedGpuMemoryFieldsUnlessStaged(loadResp, {
- customContextLength: keepCustomCtx,
- }),
+ ...loadedGpuMemoryFields(loadResp),
loadedCustomContextLength: keepCustomCtx,
defaultChatTemplate: loadResp.chat_template ?? null,
- chatTemplateOverride: null,
- loadedChatTemplateOverride: null,
+ chatTemplateOverride: effectiveChatTemplateOverride,
+ loadedChatTemplateOverride: effectiveChatTemplateOverride,
+ // Retain the saved requested context so re-saving the config keeps the
+ // override; null stays null (auto/VRAM-fit).
+ customContextLength: config.customContextLength,
loadedIsMultimodal: isMultimodalResponse(loadResp),
loadedIsDiffusion: loadResp.is_diffusion ?? false,
...resolveLoadedSpeculativeSettings(loadResp),
@@ -1720,10 +1718,11 @@ async function autoLoadSmallestModel(): Promise<{
loadedTensorParallel: loadResp.tensor_parallel ?? false,
// Non-GGUF response: clears any stale GPU baseline a prior manual-GPU
// GGUF load left, matching the interactive/status sibling load paths.
- ...loadedGpuMemoryFieldsUnlessStaged(loadResp),
+ ...loadedGpuMemoryFields(loadResp),
defaultChatTemplate: loadResp.chat_template ?? null,
- chatTemplateOverride: null,
- loadedChatTemplateOverride: null,
+ chatTemplateOverride: effectiveChatTemplateOverride,
+ loadedChatTemplateOverride: effectiveChatTemplateOverride,
+ customContextLength: null,
...resolveLoadedSpeculativeSettings(loadResp),
loadedIsMultimodal: isMultimodalResponse(loadResp),
loadedIsDiffusion: loadResp.is_diffusion ?? false,
@@ -1988,7 +1987,7 @@ async function autoLoadSmallestModel(): Promise<{
loadedKvCacheDtype: loadResp.cache_type_kv ?? null,
tensorParallel: loadResp.tensor_parallel ?? false,
loadedTensorParallel: loadResp.tensor_parallel ?? false,
- ...loadedGpuMemoryFieldsUnlessStaged(loadResp),
+ ...loadedGpuMemoryFields(loadResp),
// Drives the GPU Memory controls' diffusion gate; set alongside the
// GPU fields on every load path so the gate can't read stale.
loadedIsDiffusion: loadResp.is_diffusion ?? false,
diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts
index 631474c39a..de3e5e370c 100644
--- a/studio/frontend/src/features/chat/api/chat-api.ts
+++ b/studio/frontend/src/features/chat/api/chat-api.ts
@@ -377,14 +377,33 @@ export async function listCachedModels(
return data.cached;
}
-export async function deleteCachedModel(
+export interface CachedModelPath {
+ path: string;
+ is_dir: boolean;
+}
+
+/** Absolute on-disk path of a cached repo or one of its GGUF variants. */
+export async function getCachedModelPath(
+ repoId: string,
+ variant?: string,
+): Promise {
+ const params = new URLSearchParams({ repo_id: repoId });
+ if (variant) params.set("variant", variant);
+ const response = await authFetch(
+ `/api/models/cached-model-path?${params.toString()}`,
+ );
+ return parseJsonOrThrow(response);
+}
+
+/** Reveal a cached repo (or one GGUF variant's file) in the OS file manager. */
+export async function revealCachedModel(
repoId: string,
variant?: string,
): Promise {
const payload: Record = { repo_id: repoId };
if (variant) payload.variant = variant;
- const response = await authFetch("/api/models/delete-cached", {
- method: "DELETE",
+ const response = await authFetch("/api/models/reveal-cached-model", {
+ method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx
index 217eaf8b6d..ef018445e0 100644
--- a/studio/frontend/src/features/chat/chat-page.tsx
+++ b/studio/frontend/src/features/chat/chat-page.tsx
@@ -2,16 +2,19 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import {
+ applyModelLoadConfigToRuntime,
+ currentRuntimePerModelConfig,
type DeletedModelRef,
type ExternalModelOption,
type LoraModelOption,
type ModelOption,
ModelSelector,
-} from "@/components/assistant-ui/model-selector";
-import {
- loadRememberedLoadSettings,
- rememberedLoadSettingsKey,
-} from "@/components/assistant-ui/model-selector/remembered-load-settings";
+ type ModelSelectorChangeMeta,
+ type PerModelConfig,
+ resolveInitialConfig,
+ SidebarModelConfig,
+ useActiveModelConfig,
+} from "@/features/model-picker";
import { ProjectComposer, Thread } from "@/components/assistant-ui/thread";
import { CopyableErrorChip } from "@/components/ui/copyable-error-chip";
import {
@@ -27,10 +30,10 @@ import {
} from "@/components/ui/resizable";
import { useSidebar } from "@/components/ui/sidebar";
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
-import { useLatestRef } from "@/features/hub/hooks/use-latest-ref";
import {
DOWNLOAD_KIND,
downloadManager,
+ useRepoDownload,
} from "@/features/hub/download-manager";
import {
type NativeIntent,
@@ -93,7 +96,6 @@ import {
renameChatItem,
useChatSidebarItems,
} from "./hooks/use-chat-sidebar-items";
-import { useStagedModelPreparation } from "./hooks/use-staged-model-preparation";
import {
clearTrainingCompareHandoff,
getTrainingCompareHandoff,
@@ -128,10 +130,8 @@ import {
hasGgufSource,
isDownloadableHubRepo,
loadOptionalBool,
- pendingSelectionMatches,
useChatRuntimeStore,
} from "./stores/chat-runtime-store";
-import type { PendingModelSelection } from "./stores/chat-runtime-store";
import { useChatPreferencesStore } from "./stores/chat-preferences-store";
import { useExternalProvidersStore } from "./stores/external-providers-store";
import { buildChatTourSteps } from "./tour";
@@ -385,6 +385,7 @@ type CompareModelSelection = {
id: string;
isLora: boolean;
ggufVariant?: string;
+ config?: PerModelConfig;
};
function modelMatchesDeleted(
@@ -645,6 +646,8 @@ function GeneralCompareHeader({
loraModels,
externalModels,
value,
+ selectedConfig,
+ selectedGgufVariant,
onValueChange,
onFoldersChange,
onModelsChange,
@@ -655,9 +658,11 @@ function GeneralCompareHeader({
loraModels: LoraModelOption[];
externalModels: ExternalModelOption[];
value: string;
+ selectedConfig?: PerModelConfig | null;
+ selectedGgufVariant?: string | null;
onValueChange: (
id: string,
- meta: { isLora: boolean; ggufVariant?: string },
+ meta: ModelSelectorChangeMeta,
) => void;
onFoldersChange?: () => void;
onModelsChange?: (deletedModel?: DeletedModelRef) => void;
@@ -684,6 +689,8 @@ function GeneralCompareHeader({
loraModels={loraModels}
externalModels={externalModels}
value={value}
+ selectedConfig={selectedConfig}
+ selectedGgufVariant={selectedGgufVariant}
onValueChange={onValueChange}
onFoldersChange={onFoldersChange}
onModelsChange={onModelsChange}
@@ -811,11 +818,14 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
loraModels={loraModels}
externalModels={externalModels}
value={model1.id}
+ selectedConfig={model1.config}
+ selectedGgufVariant={model1.ggufVariant}
onValueChange={(id, meta) =>
setModel1({
id,
isLora: meta.isLora,
ggufVariant: meta.ggufVariant,
+ config: meta.config,
})
}
onFoldersChange={onFoldersChange}
@@ -838,11 +848,14 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
loraModels={loraModels}
externalModels={externalModels}
value={model2.id}
+ selectedConfig={model2.config}
+ selectedGgufVariant={model2.ggufVariant}
onValueChange={(id, meta) =>
setModel2({
id,
isLora: meta.isLora,
ggufVariant: meta.ggufVariant,
+ config: meta.config,
})
}
onFoldersChange={onFoldersChange}
@@ -1236,6 +1249,13 @@ export function validateChatSearch(search: Record): ChatSearch
};
}
+type PendingHubAutoLoad = {
+ selection: SelectedModelInput;
+ contextKey: string;
+ originCheckpoint: string;
+ originGgufVariant: string | null;
+};
+
// `search` comes from RootLayout (not useSearch) so ChatPage stays mounted off-route
// (keeping an in-flight generation alive), frozen to the last /chat search. `active`
// is false off-route: close body-portaled surfaces and stop route-specific listeners
@@ -1248,30 +1268,6 @@ export function ChatPage({
const settingsOpen = useChatRuntimeStore((s) => s.settingsPanelOpen);
const setSettingsOpen = useChatRuntimeStore((s) => s.setSettingsPanelOpen);
- // Deferred-load staging: downloads a staged GGUF (if needed) and reads its
- // header context so the sheet can show the context slider before the load.
- // autoLoad picks instead load the cached file as soon as the download ends;
- // selectModel is defined below, so the load runs through a ref.
- const autoLoadStagedRef = useRef<
- ((pending: PendingModelSelection) => void) | null
- >(null);
- const stagedDownload = useStagedModelPreparation({
- onAutoLoad: (pending) => autoLoadStagedRef.current?.(pending),
- });
- // Abandon a staged pick: the store action cancels its in-flight download and
- // reverts the edited knobs, so nothing lingers after the user walks away.
- const abandonStaged = useCallback(() => {
- useChatRuntimeStore.getState().abandonStagedModel();
- }, []);
- // Detach a staged pick on navigation without cancelling its download: the
- // transfer keeps running in the manager and lands in cache, like Hub.
- const detachStaged = useCallback(() => {
- useChatRuntimeStore.getState().abandonStagedModel({ keepDownload: true });
- }, []);
- // Tracks whether the chat page is still mounted, so a staged-load failure that
- // resolves after the user left chat doesn't resurrect the abandoned pick.
- const mountedRef = useRef(true);
- useEffect(() => () => void (mountedRef.current = false), []);
const incognito = useChatRuntimeStore((s) => s.incognito);
const setIncognito = useChatRuntimeStore((s) => s.setIncognito);
const incognitoLabel = incognito
@@ -1363,6 +1359,9 @@ export function ChatPage({
const ggufContextLength = useChatRuntimeStore(
(state) => state.ggufContextLength,
);
+ const ggufNativeContextLength = useChatRuntimeStore(
+ (state) => state.ggufNativeContextLength,
+ );
const contextUsage = useChatRuntimeStore((state) => state.contextUsage);
const modelsFromStore = useChatRuntimeStore((state) => state.models);
const lorasFromStore = useChatRuntimeStore((state) => state.loras);
@@ -1440,39 +1439,37 @@ export function ChatPage({
refreshRef.current = refresh;
selectModelRef.current = selectModel;
}, [refresh, selectModel]);
- // Load a cached autoLoad pick once its download finishes. The sheet was never
- // opened, so on a load failure just drop the orphaned staged knobs. The knobs
- // were already seeded on stage, so keepSpeculative only when a config was
- // saved -- otherwise the standing speculative preference should win.
- autoLoadStagedRef.current = (pending) => {
- // Blobs are saved for GGUF picks only (the sheet gates on it), so don't
- // let a legacy non-GGUF blob claim a seeded config here.
- const remembered = hasGgufSource(pending)
- ? loadRememberedLoadSettings(rememberedLoadSettingsKey(pending))
- : null;
- void selectModel({
- ...pending,
- isDownloaded: true,
- forceReload: true,
- keepSpeculative: remembered != null,
- throwOnError: true,
- }).catch(() => {
- const store = useChatRuntimeStore.getState();
- // selectModel only clears pendingSelection on success, so a failed
- // auto-load leaves our staged pick (and its edited load knobs) behind.
- // Abandon it when it is still the active stage; otherwise just revert the
- // settings if the stage was already cleared by something else.
- if (pendingSelectionMatches(store.pendingSelection, pending)) {
- store.abandonStagedModel();
- } else if (!store.pendingSelection) {
- store.resetModelSettingsToLoaded();
- }
- });
- };
+ const rememberedConfigFor = useCallback(
+ (selection: {
+ id: string;
+ ggufVariant?: string | null;
+ source?: string;
+ }) => {
+ if (selection.source === "external") return null;
+ const resolved = resolveInitialConfig(selection.id, selection.ggufVariant);
+ return resolved.remembered ? resolved.config : null;
+ },
+ [],
+ );
const isExternalModel = useMemo(
() => isExternalModelId(inferenceParams.checkpoint),
[inferenceParams.checkpoint],
);
+ const {
+ checkpoint: runtimeCheckpoint,
+ isGguf: runtimeModelIsGguf,
+ config: activeModelConfig,
+ } = useActiveModelConfig();
+ const activeModelIsGguf =
+ runtimeCheckpoint != null && !isExternalModel && runtimeModelIsGguf;
+ const activeModelIsLora = useMemo(() => {
+ const checkpoint = inferenceParams.checkpoint;
+ if (!checkpoint || isExternalModel) return false;
+ const model = modelsFromStore.find((entry) => entry.id === checkpoint);
+ if (model) return model.isLora;
+ const lora = lorasFromStore.find((entry) => entry.id === checkpoint);
+ return lora?.exportType === "lora";
+ }, [inferenceParams.checkpoint, isExternalModel, modelsFromStore, lorasFromStore]);
const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled);
const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle);
const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
@@ -1783,75 +1780,21 @@ export function ChatPage({
closeArtifactSurface();
}, [activeThreadId, closeArtifactSurface, selectedArtifact, view]);
- // Abandon a staged (not-yet-loaded) pick when the chat context actually
- // changes — switching threads, leaving single view, or starting a new chat /
- // project — so a stale Load button can't resurface in a different context.
- // New Chat keeps activeThreadId null and only bumps the `new` search nonce, so
- // the key includes the route identity, not just the thread. Mirrors the
- // incognito reset pattern. (Route exit is handled in __root.tsx, which runs
- // after this unmounts.) Clear only on a real change, never on mount: staging
- // from the Hub sets pendingSelection then navigates here, and clearing on
- // mount would wipe it. Comparing the previous context (rather than a first-run
- // flag) is also safe under StrictMode's double-invoke and component remounts.
- const chatContextKey = `${view.mode}|${activeThreadId ?? ""}|${search.new ?? ""}|${search.project ?? ""}`;
- const chatContextKeyRef = useLatestRef(chatContextKey);
- const prevChatContextRef = useRef(null);
- useEffect(() => {
- const prev = prevChatContextRef.current;
- prevChatContextRef.current = chatContextKey;
- if (prev === null || prev === chatContextKey) return;
- detachStaged();
- }, [chatContextKey, detachStaged]);
-
const hasActiveModel = Boolean(inferenceParams.checkpoint);
- // Load immediately, or — when "Load on selection" is off — stage the pick so
- // its load options can be set first. Shared by the main selector, native
- // drag-drop/picker, and the dropped-file chip (the Hub stages via the store).
+ const chatContextKey = `${view.mode}|${activeThreadId ?? ""}|${search.new ?? ""}|${search.project ?? ""}`;
+ const [pendingHubAutoLoad, setPendingHubAutoLoad] =
+ useState(null);
const stageOrLoad = useCallback(
async (selection: SelectedModelInput) => {
const store = useChatRuntimeStore.getState();
- // An un-cached HF repo (GGUF variant or a full non-GGUF snapshot) downloads
- // through the manager first (global indicator), then auto-loads. Everything
- // else -- cached picks, local/native files, LoRA, external -- loads now.
const wantManagerDownload =
isDownloadableHubRepo(selection) && !selection.isDownloaded;
- if (
- (!hasGgufSource(selection) && !wantManagerDownload) ||
- (store.loadOnSelection && selection.isDownloaded)
- ) {
- // Detach any staged pick first so its edited knobs (e.g. a custom
- // context length) don't leak into this immediate load -- resolveLoad
- // reads customContextLength before checking the target is GGUF. Detach
- // (not abandon) keeps its download running.
- detachStaged();
- // Load-on-selection skips the sheet, so seed the saved knobs here the
- // way the sheet's restore effect would; the switch would otherwise reset
- // the remembered speculative choice (keepSpeculative below prevents it).
- const remembered = hasGgufSource(selection)
- ? loadRememberedLoadSettings(rememberedLoadSettingsKey(selection))
- : null;
- if (remembered) store.applyRememberedLoadSettings(remembered);
- await selectModel(
- remembered ? { ...selection, keepSpeculative: true } : selection,
- );
- return;
- }
- // Loads can't queue behind each other, but a download is independent: if
- // the pick needs downloading, start it in the manager so it runs alongside
- // the load. Nothing to download (already on device) just waits.
if (store.modelLoading) {
- // Both an uncached non-GGUF snapshot (wantManagerDownload) and an
- // uncached remote GGUF quant download through the manager, so either can
- // run in the background while another model loads. wantManagerDownload
- // excludes GGUF by design, so the GGUF case is checked separately.
const wantBackgroundDownload =
wantManagerDownload ||
(selection.source === "hub" &&
hasGgufSource(selection) &&
!selection.isDownloaded);
- // The model currently loading already downloads as part of its own load
- // (the /load flow fetches before setting the checkpoint), so re-picking
- // it must not kick off a second transfer against the same cache.
const isLoadingThisPick =
!!loadingModel &&
normalizeModelRef(loadingModel.id) ===
@@ -1862,11 +1805,6 @@ export function ChatPage({
description: "It's downloading as part of the load in progress.",
});
} else if (wantBackgroundDownload) {
- // Only claim the download started once a job is actually created. A
- // transport conflict records state that is only resolvable from the
- // Hub download card, so point the user there instead of showing a
- // success toast for a transfer that never began; "busy" and "error"
- // already surface their own toasts.
const outcome = await downloadManager.requestStart({
kind: DOWNLOAD_KIND.MODEL,
repoId: selection.id,
@@ -1883,6 +1821,11 @@ export function ChatPage({
description:
"An earlier partial download used a different transport. Open the Hub tab to resume or restart it.",
});
+ } else if (outcome === "busy") {
+ toast.info("Download already in progress", {
+ description:
+ "Another download for this model is still running. Reselect it once that finishes to load it.",
+ });
}
} else {
toast.info("Another model is already loading", {
@@ -1891,23 +1834,128 @@ export function ChatPage({
}
return;
}
- // Detach the prior staged pick (keeping its download) before rebinding, so
- // a second pick downloads alongside the first instead of cancelling it.
- detachStaged();
- store.stageModel({
- id: selection.id,
- isLora: selection.isLora,
- ggufVariant: selection.ggufVariant,
- isDownloaded: selection.isDownloaded,
- expectedBytes: selection.expectedBytes,
- nativePathToken: selection.nativePathToken,
- isGguf: selection.isGguf,
- isHubRepo: wantManagerDownload || undefined,
- autoLoad: store.loadOnSelection,
+ const wantManagerStage =
+ wantManagerDownload ||
+ (selection.source === "hub" &&
+ hasGgufSource(selection) &&
+ !selection.isDownloaded);
+ if (wantManagerStage) {
+ setPendingHubAutoLoad((current) =>
+ current &&
+ current.selection.id === selection.id &&
+ (current.selection.ggufVariant ?? null) ===
+ (selection.ggufVariant ?? null) &&
+ current.contextKey === chatContextKey &&
+ current.originCheckpoint === store.params.checkpoint &&
+ current.originGgufVariant === store.activeGgufVariant
+ ? current
+ : {
+ selection,
+ contextKey: chatContextKey,
+ originCheckpoint: store.params.checkpoint,
+ originGgufVariant: store.activeGgufVariant,
+ },
+ );
+ return;
+ }
+ setPendingHubAutoLoad(null);
+ const previousConfig = currentRuntimePerModelConfig({
+ includeMaxSeqLength: true,
+ });
+ const hasAppliedConfig = applyModelLoadConfigToRuntime(
+ selection.config ?? rememberedConfigFor(selection),
+ );
+ await selectModel({
+ ...selection,
+ ...(hasAppliedConfig ? { keepSpeculative: true } : {}),
+ previousConfig,
});
},
- [detachStaged, selectModel, loadingModel],
+ [selectModel, loadingModel, rememberedConfigFor, chatContextKey],
);
+ useRepoDownload({
+ kind: DOWNLOAD_KIND.MODEL,
+ repoId: pendingHubAutoLoad?.selection.id ?? "__hub_autoload_idle__",
+ activeVariant: pendingHubAutoLoad?.selection.ggufVariant ?? null,
+ onComplete: (variant) => {
+ const pending = pendingHubAutoLoad;
+ if (
+ !pending ||
+ (pending.selection.ggufVariant ?? null) !== (variant ?? null)
+ ) {
+ return;
+ }
+ setPendingHubAutoLoad(null);
+ const store = useChatRuntimeStore.getState();
+ if (
+ !active ||
+ pending.contextKey !== chatContextKey ||
+ normalizeModelRef(pending.originCheckpoint) !==
+ normalizeModelRef(store.params.checkpoint) ||
+ pending.originGgufVariant !== store.activeGgufVariant
+ ) {
+ return;
+ }
+ void stageOrLoad({ ...pending.selection, isDownloaded: true });
+ },
+ onError: (variant) => {
+ if (
+ pendingHubAutoLoad &&
+ (pendingHubAutoLoad.selection.ggufVariant ?? null) === (variant ?? null)
+ ) {
+ setPendingHubAutoLoad(null);
+ }
+ },
+ onCancelled: (variant) => {
+ if (
+ pendingHubAutoLoad &&
+ (pendingHubAutoLoad.selection.ggufVariant ?? null) === (variant ?? null)
+ ) {
+ setPendingHubAutoLoad(null);
+ }
+ },
+ });
+ useEffect(() => {
+ const pending = pendingHubAutoLoad;
+ if (!pending) return;
+ let active = true;
+ void (async () => {
+ const outcome = await downloadManager.requestStart({
+ kind: DOWNLOAD_KIND.MODEL,
+ repoId: pending.selection.id,
+ variant: pending.selection.ggufVariant ?? null,
+ expectedBytes: pending.selection.expectedBytes ?? 0,
+ });
+ if (!active) return;
+ if (outcome === "started") {
+ toast.info("Downloading model", {
+ description: "It'll load automatically once the download finishes.",
+ });
+ return;
+ }
+ if (outcome === "conflict") {
+ // Keep pendingHubAutoLoad bound so this surface's cleanup does not wipe
+ // the conflict just recorded by requestStart (which the toast points the
+ // user to); resolving it from the Hub completes the download and this
+ // surface's onComplete auto-loads, mirroring the "started" branch.
+ toast.info("Resume this download from the Hub", {
+ description:
+ "An earlier partial download used a different transport. Open the Hub tab to resume or restart it.",
+ });
+ return;
+ }
+ if (outcome === "busy") {
+ toast.info("Download already in progress", {
+ description:
+ "Another download for this model is still running. Reselect it once that finishes to load it.",
+ });
+ }
+ setPendingHubAutoLoad((current) => (current === pending ? null : current));
+ })();
+ return () => {
+ active = false;
+ };
+ }, [pendingHubAutoLoad]);
const loadNativeModelIntent = useCallback(
async (intent: NativeIntent, loadingDescription: string) => {
const label =
@@ -1915,6 +1963,7 @@ export function ChatPage({
await stageOrLoad({
id: label,
nativePathToken: intent.path.token,
+ nativePathExpiresAtMs: intent.path.expiresAtMs ?? null,
isDownloaded: true,
loadingDescription,
forceReload: true,
@@ -1965,28 +2014,20 @@ export function ChatPage({
const handleCheckpointChange = useCallback(
(
value: string,
- meta?: {
- source?: string;
- isLora: boolean;
- ggufVariant?: string;
- isDownloaded?: boolean;
- expectedBytes?: number;
- isGguf?: boolean;
- },
+ meta?: ModelSelectorChangeMeta,
) => {
const store = useChatRuntimeStore.getState();
const currentCheckpoint = store.params.checkpoint;
const currentVariant = store.activeGgufVariant;
- if (
- !value ||
- (value === currentCheckpoint &&
- (meta?.ggufVariant ?? null) === (currentVariant ?? null))
- )
+ if (!value) return;
+ setPendingHubAutoLoad(null);
+ const isSameLoadedModel =
+ value === currentCheckpoint &&
+ (meta?.ggufVariant ?? null) === (currentVariant ?? null);
+ if (isSameLoadedModel && !meta?.forceReload) {
return;
+ }
if (meta?.source === "external" || isExternalModelId(value)) {
- // Switching to an external model abandons any staged local pick: cancel
- // its download too (setCheckpoint below only clears the pending + knobs).
- abandonStaged();
const selectedExternal = parseExternalModelId(value);
const selectedProvider = selectedExternal
? externalProvidersForChat.find(
@@ -2087,6 +2128,7 @@ export function ChatPage({
ggufMaxContextLength: null,
ggufNativeContextLength: null,
activeNativePathToken: null,
+ activeNativePathExpiresAtMs: null,
// Clear previous-model counters, else the relaxed external-provider
// render gate shows stale stats until the next completion.
contextUsage: null,
@@ -2158,19 +2200,18 @@ export function ChatPage({
source: meta?.source,
isLora: meta?.isLora,
ggufVariant: meta?.ggufVariant,
- isDownloaded: meta?.isDownloaded,
+ isDownloaded: meta?.isDownloaded || isSameLoadedModel,
expectedBytes: meta?.expectedBytes,
isGguf: meta?.isGguf,
+ config: meta?.config,
+ nativePathToken: meta?.nativePathToken,
+ nativePathExpiresAtMs: meta?.nativePathExpiresAtMs,
+ forceReload: isSameLoadedModel || undefined,
};
- // "Load on selection" off: stage the model and open settings so its
- // load knobs (tensor parallel, context length…) can be set, then it
- // loads once via the sheet's Load button. The currently loaded model
- // stays put until the user commits.
await stageOrLoad(selection);
})();
},
[
- abandonStaged,
activeThreadId,
externalProvidersForChat,
modelsFromStore,
@@ -2178,6 +2219,45 @@ export function ChatPage({
view,
],
);
+ const handleReloadActiveModel = useCallback(
+ (config: PerModelConfig) => {
+ const checkpoint = inferenceParams.checkpoint;
+ if (!checkpoint) return;
+ const runtime = useChatRuntimeStore.getState();
+ const nativeToken = runtime.activeNativePathToken;
+ const nativeExpiry = runtime.activeNativePathExpiresAtMs;
+ // A file-picked GGUF is reachable only via its native path token, which
+ // the desktop host prunes after a TTL. Reusing an expired token makes the
+ // reload fail with an opaque error, so prompt the user to re-select the
+ // file instead.
+ if (nativeToken && nativeExpiry != null && Date.now() >= nativeExpiry) {
+ toast.error("This local model file's access has expired.", {
+ description: "Re-select the model file to reload it.",
+ });
+ return;
+ }
+ handleCheckpointChange(checkpoint, {
+ source: "local",
+ isLora: activeModelIsLora,
+ ggufVariant: activeGgufVariant ?? undefined,
+ // Without the native token the reload validates the display label as a
+ // repo and fails.
+ nativePathToken: nativeToken ?? undefined,
+ nativePathExpiresAtMs: nativeExpiry,
+ isGguf: activeModelIsGguf,
+ isDownloaded: true,
+ config,
+ forceReload: true,
+ });
+ },
+ [
+ inferenceParams.checkpoint,
+ activeGgufVariant,
+ activeModelIsLora,
+ activeModelIsGguf,
+ handleCheckpointChange,
+ ],
+ );
const handleEject = useCallback(() => {
void (async () => {
if (await ejectModel()) {
@@ -2446,12 +2526,27 @@ export function ChatPage({
const state = useChatRuntimeStore.getState();
const targetLora = pickBestLoraForBase(state.loras, handoff.baseModel);
+ const selectWithConfig = async (
+ selection: Pick,
+ ) => {
+ const previousConfig = currentRuntimePerModelConfig({
+ includeMaxSeqLength: true,
+ });
+ const hasAppliedConfig = applyModelLoadConfigToRuntime(
+ rememberedConfigFor(selection),
+ );
+ await selectModelRef.current({
+ ...selection,
+ ...(hasAppliedConfig ? { keepSpeculative: true } : {}),
+ previousConfig,
+ });
+ };
if (targetLora) {
console.info("[chat-handoff] loading lora", {
id: targetLora.id,
baseModel: targetLora.baseModel,
});
- await selectModelRef.current({ id: targetLora.id, isLora: true });
+ await selectWithConfig({ id: targetLora.id, isLora: true });
if (canceled) return;
useChatRuntimeStore.getState().setActiveThreadId(null);
useChatRuntimeStore.getState().setContextUsage(null);
@@ -2468,10 +2563,7 @@ export function ChatPage({
console.info("[chat-handoff] no lora match, loading base", {
id: handoff.baseModel,
});
- await selectModelRef.current({
- id: handoff.baseModel,
- isLora: false,
- });
+ await selectWithConfig({ id: handoff.baseModel, isLora: false });
if (canceled) return;
} else {
console.warn("[chat-handoff] no lora/base match found", {
@@ -2491,7 +2583,7 @@ export function ChatPage({
return () => {
canceled = true;
};
- }, [active, navigate]);
+ }, [active, navigate, rememberedConfigFor]);
const tourSteps = useMemo(
() =>
@@ -2580,6 +2672,8 @@ export function ChatPage({
externalModels={externalModels}
value={inferenceParams.checkpoint}
activeGgufVariant={activeGgufVariant}
+ activeModelConfig={activeModelConfig}
+ activeGgufContextLength={ggufContextLength}
onValueChange={handleCheckpointChange}
onEject={handleEject}
onFoldersChange={refreshLocalModels}
@@ -2633,7 +2727,12 @@ export function ChatPage({
stageOrLoad(selection)}
+ onLoad={() =>
+ loadNativeModelIntent(
+ pendingNativeModelIntent,
+ "Loading selected local GGUF model.",
+ )
+ }
/>
) : null}
{loadingModel && loadToastDismissed ? (
@@ -2790,13 +2889,22 @@ export function ChatPage({
open={active && settingsOpen}
onOpenChange={(open) => {
setSettingsOpen(open);
- // Closing the sheet abandons a staged (not-yet-loaded) pick: cancel its
- // download and revert the staged knobs so nothing lingers as a dirty
- // edit (or a background download) on the loaded model.
- if (!open) abandonStaged();
}}
params={inferenceParams}
onParamsChange={setInferenceParams}
+ modelConfig={
+ view.mode !== "compare" && activeModelConfig && !modelLoading ? (
+
+ ) : null
+ }
isExternalModel={isExternalModel}
providerCapabilities={activeProviderCapabilities}
activeExternalProvider={activeExternalProvider}
@@ -2808,67 +2916,6 @@ export function ChatPage({
);
}}
externalProviderType={activeExternalProviderType}
- loadingModel={loadingModel}
- onReloadModel={() => {
- const state = useChatRuntimeStore.getState();
- if (state.params.checkpoint) {
- selectModel({
- id: state.params.checkpoint,
- ggufVariant: state.activeGgufVariant ?? undefined,
- // A native (drag-drop / picked) GGUF's checkpoint is only a display
- // label, so the reload needs its path token to re-mint a lease --
- // else applying the now-exposed GPU/context controls can't resolve
- // the file. Null for non-native loads, which reload by id as before.
- nativePathToken: state.activeNativePathToken ?? undefined,
- forceReload: true,
- isDownloaded: true,
- loadingDescription: "Reloading with updated chat template.",
- });
- }
- }}
- onLoadPendingModel={() => {
- const pending = useChatRuntimeStore.getState().pendingSelection;
- if (!pending) return;
- const keyAtLoad = chatContextKey;
- // forceReload: the staged model isn't loaded yet, so bypass the
- // same-checkpoint dedupe. keepSpeculative: honor the speculative mode
- // set on the sidebar.
- void selectModel({
- ...pending,
- forceReload: true,
- keepSpeculative: true,
- throwOnError: true,
- }).catch(() => {
- // Recoverable failure (expired token, gated repo, OOM…): the pick is
- // cleared only on success, so it normally stays staged with edited
- // knobs intact — nothing to restore.
- const store = useChatRuntimeStore.getState();
- // Still staged (this pick, or a newer one queued meanwhile): leave it.
- if (store.pendingSelection) return;
- // Cleared mid-load (sheet closed / switched chats). Re-stage only if
- // the staged-load is still wanted: same chat context, sheet still
- // open, page still mounted.
- const stillWanted =
- mountedRef.current &&
- store.settingsPanelOpen &&
- chatContextKeyRef.current === keyAtLoad;
- if (stillWanted) {
- store.setPendingSelection(pending);
- } else {
- // Abandoned (closed the sheet / switched chats / left chat): drop
- // the orphaned staged knob edits so they don't linger as dirty
- // settings over the loaded model.
- store.resetModelSettingsToLoaded();
- }
- });
- }}
- stagedDownloadFraction={stagedDownload.progress?.fraction ?? null}
- onCancelStagedDownload={() =>
- stagedDownload.cancelDownload(
- useChatRuntimeStore.getState().pendingSelection?.ggufVariant ??
- null,
- )
- }
/>
diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx
index bd22cc4f55..d4f154882c 100644
--- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx
+++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx
@@ -1,19 +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 {
- Alert,
- AlertDescription,
- AlertTitle,
-} from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
-import { Checkbox } from "@/components/ui/checkbox";
-import {
- clearRememberedLoadSettings,
- loadRememberedLoadSettings,
- rememberedLoadSettingsKey,
- saveRememberedLoadSettings,
-} from "@/components/assistant-ui/model-selector/remembered-load-settings";
import {
Dialog,
DialogContent,
@@ -29,7 +17,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
-import { Input } from "@/components/ui/input";
+import { InfoHint } from "@/components/ui/info-hint";
import {
InputGroup,
InputGroupAddon,
@@ -50,27 +38,22 @@ import {
SheetTitle,
} from "@/components/ui/sheet";
import { Slider } from "@/components/ui/slider";
-import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
-import { InfoHint } from "@/components/ui/info-hint";
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
-import { useGpuDevices } from "@/hooks/use-gpu-info";
-import { useIsMobile } from "@/hooks/use-mobile";
+import { NumericValueInput, snapToStep } from "@/features/model-picker";
+import { RetrievalSettingsSection } from "@/features/rag";
import { useLlamaUpdateCheck } from "@/hooks/use-llama-update-check";
-import { cn } from "@/lib/utils";
-import {
- ArrowTurnBackwardIcon,
- Edit03Icon,
- LayoutAlignRightIcon,
-} from "@hugeicons/core-free-icons";
+import { useIsMobile } from "@/hooks/use-mobile";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
+import { toast } from "@/lib/toast";
+import { cn } from "@/lib/utils";
+import { Edit03Icon, LayoutAlignRightIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Braces, ChevronDown, ExternalLink } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Fragment, type ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { toast } from "@/lib/toast";
import { OpenAICodeExecSection } from "./components/openai-code-exec-section";
import { PermissionModeDropdown } from "./permission-mode-select";
import { resyncInferenceStatusAfterServerModelChange } from "./hooks/use-chat-model-runtime";
@@ -78,8 +61,8 @@ import {
type ExternalProviderConfig,
getExternalProviderApiKey,
parseExternalModelId,
- supportsProviderPromptCaching,
supportsProviderPromptCacheTtl,
+ supportsProviderPromptCaching,
} from "./external-providers";
import {
BUILTIN_PRESETS,
@@ -99,15 +82,7 @@ import {
providerSupportsBuiltinCodeExecution,
providerSupportsFastMode,
} from "./provider-capabilities";
-import {
- GPU_LAYERS_AUTO,
- distributeByWeight,
- isPendingGguf,
- pendingSelectionMatches,
- rebalanceSplit,
- useChatRuntimeStore,
-} from "./stores/chat-runtime-store";
-import { RetrievalSettingsSection } from "@/features/rag/components/retrieval-settings-section";
+import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import type { InferenceParams } from "./types/runtime";
export { defaultInferenceParams, type Preset } from "./presets/preset-policy";
@@ -130,7 +105,7 @@ function getPromptVariablesError(raw: string): string | null {
return null;
}
} catch {
- return "Use valid JSON, for example { \"env\": \"staging\" }.";
+ return 'Use valid JSON, for example { "env": "staging" }.';
}
return "Variables must be a JSON object.";
}
@@ -139,112 +114,7 @@ function hasPromptVariableSyntax(prompt: string): boolean {
return PROMPT_VARIABLE_PATTERN.test(prompt);
}
-/**
- * Editable numeric value display, shared by every slider value and the Context
- * Length input. An that looks like text (shows `displayValue ?? value`,
- * so "Off"/"Max" labels render) until focus, when it swaps to the raw number,
- * selects it, and accepts free text. Commits on blur/Enter, reverts on Escape.
- * Clamping happens on commit so typing intermediate values isn't fought.
- */
-function snapToStep(
- value: number,
- step: number,
- min?: number,
- max?: number,
-): number {
- const lo = min ?? Number.NEGATIVE_INFINITY;
- const hi = max ?? Number.POSITIVE_INFINITY;
- const clamped = Math.min(Math.max(value, lo), hi);
- const stepStr = String(step);
- const decimals = stepStr.includes(".") ? stepStr.split(".")[1].length : 0;
- const base = Number.isFinite(lo) ? lo : 0;
- const snapped = base + Math.round((clamped - base) / step) * step;
- const reclamped = Math.min(Math.max(snapped, lo), hi);
- return Number(reclamped.toFixed(decimals));
-}
-
-function NumericValueInput({
- value,
- min,
- max,
- step,
- onChange,
- displayValue,
- className,
- ariaLabel,
- size: sizeAttr,
- disabled = false,
-}: {
- value: number;
- min?: number;
- max?: number;
- step: number;
- onChange: (v: number) => void;
- displayValue?: string;
- className?: string;
- ariaLabel?: string;
- size?: number;
- disabled?: boolean;
-}) {
- const [focused, setFocused] = useState(false);
- const [draft, setDraft] = useState("");
- const cancelBlurCommitRef = useRef(false);
-
- const commit = (raw: string) => {
- const parsed = Number.parseFloat(raw);
- if (!Number.isFinite(parsed)) {
- return;
- }
- const final = snapToStep(parsed, step, min, max);
- if (final !== value) {
- onChange(final);
- }
- };
-
- const displayed = focused ? draft : (displayValue ?? String(value));
-
- return (
- {
- cancelBlurCommitRef.current = false;
- setDraft(String(value));
- setFocused(true);
- // Defer select() so it runs after the value swap above.
- const target = e.currentTarget;
- requestAnimationFrame(() => target.select());
- }}
- onBlur={() => {
- if (cancelBlurCommitRef.current) {
- cancelBlurCommitRef.current = false;
- } else {
- commit(draft);
- }
- setFocused(false);
- }}
- onChange={(e) => setDraft(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.currentTarget.blur();
- } else if (e.key === "Escape") {
- cancelBlurCommitRef.current = true;
- setDraft(String(value));
- e.currentTarget.blur();
- }
- }}
- className={cn("panel-number-input", className)}
- />
- );
-}
-
-function ParamSlider({
+export function ParamSlider({
label,
value,
min,
@@ -285,6 +155,7 @@ function ParamSlider({
displayValue={displayValue}
ariaLabel={label}
size={valueSize ?? 4}
+ className="panel-number-input"
disabled={disabled}
/>
@@ -385,8 +256,7 @@ function CollapsibleSection({
return (
{labelHref ? (
@@ -458,6 +328,7 @@ interface ChatSettingsPanelProps {
onOpenChange?: (open: boolean) => void;
params: InferenceParams;
onParamsChange: (params: InferenceParams) => void;
+ modelConfig?: ReactNode;
isExternalModel?: boolean;
/**
* Sampling-param capabilities for the active external provider, or `null` for
@@ -472,21 +343,6 @@ interface ChatSettingsPanelProps {
* Max Tokens floor in the slider.
*/
externalProviderType?: string | null;
- onReloadModel?: () => void;
- /** The in-flight load (id + GGUF variant + native path token), or null when
- * idle. Used to show a loading state for the staged pick only — not for an
- * unrelated load or a cancel's background unload. */
- loadingModel?: {
- id: string;
- ggufVariant?: string | null;
- nativePathToken?: string | null;
- } | null;
- /** Loads the staged `pendingSelection` (deferred "Load on selection" flow). */
- onLoadPendingModel?: () => void;
- /** Download progress (0–1) for a staged GGUF being fetched, or null when idle. */
- stagedDownloadFraction?: number | null;
- /** Cancels the in-flight staged download (paired with abandoning the stage). */
- onCancelStagedDownload?: () => void;
}
export function ChatSettingsPanel({
@@ -494,16 +350,12 @@ export function ChatSettingsPanel({
onOpenChange,
params,
onParamsChange,
+ modelConfig = null,
isExternalModel = false,
providerCapabilities = null,
activeExternalProvider = null,
onExternalProviderChange,
externalProviderType = null,
- onReloadModel,
- loadingModel = null,
- onLoadPendingModel,
- stagedDownloadFraction,
- onCancelStagedDownload,
}: ChatSettingsPanelProps) {
// Local models show every knob; providerCapabilities is only consulted when
// isExternalModel. Unknown providers fall back to the OpenAI-compat shape via
@@ -518,64 +370,23 @@ export function ChatSettingsPanel({
const showPresencePenalty =
!isExternalModel || Boolean(providerCapabilities?.presencePenalty);
const isMobile = useIsMobile();
- const pendingSelection = useChatRuntimeStore((s) => s.pendingSelection);
- // "Loading" only when the in-flight load IS this staged pick (full id + GGUF
- // variant + native token match), not an unrelated load or a cancel's
- // background unload. The variant matters: a different quant of the same repo
- // staged mid-load must not read as this one loading.
- const stagedLoading =
- loadingModel != null &&
- pendingSelectionMatches(pendingSelection, {
- id: loadingModel.id,
- ggufVariant: loadingModel.ggufVariant,
- nativePathToken: loadingModel.nativePathToken,
- });
- // Load settings are snapshotted at click time; lock them while loading.
- const modelControlsDisabled = stagedLoading;
- const abandonStagedModel = useChatRuntimeStore((s) => s.abandonStagedModel);
- const resetModelSettingsToLoaded = useChatRuntimeStore(
- (s) => s.resetModelSettingsToLoaded,
+ const isLoadedGguf = useChatRuntimeStore((s) => s.activeGgufVariant) != null;
+ const currentCheckpoint = params.checkpoint;
+ const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength);
+ // Direct-file / custom-folder GGUFs load without a variant label but still
+ // report a GGUF context, so detect them via the context and the checkpoint
+ // suffix too (mirrors the chat page's activeModelIsGguf). Otherwise Max Tokens
+ // would fall back to params.maxSeqLength instead of the loaded GGUF context.
+ const isGguf =
+ isLoadedGguf ||
+ ggufContextLength != null ||
+ (currentCheckpoint?.toLowerCase().endsWith(".gguf") ?? false);
+ const ggufMaxContextLength = useChatRuntimeStore(
+ (s) => s.ggufMaxContextLength,
);
- // A staged GGUF pick (deferred load) shows the GGUF load knobs so they can be
- // set before the single load.
- const pendingIsGguf = isPendingGguf(pendingSelection);
- // Short, human-readable name for the staged pick (HF ids carry an org prefix;
- // native picks are already a display label). Drives the "staged, not loaded"
- // callout so it's obvious the selection hasn't loaded yet.
- const stagedLabel = (() => {
- const id = pendingSelection?.id ?? "";
- const slash = id.lastIndexOf("/");
- const base = slash >= 0 ? id.slice(slash + 1) : id;
- return base || id;
- })();
- const activeNativePathToken = useChatRuntimeStore(
- (s) => s.activeNativePathToken,
- );
- const loadedGgufContextLength = useChatRuntimeStore((s) => s.ggufContextLength);
- // A GGUF loaded from a native path / direct .gguf has no HF variant, so key
- // off the same signal the status hydration uses -- variant OR native token OR
- // a GGUF context -- else the GPU Memory controls hide for a loaded local GGUF.
- const isLoadedGguf =
- useChatRuntimeStore((s) => s.activeGgufVariant) != null ||
- activeNativePathToken != null ||
- loadedGgufContextLength != null;
- // While a pick is staged the sheet configures *that* model, so its GGUF-ness
- // (not the currently loaded model's) decides whether the GGUF-only controls
- // show. Otherwise a staged non-GGUF Hub repo would inherit the loaded GGUF's
- // context/KV/speculative controls.
- const isGguf = pendingSelection != null ? pendingIsGguf : isLoadedGguf;
- // The Model section (and Load button) shows for any staged pick, even when the
- // currently active model is external.
- const hasModelContent =
- pendingSelection != null ||
- (!isExternalModel && (isGguf || Boolean(params.checkpoint)));
+ const customContextLength = useChatRuntimeStore((s) => s.customContextLength);
const speculativeType = useChatRuntimeStore((s) => s.speculativeType);
- const setSpeculativeType = useChatRuntimeStore((s) => s.setSpeculativeType);
- const loadedSpeculativeType = useChatRuntimeStore(
- (s) => s.loadedSpeculativeType,
- );
const specFallbackReason = useChatRuntimeStore((s) => s.specFallbackReason);
- // Only binary fallback states are solved by a newer prebuilt.
const mtpUpdatable =
specFallbackReason === "binary_no_mtp" ||
specFallbackReason === "binary_outdated";
@@ -597,65 +408,27 @@ export function ChatSettingsPanel({
`llama.cpp updated to ${result.tag ?? "the latest build"}.${reloadHint}`,
);
} else {
- toast.error(`llama.cpp update failed: ${result.error ?? "unknown error"}`);
+ toast.error(
+ `llama.cpp update failed: ${result.error ?? "unknown error"}`,
+ );
}
}, [applyLlamaUpdate]);
- const specDraftNMax = useChatRuntimeStore((s) => s.specDraftNMax);
- const setSpecDraftNMax = useChatRuntimeStore((s) => s.setSpecDraftNMax);
- const loadedSpecDraftNMax = useChatRuntimeStore(
- (s) => s.loadedSpecDraftNMax,
- );
- const currentCheckpoint = params.checkpoint;
- const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength);
- const ggufMaxContextLength = useChatRuntimeStore(
- (s) => s.ggufMaxContextLength,
- );
- const ggufNativeContextLength = useChatRuntimeStore(
- (s) => s.ggufNativeContextLength,
- );
- const kvCacheDtype = useChatRuntimeStore((s) => s.kvCacheDtype);
- const setKvCacheDtype = useChatRuntimeStore((s) => s.setKvCacheDtype);
- const applyRememberedLoadSettings = useChatRuntimeStore(
- (s) => s.applyRememberedLoadSettings,
- );
- const loadedKvCacheDtype = useChatRuntimeStore((s) => s.loadedKvCacheDtype);
- const tensorParallel = useChatRuntimeStore((s) => s.tensorParallel);
- const setTensorParallel = useChatRuntimeStore((s) => s.setTensorParallel);
- const loadedTensorParallel = useChatRuntimeStore(
- (s) => s.loadedTensorParallel,
- );
- const gpuMemoryMode = useChatRuntimeStore((s) => s.gpuMemoryMode);
- const setGpuMemoryMode = useChatRuntimeStore((s) => s.setGpuMemoryMode);
- const loadedGpuMemoryMode = useChatRuntimeStore((s) => s.loadedGpuMemoryMode);
- const loadedIsDiffusion = useChatRuntimeStore((s) => s.loadedIsDiffusion);
- const gpuLayers = useChatRuntimeStore((s) => s.gpuLayers);
- const setGpuLayers = useChatRuntimeStore((s) => s.setGpuLayers);
- const loadedGpuLayers = useChatRuntimeStore((s) => s.loadedGpuLayers);
- const nCpuMoe = useChatRuntimeStore((s) => s.nCpuMoe);
- const setNCpuMoe = useChatRuntimeStore((s) => s.setNCpuMoe);
- const loadedNCpuMoe = useChatRuntimeStore((s) => s.loadedNCpuMoe);
- const splitRatio = useChatRuntimeStore((s) => s.splitRatio);
- const setSplitRatio = useChatRuntimeStore((s) => s.setSplitRatio);
- const loadedSplitRatio = useChatRuntimeStore((s) => s.loadedSplitRatio);
- const ggufLayerCount = useChatRuntimeStore((s) => s.ggufLayerCount);
- const moeLayerCount = useChatRuntimeStore((s) => s.moeLayerCount);
- const selectedGpuIds = useChatRuntimeStore((s) => s.selectedGpuIds);
- const setSelectedGpuIds = useChatRuntimeStore((s) => s.setSelectedGpuIds);
- const loadedGpuIds = useChatRuntimeStore((s) => s.loadedGpuIds);
- const gpuDevices = useGpuDevices();
- const chatTemplateOverride = useChatRuntimeStore(
- (s) => s.chatTemplateOverride,
- );
- const loadedChatTemplateOverride = useChatRuntimeStore(
- (s) => s.loadedChatTemplateOverride,
- );
- const customContextLength = useChatRuntimeStore((s) => s.customContextLength);
- const loadedCustomContextLength = useChatRuntimeStore(
- (s) => s.loadedCustomContextLength,
- );
- const setCustomContextLength = useChatRuntimeStore(
- (s) => s.setCustomContextLength,
- );
+ const loadedEffectiveContext = customContextLength ?? ggufContextLength;
+ const showSpecFallback =
+ !isExternalModel &&
+ isGguf &&
+ specFallbackReason != null &&
+ (speculativeType === "auto" ||
+ speculativeType === "mtp" ||
+ speculativeType === "mtp+ngram");
+ const showContextVramWarning =
+ !isExternalModel &&
+ isGguf &&
+ ggufMaxContextLength != null &&
+ loadedEffectiveContext != null &&
+ loadedEffectiveContext > ggufMaxContextLength;
+ const showLoadedDiagnostics = showSpecFallback || showContextVramWarning;
+ const hasModelContent = showLoadedDiagnostics;
const setActivePresetSource = useChatRuntimeStore(
(s) => s.setActivePresetSource,
);
@@ -666,170 +439,7 @@ export function ChatSettingsPanel({
const setActivePreset = useChatRuntimeStore((s) => s.setActivePreset);
const settingsHydrated = useChatRuntimeStore((s) => s.settingsHydrated);
- // A staged (not-yet-loaded) GGUF carries its own header context length on
- // pendingSelection, so the slider can use the staged model's real ceiling
- // without reading the loaded model's `ggufContextLength`.
- const stagedContextLength = pendingSelection?.contextLength ?? null;
- // "Remember settings next time" tick for a staged model. Seeds the store from
- // the saved per-model settings on stage, so the sheet opens with what was used
- // last time; the tick reflects whether a saved entry exists.
- const [remember, setRemember] = useState(false);
- // Keyed per quant: a different variant of the same repo has its own settings.
- const pendingKey = pendingSelection
- ? rememberedLoadSettingsKey(pendingSelection)
- : null;
- useEffect(() => {
- if (!pendingKey) return;
- // GGUF-only, like the stageOrLoad / Hub restore paths: every remembered
- // field is a llama.cpp knob, so a non-GGUF pick has nothing to restore --
- // and applying its blob would clobber the standing gpuMemoryMode with a
- // stale snapshot (the save on Load below is gated the same way).
- const saved = pendingIsGguf ? loadRememberedLoadSettings(pendingKey) : null;
- setRemember(saved != null);
- if (saved) applyRememberedLoadSettings(saved);
- }, [pendingKey, pendingIsGguf, applyRememberedLoadSettings]);
- // While staging, the sheet reflects the STAGED model, so its header context
- // takes precedence over the loaded model's (which may differ or be larger).
- const baseContext = pendingIsGguf ? stagedContextLength : ggufContextLength;
- const baseNativeContext = pendingIsGguf
- ? stagedContextLength
- : ggufNativeContextLength;
- // Context controls render once we actually have a ceiling: for a staged GGUF,
- // once its header metadata arrives (post-download); otherwise post-load.
- const showContextControl = pendingIsGguf
- ? stagedContextLength != null
- : isLoadedGguf;
- const stagedDownloading =
- stagedDownloadFraction != null && stagedDownloadFraction < 1;
- const ctxDisplayValue = customContextLength ?? baseContext ?? "";
- const ctxMaxValue = baseNativeContext ?? baseContext ?? null;
- const kvDirty = kvCacheDtype !== loadedKvCacheDtype;
- const ctxDirty = customContextLength !== loadedCustomContextLength;
- const specDirty = speculativeType !== loadedSpeculativeType;
- const specDraftDirty = specDraftNMax !== loadedSpecDraftNMax;
- const tpDirty = tensorParallel !== (loadedTensorParallel ?? false);
- // A loaded diffusion GGUF runs mode-agnostic (pins all layers on one GPU,
- // ignores --fit/--gpu-layers), so the GPU Memory mode + manual controls don't
- // apply -- hide them and don't let the preserved standing mode read as dirty.
- // The GPU picker still applies (diffusion pins the chosen device). A staged pick
- // keeps the controls (a pending pick's diffusion-ness isn't known until load).
- const gpuModeApplies =
- isGguf && (pendingSelection != null || !loadedIsDiffusion);
- const gpuDirty =
- gpuModeApplies && gpuMemoryMode !== (loadedGpuMemoryMode ?? "auto");
- const isManual = gpuModeApplies && gpuMemoryMode === "manual";
- // Manual with the GPU Layers slider at "Auto" (leftmost): --fit owns the whole
- // layout, so the offload knobs (MoE, split, TP) don't apply.
- const autoLayers = isManual && gpuLayers < 0;
- // GPUs actually in use: the picked subset, or all visible when none picked.
- const gpusInUse = selectedGpuIds ?? gpuDevices.map((d) => d.index);
- // The picker must keep one GPU selected.
- const singleGpuInUse = gpusInUse.length <= 1;
- // TP needs at least two GPUs because tensor split is a no-op on one and may
- // abort. Auto layers hides TP because --fit aborts under --split-mode tensor.
- const tpDisabled = singleGpuInUse;
- // Manual gpu-layers ceiling = model layer count + 1 (else a safe fallback):
- // llama.cpp counts the output layer as one more offloadable layer past the
- // repeating blocks ("offloaded 33/33" needs -ngl 33 on a 32-block model), so
- // the slider max must reach it or full offload is unreachable. While staging,
- // use the staged model's layer count (read from its header).
- const stagedLayerCount = pendingSelection?.layerCount ?? null;
- const modelLayerCount = pendingIsGguf ? stagedLayerCount : ggufLayerCount;
- const gpuLayersMax = modelLayerCount != null ? modelLayerCount + 1 : 256;
- // MoE-offload slider: shown only for MoE models, capped at their MoE-layer
- // count. While staging, use the staged model's count (read from its header);
- // otherwise the loaded model's.
- const stagedMoeLayerCount = pendingSelection?.moeLayerCount ?? null;
- const moeLayersMax = pendingIsGguf
- ? (stagedMoeLayerCount ?? 0)
- : (moeLayerCount ?? 0);
- const showMoeSlider = isManual && !autoLayers && moeLayersMax > 0;
- // gpuLayers always counts; MoE only with an explicit layer count (see above).
- const manualDirty =
- isManual &&
- (gpuLayers !== loadedGpuLayers ||
- (!autoLayers && nCpuMoe !== (loadedNCpuMoe ?? 0)));
- // GPU picker: only meaningful on multi-GPU, and only when the reported
- // indices are physical (relative ordinals from a parent CUDA_VISIBLE_DEVICES
- // mask can't be mapped back to pin a device). null = use all (auto).
- const showGpuPicker =
- isGguf &&
- gpuDevices.length > 1 &&
- gpuDevices.every((d) => d.physicalIndex);
- const isGpuChecked = (index: number) =>
- selectedGpuIds === null || selectedGpuIds.includes(index);
- const toggleGpu = (index: number) => {
- const all = gpuDevices.map((d) => d.index);
- const current = selectedGpuIds ?? all;
- const next = current.includes(index)
- ? current.filter((i) => i !== index)
- : [...current, index].sort((a, b) => a - b);
- if (next.length === 0) return; // keep at least one GPU selected
- setSelectedGpuIds(next.length === all.length ? null : next);
- // The per-GPU split is positional, so any change to the set of GPUs in use
- // invalidates it: drop it (the sliders fall back to the VRAM-weighted
- // default). TP needs 2+ GPUs, so disable it when only one remains.
- setSplitRatio(null);
- if (next.length <= 1) {
- setTensorParallel(false);
- }
- };
- const gpuIdsKey = (ids: number[] | null) => (ids === null ? "auto" : ids.join(","));
- const gpuIdsDirty = gpuIdsKey(selectedGpuIds) !== gpuIdsKey(loadedGpuIds);
- // Per-GPU layer split (--tensor-split): manual + 2+ GPUs in use. One slider
- // per GPU, each a layer count; together they sum to the GPU Layers total.
- const showSplitRatio =
- isManual && !autoLayers && showGpuPicker && gpusInUse.length > 1;
- // The total the per-GPU counts sum to (the GPU Layers slider value); 0 under
- // Auto, where the split is hidden. The devices behind the GPUs in use, for
- // labels + the VRAM-weighted default.
- const splitTotal = Math.max(0, Math.min(gpuLayers, gpuLayersMax));
- const gpusInUseDevices = gpusInUse.map(
- (i) => gpuDevices.find((d) => d.index === i) ?? null,
- );
- // Displayed per-GPU counts. splitRatio is a stable reference balance (only a
- // slider edit changes it), rescaled to the current total; deriving rather than
- // mutating it on GPU Layers changes keeps the balance intact when the total
- // passes through low values or Auto. No saved split: free-VRAM-weighted default
- // (llama.cpp's unset default splits by free VRAM, so the first edit starts from
- // the default's placement, not a total-VRAM ratio that can land layers on a
- // busy GPU). A genuine 0 (a full GPU) is a real weight, not missing data: the
- // probe's no-data case degrades to the total server-side, and an all-zero list
- // falls back to an even split in distributeByWeight. Not yet sent.
- const splitCounts =
- splitRatio && splitRatio.length === gpusInUse.length
- ? distributeByWeight(splitTotal, splitRatio)
- : distributeByWeight(
- splitTotal,
- gpusInUseDevices.map((d) => d?.memoryFreeGb ?? d?.memoryTotalGb ?? 1),
- );
- const setSplitCount = (k: number, v: number) =>
- setSplitRatio(rebalanceSplit(splitTotal, splitCounts, k, v));
- const splitRatioDirty =
- isManual &&
- !autoLayers &&
- JSON.stringify(splitRatio ?? null) !== JSON.stringify(loadedSplitRatio ?? null);
- // Auto-fit context (Manual + Auto layers): <= 0 means "Auto" (--fit sizes it);
- // a positive value pins it. Surface the length --fit chose once it's loaded.
- const fitCtxAuto = autoLayers && (customContextLength ?? 0) <= 0;
- const loadedAutoLayers =
- loadedGpuMemoryMode === "manual" && (loadedGpuLayers ?? GPU_LAYERS_AUTO) < 0;
- const fitResolvedCtx =
- fitCtxAuto && loadedAutoLayers ? ggufContextLength : null;
- // A saved chat-template override is a reload-time setting too, so surface
- // Apply for a template-only edit (otherwise it could never be applied).
- const templateDirty = chatTemplateOverride !== loadedChatTemplateOverride;
- const modelSettingsDirty =
- kvDirty ||
- ctxDirty ||
- specDirty ||
- specDraftDirty ||
- tpDirty ||
- gpuDirty ||
- manualDirty ||
- gpuIdsDirty ||
- splitRatioDirty ||
- templateDirty;
+ const baseContext = ggufContextLength;
const [presetNameInput, setPresetNameInput] = useState(activePreset);
const [systemPromptEditorOpen, setSystemPromptEditorOpen] = useState(false);
const [systemPromptDraft, setSystemPromptDraft] = useState("");
@@ -855,8 +465,7 @@ export function ChatSettingsPanel({
BUILTIN_PRESETS.find((preset) => preset.name === activePreset) ?? null,
[activePreset],
);
- const hasUnsavedPresetChanges = useMemo(
- () => {
+ const hasUnsavedPresetChanges = useMemo(() => {
if (activePresetDefinition == null) {
return false;
}
@@ -864,9 +473,7 @@ export function ChatSettingsPanel({
return activePresetSource === "modified";
}
return !isSamePresetConfig(activePresetDefinition.params, params);
- },
- [activePresetDefinition, activePresetSource, params],
- );
+ }, [activePresetDefinition, activePresetSource, params]);
const presetSaveState = useMemo(
() =>
getPresetSaveState({
@@ -895,6 +502,14 @@ export function ChatSettingsPanel({
const externalSelection = currentCheckpoint
? parseExternalModelId(currentCheckpoint)
: null;
+ const maxTokensMax = isExternalModel
+ ? getExternalMaxOutputTokens(
+ externalProviderType,
+ externalSelection?.modelId,
+ )
+ : isGguf && baseContext
+ ? baseContext
+ : Math.max(64, params.maxSeqLength);
const showOpenAICodeExecSection =
activeExternalProvider != null &&
providerSupportsBuiltinCodeExecution(
@@ -977,8 +592,7 @@ export function ChatSettingsPanel({
return;
}
const fallbackPreset =
- BUILTIN_PRESETS.find((preset) => preset.name === "Default") ??
- null;
+ BUILTIN_PRESETS.find((preset) => preset.name === "Default") ?? null;
const next = customPresets.filter((preset) => preset.name !== name);
setCustomPresets(next);
if (activePreset === name) {
@@ -1090,7 +704,7 @@ export function ChatSettingsPanel({
Run settings
-
+
@@ -2480,155 +1526,3 @@ function BypassPermissionsToggle() {
);
}
-
-function ChatTemplateFields({ disabled = false }: { disabled?: boolean }) {
- const defaultTemplate = useChatRuntimeStore((s) => s.defaultChatTemplate);
- const override = useChatRuntimeStore((s) => s.chatTemplateOverride);
- const setOverride = useChatRuntimeStore((s) => s.setChatTemplateOverride);
- const [editorOpen, setEditorOpen] = useState(false);
- const [draft, setDraft] = useState("");
-
- if (!defaultTemplate) return null;
-
- const displayValue = override ?? defaultTemplate;
- const isModified = override !== null;
- const draftDirty = draft !== displayValue;
-
- const openEditor = () => {
- setDraft(displayValue);
- setEditorOpen(true);
- };
- const saveEditor = () => {
- const cleared = draft.trim().length === 0 || draft === defaultTemplate;
- setOverride(cleared ? null : draft);
- setEditorOpen(false);
- toast.success(
- cleared
- ? "Chat template reset to default. It applies on the next model reload."
- : "Chat template saved. It applies on the next model reload.",
- );
- };
-
- return (
- <>
-
-
- Chat Template
-
-
- {isModified && (
-
-
- setOverride(null)}
- disabled={disabled}
- className="nav-icon-btn text-nav-icon-idle hover:bg-panel-surface-hover hover:text-black dark:hover:text-white disabled:pointer-events-none disabled:opacity-50"
- aria-label="Revert chat template"
- >
-
-
-
-
- Revert changes
-
-
- )}
-
-
-
-
-
-
-
- Edit template
-
-
-
-
-
- >
- );
-}
diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts
index 3003b52230..b72a7a95c2 100644
--- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts
+++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts
@@ -32,14 +32,13 @@ import {
GPU_LAYERS_AUTO,
isLocalModelPath,
loadedGpuMemoryFields,
- loadedGpuMemoryFieldsUnlessStaged,
- pendingSelectionMatches,
persistGpuMemoryModeOnLoad,
readPersistedSpeculativeType,
reconcilePersistedGpuIds,
resolveToolsEnabledOnLoad,
saveSpeculativeType,
useChatRuntimeStore,
+ type LoadingModelPick,
type ReasoningEffort,
} from "../stores/chat-runtime-store";
import { clampReasoningEffortToLevels } from "../provider-capabilities";
@@ -61,7 +60,10 @@ import {
isMultimodalResponse,
} from "../types/api";
import { isExternalModelId } from "../external-providers";
-import { cancelStagedModelDownload } from "@/features/hub";
+import {
+ applyPerModelConfigToRuntime,
+ type PerModelConfig,
+} from "@/features/model-picker";
import type {
ChatLoraSummary,
ChatModelSummary,
@@ -81,14 +83,16 @@ export type SelectedModelInput = {
expectedBytes?: number;
forceReload?: boolean;
nativePathToken?: string;
+ nativePathExpiresAtMs?: number | null;
/** Direct local .gguf file (no HF variant / native token) — still a GGUF
* source, so the staging flow treats it as one. */
isGguf?: boolean;
throwOnError?: boolean;
/** Keep the current speculative-decoding choice across the model switch
- * instead of resetting it to the standing preference. Set by the deferred
- * ("Load on selection") Load, where the user picked it for this model. */
+ * instead of resetting it to the standing preference. */
keepSpeculative?: boolean;
+ config?: PerModelConfig;
+ previousConfig?: PerModelConfig;
};
// Approved fingerprints by checkpoint, so a rollback after a failed switch can resend
@@ -347,6 +351,18 @@ export async function resyncInferenceStatusAfterServerModelChange(): Promise state.params);
const models = useChatRuntimeStore((state) => state.models);
@@ -385,12 +401,16 @@ export function useChatModelRuntime() {
}, []);
const resetLoadingUi = useCallback(() => {
+ const inFlight = loadingModelRef.current;
setLoadingModel(null);
setLoadProgress(null);
loadingModelRef.current = null;
loadAbortRef.current = null;
loadToastIdRef.current = null;
setLoadToastDismissedState(false);
+ if (inFlight) {
+ useChatRuntimeStore.getState().clearLoadingModelPick(pickOf(inFlight));
+ }
if (!cancelUnloadPendingRef.current) {
useChatRuntimeStore.getState().setModelLoading(false);
}
@@ -424,6 +444,7 @@ export function useChatModelRuntime() {
loadAbortRef.current?.abort();
loadAbortRef.current = null;
loadingModelRef.current = null;
+ useChatRuntimeStore.getState().clearLoadingModelPick(pickOf(model));
const tid = loadToastIdRef.current;
loadToastIdRef.current = null;
setLoadingModel(null);
@@ -460,45 +481,36 @@ export function useChatModelRuntime() {
typeof selection === "string" ? false : selection.forceReload ?? false;
const nativePathToken =
typeof selection === "string" ? undefined : selection.nativePathToken;
+ const nativePathExpiresAtMs =
+ typeof selection === "string"
+ ? null
+ : selection.nativePathExpiresAtMs ?? null;
const explicitIsGguf =
typeof selection === "string" ? undefined : selection.isGguf;
const throwOnError =
typeof selection === "string" ? false : selection.throwOnError ?? false;
const keepSpeculative =
typeof selection === "string" ? false : selection.keepSpeculative ?? false;
- // Picking/loading any model abandons a staged (deferred) selection.
- // Before the early-returns below so even a no-op re-select clears the
- // stage.
- const staged = useChatRuntimeStore.getState().pendingSelection;
- if (staged) {
- // Loading a DIFFERENT model abandons this stage. Loading the staged pick
- // ITSELF keeps it so the sidebar can show its load settings (context, KV
- // cache, …) during the load. Cleared on success below; on failure it's
- // left staged so the user can retry (see onLoadPendingModel's catch).
- const loadingStagedPick = pendingSelectionMatches(staged, {
- id: modelId,
- ggufVariant,
- nativePathToken,
- });
- if (!loadingStagedPick) {
- cancelStagedModelDownload(staged);
- useChatRuntimeStore.getState().setPendingSelection(null);
- }
- }
const currentVariant = useChatRuntimeStore.getState().activeGgufVariant;
if (!forceReload && (!modelId || (params.checkpoint === modelId && (ggufVariant ?? null) === (currentVariant ?? null)))) {
+ if (typeof selection !== "string" && selection.previousConfig) {
+ applyPerModelConfigToRuntime(selection.previousConfig);
+ }
return;
}
- // A load is already in flight. If it's this exact pick (id + GGUF variant +
- // native path token), ignore the duplicate click. If it's a DIFFERENT model
- // -- crucially including a different GGUF variant of the same repo, which the
- // old id+token-only guard wrongly treated as a duplicate and silently
- // no-op'd -- don't start a second concurrent load (the load path has no clean
- // supersession) and don't silently swallow the request: surface it so the
- // user knows to wait for, or cancel, the in-flight load. Centralized here so
- // every entry point is covered, not just the staged Load button.
- const inFlightLoad = loadingModelRef.current;
+ // A load is already in flight. If it's this exact pick (id + variant + token),
+ // ignore the duplicate click. If it's a DIFFERENT model (including a different
+ // GGUF variant of the same repo, which the old id+token guard wrongly treated
+ // as a duplicate), don't start a second concurrent load and don't swallow the
+ // request: surface it so the user waits or cancels. Centralized here so every
+ // entry point is covered, not just the staged Load button.
+ const inFlightLoad =
+ loadingModelRef.current ??
+ useChatRuntimeStore.getState().loadingModelPick;
if (inFlightLoad) {
+ if (typeof selection !== "string" && selection.previousConfig) {
+ applyPerModelConfigToRuntime(selection.previousConfig);
+ }
const loadingSamePick =
inFlightLoad.id === modelId &&
(inFlightLoad.ggufVariant ?? null) === (ggufVariant ?? null) &&
@@ -526,7 +538,11 @@ export function useChatModelRuntime() {
// native model intents only grant .gguf files), but its id is a display
// label that need not end in ".gguf" -- without this, Manual + Auto
// layers would pin the UI context instead of letting --fit size it.
- const isGguf = explicitIsGguf ?? model?.isGguf ?? nativePathToken != null;
+ const isGguf =
+ explicitIsGguf ??
+ (ggufVariant != null ||
+ nativePathToken != null ||
+ model?.isGguf === true);
const loraIsAdapter = lora?.exportType === "lora";
const isLora =
explicitIsLora ?? model?.isLora ?? loraIsAdapter ?? false;
@@ -575,6 +591,7 @@ export function useChatModelRuntime() {
};
setLoadingModel(loadInfo);
useChatRuntimeStore.getState().setModelLoading(true);
+ useChatRuntimeStore.getState().setLoadingModelPick(pickOf(loadInfo));
setLoadProgress(
isDownloaded || isCachedLora
? { percent: null, label: null, phase: "starting" }
@@ -600,26 +617,33 @@ export function useChatModelRuntime() {
|| previousVariant != null
|| previousActiveNativePathToken != null
|| (previousCheckpoint?.toLowerCase().endsWith(".gguf") ?? false);
+ // Roll back to the previous model's own context. previousConfig was
+ // snapshotted before this load pre-applied the next model's config, so
+ // params.maxSeqLength may already be the next model's; use it only when
+ // no snapshot exists.
+ const previousMaxSeqLength =
+ (typeof selection !== "string"
+ ? selection.previousConfig?.maxSeqLength
+ : null) ?? maxSeqLength;
// Respect the rolled-back model's auto-layers mode: a Manual+Auto model
- // with an unpinned (auto) context must reload with 0 (so --fit
- // re-auto-sizes), not the positive context it happened to pick (which
- // the backend would treat as a pin).
+ // with an unpinned context must reload with 0 (so --fit re-auto-sizes),
+ // not the positive context it picked (which the backend treats as a pin).
const rollbackMaxSeqLength = resolveFitMaxSeqLength(
previousIsGguf,
stateBeforeUnload.loadedGpuMemoryMode ?? "auto",
stateBeforeUnload.loadedGpuLayers ?? GPU_LAYERS_AUTO,
stateBeforeUnload.loadedCustomContextLength,
- previousIsGguf ? (stateBeforeUnload.ggufContextLength ?? 0) : maxSeqLength,
+ previousIsGguf
+ ? (stateBeforeUnload.ggufContextLength ?? 0)
+ : previousMaxSeqLength,
);
const hfToken = stateBeforeUnload.hfToken || null;
const previousModelRequiresTrustRemoteCode =
stateBeforeUnload.modelRequiresTrustRemoteCode;
+ const previousActiveNativePathExpiresAtMs =
+ stateBeforeUnload.activeNativePathExpiresAtMs;
// Snapshot the load settings at click time, before the awaits below
- // (validation, the trust dialog, unload). For a staged Load these knobs
- // stay editable and a sheet-close revert (abandonStagedModel) can fire
- // mid-load; reading them live just before loadModel would let the load
- // use post-click values. The model-switch speculative reset below
- // updates this snapshot in lock-step so non-staged loads are unchanged.
+ // (validation, the trust dialog, unload).
const loadChatTemplateOverride = stateBeforeUnload.chatTemplateOverride;
const loadKvCacheDtype = stateBeforeUnload.kvCacheDtype;
// gpuMemoryMode is a standing preference (kept across a model switch);
@@ -657,14 +681,11 @@ export function useChatModelRuntime() {
// context can exceed maxSeqLength, so sizing on raw maxSeqLength could
// pass, unload, then have /load refuse it. Uses the click-time
// snapshot (same values loadModel uses below), so the two agree.
- // Mirror what /load does on a cross-model switch: the reset below
- // clears the per-model Auto-layers context pin + GPU pick, and
- // Manual+Auto sizes context through resolveFitMaxSeqLength.
- // gpuMemoryMode is a standing preference, kept across the switch.
- // A same-repo quant switch (same checkpoint, different gguf_variant)
- // is a different model for per-model knobs: the pinned context,
- // gpuLayers, GPU pick, and MoE offload are scoped per variant, so
- // treat a variant change like a model switch and re-baseline them.
+ // Mirror /load on a cross-model switch: the reset below clears the
+ // per-model Auto-layers context pin + GPU pick; gpuMemoryMode is a
+ // standing preference kept across the switch. A same-repo quant switch
+ // (different gguf_variant) is a different model for per-model knobs
+ // (context/gpuLayers/pick/MoE are per variant), so re-baseline them too.
const switchingModelOrVariant =
currentCheckpoint !== modelId ||
(loadActiveGgufVariant ?? null) !== (ggufVariant ?? null);
@@ -867,7 +888,11 @@ export function useChatModelRuntime() {
// The load applied this spec mode, so persist the user's standing
// preference now (the requested intent, not the resolved echo;
// saveSpeculativeType keeps only the universal auto/ngram/off).
- saveSpeculativeType(loadSpeculativeType);
+ // Skip for a per-model config (keepSpeculative): that choice is
+ // model-specific and must not overwrite the global default.
+ if (!keepSpeculative) {
+ saveSpeculativeType(loadSpeculativeType);
+ }
// Persist the GPU Memory mode only on a successful load (not on
// dropdown change), so an abandoned selection doesn't stick.
persistGpuMemoryModeOnLoad(loadResponse, loadGpuMemoryMode);
@@ -907,7 +932,9 @@ export function useChatModelRuntime() {
? (loadResponse.native_context_length ?? null)
: null;
// Keep an explicit Manual+Auto context pin (so a later Apply doesn't
- // revert it to Auto); other cases baseline on ggufContextLength.
+ // revert it to Auto) and retain the user's requested context so
+ // re-open/re-save keeps the intended override, not the backend's
+ // auto-fit context; null stays null.
const keepCustomCtx = resolveManualAutoCtxPin(
loadGpuMemoryMode,
loadGpuLayers,
@@ -978,6 +1005,9 @@ export function useChatModelRuntime() {
loadedIsMultimodal: isMultimodalResponse(loadResponse),
loadedIsDiffusion: loadResponse.is_diffusion ?? false,
activeNativePathToken: nativePathToken ?? null,
+ activeNativePathExpiresAtMs: nativePathToken
+ ? nativePathExpiresAtMs
+ : null,
});
// Unlock attach menus for capabilities the catalog entry lacked.
syncModelCapabilities(modelId, loadResponse);
@@ -1031,25 +1061,6 @@ export function useChatModelRuntime() {
recordLastLocalModelLoad({ id: modelId, kind: "model" });
}
}
- // A successful load owns the shared (pick-unscoped) settings fields,
- // so any surviving stage is stale: the just-loaded pick itself, or a
- // pick queued for a different model mid-load whose knobs this load
- // overwrote. Drop it. Only a DIFFERENT pick's download needs
- // cancelling; the loaded pick's is already consumed, and cancelling
- // it inside its post-complete linger window would flicker its card.
- const staleStage = useChatRuntimeStore.getState().pendingSelection;
- if (staleStage) {
- if (
- !pendingSelectionMatches(staleStage, {
- id: modelId,
- ggufVariant,
- nativePathToken,
- })
- ) {
- cancelStagedModelDownload(staleStage);
- }
- useChatRuntimeStore.getState().setPendingSelection(null);
- }
} catch (error) {
// Skip rollback if user cancelled -- model is already being unloaded.
if (abortCtrl.signal.aborted) throw error;
@@ -1091,8 +1102,9 @@ export function useChatModelRuntime() {
// Restore the previous model in the split mode it was running,
// not the default layer split.
tensor_parallel: stateBeforeUnload.loadedTensorParallel ?? false,
+ // Restore the previous model's GPU Memory placement, not backend defaults.
gpu_memory_mode: stateBeforeUnload.loadedGpuMemoryMode ?? "auto",
- gpu_layers: stateBeforeUnload.loadedGpuLayers ?? -1,
+ gpu_layers: stateBeforeUnload.loadedGpuLayers ?? GPU_LAYERS_AUTO,
n_cpu_moe: stateBeforeUnload.loadedNCpuMoe ?? 0,
tensor_split: stateBeforeUnload.loadedSplitRatio ?? undefined,
gpu_ids: stateBeforeUnload.loadedGpuIds ?? undefined,
@@ -1102,28 +1114,27 @@ export function useChatModelRuntime() {
);
useChatRuntimeStore.setState({
activeNativePathToken: previousActiveNativePathToken ?? null,
+ // Restore the previous token's lease together with the token so a
+ // rollback never pairs restored token A with failed load B's expiry.
+ activeNativePathExpiresAtMs: previousActiveNativePathToken
+ ? (previousActiveNativePathExpiresAtMs ?? null)
+ : null,
+ // Restore the editable speculative knobs to the rolled-back
+ // model's; the loaded baselines below come from its reload echo.
+ speculativeType: stateBeforeUnload.loadedSpeculativeType ?? null,
+ specDraftNMax: stateBeforeUnload.loadedSpecDraftNMax ?? null,
loadedSpeculativeType: rollbackSpeculativeType,
loadedSpecDraftNMax:
rollbackResponse.spec_draft_n_max ?? null,
loadedKvCacheDtype: rollbackResponse.cache_type_kv ?? null,
loadedChatTemplateOverride:
stateBeforeUnload.loadedChatTemplateOverride,
- // Re-baseline the GPU knobs from the rolled-back load's own
- // response (the shared seeding every load path uses): the
- // refresh() below can't do it, since the status reseed is
- // gated off while modelLoading is still true. A failed staged
- // Load stays staged for retry, so the staged hold applies.
- ...loadedGpuMemoryFieldsUnlessStaged(rollbackResponse, {
- tensorParallel: rollbackResponse.tensor_parallel ?? false,
- loadedTensorParallel:
- rollbackResponse.tensor_parallel ?? false,
- // refresh() is held while modelLoading remains true, so
- // restore the rolled-back model's context pin directly.
- customContextLength:
- stateBeforeUnload.loadedCustomContextLength,
- }),
+ ...loadedGpuMemoryFields(rollbackResponse),
+ tensorParallel: rollbackResponse.tensor_parallel ?? false,
loadedTensorParallel:
rollbackResponse.tensor_parallel ?? false,
+ customContextLength:
+ stateBeforeUnload.loadedCustomContextLength,
loadedCustomContextLength:
stateBeforeUnload.loadedCustomContextLength,
});
@@ -1444,6 +1455,9 @@ export function useChatModelRuntime() {
resetLoadingUi();
}
} catch (error) {
+ if (typeof selection !== "string" && selection.previousConfig) {
+ applyPerModelConfigToRuntime(selection.previousConfig);
+ }
if (abortCtrl.signal.aborted) return; // User cancelled, nothing to report
resetLoadingUi();
const message =
@@ -1474,6 +1488,13 @@ export function useChatModelRuntime() {
if (!params.checkpoint) {
return false;
}
+ const runtime = useChatRuntimeStore.getState();
+ if (runtime.modelLoading || runtime.loadingModelPick) {
+ toast.info("A model is loading", {
+ description: "Wait for it to finish or cancel it first.",
+ });
+ return false;
+ }
setModelsError(null);
if (isExternalModelId(params.checkpoint)) {
clearCheckpoint();
diff --git a/studio/frontend/src/features/chat/hooks/use-staged-model-preparation.ts b/studio/frontend/src/features/chat/hooks/use-staged-model-preparation.ts
deleted file mode 100644
index a3e7a2d264..0000000000
--- a/studio/frontend/src/features/chat/hooks/use-staged-model-preparation.ts
+++ /dev/null
@@ -1,169 +0,0 @@
-// 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 { useCallback, useEffect } from "react";
-
-import { useRepoDownload } from "@/features/hub/download-manager/use-repo-download";
-import type { DownloadJob } from "@/features/hub/download-manager/use-repo-download";
-import { useLatestRef } from "@/features/hub/hooks/use-latest-ref";
-
-import { fetchGgufStagedMetadata } from "../api/chat-api";
-import {
- isPendingGguf,
- pendingSelectionMatches,
- useChatRuntimeStore,
-} from "../stores/chat-runtime-store";
-import type { PendingModelSelection } from "../stores/chat-runtime-store";
-
-/**
- * Drives the deferred ("Load on selection" off) staging flow for a GGUF:
- * download the file if needed (HF repo) or read it in place (native drag-drop /
- * picked file), then read its header context length so the settings sheet can
- * show the real context slider before the single GPU load. The staged context
- * lands on `pendingSelection.contextLength` (scoped to the staged model, never
- * the loaded model's `ggufContextLength`). Returns the live download job so the
- * sheet can render progress / cancel. Mount once on the chat page.
- */
-export function useStagedModelPreparation(opts?: {
- /** Load the cached file once an autoLoad pick's download completes. */
- onAutoLoad?: (pending: PendingModelSelection) => void;
-}): DownloadJob {
- const pendingId = useChatRuntimeStore((s) => s.pendingSelection?.id ?? null);
- const pendingVariant = useChatRuntimeStore(
- (s) => s.pendingSelection?.ggufVariant ?? null,
- );
- const pendingNativeToken = useChatRuntimeStore(
- (s) => s.pendingSelection?.nativePathToken ?? null,
- );
- // Only GGUF picks (HF variant or native file) have a header worth reading.
- const pendingIsGguf = useChatRuntimeStore((s) =>
- isPendingGguf(s.pendingSelection),
- );
- // Non-GGUF HF repos download a full snapshot (variant null) but have no header.
- const pendingIsHubRepo = useChatRuntimeStore(
- (s) => s.pendingSelection?.isHubRepo ?? false,
- );
- const pendingDownloaded = useChatRuntimeStore(
- (s) => s.pendingSelection?.isDownloaded ?? false,
- );
- // "Already probed" must key off layerCount / moeLayerCount, which only the
- // full header probe fills (it sets all three together, so either is a
- // reliable marker). contextLength alone can be list-seeded from
- // /gguf-variants, which returns no layer/MoE counts -- treating it as
- // complete would skip the probe and leave the GPU Layers slider at its 256
- // fallback and the MoE slider hidden until the model loads.
- const pendingHasMetadata = useChatRuntimeStore(
- (s) =>
- s.pendingSelection?.layerCount != null ||
- s.pendingSelection?.moeLayerCount != null,
- );
- const setPendingSelection = useChatRuntimeStore((s) => s.setPendingSelection);
- const onAutoLoadRef = useLatestRef(opts?.onAutoLoad);
-
- // A failed or cancelled autoLoad download has no sheet to retry from, so drop
- // the staged pick rather than leave it waiting on a load that won't come.
- const handleAutoLoadAbort = useCallback((variant: string | null) => {
- const latest = useChatRuntimeStore.getState().pendingSelection;
- if (
- latest?.autoLoad &&
- (latest.ggufVariant ?? null) === (variant ?? null)
- ) {
- useChatRuntimeStore.getState().abandonStagedModel();
- }
- }, []);
-
- const fetchContextMetadata = useCallback(async () => {
- const current = useChatRuntimeStore.getState().pendingSelection;
- if (!current?.id || !isPendingGguf(current)) return;
- const { id, ggufVariant, nativePathToken } = current;
- try {
- const { contextLength, layerCount, moeLayerCount } =
- await fetchGgufStagedMetadata({
- model_path: id,
- gguf_variant: ggufVariant,
- hf_token: useChatRuntimeStore.getState().hfToken || null,
- nativePathToken,
- });
- // Apply only if the same model is still staged (the user may have switched
- // picks or loaded/cancelled while the request was in flight).
- const latest = useChatRuntimeStore.getState().pendingSelection;
- if (
- latest &&
- pendingSelectionMatches(latest, { id, ggufVariant, nativePathToken }) &&
- (contextLength != null || layerCount != null || moeLayerCount != null)
- ) {
- setPendingSelection({
- ...latest,
- contextLength,
- layerCount,
- moeLayerCount,
- });
- }
- } catch {
- // Leave metadata null: the context/MoE sliders stay hidden and the user
- // can still load (they fill in from the load response afterwards).
- }
- }, [setPendingSelection]);
-
- const job = useRepoDownload({
- kind: "model",
- // useRepoDownload must be called unconditionally; an idle repo id keeps it
- // inert until something is staged.
- repoId: pendingId ?? "__staged_idle__",
- activeVariant: pendingVariant,
- onComplete: (variant) => {
- // autoLoad picks load the cached file now; staged picks read the header so
- // the sheet's context slider can show before a manual load.
- const latest = useChatRuntimeStore.getState().pendingSelection;
- if (
- latest?.autoLoad &&
- (latest.ggufVariant ?? null) === (variant ?? null)
- ) {
- onAutoLoadRef.current?.(latest);
- return;
- }
- void fetchContextMetadata();
- },
- onError: handleAutoLoadAbort,
- onCancelled: handleAutoLoadAbort,
- });
-
- // job.requestStartDownload's identity changes per render; hold it in a ref so
- // the staging effect re-runs only when the staged model itself changes.
- const startDownloadRef = useLatestRef(job.requestStartDownload);
- const fetchMetadataRef = useLatestRef(fetchContextMetadata);
-
- useEffect(() => {
- // GGUF picks (header worth reading) and uncached non-GGUF hub repos (full
- // snapshot, no header) both run here; everything else is loaded directly.
- if (
- !pendingId ||
- (!pendingIsGguf && !pendingIsHubRepo) ||
- pendingHasMetadata
- ) {
- return;
- }
- // Native files and already-downloaded HF files are local: read the header
- // now. Otherwise download first (a GGUF variant, or a null-variant snapshot
- // for a hub repo); onComplete then reads the header or auto-loads.
- if (pendingNativeToken || pendingDownloaded) {
- void fetchMetadataRef.current();
- } else {
- const expectedBytes =
- useChatRuntimeStore.getState().pendingSelection?.expectedBytes ?? 0;
- void startDownloadRef.current(pendingVariant, expectedBytes);
- }
- }, [
- pendingId,
- pendingVariant,
- pendingNativeToken,
- pendingIsGguf,
- pendingIsHubRepo,
- pendingDownloaded,
- pendingHasMetadata,
- startDownloadRef,
- fetchMetadataRef,
- ]);
-
- return job;
-}
diff --git a/studio/frontend/src/features/chat/index.ts b/studio/frontend/src/features/chat/index.ts
index b0059b57b1..c5b24bbb55 100644
--- a/studio/frontend/src/features/chat/index.ts
+++ b/studio/frontend/src/features/chat/index.ts
@@ -3,20 +3,34 @@
export { ChatPage, validateChatSearch, type ChatSearch } from "./chat-page";
export {
+ addScanFolder,
+ browseFolders,
deleteChatAttachment,
+ deleteFineTunedModel,
fetchChatAttachmentBlob,
+ fetchGgufStagedMetadata,
+ getCachedModelPath,
getInferenceStatus,
listChatAttachments,
listGgufVariants,
listLocalModels,
+ listRecommendedFolders,
+ listScanFolders,
loadModel,
+ removeScanFolder,
+ revealCachedModel,
+ type BrowseFoldersResponse,
+ type CachedGgufRepo,
+ type CachedModelRepo,
type ChatAttachmentPage,
type ChatAttachmentRecord,
type LocalModelInfo,
+ type ScanFolderInfo,
} from "./api/chat-api";
export type { GgufVariantDetail } from "./types/api";
export {
ChatSettingsPanel,
+ ParamSlider,
defaultInferenceParams,
type InferenceParams,
type Preset,
@@ -25,6 +39,11 @@ export { useChatRuntimeStore } from "./stores/chat-runtime-store";
export {
CHAT_RAG_CAPTION_KEY,
CHAT_RAG_OCR_KEY,
+ normalizeSpeculativeType,
+ readPersistedSpeculativeType,
+ readPersistedGpuMemoryMode,
+ reconcilePersistedGpuIds,
+ GPU_LAYERS_AUTO,
} from "./stores/chat-runtime-store";
export {
preferFullToolOutput,
@@ -46,9 +65,11 @@ export {
} from "./hooks/use-chat-model-runtime";
export {
customProviderDisplayName,
+ isCustomProviderType,
isExternalModelId,
parseExternalModelId,
} from "./external-providers";
+export { ApiProviderLogo } from "./api-provider-logo";
export { useExternalProvidersStore } from "./stores/external-providers-store";
export { ChatSearchDialog } from "./components/chat-search-dialog";
export { setTrainingCompareHandoff } from "./lib/training-compare-handoff";
@@ -62,8 +83,8 @@ export {
useSelectedChatArtifact,
} from "./artifacts/store";
export {
- downloadChatExport,
downloadArchivedChatExport,
+ downloadChatExport,
} from "./utils/export-chat-history";
export {
clearNewChatDraft,
diff --git a/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts b/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts
index 69bb38bbbe..547c3f374e 100644
--- a/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts
+++ b/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts
@@ -280,12 +280,9 @@ export function applyActiveModelStatusToStore(
ggufContextLength: currentGgufContextLength,
ggufMaxContextLength,
ggufNativeContextLength,
- // A non-GGUF status must also drop a stale native-path token: without this the
- // isGguf OR (activeGgufVariant || activeNativePathToken || ggufContextLength)
- // stays true after switching from a native GGUF to a transformers model, so a
- // Codex-only detection would auto-select for a model its preflight rejects. A real
- // GGUF load reports is_gguf: true, so its token is preserved (the load path owns it).
- ...(status.is_gguf ? {} : { activeNativePathToken: null }),
+ ...(status.is_gguf
+ ? {}
+ : { activeNativePathToken: null, activeNativePathExpiresAtMs: null }),
modelRequiresTrustRemoteCode: status.requires_trust_remote_code ?? false,
defaultChatTemplate: nextDefaultChatTemplate,
loadedIsMultimodal: isMultimodalResponse(status),
@@ -299,13 +296,11 @@ export function applyActiveModelStatusToStore(
// model changed underneath this tab (auto-switch, another client), the
// old model's baselines are stale and must adopt the new status.
...(seedLoadParams &&
- prevState.pendingSelection == null &&
(prevState.loadedSpeculativeType === null || hydratingExistingModel) && {
speculativeType: currentSpecType,
loadedSpeculativeType: currentSpecType,
}),
...(seedLoadParams &&
- prevState.pendingSelection == null &&
status.spec_draft_n_max !== undefined &&
(hydratingExistingModel ||
(prevState.loadedSpecDraftNMax === null &&
@@ -314,14 +309,12 @@ export function applyActiveModelStatusToStore(
loadedSpecDraftNMax: status.spec_draft_n_max ?? null,
}),
...(seedLoadParams &&
- prevState.pendingSelection == null &&
status.cache_type_kv !== undefined &&
(prevState.loadedKvCacheDtype === null || hydratingExistingModel) && {
kvCacheDtype: status.cache_type_kv,
loadedKvCacheDtype: status.cache_type_kv,
}),
...(seedLoadParams &&
- prevState.pendingSelection == null &&
status.tensor_parallel !== undefined &&
(prevState.loadedTensorParallel === null || hydratingExistingModel) && {
tensorParallel: status.tensor_parallel,
@@ -331,7 +324,6 @@ export function applyActiveModelStatusToStore(
// placement change. gpuStatusFields preserves dirty local edits in the last
// case while advancing their loaded baselines.
...(seedLoadParams &&
- prevState.pendingSelection == null &&
(prevState.loadedGpuMemoryMode === null ||
hydratingExistingModel ||
gpuStatusChanged) &&
diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx
index 461eef99b2..70dac70a23 100644
--- a/studio/frontend/src/features/chat/shared-composer.tsx
+++ b/studio/frontend/src/features/chat/shared-composer.tsx
@@ -79,6 +79,12 @@ import { KnowledgeBaseComposerButton } from "@/features/rag/components/knowledge
import { NewProjectDialog } from "./components/new-project-dialog";
import { useChatProjects } from "./hooks/use-chat-projects";
import { confirmRemoteCodeIfNeeded } from "@/features/security";
+import {
+ DEFAULT_MAX_SEQ_LENGTH,
+ normalizeMaxSeqLength,
+ resolveInitialConfig,
+ type PerModelConfig,
+} from "@/features/model-picker";
import {
confirmTransformersUpgradeIfNeeded,
useTransformersUpgradeDialogStore,
@@ -97,7 +103,7 @@ import {
usePlusMenuPrefsStore,
} from "./stores/plus-menu-prefs-store";
import {
- loadedGpuMemoryFieldsUnlessStaged,
+ loadedGpuMemoryFields,
type ReasoningEffort,
reconcilePersistedGpuIds,
resolveLoadedSpeculativeSettings,
@@ -495,8 +501,24 @@ type CompareModelSelection = {
id: string;
isLora: boolean;
ggufVariant?: string;
+ config?: PerModelConfig;
};
+function cleanCompareChatTemplate(
+ value: string | null | undefined,
+): string | null {
+ return value?.trim() ? value : null;
+}
+
+function resolveCompareSpecDraftNMax(
+ speculativeType: string | null,
+ value: number | null,
+): number | null {
+ return speculativeType === "mtp" || speculativeType === "mtp+ngram"
+ ? value
+ : null;
+}
+
// Tool icon plus an X overlay CSS reveals on hover when the pill is active.
function PillGlyph({ children }: { children: ReactNode }) {
return (
@@ -1023,15 +1045,12 @@ export function SharedComposer({
// Generalized compare: load each model before dispatching to its side
if (isGeneralizedCompare) {
const store = useChatRuntimeStore.getState();
- const maxSeqLength = store.params.maxSeqLength;
const trustRemoteCode = store.params.trustRemoteCode ?? false;
- const chatTemplateOverride = store.chatTemplateOverride;
- const effectiveChatTemplateOverride = chatTemplateOverride?.trim()
- ? chatTemplateOverride
- : null;
+ const fallbackTensorParallel = store.tensorParallel;
const specSettings = resolveSpeculativeSettingsForLoad({
usePersistedPreference: true,
});
+ let loadedFromConfig = false;
function modelDisplayName(id: string): string {
const parts = id.split("/");
@@ -1058,7 +1077,6 @@ export function SharedComposer({
// path: an early remember-restore can hold a stale cross-host pick that
// /load would reject (the device cache is populated by send time).
selectedGpuIds: reconcilePersistedGpuIds(store.selectedGpuIds),
- tensorParallel: store.tensorParallel,
customContextLength: store.customContextLength,
};
// Set when an accepted transformers install unloaded the active model
@@ -1069,15 +1087,68 @@ export function SharedComposer({
sel: CompareModelSelection,
): Promise {
const currentStore = useChatRuntimeStore.getState();
+ const config = sel.config ?? null;
+ // This pane's effective config: an explicit selection config, else the
+ // remembered store config for this model/quant (never the other pane's).
+ // No saved config resolves to all-null defaults, so settings below fall
+ // through to their session default.
+ const resolved = config
+ ? { config, remembered: true }
+ : resolveInitialConfig(sel.id, sel.ggufVariant ?? null);
+ const ownConfig = resolved.config;
+ const ownRemembered = resolved.remembered;
+ // Mirror single-view resolveLoadMaxSeqLength: a GGUF pane with no explicit
+ // context loads at native (0 -> n_ctx_train), not the session maxSeqLength,
+ // which would silently shrink the shown context.
+ const isGgufLoad =
+ (sel.ggufVariant ?? null) != null ||
+ sel.id.toLowerCase().endsWith(".gguf");
+ // A non-GGUF pane with no saved maxSeqLength falls back to the app default,
+ // not the active model's shared runtime snapshot: else comparing a saved
+ // 128K model against an unconfigured one loads the latter at 128K and OOMs.
+ const effectiveMaxSeqLength =
+ ownConfig.customContextLength ??
+ normalizeMaxSeqLength(ownConfig.maxSeqLength) ??
+ (isGgufLoad ? 0 : DEFAULT_MAX_SEQ_LENGTH);
+ const effectiveChatTemplateOverride = cleanCompareChatTemplate(
+ ownConfig.chatTemplateOverride,
+ );
+ const effectiveSpeculativeType =
+ ownConfig.speculativeType ?? specSettings.speculativeType;
+ const effectiveSpecDraftNMax = ownRemembered
+ ? resolveCompareSpecDraftNMax(
+ effectiveSpeculativeType,
+ ownConfig.specDraftNMax,
+ )
+ : specSettings.specDraftNMax;
+ const effectiveTensorParallel = ownRemembered
+ ? ownConfig.tensorParallel
+ : fallbackTensorParallel;
+ if (ownConfig.selectedGpuIds != null) {
+ await ensureGpuDeviceCache();
+ }
+ const effectiveGpuMemoryMode =
+ ownConfig.gpuMemoryMode ?? compareLoadKnobs.gpuMemoryMode;
+ const effectiveGpuLayers =
+ ownConfig.gpuLayers ?? compareLoadKnobs.gpuLayers;
+ const effectiveNCpuMoe =
+ ownConfig.nCpuMoe ?? compareLoadKnobs.nCpuMoe;
+ const effectiveSelectedGpuIds =
+ ownConfig.selectedGpuIds !== undefined
+ ? reconcilePersistedGpuIds(ownConfig.selectedGpuIds)
+ : compareLoadKnobs.selectedGpuIds;
+ // A pane's context comes from its own config only: a saved pin, or null
+ // (Auto/native). It must not inherit the active model's shared snapshot --
+ // resolveFitMaxSeqLength would treat that as a pin and load this pane at
+ // the other model's context (changing VRAM/results or OOMing).
+ const effectiveCustomContextLength = ownConfig.customContextLength;
let loadTrustRemoteCode = trustRemoteCode;
let approvedRemoteCodeFingerprint: string | null = null;
const isAlreadyActive =
currentStore.params.checkpoint === sel.id &&
(currentStore.activeGgufVariant ?? null) ===
(sel.ggufVariant ?? null);
- // Already loaded (gate passed at first load): skip a redundant reload that would
- // re-trigger the gate without the approval fingerprint and fail for HIGH custom code.
- if (isAlreadyActive) {
+ if (isAlreadyActive && !config && !loadedFromConfig) {
return "ready";
}
const targetIsGguf =
@@ -1087,10 +1158,13 @@ export function SharedComposer({
// layers the load sends 0 / the pinned context, not raw maxSeqLength).
const compareMaxSeqLength = resolveFitMaxSeqLength(
targetIsGguf,
- compareLoadKnobs.gpuMemoryMode,
- compareLoadKnobs.gpuLayers,
- compareLoadKnobs.customContextLength,
- maxSeqLength,
+ effectiveGpuMemoryMode,
+ effectiveGpuLayers,
+ // Prefer this pane's own saved context pin over the shared snapshot,
+ // falling back to its per-pane effective context (GGUF with no saved
+ // context loads at native, not the session maxSeqLength).
+ effectiveCustomContextLength,
+ effectiveMaxSeqLength,
);
const validation = await validateModel({
model_path: sel.id,
@@ -1105,8 +1179,8 @@ export function SharedComposer({
// below: a non-GGUF target must not inherit a hidden GGUF GPU pick.
...(targetIsGguf
? {
- gpu_ids: compareLoadKnobs.selectedGpuIds ?? undefined,
- gpu_memory_mode: compareLoadKnobs.gpuMemoryMode,
+ gpu_ids: effectiveSelectedGpuIds ?? undefined,
+ gpu_memory_mode: effectiveGpuMemoryMode,
}
: {}),
});
@@ -1164,27 +1238,28 @@ export function SharedComposer({
trust_remote_code: loadTrustRemoteCode,
approved_remote_code_fingerprint: approvedRemoteCodeFingerprint,
chat_template_override: effectiveChatTemplateOverride,
- speculative_type: specSettings.speculativeType,
- spec_draft_n_max: specSettings.specDraftNMax,
- // Honor the Tensor Parallelism + GPU Memory choices on compare loads.
- // GGUF-only, like the auto-load path: the picker is a GGUF control,
- // so a non-GGUF target loads via HF auto-placement instead of being
- // pinned to a leftover GGUF pick it can't even show.
- tensor_parallel: compareLoadKnobs.tensorParallel,
+ cache_type_kv: ownConfig.kvCacheDtype ?? null,
+ speculative_type: effectiveSpeculativeType,
+ spec_draft_n_max: effectiveSpecDraftNMax,
+ tensor_parallel: effectiveTensorParallel,
...(targetIsGguf
? {
- gpu_memory_mode: compareLoadKnobs.gpuMemoryMode,
- gpu_layers: compareLoadKnobs.gpuLayers,
- n_cpu_moe: compareLoadKnobs.nCpuMoe,
+ gpu_memory_mode: effectiveGpuMemoryMode,
+ gpu_layers: effectiveGpuLayers,
+ n_cpu_moe: effectiveNCpuMoe,
tensor_split: compareLoadKnobs.splitRatio ?? undefined,
- gpu_ids: compareLoadKnobs.selectedGpuIds ?? undefined,
+ gpu_ids: effectiveSelectedGpuIds ?? undefined,
}
: {}),
});
- saveSpeculativeType(specSettings.speculativeType);
+ // Keep a compare pane's per-model speculative choice load-local: persist
+ // the global preference only when it came from global settings.
+ if (ownConfig.speculativeType == null) {
+ saveSpeculativeType(effectiveSpeculativeType);
+ }
// Persist the GPU Memory mode on a non-diffusion GGUF compare-load too,
// so an applied manual choice survives a restart.
- persistGpuMemoryModeOnLoad(resp, compareLoadKnobs.gpuMemoryMode);
+ persistGpuMemoryModeOnLoad(resp, effectiveGpuMemoryMode);
upgradeUnloadedActive = false;
const store = useChatRuntimeStore.getState();
store.setCheckpoint(
@@ -1200,9 +1275,9 @@ export function SharedComposer({
// compare loads don't send the pin, so their baseline clears.
const keepCustomCtx = targetIsGguf
? resolveManualAutoCtxPin(
- compareLoadKnobs.gpuMemoryMode,
- compareLoadKnobs.gpuLayers,
- compareLoadKnobs.customContextLength,
+ effectiveGpuMemoryMode,
+ effectiveGpuLayers,
+ effectiveCustomContextLength,
)
: null;
useChatRuntimeStore.setState({
@@ -1211,37 +1286,52 @@ export function SharedComposer({
...reasoningCapsFromLoad(resp),
supportsPreserveThinking: resp.supports_preserve_thinking ?? false,
supportsTools: resp.supports_tools ?? false,
+ kvCacheDtype: resp.cache_type_kv ?? null,
+ loadedKvCacheDtype: resp.cache_type_kv ?? null,
tensorParallel: resp.tensor_parallel ?? false,
loadedTensorParallel: resp.tensor_parallel ?? false,
- customContextLength: keepCustomCtx,
+ defaultChatTemplate: resp.chat_template ?? null,
+ chatTemplateOverride: effectiveChatTemplateOverride,
+ loadedChatTemplateOverride: effectiveChatTemplateOverride,
+ // The context baseline this pane loaded with (see keepCustomCtx above),
+ // so a later Apply/Reset can't silently revert a Manual+Auto pin.
loadedCustomContextLength: keepCustomCtx,
- // Seed the loaded GGUF context (interactive/auto-load parity): the
- // settings sheet keys the GGUF GPU controls off it for a direct .gguf
- // with no variant, and a later Apply reads it as the resolved context.
- ...(targetIsGguf
- ? {
- ggufContextLength: resp.context_length ?? 131072,
- ggufMaxContextLength:
- resp.max_context_length ?? resp.context_length ?? 131072,
- ggufNativeContextLength: resp.native_context_length ?? null,
- }
- : { ggufContextLength: null }),
- // Compare loads resolve by id (HF repo / local path), never through a
- // native-path lease, so a token left by a previously loaded native
- // GGUF is stale here -- isLoadedGguf keys off it, and a stale token
- // would dress a non-GGUF compare load in GGUF controls. Mirror the
- // interactive path, which writes it on every load success.
- activeNativePathToken: null,
- // Held under an open staged pick: setCheckpoint preserves a stage on
- // the empty->active transition, so a compare load can complete with
- // staged GPU edits still on screen.
- ...loadedGpuMemoryFieldsUnlessStaged(resp),
+ // Adopt the load response's GPU-memory fields (mode/layers/MoE/split/pick
+ // plus loaded baselines) so the GPU controls round-trip. (gguf context,
+ // customContextLength and native-path token/expiry clear in the tail below.)
+ ...loadedGpuMemoryFields(resp),
// Drives the GPU Memory controls' diffusion gate; set alongside the
// GPU fields on every load path so the gate can't read stale.
loadedIsDiffusion: resp.is_diffusion ?? false,
loadedIsMultimodal: isMultimodalResponse(resp),
+ // Record the context this pane loaded with (like the single-model path)
+ // so when it becomes the active model, the UI and later reload/save use
+ // its context, not the previous/default one.
+ customContextLength: isGgufLoad
+ ? (ownConfig.customContextLength ?? keepCustomCtx)
+ : null,
+ ggufContextLength: resp.is_gguf ? (resp.context_length ?? null) : null,
+ ggufNativeContextLength: resp.is_gguf
+ ? (resp.native_context_length ?? null)
+ : null,
+ ggufMaxContextLength: resp.is_gguf
+ ? (resp.max_context_length ?? null)
+ : null,
+ // Compare selections load by repo/variant, never from the file picker,
+ // so they carry no native lease. Clear any prior picked file's
+ // token/expiry so the reload path never sends a stale lease.
+ activeNativePathToken: null,
+ activeNativePathExpiresAtMs: null,
...resolveLoadedSpeculativeSettings(resp),
});
+ if (!isGgufLoad) {
+ // Non-GGUF panes carry their context in params.maxSeqLength.
+ store.setParams({
+ ...useChatRuntimeStore.getState().params,
+ maxSeqLength: effectiveMaxSeqLength,
+ });
+ }
+ loadedFromConfig = config != null;
// Sync the models[] entry with the load response so attach/send gates
// read fresh capabilities. /api/models/list can lag a model's actual
// state (e.g. a GGUF whose mmproj arrived after the snapshot).
diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts
index 5786947118..89bc21ee18 100644
--- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts
+++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts
@@ -1,16 +1,8 @@
// 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 type { RememberedLoadSettings } from "@/components/assistant-ui/model-selector/remembered-load-settings";
-import {
- cancelStagedModelDownload,
- mirrorHfTokenInto,
- useHfTokenStore,
-} from "@/features/hub";
-import {
- cachedPinnableGpuIndices,
- ensureGpuDeviceCache,
-} from "@/hooks/use-gpu-info";
+import { mirrorHfTokenInto, useHfTokenStore } from "@/features/hub";
+import { cachedPinnableGpuIndices } from "@/hooks/use-gpu-info";
import { toast } from "@/lib/toast";
import { create } from "zustand";
import { isExternalModelId, parseExternalModelId } from "../external-providers";
@@ -46,7 +38,6 @@ export const CHAT_ALLOW_ARTIFACT_NETWORK_ACCESS_KEY =
"unsloth_chat_allow_artifact_network_access";
export const CHAT_MCP_ENABLED_KEY = "unsloth_chat_mcp_enabled";
export const CHAT_CONFIRM_TOOL_CALLS_KEY = "unsloth_chat_confirm_tool_calls";
-export const CHAT_LOAD_ON_SELECTION_KEY = "unsloth_chat_load_on_selection";
export const CHAT_EXPAND_QUANTIZATIONS_KEY =
"unsloth_chat_expand_quantizations";
export const CHAT_SHOW_ALL_QUANTIZATIONS_KEY =
@@ -229,6 +220,11 @@ export type PendingImageEditReference = {
openaiResponseId?: string;
openaiReasoningItem?: unknown;
};
+export type LoadingModelPick = {
+ id: string;
+ ggufVariant: string | null;
+ nativePathToken: string | null;
+};
export type ReasoningEffort =
| "none"
| "minimal"
@@ -680,70 +676,7 @@ export function loadedGpuMemoryFields(resp: {
};
}
-/** loadedGpuMemoryFields (plus any seedExtras), unless a staged pick is open.
- *
- * With a staged pick open (the load fired mid-staging), preserve its editable
- * GPU knobs and seedExtras, but still advance every loaded baseline. Otherwise
- * cancelling the stage restores its edits onto the newly loaded model. The
- * status reseed cannot repair that while pendingSelection holds it off.
- */
-export function loadedGpuMemoryFieldsUnlessStaged(
- resp: Parameters[0],
- seedExtras?: T,
-) {
- const fields = loadedGpuMemoryFields(resp);
- if (useChatRuntimeStore.getState().pendingSelection != null) {
- return {
- loadedGpuMemoryMode: fields.loadedGpuMemoryMode,
- loadedGpuLayers: fields.loadedGpuLayers,
- loadedNCpuMoe: fields.loadedNCpuMoe,
- loadedSplitRatio: fields.loadedSplitRatio,
- loadedGpuIds: fields.loadedGpuIds,
- // These are metadata ceilings for the model that actually loaded, not
- // editable values from the open stage. Advance them with the baselines
- // so abandoning the stage cannot expose the previous model's limits.
- ggufLayerCount: fields.ggufLayerCount,
- moeLayerCount: fields.moeLayerCount,
- };
- }
- return { ...fields, ...seedExtras };
-}
-
-/** A local model staged for a deferred load (see `pendingSelection`). Shape is
- * a subset of the load hook's `SelectedModelInput`, structurally assignable. */
-export type PendingModelSelection = {
- id: string;
- isLora?: boolean;
- ggufVariant?: string;
- isDownloaded?: boolean;
- expectedBytes?: number;
- /** Native (drag-drop / picked-from-disk) GGUF: the path token used to read
- * the header and to load. Absent for HF-repo models. */
- nativePathToken?: string;
- /** Direct local .gguf file (custom folder / LM Studio): a GGUF source even
- * though it carries neither an HF variant nor a native path token. */
- isGguf?: boolean;
- /** Native context length read from the GGUF header once the file is local.
- * Scoped here (not the shared `ggufContextLength`) so a staged model's
- * metadata never pollutes the currently-loaded model's context display. */
- contextLength?: number | null;
- /** Total layer count (GGUF block_count); the manual gpu-layers ceiling is
- * this + 1 (llama.cpp counts the output layer as offloadable too);
- * scoped here like contextLength. */
- layerCount?: number | null;
- /** MoE expert-layer count from the GGUF header (manual --n-cpu-moe ceiling);
- * 0 for dense models, scoped here like contextLength. */
- moeLayerCount?: number | null;
- /** "Load on selection" on + un-cached GGUF: download via the manager (global
- * indicator) without opening the sheet, then load once the download finishes. */
- autoLoad?: boolean;
- /** Uncached non-GGUF HF repo: download the full snapshot via the manager
- * (variant null) the same way GGUF picks download a variant. */
- isHubRepo?: boolean;
-};
-
-/** A pick is a GGUF (HF variant, native file, or a direct local .gguf) and so
- * has pre-load options worth staging. Works on a selection or a staged pick. */
+/** A pick is a GGUF: HF variant, native file, or a direct local .gguf. */
export function hasGgufSource(x: {
ggufVariant?: string;
nativePathToken?: string;
@@ -781,30 +714,6 @@ export function isDownloadableHubRepo(x: {
);
}
-export function isPendingGguf(pending: PendingModelSelection | null): boolean {
- return pending != null && hasGgufSource(pending);
-}
-
-/** Whether `pending` refers to the same model as `pick` (id + GGUF variant +
- * native path token, optionals null-normalized). Native ids are display labels
- * that can collide, so the token must match too — id alone can land on the
- * wrong file. */
-export function pendingSelectionMatches(
- pending: PendingModelSelection | null,
- pick: {
- id: string;
- ggufVariant?: string | null;
- nativePathToken?: string | null;
- },
-): boolean {
- return (
- pending != null &&
- pending.id === pick.id &&
- (pending.ggufVariant ?? null) === (pick.ggufVariant ?? null) &&
- (pending.nativePathToken ?? null) === (pick.nativePathToken ?? null)
- );
-}
-
type ChatRuntimeStore = {
settingsHydrated: boolean;
params: InferenceParams;
@@ -988,10 +897,6 @@ type ChatRuntimeStore = {
/** Picked physical GPU indices (null = use all / automatic). */
selectedGpuIds: number[] | null;
loadedGpuIds: number[] | null;
- /** Persisted: when false, picking a local model stages it as
- * `pendingSelection` (and opens settings) instead of loading immediately,
- * so load settings can be set before the single load. */
- loadOnSelection: boolean;
/** Persisted: expand every On Device GGUF repo's quantizations by default
* instead of waiting for a click. */
expandQuantizations: boolean;
@@ -1000,9 +905,6 @@ type ChatRuntimeStore = {
/** Persisted, shared by the chat model selector and the Hub page: list only
* models whose size fits this device's memory budget. */
fitOnDeviceOnly: boolean;
- /** A local model picked while `loadOnSelection` is off: staged, not loaded.
- * The settings sheet shows its load knobs and a Load button. */
- pendingSelection: PendingModelSelection | null;
loadedIsMultimodal: boolean;
/** Active model is a block-diffusion model (DiffusionGemma): drives the
* denoising-canvas artifact auto-render. */
@@ -1041,9 +943,16 @@ type ChatRuntimeStore = {
cacheWriteTokens?: number;
} | null;
modelLoading: boolean;
+ loadingModelPick: LoadingModelPick | null;
activeNativePathToken: string | null;
+ // Wall-clock expiry (ms) of the active native path token. The desktop host
+ // prunes file leases after a TTL, so a reload checks this to prompt
+ // re-selection instead of reusing a dead token.
+ activeNativePathExpiresAtMs: number | null;
hydratePersistedSettings: () => Promise;
setModelLoading: (loading: boolean) => void;
+ setLoadingModelPick: (pick: LoadingModelPick | null) => void;
+ clearLoadingModelPick: (expected: LoadingModelPick) => void;
setModelRequiresTrustRemoteCode: (required: boolean) => void;
setParams: (params: InferenceParams) => void;
setCustomPresets: (presets: Preset[]) => void;
@@ -1119,38 +1028,14 @@ type ChatRuntimeStore = {
setNudgeToolCalls: (enabled: boolean) => void;
setMaxToolCallsPerMessage: (value: number) => void;
setToolCallTimeout: (value: number) => void;
- setKvCacheDtype: (dtype: string | null) => void;
- setSpeculativeType: (type: string | null) => void;
- setSpecDraftNMax: (value: number | null) => void;
- /** Revert the editable load knobs to the loaded model's baseline (or defaults
- * when nothing is loaded). Used by the settings-sheet Reset button and to
- * start each deferred-staging session clean so one staged pick's settings
- * don't leak onto the next. */
- resetModelSettingsToLoaded: () => void;
- /** Seed the editable load knobs from a model's remembered settings. Shared by
- * the settings sheet's restore effect and the "Load on selection" paths,
- * which skip the sheet but must still honor a saved config. */
- applyRememberedLoadSettings: (settings: RememberedLoadSettings) => void;
- setTensorParallel: (value: boolean) => void;
setGpuMemoryMode: (mode: "auto" | "manual") => void;
setGpuLayers: (value: number) => void;
setNCpuMoe: (value: number) => void;
setSplitRatio: (value: number[] | null) => void;
setSelectedGpuIds: (ids: number[] | null) => void;
- setLoadOnSelection: (value: boolean) => void;
setExpandQuantizations: (value: boolean) => void;
setShowAllQuantizations: (value: boolean) => void;
setFitOnDeviceOnly: (value: boolean) => void;
- setPendingSelection: (selection: PendingModelSelection | null) => void;
- /** Stage a pick for a deferred load: revert knobs to the loaded baseline,
- * record the selection, and open the settings sheet. */
- stageModel: (selection: PendingModelSelection) => void;
- /** Abandon a staged pick without loading: revert knobs to the loaded baseline
- * and clear the pending selection. Cancels its in-flight download too, unless
- * `keepDownload` is set (navigation keeps the transfer running, like Hub). */
- abandonStagedModel: (opts?: { keepDownload?: boolean }) => void;
- setCustomContextLength: (v: number | null) => void;
- setChatTemplateOverride: (template: string | null) => void;
setPendingAudio: (base64: string, name: string) => void;
clearPendingAudio: () => void;
setPendingImageEditReference: (
@@ -1352,38 +1237,6 @@ function setScalarSettingVersion(
saveSettingsPatch({ [key]: value });
}
-/** The "revert to the loaded model" baseline for the editable load knobs.
- * Shared by resetModelSettingsToLoaded (full revert) and stageModel (which
- * overrides speculative and the per-model GPU knobs to start a fresh pick). */
-function loadedBaselineSettings(s: ChatRuntimeStore) {
- const hasLoadedModel = Boolean(s.params.checkpoint);
- return {
- // Revert to the loaded model's pin (null = Auto), not a blanket Auto.
- customContextLength: s.loadedCustomContextLength,
- kvCacheDtype: s.loadedKvCacheDtype,
- tensorParallel: s.loadedTensorParallel ?? false,
- speculativeType: hasLoadedModel
- ? s.loadedSpeculativeType
- : readPersistedSpeculativeType(),
- specDraftNMax: hasLoadedModel ? s.loadedSpecDraftNMax : null,
- chatTemplateOverride: s.loadedChatTemplateOverride,
- // GPU memory mode is a standing preference; revert to the loaded model's
- // mode (or the persisted default when nothing is loaded). Manual knobs and
- // the GPU pick are per-model and revert to their loaded baseline. A loaded
- // model with no applicable mode -- diffusion ("auto" baseline) or non-GGUF
- // (null baseline) -- keeps the live preference so Reset can't drop it.
- gpuMemoryMode: !hasLoadedModel
- ? readPersistedGpuMemoryMode()
- : s.loadedIsDiffusion
- ? s.gpuMemoryMode
- : (s.loadedGpuMemoryMode ?? s.gpuMemoryMode),
- gpuLayers: s.loadedGpuLayers ?? GPU_LAYERS_AUTO,
- nCpuMoe: s.loadedNCpuMoe ?? 0,
- splitRatio: s.loadedSplitRatio ?? null,
- selectedGpuIds: s.loadedGpuIds,
- };
-}
-
export const useChatRuntimeStore = create((set, get) => ({
settingsHydrated: false,
// Hydrate the last external checkpoint so the external picker survives a
@@ -1493,11 +1346,9 @@ export const useChatRuntimeStore = create((set, get) => ({
moeLayerCount: null,
selectedGpuIds: null,
loadedGpuIds: null,
- loadOnSelection: loadBool(CHAT_LOAD_ON_SELECTION_KEY, true),
expandQuantizations: loadBool(CHAT_EXPAND_QUANTIZATIONS_KEY, false),
showAllQuantizations: loadBool(CHAT_SHOW_ALL_QUANTIZATIONS_KEY, true),
fitOnDeviceOnly: loadBool(MODELS_FIT_ON_DEVICE_ONLY_KEY, false),
- pendingSelection: null,
loadedIsMultimodal: false,
loadedIsDiffusion: false,
customContextLength: null,
@@ -1515,7 +1366,9 @@ export const useChatRuntimeStore = create((set, get) => ({
pendingImageEditReference: null,
contextUsage: null,
modelLoading: false,
+ loadingModelPick: null,
activeNativePathToken: null,
+ activeNativePathExpiresAtMs: null,
hydratePersistedSettings: async () => {
if (get().settingsHydrated) {
return;
@@ -1554,6 +1407,20 @@ export const useChatRuntimeStore = create((set, get) => ({
return settingsHydrationPromise;
},
setModelLoading: (loading) => set({ modelLoading: loading }),
+ setLoadingModelPick: (pick) => set({ loadingModelPick: pick }),
+ clearLoadingModelPick: (expected) =>
+ set((state) => {
+ const current = state.loadingModelPick;
+ if (
+ !current ||
+ current.id !== expected.id ||
+ current.ggufVariant !== expected.ggufVariant ||
+ current.nativePathToken !== expected.nativePathToken
+ ) {
+ return state;
+ }
+ return { loadingModelPick: null };
+ }),
setModelRequiresTrustRemoteCode: (modelRequiresTrustRemoteCode) =>
set({ modelRequiresTrustRemoteCode }),
setParams: (params) =>
@@ -1634,13 +1501,6 @@ export const useChatRuntimeStore = create((set, get) => ({
// Clear stale per-turn usage on model change; the relaxed external-provider
// render gate would otherwise show old counters until the next completion.
const checkpointChanged = state.params.checkpoint !== modelId;
- const pendingToClear =
- checkpointChanged && state.params.checkpoint
- ? state.pendingSelection
- : null;
- if (pendingToClear) {
- cancelStagedModelDownload(pendingToClear);
- }
// Clamp maxTokens to the new model's cap when switching into an external
// model so a value carried over from a local session doesn't exceed the
// slider's max.
@@ -1668,14 +1528,6 @@ export const useChatRuntimeStore = create((set, get) => ({
},
activeGgufVariant: ggufVariant ?? null,
...(checkpointChanged ? { contextUsage: null } : {}),
- // Switching away from a loaded model (e.g. picking an external provider)
- // abandons any staged pick, so its Load button and edited knobs don't
- // linger over the newly active model. Same revert as abandonStagedModel.
- // Guarded on a non-empty current checkpoint: an establishing set from a
- // background status sync (empty -> active) must not wipe a fresh stage.
- ...(pendingToClear
- ? { ...loadedBaselineSettings(state), pendingSelection: null }
- : {}),
};
}),
setActiveThreadId: (activeThreadId) =>
@@ -1689,7 +1541,6 @@ export const useChatRuntimeStore = create((set, get) => ({
// clear any stored external selection so the next refresh doesn't snap
// back to a model the user intentionally cleared.
saveLastExternalCheckpoint(null);
- cancelStagedModelDownload(get().pendingSelection);
return set((state) => ({
params: {
...state.params,
@@ -1697,7 +1548,7 @@ export const useChatRuntimeStore = create((set, get) => ({
},
activeGgufVariant: null,
activeNativePathToken: null,
- pendingSelection: null,
+ activeNativePathExpiresAtMs: null,
ggufContextLength: null,
ggufMaxContextLength: null,
ggufNativeContextLength: null,
@@ -2044,10 +1895,6 @@ export const useChatRuntimeStore = create((set, get) => ({
);
return { toolCallTimeout };
}),
- setKvCacheDtype: (kvCacheDtype) => set({ kvCacheDtype }),
- setSpeculativeType: (speculativeType) => set({ speculativeType }),
- setSpecDraftNMax: (specDraftNMax) => set({ specDraftNMax }),
- setTensorParallel: (tensorParallel) => set({ tensorParallel }),
// Standing preference, but persisted only on a successful load (see
// use-chat-model-runtime), not on selection -- so an unapplied pick the user
// resets/abandons doesn't stick to the next session.
@@ -2056,63 +1903,6 @@ export const useChatRuntimeStore = create((set, get) => ({
setNCpuMoe: (nCpuMoe) => set({ nCpuMoe }),
setSplitRatio: (splitRatio) => set({ splitRatio }),
setSelectedGpuIds: (selectedGpuIds) => set({ selectedGpuIds }),
- resetModelSettingsToLoaded: () => set((s) => loadedBaselineSettings(s)),
- applyRememberedLoadSettings: (settings) => {
- const gpuCacheWasCold = cachedPinnableGpuIndices() === null;
- const restoredGpuIds =
- settings.selectedGpuIds !== undefined
- ? reconcilePersistedGpuIds(settings.selectedGpuIds)
- : undefined;
- // Coalesce every field: a blob persisted by an older/newer build can omit
- // keys, and a raw spread would push `undefined` into fields typed non-null.
- // The GPU knobs are spread only when present, but first reset the per-model
- // ones to defaults: this path (load-on-selection) starts from the loaded
- // model's baseline and skips the model-switch reset, so a blob omitting
- // gpuLayers/nCpuMoe/selectedGpuIds (older build) or splitRatio (never
- // remembered) must not inherit the previous model's placement. gpuMemoryMode
- // (standing preference) is NOT reset, only applied when the blob carries it;
- // selectedGpuIds keeps a meaningful null (all GPUs), so it keys off undefined.
- set({
- gpuLayers: GPU_LAYERS_AUTO,
- nCpuMoe: 0,
- splitRatio: null,
- selectedGpuIds: null,
- customContextLength: settings.contextLength ?? null,
- kvCacheDtype: settings.kvCacheDtype ?? null,
- speculativeType: settings.speculativeType ?? "auto",
- specDraftNMax: settings.specDraftNMax ?? null,
- tensorParallel: settings.tensorParallel ?? false,
- ...(settings.gpuMemoryMode != null && {
- gpuMemoryMode: settings.gpuMemoryMode,
- }),
- ...(settings.gpuLayers != null && { gpuLayers: settings.gpuLayers }),
- ...(settings.nCpuMoe != null && { nCpuMoe: settings.nCpuMoe }),
- ...(restoredGpuIds !== undefined && {
- // Reconcile against the GPUs present now (see reconcilePersistedGpuIds):
- // a saved [1] on a 1-GPU host (or under relative/UUID visibility) would
- // hide the picker yet still send gpu_ids, which the backend rejects.
- selectedGpuIds: restoredGpuIds,
- }),
- });
- // A cold cache makes the synchronous restore provisional. Reconcile again
- // when the shared fetch completes, but only if this exact restored array is
- // still current so a user edit, stage change, or load cannot be overwritten.
- if (gpuCacheWasCold && restoredGpuIds != null) {
- void ensureGpuDeviceCache().then(() => {
- set((state) => {
- if (state.selectedGpuIds !== restoredGpuIds) return state;
- const reconciled = reconcilePersistedGpuIds(restoredGpuIds);
- return reconciled === restoredGpuIds
- ? state
- : { selectedGpuIds: reconciled };
- });
- });
- }
- },
- setLoadOnSelection: (loadOnSelection) => {
- saveBool(CHAT_LOAD_ON_SELECTION_KEY, loadOnSelection);
- set({ loadOnSelection });
- },
setExpandQuantizations: (expandQuantizations) => {
saveBool(CHAT_EXPAND_QUANTIZATIONS_KEY, expandQuantizations);
set({ expandQuantizations });
@@ -2125,55 +1915,6 @@ export const useChatRuntimeStore = create((set, get) => ({
saveBool(MODELS_FIT_ON_DEVICE_ONLY_KEY, fitOnDeviceOnly);
set({ fitOnDeviceOnly });
},
- setPendingSelection: (pendingSelection) => set({ pendingSelection }),
- stageModel: (selection) => {
- // Refuse staging mid-load: post-load cleanup would silently drop the queued
- // pick. stageOrLoad toasts first for callers that can.
- if (get().modelLoading) return;
- // Rebinding to a new pick keeps the prior pick's download running so the
- // user can queue multiple downloads at once (Hub-style).
- set((s) => {
- return {
- ...loadedBaselineSettings(s),
- pendingSelection: selection,
- // autoLoad downloads silently and loads on completion, so keep the sheet shut.
- settingsPanelOpen: !selection.autoLoad,
- // Speculative starts from the standing default, not the loaded model's
- // mode, so a fresh pick doesn't inherit (and then carry, via the staged
- // Load's keepSpeculative) a forced MTP mode onto a model that may lack it.
- speculativeType: readPersistedSpeculativeType(),
- specDraftNMax: null,
- // Keep the on-screen GPU Memory selection (loadedBaselineSettings would
- // otherwise revert it to the loaded model's mode, dropping a Manual choice
- // just made). Use the live store value, not the persisted one, which can
- // lag a mode hydrated from an out-of-band load.
- gpuMemoryMode: s.gpuMemoryMode,
- // Per-model GPU knobs start from defaults too so a fresh pick doesn't
- // inherit the loaded model's layer/MoE/split/GPU choices, matching the
- // immediate-switch reset.
- gpuLayers: GPU_LAYERS_AUTO,
- nCpuMoe: 0,
- splitRatio: null,
- selectedGpuIds: null,
- // Fresh pick starts at Auto context (loadedBaselineSettings would
- // otherwise restore the current model's pin). Leaves the baseline
- // intact, like the GPU knobs, so abandoning restores the loaded pin.
- customContextLength: null,
- };
- });
- },
- abandonStagedModel: (opts) => {
- const { pendingSelection } = get();
- if (!pendingSelection) return;
- // Cancel the staged pick's in-flight download (centralized for every abandon
- // path: sheet close, thread switch, route exit, new chat). `keepDownload`
- // opts out so navigation leaves the transfer running, like a Hub download.
- if (!opts?.keepDownload) cancelStagedModelDownload(pendingSelection);
- set((s) => ({ ...loadedBaselineSettings(s), pendingSelection: null }));
- },
- setCustomContextLength: (customContextLength) => set({ customContextLength }),
- setChatTemplateOverride: (chatTemplateOverride) =>
- set({ chatTemplateOverride }),
setPendingAudio: (base64, name) =>
set({ pendingAudioBase64: base64, pendingAudioName: name }),
clearPendingAudio: () =>
diff --git a/studio/frontend/src/features/chat/types/api.ts b/studio/frontend/src/features/chat/types/api.ts
index c24ddde5f5..c9c06834c1 100644
--- a/studio/frontend/src/features/chat/types/api.ts
+++ b/studio/frontend/src/features/chat/types/api.ts
@@ -99,6 +99,9 @@ export interface ValidateModelResponse {
/** MoE expert-layer count from the GGUF header (manual --n-cpu-moe ceiling);
* 0 for dense models, null until downloaded. */
moe_layer_count?: number | null;
+ /** Embedded GGUF chat template, returned when include_chat_template is set
+ * (native lease-backed picks); null for non-GGUF, over-cap, or not read. */
+ chat_template?: string | null;
/** Architecture only shipped by a newer transformers; UI pauses on the upgrade dialog. */
requires_transformers_upgrade?: boolean;
/** Set only when requires_transformers_upgrade. */
diff --git a/studio/frontend/src/features/export/components/export-run-panel.tsx b/studio/frontend/src/features/export/components/export-run-panel.tsx
index 29ba5a703b..6c2794420b 100644
--- a/studio/frontend/src/features/export/components/export-run-panel.tsx
+++ b/studio/frontend/src/features/export/components/export-run-panel.tsx
@@ -2,7 +2,6 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
-import { FolderBrowser } from "@/components/assistant-ui/model-selector/folder-browser";
import { Input } from "@/components/ui/input";
import {
InputGroup,
@@ -17,6 +16,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
+import { FolderBrowser } from "@/features/model-picker";
import {
AlertCircleIcon,
ArrowRight01Icon,
@@ -28,17 +28,17 @@ import {
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
+import type { ExportLogEntry } from "../api/export-api";
import {
EXPORT_METHODS,
type ExportMethod,
findMergedFormat,
} from "../constants";
-import type { ExportLogEntry } from "../api/export-api";
import { getExportLogLineClass } from "../lib/log-style";
import {
+ type ExportDestination,
selectExportProgressPercent,
useExportRuntimeStore,
- type ExportDestination,
} from "../stores/export-runtime-store";
function useElapsedSeconds(startedAt: number | null, running: boolean): number {
@@ -165,7 +165,9 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
const isExporting = run.isExporting;
const isTerminal =
- run.phase === "success" || run.phase === "error" || run.phase === "canceled";
+ run.phase === "success" ||
+ run.phase === "error" ||
+ run.phase === "canceled";
const showConfig = run.phase === "idle";
// Gate the log area on the active run's method (from the store) as well as the
// local form selection, so it stays visible after navigating away and back
@@ -197,7 +199,9 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
setFollowTail(nearBottom);
};
- const methodTitle = EXPORT_METHODS.find((m) => m.value === exportMethod)?.title;
+ const methodTitle = EXPORT_METHODS.find(
+ (m) => m.value === exportMethod,
+ )?.title;
const summary = run.summary;
const summaryBaseModel = summary?.baseModelName ?? baseModelName;
const summaryCheckpoint = summary?.checkpointLabel ?? checkpoint;
@@ -290,7 +294,10 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
onClick={() => setFolderBrowserOpen(true)}
aria-label="Browse save folder"
>
-
+
Browse
@@ -301,8 +308,8 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
<>Default: {defaultSaveDirectory}>
) : (
<>
- Paste an absolute path if the folder browser cannot reach the
- drive.
+ Paste an absolute path if the folder browser cannot reach
+ the drive.
>
)}
@@ -410,7 +417,10 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
: [];
const showLabels = items.length > 1;
return items.map((o, i) => (
-
+
{showLabels && o.label ? (
{o.label}
@@ -431,14 +441,22 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
{run.phase === "canceled" && (
-
- Export canceled. Training and inference were not affected.
+
+
+ Export canceled. Training and inference were not affected.
+
)}
{run.phase === "error" && run.error && (
-
+
{run.error}
)}
@@ -447,15 +465,21 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
Base Model
- {summaryBaseModel}
+
+ {summaryBaseModel}
+
{isAdapter ? "Checkpoint" : "Model"}
- {summaryCheckpoint}
+
+ {summaryCheckpoint}
+
Export Method
- {summaryMethodLabel}
+
+ {summaryMethodLabel}
+
{summaryMethod === "merged" && summaryFormats.length > 0 && (
@@ -484,7 +508,12 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
{summaryMethod === "gguf" && run.quantTotal > 1 && (
- Quant {Math.min(run.quantIndex + (isExporting ? 1 : 0), run.quantTotal)} of {run.quantTotal}
+ Quant{" "}
+ {Math.min(
+ run.quantIndex + (isExporting ? 1 : 0),
+ run.quantTotal,
+ )}{" "}
+ of {run.quantTotal}
)}
@@ -506,7 +535,10 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
}
/>
{run.stage && (
-
+
{run.stage}
)}
@@ -556,10 +588,7 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
) : (
{run.logLines.map((entry, idx) => (
-
+
{formatLogLine(entry)}
))}
diff --git a/studio/frontend/src/features/hub/catalog/catalog-states.tsx b/studio/frontend/src/features/hub/catalog/catalog-states.tsx
index 1d9eb63718..a36c4bb2da 100644
--- a/studio/frontend/src/features/hub/catalog/catalog-states.tsx
+++ b/studio/frontend/src/features/hub/catalog/catalog-states.tsx
@@ -10,6 +10,7 @@ import {
} from "@hugeicons/core-free-icons";
import type { IconSvgElement } from "@hugeicons/react";
import { HugeiconsIcon } from "@hugeicons/react";
+import type { ReactNode } from "react";
import { useLayoutEffect, useRef, useState } from "react";
export function NetworkErrorState({
@@ -199,10 +200,12 @@ export function EmptyState({
title,
body,
icon = CubeIcon,
+ action,
}: {
title: string;
body: string;
icon?: IconSvgElement;
+ action?: ReactNode;
}) {
return (
@@ -217,6 +220,7 @@ export function EmptyState({
{body}
+ {action}
);
}
diff --git a/studio/frontend/src/features/hub/catalog/dataset-download-section.tsx b/studio/frontend/src/features/hub/catalog/dataset-download-section.tsx
index d407517663..b821be4b0b 100644
--- a/studio/frontend/src/features/hub/catalog/dataset-download-section.tsx
+++ b/studio/frontend/src/features/hub/catalog/dataset-download-section.tsx
@@ -152,11 +152,7 @@ export function DatasetDownloadSection({
/>
)}
{isDownloaded && cachePath && (
-
+
)}
diff --git a/studio/frontend/src/features/hub/catalog/download-section.tsx b/studio/frontend/src/features/hub/catalog/download-section.tsx
index fb2921649b..b2dd4592a1 100644
--- a/studio/frontend/src/features/hub/catalog/download-section.tsx
+++ b/studio/frontend/src/features/hub/catalog/download-section.tsx
@@ -1,9 +1,9 @@
// 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 type { ModelInventoryFormat } from "../inventory";
import { GgufDownloadCard } from "./gguf-download-card";
import { SafetensorsDownloadCard } from "./safetensors-download-card";
-import type { ModelInventoryFormat } from "../inventory";
export function DownloadSection({
repoId,
@@ -22,6 +22,7 @@ export function DownloadSection({
knownBytes,
onLoad,
onUseInChat,
+ onEject,
onTrain,
onChange,
}: {
@@ -41,6 +42,7 @@ export function DownloadSection({
knownBytes?: number | null;
onLoad: (opts: { ggufVariant?: string; expectedBytes?: number }) => void;
onUseInChat?: () => void;
+ onEject?: () => void;
onTrain?: () => void;
onChange?: () => void;
}) {
@@ -57,6 +59,7 @@ export function DownloadSection({
isPartial={isPartial}
onLoad={onLoad}
onUseInChat={onUseInChat}
+ onEject={onEject}
onChange={onChange}
/>
);
@@ -75,6 +78,7 @@ export function DownloadSection({
knownBytes={knownBytes}
onLoad={onLoad}
onUseInChat={onUseInChat}
+ onEject={onEject}
onTrain={onTrain}
onChange={onChange}
/>
diff --git a/studio/frontend/src/features/hub/catalog/gguf-download-card.tsx b/studio/frontend/src/features/hub/catalog/gguf-download-card.tsx
index f31212e4fd..0d6878b687 100644
--- a/studio/frontend/src/features/hub/catalog/gguf-download-card.tsx
+++ b/studio/frontend/src/features/hub/catalog/gguf-download-card.tsx
@@ -1,6 +1,13 @@
// 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 {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
@@ -12,49 +19,55 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
-import {
- downloadManager,
- useDownloadManagerStore,
- useRepoDownload,
-} from "../download-manager";
-import {
- type GgufVariantDetail,
- deleteCachedModel,
-} from "../inventory";
-import { formatBytes } from "../lib/format";
-import { type GgufFitClass, classifyGgufFit } from "../lib/gguf-fit";
-import { HUB_GGUF_RUN_ACTIONS_VISIBLE } from "../lib/hub-feature-flags";
-import {
- ggufVariantsMatch,
- normalizeGgufVariantIdentity,
-} from "../lib/model-identity";
+import { usePlatformStore } from "@/config/env";
+import { getCachedModelPath, revealCachedModel } from "@/features/chat";
+import { pinKey, usePinnedModelsStore } from "@/features/model-picker";
+import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
+import { copyToClipboard } from "@/lib/copy-to-clipboard";
+import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
-import { useHfTokenStore } from "../stores/hf-token-store";
-import { useOnlineStatus } from "../hooks/use-online-status";
import {
ArrowReloadHorizontalIcon,
+ Copy01Icon,
Delete02Icon,
Download01Icon,
+ Folder01Icon,
InformationCircleIcon,
- PencilEdit02Icon,
+ MoreVerticalIcon,
+ PinIcon,
+ PinOffIcon,
PlayIcon,
+ RemoveCircleIcon,
} from "@hugeicons/core-free-icons";
-import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
+ type KeyboardEventHandler,
memo,
useCallback,
useEffect,
useMemo,
useState,
- type KeyboardEventHandler,
- type MouseEventHandler,
} from "react";
+import {
+ downloadManager,
+ useDownloadManagerStore,
+ useRepoDownload,
+} from "../download-manager";
+import { useOnlineStatus } from "../hooks/use-online-status";
+import { type GgufVariantDetail, deleteCachedModel } from "../inventory";
+import { formatBytes } from "../lib/format";
+import { type GgufFitClass, classifyGgufFit } from "../lib/gguf-fit";
import {
ggufVariantDisplayLabel,
ggufVariantDownloadSizeBytes,
sortDownloadableGgufVariants,
} from "../lib/gguf-variant-sort";
+import { HUB_GGUF_RUN_ACTIONS_VISIBLE } from "../lib/hub-feature-flags";
+import {
+ ggufVariantsMatch,
+ normalizeGgufVariantIdentity,
+} from "../lib/model-identity";
+import { useHfTokenStore } from "../stores/hf-token-store";
import { DotTag } from "./dot-tag";
import { DownloadCancelIndicator } from "./download-cancel-indicator";
import {
@@ -72,7 +85,6 @@ import {
GgufDownloadStatusCard,
GgufDownloadingFallbackCard,
} from "./gguf-status-cards";
-import { PathInfoButton } from "./path-info-button";
import { useDeleteConfirmAction } from "./use-delete-confirm-action";
import { useDownloadCardState } from "./use-download-card-state";
import { useGgufVariantFetchState } from "./use-gguf-variant-fetch-state";
@@ -204,7 +216,7 @@ function QuantBadge({
onOpenChange={tooltipMode === "lazy" ? setTooltipOpen : undefined}
>
@@ -245,7 +257,181 @@ function createGgufVariantMenuItems(
}));
}
+// Shared options menu: used on every variant row, the run bar, and the
+// single-model (non-GGUF) run bar. Omit `quant` for a repo-level model. The
+// identifier uses llama.cpp's repo:quant syntax so it pastes into `-hf`.
+export function QuantOptionsMenu({
+ repoId,
+ quant,
+ label,
+ downloaded,
+ canDelete,
+ onDelete,
+ showPin = true,
+ buttonClassName,
+ iconClassName,
+}: {
+ repoId: string;
+ quant?: string;
+ label: string;
+ downloaded: boolean;
+ canDelete: boolean;
+ onDelete: (quant?: string) => void;
+ // Hidden in the run bar; pinning belongs to the On Device list.
+ showPin?: boolean;
+ buttonClassName?: string;
+ iconClassName?: string;
+}) {
+ const pinnedKeys = usePinnedModelsStore((s) => s.pinned);
+ const togglePinned = usePinnedModelsStore((s) => s.togglePinned);
+ const pinned = pinnedKeys.includes(pinKey(repoId, quant));
+ const deviceType = usePlatformStore((s) => s.deviceType);
+ const revealLabel =
+ deviceType === "mac"
+ ? "Reveal in Finder"
+ : deviceType === "windows"
+ ? "Reveal in File Explorer"
+ : "Reveal in File Manager";
+ const handleCopyPath = useCallback(async () => {
+ try {
+ const { path } = await getCachedModelPath(repoId, quant);
+ if (await copyToClipboard(path)) {
+ toast.success("Copied path");
+ } else {
+ toast.error("Failed to copy");
+ }
+ } catch (err) {
+ toast.error(
+ err instanceof Error ? err.message : "Failed to resolve model path",
+ );
+ }
+ }, [repoId, quant]);
+ const handleCopyId = useCallback(async () => {
+ const id = quant ? `${repoId}:${quant}` : repoId;
+ if (await copyToClipboard(id)) {
+ toast.success("Copied identifier");
+ } else {
+ toast.error("Failed to copy");
+ }
+ }, [repoId, quant]);
+
+ return (
+
+
+ e.stopPropagation()}
+ aria-label={`More options for ${label}`}
+ className={cn(
+ "inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-full",
+ "text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
+ "data-[state=open]:bg-muted data-[state=open]:text-foreground",
+ buttonClassName,
+ )}
+ >
+
+
+
+
+ {showPin && downloaded && (
+ {
+ e.stopPropagation();
+ togglePinned(repoId, quant);
+ }}
+ >
+
+ {pinned ? "Unpin" : "Pin to top"}
+
+ )}
+ {downloaded && (
+ {
+ e.stopPropagation();
+ revealCachedModel(repoId, quant).catch((err) => {
+ toast.error(
+ err instanceof Error
+ ? err.message
+ : "Failed to open file manager",
+ );
+ });
+ }}
+ >
+
+ {revealLabel}
+
+ )}
+ {
+ e.stopPropagation();
+ void handleCopyId();
+ }}
+ >
+
+ Copy identifier
+
+ {downloaded && (
+ {
+ e.stopPropagation();
+ void handleCopyPath();
+ }}
+ >
+
+ Copy path
+
+ )}
+ {canDelete && (
+ <>
+
+ {
+ e.stopPropagation();
+ onDelete(quant);
+ }}
+ >
+
+ Delete
+
+ >
+ )}
+
+
+ );
+}
+
const GgufVariantMenuRow = memo(function GgufVariantMenuRow({
+ repoId,
item,
selected,
loaded,
@@ -254,6 +440,7 @@ const GgufVariantMenuRow = memo(function GgufVariantMenuRow({
onSelect,
onDelete,
}: {
+ repoId: string;
item: GgufVariantMenuItem;
selected: boolean;
loaded: boolean;
@@ -275,13 +462,6 @@ const GgufVariantMenuRow = memo(function GgufVariantMenuRow({
},
[selectVariant],
);
- const handleDelete = useCallback>(
- (e) => {
- e.stopPropagation();
- onDelete(item.quant);
- },
- [item.quant, onDelete],
- );
const canDelete = (item.downloaded || item.partial) && !loaded && !liveActive;
return (
@@ -317,7 +497,7 @@ const GgufVariantMenuRow = memo(function GgufVariantMenuRow({
)}
{!item.downloaded && item.partial && (
-
+
-
-
- {item.downloadSizeLabel}
-
- {canDelete && (
-
-
-
- )}
+
+ {item.downloadSizeLabel}
+ {/* Options only apply to files on disk; placeholder keeps the size
+ chips column-aligned across rows. */}
+ {item.downloaded || item.partial ? (
+ q && onDelete(q)}
+ />
+ ) : (
+
+ )}
);
@@ -374,7 +547,7 @@ export function GgufDownloadCard({
preferLocalCache = false,
isPartial = false,
onLoad,
- onUseInChat,
+ onEject,
onChange,
}: {
repoId: string;
@@ -387,7 +560,9 @@ export function GgufDownloadCard({
preferLocalCache?: boolean;
isPartial?: boolean;
onLoad: (opts: { ggufVariant?: string; expectedBytes?: number }) => void;
+ /** Accepted for API parity; the run bar ejects instead of opening chat. */
onUseInChat?: () => void;
+ onEject?: () => void;
onChange?: () => void;
}) {
const hfToken = useHfTokenStore((s) => s.token);
@@ -422,7 +597,9 @@ export function GgufDownloadCard({
() => createLiveGgufVariantStatesSelector(repoId),
[repoId],
);
- const liveVariantStates = useDownloadManagerStore(selectLiveGgufVariantStates);
+ const liveVariantStates = useDownloadManagerStore(
+ selectLiveGgufVariantStates,
+ );
const sortedVariants = useMemo(() => {
if (!rawSortedVariants) return null;
const withLive = applyLiveGgufVariantStates(
@@ -499,12 +676,7 @@ export function GgufDownloadCard({
if (expectedBytes > progress.expectedBytes) {
setExpectedBytes(expectedBytes, progress.variant);
}
- }, [
- variants,
- progress?.variant,
- progress?.expectedBytes,
- setExpectedBytes,
- ]);
+ }, [variants, progress?.variant, progress?.expectedBytes, setExpectedBytes]);
useEffect(() => {
setCompletedVariantKeys(new Set());
@@ -595,7 +767,8 @@ export function GgufDownloadCard({
if (!deleteTarget) return;
await deleteCachedModel(repoId, deleteTarget, hfToken || undefined);
},
- successMessage: () => `Deleted ${repoId} ${deleteTargetLabel ?? deleteTarget}`,
+ successMessage: () =>
+ `Deleted ${repoId} ${deleteTargetLabel ?? deleteTarget}`,
errorToast: (err) => ({
title: err instanceof Error ? err.message : "Failed to delete",
}),
@@ -654,7 +827,7 @@ export function GgufDownloadCard({
return (
);
@@ -666,7 +839,7 @@ export function GgufDownloadCard({
void refresh()}
@@ -729,7 +902,7 @@ export function GgufDownloadCard({
}
>
-
+
{
@@ -762,7 +935,7 @@ export function GgufDownloadCard({
)}
{selected && !selected.downloaded && selected.partial && (
-
+
- {selected?.downloaded && cachePath && (
-
- )}
+ {/* TODO: inference settings gear hidden for now, work on it in a future PR. */}
+ {/* Options only resolve managed HF-cache repos, so skip local paths;
+ they also only apply to quants actually on disk. */}
+ {selected &&
+ Boolean(selected.downloaded || selected.partial) &&
+ !/^([/\\~.]|[A-Za-z]:)/.test(repoId) && (
+ q && handleDeleteVariant(q)}
+ showPin={false}
+ buttonClassName="ml-0.5 size-7"
+ iconClassName="size-4"
+ />
+ )}
{!isGgufRunCta && }
@@ -859,7 +1048,7 @@ export function GgufDownloadCard({
return;
}
if (selectedIsActive) {
- onUseInChat?.();
+ onEject?.();
return;
}
if (!selected) return;
@@ -874,7 +1063,7 @@ export function GgufDownloadCard({
}}
aria-label={downloadAction.ariaLabel}
className={cn(
- isGgufRunCta ? "hub-run-action-btn w-28" : "hub-action-btn w-28",
+ isGgufRunCta ? "hub-run-action-btn w-24" : "hub-action-btn w-24",
isGgufRunCta && "ml-2",
ctaDisabled &&
!selectedIsActive &&
@@ -917,8 +1106,8 @@ export function GgufDownloadCard({
) : selectedIsActive ? (
<>
-
- New Chat
+
+ Eject
>
) : selected?.downloaded ? (
<>
diff --git a/studio/frontend/src/features/hub/catalog/hub-option-menu.tsx b/studio/frontend/src/features/hub/catalog/hub-option-menu.tsx
index 38464d36e4..df30888d57 100644
--- a/studio/frontend/src/features/hub/catalog/hub-option-menu.tsx
+++ b/studio/frontend/src/features/hub/catalog/hub-option-menu.tsx
@@ -6,9 +6,9 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
-import { cn } from "@/lib/utils";
-import { Tick02Icon } from "@/lib/tick-icon";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
+import { Tick02Icon } from "@/lib/tick-icon";
+import { cn } from "@/lib/utils";
import { HugeiconsIcon } from "@hugeicons/react";
import {
type KeyboardEvent,
@@ -71,7 +71,9 @@ export function HubOptionMenu
({
? -1
: Math.min(activeIndex, options.length - 1);
const activeOptionId =
- resolvedActiveIndex >= 0 ? `${idBase}-option-${resolvedActiveIndex}` : undefined;
+ resolvedActiveIndex >= 0
+ ? `${idBase}-option-${resolvedActiveIndex}`
+ : undefined;
const activateIndex = useCallback((index: number) => {
setActiveIndex((current) => (current === index ? current : index));
@@ -161,7 +163,7 @@ export function HubOptionMenu({
return (
-
+
({
aria-label={ariaLabel}
title={title}
className={cn(
- "field-trigger hub-menu-trigger field-soft field-filter inline-flex h-9 shrink-0 cursor-pointer items-center justify-between gap-0.5 rounded-full pl-3 pr-2 text-[12.5px] transition-colors",
+ "field-trigger hub-menu-trigger field-soft field-filter inline-flex h-9 shrink-0 cursor-pointer items-center justify-between gap-2.5 rounded-full pl-3 pr-2.5 text-[12.5px] transition-colors",
"focus-visible:border-border focus-visible:ring-0 focus-visible:ring-offset-0",
className,
)}
@@ -183,7 +185,10 @@ export function HubOptionMenu({
}}
>
- {triggerContent ?? selected?.triggerLabel ?? selected?.label ?? value}
+ {triggerContent ??
+ selected?.triggerLabel ??
+ selected?.label ??
+ value}
{showChevron && (
{HUB_POST_DOWNLOAD_ACTIONS_VISIBLE && (
diff --git a/studio/frontend/src/features/hub/catalog/local-on-device-card.tsx b/studio/frontend/src/features/hub/catalog/local-on-device-card.tsx
index 3c020a7199..ba97cb0c53 100644
--- a/studio/frontend/src/features/hub/catalog/local-on-device-card.tsx
+++ b/studio/frontend/src/features/hub/catalog/local-on-device-card.tsx
@@ -1,12 +1,6 @@
// 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 { TrainIcon } from "../components/train-icon";
-import {
- HUB_GGUF_RUN_ACTIONS_VISIBLE,
- HUB_NON_GGUF_RUN_ACTIONS_VISIBLE,
- HUB_POST_DOWNLOAD_ACTIONS_VISIBLE,
-} from "../lib/hub-feature-flags";
import {
Popover,
PopoverContent,
@@ -18,37 +12,44 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
+import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
+import { cn } from "@/lib/utils";
import {
- type BaseModelSource,
- type LocalModelInfo,
- type ModelInventoryFormat,
- deleteCachedModel,
-} from "../inventory";
+ Alert02Icon,
+ CubeIcon,
+ PlayIcon,
+ RemoveCircleIcon,
+ Share05Icon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { useCallback, useMemo, useState } from "react";
+import { TrainIcon } from "../components/train-icon";
import {
downloadManager,
jobKeyOf,
selectActiveJob,
useDownloadManagerStore,
} from "../download-manager";
-import { formatBytes } from "../lib/format";
-import { ggufVariantsMatch } from "../lib/model-identity";
-import { cn } from "@/lib/utils";
-import { confirmExternalLink } from "../stores/external-link-confirm";
-import { useHfTokenStore } from "../stores/hf-token-store";
+import { useOnlineStatus } from "../hooks/use-online-status";
import {
- Alert02Icon,
- CubeIcon,
- PencilEdit02Icon,
- PlayIcon,
- Share05Icon,
-} from "@hugeicons/core-free-icons";
-import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { useCallback, useMemo, useState } from "react";
+ type BaseModelSource,
+ type LocalModelInfo,
+ type ModelInventoryFormat,
+ deleteCachedModel,
+} from "../inventory";
+import { formatBytes } from "../lib/format";
import {
ggufVariantDisplayLabel,
sortLocalGgufVariants,
} from "../lib/gguf-variant-sort";
+import {
+ HUB_GGUF_RUN_ACTIONS_VISIBLE,
+ HUB_NON_GGUF_RUN_ACTIONS_VISIBLE,
+ HUB_POST_DOWNLOAD_ACTIONS_VISIBLE,
+} from "../lib/hub-feature-flags";
+import { ggufVariantsMatch } from "../lib/model-identity";
+import { confirmExternalLink } from "../stores/external-link-confirm";
+import { useHfTokenStore } from "../stores/hf-token-store";
import { DotTag } from "./dot-tag";
import {
CardDeleteButton,
@@ -60,7 +61,6 @@ import { PathInfoButton } from "./path-info-button";
import { TransportConflictDialog } from "./transport-conflict-dialog";
import { useCardDelete } from "./use-card-delete";
import { useGgufVariantFetchState } from "./use-gguf-variant-fetch-state";
-import { useOnlineStatus } from "../hooks/use-online-status";
type LocalLoadOptions = {
ggufVariant?: string;
@@ -91,7 +91,9 @@ interface LocalOnDeviceCardProps {
systemRamGb?: number;
unsupportedReason?: string | null;
onLoad: (opts?: LocalLoadOptions) => void;
+ /** Accepted for API parity; the run bar ejects instead of opening chat. */
onUseInChat: () => void;
+ onEject?: () => void;
onTrain?: () => void;
onChange?: () => void;
}
@@ -151,7 +153,7 @@ function BaseModelReference({
{canOpenHub && (
-
+
{
event.stopPropagation();
- if (confirmExternalLink(`https://huggingface.co/${baseModelHubId}`)) {
+ if (
+ confirmExternalLink(
+ `https://huggingface.co/${baseModelHubId}`,
+ )
+ ) {
event.preventDefault();
}
}}
@@ -205,7 +211,7 @@ export function LocalOnDeviceCard({
systemRamGb,
unsupportedReason,
onLoad,
- onUseInChat,
+ onEject,
onTrain,
onChange,
}: LocalOnDeviceCardProps) {
@@ -371,18 +377,20 @@ export function LocalOnDeviceCard({
const handleConfirmUpdate = () => {
if (!repoId || !updateTargetVariant) return;
setUpdateOpen(false);
- void downloadManager.requestStart({
- kind: "model",
- repoId,
- variant: updateTargetVariant,
- expectedBytes: updateExpectedBytes,
- }).then((outcome) => {
- if (outcome === "conflict") {
- setUpdateConflictKey(jobKeyOf("model", repoId, updateTargetVariant));
- }
- void currentVariantState.refresh();
- void remoteVariantState.refresh();
- });
+ void downloadManager
+ .requestStart({
+ kind: "model",
+ repoId,
+ variant: updateTargetVariant,
+ expectedBytes: updateExpectedBytes,
+ })
+ .then((outcome) => {
+ if (outcome === "conflict") {
+ setUpdateConflictKey(jobKeyOf("model", repoId, updateTargetVariant));
+ }
+ void currentVariantState.refresh();
+ void remoteVariantState.refresh();
+ });
};
const selectedVariantIsActive =
needsVariantSelection && selectedQuant
@@ -428,7 +436,8 @@ export function LocalOnDeviceCard({
can still keep it on disk, or delete it to free space.
- )}
+ )}
+
@@ -540,7 +549,7 @@ export function LocalOnDeviceCard({
{canUpdate && (
setUpdateOpen(true)}
/>
)}
@@ -550,11 +559,7 @@ export function LocalOnDeviceCard({
onClick={() => setDeleteOpen(true)}
/>
)}
-
+
{onTrain && HUB_POST_DOWNLOAD_ACTIONS_VISIBLE && (
@@ -585,7 +590,7 @@ export function LocalOnDeviceCard({
onClick={() => {
if (!canRun) return;
if (selectedVariantIsActive) {
- onUseInChat();
+ onEject?.();
return;
}
if (needsVariantSelection) {
@@ -615,24 +620,24 @@ export function LocalOnDeviceCard({
>
) : selectedVariantIsActive ? (
<>
-
- Chat
+
+ Eject
>
) : variantActionPending ? (
<>
Loading…
>
- ) : !canRun ? (
- <>
-
- No run
- >
- ) : (
+ ) : canRun ? (
<>
Run
>
+ ) : (
+ <>
+
+ No run
+ >
)}
diff --git a/studio/frontend/src/features/hub/catalog/model-inspector.tsx b/studio/frontend/src/features/hub/catalog/model-inspector.tsx
index 5a6ae1615a..90663b5d76 100644
--- a/studio/frontend/src/features/hub/catalog/model-inspector.tsx
+++ b/studio/frontend/src/features/hub/catalog/model-inspector.tsx
@@ -17,9 +17,9 @@ import {
formatRelativeShort,
formatShortDate,
} from "@/features/hub/lib/format";
-import { cn, formatCompact } from "@/lib/utils";
-import { confirmExternalLink } from "../stores/external-link-confirm";
import { useHfTokenStore } from "@/features/hub/stores/hf-token-store";
+import { Tick02Icon } from "@/lib/tick-icon";
+import { cn, formatCompact } from "@/lib/utils";
import {
Calendar03Icon,
CalendarAdd01Icon,
@@ -37,10 +37,10 @@ import {
RamMemoryIcon,
Share05Icon,
} from "@hugeicons/core-free-icons";
-import { Tick02Icon } from "@/lib/tick-icon";
import type { IconSvgElement } from "@hugeicons/react";
import { HugeiconsIcon } from "@hugeicons/react";
import { memo, useDeferredValue, useMemo } from "react";
+import { selectActiveJob, useDownloadManagerStore } from "../download-manager";
import { useCopyFeedback } from "../hooks/use-copy-feedback";
import { useDatasetSize } from "../hooks/use-dataset-size";
import {
@@ -49,8 +49,8 @@ import {
formatPipelineTag,
parseLanguageTags,
} from "../lib/view-models";
+import { confirmExternalLink } from "../stores/external-link-confirm";
import type { SelectedModelView } from "../types";
-import { selectActiveJob, useDownloadManagerStore } from "../download-manager";
import { DatasetDownloadSection } from "./dataset-download-section";
import { DownloadSection } from "./download-section";
import { LocalDatasetCard } from "./local-dataset-card";
@@ -399,6 +399,7 @@ export type ModelInspectorActions = {
expectedBytes?: number;
}) => void;
onUseInChat: () => void;
+ onEject?: () => void;
onTrain?: () => void;
onInventoryChange?: () => void;
onSearchHub?: (query: string) => void;
@@ -433,6 +434,7 @@ export const ModelInspector = memo(function ModelInspector({
onLoad,
onLoadLocal,
onUseInChat,
+ onEject,
onTrain,
onInventoryChange,
onSearchHub,
@@ -517,7 +519,9 @@ export const ModelInspector = memo(function ModelInspector({
? formatRelativeShort(model.updatedAt)
: formatLocalUpdated(model.localUpdatedAt);
const updatedLabel = updatedRaw === "Unknown update" ? "N/A" : updatedRaw;
- const createdLabel = model.createdAt ? formatShortDate(model.createdAt) : null;
+ const createdLabel = model.createdAt
+ ? formatShortDate(model.createdAt)
+ : null;
const libraryLabel = isDataset ? null : formatLibrary(model.libraryName);
const gatedAccess = model.gated !== false && model.gated !== undefined;
const downloadsTooltip =
@@ -696,6 +700,7 @@ export const ModelInspector = memo(function ModelInspector({
}
onLoad={onLoadLocal}
onUseInChat={onUseInChat}
+ onEject={onEject}
onTrain={
model.isDownloaded && canTrainModel ? onTrain : undefined
}
@@ -719,6 +724,7 @@ export const ModelInspector = memo(function ModelInspector({
knownBytes={model.cachedBytes}
onLoad={model.isLocal ? onLoadLocal : onLoad}
onUseInChat={onUseInChat}
+ onEject={onEject}
onTrain={
model.isDownloaded && canTrainModel ? onTrain : undefined
}
diff --git a/studio/frontend/src/features/hub/catalog/models-catalog-lists.tsx b/studio/frontend/src/features/hub/catalog/models-catalog-lists.tsx
index 355c38a91e..8cf5fc491e 100644
--- a/studio/frontend/src/features/hub/catalog/models-catalog-lists.tsx
+++ b/studio/frontend/src/features/hub/catalog/models-catalog-lists.tsx
@@ -2,13 +2,20 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Spinner } from "@/components/ui/spinner";
+import {
+ makePinRank,
+ pinKey,
+ usePinnedModelsStore,
+} from "@/features/model-picker";
import {
CubeIcon,
DownloadCircle02Icon,
- FolderSearchIcon,
+ PinIcon,
+ Search01Icon,
} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
import type { RefObject } from "react";
-import { useMemo } from "react";
+import { useLayoutEffect, useMemo, useState } from "react";
import {
inventoryRowMatches,
scoreInventoryRow,
@@ -208,7 +215,7 @@ export function DiscoverList({
) : (
void;
scrollElement: HTMLDivElement | null;
columns?: number;
activeCheckpoint: string | null;
@@ -274,11 +285,20 @@ export function DownloadedList({
sort: InventorySort;
onInventoryChange?: () => void;
}) {
+ // Pinned repos surface first regardless of the active sort; the chosen sort
+ // still orders rows within the pinned and unpinned groups.
+ const pinnedIds = usePinnedModelsStore((s) => s.pinned);
+ const pinnedSet = useMemo(() => new Set(pinnedIds), [pinnedIds]);
const inventoryItems = useMemo(() => {
const merged: InventoryItem[] = [
...cachedRows.map((row) => ({ variant: "cached" as const, row })),
...localRows.map((row) => ({ variant: "local" as const, row })),
];
+ // Pinned rows order by pin recency (newest pin first), not the active
+ // sort, so "Pin to top" puts the row exactly where the user expects.
+ const rank = makePinRank(pinnedIds);
+ const pinRank = (item: InventoryItem) =>
+ item.row.repoId ? rank(pinKey(item.row.repoId)) : Number.MAX_SAFE_INTEGER;
if (inventoryTokens.length > 0) {
return merged
.map((item, index) => ({
@@ -286,25 +306,85 @@ export function DownloadedList({
index,
score: scoreInventoryRow(item.row, inventoryTokens),
}))
- .sort((a, b) => b.score - a.score || a.index - b.index)
+ .sort(
+ (a, b) =>
+ pinRank(a.item) - pinRank(b.item) ||
+ b.score - a.score ||
+ a.index - b.index,
+ )
.map((entry) => entry.item);
}
if (sort === "recent") {
- return merged;
+ return merged
+ .map((item, index) => ({ item, index }))
+ .sort((a, b) => pinRank(a.item) - pinRank(b.item) || a.index - b.index)
+ .map((entry) => entry.item);
}
return merged
.map((item, index) => ({ item, index }))
- .sort((a, b) =>
- sort === "name"
- ? inventoryItemTitle(a.item).localeCompare(
- inventoryItemTitle(b.item),
- ) || a.index - b.index
- : inventoryItemSize(b.item) - inventoryItemSize(a.item) ||
- a.index - b.index,
+ .sort(
+ (a, b) =>
+ pinRank(a.item) - pinRank(b.item) ||
+ (sort === "name"
+ ? inventoryItemTitle(a.item).localeCompare(
+ inventoryItemTitle(b.item),
+ ) || a.index - b.index
+ : inventoryItemSize(b.item) - inventoryItemSize(a.item) ||
+ a.index - b.index),
)
.map((entry) => entry.item);
- }, [cachedRows, localRows, inventoryTokens, sort]);
+ }, [cachedRows, localRows, inventoryTokens, sort, pinnedIds]);
const hasInventoryRows = cachedRows.length > 0 || localRows.length > 0;
+ // Pinned repos get their own labelled section so it's clear why they lead
+ // the list; inventoryItems already sorts them first, so this is a prefix.
+ const pinnedCount = useMemo(
+ () =>
+ inventoryItems.filter(
+ (item) => item.row.repoId && pinnedSet.has(pinKey(item.row.repoId)),
+ ).length,
+ [inventoryItems, pinnedSet],
+ );
+ const pinnedItems = inventoryItems.slice(0, pinnedCount);
+ const unpinnedItems = inventoryItems.slice(pinnedCount);
+ const [virtualRowsWrapper, setVirtualRowsWrapper] =
+ useState(null);
+ const [scrollMargin, setScrollMargin] = useState(0);
+ useLayoutEffect(() => {
+ if (!virtualRowsWrapper || !scrollElement) return;
+ const measure = () => {
+ const margin = Math.max(
+ 0,
+ Math.round(
+ virtualRowsWrapper.getBoundingClientRect().top -
+ scrollElement.getBoundingClientRect().top +
+ scrollElement.scrollTop,
+ ),
+ );
+ setScrollMargin((current) => (current === margin ? current : margin));
+ };
+ measure();
+ const observer = new ResizeObserver(measure);
+ observer.observe(virtualRowsWrapper.parentElement ?? scrollElement);
+ return () => observer.disconnect();
+ }, [virtualRowsWrapper, scrollElement]);
+ const rowHeightPx = compact
+ ? RESULT_SPLIT_ROW_HEIGHT_PX
+ : RESULT_GRID_ROW_HEIGHT_PX;
+ const cellHeightPx = compact ? RESULT_SPLIT_HEIGHT_PX : RESULT_GRID_HEIGHT_PX;
+ const renderInventoryRow = (item: InventoryItem) => (
+
+ );
if (!downloadedReady && !hasInventoryRows) {
return (
@@ -325,9 +405,29 @@ export function DownloadedList({
}
if (cachedRows.length === 0 && localRows.length === 0) {
+ if (!query.trim() && typeFilterActive) {
+ return (
+
+ Show all types
+
+ )
+ }
+ />
+ );
+ }
return (
`${item.variant}-${item.row.id}`}
- renderRow={(item) => (
-
+ <>
+ {pinnedItems.length > 0 && (
+ <>
+
+
+ Pinned
+
+ {/* Pinned rows are few, so render them as a plain grid matching the
+ virtualized list's lane count and row spacing. */}
+
+ {pinnedItems.map((item) => (
+
+ {renderInventoryRow(item)}
+
+ ))}
+
+ {unpinnedItems.length > 0 && (
+
+ All {isDataset ? "datasets" : "models"}
+
+ )}
+ >
)}
- />
+
+ `${item.variant}-${item.row.id}`}
+ renderRow={renderInventoryRow}
+ />
+
+ >
);
}
diff --git a/studio/frontend/src/features/hub/catalog/models-catalog-rows.tsx b/studio/frontend/src/features/hub/catalog/models-catalog-rows.tsx
index 111d78f00b..6d1dc20414 100644
--- a/studio/frontend/src/features/hub/catalog/models-catalog-rows.tsx
+++ b/studio/frontend/src/features/hub/catalog/models-catalog-rows.tsx
@@ -1,7 +1,6 @@
// 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 { ModelDeleteAction } from "@/components/assistant-ui/model-selector/model-delete-action";
import {
Tooltip,
TooltipContent,
@@ -9,18 +8,26 @@ import {
} from "@/components/ui/tooltip";
import {
type GgufVariantDetail,
- deleteCachedModel,
deleteCachedDataset,
+ deleteCachedModel,
formatLocalUpdated,
listGgufVariants,
useGgufVariantsCacheVersion,
-} from "@/features/hub/inventory";
-import { classifyUnslothSupport } from "@/features/hub/hooks/use-hub-model-search";
-import { formatBytes, formatRelativeShort } from "@/features/hub/lib/format";
-import { ggufVariantDisplayLabel } from "@/features/hub/lib/gguf-variant-sort";
-import { modelIdsMatch } from "@/features/hub/lib/model-identity";
+} from "../inventory";
+import {
+ classifyUnslothSupport,
+ formatBytes,
+ formatRelativeShort,
+ ggufVariantDisplayLabel,
+ useHfTokenStore,
+} from "@/features/hub";
+import { modelIdsMatch } from "../lib/model-identity";
+import {
+ ModelRowMenu,
+ pinKey,
+ usePinnedModelsStore,
+} from "@/features/model-picker";
import { cn, formatCompact } from "@/lib/utils";
-import { useHfTokenStore } from "@/features/hub/stores/hf-token-store";
import {
Download01Icon,
FavouriteIcon,
@@ -39,6 +46,7 @@ import {
useRef,
useState,
} from "react";
+import { paramLabelFromId } from "../lib/view-models";
import type {
CachedInventoryRow,
DiscoverRow,
@@ -46,7 +54,6 @@ import type {
} from "../types";
import { OwnerAvatar } from "./owner-avatar";
import { AccessGlyphs } from "./shared";
-import { paramLabelFromId } from "../lib/view-models";
const COARSE_POINTER =
typeof window !== "undefined" &&
@@ -142,15 +149,15 @@ function CachedSizeChipLive({
);
const rows: Array<{ label: string; size_bytes: number }> | null =
- !needsVariantFetch
- ? [{ label: repoId, size_bytes: totalBytes }]
- : currentVariantState.status === "loaded" &&
- currentVariantState.variants.length > 0
+ needsVariantFetch
+ ? currentVariantState.status === "loaded" &&
+ currentVariantState.variants.length > 0
? currentVariantState.variants.map((variant) => ({
label: ggufVariantDisplayLabel(variant),
size_bytes: variant.size_bytes,
}))
- : null;
+ : null
+ : [{ label: repoId, size_bytes: totalBytes }];
const variantMessage =
currentVariantState.status === "loading"
? "Loading downloaded variants..."
@@ -275,7 +282,9 @@ function CatalogRow({
)}
/>
-
+
{children}
@@ -653,7 +662,9 @@ export const InventoryRow = memo(function InventoryRow({
{/* Format already shows as the status dot, so the pill stays neutral. */}
{formatLabel &&
{formatLabel}}
- {paramLabel &&
{paramLabel}}
+ {paramLabel && (
+
{paramLabel}
+ )}
{quantLabel && (
{quantLabel}
@@ -697,9 +708,7 @@ export const InventoryRow = memo(function InventoryRow({
const compactMarkers =
partialRepoId || unsupported ? (
- {partialRepoId && (
-
- )}
+ {partialRepoId && }
{unsupported && (
)}
@@ -718,37 +727,72 @@ export const InventoryRow = memo(function InventoryRow({
);
+ const pinnedKeys = usePinnedModelsStore((s) => s.pinned);
+ const togglePinned = usePinnedModelsStore((s) => s.togglePinned);
+ const rowPinned =
+ cacheDeletableRepoId != null &&
+ pinnedKeys.includes(pinKey(cacheDeletableRepoId));
const deleteAction =
canDelete && cacheDeletableRepoId ? (
-
- This will remove{" "}
-
- {cacheDeletableRepoId}
- {" "}
- {isDataset
- ? "and its downloaded files"
- : row.isGguf
- ? "and all of its downloaded quantizations"
- : "and all of its downloaded files"}
- {row.kind === "cache" ? ` (${formatBytes(row.bytes)})` : ""} from
- disk. You can re-download it later.
- >
- }
- successMessage={`Deleted ${cacheDeletableRepoId}`}
+ {
- if (isDataset) {
- await deleteCachedDataset(cacheDeletableRepoId);
- } else {
- await deleteCachedModel(cacheDeletableRepoId);
- }
+ pin={
+ isDataset
+ ? undefined
+ : {
+ pinned: rowPinned,
+ pinLabel: "Pin to top",
+ unpinLabel: "Unpin",
+ onToggle: () => togglePinned(cacheDeletableRepoId),
+ }
+ }
+ cachePath={isDataset ? undefined : { repoId: cacheDeletableRepoId }}
+ del={{
+ title: isDataset ? "Delete cached dataset?" : "Delete cached model?",
+ description: (
+ <>
+ This will remove{" "}
+
+ {cacheDeletableRepoId}
+ {" "}
+ {isDataset
+ ? "and its downloaded files"
+ : row.isGguf
+ ? "and all of its downloaded quantizations"
+ : "and all of its downloaded files"}
+ {row.kind === "cache" ? ` (${formatBytes(row.bytes)})` : ""} from
+ disk. You can re-download it later.
+ >
+ ),
+ successMessage: `Deleted ${cacheDeletableRepoId}`,
+ onConfirm: async () => {
+ if (isDataset) {
+ await deleteCachedDataset(cacheDeletableRepoId);
+ } else {
+ await deleteCachedModel(cacheDeletableRepoId);
+ // Deleted repos can't stay pinned: drop the repo pin and any of
+ // its per-quant pins so stale rows don't linger up top.
+ const { pinned, togglePinned: toggle } =
+ usePinnedModelsStore.getState();
+ for (const key of pinned) {
+ if (
+ key === pinKey(cacheDeletableRepoId) ||
+ key.startsWith(`${cacheDeletableRepoId}::`)
+ ) {
+ toggle(
+ cacheDeletableRepoId,
+ key.includes("::")
+ ? key.slice(key.indexOf("::") + 2)
+ : undefined,
+ );
+ }
+ }
+ }
+ },
+ onDeleted: onChange,
}}
- onDeleted={onChange}
/>
) : null;
diff --git a/studio/frontend/src/features/hub/catalog/models-catalog.tsx b/studio/frontend/src/features/hub/catalog/models-catalog.tsx
index 344f5deb49..02b9b62fce 100644
--- a/studio/frontend/src/features/hub/catalog/models-catalog.tsx
+++ b/studio/frontend/src/features/hub/catalog/models-catalog.tsx
@@ -54,6 +54,7 @@ export interface ModelsCatalogState {
hasMore: boolean;
manualFetchAvailable: boolean;
hasActiveFilters: boolean;
+ typeFilterActive: boolean;
}
export interface ModelsCatalogPagination {
@@ -117,6 +118,7 @@ export const ModelsCatalog = memo(function ModelsCatalog({
loadingIntentCount,
hasMore,
hasActiveFilters,
+ typeFilterActive,
} = state;
const { scrollRef, sentinelRef, isLoadingMore } = pagination;
const {
@@ -469,6 +471,8 @@ export const ModelsCatalog = memo(function ModelsCatalog({
downloadedReady={downloadedReady}
inventoryError={inventoryError}
query={query}
+ typeFilterActive={typeFilterActive}
+ onClearFilters={onClearFilters}
scrollElement={downloadedScrollEl}
activeCheckpoint={activeCheckpoint}
activeGgufVariant={activeGgufVariant}
diff --git a/studio/frontend/src/features/hub/catalog/models-table.tsx b/studio/frontend/src/features/hub/catalog/models-table.tsx
index f3887fee28..3f91685b47 100644
--- a/studio/frontend/src/features/hub/catalog/models-table.tsx
+++ b/studio/frontend/src/features/hub/catalog/models-table.tsx
@@ -17,6 +17,10 @@ import {
formatRelativeLong,
formatRelativeShort,
} from "@/features/hub/lib/format";
+import {
+ MODEL_TYPE_FILTER_OPTIONS,
+ type ModelTypeFilter,
+} from "@/features/hub/lib/model-type-filter";
import {
formatModelParamLabel,
formatPipelineTag,
@@ -25,6 +29,7 @@ import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { cn, formatCompact } from "@/lib/utils";
import {
ArrowLeft01Icon,
+ ArrowUpDownIcon,
Copy01Icon,
Download01Icon,
FavouriteIcon,
@@ -124,6 +129,7 @@ export function InventorySortControl({
value: InventorySort;
onChange: (value: InventorySort) => void;
}) {
+ const selected = INVENTORY_SORTS.find((option) => option.value === value);
return (
value={value}
@@ -131,7 +137,46 @@ export function InventorySortControl({
onValueChange={onChange}
ariaLabel="Sort downloads"
align="end"
- className="h-8 text-[11.5px]"
+ title={selected?.label}
+ // Capped and shrinkable so a long label truncates instead of wrapping
+ // the "On device" heading beside these pills in the narrow split pane.
+ className="h-8 min-w-[72px] max-w-[124px] shrink text-[11.5px]"
+ triggerContent={
+
+
+ {selected?.label ?? value}
+
+ }
+ />
+ );
+}
+
+// Model-type filter pill (Text / Vision / Embedding / …) beside the sort pill.
+export function InventoryTypeFilterControl({
+ value,
+ onChange,
+}: {
+ value: ModelTypeFilter;
+ onChange: (value: ModelTypeFilter) => void;
+}) {
+ const selected = MODEL_TYPE_FILTER_OPTIONS.find(
+ (option) => option.value === value,
+ );
+ return (
+
+ value={value}
+ options={MODEL_TYPE_FILTER_OPTIONS}
+ onValueChange={onChange}
+ ariaLabel="Filter by model type"
+ align="end"
+ title={selected?.label}
+ // Capped and shrinkable so a long label ("Speech to text") truncates
+ // instead of wrapping the "On device" heading beside these pills.
+ className="h-8 min-w-[72px] max-w-[124px] shrink text-[11.5px]"
/>
);
}
@@ -183,7 +228,9 @@ export function HubListHeader({
)}
-
+ {/* truncate keeps the heading on one line and clips a long search
+ query with an ellipsis instead of overflowing the pills. */}
+
{title}
{subtitle && (
@@ -216,7 +263,9 @@ export function HubListHeader({
)}
{(actions || onViewChange) && (
-
+ // min-w-0 (not shrink-0) so shrinkable actions (the On-device filter
+ // pills) compress before the title is forced onto two lines.
+
{actions}
{onViewChange && (
{tab === "downloaded" && !isDataset && (
-
+
-
+
Only show models that fit
@@ -402,7 +402,7 @@ export const ModelsToolbar = memo(function ModelsToolbar({
)}
/>
-
+
-
+
{
- const withoutDuplicate = current.filter((row) => row.id !== folder.id);
+ const withoutDuplicate = current.filter(
+ (row) => row.id !== folder.id,
+ );
return [...withoutDuplicate, folder];
});
toast.success("Location added", {
@@ -184,9 +186,12 @@ export function OnDeviceFoldersDialog({
overlayClassName="bg-black/20 backdrop-blur-none"
>
- On-device locations
+
+ On-device locations
+
- Hugging Face model folders, GGUF files, and adapters are indexed here.
+ Hugging Face model folders, GGUF files, and adapters are indexed
+ here.
@@ -342,9 +347,7 @@ export function OnDeviceFoldersDialog({
-
+
{folder.path}
@@ -372,7 +375,10 @@ export function OnDeviceFoldersDialog({
/>
-
+
Open in file manager
@@ -397,7 +403,10 @@ export function OnDeviceFoldersDialog({
)}
-
+
Remove from list
diff --git a/studio/frontend/src/features/hub/catalog/path-info-button.tsx b/studio/frontend/src/features/hub/catalog/path-info-button.tsx
index 0d5ca6e00e..403bb0148e 100644
--- a/studio/frontend/src/features/hub/catalog/path-info-button.tsx
+++ b/studio/frontend/src/features/hub/catalog/path-info-button.tsx
@@ -1,38 +1,83 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
+import { usePlatformStore } from "@/config/env";
+import { revealCachedModel } from "@/features/chat";
+import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
-import { Copy01Icon, FolderSearchIcon } from "@hugeicons/core-free-icons";
+import { Copy01Icon, Folder01Icon } from "@hugeicons/core-free-icons";
import { Tick02Icon } from "@/lib/tick-icon";
import { HugeiconsIcon } from "@hugeicons/react";
import type { MouseEvent } from "react";
-import { useState } from "react";
import { useCopyFeedback } from "../hooks/use-copy-feedback";
+/** Reveal a cached repo (or one GGUF variant's file) in the OS file manager.
+ * Resolved server-side from the HF cache, so only managed repos qualify. */
+export function RevealPathButton({
+ repoId,
+ variant,
+ className,
+}: {
+ repoId: string;
+ variant?: string | null;
+ className?: string;
+}) {
+ const deviceType = usePlatformStore((s) => s.deviceType);
+ const revealLabel =
+ deviceType === "mac"
+ ? "Reveal in Finder"
+ : deviceType === "windows"
+ ? "Reveal in File Explorer"
+ : "Reveal in File Manager";
+
+ return (
+
+
+ {
+ e.stopPropagation();
+ revealCachedModel(repoId, variant ?? undefined).catch((err) => {
+ toast.error(
+ err instanceof Error
+ ? err.message
+ : "Failed to open file manager",
+ );
+ });
+ }}
+ className={cn(
+ "inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground opacity-0 transition-[opacity,background-color,color] duration-150 hover:bg-muted hover:text-foreground focus-visible:opacity-100 group-hover/dl:opacity-100",
+ className,
+ )}
+ >
+
+
+
+
+ {revealLabel}
+
+
+ );
+}
+
+/** Copies the on-disk path straight to the clipboard, no dialog. */
export function PathInfoButton({
path,
- title = "On-device location",
- description = "Where this model lives on disk.",
className,
}: {
path: string;
- title?: string;
- description?: string;
className?: string;
}) {
- const [open, setOpen] = useState(false);
const { copied, copy } = useCopyFeedback();
const handleCopy = async (event: MouseEvent
) => {
@@ -42,67 +87,27 @@ export function PathInfoButton({
};
return (
- <>
-
-
- {
- e.stopPropagation();
- setOpen(true);
- }}
- className={cn(
- "inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground opacity-0 transition-[opacity,background-color,color] duration-150 hover:bg-muted hover:text-foreground focus-visible:opacity-100 group-hover/dl:opacity-100",
- className,
- )}
- >
-
-
-
-
- Show path
-
-
-
- >
+
+
+
+
+ {copied ? "Copied" : "Copy path"}
+
+
);
}
diff --git a/studio/frontend/src/features/hub/catalog/safetensors-download-card.tsx b/studio/frontend/src/features/hub/catalog/safetensors-download-card.tsx
index 424afa2e05..5cba9c229b 100644
--- a/studio/frontend/src/features/hub/catalog/safetensors-download-card.tsx
+++ b/studio/frontend/src/features/hub/catalog/safetensors-download-card.tsx
@@ -7,37 +7,36 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
-import { useRepoDownload } from "../download-manager";
-import { deleteCachedModel } from "../inventory";
import { cn } from "@/lib/utils";
import {
Alert02Icon,
- PencilEdit02Icon,
PlayIcon,
+ RemoveCircleIcon,
} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { useEffect, useState } from "react";
import { TrainIcon } from "../components/train-icon";
+import { useRepoDownload } from "../download-manager";
+import { useOnlineStatus } from "../hooks/use-online-status";
+import { deleteCachedModel } from "../inventory";
+import type { ModelInventoryFormat } from "../inventory";
+import { fetchModelSize } from "../lib/dataset-size";
+import { formatBytes } from "../lib/format";
import {
HUB_NON_GGUF_RUN_ACTIONS_VISIBLE,
HUB_POST_DOWNLOAD_ACTIONS_VISIBLE,
} from "../lib/hub-feature-flags";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { useEffect, useState } from "react";
-import { useHfTokenStore } from "../stores/hf-token-store";
-import { fetchModelSize } from "../lib/dataset-size";
-import { formatBytes } from "../lib/format";
import { fingerprintToken } from "../lib/token-fingerprint";
-import { useOnlineStatus } from "../hooks/use-online-status";
+import { useHfTokenStore } from "../stores/hf-token-store";
+import { DotTag } from "./dot-tag";
import {
CardDivider,
- CardDeleteButton,
DeleteConfirmDialog,
DownloadActionButton,
DownloadCard,
} from "./download-card";
-import { DotTag } from "./dot-tag";
-import { PathInfoButton } from "./path-info-button";
+import { QuantOptionsMenu } from "./gguf-download-card";
import { useCardDelete } from "./use-card-delete";
-import type { ModelInventoryFormat } from "../inventory";
import { useDownloadCardState } from "./use-download-card-state";
function formatModelLabel(modelFormat?: ModelInventoryFormat | null): string {
@@ -62,10 +61,9 @@ export function SafetensorsDownloadCard({
canRun = true,
isActive,
isLoadingThisModel,
- cachePath,
knownBytes,
onLoad,
- onUseInChat,
+ onEject,
onTrain,
onChange,
}: {
@@ -77,10 +75,13 @@ export function SafetensorsDownloadCard({
canRun?: boolean;
isActive: boolean;
isLoadingThisModel: boolean;
+ /** Accepted for API parity; the options menu resolves the path itself. */
cachePath?: string | null;
knownBytes?: number | null;
onLoad: (opts: { ggufVariant?: string; expectedBytes?: number }) => void;
+ /** Accepted for API parity; the run bar ejects instead of opening chat. */
onUseInChat?: () => void;
+ onEject?: () => void;
onTrain?: () => void;
onChange?: () => void;
}) {
@@ -95,8 +96,8 @@ export function SafetensorsDownloadCard({
knownBytes && knownBytes > 0
? knownBytes
: modelSize.key === sizeKey
- ? modelSize.bytes
- : null;
+ ? modelSize.bytes
+ : null;
const [deleteRepoOpen, setDeleteRepoOpen] = useState(false);
const { deleting, runDelete } = useCardDelete({
action: () => deleteCachedModel(repoId, undefined, hfToken || undefined),
@@ -170,7 +171,8 @@ export function SafetensorsDownloadCard({
!isLoadingThisModel;
return (
-
+
-
+
@@ -227,17 +229,20 @@ export function SafetensorsDownloadCard({
)}
- {canDelete && (
-
setDeleteRepoOpen(true)}
- />
- )}
- {isDownloaded && cachePath && (
- setDeleteRepoOpen(true)}
+ showPin={false}
+ buttonClassName="ml-0.5 size-7"
+ iconClassName="size-4"
/>
)}
@@ -268,7 +273,7 @@ export function SafetensorsDownloadCard({
onClick={() => {
if (!canRun) return;
if (isActive) {
- onUseInChat?.();
+ onEject?.();
return;
}
onLoad({});
@@ -287,26 +292,26 @@ export function SafetensorsDownloadCard({
>
) : isActive ? (
<>
-
- Chat
+
+ Eject
>
- ) : !canRun ? (
- <>
-
- No run
- >
- ) : (
+ ) : canRun ? (
<>
Run
>
+ ) : (
+ <>
+
+ No run
+ >
)}
) : showUnavailableAction ? (
diff --git a/studio/frontend/src/features/hub/catalog/sampling-settings-dialog.tsx b/studio/frontend/src/features/hub/catalog/sampling-settings-dialog.tsx
new file mode 100644
index 0000000000..4981f7734b
--- /dev/null
+++ b/studio/frontend/src/features/hub/catalog/sampling-settings-dialog.tsx
@@ -0,0 +1,435 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+// Gear button for the GGUF run bar: model config, system prompt, reasoning,
+// sampling, tools and retrieval, using the same controls as the chat page's
+// Run settings. Edits write to the chat runtime store's persisted state.
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { InfoHint } from "@/components/ui/info-hint";
+import { Switch } from "@/components/ui/switch";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import {
+ ParamSlider,
+ useChatModelRuntime,
+ useChatRuntimeStore,
+} from "@/features/chat";
+import {
+ type PerModelConfig,
+ SidebarModelConfig,
+ applyPerModelConfigToRuntime,
+ currentRuntimePerModelConfig,
+ useActiveModelConfig,
+} from "@/features/model-picker";
+import { RetrievalSettingsSection } from "@/features/rag";
+import { toast } from "@/lib/toast";
+import { cn } from "@/lib/utils";
+import { Settings02Icon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { type ReactNode, useCallback, useState } from "react";
+import { HubOptionMenu } from "./hub-option-menu";
+
+function SettingsSection({
+ label,
+ labelClassName,
+ children,
+}: {
+ label: string;
+ labelClassName?: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+ {label}
+
+
{children}
+
+ );
+}
+
+function ToggleRow({
+ label,
+ info,
+ checked,
+ disabled,
+ onCheckedChange,
+}: {
+ label: string;
+ info?: string;
+ checked: boolean;
+ disabled?: boolean;
+ onCheckedChange: (checked: boolean) => void;
+}) {
+ return (
+
+
+ {label}
+ {info && {info}}
+
+
onCheckedChange(value === true)}
+ aria-label={label}
+ />
+
+ );
+}
+
+export function SamplingSettingsButton({ className }: { className?: string }) {
+ const [open, setOpen] = useState(false);
+ const params = useChatRuntimeStore((s) => s.params);
+ const setParams = useChatRuntimeStore((s) => s.setParams);
+
+ // Loaded model's per-model config (context length etc.), mirroring the chat
+ // page's Run settings Model section.
+ const { selectModel } = useChatModelRuntime();
+ const modelLoading = useChatRuntimeStore((s) => s.modelLoading);
+ const activeGgufVariant = useChatRuntimeStore((s) => s.activeGgufVariant);
+ const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength);
+ const ggufNativeContextLength = useChatRuntimeStore(
+ (s) => s.ggufNativeContextLength,
+ );
+ const {
+ checkpoint,
+ isGguf: activeModelIsGguf,
+ config: activeModelConfig,
+ } = useActiveModelConfig();
+ const handleReloadActiveModel = useCallback(
+ (config: PerModelConfig) => {
+ const runtime = useChatRuntimeStore.getState();
+ const activeCheckpoint = runtime.params.checkpoint;
+ if (!activeCheckpoint) return;
+ const nativeToken = runtime.activeNativePathToken;
+ const nativeExpiry = runtime.activeNativePathExpiresAtMs;
+ // Mirrors the chat page: an expired native-path token can't reload.
+ if (nativeToken && nativeExpiry != null && Date.now() >= nativeExpiry) {
+ toast.error("This local model file's access has expired.", {
+ description: "Re-select the model file to reload it.",
+ });
+ return;
+ }
+ // selectModel reads config from the runtime store, not the selection, so
+ // apply it first (snapshotting the current one for rollback).
+ const previousConfig = currentRuntimePerModelConfig({
+ includeMaxSeqLength: true,
+ });
+ applyPerModelConfigToRuntime(config);
+ void selectModel({
+ id: activeCheckpoint,
+ source: "local",
+ ggufVariant: runtime.activeGgufVariant ?? undefined,
+ nativePathToken: nativeToken ?? undefined,
+ nativePathExpiresAtMs: nativeExpiry,
+ isGguf: activeModelIsGguf,
+ isDownloaded: true,
+ keepSpeculative: true,
+ previousConfig,
+ forceReload: true,
+ });
+ },
+ [selectModel, activeModelIsGguf],
+ );
+
+ // Reasoning + tools: same store bindings as the chat page.
+ const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled);
+ const reasoningAlwaysOn = useChatRuntimeStore((s) => s.reasoningAlwaysOn);
+ const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled);
+ const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
+ const reasoningEffortLevels = useChatRuntimeStore(
+ (s) => s.reasoningEffortLevels,
+ );
+ const setReasoningEffort = useChatRuntimeStore((s) => s.setReasoningEffort);
+ const supportsPreserveThinking = useChatRuntimeStore(
+ (s) => s.supportsPreserveThinking,
+ );
+ const preserveThinking = useChatRuntimeStore((s) => s.preserveThinking);
+ const setPreserveThinking = useChatRuntimeStore((s) => s.setPreserveThinking);
+ const maxToolCalls = useChatRuntimeStore((s) => s.maxToolCallsPerMessage);
+ const setMaxToolCalls = useChatRuntimeStore(
+ (s) => s.setMaxToolCallsPerMessage,
+ );
+ const toolCallTimeout = useChatRuntimeStore((s) => s.toolCallTimeout);
+ const setToolCallTimeout = useChatRuntimeStore((s) => s.setToolCallTimeout);
+ const autoHealToolCalls = useChatRuntimeStore((s) => s.autoHealToolCalls);
+ const setAutoHealToolCalls = useChatRuntimeStore(
+ (s) => s.setAutoHealToolCalls,
+ );
+ const nudgeToolCalls = useChatRuntimeStore((s) => s.nudgeToolCalls);
+ const setNudgeToolCalls = useChatRuntimeStore((s) => s.setNudgeToolCalls);
+ const permissionMode = useChatRuntimeStore((s) => s.permissionMode);
+ const setConfirmToolCalls = useChatRuntimeStore((s) => s.setConfirmToolCalls);
+
+ const toolsEnabled = useChatRuntimeStore((s) => s.toolsEnabled);
+ const setToolsEnabled = useChatRuntimeStore((s) => s.setToolsEnabled);
+
+ const set = (key: keyof typeof params) => (value: number) =>
+ setParams({ ...params, [key]: value });
+
+ // Slider 0-41; 41 maps to 9999 ("Max"), mirroring the chat page.
+ const toolCallsSliderValue =
+ maxToolCalls >= 9999 ? 41 : Math.min(maxToolCalls, 40);
+ // Slider 1-31; 31 maps to 9999 ("Max").
+ const timeoutSliderValue =
+ toolCallTimeout >= 9999 ? 31 : Math.min(Math.max(toolCallTimeout, 1), 30);
+
+ return (
+ <>
+
+
+ {
+ e.stopPropagation();
+ setOpen(true);
+ }}
+ className={cn(
+ "inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
+ className,
+ )}
+ >
+
+
+
+
+ Inference settings
+
+
+
+ >
+ );
+}
diff --git a/studio/frontend/src/features/hub/catalog/shared.tsx b/studio/frontend/src/features/hub/catalog/shared.tsx
index c435afcfe6..fbb3f6bb1d 100644
--- a/studio/frontend/src/features/hub/catalog/shared.tsx
+++ b/studio/frontend/src/features/hub/catalog/shared.tsx
@@ -1,12 +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 {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
import {
BrainIcon,
Chat01Icon,
CodeIcon,
GlobeIcon,
HeadphonesIcon,
+ ImageIcon,
LockIcon,
LockKeyIcon,
SparklesIcon,
@@ -15,12 +22,6 @@ import {
} from "@hugeicons/core-free-icons";
import type { IconSvgElement } from "@hugeicons/react";
import { HugeiconsIcon } from "@hugeicons/react";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import { cn } from "@/lib/utils";
import type { Capability, CapabilityKey } from "../lib/model-capabilities";
const CAPABILITY_ICON: Record = {
@@ -30,6 +31,7 @@ const CAPABILITY_ICON: Record = {
reasoning: BrainIcon,
code: CodeIcon,
embedding: SparklesIcon,
+ diffusion: ImageIcon,
multilingual: GlobeIcon,
conversational: Chat01Icon,
};
@@ -45,6 +47,8 @@ const CAPABILITY_TONE: Record = {
code: "bg-cyan-500/10 text-cyan-800 dark:bg-cyan-400/20 dark:text-cyan-300",
embedding:
"bg-emerald-500/10 text-emerald-700 dark:bg-emerald-400/20 dark:text-emerald-300",
+ diffusion:
+ "bg-pink-500/10 text-pink-700 dark:bg-pink-400/20 dark:text-pink-300",
multilingual:
"bg-sky-500/10 text-sky-700 dark:bg-sky-400/20 dark:text-sky-300",
conversational:
@@ -59,9 +63,7 @@ export function AccessChip({ label }: { label: string }) {
);
}
-function isGatedAccess(
- gated: false | "auto" | "manual" | undefined,
-): boolean {
+function isGatedAccess(gated: false | "auto" | "manual" | undefined): boolean {
return gated !== false && gated !== undefined;
}
diff --git a/studio/frontend/src/features/hub/download-manager/download-manager-controller.ts b/studio/frontend/src/features/hub/download-manager/download-manager-controller.ts
index da653ddeb7..2cea1d8304 100644
--- a/studio/frontend/src/features/hub/download-manager/download-manager-controller.ts
+++ b/studio/frontend/src/features/hub/download-manager/download-manager-controller.ts
@@ -1,14 +1,10 @@
// 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 { DOWNLOAD_KIND } from "./constants";
import {
createDownloadManagerInitialState,
- jobKeyOf,
removeJob,
- selectActiveJob,
setState,
- useDownloadManagerStore,
} from "./download-manager-state";
import { resetDownloadApiAdapterState } from "./download-api-adapter";
import {
@@ -69,25 +65,6 @@ export const downloadManager: DownloadManagerController = {
dismiss: removeJob,
};
-/** Cancel the in-flight download for a staged model pick. No-op when nothing is
- * downloading (e.g. a native/local file that was never fetched). Lets non-React
- * callers (the chat store's abandon paths) stop a staged transfer without the
- * useRepoDownload hook. */
-export function cancelStagedModelDownload(
- pending: { id: string; ggufVariant?: string | null } | null,
-): void {
- if (!pending) return;
- const variant = pending.ggufVariant ?? null;
- const activeJob = selectActiveJob(
- useDownloadManagerStore.getState(),
- DOWNLOAD_KIND.MODEL,
- pending.id,
- variant,
- );
- void downloadManager.cancel(
- activeJob?.key ?? jobKeyOf(DOWNLOAD_KIND.MODEL, pending.id, variant),
- );
-}
if (import.meta.hot) {
import.meta.hot.dispose(() => {
diff --git a/studio/frontend/src/features/hub/download-manager/index.ts b/studio/frontend/src/features/hub/download-manager/index.ts
index dd88aaf3f0..60ef3851f8 100644
--- a/studio/frontend/src/features/hub/download-manager/index.ts
+++ b/studio/frontend/src/features/hub/download-manager/index.ts
@@ -20,7 +20,6 @@ export {
} from "./constants";
export {
__resetDownloadManagerForTests,
- cancelStagedModelDownload,
clearCompletedInventoryHint,
downloadManager,
hydrateDownloadManager,
diff --git a/studio/frontend/src/features/hub/hub-page.tsx b/studio/frontend/src/features/hub/hub-page.tsx
index 29e2f3f277..426f816dbe 100644
--- a/studio/frontend/src/features/hub/hub-page.tsx
+++ b/studio/frontend/src/features/hub/hub-page.tsx
@@ -1,28 +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 {
- loadRememberedLoadSettings,
- rememberedLoadSettingsKey,
-} from "@/components/assistant-ui/model-selector/remembered-load-settings";
-import { hfModelFitsDevice } from "@/components/assistant-ui/model-selector/recommended-fit";
-import { useHubInventory } from "@/features/hub/inventory";
-import { useDebouncedValue } from "@/hooks/use-debounced-value";
-import { useGpuInfo } from "@/hooks/use-gpu-info";
-import {
- type HfModelSearchChannel,
- type HfSortDirection,
- type HfSortKey,
-} from "@/features/hub/hooks/use-hub-model-search";
-import { useOnlineStatus } from "@/features/hub/hooks/use-online-status";
-import { useHubInfiniteScroll } from "@/features/hub/hooks/use-hub-infinite-scroll";
-import { ggufVariantsMatch, modelIdsMatch } from "@/features/hub/lib/model-identity";
-import { cn } from "@/lib/utils";
import { usePlatformStore } from "@/config/env";
-import {
- hfApiToken,
- useHfTokenStore,
-} from "@/features/hub/stores/hf-token-store";
import {
isChannelEntryFresh,
useHubFeedStore,
@@ -33,6 +12,25 @@ 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 {
+ applyModelLoadConfigToRuntime,
+ currentRuntimePerModelConfig,
+ hfModelFitsDevice,
+ resolveInitialConfig,
+} from "@/features/model-picker";
+import { useDebouncedValue } from "@/hooks/use-debounced-value";
+import { useGpuInfo } from "@/hooks/use-gpu-info";
+import { cn } from "@/lib/utils";
import { useNavigate, useSearch } from "@tanstack/react-router";
import {
useCallback,
@@ -42,17 +40,10 @@ import {
useRef,
useState,
} from "react";
+import { ExternalLinkConfirmDialog } from "./catalog/external-link-confirm-dialog";
import { HubDetailView } from "./catalog/hub-detail-view";
-import { HubTopBar } from "./catalog/hub-top-bar";
import { HubFeed } from "./catalog/hub-feed";
-import { OwnerScopeToggle } from "./catalog/owner-scope-toggle";
-import {
- type AllModelsView,
- HubListHeader,
- type InventorySort,
- InventorySortControl,
- ResultListHeader,
-} from "./catalog/models-table";
+import { HubTopBar } from "./catalog/hub-top-bar";
import {
ModelsCatalog,
type ModelsCatalogHandlers,
@@ -60,14 +51,22 @@ import {
type ModelsCatalogState,
} from "./catalog/models-catalog";
import { ModelsHeader } from "./catalog/models-header";
+import {
+ type AllModelsView,
+ HubListHeader,
+ type InventorySort,
+ InventorySortControl,
+ InventoryTypeFilterControl,
+ ResultListHeader,
+} from "./catalog/models-table";
import { ModelsToolbar } from "./catalog/models-toolbar";
-import { ExternalLinkConfirmDialog } from "./catalog/external-link-confirm-dialog";
import { OnDeviceFoldersDialog } from "./catalog/on-device-folders-dialog";
+import { OwnerScopeToggle } from "./catalog/owner-scope-toggle";
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 { useHubModelVram } from "./hooks/use-hub-model-vram";
-import { useHiddenEmbeddingModelIds } from "./hooks/use-hidden-embedding-models";
import { useModelsSelection } from "./hooks/use-models-selection";
import {
CHANNEL_TO_SECTION,
@@ -83,6 +82,10 @@ import {
isHiddenModelId,
} from "./lib/hidden-models";
import { inventoryRowMatches, tokenizeQuery } from "./lib/inventory-search";
+import {
+ type ModelTypeFilter,
+ matchesModelType,
+} from "./lib/model-type-filter";
import { resolveOwnerProviderLogo } from "./lib/provider-logos";
import { fingerprintToken } from "./lib/token-fingerprint";
import {
@@ -456,6 +459,8 @@ export function ModelsPage() {
setInventorySortState(sort);
writeInventorySortPreference(sort);
}, []);
+ const [inventoryTypeFilter, setInventoryTypeFilter] =
+ useState("all");
const [foldersDialogOpen, setFoldersDialogOpen] = useState(false);
const [discoverFetchIntent, setDiscoverFetchIntent] = useState(0);
const [sortBrowseActive, setSortBrowseActive] = useState(false);
@@ -582,15 +587,15 @@ export function ModelsPage() {
const deferredCapabilityFilter = useDeferredValue(capabilityFilter);
const hasQuery = deferredDebouncedQuery.trim() !== "";
- const mode: DiscoverMode = !isModelDiscover
- ? "search"
- : hasQuery
+ const mode: DiscoverMode = isModelDiscover
+ ? hasQuery
? "search"
: urlSection != null
? "channel-list"
: sortBrowseActive
? "search"
- : "feed";
+ : "feed"
+ : "search";
const isFeedMode = mode === "feed";
const isChannelListMode = mode === "channel-list";
const isSortBrowseMode =
@@ -737,7 +742,10 @@ export function ModelsPage() {
// The default feed only shows models with a provider logo.
(!isFeedMode ||
resolveOwnerProviderLogo(row.owner, row.repo) !== null) &&
- matchesFormat(detectResultFormat(row.result), effectiveDiscoverFormat) &&
+ matchesFormat(
+ detectResultFormat(row.result),
+ effectiveDiscoverFormat,
+ ) &&
matchesCapability(row.capabilities, deferredCapabilityFilter) &&
(!activeChannel?.finetunableOnly || isUnslothFinetunable(row.result)) &&
// Models already on disk stay visible regardless of device fit,
@@ -807,7 +815,10 @@ export function ModelsPage() {
}
return merged;
}, [isFeedMode, feedTrendingRows, filteredDiscoverRows]);
- const feedResults = useMemo(() => feedRows.map((row) => row.result), [feedRows]);
+ const feedResults = useMemo(
+ () => feedRows.map((row) => row.result),
+ [feedRows],
+ );
const selectionDiscoverRows = isFeedMode ? feedRows : discoverRows;
const selectionFilteredDiscoverRows = isFeedMode
? feedRows
@@ -837,7 +848,8 @@ export function ModelsPage() {
// Local rows may lack a repo id, so also check path and title.
return (
!isHiddenModelId(row.id, row.repoId, row.path, row.title) ||
- (inventoryTokens.length > 0 && inventoryRowMatches(row, inventoryTokens))
+ (inventoryTokens.length > 0 &&
+ inventoryRowMatches(row, inventoryTokens))
);
},
[hiddenEmbeddingModelIds, inventoryTokens],
@@ -855,6 +867,7 @@ export function ModelsPage() {
// id/title/path happens to contain an infra needle is not dropped.
isDatasetMode ||
(matchesFormat(row.modelFormat, deferredFormatFilter) &&
+ matchesModelType(row, inventoryTypeFilter) &&
isVisibleInventoryRow(row)),
),
inventoryTokens,
@@ -863,6 +876,7 @@ export function ModelsPage() {
effectiveCachedRows,
isDatasetMode,
deferredFormatFilter,
+ inventoryTypeFilter,
inventoryTokens,
isVisibleInventoryRow,
],
@@ -878,6 +892,7 @@ export function ModelsPage() {
// id/title/path happens to contain an infra needle is not dropped.
isDatasetMode ||
(matchesFormat(row.modelFormat, deferredFormatFilter) &&
+ matchesModelType(row, inventoryTypeFilter) &&
isVisibleInventoryRow(row)),
),
inventoryTokens,
@@ -886,6 +901,7 @@ export function ModelsPage() {
effectiveLocalRows,
isDatasetMode,
deferredFormatFilter,
+ inventoryTypeFilter,
inventoryTokens,
isVisibleInventoryRow,
],
@@ -918,6 +934,7 @@ export function ModelsPage() {
resourceType,
deferredFormatFilter,
deferredCapabilityFilter,
+ inventoryTypeFilter,
effectiveSort,
effectiveDirection,
activeChannelId,
@@ -928,6 +945,7 @@ export function ModelsPage() {
resourceType,
deferredFormatFilter,
deferredCapabilityFilter,
+ inventoryTypeFilter,
effectiveSort,
effectiveDirection,
activeChannelId,
@@ -946,6 +964,7 @@ export function ModelsPage() {
}
} else {
setDownloadedFormat("all");
+ setInventoryTypeFilter("all");
}
setCapabilityFilter("all");
}, [isDiscoverTab, urlSection, navigate]);
@@ -1155,50 +1174,22 @@ export function ModelsPage() {
(opts: ModelLoadOptions, isDownloaded: boolean) => {
if (!selectedModel) return;
const runId = selectedModel.resource.runId;
- // "Load on selection" off: stage GGUF picks instead of loading, so the
- // chat page's staging flow can read the header and show the load options.
- // Non-GGUF models have nothing to configure pre-load, so they load now.
- if (
- !useChatRuntimeStore.getState().loadOnSelection &&
- (opts.ggufVariant != null || selectedModel.isGguf)
- ) {
- useChatRuntimeStore.getState().stageModel({
- id: runId,
- ggufVariant: opts.ggufVariant,
- isGguf: selectedModel.isGguf,
- isDownloaded,
- expectedBytes: opts.expectedBytes,
- });
- openNewChat();
- return;
- }
- // Detach any leftover staged pick first so its edited knobs (e.g. a custom
- // context length) don't leak into this load -- mirrors the chat page's
- // detachStaged(); keepDownload keeps any staged download running.
- useChatRuntimeStore.getState().abandonStagedModel({ keepDownload: true });
- // Load-on-selection skips the chat sheet, so seed this GGUF pick's saved
- // load knobs here the way the sheet's restore effect would; otherwise the
- // remembered config is silently ignored on the Hub run path. keepSpeculative
- // then honors the restored speculative choice across the switch.
- const remembered =
- opts.ggufVariant != null || selectedModel.isGguf
- ? loadRememberedLoadSettings(
- rememberedLoadSettingsKey({
- id: runId,
- ggufVariant: opts.ggufVariant,
- }),
- )
- : null;
- if (remembered) {
- useChatRuntimeStore.getState().applyRememberedLoadSettings(remembered);
- }
+ const resolvedConfig = resolveInitialConfig(runId, opts.ggufVariant);
+ const rememberedConfig = resolvedConfig.remembered
+ ? resolvedConfig.config
+ : null;
+ const previousConfig = currentRuntimePerModelConfig({
+ includeMaxSeqLength: true,
+ });
+ const hasAppliedConfig = applyModelLoadConfigToRuntime(rememberedConfig);
void selectModel({
id: runId,
ggufVariant: opts.ggufVariant,
isDownloaded,
expectedBytes: opts.expectedBytes,
- keepSpeculative: remembered != null,
+ keepSpeculative: hasAppliedConfig,
throwOnError: true,
+ previousConfig,
})
.then(() => {
// Read fresh: the load is async, so the checkpoint may have changed.
@@ -1280,6 +1271,7 @@ export function ModelsPage() {
onLoad: handleLoad,
onLoadLocal: handleLoadLocal,
onUseInChat: openNewChat,
+ onEject: () => void ejectModel(),
onTrain: handleTrain,
onInventoryChange: refreshInventory,
onSearchHub: handleSearchHub,
@@ -1288,6 +1280,7 @@ export function ModelsPage() {
handleLoad,
handleLoadLocal,
openNewChat,
+ ejectModel,
handleTrain,
handleSearchHub,
refreshInventory,
@@ -1295,31 +1288,38 @@ export function ModelsPage() {
);
const catalogState = useMemo(
- () => ({
- 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"),
- }),
+ () => {
+ 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,
+ };
+ },
[
tab,
isFeedMode,
@@ -1344,6 +1344,7 @@ export function ModelsPage() {
discoverManualFetchAvailable,
deferredFormatFilter,
deferredCapabilityFilter,
+ inventoryTypeFilter,
],
);
@@ -1422,16 +1423,18 @@ export function ModelsPage() {
);
}
- const ownerToggle = !isDatasetMode ? (
+ const ownerToggle = isDatasetMode ? undefined : (
- ) : undefined;
+ );
// Compact pill so it stays beside the view-mode tabs even in the narrow
// split pane instead of dropping to its own row.
return (
{isChannelListMode ? (
{
- const sortControl = (
-
+ // Compact pills so they stay beside the view-mode tabs even in the narrow
+ // split pane instead of dropping to their own row.
+ const controls = (
+
+ {!isDatasetMode && (
+
+ )}
+
+
);
- // Compact pill so it stays beside the view-mode tabs even in the narrow
- // split pane instead of dropping to its own row.
return (
);
}, [
- visibleCachedCount,
- visibleLocalCount,
+ filteredCachedRows,
+ filteredLocalRows,
allModelsView,
setAllModelsView,
inventorySort,
setInventorySort,
+ inventoryTypeFilter,
+ isDatasetMode,
]);
const detailOpen = urlModel !== null;
diff --git a/studio/frontend/src/features/hub/index.ts b/studio/frontend/src/features/hub/index.ts
index 5d4151e87d..b464ddf0cf 100644
--- a/studio/frontend/src/features/hub/index.ts
+++ b/studio/frontend/src/features/hub/index.ts
@@ -1,10 +1,57 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
-export { cancelStagedModelDownload } from "./download-manager";
+export {
+ downloadManager,
+ jobKeyOf,
+ subscribeJobListeners,
+ useDownloadManagerStore,
+} from "./download-manager";
+export {
+ useHubInventory,
+ type CachedInventoryRow,
+ type GgufVariantDetail,
+ type LocalInventoryRow,
+ type LocalSource,
+ type ScanFolderInfo,
+ addScanFolder,
+ deleteCachedModel,
+ invalidateGgufVariantsCache,
+ listGgufVariants,
+ listScanFolders,
+ removeScanFolder,
+} from "./inventory";
+export {
+ type HfModelResult,
+ type HfSortKey,
+ useHubModelSearch,
+} from "./hooks/use-hub-model-search";
+export { useOnlineStatus } from "./hooks/use-online-status";
+export { useHubInfiniteScroll } from "./hooks/use-hub-infinite-scroll";
export { bumpInventoryVersion } from "./stores/inventory-events";
export {
getHfToken,
+ hfApiToken,
mirrorHfTokenInto,
useHfTokenStore,
} from "./stores/hf-token-store";
+export { useInventoryVersion } from "./stores/inventory-events";
+export { looksLikeLocalPath } from "./lib/local-path";
+export { hubTokenHeader } from "./lib/hub-token-header";
+export {
+ ggufVariantsMatch,
+ normalizeGgufVariantIdentity,
+ normalizeModelIdentity,
+} from "./lib/model-identity";
+export { formatBytes, formatRelativeShort } from "./lib/format";
+export { ggufVariantDisplayLabel } from "./lib/gguf-variant-sort";
+export {
+ DeleteConfirmDialog,
+ UpdateConfirmDialog,
+} from "./catalog/download-card";
+export { HubOptionMenu, type HubOption } from "./catalog/hub-option-menu";
+export { DotTag } from "./catalog/dot-tag";
+export { TransportConflictDialog } from "./catalog/transport-conflict-dialog";
+export { TrainIcon } from "./components/train-icon";
+export { isHiddenModelId } from "./lib/hidden-models";
+export { classifyUnslothSupport } from "./lib/unsloth-support";
diff --git a/studio/frontend/src/features/hub/inventory/api.ts b/studio/frontend/src/features/hub/inventory/api.ts
index 0e01c5c0cb..8c9214c9e5 100644
--- a/studio/frontend/src/features/hub/inventory/api.ts
+++ b/studio/frontend/src/features/hub/inventory/api.ts
@@ -48,6 +48,7 @@ export interface CachedGgufRepo {
capabilities?: BackendModelCapabilities | null;
size_bytes: number;
cache_path?: string;
+ last_modified?: number | null;
partial?: boolean;
partial_transport?: string | null;
pipeline_tag?: string | null;
@@ -65,6 +66,7 @@ export interface CachedModelRepo {
capabilities?: BackendModelCapabilities | null;
size_bytes: number;
cache_path?: string;
+ last_modified?: number | null;
partial?: boolean;
partial_transport?: string | null;
pipeline_tag?: string | null;
diff --git a/studio/frontend/src/features/hub/inventory/types.ts b/studio/frontend/src/features/hub/inventory/types.ts
index 6f65a56037..b300be5fb0 100644
--- a/studio/frontend/src/features/hub/inventory/types.ts
+++ b/studio/frontend/src/features/hub/inventory/types.ts
@@ -47,6 +47,7 @@ export interface CachedInventoryRow {
capabilities: ModelInventoryCapabilities;
bytes: number;
cachePath?: string | null;
+ lastModified?: number | null;
partial?: boolean;
partialTransport?: string | null;
pipelineTag?: string | null;
@@ -66,6 +67,8 @@ export interface LocalInventoryRow {
title: string;
source: LocalSource;
sourceLabel: string;
+ modelId?: string | null;
+ displayName?: string;
path: string;
isGguf: boolean;
modelFormat: ModelInventoryFormat;
diff --git a/studio/frontend/src/features/hub/inventory/use-device-inventory.ts b/studio/frontend/src/features/hub/inventory/use-device-inventory.ts
index 00eaa31a22..5ff57dfff4 100644
--- a/studio/frontend/src/features/hub/inventory/use-device-inventory.ts
+++ b/studio/frontend/src/features/hub/inventory/use-device-inventory.ts
@@ -14,6 +14,7 @@ import {
listLocalModels,
} from "./api";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
+import { ensureHiddenModelMatchers } from "../lib/hidden-models";
import { fingerprintToken } from "@/features/hub/lib/token-fingerprint";
import { useInventoryVersion } from "@/features/hub/stores/inventory-events";
import { useCallback, useEffect, useMemo } from "react";
@@ -146,12 +147,15 @@ async function runSourceFetch(
): Promise {
switch (source) {
case "cachedGguf":
+ await ensureHiddenModelMatchers();
return (await listCachedGguf(hfToken)) as DeviceInventoryRows[K];
case "cachedModels":
+ await ensureHiddenModelMatchers();
return (await listCachedModels(hfToken)) as DeviceInventoryRows[K];
case "cachedDatasets":
return (await listCachedDatasets()) as DeviceInventoryRows[K];
case "localModels":
+ await ensureHiddenModelMatchers();
return (await listLocalModels()).models as DeviceInventoryRows[K];
case "localDatasets":
return (await listLocalDatasets()).datasets as DeviceInventoryRows[K];
diff --git a/studio/frontend/src/features/hub/inventory/view-models.ts b/studio/frontend/src/features/hub/inventory/view-models.ts
index 334050fab4..5fd9cc2228 100644
--- a/studio/frontend/src/features/hub/inventory/view-models.ts
+++ b/studio/frontend/src/features/hub/inventory/view-models.ts
@@ -176,6 +176,7 @@ export function buildCachedInventoryRow(
runtime?: string | null;
format_variant?: string | null;
capabilities?: BackendModelCapabilities | null;
+ last_modified?: number | null;
optimistic?: boolean;
},
fallbackFormat: ModelInventoryFormat,
@@ -215,6 +216,12 @@ export function buildCachedInventoryRow(
capabilities,
bytes: row.size_bytes,
cachePath: row.cache_path ?? null,
+ lastModified:
+ typeof row.last_modified === "number" &&
+ Number.isFinite(row.last_modified) &&
+ row.last_modified > 0
+ ? row.last_modified
+ : null,
partial: row.partial ?? false,
partialTransport: row.partial_transport ?? null,
pipelineTag: row.pipeline_tag ?? null,
@@ -278,6 +285,8 @@ export function buildLocalInventoryRows(
title,
source: model.source,
sourceLabel: localSourceLabel(model.source),
+ modelId: model.model_id ?? null,
+ displayName: model.display_name,
path: model.path,
isGguf: modelFormat === "gguf",
modelFormat,
diff --git a/studio/frontend/src/features/hub/lib/hidden-models.ts b/studio/frontend/src/features/hub/lib/hidden-models.ts
index 634a061e0c..ce63832f99 100644
--- a/studio/frontend/src/features/hub/lib/hidden-models.ts
+++ b/studio/frontend/src/features/hub/lib/hidden-models.ts
@@ -1,19 +1,77 @@
// 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 { getInventoryVersion } from "../stores/inventory-events";
+
// Infra models hidden from browse/preview lists (Hub Discover, the chat model
// selector, and local on-device rows). Mirrors the backend
// `utils.hidden_models`: the RAG embedding model and the llama.cpp validation
// probe are not usable chat models. Server-confirmed cache rows are trusted
// because the backend applies variant-aware filtering. Optimistic cache rows
-// still use these needles until the server confirms them. Per-repo views are
-// not filtered, so reinstall flows still show downloaded files.
+// still use these needles until the server confirms them. The dynamic matchers
+// fetched from `/api/hub/hidden-models` add the user's configured embedder as
+// exact repo ids and exact resolved paths, never substring needles. Per-repo
+// views are not filtered, so reinstall flows still show downloaded files.
const HIDDEN_NEEDLES = [
"bge-small-en-v1.5", // RAG embedder: unsloth/bge-small-en-v1.5[-GGUF]
"ggml-org/models", // llama.cpp validation probe repo
"stories260k.gguf", // probe filename (carries .gguf so it stays specific)
];
+let dynamicNeedles: readonly string[] = [];
+let dynamicExactIds: readonly string[] = [];
+let dynamicExactPaths: readonly string[] = [];
+let matchersFetch: Promise | null = null;
+let matchersFetchVersion = -1;
+
+function toLowerStrings(value: unknown): string[] {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+ return value
+ .filter((v): v is string => typeof v === "string" && v.length > 0)
+ .map((v) => v.toLowerCase());
+}
+
+export function ensureHiddenModelMatchers(): Promise {
+ const version = getInventoryVersion();
+ if (matchersFetch && matchersFetchVersion === version) {
+ return matchersFetch;
+ }
+ matchersFetchVersion = version;
+ matchersFetch = (async () => {
+ try {
+ const response = await authFetch("/api/hub/hidden-models");
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ const data = (await response.json()) as {
+ needles?: unknown;
+ exact_ids?: unknown;
+ exact_paths?: unknown;
+ };
+ if (
+ getInventoryVersion() !== version ||
+ matchersFetchVersion !== version
+ ) {
+ return;
+ }
+ dynamicNeedles = toLowerStrings(data.needles);
+ dynamicExactIds = toLowerStrings(data.exact_ids);
+ dynamicExactPaths = toLowerStrings(data.exact_paths);
+ } catch {
+ if (
+ getInventoryVersion() === version &&
+ matchersFetchVersion === version
+ ) {
+ matchersFetch = null;
+ }
+ }
+ })();
+ return matchersFetch;
+}
+
/** True if any id/path is a hidden infra model. */
export function isHiddenModelId(
...values: (string | null | undefined)[]
@@ -23,7 +81,12 @@ export function isHiddenModelId(
return false;
}
const lower = v.toLowerCase();
- return HIDDEN_NEEDLES.some((needle) => lower.includes(needle));
+ return (
+ HIDDEN_NEEDLES.some((needle) => lower.includes(needle)) ||
+ dynamicNeedles.some((needle) => lower.includes(needle)) ||
+ dynamicExactIds.includes(lower) ||
+ dynamicExactPaths.includes(lower)
+ );
});
}
diff --git a/studio/frontend/src/features/hub/lib/model-capabilities.ts b/studio/frontend/src/features/hub/lib/model-capabilities.ts
index 033a3b6f1f..1baa3fb8e0 100644
--- a/studio/frontend/src/features/hub/lib/model-capabilities.ts
+++ b/studio/frontend/src/features/hub/lib/model-capabilities.ts
@@ -10,6 +10,7 @@ export type CapabilityKey =
| "reasoning"
| "code"
| "embedding"
+ | "diffusion"
| "multilingual"
| "conversational";
@@ -62,6 +63,20 @@ const REASONING_TAGS = new Set([
"step-by-step",
]);
+// Image generation / diffusion (surfaced as "Image generation" in filters).
+const DIFFUSION_TAGS = new Set([
+ "diffusers",
+ "diffusion",
+ "stable-diffusion",
+ "latent-diffusion",
+ "flux",
+ "text-to-image",
+ "image-to-image",
+ "text-to-video",
+ "image-to-video",
+ "unconditional-image-generation",
+]);
+
const CODE_TAGS = new Set([
"code",
"code-generation",
@@ -186,10 +201,7 @@ export function detectCapabilities(
) {
out.push({ key: "code", label: "Code" });
}
- if (
- hasAny(CONVERSATIONAL_TAGS) ||
- CONVERSATIONAL_ID_RE.test(lowerId)
- ) {
+ if (hasAny(CONVERSATIONAL_TAGS) || CONVERSATIONAL_ID_RE.test(lowerId)) {
out.push({ key: "conversational", label: "Conversational" });
}
if (
@@ -200,6 +212,14 @@ export function detectCapabilities(
) {
out.push({ key: "embedding", label: "Embeddings" });
}
+ if (
+ hasAny(DIFFUSION_TAGS) ||
+ /stable[-_]?diffusion|\bsdxl\b|\bflux\b|qwen[-_]?image|hunyuan[-_]?(?:video|image)|wan2|latent[-_]?consistency|[-_]lcm\b|dreamshaper/.test(
+ lowerId,
+ )
+ ) {
+ out.push({ key: "diffusion", label: "Image generation" });
+ }
const languageCodes = new Set();
for (const tag of tags ?? []) {
const lower = tag.toLowerCase();
diff --git a/studio/frontend/src/features/hub/lib/model-type-filter.ts b/studio/frontend/src/features/hub/lib/model-type-filter.ts
new file mode 100644
index 0000000000..2a2e31437e
--- /dev/null
+++ b/studio/frontend/src/features/hub/lib/model-type-filter.ts
@@ -0,0 +1,52 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+// Type filter for the On Device list. Mirrors the hub Discover capability
+// options and shares its detection, so both dropdowns behave the same.
+
+import type {
+ CachedInventoryRow,
+ LocalInventoryRow,
+} from "@/features/hub/inventory/types";
+import { type CapabilityKey, detectCapabilities } from "./model-capabilities";
+
+export type ModelTypeFilter =
+ | "all"
+ | "reasoning"
+ | "vision"
+ | "audio"
+ | "embedding"
+ | "diffusion";
+
+export const MODEL_TYPE_FILTER_OPTIONS: ReadonlyArray<{
+ value: ModelTypeFilter;
+ label: string;
+}> = [
+ { value: "all", label: "All types" },
+ { value: "reasoning", label: "Reasoning" },
+ { value: "vision", label: "Vision" },
+ { value: "audio", label: "Audio" },
+ { value: "embedding", label: "Embeddings" },
+ { value: "diffusion", label: "Image generation" },
+];
+
+function rowName(row: CachedInventoryRow | LocalInventoryRow): string {
+ return row.kind === "local"
+ ? `${row.id} ${row.repoId ?? ""} ${row.title} ${row.modelId ?? ""}`
+ : `${row.id} ${row.repoId}`;
+}
+
+export function matchesModelType(
+ row: CachedInventoryRow | LocalInventoryRow,
+ filter: ModelTypeFilter,
+): boolean {
+ if (filter === "all") return true;
+ // Honor the row's own vision flag before falling back to tag detection.
+ if (filter === "vision" && row.capabilities.supportsVision) return true;
+ const caps = detectCapabilities(
+ row.tags ?? undefined,
+ row.pipelineTag ?? undefined,
+ rowName(row),
+ );
+ return caps.some((cap: { key: CapabilityKey }) => cap.key === filter);
+}
diff --git a/studio/frontend/src/features/hub/lib/view-models.ts b/studio/frontend/src/features/hub/lib/view-models.ts
index 9ee6c5de5d..d4efdb6a65 100644
--- a/studio/frontend/src/features/hub/lib/view-models.ts
+++ b/studio/frontend/src/features/hub/lib/view-models.ts
@@ -7,18 +7,18 @@ import type {
CachedInventoryRow,
LocalInventoryRow,
} from "@/features/hub/inventory/types";
+import { ownerOf, repoOf } from "@/features/hub/lib/format";
import type {
CapabilityFilter,
DiscoverRow,
ModelFormatFilter,
} from "../types";
+import { estimateSizeFromDtypes, isGgufLike } from "./hf-model-meta";
import {
+ type CapabilityKey,
detectBaseModel,
detectCapabilities,
- type CapabilityKey,
} from "./model-capabilities";
-import { ownerOf, repoOf } from "@/features/hub/lib/format";
-import { estimateSizeFromDtypes, isGgufLike } from "./hf-model-meta";
export {
detectResultFormat,
isUnslothFinetunable,
@@ -39,6 +39,7 @@ export const CAPABILITY_FILTER_OPTIONS: ReadonlyArray<{
{ value: "vision", label: "Vision" },
{ value: "audio", label: "Audio" },
{ value: "embedding", label: "Embeddings" },
+ { value: "diffusion", label: "Image generation" },
];
export const FORMAT_FILTER_OPTIONS: ReadonlyArray<{
diff --git a/studio/frontend/src/features/model-picker/api/model-metadata.ts b/studio/frontend/src/features/model-picker/api/model-metadata.ts
new file mode 100644
index 0000000000..098ab51271
--- /dev/null
+++ b/studio/frontend/src/features/model-picker/api/model-metadata.ts
@@ -0,0 +1,20 @@
+// 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 { getModelConfig } from "@/features/training";
+
+export async function fetchModelMaxPositionEmbeddings(
+ modelName: string,
+ hfToken?: string | null,
+ signal?: AbortSignal,
+): Promise {
+ const config = await getModelConfig(
+ modelName,
+ signal,
+ hfToken?.trim() || undefined,
+ );
+ const value = config.max_position_embeddings;
+ return typeof value === "number" && Number.isFinite(value) && value > 0
+ ? Math.floor(value)
+ : null;
+}
diff --git a/studio/frontend/src/features/model-picker/api/templates.ts b/studio/frontend/src/features/model-picker/api/templates.ts
new file mode 100644
index 0000000000..29f18cce29
--- /dev/null
+++ b/studio/frontend/src/features/model-picker/api/templates.ts
@@ -0,0 +1,87 @@
+// 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 { hubTokenHeader } from "@/features/hub";
+import { consumeNativePathToken } from "@/features/native-intents/api";
+import { readFastApiError } from "@/lib/format-fastapi-error";
+
+export interface ValidateChatTemplateResult {
+ valid: boolean;
+ error: string | null;
+}
+
+async function parseJsonOrThrow(response: Response): Promise {
+ if (!response.ok) {
+ throw new Error(await readFastApiError(response));
+ }
+ return response.json();
+}
+
+export async function validateChatTemplate(
+ template: string,
+ signal?: AbortSignal,
+): Promise {
+ const response = await authFetch("/api/picker/validate-chat-template", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ template }),
+ signal,
+ });
+ return parseJsonOrThrow(response);
+}
+
+export async function fetchDefaultChatTemplate(
+ modelName: string,
+ ggufVariant?: string | null,
+ hfToken?: string | null,
+ signal?: AbortSignal,
+ nativePathToken?: string | null,
+): Promise {
+ // A native (picked / drag-drop) GGUF lives at a path only its signed lease
+ // knows, and the picker chat-template GET has no lease plumbing, so redeem a
+ // one-shot validate-model lease and read the embedded template through the
+ // lease-aware /api/inference/validate probe instead (mirrors the staged
+ // header-dims fetch). Non-native models keep the plain GET path.
+ if (nativePathToken) {
+ let nativePathLease: string | null = null;
+ try {
+ nativePathLease = (
+ await consumeNativePathToken(nativePathToken, "validate-model")
+ ).nativePathLease;
+ } catch {
+ // Lease expired / revoked: no readable path, so no default template (the
+ // subsequent load re-mints its own lease).
+ return null;
+ }
+ const response = await authFetch("/api/inference/validate", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ model_path: modelName,
+ gguf_variant: ggufVariant ?? null,
+ hf_token: hfToken ?? null,
+ native_path_lease: nativePathLease,
+ include_chat_template: true,
+ }),
+ signal,
+ });
+ const data = await parseJsonOrThrow<{ chat_template?: string | null }>(
+ response,
+ );
+ return data.chat_template ?? null;
+ }
+
+ const query = ggufVariant
+ ? `?gguf_variant=${encodeURIComponent(ggufVariant)}`
+ : "";
+ const response = await authFetch(
+ `/api/picker/chat-template/${encodeURIComponent(modelName)}${query}`,
+ { headers: hubTokenHeader(hfToken), signal },
+ );
+ const data = await parseJsonOrThrow<{
+ model_name: string;
+ chat_template: string | null;
+ }>(response);
+ return data.chat_template ?? null;
+}
diff --git a/studio/frontend/src/features/model-picker/components/chat-template-editor-dialog.tsx b/studio/frontend/src/features/model-picker/components/chat-template-editor-dialog.tsx
new file mode 100644
index 0000000000..65e5a2026d
--- /dev/null
+++ b/studio/frontend/src/features/model-picker/components/chat-template-editor-dialog.tsx
@@ -0,0 +1,191 @@
+// 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 { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Spinner } from "@/components/ui/spinner";
+import { Textarea } from "@/components/ui/textarea";
+import { useRef, useState } from "react";
+import { validateChatTemplate } from "../api/templates";
+import {
+ MAX_CHAT_TEMPLATE_BYTES,
+ chatTemplateByteLength,
+ isChatTemplateWithinLimit,
+} from "../model-config/per-model-config";
+
+interface ChatTemplateEditorDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ value: string | null;
+ defaultTemplate: string | null;
+ defaultLoading: boolean;
+ onSave: (override: string | null) => void;
+ readOnly?: boolean;
+}
+
+export function ChatTemplateEditorDialog({
+ open,
+ onOpenChange,
+ value,
+ defaultTemplate,
+ defaultLoading,
+ onSave,
+ readOnly = false,
+}: ChatTemplateEditorDialogProps) {
+ const [draft, setDraft] = useState(null);
+ const [error, setError] = useState(null);
+ const [validating, setValidating] = useState(false);
+ // Bumped whenever the dialog closes so a validation still in flight cannot
+ // apply a template the user has already dismissed.
+ const validationToken = useRef(0);
+ const renderedDraft = draft ?? value ?? defaultTemplate ?? "";
+
+ const byteLength = chatTemplateByteLength(renderedDraft);
+ const overLimit = !isChatTemplateWithinLimit(renderedDraft);
+ const matchesDefault =
+ defaultTemplate != null && renderedDraft === defaultTemplate;
+
+ const handleClose = () => {
+ validationToken.current += 1;
+ setDraft(null);
+ setError(null);
+ setValidating(false);
+ onOpenChange(false);
+ };
+
+ const handleSave = async () => {
+ if (renderedDraft.trim().length === 0 || matchesDefault) {
+ onSave(null);
+ handleClose();
+ return;
+ }
+ if (overLimit) {
+ setError("Template exceeds the size limit.");
+ return;
+ }
+ setValidating(true);
+ const token = validationToken.current;
+ try {
+ const result = await validateChatTemplate(renderedDraft);
+ // Dialog was closed (or reopened) while validating; drop the result so a
+ // discarded template is never applied.
+ if (token !== validationToken.current) {
+ return;
+ }
+ if (!result.valid) {
+ setError(result.error ?? "Invalid Jinja template.");
+ return;
+ }
+ onSave(renderedDraft);
+ handleClose();
+ } catch {
+ if (token === validationToken.current) {
+ setError("Could not validate the template.");
+ }
+ } finally {
+ if (token === validationToken.current) {
+ setValidating(false);
+ }
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/studio/frontend/src/features/model-picker/components/model-config-page.tsx b/studio/frontend/src/features/model-picker/components/model-config-page.tsx
new file mode 100644
index 0000000000..9c1bf093c1
--- /dev/null
+++ b/studio/frontend/src/features/model-picker/components/model-config-page.tsx
@@ -0,0 +1,991 @@
+// 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 { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { InfoHint } from "@/components/ui/info-hint";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Slider } from "@/components/ui/slider";
+import { Switch } from "@/components/ui/switch";
+import {
+ GPU_LAYERS_AUTO,
+ fetchGgufStagedMetadata,
+ readPersistedSpeculativeType,
+ useChatRuntimeStore,
+} from "@/features/chat";
+import { useGpuDevices } from "@/hooks/use-gpu-info";
+import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
+import { toast } from "@/lib/toast";
+import { ArrowLeft01Icon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { type ReactNode, useEffect, useId, useState } from "react";
+import {
+ useDefaultChatTemplate,
+ useModelMaxPositionEmbeddings,
+} from "../hooks/use-model-defaults";
+import { perModelConfigsEqual } from "../model-config/apply-per-model-config";
+import {
+ CONTEXT_LENGTH_MIN,
+ DEFAULT_MAX_SEQ_LENGTH,
+ 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,
+ floorMaxSeqLength,
+ isDefaultConfig,
+ normalizeMaxSeqLength,
+ resolveInitialConfig,
+ savePerModelConfig,
+} from "../model-config/per-model-config";
+import { ChatTemplateEditorDialog } from "./chat-template-editor-dialog";
+import type { ModelPickTarget } from "./model-selector/types";
+import { NumericValueInput } from "./numeric-value-input";
+
+const ROW_CLASS = "flex min-h-8 items-center justify-between gap-3";
+const LABEL_CLASS =
+ "min-w-0 truncate text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg";
+const LABEL_CLASS_WRAP =
+ "min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg";
+const CONTROL_SURFACE =
+ "rounded-full border-transparent bg-black/[0.04] dark:bg-white/[0.05] hover:bg-black/[0.06] dark:hover:bg-white/[0.1]";
+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 ||
+ (config.speculativeType ?? "auto") !== "auto" ||
+ config.specDraftNMax != null ||
+ config.tensorParallel ||
+ config.chatTemplateOverride != null ||
+ (config.gpuMemoryMode ?? "auto") !== "auto" ||
+ (config.gpuLayers != null && config.gpuLayers >= 0) ||
+ (config.nCpuMoe ?? 0) > 0 ||
+ config.selectedGpuIds != null
+ );
+}
+
+function ChatTemplateSetting({
+ config,
+ onEditTemplate,
+ readOnly = false,
+}: {
+ config: PerModelConfig;
+ onEditTemplate: () => void;
+ readOnly?: boolean;
+}) {
+ return (
+
+
+ Chat Template
+
+ {readOnly
+ ? "Preview the model's chat template. Custom overrides apply to GGUF models for now."
+ : "Override the model's chat template with custom Jinja. Applies when the model loads."}
+
+
+
+ {readOnly ? null : (
+
+ {config.chatTemplateOverride ? "Custom" : "Default"}
+
+ )}
+
+ {readOnly ? "View" : "Edit"}
+
+
+
+ );
+}
+
+function MaxSeqLengthSetting({
+ value,
+ max,
+ inputMax,
+ onChange,
+}: {
+ value: number;
+ max: number;
+ inputMax: number;
+ onChange: (value: number) => void;
+}) {
+ return (
+
+
+
+ Max Seq Length
+
+ Maximum context window size in tokens. Applies when the model loads.
+
+
+
+
+
onChange(next)}
+ className="panel-slider"
+ aria-label="Max Seq Length"
+ />
+
+ );
+}
+
+function clampMaxSeqLength(value: number, max: number): number {
+ const normalized = normalizeMaxSeqLength(value) ?? MAX_SEQ_LENGTH_MIN;
+ return Math.max(MAX_SEQ_LENGTH_MIN, Math.min(max, normalized));
+}
+
+function AdvancedGpuSlider({
+ label,
+ value,
+ min,
+ max,
+ onChange,
+ displayValue,
+ info,
+}: {
+ label: string;
+ value: number;
+ min: number;
+ max: number;
+ onChange: (value: number) => void;
+ displayValue?: string;
+ info?: ReactNode;
+}) {
+ return (
+
+
+
+ {label}
+ {info && {info}}
+
+
+
+
onChange(next)}
+ className="panel-slider"
+ aria-label={label}
+ />
+
+ );
+}
+
+// GPU Memory placement controls (mode / GPU Layers / MoE offload / GPU picker),
+// GGUF only. Slider ceilings come from the GGUF header dims, the picker from the
+// live device list. --tensor-split is not persisted per model, so not exposed here.
+function GpuMemorySettings({
+ config,
+ update,
+ layerCount,
+ moeLayerCount,
+}: {
+ config: PerModelConfig;
+ update: (patch: Partial) => void;
+ layerCount: number | null;
+ moeLayerCount: number | null;
+}) {
+ const gpuDevices = useGpuDevices();
+ const mode = config.gpuMemoryMode ?? "auto";
+ const isManual = mode === "manual";
+ const gpuLayers = config.gpuLayers ?? GPU_LAYERS_AUTO;
+ // Slider at Auto: llama.cpp --fit owns the layout, so MoE-offload doesn't apply.
+ const autoLayers = isManual && gpuLayers < 0;
+ // Ceiling = layer count + 1 (llama.cpp counts the output layer as offloadable),
+ // else a safe fallback.
+ const gpuLayersMax = layerCount != null ? layerCount + 1 : 256;
+ const nCpuMoe = config.nCpuMoe ?? 0;
+ const moeLayersMax = moeLayerCount ?? 0;
+ const showMoeSlider = isManual && !autoLayers && moeLayersMax > 0;
+ const selectedGpuIds = config.selectedGpuIds ?? null;
+ const singleGpuInUse =
+ (selectedGpuIds ?? gpuDevices.map((device) => device.index)).length <= 1;
+ // Multi-GPU only, and only with physical indices (relative ordinals from a
+ // CUDA_VISIBLE_DEVICES mask can't be mapped back to pin a device). null = all (auto).
+ const showGpuPicker =
+ gpuDevices.length > 1 && gpuDevices.every((d) => d.physicalIndex);
+ const isGpuChecked = (index: number) =>
+ selectedGpuIds === null || selectedGpuIds.includes(index);
+ const toggleGpu = (index: number) => {
+ const all = gpuDevices.map((d) => d.index);
+ const current = selectedGpuIds ?? all;
+ const next = current.includes(index)
+ ? current.filter((i) => i !== index)
+ : [...current, index].sort((a, b) => a - b);
+ if (next.length === 0) return; // keep at least one GPU selected
+ update({ selectedGpuIds: next.length === all.length ? null : next });
+ };
+ return (
+ <>
+
+
+
GPU Memory
+
+
+
+ Default: Unsloth fits the
+ model and context to your GPUs.
+
+
+ Manual: set GPU Layers
+ yourself. Leave it on Auto to let llama.cpp size the context and
+ offload overflow (including MoE experts) to RAM.
+
+
+
+
+
+
+ {isManual && (
+ <>
+ update({ gpuLayers: v })}
+ displayValue={autoLayers ? "Auto" : undefined}
+ info={
+ <>
+ Layers to keep on the GPU (--gpu-layers); the rest run on CPU.
+ Auto lets llama.cpp size the split (and the context) to fit VRAM.
+ At the maximum, the whole model is on the GPU.
+ >
+ }
+ />
+ {showMoeSlider && (
+ update({ nCpuMoe: v })}
+ info={
+ <>
+ Keep the experts of this many MoE layers on the CPU
+ (--n-cpu-moe) to save VRAM. 0 = all experts on the GPU; at the
+ maximum, all are on the CPU.
+ >
+ }
+ />
+ )}
+ >
+ )}
+ {showGpuPicker && (
+
+
+ GPUs
+
+ Which GPUs this model may use. Unchecked GPUs are hidden from
+ llama.cpp (CUDA_VISIBLE_DEVICES, or HIP_VISIBLE_DEVICES on ROCm).
+ Leave all checked to use every GPU. At least one GPU must stay
+ selected.
+
+
+
+ {gpuDevices.map((d) => (
+
+
+ GPU {d.index}: {d.name}
+ {d.memoryTotalGb
+ ? ` · ${Math.round(d.memoryTotalGb)} GB`
+ : ""}
+
+ toggleGpu(d.index)}
+ disabled={isGpuChecked(d.index) && singleGpuInUse}
+ />
+
+ ))}
+
+
+ )}
+ >
+ );
+}
+
+function GgufAdvancedSettings({
+ config,
+ update,
+ isMtp,
+ speculativeFallback,
+ onEditTemplate,
+ layerCount,
+ moeLayerCount,
+}: {
+ config: PerModelConfig;
+ update: (patch: Partial) => void;
+ isMtp: boolean;
+ speculativeFallback: string;
+ onEditTemplate: () => void;
+ layerCount: number | null;
+ moeLayerCount: number | null;
+}) {
+ return (
+ <>
+
+
+ KV Cache Dtype
+
+ Lower KV cache precision to save VRAM at the cost of some quality.
+ f16/bf16 are full precision; q8_0/q5_1/q4_1 are quantized.
+
+
+
+
+
+
+
+ Speculative Decoding
+
+ Faster generation with no accuracy hit. Auto picks MTP / ngram based
+ on the model and platform. Pick a strategy to force it.
+
+
+
+
+
+ {isMtp && (
+
+
+ Draft Tokens
+
+ Max MTP draft tokens per step. Leave blank for the platform
+ default (2 on GPU, 3 on CPU/Mac).
+
+
+
{
+ const raw = event.target.value;
+ if (raw === "") {
+ update({ specDraftNMax: null });
+ return;
+ }
+ const parsed = Number.parseInt(raw, 10);
+ if (Number.isFinite(parsed)) {
+ update({ specDraftNMax: Math.max(1, Math.min(16, parsed)) });
+ }
+ }}
+ aria-label="Speculative decoding draft tokens"
+ className={NUMBER_INPUT_CLASS}
+ />
+
+ )}
+
+
+
+ Tensor Parallelism
+
+ No effect on a single GPU. On multi-GPU setups, improves tokens/sec
+ for dense models. MoE models don't benefit.
+
+
+
update({ tensorParallel: checked })}
+ />
+
+
+
+
+
+ >
+ );
+}
+
+interface ModelConfigPageProps {
+ target: ModelPickTarget;
+ onBack?: () => void;
+ onRun: (config: PerModelConfig) => void;
+ loadedConfig?: PerModelConfig | null;
+ loadedContextLength?: number | null;
+ initialConfig?: PerModelConfig | null;
+ variant?: "page" | "sidebar";
+}
+
+export function ModelConfigPage({
+ target,
+ onBack,
+ onRun,
+ loadedConfig = null,
+ loadedContextLength = null,
+ initialConfig = null,
+ variant = "page",
+}: ModelConfigPageProps) {
+ const rememberId = useId();
+ const isActiveModel = loadedConfig != null;
+ const hfToken = useChatRuntimeStore((s) => s.hfToken);
+ const activeNativePathToken = useChatRuntimeStore(
+ (s) => s.activeNativePathToken,
+ );
+ const loadedDefaultChatTemplate = useChatRuntimeStore(
+ (s) => s.defaultChatTemplate,
+ );
+ const loadedMaxContextLength = useChatRuntimeStore(
+ (s) => s.ggufMaxContextLength,
+ );
+ const resolveInitial = () => {
+ const resolved = resolveInitialConfig(target.id, target.ggufVariant);
+ if (loadedConfig) {
+ return { config: loadedConfig, remembered: resolved.remembered };
+ }
+ if (initialConfig) {
+ return {
+ config: initialConfig,
+ remembered:
+ resolved.remembered &&
+ perModelConfigsEqual(initialConfig, resolved.config),
+ };
+ }
+ return resolved;
+ };
+ const [initial] = useState(resolveInitial);
+ const [config, setConfig] = useState(() => initial.config);
+ const [remember, setRemember] = useState(() => initial.remembered);
+ const [savedRemember, setSavedRemember] = useState(() => initial.remembered);
+ const [speculativeFallback] = useState(readPersistedSpeculativeType);
+ const [templateOpen, setTemplateOpen] = useState(false);
+ const [showAdvanced, setShowAdvanced] = useState(() =>
+ hasNonDefaultAdvanced(config),
+ );
+ const nativePathToken =
+ target.meta.nativePathToken ??
+ (isActiveModel ? activeNativePathToken : null);
+ const templateDefaults = useDefaultChatTemplate(
+ target.id,
+ target.ggufVariant,
+ templateOpen,
+ nativePathToken,
+ );
+ const modelMaxPosition = useModelMaxPositionEmbeddings(
+ 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) =>
+ setConfig((current) => ({ ...current, ...patch }));
+
+ // Fetch GGUF header dims (context + layer/MoE counts) to size the GPU Memory
+ // sliders; the context also fills in below when target.meta lacks it.
+ const contextFetchKey = target.isGguf
+ ? `${target.id}\n${target.ggufVariant ?? ""}\n${hfToken || ""}\n${nativePathToken ?? ""}`
+ : null;
+ const [fetchedStagedDims, setFetchedStagedDims] = useState<{
+ key: string;
+ contextLength: number | null;
+ layerCount: number | null;
+ moeLayerCount: number | null;
+ } | null>(null);
+ useEffect(() => {
+ if (contextFetchKey == null) {
+ return;
+ }
+ let cancelled = false;
+ void fetchGgufStagedMetadata({
+ model_path: target.id,
+ gguf_variant: target.ggufVariant ?? null,
+ hf_token: hfToken || null,
+ nativePathToken,
+ })
+ .then((dims) => {
+ if (!cancelled) {
+ setFetchedStagedDims({ key: contextFetchKey, ...dims });
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setFetchedStagedDims({
+ key: contextFetchKey,
+ contextLength: null,
+ layerCount: null,
+ moeLayerCount: null,
+ });
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [
+ contextFetchKey,
+ target.id,
+ target.ggufVariant,
+ hfToken,
+ nativePathToken,
+ ]);
+ const stagedDims =
+ fetchedStagedDims?.key === contextFetchKey ? fetchedStagedDims : null;
+
+ const isMtp =
+ config.speculativeType != null &&
+ MTP_SPECULATIVE_TYPES.has(config.speculativeType);
+ const nativeContextLength =
+ target.meta.contextLength ?? stagedDims?.contextLength ?? null;
+ const activeLoadedContext =
+ isActiveModel && target.isGguf ? loadedContextLength : null;
+ const minContext = CONTEXT_LENGTH_MIN;
+ const maxContext = Math.max(
+ minContext,
+ Math.max(
+ nativeContextLength ?? 0,
+ activeLoadedContext ?? 0,
+ config.customContextLength ?? 0,
+ ) || 32768,
+ );
+ const contextValue = Math.min(
+ Math.max(
+ config.customContextLength ??
+ activeLoadedContext ??
+ nativeContextLength ??
+ maxContext,
+ minContext,
+ ),
+ maxContext,
+ );
+ const setContextLength = (v: number) =>
+ update({ customContextLength: v });
+ const baseline = loadedConfig ?? DEFAULT_PER_MODEL_CONFIG;
+ const atBaseline = perModelConfigsEqual(config, baseline);
+ // An explicit customContextLength equal to the native ceiling is still an
+ // override (Reset stays enabled). "At default" means no override at all AND the
+ // shown context matches native (or no native context length is exposed).
+ const contextAtDefault =
+ !target.isGguf ||
+ (config.customContextLength == null &&
+ (nativeContextLength == null || contextValue === nativeContextLength));
+ const atDefault =
+ contextAtDefault &&
+ perModelConfigsEqual(
+ { ...config, customContextLength: null },
+ DEFAULT_PER_MODEL_CONFIG,
+ );
+ const nativeMaxSeqLength =
+ floorMaxSeqLength(modelMaxPosition.maxPositionEmbeddings) ??
+ MAX_SEQ_LENGTH_MAX;
+ // A non-GGUF active model seeds maxSeqLength from its loaded value. Once cleared
+ // (Reset sets null), fall back to the app default, not the loaded runtime value,
+ // else a remembered/active override can never be cleared.
+ const maxSeqLengthValue =
+ normalizeMaxSeqLength(config.maxSeqLength) ??
+ clampMaxSeqLength(DEFAULT_MAX_SEQ_LENGTH, nativeMaxSeqLength);
+ const maxSeqLengthMax = Math.max(nativeMaxSeqLength, maxSeqLengthValue);
+ // An auto-fit-below-native GGUF shows activeLoadedContext while
+ // customContextLength stays null. If the user fixes GPU Layers (Manual) and
+ // remembers, pin that shown context so a later fresh load keeps the fitted
+ // placement instead of sending native/0 for fixed layers and recreating the OOM.
+ const pinFixedLayerContext =
+ target.isGguf &&
+ config.gpuMemoryMode === "manual" &&
+ config.gpuLayers != null &&
+ config.gpuLayers >= 0 &&
+ config.customContextLength == null &&
+ activeLoadedContext != null;
+ // Persisted record: keep config as-is (non-GGUF keeps maxSeqLength null) so
+ // isDefaultConfig recognises it and clears a remembered override instead of
+ // pinning the app default.
+ const runtimeConfig = target.isGguf
+ ? pinFixedLayerContext
+ ? { ...config, customContextLength: activeLoadedContext }
+ : config
+ : config;
+ // Load request needs a concrete max length; substitute the fallback here only,
+ // never in the persisted runtimeConfig.
+ const loadConfig = target.isGguf
+ ? runtimeConfig
+ : { ...runtimeConfig, maxSeqLength: maxSeqLengthValue };
+ const rememberChanged = remember !== savedRemember;
+ const persistenceOnly = isActiveModel && atBaseline && rememberChanged;
+ const primaryActionLabel = persistenceOnly
+ ? remember
+ ? "Save settings"
+ : "Forget settings"
+ : isActiveModel
+ ? "Reload model"
+ : "Load model";
+
+ const handleRun = () => {
+ const defaultConfig = isDefaultConfig(runtimeConfig);
+ let saveFailed = false;
+ if (remember) {
+ saveFailed = !savePerModelConfig(
+ target.id,
+ target.ggufVariant,
+ runtimeConfig,
+ );
+ } else {
+ saveFailed = !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);
+ toast.success(
+ nextRemember
+ ? "Settings saved."
+ : remember
+ ? "Default settings kept."
+ : "Settings forgotten.",
+ );
+ return;
+ }
+ if (saveFailed) {
+ toast.error("Couldn't save these settings, loading with them anyway.");
+ }
+ onRun(loadConfig);
+ };
+
+ return (
+
+ {variant === "page" && (
+
+ {onBack && (
+
+
+
+ )}
+
+
+ Run settings
+
+
+ {target.displayName}
+
+
+
+ )}
+
+
+ {target.isGguf && (
+ <>
+
+
+
+ Context Length
+
+ Tokens of context to allocate. Higher uses more VRAM.
+ {nativeContextLength != null
+ ? ` This model's native context is ${nativeContextLength.toLocaleString()} tokens.`
+ : ""}
+
+
+
+
+ {nativeContextLength != null ? (
+
setContextLength(v)}
+ className="panel-slider"
+ aria-label="Context Length"
+ />
+ ) : null}
+ {isActiveModel &&
+ loadedMaxContextLength != null &&
+ contextValue > loadedMaxContextLength && (
+
+ Exceeds estimated VRAM capacity (
+ {loadedMaxContextLength.toLocaleString()} tokens). The model
+ may use system RAM.
+
+ )}
+
+
+ {showAdvanced && (
+
setTemplateOpen(true)}
+ layerCount={stagedDims?.layerCount ?? null}
+ moeLayerCount={stagedDims?.moeLayerCount ?? null}
+ />
+ )}
+
+
+
+
+ Advanced settings
+
+
+ Extra options for how the model loads. Most setups don't need
+ these.
+
+
+
+
+ >
+ )}
+ {!target.isGguf && (
+ <>
+
+ update({
+ maxSeqLength: clampMaxSeqLength(value, MAX_SEQ_LENGTH_MAX),
+ })
+ }
+ />
+ setTemplateOpen(true)}
+ readOnly={true}
+ />
+ >
+ )}
+
+
+
+
+ setRemember(checked === true)}
+ />
+
+
+
+ setConfig({ ...DEFAULT_PER_MODEL_CONFIG })}
+ >
+ Reset
+
+
+ {primaryActionLabel}
+
+
+
+
+
update({ chatTemplateOverride: override })}
+ />
+
+ );
+}
diff --git a/studio/frontend/src/components/assistant-ui/model-selector.tsx b/studio/frontend/src/features/model-picker/components/model-selector.tsx
similarity index 83%
rename from studio/frontend/src/components/assistant-ui/model-selector.tsx
rename to studio/frontend/src/features/model-picker/components/model-selector.tsx
index 6bfd1276ac..1cbce297dd 100644
--- a/studio/frontend/src/components/assistant-ui/model-selector.tsx
+++ b/studio/frontend/src/features/model-picker/components/model-selector.tsx
@@ -3,6 +3,7 @@
"use client";
+import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
@@ -10,7 +11,7 @@ import {
} from "@/components/ui/popover";
import { TooltipProvider } from "@/components/ui/tooltip";
import { usePlatformStore } from "@/config/env";
-import { isCustomProviderType } from "@/features/chat/external-providers";
+import { isCustomProviderType } from "@/features/chat";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { cn } from "@/lib/utils";
import {
@@ -33,7 +34,11 @@ import {
useRef,
useState,
} from "react";
-import { Input } from "../ui/input";
+import {
+ type PerModelConfig,
+ resolveInitialConfig,
+} from "../model-config/per-model-config";
+import { ModelConfigPage } from "./model-config-page";
import { HubModelPicker, hasDownloadedModels } from "./model-selector/pickers";
import { PillTabs } from "./model-selector/pill-tabs";
import {
@@ -45,6 +50,7 @@ import type {
ExternalModelOption,
LoraModelOption,
ModelOption,
+ ModelPickTarget,
ModelSelectorChangeMeta,
} from "./model-selector/types";
@@ -122,6 +128,10 @@ interface ModelSelectorProps {
value?: string;
defaultValue?: string;
activeGgufVariant?: string | null;
+ activeModelConfig?: PerModelConfig | null;
+ activeGgufContextLength?: number | null;
+ selectedConfig?: PerModelConfig | null;
+ selectedGgufVariant?: string | null;
onValueChange?: (value: string, meta: ModelSelectorChangeMeta) => void;
onEject?: () => void;
onFoldersChange?: () => void;
@@ -183,15 +193,12 @@ function ModelSelectorTrigger({
>
{isLoaded &&
(onEject ? (
- // Loaded status doubles as a mouse eject shortcut: green checkmark
- // at rest, red eject icon on pill hover, click to eject. A plain
- // span (no role/tabIndex) keeps it out of the trigger button's
- // content model, which forbids focusable descendants. Keyboard and
- // screen-reader users eject via the picker's "Eject model" button.
- // aria-hidden marks it decorative; stopPropagation stops the
- // popover from toggling. On touch (no hover) the eject icon and
- // tooltip never reveal, so pointer-events-none disables the
- // shortcut there and taps open the picker instead of ejecting.
+ // Loaded status doubles as a mouse eject shortcut (checkmark at rest,
+ // eject icon on hover). A plain span keeps it out of the trigger
+ // button's content model (no focusable descendants); keyboard/SR users
+ // eject via the "Eject model" button. aria-hidden marks it decorative;
+ // stopPropagation stops the popover toggling. On touch (no hover)
+ // pointer-events-none disables it so taps open the picker instead.
void;
onEject?: () => void;
onFoldersChange?: () => void;
@@ -337,8 +355,7 @@ function ModelSelectorContent({
const chatOnly = usePlatformStore((s) => s.isChatOnly());
const hasExternal = externalModels.length > 0;
// The Fine-tuned tab is for fine-tuned models only. Local models (LM Studio,
- // Ollama, custom folders) carry source "local" and live in the Hub tab's
- // Downloaded / Custom sections instead.
+ // Ollama, custom folders) carry source "local" and live in the Hub tab instead.
const fineTunedModels = useMemo(
() => loraModels.filter((model) => isFineTunedSource(model.source)),
[loraModels],
@@ -391,9 +408,12 @@ function ModelSelectorContent({
const effectiveHubSection: HubSection =
hubSection === "connected" && !hasExternal ? "recommended" : hubSection;
- // The picker below remounts on each open, but this tab state does not, so a
- // persisted selection that lands in lora/external after async load would
- // reopen on Hub. Re-derive the default tab on the open edge.
+ const [configTarget, setConfigTarget] = useState(
+ null,
+ );
+
+ // The picker remounts on each open but this tab state does not, so re-derive
+ // the default tab on the open edge (else a lora/external selection reopens on Hub).
const wasOpen = useRef(open);
useEffect(() => {
if (open && !wasOpen.current) {
@@ -402,6 +422,9 @@ function ModelSelectorContent({
// user has downloads, else their last section.
setHubSection(wantsConnectedDefault ? "connected" : defaultHubSection());
}
+ if (!open && wasOpen.current) {
+ setConfigTarget(null);
+ }
wasOpen.current = open;
}, [
open,
@@ -452,6 +475,29 @@ function ModelSelectorContent({
}
}
+ const visibleConfigTarget = open ? configTarget : null;
+ const openConfigPage = (id: string, meta: ModelSelectorChangeMeta) => {
+ const leaf = id.includes("/") ? id.slice(id.lastIndexOf("/") + 1) : id;
+ setConfigTarget({
+ id,
+ displayName: meta.ggufVariant ? `${leaf} · ${meta.ggufVariant}` : leaf,
+ ggufVariant: meta.ggufVariant ?? null,
+ isGguf: meta.isGguf ?? Boolean(meta.ggufVariant),
+ meta,
+ });
+ };
+ const handlePick = (id: string, meta: ModelSelectorChangeMeta) => {
+ if (meta.source === "external") {
+ onSelect(id, meta);
+ return;
+ }
+ const resolved = resolveInitialConfig(id, meta.ggufVariant);
+ onSelect(id, {
+ ...meta,
+ ...(resolved.remembered ? { config: resolved.config } : {}),
+ });
+ };
+
return (
@@ -477,6 +528,42 @@ function ModelSelectorContent({
skipDelayDuration={0}
disableHoverableContent={true}
>
+ {visibleConfigTarget ? (
+ setConfigTarget(null)}
+ onRun={(config) =>
+ onSelect(visibleConfigTarget.id, {
+ ...visibleConfigTarget.meta,
+ config,
+ forceReload: true,
+ })
+ }
+ loadedConfig={
+ value === visibleConfigTarget.id &&
+ (activeGgufVariant ?? null) ===
+ (visibleConfigTarget.ggufVariant ?? null)
+ ? (activeModelConfig ?? null)
+ : null
+ }
+ loadedContextLength={
+ value === visibleConfigTarget.id &&
+ (activeGgufVariant ?? null) ===
+ (visibleConfigTarget.ggufVariant ?? null)
+ ? (activeGgufContextLength ?? null)
+ : null
+ }
+ initialConfig={
+ value === visibleConfigTarget.id &&
+ (selectedGgufVariant ?? null) ===
+ (visibleConfigTarget.ggufVariant ?? null)
+ ? (selectedConfig ?? null)
+ : null
+ }
+ />
+ ) : (
+ <>
{tabs.length > 1 ? (
) : null}
- {/* Hub renders Eject inline as the last list row; other tabs keep the
- footer button. */}
{effectiveTab !== "hub" && hasSelection && onEject ? (
-
+
) : null}
+ >
+ )}
);
@@ -565,6 +653,10 @@ export function ModelSelector({
value,
defaultValue,
activeGgufVariant,
+ activeModelConfig,
+ activeGgufContextLength,
+ selectedConfig,
+ selectedGgufVariant,
onValueChange,
onEject,
onFoldersChange,
@@ -693,6 +785,11 @@ export function ModelSelector({
loraModels={loraModels}
externalModels={externalModels}
value={selected}
+ activeGgufVariant={activeGgufVariant}
+ activeModelConfig={activeModelConfig}
+ activeGgufContextLength={activeGgufContextLength}
+ selectedConfig={selectedConfig}
+ selectedGgufVariant={selectedGgufVariant}
onSelect={handleSelect}
onEject={onEject ? handleEject : undefined}
onFoldersChange={onFoldersChange}
diff --git a/studio/frontend/src/components/assistant-ui/model-selector/folder-browser.tsx b/studio/frontend/src/features/model-picker/components/model-selector/folder-browser.tsx
similarity index 84%
rename from studio/frontend/src/components/assistant-ui/model-selector/folder-browser.tsx
rename to studio/frontend/src/features/model-picker/components/model-selector/folder-browser.tsx
index 16cc8a1956..6335721271 100644
--- a/studio/frontend/src/components/assistant-ui/model-selector/folder-browser.tsx
+++ b/studio/frontend/src/features/model-picker/components/model-selector/folder-browser.tsx
@@ -14,10 +14,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
-import {
- type BrowseFoldersResponse,
- browseFolders,
-} from "@/features/chat/api/chat-api";
+import { type BrowseFoldersResponse, browseFolders } from "@/features/chat";
import { ChevronUpStandardIcon } from "@/lib/chevron-icons";
import { cn } from "@/lib/utils";
import { Folder02Icon } from "@hugeicons/core-free-icons";
@@ -35,9 +32,9 @@ export interface FolderBrowserProps {
function splitBreadcrumb(path: string): { label: string; value: string }[] {
if (!path) return [];
- // Detect path style BEFORE normalizing: on POSIX, `\` is a valid filename
- // char, so blindly rewriting `\` -> `/` mangles names like `my\backup` into
- // 404ing breadcrumbs. Only Windows-style paths (drive letter, or UNC) convert.
+ // Detect path style BEFORE normalizing: on POSIX `\` is a valid filename char,
+ // so rewriting `\` -> `/` would mangle names like `my\backup`. Only Windows
+ // paths (drive letter or UNC) convert.
const isWindowsDrive =
/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path);
const isUnc = /^\\\\/.test(path);
@@ -58,9 +55,8 @@ function splitBreadcrumb(path: string): { label: string; value: string }[] {
return parts;
}
- // Windows drive path (C:, D:): first segment is the drive. Use `C:/` as the
- // crumb value so clicking the drive root navigates to the drive root, not the
- // drive-relative CWD (`C:` alone resolves to CWD-on-C, not `C:\`).
+ // Windows drive path: use `C:/` as the crumb value so clicking the drive root
+ // goes to the drive root, not the drive-relative CWD (`C:` alone is CWD-on-C).
if (/^[A-Za-z]:$/.test(segments[0])) {
const driveRoot = `${segments[0]}/`;
let cur = driveRoot;
@@ -90,47 +86,43 @@ export function FolderBrowser({
const [error, setError] = useState
(null);
const abortRef = useRef(null);
- const navigate = useCallback(
- (
- target: string | undefined,
- hidden: boolean,
- opts?: { fallbackOnError?: boolean },
- ) => {
- abortRef.current?.abort();
- const ctrl = new AbortController();
- abortRef.current = ctrl;
- setLoading(true);
- setError(null);
- // Forward the signal so cancelled navigation aborts the backend
- // enumeration, not just the response.
- browseFolders(target, hidden, ctrl.signal)
- .then((res) => {
- if (ctrl.signal.aborted) return;
- setData(res);
- setPath(res.current);
- })
- .catch((err) => {
- if (ctrl.signal.aborted) return;
- // Surface the error; if the first request (e.g. a bad initialPath)
- // fails, fall back to HOME so the modal stays navigable.
- const message = err instanceof Error ? err.message : String(err);
- setError(message);
- if (opts?.fallbackOnError && target !== undefined) {
- // Re-issue without a target -> backend defaults to HOME.
- // Don't recurse if HOME itself fails (allowlist always has HOME).
- queueMicrotask(() => navigate(undefined, hidden));
- }
- })
- .finally(() => {
- if (!ctrl.signal.aborted) setLoading(false);
- });
- },
- [],
- );
+ function navigate(
+ target: string | undefined,
+ hidden: boolean,
+ opts?: { fallbackOnError?: boolean },
+ ) {
+ abortRef.current?.abort();
+ const ctrl = new AbortController();
+ abortRef.current = ctrl;
+ setLoading(true);
+ setError(null);
+ // Forward the signal so cancelled navigation aborts the backend
+ // enumeration, not just the response.
+ browseFolders(target, hidden, ctrl.signal)
+ .then((res) => {
+ if (ctrl.signal.aborted) return;
+ setData(res);
+ setPath(res.current);
+ })
+ .catch((err) => {
+ if (ctrl.signal.aborted) return;
+ // Surface the error; if the first request (e.g. a bad initialPath)
+ // fails, fall back to HOME so the modal stays navigable.
+ const message = err instanceof Error ? err.message : String(err);
+ setError(message);
+ if (opts?.fallbackOnError && target !== undefined) {
+ // Re-issue without a target -> backend defaults to HOME.
+ // Don't recurse if HOME itself fails (allowlist always has HOME).
+ queueMicrotask(() => navigate(undefined, hidden));
+ }
+ })
+ .finally(() => {
+ if (!ctrl.signal.aborted) setLoading(false);
+ });
+ }
// Fetch only on closed -> open; later navigation is driven by `navigate()`,
// so `path` is deliberately kept out of the dependency list.
- // eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
if (!open) return;
// fallbackOnError: recover into HOME if initialPath is bad, rather than
@@ -147,7 +139,7 @@ export function FolderBrowser({
const crumbs = useMemo(
() => (data?.current ? splitBreadcrumb(data.current) : []),
- [data?.current],
+ [data],
);
return (
diff --git a/studio/frontend/src/components/assistant-ui/model-selector/model-capabilities.ts b/studio/frontend/src/features/model-picker/components/model-selector/model-capabilities.ts
similarity index 100%
rename from studio/frontend/src/components/assistant-ui/model-selector/model-capabilities.ts
rename to studio/frontend/src/features/model-picker/components/model-selector/model-capabilities.ts
diff --git a/studio/frontend/src/components/assistant-ui/model-selector/model-delete-action.tsx b/studio/frontend/src/features/model-picker/components/model-selector/model-delete-action.tsx
similarity index 90%
rename from studio/frontend/src/components/assistant-ui/model-selector/model-delete-action.tsx
rename to studio/frontend/src/features/model-picker/components/model-selector/model-delete-action.tsx
index 4de96d3648..09bb43abdc 100644
--- a/studio/frontend/src/components/assistant-ui/model-selector/model-delete-action.tsx
+++ b/studio/frontend/src/features/model-picker/components/model-selector/model-delete-action.tsx
@@ -1,12 +1,12 @@
// 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 { DeleteConfirmDialog } from "@/features/hub/catalog/download-card";
+import { DeleteConfirmDialog } from "@/features/hub";
+import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
import { Delete02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-import { useCallback, useState, type ReactNode } from "react";
-import { toast } from "@/lib/toast";
+import { type ReactNode, useCallback, useState } from "react";
interface ModelDeleteActionProps {
ariaLabel: string;
@@ -63,7 +63,8 @@ export function ModelDeleteAction({
disabled={disabled}
className={cn(
"shrink-0 rounded-md p-1.5 text-muted-foreground/60 transition-colors hover:bg-destructive/10 hover:text-destructive",
- disabled && "cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground/60",
+ disabled &&
+ "cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground/60",
buttonClassName,
)}
>
diff --git a/studio/frontend/src/components/assistant-ui/model-selector/model-load-settings-action.tsx b/studio/frontend/src/features/model-picker/components/model-selector/model-load-settings-action.tsx
similarity index 63%
rename from studio/frontend/src/components/assistant-ui/model-selector/model-load-settings-action.tsx
rename to studio/frontend/src/features/model-picker/components/model-selector/model-load-settings-action.tsx
index 58510762d4..bbef42063f 100644
--- a/studio/frontend/src/components/assistant-ui/model-selector/model-load-settings-action.tsx
+++ b/studio/frontend/src/features/model-picker/components/model-selector/model-load-settings-action.tsx
@@ -6,24 +6,18 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
-import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
import { cn } from "@/lib/utils";
import { Settings02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-/** Gear button on a downloaded quant row. Stages the model into the Run
- * settings sidebar (always, regardless of the Load-on-selection toggle) so the
- * user can set load options, then click Load model. */
export function ModelLoadSettingsAction({
ariaLabel,
- repoId,
- quant,
- maxContext,
+ onConfigure,
+ className,
}: {
ariaLabel: string;
- repoId: string;
- quant: string;
- maxContext?: number | null;
+ onConfigure: () => void;
+ className?: string;
}) {
return (
@@ -32,16 +26,12 @@ export function ModelLoadSettingsAction({
type="button"
onClick={(e) => {
e.stopPropagation();
- useChatRuntimeStore.getState().stageModel({
- id: repoId,
- ggufVariant: quant,
- isDownloaded: true,
- contextLength: maxContext ?? null,
- });
+ onConfigure();
}}
aria-label={ariaLabel}
className={cn(
- "shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground",
+ "shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10",
+ className,
)}
>
void;
+}
+
+interface ModelRowMenuUpdate {
+ title: string;
+ description: ReactNode;
+ /** Repo + variant the update targets (see ModelUpdateAction). */
+ repoId: string;
+ variant?: string | null;
+ disabled?: boolean;
+ onConfirm: () => Promise | void;
+ onUpdated?: () => void;
+}
+
+interface ModelRowMenuDelete {
+ title: string;
+ description: ReactNode;
+ successMessage: string;
+ disabled?: boolean;
+ onConfirm: () => Promise | void;
+ onDeleted?: () => void;
+}
+
+/** Managed-cache location for "Reveal in Finder" (resolved server-side). */
+interface ModelRowMenuCachePath {
+ repoId: string;
+ variant?: string;
+}
+
+export function ModelRowMenu({
+ ariaLabel,
+ buttonClassName,
+ iconClassName,
+ cachePath,
+ pin,
+ update,
+ del,
+}: {
+ ariaLabel: string;
+ buttonClassName?: string;
+ iconClassName?: string;
+ /** Enables "Reveal in Finder" for cached repos. */
+ cachePath?: ModelRowMenuCachePath;
+ pin?: ModelRowMenuPin;
+ update?: ModelRowMenuUpdate;
+ del?: ModelRowMenuDelete;
+}) {
+ const deviceType = usePlatformStore((s) => s.deviceType);
+ const revealLabel =
+ deviceType === "mac"
+ ? "Reveal in Finder"
+ : deviceType === "windows"
+ ? "Reveal in File Explorer"
+ : "Reveal in File Manager";
+ const [deleteOpen, setDeleteOpen] = useState(false);
+ const [deleting, setDeleting] = useState(false);
+ const [updateOpen, setUpdateOpen] = useState(false);
+
+ // Refresh the caller when this repo+variant's managed update completes
+ // (mirrors ModelUpdateAction).
+ const onUpdatedRef = useRef(update?.onUpdated);
+ useEffect(() => {
+ onUpdatedRef.current = update?.onUpdated;
+ }, [update?.onUpdated]);
+ const updateRepoId = update?.repoId;
+ const updateVariant = update?.variant ?? null;
+ useEffect(() => {
+ if (!updateRepoId) return;
+ return subscribeJobListeners("model", updateRepoId, {
+ onComplete: (completedVariant) => {
+ const matches = updateVariant
+ ? ggufVariantsMatch(completedVariant, updateVariant)
+ : !completedVariant;
+ if (matches) onUpdatedRef.current?.();
+ },
+ });
+ }, [updateRepoId, updateVariant]);
+
+ const onDeleteConfirm = del?.onConfirm;
+ const onDeleted = del?.onDeleted;
+ const deleteSuccessMessage = del?.successMessage;
+ const handleDeleteConfirm = useCallback(async () => {
+ if (!onDeleteConfirm) return;
+ setDeleting(true);
+ try {
+ await onDeleteConfirm();
+ if (deleteSuccessMessage) toast.success(deleteSuccessMessage);
+ onDeleted?.();
+ setDeleteOpen(false);
+ } catch (err) {
+ toast.error(
+ err instanceof Error ? err.message : "Failed to delete model",
+ );
+ } finally {
+ setDeleting(false);
+ }
+ }, [onDeleteConfirm, onDeleted, deleteSuccessMessage]);
+
+ const onUpdateConfirm = update?.onConfirm;
+ const handleUpdateConfirm = useCallback(() => {
+ // Start the re-download and close the dialog; the Downloads panel owns
+ // progress + cancel. Only a failure to START toasts.
+ void Promise.resolve()
+ .then(onUpdateConfirm)
+ .catch((err) => {
+ toast.error(
+ err instanceof Error ? err.message : "Failed to start update",
+ );
+ });
+ setUpdateOpen(false);
+ }, [onUpdateConfirm]);
+
+ const cachePathRepoId = cachePath?.repoId;
+ const cachePathVariant = cachePath?.variant;
+ const handleReveal = useCallback(() => {
+ if (!cachePathRepoId) return;
+ revealCachedModel(cachePathRepoId, cachePathVariant).catch((err) => {
+ toast.error(
+ err instanceof Error ? err.message : "Failed to open file manager",
+ );
+ });
+ }, [cachePathRepoId, cachePathVariant]);
+
+ if (!pin && !update && !del && !cachePath) return null;
+
+ return (
+ <>
+
+
+ e.stopPropagation()}
+ aria-label={ariaLabel}
+ className={cn(
+ "shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10",
+ buttonClassName,
+ )}
+ >
+
+
+
+
+ {pin && (
+ {
+ e.stopPropagation();
+ pin.onToggle();
+ }}
+ >
+
+ {pin.pinned ? pin.unpinLabel : pin.pinLabel}
+
+ )}
+ {cachePath && (
+ {
+ e.stopPropagation();
+ handleReveal();
+ }}
+ >
+
+ {revealLabel}
+
+ )}
+ {update && (
+ {
+ e.stopPropagation();
+ setUpdateOpen(true);
+ }}
+ >
+
+ Update
+
+ )}
+ {del && (
+ <>
+ {(cachePath || pin || update) && }
+ {
+ e.stopPropagation();
+ setDeleteOpen(true);
+ }}
+ >
+
+ Delete
+
+ >
+ )}
+
+
+
+ {del && (
+ {
+ if (!nextOpen && deleting) return;
+ setDeleteOpen(nextOpen);
+ }}
+ title={del.title}
+ description={del.description}
+ deleting={deleting}
+ onConfirm={() => void handleDeleteConfirm()}
+ />
+ )}
+
+ {update && (
+
+ )}
+ >
+ );
+}
diff --git a/studio/frontend/src/components/assistant-ui/model-selector/model-update-action.tsx b/studio/frontend/src/features/model-picker/components/model-selector/model-update-action.tsx
similarity index 82%
rename from studio/frontend/src/components/assistant-ui/model-selector/model-update-action.tsx
rename to studio/frontend/src/features/model-picker/components/model-selector/model-update-action.tsx
index db7628777a..b13ed33d04 100644
--- a/studio/frontend/src/components/assistant-ui/model-selector/model-update-action.tsx
+++ b/studio/frontend/src/features/model-picker/components/model-selector/model-update-action.tsx
@@ -1,12 +1,20 @@
// 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 { subscribeJobListeners } from "@/features/hub/download-manager";
-import { UpdateConfirmDialog } from "@/features/hub/catalog/download-card";
-import { ggufVariantsMatch } from "@/features/hub/lib/model-identity";
+import {
+ UpdateConfirmDialog,
+ ggufVariantsMatch,
+ subscribeJobListeners,
+} from "@/features/hub";
import { cn } from "@/lib/utils";
import { RefreshCw } from "lucide-react";
-import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
+import {
+ type ReactNode,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
import { toast } from "sonner";
interface ModelUpdateActionProps {
@@ -42,10 +50,10 @@ export function ModelUpdateAction({
}: ModelUpdateActionProps) {
const [open, setOpen] = useState(false);
- // Refresh the caller when this repo+variant's download finishes so the "update available" cue
- // clears. A ref keeps the subscription stable across renders.
const onUpdatedRef = useRef(onUpdated);
- onUpdatedRef.current = onUpdated;
+ useEffect(() => {
+ onUpdatedRef.current = onUpdated;
+ }, [onUpdated]);
useEffect(() => {
return subscribeJobListeners("model", repoId, {
onComplete: (completedVariant) => {
@@ -83,7 +91,8 @@ export function ModelUpdateAction({
disabled={disabled}
className={cn(
"shrink-0 rounded-md p-1.5 text-muted-foreground/60 transition-colors hover:bg-amber-500/10 hover:text-amber-700 dark:hover:bg-amber-500/15 dark:hover:text-amber-300",
- disabled && "cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground/60",
+ disabled &&
+ "cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground/60",
buttonClassName,
)}
>
diff --git a/studio/frontend/src/components/assistant-ui/model-selector/model-usage.ts b/studio/frontend/src/features/model-picker/components/model-selector/model-usage.ts
similarity index 93%
rename from studio/frontend/src/components/assistant-ui/model-selector/model-usage.ts
rename to studio/frontend/src/features/model-picker/components/model-selector/model-usage.ts
index dbcd4b9a1b..c6665e7658 100644
--- a/studio/frontend/src/components/assistant-ui/model-selector/model-usage.ts
+++ b/studio/frontend/src/features/model-picker/components/model-selector/model-usage.ts
@@ -40,7 +40,8 @@ export function loadedAt(times: ModelLoadTimes, id: string): number {
export function useModelLoadTimes(currentValue?: string): ModelLoadTimes {
const [times, setTimes] = useState(() => readLoadTimes());
useEffect(() => {
- if (currentValue) setTimes(recordModelLoaded(currentValue));
+ if (!currentValue) return;
+ queueMicrotask(() => setTimes(recordModelLoaded(currentValue)));
}, [currentValue]);
return times;
}
diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/features/model-picker/components/model-selector/pickers.tsx
similarity index 58%
rename from studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx
rename to studio/frontend/src/features/model-picker/components/model-selector/pickers.tsx
index 84119cc992..4df87b876a 100644
--- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx
+++ b/studio/frontend/src/features/model-picker/components/model-selector/pickers.tsx
@@ -10,49 +10,48 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { usePlatformStore } from "@/config/env";
-import { ApiProviderLogo } from "@/features/chat/api-provider-logo";
+import { ApiProviderLogo } from "@/features/chat";
import {
type ScanFolderInfo,
addScanFolder,
- deleteCachedModel,
deleteFineTunedModel,
- listCachedGguf,
- listCachedModels,
listGgufVariants,
- listLocalModels,
listRecommendedFolders,
listScanFolders,
removeScanFolder,
-} from "@/features/chat/api/chat-api";
-import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
+} from "@/features/chat";
+import { useChatRuntimeStore } from "@/features/chat";
import type {
CachedGgufRepo,
CachedModelRepo,
+ GgufVariantDetail,
LocalModelInfo,
-} from "@/features/chat/api/chat-api";
-import type { GgufVariantDetail } from "@/features/chat/types/api";
-import { DotTag } from "@/features/hub/catalog/dot-tag";
+} from "@/features/chat";
import {
+ DotTag,
type HubOption,
HubOptionMenu,
-} from "@/features/hub/catalog/hub-option-menu";
-import { TransportConflictDialog } from "@/features/hub/catalog/transport-conflict-dialog";
-import { TrainIcon } from "@/features/hub/components/train-icon";
-import { useHubInfiniteScroll } from "@/features/hub/hooks/use-hub-infinite-scroll";
+ TrainIcon,
+ TransportConflictDialog,
+ deleteCachedModel,
+ listGgufVariants as listGgufVariantsCached,
+ useHubInfiniteScroll,
+} from "@/features/hub";
import {
type HfModelResult,
type HfSortKey,
useHubModelSearch,
-} from "@/features/hub/hooks/use-hub-model-search";
-import { useOnlineStatus } from "@/features/hub/hooks/use-online-status";
-import { isHiddenModelId } from "@/features/hub/lib/hidden-models";
-import { classifyUnslothSupport } from "@/features/hub/lib/unsloth-support";
-import { hfApiToken, useHfTokenStore } from "@/features/hub/stores/hf-token-store";
+} from "@/features/hub";
import {
+ classifyUnslothSupport,
downloadManager,
+ hfApiToken,
+ isHiddenModelId,
jobKeyOf,
useDownloadManagerStore,
-} from "@/features/hub/download-manager";
+ useHfTokenStore,
+ useOnlineStatus,
+} from "@/features/hub";
import { useDebouncedValue, useGpuInfo } from "@/hooks";
import { extractParamLabel } from "@/lib/model-size";
import { toast } from "@/lib/toast";
@@ -61,6 +60,7 @@ import type { VramFitStatus } from "@/lib/vram";
import { checkVramFit, estimateLoadingVram } from "@/lib/vram";
import {
Add01Icon,
+ ArrowUpDownIcon,
AudioWave01Icon,
Cancel01Icon,
DashboardCircleIcon,
@@ -68,7 +68,6 @@ import {
Flag01Icon,
Folder02Icon,
PinIcon,
- PinOffIcon,
RemoveCircleIcon,
Search01Icon,
ViewIcon,
@@ -87,6 +86,7 @@ import {
useRef,
useState,
} from "react";
+import { useChatPickerInventory } from "../../inventory/use-chat-picker-inventory";
import { FolderBrowser } from "./folder-browser";
import {
type ModelCapabilities,
@@ -94,14 +94,15 @@ import {
hasAnyCapability,
} from "./model-capabilities";
import { ModelDeleteAction } from "./model-delete-action";
-import { ModelUpdateAction } from "./model-update-action";
import { ModelLoadSettingsAction } from "./model-load-settings-action";
+import { ModelRowMenu } from "./model-row-menu";
import {
type ModelLoadTimes,
loadedAt,
useModelLoadTimes,
} from "./model-usage";
import {
+ makePinRank,
pinKey,
pinnedQuantEntries,
usePinnedModelsStore,
@@ -349,15 +350,12 @@ function ListLabel({
/** Format bytes to a human-readable size string. */
function formatBytes(bytes: number): string {
- // Guard non-positive / non-finite sizes (0, missing -> NaN, Infinity) so we
- // never render "NaN undefined" or a negative unit index.
+ // Guard non-positive / non-finite sizes so we never render "NaN undefined".
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
- // Decimal (base-1000) units to match what Hugging Face reports for a repo's
- // file sizes -- e.g. 217 GB, not the 201.8 GiB a base-1024 divide would show.
- // (GPU-fit math below stays base-1024 since VRAM is binary.)
- // Divide iteratively rather than via Math.log, which has float error at exact
- // powers of 1000 (log(1e12)/log(1000) = 3.9999... would mislabel 1 TB as
- // "1000 GB"); the loop also can't run off the end of units.
+ // Decimal (base-1000) units to match Hugging Face's reported file sizes (GPU-fit
+ // math below stays base-1024 since VRAM is binary). Divide iteratively rather
+ // than via Math.log, which has float error at exact powers of 1000 (mislabeling
+ // 1 TB as "1000 GB") and could run off the end of units.
const units = ["B", "KB", "MB", "GB", "TB"];
let i = 0;
let value = bytes;
@@ -422,8 +420,7 @@ function ggufVariantsMatchForPicker(
right: string | null | undefined,
): boolean {
return (
- normalizeGgufVariantForPicker(left) ===
- normalizeGgufVariantForPicker(right)
+ normalizeGgufVariantForPicker(left) === normalizeGgufVariantForPicker(right)
);
}
@@ -674,12 +671,17 @@ function isValidGgufVariant(variant: unknown): variant is GgufVariantDetail {
);
}
-function normalizeGgufVariantsResponse(res: {
- variants?: unknown;
- default_variant?: unknown;
- has_vision?: unknown;
- context_length?: unknown;
-} | null | undefined): {
+function normalizeGgufVariantsResponse(
+ res:
+ | {
+ variants?: unknown;
+ default_variant?: unknown;
+ has_vision?: unknown;
+ context_length?: unknown;
+ }
+ | null
+ | undefined,
+): {
variants: GgufVariantDetail[];
defaultVariant: string | null;
hasVision: boolean;
@@ -722,6 +724,7 @@ function GgufVariantExpander({
parentOptionKey,
onNavigatePastStart,
onNavigatePastEnd,
+ onConfigure,
sourceOverride,
variantActions,
onDevice = false,
@@ -738,6 +741,7 @@ function GgufVariantExpander({
parentOptionKey?: string;
onNavigatePastStart?: () => void;
onNavigatePastEnd?: () => void;
+ onConfigure?: (id: string, meta: ModelSelectorChangeMeta) => void;
sourceOverride?: ModelSelectorChangeMeta["source"];
/** Update/delete actions for cached variant rows. Omitted by browse-only
* expanders (Recommended, etc.) that don't manage on-disk variants. */
@@ -765,13 +769,18 @@ function GgufVariantExpander({
const pinnedKeys = usePinnedModelsStore((s) => s.pinned);
const togglePinnedQuant = usePinnedModelsStore((s) => s.togglePinned);
const onUpdateVariant = variantActions?.onUpdate;
- const updateVariantTitle = variantActions?.updateTitle ?? "Update cached model?";
- const renderUpdateVariantDescription = variantActions?.renderUpdateDescription;
+ const updateVariantTitle =
+ variantActions?.updateTitle ?? "Update cached model?";
+ const renderUpdateVariantDescription =
+ variantActions?.renderUpdateDescription;
const updateDisabled = variantActions?.updateDisabled ?? false;
const onDeleteVariant = variantActions?.onDelete;
- const deleteVariantTitle = variantActions?.deleteTitle ?? "Delete cached model?";
- const renderDeleteVariantDescription = variantActions?.renderDeleteDescription;
- const getDeleteVariantSuccessMessage = variantActions?.getDeleteSuccessMessage;
+ const deleteVariantTitle =
+ variantActions?.deleteTitle ?? "Delete cached model?";
+ const renderDeleteVariantDescription =
+ variantActions?.renderDeleteDescription;
+ const getDeleteVariantSuccessMessage =
+ variantActions?.getDeleteSuccessMessage;
const deleteDisabled = variantActions?.deleteDisabled ?? false;
const [variants, setVariants] = useState(null);
const [defaultVariant, setDefaultVariant] = useState(null);
@@ -784,8 +793,11 @@ function GgufVariantExpander({
useEffect(() => {
let canceled = false;
- setLoading(true);
- setError(null);
+ queueMicrotask(() => {
+ if (canceled) return;
+ setLoading(true);
+ setError(null);
+ });
listGgufVariants(repoId, hfToken)
.then((res) => {
@@ -813,17 +825,12 @@ function GgufVariantExpander({
}, [repoId, refreshKey, hfToken]);
// Covers Unix absolute (/), Windows drive (C:\, D:/), UNC (\\server), relative (./, ../), tilde (~/)
- const isLocalPath = /^(\/|\.{1,2}[\\\/]|~[\\\/]|[A-Za-z]:[\\\/]|\\\\)/.test(
+ const isLocalPath = /^(\/|\.{1,2}[\\/]|~[\\/]|[A-Za-z]:[\\/]|\\\\)/.test(
repoId,
);
const handleVariantClick = useCallback(
(quant: string, downloaded?: boolean, sizeBytes?: number) => {
- // Only seed the staged context for picks whose weights are already on
- // disk. The staging effect short-circuits on a known contextLength
- // (pendingHasContext) before starting the download, so attaching it to an
- // undownloaded quant from a partially cached repo would skip the download
- // entirely (and, with Load on selection, never load).
const isAvailable = isLocalPath || downloaded === true;
onSelect(repoId, {
source: sourceOverride ?? (isLocalPath ? "local" : "hub"),
@@ -832,6 +839,7 @@ function GgufVariantExpander({
isDownloaded: isLocalPath ? true : downloaded,
expectedBytes: sizeBytes,
contextLength: isAvailable ? nativeContext : undefined,
+ isGguf: true,
});
},
[repoId, isLocalPath, onSelect, sourceOverride, nativeContext],
@@ -846,13 +854,12 @@ function GgufVariantExpander({
const getGgufFit = useCallback(
(sizeBytes: number): "fits" | "tight" | "oom" => {
- // No device budget at all (no GPU and no known system RAM): can't
- // classify, so don't scare the user with OOM badges.
+ // No device budget at all: can't classify, so don't show OOM badges.
if (totalBudgetGb <= 0) return "fits";
const gb = sizeBytes / 1024 ** 3;
if (gb <= 0 || gb <= gpuBudgetGb) return "fits";
- // No-GPU / unified-memory hosts (Mac) have only the RAM budget, so the
- // tier collapses to fit-or-oom against system RAM rather than GPU+offload.
+ // No-GPU / unified-memory hosts (Mac) have only the RAM budget, so the tier
+ // collapses to fit-or-oom against system RAM.
if (gpuBudgetGb <= 0) return gb <= totalBudgetGb ? "fits" : "oom";
if (gb <= totalBudgetGb) return "tight";
return "oom";
@@ -870,17 +877,13 @@ function GgufVariantExpander({
if (defaultV && getGgufFit(defaultV.size_bytes) !== "oom")
return defaultVariant;
// Largest non-OOM variant (best quality that fits)
- const fitting = variants.filter(
- (v) => getGgufFit(v.size_bytes) !== "oom",
- );
+ const fitting = variants.filter((v) => getGgufFit(v.size_bytes) !== "oom");
if (fitting.length > 0) {
fitting.sort((a, b) => b.size_bytes - a.size_bytes);
return fitting[0].quant;
}
// All OOM -- recommend smallest (most likely to partially run)
- const sorted = [...variants].sort(
- (a, b) => a.size_bytes - b.size_bytes,
- );
+ const sorted = [...variants].sort((a, b) => a.size_bytes - b.size_bytes);
return sorted[0]?.quant ?? defaultVariant;
}, [variants, defaultVariant, totalBudgetGb, getGgufFit]);
@@ -996,7 +999,7 @@ function GgufVariantExpander({
const keyBase = `${repoId}:${v.filename}`;
const variantOptionKey = makeModelOptionKey("gguf-variant", keyBase);
return (
-
+
@@ -1022,7 +1025,7 @@ function GgufVariantExpander({
update available
- ): null}
+ ) : null}
>
) : v.quant === effectiveRecommended ? (
@@ -1046,106 +1049,104 @@ function GgufVariantExpander({
- {v.downloaded && v.update_available && onUpdateVariant && (
-
- This will update{" "}
-
- {repoId} ({v.quant})
- {"."}
- >
- )
- }
- repoId={repoId}
- variant={v.quant}
- buttonClassName="p-1"
- iconClassName="size-3"
- disabled={updateDisabled}
- onConfirm={() => onUpdateVariant(v.quant, expectedBytes)}
- onUpdated={() => setRefreshKey((key) => key + 1)}
- />
- )}
- {v.downloaded && allowPin && (
-
-
- togglePinnedQuant(repoId, v.quant)}
- aria-label={
- pinnedKeys.includes(pinKey(repoId, v.quant))
- ? `Unpin ${repoId} ${v.quant}`
- : `Pin ${repoId} ${v.quant}`
- }
- aria-pressed={pinnedKeys.includes(pinKey(repoId, v.quant))}
- className={cn(
- "shrink-0 rounded-md p-1 transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10",
- pinnedKeys.includes(pinKey(repoId, v.quant))
- ? "text-foreground/80"
- : "text-muted-foreground/60",
- )}
- >
-
-
-
-
- {pinnedKeys.includes(pinKey(repoId, v.quant))
- ? "Unpin quant"
- : "Pin quant to the top"}
-
-
- )}
- {v.downloaded && (
+ {v.downloaded && onConfigure && (
+ onConfigure(repoId, {
+ source: sourceOverride ?? (isLocalPath ? "local" : "hub"),
+ isLora: false,
+ ggufVariant: v.quant,
+ isDownloaded: true,
+ expectedBytes,
+ contextLength: nativeContext,
+ isGguf: true,
+ })
+ }
/>
)}
- {v.downloaded && onDeleteVariant && (
-
- This will remove{" "}
-
- {repoId} ({v.quant})
- {" "}
- from disk. You can re-download it later.
- >
- )
- }
- successMessage={
- getDeleteVariantSuccessMessage?.(v.quant) ??
- `Deleted ${repoId} ${v.quant}`
- }
- buttonClassName="p-1"
- iconClassName="size-3"
- disabled={deleteDisabled}
- onConfirm={async () => {
- await onDeleteVariant(v.quant);
- // Drop the pin too: a pinned row for a deleted file
- // would try to load something that no longer exists.
- if (pinnedKeys.includes(pinKey(repoId, v.quant))) {
- togglePinnedQuant(repoId, v.quant);
+ {v.downloaded &&
+ (allowPin ||
+ (v.update_available && onUpdateVariant) ||
+ onDeleteVariant ||
+ !isLocalPath) && (
+
- )}
+ pin={
+ allowPin
+ ? {
+ pinned: pinnedKeys.includes(pinKey(repoId, v.quant)),
+ pinLabel: "Pin to top",
+ unpinLabel: "Unpin",
+ onToggle: () => togglePinnedQuant(repoId, v.quant),
+ }
+ : undefined
+ }
+ update={
+ v.update_available && onUpdateVariant
+ ? {
+ title: updateVariantTitle,
+ description: renderUpdateVariantDescription?.(
+ v.quant,
+ ) ?? (
+ <>
+ This will update{" "}
+
+ {repoId} ({v.quant})
+
+ {"."}
+ >
+ ),
+ repoId,
+ variant: v.quant,
+ disabled: updateDisabled,
+ onConfirm: () =>
+ onUpdateVariant(v.quant, expectedBytes),
+ onUpdated: () => setRefreshKey((key) => key + 1),
+ }
+ : undefined
+ }
+ del={
+ onDeleteVariant
+ ? {
+ title: deleteVariantTitle,
+ description: renderDeleteVariantDescription?.(
+ v.quant,
+ ) ?? (
+ <>
+ This will remove{" "}
+
+ {repoId} ({v.quant})
+ {" "}
+ from disk. You can re-download it later.
+ >
+ ),
+ successMessage:
+ getDeleteVariantSuccessMessage?.(v.quant) ??
+ `Deleted ${repoId} ${v.quant}`,
+ disabled: deleteDisabled,
+ onConfirm: async () => {
+ await onDeleteVariant(v.quant);
+ // Drop the pin too: a pinned row for a deleted file
+ // would try to load something that no longer exists.
+ if (pinnedKeys.includes(pinKey(repoId, v.quant))) {
+ togglePinnedQuant(repoId, v.quant);
+ }
+ // Re-fetch this expander's variants so the deleted
+ // quant stops showing as downloaded (and clickable to
+ // reload) while the repo still has other cached quants.
+ setRefreshKey((key) => key + 1);
+ },
+ }
+ : undefined
+ }
+ />
+ )}
);
})}
@@ -1170,17 +1171,6 @@ let _lmStudioCache: LocalModelInfo[] = [];
let _localDirCache: LocalModelInfo[] = [];
let _customFolderCache: LocalModelInfo[] = [];
let _scanFoldersCache: ScanFolderInfo[] = [];
-let _onDeviceCachesReady = false;
-let _cachedGgufRequestVersion = 0;
-let _cachedModelsRequestVersion = 0;
-let _localModelsRequestVersion = 0;
-const _onDeviceCacheListeners = new Set<(settled?: boolean) => void>();
-
-const ON_DEVICE_CACHE_TIMEOUT_MS = 30_000;
-
-function notifyOnDeviceCachesChanged(settled = false): void {
- for (const listener of _onDeviceCacheListeners) listener(settled);
-}
/** True when any on-device model (downloaded GGUF, cached repo, LM Studio, or
* custom-folder model) is known. Reads the module caches, which persist across
@@ -1333,6 +1323,19 @@ function localPathTooltip(name: string, path: string): ReactNode {
);
}
+function localModelMeta(isGguf = false): ModelSelectorChangeMeta {
+ return {
+ source: "local",
+ isLora: false,
+ isDownloaded: true,
+ ...(isGguf ? { isGguf: true } : {}),
+ };
+}
+
+function localDirectGgufMeta(): ModelSelectorChangeMeta {
+ return localModelMeta(true);
+}
+
/** Hugging Face address for an online/Hub row, or undefined when the repo id is
* missing so the row shows no (empty) address line on hover. */
function hubRepoUrl(id: string | null | undefined): string | undefined {
@@ -1343,9 +1346,7 @@ function hubRepoUrl(id: string | null | undefined): string | undefined {
/** Whether a local model is an MLX build (name hint). MLX runs on Mac only, so
* callers gate visibility on the host being a Mac. */
function localModelIsMlx(m: LocalModelInfo): boolean {
- return (
- isMlxId(m.id) || isMlxId(m.display_name) || isMlxId(m.model_id ?? "")
- );
+ return isMlxId(m.id) || isMlxId(m.display_name) || isMlxId(m.model_id ?? "");
}
/** Whether a local model matches the format toggle (GGUF detected by name/path). */
@@ -1369,6 +1370,7 @@ export function HubModelPicker({
onFoldersChange,
onBrowseHub,
onModelsChange,
+ onConfigure,
deleteDisabled = false,
section = "downloaded",
sectionToggle,
@@ -1385,12 +1387,12 @@ export function HubModelPicker({
/** Open the full Hub page to browse more models. */
onBrowseHub?: () => void;
onModelsChange?: (deletedModel?: DeletedModelRef) => void;
+ onConfigure?: (id: string, meta: ModelSelectorChangeMeta) => void;
deleteDisabled?: boolean;
/** Section shown when not searching. Search spans all sections. */
section?: "downloaded" | "recommended" | "custom" | "connected";
/** Section toggle rendered under the search bar. */
sectionToggle?: ReactNode;
- /** Eject the loaded model. Rendered as the last list row when set. */
onEject?: () => void;
}) {
const gpu = useGpuInfo();
@@ -1434,9 +1436,8 @@ export function HubModelPicker({
pinUnslothFirst: true,
keepUnsupportedTags: true,
accessToken,
- // Only the Recommended section renders Hub results (On Device / Connected
- // use local data), so keep the Hub hooks idle on the other tabs to avoid
- // needless requests/spinner and to preserve offline-local behavior.
+ // Only Recommended renders Hub results, so keep the Hub hooks idle on other
+ // tabs to avoid needless requests and preserve offline-local behavior.
enabled: online && section === "recommended",
});
const recommendedSearch = useHubModelSearch("", {
@@ -1449,10 +1450,9 @@ export function HubModelPicker({
enabled: online && section === "recommended",
});
- // Lowercased repo ids confirmed GGUF by the store or HF search.
- // Absence means "no hint" -> hasGgufSuffix is the fallback (don't
- // conflate unknown with known-not-GGUF). Lowercased so store and HF
- // IDs differing only by casing match the same hint.
+ // Lowercased repo ids confirmed GGUF by the store or HF search. Absence means
+ // "no hint" -> hasGgufSuffix is the fallback (don't conflate unknown with
+ // known-not-GGUF). Lowercased so store and HF IDs match regardless of casing.
const modelGgufIds = useMemo(() => {
const ids = new Set
();
for (const model of models) {
@@ -1494,12 +1494,14 @@ export function HubModelPicker({
const setFitOnDeviceOnly = useChatRuntimeStore((s) => s.setFitOnDeviceOnly);
// Repos the user clicked to collapse while expand-by-default is on. Kept in
// memory only, so it resets on reload (and when the setting is toggled).
- const [collapsedGguf, setCollapsedGguf] = useState>(
- () => new Set(),
- );
- useEffect(() => {
- setCollapsedGguf(new Set());
- }, [expandQuantizations]);
+ const [collapsedGgufState, setCollapsedGgufState] = useState<{
+ expandQuantizations: boolean;
+ value: Set;
+ }>(() => ({ expandQuantizations, value: new Set() }));
+ const collapsedGguf =
+ collapsedGgufState.expandQuantizations === expandQuantizations
+ ? collapsedGgufState.value
+ : new Set();
const isGgufExpanded = useCallback(
(id: string) =>
expandQuantizations ? !collapsedGguf.has(id) : expandedGguf === id,
@@ -1510,11 +1512,15 @@ export function HubModelPicker({
const toggleGgufExpanded = useCallback(
(id: string) => {
if (expandQuantizations) {
- setCollapsedGguf((prev) => {
- const next = new Set(prev);
+ setCollapsedGgufState((prev) => {
+ const current =
+ prev.expandQuantizations === expandQuantizations
+ ? prev.value
+ : new Set();
+ const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
- return next;
+ return { expandQuantizations, value: next };
});
} else {
setExpandedGguf((prev) => (prev === id ? null : id));
@@ -1575,14 +1581,37 @@ export function HubModelPicker({
});
}, []);
- // Cached (downloaded) repos -- module-level cache avoids flashing an
- // empty "Downloaded" section when the popover re-mounts.
- const [cachedGguf, setCachedGguf] =
- useState(_cachedGgufCache);
- const [cachedModels, setCachedModels] =
- useState(_cachedModelsCache);
- const alreadyCached = _onDeviceCachesReady || hasDownloadedModels();
- const [cachedReady, setCachedReady] = useState(alreadyCached);
+ const pickerInventory = useChatPickerInventory({ enabled: true });
+ const { cachedGguf, cachedModels, cachedReady, refreshInventory } =
+ pickerInventory;
+ const lmStudioModels = useMemo(
+ () =>
+ sortLmStudio(
+ pickerInventory.localModels.filter((m) => m.source === "lmstudio"),
+ ),
+ [pickerInventory.localModels],
+ );
+ const localDirModels = useMemo(
+ () => pickerInventory.localModels.filter((m) => m.source === "models_dir"),
+ [pickerInventory.localModels],
+ );
+ const customFolderModels = useMemo(
+ () => pickerInventory.localModels.filter((m) => m.source === "custom"),
+ [pickerInventory.localModels],
+ );
+ useEffect(() => {
+ _cachedGgufCache = cachedGguf;
+ _cachedModelsCache = cachedModels;
+ _lmStudioCache = lmStudioModels;
+ _localDirCache = localDirModels;
+ _customFolderCache = customFolderModels;
+ }, [
+ cachedGguf,
+ cachedModels,
+ lmStudioModels,
+ localDirModels,
+ customFolderModels,
+ ]);
const [updateConflictKey, setUpdateConflictKey] = useState(
null,
);
@@ -1606,35 +1635,6 @@ export function HubModelPicker({
setUpdateConflictKey(null);
}, [updateConflictKey]);
- // LM Studio local models -- module-level cache, same pattern as above.
- const [lmStudioModels, setLmStudioModels] =
- useState(_lmStudioCache);
- // Models found under the local models directory (./models), so they stay
- // selectable on the On Device tab after leaving the Fine-tuned tab.
- const [localDirModels, setLocalDirModels] =
- useState(_localDirCache);
- const [customFolderModels, setCustomFolderModels] =
- useState(_customFolderCache);
-
- useEffect(() => {
- const syncModuleCaches = (settled = false) => {
- setCachedGguf(_cachedGgufCache);
- setCachedModels(_cachedModelsCache);
- setLmStudioModels(_lmStudioCache);
- setLocalDirModels(_localDirCache);
- setCustomFolderModels(_customFolderCache);
- setCachedReady(
- (ready) =>
- ready || settled || _onDeviceCachesReady || hasDownloadedModels(),
- );
- };
- _onDeviceCacheListeners.add(syncModuleCaches);
- syncModuleCaches();
- return () => {
- _onDeviceCacheListeners.delete(syncModuleCaches);
- };
- }, []);
-
// Custom scan folders management
const [scanFolders, setScanFolders] =
useState(_scanFoldersCache);
@@ -1645,94 +1645,9 @@ export function HubModelPicker({
const [showFolderBrowser, setShowFolderBrowser] = useState(false);
const [recommendedFolders, setRecommendedFolders] = useState([]);
- const applyLocalModels = useCallback(
- (res: Awaited>) => {
- const lm = sortLmStudio(
- res.models.filter((m) => m.source === "lmstudio"),
- );
- _lmStudioCache = lm;
- setLmStudioModels(lm);
- const ld = res.models.filter((m) => m.source === "models_dir");
- _localDirCache = ld;
- setLocalDirModels(ld);
- const cf = res.models.filter((m) => m.source === "custom");
- _customFolderCache = cf;
- setCustomFolderModels(cf);
- notifyOnDeviceCachesChanged();
- },
- [],
- );
-
- const refreshColdOnDeviceCaches = useCallback(() => {
- const ggufRequestVersion = ++_cachedGgufRequestVersion;
- const modelsRequestVersion = ++_cachedModelsRequestVersion;
- const localRequestVersion = ++_localModelsRequestVersion;
- let ggufResult: Awaited> | undefined;
- let modelsResult: Awaited> | undefined;
- let localResult: Awaited> | undefined;
- let released = false;
-
- const ggufRequest = listCachedGguf().then(
- (value) => { if (!released) ggufResult = value; },
- () => {},
- );
- const modelsRequest = listCachedModels(hfToken || undefined).then(
- (value) => { if (!released) modelsResult = value; },
- () => {},
- );
- const localRequest = listLocalModels().then(
- (value) => {
- localResult = value;
- if (released && localRequestVersion === _localModelsRequestVersion) {
- if (ggufResult !== undefined && modelsResult !== undefined) _onDeviceCachesReady = true;
- applyLocalModels(value);
- }
- },
- () => {},
- );
- const isCurrent = () =>
- ggufRequestVersion === _cachedGgufRequestVersion &&
- modelsRequestVersion === _cachedModelsRequestVersion &&
- localRequestVersion === _localModelsRequestVersion;
- const publish = (invalidate = false) => {
- if (!isCurrent()) return;
- if (invalidate) {
- released = true;
- ++_cachedGgufRequestVersion;
- ++_cachedModelsRequestVersion;
- }
- if (ggufResult !== undefined) {
- _cachedGgufCache = ggufResult;
- setCachedGguf(ggufResult);
- }
- if (modelsResult !== undefined) {
- _cachedModelsCache = modelsResult;
- setCachedModels(modelsResult);
- }
- if (localResult !== undefined) applyLocalModels(localResult);
- if (ggufResult !== undefined && modelsResult !== undefined && localResult !== undefined) {
- _onDeviceCachesReady = true;
- }
- notifyOnDeviceCachesChanged(true);
- };
- const timeout = window.setTimeout(() => publish(true), ON_DEVICE_CACHE_TIMEOUT_MS);
- void Promise.all([ggufRequest, modelsRequest, localRequest]).then(() => {
- window.clearTimeout(timeout);
- publish();
- });
- }, [applyLocalModels, hfToken]);
-
const refreshLocalModelsList = useCallback(() => {
- if (!_onDeviceCachesReady && !hasDownloadedModels()) return refreshColdOnDeviceCaches();
- const requestVersion = ++_localModelsRequestVersion;
- listLocalModels()
- .then((res) => {
- if (requestVersion === _localModelsRequestVersion) {
- applyLocalModels(res);
- }
- })
- .catch(() => {});
- }, [applyLocalModels, refreshColdOnDeviceCaches]);
+ void pickerInventory.refreshInventory();
+ }, [pickerInventory.refreshInventory]);
const refreshScanFolders = useCallback(() => {
listScanFolders()
@@ -1752,9 +1667,8 @@ export function HubModelPicker({
if (!trimmed || folderLoading) return;
setFolderError(null);
setFolderLoading(true);
- // From the folder browser's one-click "Use this folder": the typed-
- // input panel is closed, so the inline folderError is invisible.
- // Surface failures (denylisted path, sandbox 403, etc.) via toast.
+ // From the folder browser's "Use this folder": the typed-input panel is
+ // closed, so surface failures (denylisted path, sandbox 403) via toast.
const fromBrowser = overridePath !== undefined;
try {
const created = await addScanFolder(trimmed);
@@ -1811,46 +1725,37 @@ export function HubModelPicker({
);
const refreshCachedLists = useCallback(() => {
- if (!_onDeviceCachesReady && !hasDownloadedModels()) return refreshColdOnDeviceCaches();
- const ggufRequestVersion = ++_cachedGgufRequestVersion;
- listCachedGguf()
- .then((v) => {
- if (ggufRequestVersion !== _cachedGgufRequestVersion) return;
- _cachedGgufCache = v;
- setCachedGguf(v);
- notifyOnDeviceCachesChanged();
- })
- .catch(() => {});
- const modelsRequestVersion = ++_cachedModelsRequestVersion;
- listCachedModels(hfToken || undefined)
- .then((v) => {
- if (modelsRequestVersion !== _cachedModelsRequestVersion) return;
- _cachedModelsCache = v;
- setCachedModels(v);
- notifyOnDeviceCachesChanged();
- })
- .catch(() => {});
- refreshLocalModelsList();
- }, [hfToken, refreshColdOnDeviceCaches, refreshLocalModelsList]);
+ void pickerInventory.refreshInventory();
+ }, [pickerInventory.refreshInventory]);
// Updates run as managed downloads (Downloads panel: progress + Cancel), not a blocking
// call. The worker pulls only changed blobs, so the cached copy stays usable until done.
- const startManagedUpdate = useCallback((repoId: string, variant: string, expectedBytes: number) => {
- return downloadManager
- .requestStart({
- kind: "model",
- repoId,
- variant,
- expectedBytes,
- })
- .then((outcome) => {
- if (outcome === "conflict") {
- setUpdateConflictKey(jobKeyOf("model", repoId, variant));
- } else if (outcome === "error") {
- throw new Error("Failed to start update");
- }
- });
- }, []);
+ const startManagedUpdate = useCallback(
+ (repoId: string, variant: string, expectedBytes: number) => {
+ return downloadManager
+ .requestStart({
+ kind: "model",
+ repoId,
+ variant,
+ expectedBytes,
+ })
+ .then((outcome) => {
+ if (outcome === "conflict") {
+ setUpdateConflictKey(jobKeyOf("model", repoId, variant));
+ } else if (outcome === "busy") {
+ // A sibling variant/snapshot for this repo is already downloading,
+ // so this update did not start. Say so instead of closing the
+ // dialog as if it began and leaving the cached copy stale.
+ toast.info("A download for this model is already in progress", {
+ description: "Try updating again once it finishes.",
+ });
+ } else if (outcome === "error") {
+ throw new Error("Failed to start update");
+ }
+ });
+ },
+ [],
+ );
const updateGgufVariant = useCallback(
(repoId: string, quant: string, expectedBytes: number) =>
@@ -1863,96 +1768,11 @@ export function HubModelPicker({
listRecommendedFolders()
.then(setRecommendedFolders)
.catch(() => {});
+ }, [refreshScanFolders]);
- // Publish downloaded and local rows as one bounded snapshot. Existing data
- // stays visible during background refreshes, and a failed source keeps its
- // last successful cache instead of clearing or durably marking it ready.
- const controller = new AbortController();
- const timeout = window.setTimeout(
- () => controller.abort(),
- ON_DEVICE_CACHE_TIMEOUT_MS,
- );
- const aborted = new Promise((_, reject) => {
- controller.signal.addEventListener(
- "abort",
- () => reject(controller.signal.reason),
- { once: true },
- );
- });
- const bounded = (request: Promise) =>
- Promise.race([request, aborted]);
- let cancelled = false;
- const ggufRequestVersion = ++_cachedGgufRequestVersion;
- const modelsRequestVersion = ++_cachedModelsRequestVersion;
- const localRequestVersion = ++_localModelsRequestVersion;
- const localRequest = listLocalModels();
-
- void Promise.allSettled([
- bounded(listCachedGguf(controller.signal)),
- bounded(listCachedModels(hfToken || undefined, controller.signal)),
- bounded(localRequest),
- ]).then(([ggufResult, modelsResult, localResult]) => {
- window.clearTimeout(timeout);
- if (cancelled) return;
-
- const ggufIsCurrent =
- ggufRequestVersion === _cachedGgufRequestVersion;
- const modelsAreCurrent =
- modelsRequestVersion === _cachedModelsRequestVersion;
- const localIsCurrent =
- localRequestVersion === _localModelsRequestVersion;
-
- if (ggufResult.status === "fulfilled" && ggufIsCurrent) {
- _cachedGgufCache = ggufResult.value;
- setCachedGguf(ggufResult.value);
- notifyOnDeviceCachesChanged();
- }
- if (modelsResult.status === "fulfilled" && modelsAreCurrent) {
- _cachedModelsCache = modelsResult.value;
- setCachedModels(modelsResult.value);
- notifyOnDeviceCachesChanged();
- }
- if (localResult.status === "fulfilled" && localIsCurrent) {
- applyLocalModels(localResult.value);
- }
- if (localResult.status === "rejected" && controller.signal.aborted) {
- void localRequest.then((value) => {
- if (cancelled || localRequestVersion !== _localModelsRequestVersion) return;
- if (ggufResult.status === "fulfilled" && modelsResult.status === "fulfilled") _onDeviceCachesReady = true;
- applyLocalModels(value);
- }).catch(() => {});
- }
- const snapshotIsCurrent =
- ggufIsCurrent && modelsAreCurrent && localIsCurrent;
- if (
- ggufResult.status === "fulfilled" &&
- modelsResult.status === "fulfilled" &&
- localResult.status === "fulfilled" &&
- snapshotIsCurrent
- ) {
- _onDeviceCachesReady = true;
- }
- notifyOnDeviceCachesChanged(snapshotIsCurrent);
- });
-
- return () => {
- cancelled = true;
- window.clearTimeout(timeout);
- controller.abort();
- queueMicrotask(() => {
- if (
- ggufRequestVersion === _cachedGgufRequestVersion &&
- modelsRequestVersion === _cachedModelsRequestVersion &&
- localRequestVersion === _localModelsRequestVersion
- ) {
- ++_cachedGgufRequestVersion;
- ++_cachedModelsRequestVersion;
- ++_localModelsRequestVersion;
- notifyOnDeviceCachesChanged(true);
- }
- });
- };
- }, [applyLocalModels, hfToken, refreshScanFolders]);
+ useEffect(() => {
+ void refreshInventory();
+ }, [refreshInventory]);
// Hide downloaded models from the recommended list. Case-insensitive
// since the HF cache lowercases repo IDs.
@@ -1989,7 +1809,8 @@ export function HubModelPicker({
// Chat-only keeps runnable formats: GGUF anywhere, plus MLX/safetensors
// on Mac (matches the empty Recommended view so search stays consistent).
.filter(
- (id) => !chatOnly || isRecommendableFormat(id, isKnownGgufRepo(id), isMac),
+ (id) =>
+ !chatOnly || isRecommendableFormat(id, isKnownGgufRepo(id), isMac),
)
.filter((id) => !/-FP8[-.]|FP8-Dynamic/i.test(id));
// Sort: GGUFs first, then hub models
@@ -2259,14 +2080,12 @@ export function HubModelPicker({
const togglePinned = usePinnedModelsStore((s) => s.togglePinned);
const pinnedSet = useMemo(() => new Set(pinnedIds), [pinnedIds]);
- // Candidate pins whose repo still exists in the managed cache. Per-quant
- // validation below is required because deleting one variant can leave a
- // sibling quant (and therefore the repo row) cached.
+ // Candidate pins whose repo still exists in the cache. Per-quant validation
+ // below is needed because deleting one variant can leave a sibling cached.
const pinnedQuantCandidates = useMemo(() => {
- // The existence check ignores the text query (but keeps the format filter)
- // so a pinned quant stays findable by its quant name even when the repo id
- // does not match the query; querying visibleCachedGguf here would drop the
- // repo before the later `${repoId} ${quant}` predicate could surface it.
+ // The existence check ignores the text query (keeps the format filter) so a
+ // pinned quant stays findable by quant name; querying visibleCachedGguf would
+ // drop the repo before the `${repoId} ${quant}` predicate could surface it.
const cached = new Set(
sortedCachedGguf
.filter((c) => matchesFormatFilter(c.repo_id, true, formatFilter))
@@ -2276,21 +2095,22 @@ export function HubModelPicker({
cached.has(entry.repoId),
);
}, [pinnedIds, sortedCachedGguf, formatFilter]);
- const pinnedQuantValidationKey = useMemo(() => {
- const cacheByRepo = new Map(
- sortedCachedGguf.map((repo) => [repo.repo_id, repo]),
- );
- return pinnedQuantCandidates
- .map((entry) => {
- const cached = cacheByRepo.get(entry.repoId);
- return `${pinKey(entry.repoId, entry.quant)}@${cached?.size_bytes ?? 0}:${cached?.last_modified ?? 0}`;
- })
- .join("\u0000");
- }, [pinnedQuantCandidates, sortedCachedGguf]);
const [pinnedQuantValidation, setPinnedQuantValidation] = useState<{
- key: string;
+ validated: boolean;
downloaded: ReadonlySet;
- }>({ key: "", downloaded: new Set() });
+ }>({ validated: false, downloaded: new Set() });
+ const prunePinnedQuantValidation = useCallback(
+ (repoId: string, quant: string) => {
+ const key = pinKey(repoId, quant);
+ setPinnedQuantValidation((prev) => {
+ if (!prev.downloaded.has(key)) return prev;
+ const downloaded = new Set(prev.downloaded);
+ downloaded.delete(key);
+ return { ...prev, downloaded };
+ });
+ },
+ [],
+ );
useEffect(() => {
let cancelled = false;
@@ -2302,12 +2122,13 @@ export function HubModelPicker({
void Promise.all(
repoIds.map(async (repoId) => {
try {
- const response = await listGgufVariants(
+ const response = await listGgufVariantsCached(
repoId,
hfToken || undefined,
+ { preferLocalCache: true },
);
- return normalizeGgufVariantsResponse(response).variants
- .filter((variant) => variant.downloaded === true)
+ return normalizeGgufVariantsResponse(response)
+ .variants.filter((variant) => variant.downloaded === true)
.map((variant) => pinKey(repoId, variant.quant));
} catch {
// If the backend cannot verify a quant, hiding the direct-load row
@@ -2318,7 +2139,7 @@ export function HubModelPicker({
).then((groups) => {
if (!cancelled) {
setPinnedQuantValidation({
- key: pinnedQuantValidationKey,
+ validated: true,
downloaded: new Set(groups.flat()),
});
}
@@ -2327,13 +2148,13 @@ export function HubModelPicker({
return () => {
cancelled = true;
};
- }, [hfToken, pinnedQuantCandidates, pinnedQuantValidationKey]);
+ }, [hfToken, pinnedQuantCandidates]);
const downloadedPinnedQuantKeys = useMemo>(
() =>
- pinnedQuantValidation.key === pinnedQuantValidationKey
+ pinnedQuantValidation.validated
? pinnedQuantValidation.downloaded
: new Set(),
- [pinnedQuantValidation, pinnedQuantValidationKey],
+ [pinnedQuantValidation],
);
// Verified downloaded quants, in pin order and filtered by repo id or quant.
@@ -2345,17 +2166,32 @@ export function HubModelPicker({
(!q ||
normalizeForSearch(`${entry.repoId} ${entry.quant}`).includes(q)),
);
- }, [
- debouncedQuery,
- downloadedPinnedQuantKeys,
- pinnedQuantCandidates,
- ]);
+ }, [debouncedQuery, downloadedPinnedQuantKeys, pinnedQuantCandidates]);
const pinnedCachedModelRows = useMemo(
- () => visibleCachedModelRows.filter((c) => pinnedSet.has(pinKey(c.repo_id))),
+ () =>
+ visibleCachedModelRows.filter((c) => pinnedSet.has(pinKey(c.repo_id))),
[visibleCachedModelRows, pinnedSet],
);
+ const pinnedRows = useMemo(() => {
+ const rank = makePinRank(pinnedIds);
+ const rows = [
+ ...pinnedQuants.map((entry) => ({
+ key: pinKey(entry.repoId, entry.quant),
+ entry,
+ model: null,
+ })),
+ ...pinnedCachedModelRows.map((model) => ({
+ key: pinKey(model.repo_id),
+ entry: null,
+ model,
+ })),
+ ];
+ rows.sort((a, b) => rank(a.key) - rank(b.key));
+ return rows;
+ }, [pinnedIds, pinnedQuants, pinnedCachedModelRows]);
+
// Split downloaded models so non-Unsloth repos get their own "Other models"
// section above Fine-tuned.
const unslothCachedGguf = useMemo(
@@ -2395,26 +2231,28 @@ export function HubModelPicker({
const filteredRecommendedIds = useMemo(() => {
if (!showHfSection) return [];
const q = normalizeForSearch(debouncedQuery.trim());
- return recommendedIds
- .filter((id) => normalizeForSearch(id).includes(q))
- .filter((id) =>
- matchesFormatFilter(id, isKnownGgufRepo(id), formatFilter),
- )
- // Curated defaults obey the fit toggle like the live HF rows, else large
- // defaults resurface in search results with the filter on.
- .filter(
- (id) =>
- !fitOnDeviceOnly ||
- downloadedSet.has(id.toLowerCase()) ||
- hfModelFitsDevice(
- {
- id,
- totalParams: recommendedParamCountById.get(id),
- isGguf: isKnownGgufRepo(id),
- },
- gpu,
- ),
- );
+ return (
+ recommendedIds
+ .filter((id) => normalizeForSearch(id).includes(q))
+ .filter((id) =>
+ matchesFormatFilter(id, isKnownGgufRepo(id), formatFilter),
+ )
+ // Curated defaults obey the fit toggle like the live HF rows, else large
+ // defaults resurface in search results with the filter on.
+ .filter(
+ (id) =>
+ !fitOnDeviceOnly ||
+ downloadedSet.has(id.toLowerCase()) ||
+ hfModelFitsDevice(
+ {
+ id,
+ totalParams: recommendedParamCountById.get(id),
+ isGguf: isKnownGgufRepo(id),
+ },
+ gpu,
+ ),
+ )
+ );
}, [
showHfSection,
debouncedQuery,
@@ -2435,27 +2273,30 @@ export function HubModelPicker({
const hfIds = useMemo(() => {
// Only the Unsloth tab searches the HF listing, and only Unsloth models.
if (!showHfSection || section !== "recommended") return [];
- return results
- .filter(isChatSupported)
- .filter(
- (r) =>
- !fitOnDeviceOnly ||
- downloadedSet.has(r.id.toLowerCase()) ||
- hfModelFitsDevice(r, gpu),
- )
- .map((result) => result.id)
- .filter((id) => !isHiddenModelId(id))
- .filter((id) => id.toLowerCase().startsWith("unsloth/"))
- .filter((id) => !recommendedSet.has(id))
- // Chat-only keeps runnable formats: GGUF anywhere, plus MLX/safetensors
- // on Mac (matches the empty Recommended view so search stays consistent).
- .filter(
- (id) => !chatOnly || isRecommendableFormat(id, isKnownGgufRepo(id), isMac),
- )
- .filter((id) => !/-FP8[-.]|FP8-Dynamic/i.test(id))
- .filter((id) =>
- matchesFormatFilter(id, isKnownGgufRepo(id), formatFilter),
- );
+ return (
+ results
+ .filter(isChatSupported)
+ .filter(
+ (r) =>
+ !fitOnDeviceOnly ||
+ downloadedSet.has(r.id.toLowerCase()) ||
+ hfModelFitsDevice(r, gpu),
+ )
+ .map((result) => result.id)
+ .filter((id) => !isHiddenModelId(id))
+ .filter((id) => id.toLowerCase().startsWith("unsloth/"))
+ .filter((id) => !recommendedSet.has(id))
+ // Chat-only keeps runnable formats: GGUF anywhere, plus MLX/safetensors
+ // on Mac (matches the empty Recommended view so search stays consistent).
+ .filter(
+ (id) =>
+ !chatOnly || isRecommendableFormat(id, isKnownGgufRepo(id), isMac),
+ )
+ .filter((id) => !/-FP8[-.]|FP8-Dynamic/i.test(id))
+ .filter((id) =>
+ matchesFormatFilter(id, isKnownGgufRepo(id), formatFilter),
+ )
+ );
}, [
recommendedSet,
results,
@@ -2479,16 +2320,13 @@ export function HubModelPicker({
section === "downloaded" &&
cachedReady &&
!pinnedCollapsed &&
- (pinnedQuants.length > 0 || pinnedCachedModelRows.length > 0)
+ pinnedRows.length > 0
) {
keys.push(
- ...pinnedQuants.map((entry) =>
- makeModelOptionKey("pinned-quant", pinKey(entry.repoId, entry.quant)),
- ),
- );
- keys.push(
- ...pinnedCachedModelRows.map((model) =>
- makeModelOptionKey("downloaded-model", model.repo_id),
+ ...pinnedRows.map((row) =>
+ row.entry
+ ? makeModelOptionKey("pinned-quant", row.key)
+ : makeModelOptionKey("downloaded-model", row.model.repo_id),
),
);
}
@@ -2543,12 +2381,12 @@ export function HubModelPicker({
}
// Fine-tuned models sit below downloaded, above custom folders.
- if (section === "downloaded" && cachedReady && !fineTunedCollapsed) {
+ if (section === "downloaded" && !fineTunedCollapsed) {
keys.push(...fineTunedRows.map((m) => makeModelOptionKey("lora", m.id)));
}
// Custom folders sit right below the downloaded models on On Device.
- if (section === "downloaded" && cachedReady && !customFoldersCollapsed) {
+ if (section === "downloaded" && !customFoldersCollapsed) {
keys.push(
...sortedCustomFolderModels.map((model) =>
makeModelOptionKey("custom-folder", model.id),
@@ -2556,7 +2394,7 @@ export function HubModelPicker({
);
}
- if (section === "downloaded" && cachedReady && !lmStudioCollapsed) {
+ if (section === "downloaded" && !lmStudioCollapsed) {
keys.push(
...sortedLmStudio.map((model) =>
makeModelOptionKey("lm-studio", model.id),
@@ -2564,7 +2402,7 @@ export function HubModelPicker({
);
}
- if (section === "downloaded" && cachedReady && !localDirCollapsed) {
+ if (section === "downloaded" && !localDirCollapsed) {
keys.push(
...sortedLocalDir.map((model) =>
makeModelOptionKey("local-dir", model.id),
@@ -2584,8 +2422,7 @@ export function HubModelPicker({
chatOnly,
sortedCustomFolderModels,
customFoldersCollapsed,
- pinnedQuants,
- pinnedCachedModelRows,
+ pinnedRows,
pinnedCollapsed,
downloadedCollapsed,
fineTunedRows,
@@ -2706,9 +2543,8 @@ export function HubModelPicker({
}, [scrollRef, updateListFades]);
// Sentinel + IntersectionObserver for recommended infinite scroll. Re-running
- // on each loaded page (results length) re-attaches the observer so a heavily
- // filtered list keeps paging until the viewport fills or the listing ends;
- // fetchMore is a no-op while a page is in flight. Callback ref tracks mount.
+ // per loaded page re-attaches the observer so a heavily filtered list keeps
+ // paging until the viewport fills; fetchMore is a no-op while a page is in flight.
const [recommendedSentinel, setRecommendedSentinel] =
useState(null);
const recommendedSentinelRef = useCallback((node: HTMLDivElement | null) => {
@@ -2757,8 +2593,8 @@ export function HubModelPicker({
const showDownloaded = section === "downloaded";
const showCustom = section === "downloaded";
const showRecommendedSection = !showHfSection && section === "recommended";
- const onDeviceCacheLoading = showDownloaded && !cachedReady;
const downloadedEmpty =
+ pinnedRows.length === 0 &&
visibleCachedGguf.length === 0 &&
visibleCachedModelRows.length === 0 &&
sortedLmStudio.length === 0 &&
@@ -2767,25 +2603,21 @@ export function HubModelPicker({
// non-empty Fine-tuned section.
fineTunedRows.length === 0;
- // Sort dropdown shown inline to the right of the section toggle. Options
- // depend on the tab and stay visible while searching so results can be
- // sorted. Fixed width matching the Search Hub button so it and the format
- // dropdown always line up; text-xs matches that button too. The trigger label
- // clips (no ellipsis) when long; the open menu expands to show it in full.
+ // Sort dropdown inline right of the section toggle; options depend on the tab
+ // and stay visible while searching. Fixed width matches the Search Hub button
+ // so it and the format dropdown line up. Trigger label clips; the menu shows full.
const sortTriggerClassName =
"w-[110px] shrink-0 justify-between pr-2.5 !border-0 text-xs [&>span]:!text-clip";
- // Tighter menu like the Projects activity Select: less left/top padding and
- // text-xs to match the trigger. Keep the option's right padding so the
- // selected-item checkmark never overlaps the label.
+ // Tighter menu (less padding, text-xs) matching the trigger. Keep the option's
+ // right padding so the selected-item checkmark never overlaps the label.
const sortMenuContentClassName =
"!p-1 !rounded-[14px] [&_[role=option]]:!pl-2 [&_[role=option]]:!py-1.5 [&_[role=option]]:!text-xs [&_[role=option]]:!rounded-[10px]";
- // Device-fit toggle lives inside the sort menu (shared with the Hub page).
- // The whole row is the click target (a button): a Checkbox renders as a
- // , and label-click forwarding to a button is unreliable, so the row
- // owns the toggle and the Checkbox is presentational (pointer-events-none).
+ // Device-fit toggle inside the sort menu (shared with the Hub page). The whole
+ // row is the button: a Checkbox renders as a and label-click forwarding
+ // to it is unreliable, so the row owns the toggle and the Checkbox is presentational.
const fitOnDeviceFooter = (
-
+
Only show models that fit
@@ -2808,6 +2640,19 @@ export function HubModelPicker({
);
+ // Sort icon + selected label inside the trigger pill.
+ const sortTriggerContent = (label: ReactNode) => (
+
+
+ {label}
+
+ );
+ // On Device / Custom rows are already on disk, so the device-fit filter
+ // only applies to the Unsloth listing.
const sectionSortDropdown =
section === "recommended" ? (
o.value === recommendedSort)
+ ?.label ?? recommendedSort,
+ )}
footer={fitOnDeviceFooter}
/>
) : section === "downloaded" ? (
@@ -2829,7 +2678,10 @@ export function HubModelPicker({
align="end"
className={sortTriggerClassName}
contentClassName={sortMenuContentClassName}
- footer={fitOnDeviceFooter}
+ triggerContent={sortTriggerContent(
+ LOCAL_SORT_OPTIONS.find((o) => o.value === downloadedSort)?.label ??
+ downloadedSort,
+ )}
/>
) : (
o.value === customSort)?.label ??
+ customSort,
+ )}
/>
);
@@ -2896,60 +2751,6 @@ export function HubModelPicker({
selected && "bg-[#ececec] dark:bg-[var(--sidebar-accent)]",
);
- // Pin toggle at a row's right edge: hidden until the row is hovered (or the
- // button is focused), always visible while pinned so pinned rows read as such.
- // `small` matches the compact quant-row action sizing; it also skips the
- // hide-until-hover classes since small pins render inside a hover-gated group.
- const renderPinAction = (
- repoId: string,
- quant?: string,
- opts?: { className?: string; small?: boolean },
- ) => {
- const pinned = pinnedSet.has(pinKey(repoId, quant));
- const target = quant ? `${repoId} ${quant}` : repoId;
- return (
-
-
- {
- e.stopPropagation();
- togglePinned(repoId, quant);
- }}
- aria-label={pinned ? `Unpin ${target}` : `Pin ${target}`}
- aria-pressed={pinned}
- className={cn(
- "shrink-0 rounded-md transition-colors hover:bg-black/5 dark:hover:bg-white/10",
- opts?.small ? "p-1" : "p-1.5",
- pinned
- ? "text-foreground/80 hover:text-foreground"
- : "text-muted-foreground/60 hover:text-foreground",
- !pinned &&
- !opts?.small &&
- "opacity-0 focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100",
- opts?.className,
- )}
- >
-
-
-
-
- {pinned
- ? quant
- ? "Unpin quant"
- : "Unpin model"
- : quant
- ? "Pin quant to the top"
- : "Pin model to the top"}
-
-
- );
- };
-
// A pinned quant: repo name with the quant as a grey chip. One click loads
// that quant directly, no expansion needed.
const renderPinnedQuantRow = (entry: { repoId: string; quant: string }) => {
@@ -2958,16 +2759,14 @@ export function HubModelPicker({
pinKey(entry.repoId, entry.quant),
);
const { owner, name } = splitRepoLabel(entry.repoId);
- const isSelected = value === entry.repoId && activeGgufVariant === entry.quant;
+ const isSelected =
+ value === entry.repoId && activeGgufVariant === entry.quant;
const isLoaded =
modelIdsMatchForPicker(loadedModelId, entry.repoId) &&
!ggufVariantsMatchForPicker(activeGgufVariant, null) &&
ggufVariantsMatchForPicker(activeGgufVariant, entry.quant);
return (
-
+
)}
-
- {renderPinAction(entry.repoId, entry.quant, { small: true })}
-
-
- This will remove{" "}
-
- {entry.repoId} ({entry.quant})
- {" "}
- from disk. You can re-download it later.
- >
- }
- successMessage={`Deleted ${entry.repoId} ${entry.quant}`}
- buttonClassName="p-1"
+
+ {onConfigure && (
+
+ onConfigure(entry.repoId, {
+ source: "hub",
+ isLora: false,
+ ggufVariant: entry.quant,
+ isDownloaded: true,
+ isGguf: true,
+ })
+ }
+ />
+ )}
+ {
- await deleteCachedModel(entry.repoId, entry.quant);
- refreshCachedLists();
- // The file is gone, so drop its pin too.
- togglePinned(entry.repoId, entry.quant);
+ cachePath={{ repoId: entry.repoId, variant: entry.quant }}
+ pin={{
+ pinned: true,
+ pinLabel: "Pin to top",
+ unpinLabel: "Unpin",
+ onToggle: () => togglePinned(entry.repoId, entry.quant),
+ }}
+ del={{
+ title: "Delete cached model?",
+ description: (
+ <>
+ This will remove{" "}
+
+ {entry.repoId} ({entry.quant})
+ {" "}
+ from disk. You can re-download it later.
+ >
+ ),
+ successMessage: `Deleted ${entry.repoId} ${entry.quant}`,
+ disabled: deleteDisabled,
+ onConfirm: async () => {
+ await deleteCachedModel(
+ entry.repoId,
+ entry.quant,
+ hfToken || undefined,
+ );
+ refreshCachedLists();
+ // The file is gone, so drop its pin too.
+ togglePinned(entry.repoId, entry.quant);
+ },
}}
/>
@@ -3081,6 +2900,7 @@ export function HubModelPicker({
allowPin={true}
onHasVision={(v) => reportVision(c.repo_id, v)}
onSelect={onSelect}
+ onConfigure={onConfigure}
hfToken={hfToken || undefined}
parentOptionKey={optionKey}
onNavigatePastStart={() => hubModelList.focusOption(optionKey)}
@@ -3090,13 +2910,12 @@ export function HubModelPicker({
variantActions={{
onUpdate: (quant, expectedBytes) =>
updateGgufVariant(c.repo_id, quant, expectedBytes),
- // Can't update the model that's live in memory under itself.
updateDisabled: loadedModelId === c.repo_id,
onDelete: async (quant) => {
- await deleteCachedModel(c.repo_id, quant);
+ await deleteCachedModel(c.repo_id, quant, hfToken || undefined);
+ prunePinnedQuantValidation(c.repo_id, quant);
refreshCachedLists();
},
- deleteDisabled,
}}
/>
)}
@@ -3109,10 +2928,7 @@ export function HubModelPicker({
const optionKey = makeModelOptionKey("downloaded-model", c.repo_id);
const isSelected = value === c.repo_id;
return (
-
+
onSelect(c.repo_id, {
source: "hub",
@@ -3142,27 +2955,48 @@ export function HubModelPicker({
className={downloadedRowButtonClassName}
/>
- {renderPinAction(c.repo_id)}
-
- This will remove{" "}
- {c.repo_id}{" "}
- from disk. You can re-download it later.
- >
- }
- successMessage={`Deleted ${c.repo_id}`}
- buttonClassName="mr-1"
- disabled={deleteDisabled}
- onConfirm={async () => {
- await deleteCachedModel(c.repo_id);
- if (pinnedSet.has(pinKey(c.repo_id))) {
- togglePinned(c.repo_id);
+ {onConfigure && (
+
+ onConfigure(c.repo_id, {
+ source: "hub",
+ isLora: false,
+ isDownloaded: true,
+ isGguf: false,
+ })
}
+ />
+ )}
+ togglePinned(c.repo_id),
+ }}
+ del={{
+ title: "Delete cached model?",
+ description: (
+ <>
+ This will remove{" "}
+ {c.repo_id}{" "}
+ from disk. You can re-download it later.
+ >
+ ),
+ successMessage: `Deleted ${c.repo_id}`,
+ disabled: deleteDisabled,
+ onConfirm: async () => {
+ await deleteCachedModel(c.repo_id, undefined, hfToken || undefined);
+ if (pinnedSet.has(pinKey(c.repo_id))) {
+ togglePinned(c.repo_id);
+ }
+ },
+ onDeleted: refreshCachedLists,
}}
- onDeleted={refreshCachedLists}
/>
);
@@ -3171,214 +3005,237 @@ export function HubModelPicker({
return (
<>
- {/* A small right inset shortens the search bar so Search Hub lands on the
+ {/* A small right inset shortens the search bar so Search Hub lands on the
last dropdown's right edge (none on the wider Connected box). */}
-
-
-
- setQuery(event.target.value)}
- placeholder={
- section === "downloaded"
- ? "Search local models"
- : "Search Unsloth models"
- }
- data-model-picker-search-input={true}
- className="field-soft h-9 border-0 pl-8 pr-8"
- />
- {isLoading && (
-
- )}
-
- {onBrowseHub ? (
-
-
-
-
- Search Hub
-
-
- Search all models
-
- ) : null}
-
-
- {/* Section tabs then the format and sort dropdowns, packed left with one
- uniform gap between every control. The box is sized so the last
- dropdown still lands on Search Hub's edge. Dropdowns hide on Connected. */}
-
- {sectionToggle}
- {showConnected ? null : (
-
-
- {sectionSortDropdown}
-
- )}
-
-
-
updateListFades(e.currentTarget)}
- className={cn(
- // List sits within the menu padding so left and right gaps match.
- // Height tracks the content up to the cap, so short lists do not
- // leave white space. scroll-py + symmetric px keep the focus ring off
- // the overflow clip edges during keyboard nav.
- "model-list-scroll max-h-[335px] overflow-y-auto scroll-py-1.5 px-0.5 mr-1",
- listScrolled && "is-scrolled",
- listMoreBelow && "is-bottom-faded",
- )}
- {...hubModelList.listboxProps}
- >
- {/* Clear space for the floating Eject pill when scrolled to the end, so
- its gap above the last row matches its gap below (applies to every
- section, including Recommended). */}
- {showConnected ? (
- connectedGroups.length === 0 ? (
-
- {externalModels.length === 0
- ? "No models from your connections. Set up in Settings then Connections."
- : "No models match your search."}
-
+
+
+ setQuery(event.target.value)}
+ placeholder={
+ section === "downloaded"
+ ? "Search local models"
+ : "Search Unsloth models"
+ }
+ data-model-picker-search-input={true}
+ className="field-soft h-9 border-0 pl-8 pr-8"
+ />
+ {isLoading && (
+
+ )}
+
+ {onBrowseHub ? (
+
+
+
+
+ Search Hub
+
+
+ Search all models
+
+ ) : null}
+
+
+ {/* Section tabs then the format and sort dropdowns, packed left with one
+ uniform gap between every control. The box is sized so the last
+ dropdown still lands on Search Hub's edge. Dropdowns hide on Connected. */}
+
+ {sectionToggle}
+ {showConnected ? null : (
+
+
+ {sectionSortDropdown}
+
+ )}
+
+
+
updateListFades(e.currentTarget)}
+ className={cn(
+ // List sits within the menu padding so gaps match; height tracks content
+ // up to the cap. scroll-py + symmetric px keep the focus ring off the
+ // overflow clip edges during keyboard nav.
+ "model-list-scroll max-h-[335px] overflow-y-auto scroll-py-1.5 px-0.5 mr-1",
+ listScrolled && "is-scrolled",
+ listMoreBelow && "is-bottom-faded",
+ )}
+ {...hubModelList.listboxProps}
+ >
+
+ {showConnected ? (
+ connectedGroups.length === 0 ? (
+
+ {externalModels.length === 0
+ ? "No models from your connections. Set up in Settings then Connections."
+ : "No models match your search."}
+
+ ) : (
+ connectedGroups.map((group) => (
+
+
+
+
+ {group.providerName}
+
+
+ {group.models.map((model) => (
+
+ onSelect(model.id, {
+ source: "external",
+ isLora: false,
+ })
+ }
+ className={cn(
+ "flex w-full items-center rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-[#ececec] dark:hover:bg-[var(--sidebar-accent)]",
+ value === model.id &&
+ "bg-[#ececec] dark:bg-[var(--sidebar-accent)]",
+ )}
+ >
+ {model.name}
+
+ ))}
+
+ ))
+ )
) : (
- connectedGroups.map((group) => (
-
-
-
-
- {group.providerName}
+ <>
+ {/* First-load spinner only when nothing cached is shown yet. */}
+ {showDownloaded &&
+ !cachedReady &&
+ !showHfSection &&
+ downloadedEmpty ? (
+
+
+
+ Loading models…
- {group.models.map((model) => (
-
- onSelect(model.id, {
- source: "external",
- isLora: false,
- })
- }
- className={cn(
- "flex w-full items-center rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-[#ececec] dark:hover:bg-[var(--sidebar-accent)]",
- value === model.id &&
- "bg-[#ececec] dark:bg-[var(--sidebar-accent)]",
- )}
- >
- {model.name}
-
- ))}
-
- ))
- )
- ) : (
- <>
- {/* First-load spinner while downloaded/local scans are resolving. */}
- {onDeviceCacheLoading ? (
-
-
-
- Loading models…
-
-
- ) : null}
+ ) : null}
- {/* Empty On Device: a search miss vs nothing downloaded yet. Hidden
+ {/* Empty On Device: a search miss vs nothing downloaded yet. Hidden
when custom folders below still have matches. */}
- {showDownloaded &&
- cachedReady &&
- downloadedEmpty &&
- sortedCustomFolderModels.length === 0 ? (
-
- {showHfSection
- ? "No matching models on device."
- : formatFilter === "all"
- ? "No downloaded models yet. Search above or pick Recommended."
- : `No downloaded ${FORMAT_FILTER_LABELS[formatFilter]} models yet.`}
-
- ) : null}
+ {showDownloaded &&
+ cachedReady &&
+ downloadedEmpty &&
+ sortedCustomFolderModels.length === 0 ? (
+
+ {showHfSection
+ ? "No matching models on device."
+ : formatFilter === "all"
+ ? "No downloaded models yet. Search above or pick Recommended."
+ : `No downloaded ${FORMAT_FILTER_LABELS[formatFilter]} models yet.`}
+
+ ) : null}
- {/* Pinned quants and models sit above the Unsloth heading so
+ {/* Pinned quants and models sit above the Unsloth heading so
favorites are always first. Filtered by the query like the
sections below. */}
- {showDownloaded &&
- (pinnedQuants.length > 0 ||
- pinnedCachedModelRows.length > 0) ? (
- <>
-
}
- collapsed={pinnedCollapsed}
- onToggle={() => setPinnedCollapsed((v) => !v)}
- >
- Pinned
-
- {!pinnedCollapsed && pinnedQuants.map(renderPinnedQuantRow)}
- {!pinnedCollapsed &&
- pinnedCachedModelRows.map(renderDownloadedModelRow)}
- >
- ) : null}
+ {showDownloaded && pinnedRows.length > 0 ? (
+ <>
+
+ }
+ collapsed={pinnedCollapsed}
+ onToggle={() => setPinnedCollapsed((v) => !v)}
+ >
+ Pinned
+
+ {!pinnedCollapsed &&
+ pinnedRows.map((row) =>
+ row.entry
+ ? renderPinnedQuantRow(row.entry)
+ : renderDownloadedModelRow(row.model),
+ )}
+ >
+ ) : null}
- {/* Downloaded (Unsloth) stays visible (filtered) while searching. */}
- {showDownloaded &&
- cachedReady &&
- (unslothCachedGguf.length > 0 ||
- unslothCachedModelRows.length > 0) ? (
- <>
-
0 ||
- pinnedCachedModelRows.length > 0
- }
- collapsed={downloadedCollapsed}
- onToggle={() => setDownloadedCollapsed((v) => !v)}
- action={
- <>
- {hasOtherModels ? (
+ {/* Downloaded (Unsloth) stays visible (filtered) while searching. */}
+ {showDownloaded &&
+ (unslothCachedGguf.length > 0 ||
+ unslothCachedModelRows.length > 0) ? (
+ <>
+ 0}
+ collapsed={downloadedCollapsed}
+ onToggle={() => setDownloadedCollapsed((v) => !v)}
+ action={
+ <>
+ {hasOtherModels ? (
+
+
+
+
+
+
+
+ Other non-Unsloth models
+
+
+ ) : null}
@@ -3387,845 +3244,809 @@ export function HubModelPicker({
side="bottom"
className="tooltip-compact"
>
- Other non-Unsloth models
+ Go to fine-tuned models
- ) : null}
-
-
-
+
+
+
+
+
+
-
-
-
-
- Go to fine-tuned models
-
-
-
-
-
-
-
-
-
- Go to custom folders
-
-
- >
- }
- >
- {/* When other providers (LM Studio/Ollama) also show here, name
- this group "Unsloth" so the two are easy to tell apart. */}
- {sortedLmStudio.length > 0 ? "Unsloth" : "Downloaded"}
-
- {!downloadedCollapsed &&
- unslothCachedGguf.map(renderDownloadedGgufRow)}
- {!downloadedCollapsed &&
- unslothCachedModelRows.map(renderDownloadedModelRow)}
- >
- ) : null}
-
- {/* Other models: non-Unsloth downloads, grouped just above
- Fine-tuned. Shown only when such models exist. */}
- {showDownloaded && cachedReady && hasOtherModels ? (
-
-
- }
- collapsed={otherModelsCollapsed}
- onToggle={() => setOtherModelsCollapsed((v) => !v)}
- >
- Other models
-
- {!otherModelsCollapsed &&
- otherCachedGguf.map(renderDownloadedGgufRow)}
- {!otherModelsCollapsed &&
- otherCachedModelRows.map(renderDownloadedModelRow)}
-
- ) : null}
-
- {/* Fine-tuned models: shown after the On Device scans resolve so
- downloaded sections do not reorder during startup. */}
- {section === "downloaded" && cachedReady ? (
- <>
-
-
-
- Fine-tuned
-
-
- setFineTunedCollapsed((v) => !v)}
- className="shrink-0 rounded p-1 text-muted-foreground/60 transition-colors hover:text-foreground"
- >
- {fineTunedCollapsed ? (
-
- ) : (
-
- )}
-
-
-
- {!fineTunedCollapsed && fineTunedRows.length > 0 && (
-
- )}
- >
- ) : null}
-
- {showCustom && cachedReady ? (
- <>
-
-
setShowFolderBrowser(true)}
- title="Browse folders on the server"
- className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
+ Go to custom folders
+
+
+ >
+ }
>
-
- Custom Folders
-
-
- {
- setShowFolderInput((open) => {
- if (open) {
- setFolderInput("");
- setFolderError(null);
- }
- return !open;
- });
- }}
- className="shrink-0 rounded p-1 text-muted-foreground/60 transition-colors hover:text-foreground"
- >
-
-
- setShowFolderBrowser(true)}
- className="shrink-0 rounded p-0.5 text-muted-foreground/60 transition-colors hover:text-foreground"
- >
-
-
-
-
- setCustomFoldersCollapsed((v) => !v)}
- className="shrink-0 rounded p-1 text-muted-foreground/60 transition-colors hover:text-foreground"
- >
- {customFoldersCollapsed ? (
-
- ) : (
-
- )}
-
-
-
+ {/* When other providers (LM Studio/Ollama) also show here, name
+ this group "Unsloth" so the two are easy to tell apart. */}
+ {sortedLmStudio.length > 0 ? "Unsloth" : "Downloaded"}
+
+ {!downloadedCollapsed &&
+ unslothCachedGguf.map(renderDownloadedGgufRow)}
+ {!downloadedCollapsed &&
+ unslothCachedModelRows.map(renderDownloadedModelRow)}
+ >
+ ) : null}
- {/* Folder paths */}
- {!customFoldersCollapsed &&
- scanFolders.map((f) => (
-
+
+ }
+ collapsed={otherModelsCollapsed}
+ onToggle={() => setOtherModelsCollapsed((v) => !v)}
+ >
+ Other models
+
+ {!otherModelsCollapsed &&
+ otherCachedGguf.map(renderDownloadedGgufRow)}
+ {!otherModelsCollapsed &&
+ otherCachedModelRows.map(renderDownloadedModelRow)}
+
+ ) : null}
+
+ {/* Fine-tuned models: a section above Custom Folders. Always shown on
+ On Device so the train shortcut always has a target, with an empty
+ state when none exist. */}
+ {section === "downloaded" ? (
+ <>
+
+
+
+ Fine-tuned
+
+
+ setFineTunedCollapsed((v) => !v)}
+ className="shrink-0 rounded p-1 text-muted-foreground/60 transition-colors hover:text-foreground"
+ >
+ {fineTunedCollapsed ? (
+
+ ) : (
+
+ )}
+
+
+
+ {!fineTunedCollapsed && fineTunedRows.length > 0 && (
+
+ )}
+ >
+ ) : null}
+
+ {showCustom ? (
+ <>
+
+
setShowFolderBrowser(true)}
+ title="Browse folders on the server"
+ className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
>
-
- {f.path}
-
+ Custom Folders
+
+
handleRemoveFolder(f.id)}
- aria-label={`Remove folder ${f.path}`}
- className="shrink-0 rounded p-1 text-foreground/70 transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive"
+ aria-label={
+ showFolderInput
+ ? "Cancel adding folder"
+ : "Add scan folder by path"
+ }
+ title={
+ showFolderInput ? "Cancel" : "Add by typing a path"
+ }
+ onClick={() => {
+ setShowFolderInput((open) => {
+ if (open) {
+ setFolderInput("");
+ setFolderError(null);
+ }
+ return !open;
+ });
+ }}
+ className="shrink-0 rounded p-1 text-muted-foreground/60 transition-colors hover:text-foreground"
>
-
- ))}
-
- {/* Recommended folders */}
- {!customFoldersCollapsed &&
- (() => {
- const registered = new Set(
- scanFolders.map((f) => f.path),
- );
- const unregistered = recommendedFolders.filter(
- (p) => !registered.has(p),
- );
- if (unregistered.length === 0) return null;
- return (
-
- {unregistered.map((p) => (
- void handleAddFolder(p)}
- disabled={folderLoading}
- title={`Add ${p}`}
- className="rounded-full border border-dashed border-border/50 px-2 py-0.5 font-mono text-[10px] text-muted-foreground/70 transition-colors hover:border-foreground/30 hover:bg-accent hover:text-foreground disabled:opacity-40"
- >
-
- +
- {" "}
- {p.length > 30 ? `...${p.slice(-27)}` : p}
-
- ))}
-
- );
- })()}
-
- {/* Add folder input */}
- {!customFoldersCollapsed && showFolderInput && (
-
-
-
- {
- setFolderInput(e.target.value);
- setFolderError(null);
- }}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- handleAddFolder();
- }
- if (e.key === "Escape") {
- e.preventDefault();
- e.stopPropagation();
- setShowFolderInput(false);
- setFolderInput("");
- setFolderError(null);
- }
- }}
- placeholder="/path/to/models"
- className="h-6 min-w-0 flex-1 rounded border border-border/50 bg-transparent px-1.5 font-mono text-[10px] text-foreground outline-none placeholder:text-muted-foreground/40 focus:border-foreground/20"
- disabled={folderLoading}
- autoFocus={true}
- />
setShowFolderBrowser(true)}
- disabled={folderLoading}
- aria-label="Browse for folder"
+ aria-label="Browse for a folder on the server"
title="Browse folders on the server"
- className="flex h-6 shrink-0 items-center justify-center rounded border border-border/50 px-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
+ onClick={() => setShowFolderBrowser(true)}
+ className="shrink-0 rounded p-0.5 text-muted-foreground/60 transition-colors hover:text-foreground"
>
+
+
{
- void handleAddFolder();
- }}
- disabled={folderLoading || !folderInput.trim()}
- className="h-6 shrink-0 rounded border border-border/50 px-1.5 text-[10px] text-muted-foreground transition-colors hover:bg-accent disabled:opacity-40"
+ aria-label={
+ customFoldersCollapsed
+ ? "Expand custom folders"
+ : "Collapse custom folders"
+ }
+ title={customFoldersCollapsed ? "Expand" : "Collapse"}
+ onClick={() => setCustomFoldersCollapsed((v) => !v)}
+ className="shrink-0 rounded p-1 text-muted-foreground/60 transition-colors hover:text-foreground"
>
- Add
+ {customFoldersCollapsed ? (
+
+ ) : (
+
+ )}
- {folderError && (
-
- {folderError}
-
- )}
- )}
-
{
- setFolderInput(picked);
- setFolderError(null);
- // Pass the path explicitly: `folderInput` state hasn't
- // flushed yet when "Use this folder" submits.
- void handleAddFolder(picked);
- }}
- />
-
- {/* Models from custom folders */}
- {!customFoldersCollapsed &&
- sortedCustomFolderModels.map((m) => {
- const isGgufFile = m.path.toLowerCase().endsWith(".gguf");
- // Honor the backend model_format hint (suffixless GGUF
- // folders) in addition to name/path so the row classifies
- // and loads through the same GGUF path as the filter.
- const isGguf = localModelIsGguf(m);
- // Single .gguf files (e.g. Ollama blobs) load directly;
- // GGUF repos/directories expand to pick a variant.
- const isDirectGguf = isGgufFile;
- const optionKey = makeModelOptionKey(
- "custom-folder",
- m.id,
- );
- return (
-
-
{
- if (isDirectGguf) {
- onSelect(m.id, {
- source: "local",
- isLora: false,
- isDownloaded: true,
- // Mark GGUF so "Load on selection = off" stages
- // through Run settings (matches LM Studio path).
- isGguf: true,
- });
- } else if (isGguf) {
- toggleGgufExpanded(m.id);
- } else {
- onSelect(m.id, {
- source: "local",
- isLora: false,
- isDownloaded: true,
- });
- }
- }}
- onArrowDownIntoChildren={
- isGguf && !isDirectGguf && isGgufExpanded(m.id)
- ? () => {
- const focused =
- focusFirstChildOption(optionKey);
- return focused;
- }
- : undefined
- }
- vramStatus={null}
+ {/* Folder paths */}
+ {!customFoldersCollapsed &&
+ scanFolders.map((f) => (
+
+
- {isGguf && !isDirectGguf && isGgufExpanded(m.id) && (
-
- hubModelList.focusOption(optionKey)
- }
- onNavigatePastEnd={() =>
- hubModelList.moveFocus(optionKey, "next")
- }
- gpuGb={
- gpu.available ? gpu.memoryTotalGb : undefined
- }
- systemRamGb={gpu.systemRamAvailableGb || undefined}
+
+ {f.path}
+
+ handleRemoveFolder(f.id)}
+ aria-label={`Remove folder ${f.path}`}
+ className="shrink-0 rounded p-1 text-foreground/70 transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive"
+ >
+
- )}
+
- );
- })}
- {!customFoldersCollapsed &&
- showHfSection &&
- sortedCustomFolderModels.length === 0 ? (
-
- No matching models in custom folders.
-
- ) : null}
- >
- ) : null}
+ ))}
- {section === "downloaded" &&
- cachedReady &&
- sortedLmStudio.length > 0 ? (
- <>
- setLmStudioCollapsed((v) => !v)}
- >
- LM Studio
-
- {!lmStudioCollapsed &&
- sortedLmStudio.map((m) => {
- const isGgufFile = m.path.toLowerCase().endsWith(".gguf");
- // LM Studio dirs are GGUF but rarely carry a -GGUF suffix;
- // use the shared helper (model_format hint) so the row,
- // filter, and load path agree.
- const isGguf = localModelIsGguf(m);
- const optionKey = makeModelOptionKey("lm-studio", m.id);
- return (
-
-
{
- if (isGgufFile) {
- onSelect(m.id, {
- source: "local",
- isLora: false,
- isDownloaded: true,
- isGguf: true,
- });
- } else if (isGguf) {
- toggleGgufExpanded(m.id);
- } else {
- onSelect(m.id, {
- source: "local",
- isLora: false,
- isDownloaded: true,
- });
+ {/* Recommended folders */}
+ {!customFoldersCollapsed &&
+ (() => {
+ const registered = new Set(
+ scanFolders.map((f) => f.path),
+ );
+ const unregistered = recommendedFolders.filter(
+ (p) => !registered.has(p),
+ );
+ if (unregistered.length === 0) return null;
+ return (
+
+ {unregistered.map((p) => (
+ void handleAddFolder(p)}
+ disabled={folderLoading}
+ title={`Add ${p}`}
+ className="rounded-full border border-dashed border-border/50 px-2 py-0.5 font-mono text-[10px] text-muted-foreground/70 transition-colors hover:border-foreground/30 hover:bg-accent hover:text-foreground disabled:opacity-40"
+ >
+
+ +
+ {" "}
+ {p.length > 30 ? `...${p.slice(-27)}` : p}
+
+ ))}
+
+ );
+ })()}
+
+ {/* Add folder input */}
+ {!customFoldersCollapsed && showFolderInput && (
+
+
+
+ {
+ setFolderInput(e.target.value);
+ setFolderError(null);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleAddFolder();
+ }
+ if (e.key === "Escape") {
+ e.preventDefault();
+ e.stopPropagation();
+ setShowFolderInput(false);
+ setFolderInput("");
+ setFolderError(null);
}
}}
- onArrowDownIntoChildren={
- isGguf && !isGgufFile && isGgufExpanded(m.id)
- ? () => {
- const focused =
- focusFirstChildOption(optionKey);
- return focused;
- }
- : undefined
- }
- vramStatus={null}
+ placeholder="/path/to/models"
+ className="h-6 min-w-0 flex-1 rounded border border-border/50 bg-transparent px-1.5 font-mono text-[10px] text-foreground outline-none placeholder:text-muted-foreground/40 focus:border-foreground/20"
+ disabled={folderLoading}
+ autoFocus={true}
/>
- {isGguf && !isGgufFile && isGgufExpanded(m.id) && (
-
- hubModelList.focusOption(optionKey)
- }
- onNavigatePastEnd={() =>
- hubModelList.moveFocus(optionKey, "next")
- }
- gpuGb={
- gpu.available ? gpu.memoryTotalGb : undefined
- }
- systemRamGb={gpu.systemRamAvailableGb || undefined}
+ setShowFolderBrowser(true)}
+ disabled={folderLoading}
+ aria-label="Browse for folder"
+ title="Browse folders on the server"
+ className="flex h-6 shrink-0 items-center justify-center rounded border border-border/50 px-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
+ >
+
- )}
-
- );
- })}
- >
- ) : null}
-
- {section === "downloaded" &&
- cachedReady &&
- sortedLocalDir.length > 0 ? (
- <>
-
setLocalDirCollapsed((v) => !v)}
- >
- Local models
-
- {!localDirCollapsed &&
- sortedLocalDir.map((m) => {
- // A loose ./models/*.gguf file loads directly; a GGUF repo
- // directory expands to pick a variant. The backend's local
- // variant scanner returns nothing for a config-less loose
- // file, so expanding it would dead-end at "No GGUF variants".
- const isGgufFile = m.path.toLowerCase().endsWith(".gguf");
- const isGguf = localModelIsGguf(m);
- const optionKey = makeModelOptionKey("local-dir", m.id);
- return (
-
-
+ {
- if (isGgufFile) {
- onSelect(m.id, {
- source: "local",
- isLora: false,
- isDownloaded: true,
- isGguf: true,
- });
- } else if (isGguf) {
- toggleGgufExpanded(m.id);
- } else {
- onSelect(m.id, {
- source: "local",
- isLora: false,
- isDownloaded: true,
- });
- }
+ void handleAddFolder();
}}
- onArrowDownIntoChildren={
- isGguf && !isGgufFile && isGgufExpanded(m.id)
- ? () => focusFirstChildOption(optionKey)
- : undefined
- }
- vramStatus={null}
- />
- {isGguf && !isGgufFile && isGgufExpanded(m.id) && (
-
- hubModelList.focusOption(optionKey)
- }
- onNavigatePastEnd={() =>
- hubModelList.moveFocus(optionKey, "next")
- }
- gpuGb={
- gpu.available ? gpu.memoryTotalGb : undefined
- }
- systemRamGb={gpu.systemRamAvailableGb || undefined}
- />
- )}
+ disabled={folderLoading || !folderInput.trim()}
+ className="h-6 shrink-0 rounded border border-border/50 px-1.5 text-[10px] text-muted-foreground transition-colors hover:bg-accent disabled:opacity-40"
+ >
+ Add
+
- );
- })}
- >
- ) : null}
-
- {showRecommendedSection ? (
- <>
- {recommendedSearch.isLoading &&
- recommendedRows.length === 0 ? (
-
-
-
- Loading models…
-
-
- ) : recommendedRows.length === 0 ? (
-
- No models found.
-
- ) : (
- recommendedRows.map((r) => {
- const id = r.id;
- const info = recommendedMeta.get(id);
- const isG = isKnownGgufRepo(id);
- const optionKey = makeModelOptionKey("recommended", id);
- return (
-
- {
- if (isG) {
- setExpandedGguf((prev) =>
- prev === id ? null : id,
- );
- } else {
- handleModelClick(id);
- }
- }}
- vramStatus={info?.status ?? null}
- vramEst={info?.est}
- gpuGb={
- gpu.available ? gpu.memoryTotalGb : undefined
- }
- onArrowDownIntoChildren={
- expandedGguf === id
- ? () => focusFirstChildOption(optionKey)
- : undefined
- }
- />
- {expandedGguf === id && (
-
- hubModelList.focusOption(optionKey)
- }
- onNavigatePastEnd={() =>
- hubModelList.moveFocus(optionKey, "next")
- }
- gpuGb={
- gpu.available ? gpu.memoryTotalGb : undefined
- }
- systemRamGb={gpu.systemRamAvailableGb || undefined}
- variantActions={{
- onDelete: async (quant) => {
- await deleteCachedModel(id, quant);
- refreshCachedLists();
- },
- deleteDisabled,
- }}
- />
- )}
-
- );
- })
- )}
- {recommendedSearch.hasMore && (
- <>
-
-
-
-
- >
- )}
- >
- ) : null}
-
- {showHfSection &&
- section === "recommended" &&
- filteredRecommendedIds.length > 0 ? (
- <>
- {filteredRecommendedIds.map((id) => {
- const vram = recommendedVramMap.get(id);
- const optionKey = makeModelOptionKey(
- "search-recommended",
- id,
- );
- return (
-
-
{
- if (isKnownGgufRepo(id)) {
- setExpandedGguf((prev) =>
- prev === id ? null : id,
- );
- } else {
- handleModelClick(id);
- }
- }}
- vramStatus={
- isKnownGgufRepo(id) ? null : (vram?.status ?? null)
- }
- vramEst={isKnownGgufRepo(id) ? undefined : vram?.est}
- gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
- onArrowDownIntoChildren={
- expandedGguf === id
- ? () => {
- const focused =
- focusFirstChildOption(optionKey);
- return focused;
- }
- : undefined
- }
- />
- {expandedGguf === id && (
-
- hubModelList.focusOption(optionKey)
- }
- onNavigatePastEnd={() =>
- hubModelList.moveFocus(optionKey, "next")
- }
- gpuGb={
- gpu.available ? gpu.memoryTotalGb : undefined
- }
- systemRamGb={gpu.systemRamAvailableGb || undefined}
- variantActions={{
- onDelete: async (quant) => {
- await deleteCachedModel(id, quant);
- refreshCachedLists();
- },
- deleteDisabled,
- }}
- />
+ {folderError && (
+
+ {folderError}
+
)}
- );
- })}
- >
- ) : null}
+ )}
- {showHfSection && section === "recommended" ? (
- <>
- {hfIds.length === 0 && !isLoading ? (
- filteredRecommendedIds.length === 0 ? (
+
{
+ setFolderInput(picked);
+ setFolderError(null);
+ // Pass the path explicitly: `folderInput` state hasn't
+ // flushed yet when "Use this folder" submits.
+ void handleAddFolder(picked);
+ }}
+ />
+
+ {/* Models from custom folders */}
+ {!customFoldersCollapsed &&
+ sortedCustomFolderModels.map((m) => {
+ const isGgufFile = m.path
+ .toLowerCase()
+ .endsWith(".gguf");
+ // Honor the backend model_format hint (suffixless GGUF
+ // folders) in addition to name/path so the row classifies
+ // and loads through the same GGUF path as the filter.
+ const isGguf = localModelIsGguf(m);
+ // Single .gguf files (e.g. Ollama blobs) load directly;
+ // GGUF repos/directories expand to pick a variant.
+ const isDirectGguf = isGgufFile;
+ const optionKey = makeModelOptionKey(
+ "custom-folder",
+ m.id,
+ );
+ return (
+
+
+
+ {
+ if (isDirectGguf) {
+ onSelect(m.id, localDirectGgufMeta());
+ } else if (isGguf) {
+ toggleGgufExpanded(m.id);
+ } else {
+ onSelect(m.id, localModelMeta());
+ }
+ }}
+ onArrowDownIntoChildren={
+ isGguf &&
+ !isDirectGguf &&
+ isGgufExpanded(m.id)
+ ? () => {
+ const focused =
+ focusFirstChildOption(optionKey);
+ return focused;
+ }
+ : undefined
+ }
+ vramStatus={null}
+ />
+
+ {isDirectGguf && onConfigure && (
+
+ onConfigure(m.id, localDirectGgufMeta())
+ }
+ />
+ )}
+ {!isGguf && onConfigure && (
+
+ onConfigure(m.id, localModelMeta())
+ }
+ />
+ )}
+
+ {isGguf &&
+ !isDirectGguf &&
+ isGgufExpanded(m.id) && (
+
+ hubModelList.focusOption(optionKey)
+ }
+ onNavigatePastEnd={() =>
+ hubModelList.moveFocus(optionKey, "next")
+ }
+ gpuGb={
+ gpu.available
+ ? gpu.memoryTotalGb
+ : undefined
+ }
+ systemRamGb={
+ gpu.systemRamAvailableGb || undefined
+ }
+ />
+ )}
+
+ );
+ })}
+ {!customFoldersCollapsed &&
+ showHfSection &&
+ sortedCustomFolderModels.length === 0 ? (
- No matching Unsloth models.
+ No matching models in custom folders.
- ) : null
- ) : (
- hfIds.map((id) => {
- const vram = vramMap.get(id);
- const isSearchGguf = isKnownGgufRepo(id);
- const optionKey = makeModelOptionKey("search-hf", id);
+ ) : null}
+ >
+ ) : null}
+
+ {section === "downloaded" && sortedLmStudio.length > 0 ? (
+ <>
+ setLmStudioCollapsed((v) => !v)}
+ >
+ LM Studio
+
+ {!lmStudioCollapsed &&
+ sortedLmStudio.map((m) => {
+ const isGgufFile = m.path
+ .toLowerCase()
+ .endsWith(".gguf");
+ // LM Studio dirs are GGUF but rarely carry a -GGUF suffix;
+ // use the shared helper (model_format hint) so the row,
+ // filter, and load path agree.
+ const isGguf = localModelIsGguf(m);
+ const optionKey = makeModelOptionKey("lm-studio", m.id);
+ return (
+
+
+
+ {
+ if (isGgufFile) {
+ onSelect(m.id, localDirectGgufMeta());
+ } else if (isGguf) {
+ toggleGgufExpanded(m.id);
+ } else {
+ onSelect(m.id, localModelMeta());
+ }
+ }}
+ onArrowDownIntoChildren={
+ isGguf &&
+ !isGgufFile &&
+ isGgufExpanded(m.id)
+ ? () => {
+ const focused =
+ focusFirstChildOption(optionKey);
+ return focused;
+ }
+ : undefined
+ }
+ vramStatus={null}
+ />
+
+ {isGgufFile && onConfigure && (
+
+ onConfigure(m.id, localDirectGgufMeta())
+ }
+ />
+ )}
+ {!isGguf && onConfigure && (
+
+ onConfigure(m.id, localModelMeta())
+ }
+ />
+ )}
+
+ {isGguf && !isGgufFile && isGgufExpanded(m.id) && (
+
+ hubModelList.focusOption(optionKey)
+ }
+ onNavigatePastEnd={() =>
+ hubModelList.moveFocus(optionKey, "next")
+ }
+ gpuGb={
+ gpu.available ? gpu.memoryTotalGb : undefined
+ }
+ systemRamGb={
+ gpu.systemRamAvailableGb || undefined
+ }
+ />
+ )}
+
+ );
+ })}
+ >
+ ) : null}
+
+ {section === "downloaded" && sortedLocalDir.length > 0 ? (
+ <>
+ setLocalDirCollapsed((v) => !v)}
+ >
+ Local models
+
+ {!localDirCollapsed &&
+ sortedLocalDir.map((m) => {
+ // A loose ./models/*.gguf loads directly; a GGUF repo dir
+ // expands to pick a variant. The variant scanner returns
+ // nothing for a config-less loose file, so expanding it would
+ // dead-end at "No GGUF variants".
+ const isGgufFile = m.path
+ .toLowerCase()
+ .endsWith(".gguf");
+ const isGguf = localModelIsGguf(m);
+ const optionKey = makeModelOptionKey("local-dir", m.id);
+ return (
+
+
+
+ {
+ if (isGgufFile) {
+ onSelect(m.id, localDirectGgufMeta());
+ } else if (isGguf) {
+ toggleGgufExpanded(m.id);
+ } else {
+ onSelect(m.id, localModelMeta());
+ }
+ }}
+ onArrowDownIntoChildren={
+ isGguf &&
+ !isGgufFile &&
+ isGgufExpanded(m.id)
+ ? () => focusFirstChildOption(optionKey)
+ : undefined
+ }
+ vramStatus={null}
+ />
+
+ {isGgufFile && onConfigure && (
+
+ onConfigure(m.id, localDirectGgufMeta())
+ }
+ />
+ )}
+ {!isGguf && onConfigure && (
+
+ onConfigure(m.id, localModelMeta())
+ }
+ />
+ )}
+
+ {isGguf && !isGgufFile && isGgufExpanded(m.id) && (
+
+ hubModelList.focusOption(optionKey)
+ }
+ onNavigatePastEnd={() =>
+ hubModelList.moveFocus(optionKey, "next")
+ }
+ gpuGb={
+ gpu.available ? gpu.memoryTotalGb : undefined
+ }
+ systemRamGb={
+ gpu.systemRamAvailableGb || undefined
+ }
+ />
+ )}
+
+ );
+ })}
+ >
+ ) : null}
+
+ {showRecommendedSection ? (
+ <>
+ {recommendedSearch.isLoading &&
+ recommendedRows.length === 0 ? (
+
+
+
+ Loading models…
+
+
+ ) : recommendedRows.length === 0 ? (
+
+ No models found.
+
+ ) : (
+ recommendedRows.map((r) => {
+ const id = r.id;
+ const info = recommendedMeta.get(id);
+ const isG = isKnownGgufRepo(id);
+ const optionKey = makeModelOptionKey("recommended", id);
+ return (
+
+ {
+ if (isG) {
+ setExpandedGguf((prev) =>
+ prev === id ? null : id,
+ );
+ } else {
+ handleModelClick(id);
+ }
+ }}
+ vramStatus={info?.status ?? null}
+ vramEst={info?.est}
+ gpuGb={
+ gpu.available ? gpu.memoryTotalGb : undefined
+ }
+ onArrowDownIntoChildren={
+ expandedGguf === id
+ ? () => focusFirstChildOption(optionKey)
+ : undefined
+ }
+ />
+ {expandedGguf === id && (
+
+ hubModelList.focusOption(optionKey)
+ }
+ onNavigatePastEnd={() =>
+ hubModelList.moveFocus(optionKey, "next")
+ }
+ gpuGb={
+ gpu.available ? gpu.memoryTotalGb : undefined
+ }
+ systemRamGb={
+ gpu.systemRamAvailableGb || undefined
+ }
+ variantActions={{
+ onDelete: async (quant) => {
+ await deleteCachedModel(
+ id,
+ quant,
+ hfToken || undefined,
+ );
+ prunePinnedQuantValidation(id, quant);
+ refreshCachedLists();
+ },
+ }}
+ />
+ )}
+
+ );
+ })
+ )}
+ {recommendedSearch.hasMore && (
+ <>
+
+
+
+
+ >
+ )}
+ >
+ ) : null}
+
+ {showHfSection &&
+ section === "recommended" &&
+ filteredRecommendedIds.length > 0 ? (
+ <>
+ {filteredRecommendedIds.map((id) => {
+ const vram = recommendedVramMap.get(id);
+ const optionKey = makeModelOptionKey(
+ "search-recommended",
+ id,
+ );
return (
{
- if (isSearchGguf) {
+ if (isKnownGgufRepo(id)) {
setExpandedGguf((prev) =>
prev === id ? null : id,
);
@@ -4264,9 +4079,13 @@ export function HubModelPicker({
}
}}
vramStatus={
- isSearchGguf ? null : (vram?.status ?? null)
+ isKnownGgufRepo(id)
+ ? null
+ : (vram?.status ?? null)
+ }
+ vramEst={
+ isKnownGgufRepo(id) ? undefined : vram?.est
}
- vramEst={isSearchGguf ? undefined : vram?.est}
gpuGb={
gpu.available ? gpu.memoryTotalGb : undefined
}
@@ -4284,6 +4103,7 @@ export function HubModelPicker({
@@ -4295,47 +4115,156 @@ export function HubModelPicker({
gpuGb={
gpu.available ? gpu.memoryTotalGb : undefined
}
- systemRamGb={gpu.systemRamAvailableGb || undefined}
+ systemRamGb={
+ gpu.systemRamAvailableGb || undefined
+ }
variantActions={{
onDelete: async (quant) => {
- await deleteCachedModel(id, quant);
+ await deleteCachedModel(
+ id,
+ quant,
+ hfToken || undefined,
+ );
+ prunePinnedQuantValidation(id, quant);
refreshCachedLists();
},
- deleteDisabled,
}}
/>
)}
);
- })
- )}
-
- {isLoadingMore ? (
-
-
-
- ) : null}
- >
- ) : null}
- >
- )}
+ })}
+ >
+ ) : null}
+
+ {showHfSection && section === "recommended" ? (
+ <>
+ {hfIds.length === 0 && !isLoading ? (
+ filteredRecommendedIds.length === 0 ? (
+
+ No matching Unsloth models.
+
+ ) : null
+ ) : (
+ hfIds.map((id) => {
+ const vram = vramMap.get(id);
+ const isSearchGguf = isKnownGgufRepo(id);
+ const optionKey = makeModelOptionKey("search-hf", id);
+ return (
+
+ {
+ if (isSearchGguf) {
+ setExpandedGguf((prev) =>
+ prev === id ? null : id,
+ );
+ } else {
+ handleModelClick(id);
+ }
+ }}
+ vramStatus={
+ isSearchGguf ? null : (vram?.status ?? null)
+ }
+ vramEst={isSearchGguf ? undefined : vram?.est}
+ gpuGb={
+ gpu.available ? gpu.memoryTotalGb : undefined
+ }
+ onArrowDownIntoChildren={
+ expandedGguf === id
+ ? () => {
+ const focused =
+ focusFirstChildOption(optionKey);
+ return focused;
+ }
+ : undefined
+ }
+ />
+ {expandedGguf === id && (
+
+ hubModelList.focusOption(optionKey)
+ }
+ onNavigatePastEnd={() =>
+ hubModelList.moveFocus(optionKey, "next")
+ }
+ gpuGb={
+ gpu.available ? gpu.memoryTotalGb : undefined
+ }
+ systemRamGb={
+ gpu.systemRamAvailableGb || undefined
+ }
+ variantActions={{
+ onDelete: async (quant) => {
+ await deleteCachedModel(
+ id,
+ quant,
+ hfToken || undefined,
+ );
+ prunePinnedQuantValidation(id, quant);
+ refreshCachedLists();
+ },
+ }}
+ />
+ )}
+
+ );
+ })
+ )}
+
+ {isLoadingMore ? (
+
+
+
+ ) : null}
+ >
+ ) : null}
+ >
+ )}
+
-
- {/* Floating eject pill: overlaid on the list bottom, outside the scroll
- so the edge fade never touches it. Only the pill catches clicks. */}
- {onEject ? (
-
-
-
- Eject model
-
-
- ) : null}
+ {onEject ? (
+
+
+
+ Eject model
+
+
+ ) : null}
void;
+ onConfigure?: (id: string, meta: ModelSelectorChangeMeta) => void;
onModelsChange?: (deletedModel?: DeletedModelRef) => void;
deleteDisabled?: boolean;
loraModelList: ReturnType;
@@ -4391,6 +4322,13 @@ function FineTunedRows({
const isTrainingFull = isTraining && isMerged;
const isLocalGgufDir =
isLocal && (isGgufRepo(adapter.id) || isGgufRepo(adapter.name));
+ const selectionMeta: ModelSelectorChangeMeta = {
+ source: isLocal ? "local" : isExported ? "exported" : "lora",
+ isLora: !isLocal && !isMerged && !isGguf,
+ isDownloaded: true,
+ isGguf: false,
+ };
+ const canConfigure = !(isLocalGgufDir || isExportedGguf);
const optionKey = makeModelOptionKey("lora", adapter.id);
const tag = isLocal
? isLocalGgufDir
@@ -4438,15 +4376,7 @@ function FineTunedRows({
prev === adapter.id ? null : adapter.id,
);
} else {
- onSelect(adapter.id, {
- source: isLocal
- ? "local"
- : isExported
- ? "exported"
- : "lora",
- isLora: !isLocal && !isMerged && !isGguf,
- isDownloaded: true,
- });
+ onSelect(adapter.id, selectionMeta);
}
}}
tooltipText={
@@ -4467,6 +4397,12 @@ function FineTunedRows({
}
/>
+ {canConfigure && onConfigure && (
+
onConfigure(adapter.id, selectionMeta)}
+ />
+ )}
{canDelete && (
loraModelList.focusOption(optionKey)}
onNavigatePastEnd={() =>
diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pill-tabs.tsx b/studio/frontend/src/features/model-picker/components/model-selector/pill-tabs.tsx
similarity index 98%
rename from studio/frontend/src/components/assistant-ui/model-selector/pill-tabs.tsx
rename to studio/frontend/src/features/model-picker/components/model-selector/pill-tabs.tsx
index e6da8a7b74..fbc1d5ac91 100644
--- a/studio/frontend/src/components/assistant-ui/model-selector/pill-tabs.tsx
+++ b/studio/frontend/src/features/model-picker/components/model-selector/pill-tabs.tsx
@@ -78,7 +78,8 @@ export function PillTabs({
onValueChange(tabs[next].value);
e.currentTarget.parentElement
?.querySelectorAll('button[role="tab"]')
- [next]?.focus();
+ .item(next)
+ ?.focus();
}}
onClick={() => onValueChange(tab.value)}
className={cn(
diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pinned-models.ts b/studio/frontend/src/features/model-picker/components/model-selector/pinned-models.ts
similarity index 78%
rename from studio/frontend/src/components/assistant-ui/model-selector/pinned-models.ts
rename to studio/frontend/src/features/model-picker/components/model-selector/pinned-models.ts
index 4835c4c0cf..0444b9f3cc 100644
--- a/studio/frontend/src/components/assistant-ui/model-selector/pinned-models.ts
+++ b/studio/frontend/src/features/model-picker/components/model-selector/pinned-models.ts
@@ -21,6 +21,13 @@ export interface PinnedQuantEntry {
quant: string;
}
+export function makePinRank(
+ pinned: readonly string[],
+): (key: string) => number {
+ const pinIndex = new Map(pinned.map((key, index) => [key, index]));
+ return (key) => pinIndex.get(key) ?? Number.MAX_SAFE_INTEGER;
+}
+
/** The pinned GGUF quants, in pin order. Plain repo pins are excluded. */
export function pinnedQuantEntries(pinned: string[]): PinnedQuantEntry[] {
const out: PinnedQuantEntry[] = [];
@@ -63,10 +70,20 @@ export const usePinnedModelsStore = create((set) => ({
togglePinned: (repoId, quant) =>
set((state) => {
const key = pinKey(repoId, quant);
+ // Newest pin first, so "Pin to top" literally lands on top of the
+ // pinned group rather than under earlier pins.
const next = state.pinned.includes(key)
? state.pinned.filter((id) => id !== key)
- : [...state.pinned, key];
+ : [key, ...state.pinned];
writePinned(next);
return { pinned: next };
}),
}));
+
+if (typeof window !== "undefined") {
+ window.addEventListener("storage", (event) => {
+ if (event.key === KEY || event.key === null) {
+ usePinnedModelsStore.setState({ pinned: readPinned() });
+ }
+ });
+}
diff --git a/studio/frontend/src/components/assistant-ui/model-selector/recommended-fit.ts b/studio/frontend/src/features/model-picker/components/model-selector/recommended-fit.ts
similarity index 91%
rename from studio/frontend/src/components/assistant-ui/model-selector/recommended-fit.ts
rename to studio/frontend/src/features/model-picker/components/model-selector/recommended-fit.ts
index 7c2ed266c0..b8fe47c706 100644
--- a/studio/frontend/src/components/assistant-ui/model-selector/recommended-fit.ts
+++ b/studio/frontend/src/features/model-picker/components/model-selector/recommended-fit.ts
@@ -64,9 +64,8 @@ export function matchesFormatFilter(
}
}
-// First "B" token in a repo id, e.g. "Qwen3-4B-GGUF" -> 4, "gpt-oss-20b" ->
-// 20, "Qwen3-30B-A3B" -> 30 (MoE total), "gemma-4-E4B" -> 4 (effective-param
-// "E" series). The digits must be bounded by a separator so we never read "16"
+// First "B" token in a repo id, e.g. "Qwen3-30B-A3B" -> 30 (MoE total),
+// "gemma-4-E4B" -> 4. Digits must be separator-bounded so we never read "16"
// from "bf16" or the "2" in "Kimi-K2".
const PARAM_RE = /(?:^|[-_/. ])[eE]?(\d+(?:\.\d+)?)\s*[bB](?=$|[-_./ ])/;
@@ -79,9 +78,8 @@ export function paramsFromId(id: string): number | undefined {
return Number.isFinite(billions) && billions > 0 ? billions * 1e9 : undefined;
}
-// Smallest practical GGUF/MLX quant (~Q2_K, low-bit). The fit check asks whether
-// a model can run at all, so it uses this rather than a default 4-bit size; a
-// user with a smaller device can still pick a low-bit variant.
+// Smallest practical GGUF/MLX quant (~Q2_K). The fit check asks whether a model
+// can run at all, so it uses this rather than a default 4-bit size.
const MIN_QUANT_BYTES_PER_PARAM = 0.4;
/** Rough on-disk bytes for the smallest practical quant of `params` weights. */
diff --git a/studio/frontend/src/components/assistant-ui/model-selector/row-meta.ts b/studio/frontend/src/features/model-picker/components/model-selector/row-meta.ts
similarity index 100%
rename from studio/frontend/src/components/assistant-ui/model-selector/row-meta.ts
rename to studio/frontend/src/features/model-picker/components/model-selector/row-meta.ts
diff --git a/studio/frontend/src/components/assistant-ui/model-selector/source-tabs.ts b/studio/frontend/src/features/model-picker/components/model-selector/source-tabs.ts
similarity index 100%
rename from studio/frontend/src/components/assistant-ui/model-selector/source-tabs.ts
rename to studio/frontend/src/features/model-picker/components/model-selector/source-tabs.ts
diff --git a/studio/frontend/src/components/assistant-ui/model-selector/types.ts b/studio/frontend/src/features/model-picker/components/model-selector/types.ts
similarity index 74%
rename from studio/frontend/src/components/assistant-ui/model-selector/types.ts
rename to studio/frontend/src/features/model-picker/components/model-selector/types.ts
index 6a86515267..9adf2d899e 100644
--- a/studio/frontend/src/components/assistant-ui/model-selector/types.ts
+++ b/studio/frontend/src/features/model-picker/components/model-selector/types.ts
@@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import type { ReactNode } from "react";
+import type { PerModelConfig } from "../../model-config/per-model-config";
export interface ModelOption {
id: string;
@@ -36,6 +37,19 @@ export interface ModelSelectorChangeMeta {
/** Direct local .gguf file picked without a variant (custom folder / LM
* Studio). Marks it as a GGUF source for the deferred-load staging flow. */
isGguf?: boolean;
+ config?: PerModelConfig;
+ forceReload?: boolean;
+ /** Native path token so an active-model reload can reopen a file-picked GGUF. */
+ nativePathToken?: string;
+ nativePathExpiresAtMs?: number | null;
+}
+
+export interface ModelPickTarget {
+ id: string;
+ displayName: string;
+ ggufVariant?: string | null;
+ isGguf: boolean;
+ meta: ModelSelectorChangeMeta;
}
export interface DeletedModelRef {
diff --git a/studio/frontend/src/features/model-picker/components/numeric-value-input.tsx b/studio/frontend/src/features/model-picker/components/numeric-value-input.tsx
new file mode 100644
index 0000000000..2489927fc2
--- /dev/null
+++ b/studio/frontend/src/features/model-picker/components/numeric-value-input.tsx
@@ -0,0 +1,113 @@
+// 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 { cn } from "@/lib/utils";
+import { useRef, useState } from "react";
+
+export function snapToStep(
+ value: number,
+ step: number,
+ min?: number,
+ max?: number,
+): number {
+ const lo = min ?? Number.NEGATIVE_INFINITY;
+ const hi = max ?? Number.POSITIVE_INFINITY;
+ const clamped = Math.min(Math.max(value, lo), hi);
+ const stepStr = String(step);
+ const decimals = stepStr.includes(".") ? stepStr.split(".")[1].length : 0;
+ const base = Number.isFinite(lo) ? lo : 0;
+ const snapped = base + Math.round((clamped - base) / step) * step;
+ const reclamped = Math.min(Math.max(snapped, lo), hi);
+ return Number(reclamped.toFixed(decimals));
+}
+
+function sanitizeNumeric(raw: string, allowNegative: boolean): string {
+ const sign = allowNegative && raw.startsWith("-") ? "-" : "";
+ const [head, ...rest] = raw.replace(/[^\d.]/g, "").split(".");
+ const tail = rest.length > 0 ? `.${rest.join("")}` : "";
+ return `${sign}${head}${tail}`;
+}
+
+export function NumericValueInput({
+ value,
+ min,
+ max,
+ step,
+ onChange,
+ displayValue,
+ className,
+ ariaLabel,
+ size: sizeAttr,
+ disabled = false,
+}: {
+ value: number;
+ min?: number;
+ max?: number;
+ step: number;
+ onChange: (v: number) => void;
+ displayValue?: string;
+ className?: string;
+ ariaLabel?: string;
+ size?: number;
+ disabled?: boolean;
+}) {
+ const [focused, setFocused] = useState(false);
+ const [draft, setDraft] = useState("");
+ const cancelBlurCommitRef = useRef(false);
+
+ const commit = (raw: string) => {
+ const parsed = Number.parseFloat(raw);
+ if (!Number.isFinite(parsed)) {
+ return;
+ }
+ const final = snapToStep(parsed, step, min, max);
+ if (final !== value) {
+ onChange(final);
+ }
+ };
+
+ const displayed = focused ? draft : (displayValue ?? String(value));
+
+ return (
+ {
+ cancelBlurCommitRef.current = false;
+ setDraft(String(value));
+ setFocused(true);
+ const target = e.currentTarget;
+ requestAnimationFrame(() => target.select());
+ }}
+ onBlur={() => {
+ if (cancelBlurCommitRef.current) {
+ cancelBlurCommitRef.current = false;
+ } else {
+ commit(draft);
+ }
+ setFocused(false);
+ }}
+ onChange={(e) =>
+ setDraft(sanitizeNumeric(e.target.value, (min ?? 0) < 0))
+ }
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.currentTarget.blur();
+ } else if (e.key === "Escape") {
+ cancelBlurCommitRef.current = true;
+ setDraft(String(value));
+ e.currentTarget.blur();
+ }
+ }}
+ className={cn(className)}
+ />
+ );
+}
diff --git a/studio/frontend/src/features/model-picker/components/sidebar-model-config.tsx b/studio/frontend/src/features/model-picker/components/sidebar-model-config.tsx
new file mode 100644
index 0000000000..2d12c503a4
--- /dev/null
+++ b/studio/frontend/src/features/model-picker/components/sidebar-model-config.tsx
@@ -0,0 +1,91 @@
+// 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 { useMemo } from "react";
+import { gpuFieldsSignature } from "../model-config/apply-per-model-config";
+import type { PerModelConfig } from "../model-config/per-model-config";
+import { ModelConfigPage } from "./model-config-page";
+import type { ModelPickTarget } from "./model-selector/types";
+
+interface SidebarModelConfigProps {
+ modelId: string;
+ ggufVariant: string | null;
+ isGguf: boolean;
+ nativeContextLength: number | null;
+ loadedContextLength: number | null;
+ loadedConfig: PerModelConfig;
+ onReload: (config: PerModelConfig) => void;
+}
+
+const TRAILING_SEPARATORS = /[\\/]+$/;
+
+function leafName(id: string): string {
+ const trimmed = id.replace(TRAILING_SEPARATORS, "");
+ const separator = Math.max(
+ trimmed.lastIndexOf("/"),
+ trimmed.lastIndexOf("\\"),
+ );
+ return separator >= 0 ? trimmed.slice(separator + 1) : trimmed;
+}
+
+function hashString(value: string): number {
+ let hash = 5381;
+ for (let i = 0; i < value.length; i += 1) {
+ hash = (Math.imul(hash, 33) ^ value.charCodeAt(i)) >>> 0;
+ }
+ return hash;
+}
+
+function configSignature(config: PerModelConfig): string {
+ return [
+ config.customContextLength ?? "",
+ config.maxSeqLength ?? "",
+ config.kvCacheDtype ?? "",
+ config.speculativeType ?? "",
+ config.specDraftNMax ?? "",
+ config.tensorParallel ? "1" : "0",
+ config.chatTemplateOverride == null
+ ? ""
+ : `${config.chatTemplateOverride.length}:${hashString(config.chatTemplateOverride)}`,
+ gpuFieldsSignature(config),
+ ].join("|");
+}
+
+export function SidebarModelConfig({
+ modelId,
+ ggufVariant,
+ isGguf,
+ nativeContextLength,
+ loadedContextLength,
+ loadedConfig,
+ onReload,
+}: SidebarModelConfigProps) {
+ const target = useMemo(() => {
+ const leaf = leafName(modelId);
+ return {
+ id: modelId,
+ displayName: ggufVariant ? `${leaf} · ${ggufVariant}` : leaf,
+ ggufVariant,
+ isGguf,
+ meta: {
+ source: "local",
+ isLora: false,
+ ggufVariant: ggufVariant ?? undefined,
+ isGguf,
+ isDownloaded: true,
+ contextLength: nativeContextLength,
+ },
+ };
+ }, [modelId, ggufVariant, isGguf, nativeContextLength]);
+
+ return (
+
+ );
+}
diff --git a/studio/frontend/src/features/model-picker/hooks/use-active-model-config.ts b/studio/frontend/src/features/model-picker/hooks/use-active-model-config.ts
new file mode 100644
index 0000000000..9d09ee6897
--- /dev/null
+++ b/studio/frontend/src/features/model-picker/hooks/use-active-model-config.ts
@@ -0,0 +1,77 @@
+// 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 { isExternalModelId, useChatRuntimeStore } from "@/features/chat";
+import { useMemo } from "react";
+import type { PerModelConfig } from "../model-config/per-model-config";
+
+export interface ActiveModelConfigState {
+ checkpoint: string | null;
+ isGguf: boolean;
+ config: PerModelConfig | null;
+}
+
+export function useActiveModelConfig(): ActiveModelConfigState {
+ const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint) || null;
+ const maxSeqLength = useChatRuntimeStore((s) => s.params.maxSeqLength);
+ const activeGgufVariant = useChatRuntimeStore((s) => s.activeGgufVariant);
+ const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength);
+ const customContextLength = useChatRuntimeStore((s) => s.customContextLength);
+ const kvCacheDtype = useChatRuntimeStore((s) => s.kvCacheDtype);
+ const speculativeType = useChatRuntimeStore((s) => s.speculativeType);
+ const specDraftNMax = useChatRuntimeStore((s) => s.specDraftNMax);
+ const tensorParallel = useChatRuntimeStore((s) => s.tensorParallel);
+ const chatTemplateOverride = useChatRuntimeStore(
+ (s) => s.chatTemplateOverride,
+ );
+ const gpuMemoryMode = useChatRuntimeStore((s) => s.gpuMemoryMode);
+ const gpuLayers = useChatRuntimeStore((s) => s.gpuLayers);
+ const nCpuMoe = useChatRuntimeStore((s) => s.nCpuMoe);
+ const selectedGpuIds = useChatRuntimeStore((s) => s.selectedGpuIds);
+
+ const isGguf =
+ activeGgufVariant != null ||
+ ggufContextLength != null ||
+ (checkpoint?.toLowerCase().endsWith(".gguf") ?? false);
+
+ const config = useMemo(() => {
+ if (!checkpoint || isExternalModelId(checkpoint)) {
+ return null;
+ }
+ const base: PerModelConfig = {
+ customContextLength: customContextLength ?? null,
+ maxSeqLength: isGguf ? null : maxSeqLength,
+ kvCacheDtype: kvCacheDtype ?? null,
+ speculativeType: speculativeType ?? "auto",
+ specDraftNMax: specDraftNMax ?? null,
+ tensorParallel: tensorParallel ?? false,
+ chatTemplateOverride: chatTemplateOverride ?? null,
+ };
+ if (!isGguf) {
+ return base;
+ }
+ return {
+ ...base,
+ gpuMemoryMode,
+ gpuLayers,
+ nCpuMoe,
+ selectedGpuIds,
+ };
+ }, [
+ checkpoint,
+ isGguf,
+ maxSeqLength,
+ customContextLength,
+ kvCacheDtype,
+ speculativeType,
+ specDraftNMax,
+ tensorParallel,
+ chatTemplateOverride,
+ gpuMemoryMode,
+ gpuLayers,
+ nCpuMoe,
+ selectedGpuIds,
+ ]);
+
+ return { checkpoint, isGguf, config };
+}
diff --git a/studio/frontend/src/features/model-picker/hooks/use-model-defaults.ts b/studio/frontend/src/features/model-picker/hooks/use-model-defaults.ts
new file mode 100644
index 0000000000..530e6e06d8
--- /dev/null
+++ b/studio/frontend/src/features/model-picker/hooks/use-model-defaults.ts
@@ -0,0 +1,191 @@
+// 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 { useHfTokenStore, useInventoryVersion } from "@/features/hub";
+import { useEffect, useState } from "react";
+import { fetchModelMaxPositionEmbeddings } from "../api/model-metadata";
+import { fetchDefaultChatTemplate } from "../api/templates";
+
+export interface DefaultChatTemplateState {
+ template: string | null;
+ loading: boolean;
+ error: string | null;
+}
+
+export interface ModelMaxPositionState {
+ maxPositionEmbeddings: number | null;
+ loading: boolean;
+ error: string | null;
+}
+
+const TEMPLATE_CACHE_MAX_ENTRIES = 50;
+const templateCache = new Map();
+const maxPositionCache = new Map();
+
+function cacheTemplate(key: string, template: string | null): void {
+ templateCache.delete(key);
+ templateCache.set(key, template);
+ while (templateCache.size > TEMPLATE_CACHE_MAX_ENTRIES) {
+ const oldest = templateCache.keys().next().value;
+ if (oldest === undefined) {
+ break;
+ }
+ templateCache.delete(oldest);
+ }
+}
+
+function cacheMaxPosition(key: string, value: number | null): void {
+ maxPositionCache.delete(key);
+ maxPositionCache.set(key, value);
+ while (maxPositionCache.size > TEMPLATE_CACHE_MAX_ENTRIES) {
+ const oldest = maxPositionCache.keys().next().value;
+ if (oldest === undefined) {
+ break;
+ }
+ maxPositionCache.delete(oldest);
+ }
+}
+
+export function useDefaultChatTemplate(
+ modelId: string | null,
+ ggufVariant: string | null | undefined,
+ enabled: boolean,
+ nativePathToken?: string | null,
+): DefaultChatTemplateState {
+ const token = useHfTokenStore((s) => s.token);
+ const inventoryVersion = useInventoryVersion();
+ // The native token is part of the identity: a picked GGUF resolves its
+ // template through the lease, not the model id, so two picks of the same
+ // basename must not share a cache entry.
+ const cacheKey =
+ enabled && modelId
+ ? `${modelId}::${ggufVariant ?? ""}::${token}::${inventoryVersion}::${nativePathToken ?? ""}`
+ : null;
+ const [fetched, setFetched] = useState<{
+ key: string;
+ state: DefaultChatTemplateState;
+ } | null>(null);
+
+ useEffect(() => {
+ if (cacheKey == null || !modelId || templateCache.has(cacheKey)) {
+ return;
+ }
+ const controller = new AbortController();
+ fetchDefaultChatTemplate(
+ modelId,
+ ggufVariant,
+ token,
+ controller.signal,
+ nativePathToken,
+ )
+ .then((template) => {
+ if (controller.signal.aborted) {
+ return;
+ }
+ // Cache the terminal result, including a null "no default template",
+ // so reopening the viewer for such a model reuses it instead of
+ // re-running the backend/Hugging Face lookup every time.
+ cacheTemplate(cacheKey, template);
+ setFetched({
+ key: cacheKey,
+ state: { template, loading: false, error: null },
+ });
+ })
+ .catch((err: unknown) => {
+ if (controller.signal.aborted) {
+ return;
+ }
+ setFetched({
+ key: cacheKey,
+ state: {
+ template: null,
+ loading: false,
+ error:
+ err instanceof Error ? err.message : "Failed to load template",
+ },
+ });
+ });
+
+ return () => controller.abort();
+ }, [cacheKey, modelId, ggufVariant, token, nativePathToken]);
+
+ if (cacheKey == null) {
+ return { template: null, loading: false, error: null };
+ }
+ if (templateCache.has(cacheKey)) {
+ return {
+ template: templateCache.get(cacheKey) ?? null,
+ loading: false,
+ error: null,
+ };
+ }
+ if (fetched?.key === cacheKey) {
+ return fetched.state;
+ }
+ return { template: null, loading: true, error: null };
+}
+
+export function useModelMaxPositionEmbeddings(
+ modelId: string | null,
+ enabled: boolean,
+): ModelMaxPositionState {
+ const token = useHfTokenStore((s) => s.token);
+ const inventoryVersion = useInventoryVersion();
+ const cacheKey =
+ enabled && modelId ? `${modelId}::${token}::${inventoryVersion}` : null;
+ const [fetched, setFetched] = useState<{
+ key: string;
+ state: ModelMaxPositionState;
+ } | null>(null);
+
+ useEffect(() => {
+ if (cacheKey == null || !modelId || maxPositionCache.has(cacheKey)) {
+ return;
+ }
+ const controller = new AbortController();
+ fetchModelMaxPositionEmbeddings(modelId, token, controller.signal)
+ .then((maxPositionEmbeddings) => {
+ if (controller.signal.aborted) {
+ return;
+ }
+ cacheMaxPosition(cacheKey, maxPositionEmbeddings);
+ setFetched({
+ key: cacheKey,
+ state: { maxPositionEmbeddings, loading: false, error: null },
+ });
+ })
+ .catch((err: unknown) => {
+ if (controller.signal.aborted) {
+ return;
+ }
+ setFetched({
+ key: cacheKey,
+ state: {
+ maxPositionEmbeddings: null,
+ loading: false,
+ error:
+ err instanceof Error
+ ? err.message
+ : "Failed to load model metadata",
+ },
+ });
+ });
+
+ return () => controller.abort();
+ }, [cacheKey, modelId, token]);
+
+ if (cacheKey == null) {
+ return { maxPositionEmbeddings: null, loading: false, error: null };
+ }
+ if (maxPositionCache.has(cacheKey)) {
+ return {
+ maxPositionEmbeddings: maxPositionCache.get(cacheKey) ?? null,
+ loading: false,
+ error: null,
+ };
+ }
+ if (fetched?.key === cacheKey) {
+ return fetched.state;
+ }
+ return { maxPositionEmbeddings: null, loading: true, error: null };
+}
diff --git a/studio/frontend/src/features/model-picker/index.ts b/studio/frontend/src/features/model-picker/index.ts
new file mode 100644
index 0000000000..d2b4785ec3
--- /dev/null
+++ b/studio/frontend/src/features/model-picker/index.ts
@@ -0,0 +1,39 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+export { ModelSelector } from "./components/model-selector";
+export { FolderBrowser } from "./components/model-selector/folder-browser";
+export { ModelRowMenu } from "./components/model-selector/model-row-menu";
+export {
+ makePinRank,
+ pinKey,
+ usePinnedModelsStore,
+} from "./components/model-selector/pinned-models";
+export { hfModelFitsDevice } from "./components/model-selector/recommended-fit";
+export {
+ NumericValueInput,
+ snapToStep,
+} from "./components/numeric-value-input";
+export { SidebarModelConfig } from "./components/sidebar-model-config";
+export {
+ useActiveModelConfig,
+} from "./hooks/use-active-model-config";
+export type {
+ DeletedModelRef,
+ ExternalModelOption,
+ LoraModelOption,
+ ModelOption,
+ ModelSelectorChangeMeta,
+} from "./components/model-selector";
+export {
+ applyModelLoadConfigToRuntime,
+ applyPerModelConfigToRuntime,
+ currentRuntimePerModelConfig,
+ perModelConfigsEqual,
+} from "./model-config/apply-per-model-config";
+export {
+ DEFAULT_MAX_SEQ_LENGTH,
+ normalizeMaxSeqLength,
+ type PerModelConfig,
+ resolveInitialConfig,
+} from "./model-config/per-model-config";
diff --git a/studio/frontend/src/features/model-picker/inventory/use-chat-picker-inventory.ts b/studio/frontend/src/features/model-picker/inventory/use-chat-picker-inventory.ts
new file mode 100644
index 0000000000..b46e83819c
--- /dev/null
+++ b/studio/frontend/src/features/model-picker/inventory/use-chat-picker-inventory.ts
@@ -0,0 +1,123 @@
+// 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 type {
+ CachedGgufRepo,
+ CachedModelRepo,
+ LocalModelInfo,
+} from "@/features/chat";
+import {
+ type CachedInventoryRow,
+ type LocalInventoryRow,
+ type LocalSource,
+ isHiddenModelId,
+ useHubInventory,
+} from "@/features/hub";
+import { useMemo } from "react";
+
+const PICKER_LOCAL_SOURCES: ReadonlySet = new Set([
+ "lmstudio",
+ "models_dir",
+ "custom",
+]);
+
+function isCompleteCachedRow(row: CachedInventoryRow): boolean {
+ return !row.partial && !row.liveDownload;
+}
+
+function toCachedGgufRepo(row: CachedInventoryRow): CachedGgufRepo {
+ return {
+ repo_id: row.repoId,
+ size_bytes: row.bytes,
+ cache_path: row.cachePath ?? "",
+ last_modified: row.lastModified ?? undefined,
+ has_vision: row.capabilities.supportsVision,
+ };
+}
+
+function toCachedModelRepo(row: CachedInventoryRow): CachedModelRepo {
+ return {
+ repo_id: row.repoId,
+ size_bytes: row.bytes,
+ last_modified: row.lastModified ?? undefined,
+ };
+}
+
+function toLocalModelInfo(row: LocalInventoryRow): LocalModelInfo {
+ return {
+ id: row.loadId,
+ display_name: row.displayName ?? row.title,
+ path: row.path,
+ source: row.source as LocalModelInfo["source"],
+ model_id: row.modelId ?? row.repoId,
+ model_format: row.modelFormat,
+ updated_at: row.updatedAt,
+ };
+}
+
+export interface ChatPickerInventory {
+ cachedGguf: CachedGgufRepo[];
+ cachedModels: CachedModelRepo[];
+ cachedReady: boolean;
+ localModels: LocalModelInfo[];
+ refreshInventory: () => Promise;
+}
+
+export function useChatPickerInventory(
+ options: { enabled?: boolean } = {},
+): ChatPickerInventory {
+ const inventory = useHubInventory({
+ kind: "models",
+ enabled: options.enabled,
+ includeLocal: true,
+ });
+
+ const cachedGguf = useMemo(
+ () =>
+ inventory.cachedRows
+ .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) &&
+ !isHiddenModelId(row.repoId),
+ )
+ .map(toCachedModelRepo),
+ [inventory.cachedRows],
+ );
+ const localModels = useMemo(
+ () =>
+ inventory.localRows
+ .filter(
+ (row) =>
+ PICKER_LOCAL_SOURCES.has(row.source) &&
+ // Skip non-chat rows (e.g. a folder with only config.json is
+ // classified "unknown" -> canChat false); selecting one would try to
+ // load a weightless path. toLocalModelInfo drops capabilities, so
+ // this is the only place the guard can live.
+ row.capabilities.canChat &&
+ !isHiddenModelId(row.modelId, row.repoId, row.path),
+ )
+ .map(toLocalModelInfo),
+ [inventory.localRows],
+ );
+
+ return {
+ cachedGguf,
+ cachedModels,
+ cachedReady: inventory.downloadedReady,
+ localModels,
+ refreshInventory: inventory.refreshInventory,
+ };
+}
diff --git a/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts b/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts
new file mode 100644
index 0000000000..c21d3e164a
--- /dev/null
+++ b/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts
@@ -0,0 +1,127 @@
+// 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 {
+ GPU_LAYERS_AUTO,
+ defaultInferenceParams,
+ normalizeSpeculativeType,
+ readPersistedGpuMemoryMode,
+ readPersistedSpeculativeType,
+ reconcilePersistedGpuIds,
+ useChatRuntimeStore,
+} from "@/features/chat";
+import {
+ DEFAULT_PER_MODEL_CONFIG,
+ type PerModelConfig,
+ normalizeMaxSeqLength,
+} from "./per-model-config";
+
+function cleanTemplate(value: string | null | undefined): string | null {
+ return value?.trim() ? value : null;
+}
+
+export function applyPerModelConfigToRuntime(config: PerModelConfig): void {
+ // Fall back to the standing default when the model has no saved
+ // maxSeqLength. maxSeqLength is the only per-model field carried on
+ // params (the rest are reset below), so without this a model with no
+ // remembered config would inherit the previously loaded model's value.
+ const maxSeqLength =
+ normalizeMaxSeqLength(config.maxSeqLength) ??
+ defaultInferenceParams.maxSeqLength;
+ const store = useChatRuntimeStore.getState();
+ if (maxSeqLength !== store.params.maxSeqLength) {
+ store.setParams({ ...store.params, maxSeqLength });
+ }
+ useChatRuntimeStore.setState({
+ customContextLength: config.customContextLength ?? null,
+ kvCacheDtype: config.kvCacheDtype ?? null,
+ speculativeType:
+ normalizeSpeculativeType(config.speculativeType) ??
+ readPersistedSpeculativeType(),
+ specDraftNMax: config.specDraftNMax ?? null,
+ tensorParallel: config.tensorParallel ?? false,
+ chatTemplateOverride: cleanTemplate(config.chatTemplateOverride),
+ // GPU Memory knobs are per-model (GGUF-only). Absent = defaults; the mode is
+ // a standing preference so an absent mode falls back to the persisted one.
+ // The per-GPU split ratio is never remembered, so it always resets. The GPU
+ // pick is reconciled against the GPUs present now (a saved [1] on a 1-GPU
+ // host would otherwise be sent and rejected).
+ gpuMemoryMode: config.gpuMemoryMode ?? readPersistedGpuMemoryMode(),
+ gpuLayers: config.gpuLayers ?? GPU_LAYERS_AUTO,
+ nCpuMoe: config.nCpuMoe ?? 0,
+ splitRatio: null,
+ selectedGpuIds:
+ config.selectedGpuIds !== undefined
+ ? reconcilePersistedGpuIds(config.selectedGpuIds)
+ : null,
+ });
+}
+
+export function applyModelLoadConfigToRuntime(
+ config: PerModelConfig | null | undefined,
+): boolean {
+ const hasConfig = config != null;
+ applyPerModelConfigToRuntime(config ?? DEFAULT_PER_MODEL_CONFIG);
+ return hasConfig;
+}
+
+export function currentRuntimePerModelConfig(
+ options: { includeMaxSeqLength?: boolean } = {},
+): PerModelConfig {
+ const s = useChatRuntimeStore.getState();
+ return {
+ customContextLength: s.customContextLength ?? null,
+ maxSeqLength: options.includeMaxSeqLength
+ ? normalizeMaxSeqLength(s.params.maxSeqLength)
+ : null,
+ kvCacheDtype: s.kvCacheDtype ?? null,
+ speculativeType: normalizeSpeculativeType(s.speculativeType),
+ specDraftNMax: s.specDraftNMax ?? null,
+ tensorParallel: s.tensorParallel ?? false,
+ chatTemplateOverride: cleanTemplate(s.chatTemplateOverride),
+ // Snapshot the live GPU knobs too so a failed switch rolls the previous
+ // model's GPU Memory settings back (see applyPerModelConfigToRuntime). The
+ // split ratio is intentionally never remembered.
+ gpuMemoryMode: s.gpuMemoryMode,
+ gpuLayers: s.gpuLayers,
+ nCpuMoe: s.nCpuMoe,
+ selectedGpuIds: s.selectedGpuIds,
+ };
+}
+
+export function perModelConfigsEqual(
+ a: PerModelConfig,
+ b: PerModelConfig,
+): boolean {
+ return (
+ (a.customContextLength ?? null) === (b.customContextLength ?? null) &&
+ normalizeMaxSeqLength(a.maxSeqLength) ===
+ normalizeMaxSeqLength(b.maxSeqLength) &&
+ (a.kvCacheDtype ?? null) === (b.kvCacheDtype ?? null) &&
+ normalizeSpeculativeType(a.speculativeType) ===
+ normalizeSpeculativeType(b.speculativeType) &&
+ (a.specDraftNMax ?? null) === (b.specDraftNMax ?? null) &&
+ Boolean(a.tensorParallel) === Boolean(b.tensorParallel) &&
+ cleanTemplate(a.chatTemplateOverride) ===
+ cleanTemplate(b.chatTemplateOverride) &&
+ gpuFieldsEqual(a, b)
+ );
+}
+
+// Serialize the per-model GPU knobs with the same "absent == default"
+// coalescing the store applies: mode auto/absent, gpuLayers Auto (< 0) /
+// absent, nCpuMoe 0 / absent, and the GPU pick (null / absent = all GPUs).
+export function gpuFieldsSignature(config: PerModelConfig): string {
+ return [
+ config.gpuMemoryMode ?? "auto",
+ config.gpuLayers == null || config.gpuLayers < 0 ? -1 : config.gpuLayers,
+ config.nCpuMoe ?? 0,
+ config.selectedGpuIds == null
+ ? "all"
+ : [...config.selectedGpuIds].sort((a, b) => a - b).join(","),
+ ].join("|");
+}
+
+function gpuFieldsEqual(a: PerModelConfig, b: PerModelConfig): boolean {
+ return gpuFieldsSignature(a) === gpuFieldsSignature(b);
+}
diff --git a/studio/frontend/src/features/model-picker/model-config/model-identity.ts b/studio/frontend/src/features/model-picker/model-config/model-identity.ts
new file mode 100644
index 0000000000..0caa7c1312
--- /dev/null
+++ b/studio/frontend/src/features/model-picker/model-config/model-identity.ts
@@ -0,0 +1,69 @@
+// 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 {
+ normalizeGgufVariantIdentity,
+ normalizeModelIdentity,
+} from "@/features/hub";
+
+export {
+ normalizeGgufVariantIdentity,
+ normalizeModelIdentity,
+} from "@/features/hub";
+
+const MODEL_STORAGE_KEY_PREFIX = "v2:";
+
+type ParsedModelStorageKey = {
+ modelId: string;
+ ggufVariant: string;
+};
+
+function parseVersionedModelStorageKey(
+ key: string,
+): ParsedModelStorageKey | null {
+ if (!key.startsWith(MODEL_STORAGE_KEY_PREFIX)) {
+ return null;
+ }
+ try {
+ const parsed = JSON.parse(key.slice(MODEL_STORAGE_KEY_PREFIX.length));
+ if (
+ !Array.isArray(parsed) ||
+ parsed.length !== 2 ||
+ typeof parsed[0] !== "string" ||
+ typeof parsed[1] !== "string"
+ ) {
+ return null;
+ }
+ return { modelId: parsed[0], ggufVariant: parsed[1] };
+ } catch {
+ return null;
+ }
+}
+
+export function modelStorageKey(
+ modelId: string,
+ ggufVariant?: string | null,
+): string {
+ return `${MODEL_STORAGE_KEY_PREFIX}${JSON.stringify([
+ normalizeModelIdentity(modelId),
+ normalizeGgufVariantIdentity(ggufVariant),
+ ])}`;
+}
+
+export function modelIdFromStorageKey(key: string): string | null {
+ const parsed = parseVersionedModelStorageKey(key);
+ if (parsed) {
+ return parsed.modelId;
+ }
+ const separator = key.lastIndexOf("::");
+ return separator >= 0 ? key.slice(0, separator) : null;
+}
+
+export function ggufVariantFromStorageKey(key: string): string | null {
+ const parsed = parseVersionedModelStorageKey(key);
+ if (parsed) {
+ return parsed.ggufVariant;
+ }
+ const separator = key.lastIndexOf("::");
+ return separator >= 0 ? key.slice(separator + 2) : null;
+}
diff --git a/studio/frontend/src/features/model-picker/model-config/per-model-config.ts b/studio/frontend/src/features/model-picker/model-config/per-model-config.ts
new file mode 100644
index 0000000000..0b03423736
--- /dev/null
+++ b/studio/frontend/src/features/model-picker/model-config/per-model-config.ts
@@ -0,0 +1,665 @@
+// 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 {
+ ggufVariantFromStorageKey,
+ modelIdFromStorageKey,
+ modelStorageKey,
+ normalizeGgufVariantIdentity,
+ normalizeModelIdentity,
+} from "./model-identity";
+
+export interface PerModelConfig {
+ customContextLength: number | null;
+ maxSeqLength: number | null;
+ kvCacheDtype: string | null;
+ speculativeType: string | null;
+ specDraftNMax: number | null;
+ tensorParallel: boolean;
+ chatTemplateOverride: string | null;
+ // GPU Memory controls (per-model, GGUF-only), optional so older blobs still
+ // parse. null selectedGpuIds (all GPUs) is distinct from absent. The --tensor-split
+ // ratio is deliberately not remembered: it is positionally bound to the exact
+ // GPU set/order and unvalidated.
+ gpuMemoryMode?: "auto" | "manual";
+ gpuLayers?: number;
+ nCpuMoe?: number;
+ selectedGpuIds?: number[] | null;
+}
+
+export const DEFAULT_PER_MODEL_CONFIG: PerModelConfig = {
+ customContextLength: null,
+ maxSeqLength: null,
+ kvCacheDtype: null,
+ speculativeType: null,
+ specDraftNMax: null,
+ tensorParallel: false,
+ chatTemplateOverride: null,
+};
+
+export const MAX_SEQ_LENGTH_MIN = 128;
+export const MAX_SEQ_LENGTH_MAX = 1048576;
+export const MAX_SEQ_LENGTH_STEP = 128;
+// App-default max sequence length when a non-GGUF model has no override. Both
+// paths fall back to this rather than an active model's runtime value, so an
+// unconfigured pane never inherits another model's larger context and OOMs.
+export const DEFAULT_MAX_SEQ_LENGTH = 4096;
+export const CONTEXT_LENGTH_MIN = 128;
+
+export const KV_CACHE_DTYPES = ["bf16", "q8_0", "q5_1", "q4_1"] as const;
+const VALID_KV_CACHE_DTYPES = new Set(KV_CACHE_DTYPES);
+
+export const SPECULATIVE_TYPES = [
+ "auto",
+ "mtp",
+ "ngram",
+ "mtp+ngram",
+ "off",
+] as const;
+export const MTP_SPECULATIVE_TYPES: ReadonlySet = new Set([
+ "mtp",
+ "mtp+ngram",
+]);
+
+const STORAGE_KEY = "unsloth_model_configs";
+const LEGACY_STORAGE_KEY = "unsloth_load_settings";
+const LEGACY_MIGRATION_FLAG = "unsloth_model_configs_migrated";
+const STORAGE_SCHEMA_VERSION = 1;
+const MAX_ENTRIES = 500;
+const MAX_PER_MODEL_CONFIG_STORAGE_BYTES = 1024 * 1024;
+export const MAX_CHAT_TEMPLATE_BYTES = 65_536;
+
+type StoredPerModelConfig = PerModelConfig & {
+ version: typeof STORAGE_SCHEMA_VERSION;
+};
+type StoredMap = Record;
+type RawConfig = Partial & { version?: unknown };
+
+const STORED_CONFIG_FIELDS = new Set([
+ "version",
+ "customContextLength",
+ "maxSeqLength",
+ "kvCacheDtype",
+ "speculativeType",
+ "specDraftNMax",
+ "tensorParallel",
+ "chatTemplateOverride",
+ "gpuMemoryMode",
+ "gpuLayers",
+ "nCpuMoe",
+ "selectedGpuIds",
+]);
+
+function normalizeGpuFields(partial: RawConfig): {
+ gpuMemoryMode?: "auto" | "manual";
+ gpuLayers?: number;
+ nCpuMoe?: number;
+ selectedGpuIds?: number[] | null;
+} {
+ const out: {
+ gpuMemoryMode?: "auto" | "manual";
+ gpuLayers?: number;
+ nCpuMoe?: number;
+ selectedGpuIds?: number[] | null;
+ } = {};
+ // Only "manual" is a real override; persisting "auto" would pin the model and
+ // stop it following later changes to the global GPU Memory preference.
+ if (partial.gpuMemoryMode === "manual") {
+ out.gpuMemoryMode = "manual";
+ }
+ if (
+ typeof partial.gpuLayers === "number" &&
+ Number.isFinite(partial.gpuLayers)
+ ) {
+ out.gpuLayers = Math.trunc(partial.gpuLayers);
+ }
+ if (
+ typeof partial.nCpuMoe === "number" &&
+ Number.isFinite(partial.nCpuMoe) &&
+ partial.nCpuMoe >= 0
+ ) {
+ out.nCpuMoe = Math.trunc(partial.nCpuMoe);
+ }
+ if (partial.selectedGpuIds === null) {
+ out.selectedGpuIds = null;
+ } else if (
+ Array.isArray(partial.selectedGpuIds) &&
+ partial.selectedGpuIds.every(
+ (n) => typeof n === "number" && Number.isFinite(n),
+ )
+ ) {
+ out.selectedGpuIds = partial.selectedGpuIds.map((n) => Math.trunc(n));
+ }
+ return out;
+}
+
+function canonicalizeSpeculativeType(value: string): string | null {
+ const s = value.trim().toLowerCase();
+ if (!s) {
+ return null;
+ }
+ // "auto"/"default" is the follow-global sentinel; store as null so it is never
+ // persisted as an override and global speculative-decoding changes keep applying.
+ if (s === "auto" || s === "default") {
+ return null;
+ }
+ if (s === "off") {
+ return "off";
+ }
+ if (s === "mtp" || s === "draft-mtp") {
+ return "mtp";
+ }
+ if (s === "ngram" || s === "ngram-mod" || s === "ngram-simple") {
+ return "ngram";
+ }
+ if (s === "mtp+ngram") {
+ return "mtp+ngram";
+ }
+ return null;
+}
+
+export function normalizeMaxSeqLength(value: unknown): number | null {
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
+ return null;
+ }
+ const snapped = Math.round(value / MAX_SEQ_LENGTH_STEP) * MAX_SEQ_LENGTH_STEP;
+ return Math.max(MAX_SEQ_LENGTH_MIN, Math.min(MAX_SEQ_LENGTH_MAX, snapped));
+}
+
+export function floorMaxSeqLength(value: unknown): number | null {
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
+ return null;
+ }
+ const snapped = Math.floor(value / MAX_SEQ_LENGTH_STEP) * MAX_SEQ_LENGTH_STEP;
+ return Math.max(MAX_SEQ_LENGTH_MIN, Math.min(MAX_SEQ_LENGTH_MAX, snapped));
+}
+
+function canUseStorage(): boolean {
+ return typeof window !== "undefined";
+}
+
+function serializedByteLength(value: string): number {
+ return typeof TextEncoder !== "undefined"
+ ? new TextEncoder().encode(value).byteLength
+ : value.length;
+}
+
+export function chatTemplateByteLength(value: string): number {
+ return serializedByteLength(value);
+}
+
+export function isChatTemplateWithinLimit(value: string): boolean {
+ return chatTemplateByteLength(value) <= MAX_CHAT_TEMPLATE_BYTES;
+}
+
+function serializedMapSize(map: StoredMap): number {
+ return serializedByteLength(JSON.stringify(map));
+}
+
+function serializedMapEntrySize(key: string, value: StoredMap[string]): number {
+ return (
+ serializedByteLength(JSON.stringify(key)) +
+ 1 +
+ serializedByteLength(JSON.stringify(value))
+ );
+}
+
+function deleteOldestEvictableEntry(
+ map: StoredMap,
+ protectedKeys?: ReadonlySet,
+): { key: string; value: StoredMap[string] } | null {
+ for (const key of Object.keys(map)) {
+ // Never evict a future-schema entry an older client cannot interpret.
+ if (
+ protectedKeys?.has(key) ||
+ storedConfigVersion(map[key]) > STORAGE_SCHEMA_VERSION
+ ) {
+ continue;
+ }
+ const value = map[key];
+ delete map[key];
+ return { key, value };
+ }
+ return null;
+}
+
+function enforceStorageBudget(
+ map: StoredMap,
+ protectedKeys?: ReadonlySet,
+): boolean {
+ let entryCount = Object.keys(map).length;
+ while (entryCount > MAX_ENTRIES) {
+ if (!deleteOldestEvictableEntry(map, protectedKeys)) {
+ return false;
+ }
+ entryCount -= 1;
+ }
+ let bytes = serializedMapSize(map);
+ while (bytes > MAX_PER_MODEL_CONFIG_STORAGE_BYTES) {
+ const removed = deleteOldestEvictableEntry(map, protectedKeys);
+ if (!removed) {
+ return false;
+ }
+ bytes -=
+ serializedMapEntrySize(removed.key, removed.value) +
+ (entryCount > 1 ? 1 : 0);
+ entryCount -= 1;
+ }
+ return true;
+}
+
+function storedConfigVersion(raw: unknown): number {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
+ return 0;
+ }
+ const version = (raw as RawConfig).version;
+ return typeof version === "number" && Number.isFinite(version) ? version : 0;
+}
+
+let legacyMigrationChecked = false;
+
+function parseLegacyModelKey(
+ key: string,
+): { modelId: string; ggufVariant: string | null } | null {
+ const separator = key.lastIndexOf("::");
+ if (separator >= 0) {
+ const modelId = key.slice(0, separator);
+ return modelId
+ ? { modelId, ggufVariant: key.slice(separator + 2) || null }
+ : null;
+ }
+ return key ? { modelId: key, ggufVariant: null } : null;
+}
+
+function legacyEntryToConfig(raw: Record): PerModelConfig {
+ return normalizeV1({
+ customContextLength:
+ typeof raw.contextLength === "number" ? raw.contextLength : null,
+ maxSeqLength: null,
+ kvCacheDtype:
+ typeof raw.kvCacheDtype === "string" ? raw.kvCacheDtype : null,
+ speculativeType:
+ typeof raw.speculativeType === "string" ? raw.speculativeType : null,
+ specDraftNMax:
+ typeof raw.specDraftNMax === "number" ? raw.specDraftNMax : null,
+ tensorParallel:
+ typeof raw.tensorParallel === "boolean" ? raw.tensorParallel : false,
+ chatTemplateOverride: null,
+ // Carry legacy GPU Memory knobs; normalizeGpuFields drops anything malformed.
+ gpuMemoryMode:
+ raw.gpuMemoryMode === "auto" || raw.gpuMemoryMode === "manual"
+ ? raw.gpuMemoryMode
+ : undefined,
+ gpuLayers: typeof raw.gpuLayers === "number" ? raw.gpuLayers : undefined,
+ nCpuMoe: typeof raw.nCpuMoe === "number" ? raw.nCpuMoe : undefined,
+ selectedGpuIds:
+ raw.selectedGpuIds === null
+ ? null
+ : Array.isArray(raw.selectedGpuIds)
+ ? (raw.selectedGpuIds as number[])
+ : undefined,
+ });
+}
+
+function mergeLegacyEntries(
+ map: StoredMap,
+ legacy: Record,
+): string[] {
+ const addedKeys: string[] = [];
+ for (const [legacyKey, value] of Object.entries(legacy)) {
+ if (!value || typeof value !== "object") {
+ continue;
+ }
+ const parsedKey = parseLegacyModelKey(legacyKey);
+ if (!parsedKey) {
+ continue;
+ }
+ const migrated = legacyEntryToConfig(value as Record);
+ const key = modelStorageKey(parsedKey.modelId, parsedKey.ggufVariant);
+ if (isDefaultConfig(migrated) || Object.hasOwn(map, key)) {
+ continue;
+ }
+ map[key] = toStoredConfig(migrated);
+ addedKeys.push(key);
+ }
+ return addedKeys;
+}
+
+function migrateLegacyLoadSettingsOnce(): void {
+ if (legacyMigrationChecked || !canUseStorage()) {
+ return;
+ }
+ legacyMigrationChecked = true;
+ try {
+ if (localStorage.getItem(LEGACY_MIGRATION_FLAG)) {
+ return;
+ }
+ 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;
+ }
+ const map = readMapRaw();
+ // Snapshot existing entries so eviction can protect them: importing old load
+ // settings must never discard a newer per-model config the user already has.
+ const existingKeys = new Set(Object.keys(map));
+ const migratedKeys = mergeLegacyEntries(
+ map,
+ legacy as Record,
+ );
+ if (migratedKeys.length === 0) {
+ localStorage.setItem(LEGACY_MIGRATION_FLAG, "1");
+ return;
+ }
+ // Protect pre-existing entries so only just-migrated legacy entries are
+ // dropped when over budget.
+ if (!enforceStorageBudget(map, existingKeys)) {
+ return;
+ }
+ if (writeMap(map)) {
+ localStorage.setItem(LEGACY_MIGRATION_FLAG, "1");
+ }
+ } catch (err) {
+ console.warn("Failed to migrate legacy load settings:", err);
+ }
+}
+
+function readMapRaw(): StoredMap {
+ if (!canUseStorage()) {
+ return {};
+ }
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) {
+ return {};
+ }
+ const parsed = JSON.parse(raw);
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return {};
+ }
+ return parsed as StoredMap;
+ } catch {
+ return {};
+ }
+}
+
+function readMap(): StoredMap {
+ migrateLegacyLoadSettingsOnce();
+ return readMapRaw();
+}
+
+function writeMap(map: StoredMap): boolean {
+ if (!canUseStorage()) {
+ return false;
+ }
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
+ return true;
+ } catch (err) {
+ console.warn("Failed to persist per-model config:", err);
+ return false;
+ }
+}
+
+function warnDroppedFields(raw: Record, version: number): void {
+ if (!import.meta.env?.DEV) {
+ return;
+ }
+ const dropped = Object.keys(raw).filter(
+ (key) => !STORED_CONFIG_FIELDS.has(key),
+ );
+ if (dropped.length > 0) {
+ console.warn("Dropped unknown per-model config fields:", dropped);
+ }
+ if (version > STORAGE_SCHEMA_VERSION) {
+ console.warn("Per-model config schema is newer than this app:", version);
+ }
+}
+
+function normalizeV1(partial: RawConfig): PerModelConfig {
+ const rawSpecType =
+ typeof partial.speculativeType === "string"
+ ? canonicalizeSpeculativeType(partial.speculativeType)
+ : null;
+ const speculativeType = rawSpecType ?? DEFAULT_PER_MODEL_CONFIG.speculativeType;
+ const specDraftNMax =
+ speculativeType != null &&
+ MTP_SPECULATIVE_TYPES.has(speculativeType) &&
+ typeof partial.specDraftNMax === "number" &&
+ Number.isFinite(partial.specDraftNMax)
+ ? Math.max(1, Math.min(16, Math.round(partial.specDraftNMax)))
+ : null;
+ return {
+ customContextLength:
+ typeof partial.customContextLength === "number" &&
+ Number.isFinite(partial.customContextLength) &&
+ partial.customContextLength > 0
+ ? Math.max(CONTEXT_LENGTH_MIN, Math.floor(partial.customContextLength))
+ : null,
+ maxSeqLength: normalizeMaxSeqLength(partial.maxSeqLength),
+ kvCacheDtype:
+ typeof partial.kvCacheDtype === "string" &&
+ VALID_KV_CACHE_DTYPES.has(partial.kvCacheDtype)
+ ? partial.kvCacheDtype
+ : null,
+ speculativeType,
+ specDraftNMax,
+ tensorParallel:
+ typeof partial.tensorParallel === "boolean"
+ ? partial.tensorParallel
+ : DEFAULT_PER_MODEL_CONFIG.tensorParallel,
+ chatTemplateOverride:
+ typeof partial.chatTemplateOverride === "string" &&
+ isChatTemplateWithinLimit(partial.chatTemplateOverride)
+ ? partial.chatTemplateOverride
+ : null,
+ ...normalizeGpuFields(partial),
+ };
+}
+
+function normalize(raw: unknown): PerModelConfig {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
+ return normalizeV1({});
+ }
+ const partial = raw as RawConfig;
+ const version =
+ typeof partial.version === "number" && Number.isFinite(partial.version)
+ ? partial.version
+ : 0;
+ warnDroppedFields(raw as Record, version);
+ return normalizeV1(partial);
+}
+
+function toStoredConfig(config: PerModelConfig): StoredPerModelConfig {
+ return {
+ version: STORAGE_SCHEMA_VERSION,
+ ...normalize(config),
+ };
+}
+
+function legacyModelStorageKey(
+ modelId: string,
+ ggufVariant?: string | null,
+): string {
+ return `${modelId}::${ggufVariant ?? ""}`;
+}
+
+function storageKeysForModelVariant(
+ modelId: string,
+ ggufVariant?: string | null,
+): string[] {
+ const key = modelStorageKey(modelId, ggufVariant);
+ const legacyKey = legacyModelStorageKey(modelId, ggufVariant);
+ return key === legacyKey ? [key] : [key, legacyKey];
+}
+
+function configKeyMatchesModelVariant(
+ key: string,
+ modelId: string,
+ ggufVariant?: string | null,
+): boolean {
+ const storedModelId = modelIdFromStorageKey(key);
+ if (!storedModelId) {
+ return false;
+ }
+ return (
+ normalizeModelIdentity(storedModelId) === normalizeModelIdentity(modelId) &&
+ normalizeGgufVariantIdentity(ggufVariantFromStorageKey(key)) ===
+ normalizeGgufVariantIdentity(ggufVariant)
+ );
+}
+
+function findConfigKeyForModelVariant(
+ map: StoredMap,
+ modelId: string,
+ ggufVariant?: string | null,
+): string | null {
+ for (const key of storageKeysForModelVariant(modelId, ggufVariant)) {
+ if (Object.hasOwn(map, key)) {
+ return key;
+ }
+ }
+ for (const key of Object.keys(map)) {
+ if (configKeyMatchesModelVariant(key, modelId, ggufVariant)) {
+ return key;
+ }
+ }
+ return null;
+}
+
+function hasFutureConfigForModelVariant(
+ map: StoredMap,
+ modelId: string,
+ ggufVariant?: string | null,
+): boolean {
+ for (const key of Object.keys(map)) {
+ if (
+ configKeyMatchesModelVariant(key, modelId, ggufVariant) &&
+ storedConfigVersion(map[key]) > STORAGE_SCHEMA_VERSION
+ ) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function deleteConfigEntriesForModelVariant(
+ map: StoredMap,
+ modelId: string,
+ ggufVariant?: string | null,
+): boolean {
+ let changed = false;
+ for (const key of Object.keys(map)) {
+ if (!configKeyMatchesModelVariant(key, modelId, ggufVariant)) {
+ continue;
+ }
+ delete map[key];
+ changed = true;
+ }
+ return changed;
+}
+
+function loadPerModelConfig(
+ modelId: string,
+ ggufVariant?: string | null,
+): PerModelConfig | null {
+ const map = readMap();
+ const key = findConfigKeyForModelVariant(map, modelId, ggufVariant);
+ if (!key) {
+ return null;
+ }
+ // Never apply a future-schema record an older client cannot interpret.
+ if (storedConfigVersion(map[key]) > STORAGE_SCHEMA_VERSION) {
+ return null;
+ }
+ return normalize(map[key]);
+}
+
+export function isDefaultConfig(config: PerModelConfig): boolean {
+ return (
+ config.customContextLength == null &&
+ config.maxSeqLength == null &&
+ (config.kvCacheDtype ?? null) === DEFAULT_PER_MODEL_CONFIG.kvCacheDtype &&
+ config.speculativeType === DEFAULT_PER_MODEL_CONFIG.speculativeType &&
+ config.specDraftNMax == null &&
+ Boolean(config.tensorParallel) ===
+ Boolean(DEFAULT_PER_MODEL_CONFIG.tensorParallel) &&
+ (config.chatTemplateOverride ?? null) === null &&
+ gpuFieldsAtDefault(config)
+ );
+}
+
+// GPU knobs are "default" when mode is Auto with no explicit choice: mode
+// auto/absent, gpuLayers < 0/absent, nCpuMoe 0/absent, selectedGpuIds null/absent.
+function gpuFieldsAtDefault(config: PerModelConfig): boolean {
+ return (
+ (config.gpuMemoryMode ?? "auto") === "auto" &&
+ (config.gpuLayers == null || config.gpuLayers < 0) &&
+ (config.nCpuMoe == null || config.nCpuMoe === 0) &&
+ config.selectedGpuIds == null
+ );
+}
+
+export function savePerModelConfig(
+ modelId: string,
+ ggufVariant: string | null | undefined,
+ config: PerModelConfig,
+): boolean {
+ if (
+ typeof config.chatTemplateOverride === "string" &&
+ !isChatTemplateWithinLimit(config.chatTemplateOverride)
+ ) {
+ return false;
+ }
+ const normalized = normalize(config);
+ const map = readMap();
+ if (hasFutureConfigForModelVariant(map, modelId, ggufVariant)) {
+ return false;
+ }
+ if (isDefaultConfig(normalized)) {
+ const changed = deleteConfigEntriesForModelVariant(
+ map,
+ modelId,
+ ggufVariant,
+ );
+ return changed ? writeMap(map) : true;
+ }
+ const [key] = storageKeysForModelVariant(modelId, ggufVariant);
+ deleteConfigEntriesForModelVariant(map, modelId, ggufVariant);
+ map[key] = toStoredConfig(normalized);
+ if (!enforceStorageBudget(map, new Set([key]))) {
+ return false;
+ }
+ return writeMap(map);
+}
+
+export function deletePerModelConfig(
+ modelId: string,
+ ggufVariant?: string | null,
+): boolean {
+ const map = readMap();
+ // Mirror savePerModelConfig: never let an older client destroy a future-schema entry.
+ if (hasFutureConfigForModelVariant(map, modelId, ggufVariant)) {
+ return false;
+ }
+ if (!deleteConfigEntriesForModelVariant(map, modelId, ggufVariant)) {
+ return true;
+ }
+ return writeMap(map);
+}
+
+export function resolveInitialConfig(
+ modelId: string,
+ ggufVariant?: string | null,
+): { config: PerModelConfig; remembered: boolean } {
+ const saved = loadPerModelConfig(modelId, ggufVariant);
+ if (saved) {
+ return { config: saved, remembered: true };
+ }
+ return { config: { ...DEFAULT_PER_MODEL_CONFIG }, remembered: false };
+}
diff --git a/studio/frontend/src/features/settings/tabs/chat-tab.tsx b/studio/frontend/src/features/settings/tabs/chat-tab.tsx
index f7f3bccad6..a6fcf87c57 100644
--- a/studio/frontend/src/features/settings/tabs/chat-tab.tsx
+++ b/studio/frontend/src/features/settings/tabs/chat-tab.tsx
@@ -16,7 +16,6 @@ import {
Folder01Icon,
McpServerIcon,
PencilRulerIcon,
- Settings02Icon,
ShieldBanIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
@@ -148,10 +147,6 @@ export function ChatTab() {
const hydratePersistedSettings = useChatRuntimeStore(
(state) => state.hydratePersistedSettings,
);
- const loadOnSelection = useChatRuntimeStore((state) => state.loadOnSelection);
- const setLoadOnSelection = useChatRuntimeStore(
- (state) => state.setLoadOnSelection,
- );
const expandQuantizations = useChatRuntimeStore(
(state) => state.expandQuantizations,
);
@@ -193,40 +188,6 @@ export function ChatTab() {
-
- On: Unsloth auto-picks the best settings and loads it.
-
- Off: opens Run settings to customize, then load.
-
- The gear always opens Run settings:{" "}
-
-
- Q4_K_M
-
-
- downloaded
-
- 16 GB
-
-
-
-
-
- }
- >
-
-
{
const encoded = encodeURIComponent(modelName);
- const query = hfToken?.trim() ? `?hf_token=${encodeURIComponent(hfToken.trim())}` : "";
- const response = await authFetch(`/api/models/check-vision/${encoded}${query}`);
+ const response = await authFetch(`/api/models/check-vision/${encoded}`, {
+ headers: hubTokenHeader(hfToken?.trim() || null),
+ });
if (!response.ok) {
// If the check fails (e.g. network error), default to non-vision
return false;
@@ -114,8 +116,9 @@ export async function checkEmbeddingModel(
hfToken?: string | null,
): Promise {
const encoded = encodeURIComponent(modelName);
- const query = hfToken?.trim() ? `?hf_token=${encodeURIComponent(hfToken.trim())}` : "";
- const response = await authFetch(`/api/models/check-embedding/${encoded}${query}`);
+ const response = await authFetch(`/api/models/check-embedding/${encoded}`, {
+ headers: hubTokenHeader(hfToken?.trim() || null),
+ });
if (!response.ok) {
// If the check fails (e.g. network error), default to non-embedding
return false;
@@ -130,8 +133,10 @@ export async function getModelConfig(
hfToken?: string,
): Promise {
const encoded = encodeURIComponent(modelName);
- const params = hfToken ? `?hf_token=${encodeURIComponent(hfToken)}` : "";
- const response = await authFetch(`/api/models/config/${encoded}${params}`, { signal });
+ const response = await authFetch(`/api/models/config/${encoded}`, {
+ headers: hubTokenHeader(hfToken?.trim() || null),
+ signal,
+ });
if (!response.ok) {
throw new Error(`Failed to fetch model config (${response.status})`);
}
diff --git a/studio/frontend/src/features/training/index.ts b/studio/frontend/src/features/training/index.ts
index a0d249ff1b..81514b4415 100644
--- a/studio/frontend/src/features/training/index.ts
+++ b/studio/frontend/src/features/training/index.ts
@@ -25,8 +25,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,
diff --git a/tests/studio/install/test_rocm_support.py b/tests/studio/install/test_rocm_support.py
index b94578369d..f7dcd79d10 100644
--- a/tests/studio/install/test_rocm_support.py
+++ b/tests/studio/install/test_rocm_support.py
@@ -1557,7 +1557,7 @@ class TestWorkerRocmMambaSsm:
assert "getattr(torch.version, 'hip', None)" in source
def test_direct_wheel_url_returns_none_without_cuda_major(self, monkeypatch):
- """_direct_wheel_url should return None when cuda_major is empty (ROCm)."""
+ """direct_wheel_url should return None when cuda_major is empty (ROCm)."""
_worker_spec = importlib.util.spec_from_file_location("test_worker", _WORKER_PATH)
assert _worker_spec is not None and _worker_spec.loader is not None
worker_mod = importlib.util.module_from_spec(_worker_spec)
@@ -1583,7 +1583,7 @@ class TestWorkerRocmMambaSsm:
"hip_version": "7.1.12345",
"cxx11abi": "TRUE",
}
- result = worker_mod._direct_wheel_url(
+ result = worker_mod.direct_wheel_url(
filename_prefix = "causal_conv1d",
package_version = "1.6.1",
release_tag = "v1.6.1.post4",
diff --git a/tests/studio/playwright_chat_ui.py b/tests/studio/playwright_chat_ui.py
index b00b45f97a..4d13889878 100644
--- a/tests/studio/playwright_chat_ui.py
+++ b/tests/studio/playwright_chat_ui.py
@@ -821,10 +821,16 @@ with sync_playwright() as p:
last_assistant = page.locator('[data-role="assistant"]').last
last_assistant.hover()
page.wait_for_timeout(400)
- regen_btn = page.get_by_role(
- "button",
- name = re.compile(r"(reload|regenerate)", re.I),
- ).first
+ # Exclude disabled controls: the picker's new disabled "Reload model"
+ # button also matches and sorts first, so .first would target it.
+ regen_btn = (
+ page.get_by_role(
+ "button",
+ name = re.compile(r"(reload|regenerate)", re.I),
+ )
+ .and_(page.locator("button:not([disabled])"))
+ .first
+ )
if regen_btn.count() > 0:
regen_btn.click()
try:
diff --git a/tests/studio/playwright_model_config.py b/tests/studio/playwright_model_config.py
new file mode 100644
index 0000000000..a8d143a253
--- /dev/null
+++ b/tests/studio/playwright_model_config.py
@@ -0,0 +1,740 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Model-picker per-model-config Playwright regression test (GPU-free, CPU gemma).
+
+Guards, end to end against the real frontend, the exact regressions that got the
+predecessor PR reverted:
+
+ - Context Length persists: set a distinctive per-model Context Length + tick
+ "Remember for this model" + Load; the value reaches the /api/inference/load
+ request (max_seq_length) AND lands in localStorage (unsloth_model_configs),
+ and survives a full browser reload (HARD).
+ - Reset clears: after customizing, Reset must clear the stored override, never
+ pin the context to a fixed number (the "Reset pins context" regression) (HARD).
+ - Hidden infra models absent: the RAG embedder (bge-small-en-v1.5) and the
+ llama.cpp validation probe (stories260K) never appear in the picker. The
+ probe GGUF is primed into the HF cache by the CI job, so "absent" proves
+ "hidden", not "not downloaded" (HARD).
+ - Legacy migration is idempotent: a pre-feature unsloth_load_settings store
+ migrates once into the versioned unsloth_model_configs map with the value
+ preserved, and a second reload with a fresh legacy seed present does not
+ re-migrate, duplicate, or clobber (gates under STUDIO_UI_STRICT via soft_fail).
+ - Advanced settings persist: KV cache dtype / tensor-parallel toggled under
+ Advanced + Remember land in unsloth_model_configs (best-effort).
+
+Runs as a plain script (not via pytest), mirroring tests/studio/playwright_extra_ui.py:
+accumulate failures in `_failed`, exit non-zero if any HARD gate failed. With
+STUDIO_UI_STRICT=1 (as CI sets), soft_fail also gates; genuinely-optional checks
+use runtime_warn so they never flake the merge gate.
+"""
+
+import json
+import re
+import sys
+import os
+import time
+from pathlib import Path
+
+from playwright.sync_api import sync_playwright
+
+# Run as a plain script (not via pytest), so prepend the dir to sys.path.
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from _playwright_robust import ( # noqa: E402
+ chromium_launch_args,
+ click_and_wait_for_response,
+ evaluate_fetch,
+ install_view_transition_killer,
+ install_wall_clock_watchdog,
+ is_benign_page_error,
+ recover_or_replace_page,
+ robust_evaluate,
+ wait_for_health,
+)
+
+BASE = os.environ["BASE_URL"]
+NEW = os.environ.get("STUDIO_NEW_PW", "ModelCfg-NEW-2026!")
+# Attach mode: log into an already-provisioned Studio with an existing password
+# instead of the first-boot change-password dance. CI leaves STUDIO_LOGIN_PW unset
+# to exercise the real change-password flow; local runs can set it to skip re-provisioning.
+LOGIN_PW = os.environ.get("STUDIO_LOGIN_PW")
+LOGIN_USER = os.environ.get("STUDIO_LOGIN_USER", "unsloth")
+GGUF_REPO = os.environ.get("GGUF_REPO", "unsloth/gemma-3-270m-it-GGUF")
+GGUF_VARIANT = os.environ.get("GGUF_VARIANT", "UD-Q4_K_XL")
+# Substring of the On Device picker row for the loaded model.
+MODEL_HINT = os.environ.get("STUDIO_MODEL_HINT", "gemma-3-270m")
+# A distinctive valid (>=128, multiple of 128, below the model's 32768 ceiling)
+# Context Length, clearly not a default, so persistence is unambiguous.
+DISTINCT_CTX = int(os.environ.get("STUDIO_DISTINCT_CTX", "4096"))
+ART_DIR = os.environ.get("PW_ART_DIR", "logs/playwright_modelcfg")
+ART = Path(ART_DIR)
+ART.mkdir(parents = True, exist_ok = True)
+STRICT = os.environ.get("STUDIO_UI_STRICT", "0") == "1"
+TURN_TIMEOUT_MS = int(os.environ.get("STUDIO_UI_TURN_TIMEOUT_MS", "180000"))
+WALL_TIMEOUT_S = float(os.environ.get("STUDIO_UI_WALL_TIMEOUT_S", "720"))
+FETCH_TIMEOUT_MS = int(os.environ.get("STUDIO_UI_FETCH_TIMEOUT_MS", "30000"))
+LOAD_FETCH_TIMEOUT_MS = int(os.environ.get("STUDIO_UI_LOAD_TIMEOUT_MS", "180000"))
+
+_n = [0]
+_failed: list[str] = []
+
+
+def step(s: str) -> None:
+ print(f"[ui-modelcfg] STEP {s}", flush = True)
+
+
+def info(s: str) -> None:
+ print(f"[ui-modelcfg] {s}", flush = True)
+
+
+def fail(m: str) -> None:
+ print(f"[ui-modelcfg] FAIL: {m}", flush = True)
+ _failed.append(m)
+
+
+def soft_fail(m: str) -> None:
+ if STRICT:
+ fail(m)
+ else:
+ info(f"WARN (strict-off): {m}")
+
+
+def runtime_warn(m: str) -> None:
+ """Warn about a genuinely-optional check that STRICT does not gate."""
+ info(f"WARN (runtime): {m}")
+
+
+def _count(loc) -> int:
+ try:
+ return loc.count()
+ except Exception:
+ return 0
+
+
+def _as_int(value) -> int | None:
+ """Parse an input value to int, tolerating commas/whitespace. Comparisons
+ must be numeric, never substring: '40960' (a model's native default) would
+ spuriously "contain" '4096'."""
+ if value is None:
+ return None
+ try:
+ return int(str(value).replace(",", "").strip())
+ except Exception:
+ return None
+
+
+def _login_token_via_api(base: str, user: str, pw: str) -> str:
+ """POST /api/auth/login -> access_token (attach-mode helper, stdlib only)."""
+ import urllib.request
+
+ req = urllib.request.Request(
+ f"{base}/api/auth/login",
+ data = json.dumps({"username": user, "password": pw}).encode(),
+ headers = {"Content-Type": "application/json"},
+ method = "POST",
+ )
+ with urllib.request.urlopen(req, timeout = 15) as r:
+ return json.loads(r.read().decode())["access_token"]
+
+
+with sync_playwright() as p:
+ _watchdog = install_wall_clock_watchdog(
+ WALL_TIMEOUT_S,
+ label = "ui-modelcfg",
+ info = info,
+ )
+ # Health pre-flight: bash-side health wait can pass before the auth DB migrates.
+ wait_for_health(BASE, timeout = 30.0, info = info)
+ browser = p.chromium.launch(
+ headless = True,
+ args = chromium_launch_args(),
+ )
+ ctx = browser.new_context(
+ viewport = {"width": 1280, "height": 900},
+ reduced_motion = "reduce",
+ )
+ install_view_transition_killer(ctx)
+ page = ctx.new_page()
+ page.set_default_timeout(60_000)
+ page_errors = []
+
+ def _on_pageerror(e):
+ msg = str(e)
+ if is_benign_page_error(msg):
+ info(f"WARN ignoring benign pageerror: {msg!r}")
+ return
+ page_errors.append(msg)
+
+ page.on("pageerror", _on_pageerror)
+
+ # Record every /api/inference/load POST payload so the persistence gate can
+ # assert max_seq_length.
+ load_posts: list[str] = []
+
+ def _on_request(req):
+ try:
+ if req.method == "POST" and "/api/inference/load" in req.url:
+ load_posts.append(req.post_data or "")
+ except Exception:
+ pass
+
+ page.on("request", _on_request)
+
+ def shoot(name: str) -> None:
+ _n[0] += 1
+ try:
+ page.screenshot(
+ path = str(ART / f"{_n[0]:02d}-{name}.png"),
+ full_page = True,
+ timeout = 90_000,
+ animations = "disabled",
+ )
+ except Exception as _shoot_err:
+ info(f"WARN: screenshot {name} failed: {_shoot_err}")
+
+ def read_configs() -> dict:
+ """Return the parsed unsloth_model_configs map (or {} if absent/invalid)."""
+ raw = robust_evaluate(page, "() => localStorage.getItem('unsloth_model_configs')")
+ if not raw:
+ return {}
+ try:
+ data = json.loads(raw)
+ return data if isinstance(data, dict) else {}
+ except Exception:
+ return {}
+
+ def config_entries(cfg: dict) -> list[dict]:
+ """The per-model entries (dict values) of the stored map, schema-tolerant."""
+ return [v for v in cfg.values() if isinstance(v, dict)]
+
+ # ─────────────────────────────────────────────────────
+ # Setup: authenticate + model load.
+ # ─────────────────────────────────────────────────────
+ if LOGIN_PW:
+ # Attach mode: log in via the API and seed the token before navigation,
+ # skipping the first-boot change-password dance.
+ step("setup: API login + token seed (attach to running Studio)")
+ _tok = _login_token_via_api(BASE, LOGIN_USER, LOGIN_PW)
+ ctx.add_init_script(
+ f"try{{localStorage.setItem('unsloth_auth_token', {json.dumps(_tok)});}}"
+ f"catch(e){{}}"
+ )
+ page.goto(BASE, wait_until = "domcontentloaded", timeout = 60_000)
+ else:
+ step("setup: change-password")
+ # 3-attempt retry: the form can re-render mid-fill on slow runners and
+ # detach the password fields; each retry re-navigates with a fresh page.
+ form_err: Exception | None = None
+ for _form_attempt in range(3):
+ try:
+ page.goto(f"{BASE}/change-password", wait_until = "domcontentloaded", timeout = 60_000)
+ try:
+ page.wait_for_load_state("networkidle", timeout = 30_000)
+ except Exception:
+ pass
+ pw_field = page.locator("#new-password")
+ pw_field.wait_for(state = "visible", timeout = 60_000)
+ pw_field.fill(NEW, timeout = 60_000)
+ page.fill("#confirm-password", NEW, timeout = 60_000)
+ status, _ = click_and_wait_for_response(
+ page,
+ url_substr = "/api/auth/change-password",
+ method = "POST",
+ do_click = lambda: page.locator('button[type="submit"]').click(),
+ timeout_ms = 30_000,
+ info = lambda m: print(f"[ui-modelcfg] {m}", flush = True),
+ )
+ if status is not None and status >= 400:
+ raise AssertionError(
+ f"change-password POST returned {status}; page_errors={page_errors[:1]!r}"
+ )
+ form_err = None
+ break
+ except Exception as e:
+ form_err = e
+ try:
+ cur_url = page.url
+ except Exception:
+ cur_url = ""
+ print(
+ f"[ui-modelcfg] change-password attempt {_form_attempt + 1} failed: "
+ f"{type(e).__name__}: {str(e)[:200]}; page.url={cur_url}; "
+ f"page_errors={len(page_errors)}",
+ flush = True,
+ )
+ if _form_attempt < 2:
+ if "ERR_NO_BUFFER_SPACE" in str(e):
+ backoff_s = 5 if _form_attempt == 0 else 15
+ time.sleep(backoff_s)
+ page = recover_or_replace_page(
+ page,
+ ctx,
+ default_timeout_ms = 60_000,
+ info = lambda m: print(f"[ui-modelcfg] recovery: {m}", flush = True),
+ )
+ page.on("request", _on_request)
+ if form_err is not None:
+ raise form_err
+
+ try:
+ page.wait_for_load_state("networkidle", timeout = 30_000)
+ except Exception:
+ pass
+ composer = page.locator('textarea[aria-label="Message input"]')
+ last_err: Exception | None = None
+ for _attempt in range(2):
+ try:
+ composer.wait_for(state = "visible", timeout = 60_000)
+ last_err = None
+ break
+ except Exception as e:
+ last_err = e
+ try:
+ shoot(f"00-composer-wait-attempt-{_attempt + 1}-fail")
+ except Exception:
+ pass
+ if _attempt == 0:
+ page = recover_or_replace_page(
+ page,
+ ctx,
+ default_timeout_ms = 60_000,
+ goto_url = BASE,
+ settle_networkidle = True,
+ info = lambda m: print(f"[ui-modelcfg] recovery: {m}", flush = True),
+ )
+ page.on("request", _on_request)
+ composer = page.locator('textarea[aria-label="Message input"]')
+ if last_err is not None:
+ raise last_err
+ shoot("01-chat-loaded")
+
+ token = robust_evaluate(page, "() => localStorage.getItem('unsloth_auth_token')")
+ if not token:
+ fail("no access token after auth setup")
+ sys.exit(1)
+
+ # Load the tiny GGUF so it is a live "On Device" model in the picker.
+ load_resp = evaluate_fetch(
+ page,
+ f"{BASE}/api/inference/load",
+ method = "POST",
+ headers = {
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json",
+ },
+ body = {
+ "model_path": GGUF_REPO,
+ "gguf_variant": GGUF_VARIANT,
+ "is_lora": False,
+ "max_seq_length": 2048,
+ },
+ timeout_ms = LOAD_FETCH_TIMEOUT_MS,
+ )
+ if load_resp.get("error"):
+ fail(f"/api/inference/load wedged: {load_resp['error']!r}")
+ sys.exit(1)
+ if load_resp["status"] != 200:
+ fail(f"/api/inference/load -> {load_resp['status']}: {load_resp.get('body')!r}")
+ sys.exit(1)
+ info(f"loaded model: {(load_resp['body'] or {}).get('display_name')}")
+ page.reload()
+ composer = page.locator('textarea[aria-label="Message input"]')
+ composer.wait_for(state = "visible", timeout = 60_000)
+ load_posts.clear() # drop the setup load; keep only UI-driven loads below.
+
+ # ─────────────────────────────────────────────────────
+ # Picker helpers (proven selectors).
+ # ─────────────────────────────────────────────────────
+ POPOVER = '[data-tour="chat-model-selector-popover"]'
+ TRIGGER = '[data-tour="chat-model-selector"]'
+
+ def open_picker():
+ popover = page.locator(POPOVER).first
+ if _count(popover) == 0 or not popover.is_visible():
+ page.locator(TRIGGER).first.click()
+ page.wait_for_timeout(900)
+ popover = page.locator(POPOVER).first
+ popover.wait_for(state = "visible", timeout = 30_000)
+ return popover
+
+ def close_picker():
+ try:
+ page.keyboard.press("Escape")
+ page.wait_for_timeout(400)
+ except Exception:
+ pass
+
+ def select_on_device_row(popover, hint):
+ od = page.get_by_role("tab", name = "On Device").first
+ if _count(od):
+ od.click()
+ page.wait_for_timeout(700)
+ row = popover.locator("[data-model-picker-option]", has_text = hint).first
+ if _count(row) == 0:
+ # Fall back to search filtering.
+ search = popover.locator("[data-model-picker-search-input]").first
+ if _count(search):
+ search.click()
+ search.fill(hint)
+ page.wait_for_timeout(700)
+ row = popover.locator("[data-model-picker-option]", has_text = hint).first
+ if _count(row) == 0:
+ return None
+ row.click()
+ page.wait_for_timeout(800)
+ return row
+
+ def open_config(popover, hint):
+ if select_on_device_row(popover, hint) is None:
+ return None
+ gear = popover.locator('button[aria-label^="Inference settings for"]').first
+ if _count(gear) == 0:
+ return None
+ gear.click()
+ page.wait_for_timeout(800)
+ return popover
+
+ def context_input(popover):
+ for role in ("textbox", "spinbutton"):
+ loc = popover.get_by_role(role, name = "Context Length").first
+ if _count(loc):
+ return loc
+ loc = popover.locator('input[aria-label="Context Length"]').first
+ return loc if _count(loc) else None
+
+ def primary_button(popover):
+ for name in ("Load model", "Reload model", "Save settings", "Forget settings"):
+ b = popover.get_by_role("button", name = name).first
+ if _count(b):
+ return b
+ return None
+
+ # ─────────────────────────────────────────────────────
+ # 1. Hidden infra models absent from the picker (HARD).
+ # ─────────────────────────────────────────────────────
+ step("hidden infra models absent from picker")
+ popover = open_picker()
+ shoot("02-picker-open")
+ needles = ["bge-small-en-v1.5", "stories260"]
+ tabs = ["Recommended", "On Device", "Connected"]
+ hidden_ok = True
+ for needle in needles:
+ for tab_name in tabs:
+ tab = page.get_by_role("tab", name = tab_name).first
+ if _count(tab) == 0:
+ continue
+ try:
+ tab.click()
+ page.wait_for_timeout(400)
+ except Exception:
+ continue
+ search = popover.locator("[data-model-picker-search-input]").first
+ if _count(search):
+ search.click()
+ search.fill(needle)
+ page.wait_for_timeout(600)
+ hit = popover.locator(
+ "[data-model-picker-option]",
+ has_text = re.compile(re.escape(needle), re.I),
+ )
+ c = _count(hit)
+ if c > 0:
+ hidden_ok = False
+ fail(f"infra model {needle!r} visible in picker '{tab_name}' tab ({c} rows)")
+ if _count(search):
+ search.fill("")
+ page.wait_for_timeout(300)
+ if hidden_ok:
+ info("OK hidden: bge-small-en-v1.5 + stories260K absent from every picker tab")
+ shoot("03-hidden-check")
+ close_picker()
+
+ # ─────────────────────────────────────────────────────
+ # 2. Context Length persists (load + request + reload) (HARD).
+ # ─────────────────────────────────────────────────────
+ step(f"context length {DISTINCT_CTX} persists")
+ popover = open_picker()
+ if open_config(popover, MODEL_HINT) is None:
+ fail(f"could not open run-settings for a model matching {MODEL_HINT!r}")
+ else:
+ shoot("04-config-open")
+ ctx_in = context_input(popover)
+ if ctx_in is None:
+ fail("Context Length input not found in run-settings")
+ else:
+ default_ctx = ctx_in.input_value()
+ info(f"default Context Length shown: {default_ctx!r}")
+ ctx_in.click()
+ ctx_in.fill(str(DISTINCT_CTX))
+ page.wait_for_timeout(300)
+ page.keyboard.press("Tab") # blur to commit
+ page.wait_for_timeout(300)
+ remember = popover.get_by_label("Remember for this model").first
+ if _count(remember):
+ try:
+ remember.check()
+ except Exception:
+ remember.click()
+ else:
+ fail("'Remember for this model' checkbox not found")
+ page.wait_for_timeout(300)
+ shoot("05-ctx-set")
+ btn = primary_button(popover)
+ if btn is None:
+ fail("primary Load/Save button not found in run-settings")
+ else:
+ btn.click()
+ page.wait_for_timeout(2500)
+ shoot("06-after-load")
+
+ # (a) localStorage stored the distinctive context.
+ cfg = read_configs()
+ entries = config_entries(cfg)
+ got_ls = any(e.get("customContextLength") == DISTINCT_CTX for e in entries)
+ if got_ls:
+ info(f"OK persist(localStorage): customContextLength={DISTINCT_CTX} stored")
+ else:
+ fail(
+ "context not stored in unsloth_model_configs "
+ f"(entries={json.dumps(entries)[:400]})"
+ )
+
+ # (b) the load request carried max_seq_length == distinctive value.
+ got_req = False
+ for body in load_posts:
+ try:
+ payload = json.loads(body) if body else {}
+ except Exception:
+ payload = {}
+ if payload.get("max_seq_length") == DISTINCT_CTX:
+ got_req = True
+ break
+ if got_req:
+ info(f"OK persist(request): /api/inference/load max_seq_length={DISTINCT_CTX}")
+ else:
+ # The UI may debounce the load; localStorage is the primary
+ # proof, so only warn if the request was missed.
+ runtime_warn(
+ "no /api/inference/load carried "
+ f"max_seq_length={DISTINCT_CTX}; posts={load_posts!r}"
+ )
+
+ # (c) survives a full browser reload.
+ close_picker()
+ page.reload()
+ composer = page.locator('textarea[aria-label="Message input"]')
+ composer.wait_for(state = "visible", timeout = 60_000)
+ popover = open_picker()
+ if open_config(popover, MODEL_HINT) is None:
+ fail("could not reopen run-settings after reload")
+ else:
+ ctx_in = context_input(popover)
+ val = ctx_in.input_value() if ctx_in else None
+ if _as_int(val) == DISTINCT_CTX:
+ info(f"OK persist(reload): Context Length still {val!r} after reload")
+ else:
+ fail(f"Context Length did not persist across reload (got {val!r})")
+ shoot("07-after-reload")
+
+ # ─────────────────────────────────────────────────────
+ # 3. Reset clears the override (never pins context) (HARD).
+ # ─────────────────────────────────────────────────────
+ step("reset clears the per-model override")
+ # (popover + config still open from the reload check.)
+ reset_btn = popover.get_by_role("button", name = "Reset").first
+ if _count(reset_btn) == 0:
+ fail("Reset button not found in run-settings")
+ else:
+ try:
+ reset_btn.click()
+ page.wait_for_timeout(500)
+ except Exception as e:
+ fail(f"Reset click failed: {e}")
+ # The input after Reset is informational only: a live-loaded model can still
+ # echo its context even with the stored override gone. The regression we
+ # guard ("Reset PINS the override") lives in localStorage, asserted below.
+ ctx_in = context_input(popover)
+ after_reset = ctx_in.input_value() if ctx_in else None
+ info(f"reset: Context Length input now shows {after_reset!r}")
+ # Commit the reset so the stored override is dropped, then assert storage.
+ btn = primary_button(popover)
+ if btn is not None and btn.is_enabled():
+ btn.click()
+ page.wait_for_timeout(1500)
+ cfg = read_configs()
+ pinned = any(
+ _as_int(e.get("customContextLength")) == DISTINCT_CTX for e in config_entries(cfg)
+ )
+ if pinned:
+ fail("Reset left the distinctive context pinned in unsloth_model_configs")
+ else:
+ info("OK reset: distinctive context cleared from unsloth_model_configs")
+ shoot("08-after-reset")
+ close_picker()
+
+ # ─────────────────────────────────────────────────────
+ # 4. Advanced settings persist (best-effort, never gates).
+ # ─────────────────────────────────────────────────────
+ step("advanced (KV cache dtype / tensor parallel) persists")
+ try:
+ popover = open_picker()
+ if open_config(popover, MODEL_HINT) is not None:
+ adv = popover.get_by_role("switch", name = re.compile("advanced settings", re.I)).first
+ if _count(adv):
+ try:
+ adv.check()
+ except Exception:
+ adv.click()
+ page.wait_for_timeout(500)
+ # The Tensor Parallelism Radix Switch has no aria-label, so target the
+ # first switch after the "Tensor Parallelism" text.
+ tp = popover.locator(
+ 'xpath=.//span[contains(text(),"Tensor Parallelism")]'
+ '/following::*[@role="switch"][1]'
+ ).first
+ toggled = False
+ if _count(tp):
+ try:
+ tp.click()
+ toggled = True
+ except Exception:
+ pass
+ remember = popover.get_by_label("Remember for this model").first
+ if _count(remember):
+ try:
+ remember.check()
+ except Exception:
+ remember.click()
+ btn = primary_button(popover)
+ if btn is not None and btn.is_enabled():
+ btn.click()
+ page.wait_for_timeout(1500)
+ cfg = read_configs()
+ has_adv = any(
+ e.get("tensorParallel") or e.get("kvCacheDtype") for e in config_entries(cfg)
+ )
+ if toggled and has_adv:
+ info("OK advanced: tensorParallel/kvCacheDtype persisted")
+ else:
+ runtime_warn(
+ f"advanced persistence not observed (toggled={toggled}, "
+ f"entries={json.dumps(config_entries(cfg))[:300]})"
+ )
+ else:
+ runtime_warn("could not open run-settings for the advanced-persist check")
+ close_picker()
+ except Exception as e:
+ runtime_warn(f"advanced-persist check errored: {e}")
+
+ # ─────────────────────────────────────────────────────
+ # 5. Legacy migration is idempotent (gates in CI via soft_fail).
+ # Seed a pre-feature unsloth_load_settings store, confirm it migrates once
+ # with the value preserved, then reload with a fresh legacy seed and confirm
+ # the migration does not re-run, duplicate, or clobber. Re-running on every
+ # reload was the regression that reverted the predecessor PR.
+ # ─────────────────────────────────────────────────────
+ step("legacy unsloth_load_settings migrates once and stays idempotent")
+ try:
+ legacy_key = f"{GGUF_REPO}::{GGUF_VARIANT}"
+ legacy = {
+ legacy_key: {
+ "contextLength": DISTINCT_CTX,
+ "kvCacheDtype": "q8_0",
+ "tensorParallel": True,
+ }
+ }
+ robust_evaluate(
+ page,
+ "(seed) => {"
+ " localStorage.setItem('unsloth_load_settings', JSON.stringify(seed));"
+ " localStorage.removeItem('unsloth_model_configs');"
+ " localStorage.removeItem('unsloth_model_configs_migrated');"
+ " return true;"
+ "}",
+ arg = legacy,
+ )
+ page.reload()
+ composer = page.locator('textarea[aria-label="Message input"]')
+ composer.wait_for(state = "visible", timeout = 60_000)
+ # Opening the picker config forces the store to read (which migrates).
+ popover = open_picker()
+ open_config(popover, MODEL_HINT)
+ page.wait_for_timeout(800)
+ cfg_first = read_configs()
+ migrated_ctx = any(
+ e.get("customContextLength") == DISTINCT_CTX for e in config_entries(cfg_first)
+ )
+ if migrated_ctx:
+ info(f"OK migration: legacy context {DISTINCT_CTX} preserved after migrating")
+ else:
+ soft_fail(
+ f"legacy context {DISTINCT_CTX} not migrated into unsloth_model_configs "
+ f"(got {json.dumps(cfg_first)[:400]})"
+ )
+ flag_first = robust_evaluate(
+ page, "() => localStorage.getItem('unsloth_model_configs_migrated')"
+ )
+ if flag_first != "1":
+ soft_fail(f"migration flag not set after migrating (got {flag_first!r})")
+ shoot("09-after-migration")
+ close_picker()
+
+ # Idempotency: a second reload with a DIFFERENT legacy entry must not re-run
+ # the migration (the persistent flag blocks it), so the new key must not leak
+ # in, nothing duplicates, and the migrated value is untouched.
+ if migrated_ctx:
+ probe_key = "unsloth/__idem_probe__::Q4_K_M"
+ robust_evaluate(
+ page,
+ "(seed) => {"
+ " localStorage.setItem('unsloth_load_settings', JSON.stringify(seed));"
+ " return true;"
+ "}",
+ arg = {probe_key: {"contextLength": DISTINCT_CTX + 2048, "tensorParallel": True}},
+ )
+ page.reload()
+ composer.wait_for(state = "visible", timeout = 60_000)
+ popover = open_picker()
+ open_config(popover, MODEL_HINT)
+ page.wait_for_timeout(800)
+ cfg_second = read_configs()
+ keys_first = set(cfg_first.keys())
+ keys_second = set(cfg_second.keys())
+ new_keys = keys_second - keys_first
+ still_has_ctx = any(
+ e.get("customContextLength") == DISTINCT_CTX for e in config_entries(cfg_second)
+ )
+ if new_keys:
+ soft_fail(
+ "legacy migration re-ran on a second reload (persistent flag "
+ f"ignored): new keys {sorted(new_keys)}"
+ )
+ elif keys_second != keys_first:
+ soft_fail(
+ "legacy migration dropped entries on a second reload: "
+ f"{sorted(keys_first)} -> {sorted(keys_second)}"
+ )
+ elif not still_has_ctx:
+ soft_fail("legacy migration clobbered the migrated context on a second reload")
+ else:
+ info(
+ "OK migration idempotent: second reload did not re-migrate, duplicate, or clobber"
+ )
+ shoot("10-after-second-reload")
+ close_picker()
+ except Exception as e:
+ soft_fail(f"migration idempotency check errored: {e}")
+
+ # ─────────────────────────────────────────────────────
+ if page_errors:
+ fail(f"page errors during run: {page_errors[:3]!r}")
+
+ browser.close()
+
+if _failed:
+ print(f"[ui-modelcfg] RESULT: FAIL ({len(_failed)} issue(s))", flush = True)
+ for m in _failed:
+ print(f"[ui-modelcfg] - {m}", flush = True)
+ sys.exit(1)
+print("[ui-modelcfg] RESULT: PASS", flush = True)
+sys.exit(0)
diff --git a/tests/studio/test_cached_model_path_selection.py b/tests/studio/test_cached_model_path_selection.py
new file mode 100644
index 0000000000..2b6b9c7829
--- /dev/null
+++ b/tests/studio/test_cached_model_path_selection.py
@@ -0,0 +1,241 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Variant-file selection guards for the cached-model-path endpoint.
+
+The Copy path / Reveal endpoint must resolve a quant label to the same file
+the variant menus offer: MTP drafters, mmproj vision adapters, and big-endian
+builds are excluded, and directory layouts (``BF16/model-00001-of-....gguf``)
+resolve their label from the snapshot-relative path, not the basename.
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+
+def _find_repo_root() -> Path | None:
+ env = os.environ.get("UNSLOTH_REPO_ROOT")
+ if env:
+ p = Path(env).resolve()
+ if (p / "studio" / "backend").is_dir():
+ return p
+ here = Path(__file__).resolve()
+ for parent in (here, *here.parents):
+ if (parent / "studio" / "backend").is_dir():
+ return parent
+ return None
+
+
+_REPO_ROOT = _find_repo_root()
+if _REPO_ROOT is None:
+ pytest.skip(
+ "Could not locate studio/backend. Set UNSLOTH_REPO_ROOT or run from "
+ "the repository checkout.",
+ allow_module_level = True,
+ )
+
+_STUDIO_BACKEND = _REPO_ROOT / "studio" / "backend"
+if str(_STUDIO_BACKEND) not in sys.path:
+ sys.path.insert(0, str(_STUDIO_BACKEND))
+
+pytest.importorskip("fastapi")
+pytest.importorskip("huggingface_hub")
+
+try:
+ from routes import models as routes_models
+except Exception as exc:
+ pytest.skip(f"studio backend import unavailable: {exc}", allow_module_level = True)
+
+from fastapi import HTTPException
+
+
+def test_plain_quant_label_resolves():
+ assert routes_models._main_variant_gguf_label("Model-Q8_0.gguf") == "Q8_0"
+
+
+def test_mtp_drafter_in_subdir_is_excluded():
+ assert routes_models._main_variant_gguf_label("MTP/Model-Q8_0-MTP.gguf") is None
+
+
+def test_mtp_drafter_root_prefix_is_excluded():
+ assert routes_models._main_variant_gguf_label("mtp-Model-Q8_0.gguf") is None
+
+
+def test_mmproj_adapter_is_excluded():
+ assert routes_models._main_variant_gguf_label("mmproj-Model-F16.gguf") is None
+
+
+def test_directory_layout_quant_resolves_from_parent_dir():
+ assert routes_models._main_variant_gguf_label("BF16/Model-00001-of-00002.gguf") == "BF16"
+
+
+def test_big_endian_build_is_excluded():
+ assert routes_models._main_variant_gguf_label("Model-Q8_0-BE.gguf") is None
+
+
+def test_non_gguf_file_is_excluded():
+ assert routes_models._main_variant_gguf_label("config.json") is None
+
+
+def test_normalized_quant_label_ignores_separators():
+ assert routes_models._normalized_quant_label("UD-Q4_K_XL") == "udq4kxl"
+ assert routes_models._normalized_quant_label("Q8-0") == routes_models._normalized_quant_label(
+ "Q8_0"
+ )
+
+
+def _revision(
+ snapshot: Path,
+ last_modified: float,
+ names: list[str],
+ size_on_disk: int = 4,
+) -> SimpleNamespace:
+ files = []
+ for name in names:
+ path = snapshot / name
+ path.parent.mkdir(parents = True, exist_ok = True)
+ path.write_bytes(b"x" * size_on_disk)
+ files.append(
+ SimpleNamespace(
+ file_name = name,
+ file_path = path,
+ blob_path = path,
+ size_on_disk = size_on_disk,
+ )
+ )
+ return SimpleNamespace(snapshot_path = snapshot, last_modified = last_modified, files = files)
+
+
+def _patch_cache(monkeypatch, tmp_path: Path, revisions: list[SimpleNamespace]) -> None:
+ repo = SimpleNamespace(
+ repo_id = "Org/Repo",
+ repo_type = "model",
+ repo_path = tmp_path,
+ revisions = revisions,
+ )
+ monkeypatch.setattr(
+ routes_models, "_all_hf_cache_scans", lambda: [SimpleNamespace(repos = [repo])]
+ )
+
+
+def _repo(root: Path, revisions: list[SimpleNamespace]) -> SimpleNamespace:
+ return SimpleNamespace(
+ repo_id = "Org/Repo",
+ repo_type = "model",
+ repo_path = root,
+ revisions = revisions,
+ )
+
+
+def _patch_caches(monkeypatch, repos: list[SimpleNamespace]) -> None:
+ monkeypatch.setattr(
+ routes_models,
+ "_all_hf_cache_scans",
+ lambda: [SimpleNamespace(repos = [repo]) for repo in repos],
+ )
+
+
+@pytest.mark.parametrize("newest_first", [True, False])
+def test_variant_resolves_from_newest_revision(monkeypatch, tmp_path, newest_first):
+ old = _revision(tmp_path / "snapshots" / "aaa", 1_000.0, ["Model-Q4_K_M.gguf"])
+ new = _revision(tmp_path / "snapshots" / "bbb", 2_000.0, ["Model-Q4_K_M.gguf"])
+ _patch_cache(monkeypatch, tmp_path, [new, old] if newest_first else [old, new])
+ resolved = routes_models._resolve_cached_model_path("Org/Repo", "Q4_K_M")
+ assert resolved == tmp_path / "snapshots" / "bbb" / "Model-Q4_K_M.gguf"
+
+
+def test_sharded_variant_resolves_first_split(monkeypatch, tmp_path):
+ rev = _revision(
+ tmp_path / "snapshots" / "aaa",
+ 1_000.0,
+ ["Model-Q4_K_M-00002-of-00002.gguf", "Model-Q4_K_M-00001-of-00002.gguf"],
+ )
+ _patch_cache(monkeypatch, tmp_path, [rev])
+ resolved = routes_models._resolve_cached_model_path("Org/Repo", "Q4_K_M")
+ assert resolved.name == "Model-Q4_K_M-00001-of-00002.gguf"
+
+
+def test_variant_only_in_older_revision_resolves(monkeypatch, tmp_path):
+ old = _revision(tmp_path / "snapshots" / "aaa", 1_000.0, ["Model-Q4_K_M.gguf"])
+ new = _revision(tmp_path / "snapshots" / "bbb", 2_000.0, ["Model-Q8_0.gguf"])
+ _patch_cache(monkeypatch, tmp_path, [new, old])
+ resolved = routes_models._resolve_cached_model_path("Org/Repo", "Q4_K_M")
+ assert resolved == tmp_path / "snapshots" / "aaa" / "Model-Q4_K_M.gguf"
+
+
+def test_missing_newest_file_falls_back_to_older_revision(monkeypatch, tmp_path):
+ old = _revision(tmp_path / "snapshots" / "aaa", 1_000.0, ["Model-Q4_K_M.gguf"])
+ new_snapshot = tmp_path / "snapshots" / "bbb"
+ new_snapshot.mkdir(parents = True)
+ new = SimpleNamespace(
+ snapshot_path = new_snapshot,
+ last_modified = 2_000.0,
+ files = [
+ SimpleNamespace(
+ file_name = "Model-Q4_K_M.gguf",
+ file_path = new_snapshot / "Model-Q4_K_M.gguf",
+ )
+ ],
+ )
+ _patch_cache(monkeypatch, tmp_path, [new, old])
+ resolved = routes_models._resolve_cached_model_path("Org/Repo", "Q4_K_M")
+ assert resolved == tmp_path / "snapshots" / "aaa" / "Model-Q4_K_M.gguf"
+
+
+def test_variant_resolves_across_all_cache_roots(monkeypatch, tmp_path):
+ first_root = tmp_path / "active"
+ second_root = tmp_path / "default"
+ old = _revision(
+ first_root / "snapshots" / "aaa",
+ 1_000.0,
+ ["Model-Q4_K_M.gguf"],
+ )
+ new = _revision(
+ second_root / "snapshots" / "bbb",
+ 2_000.0,
+ ["Model-Q4_K_M.gguf"],
+ )
+ _patch_caches(
+ monkeypatch,
+ [_repo(first_root, [old]), _repo(second_root, [new])],
+ )
+ resolved = routes_models._resolve_cached_model_path("Org/Repo", "Q4_K_M")
+ assert resolved == second_root / "snapshots" / "bbb" / "Model-Q4_K_M.gguf"
+
+
+def test_repo_path_matches_largest_visible_cache_entry(monkeypatch, tmp_path):
+ first_root = tmp_path / "active"
+ second_root = tmp_path / "default"
+ small = _revision(
+ first_root / "snapshots" / "aaa",
+ 2_000.0,
+ ["Model-Q8_0.gguf"],
+ size_on_disk = 4,
+ )
+ large = _revision(
+ second_root / "snapshots" / "bbb",
+ 1_000.0,
+ ["Model-Q8_0.gguf"],
+ size_on_disk = 8,
+ )
+ _patch_caches(
+ monkeypatch,
+ [_repo(first_root, [small]), _repo(second_root, [large])],
+ )
+ resolved = routes_models._resolve_cached_model_path("Org/Repo", None)
+ assert resolved == second_root / "snapshots" / "bbb"
+
+
+def test_unknown_variant_raises_404(monkeypatch, tmp_path):
+ rev = _revision(tmp_path / "snapshots" / "aaa", 1_000.0, ["Model-Q8_0.gguf"])
+ _patch_cache(monkeypatch, tmp_path, [rev])
+ with pytest.raises(HTTPException) as excinfo:
+ routes_models._resolve_cached_model_path("Org/Repo", "Q4_K_M")
+ assert excinfo.value.status_code == 404
+ assert "Q4_K_M" in excinfo.value.detail
diff --git a/tests/studio/test_gpu_inference_smoke.py b/tests/studio/test_gpu_inference_smoke.py
new file mode 100644
index 0000000000..a28166e1e0
--- /dev/null
+++ b/tests/studio/test_gpu_inference_smoke.py
@@ -0,0 +1,65 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Fast, GPU-gated real-inference smoke.
+
+GitHub-hosted CI runners have no GPU, so this AUTO-SKIPS there; the full picker
+-> load -> chat flow is covered on CPU by tests/studio/playwright_model_config.py
+and studio-ui-smoke.yml. This test adds a quick real-generation check for local
+dev and self-hosted GPU runners: it loads the smallest model (gemma-3-270m-it)
+on the GPU and does a single short greedy generation, asserting a non-empty
+reply. Kept deliberately short (a handful of new tokens) so it is a confidence
+check, not a benchmark. Select/deselect it by name, e.g. `-k gpu_generation`.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+torch = pytest.importorskip("torch")
+
+# Smallest instruct model in the CI fixture family; ~270M params loads and
+# generates a few tokens in seconds on any GPU.
+MODEL_ID = "unsloth/gemma-3-270m-it"
+# A handful of forced real tokens: enough to prove GPU decode produced content,
+# short enough to stay a few seconds.
+MIN_NEW_TOKENS = 4
+MAX_NEW_TOKENS = 16
+
+
+@pytest.mark.skipif(not torch.cuda.is_available(), reason = "requires a CUDA GPU")
+def test_gpu_generation_smoke():
+ try:
+ from transformers import AutoModelForCausalLM, AutoTokenizer
+ except Exception as exc: # pragma: no cover - env without transformers
+ pytest.skip(f"transformers unavailable: {exc}")
+
+ # Gemma is numerically unstable in fp16 (it emits only ); use bf16 where
+ # supported, else fp32. The model is tiny, so fp32 is still fast.
+ dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float32
+ try:
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
+ model = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype = dtype).to("cuda")
+ except Exception as exc: # offline / gated / download failure is not a code defect
+ pytest.skip(f"could not fetch/load {MODEL_ID}: {exc}")
+
+ model.eval()
+ messages = [{"role": "user", "content": "Say hello in one word."}]
+ inputs = tokenizer.apply_chat_template(
+ messages, add_generation_prompt = True, return_dict = True, return_tensors = "pt"
+ ).to("cuda")
+ prompt_len = inputs["input_ids"].shape[1]
+
+ with torch.no_grad():
+ output = model.generate(
+ **inputs,
+ min_new_tokens = MIN_NEW_TOKENS,
+ max_new_tokens = MAX_NEW_TOKENS,
+ do_sample = False,
+ )
+
+ # The model produced new tokens on the GPU (the real inference proof)...
+ assert output.shape[1] > prompt_len, "no tokens were generated on the GPU"
+ # ...and they decode to non-empty text (min_new_tokens forces real content).
+ reply = tokenizer.decode(output[0][prompt_len:], skip_special_tokens = True)
+ assert reply.strip(), "expected a non-empty GPU generation"
diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py
new file mode 100644
index 0000000000..20335a279c
--- /dev/null
+++ b/tests/studio/test_model_picker_contracts.py
@@ -0,0 +1,407 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Source-contract guards for the model-picker per-model-config feature.
+
+These are cheap, CPU-only, no-browser checks that read the frontend source and
+assert the specific fixes that got the predecessor PR reverted stay in place. If
+a future edit reverts one of them (e.g. rounds the context ceiling up again, or
+puts the HF token back in the URL), the matching assertion reddens. They pair
+with the runtime Playwright checks (which prove the behavior end to end) and the
+backend pytest checks (which prove the backend logic).
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+WORKDIR = Path(__file__).resolve().parents[2]
+FRONTEND = WORKDIR / "studio" / "frontend" / "src"
+
+
+def _read(rel: str) -> str:
+ path = FRONTEND / rel
+ assert path.exists(), f"missing source file: {path}"
+ return path.read_text()
+
+
+def test_models_api_sends_token_via_header_not_query():
+ """getModelConfig / checkVisionModel / checkEmbeddingModel must pass the HF
+ token through hubTokenHeader, never as a ?hf_token= query param (which leaks
+ the credential into server/proxy access logs)."""
+ src = _read("features/training/api/models-api.ts")
+ assert src.count("hubTokenHeader(") >= 3
+ assert "hf_token=" not in src
+ assert '"hf_token"' not in src and "'hf_token'" not in src
+
+
+def test_model_metadata_probe_never_puts_token_in_query():
+ src = _read("features/model-picker/api/model-metadata.ts")
+ assert "hf_token=" not in src
+ assert '"hf_token"' not in src and "'hf_token'" not in src
+
+
+def test_model_config_page_floors_the_context_ceiling():
+ """The model's native max-context must be FLOORED to the step grid, never
+ rounded up (rounding up can offer/persist a length above the model's real
+ ceiling and break loading)."""
+ src = _read("features/model-picker/components/model-config-page.tsx")
+ assert "floorMaxSeqLength(modelMaxPosition.maxPositionEmbeddings)" in src
+ assert "normalizeMaxSeqLength(modelMaxPosition.maxPositionEmbeddings)" not in src
+
+
+def test_compare_load_clears_stale_native_lease():
+ """A compare-pane load never comes from the desktop file picker, so it must
+ clear any prior picked file's lease token + expiry, otherwise a reload can
+ send a stale lease for the now-active model."""
+ src = _read("features/chat/shared-composer.tsx")
+ assert "activeNativePathToken: null" in src
+ assert "activeNativePathExpiresAtMs: null" in src
+
+
+def test_rollback_restores_native_lease_expiry_with_token():
+ """A failed model switch that rolls back to a previously loaded picked GGUF
+ must restore the lease expiry paired with the token, never the token alone
+ (which would look non-expiring and skip the expiry guard)."""
+ src = _read("features/chat/hooks/use-chat-model-runtime.ts")
+ assert "previousActiveNativePathExpiresAtMs" in src
+ assert re.search(
+ r"activeNativePathExpiresAtMs:\s*previousActiveNativePathToken", src
+ ), "rollback must restore the expiry alongside the token"
+
+
+def test_default_caches_keyed_on_inventory_version():
+ """The chat-template and max-position caches must key on the inventory
+ version so a model update in the same session invalidates the cached value
+ instead of showing the stale revision."""
+ src = _read("features/model-picker/hooks/use-model-defaults.ts")
+ # Both cache keys (template + max-position) end with the inventory version.
+ assert src.count("${inventoryVersion}") >= 2
+
+
+def test_hidden_infra_model_needles_present():
+ """The frontend static needle list must keep hiding the RAG embedder and the
+ llama.cpp validation probe."""
+ src = _read("features/hub/lib/hidden-models.ts")
+ assert '"bge-small-en-v1.5"' in src
+ assert '"ggml-org/models"' in src
+ assert '"stories260k.gguf"' in src
+
+
+def test_hidden_models_dynamic_exact_ids_wired():
+ """The configured embedder arrives from /api/hub/hidden-models as exact
+ repo ids; a substring needle would let a generic basename like "model"
+ hide unrelated chat models."""
+ src = _read("features/hub/lib/hidden-models.ts")
+ assert "toLowerStrings(data.exact_ids)" in src
+ assert "dynamicExactIds.includes(lower)" in src
+
+
+def test_hidden_model_matchers_refresh_with_inventory_version():
+ src = _read("features/hub/lib/hidden-models.ts")
+ assert "const version = getInventoryVersion()" in src
+ assert "matchersFetchVersion === version" in src
+ assert "getInventoryVersion() !== version" in src
+
+
+def test_diffusion_capability_labeled_image_generation():
+ """The diffusion capability detects image GENERATORS (FLUX, SDXL,
+ text-to-image tags); labeling it "Image to text" showed generators when
+ users asked for captioning models."""
+ for rel in (
+ "features/hub/lib/model-capabilities.ts",
+ "features/hub/lib/model-type-filter.ts",
+ "features/hub/lib/view-models.ts",
+ ):
+ src = _read(rel)
+ assert "Image to text" not in src, rel
+ assert "Image generation" in src, rel
+
+
+def test_active_model_config_round_trips_gpu_fields():
+ """The active model's config must carry the GPU Memory knobs (GGUF only) so
+ a sidebar/hub-gear reload cannot silently reset manual GPU settings, and
+ "Remember settings" cannot persist a GPU-less config over a saved one."""
+ src = _read("features/model-picker/hooks/use-active-model-config.ts")
+ for field in ("gpuMemoryMode", "gpuLayers", "nCpuMoe", "selectedGpuIds"):
+ assert field in src, field
+ assert "if (!isGguf)" in src and "return base" in src
+ for rel in (
+ "features/chat/chat-page.tsx",
+ "features/hub/catalog/sampling-settings-dialog.tsx",
+ ):
+ assert "useActiveModelConfig(" in _read(rel), rel
+ signature = _read("features/model-picker/components/sidebar-model-config.tsx")
+ assert "gpuFieldsSignature(config)" in signature
+ shared = _read("features/model-picker/model-config/apply-per-model-config.ts")
+ assert "export function gpuFieldsSignature" in shared
+
+
+def test_compare_load_uses_each_models_gpu_config():
+ src = _read("features/chat/shared-composer.tsx")
+ assert "ownConfig.gpuMemoryMode ?? compareLoadKnobs.gpuMemoryMode" in src
+ assert "ownConfig.gpuLayers ?? compareLoadKnobs.gpuLayers" in src
+ assert "ownConfig.nCpuMoe ?? compareLoadKnobs.nCpuMoe" in src
+ assert "if (ownConfig.selectedGpuIds != null)" in src
+ assert "reconcilePersistedGpuIds(ownConfig.selectedGpuIds)" in src
+ for field in (
+ "gpu_memory_mode: effectiveGpuMemoryMode",
+ "gpu_layers: effectiveGpuLayers",
+ "n_cpu_moe: effectiveNCpuMoe",
+ "gpu_ids: effectiveSelectedGpuIds ?? undefined",
+ ):
+ assert field in src
+
+
+def test_active_native_gguf_metadata_uses_path_token():
+ src = _read("features/model-picker/components/model-config-page.tsx")
+ assert "(isActiveModel ? activeNativePathToken : null)" in src
+ assert "target.meta.nativePathToken ??" in src
+ assert "nativePathToken," in src
+ assert '${nativePathToken ?? ""}' in src
+
+
+def test_model_default_hooks_do_not_reset_state_in_effect():
+ src = _read("features/model-picker/hooks/use-model-defaults.ts")
+ assert "setFetched(null)" not in src
+
+
+def test_variant_expander_refreshes_after_delete():
+ """Deleting a downloaded quant from an expanded repo that still has other
+ cached quants must bump the expander refresh key, or the deleted quant stays
+ shown as downloaded and clickable and tries to reload the removed file."""
+ src = _read("features/model-picker/components/model-selector/pickers.tsx")
+ del_confirm = re.search(
+ r"await onDeleteVariant\(v\.quant\);.*?setRefreshKey\(\(key\) => key \+ 1\)",
+ src,
+ re.S,
+ )
+ assert del_confirm, "delete onConfirm must bump refreshKey after a successful delete"
+
+
+def test_local_picker_rows_require_chat_capability():
+ """Local inventory rows can be classified non-chat (canChat false, e.g. a
+ folder with only config.json). The picker must filter those out, or selecting
+ one loads a weightless path; toLocalModelInfo drops capabilities so the memo
+ is the only place the guard can live."""
+ src = _read("features/model-picker/inventory/use-chat-picker-inventory.ts")
+ memo = re.search(r"const localModels = useMemo\(.*?\[inventory\.localRows\]", src, re.S)
+ assert memo, "localModels memo not found"
+ assert "row.capabilities.canChat" in memo.group(0)
+
+
+def test_native_picked_gguf_template_read_through_lease():
+ """A native (picked / drag-drop) GGUF's path lives only in its signed lease,
+ and the picker chat-template GET has no lease plumbing, so the default
+ template must be read through the lease-aware validate probe: mint a
+ validate-model lease and post include_chat_template. The native token also
+ has to reach the fetch (threaded through the hook) and be part of the cache
+ key so two picks of the same basename don't share a template."""
+ api = _read("features/model-picker/api/templates.ts")
+ assert 'consumeNativePathToken(nativePathToken, "validate-model")' in api
+ assert "include_chat_template: true" in api
+ assert "/api/inference/validate" in api
+ hook = _read("features/model-picker/hooks/use-model-defaults.ts")
+ assert "nativePathToken," in hook
+ assert '${nativePathToken ?? ""}' in hook
+
+
+def test_model_load_guard_is_cross_instance():
+ """The in-flight load guard must consult the shared store pick (not only the
+ per-hook ref) and ejectModel must refuse while any instance is loading:
+ three live useChatModelRuntime instances exist (chat page, hub page, hub
+ gear dialog)."""
+ src = _read("features/chat/hooks/use-chat-model-runtime.ts")
+ assert "useChatRuntimeStore.getState().loadingModelPick" in src
+ assert "clearLoadingModelPick" in src
+ eject_body = src.split("const ejectModel", 1)[1]
+ assert "loadingModelPick" in eject_body.split("ejectModel,", 1)[0]
+
+
+def test_partial_safetensors_download_keeps_delete_menu():
+ """A stopped partial safetensors download must keep its options menu (the
+ Delete affordance) like the GGUF card does, or partial downloads can only
+ be cleaned up by finishing or leaving them. During an ACTIVE download the
+ menu stays hidden (every item would be disabled: no Copy path while not
+ downloaded, no Delete while downloading, pin suppressed in the run bar)."""
+ src = _read("features/hub/catalog/safetensors-download-card.tsx")
+ assert "(isDownloaded || (isPartial && !downloading))" in src
+
+
+def test_pinned_validation_uses_cached_local_variant_listing():
+ """Pinned-quant validation must use the TTL-cached hub client with
+ preferLocalCache (downloaded-ness is local state) instead of one uncached
+ round-trip per pinned repo on every picker open. Picker deletes must go
+ through the hub inventory client, whose delete invalidates both the
+ variants TTL cache and the server-side HF cache scan (the legacy
+ /api/models/delete-cached route invalidates neither, so a post-delete
+ inventory refresh would resurrect the deleted row until the scan TTL)."""
+ src = _read("features/model-picker/components/model-selector/pickers.tsx")
+ assert "listGgufVariantsCached(" in src
+ assert "preferLocalCache: true" in src
+ assert re.search(r'import \{[^}]*\bdeleteCachedModel\b[^}]*\} from "@/features/hub"', src)
+ hub_api = _read("features/hub/inventory/api.ts")
+ delete_fn = hub_api.split("export async function deleteCachedModel", 1)[1]
+ delete_fn = delete_fn.split("export ", 1)[0]
+ assert "invalidateGgufVariantsCache(" in delete_fn
+ assert "bumpInventoryVersion(" in delete_fn
+
+
+def test_downloaded_list_offsets_virtual_rows():
+ """The On Device virtualized list sits below the Pinned block in the same
+ scroll element, so it must pass its measured offset as scrollMargin or rows
+ past the overscan render blank."""
+ src = _read("features/hub/catalog/models-catalog-lists.tsx")
+ assert "scrollMargin={scrollMargin}" in src
+
+
+def test_local_gguf_diagnostics_gate_on_broad_is_gguf():
+ """The MTP fallback note and the context/VRAM warning must gate on the broad
+ isGguf (variant, loaded gguf context, or .gguf suffix), not the variant-only
+ isLoadedGguf, so direct-file and custom-folder GGUF loads keep those
+ diagnostics."""
+ src = _read("features/chat/chat-settings-sheet.tsx")
+ spec = re.search(r"const showSpecFallback =.*?;", src, re.S)
+ vram = re.search(r"const showContextVramWarning =.*?;", src, re.S)
+ assert spec and "isGguf &&" in spec.group(0) and "isLoadedGguf" not in spec.group(0)
+ assert vram and "isGguf &&" in vram.group(0) and "isLoadedGguf" not in vram.group(0)
+
+
+def test_fixed_layer_gguf_pins_displayed_context():
+ """An already-loaded auto-fit GGUF saved with Manual fixed GPU layers must
+ pin the shown context, so a later fresh load keeps the fitted placement
+ instead of sending native/0 and recreating the OOM."""
+ src = _read("features/model-picker/components/model-config-page.tsx")
+ assert "const pinFixedLayerContext =" in src
+ assert 'config.gpuMemoryMode === "manual"' in src
+ assert "customContextLength: activeLoadedContext" in src
+
+
+def test_auto_defaults_not_persisted_as_overrides():
+ """Auto GPU memory mode and Auto/default speculative type are follow-global
+ defaults; normalization must not persist them as per-model overrides, else a
+ model stops following later changes to the global preference."""
+ src = _read("features/model-picker/model-config/per-model-config.ts")
+ assert 'if (partial.gpuMemoryMode === "manual") {' in src
+ assert 'partial.gpuMemoryMode === "auto" || partial.gpuMemoryMode === "manual"' not in src
+ spec = re.search(r'if \(s === "auto" \|\| s === "default"\) \{\s*return ([^;]+);', src)
+ assert spec and spec.group(1).strip() == "null"
+
+
+def test_compare_pane_context_from_own_config_only():
+ """A compare pane's context comes from its own config only (a saved pin, else
+ null for Auto/native); it must not inherit the active model's shared snapshot,
+ which resolveFitMaxSeqLength would treat as an explicit pin (VRAM/OOM)."""
+ src = _read("features/chat/shared-composer.tsx")
+ assert "const effectiveCustomContextLength = ownConfig.customContextLength;" in src
+ assert "compareLoadKnobs.customContextLength" not in src
+
+
+def test_reset_max_seq_length_falls_back_to_app_default():
+ """After Reset clears maxSeqLength (null), a non-GGUF active model's shown
+ max sequence length must fall back to the app default, never the loaded
+ runtime snapshot, or a remembered/active override can never be cleared."""
+ src = _read("features/model-picker/components/model-config-page.tsx")
+ # The null fallback resolves to the app-default constant, not a runtime value.
+ assert "clampMaxSeqLength(DEFAULT_MAX_SEQ_LENGTH, nativeMaxSeqLength)" in src
+ # The buggy runtime-seeded fallback must not come back.
+ assert "clampMaxSeqLength(initialMaxSeqLength" not in src
+
+
+def test_reset_persists_null_max_length_and_substitutes_only_for_load():
+ """The persisted per-model record must keep config.maxSeqLength (null after
+ Reset) so isDefaultConfig can clear a remembered override; the concrete
+ fallback is substituted only into the load request, not the saved record."""
+ src = _read("features/model-picker/components/model-config-page.tsx")
+ # Load-only substitution of the resolved value.
+ assert "maxSeqLength: maxSeqLengthValue" in src
+ assert "const loadConfig" in src
+ # The persisted record is loaded via onRun(loadConfig), and save uses the
+ # untouched runtimeConfig (so a reset/default config stays default).
+ assert "onRun(loadConfig)" in src
+ assert "savePerModelConfig(" in src
+
+
+def test_reset_enabled_for_explicit_context_pin_at_native():
+ """An explicit customContextLength that equals the native ceiling is still a
+ user override, so contextAtDefault must require customContextLength == null.
+ The buggy form treated `contextValue === native` alone as default, wedging
+ the Reset button disabled for a deliberate pin-to-native."""
+ src = " ".join(_read("features/model-picker/components/model-config-page.tsx").split())
+ assert (
+ "const contextAtDefault = !target.isGguf || "
+ "(config.customContextLength == null && "
+ "(nativeContextLength == null || contextValue === nativeContextLength));" in src
+ )
+ # The old form that ignored an explicit pin equal to native must not return.
+ assert (
+ "(nativeContextLength == null ? config.customContextLength == null : "
+ "contextValue === nativeContextLength)" not in src
+ )
+ # The app-default constant is the single source of truth (imported, not local).
+ assert "DEFAULT_MAX_SEQ_LENGTH," in src
+ assert "const DEFAULT_MAX_SEQ_LENGTH = 4096" not in src
+
+
+def test_compare_pane_non_gguf_falls_back_to_app_default():
+ """A non-GGUF compare pane with no saved maxSeqLength must fall back to the
+ shared app default, not the active model's runtime snapshot; otherwise an
+ unconfigured pane inherits a saved 128K neighbor's context and can OOM."""
+ per_model = _read("features/model-picker/model-config/per-model-config.ts")
+ assert "export const DEFAULT_MAX_SEQ_LENGTH = 4096;" in per_model
+ barrel = _read("features/model-picker/index.ts")
+ assert "DEFAULT_MAX_SEQ_LENGTH," in barrel
+ src = " ".join(_read("features/chat/shared-composer.tsx").split())
+ assert "DEFAULT_MAX_SEQ_LENGTH," in src
+ assert (
+ "const effectiveMaxSeqLength = ownConfig.customContextLength ?? "
+ "normalizeMaxSeqLength(ownConfig.maxSeqLength) ?? "
+ "(isGgufLoad ? 0 : DEFAULT_MAX_SEQ_LENGTH);" in src
+ )
+ # The buggy fallback to the active model's shared runtime value must not return.
+ assert "(isGgufLoad ? 0 : maxSeqLength)" not in src
+ assert "const maxSeqLength = store.params.maxSeqLength;" not in src
+
+
+def test_default_gpu_mode_clears_manual_knobs():
+ """Switching GPU Memory back to Default must clear the Manual-only knobs
+ (gpuLayers/nCpuMoe/selectedGpuIds); otherwise a remembered config keeps stale
+ pins that a later load re-applies when the global preference is Manual."""
+ src = _read("features/model-picker/components/model-config-page.tsx")
+ assert 'gpuMemoryMode: "auto",' in src
+ assert "gpuLayers: undefined," in src
+ assert "nCpuMoe: undefined," in src
+ assert "selectedGpuIds: undefined," in src
+
+
+def test_legacy_migration_is_idempotent_and_non_destructive():
+ """The v1->v2 localStorage migration (unsloth_load_settings ->
+ unsloth_model_configs) is invoked on every store read, so it must be
+ idempotent: repeated reads, browser reloads, and Studio restarts must never
+ re-migrate, duplicate records, or overwrite a newer per-model config. This
+ was the class of regression that reverted the predecessor PR, so pin all
+ three idempotency layers at source level; dropping any of them reddens here.
+ """
+ raw = _read("features/model-picker/model-config/per-model-config.ts")
+ src = " ".join(raw.split())
+ # Migration runs from readMap (every store read), so it must be safe to repeat.
+ assert (
+ "function readMap(): StoredMap { migrateLegacyLoadSettingsOnce(); "
+ "return readMapRaw(); }" in src
+ )
+ # Layer 1: in-memory once-per-session guard so repeated readMap() calls
+ # migrate at most once.
+ assert "let legacyMigrationChecked = false;" in src
+ assert "if (legacyMigrationChecked || !canUseStorage()) {" in src
+ assert "legacyMigrationChecked = true;" in src
+ # Layer 2: persistent cross-session flag so a completed migration is never
+ # redone. Set in every terminal branch (malformed data, nothing to migrate,
+ # successful write); a failed quota write leaves it unset so the next session
+ # retries. Three set-sites encode exactly that.
+ assert 'const LEGACY_MIGRATION_FLAG = "unsloth_model_configs_migrated";' in src
+ assert "if (localStorage.getItem(LEGACY_MIGRATION_FLAG)) {" in src
+ assert src.count('localStorage.setItem(LEGACY_MIGRATION_FLAG, "1");') >= 3
+ # Layer 3: non-overwriting merge skips an existing (or default) key, so even a
+ # forced re-run cannot duplicate or clobber a user's config.
+ assert "if (isDefaultConfig(migrated) || Object.hasOwn(map, key)) {" in src
diff --git a/tests/studio/test_reveal_file_manager.py b/tests/studio/test_reveal_file_manager.py
new file mode 100644
index 0000000000..8a1df586e5
--- /dev/null
+++ b/tests/studio/test_reveal_file_manager.py
@@ -0,0 +1,128 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Platform guards for the reveal-in-file-manager endpoint.
+
+A stock WSL distro has no Linux desktop, so the generic Linux branch
+(``xdg-open``) fails there. Under WSL the reveal must route through Windows
+interop (``wslpath -w`` + ``explorer.exe``), fall back to ``xdg-open`` when
+interop is unavailable, and leave native Linux behavior unchanged.
+"""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+import types
+from pathlib import Path
+
+import pytest
+
+
+def _find_repo_root() -> Path | None:
+ env = os.environ.get("UNSLOTH_REPO_ROOT")
+ if env:
+ p = Path(env).resolve()
+ if (p / "studio" / "backend").is_dir():
+ return p
+ here = Path(__file__).resolve()
+ for parent in (here, *here.parents):
+ if (parent / "studio" / "backend").is_dir():
+ return parent
+ return None
+
+
+_REPO_ROOT = _find_repo_root()
+if _REPO_ROOT is None:
+ pytest.skip(
+ "Could not locate studio/backend. Set UNSLOTH_REPO_ROOT or run from "
+ "the repository checkout.",
+ allow_module_level = True,
+ )
+
+_STUDIO_BACKEND = _REPO_ROOT / "studio" / "backend"
+if str(_STUDIO_BACKEND) not in sys.path:
+ sys.path.insert(0, str(_STUDIO_BACKEND))
+
+pytest.importorskip("fastapi")
+pytest.importorskip("huggingface_hub")
+
+try:
+ from routes import models as routes_models
+ from utils.paths import path_utils
+except Exception as exc:
+ pytest.skip(f"studio backend import unavailable: {exc}", allow_module_level = True)
+
+_WINDOWS_PATH = r"\\wsl.localhost\Distro\cache\model.gguf"
+
+
+@pytest.fixture()
+def linux_host(monkeypatch):
+ monkeypatch.setattr(sys, "platform", "linux")
+ monkeypatch.setattr(os, "name", "posix")
+
+
+@pytest.fixture()
+def spawned(monkeypatch):
+ calls = types.SimpleNamespace(run = [], popen = [], run_error = None)
+
+ def fake_run(cmd, **kwargs):
+ calls.run.append(list(cmd))
+ if calls.run_error is not None:
+ raise calls.run_error
+ return types.SimpleNamespace(stdout = _WINDOWS_PATH + "\n")
+
+ def fake_popen(cmd, **kwargs):
+ calls.popen.append(list(cmd))
+ return types.SimpleNamespace()
+
+ monkeypatch.setattr(subprocess, "run", fake_run)
+ monkeypatch.setattr(subprocess, "Popen", fake_popen)
+ return calls
+
+
+def test_wsl_file_is_selected_in_explorer(linux_host, spawned, monkeypatch, tmp_path):
+ monkeypatch.setattr(path_utils, "_IS_WSL", True)
+ target = tmp_path / "model.gguf"
+ target.write_bytes(b"gguf")
+ routes_models._reveal_in_file_manager(target)
+ assert spawned.run == [["wslpath", "-w", str(target)]]
+ assert spawned.popen == [["explorer.exe", f"/select,{_WINDOWS_PATH}"]]
+
+
+def test_wsl_directory_opens_in_explorer(linux_host, spawned, monkeypatch, tmp_path):
+ monkeypatch.setattr(path_utils, "_IS_WSL", True)
+ routes_models._reveal_in_file_manager(tmp_path)
+ assert spawned.popen == [["explorer.exe", _WINDOWS_PATH]]
+
+
+def test_wsl_without_interop_falls_back_to_xdg_open(linux_host, spawned, monkeypatch, tmp_path):
+ monkeypatch.setattr(path_utils, "_IS_WSL", True)
+ spawned.run_error = FileNotFoundError("wslpath")
+ target = tmp_path / "model.gguf"
+ target.write_bytes(b"gguf")
+ routes_models._reveal_in_file_manager(target)
+ assert spawned.popen == [["xdg-open", str(tmp_path)]]
+
+
+def test_wsl_empty_conversion_falls_back_to_xdg_open(linux_host, spawned, monkeypatch, tmp_path):
+ monkeypatch.setattr(path_utils, "_IS_WSL", True)
+
+ def empty_run(cmd, **kwargs):
+ return types.SimpleNamespace(stdout = "\n")
+
+ monkeypatch.setattr(subprocess, "run", empty_run)
+ routes_models._reveal_in_file_manager(tmp_path)
+ assert spawned.popen == [["xdg-open", str(tmp_path)]]
+
+
+def test_native_linux_keeps_xdg_open_on_parent_directory(
+ linux_host, spawned, monkeypatch, tmp_path
+):
+ monkeypatch.setattr(path_utils, "_IS_WSL", False)
+ target = tmp_path / "model.gguf"
+ target.write_bytes(b"gguf")
+ routes_models._reveal_in_file_manager(target)
+ assert spawned.run == []
+ assert spawned.popen == [["xdg-open", str(tmp_path)]]
diff --git a/tests/studio/test_studio_text_descender_clipping.py b/tests/studio/test_studio_text_descender_clipping.py
index 359ca4873a..ae8c8d6d8a 100644
--- a/tests/studio/test_studio_text_descender_clipping.py
+++ b/tests/studio/test_studio_text_descender_clipping.py
@@ -10,7 +10,14 @@ from pathlib import Path
WORKDIR = Path(__file__).resolve().parents[2]
MODEL_SELECTOR = (
- WORKDIR / "studio" / "frontend" / "src" / "components" / "assistant-ui" / "model-selector.tsx"
+ WORKDIR
+ / "studio"
+ / "frontend"
+ / "src"
+ / "features"
+ / "model-picker"
+ / "components"
+ / "model-selector.tsx"
)
APP_SIDEBAR = WORKDIR / "studio" / "frontend" / "src" / "components" / "app-sidebar.tsx"