Merge branch 'main' into pip

This commit is contained in:
Daniel Han 2026-03-27 07:23:27 -07:00
commit 6700dd60f0
27 changed files with 787 additions and 137 deletions

View file

@ -663,15 +663,29 @@ shell.Run cmd, 0, False
# CUDA wheels. Missing dependencies (transformers, trl, peft, etc.)
# are still pulled in because they are new, not upgrades.
#
# ── Helper: find no-torch-runtime.txt ──
function Find-NoTorchRuntimeFile {
if ($StudioLocalInstall -and (Test-Path (Join-Path $RepoRoot "studio\backend\requirements\no-torch-runtime.txt"))) {
return Join-Path $RepoRoot "studio\backend\requirements\no-torch-runtime.txt"
}
$installed = Get-ChildItem -Path $VenvDir -Recurse -Filter "no-torch-runtime.txt" -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -like "*studio*backend*requirements*no-torch-runtime.txt" } |
Select-Object -ExpandProperty FullName -First 1
return $installed
}
if ($_Migrated) {
# Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state
# in the new venv location, while preserving existing torch/CUDA
Write-Host "==> Upgrading unsloth in migrated environment..."
if ($SkipTorch) {
# No-torch: install runtime deps via [huggingfacenotorch] extras,
# then unsloth-zoo with --no-deps to avoid pulling torch.
uv pip install --python $VenvPython --reinstall-package unsloth "unsloth[huggingfacenotorch]>=2026.3.14"
uv pip install --python $VenvPython --no-deps --reinstall-package unsloth-zoo unsloth-zoo
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.14" unsloth-zoo
$NoTorchReq = Find-NoTorchRuntimeFile
if ($NoTorchReq) {
uv pip install --python $VenvPython --no-deps -r $NoTorchReq
}
} else {
uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.14" unsloth-zoo
}
@ -693,10 +707,13 @@ shell.Run cmd, 0, False
Write-Host "==> Installing unsloth (this may take a few minutes)..."
if ($SkipTorch) {
# No-torch: install runtime deps via [huggingfacenotorch] extras,
# then unsloth-zoo with --no-deps to avoid pulling torch.
uv pip install --python $VenvPython --upgrade-package unsloth "unsloth[huggingfacenotorch]>=2026.3.14"
uv pip install --python $VenvPython --no-deps --upgrade-package unsloth-zoo unsloth-zoo
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.3.14" unsloth-zoo
$NoTorchReq = Find-NoTorchRuntimeFile
if ($NoTorchReq) {
uv pip install --python $VenvPython --no-deps -r $NoTorchReq
}
if ($StudioLocalInstall) {
Write-Host "==> Overlaying local repo (editable)..."
uv pip install --python $VenvPython -e $RepoRoot --no-deps
@ -737,6 +754,7 @@ shell.Run cmd, 0, False
return
}
# Tell setup.ps1 to skip base package installation (install.ps1 already did it)
# Tell setup.ps1 to skip base package installation (install.ps1 already did it)
$env:SKIP_STUDIO_BASE = "1"
$env:STUDIO_PACKAGE_NAME = $PackageName
$env:UNSLOTH_NO_TORCH = if ($SkipTorch) { "true" } else { "false" }

View file

@ -891,6 +891,21 @@ fi
# ── Resolve repo root (for --local installs) ──
_REPO_ROOT="$(cd "$(dirname "$0" 2>/dev/null || echo ".")" && pwd)"
# ── Helper: find no-torch-runtime.txt (local repo or site-packages) ──
_find_no_torch_runtime() {
# Check local repo first (for --local installs)
if [ -f "$_REPO_ROOT/studio/backend/requirements/no-torch-runtime.txt" ]; then
echo "$_REPO_ROOT/studio/backend/requirements/no-torch-runtime.txt"
return
fi
# Check inside installed package
_rt=$(find "$VENV_DIR" -path "*/studio/backend/requirements/no-torch-runtime.txt" -print -quit 2>/dev/null || echo "")
if [ -n "$_rt" ]; then
echo "$_rt"
return
fi
}
# ── Detect GPU and choose PyTorch index URL ──
# Mirrors Get-TorchIndexUrl in install.ps1.
# On CPU-only machines this returns the cpu index, avoiding the solver
@ -947,13 +962,17 @@ if [ "$_MIGRATED" = true ]; then
# in the new venv location, while preserving existing torch/CUDA
echo "==> Upgrading unsloth in migrated environment..."
if [ "$SKIP_TORCH" = true ]; then
# No-torch: install runtime deps via [huggingfacenotorch] extras,
# then unsloth-zoo with --no-deps to avoid pulling torch.
uv pip install --python "$_VENV_PY" \
--reinstall-package unsloth \
"unsloth[huggingfacenotorch]>=2026.3.14"
# No-torch: install unsloth + unsloth-zoo with --no-deps (current
# PyPI metadata still declares torch as a hard dep), then install
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps
# to prevent transitive torch resolution.
uv pip install --python "$_VENV_PY" --no-deps \
--reinstall-package unsloth-zoo unsloth-zoo
--reinstall-package unsloth --reinstall-package unsloth-zoo \
"unsloth>=2026.3.14" unsloth-zoo
_NO_TORCH_RT="$(_find_no_torch_runtime)"
if [ -n "$_NO_TORCH_RT" ]; then
uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT"
fi
else
uv pip install --python "$_VENV_PY" \
--reinstall-package unsloth --reinstall-package unsloth-zoo \
@ -975,13 +994,15 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
# Fresh: Step 2 - install unsloth, preserving pre-installed torch
echo "==> Installing unsloth (this may take a few minutes)..."
if [ "$SKIP_TORCH" = true ]; then
# No-torch: install runtime deps via [huggingfacenotorch] extras,
# then unsloth-zoo with --no-deps to avoid pulling torch.
uv pip install --python "$_VENV_PY" \
--upgrade-package unsloth \
"unsloth[huggingfacenotorch]>=2026.3.14"
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
uv pip install --python "$_VENV_PY" --no-deps \
--upgrade-package unsloth-zoo unsloth-zoo
--upgrade-package unsloth --upgrade-package unsloth-zoo \
"unsloth>=2026.3.14" unsloth-zoo
_NO_TORCH_RT="$(_find_no_torch_runtime)"
if [ -n "$_NO_TORCH_RT" ]; then
uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT"
fi
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
echo "==> Overlaying local repo (editable)..."
uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps

View file

@ -50,6 +50,7 @@ class LlamaCppBackend:
self._effective_context_length: Optional[int] = None
self._chat_template: Optional[str] = None
self._supports_reasoning: bool = False
self._reasoning_always_on: bool = False
self._supports_tools: bool = False
self._cache_type_kv: Optional[str] = None
self._reasoning_default: bool = True
@ -107,6 +108,10 @@ class LlamaCppBackend:
def supports_reasoning(self) -> bool:
return self._supports_reasoning
@property
def reasoning_always_on(self) -> bool:
return self._reasoning_always_on
@property
def reasoning_default(self) -> bool:
return self._reasoning_default
@ -550,6 +555,7 @@ class LlamaCppBackend:
self._context_length = None
self._chat_template = None
self._supports_reasoning = False
self._reasoning_always_on = False
self._supports_tools = False
self._n_layers = None
self._n_kv_heads = None
@ -627,6 +633,20 @@ class LlamaCppBackend:
logger.info(
"GGUF metadata: model supports reasoning (DeepSeek thinking)"
)
# Models with hardcoded <think> tags or reasoning_content
# in their chat template always produce thinking output
# (no toggle to disable it).
if not self._supports_reasoning:
if (
"<think>" in tpl
and "</think>" in tpl
or "reasoning_content" in tpl
):
self._supports_reasoning = True
self._reasoning_always_on = True
logger.info(
"GGUF metadata: model always reasons (<think> tags in template)"
)
# Detect tool calling support from chat template
tool_markers = [
"{%- if tools %}",
@ -1272,7 +1292,18 @@ class LlamaCppBackend:
self._gguf_path = gguf_path
self._hf_repo = hf_repo
self._hf_variant = hf_variant
# For local GGUF files, extract variant from filename if not provided
if hf_variant:
self._hf_variant = hf_variant
elif gguf_path:
try:
from utils.models.model_config import _extract_quant_label
self._hf_variant = _extract_quant_label(gguf_path)
except Exception:
self._hf_variant = None
else:
self._hf_variant = None
self._is_vision = is_vision
self._model_identifier = model_identifier
@ -1284,7 +1315,7 @@ class LlamaCppBackend:
)
# Wait for llama-server to become healthy
if not self._wait_for_health(timeout = 120.0):
if not self._wait_for_health(timeout = 600.0):
self._kill_process()
raise RuntimeError(
"llama-server failed to start. "
@ -1318,6 +1349,7 @@ class LlamaCppBackend:
self._effective_context_length = None
self._chat_template = None
self._supports_reasoning = False
self._reasoning_always_on = False
self._supports_tools = False
self._cache_type_kv = None
self._n_layers = None
@ -1367,27 +1399,33 @@ class LlamaCppBackend:
"""Kill orphaned llama-server processes started by studio.
Only kills processes whose resolved binary lives under a known
Unsloth install directory to avoid terminating unrelated
llama-server instances on the machine.
Studio install directory (or matches an exact env-var override)
to avoid terminating unrelated llama-server instances.
Mirrors every location that _find_llama_server_binary() can
return from so that orphans from any supported install path
are still cleaned up.
Uses psutil for cross-platform support (Linux, macOS, Windows).
Falls back to pgrep + /proc/<pid>/exe on Linux when psutil is
not installed.
"""
import os
import signal
import sys
try:
import psutil
except ImportError:
return
try:
# Build the same set of directories that _find_llama_server_binary
# searches, so we only kill servers we could have started.
# -- Build the ownership allowlist --------------------------------
# Two kinds of matches:
# exact_binaries -- env var overrides (exact path match only)
# install_roots -- directory trees that are Studio-owned
# (binary must be *under* one of these)
install_roots: list[Path] = []
# ~/.unsloth/llama.cpp (primary install location)
# Primary install dir (setup.sh / prebuilt installer)
install_roots.append(Path.home() / ".unsloth" / "llama.cpp")
# Legacy: in-tree build
# Legacy in-tree build dirs (older setup.sh versions)
project_root = Path(__file__).resolve().parents[4]
install_roots.append(project_root / "llama.cpp")
@ -1418,40 +1456,103 @@ class LlamaCppBackend:
my_pid = os.getpid()
for proc in psutil.process_iter(["pid", "name", "exe"]):
try:
if proc.info["pid"] == my_pid:
# -- Enumerate processes -------------------------------------------
# Prefer psutil (cross-platform). Fall back to pgrep + /proc on
# Linux when psutil is not installed.
try:
import psutil
has_psutil = True
except ImportError:
has_psutil = False
if has_psutil:
for proc in psutil.process_iter(["pid", "name", "exe"]):
try:
if proc.info["pid"] == my_pid:
continue
name = proc.info.get("name") or ""
if not name.lower().startswith("llama-server"):
continue
exe = proc.info.get("exe")
if not exe:
continue
exe_path = Path(exe).resolve()
# Check ownership: exact binary match OR binary is
# under a known install root (proper ancestry, not
# substring).
is_ours = exe_path in exact_binaries or any(
exe_path.is_relative_to(root) for root in resolved_roots
)
if not is_ours:
continue
proc.kill()
logger.info(
f"Killed orphaned llama-server process "
f"(pid={proc.info['pid']})"
)
except (
psutil.NoSuchProcess,
psutil.AccessDenied,
psutil.ZombieProcess,
):
pass
else:
# -- Fallback: pgrep + /proc/<pid>/exe (Linux only) -----------
if sys.platform != "linux":
return
result = subprocess.run(
["pgrep", "-a", "-f", "llama-server"],
capture_output = True,
text = True,
timeout = 5,
)
if result.returncode != 0:
return
for line in result.stdout.strip().splitlines():
parts = line.strip().split(None, 1)
if len(parts) < 2:
continue
pid = int(parts[0])
if pid == my_pid:
continue
name = proc.info.get("name") or ""
if not name.lower().startswith("llama-server"):
continue
# Resolve the actual executable. /proc/<pid>/exe is a
# symlink to the real binary and avoids all cmdline-
# parsing ambiguities (spaces in paths, argv rewriting).
# Fall back to the first cmdline token when /proc is
# unavailable.
proc_exe = Path(f"/proc/{pid}/exe")
try:
binary = proc_exe.resolve(strict = True)
except (OSError, ValueError):
cmdline = parts[1]
token = cmdline.split()[0] if cmdline.strip() else ""
if not token:
continue
binary = Path(token).resolve(strict = False)
exe = proc.info.get("exe")
if not exe:
continue
exe_path = Path(exe).resolve()
# Check if this binary is one we manage
is_ours = exe_path in exact_binaries or any(
exe_path.is_relative_to(root) for root in resolved_roots
owned = binary in exact_binaries or any(
binary.is_relative_to(root) for root in resolved_roots
)
if not is_ours:
if not owned:
continue
proc.kill()
logger.info(
f"Killed orphaned llama-server process (pid={proc.info['pid']})"
)
except (
psutil.NoSuchProcess,
psutil.AccessDenied,
psutil.ZombieProcess,
):
pass
try:
os.kill(pid, signal.SIGKILL)
logger.info(f"Killed orphaned llama-server process (pid={pid})")
except ProcessLookupError:
pass
except PermissionError:
pass
except Exception:
pass
logger.warning("Error during orphan server cleanup", exc_info = True)
def _cleanup(self):
"""atexit handler to ensure llama-server is terminated."""
@ -2135,6 +2236,11 @@ class LlamaCppBackend:
# ── Structured tool_calls ──
tc_deltas = delta.get("tool_calls")
if tc_deltas:
# Once visible content has been
# emitted, do not reclassify this
# turn as a tool call.
if _last_emitted:
continue
has_structured_tc = True
detect_state = _S_DRAINING
for tc_d in tc_deltas:
@ -2362,7 +2468,18 @@ class LlamaCppBackend:
tool_calls = None
content_text = content_accum
if has_structured_tc:
tool_calls = [tool_calls_acc[i] for i in sorted(tool_calls_acc)]
# Filter out incomplete fragments (e.g. from
# truncation by max_tokens or disconnect).
tool_calls = [
tool_calls_acc[i]
for i in sorted(tool_calls_acc)
if (
tool_calls_acc[i]
.get("function", {})
.get("name", "")
.strip()
)
] or None
if (
not tool_calls
and auto_heal_tool_calls

View file

@ -156,12 +156,28 @@ def _handle_load(backend, config: dict, resp_queue: Any) -> None:
except Exception as e:
logger.warning("Could not read adapter_config.json: %s", e)
# Auto-enable trust_remote_code for unsloth/* transformers 5.x models
# (matches the training worker logic in core/training/worker.py)
trust_remote_code = config.get("trust_remote_code", False)
if not trust_remote_code:
from utils.transformers_version import needs_transformers_5
model_name = config["model_name"]
if needs_transformers_5(model_name) and model_name.lower().startswith(
"unsloth/"
):
trust_remote_code = True
logger.info(
"Auto-enabled trust_remote_code for unsloth/* transformers 5.x model: %s",
model_name,
)
success = backend.load_model(
config = mc,
max_seq_length = config.get("max_seq_length", 2048),
load_in_4bit = load_in_4bit,
hf_token = hf_token,
trust_remote_code = config.get("trust_remote_code", False),
trust_remote_code = trust_remote_code,
)
if success:

View file

@ -34,7 +34,7 @@ if os.getenv("ENVIRONMENT_TYPE", "production") == "production":
# warnings.filterwarnings("ignore", category=DeprecationWarning)
# warnings.filterwarnings("ignore", module="triton.*")
from fastapi import FastAPI
from fastapi import Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, HTMLResponse, Response
@ -53,6 +53,7 @@ from routes import (
training_router,
)
from auth import storage
from auth.authentication import get_current_subject
from utils.hardware import detect_hardware, get_device, DeviceType
import utils.hardware.hardware as _hw_module
@ -184,6 +185,34 @@ async def health_check():
}
@app.post("/api/shutdown")
async def shutdown_server(
request: Request,
current_subject: str = Depends(get_current_subject),
):
"""Gracefully shut down the Unsloth Studio server.
Called by the frontend quit dialog so users can stop the server from the UI
without needing to use the CLI or kill the process manually.
"""
import asyncio
async def _delayed_shutdown():
await asyncio.sleep(0.2) # Let the HTTP response return first
trigger = getattr(request.app.state, "trigger_shutdown", None)
if trigger is not None:
trigger()
else:
# Fallback when not launched via run_server() (e.g. direct uvicorn)
import signal
import os
os.kill(os.getpid(), signal.SIGTERM)
request.app.state._shutdown_task = asyncio.create_task(_delayed_shutdown())
return {"status": "shutting_down"}
@app.get("/api/system")
async def get_system_info():
"""Get system information"""

View file

@ -136,6 +136,10 @@ class LoadResponse(BaseModel):
False,
description = "Whether model supports thinking/reasoning mode (enable_thinking)",
)
reasoning_always_on: bool = Field(
False,
description = "Whether reasoning is always on (hardcoded <think> tags, not toggleable)",
)
supports_tools: bool = Field(
False,
description = "Whether model supports tool calling (web search, etc.)",
@ -193,6 +197,9 @@ class InferenceStatusResponse(BaseModel):
supports_reasoning: bool = Field(
False, description = "Whether the active model supports reasoning/thinking mode"
)
reasoning_always_on: bool = Field(
False, description = "Whether reasoning is always on (not toggleable)"
)
supports_tools: bool = Field(
False, description = "Whether the active model supports tool calling"
)

View file

@ -0,0 +1,35 @@
# Runtime dependencies for no-torch (GGUF-only) mode.
# Installed with --no-deps to prevent transitive torch resolution
# from packages like accelerate, peft, trl, sentence-transformers.
#
# Includes unsloth's own direct deps (typer, pydantic, pyyaml,
# nest-asyncio) since unsloth is also installed with --no-deps
# (current PyPI metadata still declares torch as a hard dep).
# unsloth direct deps (from pyproject.toml [project].dependencies)
typer
pydantic
pyyaml
nest-asyncio
# HF ecosystem (from [huggingfacenotorch] extras in pyproject.toml)
wheel>=0.42.0
packaging
numpy
tqdm
psutil
tyro
protobuf
sentencepiece>=0.2.0
safetensors>=0.4.3
datasets>=3.4.1,!=4.0.*,!=4.1.0,<4.4.0
accelerate>=0.34.1
peft>=0.18.0,!=0.11.0
huggingface_hub>=0.34.0
hf_transfer
diffusers
transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.3.0
trl>=0.18.2,!=0.19.0,<=0.24.0
sentence-transformers
cut_cross_entropy
pillow

View file

@ -388,7 +388,7 @@ def _extract_text_from_file(file_path: Path, ext: str) -> str:
import pymupdf4llm
raw = pymupdf4llm.to_markdown(
str(file_path), write_images = False, show_progress = False
str(file_path), write_images = False, show_progress = False, use_ocr = False
)
elif ext == ".docx":
import mammoth

View file

@ -156,6 +156,7 @@ async def load_model(
inference = inference_config,
context_length = llama_backend.context_length,
supports_reasoning = llama_backend.supports_reasoning,
reasoning_always_on = llama_backend.reasoning_always_on,
chat_template = llama_backend.chat_template,
)
else:
@ -280,6 +281,7 @@ async def load_model(
inference = inference_config,
context_length = llama_backend.context_length,
supports_reasoning = llama_backend.supports_reasoning,
reasoning_always_on = llama_backend.reasoning_always_on,
supports_tools = llama_backend.supports_tools,
cache_type_kv = llama_backend.cache_type_kv,
chat_template = llama_backend.chat_template,
@ -609,6 +611,7 @@ async def get_status(
loaded = [_model_id],
inference = _inference_cfg,
supports_reasoning = llama_backend.supports_reasoning,
reasoning_always_on = llama_backend.reasoning_always_on,
supports_tools = llama_backend.supports_tools,
context_length = llama_backend.context_length,
)

View file

@ -271,6 +271,7 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]:
found.append(
LocalModelInfo(
id = str(model_dir),
model_id = f"{child.name}/{model_dir.stem}",
display_name = model_dir.stem,
path = str(model_dir),
source = "lmstudio",
@ -725,13 +726,40 @@ async def get_gguf_variants(
current_subject: str = Depends(get_current_subject),
):
"""
List available GGUF quantization variants for a HuggingFace repo.
List available GGUF quantization variants for a HuggingFace repo
or a local directory (e.g. LM Studio model folder).
Returns all available quantization variants (Q4_K_M, Q8_0, BF16, etc.)
with file sizes, whether the model supports vision, and the recommended
default variant.
"""
try:
from utils.models.model_config import is_local_path, list_local_gguf_variants
# Local directory path (e.g. LM Studio models) — scan filesystem
if is_local_path(repo_id):
variants, has_vision = list_local_gguf_variants(repo_id)
filenames = [v.filename for v in variants]
best = _pick_best_gguf(filenames)
default_variant = _extract_quant_label(best) if best else None
return GgufVariantsResponse(
repo_id = repo_id,
variants = [
GgufVariantDetail(
filename = v.filename,
quant = v.quant,
size_bytes = v.size_bytes,
downloaded = True, # all local variants are downloaded
)
for v in variants
],
has_vision = has_vision,
default_variant = default_variant,
)
# Remote HuggingFace repo — query HF API
variants, has_vision = list_gguf_variants(repo_id, hf_token = hf_token)
# Determine default variant

View file

@ -337,6 +337,15 @@ def run_server(
atexit.register(_remove_pid_file)
# Expose a shutdown callable via app.state so the /api/shutdown endpoint
# can trigger graceful shutdown without circular imports.
def _trigger_shutdown():
_graceful_shutdown(_server)
if _shutdown_event is not None:
_shutdown_event.set()
app.state.trigger_shutdown = _trigger_shutdown
if not silent:
display_host = _resolve_external_ip() if host == "0.0.0.0" else host
print_studio_access_banner(

View file

@ -973,6 +973,73 @@ def list_gguf_variants(
return variants, has_vision
def list_local_gguf_variants(
directory: str,
) -> tuple[list[GgufVariantInfo], bool]:
"""List GGUF quantization variants in a local directory.
Mirrors :func:`list_gguf_variants` but reads from the filesystem
instead of the HuggingFace API. Aggregates shard sizes by quant
label so that split GGUFs appear as a single variant.
Returns:
(variants, has_vision): list of non-mmproj GGUF variants + vision flag.
"""
p = Path(directory)
if not p.is_dir():
return [], False
quant_totals: dict[str, int] = {}
quant_first_file: dict[str, str] = {}
has_vision = False
for f in sorted(p.glob("*.gguf")):
if _is_mmproj(f.name):
has_vision = True
continue
try:
size = f.stat().st_size
except OSError:
size = 0
quant = _extract_quant_label(f.name)
quant_totals[quant] = quant_totals.get(quant, 0) + size
if quant not in quant_first_file:
quant_first_file[quant] = f.name
variants = [
GgufVariantInfo(
filename = quant_first_file[q],
quant = q,
size_bytes = s,
)
for q, s in quant_totals.items()
]
variants.sort(key = lambda v: -v.size_bytes)
return variants, has_vision
def _find_local_gguf_by_variant(directory: str, variant: str) -> Optional[str]:
"""Find the GGUF file in *directory* matching a quantization *variant*.
For sharded GGUFs (multiple files with the same quant label), returns
the first shard (sorted by name) which is what ``llama-server -m`` expects.
Returns the resolved absolute path, or ``None`` if no match.
"""
p = Path(directory)
if not p.is_dir():
return None
matches = sorted(
f
for f in p.glob("*.gguf")
if not _is_mmproj(f.name) and _extract_quant_label(f.name) == variant
)
if matches:
return str(matches[0].resolve())
return None
def detect_gguf_model_remote(
repo_id: str,
hf_token: Optional[str] = None,
@ -1530,7 +1597,10 @@ class ModelConfig:
# Auto-detect GGUF models (check before LoRA/vision detection)
if is_local:
gguf_file = detect_gguf_model(path)
if gguf_variant:
gguf_file = _find_local_gguf_by_variant(path, gguf_variant)
else:
gguf_file = detect_gguf_model(path)
if gguf_file:
display_name = Path(gguf_file).stem
logger.info(f"Detected local GGUF model: {gguf_file}")

View file

@ -133,27 +133,28 @@ def lmstudio_model_dirs() -> list[Path]:
def _setup_cache_env() -> None:
"""Set cache environment variables for HuggingFace, uv, and vLLM.
HuggingFace cache variables are only set when the legacy Unsloth HF
cache already exists, preserving existing model locations. New
installations leave HF at its own defaults.
Respects the standard HF cache resolution chain: explicit ``HF_HOME``
/ ``HF_HUB_CACHE`` env vars take priority, then ``XDG_CACHE_HOME``,
then the platform default (``~/.cache/huggingface``). The legacy
Unsloth cache is still *scanned* for models but is never set as the
active download target.
Only sets variables that are not already set by the user, so
explicit overrides (e.g. HF_HOME=/data/hf) are respected.
Works on Linux, macOS, and Windows.
"""
root = cache_root()
hf_dir = root / "huggingface"
xdg_cache = Path(
os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")
).expanduser()
hf_default = xdg_cache / "huggingface"
defaults: dict[str, str] = {
"HF_HOME": str(hf_default),
"HF_HUB_CACHE": str(hf_default / "hub"),
"HF_XET_CACHE": str(hf_default / "xet"),
"UV_CACHE_DIR": str(root / "uv"),
"VLLM_CACHE_ROOT": str(root / "vllm"),
}
# Preserve legacy HF cache for existing installations
legacy_hub = hf_dir / "hub"
if legacy_hub.is_dir() and any(legacy_hub.iterdir()):
defaults["HF_HOME"] = str(hf_dir)
defaults["HF_HUB_CACHE"] = str(legacy_hub)
defaults["HF_XET_CACHE"] = str(hf_dir / "xet")
for key, value in defaults.items():
if key not in os.environ:
os.environ[key] = value

View file

@ -18,8 +18,8 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { deleteCachedModel, listCachedGguf, listCachedModels, listGgufVariants } from "@/features/chat/api/chat-api";
import type { CachedGgufRepo, CachedModelRepo } from "@/features/chat/api/chat-api";
import { deleteCachedModel, listCachedGguf, listCachedModels, listGgufVariants, listLocalModels } from "@/features/chat/api/chat-api";
import type { CachedGgufRepo, CachedModelRepo, LocalModelInfo } from "@/features/chat/api/chat-api";
import type { GgufVariantDetail } from "@/features/chat/types/api";
import { usePlatformStore } from "@/config/env";
import {
@ -203,17 +203,20 @@ function GgufVariantExpander({
};
}, [repoId]);
// Covers Unix absolute (/), Windows drive (C:\, D:/), UNC (\\server), relative (./, ../), tilde (~/)
const isLocalPath = /^(\/|\.{1,2}[\\\/]|~[\\\/]|[A-Za-z]:[\\\/]|\\\\)/.test(repoId);
const handleVariantClick = useCallback(
(quant: string, downloaded?: boolean, sizeBytes?: number) => {
onSelect(repoId, {
source: "hub",
source: isLocalPath ? "local" : "hub",
isLora: false,
ggufVariant: quant,
isDownloaded: downloaded,
isDownloaded: isLocalPath ? true : downloaded,
expectedBytes: sizeBytes,
});
},
[repoId, onSelect],
[repoId, isLocalPath, onSelect],
);
// GGUF fit classification matching llama-server's _select_gpus logic:
@ -380,6 +383,17 @@ function extractParamLabel(id: string): string | undefined {
// Module-level caches so re-mounting the popover shows results instantly
let _cachedGgufCache: CachedGgufRepo[] = [];
let _cachedModelsCache: CachedModelRepo[] = [];
let _lmStudioCache: LocalModelInfo[] = [];
/** Sort LM Studio models with unsloth publisher first. */
function sortLmStudio(models: LocalModelInfo[]): LocalModelInfo[] {
return [...models].sort((a, b) => {
const aUnsloth = (a.model_id ?? "").startsWith("unsloth/") ? 0 : 1;
const bUnsloth = (b.model_id ?? "").startsWith("unsloth/") ? 0 : 1;
if (aUnsloth !== bUnsloth) return aUnsloth - bUnsloth;
return (a.model_id ?? a.display_name).localeCompare(b.model_id ?? b.display_name);
});
}
// ── Hub Model Picker ──────────────────────────────────────────
@ -413,12 +427,28 @@ export function HubModelPicker({
const alreadyCached = _cachedGgufCache.length > 0 || _cachedModelsCache.length > 0;
const [cachedReady, setCachedReady] = useState(alreadyCached);
// LM Studio local models -- module-level cache so re-mounting the
// popover does not flash an empty section (same pattern as GGUF/models).
const [lmStudioModels, setLmStudioModels] = useState<LocalModelInfo[]>(_lmStudioCache);
const refreshCachedLists = useCallback(() => {
listCachedGguf().then((v) => { _cachedGgufCache = v; setCachedGguf(v); }).catch(() => {});
listCachedModels().then((v) => { _cachedModelsCache = v; setCachedModels(v); }).catch(() => {});
listLocalModels().then((res) => {
const next = sortLmStudio(res.models.filter((m) => m.source === "lmstudio"));
_lmStudioCache = next;
setLmStudioModels(next);
}).catch(() => {});
}, []);
useEffect(() => {
// Always refresh LM Studio models (not gated by alreadyCached)
listLocalModels().then((res) => {
const next = sortLmStudio(res.models.filter((m) => m.source === "lmstudio"));
_lmStudioCache = next;
setLmStudioModels(next);
}).catch(() => {});
if (alreadyCached) return;
let done = 0;
const check = () => { if (++done >= 2) setCachedReady(true); };
@ -686,6 +716,40 @@ export function HubModelPicker({
</>
) : null}
{!showHfSection && chatOnly && lmStudioModels.length > 0 ? (
<>
<ListLabel>LM Studio</ListLabel>
{lmStudioModels.map((m) => {
const isGguf = isGgufRepo(m.id) || isGgufRepo(m.display_name);
return (
<div key={m.id}>
<ModelRow
label={m.model_id ?? m.display_name}
meta={isGguf || m.path.endsWith(".gguf") ? "GGUF" : "Local"}
selected={value === m.id}
onClick={() => {
if (isGguf) {
setExpandedGguf((prev) => (prev === m.id ? null : m.id));
} else {
onSelect(m.id, { source: "local", isLora: false, isDownloaded: true });
}
}}
vramStatus={null}
/>
{expandedGguf === m.id && (
<GgufVariantExpander
repoId={m.id}
onSelect={onSelect}
gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
systemRamGb={gpu.available ? gpu.systemRamAvailableGb : undefined}
/>
)}
</div>
);
})}
</>
) : null}
{!showHfSection && cachedReady ? (
<>
<ListLabel>{"\uD83E\uDDA5"} Recommended</ListLabel>
@ -837,6 +901,8 @@ export function LoraModelPicker({
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
}) {
const [query, setQuery] = useState("");
const [expandedGguf, setExpandedGguf] = useState<string | null>(null);
const gpu = useGpuInfo();
const normalized = useMemo(
() =>
@ -846,11 +912,17 @@ export function LoraModelPicker({
baseModel: model.baseModel || model.description || "Unknown base model",
}))
.sort((a, b) => {
const baseCmp = a.baseModel.localeCompare(b.baseModel);
if (baseCmp !== 0) return baseCmp;
// Prioritize unsloth publisher within LM Studio group
if (a.baseModel === "LM Studio" && b.baseModel === "LM Studio") {
const aUnsloth = a.name.startsWith("unsloth/") ? 0 : 1;
const bUnsloth = b.name.startsWith("unsloth/") ? 0 : 1;
if (aUnsloth !== bUnsloth) return aUnsloth - bUnsloth;
}
const aTime = a.updatedAt ?? -1;
const bTime = b.updatedAt ?? -1;
if (aTime !== bTime) return bTime - aTime;
const baseCmp = a.baseModel.localeCompare(b.baseModel);
if (baseCmp !== 0) return baseCmp;
return a.name.localeCompare(b.name);
}),
[loraModels],
@ -905,34 +977,53 @@ export function LoraModelPicker({
{index > 0 ? <div className="my-1" /> : null}
<ListLabel>{baseModel}</ListLabel>
{adapters.map((adapter) => {
const isLocal = adapter.source === "local";
const isExported = adapter.source === "exported";
const isMerged = adapter.exportType === "merged";
const isGguf = adapter.exportType === "gguf";
const tag = isGguf
? "GGUF"
: isExported
? isMerged ? "Merged" : "LoRA"
: "LoRA";
const meta = isExported ? `${tag} · Exported` : tag;
const isLocalGgufDir = isLocal && (isGgufRepo(adapter.id) || isGgufRepo(adapter.name));
const tag = isLocal
? isLocalGgufDir ? "GGUF" : "Local"
: isGguf
? "GGUF"
: isExported
? isMerged ? "Merged" : "LoRA"
: "LoRA";
const meta = isLocal ? (isLocalGgufDir ? "GGUF" : "Local") : isExported ? `${tag} · Exported` : tag;
return (
<ModelRow
key={adapter.id}
label={adapter.name}
meta={meta}
selected={value === adapter.id}
onClick={() => onSelect(adapter.id, {
source: isExported ? "exported" : "lora",
isLora: !isMerged && !isGguf,
})}
tooltipText={
<>
<span className="block break-words">{adapter.name}</span>
<span className="block mt-1 text-[10px] text-muted-foreground break-all">
{adapter.id}
</span>
</>
}
/>
<div key={adapter.id}>
<ModelRow
label={adapter.name}
meta={meta}
selected={value === adapter.id}
onClick={() => {
if (isLocalGgufDir) {
setExpandedGguf((prev) => (prev === adapter.id ? null : adapter.id));
} else {
onSelect(adapter.id, {
source: isLocal ? "local" : isExported ? "exported" : "lora",
isLora: !isLocal && !isMerged && !isGguf,
});
}
}}
tooltipText={
<>
<span className="block break-words">{adapter.name}</span>
<span className="block mt-1 text-[10px] text-muted-foreground break-all">
{adapter.id}
</span>
</>
}
/>
{expandedGguf === adapter.id && (
<GgufVariantExpander
repoId={adapter.id}
onSelect={onSelect}
gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
systemRamGb={gpu.available ? gpu.systemRamAvailableGb : undefined}
/>
)}
</div>
);
})}
</div>

View file

@ -13,12 +13,12 @@ export interface ModelOption {
export interface LoraModelOption extends ModelOption {
baseModel?: string;
updatedAt?: number;
source?: "training" | "exported";
source?: "training" | "exported" | "local";
exportType?: "lora" | "merged" | "gguf";
}
export interface ModelSelectorChangeMeta {
source: "hub" | "lora" | "exported";
source: "hub" | "lora" | "exported" | "local";
isLora: boolean;
ggufVariant?: string;
isDownloaded?: boolean;

View file

@ -17,6 +17,7 @@ import {
import { cn } from "@/lib/utils";
import {
ArrowRight01Icon,
Cancel01Icon,
Book03Icon,
BubbleChatIcon,
ChefHatIcon,
@ -29,8 +30,9 @@ import { useTrainingRuntimeStore } from "@/features/training";
import { usePlatformStore } from "@/config/env";
import { Link, useRouterState } from "@tanstack/react-router";
import { motion } from "motion/react";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { TOUR_OPEN_EVENT } from "@/features/tour";
import { ShutdownDialog } from "@/components/shutdown-dialog";
const NAV_ITEMS = [
{ label: "Studio", href: "/studio", icon: ZapIcon, enabled: true },
@ -50,9 +52,35 @@ export function Navbar() {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const isTrainingRunning = useTrainingRuntimeStore((s) => s.isTrainingRunning);
const [mobileOpen, setMobileOpen] = useState(false);
const [shutdownOpen, setShutdownOpen] = useState(false);
const chatOnly = usePlatformStore((s) => s.isChatOnly());
// Warn before closing the tab only when training is running (data loss risk).
// We store the handler in a ref so removeUnloadHandler() can clean it up
// before the "Server stopped" page renders.
const unloadHandlerRef = useRef<((e: BeforeUnloadEvent) => void) | null>(null);
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (!useTrainingRuntimeStore.getState().isTrainingRunning) return;
e.preventDefault();
e.returnValue = "";
};
unloadHandlerRef.current = handler;
window.addEventListener("beforeunload", handler);
return () => {
window.removeEventListener("beforeunload", handler);
};
}, []);
const removeUnloadHandler = () => {
if (unloadHandlerRef.current) {
window.removeEventListener("beforeunload", unloadHandlerRef.current);
unloadHandlerRef.current = null;
}
};
const tourId = getTourId(pathname);
const openTour = () => {
@ -63,6 +91,7 @@ export function Navbar() {
};
return (
<>
<header className="relative top-0 z-40 h-16 w-full">
<div className="mx-auto grid h-full max-w-7xl grid-cols-[1fr_auto_1fr] items-center px-4 sm:px-6">
{/* Left: logo */}
@ -206,6 +235,16 @@ export function Navbar() {
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
<span className="text-sm font-medium">Tour</span>
</button>
<button
type="button"
onClick={() => setShutdownOpen(true)}
className="-mr-1.5 flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Shut down Unsloth Studio server"
aria-label="Shut down Unsloth Studio server"
>
<HugeiconsIcon icon={Cancel01Icon} className="size-5" />
</button>
</div>
{/* Right: mobile */}
@ -292,6 +331,17 @@ export function Navbar() {
Start tour
</button>
) : null}
<button
type="button"
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-left text-sm font-medium text-foreground hover:bg-accent"
onClick={() => {
setMobileOpen(false);
setShutdownOpen(true);
}}
>
<HugeiconsIcon icon={Cancel01Icon} className="size-5" />
Quit Unsloth Studio
</button>
<div className="mt-2 flex items-center justify-between rounded-md border border-border px-3 py-2">
<span className="text-sm font-medium text-foreground">Theme</span>
<AnimatedThemeToggler
@ -306,5 +356,12 @@ export function Navbar() {
</div>
</div>
</header>
<ShutdownDialog
open={shutdownOpen}
onOpenChange={setShutdownOpen}
onBeforeShutdown={removeUnloadHandler}
/>
</>
);
}

View file

@ -0,0 +1,84 @@
// 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 { toastError } from "@/shared/toast";
import { useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface ShutdownDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called right before the shutdown API request so callers can remove the
* beforeunload listener otherwise the "Server stopped" page would still
* trigger a "Leave site?" prompt when the user tries to close it. */
onBeforeShutdown?: () => void;
}
export function ShutdownDialog({
open,
onOpenChange,
onBeforeShutdown,
}: ShutdownDialogProps) {
const [stopping, setStopping] = useState(false);
const handleStop = async () => {
setStopping(true);
let accepted = false;
try {
const res = await authFetch("/api/shutdown", { method: "POST" });
accepted = res.ok;
if (!accepted) {
toastError("Failed to shut down server");
setStopping(false);
return;
}
} catch {
// Network error — shutdown request never reached the server
toastError("Could not reach server");
setStopping(false);
return;
}
onBeforeShutdown?.();
document.body.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;gap:12px">
<p style="font-size:1.1rem;font-weight:600;margin:0">Unsloth Studio has stopped.</p>
<p style="font-size:0.9rem;color:#888;margin:0">You can now close this tab.</p>
</div>`;
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Stop Unsloth Studio?</AlertDialogTitle>
<AlertDialogDescription>
This will shut down the server. Any active training or inference
jobs will be terminated. You can restart it any time from the
desktop shortcut.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleStop}
disabled={stopping}
variant="destructive"
>
{stopping ? "Stopping…" : "Stop server"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View file

@ -307,6 +307,7 @@ async function autoLoadSmallestModel(): Promise<boolean> {
useChatRuntimeStore.setState({
ggufContextLength: loadResp.context_length ?? 131072,
supportsReasoning: loadResp.supports_reasoning ?? false,
reasoningAlwaysOn: loadResp.reasoning_always_on ?? false,
reasoningEnabled: loadResp.supports_reasoning ?? false,
supportsTools: loadResp.supports_tools ?? false,
toolsEnabled: loadResp.supports_tools ?? false,
@ -392,6 +393,7 @@ async function autoLoadSmallestModel(): Promise<boolean> {
useChatRuntimeStore.setState({
ggufContextLength: loadResp.context_length ?? 131072,
supportsReasoning: loadResp.supports_reasoning ?? false,
reasoningAlwaysOn: loadResp.reasoning_always_on ?? false,
reasoningEnabled: loadResp.supports_reasoning ?? false,
supportsTools: loadResp.supports_tools ?? false,
toolsEnabled: loadResp.supports_tools ?? false,

View file

@ -125,6 +125,27 @@ export async function getDownloadProgress(
return parseJsonOrThrow(response);
}
export interface LocalModelInfo {
id: string;
display_name: string;
path: string;
source: "models_dir" | "hf_cache" | "lmstudio";
model_id?: string | null;
updated_at?: number | null;
}
interface LocalModelListResponse {
models_dir: string;
hf_cache_dir?: string | null;
lmstudio_dirs: string[];
models: LocalModelInfo[];
}
export async function listLocalModels(): Promise<LocalModelListResponse> {
const response = await authFetch("/api/models/local");
return parseJsonOrThrow<LocalModelListResponse>(response);
}
export async function listCachedGguf(): Promise<CachedGgufRepo[]> {
const response = await authFetch("/api/models/cached-gguf");
const data = await parseJsonOrThrow<{ cached: CachedGgufRepo[] }>(response);

View file

@ -37,6 +37,7 @@ import {
} from "react";
import { toast } from "sonner";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { listLocalModels } from "./api/chat-api";
import { ChatSettingsPanel } from "./chat-settings-sheet";
import { ContextUsageBar } from "./components/context-usage-bar";
import { ModelLoadInlineStatus } from "./components/model-load-status";
@ -578,22 +579,36 @@ export function ChatPage(): ReactElement {
[modelsFromStore],
);
const loraModels = useMemo<LoraModelOption[]>(
() =>
lorasFromStore.map((lora) => ({
id: lora.id,
name: lora.name,
baseModel: lora.baseModel,
updatedAt: lora.updatedAt,
source: lora.source,
exportType: lora.exportType,
})),
[lorasFromStore],
);
const [localModels, setLocalModels] = useState<LoraModelOption[]>([]);
const loraModels = useMemo<LoraModelOption[]>(() => {
const fromLoras = lorasFromStore.map((lora) => ({
id: lora.id,
name: lora.name,
baseModel: lora.baseModel,
updatedAt: lora.updatedAt,
source: lora.source,
exportType: lora.exportType,
}));
return [...fromLoras, ...localModels];
}, [lorasFromStore, localModels]);
useEffect(() => {
if (getTrainingCompareHandoff()) return;
void refresh();
void listLocalModels().then((res) => {
setLocalModels(
res.models
.filter((m) => m.source === "lmstudio" || m.source === "models_dir")
.map((m) => ({
id: m.id,
name: m.source === "lmstudio" && m.model_id ? m.model_id : m.display_name,
baseModel: m.source === "lmstudio" ? "LM Studio" : "Local models",
updatedAt: m.updated_at ?? undefined,
source: "local" as const,
})),
);
}).catch(() => {});
}, [refresh]);
useEffect(() => {

View file

@ -238,9 +238,11 @@ export function useChatModelRuntime() {
// Restore reasoning/tools support flags and context length
const supportsReasoning = statusRes.supports_reasoning ?? false;
const reasoningAlwaysOn = statusRes.reasoning_always_on ?? false;
const supportsTools = statusRes.supports_tools ?? false;
useChatRuntimeStore.setState({
supportsReasoning,
reasoningAlwaysOn,
supportsTools,
ggufContextLength: statusRes.is_gguf ? (statusRes.context_length ?? null) : null,
});
@ -420,10 +422,12 @@ export function useChatModelRuntime() {
&& customContextLength !== nativeCtx
? customContextLength
: null;
const reasoningAlwaysOn = loadResponse.reasoning_always_on ?? false;
useChatRuntimeStore.setState({
ggufContextLength: nativeCtx,
supportsReasoning: loadResponse.supports_reasoning ?? false,
reasoningEnabled: reasoningDefault,
reasoningAlwaysOn,
reasoningEnabled: reasoningAlwaysOn ? true : reasoningDefault,
supportsTools: loadResponse.supports_tools ?? false,
toolsEnabled: loadResponse.supports_tools ?? false,
codeToolsEnabled: loadResponse.supports_tools ?? false,

View file

@ -241,6 +241,7 @@ export function SharedComposer({
(s) => !!s.params.checkpoint && !s.modelLoading,
);
const supportsReasoning = useChatRuntimeStore((s) => s.supportsReasoning);
const reasoningAlwaysOn = useChatRuntimeStore((s) => s.reasoningAlwaysOn);
const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled);
const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled);
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
@ -528,6 +529,7 @@ export function SharedComposer({
type="button"
disabled={reasoningDisabled}
onClick={() => {
if (reasoningAlwaysOn) return;
const next = !reasoningEnabled;
setReasoningEnabled(next);
// Qwen3/3.5: adjust params for thinking on/off
@ -544,13 +546,13 @@ export function SharedComposer({
"flex items-center gap-0.5 rounded-full px-2 py-0.5 text-xs font-medium transition-colors",
reasoningDisabled
? "cursor-not-allowed opacity-40"
: reasoningEnabled
: (reasoningEnabled || reasoningAlwaysOn)
? "bg-primary/10 text-primary hover:bg-primary/20"
: "bg-muted text-muted-foreground hover:bg-muted-foreground/15",
)}
aria-label={reasoningEnabled ? "Disable thinking" : "Enable thinking"}
>
{reasoningEnabled && !reasoningDisabled ? (
{(reasoningEnabled || reasoningAlwaysOn) && !reasoningDisabled ? (
<LightbulbIcon className="size-3" />
) : (
<LightbulbOffIcon className="size-3" />

View file

@ -151,6 +151,7 @@ type ChatRuntimeStore = {
activeGgufVariant: string | null;
ggufContextLength: number | null;
supportsReasoning: boolean;
reasoningAlwaysOn: boolean;
reasoningEnabled: boolean;
supportsTools: boolean;
toolsEnabled: boolean;
@ -213,6 +214,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
activeGgufVariant: null,
ggufContextLength: null,
supportsReasoning: false,
reasoningAlwaysOn: false,
reasoningEnabled: true,
supportsTools: false,
toolsEnabled: false,

View file

@ -87,6 +87,7 @@ export interface LoadModelResponse {
};
context_length?: number | null;
supports_reasoning?: boolean;
reasoning_always_on?: boolean;
supports_tools?: boolean;
cache_type_kv?: string | null;
chat_template?: string | null;
@ -115,6 +116,7 @@ export interface InferenceStatusResponse {
trust_remote_code?: boolean;
};
supports_reasoning?: boolean;
reasoning_always_on?: boolean;
supports_tools?: boolean;
context_length?: number | null;
}

View file

@ -58,6 +58,13 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
/** Extract param count label from model name (e.g. "Qwen3-0.6B" -> "0.6B"). */
function extractParamLabel(id: string): string | null {
const name = id.split("/").pop() ?? id;
const match = name.match(/(?:^|[-_])(\d+(?:\.\d+)?)[Bb](?:[-_]|$)/);
return match ? `${match[1]}B` : null;
}
export function ModelSelectionStep() {
const gpu = useGpuInfo();
const {
@ -119,7 +126,7 @@ export function ModelSelectionStep() {
const fit = fitMap.get(r.id);
map.set(r.id, {
status: fit?.status ?? null,
detail: r.totalParams ? formatCompact(r.totalParams) : null,
detail: r.totalParams ? formatCompact(r.totalParams) : extractParamLabel(r.id),
});
}
return map;

View file

@ -72,6 +72,13 @@ const DARK_CONTENT =
const DARK_COMBOBOX_CONTENT =
"bg-foreground text-background shadow-xl border-background/10 dark:[--accent:rgba(2,6,23,0.08)] dark:[--accent-foreground:rgb(2,6,23)] dark:[&_[data-slot=combobox-item]]:text-slate-900 dark:[&_.text-muted-foreground]:text-slate-500";
/** Extract param count label from model name (e.g. "Qwen3-0.6B" -> "0.6B"). */
function extractParamLabel(id: string): string | null {
const name = id.split("/").pop() ?? id;
const match = name.match(/(?:^|[-_])(\d+(?:\.\d+)?)[Bb](?:[-_]|$)/);
return match ? `${match[1]}B` : null;
}
export function ModelSection() {
const gpu = useGpuInfo();
@ -233,7 +240,7 @@ export function ModelSection() {
{ est: number; status: VramFitStatus | null; detail: string | null }
>();
for (const r of hfResults) {
const detail = r.totalParams ? formatCompact(r.totalParams) : null;
const detail = r.totalParams ? formatCompact(r.totalParams) : extractParamLabel(r.id);
const fit = fitMap.get(r.id);
map.set(r.id, {
est: fit?.est ?? 0,

View file

@ -462,25 +462,27 @@ def install_python_stack() -> int:
if skip_base:
print(_green(f"{package_name} already installed — skipping base packages"))
elif NO_TORCH:
# No-torch mode: install runtime deps via [huggingfacenotorch] extras
# (safetensors, transformers, datasets, etc.), then unsloth-zoo with
# --no-deps to avoid pulling torch.
# No-torch update path: install unsloth + unsloth-zoo with --no-deps
# (current PyPI metadata still declares torch as a hard dep), then
# runtime deps with --no-deps (avoids transitive torch).
_progress("base packages (no torch)")
pip_install(
"Installing unsloth runtime deps (no-torch mode)",
"--no-cache-dir",
"--upgrade-package",
"unsloth",
"unsloth[huggingfacenotorch]>=2026.3.14",
)
pip_install(
"Installing unsloth-zoo (no-torch mode)",
f"Updating {package_name} + unsloth-zoo (no-torch mode)",
"--no-cache-dir",
"--no-deps",
"--upgrade-package",
package_name,
"--upgrade-package",
"unsloth-zoo",
package_name,
"unsloth-zoo",
)
pip_install(
"Installing no-torch runtime deps",
"--no-cache-dir",
"--no-deps",
req = REQ_ROOT / "no-torch-runtime.txt",
)
if local_repo:
pip_install(
"Overlaying local repo (editable)",