From 9d68621614b56700f39c5ba18d470c67439cac59 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 27 Mar 2026 03:40:14 -0700 Subject: [PATCH 01/12] Streaming tool detection: guard late tool_calls, filter incomplete fragments (#4648) * Guard against late tool_calls after visible content, filter incomplete fragments 1. If visible content was already emitted (_last_emitted is non-empty) when delta.tool_calls arrives, ignore the tool_calls instead of reclassifying the turn as a tool call. llama-server never interleaves content and tool_calls (they are mutually exclusive), but this guard is defensive for other OpenAI-compatible backends. 2. Filter out incomplete structured tool_calls fragments before execution. Entries with empty function.name (from truncation by max_tokens, disconnect, or interruption) are skipped instead of being passed to execute_tool(). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/core/inference/llama_cpp.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 8a0d1c0962..2b4ed3b2e6 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -2135,6 +2135,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 +2367,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 From b1c3a1e857b4dacf5b76ad51b0aac57be6359d2d Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 27 Mar 2026 03:58:51 -0700 Subject: [PATCH 02/12] fix: replace [huggingfacenotorch] with no-torch-runtime.txt requirements (#4649) The [huggingfacenotorch] extras only exist in pyproject.toml but are NOT published on PyPI, so uv pip install "unsloth[huggingfacenotorch]" fails on fresh installs from the registry. Fix: add studio/backend/requirements/no-torch-runtime.txt with the runtime deps (safetensors, transformers, datasets, accelerate, etc.) that mirror [huggingfacenotorch] from pyproject.toml. In no-torch mode: 1. install.sh/ps1 install unsloth + unsloth-zoo with --no-deps 2. SKIP_STUDIO_BASE=0 so install_python_stack.py's NO_TORCH branch runs 3. install_python_stack.py installs no-torch-runtime.txt --- install.ps1 | 18 +++++------ install.sh | 31 ++++++++++--------- .../backend/requirements/no-torch-runtime.txt | 24 ++++++++++++++ studio/install_python_stack.py | 20 +++--------- 4 files changed, 55 insertions(+), 38 deletions(-) create mode 100644 studio/backend/requirements/no-torch-runtime.txt diff --git a/install.ps1 b/install.ps1 index dede04d2c5..df80f23716 100644 --- a/install.ps1 +++ b/install.ps1 @@ -668,10 +668,9 @@ shell.Run cmd, 0, False # 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 packages without deps to avoid pulling torch. + # Runtime deps are installed by install_python_stack.py via no-torch-runtime.txt. + uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.14" unsloth-zoo } else { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.14" unsloth-zoo } @@ -693,10 +692,9 @@ 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 packages without deps to avoid pulling torch. + # Runtime deps are installed by install_python_stack.py via no-torch-runtime.txt. + uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.3.14" unsloth-zoo if ($StudioLocalInstall) { Write-Host "==> Overlaying local repo (editable)..." uv pip install --python $VenvPython -e $RepoRoot --no-deps @@ -737,7 +735,9 @@ shell.Run cmd, 0, False return } # Tell setup.ps1 to skip base package installation (install.ps1 already did it) - $env:SKIP_STUDIO_BASE = "1" + # When no-torch, don't skip base so install_python_stack installs + # no-torch-runtime.txt (the runtime deps that --no-deps skipped). + $env:SKIP_STUDIO_BASE = if ($SkipTorch) { "0" } else { "1" } $env:STUDIO_PACKAGE_NAME = $PackageName $env:UNSLOTH_NO_TORCH = if ($SkipTorch) { "true" } else { "false" } if ($StudioLocalInstall) { diff --git a/install.sh b/install.sh index 3f758527fc..16736b43ee 100755 --- a/install.sh +++ b/install.sh @@ -947,13 +947,12 @@ 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 packages without deps to avoid pulling torch. + # Runtime deps (safetensors, transformers, etc.) are installed by + # install_python_stack.py via no-torch-runtime.txt. 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 else uv pip install --python "$_VENV_PY" \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ @@ -975,13 +974,11 @@ 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 packages without deps to avoid pulling torch. + # Runtime deps are installed by install_python_stack.py via no-torch-runtime.txt. 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 if [ "$STUDIO_LOCAL_INSTALL" = true ]; then echo "==> Overlaying local repo (editable)..." uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps @@ -1039,15 +1036,21 @@ if [ -n "$VENV_ABS_BIN" ]; then fi echo "==> Running unsloth setup..." +# When no-torch, don't skip base so install_python_stack installs +# no-torch-runtime.txt (the runtime deps that --no-deps skipped). +_SKIP_BASE=1 +if [ "$SKIP_TORCH" = true ]; then + _SKIP_BASE=0 +fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then - SKIP_STUDIO_BASE=1 \ + SKIP_STUDIO_BASE="$_SKIP_BASE" \ STUDIO_PACKAGE_NAME="$PACKAGE_NAME" \ STUDIO_LOCAL_INSTALL=1 \ STUDIO_LOCAL_REPO="$_REPO_ROOT" \ UNSLOTH_NO_TORCH="$SKIP_TORCH" \ bash "$SETUP_SH" =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 diff --git a/studio/install_python_stack.py b/studio/install_python_stack.py index f6fd38d5d8..d32e29d0f7 100644 --- a/studio/install_python_stack.py +++ b/studio/install_python_stack.py @@ -462,24 +462,14 @@ 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 mode: unsloth + unsloth-zoo are already installed with + # --no-deps by install.sh/install.ps1. Install the runtime deps + # (safetensors, transformers, datasets, etc.) from no-torch-runtime.txt. _progress("base packages (no torch)") pip_install( - "Installing unsloth runtime deps (no-torch mode)", + "Installing no-torch runtime deps", "--no-cache-dir", - "--upgrade-package", - "unsloth", - "unsloth[huggingfacenotorch]>=2026.3.14", - ) - pip_install( - "Installing unsloth-zoo (no-torch mode)", - "--no-cache-dir", - "--no-deps", - "--upgrade-package", - "unsloth-zoo", - "unsloth-zoo", + req = REQ_ROOT / "no-torch-runtime.txt", ) if local_repo: pip_install( From 1fb9fe330416abe4cf795dc6aed4f0d9aa90e619 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 27 Mar 2026 04:33:04 -0700 Subject: [PATCH 03/12] Fix orphan server cleanup killing user's own llama-server (#4622) * fix: only kill studio-managed llama-server processes, not user's own servers _kill_orphaned_servers() checked for "unsloth" anywhere in the process cmdline, which matched the user's own llama-server when serving models from unsloth/ HF repos (the model path in -m contains "unsloth"). This caused the user's server to get SIGKILLed on Studio startup, destroying their prompt cache and forcing full model re-loads. Narrow the check to only match processes whose binary path lives under ~/.unsloth/llama.cpp/ (the Studio install directory). * Address review: cover env var paths, move Path.home() inside try block - Also check LLAMA_SERVER_PATH and UNSLOTH_LLAMA_CPP_PATH so orphans from custom install locations are still cleaned up. - Move studio_dirs construction inside the try/except so a Path.home() failure (containers without HOME) does not crash the constructor. * Address reviewer feedback: proper path ancestry, /proc/pid/exe, legacy paths Changes based on 10-reviewer consensus: - Use Path.is_relative_to() instead of substring matching to prevent false positives on sibling paths like ~/.unsloth/llama.cpp-backup/. - Use /proc//exe (symlink to real binary) instead of parsing the first cmdline token, which breaks on paths with spaces. Falls back to cmdline parsing on non-Linux or when /proc is unavailable. - Add legacy in-tree install paths (project_root/llama.cpp/ and project_root/bin/) so orphans from older setup.sh are still cleaned. - Treat LLAMA_SERVER_PATH as an exact binary match rather than widening it to its parent directory, which could match unrelated servers in shared locations like /usr/local/bin/. - Keep everything inside the try/except so Path.home() failures in containers do not crash the constructor. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address review: add Linux platform guard and log cleanup errors - Guard pgrep fallback with sys.platform check so it does not crash on Windows/macOS when psutil is unavailable. - Replace silent except-pass with logger.warning for observability. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/core/inference/llama_cpp.py | 145 +++++++++++++++------ 1 file changed, 107 insertions(+), 38 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 2b4ed3b2e6..3f9bc0421e 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -1367,27 +1367,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//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 +1424,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//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//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.""" From 887b8cb1c27723212422af4f86bc8c1e492043b7 Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Fri, 27 Mar 2026 12:36:08 +0100 Subject: [PATCH 04/12] fix: add auth + UX improvements to shutdown button (#4642) * Studio shutdown button * fix: add auth to shutdown endpoint and improve UX - Add JWT auth (Depends(get_current_subject)) to POST /api/shutdown - Use authFetch instead of bare fetch in shutdown dialog - Only show beforeunload prompt when training is running - Remove Ctrl+W/Cmd+W interception (browsers don't allow it) - Store shutdown task on app.state to prevent GC --------- Co-authored-by: Datta Nimmaturi Co-authored-by: Daniel Han --- studio/backend/main.py | 31 ++++++- studio/backend/run.py | 9 ++ studio/frontend/src/components/navbar.tsx | 60 ++++++++++++- .../src/components/shutdown-dialog.tsx | 84 +++++++++++++++++++ 4 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 studio/frontend/src/components/shutdown-dialog.tsx diff --git a/studio/backend/main.py b/studio/backend/main.py index 5e647f6312..65f5e7fe90 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -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""" diff --git a/studio/backend/run.py b/studio/backend/run.py index 87fcdc01da..18babf6229 100644 --- a/studio/backend/run.py +++ b/studio/backend/run.py @@ -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( diff --git a/studio/frontend/src/components/navbar.tsx b/studio/frontend/src/components/navbar.tsx index d90276a641..5fece2b2b2 100644 --- a/studio/frontend/src/components/navbar.tsx +++ b/studio/frontend/src/components/navbar.tsx @@ -17,6 +17,7 @@ import { import { cn } from "@/lib/utils"; import { ArrowRight01Icon, + StopIcon, 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 ( + <>
{/* Left: logo */} @@ -206,6 +235,17 @@ export function Navbar() { Tour + +
{/* Right: mobile */} @@ -292,6 +332,17 @@ export function Navbar() { Start tour ) : null} +
Theme
+ + + ); } diff --git a/studio/frontend/src/components/shutdown-dialog.tsx b/studio/frontend/src/components/shutdown-dialog.tsx new file mode 100644 index 0000000000..dea738bf6b --- /dev/null +++ b/studio/frontend/src/components/shutdown-dialog.tsx @@ -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 = ` +
+

Unsloth Studio has stopped.

+

You can now close this tab.

+
`; + }; + + return ( + + + + Stop Unsloth Studio? + + 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. + + + + Cancel + + {stopping ? "Stopping…" : "Stop server"} + + + + + ); +} From a7c43bc46d0fff1bb19a5b307b1fae34b5a964db Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 27 Mar 2026 04:51:30 -0700 Subject: [PATCH 05/12] Fix inference failing for transformers 5.x models (trust_remote_code) (#4652) * Fix inference failing for transformers 5.x models (trust_remote_code) The training worker in core/training/worker.py auto-enables trust_remote_code for unsloth/* models that need transformers 5.x (e.g. NVIDIA-Nemotron-3-Nano-4B). The inference worker did not have the same logic, so loading these models for chat would fail with "No config file found" while training worked fine. Add the same auto-detection to the inference worker so trust_remote_code is set automatically when needed. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: Daniel Han Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/core/inference/worker.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/studio/backend/core/inference/worker.py b/studio/backend/core/inference/worker.py index 2eb46f3217..afe0ecc458 100644 --- a/studio/backend/core/inference/worker.py +++ b/studio/backend/core/inference/worker.py @@ -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: From eacaf6827c0c73b6ceb170b4a8a29dfb4eead556 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 27 Mar 2026 05:19:26 -0700 Subject: [PATCH 06/12] fix: no-torch install deps without pulling torch transitively (#4650) Use --no-deps for ALL packages (unsloth, unsloth-zoo, and runtime deps) since the current PyPI metadata for unsloth still declares torch as a hard dependency. Runtime deps (typer, pydantic, safetensors, transformers, etc.) are installed from no-torch-runtime.txt with --no-deps to prevent transitive torch resolution from accelerate, peft, trl, and sentence-transformers. no-torch-runtime.txt now includes unsloth's own direct deps (typer, pydantic, pyyaml, nest-asyncio) since --no-deps skips those too. install.sh installs no-torch-runtime.txt directly (via helper function _find_no_torch_runtime). install.ps1 does the same via Find-NoTorchRuntimeFile. SKIP_STUDIO_BASE stays at 1 to avoid setup.sh fast-path issues. install_python_stack.py NO_TORCH branch does the same for unsloth studio update, using package_name instead of hardcoded "unsloth". --- install.ps1 | 32 +++++++++++--- install.sh | 44 +++++++++++++------ .../backend/requirements/no-torch-runtime.txt | 17 +++++-- studio/install_python_stack.py | 18 ++++++-- 4 files changed, 85 insertions(+), 26 deletions(-) diff --git a/install.ps1 b/install.ps1 index df80f23716..215c8f6040 100644 --- a/install.ps1 +++ b/install.ps1 @@ -663,14 +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 packages without deps to avoid pulling torch. - # Runtime deps are installed by install_python_stack.py via no-torch-runtime.txt. + # 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 } @@ -692,9 +707,13 @@ shell.Run cmd, 0, False Write-Host "==> Installing unsloth (this may take a few minutes)..." if ($SkipTorch) { - # No-torch: install packages without deps to avoid pulling torch. - # Runtime deps are installed by install_python_stack.py via no-torch-runtime.txt. + # 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 @@ -735,9 +754,8 @@ shell.Run cmd, 0, False return } # Tell setup.ps1 to skip base package installation (install.ps1 already did it) - # When no-torch, don't skip base so install_python_stack installs - # no-torch-runtime.txt (the runtime deps that --no-deps skipped). - $env:SKIP_STUDIO_BASE = if ($SkipTorch) { "0" } else { "1" } + # 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" } if ($StudioLocalInstall) { diff --git a/install.sh b/install.sh index 16736b43ee..4c960a129e 100755 --- a/install.sh +++ b/install.sh @@ -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,12 +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 packages without deps to avoid pulling torch. - # Runtime deps (safetensors, transformers, etc.) are installed by - # install_python_stack.py via no-torch-runtime.txt. + # 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 --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 \ @@ -974,11 +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 packages without deps to avoid pulling torch. - # Runtime deps are installed by install_python_stack.py via no-torch-runtime.txt. + # 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 --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 @@ -1036,21 +1060,15 @@ if [ -n "$VENV_ABS_BIN" ]; then fi echo "==> Running unsloth setup..." -# When no-torch, don't skip base so install_python_stack installs -# no-torch-runtime.txt (the runtime deps that --no-deps skipped). -_SKIP_BASE=1 -if [ "$SKIP_TORCH" = true ]; then - _SKIP_BASE=0 -fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then - SKIP_STUDIO_BASE="$_SKIP_BASE" \ + SKIP_STUDIO_BASE=1 \ STUDIO_PACKAGE_NAME="$PACKAGE_NAME" \ STUDIO_LOCAL_INSTALL=1 \ STUDIO_LOCAL_REPO="$_REPO_ROOT" \ UNSLOTH_NO_TORCH="$SKIP_TORCH" \ bash "$SETUP_SH" =0.42.0 packaging numpy diff --git a/studio/install_python_stack.py b/studio/install_python_stack.py index d32e29d0f7..e8ae22f470 100644 --- a/studio/install_python_stack.py +++ b/studio/install_python_stack.py @@ -462,13 +462,25 @@ def install_python_stack() -> int: if skip_base: print(_green(f"✅ {package_name} already installed — skipping base packages")) elif NO_TORCH: - # No-torch mode: unsloth + unsloth-zoo are already installed with - # --no-deps by install.sh/install.ps1. Install the runtime deps - # (safetensors, transformers, datasets, etc.) from no-torch-runtime.txt. + # 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( + 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: From e36f72c685b275bb4efd33c0e0daebbdec4d1877 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 27 Mar 2026 05:42:26 -0700 Subject: [PATCH 07/12] Detect always-on reasoning models and show Think button as locked-on (#4654) * Detect always-on reasoning models and show Think button as locked-on Models with hardcoded / tags or reasoning_content in their chat template (e.g. distilled reasoning models) always produce thinking output regardless of any toggle. Previously these models were not detected as reasoning-capable at all, so the Think button was grayed out even though the model was actively reasoning. Backend: - Detect / and reasoning_content in GGUF chat templates as a fallback when enable_thinking is not present - Add reasoning_always_on flag to LoadResponse and InferenceStatusResponse - Pass the flag through all GGUF load and status response paths Frontend: - Add reasoningAlwaysOn to the chat runtime store and API types - When reasoning_always_on is true, show the Think button as lit (active) but not clickable, with a tooltip explaining the model always uses thinking - Force reasoningEnabled=true when the model always reasons * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Use pointer-events-none instead of disabled for always-on Think button The HTML disabled attribute was not fully blocking clicks on the Think button for always-on reasoning models. Switch to pointer-events-none CSS class which prevents all mouse interaction at the CSS level. * Use a static span instead of disabled button for always-on Think Replace the button element with a plain span when reasoning is always on. This makes it physically impossible to toggle since there is no clickable element at all, avoiding any CSS or disabled-attribute edge cases. * Simplify always-on Think button to stay lit and remain toggleable Keep the Think button as a normal toggleable button but ensure it shows as lit when reasoning_always_on is true. The model always reasons regardless of the toggle state so there is no need to block interaction. --------- Co-authored-by: Daniel Han Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/core/inference/llama_cpp.py | 21 +++++++++++++++++++ studio/backend/models/inference.py | 7 +++++++ studio/backend/routes/inference.py | 3 +++ .../src/features/chat/api/chat-adapter.ts | 2 ++ .../chat/hooks/use-chat-model-runtime.ts | 6 +++++- .../src/features/chat/shared-composer.tsx | 6 ++++-- .../chat/stores/chat-runtime-store.ts | 2 ++ .../frontend/src/features/chat/types/api.ts | 2 ++ 8 files changed, 46 insertions(+), 3 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 3f9bc0421e..7909af8a23 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -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 tags or reasoning_content + # in their chat template always produce thinking output + # (no toggle to disable it). + if not self._supports_reasoning: + if ( + "" in tpl + and "" in tpl + or "reasoning_content" in tpl + ): + self._supports_reasoning = True + self._reasoning_always_on = True + logger.info( + "GGUF metadata: model always reasons ( tags in template)" + ) # Detect tool calling support from chat template tool_markers = [ "{%- if tools %}", @@ -1318,6 +1338,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 diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index 36395af7bd..accdcc1290 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -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 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" ) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index f57342b59c..6f44a3c69f 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -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, ) diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 95af560305..ab4cdd9d6a 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -307,6 +307,7 @@ async function autoLoadSmallestModel(): Promise { 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 { 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, 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 25c776948f..cc9ec3971d 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 @@ -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, diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index 5ac8c79160..59b0880add 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -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 ? ( ) : ( 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 2d60d52043..35a7ab068b 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -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((set) => ({ activeGgufVariant: null, ggufContextLength: null, supportsReasoning: false, + reasoningAlwaysOn: false, reasoningEnabled: true, supportsTools: false, toolsEnabled: false, diff --git a/studio/frontend/src/features/chat/types/api.ts b/studio/frontend/src/features/chat/types/api.ts index ff5ebe50ca..f41f1279a7 100644 --- a/studio/frontend/src/features/chat/types/api.ts +++ b/studio/frontend/src/features/chat/types/api.ts @@ -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; } From 4ab7fb1f7b4ea94a69678c20f3e17db575d5ea19 Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Fri, 27 Mar 2026 13:44:59 +0100 Subject: [PATCH 08/12] fix: replace navbar shutdown text button with icon-only button (#4655) --- studio/frontend/src/components/navbar.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/studio/frontend/src/components/navbar.tsx b/studio/frontend/src/components/navbar.tsx index 5fece2b2b2..99909b80fa 100644 --- a/studio/frontend/src/components/navbar.tsx +++ b/studio/frontend/src/components/navbar.tsx @@ -17,7 +17,7 @@ import { import { cn } from "@/lib/utils"; import { ArrowRight01Icon, - StopIcon, + Cancel01Icon, Book03Icon, BubbleChatIcon, ChefHatIcon, @@ -239,12 +239,11 @@ export function Navbar() { @@ -340,7 +339,7 @@ export function Navbar() { setShutdownOpen(true); }} > - + Quit Unsloth Studio
From c4e34c88c887d90b4d8c307eb3f72f06ca9cc8be Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 27 Mar 2026 05:57:49 -0700 Subject: [PATCH 09/12] Fall back to parsing model name when HF API has no param count (#4656) Some models like unsloth/Qwen3-0.6B have no safetensors metadata on Hugging Face, so the training model selector showed no parameter size badge. The chat model picker already had extractParamLabel() as a fallback that parses sizes like "0.6B" from the model name. Add the same fallback to the training model selector and the onboarding model selection step. Co-authored-by: Daniel Han --- .../onboarding/components/steps/model-selection-step.tsx | 9 ++++++++- .../src/features/studio/sections/model-section.tsx | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/studio/frontend/src/features/onboarding/components/steps/model-selection-step.tsx b/studio/frontend/src/features/onboarding/components/steps/model-selection-step.tsx index f05643c092..4e7454e20f 100644 --- a/studio/frontend/src/features/onboarding/components/steps/model-selection-step.tsx +++ b/studio/frontend/src/features/onboarding/components/steps/model-selection-step.tsx @@ -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; diff --git a/studio/frontend/src/features/studio/sections/model-section.tsx b/studio/frontend/src/features/studio/sections/model-section.tsx index fa732bf62a..0d2b3e074d 100644 --- a/studio/frontend/src/features/studio/sections/model-section.tsx +++ b/studio/frontend/src/features/studio/sections/model-section.tsx @@ -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, From 73969a1e4f57f8c296ba1e1bc54c446ad2e3add0 Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Fri, 27 Mar 2026 14:53:33 +0100 Subject: [PATCH 10/12] fix: disable OCR in pymupdf4llm PDF extraction (#4659) --- studio/backend/routes/data_recipe/seed.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/studio/backend/routes/data_recipe/seed.py b/studio/backend/routes/data_recipe/seed.py index 74a3abe972..e9cf828610 100644 --- a/studio/backend/routes/data_recipe/seed.py +++ b/studio/backend/routes/data_recipe/seed.py @@ -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 From 562e54fc6e78409bf0ef30c6eb30c1f59b496635 Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:59:27 +0400 Subject: [PATCH 11/12] Fix HF cache default and show LM Studio models in chat/inference (#4653) * fix: default HF cache to standard platform path instead of legacy Unsloth cache * feat: show LM Studio and local models in chat Fine-tuned tab * feat: show LM Studio models in Hub models tab * fix: fetch local models after auth refresh completes * Revert "fix: fetch local models after auth refresh completes" This reverts commit cfd61f0ac76a6f578f14bcd0c668bb011b0ff330. * fix: increase llama-server health check timeout to 600s for large models * feat: expandable GGUF variant picker for LM Studio local models * fix: show GGUF variant label for locally loaded LM Studio models * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: show publisher name in LM Studio model labels * fix: set model_id for loose GGUF files in LM Studio publisher dirs * fix: show publisher prefix in Fine-tuned tab LM Studio models * fix: only use model_id for lmstudio source models * fix: only show LM Studio models in Hub tab on Mac/chat-only mode * fix: respect XDG_CACHE_HOME, handle Windows paths in isLocalPath, refresh LM Studio on remount - _setup_cache_env now reads XDG_CACHE_HOME (falls back to ~/.cache) instead of hard-coding ~/.cache/huggingface. This follows the standard HF cache resolution chain and respects distro/container overrides. - isLocalPath in GgufVariantExpander uses a regex that covers Windows drive letters (C:\, D:/), UNC paths (\\server\share), relative paths (./, ../), and tilde (~/) -- not just startsWith("/"). - HubModelPicker.useEffect now calls listLocalModels() before the alreadyCached early-return gate so LM Studio models are always refreshed on remount. Also seeds useState from _lmStudioCache for instant display on re-open. * fix: add comment explaining isLocalPath regex for Windows/cross-platform paths * fix: prioritize unsloth publisher in LM Studio model list * fix: scope unsloth-first sort to LM Studio models on all platforms * fix: add missing _lmStudioCache module-level declaration * fix: prioritize unsloth publisher before timestamp sort in LM Studio group --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han --- studio/backend/core/inference/llama_cpp.py | 15 +- studio/backend/routes/models.py | 30 +++- studio/backend/utils/models/model_config.py | 72 ++++++++- studio/backend/utils/paths/storage_roots.py | 23 +-- .../assistant-ui/model-selector/pickers.tsx | 153 ++++++++++++++---- .../assistant-ui/model-selector/types.ts | 4 +- .../src/features/chat/api/chat-api.ts | 21 +++ .../frontend/src/features/chat/chat-page.tsx | 39 +++-- 8 files changed, 297 insertions(+), 60 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 7909af8a23..05e038dbb7 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -1292,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 @@ -1304,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. " diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index f76034c95b..348ffbf6ea 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -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 diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index 13f1b5febf..5de3fd2cf9 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -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}") diff --git a/studio/backend/utils/paths/storage_roots.py b/studio/backend/utils/paths/storage_roots.py index 9bcf3758ad..4841c5d0a3 100644 --- a/studio/backend/utils/paths/storage_roots.py +++ b/studio/backend/utils/paths/storage_roots.py @@ -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 diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index 8d4b6ae0d6..3ac3416df4 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -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(_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 ? ( + <> + LM Studio + {lmStudioModels.map((m) => { + const isGguf = isGgufRepo(m.id) || isGgufRepo(m.display_name); + return ( +
+ { + 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 && ( + + )} +
+ ); + })} + + ) : null} + {!showHfSection && cachedReady ? ( <> {"\uD83E\uDDA5"} Recommended @@ -837,6 +901,8 @@ export function LoraModelPicker({ onSelect: (id: string, meta: ModelSelectorChangeMeta) => void; }) { const [query, setQuery] = useState(""); + const [expandedGguf, setExpandedGguf] = useState(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 ?
: null} {baseModel} {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 ( - onSelect(adapter.id, { - source: isExported ? "exported" : "lora", - isLora: !isMerged && !isGguf, - })} - tooltipText={ - <> - {adapter.name} - - {adapter.id} - - - } - /> +
+ { + 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={ + <> + {adapter.name} + + {adapter.id} + + + } + /> + {expandedGguf === adapter.id && ( + + )} +
); })}
diff --git a/studio/frontend/src/components/assistant-ui/model-selector/types.ts b/studio/frontend/src/components/assistant-ui/model-selector/types.ts index 9da7fd975f..f70cfc3b01 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/types.ts +++ b/studio/frontend/src/components/assistant-ui/model-selector/types.ts @@ -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; diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts index 57cbcccf66..bb603b90c4 100644 --- a/studio/frontend/src/features/chat/api/chat-api.ts +++ b/studio/frontend/src/features/chat/api/chat-api.ts @@ -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 { + const response = await authFetch("/api/models/local"); + return parseJsonOrThrow(response); +} + export async function listCachedGguf(): Promise { const response = await authFetch("/api/models/cached-gguf"); const data = await parseJsonOrThrow<{ cached: CachedGgufRepo[] }>(response); diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index c04cfbc89c..07b52ebc30 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -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( - () => - 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([]); + + const loraModels = useMemo(() => { + 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(() => { From 844a816ed0ffc2f7bc5c01e4d527f66465429f9b Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 27 Mar 2026 07:14:03 -0700 Subject: [PATCH 12/12] Update pyproject.toml --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0c7dd0b962..e2173d6811 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,7 +87,7 @@ huggingfacenotorch = [ ] huggingface = [ "unsloth[huggingfacenotorch]", - "unsloth_zoo>=2026.3.5", + "unsloth_zoo>=2026.3.6", "torchvision", "unsloth[triton]", ] @@ -577,7 +577,7 @@ colab-ampere-torch220 = [ "flash-attn>=2.6.3 ; ('linux' in sys_platform)", ] colab-new = [ - "unsloth_zoo>=2026.3.5", + "unsloth_zoo>=2026.3.6", "packaging", "tyro", "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",