diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 25eeaedd3c..f3b5987a9c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.7 + rev: v0.15.8 hooks: - id: ruff args: diff --git a/README.md b/README.md index b392ed145f..26a578656c 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ unsloth studio -H 0.0.0.0 -p 8888 ``` #### Update +To update, use the same install commands as above. Or run (does not work on Windows): ```bash unsloth studio update ``` @@ -152,7 +153,7 @@ unsloth studio -H 0.0.0.0 -p 8888 ``` Then to update : ```bash -unsloth studio update --local +unsloth studio update ``` #### Developer installs: Windows PowerShell: @@ -165,7 +166,7 @@ unsloth studio -H 0.0.0.0 -p 8888 ``` Then to update : ```bash -unsloth studio update --local +unsloth studio update ``` #### Nightly: MacOS, Linux, WSL: diff --git a/install.ps1 b/install.ps1 index f1f16d818d..0c36046195 100644 --- a/install.ps1 +++ b/install.ps1 @@ -6,6 +6,7 @@ function Install-UnslothStudio { $ErrorActionPreference = "Stop" + $script:UnslothVerbose = ($env:UNSLOTH_VERBOSE -eq "1") # ── Parse flags ── $StudioLocalInstall = $false @@ -17,6 +18,8 @@ function Install-UnslothStudio { switch ($argList[$i]) { "--local" { $StudioLocalInstall = $true } "--no-torch" { $SkipTorch = $true } + "--verbose" { $script:UnslothVerbose = $true } + "-v" { $script:UnslothVerbose = $true } "--package" { $i++ if ($i -ge $argList.Count) { @@ -27,6 +30,12 @@ function Install-UnslothStudio { } } } + # Propagate to child processes so they also respect verbose mode. + # Process-scoped -- does not persist. + if ($script:UnslothVerbose) { + $env:UNSLOTH_VERBOSE = '1' + } + if ($StudioLocalInstall) { $RepoRoot = (Resolve-Path (Split-Path -Parent $PSCommandPath)).Path if (-not (Test-Path (Join-Path $RepoRoot "pyproject.toml"))) { @@ -39,10 +48,55 @@ function Install-UnslothStudio { $StudioHome = Join-Path $env:USERPROFILE ".unsloth\studio" $VenvDir = Join-Path $StudioHome "unsloth_studio" + $Rule = [string]::new([char]0x2500, 52) + $Sloth = [char]::ConvertFromUtf32(0x1F9A5) + + function Enable-StudioVirtualTerminal { + if ($env:NO_COLOR) { return $false } + try { + if (-not ("StudioVT.Native" -as [type])) { + Add-Type -Namespace StudioVT -Name Native -MemberDefinition @' +[DllImport("kernel32.dll")] public static extern IntPtr GetStdHandle(int nStdHandle); +[DllImport("kernel32.dll")] public static extern bool GetConsoleMode(IntPtr h, out uint m); +[DllImport("kernel32.dll")] public static extern bool SetConsoleMode(IntPtr h, uint m); +'@ -ErrorAction Stop + } + $h = [StudioVT.Native]::GetStdHandle(-11) + [uint32]$mode = 0 + if (-not [StudioVT.Native]::GetConsoleMode($h, [ref]$mode)) { return $false } + $mode = $mode -bor 0x0004 + return [StudioVT.Native]::SetConsoleMode($h, $mode) + } catch { + return $false + } + } + $script:StudioVtOk = Enable-StudioVirtualTerminal + + function Get-StudioAnsi { + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Title', 'Dim', 'Ok', 'Warn', 'Err', 'Reset')] + [string]$Kind + ) + $e = [char]27 + switch ($Kind) { + 'Title' { return "${e}[38;5;150m" } + 'Dim' { return "${e}[38;5;245m" } + 'Ok' { return "${e}[38;5;108m" } + 'Warn' { return "${e}[38;5;136m" } + 'Err' { return "${e}[91m" } + 'Reset' { return "${e}[0m" } + } + } + Write-Host "" - Write-Host "=========================================" - Write-Host " Unsloth Studio Installer (Windows)" - Write-Host "=========================================" + if ($script:StudioVtOk -and -not $env:NO_COLOR) { + Write-Host (" " + (Get-StudioAnsi Title) + $Sloth + " Unsloth Studio Installer (Windows)" + (Get-StudioAnsi Reset)) + Write-Host (" {0}{1}{2}" -f (Get-StudioAnsi Dim), $Rule, (Get-StudioAnsi Reset)) + } else { + Write-Host (" {0} Unsloth Studio Installer (Windows)" -f $Sloth) -ForegroundColor DarkGreen + Write-Host " $Rule" -ForegroundColor DarkGray + } Write-Host "" # ── Helper: refresh PATH from registry (deduplicating entries) ── @@ -62,13 +116,96 @@ function Install-UnslothStudio { $env:Path = $unique -join ";" } + function step { + param( + [Parameter(Mandatory = $true)][string]$Label, + [Parameter(Mandatory = $true)][string]$Value, + [string]$Color = "Green" + ) + if ($script:StudioVtOk -and -not $env:NO_COLOR) { + $dim = Get-StudioAnsi Dim + $rst = Get-StudioAnsi Reset + $val = switch ($Color) { + 'Green' { Get-StudioAnsi Ok } + 'Yellow' { Get-StudioAnsi Warn } + 'Red' { Get-StudioAnsi Err } + 'DarkGray' { Get-StudioAnsi Dim } + default { Get-StudioAnsi Ok } + } + $padded = if ($Label.Length -ge 15) { $Label.Substring(0, 15) } else { $Label.PadRight(15) } + Write-Host (" {0}{1}{2}{3}{4}{2}" -f $dim, $padded, $rst, $val, $Value) + } else { + $padded = if ($Label.Length -ge 15) { $Label.Substring(0, 15) } else { $Label.PadRight(15) } + Write-Host (" {0}" -f $padded) -NoNewline -ForegroundColor DarkGray + $fc = switch ($Color) { + 'Green' { 'DarkGreen' } + 'Yellow' { 'Yellow' } + 'Red' { 'Red' } + 'DarkGray' { 'DarkGray' } + default { 'DarkGreen' } + } + Write-Host $Value -ForegroundColor $fc + } + } + + function substep { + param( + [Parameter(Mandatory = $true)][string]$Message, + [string]$Color = "DarkGray" + ) + if ($script:StudioVtOk -and -not $env:NO_COLOR) { + $msgCol = switch ($Color) { + 'Yellow' { (Get-StudioAnsi Warn) } + 'Red' { (Get-StudioAnsi Err) } + default { (Get-StudioAnsi Dim) } + } + $pad = "".PadRight(15) + Write-Host (" {0}{1}{2}{3}" -f $msgCol, $pad, $Message, (Get-StudioAnsi Reset)) + } else { + $fc = switch ($Color) { + 'Yellow' { 'Yellow' } + 'Red' { 'Red' } + default { 'DarkGray' } + } + Write-Host (" {0,-15}{1}" -f "", $Message) -ForegroundColor $fc + } + } + + # Run native commands quietly by default to match install.sh behavior. + # Full command output is shown only when --verbose / UNSLOTH_VERBOSE=1. + function Invoke-InstallCommand { + param( + [Parameter(Mandatory = $true)][ScriptBlock]$Command + ) + $prevEap = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + # Reset to avoid stale values from prior native commands. + $global:LASTEXITCODE = 0 + if ($script:UnslothVerbose) { + # Merge stderr into stdout so progress/warning output stays visible + # without flipping $? on successful native commands (PS 5.1 treats + # stderr records as errors that set $? = $false even on exit code 0). + & $Command 2>&1 | Out-Host + } else { + $output = & $Command 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + Write-Host $output -ForegroundColor Red + } + } + return [int]$LASTEXITCODE + } finally { + $ErrorActionPreference = $prevEap + } + } + function New-StudioShortcuts { param( [Parameter(Mandatory = $true)][string]$UnslothExePath ) if (-not (Test-Path $UnslothExePath)) { - Write-Host "[WARN] Cannot create shortcuts: unsloth.exe not found at $UnslothExePath" -ForegroundColor Yellow + substep "cannot create shortcuts, unsloth.exe not found at $UnslothExePath" "Yellow" return } try { @@ -81,7 +218,7 @@ function Install-UnslothStudio { $localAppDataDir = $env:LOCALAPPDATA if (-not $localAppDataDir -or [string]::IsNullOrWhiteSpace($localAppDataDir)) { - Write-Host "[WARN] LOCALAPPDATA path unavailable; skipped shortcut creation" -ForegroundColor Yellow + substep "LOCALAPPDATA path unavailable; skipped shortcut creation" "Yellow" return } $appDir = Join-Path $localAppDataDir "Unsloth Studio" @@ -104,10 +241,10 @@ function Install-UnslothStudio { $null } if (-not $desktopLink) { - Write-Host "[WARN] Desktop path unavailable; skipped desktop shortcut creation" -ForegroundColor Yellow + substep "Desktop path unavailable; skipped desktop shortcut creation" "Yellow" } if (-not $startMenuLink) { - Write-Host "[WARN] APPDATA/Start Menu path unavailable; skipped Start menu shortcut creation" -ForegroundColor Yellow + substep "APPDATA/Start Menu path unavailable; skipped Start menu shortcut creation" "Yellow" } $iconPath = Join-Path $appDir "unsloth.ico" $bundledIcon = $null @@ -362,27 +499,27 @@ shell.Run cmd, 0, False $shortcut.Save() $createdShortcutCount++ } catch { - Write-Host "[WARN] Could not create shortcut at ${linkPath}: $($_.Exception.Message)" -ForegroundColor Yellow + substep "could not create shortcut at ${linkPath}: $($_.Exception.Message)" "Yellow" } } if ($createdShortcutCount -gt 0) { - Write-Host "[OK] Created Unsloth Studio shortcut(s): $createdShortcutCount" -ForegroundColor Green + substep "Created Unsloth Studio shortcut" } else { - Write-Host "[WARN] No Unsloth Studio shortcuts were created" -ForegroundColor Yellow + substep "no Unsloth Studio shortcuts were created" "Yellow" } } catch { - Write-Host "[WARN] Shortcut creation unavailable: $($_.Exception.Message)" -ForegroundColor Yellow + substep "shortcut creation unavailable: $($_.Exception.Message)" "Yellow" } } catch { - Write-Host "[WARN] Shortcut setup failed; skipping shortcuts: $($_.Exception.Message)" -ForegroundColor Yellow + substep "shortcut setup failed; skipping shortcuts: $($_.Exception.Message)" "Yellow" } } # ── Check winget ── if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { - Write-Host "Error: winget is not available." -ForegroundColor Red - Write-Host " Install it from https://aka.ms/getwinget" -ForegroundColor Yellow - Write-Host " or install Python $PythonVersion and uv manually, then re-run." -ForegroundColor Yellow + step "winget" "not available" "Red" + substep "Install it from https://aka.ms/getwinget" "Yellow" + substep "or install Python $PythonVersion and uv manually, then re-run." "Yellow" return } @@ -460,10 +597,10 @@ shell.Run cmd, 0, False # Find-CompatiblePython returns @{ Version = "3.13"; Path = "C:\...\python.exe" } or $null. $DetectedPython = Find-CompatiblePython if ($DetectedPython) { - Write-Host "==> Python already installed: Python $($DetectedPython.Version)" + step "python" "Python $($DetectedPython.Version) already installed" } if (-not $DetectedPython) { - Write-Host "==> Installing Python ${PythonVersion}..." + substep "installing Python ${PythonVersion}..." $pythonPackageId = "Python.Python.$PythonVersion" # Temporarily lower ErrorActionPreference so that winget stderr # (progress bars, warnings) does not become a terminating error @@ -485,7 +622,7 @@ shell.Run cmd, 0, False # This handles both real failures AND "already installed" codes where # winget thinks Python is present but it's not actually on PATH # (e.g. user partially uninstalled, or installed via a different method). - Write-Host " Python not found on PATH after winget. Retrying with --force..." + substep "Python not found on PATH after winget. Retrying with --force..." "Yellow" $ErrorActionPreference = "Continue" try { winget install -e --id $pythonPackageId --accept-package-agreements --accept-source-agreements --force @@ -507,7 +644,7 @@ shell.Run cmd, 0, False # ── Install uv if not present ── if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { - Write-Host "==> Installing uv package manager..." + substep "installing uv package manager..." $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" try { winget install --id=astral-sh.uv -e --accept-package-agreements --accept-source-agreements } catch {} @@ -515,15 +652,15 @@ shell.Run cmd, 0, False Refresh-SessionPath # Fallback: if winget didn't put uv on PATH, try the PowerShell installer if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { - Write-Host " Trying alternative uv installer..." + substep "trying alternative uv installer..." "Yellow" powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" Refresh-SessionPath } } if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { - Write-Host "Error: uv could not be installed." -ForegroundColor Red - Write-Host " Install it from https://docs.astral.sh/uv/" -ForegroundColor Yellow + step "uv" "could not be installed" "Red" + substep "Install it from https://docs.astral.sh/uv/" "Yellow" return } @@ -539,13 +676,13 @@ shell.Run cmd, 0, False if (Test-Path $VenvPython) { # New layout already exists -- nuke for fresh install - Write-Host "==> Removing existing environment for fresh install..." + substep "removing existing environment for fresh install..." Remove-Item -Recurse -Force $VenvDir } elseif (Test-Path (Join-Path $StudioHome ".venv\Scripts\python.exe")) { # Old layout (~/.unsloth/studio/.venv) exists -- validate before migrating $OldVenv = Join-Path $StudioHome ".venv" $OldPy = Join-Path $OldVenv "Scripts\python.exe" - Write-Host "==> Found legacy Studio environment, validating..." + substep "found legacy Studio environment, validating..." $prevEAP2 = $ErrorActionPreference $ErrorActionPreference = "Continue" try { @@ -554,32 +691,34 @@ shell.Run cmd, 0, False } catch { $torchOk = $false } $ErrorActionPreference = $prevEAP2 if ($torchOk) { - Write-Host " Legacy environment is healthy -- migrating..." + substep "legacy environment is healthy -- migrating..." Move-Item -Path $OldVenv -Destination $VenvDir -Force - Write-Host " Moved .venv -> unsloth_studio" + substep "moved .venv -> unsloth_studio" $_Migrated = $true } else { - Write-Host " Legacy environment failed validation -- creating fresh environment" + substep "legacy environment failed validation -- creating fresh environment" "Yellow" Remove-Item -Recurse -Force $OldVenv -ErrorAction SilentlyContinue } } elseif (Test-Path (Join-Path $env:USERPROFILE "unsloth_studio\Scripts\python.exe")) { # CWD-relative venv from old install.ps1 -- migrate to absolute path $CwdVenv = Join-Path $env:USERPROFILE "unsloth_studio" - Write-Host "==> Found CWD-relative Studio environment, migrating to $VenvDir..." + substep "found CWD-relative Studio environment, migrating to $VenvDir..." Move-Item -Path $CwdVenv -Destination $VenvDir -Force - Write-Host " Moved ~/unsloth_studio -> ~/.unsloth/studio/unsloth_studio" + substep "moved ~/unsloth_studio -> ~/.unsloth/studio/unsloth_studio" $_Migrated = $true } if (-not (Test-Path $VenvPython)) { - Write-Host "==> Creating Python $($DetectedPython.Version) virtual environment ($VenvDir)..." - uv venv $VenvDir --python "$($DetectedPython.Path)" - if ($LASTEXITCODE -ne 0) { - Write-Host "[ERROR] Failed to create virtual environment (exit code $LASTEXITCODE)" -ForegroundColor Red + step "venv" "creating Python $($DetectedPython.Version) virtual environment" + substep "$VenvDir" + $venvExit = Invoke-InstallCommand { uv venv $VenvDir --python "$($DetectedPython.Path)" } + if ($venvExit -ne 0) { + Write-Host "[ERROR] Failed to create virtual environment (exit code $venvExit)" -ForegroundColor Red return } } else { - Write-Host "==> Using migrated environment at $VenvDir" + step "venv" "using migrated environment" + substep "$VenvDir" } # ── Detect GPU (robust: PATH + hardcoded fallback paths, mirrors setup.ps1) ── @@ -588,7 +727,7 @@ shell.Run cmd, 0, False try { $nvSmiCmd = Get-Command nvidia-smi -ErrorAction SilentlyContinue if ($nvSmiCmd) { - & $nvSmiCmd.Source 2>&1 | Out-Null + & $nvSmiCmd.Source *> $null if ($LASTEXITCODE -eq 0) { $HasNvidiaSmi = $true; $NvidiaSmiExe = $nvSmiCmd.Source } } } catch {} @@ -599,18 +738,18 @@ shell.Run cmd, 0, False )) { if (Test-Path $p) { try { - & $p 2>&1 | Out-Null + & $p *> $null if ($LASTEXITCODE -eq 0) { $HasNvidiaSmi = $true; $NvidiaSmiExe = $p; break } } catch {} } } } if ($HasNvidiaSmi) { - Write-Host "[OK] NVIDIA GPU detected" -ForegroundColor Green + step "gpu" "NVIDIA GPU detected" } else { - Write-Host "[WARN] No NVIDIA GPU detected. Studio will run in chat-only (GGUF) mode." -ForegroundColor Yellow - Write-Host " Training and GPU inference require an NVIDIA GPU with drivers installed." -ForegroundColor Yellow - Write-Host " https://www.nvidia.com/Download/index.aspx" -ForegroundColor Yellow + step "gpu" "none (chat-only / GGUF)" "Yellow" + substep "Training and GPU inference require an NVIDIA GPU with drivers installed." "Yellow" + substep "https://www.nvidia.com/Download/index.aspx" "Yellow" } # ── Choose the correct PyTorch index URL based on driver CUDA version ── @@ -630,7 +769,7 @@ shell.Run cmd, 0, False return "$baseUrl/cpu" } } catch {} - Write-Host "[WARN] Could not determine CUDA version from nvidia-smi, defaulting to cu126" -ForegroundColor Yellow + substep "could not determine CUDA version from nvidia-smi, defaulting to cu126" "Yellow" return "$baseUrl/cu126" } $TorchIndexUrl = Get-TorchIndexUrl @@ -677,74 +816,101 @@ shell.Run cmd, 0, False if ($_Migrated) { # Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state # in the new venv location, while preserving existing torch/CUDA - Write-Host "==> Upgrading unsloth in migrated environment..." + substep "upgrading unsloth in migrated environment..." if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.16" unsloth-zoo - $NoTorchReq = Find-NoTorchRuntimeFile - if ($NoTorchReq) { - uv pip install --python $VenvPython --no-deps -r $NoTorchReq + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.16" unsloth-zoo } + if ($baseInstallExit -eq 0) { + $NoTorchReq = Find-NoTorchRuntimeFile + if ($NoTorchReq) { + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps -r $NoTorchReq } + } } } else { - uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.16" unsloth-zoo + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.16" unsloth-zoo } + } + if ($baseInstallExit -ne 0) { + Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red + return } if ($StudioLocalInstall) { - Write-Host "==> Overlaying local repo (editable)..." - uv pip install --python $VenvPython -e $RepoRoot --no-deps + substep "overlaying local repo (editable)..." + $overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps } + if ($overlayExit -ne 0) { + Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red + return + } } } elseif ($TorchIndexUrl) { if ($SkipTorch) { - Write-Host "==> Skipping PyTorch (--no-torch flag set)." + substep "skipping PyTorch (--no-torch flag set)." "Yellow" } else { - Write-Host "==> Installing PyTorch ($TorchIndexUrl)..." - uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $TorchIndexUrl - if ($LASTEXITCODE -ne 0) { - Write-Host "[ERROR] Failed to install PyTorch (exit code $LASTEXITCODE)" -ForegroundColor Red + substep "installing PyTorch ($TorchIndexUrl)..." + $torchInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $TorchIndexUrl } + if ($torchInstallExit -ne 0) { + Write-Host "[ERROR] Failed to install PyTorch (exit code $torchInstallExit)" -ForegroundColor Red return } } - Write-Host "==> Installing unsloth (this may take a few minutes)..." + substep "installing unsloth (this may take a few minutes)..." if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.3.16" unsloth-zoo - $NoTorchReq = Find-NoTorchRuntimeFile - if ($NoTorchReq) { - uv pip install --python $VenvPython --no-deps -r $NoTorchReq - } - if ($StudioLocalInstall) { - Write-Host "==> Overlaying local repo (editable)..." - uv pip install --python $VenvPython -e $RepoRoot --no-deps + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.3.16" unsloth-zoo } + if ($baseInstallExit -eq 0) { + $NoTorchReq = Find-NoTorchRuntimeFile + if ($NoTorchReq) { + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps -r $NoTorchReq } + } } } elseif ($StudioLocalInstall) { - uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.3.16" unsloth-zoo - Write-Host "==> Overlaying local repo (editable)..." - uv pip install --python $VenvPython -e $RepoRoot --no-deps + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.3.16" unsloth-zoo } } else { - uv pip install --python $VenvPython --upgrade-package unsloth "$PackageName" + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "$PackageName" } + } + if ($baseInstallExit -ne 0) { + Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red + return + } + + if ($StudioLocalInstall) { + substep "overlaying local repo (editable)..." + $overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps } + if ($overlayExit -ne 0) { + Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red + return + } } } else { # Fallback: GPU detection failed to produce a URL -- let uv resolve torch - Write-Host "==> Installing unsloth (this may take a few minutes)..." + substep "installing unsloth (this may take a few minutes)..." if ($StudioLocalInstall) { - uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.3.16" --torch-backend=auto - Write-Host "==> Overlaying local repo (editable)..." - uv pip install --python $VenvPython -e $RepoRoot --no-deps + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.3.16" --torch-backend=auto } + if ($baseInstallExit -ne 0) { + Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red + return + } + substep "overlaying local repo (editable)..." + $overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps } + if ($overlayExit -ne 0) { + Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red + return + } } else { - uv pip install --python $VenvPython "$PackageName" --torch-backend=auto + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython "$PackageName" --torch-backend=auto } + if ($baseInstallExit -ne 0) { + Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red + return + } } } - if ($LASTEXITCODE -ne 0) { - Write-Host "[ERROR] Failed to install unsloth (exit code $LASTEXITCODE)" -ForegroundColor Red - return - } # ── Run studio setup ── # setup.ps1 will handle installing Git, CMake, Visual Studio Build Tools, # CUDA Toolkit, Node.js, and other dependencies automatically via winget. - Write-Host "==> Running unsloth studio setup..." + step "setup" "running unsloth studio setup..." $UnslothExe = Join-Path $VenvDir "Scripts\unsloth.exe" if (-not (Test-Path $UnslothExe)) { Write-Host "[ERROR] unsloth CLI was not installed correctly." -ForegroundColor Red @@ -754,17 +920,27 @@ shell.Run cmd, 0, False return } # Tell setup.ps1 to skip base package installation (install.ps1 already did it) - # Tell setup.ps1 to skip base package installation (install.ps1 already did it) $env:SKIP_STUDIO_BASE = "1" $env:STUDIO_PACKAGE_NAME = $PackageName $env:UNSLOTH_NO_TORCH = if ($SkipTorch) { "true" } else { "false" } + # Always set STUDIO_LOCAL_INSTALL explicitly to avoid stale values from + # a previous --local run in the same PowerShell session. if ($StudioLocalInstall) { $env:STUDIO_LOCAL_INSTALL = "1" $env:STUDIO_LOCAL_REPO = $RepoRoot + } else { + $env:STUDIO_LOCAL_INSTALL = "0" + Remove-Item Env:STUDIO_LOCAL_REPO -ErrorAction SilentlyContinue } - & $UnslothExe studio setup - if ($LASTEXITCODE -ne 0) { - Write-Host "[ERROR] unsloth studio setup failed (exit code $LASTEXITCODE)" -ForegroundColor Red + # Use 'studio setup' (not 'studio update') because 'update' pops + # SKIP_STUDIO_BASE, which would cause redundant package reinstallation + # and bypass the fast-path version check from PR #4667. + $studioArgs = @('studio', 'setup') + if ($script:UnslothVerbose) { $studioArgs += '--verbose' } + & $UnslothExe @studioArgs + $setupExit = $LASTEXITCODE + if ($setupExit -ne 0) { + Write-Host "[ERROR] unsloth studio setup failed (exit code $setupExit)" -ForegroundColor Red return } @@ -780,27 +956,18 @@ shell.Run cmd, 0, False [System.Environment]::SetEnvironmentVariable("Path", "$ScriptsDir", "User") } Refresh-SessionPath - Write-Host "[OK] Added unsloth to PATH" -ForegroundColor Green + step "path" "added unsloth to PATH" } - Write-Host "" - Write-Host "=========================================" - Write-Host " Unsloth Studio installed!" - Write-Host "=========================================" - Write-Host "" - # Launch studio automatically in interactive terminals; # in non-interactive environments (CI, Docker) just print instructions. $IsInteractive = [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected) if ($IsInteractive) { - Write-Host "==> Launching Unsloth Studio..." - Write-Host "" & $UnslothExe studio -H 0.0.0.0 -p 8888 } else { - Write-Host " To launch, run:" - Write-Host "" - Write-Host " & `"$VenvDir\Scripts\Activate.ps1`"" - Write-Host " unsloth studio -H 0.0.0.0 -p 8888" + step "launch" "manual commands:" + substep "& `"$VenvDir\Scripts\Activate.ps1`"" + substep "unsloth studio -H 0.0.0.0 -p 8888" Write-Host "" } } diff --git a/install.sh b/install.sh index b50256ea5a..9ea80bc161 100755 --- a/install.sh +++ b/install.sh @@ -8,11 +8,36 @@ # Usage (py): ./install.sh --python 3.12 (override auto-detected Python version) set -e +# ── Output style (aligned with studio/setup.sh) ── +RULE="" +_rule_i=0 +while [ "$_rule_i" -lt 52 ]; do + RULE="${RULE}─" + _rule_i=$((_rule_i + 1)) +done +if [ -n "${NO_COLOR:-}" ]; then + C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST= +elif [ -t 1 ] || [ -n "${FORCE_COLOR:-}" ]; then + _ESC="$(printf '\033')" + C_TITLE="${_ESC}[38;5;150m" + C_DIM="${_ESC}[38;5;245m" + C_OK="${_ESC}[38;5;108m" + C_WARN="${_ESC}[38;5;136m" + C_ERR="${_ESC}[91m" + C_RST="${_ESC}[0m" +else + C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST= +fi + +step() { printf " ${C_DIM}%-15.15s${C_RST}${3:-$C_OK}%s${C_RST}\n" "$1" "$2"; } +substep() { printf " ${C_DIM}%-15s${2:-$C_DIM}%s${C_RST}\n" "" "$1"; } + # ── Parse flags ── STUDIO_LOCAL_INSTALL=false PACKAGE_NAME="unsloth" _USER_PYTHON="" _NO_TORCH_FLAG=false +_VERBOSE=false _next_is_package=false _next_is_python=false for arg in "$@"; do @@ -31,9 +56,44 @@ for arg in "$@"; do --package) _next_is_package=true ;; --python) _next_is_python=true ;; --no-torch) _NO_TORCH_FLAG=true ;; + --verbose|-v) _VERBOSE=true ;; esac done +if [ "$_VERBOSE" = true ]; then + export UNSLOTH_VERBOSE=1 +fi + +_is_verbose() { + [ "${UNSLOTH_VERBOSE:-0}" = "1" ] +} + +run_maybe_quiet() { + if _is_verbose; then + "$@" + else + "$@" > /dev/null 2>&1 + fi +} + +run_install_cmd() { + _label="$1" + shift + if _is_verbose; then + "$@" && return 0 + _rc=$? + step "error" "$_label failed (exit code $_rc)" "$C_ERR" >&2 + return "$_rc" + fi + _log=$(mktemp) + "$@" >"$_log" 2>&1 && { rm -f "$_log"; return 0; } + _rc=$? + step "error" "$_label failed (exit code $_rc)" "$C_ERR" >&2 + cat "$_log" >&2 + rm -f "$_log" + return $_rc +} + if [ "$_next_is_package" = true ]; then echo "❌ ERROR: --package requires an argument." >&2 exit 1 @@ -643,14 +703,13 @@ WSLPS1_EOF fi if [ "$_css_created" -eq 1 ]; then - echo "[OK] Created Unsloth Studio shortcut(s)" + substep "Created Unsloth Studio shortcut" fi } echo "" -echo "=========================================" -echo " Unsloth Studio Installer" -echo "=========================================" +printf " ${C_TITLE}%s${C_RST}\n" "🦥 Unsloth Studio Installer" +printf " ${C_DIM}%s${C_RST}\n" "$RULE" echo "" # ── Detect platform ── @@ -660,7 +719,7 @@ if [ "$(uname)" = "Darwin" ]; then elif grep -qi microsoft /proc/version 2>/dev/null; then OS="wsl" fi -echo "==> Platform: $OS" +step "platform" "$OS" # ── Architecture detection & Python version ── _ARCH=$(uname -m) @@ -740,8 +799,8 @@ MISSING=$(echo "$MISSING" | sed 's/^ *//') if [ -n "$MISSING" ]; then echo "" - echo "==> Unsloth Studio needs these packages: $MISSING" - echo " These are needed to build the GGUF inference engine." + step "deps" "missing: $MISSING" "$C_WARN" + substep "These are needed to build the GGUF inference engine." case "$OS" in macos) @@ -766,7 +825,7 @@ if [ -n "$MISSING" ]; then esac echo "" else - echo "==> All system dependencies found." + step "deps" "all system dependencies found" fi # ── Install uv ── @@ -812,10 +871,10 @@ _uv_version_ok() { } if ! command -v uv >/dev/null 2>&1 || ! _uv_version_ok uv; then - echo "==> Installing uv package manager..." + substep "installing uv package manager..." _uv_tmp=$(mktemp) download "https://astral.sh/uv/install.sh" "$_uv_tmp" - sh "$_uv_tmp" Found legacy Studio environment, validating..." + substep "found legacy Studio environment, validating..." if "$STUDIO_HOME/.venv/bin/python" -c " import torch device = 'cuda' if torch.cuda.is_available() else 'cpu' @@ -866,8 +925,9 @@ if [ "$SKIP_TORCH" = true ] && [ "$MAC_INTEL" = true ] && [ -z "$_USER_PYTHON" ] fi if [ ! -x "$VENV_DIR/bin/python" ]; then - echo "==> Creating Python ${PYTHON_VERSION} virtual environment (${VENV_DIR})..." - uv venv "$VENV_DIR" --python "$PYTHON_VERSION" + step "venv" "creating Python ${PYTHON_VERSION} virtual environment" + substep "$VENV_DIR" + run_install_cmd "create venv" uv venv "$VENV_DIR" --python "$PYTHON_VERSION" fi # Guard against Python 3.13.8 torch import bug on Apple Silicon @@ -880,12 +940,13 @@ if [ -z "$_USER_PYTHON" ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then echo " Recreating venv with Python 3.12..." rm -rf "$VENV_DIR" PYTHON_VERSION="3.12" - uv venv "$VENV_DIR" --python "$PYTHON_VERSION" + run_install_cmd "recreate venv" uv venv "$VENV_DIR" --python "$PYTHON_VERSION" fi fi if [ -x "$VENV_DIR/bin/python" ]; then - echo "==> Using environment at ${VENV_DIR}" + step "venv" "using environment" + substep "${VENV_DIR}" fi # ── Resolve repo root (for --local installs) ── @@ -960,71 +1021,71 @@ _VENV_PY="$VENV_DIR/bin/python" if [ "$_MIGRATED" = true ]; then # Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state # in the new venv location, while preserving existing torch/CUDA - echo "==> Upgrading unsloth in migrated environment..." + substep "upgrading unsloth in migrated environment..." if [ "$SKIP_TORCH" = true ]; then # No-torch: install unsloth + unsloth-zoo with --no-deps (current # PyPI metadata still declares torch as a hard dep), then install # runtime deps (typer, safetensors, transformers, etc.) with --no-deps # to prevent transitive torch resolution. - uv pip install --python "$_VENV_PY" --no-deps \ + run_install_cmd "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ "unsloth>=2026.3.16" unsloth-zoo _NO_TORCH_RT="$(_find_no_torch_runtime)" if [ -n "$_NO_TORCH_RT" ]; then - uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT" + run_install_cmd "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT" fi else - uv pip install --python "$_VENV_PY" \ + run_install_cmd "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ "unsloth>=2026.3.16" unsloth-zoo fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then - echo "==> Overlaying local repo (editable)..." - uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps + substep "overlaying local repo (editable)..." + run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps fi elif [ -n "$TORCH_INDEX_URL" ]; then # Fresh: Step 1 - install torch from explicit index (skip when --no-torch or Intel Mac) if [ "$SKIP_TORCH" = true ]; then - echo "==> Skipping PyTorch (--no-torch or Intel Mac x86_64)." + substep "skipping PyTorch (--no-torch or Intel Mac x86_64)." "$C_WARN" else - echo "==> Installing PyTorch ($TORCH_INDEX_URL)..." - uv pip install --python "$_VENV_PY" "torch>=2.4,<2.11.0" torchvision torchaudio \ + substep "installing PyTorch ($TORCH_INDEX_URL)..." + run_install_cmd "install PyTorch" uv pip install --python "$_VENV_PY" "torch>=2.4,<2.11.0" torchvision torchaudio \ --index-url "$TORCH_INDEX_URL" fi # Fresh: Step 2 - install unsloth, preserving pre-installed torch - echo "==> Installing unsloth (this may take a few minutes)..." + substep "installing unsloth (this may take a few minutes)..." if [ "$SKIP_TORCH" = true ]; then # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - uv pip install --python "$_VENV_PY" --no-deps \ + run_install_cmd "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --upgrade-package unsloth --upgrade-package unsloth-zoo \ "unsloth>=2026.3.16" unsloth-zoo _NO_TORCH_RT="$(_find_no_torch_runtime)" if [ -n "$_NO_TORCH_RT" ]; then - uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT" + run_install_cmd "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT" fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then - echo "==> Overlaying local repo (editable)..." - uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps + substep "overlaying local repo (editable)..." + run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps fi elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then - uv pip install --python "$_VENV_PY" \ + run_install_cmd "install unsloth (local)" uv pip install --python "$_VENV_PY" \ --upgrade-package unsloth "unsloth>=2026.3.16" unsloth-zoo - echo "==> Overlaying local repo (editable)..." - uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps + substep "overlaying local repo (editable)..." + run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps else - uv pip install --python "$_VENV_PY" \ + run_install_cmd "install unsloth" uv pip install --python "$_VENV_PY" \ --upgrade-package unsloth "$PACKAGE_NAME" fi else # Fallback: GPU detection failed to produce a URL -- let uv resolve torch - echo "==> Installing unsloth (this may take a few minutes)..." + substep "installing unsloth (this may take a few minutes)..." if [ "$STUDIO_LOCAL_INSTALL" = true ]; then - uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.3.16" --torch-backend=auto - echo "==> Overlaying local repo (editable)..." - uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps + run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.3.16" --torch-backend=auto + substep "overlaying local repo (editable)..." + run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps else - uv pip install --python "$_VENV_PY" "$PACKAGE_NAME" --torch-backend=auto + run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" "$PACKAGE_NAME" --torch-backend=auto fi fi @@ -1059,19 +1120,33 @@ if [ -n "$VENV_ABS_BIN" ]; then export PATH="$VENV_ABS_BIN:$PATH" fi -echo "==> Running unsloth setup..." +if ! command -v bash >/dev/null 2>&1; then + step "setup" "bash is required to run studio setup" "$C_ERR" + substep "Please install bash and re-run install.sh" + exit 1 +fi + +step "setup" "running unsloth studio update..." +# install.sh already installs base packages (unsloth + unsloth-zoo) and +# no-torch-runtime.txt above, so tell install_python_stack.py to skip +# the base step to avoid redundant reinstallation. +_SKIP_BASE=1 +# Run setup.sh outside set -e so that a llama.cpp build failure (exit 1) +# does not skip PATH setup, shortcuts, and launch below. We capture the +# exit code and propagate it after post-install steps finish. +_SETUP_EXIT=0 if [ "$STUDIO_LOCAL_INSTALL" = true ]; then - SKIP_STUDIO_BASE=1 \ + SKIP_STUDIO_BASE="$_SKIP_BASE" \ STUDIO_PACKAGE_NAME="$PACKAGE_NAME" \ STUDIO_LOCAL_INSTALL=1 \ STUDIO_LOCAL_REPO="$_REPO_ROOT" \ UNSLOTH_NO_TORCH="$SKIP_TORCH" \ - bash "$SETUP_SH" > "$_SHELL_PROFILE" echo '# Added by Unsloth installer' >> "$_SHELL_PROFILE" echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$_SHELL_PROFILE" - echo "==> Added ~/.local/bin to PATH in $_SHELL_PROFILE" + step "path" "added ~/.local/bin to PATH in $_SHELL_PROFILE" fi fi export PATH="$_LOCAL_BIN:$PATH" @@ -1105,17 +1180,30 @@ esac create_studio_shortcuts "$VENV_ABS_BIN/unsloth" "$OS" +# If setup.sh failed, report and exit now. +# PATH and shortcuts are already set up so the user can fix and retry. +if [ "$_SETUP_EXIT" -ne 0 ]; then + echo "" + step "error" "studio setup failed (exit code $_SETUP_EXIT)" "$C_ERR" + substep "Check the output above for details, then re-run:" + if [ "$STUDIO_LOCAL_INSTALL" = true ]; then + substep " unsloth studio update --local" + else + substep " unsloth studio update" + fi + echo "" + exit "$_SETUP_EXIT" +fi + echo "" -echo "=========================================" -echo " Unsloth Studio installed!" -echo "=========================================" +printf " ${C_TITLE}%s${C_RST}\n" "Unsloth Studio installed!" +printf " ${C_DIM}%s${C_RST}\n" "$RULE" echo "" # Launch studio automatically in interactive terminals; # in non-interactive environments (Docker, CI, cloud-init) just print instructions. if [ -t 1 ]; then - echo "==> Launching Unsloth Studio..." - echo "" + step "launch" "starting Unsloth Studio..." "$VENV_DIR/bin/unsloth" studio -H 0.0.0.0 -p 8888 _LAUNCH_EXIT=$? if [ "$_LAUNCH_EXIT" -ne 0 ] && [ "$_MIGRATED" = true ]; then @@ -1130,13 +1218,10 @@ if [ -t 1 ]; then fi exit "$_LAUNCH_EXIT" else - echo " To launch, run:" - echo "" - echo " unsloth studio -H 0.0.0.0 -p 8888" - echo "" - echo " Or activate the environment first:" - echo "" - echo " source ${VENV_DIR}/bin/activate" - echo " unsloth studio -H 0.0.0.0 -p 8888" + step "launch" "manual commands:" + substep "unsloth studio -H 0.0.0.0 -p 8888" + substep "or activate env first:" + substep "source ${VENV_DIR}/bin/activate" + substep "unsloth studio -H 0.0.0.0 -p 8888" echo "" fi diff --git a/studio/backend/assets/configs/full_finetune.yaml b/studio/backend/assets/configs/full_finetune.yaml index 7536ed1f11..e398515f61 100644 --- a/studio/backend/assets/configs/full_finetune.yaml +++ b/studio/backend/assets/configs/full_finetune.yaml @@ -10,13 +10,13 @@ training: load_in_4bit: false output_dir: outputs num_epochs: 1 - learning_rate: 0.0002 + learning_rate: 2e-5 batch_size: 1 gradient_accumulation_steps: 4 warmup_steps: 5 max_steps: 0 save_steps: 0 - weight_decay: 0.01 + weight_decay: 0.001 random_seed: 3407 packing: false train_on_completions: false diff --git a/studio/backend/assets/configs/lora_text.yaml b/studio/backend/assets/configs/lora_text.yaml index 7101a00e85..9cb6b8c700 100644 --- a/studio/backend/assets/configs/lora_text.yaml +++ b/studio/backend/assets/configs/lora_text.yaml @@ -16,7 +16,7 @@ training: warmup_steps: 5 max_steps: 0 save_steps: 0 - weight_decay: 0.01 + weight_decay: 0.001 random_seed: 3407 packing: false train_on_completions: false diff --git a/studio/backend/assets/configs/model_defaults/default.yaml b/studio/backend/assets/configs/model_defaults/default.yaml index d96e5077b2..12566019b8 100644 --- a/studio/backend/assets/configs/model_defaults/default.yaml +++ b/studio/backend/assets/configs/model_defaults/default.yaml @@ -6,13 +6,13 @@ training: max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 - learning_rate: 5e-5 + learning_rate: 2e-4 batch_size: 2 gradient_accumulation_steps: 4 warmup_ratio: 0.1 max_steps: 30 save_steps: 30 - weight_decay: 0.01 + weight_decay: 0.001 random_seed: 3407 packing: false train_on_completions: true diff --git a/studio/backend/assets/configs/model_defaults/embedding/unsloth_Qwen3-Embedding-0.6B.yaml b/studio/backend/assets/configs/model_defaults/embedding/unsloth_Qwen3-Embedding-0.6B.yaml index 1219648a8a..f7b49c75b7 100644 --- a/studio/backend/assets/configs/model_defaults/embedding/unsloth_Qwen3-Embedding-0.6B.yaml +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_Qwen3-Embedding-0.6B.yaml @@ -12,7 +12,7 @@ training: warmup_ratio: 0.03 max_steps: 30 save_steps: 30 - weight_decay: 0.01 + weight_decay: 0.001 random_seed: 3407 packing: false train_on_completions: false diff --git a/studio/backend/assets/configs/model_defaults/embedding/unsloth_all-MiniLM-L6-v2.yaml b/studio/backend/assets/configs/model_defaults/embedding/unsloth_all-MiniLM-L6-v2.yaml index db742e11b5..be7da0f624 100644 --- a/studio/backend/assets/configs/model_defaults/embedding/unsloth_all-MiniLM-L6-v2.yaml +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_all-MiniLM-L6-v2.yaml @@ -11,7 +11,7 @@ training: warmup_ratio: 0.03 max_steps: 30 save_steps: 30 - weight_decay: 0.01 + weight_decay: 0.001 random_seed: 3407 packing: false train_on_completions: false diff --git a/studio/backend/assets/configs/model_defaults/embedding/unsloth_bge-m3.yaml b/studio/backend/assets/configs/model_defaults/embedding/unsloth_bge-m3.yaml index 499c112929..d9e49bc0d5 100644 --- a/studio/backend/assets/configs/model_defaults/embedding/unsloth_bge-m3.yaml +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_bge-m3.yaml @@ -11,7 +11,7 @@ training: warmup_ratio: 0.03 max_steps: 30 save_steps: 30 - weight_decay: 0.01 + weight_decay: 0.001 random_seed: 3407 packing: false train_on_completions: false diff --git a/studio/backend/assets/configs/model_defaults/embedding/unsloth_embeddinggemma-300m.yaml b/studio/backend/assets/configs/model_defaults/embedding/unsloth_embeddinggemma-300m.yaml index 016d284a16..c3422d399f 100644 --- a/studio/backend/assets/configs/model_defaults/embedding/unsloth_embeddinggemma-300m.yaml +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_embeddinggemma-300m.yaml @@ -11,7 +11,7 @@ training: warmup_ratio: 0.03 max_steps: 30 save_steps: 30 - weight_decay: 0.01 + weight_decay: 0.001 random_seed: 3407 packing: false train_on_completions: false diff --git a/studio/backend/assets/configs/model_defaults/embedding/unsloth_gte-modernbert-base.yaml b/studio/backend/assets/configs/model_defaults/embedding/unsloth_gte-modernbert-base.yaml index eefaecc8cd..529a56a527 100644 --- a/studio/backend/assets/configs/model_defaults/embedding/unsloth_gte-modernbert-base.yaml +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_gte-modernbert-base.yaml @@ -11,7 +11,7 @@ training: warmup_ratio: 0.03 max_steps: 30 save_steps: 30 - weight_decay: 0.01 + weight_decay: 0.001 random_seed: 3407 packing: false train_on_completions: false diff --git a/studio/backend/assets/configs/model_defaults/falcon/tiiuae_Falcon-H1-0.5B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/falcon/tiiuae_Falcon-H1-0.5B-Instruct.yaml index c45b71b4ae..fa7bd8c1ea 100644 --- a/studio/backend/assets/configs/model_defaults/falcon/tiiuae_Falcon-H1-0.5B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/falcon/tiiuae_Falcon-H1-0.5B-Instruct.yaml @@ -13,7 +13,7 @@ training: warmup_steps: 5 max_steps: 30 save_steps: 30 - weight_decay: 0.01 + weight_decay: 0.001 random_seed: 3407 packing: false train_on_completions: true diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-2b.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-2b.yaml index f8f78f5edc..a4acbe9262 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-2b.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-2b.yaml @@ -13,7 +13,7 @@ training: warmup_steps: 5 max_steps: 30 save_steps: 30 - weight_decay: 0.01 + weight_decay: 0.001 random_seed: 3407 packing: false train_on_completions: true diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-1B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-1B-Instruct.yaml index 3938f10627..2bc3f6f871 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-1B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-1B-Instruct.yaml @@ -13,7 +13,7 @@ training: warmup_steps: 0 max_steps: 30 save_steps: 30 - weight_decay: 0.01 + weight_decay: 0.001 random_seed: 3407 packing: false train_on_completions: true diff --git a/studio/backend/assets/configs/vision_lora.yaml b/studio/backend/assets/configs/vision_lora.yaml index 60641b16e8..063a970316 100644 --- a/studio/backend/assets/configs/vision_lora.yaml +++ b/studio/backend/assets/configs/vision_lora.yaml @@ -16,7 +16,7 @@ training: warmup_steps: 5 max_steps: 0 save_steps: 0 - weight_decay: 0.01 + weight_decay: 0.001 random_seed: 3407 packing: false train_on_completions: false diff --git a/studio/backend/core/export/orchestrator.py b/studio/backend/core/export/orchestrator.py index a9fbe659b3..500bc9e706 100644 --- a/studio/backend/core/export/orchestrator.py +++ b/studio/backend/core/export/orchestrator.py @@ -217,6 +217,7 @@ class ExportOrchestrator: max_seq_length: int = 2048, load_in_4bit: bool = True, trust_remote_code: bool = False, + hf_token: Optional[str] = None, ) -> Tuple[bool, str]: """Load a checkpoint for export. @@ -227,6 +228,7 @@ class ExportOrchestrator: "max_seq_length": max_seq_length, "load_in_4bit": load_in_4bit, "trust_remote_code": trust_remote_code, + "hf_token": hf_token, } # Always kill existing subprocess and spawn fresh. diff --git a/studio/backend/core/inference/_html_to_md.py b/studio/backend/core/inference/_html_to_md.py new file mode 100644 index 0000000000..d96b8168e2 --- /dev/null +++ b/studio/backend/core/inference/_html_to_md.py @@ -0,0 +1,439 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Minimal HTML-to-Markdown converter using only the standard library. + +Replaces the external ``html2text`` (GPL-3.0) dependency with a ~250-line +``html.parser.HTMLParser`` subclass. Covers headings, links, bold/italic, +lists, tables, blockquotes, code blocks, and entity decoding. +""" + +from __future__ import annotations + +import html +import re +from html.parser import HTMLParser + +__all__ = ["html_to_markdown"] + +_SKIP_TAGS = frozenset({"script", "style", "head", "noscript", "svg", "math"}) +_BLOCK_TAGS = frozenset( + { + "p", + "div", + "section", + "article", + "header", + "footer", + "main", + "aside", + "nav", + "figure", + "figcaption", + "details", + "summary", + "dl", + "dt", + "dd", + } +) +_HEADING_TAGS = frozenset({"h1", "h2", "h3", "h4", "h5", "h6"}) +_INLINE_EMPHASIS = {"strong": "**", "b": "**", "em": "*", "i": "*"} + + +class _MarkdownRenderer(HTMLParser): + """HTMLParser subclass that emits Markdown tokens into a list.""" + + def __init__(self): + super().__init__(convert_charrefs = False) + self._out: list[str] = [] + self._skip_depth: int = 0 + + # Link state + self._link_href: str | None = None + self._link_text_parts: list[str] = [] + self._in_link: bool = False + + # List state + self._list_stack: list[str] = [] # "ul" or "ol" + self._ol_counter: list[int] = [] + + # Table state + self._in_table: bool = False + self._current_row: list[str] = [] + self._cell_parts: list[str] = [] + self._in_cell: bool = False + self._header_row_done: bool = False + self._row_has_th: bool = False + self._is_first_row: bool = False + + # Pre/code state + self._in_pre: bool = False + self._pre_parts: list[str] = [] + self._in_inline_code: bool = False + + # Blockquote state -- stack of output buffers so nested + # blockquotes each collect their own content and get prefixed + # with the correct number of ">" markers on close. + self._bq_stack: list[list[str]] = [] + + # ------------------------------------------------------------------ + def _emit(self, text: str) -> None: + if self._in_link: + self._link_text_parts.append(text) + elif self._in_cell: + self._cell_parts.append(text) + elif self._in_pre: + self._pre_parts.append(text) + elif self._bq_stack: + self._bq_stack[-1].append(text) + else: + self._out.append(text) + + # ------------------------------------------------------------------ + def _prefix_blockquote(self, content: str) -> str: + """Prefix every line of *content* with ``> ``.""" + # Strip trailing whitespace first, then collapse blank lines + content = re.sub(r"[ \t]+$", "", content, flags = re.MULTILINE) + content = re.sub(r"\n{3,}", "\n\n", content).strip() + if not content: + return "" + lines = content.split("\n") + prefixed: list[str] = [] + for line in lines: + if line.strip(): + prefixed.append("> " + line) + else: + prefixed.append(">") + return "\n".join(prefixed) + + # ------------------------------------------------------------------ + # Table helpers -- flush open cells and rows so that HTML with + # omitted optional end tags (, ) does not lose data. + # ------------------------------------------------------------------ + def _finish_cell(self) -> None: + if not self._in_cell: + return + self._in_cell = False + cell_text = "".join(self._cell_parts).strip().replace("\n", " ") + cell_text = cell_text.replace("|", "\\|") + self._current_row.append(cell_text) + self._cell_parts = [] + + def _finish_row(self) -> None: + if not self._current_row: + return + line = "| " + " | ".join(self._current_row) + " |" + self._emit(line + "\n") + if not self._header_row_done and (self._row_has_th or self._is_first_row): + sep = "| " + " | ".join("---" for _ in self._current_row) + " |" + self._emit(sep + "\n") + self._header_row_done = True + self._is_first_row = False + self._current_row = [] + self._row_has_th = False + + # ------------------------------------------------------------------ + # Link text helper -- normalize whitespace so block-level content + # inside an does not produce multiline Markdown link labels. + # ------------------------------------------------------------------ + def _finish_link(self) -> None: + text = re.sub(r"\s+", " ", "".join(self._link_text_parts)).strip() + href = self._link_href or "" + self._in_link = False + if href and text: + self._emit(f"[{text}]({href})") + elif text: + self._emit(text) + + # ------------------------------------------------------------------ + # Tag handlers + # ------------------------------------------------------------------ + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + tag = tag.lower() + + if tag in _SKIP_TAGS: + self._skip_depth += 1 + return + if self._skip_depth: + return + + attr_dict = dict(attrs) + + if tag in _HEADING_TAGS: + level = int(tag[1]) + self._emit("\n\n" + "#" * level + " ") + + elif tag == "a": + self._link_href = attr_dict.get("href") + self._link_text_parts = [] + self._in_link = True + + elif tag in _INLINE_EMPHASIS: + self._emit(_INLINE_EMPHASIS[tag]) + + elif tag == "br": + self._emit("\n") + + elif tag in _BLOCK_TAGS: + self._emit("\n\n") + + elif tag == "hr": + self._emit("\n\n---\n\n") + + elif tag == "blockquote": + self._emit("\n\n") + self._bq_stack.append([]) + + elif tag == "ul": + self._list_stack.append("ul") + self._emit("\n") + + elif tag == "ol": + self._list_stack.append("ol") + start_attr = attr_dict.get("start") + try: + start = int(start_attr) if start_attr is not None else 1 + except (ValueError, TypeError): + start = 1 + self._ol_counter.append(start - 1) + self._emit("\n") + + elif tag == "li": + indent = " " * max(0, len(self._list_stack) - 1) + if self._list_stack and self._list_stack[-1] == "ol": + if self._ol_counter: + self._ol_counter[-1] += 1 + self._emit(f"\n{indent}{self._ol_counter[-1]}. ") + else: + self._emit(f"\n{indent}1. ") + else: + self._emit(f"\n{indent}* ") + + elif tag == "pre": + self._pre_parts = [] + self._in_pre = True + + elif tag == "code" and not self._in_pre: + self._in_inline_code = True + self._emit("`") + + elif tag == "table": + self._in_table = True + self._header_row_done = False + self._is_first_row = True + self._emit("\n\n") + + elif tag == "tr": + # Flush any open cell/row from a previous row that may + # have omitted its optional or end tags. + self._finish_cell() + self._finish_row() + + elif tag in ("th", "td"): + # Flush any open cell (handles omitted /) + self._finish_cell() + self._cell_parts = [] + self._in_cell = True + if tag == "th": + self._row_has_th = True + + elif tag == "img": + # Skip images -- keeps fetched page text focused on readable + # content and avoids data-URI amplification. + return + + def handle_endtag(self, tag: str) -> None: + tag = tag.lower() + + if tag in _SKIP_TAGS: + self._skip_depth = max(0, self._skip_depth - 1) + return + if self._skip_depth: + return + + if tag in _HEADING_TAGS: + self._emit("\n\n") + + elif tag == "a": + self._finish_link() + + elif tag in _INLINE_EMPHASIS: + self._emit(_INLINE_EMPHASIS[tag]) + + elif tag in _BLOCK_TAGS: + self._emit("\n\n") + + elif tag == "blockquote": + if self._bq_stack: + content = "".join(self._bq_stack.pop()) + prefixed = self._prefix_blockquote(content) + if prefixed: + self._emit("\n\n" + prefixed + "\n\n") + + elif tag == "ul": + if self._list_stack and self._list_stack[-1] == "ul": + self._list_stack.pop() + self._emit("\n") + + elif tag == "ol": + if self._list_stack and self._list_stack[-1] == "ol": + self._list_stack.pop() + if self._ol_counter: + self._ol_counter.pop() + self._emit("\n") + + elif tag == "pre": + raw = "".join(self._pre_parts) + self._in_pre = False + block = "```\n" + raw + "\n```" + self._emit("\n\n" + block + "\n\n") + + elif tag == "code" and not self._in_pre: + self._in_inline_code = False + self._emit("`") + + elif tag in ("th", "td"): + self._finish_cell() + + elif tag == "tr": + self._finish_cell() + self._finish_row() + + elif tag == "table": + # Flush any remaining row (handles omitted ) + self._finish_cell() + self._finish_row() + self._in_table = False + self._emit("\n") + + # ------------------------------------------------------------------ + # Text / entity handlers + # ------------------------------------------------------------------ + def handle_data(self, data: str) -> None: + if self._skip_depth: + return + if self._in_pre: + self._pre_parts.append(data) + return + # Preserve literal whitespace inside inline spans + if self._in_inline_code: + self._emit(data) + return + # Collapse all whitespace (including newlines) per HTML rules + text = re.sub(r"\s+", " ", data) + # Suppress whitespace-only text nodes between table structural + # elements (indentation from source HTML) to prevent leading + # spaces from breaking Markdown table row alignment. + if self._in_table and not self._in_cell and not text.strip(): + return + self._emit(text) + + def handle_entityref(self, name: str) -> None: + if self._skip_depth: + return + self._emit(html.unescape(f"&{name};")) + + def handle_charref(self, name: str) -> None: + if self._skip_depth: + return + self._emit(html.unescape(f"&#{name};")) + + # ------------------------------------------------------------------ + # Flush pending buffers (handles truncated HTML from capped fetches) + # ------------------------------------------------------------------ + def flush_pending(self) -> None: + """Flush any open side-buffers into ``_out``. + + Called after ``close()`` to recover content from truncated HTML + where closing tags were never seen (common when ``_fetch_page_text`` + caps the download by byte count). + """ + # Flush innermost buffers first so their content propagates outward. + + if self._in_link: + self._finish_link() + + if self._in_inline_code: + self._in_inline_code = False + self._emit("`") + + self._finish_cell() + self._finish_row() + + if self._in_pre: + raw = "".join(self._pre_parts) + self._in_pre = False + block = "```\n" + raw + "\n```" + self._emit("\n\n" + block + "\n\n") + + # Flatten any open blockquote buffers (innermost first) + while self._bq_stack: + content = "".join(self._bq_stack.pop()) + prefixed = self._prefix_blockquote(content) + if not prefixed: + continue + if self._bq_stack: + self._bq_stack[-1].append("\n\n" + prefixed + "\n\n") + else: + self._out.append("\n\n" + prefixed + "\n\n") + + +# ------------------------------------------------------------------ +# Post-processing +# ------------------------------------------------------------------ +def _cleanup(text: str) -> str: + """Normalize whitespace and blank lines in the final output. + + Preserves content inside fenced code blocks verbatim so that + intentional blank lines in ``
`` content are not collapsed.
+    """
+    lines = text.split("\n")
+    out: list[str] = []
+    in_fence = False
+    blank_run = 0
+
+    for line in lines:
+        stripped = line.rstrip(" \t")
+        if stripped.startswith("```"):
+            in_fence = not in_fence
+            blank_run = 0
+            out.append(stripped)
+            continue
+
+        if in_fence:
+            # Preserve code block content exactly as-is
+            out.append(line)
+            continue
+
+        if not stripped:
+            blank_run += 1
+            if blank_run <= 1:
+                out.append("")
+            continue
+
+        blank_run = 0
+        out.append(stripped)
+
+    return "\n".join(out).strip()
+
+
+# ------------------------------------------------------------------
+# Public API
+# ------------------------------------------------------------------
+def html_to_markdown(source_html: str) -> str:
+    """Convert an HTML string to Markdown.
+
+    Handles headings, links, bold/italic, lists (ordered and unordered),
+    tables, blockquotes, code blocks, and HTML entities.  ``