From 7af8cac0148d209a0ca7b607756d6c1eea917af7 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 7 May 2026 02:26:58 -0700 Subject: [PATCH 1/8] tests/studio/install: parallel UNSLOTH_STUDIO_HOME smoke test (#5306) * tests/studio/install: parallel UNSLOTH_STUDIO_HOME smoke test Adds tests/studio/install/smoke_test_parallel_studio_home.py to lock in the install-time and runtime isolation guarantees added by #5190. The runner spawns N concurrent install.sh --local --no-torch jobs, each with its own UNSLOTH_STUDIO_HOME and a redirected HOME, then launches N backends on dynamically allocated ports and cross-checks every install against its running process. Asserts: install-time - all N installs exit 0 - per-install bin / share / llama.cpp / unsloth_studio venv tree - shim symlink resolves into its own venv, no cross-resolution - share/studio_install_id is unique across the N installs - share/studio.conf exports UNSLOTH_EXE / UNSLOTH_STUDIO_HOME / UNSLOTH_LLAMA_CPP_PATH all pointing inside the install - share/launch-studio.sh has @@DATA_DIR@@ substituted to its own share/ at install time - the redirected HOME stays clean: no rc-file append, no .desktop file, no Studio.app stub, no shared marker runtime - /api/health returns 200 with status healthy and chat_only true - /api/health.studio_root_id matches share/studio_install_id (runtime resolver agrees with install-time write) - studio_root_id values are pairwise distinct - GET / and GET /api/chat return 200 on each backend - /proc/PID/exe is the install's own venv python Standalone smoke runner, not pytest collected. Default --n 4 finishes in about 60 seconds on a warm uv cache; artifacts are removed on PASS unless --keep is passed and kept on FAIL or ERROR for inspection. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * tests/studio/install: portability + log fd cleanup in parallel smoke Two cleanups on the parallel UNSLOTH_STUDIO_HOME smoke runner: - Skip the /proc/PID/exe runtime cross-resolution check on platforms without /proc (macOS, BSD, Windows). install.sh supports macOS, so the smoke should not hard-error there. The install-time symlink, studio.conf and launch-studio.sh assertions already pin the venv python target statically; the proc check stays as a Linux-only redundant cross-resolution catch and now returns None cleanly on other platforms instead of raising. - Wrap the per-backend log file in a with-statement so its parent fd is released deterministically at function return. The child still holds its own dup'd fd via Popen, so logging continues unchanged. The prior code relied on local-scope GC and was fine in CPython, but the with form makes the intent explicit. Smoke still passes locally: 4 parallel installs in 42s, 4 backends healthy in 5s, all install + runtime invariants hold. * tests/studio/install: pin UNSLOTH_STUDIO_HOME on backend launch The launch step copied os.environ unchanged except for HOME. If the parent shell already exports UNSLOTH_STUDIO_HOME or STUDIO_HOME (for example, when the developer is sourcing studio.conf from an existing install), every backend inherits it and the Studio resolver prioritises those env vars over the per-label sys.prefix inference. The runtime invariant block then reports the caller's install_id on every port instead of the per-label one, and the test fails spuriously rather than testing the right roots. Pin UNSLOTH_STUDIO_HOME to the per-label studio_home and pop the STUDIO_HOME alias for each launch, mirroring what _run_one_install already does for the install step. Verified by running the smoke with UNSLOTH_STUDIO_HOME=/nonexistent and STUDIO_HOME=/also-bogus exported in the parent env: PASS, all four backends report their own install_id rather than the parent value. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .../smoke_test_parallel_studio_home.py | 421 ++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 tests/studio/install/smoke_test_parallel_studio_home.py diff --git a/tests/studio/install/smoke_test_parallel_studio_home.py b/tests/studio/install/smoke_test_parallel_studio_home.py new file mode 100644 index 0000000000..133591fb33 --- /dev/null +++ b/tests/studio/install/smoke_test_parallel_studio_home.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python3 +"""Smoke test: N parallel install.sh runs with distinct UNSLOTH_STUDIO_HOME +values must produce N fully isolated installs whose backends can run +side by side without clashing. + +Covers the env-override path added in #5190: + + install-time + * N concurrent ``install.sh --local --no-torch`` runs against + this checkout, each pinned to its own UNSLOTH_STUDIO_HOME and + a redirected HOME, all exit 0. + * Each STUDIO_HOME contains its own bin/, share/, llama.cpp/ + and unsloth_studio/ venv, with no cross-install absolute + paths. + * share/studio_install_id is unique across the N installs. + * share/studio.conf exports UNSLOTH_EXE, UNSLOTH_STUDIO_HOME + and UNSLOTH_LLAMA_CPP_PATH, all pointing inside this install. + * share/launch-studio.sh has @@DATA_DIR@@ substituted to its + own share/ at install time. + * bin/unsloth is a symlink that resolves into its own venv. + * The redirected HOME is left clean: no shell-rc append, no + .desktop file, no Studio.app stub, no shared marker. + + runtime + * N concurrent ``bin/unsloth studio`` launches each bind their + own dynamically allocated free port and stay healthy. + * /api/health is 200, status is healthy, chat_only is true + under --no-torch. + * The studio_root_id reported by /api/health on each backend + equals that install's share/studio_install_id, so the + runtime resolver agrees with the install-time write. + * studio_root_id values are pairwise distinct. + * GET / and GET /api/chat are 200 on every backend. + * The Python interpreter behind each PID is the install's own + venv python (the bin/unsloth shim does not cross-resolve). + +This is an integration smoke runner, not a pytest unit test. It does +real installs (~1 minute end to end on a warm uv cache) and is meant +to be invoked explicitly: + + python tests/studio/install/smoke_test_parallel_studio_home.py + python tests/studio/install/smoke_test_parallel_studio_home.py --n 6 --keep + +Exits 0 on PASS, 1 on FAIL, 2 on infrastructure error. Artifacts land +under a temporary directory and are removed on PASS unless --keep is +set; on FAIL or ERROR they are kept regardless so logs can be +inspected. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime +from pathlib import Path + +PACKAGE_ROOT = Path(__file__).resolve().parents[3] +INSTALL_TIMEOUT_S = 600 +HEALTH_TIMEOUT_S = 120 +HEALTH_POLL_INTERVAL_S = 1.0 + + +class TestFailure(AssertionError): + pass + + +def _log(msg: str) -> None: + ts = datetime.now().strftime("%H:%M:%S") + print(f"[smoke {ts}] {msg}", flush = True) + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _run_one_install( + label: str, + repo: Path, + studio_home: Path, + fake_home: Path, + uv_cache: Path, + log_path: Path, +) -> tuple[str, int]: + studio_home.mkdir(parents = True, exist_ok = True) + fake_home.mkdir(parents = True, exist_ok = True) + uv_cache.mkdir(parents = True, exist_ok = True) + log_path.parent.mkdir(parents = True, exist_ok = True) + env = os.environ.copy() + env["HOME"] = str(fake_home) + env["UNSLOTH_STUDIO_HOME"] = str(studio_home) + env["UV_CACHE_DIR"] = str(uv_cache) + env["NO_COLOR"] = "1" + with log_path.open("w") as fh: + proc = subprocess.run( + ["bash", "install.sh", "--local", "--no-torch"], + cwd = str(repo), + env = env, + stdout = fh, + stderr = subprocess.STDOUT, + timeout = INSTALL_TIMEOUT_S, + ) + return label, proc.returncode + + +def _launch_backend( + studio_home: Path, fake_home: Path, port: int, log_path: Path +) -> subprocess.Popen: + log_path.parent.mkdir(parents = True, exist_ok = True) + env = os.environ.copy() + env["HOME"] = str(fake_home) + # Pin UNSLOTH_STUDIO_HOME (and clear the alias) so the child cannot + # inherit a Studio root from the caller's shell. Without this, a shell + # that already exports either var would override the per-label sys.prefix + # inference and every backend would resolve to the caller's install. + env["UNSLOTH_STUDIO_HOME"] = str(studio_home) + env.pop("STUDIO_HOME", None) + # The child process inherits a dup of stdout via Popen, so closing the + # parent's handle when this function returns is safe and avoids relying + # on GC timing to release the fd. + with log_path.open("w") as fh: + return subprocess.Popen( + [ + str(studio_home / "bin" / "unsloth"), + "studio", + "-H", + "127.0.0.1", + "-p", + str(port), + "--silent", + ], + env = env, + stdout = fh, + stderr = subprocess.STDOUT, + start_new_session = True, + ) + + +def _wait_for_health(port: int, timeout: float) -> dict: + deadline = time.time() + timeout + last_err: Exception | None = None + url = f"http://127.0.0.1:{port}/api/health" + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout = 2) as r: + if r.status == 200: + return json.loads(r.read().decode()) + except (urllib.error.URLError, ConnectionError, OSError) as e: + last_err = e + time.sleep(HEALTH_POLL_INTERVAL_S) + raise TestFailure( + f"port {port}: /api/health never returned 200 (last_err={last_err})" + ) + + +def _http_status(port: int, path: str, timeout: float = 5.0) -> int: + url = f"http://127.0.0.1:{port}{path}" + try: + with urllib.request.urlopen(url, timeout = timeout) as r: + return r.status + except urllib.error.HTTPError as e: + return e.code + + +def _check_install_layout(label: str, studio_home: Path) -> dict: + for d in ("bin", "share", "llama.cpp", "unsloth_studio"): + if not (studio_home / d).is_dir(): + raise TestFailure(f"[{label}] missing {studio_home / d}") + + shim = studio_home / "bin" / "unsloth" + if not shim.is_symlink(): + raise TestFailure(f"[{label}] {shim} is not a symlink") + expected_target = (studio_home / "unsloth_studio" / "bin" / "unsloth").resolve() + if shim.resolve() != expected_target: + raise TestFailure( + f"[{label}] shim resolves to {shim.resolve()}, expected {expected_target}" + ) + + install_id_path = studio_home / "share" / "studio_install_id" + if not install_id_path.is_file(): + raise TestFailure(f"[{label}] missing {install_id_path}") + install_id = install_id_path.read_text().strip() + if len(install_id) < 32: + raise TestFailure(f"[{label}] studio_install_id too short: {install_id!r}") + + conf = (studio_home / "share" / "studio.conf").read_text() + must_contain = [ + f"UNSLOTH_EXE='{studio_home}/unsloth_studio/bin/unsloth'", + f"export UNSLOTH_STUDIO_HOME='{studio_home}'", + f"export UNSLOTH_LLAMA_CPP_PATH='{studio_home}/llama.cpp'", + ] + for needle in must_contain: + if needle not in conf: + raise TestFailure( + f"[{label}] studio.conf missing line:\n {needle}\n" f"actual:\n{conf}" + ) + + launcher = (studio_home / "share" / "launch-studio.sh").read_text() + if "@@DATA_DIR@@" in launcher: + raise TestFailure(f"[{label}] launch-studio.sh kept @@DATA_DIR@@ placeholder") + expected_data_dir_line = f"DATA_DIR='{studio_home}/share'" + if expected_data_dir_line not in launcher: + raise TestFailure( + f"[{label}] launch-studio.sh missing {expected_data_dir_line!r}" + ) + + return {"label": label, "studio_home": str(studio_home), "install_id": install_id} + + +def _check_fake_home_clean(fake_home: Path) -> None: + forbidden = [ + ".bashrc", + ".zshrc", + ".profile", + ".unsloth", + Path(".local") / "share" / "applications" / "unsloth-studio.desktop", + Path("Desktop") / "unsloth-studio.desktop", + Path("Applications") / "Unsloth Studio.app", + ] + leaked = [str(p) for p in forbidden if (fake_home / p).exists()] + if leaked: + raise TestFailure( + f"redirected HOME picked up persistent install pollution: {leaked}" + ) + + +def _backend_pid_python(pid: int) -> Path | None: + """Resolve the binary backing a running PID. Linux exposes this at + /proc/PID/exe; on platforms without /proc (macOS, BSD, Windows) we + skip this check and rely on the install-time symlink + studio.conf + invariants to catch cross-resolution. Returns None when /proc is + unavailable so the caller can skip cleanly.""" + if sys.platform != "linux": + return None + proc_exe = Path(f"/proc/{pid}/exe") + if not proc_exe.exists(): + return None + return proc_exe.resolve() + + +def run(n_installs: int, keep: bool) -> int: + if n_installs < 2: + raise TestFailure("--n must be >= 2 to test for clashes") + labels = [chr(ord("a") + i) for i in range(n_installs)] + + repo = PACKAGE_ROOT + if not (repo / "install.sh").is_file(): + raise TestFailure( + f"install.sh not found at {repo}; " "run from a clone of unslothai/unsloth" + ) + + test_root = Path(tempfile.mkdtemp(prefix = "unsloth_studio_clash_")) + _log(f"test root: {test_root}") + _log(f"repo: {repo}") + + backends: list[tuple[str, Path, Path, int, subprocess.Popen]] = [] + failed = False + try: + # ---- parallel installs -------------------------------------------- + _log(f"launching {n_installs} parallel installs (--local --no-torch)") + with ThreadPoolExecutor(max_workers = n_installs) as pool: + futures = [] + for label in labels: + futures.append( + pool.submit( + _run_one_install, + label, + repo, + test_root / "installs" / label, + test_root / "fake_homes" / label, + test_root / "uv_caches" / label, + test_root / "logs" / f"install_{label}.log", + ) + ) + for fut in as_completed(futures): + label, rc = fut.result() + _log(f" install {label}: exit {rc}") + if rc != 0: + raise TestFailure( + f"install {label} failed (rc={rc}); see " + f"{test_root / 'logs' / f'install_{label}.log'}" + ) + + # ---- install-layout invariants ------------------------------------ + _log("verifying install-time invariants") + observed = [] + for label in labels: + studio_home = test_root / "installs" / label + obs = _check_install_layout(label, studio_home) + observed.append(obs) + _check_fake_home_clean(test_root / "fake_homes" / label) + ids = [o["install_id"] for o in observed] + if len(set(ids)) != len(ids): + raise TestFailure(f"studio_install_id collision: {ids}") + _log(f" {len(ids)} unique studio_install_ids, all redirected HOMEs clean") + + # ---- parallel backend launches ------------------------------------ + _log(f"launching {n_installs} backends in parallel") + for label in labels: + port = _free_port() + studio_home = test_root / "installs" / label + fake_home = test_root / "fake_homes" / label + log_path = test_root / "logs" / f"run_{label}.log" + proc = _launch_backend(studio_home, fake_home, port, log_path) + backends.append((label, studio_home, fake_home, port, proc)) + _log(f" {label} -> port {port} (pid {proc.pid})") + + # ---- wait for health ---------------------------------------------- + _log("waiting for /api/health on each backend") + health_payloads: dict[str, dict] = {} + with ThreadPoolExecutor(max_workers = n_installs) as pool: + fut_to_label = { + pool.submit(_wait_for_health, port, HEALTH_TIMEOUT_S): label + for (label, _sh, _fh, port, _p) in backends + } + for fut in as_completed(fut_to_label): + label = fut_to_label[fut] + health_payloads[label] = fut.result() + _log(f" {label}: healthy") + + # ---- runtime invariants ------------------------------------------- + _log("checking runtime invariants") + seen_root_ids: set[str] = set() + for (label, studio_home, _fh, port, proc), obs in zip(backends, observed): + health = health_payloads[label] + if health.get("status") != "healthy": + raise TestFailure(f"[{label}] health status != healthy: {health}") + if health.get("studio_root_id") != obs["install_id"]: + raise TestFailure( + f"[{label}] runtime studio_root_id " + f"{health.get('studio_root_id')!r} != install_id " + f"{obs['install_id']!r}" + ) + if not health.get("chat_only"): + raise TestFailure(f"[{label}] chat_only is not true under --no-torch") + if health["studio_root_id"] in seen_root_ids: + raise TestFailure( + f"[{label}] studio_root_id collision at runtime: " + f"{health['studio_root_id']}" + ) + seen_root_ids.add(health["studio_root_id"]) + + for path in ("/", "/api/chat"): + code = _http_status(port, path) + if code != 200: + raise TestFailure(f"[{label}] GET {path} -> {code}") + + exe = _backend_pid_python(proc.pid) + if exe is not None: + expected_python = ( + studio_home / "unsloth_studio" / "bin" / "python" + ).resolve() + if exe != expected_python: + raise TestFailure( + f"[{label}] PID {proc.pid} exe={exe}, expected {expected_python}" + ) + + versions = {h.get("version") for h in health_payloads.values()} + if len(versions) != 1: + raise TestFailure(f"version mismatch across installs: {versions}") + + _log( + f"PASS: all install + runtime invariants hold " + f"(version={next(iter(versions))})" + ) + return 0 + + except TestFailure as e: + _log(f"FAIL: {e}") + failed = True + return 1 + except Exception as e: + _log(f"ERROR: {type(e).__name__}: {e}") + failed = True + return 2 + finally: + for _lbl, _sh, _fh, _port, proc in backends: + if proc.poll() is None: + try: + proc.terminate() + proc.wait(timeout = 10) + except Exception: + proc.kill() + + if keep or failed: + _log(f"artifacts kept at {test_root}") + else: + shutil.rmtree(test_root, ignore_errors = True) + _log(f"cleaned up {test_root}") + + +def main() -> int: + ap = argparse.ArgumentParser(description = __doc__) + ap.add_argument( + "--n", + type = int, + default = 4, + help = "number of parallel installs (default 4, must be >= 2)", + ) + ap.add_argument( + "--keep", + action = "store_true", + help = "leave the temp test root on disk even on PASS", + ) + args = ap.parse_args() + return run(args.n, args.keep) + + +if __name__ == "__main__": + raise SystemExit(main()) From b65a7450ca6091c230e0d8c721182b2ff7d20718 Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Thu, 7 May 2026 11:33:31 +0100 Subject: [PATCH 2/8] Studio: Dark theme refactor, right sidebar redesign, and chat UI polish (#5150) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Dark theme refactor, right sidebar redesign, and chat UI polish - Dark theme refactor - Redesign right sidebar - Further left sidebar adjustments - Wider chat and content area; layout tweaks for chat content - Rounded corners across elements for consistency - Show chat message menu icons on menu-area hover, not only on message hover - Assistant message menu icons now always visible; user messages keep on-hover - Redesigned copy icon used consistently across chat blocks and messages - Redesigned trash icon, applied consistently - Unified icon sizing and style with the sidebar - Adjusted icon colors across chat - Fix on-hover background design for chat icons - Fix tooltip from 'more' button staying visible after clicking elsewhere - Adjust position and design of generation speed info text below messages - Adjust design of token speed info popup - Adjust sidebar scrollbar to cover recent chats only * Recents sidebar rename, UI/theme refactor, layout and chat polish UI & Theme: - Dark theme refactor - Consistent rounded corners across elements - CSS polish and cleanup - Remove unused logo image assets Recents sidebar: - Add 'more' button for options menu - Support renaming conversations and training runs - Confirmation dialog before deleting chats - Add optional display_name column to training_runs (idempotent ALTER TABLE) so renaming doesn't lose model_name/dataset_name from the run config - New PATCH /api/train/runs/{run_id} endpoint accepts { display_name: string | null }; empty/whitespace clears the override - Sidebar shows display_name ?? model_name and exposes Rename in the row's More menu, mirroring the chat rename flow - Cache last list response in localStorage and hydrate from it on mount, so recents paint instantly on F5 / route revisit; cached items are shape-validated and dropped if malformed - Optimistic updates on rename and delete (apply locally + cache before background refresh) - Visible toast on rename/delete failure instead of swallowed errors Layout: - Redesigned right sidebar - Further left sidebar adjustments - Updated chat content layout; chat and content area slightly widened - Sidebar scrollbar covers recent chats only Icons: - Redesigned copy icon, unified across chat blocks and messages - Redesigned trash icon to match - Consistent icon sizing and style across chat and sidebar - Adjusted icon colors across chat - Fix icon on-hover background design Chat messages: - Menu icons now appear on hover over the menu area, not just the message - Assistant message menu icons always visible; user messages keep on-hover (next/previous response stays visible for edited prompts) - Repositioned and restyled generation speed info text below messages - Restyled token generation speed popup Tooltips: - Removed tooltip on hover for previous/next assistant response icons - Unified tooltip design across sidebars and chat - Removed tooltip animations (also fixes related lag) Model & Chat Template config: - Merged Chat Template config into Model Configuration section - Added revert-to-original for chat template - Fix Chat Template config disappearing on page refresh until model reload Performance & scroll: - Removed chatbox movement animations across pages/navigation (fixes related UI lag) - Fix scroll flicker at end of streaming when a code block is the final element - Additional chat scroll improvements Bug fixes: - Fix 'more' button tooltip remaining visible after clicking elsewhere * Remove sidebar localStorage cache and optimistic updates Drops the localStorage hydration and optimistic rename/delete logic from the recents sidebar; reverts to fetching fresh on mount. * Fix missing cn import in shared-composer (regression from merge) * chore(sidebar): import sidebar deps from feature indexes Re-export deleteChatItem / renameChatItem / useChatSidebarItems / SidebarItem / useChatSearchStore / ChatSearchDialog from @/features/chat, and removeTrainingUnloadGuard from @/features/training. Switch app-sidebar.tsx to consume them via the public feature indexes instead of deep paths, clearing the no-restricted-imports eslint errors. No behavior or UX change. * fix(studio/frontend): reload training Recents sidebar after F5 refresh The Recents sidebar showed empty after a hard refresh. The hook's inFlightRef dedup guard collided with React StrictMode's double-mount in dev: the second mount's fetch returned silently with no error, no retry, and no toast — leaving the sidebar empty until navigation. Replace skip-if-busy dedup with abort-previous via a hook-level AbortController. This also fixes a latent race where a slow poll could resurrect a just-deleted row by clobbering the optimistic update. Changes (all in use-training-history-sidebar.ts): - fetchRuns aborts any in-flight request before starting a new one; post-await signal.aborted check drops stale responses. - Optimistic helpers (applyRunUpdate, removeRun) abort in-flight fetches so they don't depend on caller discipline to invalidate stale data. - Initial load gets bounded retry-with-backoff (500ms / 1.5s / 3.5s) and surfaces a sonner toast with a Retry action on final failure. - Failure toast auto-dismisses on any successful load (initial retry, Retry click, or polling recovery). - Polling pauses while the tab is hidden and catches up on visible, avoiding wasted requests during long training runs. - Both effects own their teardown explicitly (abort + clear timer). * Apply unified tooltip design and behavior across remaining pages for consistency * UI polish: spacing, tooltip on source icons, letter spacing, smaller icons, consistent edit icon - Adjust tiny spacing between elements around the UI for subtle polish - Redesign tooltip on source icons for web search / tool use, consistent with the new design - Adjust chat text letter spacing - Smaller icon sizes - Replace 'edit message' icon in chat with the new Rename icon used in Recents for consistency * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Adjust CSS for right sidebar * Fix scrollbar UI compatibility across browsers * fix: preserve chat preset settings on model load * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix(studio): remove duplicate chat template status field * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * chore: remove creative preset assumption * fix(studio): align speculative decoding default * fix(studio/chat): snap numeric param inputs to step grid - Type a value in any param input (Temperature, Top K, Max Tokens, etc.) now clamps to [min, max] and snaps to the slider's step grid, killing off-grid values like 1.051234 and FP residue from slider drags. - Branch picker chevrons share the action bar's 32px height + 10px radius via a new .aui-branch-chevron-btn utility; hover area aligns visually while staying narrower than the sibling icon buttons. * fix(studio/chat): keep training-run polls converging and drop dead preset code - Keep training-run polls converging when responses outrun the 5s interval (don't unconditionally abort prior in-flight; skip if one is still pending, mutation race still guarded). - Drop dead Creative/Precise preset code paths (remove 'builtin-fixed' source variant + unreachable branches). * fix(studio): training-run cards show custom name + model + dataset - Training-run cards now display custom display_name + model + dataset, with cross-view sync on rename/delete. - Enhance clarity of borders and colors in dark theme on export etc. * fix(studio): match active state green to unsloth brand color * fix(studio): preserve can_resume on training rename * fix(studio): keep GGUF chat template override distinct * fix(studio): treat audio input models as multimodal * fix(studio): cancel numeric draft on Escape * fix(studio): use default speculative mode on toggle * fix(studio): detect GGUF audio VLM input models * fix(studio): address final PR review findings * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix(studio): refresh sidebar/history when a new training run starts so it appears without a manual reload * fix: API and svg * fix(studio/sidebar): align run rename dirty check with displayed baseline * fix(studio/sidebar): use leading-tight on account block to prevent descender clipping with truncate --------- Co-authored-by: sneakr Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Co-authored-by: shine1i --- studio/backend/core/inference/llama_cpp.py | 12 +- studio/backend/models/__init__.py | 2 + studio/backend/models/inference.py | 34 +- studio/backend/models/training.py | 11 +- studio/backend/routes/inference.py | 32 +- studio/backend/routes/training_history.py | 37 +- studio/backend/storage/studio_db.py | 22 +- .../tests/test_inference_model_validation.py | 36 + .../tests/test_training_history_update.py | 100 ++ studio/frontend/public/blacklogo-c.png | Bin 141545 -> 0 bytes studio/frontend/public/blacklogo.png | Bin 166049 -> 0 bytes studio/frontend/public/sidebar-logo-black.png | Bin 9170 -> 0 bytes studio/frontend/public/sidebar-logo-white.png | Bin 9057 -> 0 bytes studio/frontend/public/unsloth-beta-black.png | Bin 160502 -> 0 bytes studio/frontend/public/unsloth-beta-white.png | Bin 156421 -> 0 bytes studio/frontend/public/whitelogo-c.png | Bin 139810 -> 0 bytes studio/frontend/public/whitelogo.png | Bin 162842 -> 0 bytes .../frontend/src/components/app-sidebar.tsx | 491 ++++-- .../components/assistant-ui/attachment.tsx | 2 +- .../components/assistant-ui/markdown-text.tsx | 20 +- .../assistant-ui/message-timing.tsx | 5 +- .../assistant-ui/model-selector.tsx | 8 +- .../assistant-ui/model-selector/pickers.tsx | 10 +- .../src/components/assistant-ui/sources.tsx | 21 +- .../src/components/assistant-ui/thread.tsx | 142 +- .../assistant-ui/tooltip-icon-button.tsx | 4 +- .../use-intent-aware-autoscroll.tsx | 153 +- studio/frontend/src/components/ui/button.tsx | 132 +- studio/frontend/src/components/ui/select.tsx | 495 +++--- studio/frontend/src/components/ui/sidebar.tsx | 1538 +++++++++-------- studio/frontend/src/components/ui/tooltip.tsx | 22 +- .../src/features/chat/api/chat-adapter.ts | 7 + .../frontend/src/features/chat/chat-page.tsx | 24 +- .../src/features/chat/chat-settings-sheet.tsx | 1083 +++++++----- .../chat/components/context-usage-bar.tsx | 7 +- .../chat/hooks/use-chat-model-runtime.ts | 71 +- .../chat/hooks/use-chat-sidebar-items.ts | 23 + studio/frontend/src/features/chat/index.ts | 8 + .../features/chat/presets/preset-policy.ts | 93 +- .../src/features/chat/shared-composer.tsx | 34 +- .../chat/stores/chat-runtime-store.ts | 6 + .../frontend/src/features/chat/types/api.ts | 21 + .../src/features/export/export-page.tsx | 6 +- .../src/features/settings/settings-dialog.tsx | 12 +- .../studio/historical-training-view.tsx | 12 +- .../src/features/studio/history-card-grid.tsx | 51 +- .../src/features/training/api/history-api.ts | 18 + .../frontend/src/features/training/events.ts | 51 + .../training/hooks/use-training-actions.ts | 3 + .../hooks/use-training-history-sidebar.ts | 174 +- .../frontend/src/features/training/index.ts | 16 +- .../src/features/training/types/history.ts | 1 + studio/frontend/src/index.css | 619 ++++++- .../test_chat_preset_builtin_invariants.py | 56 +- 54 files changed, 3752 insertions(+), 1973 deletions(-) create mode 100644 studio/backend/tests/test_inference_model_validation.py create mode 100644 studio/backend/tests/test_training_history_update.py delete mode 100644 studio/frontend/public/blacklogo-c.png delete mode 100644 studio/frontend/public/blacklogo.png delete mode 100644 studio/frontend/public/sidebar-logo-black.png delete mode 100644 studio/frontend/public/sidebar-logo-white.png delete mode 100644 studio/frontend/public/unsloth-beta-black.png delete mode 100644 studio/frontend/public/unsloth-beta-white.png delete mode 100644 studio/frontend/public/whitelogo-c.png delete mode 100644 studio/frontend/public/whitelogo.png create mode 100644 studio/frontend/src/features/training/events.ts diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 8da836de38..38c0261f5a 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -437,6 +437,7 @@ class LlamaCppBackend: self._effective_context_length: Optional[int] = None self._max_context_length: Optional[int] = None self._chat_template: Optional[str] = None + self._chat_template_override: Optional[str] = None self._supports_reasoning: bool = False self._reasoning_always_on: bool = False self._reasoning_style: str = "enable_thinking" @@ -621,6 +622,10 @@ class LlamaCppBackend: def chat_template(self) -> Optional[str]: return self._chat_template + @property + def chat_template_override(self) -> Optional[str]: + return self._chat_template_override + @property def supports_reasoning(self) -> bool: return self._supports_reasoning @@ -2221,12 +2226,12 @@ class LlamaCppBackend: self._speculative_type = None # Apply custom chat template override if provided + self._chat_template_override = chat_template_override if chat_template_override: import tempfile - self._chat_template = chat_template_override flags = detect_reasoning_flags( - self._chat_template, + chat_template_override, self._model_identifier, log_source = "GGUF chat template override", ) @@ -2525,6 +2530,7 @@ class LlamaCppBackend: self._effective_context_length = None self._max_context_length = None self._chat_template = None + self._chat_template_override = None self._supports_reasoning = False self._reasoning_always_on = False self._reasoning_style = "enable_thinking" @@ -4211,6 +4217,8 @@ class LlamaCppBackend: return "csm" if len(_tok("<|startoftranscript|>")) == 1: return "whisper" + if len(_tok("")) == 1: + return "audio_vlm" if ( len(_tok("<|bicodec_semantic_0|>")) == 1 and len(_tok("<|bicodec_global_0|>")) == 1 diff --git a/studio/backend/models/__init__.py b/studio/backend/models/__init__.py index a4fbbbe6ee..7addca02ca 100644 --- a/studio/backend/models/__init__.py +++ b/studio/backend/models/__init__.py @@ -15,6 +15,7 @@ from .training import ( TrainingRunMetrics, TrainingRunDetailResponse, TrainingRunDeleteResponse, + TrainingRunUpdateRequest, ) from .models import ( CheckpointInfo, @@ -81,6 +82,7 @@ __all__ = [ "TrainingRunMetrics", "TrainingRunDetailResponse", "TrainingRunDeleteResponse", + "TrainingRunUpdateRequest", # Model management schemas "ModelDetails", "LocalModelInfo", diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index 43087cc5bf..7a4c7d0b3c 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -11,7 +11,14 @@ import time import uuid from typing import Annotated, Any, Dict, Literal, Optional, List, Union -from pydantic import BaseModel, Discriminator, Field, Tag, model_validator +from pydantic import ( + BaseModel, + Discriminator, + Field, + Tag, + field_validator, + model_validator, +) class LoadRequest(BaseModel): @@ -43,6 +50,16 @@ class LoadRequest(BaseModel): None, description = "Custom Jinja2 chat template to use instead of the model's default", ) + + @field_validator("chat_template_override") + @classmethod + def normalize_blank_chat_template_override( + cls, value: Optional[str] + ) -> Optional[str]: + if value is not None and value.strip() == "": + return None + return value + cache_type_kv: Optional[str] = Field( None, description = "KV cache data type for both K and V (e.g. 'f16', 'bf16', 'q8_0', 'q4_1', 'q5_1')", @@ -299,10 +316,6 @@ class InferenceStatusResponse(BaseModel): supports_tools: bool = Field( False, description = "Whether the active model supports tool calling" ) - chat_template: Optional[str] = Field( - None, - description = "Jinja2 chat template string for the active model", - ) context_length: Optional[int] = Field( None, description = "Context length of the active model" ) @@ -314,6 +327,17 @@ class InferenceStatusResponse(BaseModel): None, description = "Model's native context length from GGUF metadata (not capped by VRAM)", ) + cache_type_kv: Optional[str] = Field( + None, + description = "KV cache quantization dtype (e.g. 'q8_0'), or None for default", + ) + chat_template: Optional[str] = Field( + None, description = "Model's default chat template (Jinja2 source), if any" + ) + chat_template_override: Optional[str] = Field( + None, + description = "Active chat template override applied at load time, or None if model is using its default", + ) speculative_type: Optional[str] = Field( None, description = "Active speculative decoding mode (e.g. 'ngram-simple', 'ngram-mod'), or None if disabled", diff --git a/studio/backend/models/training.py b/studio/backend/models/training.py index 8127af1ee6..0c5825c54e 100644 --- a/studio/backend/models/training.py +++ b/studio/backend/models/training.py @@ -5,7 +5,7 @@ Pydantic schemas for Training API """ -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator from typing import Any, Optional, List, Dict, Literal @@ -224,6 +224,7 @@ class TrainingRunSummary(BaseModel): status: Literal["running", "completed", "stopped", "error"] model_name: str dataset_name: str + display_name: Optional[str] = None started_at: str ended_at: Optional[str] = None total_steps: Optional[int] = None @@ -237,6 +238,14 @@ class TrainingRunSummary(BaseModel): resumed_later: bool = False +class TrainingRunUpdateRequest(BaseModel): + """Mutable fields on a training run.""" + + model_config = ConfigDict(extra = "forbid") + + display_name: Optional[str] = Field(None, max_length = 120) + + class TrainingRunListResponse(BaseModel): """Response for listing training runs.""" diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index a6b00360af..6b559b9c45 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -474,7 +474,6 @@ async def load_model( f"Model already loaded (GGUF): {model_log_label} variant={request.gguf_variant}, skipping reload" ) inference_config = load_inference_config(llama_backend.model_identifier) - from utils.models import is_audio_input_type _gguf_audio = ( llama_backend._audio_type @@ -495,9 +494,7 @@ async def load_model( is_gguf = True, is_audio = _gguf_is_audio, audio_type = _gguf_audio, - has_audio_input = is_audio_input_type(_gguf_audio) - if _gguf_audio - else False, + has_audio_input = False, inference = inference_config, requires_trust_remote_code = bool( inference_config.get("trust_remote_code", False) @@ -658,9 +655,10 @@ async def load_model( f"Loaded GGUF model via llama-server: {model_log_label if native_grant_backed else config.identifier}" ) - # Detect TTS audio by probing the loaded model's vocabulary - from utils.models import is_audio_input_type - + # Detect TTS/audio marker tokens by probing the loaded model's vocabulary. + # GGUF audio input is not wired through the chat path yet, so do not + # advertise has_audio_input for GGUF models until uploaded audio is + # actually forwarded to llama-server. _gguf_audio = llama_backend.detect_audio_type() _gguf_is_audio = _gguf_audio in ("snac", "bicodec", "dac") llama_backend._is_audio = _gguf_is_audio @@ -686,7 +684,7 @@ async def load_model( is_gguf = True, is_audio = _gguf_is_audio, audio_type = _gguf_audio, - has_audio_input = is_audio_input_type(_gguf_audio), + has_audio_input = False, inference = inference_config, requires_trust_remote_code = bool( inference_config.get("trust_remote_code", False) @@ -1156,13 +1154,15 @@ async def get_status( ): _display_model_id = os.path.basename(_model_id) _inference_cfg = load_inference_config(_model_id) if _model_id else None + _audio_type = getattr(llama_backend, "_audio_type", None) return InferenceStatusResponse( active_model = _display_model_id, is_vision = llama_backend.is_vision, is_gguf = True, gguf_variant = llama_backend.hf_variant, is_audio = getattr(llama_backend, "_is_audio", False), - audio_type = getattr(llama_backend, "_audio_type", None), + audio_type = _audio_type, + has_audio_input = False, loading = [], loaded = [_display_model_id] if _display_model_id else [], inference = _inference_cfg, @@ -1178,6 +1178,8 @@ async def get_status( context_length = llama_backend.context_length, max_context_length = llama_backend.max_context_length, native_context_length = llama_backend.native_context_length, + cache_type_kv = llama_backend.cache_type_kv, + chat_template_override = llama_backend.chat_template_override, speculative_type = llama_backend.speculative_type, ) @@ -1669,6 +1671,12 @@ async def openai_chat_completions( and not _effective_enable_tools(payload) and (_tools_passthrough or _has_response_format) ): + if payload.audio_base64: + raise HTTPException( + status_code = 400, + detail = "Audio input is not supported for GGUF chat models yet.", + ) + # Preserve the vision guard that would otherwise run in the # non-passthrough path below: text-only tool-capable GGUFs # should return a clear 400 here rather than forwarding the @@ -1716,6 +1724,12 @@ async def openai_chat_completions( # ── GGUF path: proxy to llama-server /v1/chat/completions ── if using_gguf: + if payload.audio_base64: + raise HTTPException( + status_code = 400, + detail = "Audio input is not supported for GGUF chat models yet.", + ) + # Reject images if this GGUF model doesn't support vision image_b64 = extracted_image_b64 or payload.image_base64 if image_b64 and not llama_backend.is_vision: diff --git a/studio/backend/routes/training_history.py b/studio/backend/routes/training_history.py index 6f34321959..771d9f1e35 100644 --- a/studio/backend/routes/training_history.py +++ b/studio/backend/routes/training_history.py @@ -18,8 +18,15 @@ from models import ( TrainingRunListResponse, TrainingRunMetrics, TrainingRunSummary, + TrainingRunUpdateRequest, +) +from storage.studio_db import ( + delete_run, + get_run, + get_run_metrics, + list_runs, + update_run_display_name, ) -from storage.studio_db import delete_run, get_run, get_run_metrics, list_runs logger = get_logger(__name__) @@ -73,6 +80,34 @@ async def get_training_run_detail( ) +@router.patch("/runs/{run_id}", response_model = TrainingRunSummary) +async def update_training_run( + run_id: str, + payload: TrainingRunUpdateRequest, + current_subject: str = Depends(get_current_subject), +): + """Update mutable fields on a training run (currently only display_name).""" + run = get_run(run_id) + if run is None: + raise HTTPException(status_code = 404, detail = f"Run {run_id} not found") + + if "display_name" in payload.model_fields_set: + next_display = payload.display_name + if next_display is not None: + next_display = next_display.strip() or None + update_run_display_name(run_id, next_display) + + refreshed = get_run(run_id) + if refreshed is None: + raise HTTPException(status_code = 404, detail = f"Run {run_id} not found") + return TrainingRunSummary( + **{ + **{k: v for k, v in refreshed.items() if k != "config_json"}, + "can_resume": can_resume_run(refreshed), + } + ) + + @router.delete("/runs/{run_id}", response_model = TrainingRunDeleteResponse) async def delete_training_run( run_id: str, diff --git a/studio/backend/storage/studio_db.py b/studio/backend/storage/studio_db.py index 29e787c196..8dc29a9f24 100644 --- a/studio/backend/storage/studio_db.py +++ b/studio/backend/storage/studio_db.py @@ -75,10 +75,16 @@ def _ensure_schema(conn: sqlite3.Connection) -> None: output_dir TEXT, error_message TEXT, duration_seconds REAL, - loss_sparkline TEXT + loss_sparkline TEXT, + display_name TEXT ) """ ) + existing_cols = { + row[1] for row in conn.execute("PRAGMA table_info(training_runs)").fetchall() + } + if "display_name" not in existing_cols: + conn.execute("ALTER TABLE training_runs ADD COLUMN display_name TEXT") conn.execute( """ CREATE TABLE IF NOT EXISTS training_metrics ( @@ -261,6 +267,18 @@ def insert_metrics_batch(run_id: str, metrics: list[dict]) -> None: conn.close() +def update_run_display_name(id: str, display_name: Optional[str]) -> None: + conn = get_connection() + try: + conn.execute( + "UPDATE training_runs SET display_name = ? WHERE id = ?", + (display_name, id), + ) + conn.commit() + finally: + conn.close() + + def list_runs(limit: int = 50, offset: int = 0) -> dict: conn = get_connection() try: @@ -270,7 +288,7 @@ def list_runs(limit: int = 50, offset: int = 0) -> dict: SELECT r.id, r.status, r.model_name, r.dataset_name, r.started_at, r.ended_at, r.total_steps, r.final_step, r.final_loss, r.output_dir, r.duration_seconds, r.error_message, - r.loss_sparkline, + r.loss_sparkline, r.display_name, CASE WHEN r.status = 'stopped' AND r.output_dir IS NOT NULL diff --git a/studio/backend/tests/test_inference_model_validation.py b/studio/backend/tests/test_inference_model_validation.py new file mode 100644 index 0000000000..219affade3 --- /dev/null +++ b/studio/backend/tests/test_inference_model_validation.py @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +import os +import sys + +_backend = os.path.join(os.path.dirname(__file__), "..") +sys.path.insert(0, _backend) + +from models.inference import LoadRequest + + +def _base_load_request(**overrides): + data = { + "model_path": "unsloth/test-model-GGUF", + "hf_token": None, + "max_seq_length": 4096, + "load_in_4bit": True, + "is_lora": False, + "gguf_variant": "Q4_K_M", + } + data.update(overrides) + return LoadRequest.model_validate(data) + + +def test_blank_chat_template_override_normalizes_to_none(): + req = _base_load_request(chat_template_override = " \n\t") + + assert req.chat_template_override is None + + +def test_nonblank_chat_template_override_is_preserved_verbatim(): + template = " {{ messages }} " + req = _base_load_request(chat_template_override = template) + + assert req.chat_template_override == template diff --git a/studio/backend/tests/test_training_history_update.py b/studio/backend/tests/test_training_history_update.py new file mode 100644 index 0000000000..d8a0c93622 --- /dev/null +++ b/studio/backend/tests/test_training_history_update.py @@ -0,0 +1,100 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +import asyncio +import os +import sys + +import pytest +from pydantic import ValidationError + +_backend = os.path.join(os.path.dirname(__file__), "..") +sys.path.insert(0, _backend) + +from models.training import TrainingRunUpdateRequest +from routes import training_history + + +BASE_RUN = { + "id": "run-1", + "status": "stopped", + "model_name": "unsloth/test-model", + "dataset_name": "test-dataset", + "display_name": "Existing name", + "started_at": "2026-01-01T00:00:00Z", + "ended_at": "2026-01-01T00:01:00Z", + "total_steps": 10, + "final_step": 5, + "output_dir": "/tmp/run-1", + "resumed_later": False, +} + + +def _patch_run(monkeypatch: pytest.MonkeyPatch, payload: TrainingRunUpdateRequest): + stored = dict(BASE_RUN) + calls: list[str | None] = [] + + def fake_get_run(run_id: str): + assert run_id == "run-1" + return dict(stored) + + def fake_update_run_display_name(run_id: str, display_name: str | None): + assert run_id == "run-1" + calls.append(display_name) + stored["display_name"] = display_name + + monkeypatch.setattr(training_history, "get_run", fake_get_run) + monkeypatch.setattr( + training_history, + "update_run_display_name", + fake_update_run_display_name, + ) + monkeypatch.setattr(training_history, "can_resume_run", lambda run: True) + + result = asyncio.run( + training_history.update_training_run( + "run-1", + payload, + current_subject = "test-user", + ) + ) + return result, calls + + +def test_update_run_omitted_display_name_is_noop(monkeypatch: pytest.MonkeyPatch): + result, calls = _patch_run(monkeypatch, TrainingRunUpdateRequest.model_validate({})) + + assert calls == [] + assert result.display_name == "Existing name" + assert result.can_resume is True + + +def test_update_run_explicit_null_clears_display_name(monkeypatch: pytest.MonkeyPatch): + result, calls = _patch_run( + monkeypatch, + TrainingRunUpdateRequest.model_validate({"display_name": None}), + ) + + assert calls == [None] + assert result.display_name is None + assert result.can_resume is True + + +def test_update_run_whitespace_clears_display_name(monkeypatch: pytest.MonkeyPatch): + result, calls = _patch_run( + monkeypatch, + TrainingRunUpdateRequest.model_validate({"display_name": " "}), + ) + + assert calls == [None] + assert result.display_name is None + + +def test_update_run_rejects_unknown_fields(): + with pytest.raises(ValidationError): + TrainingRunUpdateRequest.model_validate({"unknown": "value"}) + + +def test_update_run_rejects_overlong_display_name(): + with pytest.raises(ValidationError): + TrainingRunUpdateRequest.model_validate({"display_name": "x" * 121}) diff --git a/studio/frontend/public/blacklogo-c.png b/studio/frontend/public/blacklogo-c.png deleted file mode 100644 index 7ab9959536d21dc48ed49f7782d579033014b9f9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 141545 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