From 95a2627bf6d96efebd64681a555bf8b9ab90e022 Mon Sep 17 00:00:00 2001 From: Irakli <39024518+IrakliXYZ@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:30:53 +0400 Subject: [PATCH 01/48] Fix step count mismatch when sequence packing is enabled (#5967) * Fix step count mismatch when sequence packing is enabled * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Emit a single step-0 progress event and guard applyStatus totalSteps Merge the two consecutive _update_progress calls before train() so the step-0 gate in _on_progress fires once instead of twice, avoiding a duplicate startup event and a null-metric step-0 row in training_metrics. Apply the same positive-number guard to applyStatus that applyProgress uses, so a stale or startup status poll can no longer overwrite the packed step count with 0 or replace it with a stale total. * Log debug message when train_dataset length is unavailable The TypeError fallback for length-less datasets (e.g. streaming IterableDataset) was silent, leaving no trace that the step estimate came from the raw dataset rather than the packed one. * [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> Co-authored-by: Etherll <61019402+Etherll@users.noreply.github.com> --- studio/backend/core/training/trainer.py | 18 ++++++++++++++---- studio/backend/core/training/worker.py | 2 +- .../training/stores/training-runtime-store.ts | 9 ++++++--- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py index 57342b2453..085f999dd6 100644 --- a/studio/backend/core/training/trainer.py +++ b/studio/backend/core/training/trainer.py @@ -3367,7 +3367,19 @@ class UnslothTrainer: # ========== PROGRESS TRACKING ========== self.trainer.add_callback(self._create_progress_callback()) - num_samples = len(dataset["dataset"] if isinstance(dataset, dict) else dataset) + num_samples = None + if hasattr(self.trainer, "train_dataset") and self.trainer.train_dataset is not None: + try: + num_samples = len(self.trainer.train_dataset) + except TypeError: + logger.debug( + "train_dataset does not support len(); falling back to " + "raw dataset size for step estimation." + ) + + if num_samples is None: + num_samples = len(dataset["dataset"] if isinstance(dataset, dict) else dataset) + batch_size = training_args.get("batch_size", 2) total_steps = self._calculate_total_steps( num_samples, @@ -3376,10 +3388,8 @@ class UnslothTrainer: training_args.get("num_epochs", 3), training_args.get("max_steps", 0), ) - self._update_progress(total_steps = total_steps) - # ========== START TRAINING ========== - self._update_progress(status_message = "Starting training...") + self._update_progress(total_steps = total_steps, status_message = "Starting training...") logger.info("Starting training...\n") self.trainer.train(resume_from_checkpoint = training_args.get("resume_from_checkpoint")) diff --git a/studio/backend/core/training/worker.py b/studio/backend/core/training/worker.py index 18b25cb4fe..c7c9003a04 100644 --- a/studio/backend/core/training/worker.py +++ b/studio/backend/core/training/worker.py @@ -2462,7 +2462,7 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) -> def _on_progress(progress: TrainingProgress): has_train_loss = progress.step > 0 and progress.loss is not None has_eval_loss = progress.eval_loss is not None - if has_train_loss or has_eval_loss: + if (progress.step == 0 and progress.total_steps > 0) or has_train_loss or has_eval_loss: event_queue.put( { "type": "progress", diff --git a/studio/frontend/src/features/training/stores/training-runtime-store.ts b/studio/frontend/src/features/training/stores/training-runtime-store.ts index 97fbd32d57..9eaaa98c0e 100644 --- a/studio/frontend/src/features/training/stores/training-runtime-store.ts +++ b/studio/frontend/src/features/training/stores/training-runtime-store.ts @@ -209,8 +209,8 @@ export const useTrainingRuntimeStore = create()((set) => ( currentStep: typeof detailStep === "number" ? Math.max(detailStep, 0) : state.currentStep, totalSteps: - typeof detailTotal === "number" - ? Math.max(detailTotal, 0) + typeof detailTotal === "number" && detailTotal > 0 + ? detailTotal : state.totalSteps, currentLoss: typeof detailLoss === "number" ? detailLoss : state.currentLoss, @@ -273,7 +273,10 @@ export const useTrainingRuntimeStore = create()((set) => ( ...state, jobId: payload.job_id || state.jobId, currentStep: step, - totalSteps: Math.max(payload.total_steps, state.totalSteps), + totalSteps: + typeof payload.total_steps === "number" && payload.total_steps > 0 + ? payload.total_steps + : state.totalSteps, // A null loss at a new step means the backend reported a non-finite // loss; clear the display instead of keeping the stale value. currentLoss: From e59ce0db0477b4b3161be2ce63f199b5e270b726 Mon Sep 17 00:00:00 2001 From: alkinun Date: Fri, 12 Jun 2026 12:37:51 +0300 Subject: [PATCH 02/48] fix/uv-bytecode-timeout (#6166) * fix/uv-bytecode-timeout * make sure that win installer upgrades uv for bytecode timeout * Clarify uv bytecode timeout comment in install.sh and install.ps1 * Read installer scripts as UTF-8 in parity test so it runs on Windows * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Prefer freshly installed uv when an older one shadows it on PATH --------- Co-authored-by: Daniel Han Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- install.ps1 | 57 +++++++++++++++++++--- install.sh | 6 ++- tests/python/test_cross_platform_parity.py | 56 ++++++++++++++++++--- 3 files changed, 104 insertions(+), 15 deletions(-) diff --git a/install.ps1 b/install.ps1 index cf7bb63cdf..9abddc9ce2 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1177,14 +1177,38 @@ shell.Run cmd, 0, False if ($SkipTorch) { $InitialGpuBranch = "no_torch" } Write-TauriDiag -GpuBranch $InitialGpuBranch -TorchIndexFamily "none" -PythonVersionForDiag $DiagPythonVersion - # ── Install uv if not present ── + # ── Install uv ── Write-TauriLog "STEP" "Installing uv package manager" - if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { - substep "installing uv package manager..." + $UvMinVersion = "0.7.22" + function Test-UvVersionOk { + $cmd = Get-Command uv -ErrorAction SilentlyContinue + if (-not $cmd) { return $false } + try { + $raw = (& uv --version 2>$null | Select-Object -First 1) + } catch { + return $false + } + if ($raw -notmatch 'uv\s+([0-9]+(?:\.[0-9]+)+)') { return $false } + try { + return ([version]$Matches[1] -ge [version]$UvMinVersion) + } catch { + return $false + } + } + + if (-not (Test-UvVersionOk)) { + if (Get-Command uv -ErrorAction SilentlyContinue) { + substep "updating uv package manager..." + } else { + substep "installing uv package manager..." + } if ($script:WingetAvailable) { $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" - try { winget install --id=astral-sh.uv -e --source winget --accept-package-agreements --accept-source-agreements } catch {} + try { winget upgrade --id=astral-sh.uv -e --source winget --accept-package-agreements --accept-source-agreements } catch {} + if (-not (Test-UvVersionOk)) { + try { winget install --id=astral-sh.uv -e --source winget --accept-package-agreements --accept-source-agreements } catch {} + } $ErrorActionPreference = $prevEAP Refresh-SessionPath } @@ -1192,19 +1216,40 @@ shell.Run cmd, 0, False # use Astral's official PowerShell installer. This is the only # supported path on hosts without winget (Windows ARM64 runners, # corporate machines without the Store, etc.). - if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { + if (-not (Test-UvVersionOk)) { substep "installing uv via https://astral.sh/uv/install.ps1..." "Yellow" Invoke-Expression (Invoke-RestMethod -Uri "https://astral.sh/uv/install.ps1") Refresh-SessionPath } } - if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { + # A freshly installed uv can sit later on PATH than an older one (active + # venv, Scoop/pipx shim). Prefer a just-installed uv from a known location. + if (-not (Test-UvVersionOk)) { + $origPath = $env:PATH + foreach ($d in @($env:UV_INSTALL_DIR, $env:XDG_BIN_HOME, + (Join-Path $env:USERPROFILE ".local\bin"), + (Join-Path $env:LOCALAPPDATA "Microsoft\WinGet\Links"))) { + if ($d -and (Test-Path $d)) { + $env:PATH = "$d;$origPath" + if (Test-UvVersionOk) { break } + $env:PATH = $origPath + } + } + } + + if (-not (Test-UvVersionOk)) { step "uv" "could not be installed" "Red" substep "Install it from https://docs.astral.sh/uv/" "Yellow" return (Exit-InstallFailure "uv could not be installed") } + # When bytecode compilation is enabled, large installs can exceed uv's 60s + # default on slow machines. Default to 180s, preserving overrides ("0" disables). + if (-not $env:UV_COMPILE_BYTECODE_TIMEOUT) { + $env:UV_COMPILE_BYTECODE_TIMEOUT = "180" + } + # ── Create venv (migrate old layout if possible, otherwise fresh) ── # Pass the resolved executable path to uv so it does not re-resolve # a version string back to a conda interpreter. diff --git a/install.sh b/install.sh index 532ac61bc0..eba92d0746 100755 --- a/install.sh +++ b/install.sh @@ -1456,7 +1456,11 @@ fi # ── Install uv ── tauri_log "STEP" "Installing uv package manager" -UV_MIN_VERSION="0.7.14" +UV_MIN_VERSION="0.7.22" + +# When bytecode compilation is enabled, large installs can exceed uv's 60s default on slow machines. Default to 180s, preserving overrides ("0" disables). +: "${UV_COMPILE_BYTECODE_TIMEOUT:=180}" +export UV_COMPILE_BYTECODE_TIMEOUT version_ge() { # returns 0 if $1 >= $2 diff --git a/tests/python/test_cross_platform_parity.py b/tests/python/test_cross_platform_parity.py index 0f2e73257a..34f984714e 100644 --- a/tests/python/test_cross_platform_parity.py +++ b/tests/python/test_cross_platform_parity.py @@ -20,7 +20,7 @@ class TestNoTorchBackendAutoInInstallSh: """ def test_no_torch_backend_auto_outside_fallback(self): - lines = INSTALL_SH.read_text().splitlines() + lines = INSTALL_SH.read_text(encoding = "utf-8").splitlines() # Fallback block: from "GPU detection failed" to the next "fi". fallback_start = None fallback_end = None @@ -48,7 +48,7 @@ class TestNoTorchBackendAutoInInstallSh: def test_fallback_uses_torch_backend_auto(self): """The fallback branch should use --torch-backend=auto as recovery.""" - text = INSTALL_SH.read_text() + text = INSTALL_SH.read_text(encoding = "utf-8") assert ( "GPU detection failed" in text ), "install.sh should have a fallback branch for when GPU detection fails" @@ -58,13 +58,13 @@ class TestInstallShHasGpuDetection: """install.sh must contain the get_torch_index_url function.""" def test_function_exists(self): - text = INSTALL_SH.read_text() + text = INSTALL_SH.read_text(encoding = "utf-8") assert ( "get_torch_index_url()" in text ), "install.sh is missing the get_torch_index_url() function" def test_torch_index_url_assigned(self): - text = INSTALL_SH.read_text() + text = INSTALL_SH.read_text(encoding = "utf-8") assert ( "TORCH_INDEX_URL=$(get_torch_index_url)" in text ), "install.sh should assign TORCH_INDEX_URL from get_torch_index_url()" @@ -115,8 +115,8 @@ class TestCudaMappingParity: def test_same_cuda_suffixes(self): """Both scripts should produce the same ordered list of CUDA index suffixes.""" - sh_text = INSTALL_SH.read_text() - ps1_text = INSTALL_PS1.read_text() + sh_text = INSTALL_SH.read_text(encoding = "utf-8") + ps1_text = INSTALL_PS1.read_text(encoding = "utf-8") sh_thresholds = self._extract_cuda_thresholds_sh(sh_text) ps1_thresholds = self._extract_cuda_thresholds_ps1(ps1_text) @@ -134,13 +134,53 @@ class TestPyTorchMirrorEnvVar: """Both install scripts must support the UNSLOTH_PYTORCH_MIRROR env var.""" def test_install_sh_has_mirror_var(self): - text = INSTALL_SH.read_text() + text = INSTALL_SH.read_text(encoding = "utf-8") assert ( "UNSLOTH_PYTORCH_MIRROR" in text ), "install.sh should reference UNSLOTH_PYTORCH_MIRROR" def test_install_ps1_has_mirror_var(self): - text = INSTALL_PS1.read_text() + text = INSTALL_PS1.read_text(encoding = "utf-8") assert ( "UNSLOTH_PYTORCH_MIRROR" in text ), "install.ps1 should reference UNSLOTH_PYTORCH_MIRROR" + + +class TestUvBytecodeCompileTimeout: + """Installers should relax uv bytecode compilation timeout by default.""" + + @staticmethod + def _version_tuple(version: str) -> tuple[int, ...]: + return tuple(int(part) for part in version.split(".")) + + def test_install_sh_uses_uv_version_with_timeout_env(self): + text = INSTALL_SH.read_text(encoding = "utf-8") + match = re.search(r'^UV_MIN_VERSION="([^"]+)"$', text, re.MULTILINE) + assert match, "install.sh should declare UV_MIN_VERSION" + assert self._version_tuple(match.group(1)) >= self._version_tuple("0.7.22") + + def test_install_ps1_uses_uv_version_with_timeout_env(self): + text = INSTALL_PS1.read_text(encoding = "utf-8") + match = re.search(r'^\s*\$UvMinVersion = "([^"]+)"$', text, re.MULTILINE) + assert match, "install.ps1 should declare $UvMinVersion" + assert self._version_tuple(match.group(1)) >= self._version_tuple("0.7.22") + assert "function Test-UvVersionOk" in text + assert "if (-not (Test-UvVersionOk))" in text + + def test_install_sh_preserves_timeout_override(self): + text = INSTALL_SH.read_text(encoding = "utf-8") + assert ( + ': "${UV_COMPILE_BYTECODE_TIMEOUT:=180}"' in text + ), "install.sh should default UV_COMPILE_BYTECODE_TIMEOUT without overwriting callers" + assert ( + "export UV_COMPILE_BYTECODE_TIMEOUT" in text + ), "install.sh should export UV_COMPILE_BYTECODE_TIMEOUT for uv subprocesses" + + def test_install_ps1_preserves_timeout_override(self): + text = INSTALL_PS1.read_text(encoding = "utf-8") + assert ( + "if (-not $env:UV_COMPILE_BYTECODE_TIMEOUT)" in text + ), "install.ps1 should preserve caller UV_COMPILE_BYTECODE_TIMEOUT overrides" + assert ( + '$env:UV_COMPILE_BYTECODE_TIMEOUT = "180"' in text + ), "install.ps1 should default UV_COMPILE_BYTECODE_TIMEOUT" From 25ccfebc0b218fe8321c023d25f7363aad8b7016 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 12 Jun 2026 02:39:01 -0700 Subject: [PATCH 03/48] Studio: tune llama.cpp env for data-center GPUs (#6098) * Studio: tune llama.cpp env for data-center GPUs Detect datacenter/professional NVIDIA GPUs at llama-server launch and set the llama.cpp env flags that help them, gated so consumer GeForce, AMD/ROCm, CPU and macOS are never touched. - GGML_CUDA_FORCE_CUBLAS_COMPUTE_32F=1 for any DC GPU (FP32 cuBLAS accumulation). On a B200 this is ~0% throughput cost with identical perplexity (7.3230 wikitext-2-raw, baseline and on), where on GeForce the same flag costs real throughput, hence the gate. - GGML_CUDA_P2P=1 and CUDA_SCALE_LAUNCH_QUEUES=4x for multi-GPU DC boxes. Benchmarked on 6x B200: +33-51% prompt processing on tensor (row) split and +8-16% on the default pipeline (layer) split, with no regression on the other split or on token generation. Detection uses torch device names (A100/A30/H100/H200/H800/GH200/B200/GB200/ GB300/L40/L4/RTX PRO 6000/RTX 6000 Ada). A mixed box with one consumer GPU in the selection is treated as non-DC. All writes are setdefault so a user value always wins, and UNSLOTH_DISABLE_DC_TUNING=1 turns the whole thing off. 37 unit tests cover detection, multi-GPU gating, user-override precedence, the disable flag and fail-open on error. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: fix data-center GPU detection false positives and physical-id mapping Two issues in the data-center llama.cpp env tuning gate: - _is_datacenter_gpu matched the marker allowlist as unbounded substrings, so workstation/laptop parts "NVIDIA RTX A1000" and "NVIDIA RTX A3000" matched "a100"/"a30" and were wrongly tuned as data-center GPUs (forcing FP32 cuBLAS accumulation and the multi-GPU env, which carry a real cost on those cards). Switch to a word-boundary regex. - gpu_indices carries physical GPU ids (translated from torch ordinals by _get_gpu_free_memory via CUDA_VISIBLE_DEVICES), but they were passed straight into torch.cuda.get_device_properties, which expects mask-relative ordinals. On a masked host (e.g. CUDA_VISIBLE_DEVICES=4,5,6,7) a selection like [4,5] fell out of range and silently dropped the tuning, and on a mixed mask it could probe the wrong GPU class. Build a physical-id to device-name map mirroring _get_gpu_free_memory, then look up the selection by physical id. Add regression tests for the A1000/A3000 false positives and for masked-host physical-id selection (reordered and mixed-class masks included). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: tighten data-center GPU tuning comments Comment-only pass over the DC tuning block and its tests: shorten verbose docstrings/comments, drop ones that restate the code, collapse multi-line blocks. Keep the load-bearing rationale (physical-id vs ordinal mapping, the word-boundary reason, the B200 benchmark numbers). No code change: verified with comment_tools.py check --strip-docstrings (code unchanged, comments only). --------- Co-authored-by: danielhanchen Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/core/inference/llama_cpp.py | 107 +++++++ .../tests/test_datacenter_gpu_tuning.py | 278 ++++++++++++++++++ 2 files changed, 385 insertions(+) create mode 100644 studio/backend/tests/test_datacenter_gpu_tuning.py diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 8cf37ed9ec..3393f2b4be 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -1329,6 +1329,105 @@ class LlamaCppBackend: return False return False + # Datacenter / professional NVIDIA parts that benefit from the llama.cpp + # FP32-accum / P2P tunings. Whole-word (\b) so short markers don't match + # workstation parts as substrings: "a100" must not fire on "RTX A1000". + _DATACENTER_GPU_RE = re.compile( + r"\b(?:a100|a30|h100|h200|h800|gh200|b200|b100|b300|gb200|gb300|" + r"l40s?|l4|rtx pro 6000|rtx 6000 ada)\b" + ) + + @staticmethod + def _is_datacenter_gpu(gpu_indices = None) -> bool: + """True iff every selected NVIDIA GPU is a datacenter/professional part. + NVIDIA-only, fails open to False (consumer GeForce, ROCm, CPU and errors + are left untouched); a mixed DC+consumer selection counts as non-DC. + + gpu_indices are PHYSICAL ids (see _get_gpu_free_memory), but + get_device_properties wants mask-relative ordinals, so we rebuild the + ordinal->physical map from CUDA_VISIBLE_DEVICES and key names by physical + id. Otherwise a masked host (CUDA_VISIBLE_DEVICES=4,5,6,7, selection [4,5]) + would drop the tuning or probe the wrong GPU.""" + try: + import torch + + if getattr(torch.version, "hip", None) is not None: + return False # ROCm reuses torch.cuda.*; not a CUDA part + if not (hasattr(torch, "cuda") and torch.cuda.is_available()): + return False + count = torch.cuda.device_count() + + # Mirror _get_gpu_free_memory: map visible ordinal -> physical id via + # CUDA_VISIBLE_DEVICES; unset/unparsable leaves physical id == ordinal. + physical_ids: Optional[list[int]] = None + cvd = os.environ.get("CUDA_VISIBLE_DEVICES") + if cvd is not None: + try: + physical_ids = [int(x.strip()) for x in cvd.split(",") if x.strip()] + except ValueError: + physical_ids = None + + pattern = LlamaCppBackend._DATACENTER_GPU_RE + names_by_id: dict[int, str] = {} + for ordinal in range(count): + try: + name = (torch.cuda.get_device_properties(ordinal).name or "").lower() + except Exception: + continue + pid = ( + physical_ids[ordinal] + if physical_ids is not None and ordinal < len(physical_ids) + else ordinal + ) + names_by_id[pid] = name + + indices = list(gpu_indices) if gpu_indices else list(names_by_id) + saw = False + for _i in indices: + name = names_by_id.get(_i) + if name is None: + continue # not visible -> skip (fail conservative) + saw = True + if not pattern.search(name): + return False + return saw + except Exception: + return False + + @staticmethod + def _effective_gpu_count(gpu_indices = None) -> int: + """GPUs llama-server will use: len(selection), else the visible CUDA + device count (None = every visible GPU). 0 on error so multi-GPU tuning + stays off when the count is unknown.""" + if gpu_indices is not None: + return len(gpu_indices) + try: + import torch + if hasattr(torch, "cuda") and torch.cuda.is_available(): + return torch.cuda.device_count() + except Exception: + return 0 + return 0 + + @staticmethod + def _apply_datacenter_env(env: dict, gpu_indices = None) -> bool: + """Inject DC llama.cpp tuning into env in place via setdefault (user + values win); return whether the box qualified. Opt out with + UNSLOTH_DISABLE_DC_TUNING=1; only datacenter NVIDIA parts qualify + (consumer/ROCm/CPU/error are a no-op). Sets GGML_CUDA_FORCE_CUBLAS_COMPUTE_32F + for any qualifying GPU (FP32 accum: ~0% cost on B200, real cost on GeForce), + plus GGML_CUDA_P2P + CUDA_SCALE_LAUNCH_QUEUES=4x for multi-GPU (+33-51% pp + tensor-split, +8-16% pipeline split on B200).""" + if os.environ.get("UNSLOTH_DISABLE_DC_TUNING") == "1": + return False + if not LlamaCppBackend._is_datacenter_gpu(gpu_indices): + return False + env.setdefault("GGML_CUDA_FORCE_CUBLAS_COMPUTE_32F", "1") + if LlamaCppBackend._effective_gpu_count(gpu_indices) > 1: + env.setdefault("GGML_CUDA_P2P", "1") + env.setdefault("CUDA_SCALE_LAUNCH_QUEUES", "4x") + return True + @staticmethod def _get_gpu_free_memory() -> list[tuple[int, int]]: """Query free memory per GPU. @@ -3406,6 +3505,14 @@ class LlamaCppBackend: env.setdefault("GGML_CUDA_ENABLE_UNIFIED_MEMORY", "1") logger.info("AMD unified-memory APU: set GGML_CUDA_ENABLE_UNIFIED_MEMORY=1") + # DC NVIDIA GPUs: FP32 accum (+ P2P / launch queues for multi-GPU). + # See _apply_datacenter_env; opt out with UNSLOTH_DISABLE_DC_TUNING=1. + if self._apply_datacenter_env(env, gpu_indices): + multi_gpu = self._effective_gpu_count(gpu_indices) > 1 + logger.info( + f"Data-center GPU detected: applied DC llama.cpp env tuning (multi_gpu={multi_gpu})" + ) + if sys.platform == "win32": # Ordering: see _build_windows_path_dirs. #5106. path_dirs = self._build_windows_path_dirs( diff --git a/studio/backend/tests/test_datacenter_gpu_tuning.py b/studio/backend/tests/test_datacenter_gpu_tuning.py new file mode 100644 index 0000000000..fd9b291e8a --- /dev/null +++ b/studio/backend/tests/test_datacenter_gpu_tuning.py @@ -0,0 +1,278 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Data-center llama.cpp env tuning: FP32 accum (+ P2P / launch queues for +multi-GPU) must apply only to datacenter NVIDIA parts, never consumer GeForce, +AMD/ROCm, CPU or macOS. User values win; UNSLOTH_DISABLE_DC_TUNING=1 disables. +""" + +from __future__ import annotations + +import sys +import types + +import pytest + +from core.inference.llama_cpp import LlamaCppBackend + + +def _fake_torch( + names, + *, + hip = None, + cuda_ok = True, +): + """torch stub: version.hip, cuda.is_available/device_count, get_device_properties(i).name.""" + t = types.ModuleType("torch") + t.version = types.SimpleNamespace(hip = hip) + t.cuda = types.SimpleNamespace( + is_available = lambda: cuda_ok, + device_count = lambda: len(names), + get_device_properties = lambda i: types.SimpleNamespace(name = names[i]), + ) + return t + + +@pytest.fixture(autouse = True) +def _clear_cuda_visible_devices(monkeypatch): + """Detection reads CUDA_VISIBLE_DEVICES, so clear it by default (run unmasked, + physical id == ordinal) regardless of host; masked tests set it explicitly.""" + monkeypatch.delenv("CUDA_VISIBLE_DEVICES", raising = False) + + +# --------------------------------------------------------------------------- +# _is_datacenter_gpu +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "names,expected", + [ + # Datacenter / professional parts. + (["NVIDIA A100-SXM4-80GB"], True), + (["NVIDIA A30"], True), + (["NVIDIA H100 80GB HBM3"], True), + (["NVIDIA H200"], True), + (["NVIDIA H800"], True), + (["NVIDIA GH200 480GB"], True), + (["NVIDIA B200"], True), + (["NVIDIA GB200"], True), + (["NVIDIA L40S"], True), + (["NVIDIA L4"], True), + (["NVIDIA RTX PRO 6000 Blackwell Server Edition"], True), + (["NVIDIA RTX 6000 Ada Generation"], True), + # Consumer GeForce: never. + (["NVIDIA GeForce RTX 4090"], False), + (["NVIDIA GeForce RTX 5090"], False), + (["NVIDIA GeForce RTX 3090"], False), + (["NVIDIA GeForce RTX 2080 Ti"], False), + (["NVIDIA GeForce GTX 1080"], False), + # Workstation/laptop: short markers must not match as substrings + # ("a100" in "A1000", "a30" in "A3000"). + (["NVIDIA RTX A1000 Laptop GPU"], False), + (["NVIDIA RTX A1000 6GB Laptop GPU"], False), + (["NVIDIA RTX A3000 Laptop GPU"], False), + # Homogeneous multi-DC: all must match. + (["NVIDIA B200", "NVIDIA B200"], True), + (["NVIDIA H100 80GB HBM3", "NVIDIA H100 80GB HBM3"], True), + # Mixed DC + consumer: non-DC, so tuning never lands on the GeForce. + (["NVIDIA B200", "NVIDIA GeForce RTX 4090"], False), + (["NVIDIA GeForce RTX 4090", "NVIDIA B200"], False), + ], +) +def test_is_datacenter_gpu(monkeypatch, names, expected): + monkeypatch.setitem(sys.modules, "torch", _fake_torch(names)) + assert LlamaCppBackend._is_datacenter_gpu() is expected + + +def test_is_datacenter_gpu_respects_selection(monkeypatch): + # A mixed box where only the DC GPU is selected -> True; only consumer -> False. + monkeypatch.setitem( + sys.modules, + "torch", + _fake_torch(["NVIDIA B200", "NVIDIA GeForce RTX 4090"]), + ) + assert LlamaCppBackend._is_datacenter_gpu([0]) is True + assert LlamaCppBackend._is_datacenter_gpu([1]) is False + assert LlamaCppBackend._is_datacenter_gpu([0, 1]) is False + + +def test_is_datacenter_gpu_out_of_range_indices_skipped(monkeypatch): + monkeypatch.setitem(sys.modules, "torch", _fake_torch(["NVIDIA B200"])) + # Out-of-range / negative indices are skipped; the one valid DC GPU still wins. + assert LlamaCppBackend._is_datacenter_gpu([0, 5, -1]) is True + # Only invalid indices -> nothing seen -> False (fail closed for the flag). + assert LlamaCppBackend._is_datacenter_gpu([5, 9]) is False + + +def test_is_datacenter_gpu_masked_host_physical_ids(monkeypatch): + # Mask 4,5,6,7 -> ordinals 0..3 == physical 4..7. PHYSICAL selection [4,5] + # must resolve, not index out of range (the pre-fix bug: 4 >= device_count). + monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "4,5,6,7") + monkeypatch.setitem(sys.modules, "torch", _fake_torch(["NVIDIA B200"] * 4)) + assert LlamaCppBackend._is_datacenter_gpu([4, 5]) is True + assert LlamaCppBackend._is_datacenter_gpu([4, 5, 6, 7]) is True + assert LlamaCppBackend._is_datacenter_gpu(None) is True + assert LlamaCppBackend._is_datacenter_gpu([0, 1]) is False # not visible -> skip + + +def test_is_datacenter_gpu_masked_host_reordered(monkeypatch): + # Reordered mask preserves order: ordinal 0 -> physical 7, 1 -> 4, ... + monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "7,4,5,6") + monkeypatch.setitem(sys.modules, "torch", _fake_torch(["NVIDIA H100 80GB HBM3"] * 4)) + assert LlamaCppBackend._is_datacenter_gpu([7, 4]) is True + + +def test_is_datacenter_gpu_masked_host_mixed_class(monkeypatch): + # Mask 4,5: physical 4 = GeForce, physical 5 = B200. Detection must follow the + # selected physical GPU, not a same-numbered ordinal. + monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "4,5") + monkeypatch.setitem( + sys.modules, + "torch", + _fake_torch(["NVIDIA GeForce RTX 4090", "NVIDIA B200"]), + ) + assert LlamaCppBackend._is_datacenter_gpu([4]) is False + assert LlamaCppBackend._is_datacenter_gpu([5]) is True + assert LlamaCppBackend._is_datacenter_gpu([4, 5]) is False + + +def test_is_datacenter_gpu_unparsable_mask_falls_back(monkeypatch): + # Unparsable (UUID) mask falls back to physical id == ordinal (mirrors + # _get_gpu_free_memory), so ordinal lookup still classifies the device. + monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "GPU-abcdef12") + monkeypatch.setitem(sys.modules, "torch", _fake_torch(["NVIDIA B200"])) + assert LlamaCppBackend._is_datacenter_gpu([0]) is True + + +def test_is_datacenter_gpu_rocm_is_false(monkeypatch): + # ROCm reuses torch.cuda.*; an MI300X must not qualify. + monkeypatch.setitem( + sys.modules, + "torch", + _fake_torch(["AMD Instinct MI300X"], hip = "6.2.0"), + ) + assert LlamaCppBackend._is_datacenter_gpu() is False + + +def test_is_datacenter_gpu_no_cuda_is_false(monkeypatch): + monkeypatch.setitem(sys.modules, "torch", _fake_torch([], cuda_ok = False)) + assert LlamaCppBackend._is_datacenter_gpu() is False + + +def test_is_datacenter_gpu_missing_torch_is_false(monkeypatch): + monkeypatch.setitem(sys.modules, "torch", None) + assert LlamaCppBackend._is_datacenter_gpu() is False + + +# --------------------------------------------------------------------------- +# _effective_gpu_count +# --------------------------------------------------------------------------- + + +def test_effective_gpu_count_explicit_selection(monkeypatch): + monkeypatch.setitem(sys.modules, "torch", _fake_torch(["NVIDIA B200"] * 4)) + assert LlamaCppBackend._effective_gpu_count([0]) == 1 + assert LlamaCppBackend._effective_gpu_count([0, 1, 2]) == 3 + + +def test_effective_gpu_count_none_uses_visible(monkeypatch): + # None -> visible device count. + monkeypatch.setitem(sys.modules, "torch", _fake_torch(["NVIDIA B200"] * 4)) + assert LlamaCppBackend._effective_gpu_count(None) == 4 + + +def test_effective_gpu_count_no_cuda_is_zero(monkeypatch): + monkeypatch.setitem(sys.modules, "torch", _fake_torch([], cuda_ok = False)) + assert LlamaCppBackend._effective_gpu_count(None) == 0 + + +def test_effective_gpu_count_missing_torch_is_zero(monkeypatch): + monkeypatch.setitem(sys.modules, "torch", None) + assert LlamaCppBackend._effective_gpu_count(None) == 0 + + +# --------------------------------------------------------------------------- +# _apply_datacenter_env (the env-injection decision) +# --------------------------------------------------------------------------- + + +def test_apply_env_single_dc_gpu_sets_only_fp32(monkeypatch): + monkeypatch.delenv("UNSLOTH_DISABLE_DC_TUNING", raising = False) + monkeypatch.setitem(sys.modules, "torch", _fake_torch(["NVIDIA B200"])) + env: dict = {} + assert LlamaCppBackend._apply_datacenter_env(env, [0]) is True + assert env == {"GGML_CUDA_FORCE_CUBLAS_COMPUTE_32F": "1"} + assert "GGML_CUDA_P2P" not in env # no multi-GPU flags on one GPU + assert "CUDA_SCALE_LAUNCH_QUEUES" not in env + + +def test_apply_env_multi_dc_gpu_sets_all(monkeypatch): + monkeypatch.delenv("UNSLOTH_DISABLE_DC_TUNING", raising = False) + monkeypatch.setitem(sys.modules, "torch", _fake_torch(["NVIDIA B200"] * 4)) + env: dict = {} + assert LlamaCppBackend._apply_datacenter_env(env, [0, 1]) is True + assert env["GGML_CUDA_FORCE_CUBLAS_COMPUTE_32F"] == "1" + assert env["GGML_CUDA_P2P"] == "1" + assert env["CUDA_SCALE_LAUNCH_QUEUES"] == "4x" + + +def test_apply_env_none_indices_uses_visible_count(monkeypatch): + # None on a 2x DC box -> multi-GPU flags applied. + monkeypatch.delenv("UNSLOTH_DISABLE_DC_TUNING", raising = False) + monkeypatch.setitem(sys.modules, "torch", _fake_torch(["NVIDIA H100", "NVIDIA H100"])) + env: dict = {} + assert LlamaCppBackend._apply_datacenter_env(env, None) is True + assert env["GGML_CUDA_P2P"] == "1" + assert env["CUDA_SCALE_LAUNCH_QUEUES"] == "4x" + + +def test_apply_env_consumer_gpu_is_noop(monkeypatch): + monkeypatch.delenv("UNSLOTH_DISABLE_DC_TUNING", raising = False) + monkeypatch.setitem(sys.modules, "torch", _fake_torch(["NVIDIA GeForce RTX 4090"] * 2)) + env: dict = {} + assert LlamaCppBackend._apply_datacenter_env(env, [0, 1]) is False + assert env == {} + + +def test_apply_env_user_value_wins(monkeypatch): + monkeypatch.delenv("UNSLOTH_DISABLE_DC_TUNING", raising = False) + monkeypatch.setitem(sys.modules, "torch", _fake_torch(["NVIDIA B200"] * 2)) + env = { + "GGML_CUDA_FORCE_CUBLAS_COMPUTE_32F": "0", # user explicitly disabled + "CUDA_SCALE_LAUNCH_QUEUES": "8x", # user override + } + assert LlamaCppBackend._apply_datacenter_env(env, [0, 1]) is True + # setdefault must not clobber user values; the unset one still defaults. + assert env["GGML_CUDA_FORCE_CUBLAS_COMPUTE_32F"] == "0" + assert env["CUDA_SCALE_LAUNCH_QUEUES"] == "8x" + assert env["GGML_CUDA_P2P"] == "1" + + +def test_apply_env_disable_flag_respected(monkeypatch): + monkeypatch.setenv("UNSLOTH_DISABLE_DC_TUNING", "1") + monkeypatch.setitem(sys.modules, "torch", _fake_torch(["NVIDIA B200"] * 2)) + env: dict = {} + assert LlamaCppBackend._apply_datacenter_env(env, [0, 1]) is False + assert env == {} + + +def test_apply_env_fail_open_on_detection_error(monkeypatch): + monkeypatch.delenv("UNSLOTH_DISABLE_DC_TUNING", raising = False) + monkeypatch.setitem(sys.modules, "torch", None) # detection raises -> False + env: dict = {} + assert LlamaCppBackend._apply_datacenter_env(env, [0]) is False + assert env == {} + + +def test_apply_env_masked_host_multi_dc(monkeypatch): + # End-to-end masked host (mask 4,5,6,7, physical selection [4,5]): pre-fix + # applied no tuning; now all three multi-GPU flags must be set. + monkeypatch.delenv("UNSLOTH_DISABLE_DC_TUNING", raising = False) + monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "4,5,6,7") + monkeypatch.setitem(sys.modules, "torch", _fake_torch(["NVIDIA B200"] * 4)) + env: dict = {} + assert LlamaCppBackend._apply_datacenter_env(env, [4, 5]) is True + assert env["GGML_CUDA_FORCE_CUBLAS_COMPUTE_32F"] == "1" + assert env["GGML_CUDA_P2P"] == "1" + assert env["CUDA_SCALE_LAUNCH_QUEUES"] == "4x" From 6a0a62ef65f56510d6475f35690ae2a937c3d4b5 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 12 Jun 2026 02:43:55 -0700 Subject: [PATCH 04/48] Studio: drop the on-disk freshness cache after a llama.cpp update (#6234) The post-install path cleared only the in-memory freshness caches and then re-primed the 24h disk cache with a forced GitHub refresh. When that refresh cannot reach GitHub, latest_published_release falls back to the last-good disk value, so a still-fresh same-base mix tag cached before the swap (b9596-mix-aaa vs the just-installed b9596-mix-bbb) is replayed and the prebuilt reads as behind, surfacing a false update banner that points back at the build that was just replaced. Give reset_caches a drop_disk option and use it on the update path: with the disk cache gone, an offline post-install refresh leaves latest as None and the banner fails open (off) instead of lingering on the stale same-base value. The no-arg form stays in-memory only. Adds regression coverage for the drop, the default no-op, and the fail-open vs stale-replay contrast. --- .../backend/tests/test_llama_cpp_freshness.py | 87 +++++++++++++++++++ studio/backend/utils/llama_cpp_freshness.py | 19 +++- studio/backend/utils/llama_cpp_update.py | 11 ++- 3 files changed, 111 insertions(+), 6 deletions(-) diff --git a/studio/backend/tests/test_llama_cpp_freshness.py b/studio/backend/tests/test_llama_cpp_freshness.py index f8e4619ded..f90c4ba0e7 100644 --- a/studio/backend/tests/test_llama_cpp_freshness.py +++ b/studio/backend/tests/test_llama_cpp_freshness.py @@ -433,3 +433,90 @@ def test_fetch_latest_release_tag_uses_publish_time(monkeypatch): ] monkeypatch.setattr(urllib.request, "urlopen", lambda req, timeout = 5.0: _Resp(payload)) assert fr._fetch_latest_release_tag("unslothai/llama.cpp") == "b9596-mix-e6f2453" + + +# reset_caches(drop_disk=...) -- post-update stale same-base mix disk cache. + + +def _seed_disk_cache(tmp_path: Path, latest_tag: str) -> Path: + # Matches _cache_path_for under the fixture's stubbed _cache_dir. + cache_dir = tmp_path / ".freshness" + cache_dir.mkdir(exist_ok = True) + cache_file = cache_dir / "unslothai__llama.cpp.json" + cache_file.write_text(json.dumps({"fetched_at": time.time(), "latest_tag": latest_tag})) + return cache_file + + +def test_reset_caches_drop_disk_removes_disk_cache(tmp_path): + cache_file = _seed_disk_cache(tmp_path, "b9596-mix-aaa") + assert cache_file.exists() + fr.reset_caches(drop_disk = True) + assert not cache_file.exists() + + +def test_reset_caches_default_keeps_disk_cache(tmp_path): + # The no-arg form is in-memory only (its existing test-only contract); it + # must not delete the on-disk cache. + cache_file = _seed_disk_cache(tmp_path, "b9596-mix-aaa") + fr.reset_caches() + assert cache_file.exists() + + +def test_reset_caches_drop_disk_on_missing_dir_is_noop(tmp_path): + # Fresh machine, no cache dir yet: drop_disk must be a quiet no-op. + assert not (tmp_path / ".freshness").exists() + fr.reset_caches(drop_disk = True) # must not raise + + +def test_drop_disk_lets_banner_fail_open_after_same_base_mix_swap(monkeypatch, tmp_path): + # P2 #2: the disk cache holds a still-fresh same-base mix (b9596-mix-aaa) + # from before an update to a *different* same-base mix (b9596-mix-bbb). + # The post-install path drops the disk cache; if the forced refresh is then + # offline, latest reads as None and the banner fails open -- instead of + # replaying the stale b9596-mix-aaa and falsely reading "behind". + _seed_disk_cache(tmp_path, "b9596-mix-aaa") + install_dir = tmp_path / "llama.cpp" + _write_marker( + install_dir, + tag = "b9596", + release_tag = "b9596-mix-bbb", + installed_at_utc = (datetime.now(tz = timezone.utc) - timedelta(days = 5)) + .isoformat() + .replace("+00:00", "Z"), + ) + bin_path = _fake_binary(install_dir, layout = "root") + # GitHub unreachable for the rest of the test (the offline post-install + # refresh, and the later status check). + monkeypatch.setattr(fr, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: None) + + fr.reset_caches(drop_disk = True) # exactly what the apply path now does + info = fr.check_prebuilt_freshness(str(bin_path)) + assert info["latest_tag"] is None + assert info["behind"] is False + assert info["stale"] is False + + +def test_in_memory_only_reset_replays_stale_same_base_mix(monkeypatch, tmp_path): + # Contrast/guard for the case above: an in-memory-only reset leaves the + # stale same-base mix on disk, so an offline check replays it and falsely + # reads behind/stale. This is exactly the failure drop_disk removes; if a + # future change makes the no-arg reset also clear disk, the apply-path call + # and this guard should be revisited together. + _seed_disk_cache(tmp_path, "b9596-mix-aaa") + install_dir = tmp_path / "llama.cpp" + _write_marker( + install_dir, + tag = "b9596", + release_tag = "b9596-mix-bbb", + installed_at_utc = (datetime.now(tz = timezone.utc) - timedelta(days = 5)) + .isoformat() + .replace("+00:00", "Z"), + ) + bin_path = _fake_binary(install_dir, layout = "root") + monkeypatch.setattr(fr, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: None) + + fr.reset_caches() # in-memory only -> stale disk value survives + info = fr.check_prebuilt_freshness(str(bin_path)) + assert info["latest_tag"] == "b9596-mix-aaa" + assert info["behind"] is True + assert info["stale"] is True diff --git a/studio/backend/utils/llama_cpp_freshness.py b/studio/backend/utils/llama_cpp_freshness.py index f5fd745334..87d0d2ec01 100644 --- a/studio/backend/utils/llama_cpp_freshness.py +++ b/studio/backend/utils/llama_cpp_freshness.py @@ -301,7 +301,22 @@ def format_stale_warning(info: dict) -> str: ) -def reset_caches() -> None: - """Test-only: drop all in-memory caches.""" +def reset_caches(*, drop_disk: bool = False) -> None: + """Drop the in-memory freshness caches. The no-arg form is test-only. + + With ``drop_disk = True`` also delete the on-disk 24h release cache. Used by + the post-install/update path: in-memory clearing alone leaves the stale + same-base value on disk, so if the post-install GitHub refresh can't reach + the network, ``latest_published_release`` would replay that stale disk value + (see its last-good fallback) and the banner could linger. Dropping the disk + cache makes latest read as None in that offline case, so the banner fails + open (off) instead of pointing at the just-replaced build.""" _marker_cache.clear() _release_memo.clear() + if drop_disk: + import shutil + + # _cache_dir() is a dedicated freshness-only subdir; it is re-created on + # the next _save_disk_cache. ignore_errors so a missing/locked dir is a + # no-op rather than breaking an otherwise successful install. + shutil.rmtree(_cache_dir(), ignore_errors = True) diff --git a/studio/backend/utils/llama_cpp_update.py b/studio/backend/utils/llama_cpp_update.py index 654ade6cd4..6eb34ffe34 100644 --- a/studio/backend/utils/llama_cpp_update.py +++ b/studio/backend/utils/llama_cpp_update.py @@ -406,10 +406,13 @@ def _run_update(install_dir: Path, repo: str, asset: Optional[str], script: Path tail = "".join(tail_lines).strip()[-1500:] raise RuntimeError(f"installer exited {returncode}: {tail or 'no output'}") - # New UNSLOTH_PREBUILT_INFO.json is on disk; drop in-memory caches and - # re-prime the 24h disk freshness cache with the true newest, so the - # banner can't linger on a stale same-base value after the swap. - reset_caches() + # New UNSLOTH_PREBUILT_INFO.json is on disk; drop the in-memory AND the + # on-disk freshness caches, then re-prime the 24h disk cache with the + # true newest, so the banner can't linger on a stale same-base value + # after the swap. drop_disk matters when the refresh below can't reach + # GitHub: without it, latest_published_release would replay the stale + # disk value; with it, latest reads as None and the banner fails open. + reset_caches(drop_disk = True) try: latest_published_release(repo, force_refresh = True) except Exception as exc: # pragma: no cover - network defensive From e0d6674ff6f19498f6825f2ff2c38e8091674fd2 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 12 Jun 2026 02:54:26 -0700 Subject: [PATCH 05/48] Add RAG runtime deps to no-torch-runtime.txt (#6236) The --local / GGUF-only install resolves its Python deps from no-torch-runtime.txt, installed with --no-deps. That file was missing the RAG group that studio.txt declares (sqlite-vec, pymupdf, python-docx), so a fresh `unsloth studio` came up with RAG disabled: rag_db.py cannot import sqlite_vec and logs "RAG unavailable: sqlite-vec extension could not be loaded", and the knowledge-base routes return 503. python-docx was also absent, so DOCX ingestion failed. Add the three RAG store and document-parsing deps with the same pins as studio.txt so knowledge bases work out of the box on the no-torch path. sentence-transformers (dense embeddings) was already present. --- studio/backend/requirements/no-torch-runtime.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/studio/backend/requirements/no-torch-runtime.txt b/studio/backend/requirements/no-torch-runtime.txt index 85294114b1..6efe91d448 100644 --- a/studio/backend/requirements/no-torch-runtime.txt +++ b/studio/backend/requirements/no-torch-runtime.txt @@ -68,3 +68,9 @@ trl>=0.18.2,!=0.19.0,<=0.24.0 sentence-transformers cut_cross_entropy pillow + +# RAG store + document parsing, mirroring studio.txt. Pinned here because +# this file installs --no-deps; without them Studio runs with RAG disabled. +sqlite-vec==0.1.9 +pymupdf==1.27.2.3 +python-docx==1.2.0 From 068b2c120fa389f93e3027def6f44bd4cc98f39e Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Fri, 12 Jun 2026 03:03:24 -0700 Subject: [PATCH 06/48] Studio: rounded rectangle hover states for menu items instead of pills (#6210) * Studio: use rounded rectangles for menu item hover states instead of pills Dropdown, select, and model picker items previously used fully rounded pill highlights. Switch them to an 11px rounded rectangle so hover and selected states match across the plus menu, profile menu, run settings, selects, and the model picker. Also add a small side gutter to the plus menu so item highlights sit slightly inset from the menu edge. * Studio: concentric menu corners, wider gutters, single-item pill menus Container radius now equals the item hover radius plus the side gutter (12px + 10px = 22px) so the curves run parallel. Menus with a single item render as fully rounded pills. The profile menu gets the same gutter and hover radius. Model picker rows go back to their original fully rounded hover. --- .../frontend/src/components/app-sidebar.tsx | 2 +- .../src/components/ui/dropdown-menu.tsx | 8 ++-- studio/frontend/src/components/ui/select.tsx | 2 +- studio/frontend/src/index.css | 40 +++++++++++++------ 4 files changed, 34 insertions(+), 18 deletions(-) diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index f1a200b09b..faa98abeea 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -1099,7 +1099,7 @@ export function AppSidebar() { side="top" align="center" sideOffset={8} - className="app-user-menu menu-soft-surface-up ring-0 w-[16rem] px-1.5 py-2.5 font-heading rounded-[20px] border-0" + className="app-user-menu menu-soft-surface-up ring-0 w-[16rem] px-2.5 py-2.5 font-heading rounded-[20px] border-0" > :nth-child(2))) { + border-radius: 9999px !important; + } + .unsloth-plus-menu[data-slot]:not(:has(> :nth-child(2))) + :is([data-slot="dropdown-menu-item"], [data-slot="dropdown-menu-sub-trigger"]) { + border-radius: 9999px; } .dark .unsloth-plus-menu[data-slot] { @@ -1319,14 +1334,15 @@ [data-slot="dropdown-menu-item"], [data-slot="dropdown-menu-sub-trigger"] ) { - @apply gap-3 pl-4 pr-3 py-2 text-[14px]; + @apply gap-3 pl-3 pr-3 py-2 text-[14px]; cursor: pointer; - /* Pin hover-box radius so dark matches light (same as the container). */ - border-radius: 1.1rem; + /* Pin hover-box radius so dark matches light (container radius minus the + side gutter keeps the curves concentric). */ + border-radius: 12px; } .unsloth-plus-menu [data-slot="dropdown-menu-label"] { - @apply pl-4 pr-3 py-1.5 text-[12px]; + @apply pl-3 pr-3 py-1.5 text-[12px]; } /* Active (green) items keep their primary text and icon color on hover. */ @@ -1370,8 +1386,8 @@ [data-slot="dropdown-menu-sub-trigger"] ) svg { - width: 1.05rem; - height: 1.05rem; + width: 1.15rem; + height: 1.15rem; } /* Destructive items keep red text and a red-tinted hover, not the grey one. */ From c773d45a2ead76167c5354154fbe78f02462ad27 Mon Sep 17 00:00:00 2001 From: Agnibha Mukherjee Date: Fri, 12 Jun 2026 15:37:04 +0530 Subject: [PATCH 07/48] docs: repository cleanup (#5617) * docs: small repository cleanup * docs: improve contribution guidelines --------- Co-authored-by: Agnibha007 Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> --- CONTRIBUTING.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eb60a5a201..6eb8d1bc6e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,3 +27,9 @@ Your support extends beyond code: Finally, please be mindful of our [Code of Conduct](https://github.com/unslothai/unsloth/blob/main/CODE_OF_CONDUCT.md) to ensure a welcoming and inclusive environment for everyone. Thank you so much for reading and we hope you have lots of fun using Unsloth! 🦥 + + +## Pull Request Guidelines +- Keep PRs focused on a single change +- Include a concise description and motivation +- Link related issues when applicable From 36ea9a9196938fcc18a5690ab0687295a1715669 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 12 Jun 2026 03:40:50 -0700 Subject: [PATCH 08/48] Run cross-platform parity test on Windows and macOS in CI (#6241) --- .../workflows/cross-platform-parity-ci.yml | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .github/workflows/cross-platform-parity-ci.yml diff --git a/.github/workflows/cross-platform-parity-ci.yml b/.github/workflows/cross-platform-parity-ci.yml new file mode 100644 index 0000000000..4632794587 --- /dev/null +++ b/.github/workflows/cross-platform-parity-ci.yml @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +# Runs tests/python/test_cross_platform_parity.py on Windows and macOS. +# +# Why: that test is the guard that install.sh and install.ps1 stay in +# sync, but today it only runs on ubuntu-latest (auto-discovered by +# studio-backend-ci.yml's "Repo tests (CPU)" job). The test reads both +# installer scripts, and on Windows Path.read_text() defaults to the +# cp1252 locale encoding, so a non-cp1252 byte in install.sh (it already +# contains a U+274C) raises UnicodeDecodeError there even though Linux and +# macOS default to UTF-8. The reads were pinned to encoding="utf-8" in +# #6166; this job keeps that from silently regressing by exercising the +# test on the platforms it claims parity for. Pure pytest, no GPU, +# sub-second, so the matrix is cheap. + +name: Cross-platform parity + +on: + pull_request: + paths: + - 'install.sh' + - 'install.ps1' + - 'tests/python/test_cross_platform_parity.py' + - '.github/workflows/cross-platform-parity-ci.yml' + push: + branches: [main] + paths: + - 'install.sh' + - 'install.ps1' + - 'tests/python/test_cross_platform_parity.py' + - '.github/workflows/cross-platform-parity-ci.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + parity: + name: parity (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.12' + cache: 'pip' + - run: python -m pip install -U pip pytest + - name: Cross-platform parity test + run: python -m pytest tests/python/test_cross_platform_parity.py -q From 6d206b488c46d8407336c7f762cd72ed3b9b687b Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 12 Jun 2026 03:51:59 -0700 Subject: [PATCH 09/48] chore(studio/frontend): normalize line endings to LF (#6012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(studio/frontend): normalize line endings to LF 45 source files under studio/frontend/ were committed with CRLF or mixed line endings while the rest of the repo and the JS/TS tooling assume LF. Add a scoped `studio/frontend/** text=auto eol=lf` rule to .gitattributes and run `git add --renormalize studio/frontend` so these files are stored with LF in the index. The rule is scoped to the frontend tree (not a repo-wide *.ts/*.tsx/... policy) so it cannot force LF on files elsewhere; text=auto leaves binary assets (logos, fonts) untouched. This commit is whitespace-only (CRLF -> LF) — no source content changed (verified with `git diff --ignore-cr-at-eol`). It is intentionally isolated so it can be listed in .git-blame-ignore-revs and skipped by reviewers and `git blame`. Co-Authored-By: Claude Opus 4.8 * chore: ignore the frontend LF-normalization commit in git blame Add .git-blame-ignore-revs listing the whitespace-only line-ending normalization commit so it doesn't pollute `git blame` output. GitHub applies this file automatically; locally run `git config blame.ignoreRevsFile .git-blame-ignore-revs`. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .git-blame-ignore-revs | 8 + .gitattributes | 6 + studio/frontend/index.html | 26 +- .../frontend/public/hub/profile/logo/meta.svg | 36 +- .../public/provider-logos/misc/meta.svg | 36 +- .../frontend/src/components/ui/accordion.tsx | 190 ++--- .../src/components/ui/alert-dialog.tsx | 368 ++++----- studio/frontend/src/components/ui/alert.tsx | 152 ++-- .../src/components/ui/animated-shiny-text.tsx | 76 +- .../src/components/ui/aspect-ratio.tsx | 18 +- studio/frontend/src/components/ui/avatar.tsx | 220 ++--- studio/frontend/src/components/ui/badge.tsx | 102 +-- .../frontend/src/components/ui/breadcrumb.tsx | 246 +++--- .../frontend/src/components/ui/calendar.tsx | 468 +++++------ studio/frontend/src/components/ui/card.tsx | 200 ++--- studio/frontend/src/components/ui/chart.tsx | 718 ++++++++-------- .../frontend/src/components/ui/checkbox.tsx | 62 +- .../frontend/src/components/ui/combobox.tsx | 764 +++++++++--------- studio/frontend/src/components/ui/command.tsx | 414 +++++----- .../src/components/ui/context-menu.tsx | 528 ++++++------ .../src/components/ui/dropdown-menu.tsx | 558 ++++++------- studio/frontend/src/components/ui/field.tsx | 472 +++++------ .../frontend/src/components/ui/hover-card.tsx | 90 +-- .../src/components/ui/input-group.tsx | 306 +++---- studio/frontend/src/components/ui/input.tsx | 38 +- studio/frontend/src/components/ui/label.tsx | 48 +- .../frontend/src/components/ui/light-rays.tsx | 286 +++---- studio/frontend/src/components/ui/menubar.tsx | 562 ++++++------- .../src/components/ui/navigation-menu.tsx | 348 ++++---- .../frontend/src/components/ui/pagination.tsx | 276 +++---- studio/frontend/src/components/ui/popover.tsx | 184 ++--- .../frontend/src/components/ui/progress.tsx | 74 +- .../src/components/ui/radio-group.tsx | 96 +-- .../src/components/ui/scroll-area.tsx | 110 +-- .../frontend/src/components/ui/separator.tsx | 52 +- .../frontend/src/components/ui/skeleton.tsx | 26 +- studio/frontend/src/components/ui/sonner.tsx | 168 ++-- .../src/components/ui/sparkles-text.tsx | 308 +++---- studio/frontend/src/components/ui/switch.tsx | 62 +- studio/frontend/src/components/ui/table.tsx | 228 +++--- studio/frontend/src/components/ui/tabs.tsx | 268 +++--- .../frontend/src/components/ui/textarea.tsx | 10 +- .../src/components/ui/toggle-group.tsx | 178 ++-- studio/frontend/src/components/ui/toggle.tsx | 92 +-- .../inline/inline-category-badges.tsx | 150 ++-- studio/frontend/tsconfig.app.json | 62 +- studio/frontend/tsconfig.json | 26 +- studio/frontend/tsconfig.node.json | 52 +- 48 files changed, 4891 insertions(+), 4877 deletions(-) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000000..17d96cd0f5 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,8 @@ +# Commits listed here are skipped by `git blame` so that bulk, whitespace-only +# changes don't obscure the real authorship of a line. +# +# GitHub honors this file automatically. To use it locally, run once: +# git config blame.ignoreRevsFile .git-blame-ignore-revs + +# chore(studio/frontend): normalize line endings to LF +c50b8ab910f5aa56dd7ae0022d2c7b96bfe3384a diff --git a/.gitattributes b/.gitattributes index 75fba5d6ab..5f04b5e9d1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,9 @@ # clone (core.autocrlf=true) rewrites them to CRLF, and the trailing \r breaks # them when run in WSL/Linux (e.g. `set -e` -> "set: Illegal option -"). *.sh text eol=lf + +# Normalize Studio frontend sources to LF. Scoped to the frontend tree (rather +# than repo-wide *.ts/*.tsx/... rules) so the policy can't force LF on files +# elsewhere. text=auto lets Git detect and leave binary assets (logos, fonts) +# untouched while text files (.ts/.tsx/.json/.html/.svg/...) are stored as LF. +studio/frontend/** text=auto eol=lf diff --git a/studio/frontend/index.html b/studio/frontend/index.html index 4f81ffd4ff..0fbb4eaeeb 100644 --- a/studio/frontend/index.html +++ b/studio/frontend/index.html @@ -1,16 +1,16 @@ - + - - - - - - Unsloth Studio - - -
- - - + + + + + + Unsloth Studio + + +
+ + + diff --git a/studio/frontend/public/hub/profile/logo/meta.svg b/studio/frontend/public/hub/profile/logo/meta.svg index 9fa656bd6b..fe3709aeea 100644 --- a/studio/frontend/public/hub/profile/logo/meta.svg +++ b/studio/frontend/public/hub/profile/logo/meta.svg @@ -1,19 +1,19 @@ - - -Logo of Meta Platforms -- Graphic created by Detmar Owen - - - - - - - - - - - - - - - + + +Logo of Meta Platforms -- Graphic created by Detmar Owen + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/studio/frontend/public/provider-logos/misc/meta.svg b/studio/frontend/public/provider-logos/misc/meta.svg index 9fa656bd6b..fe3709aeea 100644 --- a/studio/frontend/public/provider-logos/misc/meta.svg +++ b/studio/frontend/public/provider-logos/misc/meta.svg @@ -1,19 +1,19 @@ - - -Logo of Meta Platforms -- Graphic created by Detmar Owen - - - - - - - - - - - - - - - + + +Logo of Meta Platforms -- Graphic created by Detmar Owen + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/studio/frontend/src/components/ui/accordion.tsx b/studio/frontend/src/components/ui/accordion.tsx index 7754c78a11..35de233858 100644 --- a/studio/frontend/src/components/ui/accordion.tsx +++ b/studio/frontend/src/components/ui/accordion.tsx @@ -1,98 +1,98 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -"use client"; - -import { Accordion as AccordionPrimitive } from "radix-ui"; -import type * as React from "react"; - -import { cn } from "@/lib/utils"; -import { ArrowDown01Icon, ArrowUp01Icon } from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; - -function Accordion({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function AccordionItem({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function AccordionTrigger({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - - - {children} - - - - - ); -} - -function AccordionContent({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - -
- {children} -
- - ); -} - -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; +"use client"; + +import { Accordion as AccordionPrimitive } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; +import { ArrowDown01Icon, ArrowUp01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; + +function Accordion({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ); +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
+ {children} +
+
+ ); +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/studio/frontend/src/components/ui/alert-dialog.tsx b/studio/frontend/src/components/ui/alert-dialog.tsx index f5c1dbacca..97f4be7f44 100644 --- a/studio/frontend/src/components/ui/alert-dialog.tsx +++ b/studio/frontend/src/components/ui/alert-dialog.tsx @@ -1,50 +1,50 @@ // 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 { AlertDialog as AlertDialogPrimitive } from "radix-ui"; -import type * as React from "react"; - -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; - -function AlertDialog({ - ...props -}: React.ComponentProps) { - return ; -} - -function AlertDialogTrigger({ - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function AlertDialogPortal({ - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function AlertDialogOverlay({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - +import { AlertDialog as AlertDialogPrimitive } from "radix-ui"; +import type * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return ; +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + function AlertDialogContent({ className, size = "default", @@ -60,143 +60,143 @@ function AlertDialogContent({ - - ); -} - -function AlertDialogHeader({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
- ); -} - -function AlertDialogFooter({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
- ); -} - -function AlertDialogMedia({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
- ); -} - -function AlertDialogTitle({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function AlertDialogDescription({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function AlertDialogAction({ - className, - variant = "default", - size = "default", - ...props -}: React.ComponentProps & - Pick, "variant" | "size">) { - return ( - - ); -} - -function AlertDialogCancel({ - className, - variant = "outline", - size = "default", - ...props -}: React.ComponentProps & - Pick, "variant" | "size">) { - return ( - - ); -} - -export { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogMedia, - AlertDialogOverlay, - AlertDialogPortal, - AlertDialogTitle, - AlertDialogTrigger, -}; + className={cn( + "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/5 gap-6 rounded-4xl p-6 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-md group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none", + className, + )} + {...props} + /> + + ); +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogAction({ + className, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ); +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ); +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +}; diff --git a/studio/frontend/src/components/ui/alert.tsx b/studio/frontend/src/components/ui/alert.tsx index a4a5f4c4b7..094b607d9a 100644 --- a/studio/frontend/src/components/ui/alert.tsx +++ b/studio/frontend/src/components/ui/alert.tsx @@ -1,79 +1,79 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -import { type VariantProps, cva } from "class-variance-authority"; -import type * as React from "react"; - -import { cn } from "@/lib/utils"; - -const alertVariants = cva( - "grid gap-0.5 rounded-lg border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4 w-full relative group/alert", - { - variants: { - variant: { - default: "bg-card text-card-foreground", - destructive: - "text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", - }, - }, - defaultVariants: { - variant: "default", - }, - }, -); - -function Alert({ - className, - variant, - ...props -}: React.ComponentProps<"div"> & VariantProps) { - return ( -
- ); -} - -function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { - return ( -
svg]/alert:col-start-2 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3", - className, - )} - {...props} - /> - ); -} - -function AlertDescription({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
- ); -} - -function AlertAction({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -export { Alert, AlertTitle, AlertDescription, AlertAction }; +import { type VariantProps, cva } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "grid gap-0.5 rounded-lg border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4 w-full relative group/alert", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
svg]/alert:col-start-2 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3", + className, + )} + {...props} + /> + ); +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Alert, AlertTitle, AlertDescription, AlertAction }; diff --git a/studio/frontend/src/components/ui/animated-shiny-text.tsx b/studio/frontend/src/components/ui/animated-shiny-text.tsx index 4c650f1003..8d366ca3d6 100644 --- a/studio/frontend/src/components/ui/animated-shiny-text.tsx +++ b/studio/frontend/src/components/ui/animated-shiny-text.tsx @@ -1,41 +1,41 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -import type { ComponentPropsWithoutRef, CSSProperties, FC } from "react" - -import { cn } from "@/lib/utils" - -export interface AnimatedShinyTextProps extends ComponentPropsWithoutRef<"span"> { - shimmerWidth?: number -} - -export const AnimatedShinyText: FC = ({ - children, - className, - shimmerWidth = 100, - ...props -}) => { - return ( - - {children} - - ) -} +import type { ComponentPropsWithoutRef, CSSProperties, FC } from "react" + +import { cn } from "@/lib/utils" + +export interface AnimatedShinyTextProps extends ComponentPropsWithoutRef<"span"> { + shimmerWidth?: number +} + +export const AnimatedShinyText: FC = ({ + children, + className, + shimmerWidth = 100, + ...props +}) => { + return ( + + {children} + + ) +} diff --git a/studio/frontend/src/components/ui/aspect-ratio.tsx b/studio/frontend/src/components/ui/aspect-ratio.tsx index cb605f01eb..2471f4333d 100644 --- a/studio/frontend/src/components/ui/aspect-ratio.tsx +++ b/studio/frontend/src/components/ui/aspect-ratio.tsx @@ -1,12 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -import { AspectRatio as AspectRatioPrimitive } from "radix-ui"; - -function AspectRatio({ - ...props -}: React.ComponentProps) { - return ; -} - -export { AspectRatio }; +import { AspectRatio as AspectRatioPrimitive } from "radix-ui"; + +function AspectRatio({ + ...props +}: React.ComponentProps) { + return ; +} + +export { AspectRatio }; diff --git a/studio/frontend/src/components/ui/avatar.tsx b/studio/frontend/src/components/ui/avatar.tsx index 2250bb849a..31262b32f7 100644 --- a/studio/frontend/src/components/ui/avatar.tsx +++ b/studio/frontend/src/components/ui/avatar.tsx @@ -1,113 +1,113 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -import { Avatar as AvatarPrimitive } from "radix-ui"; -import type * as React from "react"; - -import { cn } from "@/lib/utils"; - -function Avatar({ - className, - size = "default", - ...props -}: React.ComponentProps & { - size?: "default" | "sm" | "lg"; -}) { - return ( - - ); -} - -function AvatarImage({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function AvatarFallback({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { - return ( - svg]:hidden", - "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", - "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", - className, - )} - {...props} - /> - ); -} - -function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -function AvatarGroupCount({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2", - className, - )} - {...props} - /> - ); -} - -export { - Avatar, - AvatarImage, - AvatarFallback, - AvatarGroup, - AvatarGroupCount, - AvatarBadge, -}; +import { Avatar as AvatarPrimitive } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" | "lg"; +}) { + return ( + + ); +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className, + )} + {...props} + /> + ); +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2", + className, + )} + {...props} + /> + ); +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarBadge, +}; diff --git a/studio/frontend/src/components/ui/badge.tsx b/studio/frontend/src/components/ui/badge.tsx index 3951ae9de0..0f2f334986 100644 --- a/studio/frontend/src/components/ui/badge.tsx +++ b/studio/frontend/src/components/ui/badge.tsx @@ -1,54 +1,54 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -/* eslint-disable react-refresh/only-export-components */ - -import { type VariantProps, cva } from "class-variance-authority"; -import { Slot } from "radix-ui"; -import type * as React from "react"; - -import { cn } from "@/lib/utils"; - -export const badgeVariants = cva( - "h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", - secondary: - "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", - destructive: - "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20", - outline: - "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground bg-input/30", - ghost: - "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", - link: "text-primary underline-offset-4 hover:underline", - }, - }, - defaultVariants: { - variant: "default", - }, - }, -); - -export function Badge({ - className, - variant = "default", - asChild = false, - ...props -}: React.ComponentProps<"span"> & - VariantProps & { - asChild?: boolean; - }): React.ReactElement { - const Comp = asChild ? Slot.Root : "span"; - - return ( - - ); -} +/* eslint-disable react-refresh/only-export-components */ + +import { type VariantProps, cva } from "class-variance-authority"; +import { Slot } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +export const badgeVariants = cva( + "h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground bg-input/30", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { + asChild?: boolean; + }): React.ReactElement { + const Comp = asChild ? Slot.Root : "span"; + + return ( + + ); +} diff --git a/studio/frontend/src/components/ui/breadcrumb.tsx b/studio/frontend/src/components/ui/breadcrumb.tsx index dc026994ce..a2dad8783f 100644 --- a/studio/frontend/src/components/ui/breadcrumb.tsx +++ b/studio/frontend/src/components/ui/breadcrumb.tsx @@ -1,126 +1,126 @@ // 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 { Slot } from "radix-ui"; -import type * as React from "react"; - -import { cn } from "@/lib/utils"; -import { - ArrowRight01Icon, - MoreHorizontalCircle01Icon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; - -function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) { - return ( -