install.sh
Rama main · réplica de lectura del repositorio original.
#!/bin/sh
# Unsloth Studio Installer. Usage, supported options and the web one-liner live in the README under "Unsloth Studio (web UI)" and are deliberately not repeated here: this file ships inside the Linux desktop bundle, where a header rehearsing download-and-run command lines is the first thing a generic script classifier reads. A piped install takes options as environment variables after the pipe (UNSLOTH_NO_TORCH, UNSLOTH_SKIP_AUTOSTART, UNSLOTH_INSTALL_SYSTEMD, UNSLOTH_SYSTEMD_HOST, UNSLOTH_SYSTEMD_PORT, UNSLOTH_ISOLATE_UV_CACHE, UNSLOTH_INSTALL_NO_ROLLBACK, UNSLOTH_PYTHON, UNSLOTH_STUDIO_HOME), because a bare `--no-torch` after the pipe would be read as an option to sh itself; a local run takes the equivalent flags (--no-torch, --isolated-uv-cache, --no-rollback, --python, --local). Install dir priority: UNSLOTH_STUDIO_HOME > STUDIO_HOME > UNSLOTH_HOME/studio > $HOME/.unsloth/studio
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
set -e
# ── Why the installer lives in a function ──
# Body is wrapped in a function so a piped sh reads the whole file before running; an early
# exit otherwise breaks curl's pipe. Do not add `exec < /dev/null` (closes the piped source).
_unsloth_main() {
# ── 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"
TAURI_MODE=false
_USER_PYTHON=""
_NO_TORCH_FLAG=false
_SKIP_AUTOSTART=false
_ISOLATE_UV_CACHE=false
_NO_ROLLBACK=false
_VENV_DISCARDED=false
_VENV_DISCARD_LEFTOVER=""
_INSTALL_SYSTEMD=false
_SYSTEMD_STARTED=false
_VERBOSE=false
_SHORTCUTS_ONLY=false
_next_is_package=false
_next_is_python=false
_next_is_llama_cpp_dir=false
_WITH_LLAMA_CPP_DIR="${UNSLOTH_LOCAL_LLAMA_CPP_DIR:-}"
for arg in "$@"; do
if [ "$_next_is_package" = true ]; then
PACKAGE_NAME="$arg"
_next_is_package=false
continue
fi
if [ "$_next_is_python" = true ]; then
_USER_PYTHON="$arg"
_next_is_python=false
continue
fi
if [ "$_next_is_llama_cpp_dir" = true ]; then
_WITH_LLAMA_CPP_DIR="$arg"
_next_is_llama_cpp_dir=false
continue
fi
case "$arg" in
--local) STUDIO_LOCAL_INSTALL=true ;;
--package) _next_is_package=true ;;
--tauri) TAURI_MODE=true ;;
--python) _next_is_python=true ;;
--no-torch) _NO_TORCH_FLAG=true ;;
--isolated-uv-cache) _ISOLATE_UV_CACHE=true ;;
--no-rollback) _NO_ROLLBACK=true ;;
--verbose|-v) _VERBOSE=true ;;
--shortcuts-only) _SHORTCUTS_ONLY=true ;;
--with-llama-cpp-dir) _next_is_llama_cpp_dir=true ;;
esac
done
case "${UNSLOTH_NO_TORCH:-}" in 1|true|TRUE|yes|YES|on|ON) _NO_TORCH_FLAG=true ;; esac
case "${UNSLOTH_SKIP_AUTOSTART:-}" in 1|true|TRUE|yes|YES|on|ON) _SKIP_AUTOSTART=true ;; esac
case "${UNSLOTH_ISOLATE_UV_CACHE:-}" in 1|true|TRUE|yes|YES|on|ON) _ISOLATE_UV_CACHE=true ;; esac
case "${UNSLOTH_INSTALL_NO_ROLLBACK:-}" in 1|true|TRUE|yes|YES|on|ON) _NO_ROLLBACK=true ;; esac
case "${UNSLOTH_INSTALL_SYSTEMD:-}" in 1|true|TRUE|yes|YES|on|ON) _INSTALL_SYSTEMD=true ;; esac
[ -z "$_USER_PYTHON" ] && [ -n "${UNSLOTH_PYTHON:-}" ] && _USER_PYTHON="$UNSLOTH_PYTHON"
if [ "$_VERBOSE" = true ]; then
export UNSLOTH_VERBOSE=1
fi
if [ "$TAURI_MODE" = true ]; then
_tauri_canonical_root() (
_tcr_path=$1
case "$_tcr_path" in /*) ;; *) _tcr_path="$PWD/$_tcr_path" ;; esac
_tcr_tail=""
# Resolve the existing ancestor without creating a fresh install root.
while [ ! -d "$_tcr_path" ]; do
_tcr_tail="/${_tcr_path##*/}$_tcr_tail"
_tcr_path=${_tcr_path%/*}
[ -n "$_tcr_path" ] || _tcr_path=/
done
_tcr_path=$(CDPATH= cd -P -- "$_tcr_path" 2>/dev/null && pwd -P) || return 1
printf '%s\n' "$_tcr_path$_tcr_tail" | awk -F/ '{
for (i = 1; i <= NF; i++) {
if ($i == "" || $i == ".") continue
if ($i == "..") { if (n > 0) n-- }
else parts[++n] = $i
}
for (i = 1; i <= n; i++) printf "/%s", parts[i]
if (n == 0) printf "/"
printf "\n"
}'
)
_tauri_override_var=""
_tauri_override=$(printf '%s' "${UNSLOTH_STUDIO_HOME:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
if [ -n "$_tauri_override" ]; then
_tauri_override_var="UNSLOTH_STUDIO_HOME"
else
_tauri_override=$(printf '%s' "${STUDIO_HOME:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
[ -n "$_tauri_override" ] && _tauri_override_var="STUDIO_HOME"
fi
if [ -z "$_tauri_override" ]; then
_tauri_override=$(printf '%s' "${UNSLOTH_HOME:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
if [ -n "$_tauri_override" ]; then
_tauri_override_var="UNSLOTH_HOME (studio child)"
_tauri_override="${_tauri_override%/}/studio"
fi
fi
if [ -n "$_tauri_override" ]; then
case "$_tauri_override" in
"~") _tauri_override="$HOME" ;;
"~/"*) _tauri_override="$HOME/${_tauri_override#'~/'}" ;;
esac
_tauri_override_abs=$(_tauri_canonical_root "$_tauri_override") \
|| _tauri_override_abs="$_tauri_override"
_tauri_legacy_root=$(_tauri_canonical_root "$HOME/.unsloth/studio") \
|| _tauri_legacy_root="$HOME/.unsloth/studio"
if [ "$_tauri_override_abs" != "$_tauri_legacy_root" ]; then
echo "ERROR: $_tauri_override_var is not supported with --tauri." >&2
echo " The desktop app still uses the legacy ~/.unsloth/studio root." >&2
echo " Run install.sh without --tauri for custom-root shell installs," >&2
echo " or unset the env var for default desktop installs." >&2
exit 1
fi
fi
fi
_is_verbose() {
[ "${UNSLOTH_VERBOSE:-0}" = "1" ]
}
run_maybe_quiet() {
if _is_verbose; then
"$@"
else
"$@" > /dev/null 2>&1
fi
}
_trim_index_path_slashes() {
_tips_v="$1"
case "$_tips_v" in
*[?#]*)
_tips_head="${_tips_v%%[?#]*}"
_tips_tail="${_tips_v#"$_tips_head"}"
;;
*)
_tips_head="$_tips_v"
_tips_tail=""
;;
esac
while [ -n "$_tips_head" ] && [ "${_tips_head%/}" != "$_tips_head" ]; do
_tips_head="${_tips_head%/}"
done
printf '%s%s' "$_tips_head" "$_tips_tail"
}
_redact_install_output() {
sed -E \
-e 's#(https?://)[^/@[:space:]`]+@#\1<redacted>@#g' \
-e 's#([?&][^=[:space:]&`]+)=[^&#[:space:]`]+#\1=<redacted>#g' \
-e 's|(https?://[^[:space:]`#]+)#[^[:space:]`]+|\1#<redacted>|g' \
"$@"
}
: "${UNSLOTH_DL_MARKER_MIN_BYTES:=52428800}"
# Markers go to stderr: the verbose path's sed redactor block-buffers and would delay them.
_uv_download_markers() {
# Minimal images lack awk; without this the pipe closes and the child dies of SIGPIPE.
if ! command -v awk >/dev/null 2>&1; then
if [ -n "$1" ]; then cat >> "$1"; else cat; fi
return
fi
awk -v logf="$1" -v minb="$2" -v tauri="${TAURI_MODE:-false}" -v err=/dev/stderr '
{ if (logf == "") print; else print >> logf }
tauri != "true" { next }
# Field-relative so a leading status glyph cannot shift the match.
/(^| )Downloading [^ ]+ \([0-9.]+[KMG]iB\)$/ {
size = $NF
gsub(/[()]/, "", size)
n = size; sub(/[KMG]iB$/, "", n)
u = size; sub(/^[0-9.]+/, "", u)
mult = (u == "GiB") ? 1073741824 : (u == "MiB") ? 1048576 : 1024
if (n * mult >= minb) {
announced[$(NF - 1)] = 1
print "[TAURI:DL] " $(NF - 1) " " size > err
fflush(err)
}
next
}
# Only close what was opened: uv also reports completion for unannounced packages.
/(^| )Downloaded [^ ]+$/ && ($NF in announced) {
delete announced[$NF]
print "[TAURI:DL_DONE] " $NF > err
fflush(err)
}
'
}
run_install_cmd() {
if [ -z "${_UNSLOTH_MIRROR_SPARE:-}" ]; then
_run_install_cmd_once "$@"
return
fi
_run_install_cmd_once "$@" || _mirror_retry_install "$?" "$@"
}
_mirror_retry_install() {
[ -n "${_UNSLOTH_MIRROR_SPARE:-}" ] || return "$1"
_mri_rc=$1
_mri_label=$2
shift 2
case " $* " in
*" https://download.pytorch.org/whl"*) _mri_ran=torch ;;
*" --index-url "*|*" --default-index "*|*" --find-links "*|*" --no-index "*|*"://"*|*" --torch-backend"*) _mri_ran="" ;;
*" uv venv "*|*" uv python install "*) _mri_ran=python ;;
*) _mri_ran=pypi ;;
esac
_mri_host=$(_mirror_failed_host "${_ric_log:-}" "$_mri_ran") || _mri_host=""
rm -f "${_ric_log:-}"
case "$_mri_host $* " in
"pypi "*" --index-url "*|"pypi "*" --default-index "*) return "$_mri_rc" ;;
"unsynced "*" --index-url "*|"unsynced "*" --default-index "*|"unsynced "*" --no-index "*) return "$_mri_rc" ;;
"torch "*https://download.pytorch.org/whl*|"pypi "*|"python "*|"unsynced "*) ;;
*) return "$_mri_rc" ;;
esac
_mirror_take "$_mri_host" || return "$_mri_rc"
(
for _mri_pair in $_MT_PAIRS; do export "$_mri_pair"; done
[ "$_mri_host" != torch ] || _ric_torch_mirror=$UNSLOTH_PYTORCH_MIRROR
_run_install_cmd_once "$_mri_label" "$@" || { _mri_rc=$?; rm -f "${_ric_log:-}"; exit "$_mri_rc"; }
) || return
for _mri_pair in $_MT_PAIRS; do export "$_mri_pair"; done
if [ "$_mri_host" = torch ]; then
_ric_torch_mirror=$UNSLOTH_PYTORCH_MIRROR
case "${TORCH_INDEX_URL:-}" in
https://download.pytorch.org/whl*) TORCH_INDEX_URL=$_ric_torch_mirror${TORCH_INDEX_URL#https://download.pytorch.org/whl} ;;
esac
fi
}
_ric_run() {
if "$@" 2>&1; then
_cmd_rc=0
else
_cmd_rc=$?
fi
printf '%s' "$_cmd_rc" > "$_rcf"
}
_ric_tee() {
if command -v tee >/dev/null 2>&1; then tee "$1"; else cat; fi
}
_run_install_cmd_once() {
_label="$1"
shift
[ -z "${_ric_log:-}" ] || rm -f "$_ric_log"
_ric_log=""
if [ -n "${_ric_torch_mirror:-}" ]; then
_ric_n=$#
for _ric_arg in "$@"; do
case "$_ric_arg" in
https://download.pytorch.org/whl*) _ric_arg=$_ric_torch_mirror${_ric_arg#https://download.pytorch.org/whl} ;;
esac
set -- "$@" "$_ric_arg"
done
shift "$_ric_n"
fi
# Clear inherited uv index vars so a uv.toml cannot outrank the --default-index pin.
case " $* " in
*" --default-index "*) set -- env -u UV_DEFAULT_INDEX -u UV_INDEX_URL -u UV_INDEX -u UV_EXTRA_INDEX_URL -u UV_TORCH_BACKEND -u UV_FIND_LINKS -u UV_CONFIG_FILE UV_NO_CONFIG=1 "$@" ;;
esac
if _is_verbose; then
_rcf=$(mktemp)
tauri_stream_log stdout "OUTPUT_CLEAR" "$_label"
_log=$(mktemp)
_ric_run "$@" | _ric_tee "$_log" | _uv_download_markers "" "$UNSLOTH_DL_MARKER_MIN_BYTES" | _redact_install_output
_rc=$(cat "$_rcf" 2>/dev/null || echo 1)
rm -f "$_rcf"
_rc=${_rc:-1}
if [ "$_rc" -eq 0 ] 2>/dev/null; then
rm -f "$_log"
tauri_clear_install_error "$_label recovered"
return 0
fi
_ric_damaged=$(_uv_damaged_wheels "$_log")
if [ -n "${_UNSLOTH_MIRROR_SPARE:-}" ]; then _ric_log=$_log; else rm -f "$_log"; fi
tauri_stream_log stdout "ERROR_OUTPUT" "$_label failed (exit code $_rc)"
step "error" "$_label failed (exit code $_rc)" "$C_ERR" >&2
return "$_rc"
fi
_log=$(mktemp)
_rcf=$(mktemp)
tauri_stream_log stderr "OUTPUT_CLEAR" "$_label"
{
if "$@" 2>&1; then
_cmd_rc=0
else
_cmd_rc=$?
fi
printf '%s' "$_cmd_rc" > "$_rcf"
} | _uv_download_markers "$_log" "$UNSLOTH_DL_MARKER_MIN_BYTES"
_rc=$(cat "$_rcf" 2>/dev/null || echo 1)
rm -f "$_rcf"
_rc=${_rc:-1}
if [ "$_rc" -eq 0 ] 2>/dev/null; then
rm -f "$_log"
tauri_clear_install_error "$_label recovered"
return 0
fi
step "error" "$_label failed (exit code $_rc)" "$C_ERR" >&2
_redact_install_output "$_log" >&2
tauri_stream_log stderr "ERROR_OUTPUT" "$_label failed (exit code $_rc)"
_ric_damaged=$(_uv_damaged_wheels "$_log")
if [ -n "${_UNSLOTH_MIRROR_SPARE:-}" ]; then _ric_log=$_log; else rm -f "$_log"; fi
return $_rc
}
# uv reuses a damaged cached wheel on every run, --refresh included (#17375).
_uv_damaged_wheels() {
[ -f "$1" ] || return 0
awk '
p != "" && /^[ \t]*(cause|Caused by): The wheel is invalid/ { print tolower(p) }
{ p = "" }
/Failed to install: [^ ]+ \(/ {
p = $0; sub(/.*Failed to install: [^ ]+ \(/, "", p); sub(/(==| @ ).*/, "", p)
if (p !~ /^[A-Za-z0-9][A-Za-z0-9._-]*$/) p = ""
}' "$1"
}
_repair_uv_damaged_wheels() {
_rudw_new=""
for _rudw_n in ${_ric_damaged:-}; do
case "$_ricr_cleaned" in *" $_rudw_n "*) continue ;; esac
[ "$_ricr_cleaned_n" -lt 50 ] || break
_ricr_cleaned="$_ricr_cleaned$_rudw_n "
_ricr_cleaned_n=$((_ricr_cleaned_n + 1))
_rudw_new="$_rudw_new $_rudw_n"
done
[ -n "$_rudw_new" ] || return 1
substep "clearing damaged uv cache entries for$_rudw_new..." "$C_WARN"
# Another uv process may hold the cache lock.
# shellcheck disable=SC2086
UV_LOCK_TIMEOUT=${UV_LOCK_TIMEOUT:-60} uv cache clean $_rudw_new >/dev/null 2>&1
}
: "${UNSLOTH_INSTALL_RETRIES:=3}"
: "${UNSLOTH_INSTALL_RETRY_DELAY:=3}"
run_install_cmd_retry() {
_ricr_label="$1"
# Bounds 1..100 retries and 0..3600s delay; 0?* rejects octal delays.
case "$UNSLOTH_INSTALL_RETRIES" in
''|*[!0-9]*|0) _ricr_max=3 ;;
*) if [ "${#UNSLOTH_INSTALL_RETRIES}" -le 3 ] && [ "$UNSLOTH_INSTALL_RETRIES" -ge 1 ] 2>/dev/null && [ "$UNSLOTH_INSTALL_RETRIES" -le 100 ] 2>/dev/null; then _ricr_max=$UNSLOTH_INSTALL_RETRIES; else _ricr_max=3; fi ;;
esac
case "$UNSLOTH_INSTALL_RETRY_DELAY" in
''|*[!0-9]*|0?*) _ricr_delay=3 ;;
*) if [ "${#UNSLOTH_INSTALL_RETRY_DELAY}" -le 4 ] && [ "$UNSLOTH_INSTALL_RETRY_DELAY" -ge 0 ] 2>/dev/null && [ "$UNSLOTH_INSTALL_RETRY_DELAY" -le 3600 ] 2>/dev/null; then _ricr_delay=$UNSLOTH_INSTALL_RETRY_DELAY; else _ricr_delay=3; fi ;;
esac
_ricr_attempt=1
_ricr_cleaned=" "
_ricr_cleaned_n=0
while :; do
_ric_damaged=""
# AND-OR (not `if`) preserves the real failure code for the rollback path.
_run_install_cmd_once "$@" && return 0
_ricr_rc=$?
# Not an attempt: uv names one damaged package per run.
_repair_uv_damaged_wheels && continue
if [ "$_ricr_attempt" -ge "$_ricr_max" ]; then
_mirror_retry_install "$_ricr_rc" "$@" && return 0
return $?
fi
substep "retrying \"$_ricr_label\" after transient failure (attempt $((_ricr_attempt + 1))/$_ricr_max, waiting ${_ricr_delay}s)..." "$C_WARN"
sleep "$_ricr_delay" || true
_ricr_attempt=$((_ricr_attempt + 1))
_ricr_delay=$((_ricr_delay * 2))
done
}
# gfx906: the prebuilt AMD bnb wheel has no gfx906 kernels and would clobber a source-built bnb.
_is_gfx906_bnb_skip() {
[ "${_gfx906_target:-false}" = true ] && return 0
_bnb_gfx_env=$(printf '%s' "${UNSLOTH_ROCM_GFX_ARCH:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
_bnb_gfx_env=${_bnb_gfx_env%%:*}
[ "$_bnb_gfx_env" = "gfx906" ] && return 0
# A pinned index skips the reroute that sets _gfx906_target, so probe here (sole-arch rule).
if [ -z "$_bnb_gfx_env" ] && [ "${_torch_index_pinned:-false}" = true ]; then
_bnb_gfx_probe=$(_probe_amd_gfx_arch | awk 'NF && !seen[$0]++')
[ "$_bnb_gfx_probe" = "gfx906" ] && return 0
fi
return 1
}
# pip install unsloth pulls a generic bnb; drop a freshly pulled wheel but keep a source build.
_gfx906_bnb_installed() {
"$_VENV_PY" -c "import importlib.util as u, sys; sys.exit(0 if u.find_spec('bitsandbytes') else 1)" >/dev/null 2>&1
}
_gfx906_bnb_snapshot() {
_gfx906_bnb_absent_before=false
_is_gfx906_bnb_skip || return 0
_gfx906_bnb_installed || _gfx906_bnb_absent_before=true
}
_gfx906_bnb_prune() {
_is_gfx906_bnb_skip || return 0
[ "${_gfx906_bnb_absent_before:-false}" = true ] || return 0
_gfx906_bnb_installed || return 0
substep "gfx906: removing generic bitsandbytes pulled in as a dependency (no gfx906 kernels; build from source for 4-bit QLoRA)" "$C_WARN"
uv pip uninstall --python "$_VENV_PY" bitsandbytes >/dev/null 2>&1 \
|| "$_VENV_PY" -m pip uninstall -y bitsandbytes >/dev/null 2>&1 || true
}
# bnb <= 0.49.2 NaNs at 4-bit decode on AMD (bnb #1887). Keep this floor in step with the amd extra
# in pyproject.toml and studio/install_python_stack.py.
_BNB_ROCM_PYPI_FALLBACK="bitsandbytes>=0.50.0"
# Intel XPU: 0.50.0 is the first with xpu libs; studio/setup.ps1 uses the same floor.
_BNB_XPU_SPEC="bitsandbytes>=0.50.0"
# bnb aarch64 wheels carry no ROCm binary at any version, so messages must not claim 4-bit.
_bnb_rocm_arch_has_binary() {
case "$_ARCH" in
aarch64|arm64) return 1 ;;
*) return 0 ;;
esac
}
_warn_bnb_no_rocm_binary() {
_bnb_rocm_arch_has_binary && return 0
substep "[WARN] aarch64: bitsandbytes ships no ROCm kernels on this arch; 4-bit QLoRA needs a source build -- https://unsloth.ai/docs/get-started/install/amd" "$C_WARN"
}
_install_bnb_rocm() {
_label="$1"
_venv_py="$2"
case "$_ARCH" in
x86_64|amd64)
_bnb_whl_url="https://github.com/bitsandbytes-foundation/bitsandbytes/releases/download/continuous-release_main/bitsandbytes-1.33.7.preview-py3-none-manylinux_2_24_x86_64.whl"
;;
aarch64|arm64)
_bnb_whl_url="https://github.com/bitsandbytes-foundation/bitsandbytes/releases/download/continuous-release_main/bitsandbytes-1.33.7.preview-py3-none-manylinux_2_24_aarch64.whl"
;;
*)
_bnb_whl_url=""
;;
esac
# uv rejects this pre-release wheel (filename/metadata version mismatch); pip accepts it.
if ! "$_venv_py" -m pip --version >/dev/null 2>&1; then
if ! run_maybe_quiet "$_venv_py" -m ensurepip --upgrade; then
run_maybe_quiet uv pip install --python "$_venv_py" pip || \
substep "[WARN] could not bootstrap pip; bitsandbytes install will likely fail" "$C_WARN"
fi
fi
if [ -n "$_bnb_whl_url" ]; then
substep "installing bitsandbytes for AMD ROCm (pre-release, PR #1887)..."
_bnb_log=$(mktemp)
if "$_venv_py" -m pip install \
--disable-pip-version-check \
--force-reinstall --no-cache-dir --no-deps \
--retries 8 --timeout 90 \
"$_bnb_whl_url" >"$_bnb_log" 2>&1; then
rm -f "$_bnb_log"
_warn_bnb_no_rocm_binary
return 0
fi
_bnb_rc=$?
if _is_verbose; then
_redact_install_output "$_bnb_log" >&2
fi
rm -f "$_bnb_log"
step "warning" "$_label (pre-release) failed (exit code $_bnb_rc)" "$C_WARN" >&2
if _bnb_rocm_arch_has_binary; then
substep "[WARN] bnb pre-release install failed; falling back to PyPI $_BNB_ROCM_PYPI_FALLBACK, which carries the ROCm 4-bit fix" "$C_WARN"
else
substep "[WARN] bnb pre-release install failed; falling back to PyPI $_BNB_ROCM_PYPI_FALLBACK" "$C_WARN"
fi
fi
run_install_cmd "$_label (pypi fallback)" "$_venv_py" -m pip install \
--force-reinstall --no-cache-dir --no-deps "$_BNB_ROCM_PYPI_FALLBACK"
_bnb_pypi_rc=$?
_warn_bnb_no_rocm_binary
return $_bnb_pypi_rc
}
if [ "$_next_is_package" = true ]; then
echo "❌ ERROR: --package requires an argument." >&2
exit 1
fi
if [ "$_next_is_python" = true ]; then
echo "❌ ERROR: --python requires a version argument (e.g. --python 3.12)." >&2
exit 1
fi
if [ "$_next_is_llama_cpp_dir" = true ]; then
echo "❌ ERROR: --with-llama-cpp-dir requires a path argument." >&2
exit 1
fi
case "$PACKAGE_NAME" in
[!a-zA-Z0-9]*)
echo "❌ ERROR: --package name must start with a letter or digit." >&2
exit 1 ;;
*[!a-zA-Z0-9._-]*)
echo "❌ ERROR: --package name contains invalid characters (allowed: a-z A-Z 0-9 . _ -)" >&2
exit 1 ;;
esac
# ── Tauri structured output ──
tauri_log() {
if [ "$TAURI_MODE" = true ]; then
echo "[TAURI:$1] $2"
fi
}
tauri_stream_log() {
_tsl_stream="$1"
_tsl_tag="$2"
shift 2
if [ "$TAURI_MODE" = true ]; then
if [ "$_tsl_stream" = stderr ]; then
printf '[TAURI:%s] %s\n' "$_tsl_tag" "$*" >&2
else
printf '[TAURI:%s] %s\n' "$_tsl_tag" "$*"
fi
fi
}
rollback_substep() {
if [ "$TAURI_MODE" = true ]; then
tauri_log "PROGRESS" "$1"
else
substep "$@"
fi
}
tauri_clear_install_error() {
if [ "$TAURI_MODE" = true ]; then
tauri_log "ERROR_CLEAR" "$1"
printf '[TAURI:ERROR_CLEAR] %s\n' "$1" >&2
fi
}
tauri_diag_marker() {
_diag_gpu_branch="${1:-unknown}"
_diag_torch_index_family="${2:-none}"
tauri_log "DIAG" "diag_schema=1 platform=${OS:-unknown} arch=${_ARCH:-unknown} python_version=${PYTHON_VERSION:-unknown} skip_torch=${SKIP_TORCH:-false} mac_intel=${MAC_INTEL:-false} gpu_branch=${_diag_gpu_branch} torch_index_family=${_diag_torch_index_family}"
}
_tauri_torch_index_family() {
if [ "${SKIP_TORCH:-false}" = true ]; then
echo "none"
return
fi
_diag_url="${1:-}"
_diag_url="${_diag_url%%\?*}"
_diag_url="${_diag_url%%#*}"
_diag_url="${_diag_url%/}"
case "$_diag_url" in
*/cu118) echo "cu118" ;;
*/cu124) echo "cu124" ;;
*/cu126) echo "cu126" ;;
*/cu128) echo "cu128" ;;
*/cu130) echo "cu130" ;;
*/cpu) echo "cpu" ;;
*/xpu) echo "xpu" ;;
*/rocm[0-9]*.[0-9]*)
_diag_family=${_diag_url##*/}
case "$_diag_family" in
rocm[0-9]*.[0-9]*) echo "$_diag_family" ;;
*) echo "auto" ;;
esac ;;
*repo.amd.com/rocm/whl/gfx*|*rocm/whl/gfx*) echo "rocm7.13" ;;
"") echo "none" ;;
*) echo "auto" ;;
esac
}
_tauri_gpu_branch() {
_diag_family="${1:-unknown}"
_diag_radeon="${2:-false}"
if [ "${SKIP_TORCH:-false}" = true ]; then
echo "no_torch"
return
fi
if [ "${OS:-}" = "macos" ]; then
echo "mac"
return
fi
case "$_diag_family" in
cu[0-9]*) echo "cuda" ;;
rocm*)
if [ "$_diag_radeon" = true ]; then
echo "rocm_radeon"
else
echo "rocm"
fi ;;
radeon) echo "rocm_radeon" ;;
xpu) echo "xpu" ;;
cpu) echo "cpu" ;;
none) echo "no_torch" ;;
*) echo "unknown" ;;
esac
}
PYTHON_VERSION=""
_resolve_studio_destinations() {
_override_var=""
_override=$(printf '%s' "${UNSLOTH_STUDIO_HOME:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
if [ -n "$_override" ]; then
_override_var="UNSLOTH_STUDIO_HOME"
else
_override=$(printf '%s' "${STUDIO_HOME:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
[ -n "$_override" ] && _override_var="STUDIO_HOME"
fi
if [ -z "$_override" ]; then
_override=$(printf '%s' "${UNSLOTH_HOME:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
if [ -n "$_override" ]; then
_override_var="UNSLOTH_HOME (studio child)"
_override="${_override%/}/studio"
fi
fi
# Tilde expansion: quoted env vars aren't subject to it on assignment.
case "$_override" in
"~") _override="$HOME" ;;
"~/"*) _override="$HOME/${_override#'~/'}" ;;
esac
if [ -n "$_override" ]; then
mkdir -p -- "$_override" 2>/dev/null || { echo "ERROR: $_override_var=$_override cannot be created." >&2; exit 1; }
[ -w "$_override" ] || { echo "ERROR: $_override_var=$_override is not writable." >&2; exit 1; }
STUDIO_HOME="$(CDPATH= cd -P -- "$_override" && pwd -P)" || exit 1
DATA_DIR="$STUDIO_HOME/share"
_LOCAL_BIN="$STUDIO_HOME/bin"
_STUDIO_HOME_REDIRECT=env
substep "custom $_override_var=$STUDIO_HOME"
return 0
fi
_default_home=""
if command -v getent >/dev/null 2>&1; then
_default_home=$(getent passwd "${USER:-$(whoami)}" 2>/dev/null | cut -d: -f6)
elif [ "$(uname)" = "Darwin" ] && command -v dscl >/dev/null 2>&1; then
_default_home=$(dscl . -read "/Users/${USER:-$(whoami)}" NFSHomeDirectory 2>/dev/null | awk '{print $2}')
fi
_home_canon="$HOME"
if [ -d "$_home_canon" ]; then
_home_canon=$(CDPATH= cd -P -- "$_home_canon" 2>/dev/null && pwd -P) || _home_canon="$HOME"
fi
_default_home_canon="$_default_home"
if [ -n "$_default_home_canon" ] && [ -d "$_default_home_canon" ]; then
_default_home_canon=$(CDPATH= cd -P -- "$_default_home_canon" 2>/dev/null && pwd -P) || _default_home_canon="$_default_home"
fi
if [ -n "$_default_home_canon" ] && [ "$_home_canon" != "$_default_home_canon" ]; then
STUDIO_HOME="$HOME/.unsloth/studio"
DATA_DIR="$HOME/.local/share/unsloth"
_LOCAL_BIN="$HOME/.local/bin"
_STUDIO_HOME_REDIRECT=home
substep "HOME redirected ($HOME); install follows \$HOME"
return 0
fi
STUDIO_HOME="$HOME/.unsloth/studio"
DATA_DIR="$HOME/.local/share/unsloth"
_LOCAL_BIN="$HOME/.local/bin"
_STUDIO_HOME_REDIRECT=default
}
# uv --no-cache neither reads nor writes a cache, so nothing is recorded. Values are clap's
# BoolishValueParser set. Mirrors _uv_no_cache_requested() in unsloth_cli/commands/studio.py.
_uv_no_cache_requested() {
case "$(printf '%s' "${UV_NO_CACHE:-}" | tr '[:upper:]' '[:lower:]')" in
1|y|yes|t|true|on) return 0 ;;
esac
return 1
}
_uv_is_bucket_name() {
case "$1" in
*-v[0-9]*) ;;
*) return 1 ;;
esac
# '' too: `##*-v` on `archive-v1-v` leaves an empty suffix; Test-StudioUvBucketName rejects it.
case "${1##*-v}" in
''|*[!0-9]*) return 1 ;;
esac
# Every CacheBucket in uv 0.13.5 (UV_PINNED_VERSION) plus built-wheels; re-read
# uv-cache/src/lib.rs on a pin bump, or a missing kind goes unprobed and uv fails on it.
case "${1%-v*}" in
archive|binaries|builds|built-wheels|environments|files|flat-index) ;;
git|interpreter|osv|python|sdists|simple|wheels) ;;
*) return 1 ;;
esac
return 0
}
_absolutize_uv_cache_dir() {
_uv_cache_path="${1-$UV_CACHE_DIR}"
case "$_uv_cache_path" in
/*) ;;
*)
_uv_cache_base="${UV_WORKING_DIR:-$PWD}"
case "$_uv_cache_base" in
/*) ;;
*) _uv_cache_base="$PWD/$_uv_cache_base" ;;
esac
_uv_cache_path="$_uv_cache_base/$_uv_cache_path"
;;
esac
# Strip trailing slashes: the studio-vs-shared comparison below is a string compare.
while [ "$_uv_cache_path" != / ]; do
case "$_uv_cache_path" in
*/) _uv_cache_path="${_uv_cache_path%/}" ;;
*) break ;;
esac
done
printf '%s\n' "$_uv_cache_path"
}
# Can we create AND fill this directory? A real create, since mkdir -p exits 0 on an existing
# unwritable one and -w reads the mode, not the filesystem. mktemp, since a predictable name
# in another account's directory can be pre-created as a symlink to follow.
_uv_cache_root_is_writable() {
mkdir -p "$1" 2>/dev/null || return 1
_uv_root_probe=$(mktemp "$1/.unsloth-write-probe.XXXXXX" 2>/dev/null) || return 1
# Retry the unlink and trust whether the file is gone: a held handle can make rm fail once.
_uv_root_tries=0
while :; do
rm -f "$_uv_root_probe" 2>/dev/null || true
[ -e "$_uv_root_probe" ] || break
_uv_root_tries=$((_uv_root_tries + 1))
if [ "$_uv_root_tries" -ge 3 ]; then
unset _uv_root_probe _uv_root_tries
return 1
fi
sleep 1
done
unset _uv_root_probe _uv_root_tries
return 0
}
# Also check every bucket: a root-owned bucket from an elevated run makes uv abort.
_uv_cache_is_writable() {
_uv_cache_root_is_writable "$1" || return 1
_uv_w_bad=0
# Case folding is filesystem-dependent (APFS folds, ext4 does not), so measure it.
_uv_w_fold=0
_uv_w_probe="$1/.unsloth-case-probe.$$-A"
if mkdir "$_uv_w_probe" 2>/dev/null; then
[ -d "$1/.unsloth-case-probe.$$-a" ] && _uv_w_fold=1
rmdir "$_uv_w_probe" 2>/dev/null || true
fi
unset _uv_w_probe
_uv_w_glob=on
case $- in *f*) _uv_w_glob=off ;; esac
set +f
for _uv_w_dir in "$1"/*; do
_uv_w_name="${_uv_w_dir##*/}"
if ! _uv_is_bucket_name "$_uv_w_name"; then
[ "$_uv_w_fold" = 1 ] || continue
case "$_uv_w_name" in *[[:upper:]]*) ;; *) continue ;; esac
_uv_w_lower=$(printf '%s' "$_uv_w_name" | tr '[:upper:]' '[:lower:]')
_uv_is_bucket_name "$_uv_w_lower" || continue
fi
if [ ! -d "$_uv_w_dir" ]; then
if [ -e "$_uv_w_dir" ] || [ -L "$_uv_w_dir" ]; then
_uv_w_bad=1
fi
continue
fi
_uv_cache_root_is_writable "$_uv_w_dir" || _uv_w_bad=1
done
if [ "$_uv_w_glob" = off ]; then set -f; fi
unset _uv_w_dir _uv_w_glob _uv_w_name _uv_w_lower _uv_w_fold
if [ "$_uv_w_bad" -ne 0 ]; then
unset _uv_w_bad
return 1
fi
unset _uv_w_bad
return 0
}
# Records which cache this install used so an update reuses it. UV_CACHE_DIR is made absolute
# because setup.sh changes directory.
_record_uv_cache_choice() {
UV_CACHE_DIR=$(_absolutize_uv_cache_dir)
_uv_marker_dir="$STUDIO_HOME/cache"
_uv_marker_file="$_uv_marker_dir/uv-cache-dir"
_uv_marker_value="$UV_CACHE_DIR"
if [ "$_UV_MARKER_SAVED" != true ]; then
if [ -e "$_uv_marker_file" ] || [ -L "$_uv_marker_file" ]; then
_UV_MARKER_PREVIOUS=$(cat "$_uv_marker_file" 2>/dev/null) || return 0
_UV_MARKER_EXISTED=true
else
_UV_MARKER_PREVIOUS=""
_UV_MARKER_EXISTED=false
fi
_UV_MARKER_SAVED=true
fi
(
mkdir -p "$_uv_marker_dir" 2>/dev/null &&
# Unlink first: a redirection follows a symlink and truncates its target.
rm -f "$_uv_marker_file" 2>/dev/null &&
# And only once gone: rm can fail on a link in an undeletable directory.
! { [ -e "$_uv_marker_file" ] || [ -L "$_uv_marker_file" ]; } &&
printf '%s\n' "$_uv_marker_value" > "$_uv_marker_file" 2>/dev/null
) || true
}
_restore_uv_cache_marker() {
[ "${_STUDIO_INSTALL_COMMITTED:-false}" = true ] && return 0
[ "$_UV_MARKER_SAVED" = true ] || return 0
_uv_marker_file="$STUDIO_HOME/cache/uv-cache-dir"
rm -f "$_uv_marker_file" 2>/dev/null || true
if [ "$_UV_MARKER_EXISTED" = true ] \
&& ! { [ -e "$_uv_marker_file" ] || [ -L "$_uv_marker_file" ]; }; then
printf '%s\n' "$_UV_MARKER_PREVIOUS" > "$_uv_marker_file" 2>/dev/null || true
fi
_UV_MARKER_SAVED=false
}
_configure_uv_cache() {
_uv_studio_cache="$STUDIO_HOME/cache/uv"
# Only a caller's UV_CACHE_DIR outranks selection; the flag tells it apart from our default.
if [ "${_UV_CACHE_DIR_INSTALLER_DEFAULT:-false}" != true ]; then
case "${UV_CACHE_DIR-}" in
*[![:space:]]*)
_UV_CACHE_MODE=custom
export UV_CACHE_DIR
if _uv_no_cache_requested; then
step "uv cache" "preserving custom UV_CACHE_DIR ($UV_CACHE_DIR); uv caching is off (UV_NO_CACHE), so nothing is recorded"
else
_record_uv_cache_choice
step "uv cache" "preserving custom UV_CACHE_DIR ($UV_CACHE_DIR)"
fi
return 0
;;
esac
fi
if [ "$_ISOLATE_UV_CACHE" = true ] || { [ "$_STUDIO_KEEPS_TOOLS" = true ] && _uv_cache_is_writable "$_uv_studio_cache"; }; then
UV_CACHE_DIR="$_uv_studio_cache"
_UV_CACHE_MODE=isolated
export UV_CACHE_DIR
_uv_no_cache_requested || _record_uv_cache_choice
step "uv cache" "forced Unsloth Studio cache isolation ($UV_CACHE_DIR); already-cached packages may download again" "$C_WARN"
return 0
fi
if _uv_no_cache_requested; then
UV_CACHE_DIR="$_uv_studio_cache"
_UV_CACHE_MODE=studio
export UV_CACHE_DIR
step "uv cache" "uv caching is off (UV_NO_CACHE); nothing to select or record"
return 0
fi
# Ask uv so uv.toml / UV_CONFIG_FILE count; take the last line so a notice is not the path.
_uv_default_cache=$(env -u UV_CACHE_DIR uv cache dir 2>/dev/null \
| sed -e 's/[[:space:]]*$//' -e '/^$/d' | tail -n 1) || _uv_default_cache=""
if [ -z "$_uv_default_cache" ]; then
if [ -n "${XDG_CACHE_HOME:-}" ]; then
_uv_default_cache="${XDG_CACHE_HOME}/uv"
elif [ -n "${HOME:-}" ]; then
_uv_default_cache="${HOME}/.cache/uv"
fi
fi
if [ -n "$_uv_default_cache" ]; then
_uv_default_cache=$(_absolutize_uv_cache_dir "$_uv_default_cache")
fi
# The recorded cache outranks uv's default while warm (same as studio.py _with_studio_uv_cache).
# Strip BOM and trailing CR: install.ps1 writes it with PowerShell 5.1 utf8 under WSL.
_uv_recorded=$(cat "$STUDIO_HOME/cache/uv-cache-dir" 2>/dev/null) || _uv_recorded=""
_uv_cr=$(printf '\r')
_uv_recorded="${_uv_recorded%"$_uv_cr"}"
unset _uv_cr
_uv_bom=$(printf '\357\273\277')
_uv_recorded="${_uv_recorded#"$_uv_bom"}"
unset _uv_bom
case "$_uv_recorded" in
*[![:space:]]*) _uv_recorded=$(_absolutize_uv_cache_dir "$_uv_recorded") ;;
*) _uv_recorded="" ;;
esac
# A pre-marker $STUDIO_HOME/cache/uv goes LAST, behind uv's default: its content may be
# leftover backend wheels while Torch and CUDA live in the default.
_uv_unmarked_studio=""
if [ -z "$_uv_recorded" ] || [ ! -d "$_uv_recorded" ]; then
_uv_unmarked_studio="$_uv_studio_cache"
fi
# Root and buckets must be writable; nested entries are not probed (too many files).
_uv_scan_blocked=false
_uv_blocked_cache=""
_uv_warn_cache=""
_uv_chosen_cache=""
for _uv_candidate in "$_uv_recorded" "$_uv_default_cache" "$_uv_unmarked_studio"; do
{ [ -n "$_uv_candidate" ] && [ -z "$_uv_chosen_cache" ]; } || continue
_uv_cand_populated=false
_uv_cand_writable=true
if [ -d "$_uv_candidate" ] && [ -r "$_uv_candidate" ]; then
# The globs below are the scan, so a caller's set -f would read every cache as empty.
_uv_glob=on
case $- in *f*) _uv_glob=off ;; esac
set +f
_uv_cache_is_writable "$_uv_candidate" || _uv_cand_writable=false
# Warm means package bytes: wheels-* holds metadata only. -L to match Get-ChildItem.
for _uv_bucket in \
"$_uv_candidate"/archive-* \
"$_uv_candidate"/builds-* \
"$_uv_candidate"/built-wheels-* \
"$_uv_candidate"/wheels-* \
"$_uv_candidate"/sdists-*; do
[ -d "$_uv_bucket" ] || continue
# Stricter than the probe: only kinds uv fills count (`archive-v0.backup` does not).
_uv_bucket_base="${_uv_bucket##*/}"
_uv_is_bucket_name "$_uv_bucket_base" || continue
case "${_uv_bucket_base%-v*}" in
archive|builds|built-wheels|wheels|sdists) ;;
*) continue ;;
esac
if [ ! -r "$_uv_bucket" ] || [ ! -x "$_uv_bucket" ]; then
_uv_scan_blocked=true
[ -n "$_uv_blocked_cache" ] || _uv_blocked_cache="$_uv_candidate"
continue
fi
# `|| true`: head closes the pipe and find's SIGPIPE under pipefail would drop the path.
_uv_artifact=$(find -L "$_uv_bucket" -type f \
! -name CACHEDIR.TAG ! -name .git ! -name .gitignore \
! -name '.unsloth-write-probe.*' \
! -name '*.lock' ! -name '*.msgpack' ! -name '*.http' ! -name '*.rev' \
-print 2>/dev/null | head -n 1) || true
if [ -n "$_uv_artifact" ]; then
_uv_cand_populated=true
break
fi
done
if [ "$_uv_glob" = off ]; then set -f; fi
fi
if [ "$_uv_cand_populated" = true ] && [ "$_uv_cand_writable" = true ]; then
_uv_chosen_cache="$_uv_candidate"
elif [ "$_uv_cand_populated" = true ] && [ -z "$_uv_warn_cache" ]; then
_uv_warn_cache="$_uv_candidate"
fi
done
unset _uv_candidate _uv_cand_populated _uv_cand_writable _uv_unmarked_studio
if [ -n "$_uv_chosen_cache" ]; then
UV_CACHE_DIR="$_uv_chosen_cache"
if [ "$_uv_chosen_cache" = "$_uv_studio_cache" ]; then
_UV_CACHE_MODE=studio
else
_UV_CACHE_MODE=shared
fi
else
UV_CACHE_DIR="$_uv_studio_cache"
_UV_CACHE_MODE=studio
# An unwritable Studio cache is no fallback: prefer the warm suspect cache, then uv's default.
if ! _uv_cache_is_writable "$_uv_studio_cache"; then
if [ -n "$_uv_warn_cache" ]; then
UV_CACHE_DIR="$_uv_warn_cache"
if [ "$_uv_warn_cache" != "$_uv_studio_cache" ]; then
_UV_CACHE_MODE=shared
fi
elif [ -n "$_uv_default_cache" ] \
&& [ "$_uv_default_cache" != "$_uv_studio_cache" ] \
&& _uv_cache_is_writable "$_uv_default_cache"; then
UV_CACHE_DIR="$_uv_default_cache"
_UV_CACHE_MODE=shared
_uv_studio_unusable=true
fi
fi
fi
export UV_CACHE_DIR
_record_uv_cache_choice
case "$_UV_CACHE_MODE" in
shared)
step "uv cache" "reusing existing shared cache ($UV_CACHE_DIR) to avoid duplicate Torch/CUDA downloads; use --isolated-uv-cache to isolate"
;;
studio)
if [ -n "$_uv_chosen_cache" ]; then
step "uv cache" "reusing this install's Unsloth Studio cache ($UV_CACHE_DIR)"
elif [ "$_uv_scan_blocked" = true ] && [ "$_uv_blocked_cache" != "$UV_CACHE_DIR" ]; then
step "uv cache" "using new Unsloth Studio-owned cache ($UV_CACHE_DIR); part of $_uv_blocked_cache could not be read, so cached packages may download again" "$C_WARN"
elif [ -n "$_uv_warn_cache" ] && [ "$_uv_warn_cache" != "$UV_CACHE_DIR" ]; then
step "uv cache" "using new Unsloth Studio-owned cache ($UV_CACHE_DIR); $_uv_warn_cache is populated but not writable, so cached packages may download again" "$C_WARN"
else
step "uv cache" "using new Unsloth Studio-owned cache ($UV_CACHE_DIR)"
fi
;;
esac
}
_prepare_studio_uv_cache_for_launch() {
[ "${_UV_CACHE_MODE:-}" = shared ] || return 0
# Repoint the backend only to a cache whose root AND buckets are writable.
_uv_launch_cache="$STUDIO_HOME/cache/uv"
_uv_cache_is_writable "$_uv_launch_cache" || return 0
UV_CACHE_DIR="$_uv_launch_cache"
export UV_CACHE_DIR
}
_resolve_studio_destinations
# A custom home keeps uv, its cache and the uv-managed Python inside it (#11327); the legacy root keeps the default layout.
_STUDIO_KEEPS_TOOLS=false
if [ "$_STUDIO_HOME_REDIRECT" = "env" ] && [ "$STUDIO_HOME" != "$(CDPATH= cd -P -- "$HOME/.unsloth/studio" 2>/dev/null && pwd -P)" ]; then
_STUDIO_KEEPS_TOOLS=true
[ -n "${UV_PYTHON_INSTALL_DIR:-}" ] || export UV_PYTHON_INSTALL_DIR="$STUDIO_HOME/python"
fi
# The PATH we inherited, before anything below prepends to it. The shim setup at the end asks whether a NEW login shell will find _LOCAL_BIN, and by then this process has prepended it several times (uv bootstrap, venv), so testing $PATH there answers yes for a shell that would answer no and the profile entry never gets written. astral's installer used to write that line for us; the pinned path does not.
_UNSLOTH_LOGIN_PATH="$PATH"
VENV_DIR="$STUDIO_HOME/unsloth_studio"
# Claim the root before writing into it, but only one this run may take over (env mode is a
# user workspace). A sentinel counts only as a regular non-link file outside linked dirs.
_claim_sentinel() {
[ -f "$1" ] || return 1
[ -L "$1" ] && return 1
[ -n "${2:-}" ] && [ -L "$2" ] && return 1
return 0
}
_claim_studio_root() {
_claim_marker="$STUDIO_HOME/.unsloth-studio-owned"
# Leave a valid marker alone: a kill between unlink and write would lose the proof.
_claim_sentinel "$_claim_marker" && return 0
if [ "$_STUDIO_HOME_REDIRECT" = "env" ] \
&& ! _claim_sentinel "$VENV_DIR/.unsloth-studio-owned" "$VENV_DIR" \
&& ! _claim_sentinel "$STUDIO_HOME/share/studio.conf" "$STUDIO_HOME/share"; then
if [ -d "$STUDIO_HOME" ]; then
{ [ -r "$STUDIO_HOME" ] && [ -x "$STUDIO_HOME" ]; } || return 0
_claim_glob=on
case $- in *f*) _claim_glob=off ;; esac
set +f
_claim_empty=true
for _claim_entry in "$STUDIO_HOME"/* "$STUDIO_HOME"/.[!.]* "$STUDIO_HOME"/..?*; do
if [ -e "$_claim_entry" ] || [ -L "$_claim_entry" ]; then _claim_empty=false; break; fi
done
[ "$_claim_glob" = off ] && set -f
[ "$_claim_empty" = true ] || return 0
fi
fi
mkdir -p "$STUDIO_HOME" 2>/dev/null || true
# Unlink first: the redirection would follow a symlink and truncate its target.
rm -f "$_claim_marker" 2>/dev/null || true
if [ -e "$_claim_marker" ] || [ -L "$_claim_marker" ]; then return 0; fi
printf '' > "$_claim_marker" 2>/dev/null || true
}
_claim_studio_root
# After the claim, which needs an empty root. Created even when unused: Studio reads it as this choice at runtime.
if [ "$_STUDIO_KEEPS_TOOLS" = true ]; then mkdir -p "$STUDIO_HOME/python" 2>/dev/null || true; fi
# Keep uv's cache on the same filesystem as the venv it fills.
# uv hardlinks wheels within one filesystem and copies across a boundary, so a moved STUDIO_HOME paid double the disk and stranded the cache. An explicit UV_CACHE_DIR wins. The fallback is required, since uv aborts on a cache it cannot create; mkdir -p exits 0 for an existing unwritable directory and -w reads the mode rather than the filesystem, so probe with a real create.
# True only where the installer assigns its own default; the variable reads the same either way.
_UV_CACHE_DIR_INSTALLER_DEFAULT=false
if [ -z "${UV_CACHE_DIR:-}" ]; then
UV_CACHE_DIR="$STUDIO_HOME/cache/uv"
export UV_CACHE_DIR
_UV_CACHE_DIR_INSTALLER_DEFAULT=true
if ! _uv_cache_root_is_writable "$UV_CACHE_DIR"; then
echo "[WARN] Cannot write to $UV_CACHE_DIR -- using uv's default cache." >&2
echo "[WARN] Wheels will be copied into the venv rather than hardlinked, costing extra disk." >&2
unset UV_CACHE_DIR
_UV_CACHE_DIR_INSTALLER_DEFAULT=false
fi
fi
_VENV_ROLLBACK_DIR=""
_VENV_ROLLBACK_TARGET="$VENV_DIR"
_VENV_ROLLBACK_ACTIVE=false
_UV_MARKER_SAVED=false
_UV_MARKER_EXISTED=false
_UV_MARKER_PREVIOUS=""
# One flag for both rollbacks so a signal cannot restore half a committed install.
_STUDIO_INSTALL_COMMITTED=false
_start_studio_venv_replacement() {
_existing_dir="$1"
_stamp=$(date +%Y%m%d%H%M%S 2>/dev/null || echo "time")
_candidate="$STUDIO_HOME/unsloth_studio.rollback.$_stamp.$$"
_suffix=0
while [ -e "$_candidate" ] || [ -L "$_candidate" ]; do
_suffix=$((_suffix + 1))
_candidate="$STUDIO_HOME/unsloth_studio.rollback.$_stamp.$$.$_suffix"
done
_VENV_ROLLBACK_DIR="$_candidate"
_VENV_ROLLBACK_TARGET="$_existing_dir"
_VENV_ROLLBACK_ACTIVE=true
# Publish rollback state before the rename so a signal after mv still finds the old venv.
if ! mv "$_existing_dir" "$_candidate"; then
_VENV_ROLLBACK_ACTIVE=false
_VENV_ROLLBACK_DIR=""
return 1
fi
# --no-rollback: drop the old env now. Clear rollback state first so a signal cannot restore
# a half-deleted backup.
if [ "${_NO_ROLLBACK:-false}" = true ]; then
_VENV_ROLLBACK_ACTIVE=false
_VENV_ROLLBACK_DIR=""
rm -rf "$_candidate" 2>/dev/null || true
# rm -f can fail (immutable, busy mount): report what is on disk; never re-arm rollback.
if [ -e "$_candidate" ] || [ -L "$_candidate" ]; then
_VENV_DISCARD_LEFTOVER="$_candidate"
substep "could not discard the previous environment at $_candidate" "$C_WARN"
substep "it is no longer used for rollback; remove it by hand to reclaim the space." "$C_WARN"
else
_VENV_DISCARDED=true
substep "previous environment discarded (--no-rollback); a failed install cannot be undone"
fi
return 0
fi
substep "previous environment preserved for rollback"
}
_free_space_kb() { # path
df -Pk "$1" 2>/dev/null | awk 'NR == 2 { print $4 }'
}
# Below 64 MiB a full disk is the cause. Fold the suffix into ERROR_DEFAULT: --tauri UI shows only that.
_set_disk_full_suffix() {
_DISK_FULL_SUFFIX=""
_DISK_FULL_REMEDY=""
_DISK_FULL_MB=""
[ -n "${STUDIO_HOME:-}" ] || return 0
_dfs_free=$(_free_space_kb "$STUDIO_HOME")
[ -n "$_dfs_free" ] || return 0
[ "$_dfs_free" -lt 65536 ] 2>/dev/null || return 0
_DISK_FULL_MB=$((_dfs_free / 1024))
# A failed discard left a tree behind; deleting it is likely what makes a retry fit.
if [ -n "${_VENV_DISCARD_LEFTOVER:-}" ]; then
_DISK_FULL_REMEDY="Free some space and re-run. The previous environment could not be removed and is still at $_VENV_DISCARD_LEFTOVER; deleting it will reclaim that space."
elif [ "${_VENV_DISCARDED:-false}" = true ]; then
_DISK_FULL_REMEDY="Free some space and re-run. The previous environment was already discarded by --no-rollback, so the installer has nothing further of its own to reclaim."
elif [ "${_NO_ROLLBACK:-false}" = true ]; then
_DISK_FULL_REMEDY="Free some space and re-run."
else
_DISK_FULL_REMEDY="Free some space and re-run. --no-rollback (UNSLOTH_INSTALL_NO_ROLLBACK=1) drops the previous environment instead of keeping a copy of it during the install."
fi
_DISK_FULL_SUFFIX=": $STUDIO_HOME has only $_DISK_FULL_MB MB free, so the disk is full, which is very likely the cause. $_DISK_FULL_REMEDY"
}
# uv creates only into an absent path or empty dir; hidden entries and dangling links count.
_dir_has_entries() { # dir
if [ ! -d "$1" ]; then
# -L too: mkdir(2) gives EEXIST for a dangling symlink, which -e misses.
{ [ -e "$1" ] || [ -L "$1" ]; } && return 0
return 1
fi
# Unreadable dirs cannot be enumerated and would look empty; fail closed like install.ps1.
{ [ -r "$1" ] && [ -x "$1" ]; } || return 0
_dhe_glob=on
case $- in *f*) _dhe_glob=off ;; esac
set +f
_dhe_found=1
for _dhe_entry in "$1"/* "$1"/.[!.]* "$1"/..?*; do
if [ -e "$_dhe_entry" ] || [ -L "$_dhe_entry" ]; then
_dhe_found=0
break
fi
done
[ "$_dhe_glob" = off ] && set -f
return "$_dhe_found"
}
# Version of distribution $2 in venv $1, read from its dist-info METADATA without running the interpreter. Prints nothing when there is no record or more than one.
_dist_version_from_disk() { # venv dir, distribution name
_dvd_name=$(printf '%s' "$2" | tr '.-' '__')
_dvd_meta=""
for _dvd_candidate in "$1"/lib/python*/site-packages/"$_dvd_name"-*.dist-info/METADATA; do
[ -f "$_dvd_candidate" ] || continue
[ -z "$_dvd_meta" ] || return 0
_dvd_meta=$_dvd_candidate
done
[ -n "$_dvd_meta" ] || return 0
sed -n '/^Version:/{s/^Version:[[:space:]]*//;s/[[:space:]]*$//;p;q;}' "$_dvd_meta" 2>/dev/null || true
}
# Clear $VENV_DIR for a recreate without ever destroying the only copy. The legacy-layout migration below moves $STUDIO_HOME/.venv straight into $VENV_DIR without going through _start_studio_venv_replacement, so a plain `rm -rf` there is unrecoverable: if the `uv venv` that follows cannot resolve an interpreter the user is left with no environment at all. Move it aside instead and let the exit/signal traps put it back. When a replacement is already in flight the rollback copy holds the user's real environment and $VENV_DIR is this run's own work, so plain removal stays correct.
_discard_venv_for_recreate() { # venv dir
if [ "$_VENV_ROLLBACK_ACTIVE" != true ] && [ -d "$1" ] \
&& _start_studio_venv_replacement "$1"; then
return 0
fi
rm -rf "$1"
}
_restore_studio_venv_replacement() {
[ "${_STUDIO_INSTALL_COMMITTED:-false}" = true ] && return 0
[ "$_VENV_ROLLBACK_ACTIVE" = true ] || return 0
[ -n "$_VENV_ROLLBACK_DIR" ] \
&& { [ -e "$_VENV_ROLLBACK_DIR" ] || [ -L "$_VENV_ROLLBACK_DIR" ]; } || {
_VENV_ROLLBACK_ACTIVE=false
return 0
}
rollback_substep "restoring previous environment after failed install..." "$C_WARN"
rm -rf "$_VENV_ROLLBACK_TARGET"
if mv "$_VENV_ROLLBACK_DIR" "$_VENV_ROLLBACK_TARGET"; then
rollback_substep "restored previous environment"
_VENV_ROLLBACK_ACTIVE=false
_VENV_ROLLBACK_DIR=""
else
echo "⚠️ Could not restore previous environment from $_VENV_ROLLBACK_DIR to $_VENV_ROLLBACK_TARGET" >&2
fi
}
_studio_venv_rollback_must_be_preserved() {
_rollback_name=${1##*/}
_rollback_metadata=${_rollback_name#unsloth_studio.rollback.}
_rollback_stamp=${_rollback_metadata%%.*}
_rollback_process=${_rollback_metadata#*.}
[ "$_rollback_process" != "$_rollback_metadata" ] || return 0
case "$_rollback_stamp" in
time) ;;
''|*[!0-9]*) return 0 ;;
*) [ "${#_rollback_stamp}" -eq 14 ] || return 0 ;;
esac
_rollback_pid=${_rollback_process%%.*}
case "$_rollback_pid" in
''|*[!0-9]*) return 0 ;;
esac
_rollback_suffix=${_rollback_process#*.}
if [ "$_rollback_suffix" != "$_rollback_process" ]; then
case "$_rollback_suffix" in ''|*[!0-9]*) return 0 ;; esac
fi
kill -0 "$_rollback_pid" 2>/dev/null
}
_prune_stale_studio_venv_rollbacks() {
for _stale_rollback in "$STUDIO_HOME"/unsloth_studio.rollback.*; do
[ -d "$_stale_rollback" ] || continue
if [ -L "$_stale_rollback" ]; then
echo "⚠️ Refusing to remove rollback symlink $_stale_rollback" >&2
continue
fi
_studio_venv_rollback_must_be_preserved "$_stale_rollback" && continue
if rm -rf "$_stale_rollback"; then
substep "removed stale environment rollback ${_stale_rollback##*/}"
else
echo "⚠️ Could not remove stale environment rollback $_stale_rollback" >&2
fi
done
}
_commit_studio_venv_replacement() {
# First and alone, because a signal can land between any two statements.
_STUDIO_INSTALL_COMMITTED=true
_UV_MARKER_SAVED=false
if [ "$_VENV_ROLLBACK_ACTIVE" = true ]; then
_rollback_to_remove="$_VENV_ROLLBACK_DIR"
# Clear restore state before deleting so an interrupt cannot restore a half-deleted backup.
_VENV_ROLLBACK_ACTIVE=false
_VENV_ROLLBACK_DIR=""
if [ -n "$_rollback_to_remove" ] \
&& { [ -e "$_rollback_to_remove" ] || [ -L "$_rollback_to_remove" ]; }; then
if ! rm -rf "$_rollback_to_remove"; then
echo "⚠️ Could not remove environment rollback $_rollback_to_remove" >&2
fi
fi
fi
# Prune only after success so an interrupted install keeps the last known-good env.
_prune_stale_studio_venv_rollbacks
}
_cleanup_install_temporaries() {
[ -n "${_UV_OVERRIDE_TMPDIR:-}" ] && rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true
[ -n "${_UV_INSTALL_NAME_TOOL_SHIM_DIR:-}" ] && rm -rf "$_UV_INSTALL_NAME_TOOL_SHIM_DIR" 2>/dev/null || true
[ -n "${_UV_VENV_CAPTURE_DIR:-}" ] && rm -rf "$_UV_VENV_CAPTURE_DIR" 2>/dev/null || true
[ -n "${_UNSLOTH_TORCH_OVERRIDES:-}" ] && rm -f "$_UNSLOTH_TORCH_OVERRIDES" 2>/dev/null || true
# The pinned uv path's cleanup runs only on return; a Ctrl-C would leave staging files on PATH.
[ -n "${_UIP_WORK:-}" ] && rm -rf "$_UIP_WORK" 2>/dev/null || true
[ -n "${_UIP_STAGE:-}" ] && rm -f "$_UIP_STAGE" 2>/dev/null || true
[ -n "${_UIP_STAGE2:-}" ] && rm -f "$_UIP_STAGE2" 2>/dev/null || true
[ -n "${_ROCM_TAG_MEMO_DIR:-}" ] && rm -rf "$_ROCM_TAG_MEMO_DIR" 2>/dev/null || true
[ -n "${_ric_log:-}" ] && rm -f "$_ric_log" 2>/dev/null || true
# This shell holds the probe's ceiling; kill the probe on cancel or it runs on.
if [ -n "${_UV_PROBE_TARGET:-}" ] && [ -n "${_UV_PROBE_PID:-}" ]; then
# Two seconds so a cancel does not look ignored.
_uv_probe_terminate "$_UV_PROBE_TARGET" "$_UV_PROBE_PID" 2
_UV_PROBE_TARGET=""
_UV_PROBE_PID=""
fi
}
_on_install_exit() {
_status=$?
if [ "$_status" -ne 0 ]; then
# Restore must come first and nothing may prevent it (a failed write under set -e would abort).
# Disk is measured before the restore, which can free gigabytes; `|| true` so it cannot abort.
if [ "${_DISK_FULL_REPORTED:-false}" != true ] && command -v _set_disk_full_suffix >/dev/null 2>&1; then
_set_disk_full_suffix || true
fi
_restore_studio_venv_replacement
_restore_uv_cache_marker
# Written after the restore, every write `|| true`: a closed --tauri stdout must not abort the trap.
if [ "${_DISK_FULL_REPORTED:-false}" != true ] && [ -n "${_DISK_FULL_SUFFIX:-}" ]; then
tauri_log "ERROR_DEFAULT" "unsloth studio install failed (exit code $_status)$_DISK_FULL_SUFFIX" || true
echo " $STUDIO_HOME has only $_DISK_FULL_MB MB free -- the disk is full, which is very likely the cause." >&2 || true
echo " $_DISK_FULL_REMEDY" >&2 || true
fi
fi
_cleanup_install_temporaries
exit "$_status"
}
_on_install_signal() {
_signal_status="$1"
trap - EXIT
trap '' HUP INT TERM
_restore_studio_venv_replacement
_restore_uv_cache_marker
_cleanup_install_temporaries
exit "$_signal_status"
}
# Clear inherited cleanup targets before installing traps.
_UV_OVERRIDE_TMPDIR=""
_UV_INSTALL_NAME_TOOL_SHIM_DIR=""
_UV_VENV_CAPTURE_DIR=""
_UNSLOTH_TORCH_OVERRIDES=""
_UIP_WORK=""
_UIP_STAGE=""
_UIP_STAGE2=""
_ROCM_TAG_MEMO_DIR=""
_ROCM_TAG_MEMO=""
_LSPCI_MEMO=""
_UV_PROBE_TARGET=""
_UV_PROBE_PID=""
trap _on_install_exit EXIT
trap '_on_install_signal 129' HUP
trap '_on_install_signal 130' INT
trap '_on_install_signal 143' TERM
# ── Helper: download a URL to a file (supports curl and wget) ──
download() {
if command -v curl >/dev/null 2>&1; then
curl -LsSf "$1" -o "$2"
elif command -v wget >/dev/null 2>&1; then
wget -qO "$2" "$1"
else
echo "Error: neither curl nor wget found. Install one and re-run."
exit 1
fi
}
# ── Helper: check if a single package is available on the system ──
_is_pkg_installed() {
case "$1" in
build-essential) command -v gcc >/dev/null 2>&1 ;;
libcurl4-openssl-dev)
command -v dpkg >/dev/null 2>&1 && dpkg -s "$1" >/dev/null 2>&1 ;;
pciutils)
command -v lspci >/dev/null 2>&1 ;;
bubblewrap)
command -v bwrap >/dev/null 2>&1 ;;
*) command -v "$1" >/dev/null 2>&1 ;;
esac
}
# ── Helper: human-readable apt distro label for the sudo package prompt (#6207) ──
# Distro label for the elevation prompt (#6207), read from /etc/os-release.
_apt_distro_description() {
# Plain ( ... ) subshell, not $(): bash 3.2 misparses case `;;` inside command substitution.
(
if [ ! -r /etc/os-release ]; then
printf 'a debian-like system'
exit 0
fi
# shellcheck disable=SC1091
. /etc/os-release 2>/dev/null || true
if [ -n "${NAME:-}" ] && [ -n "${VERSION_ID:-}" ]; then
_ad_label="$NAME $VERSION_ID"
elif [ -n "${PRETTY_NAME:-}" ]; then
_ad_label="$PRETTY_NAME"
elif [ -n "${NAME:-}" ]; then
_ad_label="$NAME"
else
printf 'a debian-like system'
exit 0
fi
case " ${ID:-} ${ID_LIKE:-} " in
*" debian "*|*" ubuntu "*) _ad_label="${_ad_label} (debian-like)" ;;
esac
printf '%s' "$_ad_label"
)
}
# ── Helper: can the controlling terminal actually be opened for reading? ──
# `test -r` passes in containers where open() fails with ENXIO, so probe with a real open.
# Subshell required: in dash a failed redirection on `:` exits the script.
_can_read_tty() {
( : </dev/tty ) >/dev/null 2>&1
}
_resolve_systemd_install_script() {
if [ "$_REPO_IS_CHECKOUT" = "1" ] && [ -f "$_REPO_ROOT/studio/systemd/install_user_service.sh" ]; then
printf '%s\n' "$_REPO_ROOT/studio/systemd/install_user_service.sh"
return 0
fi
[ -x "$VENV_DIR/bin/python" ] || return 0
"$VENV_DIR/bin/python" -I -c \
"import importlib.resources as r; print(r.files('studio') / 'systemd' / 'install_user_service.sh')" \
2>/dev/null || true
}
_install_systemd_user_service() {
case "$OS" in
linux|wsl) ;;
*) step "systemd" "UNSLOTH_INSTALL_SYSTEMD is Linux only; skipped" "$C_WARN"; return 0 ;;
esac
_sd_script=$(_resolve_systemd_install_script)
if [ -z "$_sd_script" ] || [ ! -f "$_sd_script" ]; then
step "systemd" "service helper not found in this install; skipped" "$C_WARN"
return 0
fi
set -- --unsloth-exe "$VENV_DIR/bin/unsloth" \
--host "${UNSLOTH_SYSTEMD_HOST:-127.0.0.1}" --port "${UNSLOTH_SYSTEMD_PORT:-8888}" --enable --start
[ "$_STUDIO_HOME_REDIRECT" != "default" ] && set -- "$@" --studio-home "$STUDIO_HOME"
if bash "$_sd_script" "$@" >/dev/null; then
_SYSTEMD_STARTED=true
step "systemd" "user service enabled (unsloth-studio.service)"
substep "status: systemctl --user status unsloth-studio.service"
substep "logs: journalctl --user -u unsloth-studio.service -f"
substep "stop: systemctl --user stop unsloth-studio.service"
substep "binds 127.0.0.1 by default; set UNSLOTH_SYSTEMD_HOST=0.0.0.0 before install for LAN"
substep "for boot without a login session, run once: loginctl enable-linger \"\$USER\""
else
step "systemd" "user service install failed; see the error above" "$C_WARN"
fi
}
# ── Helper: install packages via apt, escalating to sudo only if needed ──
_smart_apt_install() {
_PKGS="$*"
apt-get update -y </dev/null >/dev/null 2>&1 || true
apt-get install -y $_PKGS </dev/null >/dev/null 2>&1 || true
_STILL_MISSING=""
for _pkg in $_PKGS; do
if ! _is_pkg_installed "$_pkg"; then
_STILL_MISSING="$_STILL_MISSING $_pkg"
fi
done
_STILL_MISSING=$(echo "$_STILL_MISSING" | sed 's/^ *//')
if [ -z "$_STILL_MISSING" ]; then
return 0
fi
# Optional installs never elevate: no prompt or Tauri NEED_SUDO over unused build tools.
if [ "${_SMART_APT_OPTIONAL:-false}" = true ]; then
return 2
fi
if [ "$TAURI_MODE" = true ]; then
# Rust handles elevation.
tauri_log "NEED_SUDO" "$_STILL_MISSING"
exit 2
fi
if command -v sudo >/dev/null 2>&1; then
_ad_desc="$(_apt_distro_description)"
echo ""
echo " !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
echo " WARNING: We require sudo elevated permissions to install:"
echo " $_STILL_MISSING"
echo " Detected ${_ad_desc}."
echo " If you accept, we'll run sudo apt-get to install these packages"
echo " from your distro's official repositories (not a third-party tarball)."
echo " !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
echo ""
if _can_read_tty; then
printf " Accept? [Y/n] "
read -r REPLY </dev/tty || REPLY="n"
case "$REPLY" in
[nN]*)
echo ""
echo " Please install these packages first, then re-run Unsloth Studio setup:"
echo " sudo apt-get update -y && sudo apt-get install -y $_STILL_MISSING"
exit 1
;;
esac
if sudo apt-get update -y </dev/null &&
sudo apt-get install -y $_STILL_MISSING </dev/null; then
:
else
echo ""
echo " Could not install these packages: $_STILL_MISSING"
echo " See the error above."
echo " Please install them first, then re-run Unsloth Studio setup:"
echo " sudo apt-get update -y && sudo apt-get install -y $_STILL_MISSING"
exit 1
fi
else
# -n -k: only a real NOPASSWD rule passes, without prompting into a closed stdin.
echo " No terminal to confirm on; trying passwordless sudo."
if sudo -n -k apt-get update -y </dev/null &&
sudo -n -k apt-get install -y $_STILL_MISSING </dev/null; then
echo " Installed with passwordless sudo."
else
echo ""
echo " Could not install these packages: $_STILL_MISSING"
echo " Detected ${_ad_desc}."
echo " Either sudo needs a password here, or apt-get itself"
echo " failed; see the error above. With no terminal to"
echo " authenticate on, this cannot be done unattended."
echo " Please install them first, then re-run Unsloth Studio setup:"
echo " sudo apt-get update -y && sudo apt-get install -y $_STILL_MISSING"
exit 1
fi
fi
else
echo ""
echo " sudo is not available on this system."
echo " Please install these packages as root, then re-run Unsloth Studio setup:"
echo " apt-get update -y && apt-get install -y $_STILL_MISSING"
exit 1
fi
}
# ── Helper: the studio_install_id contract ──
# Install id is 64 lowercase hex (backend _STUDIO_INSTALL_ID_RE). It is baked into a single-quoted
# launcher assignment, so anything else is rejected. LC_ALL=C keeps character classes stable.
_css_install_id_is_valid() (
LC_ALL=C
export LC_ALL
case "${1:-}" in
"" | *[!0123456789abcdef]*) return 1 ;;
esac
[ "${#1}" -eq 64 ]
)
_css_read_valid_install_id() (
LC_ALL=C
export LC_ALL
[ -f "$1" ] || return 0
[ -s "$1" ] || return 0
# Shell variables drop NULs, so detect them separately or an id with NUL reads as valid.
if [ -n "$({ tr -dc '\000' < "$1" | tr '\000' 'N'; } 2>/dev/null)" ]; then
return 0
fi
# Group the redirect so the shell's own error is silenced; a failed read must not license a rewrite.
_cvi_id=$({ cat "$1"; } 2>/dev/null) || return 1
_cvi_id=${_cvi_id#"${_cvi_id%%[![:space:]]*}"}
_cvi_id=${_cvi_id%"${_cvi_id##*[![:space:]]}"}
if _css_install_id_is_valid "$_cvi_id"; then
printf '%s' "$_cvi_id"
fi
)
# ── Helper: create desktop shortcuts and launcher script ──
create_studio_shortcuts() {
_css_exe="$1"
_css_os="$2"
if [ ! -x "$_css_exe" ]; then
echo "[WARN] Cannot create shortcuts: unsloth not found at $_css_exe"
return 0
fi
_css_exe_dir=$(cd "$(dirname "$_css_exe")" && pwd)
_css_exe="$_css_exe_dir/$(basename "$_css_exe")"
_css_data_dir="$DATA_DIR"
_css_launcher="$_css_data_dir/launch-studio.sh"
_css_icon_png="$_css_data_dir/unsloth-studio.png"
_css_gem_png="$_css_data_dir/unsloth-gem.png"
mkdir -p "$_css_data_dir"
_css_id_dir="$STUDIO_HOME/share"
mkdir -p "$_css_id_dir"
_css_id_file="$_css_id_dir/studio_install_id"
# Reuse a valid id; unreadable is not malformed (another user's backend may report it).
if ! _css_studio_root_id=$(_css_read_valid_install_id "$_css_id_file"); then
echo "[WARN] Cannot create launcher: cannot read $_css_id_file" >&2
return 1
fi
if [ -z "$_css_studio_root_id" ]; then
if [ -r /dev/urandom ]; then
_css_new_id=$(od -An -N32 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n')
fi
if ! _css_install_id_is_valid "${_css_new_id:-}" && command -v python3 >/dev/null 2>&1; then
_css_new_id=$(python3 -c 'import secrets; print(secrets.token_hex(32))' 2>/dev/null)
fi
if ! _css_install_id_is_valid "${_css_new_id:-}"; then
echo "[WARN] Cannot create launcher: no entropy source for studio_install_id" >&2
return 1
fi
# Publish no-clobber via ln (EEXIST) since the desktop app mints the same id; $$ is unreliable
# in subshells, so the id is in the temp name.
_css_id_tmp="$_css_id_file.$$.$(printf '%.8s' "$_css_new_id").tmp"
if printf '%s' "$_css_new_id" > "$_css_id_tmp"; then
if ! ln "$_css_id_tmp" "$_css_id_file" 2>/dev/null; then
# Replace an invalid incumbent with one rename. -d: renaming onto a directory moves inside it.
if _css_incumbent=$(_css_read_valid_install_id "$_css_id_file") \
&& [ -z "$_css_incumbent" ] && [ ! -d "$_css_id_file" ]; then
mv "$_css_id_tmp" "$_css_id_file" 2>/dev/null || true
fi
fi
fi
rm -f "$_css_id_tmp"
if [ -f "$_css_id_file" ]; then
chmod 600 "$_css_id_file" 2>/dev/null || true
fi
_css_studio_root_id=$(_css_read_valid_install_id "$_css_id_file") || true
unset _css_new_id _css_id_tmp _css_incumbent
fi
if [ -z "$_css_studio_root_id" ]; then
echo "[WARN] Cannot create launcher: failed to read $_css_id_file" >&2
return 1
fi
_css_is_env_mode=false
[ "$_STUDIO_HOME_REDIRECT" = "env" ] && _css_is_env_mode=true
# ── Write launcher script ──
cat > "$_css_launcher" << 'LAUNCHER_EOF'
#!/usr/bin/env bash
# Unsloth Studio Launcher
# Auto-generated by install.sh -- do not edit manually.
set -euo pipefail
DATA_DIR='@@DATA_DIR@@'
_EXPECTED_STUDIO_ROOT_ID='@@STUDIO_ROOT_ID@@'
_INSTALLED_IS_ENV_MODE='@@INSTALLED_IS_ENV_MODE@@'
# Read exe path from config written at install time.
# Sourcing is safe: the config file is written by install.sh, not user input.
if [ -f "$DATA_DIR/studio.conf" ]; then
. "$DATA_DIR/studio.conf"
fi
if [ -z "${UNSLOTH_EXE:-}" ] || [ ! -x "${UNSLOTH_EXE:-}" ]; then
echo "Error: UNSLOTH_EXE not set or not executable. Re-run the installer." >&2
exit 1
fi
# A missing or malformed id makes /api/health report "", which the baked id never matches: restore ours no-clobber (the desktop app mints it too), leaving a different valid id or an unreadable file alone.
_repair_studio_install_id() (
LC_ALL=C
export LC_ALL
_rid_file=${STUDIO_INSTALL_ID_FILE:-}
[ -n "$_rid_file" ] && [ -n "$_EXPECTED_STUDIO_ROOT_ID" ] || return 0
_rid_has_valid() {
[ -e "$_rid_file" ] || return 1
[ -f "$_rid_file" ] || return 0
_rid_cur=$({ cat "$_rid_file"; } 2>/dev/null) || return 0
_rid_cur=${_rid_cur#"${_rid_cur%%[![:space:]]*}"}
_rid_cur=${_rid_cur%"${_rid_cur##*[![:space:]]}"}
case "$_rid_cur" in
"" | *[!0123456789abcdef]*) return 1 ;;
esac
[ "${#_rid_cur}" -eq 64 ]
}
_rid_has_valid && return 0
mkdir -p "$(dirname "$_rid_file")" 2>/dev/null || return 0
_rid_tmp=$(mktemp "$_rid_file.XXXXXX" 2>/dev/null) || return 0
if printf '%s' "$_EXPECTED_STUDIO_ROOT_ID" > "$_rid_tmp" 2>/dev/null; then
if ! ln "$_rid_tmp" "$_rid_file" 2>/dev/null && ! _rid_has_valid; then
mv -f "$_rid_tmp" "$_rid_file" 2>/dev/null || true
fi
chmod 600 "$_rid_file" 2>/dev/null || true
fi
rm -f "$_rid_tmp" 2>/dev/null
return 0
)
_repair_studio_install_id
BASE_PORT=8888
MAX_PORT_OFFSET=20
TIMEOUT_SEC=60
POLL_INTERVAL_SEC=0.25
LOG_FILE="$DATA_DIR/studio.log"
# why: in env-override mode multiple installs share an OS user; namespace the
# lock and remember our own healthy port so we never attach to an unrelated
# Unsloth listening on the global 8888..8908 range.
LOCK_DIR="${XDG_RUNTIME_DIR:-/tmp}/unsloth-studio-launcher-$(id -u).lock"
PORT_FILE=""
# why: gate on the install-time mode (baked above) instead of the runtime env
# var; sourcing a custom-root studio.conf in shell must not flip a default-mode
# launcher into env-mode behavior with stale state.
if [ "$_INSTALLED_IS_ENV_MODE" = "true" ]; then
if command -v cksum >/dev/null 2>&1; then
_LOCK_KEY=$(printf '%s' "$DATA_DIR" | cksum | awk '{print $1}')
else
_LOCK_KEY=""
fi
[ -n "$_LOCK_KEY" ] && LOCK_DIR="${XDG_RUNTIME_DIR:-/tmp}/unsloth-studio-launcher-$(id -u)-${_LOCK_KEY}.lock"
PORT_FILE="$DATA_DIR/studio.port"
fi
# ── HTTP GET helper (supports curl and wget) ──
_http_get() {
_url="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsS --max-time 1 "$_url" 2>/dev/null
elif command -v wget >/dev/null 2>&1; then
wget -qO- --timeout=1 "$_url" 2>/dev/null
else
return 1
fi
}
# ── Health check ──
_check_health() {
_port=$1
_resp=$(_http_get "http://127.0.0.1:$_port/api/health") || return 1
case "$_resp" in
*'"status"'*'"healthy"'*'"service"'*'"Unsloth UI Backend"'*) ;;
*'"service"'*'"Unsloth UI Backend"'*'"status"'*'"healthy"'*) ;;
*) return 1 ;;
esac
# why: verify the backend belongs to THIS install. Baked hex digest avoids
# JSON-escape mismatches on paths with `\`/`"` and avoids leaking the raw
# install path to unauthenticated callers.
if [ -n "$_EXPECTED_STUDIO_ROOT_ID" ]; then
case "$_resp" in
*"\"studio_root_id\":\"$_EXPECTED_STUDIO_ROOT_ID\""*|*"\"studio_root_id\": \"$_EXPECTED_STUDIO_ROOT_ID\""*) return 0 ;;
*) return 1 ;;
esac
fi
return 0
}
# ── Port scanning ──
_candidate_ports() {
echo "$BASE_PORT"
_max_port=$((BASE_PORT + MAX_PORT_OFFSET))
if command -v ss >/dev/null 2>&1; then
ss -tlnH 2>/dev/null | awk '{print $4}' | grep -oE '[0-9]+$' | \
awk -v lo="$BASE_PORT" -v hi="$_max_port" '$1 >= lo && $1 <= hi && $1 != lo {print}' || true
elif command -v lsof >/dev/null 2>&1; then
lsof -iTCP -sTCP:LISTEN -nP 2>/dev/null | awk '{print $9}' | grep -oE '[0-9]+$' | \
awk -v lo="$BASE_PORT" -v hi="$_max_port" '$1 >= lo && $1 <= hi && $1 != lo {print}' || true
else
_offset=1
while [ "$_offset" -le "$MAX_PORT_OFFSET" ]; do
echo $((BASE_PORT + _offset))
_offset=$((_offset + 1))
done
fi
}
_find_healthy_port() {
if [ -n "$PORT_FILE" ] && [ -f "$PORT_FILE" ]; then
# why: env-mode installs only attach to a port we previously launched
# ourselves; never to a sibling Unsloth that happens to be healthy.
_p=$(cat "$PORT_FILE" 2>/dev/null || true)
case "$_p" in
''|*[!0-9]*) ;;
*)
if _check_health "$_p"; then
echo "$_p"
return 0
fi
rm -f "$PORT_FILE"
;;
esac
return 1
fi
if [ -n "$PORT_FILE" ]; then
return 1
fi
for _p in $(_candidate_ports | sort -un); do
if _check_health "$_p"; then
echo "$_p"
return 0
fi
done
return 1
}
# ── Check if a port is busy ──
_is_port_busy() {
_port=$1
if command -v ss >/dev/null 2>&1; then
ss -tlnH 2>/dev/null | awk '{print $4}' | grep -qE "[.:]$_port$"
elif command -v lsof >/dev/null 2>&1; then
lsof -iTCP:"$_port" -sTCP:LISTEN -nP >/dev/null 2>&1
else
return 1
fi
}
# ── Find a free port in range ──
_find_launch_port() {
_offset=0
while [ "$_offset" -le "$MAX_PORT_OFFSET" ]; do
_candidate=$((BASE_PORT + _offset))
if ! _is_port_busy "$_candidate"; then
echo "$_candidate"
return 0
fi
_offset=$((_offset + 1))
done
return 1
}
# ── Open browser ──
_open_browser() {
_url="$1"
if [ "$(uname)" = "Darwin" ] && command -v open >/dev/null 2>&1; then
open "$_url"
elif grep -qi microsoft /proc/version 2>/dev/null; then
# WSL: xdg-open is unreliable; use Windows browser via PowerShell or cmd
if command -v powershell.exe >/dev/null 2>&1; then
powershell.exe -NoProfile -Command "Start-Process '$_url'" >/dev/null 2>&1 &
elif command -v cmd.exe >/dev/null 2>&1; then
cmd.exe /c start "" "$_url" >/dev/null 2>&1 &
elif command -v xdg-open >/dev/null 2>&1; then
xdg-open "$_url" >/dev/null 2>&1 &
else
echo "Open in your browser: $_url" >&2
fi
elif command -v xdg-open >/dev/null 2>&1; then
xdg-open "$_url" >/dev/null 2>&1 &
else
echo "Open in your browser: $_url" >&2
fi
}
# ── Spawn terminal with studio command ──
_spawn_terminal() {
_cmd="$1"
_os=$(uname)
if [ "$_os" = "Darwin" ]; then
# AppleEvents are TCC-denied from unsigned .app bundles; spawn
# Terminal via a .command file + Launch Services instead. Server
# is nohup'd so warm relaunches hit the fast-path; watcher + trap
# in the .command couple Terminal close <-> server shutdown.
# `exec` keeps the recorded PID equal to the studio process so
# signals reach studio directly rather than a wrapper shell.
nohup sh -c "exec $_cmd" >> "$LOG_FILE" 2>&1 &
_server_pid=$!
_pid_file="$DATA_DIR/studio-$_launch_port.pid"
printf '%d\n' "$_server_pid" > "$_pid_file" 2>/dev/null || true
_cmd_file="$DATA_DIR/launch-terminal.command"
_logfile_q=$(printf '%s' "$LOG_FILE" | sed "s/'/'\\\\''/g")
_pidfile_q=$(printf '%s' "$_pid_file" | sed "s/'/'\\\\''/g")
if {
{
printf '#!/bin/bash\n'
printf "SERVER_PID=%s\n" "$_server_pid"
printf "PID_FILE='%s'\n" "$_pidfile_q"
# Wait up to 12s for graceful shutdown before SIGKILL.
printf 'shutdown_studio() {\n'
printf ' kill -TERM "$SERVER_PID" 2>/dev/null\n'
printf ' _i=0\n'
printf ' while kill -0 "$SERVER_PID" 2>/dev/null && [ "$_i" -lt 24 ]; do\n'
printf ' sleep 0.5\n'
printf ' _i=$((_i + 1))\n'
printf ' done\n'
printf ' kill -0 "$SERVER_PID" 2>/dev/null && kill -KILL "$SERVER_PID" 2>/dev/null\n'
printf ' rm -f "$PID_FILE" 2>/dev/null\n'
printf '}\n'
printf "tail -n 100 -F '%s' &\n" "$_logfile_q"
printf 'TAIL_PID=$!\n'
# Server gone -> kill tail so bash exits cleanly.
printf '(\n'
printf ' while kill -0 "$SERVER_PID" 2>/dev/null; do sleep 1; done\n'
printf ' kill "$TAIL_PID" 2>/dev/null\n'
printf ') &\n'
printf 'WATCHER_PID=$!\n'
printf "trap 'shutdown_studio; kill \"\$WATCHER_PID\" \"\$TAIL_PID\" 2>/dev/null; exit' HUP INT TERM\n"
printf "trap 'rm -f \"\$PID_FILE\" 2>/dev/null' EXIT\n"
printf 'wait "$TAIL_PID" 2>/dev/null\n'
} > "$_cmd_file" 2>/dev/null \
&& chmod +x "$_cmd_file" 2>/dev/null \
&& open -a Terminal "$_cmd_file" 2>/dev/null
}; then
# Foreground Terminal (Launch Services spawns us backgrounded).
osascript -e 'tell application "Terminal" to activate' >/dev/null 2>&1 || true
return 0
fi
# .command/open failed: kill orphan, fall through to generic fallback.
kill -TERM "$_server_pid" 2>/dev/null || true
_i=0
while kill -0 "$_server_pid" 2>/dev/null && [ "$_i" -lt 6 ]; do
sleep 0.5
_i=$((_i + 1))
done
kill -0 "$_server_pid" 2>/dev/null && kill -KILL "$_server_pid" 2>/dev/null || true
rm -f "$_pid_file" 2>/dev/null || true
echo "[WARN] Could not open Terminal; falling back to background launch" >&2
else
for _term in gnome-terminal konsole xfce4-terminal mate-terminal lxterminal xterm; do
if command -v "$_term" >/dev/null 2>&1; then
case "$_term" in
gnome-terminal) "$_term" -- sh -c "$_cmd" & return 0 ;;
konsole) "$_term" -e sh -c "$_cmd" & return 0 ;;
xterm) "$_term" -e sh -c "$_cmd" & return 0 ;;
*) "$_term" -e sh -c "$_cmd" & return 0 ;;
esac
fi
done
fi
# Fallback: background with log
echo "No terminal emulator found; running in background. Logs: $LOG_FILE" >&2
nohup sh -c "$_cmd" >> "$LOG_FILE" 2>&1 &
return 0
}
# ── Atomic directory-based single-instance guard ──
_acquire_lock() {
if mkdir "$LOCK_DIR" 2>/dev/null; then
echo "$$" > "$LOCK_DIR/pid"
return 0
fi
# Lock dir exists -- check if owner is still alive
_old_pid=$(cat "$LOCK_DIR/pid" 2>/dev/null || true)
if [ -n "$_old_pid" ] && kill -0 "$_old_pid" 2>/dev/null; then
# Another launcher is running; wait for it to bring Unsloth up
_deadline=$(($(date +%s) + TIMEOUT_SEC))
while [ "$(date +%s)" -lt "$_deadline" ]; do
_port=$(_find_healthy_port) && {
_open_browser "http://localhost:$_port"
exit 0
}
sleep "$POLL_INTERVAL_SEC"
done
echo "Timed out waiting for other launcher (PID $_old_pid)" >&2
exit 0
fi
# Stale lock -- reclaim
rm -rf "$LOCK_DIR"
mkdir "$LOCK_DIR" 2>/dev/null || return 1
echo "$$" > "$LOCK_DIR/pid"
}
_release_lock() {
[ -d "$LOCK_DIR" ] || return 0
[ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$$" ] || return 0
rm -rf "$LOCK_DIR"
}
# ── Main ──
# Fast path: already healthy
_port=$(_find_healthy_port) && {
_open_browser "http://localhost:$_port"
exit 0
}
_acquire_lock
trap '_release_lock' EXIT INT TERM
# Post-lock re-check (handles race with another launcher)
_port=$(_find_healthy_port) && {
_open_browser "http://localhost:$_port"
exit 0
}
# Find a free port in range
_launch_port=$(_find_launch_port) || {
echo "No free port found in range ${BASE_PORT}-$((BASE_PORT + MAX_PORT_OFFSET))" >&2
exit 1
}
if [ -t 1 ]; then
# ── Foreground mode (TTY available) ──
# Background subshell: wait for studio to become healthy, release the
# single-instance lock, then open the browser. The lock stays held until
# health is confirmed so a second launcher cannot race during startup.
(
_obwr_deadline=$(($(date +%s) + TIMEOUT_SEC))
while [ "$(date +%s)" -lt "$_obwr_deadline" ]; do
if _check_health "$_launch_port"; then
[ -n "$PORT_FILE" ] && printf '%s\n' "$_launch_port" > "$PORT_FILE" 2>/dev/null || true
_release_lock
_open_browser "http://localhost:$_launch_port"
exit 0
fi
sleep "$POLL_INTERVAL_SEC"
done
# Timed out -- release the lock anyway so future launches are not blocked
_release_lock
) &
# Clear traps so exec does not trigger _release_lock (the subshell owns it)
trap - EXIT INT TERM
exec "$UNSLOTH_EXE" studio -p "$_launch_port"
else
# ── Background mode (no TTY) ──
# Used by macOS .app and headless invocations.
_launch_cmd=$(printf '%q ' "$UNSLOTH_EXE" studio -p "$_launch_port")
_launch_cmd=${_launch_cmd% }
_spawn_terminal "$_launch_cmd"
# Poll for health on the specific port we launched on
_deadline=$(($(date +%s) + TIMEOUT_SEC))
while [ "$(date +%s)" -lt "$_deadline" ]; do
if _check_health "$_launch_port"; then
[ -n "$PORT_FILE" ] && printf '%s\n' "$_launch_port" > "$PORT_FILE" 2>/dev/null || true
_open_browser "http://localhost:$_launch_port"
exit 0
fi
sleep "$POLL_INTERVAL_SEC"
done
echo "Unsloth Studio did not become healthy within ${TIMEOUT_SEC}s." >&2
echo "Check logs at: $LOG_FILE" >&2
exit 1
fi
LAUNCHER_EOF
# Bake fixed placeholders FIRST so a literal @@STUDIO_ROOT_ID@@ in $DATA_DIR survives.
sed -e "s|@@STUDIO_ROOT_ID@@|$_css_studio_root_id|g" \
-e "s|@@INSTALLED_IS_ENV_MODE@@|$_css_is_env_mode|g" \
"$_css_launcher" > "$_css_launcher.tmp" \
&& mv "$_css_launcher.tmp" "$_css_launcher"
if [ "$_STUDIO_HOME_REDIRECT" = "env" ]; then
_sq_escaped=$(printf '%s' "$DATA_DIR" | sed "s/'/'\\\\''/g")
_sed_safe=$(printf '%s' "$_sq_escaped" | sed 's/[\\&|]/\\&/g')
sed "s|@@DATA_DIR@@|$_sed_safe|g" "$_css_launcher" > "$_css_launcher.tmp" \
&& mv "$_css_launcher.tmp" "$_css_launcher"
else
sed "s|DATA_DIR='@@DATA_DIR@@'|DATA_DIR=\"\$HOME/.local/share/unsloth\"|" \
"$_css_launcher" > "$_css_launcher.tmp" \
&& mv "$_css_launcher.tmp" "$_css_launcher"
fi
chmod +x "$_css_launcher"
_css_quoted_exe=$(printf '%s' "$_css_exe" | sed "s/'/'\\\\''/g")
{
printf '%s\n' "UNSLOTH_EXE='$_css_quoted_exe'"
_css_quoted_id_file=$(printf '%s' "$_css_id_file" | sed "s/'/'\\\\''/g")
printf '%s\n' "STUDIO_INSTALL_ID_FILE='$_css_quoted_id_file'"
if [ "$_STUDIO_HOME_REDIRECT" = "env" ]; then
_css_legacy_studio="$HOME/.unsloth/studio"
if [ -d "$_css_legacy_studio" ]; then
_css_legacy_studio=$(CDPATH= cd -P -- "$_css_legacy_studio" 2>/dev/null && pwd -P) \
|| _css_legacy_studio="$HOME/.unsloth/studio"
fi
_css_master_root=$(printf '%s' "${UNSLOTH_HOME:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
if [ -n "$_css_master_root" ]; then
case "$_css_master_root" in
"~") _css_master_root="$HOME" ;;
"~/"*) _css_master_root="$HOME/${_css_master_root#'~/'}" ;;
esac
case "$_css_master_root" in /*) ;; *) _css_master_root="$PWD/$_css_master_root" ;; esac
_css_master_canon=$(CDPATH= cd -P -- "$_css_master_root" 2>/dev/null && pwd -P) || _css_master_canon=""
[ -z "$_css_master_canon" ] || _css_master_root="$_css_master_canon"
_css_llama_path="${_css_master_root%/}/llama.cpp"
elif [ "$STUDIO_HOME" = "$_css_legacy_studio" ]; then
_css_llama_path="$HOME/.unsloth/llama.cpp"
else
_css_llama_path="$STUDIO_HOME/llama.cpp"
fi
_css_quoted_home=$(printf '%s' "$STUDIO_HOME" | sed "s/'/'\\\\''/g")
_css_quoted_llama=$(printf '%s' "$_css_llama_path" | sed "s/'/'\\\\''/g")
printf '%s\n' "export UNSLOTH_STUDIO_HOME='$_css_quoted_home'"
printf '%s\n' 'if [ -z "${UNSLOTH_LLAMA_CPP_PATH:-}" ]; then'
printf '%s\n' " export UNSLOTH_LLAMA_CPP_PATH='$_css_quoted_llama'"
printf '%s\n' 'fi'
fi
} > "$_css_data_dir/studio.conf"
# ── Icon: try bundled, then download ──
_css_script_dir=""
if [ -n "${0:-}" ] && [ -f "$0" ]; then
_css_script_dir=$(cd "$(dirname "$0")" 2>/dev/null && pwd) || true
fi
_css_found_icon=""
_css_venv_dir=$(dirname "$(dirname "$_css_exe")")
# `studio` is a top-level package in the wheel, and only frontend/dist ships.
for _sp in "$_css_venv_dir"/lib/python*/site-packages/studio/frontend/dist; do
if [ -f "$_sp/rounded-512.png" ]; then
_css_found_icon="$_sp/rounded-512.png"
fi
done
if [ -z "$_css_found_icon" ] && [ -n "$_css_script_dir" ] && [ -f "$_css_script_dir/studio/frontend/public/rounded-512.png" ]; then
_css_found_icon="$_css_script_dir/studio/frontend/public/rounded-512.png"
fi
if [ -n "$_css_found_icon" ]; then
cp "$_css_found_icon" "$_css_icon_png" 2>/dev/null || true
cp "$_css_found_icon" "$_css_gem_png" 2>/dev/null || true
else
download "https://raw.githubusercontent.com/unslothai/unsloth/main/studio/frontend/public/rounded-512.png" "$_css_icon_png" 2>/dev/null || true
cp "$_css_icon_png" "$_css_gem_png" 2>/dev/null || true
fi
_css_validate_png() {
[ -f "$1" ] || return 1
_hdr=$(od -An -tx1 -N4 "$1" 2>/dev/null | tr -d ' ')
[ "$_hdr" = "89504e47" ]
}
if [ -f "$_css_icon_png" ] && ! _css_validate_png "$_css_icon_png"; then
rm -f "$_css_icon_png"
fi
if [ -f "$_css_gem_png" ] && ! _css_validate_png "$_css_gem_png"; then
rm -f "$_css_gem_png"
fi
_css_tauri_icns=""
for _sp in "$_css_venv_dir"/lib/python*/site-packages/studio/src-tauri/icons; do
if [ -f "$_sp/icon.icns" ]; then
_css_tauri_icns="$_sp/icon.icns"
fi
done
if [ -z "$_css_tauri_icns" ] && [ -n "$_css_script_dir" ] && [ -f "$_css_script_dir/studio/src-tauri/icons/icon.icns" ]; then
_css_tauri_icns="$_css_script_dir/studio/src-tauri/icons/icon.icns"
fi
_css_tauri_png=""
for _sp in "$_css_venv_dir"/lib/python*/site-packages/studio/src-tauri/icons; do
if [ -f "$_sp/icon.png" ]; then
_css_tauri_png="$_sp/icon.png"
fi
done
if [ -z "$_css_tauri_png" ] && [ -n "$_css_script_dir" ] && [ -f "$_css_script_dir/studio/src-tauri/icons/icon.png" ]; then
_css_tauri_png="$_css_script_dir/studio/src-tauri/icons/icon.png"
fi
# ── Platform-specific shortcuts ──
# Env-mode is workspace-scoped: skip launchers that may point at a deleted workspace.
if [ "$_STUDIO_HOME_REDIRECT" = "env" ]; then
substep "wrote launcher at $_css_launcher (persistent shortcuts skipped in env-override mode)"
return 0
fi
_css_created=0
if [ "$_css_os" = "linux" ]; then
# ── Linux: .desktop file ──
_css_app_dir="$HOME/.local/share/applications"
mkdir -p "$_css_app_dir"
_css_desktop="$_css_app_dir/unsloth-studio.desktop"
_css_exec_escaped=$(printf '%s' "$_css_launcher" | sed 's/\\/\\\\/g; s/"/\\"/g')
# Prefer the higher-resolution Tauri icon.png, but persist it under the installed data directory so local-checkout shortcuts survive repo moves.
_css_desktop_icon="$_css_icon_png"
if [ -f "$_css_tauri_png" ]; then
_css_desktop_icon_tmp="${_css_icon_png}.tmp"
if cp "$_css_tauri_png" "$_css_desktop_icon_tmp" 2>/dev/null \
&& mv "$_css_desktop_icon_tmp" "$_css_icon_png" 2>/dev/null; then
:
else
rm -f "$_css_desktop_icon_tmp"
fi
fi
_css_icon_escaped=$(printf '%s' "$_css_desktop_icon" | sed 's/\\/\\\\/g; s/"/\\"/g')
cat > "$_css_desktop" << DESKTOP_EOF
[Desktop Entry]
Version=1.0
Type=Application
Name=Unsloth Studio
Comment=Launch Unsloth Studio
Exec="$_css_exec_escaped"
Icon=$_css_icon_escaped
Terminal=true
StartupNotify=true
Categories=Development;Science;
DESKTOP_EOF
chmod +x "$_css_desktop"
if [ -d "$HOME/Desktop" ]; then
cp "$_css_desktop" "$HOME/Desktop/unsloth-studio.desktop" 2>/dev/null || true
chmod +x "$HOME/Desktop/unsloth-studio.desktop" 2>/dev/null || true
if command -v gio >/dev/null 2>&1; then
gio set "$HOME/Desktop/unsloth-studio.desktop" metadata::trusted true 2>/dev/null || true
fi
fi
update-desktop-database "$_css_app_dir" 2>/dev/null || true
_css_created=1
elif [ "$_css_os" = "macos" ]; then
# ── macOS: .app bundle ──
_css_app="$HOME/Applications/Unsloth Studio.app"
_css_contents="$_css_app/Contents"
_css_macos_dir="$_css_contents/MacOS"
_css_res_dir="$_css_contents/Resources"
if [ -L "$_css_app" ] || [ -L "$_css_contents" ] \
|| [ -L "$_css_macos_dir" ] || [ -L "$_css_res_dir" ]; then
rm -rf "$_css_app" 2>/dev/null || {
echo "[ERROR] $_css_app contains a symlinked bundle path; remove manually and re-run install" >&2
return 1
}
elif [ -e "$_css_app" ] && [ ! -d "$_css_app" ]; then
echo "[ERROR] $_css_app exists but is not a directory; remove manually and re-run install" >&2
return 1
fi
# Old `ln -sf` shortcuts planted a self-referential link inside the bundle.
if [ -L "$_css_app/Unsloth Studio.app" ]; then
rm -f "$_css_app/Unsloth Studio.app" 2>/dev/null || true
fi
mkdir -p "$_css_macos_dir" "$_css_res_dir"
cat > "$_css_contents/Info.plist" << 'PLIST_EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>ai.unsloth.studio</string>
<key>CFBundleName</key>
<string>Unsloth Studio</string>
<key>CFBundleDisplayName</key>
<string>Unsloth Studio</string>
<key>CFBundleExecutable</key>
<string>launch-studio</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>LSMinimumSystemVersion</key>
<string>10.15</string>
<key>LSArchitecturePriority</key>
<array>
<string>arm64</string>
<string>x86_64</string>
</array>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
PLIST_EOF
_css_sq_dir=$(printf '%s' "$_css_data_dir" | sed "s/'/'\\\\''/g")
_css_sed_dir=$(printf '%s' "$_css_sq_dir" | sed 's/[\\&|]/\\&/g')
# A script-only bundle may be started under Rosetta (#9467), and a translated shell hands the x86_64 slice of a universal2 venv python its arm64 wheels. LSArchitecturePriority asks Launch Services for arm64, and arch -arm64 also covers a bundle id whose Rosetta choice Launch Services already cached. hw.optional.arm64 is absent on Intel, so Intel keeps the plain exec.
cat > "$_css_macos_dir/launch-studio" << 'STUB_EOF'
#!/bin/sh
if [ "$(/usr/sbin/sysctl -in hw.optional.arm64 2>/dev/null)" = "1" ] && [ -x /usr/bin/arch ]; then
exec /usr/bin/arch -arm64 '@@DATA_DIR@@/launch-studio.sh' "$@"
fi
exec '@@DATA_DIR@@/launch-studio.sh' "$@"
STUB_EOF
sed "s|@@DATA_DIR@@|$_css_sed_dir|g" "$_css_macos_dir/launch-studio" \
> "$_css_macos_dir/launch-studio.tmp" \
&& mv "$_css_macos_dir/launch-studio.tmp" "$_css_macos_dir/launch-studio"
chmod +x "$_css_macos_dir/launch-studio"
# Prefer the pre-built Tauri icon.icns; fall back to generating one from the gem PNG via sips+iconutil, then to a plain PNG copy.
# ── AppIcon ──
if [ -f "$_css_tauri_icns" ] \
&& cp "$_css_tauri_icns" "$_css_res_dir/AppIcon.icns" 2>/dev/null; then
:
elif [ -f "$_css_gem_png" ] && command -v sips >/dev/null 2>&1 && command -v iconutil >/dev/null 2>&1; then
_css_tmpdir=$(mktemp -d 2>/dev/null)
if [ -d "$_css_tmpdir" ]; then
_css_iconset="$_css_tmpdir/AppIcon.iconset"
mkdir -p "$_css_iconset"
_css_icon_ok=true
for _sz in 16 32 128 256 512; do
_sz2=$((_sz * 2))
sips -z "$_sz" "$_sz" "$_css_gem_png" --out "$_css_iconset/icon_${_sz}x${_sz}.png" >/dev/null 2>&1 || _css_icon_ok=false
sips -z "$_sz2" "$_sz2" "$_css_gem_png" --out "$_css_iconset/icon_${_sz}x${_sz}@2x.png" >/dev/null 2>&1 || _css_icon_ok=false
done
if [ "$_css_icon_ok" = "true" ]; then
iconutil -c icns "$_css_iconset" -o "$_css_res_dir/AppIcon.icns" 2>/dev/null || true
fi
rm -rf "$_css_tmpdir"
fi
fi
if [ ! -f "$_css_res_dir/AppIcon.icns" ] && [ -f "$_css_icon_png" ]; then
cp "$_css_icon_png" "$_css_res_dir/AppIcon.icns" 2>/dev/null || true
fi
# Touch so Finder indexes it
touch "$_css_app"
# -n is required: without it a re-run creates the link inside the bundle.
if [ -d "$HOME/Desktop" ]; then
ln -sfn "$_css_app" "$HOME/Desktop/Unsloth Studio" 2>/dev/null || true
fi
_css_created=1
elif [ "$_css_os" = "wsl" ]; then
# ── WSL: create Windows Desktop and Start Menu shortcuts ──
_css_distro="${WSL_DISTRO_NAME:-}"
_css_wsl_args=""
if [ -n "$_css_distro" ]; then
_css_wsl_args="-d \"$_css_distro\" "
fi
_css_wsl_args="${_css_wsl_args}-- bash -l -c \"exec \\\"$_css_launcher\\\"\""
_css_use_wt=false
if command -v wt.exe >/dev/null 2>&1; then
_css_use_wt=true
fi
if [ "$_css_use_wt" = true ]; then
_css_sc_target='wt.exe'
_css_sc_args="wsl.exe $_css_wsl_args"
else
_css_sc_target='wsl.exe'
_css_sc_args="$_css_wsl_args"
fi
_css_sc_args_ps=$(printf '%s' "$_css_sc_args" | sed "s/'/''/g")
if [ -n "$_css_distro" ]; then
_css_lnk_name="Unsloth Studio (WSL - ${_css_distro}).lnk"
else
_css_lnk_name="Unsloth Studio (WSL).lnk"
fi
_css_lnk_name_ps=$(printf '%s' "$_css_lnk_name" | sed "s/'/''/g")
_css_wsl_ico_win=""
for _sp in "$_css_venv_dir"/lib/python*/site-packages/studio/frontend/dist; do
if [ -f "$_sp/unsloth.ico" ] && command -v wslpath >/dev/null 2>&1; then
_css_wsl_ico_win=$(wslpath -w "$_sp/unsloth.ico" 2>/dev/null) || _css_wsl_ico_win=""
fi
done
_css_wsl_ico_win_ps=$(printf '%s' "$_css_wsl_ico_win" | sed "s/'/''/g")
# Create shortcuts via a temp PowerShell script to avoid escaping issues.
# Written to Windows %TEMP%, not WSL /tmp: a UNC path is remote and RemoteSigned refuses it.
_css_win_temp=""
if command -v wslpath >/dev/null 2>&1 && command -v cmd.exe >/dev/null 2>&1; then
# cmd /d skips AutoRun banners; values are quoted because cmd expands %TEMP% before parsing &.
# Candidates in order; a redirected network %TEMP% is remote, so fall through to a local dir.
_css_win_temp_list=$(cmd.exe /d /c 'echo "%TEMP%"&echo "%LOCALAPPDATA%\Temp"&echo "%SystemRoot%\Temp"' 2>/dev/null \
| tr -d '\r') || _css_win_temp_list=""
# Mapped network drives are the remote zone too, so exclude them like UNC paths.
_css_net_drives=$(cmd.exe /d /c 'net use' 2>/dev/null | tr -d '\r' \
| awk '/\\\\/ { for (i = 1; i <= NF; i++) if ($i ~ /^[A-Za-z]:$/) print substr($i, 1, 1) }' \
| tr 'a-z' 'A-Z') || _css_net_drives=""
_css_old_ifs=$IFS
IFS='
'
for _css_cand in $_css_win_temp_list; do
IFS=$_css_old_ifs
_css_cand=${_css_cand#\"}
_css_cand=${_css_cand%\"}
_css_cand=$(printf '%s' "$_css_cand" | sed 's/[[:space:]]*$//')
case "$_css_cand" in
# Unexpanded variable, or a UNC path, which is the remote zone.
""|'%'*'%'*|'\\'*) continue ;;
esac
_css_cand_letter=$(printf '%s' "$_css_cand" | cut -c1 | tr 'a-z' 'A-Z')
_css_is_net=0
for _css_nd in $_css_net_drives; do
[ "$_css_nd" = "$_css_cand_letter" ] && _css_is_net=1 && break
done
[ "$_css_is_net" = 1 ] && continue
_css_cand_unix=$(wslpath -u "$_css_cand" 2>/dev/null) || continue
[ -d "$_css_cand_unix" ] || continue
_css_win_temp=$_css_cand_unix
break
done
IFS=$_css_old_ifs
if [ -n "$_css_win_temp" ] && [ ! -d "$_css_win_temp" ]; then
_css_win_temp=""
fi
fi
# No fallback to WSL /tmp: it is a UNC path again, and this branch is best-effort.
_css_ps1_tmp=""
if [ -n "$_css_win_temp" ]; then
_css_ps1_tmp=$(mktemp "$_css_win_temp/unsloth-shortcut-XXXXXX.ps1" 2>/dev/null) || _css_ps1_tmp=""
fi
if [ -n "$_css_sc_target" ]; then
_css_ps1_body=$(cat << WSLPS1_EOF
\$WshShell = New-Object -ComObject WScript.Shell
\$targetExe = (Get-Command '$_css_sc_target' -ErrorAction SilentlyContinue).Source
if (-not \$targetExe) { exit 1 }
# Best-effort: fetch the Unsloth icon to a stable Windows path (shared with a
# native install if one exists) so the WSL shortcut shows the proper icon.
\$iconDir = Join-Path \$env:LOCALAPPDATA 'Unsloth Studio'
\$iconPath = Join-Path \$iconDir 'unsloth.ico'
\$preIconHash = \$null
if (Test-Path -LiteralPath \$iconPath) {
try { \$preIconHash = (Get-FileHash -LiteralPath \$iconPath -Algorithm SHA256).Hash } catch {}
}
\$packagedIcon = '$_css_wsl_ico_win_ps'
if (-not (Test-Path -LiteralPath \$iconPath) -and \$packagedIcon -and (Test-Path -LiteralPath \$packagedIcon)) {
try {
New-Item -ItemType Directory -Force -Path \$iconDir | Out-Null
Copy-Item -LiteralPath \$packagedIcon -Destination \$iconPath -Force -ErrorAction Stop
} catch {}
}
if (-not (Test-Path -LiteralPath \$iconPath)) {
try {
New-Item -ItemType Directory -Force -Path \$iconDir | Out-Null
Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/unslothai/unsloth/main/studio/frontend/public/unsloth.ico' -OutFile \$iconPath -UseBasicParsing -ErrorAction Stop
} catch {}
}
\$hasIcon = \$false
if (Test-Path -LiteralPath \$iconPath) {
try { \$b = [System.IO.File]::ReadAllBytes(\$iconPath); if (\$b.Length -ge 4 -and \$b[0] -eq 0 -and \$b[1] -eq 0 -and \$b[2] -eq 1 -and \$b[3] -eq 0) { \$hasIcon = \$true } } catch {}
}
\$locations = @(
[Environment]::GetFolderPath('Desktop'),
(Join-Path \$env:APPDATA 'Microsoft\Windows\Start Menu\Programs')
)
\$created = @()
\$firstShortcut = \$false
foreach (\$dir in \$locations) {
if (-not \$dir -or -not (Test-Path \$dir)) { continue }
\$linkPath = Join-Path \$dir '$_css_lnk_name_ps'
if (-not (Test-Path -LiteralPath \$linkPath)) { \$firstShortcut = \$true }
\$shortcut = \$WshShell.CreateShortcut(\$linkPath)
\$shortcut.TargetPath = \$targetExe
\$shortcut.Arguments = '$_css_sc_args_ps'
\$shortcut.Description = 'Launch Unsloth Studio (WSL)'
if (\$hasIcon) { \$shortcut.IconLocation = "\$iconPath,0" }
\$shortcut.Save()
\$created += \$linkPath
}
\$iconChanged = \$false
if (\$hasIcon) {
if (-not \$preIconHash) {
\$iconChanged = \$true
} else {
try {
\$postIconHash = (Get-FileHash -LiteralPath \$iconPath -Algorithm SHA256).Hash
\$iconChanged = (\$postIconHash -ne \$preIconHash)
} catch { \$iconChanged = \$true }
}
} elseif (\$preIconHash) {
\$iconChanged = \$true
}
# Per-item SHCNE_UPDATEITEM so a rewritten same-name .lnk re-reads its icon; the global broadcast
# alone does not. Called through a Windows Python's ctypes, as install.ps1 does, so this script
# defines no native types. Without one the shortcut still works, and the heavier refresh below
# still runs on a first install or an icon change.
if (\$created.Count -gt 0) {
\$isAdmin = \$true
try {
\$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
} catch {}
# Never launch a user-writable interpreter from an elevated shell.
\$pyCandidates = @()
if (-not \$isAdmin) {
\$pyCandidates += Join-Path \$env:USERPROFILE '.unsloth\studio\unsloth_studio\Scripts\python.exe'
foreach (\$name in @('python3', 'python')) {
try {
foreach (\$cmd in @(Get-Command \$name -All -CommandType Application -ErrorAction SilentlyContinue)) {
if (\$cmd -and \$cmd.Source) { \$pyCandidates += \$cmd.Source }
}
} catch {}
}
}
# SHCNF_FLUSH (0x1000): the child exits at once and a queued notification would be lost.
\$refreshCode = "import ctypes,os;from ctypes import wintypes as w;f=ctypes.WinDLL('shell32').SHChangeNotify;f.restype=None;f.argtypes=[w.LONG,w.UINT,w.LPCWSTR,w.LPCWSTR];[f(0x2000,0x1005,p,None) for p in os.environ['UNSLOTH_SHORTCUT_PATHS'].split('|') if p];f(0x8000000,0x1000,None,None);print('ok')"
foreach (\$py in \$pyCandidates) {
# The WindowsApps alias opens the Store instead of running anything.
if (-not \$py -or \$py -like '*\Microsoft\WindowsApps\*') { continue }
if (-not (Test-Path -LiteralPath \$py -PathType Leaf)) { continue }
\$proc = \$null
try {
\$psi = New-Object System.Diagnostics.ProcessStartInfo
\$psi.FileName = \$py
# -I -S: no user site, no PYTHON* variables, no sitecustomize. -B: write no .pyc.
\$psi.Arguments = '-I -S -B -c "' + \$refreshCode + '"'
# '|' cannot appear in a Windows path, so it separates them safely.
\$psi.EnvironmentVariables['UNSLOTH_SHORTCUT_PATHS'] = (\$created -join '|')
\$psi.WorkingDirectory = Split-Path -Parent \$py
\$psi.UseShellExecute = \$false
\$psi.RedirectStandardOutput = \$true
\$psi.RedirectStandardError = \$true
\$psi.CreateNoWindow = \$true
\$proc = [System.Diagnostics.Process]::Start(\$psi)
\$out = \$proc.StandardOutput.ReadToEndAsync()
\$null = \$proc.StandardError.ReadToEndAsync()
if (-not \$proc.WaitForExit(10000)) {
try { \$proc.Kill() } catch {}
continue
}
if (\$proc.ExitCode -eq 0 -and \$out.Wait(2000) -and "\$(\$out.Result)".Trim() -eq 'ok') { break }
} catch {
} finally {
if (\$proc) { try { \$proc.Dispose() } catch {} }
}
}
}
# Heavier on-disk icon-cache clear + StartMenuExperienceHost tile rebuild
# (preserve start2.bin) only on first install or a real icon change, so a no-op
# WSL reinstall does not purge caches and kill a shell process for nothing.
# See tests/studio/test_installer_av_shapes.py (AV_SHAPES_RECORD)
if (\$created.Count -gt 0 -and (\$firstShortcut -or \$iconChanged)) {
try { & "\$env:SystemRoot\System32\ie4uinit.exe" -ClearIconCache } catch {}
try { & "\$env:SystemRoot\System32\ie4uinit.exe" -show } catch {}
try {
\$smeh = Join-Path \$env:LOCALAPPDATA 'Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\TempState'
if (Test-Path -LiteralPath \$smeh) {
Get-ChildItem -LiteralPath \$smeh -Filter 'TileCache_*' -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath (Join-Path \$smeh 'StartUnifiedTileModelCache.dat') -Force -ErrorAction SilentlyContinue
Stop-Process -Name StartMenuExperienceHost -Force -ErrorAction SilentlyContinue
}
} catch {}
}
WSLPS1_EOF
)
if [ -n "$_css_ps1_tmp" ]; then
printf '%s\n' "$_css_ps1_body" > "$_css_ps1_tmp"
_css_ps1_win=$(wslpath -w "$_css_ps1_tmp" 2>/dev/null)
if [ -n "$_css_ps1_win" ]; then
powershell.exe -NoProfile -ExecutionPolicy RemoteSigned -File "$_css_ps1_win" >/dev/null 2>&1 && _css_created=1
fi
rm -f "$_css_ps1_tmp"
else
# No Windows dir is reachable (automount off): pipe the script on stdin, where no execution
# policy applies. Use our own pipe: a piped install's stdin is the download (#7548).
printf '%s\n' "$_css_ps1_body" | powershell.exe -NoProfile -Command - >/dev/null 2>&1 && _css_created=1
fi
fi
if [ "$_css_created" -ne 1 ]; then
substep "Couldn't create the Windows shortcut (WSL interop may be disabled)." "$C_WARN"
substep " Launch Unsloth from Windows: wsl -d \"$_css_distro\" -- bash -lc 'unsloth studio'" "$C_WARN"
substep " (re-enable shortcuts: turn WSL interop back on, e.g. run 'wsl --shutdown' then reopen WSL.)" "$C_WARN"
fi
fi
if [ "$_css_created" -eq 1 ]; then
substep "Created Unsloth Studio shortcut"
fi
}
echo ""
printf " ${C_TITLE}%s${C_RST}\n" "🦥 Unsloth Studio Installer"
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
echo ""
# ── Detect platform ──
tauri_log "STEP" "Detecting platform"
OS="linux"
if [ "$(uname)" = "Darwin" ]; then
OS="macos"
elif grep -qi microsoft /proc/version 2>/dev/null; then
OS="wsl"
fi
step "platform" "$OS"
if [ "$_SHORTCUTS_ONLY" = true ]; then
if [ "$TAURI_MODE" != true ]; then
VENV_ABS_BIN="$VENV_DIR/bin"
if [ ! -x "$VENV_ABS_BIN/unsloth" ]; then
echo "ERROR: unsloth binary missing at '$VENV_ABS_BIN/unsloth'; run install.sh first." >&2
exit 1
fi
create_studio_shortcuts "$VENV_ABS_BIN/unsloth" "$OS"
fi
exit 0
fi
# ── Architecture detection & Python version ──
_ARCH=$(uname -m)
MAC_INTEL=false
# Rosetta is tracked apart from MAC_INTEL: hardware checks must still see Apple Silicon.
_MAC_ROSETTA=false
if [ "$OS" = "macos" ] && [ "$_ARCH" = "x86_64" ]; then
if [ "$(sysctl -in hw.optional.arm64 2>/dev/null || echo 0)" = "1" ]; then
_MAC_ROSETTA=true
echo ""
echo " WARNING: Apple Silicon detected, but this shell is running under Rosetta (x86_64)."
echo " Re-run install.sh from a native arm64 terminal for full PyTorch support."
echo " Continuing in GGUF-only mode for now."
echo ""
fi
MAC_INTEL=true
fi
if [ -n "$_USER_PYTHON" ]; then
PYTHON_VERSION="$_USER_PYTHON"
echo " Using user-specified Python $PYTHON_VERSION (--python override)"
elif [ "$MAC_INTEL" = true ]; then
PYTHON_VERSION="3.12"
else
PYTHON_VERSION="3.13"
fi
if [ "$MAC_INTEL" = true ]; then
echo ""
echo " NOTE: Intel Mac (x86_64) detected."
echo " PyTorch is unavailable for this platform (dropped Jan 2024)."
echo " Unsloth will install in GGUF-only mode."
echo " Chat, inference via GGUF, and data recipes will work."
echo " Training requires Apple Silicon or Linux with GPU."
echo ""
fi
# ── Unified SKIP_TORCH: --no-torch flag OR Intel Mac auto-detection ──
SKIP_TORCH=false
if [ "$_NO_TORCH_FLAG" = true ] || [ "$MAC_INTEL" = true ]; then
SKIP_TORCH=true
fi
if [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then
_OVERRIDES_FILE="$(cd "$(dirname "$0" 2>/dev/null || echo ".")" && pwd)/studio/backend/requirements/single-env/overrides-darwin-arm64.txt"
if [ -f "$_OVERRIDES_FILE" ]; then
# uv splits UV_OVERRIDE on whitespace; hand uv a copy in a whitespace-free temp dir.
case "$_OVERRIDES_FILE" in
*[[:space:]]*)
_UV_OVERRIDE_TMP_ROOT=${TMPDIR:-/tmp}
case "$_UV_OVERRIDE_TMP_ROOT" in *[[:space:]]*) _UV_OVERRIDE_TMP_ROOT=/tmp ;; esac
_UV_OVERRIDE_TMPDIR=$(mktemp -d "$_UV_OVERRIDE_TMP_ROOT/unsloth_uv.XXXXXX" 2>/dev/null) || _UV_OVERRIDE_TMPDIR=""
case "$_UV_OVERRIDE_TMPDIR" in
"") ;;
*[[:space:]]*) rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true; _UV_OVERRIDE_TMPDIR="" ;;
*)
if cp "$_OVERRIDES_FILE" "$_UV_OVERRIDE_TMPDIR/overrides-darwin-arm64.txt" 2>/dev/null; then
_OVERRIDES_FILE="$_UV_OVERRIDE_TMPDIR/overrides-darwin-arm64.txt"
else
rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true
_UV_OVERRIDE_TMPDIR=""
fi
;;
esac
;;
esac
export UV_OVERRIDE="$_OVERRIDES_FILE"
fi
fi
_TAURI_INITIAL_GPU_BRANCH="unknown"
if [ "$SKIP_TORCH" = true ]; then
_TAURI_INITIAL_GPU_BRANCH="no_torch"
elif [ "$OS" = "macos" ]; then
_TAURI_INITIAL_GPU_BRANCH="mac"
fi
tauri_diag_marker "$_TAURI_INITIAL_GPU_BRANCH" "none"
# Discrete AMD cards are not in /proc/cpuinfo, so ask the Windows host via WMI.
_WSL_AMD_GPU_NAME_CACHE=""
_wsl_amd_gpu_name() {
if [ -n "$_WSL_AMD_GPU_NAME_CACHE" ]; then
[ "$_WSL_AMD_GPU_NAME_CACHE" = "-" ] && return 1
printf '%s' "$_WSL_AMD_GPU_NAME_CACHE"; return 0
fi
command -v powershell.exe >/dev/null 2>&1 || { _WSL_AMD_GPU_NAME_CACHE="-"; return 1; }
_wag_ps="(Get-CimInstance Win32_VideoController | Where-Object { \$_.Name -match 'AMD|Radeon' } | Select-Object -First 1).Name"
if command -v timeout >/dev/null 2>&1; then
_wag_n="$(timeout 10 powershell.exe -NoProfile -Command "$_wag_ps" 2>/dev/null | tr -d '\r\n\000')"
else
_wag_n="$(powershell.exe -NoProfile -Command "$_wag_ps" 2>/dev/null | tr -d '\r\n\000')"
fi
if [ -n "$_wag_n" ]; then _WSL_AMD_GPU_NAME_CACHE="$_wag_n"; printf '%s' "$_wag_n"; return 0; fi
_WSL_AMD_GPU_NAME_CACHE="-"; return 1
}
# ── Bounded command runner ──
_run_bounded() {
_rb_secs=10
if [ "${1:-}" = "--secs" ]; then _rb_secs=$2; shift 2; fi
if command -v timeout >/dev/null 2>&1; then
timeout "$_rb_secs" "$@"
else
"$@"
fi
}
_cvd_hides_nvidia() {
[ "${CUDA_VISIBLE_DEVICES+set}" = "set" ] || return 1
_cvd_trim=$(printf '%s' "$CUDA_VISIBLE_DEVICES" | tr -d '[:space:]')
[ -z "$_cvd_trim" ] || [ "$_cvd_trim" = "-1" ]
}
# NVIDIA inventory via NVML / CUDA driver API when nvidia-smi is absent or hangs. Inline because
# studio/nvidia_probe.py is not on disk yet. Memoised so all callers agree.
_NVIDIA_LIBRARY_INVENTORY_STATE=""
_NVIDIA_LIBRARY_INVENTORY_VALUE=""
_nvidia_library_inventory() {
[ "${UNSLOTH_NVIDIA_LIBRARY_PROBE:-1}" != "0" ] || return 1
case "${_NVIDIA_LIBRARY_INVENTORY_STATE:-}" in
found) printf '%s\n' "$_NVIDIA_LIBRARY_INVENTORY_VALUE"; return 0 ;;
none) return 1 ;;
esac
if command -v python3 >/dev/null 2>&1; then _nli_py=python3
elif [ -n "${VENV_DIR:-}" ] && [ -x "$VENV_DIR/bin/python" ]; then _nli_py="$VENV_DIR/bin/python"
else return 1
fi
_NVIDIA_LIBRARY_INVENTORY_STATE="none"
_NVIDIA_LIBRARY_INVENTORY_VALUE=""
for _nli_reader in nvml cuda; do
case "$_nli_reader" in nvml) _nli_secs=30 ;; *) _nli_secs=20 ;; esac
_NVIDIA_LIBRARY_INVENTORY_VALUE=$(_run_bounded --secs "$_nli_secs" "$_nli_py" -I - "$_nli_reader" 2>/dev/null <<'PY'
import ctypes, os, sys
def load(*names):
for name in names:
try:
return ctypes.CDLL(name)
except OSError:
pass
def sym(lib, name): # an older NVML exports the unversioned entry point only
return getattr(lib, name, None) or getattr(lib, name.replace("_v2", ""))
def nvml():
lib = load("libnvidia-ml.so.1", "libnvidia-ml.so")
if lib is None or sym(lib, "nvmlInit_v2")() != 0:
return None
try:
count, version = ctypes.c_uint(), ctypes.c_int()
if sym(lib, "nvmlDeviceGetCount_v2")(ctypes.byref(count)) != 0 or not count.value:
return None
if sym(lib, "nvmlSystemGetCudaDriverVersion_v2")(ctypes.byref(version)) != 0 or version.value < 1000:
return None
caps = []
for i in range(count.value):
dev, major, minor = ctypes.c_void_p(), ctypes.c_int(), ctypes.c_int()
if sym(lib, "nvmlDeviceGetHandleByIndex_v2")(i, ctypes.byref(dev)) != 0 or \
lib.nvmlDeviceGetCudaComputeCapability(dev, ctypes.byref(major), ctypes.byref(minor)) != 0:
return None # one unreadable GPU voids the source
caps.append(f"{major.value}.{minor.value}")
return version.value, caps
finally:
lib.nvmlShutdown()
def cuda():
# The driver API honours CUDA_VISIBLE_DEVICES; the inventory must be the physical one, so a
# hidden pre-Turing card still caps the family (studio/nvidia_probe.py does the same).
os.environ.pop("CUDA_VISIBLE_DEVICES", None)
lib = load("libcuda.so.1", "libcuda.so")
if lib is None or lib.cuInit(0) != 0:
return None
count, version = ctypes.c_int(), ctypes.c_int()
if lib.cuDeviceGetCount(ctypes.byref(count)) != 0 or not count.value:
return None
if lib.cuDriverGetVersion(ctypes.byref(version)) != 0 or version.value < 1000:
return None
caps = []
for i in range(count.value):
dev, major, minor = ctypes.c_int(), ctypes.c_int(), ctypes.c_int()
# CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MAJOR = 75, _MINOR = 76.
if lib.cuDeviceGet(ctypes.byref(dev), i) != 0 or \
lib.cuDeviceGetAttribute(ctypes.byref(major), 75, dev) != 0 or \
lib.cuDeviceGetAttribute(ctypes.byref(minor), 76, dev) != 0:
return None
caps.append(f"{major.value}.{minor.value}")
return version.value, caps
# argv[1] runs one reader ("nvml" / "cuda") so each gets its own deadline; none runs both.
only = {"nvml": (nvml,), "cuda": (cuda,)}.get(sys.argv[1] if len(sys.argv) > 1 else "")
found = None
for reader in only or (nvml, cuda): # a reader that raises (a missing symbol) yields to the next
try:
found = reader()
except Exception:
found = None
if found and found[1]:
break
if not found or not found[1]:
sys.exit(1)
version, caps = found
print(f"{version // 1000}.{version % 1000 // 10} {','.join(caps)}")
PY
) && [ -n "$_NVIDIA_LIBRARY_INVENTORY_VALUE" ] && break
_NVIDIA_LIBRARY_INVENTORY_VALUE=""
done
[ -n "$_NVIDIA_LIBRARY_INVENTORY_VALUE" ] || return 1
_NVIDIA_LIBRARY_INVENTORY_STATE="found"
printf '%s\n' "$_NVIDIA_LIBRARY_INVENTORY_VALUE"
}
_nvidia_driver_cuda_version() {
[ "${UNSLOTH_NVIDIA_LIBRARY_PROBE:-1}" != "0" ] || return 1
if command -v python3 >/dev/null 2>&1; then _ndv_py=python3
elif [ -n "${VENV_DIR:-}" ] && [ -x "$VENV_DIR/bin/python" ]; then _ndv_py="$VENV_DIR/bin/python"
else _ndv_py=""
fi
if [ -n "$_ndv_py" ]; then
_ndv_ver=$(_run_bounded "$_ndv_py" -I -c '
import ctypes, sys
for name in ("libcuda.so.1", "libcuda.so"):
try:
lib = ctypes.CDLL(name)
break
except OSError:
lib = None
v = ctypes.c_int()
if lib is None or lib.cuDriverGetVersion(ctypes.byref(v)) != 0 or v.value < 1000:
sys.exit(1)
print(f"{v.value // 1000}.{v.value % 1000 // 10}")
' 2>/dev/null </dev/null | awk 'NR == 1 { print $1 }') || _ndv_ver=""
case "$_ndv_ver" in
[0-9]*.[0-9]*) printf '%s\n' "$_ndv_ver"; return 0 ;;
esac
fi
_ndv_drv=$(awk 'NR == 1 { for (i = 1; i <= NF; i++) if ($i ~ /^[0-9]+\.[0-9]+(\.[0-9]+)?$/) { split($i, v, "."); print v[1]; exit } }' \
/proc/driver/nvidia/version 2>/dev/null) || _ndv_drv=""
case "$_ndv_drv" in ''|*[!0-9]*) return 1 ;; esac
if [ "$_ndv_drv" -ge 580 ]; then echo "13.0"
elif [ "$_ndv_drv" -ge 570 ]; then echo "12.8"
elif [ "$_ndv_drv" -ge 560 ]; then echo "12.6"
elif [ "$_ndv_drv" -ge 525 ]; then echo "12.0"
elif [ "$_ndv_drv" -ge 450 ]; then echo "11.0"
else return 1
fi
}
# ── NVIDIA usable-GPU helper ──
_has_usable_nvidia_gpu() {
_nv_smi_wedged=""
if _cvd_hides_nvidia; then
return 1
fi
_nvsmi=""
if command -v nvidia-smi >/dev/null 2>&1; then
_nvsmi="nvidia-smi"
elif [ -x "/usr/bin/nvidia-smi" ]; then
_nvsmi="/usr/bin/nvidia-smi"
fi
if [ -n "$_nvsmi" ]; then
# Captured rather than piped so `timeout`'s own 124 stays visible. A wedged
# driver still detects as a GPU through /proc below, and the banner must not
# then pay the 10s bound a second time asking a hung nvidia-smi for a name.
_nv_l_rc=0
_nv_l_out=$(_run_bounded "$_nvsmi" -L 2>/dev/null) || _nv_l_rc=$?
if [ "$_nv_l_rc" = "124" ]; then
_nv_smi_wedged=1
fi
if printf '%s\n' "$_nv_l_out" | awk '/^GPU[[:space:]]+[0-9]+:/{found=1} END{exit !found}'; then
return 0
fi
fi
if [ -d /proc/driver/nvidia/gpus ] && \
[ -n "$(ls -A /proc/driver/nvidia/gpus 2>/dev/null)" ]; then
return 0
fi
_nvidia_library_inventory >/dev/null 2>&1 && return 0
return 1
}
# Row index of the GPU whose UUID starts with $1, or empty. NVIDIA allows a UUID to
# be abbreviated to any unique leading portion, hence the prefix match.
_nv_idx_from_uuid() {
# NVIDIA accepts an abbreviation only when it is a UNIQUE leading portion, so a
# prefix matching two cards selects NO device. Counting instead of stopping at the
# first hit keeps the banner from naming one of them.
_run_bounded "$_nvsmi" --query-gpu=uuid --format=csv,noheader 2>/dev/null \
| awk -v want="$1" '
NF { gsub(/^[[:space:]]+|[[:space:]]+$/,""); if (index($0, want) == 1) { hits++; idx = NR-1 } }
END { if (hits == 1) print idx }' || true
}
# Bounded, and reuses the resolved $_nvsmi: detection may have succeeded without PATH nvidia-smi.
_nv_banner_fields() {
_nv_name=""; _nv_sm=""; _nv_driver=""; _nv_row=""; _nv_cc=""; _nv_ambiguous=""
[ -n "${_nvsmi:-}" ] || return 0
# Detection already waited out the full bound on this binary. Asking again cannot
# succeed and would double the stall, so the banner keeps the vendor-only wording.
[ -z "${_nv_smi_wedged:-}" ] || return 0
# nvidia-smi ignores CUDA_VISIBLE_DEVICES, so its rows are the physical devices and
# the mask has to be resolved against them by hand.
_nv_idx=0
_nv_by_ordinal=1
_nv_vis="${CUDA_VISIBLE_DEVICES:-}"
# Only the FIRST entry selects the device, and only IT decides the form of the mask.
# CUDA truncates enumeration at the first invalid index, so the documented `2,-1`
# means "device 2, then stop"; classifying the whole string would see the `-1`, call
# it non-numeric, and send a plain ordinal down the UUID path.
_nv_tok="${_nv_vis%%,*}"
case "$_nv_tok" in
'') ;;
*[!0-9]*)
_nv_by_ordinal=""
case "$_nv_tok" in
MIG-GPU-*)
# Pre-R470 MIG name, MIG-<GPU-UUID>/<gi>/<ci>: the parent UUID is
# embedded, so it still matches a --query-gpu=uuid row.
_nv_tok="${_nv_tok#MIG-}"; _nv_tok="${_nv_tok%%/*}"
_nv_idx=$(_nv_idx_from_uuid "$_nv_tok") ;;
MIG-*)
# R470+ MIG UUIDs are opaque; nvidia-smi -L nests them under their GPU.
_nv_idx=$(_run_bounded "$_nvsmi" -L 2>/dev/null | awk -v want="$_nv_tok" '
/^GPU[[:space:]]+[0-9]+:/ { cur = $2 + 0 }
index($0, want) > 0 { print cur; exit }' || true) ;;
*)
_nv_idx=$(_nv_idx_from_uuid "$_nv_tok") ;;
esac
# An identity mask that does not resolve means CUDA selected NO device: an
# abbreviation short enough to match two cards, or a UUID for a card that is
# not here. Row 0 is not a fallback for that -- it is a different card.
case "$_nv_idx" in ''|*[!0-9]*) _nv_idx=0; _nv_ambiguous=1 ;; esac
;;
*) _nv_idx="$_nv_tok" ;;
esac
# Canonicalise the ordinal before anything compares or subscripts with it.
# `[` parses with strtol and ERRORS on a value wider than a long, printing a
# shell diagnostic and skipping the range check below, so an absurd ordinal
# would have named row 0. Clamped rather than rejected: 9999 is past any real
# host, so it stays out of range and declines, which is the right answer.
_nv_idx=$(printf '%s' "$_nv_idx" \
| awk '{ n = $0 + 0; if (n < 0) n = 0; if (n > 9999) n = 9999; printf "%d", n }')
_nv_all=$(_run_bounded "$_nvsmi" --query-gpu=name,compute_cap,driver_version --format=csv,noheader 2>/dev/null || true)
_nv_row=$(printf '%s\n' "$_nv_all" \
| awk -v idx="$_nv_idx" 'NF { a[n++]=$0 } END { if(idx>=n) idx=0; if(n>0) print a[idx+0] }')
[ -n "$_nv_row" ] || return 0
# CUDA's default FASTEST_FIRST order differs from nvidia-smi's PCI order, so an ordinal names a row
# only under PCI_BUS_ID or identical cards. An out-of-range ordinal exposes no device.
_nv_rowcount=$(printf '%s\n' "$_nv_all" | awk 'NF { n++ } END { print n+0 }')
if [ -n "$_nv_by_ordinal" ] && [ "$_nv_idx" -ge "$_nv_rowcount" ]; then
_nv_ambiguous=1
fi
if [ -n "$_nv_by_ordinal" ]; then
_nv_order=$(printf '%s' "${CUDA_DEVICE_ORDER:-}" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]')
_nv_models=$(printf '%s\n' "$_nv_all" \
| awk -F, 'NF { k=$0; sub(/,[^,]*$/,"",k); if (!(k in s)) { s[k]; n++ } } END { print n+0 }')
if [ "$_nv_order" != "PCI_BUS_ID" ] && [ "$_nv_models" -gt 1 ]; then
_nv_ambiguous=1
fi
fi
# Split from the right: nvidia-smi does not quote, so a comma in a device name
# would otherwise shift every field.
_nv_driver=$(printf '%s' "$_nv_row" | awk -F, 'NF>=3 { gsub(/^[[:space:]]+|[[:space:]]+$/,"",$NF); print $NF }')
_nv_cc=$(printf '%s' "$_nv_row" | awk -F, 'NF>=3 { gsub(/^[[:space:]]+|[[:space:]]+$/,"",$(NF-1)); print $(NF-1) }')
_nv_name=$(printf '%s' "$_nv_row" | awk -F, 'NF>=3 { out=$1; for(i=2;i<=NF-2;i++) out=out","$i; gsub(/^[[:space:]]+|[[:space:]]+$/,"",out); print out }')
[ -n "$_nv_name" ] || _nv_name=$(printf '%s' "$_nv_row" | awk -F, '{ gsub(/^[[:space:]]+|[[:space:]]+$/,"",$1); print $1 }')
# Old nvidia-smi prints placeholders for unknown fields (470 has no compute_cap).
case "$_nv_name" in '[N/A]'|'[Not Supported]'|'[Unknown Error]') _nv_name="" ;; esac
case "$_nv_driver" in '[N/A]'|'[Not Supported]'|'[Unknown Error]') _nv_driver="" ;; esac
if [ -n "$_nv_ambiguous" ]; then _nv_name=""; _nv_cc=""; fi
case "$_nv_cc" in
[0-9]*.[0-9]*) _nv_sm="sm_$(printf '%s' "$_nv_cc" | awk -F. '{ print ($1*10)+$2 }')" ;;
esac
return 0
}
# Strix Halo ROCm-on-WSL needs Ubuntu 24.04: re-run in one, else CPU. Never create a distro.
_maybe_reroute_strixhalo_to_2404() {
[ "${OS:-}" = "wsl" ] || return 0
_rr_pin=$(printf '%s' "${UNSLOTH_TORCH_INDEX_URL:-}${UNSLOTH_TORCH_INDEX_FAMILY:-}" | tr -d '[:space:]')
[ -n "$_rr_pin" ] && return 0
[ "${SKIP_TORCH:-false}" = "false" ] || return 0
[ "${UNSLOTH_SKIP_ROCM_WSL_SETUP:-0}" = "1" ] && return 0
[ "${UNSLOTH_WSL_REROUTED:-0}" = "1" ] && return 0
[ -e /dev/dxg ] || return 0
if _has_usable_nvidia_gpu; then return 0; fi
if ! grep -qiE 'Ryzen AI Max|Radeon 80[0-9][05]S|Strix Halo' /proc/cpuinfo 2>/dev/null \
&& ! _wsl_amd_gpu_name >/dev/null 2>&1; then
return 0
fi
if [ -e /opt/rocm/lib/librocdxg.so ] || [ -e /opt/rocm/lib64/librocdxg.so ]; then
return 0
fi
_rr_ver=""
[ -r /etc/os-release ] && _rr_ver=$(. /etc/os-release 2>/dev/null; printf '%s' "${VERSION_ID:-}")
case "$_rr_ver" in 24.04) return 0 ;; esac
command -v wsl.exe >/dev/null 2>&1 || { UNSLOTH_SKIP_ROCM_WSL_SETUP=1; return 0; }
_rr_distros=$(wsl.exe -l -q 2>/dev/null | tr -d '\000\r')
_rr_target=$(printf '%s\n' "$_rr_distros" | grep -ixF "Ubuntu-24.04" | head -n1) || true
[ -n "$_rr_target" ] || {
substep "ROCm-on-WSL (GPU) needs Ubuntu 24.04; this distro is Ubuntu ${_rr_ver:-unknown}." "$C_WARN"
substep "No Ubuntu-24.04 WSL distro found; staying CPU-only. Install Ubuntu-24.04 and re-run there for GPU." "$C_WARN"
UNSLOTH_SKIP_ROCM_WSL_SETUP=1
return 0
}
echo ""
substep "ROCm-on-WSL (GPU) needs Ubuntu 24.04; this distro is Ubuntu ${_rr_ver:-unknown}." "$C_WARN"
substep "Found an existing $_rr_target distro -- continuing the GPU install there." "$C_OK"
# A --local checkout cannot be replayed in another distro; tell the user instead.
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
substep "This is a --local install; re-run it from $_rr_target instead:" "$C_WARN"
substep " wsl -d $_rr_target -- bash -lc 'cd <your checkout> && ./install.sh --local'" "$C_WARN"
substep "Continuing CPU-only in Ubuntu ${_rr_ver:-this distro} for now." "$C_WARN"
UNSLOTH_SKIP_ROCM_WSL_SETUP=1
return 0
fi
_rr_q() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
_rr_exports="set -o pipefail; export UNSLOTH_WSL_REROUTED=1"
# Forward only a user override of UV_CACHE_DIR; our own default would pin the child to `custom`.
_rr_uv_cache=""
if [ "${_UV_CACHE_DIR_INSTALLER_DEFAULT:-false}" != true ]; then
case "${UV_CACHE_DIR-}" in
*[![:space:]]*) _rr_uv_cache="$UV_CACHE_DIR" ;;
esac
fi
if [ -n "$_rr_uv_cache" ]; then
_rr_exports="$_rr_exports; export UV_CACHE_DIR=$(_rr_q "$_rr_uv_cache")"
else
_rr_exports="$_rr_exports; unset UV_CACHE_DIR"
fi
unset _rr_uv_cache
[ "$_ISOLATE_UV_CACHE" = true ] && _rr_exports="$_rr_exports; export UNSLOTH_ISOLATE_UV_CACHE=1"
[ "$_STUDIO_HOME_REDIRECT" = "env" ] && _rr_exports="$_rr_exports; export UNSLOTH_STUDIO_HOME=$(_rr_q "$STUDIO_HOME")"
[ "${UNSLOTH_ROCM_WSL_AUTO:-0}" = "1" ] && _rr_exports="$_rr_exports; export UNSLOTH_ROCM_WSL_AUTO=1"
[ -n "${UNSLOTH_TORCH_INDEX_URL:-}" ] && _rr_exports="$_rr_exports; export UNSLOTH_TORCH_INDEX_URL=$(_rr_q "$UNSLOTH_TORCH_INDEX_URL")"
[ -n "${UNSLOTH_TORCH_INDEX_FAMILY:-}" ] && _rr_exports="$_rr_exports; export UNSLOTH_TORCH_INDEX_FAMILY=$(_rr_q "$UNSLOTH_TORCH_INDEX_FAMILY")"
[ -n "${UNSLOTH_MIRROR_FALLBACK:-}" ] && _rr_exports="$_rr_exports; export UNSLOTH_MIRROR_FALLBACK=$(_rr_q "$UNSLOTH_MIRROR_FALLBACK")"
[ "$_SKIP_AUTOSTART" = true ] && _rr_exports="$_rr_exports; export UNSLOTH_SKIP_AUTOSTART=1"
if [ "$_INSTALL_SYSTEMD" = true ]; then
_rr_exports="$_rr_exports; export UNSLOTH_INSTALL_SYSTEMD=1"
[ -n "${UNSLOTH_SYSTEMD_HOST:-}" ] && _rr_exports="$_rr_exports; export UNSLOTH_SYSTEMD_HOST=$(_rr_q "$UNSLOTH_SYSTEMD_HOST")"
[ -n "${UNSLOTH_SYSTEMD_PORT:-}" ] && _rr_exports="$_rr_exports; export UNSLOTH_SYSTEMD_PORT=$(_rr_q "$UNSLOTH_SYSTEMD_PORT")"
fi
_rr_args=""
[ "$PACKAGE_NAME" != "unsloth" ] && _rr_args="$_rr_args --package $(_rr_q "$PACKAGE_NAME")"
[ -n "$_USER_PYTHON" ] && _rr_args="$_rr_args --python $(_rr_q "$_USER_PYTHON")"
[ "$_VERBOSE" = true ] && _rr_args="$_rr_args --verbose"
[ "$TAURI_MODE" = true ] && _rr_args="$_rr_args --tauri"
if [ -n "${UNSLOTH_WSL_REROUTE_CMD:-}" ]; then
_rr_cmd="$UNSLOTH_WSL_REROUTE_CMD" # user took full control
elif [ -n "$_rr_args" ]; then
_rr_cmd="curl -fsSL https://unsloth.ai/install.sh | sh -s --$_rr_args"
else
_rr_cmd="curl -fsSL https://unsloth.ai/install.sh | sh"
fi
# pipefail so a failed download is not masked by sh exiting 0 on empty input.
_rr_rc=0
wsl.exe -d "$_rr_target" -- bash -lc "$_rr_exports; $_rr_cmd" || _rr_rc=$?
if [ "$_rr_rc" -eq 0 ]; then
exit 0
fi
if [ "$TAURI_MODE" = true ] && [ "$_rr_rc" -eq 2 ]; then
exit 2
fi
substep "Could not auto-continue in $_rr_target; run it yourself:" "$C_WARN"
substep " wsl -d $_rr_target -- bash -lc 'curl -fsSL https://unsloth.ai/install.sh | sh'"
substep "Continuing CPU-only in Ubuntu ${_rr_ver:-this distro} for now." "$C_WARN"
UNSLOTH_SKIP_ROCM_WSL_SETUP=1
return 0
}
_maybe_reroute_strixhalo_to_2404 || true
# ── Check system dependencies ──
tauri_log "STEP" "Checking system dependencies"
# Without the Xcode CLT /usr/bin/git is a stub, so `command -v git` is not enough.
_has_working_git() {
command -v git >/dev/null 2>&1 || return 1
# On Apple Silicon, running the /usr/bin/git CLT shim pops a GUI dialog, so answer from the path.
# Intel Macs may have a working /usr/bin/git and still probe it. _CLT_GIT_SHIM is a test hook.
if [ "${OS:-}" = "macos" ] &&
{ [ "${MAC_INTEL:-false}" != true ] || [ "${_MAC_ROSETTA:-false}" = true ]; } &&
[ "$(command -v git)" = "${_CLT_GIT_SHIM:-/usr/bin/git}" ] &&
! xcode-select -p >/dev/null 2>&1; then
return 1
fi
git --version >/dev/null 2>&1
}
# A function so tests/sh can sed-extract it. Consumer installs need no toolchain; only --local needs git.
_check_macos_deps() {
_clt_missing=false
xcode-select -p >/dev/null 2>&1 || _clt_missing=true
if [ "$STUDIO_LOCAL_INSTALL" = true ] && ! _has_working_git; then
echo ""
step "deps" "git is required for --local installs" "$C_ERR"
substep "--local installs unsloth-zoo from git+https://github.com/unslothai/unsloth-zoo,"
substep "which needs a working git. Install the Xcode Command Line Tools:"
substep " xcode-select --install"
substep "Then re-run this script. A normal (non---local) install needs no compiler"
substep "and no git -- it uses prebuilt binaries and wheels only."
tauri_log "NEED_XCODE_CLT" "git"
return 1
fi
if [ "$_clt_missing" = true ]; then
step "deps" "no Xcode Command Line Tools (not required)" "$C_WARN"
substep "Unsloth installs prebuilt binaries and wheels, so no compiler is needed."
substep "Install them only for a llama.cpp source build: xcode-select --install"
elif command -v cmake >/dev/null 2>&1; then
step "deps" "all system dependencies found"
else
step "deps" "using prebuilt llama.cpp (cmake not found)" "$C_WARN"
substep "Install cmake only if you want a source build: brew install cmake"
fi
return 0
}
# Only a download transport is required: llama.cpp ships prebuilts, so build tools are optional.
_check_linux_deps() {
_transport_missing=false
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
_transport_missing=true
fi
# Optional: git for triton_kernels, the rest for a source build. Warn, never stop.
_optional_missing=""
command -v cmake >/dev/null 2>&1 || _optional_missing="$_optional_missing cmake"
_has_working_git || _optional_missing="$_optional_missing git"
command -v gcc >/dev/null 2>&1 || _optional_missing="$_optional_missing build-essential"
command -v curl-config >/dev/null 2>&1 || _optional_missing="$_optional_missing libcurl4-openssl-dev"
_optional_missing="${_optional_missing# }"
if [ "$STUDIO_LOCAL_INSTALL" = true ] && ! _has_working_git; then
echo ""
step "deps" "git is required for --local installs" "$C_ERR"
substep "--local installs unsloth-zoo from git+https://github.com/unslothai/unsloth-zoo,"
substep "which needs git. Install it with your package manager, then re-run."
substep "A normal (non---local) install needs no git and no compiler."
return 1
fi
if [ "$_transport_missing" = true ]; then
if command -v apt-get >/dev/null 2>&1; then
echo ""
step "deps" "missing: curl" "$C_WARN"
substep "Needed to download uv, Python and the prebuilt inference engine."
_smart_apt_install curl
echo ""
else
echo ""
step "deps" "missing: curl (or wget)" "$C_ERR"
substep "Unsloth needs one of them to download uv, Python and the prebuilt"
substep "inference engine. Install one, then re-run setup:"
substep " Fedora/RHEL: sudo dnf install curl"
substep " Arch: sudo pacman -S --needed curl"
substep " openSUSE: sudo zypper install curl"
return 1
fi
fi
if [ -n "$_optional_missing" ] && command -v apt-get >/dev/null 2>&1; then
step "deps" "installing optional build tools: $_optional_missing" "$C_DIM"
( _SMART_APT_OPTIONAL=true; _smart_apt_install $_optional_missing ) || true
_optional_missing=""
command -v cmake >/dev/null 2>&1 || _optional_missing="$_optional_missing cmake"
_has_working_git || _optional_missing="$_optional_missing git"
command -v gcc >/dev/null 2>&1 || _optional_missing="$_optional_missing build-essential"
command -v curl-config >/dev/null 2>&1 || _optional_missing="$_optional_missing libcurl4-openssl-dev"
_optional_missing="${_optional_missing# }"
fi
if [ -n "$_optional_missing" ]; then
# Off apt the Debian package names mean nothing (NixOS, Fedora, Arch, SteamOS: #4986), so name the commands probed above.
_shown_missing="$_optional_missing"
if ! command -v apt-get >/dev/null 2>&1; then
_shown_missing=""
for _m in $_optional_missing; do
case "$_m" in
build-essential) _m="gcc" ;;
libcurl4-openssl-dev) _m="curl-config" ;;
esac
_shown_missing="$_shown_missing $_m"
done
_shown_missing="${_shown_missing# }"
fi
step "deps" "using prebuilt llama.cpp (missing: $_shown_missing)" "$C_WARN"
substep "Not required to run: Unsloth downloads a prebuilt inference engine."
case " $_optional_missing " in
*" git "*) substep "Without git the triton kernels training speedup is skipped." ;;
esac
else
step "deps" "all system dependencies found"
fi
return 0
}
# Keep in step with os_sandbox._BWRAP_APPARMOR_FIX.
_BWRAP_APPARMOR_FIX="sudo apt-get install -y apparmor-profiles && sudo install -m 644 /usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/ && sudo apparmor_parser -r /etc/apparmor.d/bwrap-userns-restrict"
# The command that installs bubblewrap here; keep in step with os_sandbox._BWRAP_INSTALL_COMMANDS.
_bwrap_install_command() {
if command -v apt-get >/dev/null 2>&1; then echo "sudo apt-get install -y bubblewrap"
elif command -v dnf >/dev/null 2>&1; then echo "sudo dnf install -y bubblewrap"
elif command -v pacman >/dev/null 2>&1; then echo "sudo pacman -S --needed bubblewrap"
elif command -v zypper >/dev/null 2>&1; then echo "sudo zypper install -y bubblewrap"
elif command -v apk >/dev/null 2>&1; then echo "sudo apk add bubblewrap"
fi
}
# Optional: without bubblewrap, tool calls run with software safeguards. Never elevates.
_check_linux_tool_sandbox() {
_bw_restrict=""
_bw_sysctl="${_BW_USERNS_SYSCTL:-/proc/sys/kernel/apparmor_restrict_unprivileged_userns}"
# read, not cat: a builtin, so a minimal image without coreutils still gets the right advice.
# The -r test, not 2>/dev/null: a shell reports a failed < open before that redirection applies.
if [ -r "$_bw_sysctl" ]; then read -r _bw_restrict <"$_bw_sysctl" 2>/dev/null || true; fi
if ! command -v bwrap >/dev/null 2>&1 && command -v apt-get >/dev/null 2>&1; then
( _SMART_APT_OPTIONAL=true; _smart_apt_install bubblewrap ) || true
fi
if ! command -v bwrap >/dev/null 2>&1; then
step "sandbox" "bubblewrap not installed: tool calls run with software safeguards" "$C_WARN"
_bw_cmd="$(_bwrap_install_command)"
# Ubuntu 23.10+ blocks bwrap via AppArmor even when installed.
case "$_bw_restrict:$_bw_cmd" in
1:*apt-get*) _bw_cmd="$_bw_cmd && $_BWRAP_APPARMOR_FIX" ;;
esac
if [ -n "$_bw_cmd" ]; then
substep "To run them in an OS sandbox: $_bw_cmd"
else
substep "To run them in an OS sandbox, install bubblewrap with your package manager."
fi
return 0
fi
if bwrap --unshare-user --unshare-pid --unshare-ipc --unshare-uts --unshare-cgroup --ro-bind / / true </dev/null >/dev/null 2>&1; then
step "sandbox" "bubblewrap works: tool calls run in an OS sandbox"
return 0
fi
if [ "$_bw_restrict" = 1 ]; then
step "sandbox" "AppArmor blocks bubblewrap: tool calls run with software safeguards" "$C_WARN"
substep "To enable it, load Ubuntu's own bwrap profile:"
substep " $_BWRAP_APPARMOR_FIX"
else
step "sandbox" "bubblewrap cannot create a sandbox here: tool calls run with software safeguards" "$C_WARN"
substep "Containers usually block user namespaces; outside one, check user.max_user_namespaces."
fi
return 0
}
case "$OS" in
macos)
_check_macos_deps || exit 1
;;
linux|wsl)
_check_linux_deps || exit 1
_check_linux_tool_sandbox || true
;;
esac
# ── BEGIN mirror fallback (kept identical in install.sh and studio/setup.sh) ──
# Only in mainland China (or UNSLOTH_MIRROR_FALLBACK=1): swaps a default host below 1 MiB/s or unreachable for its mirror when faster; user-set sources untouched; UNSLOTH_MIRROR_FALLBACK=0 disables.
_MIRROR_CERNET="https://tuna.mirrors.cernet.edu.cn"
_MIRROR_PYPI="$_MIRROR_CERNET/pypi/web/simple"
_MIRROR_NPM="https://registry.npmmirror.com"
# GitHub-release mirrors keep only the newest Python builds, while a pinned uv asks for the builds it shipped with; npmmirror keeps every release.
_MIRROR_PYTHON="$_MIRROR_NPM/-/binary/python-build-standalone"
_MIRROR_MIN_BPS=1048576
_mirror_probe() {
_mp_out=$(curl -sL -o /dev/null -r "0-$3" -w '%{http_code} %{speed_download}' --connect-timeout "$2" --max-time "$2" "$1" 2>/dev/null) || true
_mp_bps=${_mp_out#* }
_mp_bps=${_mp_bps%%.*}
case "$_mp_bps" in ''|*[!0-9]*) _mp_bps=0 ;; esac
_mp_code=${_mp_out%% *}
case "$_mp_code" in [0-9][0-9][0-9]) ;; *) _mp_code=000 ;; esac
echo "$_mp_code $_mp_bps"
}
_mirror_url() {
case "$1" in
pypi) echo "https://files.pythonhosted.org/packages/3a/ad/3c2a81d66d1839063662e5b6c0002ec31f8ee987dbc5295747627ca6160a/uv-0.13.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" ;;
cernet-pypi) echo "$_MIRROR_CERNET/pypi/web/packages/3a/ad/3c2a81d66d1839063662e5b6c0002ec31f8ee987dbc5295747627ca6160a/uv-0.13.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" ;;
torch) echo "https://download-r2.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl" ;;
cernet-torch) echo "$_MIRROR_CERNET/pytorch/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl" ;;
node) echo "https://nodejs.org/dist/v24.18.0/node-v24.18.0-linux-x64.tar.gz" ;;
npmmirror-node) echo "$_MIRROR_NPM/-/binary/node/v24.18.0/node-v24.18.0-linux-x64.tar.gz" ;;
npm) echo "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz" ;;
npmmirror) echo "$_MIRROR_NPM/typescript/-/typescript-5.9.3.tgz" ;;
astral) echo "https://releases.astral.sh/github/uv/releases/download/0.13.5/uv-x86_64-unknown-linux-gnu.tar.gz" ;;
pypi-index) echo "https://pypi.org/simple/uv/" ;;
torch-index) echo "https://download.pytorch.org/whl/cpu/torch/" ;;
cernet-pypi-index) echo "$_MIRROR_PYPI/uv/" ;;
cernet-torch-index) echo "$_MIRROR_CERNET/pytorch/whl/cpu/torch/" ;;
esac
}
_mirror_default() {
case "$1" in python|uvbin) echo astral ;; *) echo "$1" ;; esac
}
_mirror_source() {
case "$1" in npm|python) echo npmmirror ;; node) echo npmmirror-node ;; pypi|uvbin) echo cernet-pypi ;; *) echo "cernet-$1" ;; esac
}
_mirror_index_probe() {
for _mip_name in "$@"; do
case "$_mip_name" in
pypi|torch|cernet-pypi|cernet-torch)
_mirror_probe "$(_mirror_url "$_mip_name-index")" 4 1023 > "$_mf_dir/$_mip_name-index" &
_mf_pids="$_mf_pids $!" ;;
esac
done
}
_mirror_index_wait() {
for _miw_pid in $_mf_pids; do
wait "$_miw_pid" || true
done
_mf_pids=""
}
_mirror_index_ok() {
[ -f "$_mf_dir/$1-index" ] || return 0
read -r _mio_code _mio_bps < "$_mf_dir/$1-index"
case "$_mio_code" in 2??) return 0 ;; *) return 1 ;; esac
}
_mirror_uv_project_config() {
_mup_dir=$PWD
while [ -n "$_mup_dir" ]; do
if [ -f "$_mup_dir/uv.toml" ]; then echo "$_mup_dir/uv.toml"; return 0; fi
if grep -Eqs '^[[:space:]]*\[+tool\.uv(\.|\])' "$_mup_dir/pyproject.toml"; then echo "$_mup_dir/pyproject.toml"; return 0; fi
[ "$_mup_dir" = / ] && return 0
_mup_dir=$(dirname "$_mup_dir")
done
}
_mirror_configured() {
case "$1" in
uv)
[ -n "${UV_DEFAULT_INDEX:-}${UV_INDEX_URL:-}${UV_INDEX:-}${UV_EXTRA_INDEX_URL:-}" ] && return 0
_mic_key='\[\[(tool\.uv\.)?index\]\]|(pip\.)?(index|index-url|default-index|extra-index-url|no-index)[[:space:]]*=' ;;
python)
[ -n "${UV_PYTHON_INSTALL_MIRROR:-}" ] && return 0
_mic_key='python-install-mirror[[:space:]]*=' ;;
pip)
[ -n "${PIP_INDEX_URL:-}${PIP_EXTRA_INDEX_URL:-}${PIP_NO_INDEX:-}" ] && return 0
_mic_key='(index[-_]url|extra[-_]index[-_]url|no[-_]index)[[:space:]]*[=:]' ;;
esac
_mic_suffix=uv/uv.toml
[ "$1" != pip ] || _mic_suffix=pip/pip.conf
if [ "$1" = pip ]; then
set -- "${PIP_CONFIG_FILE:-}" "${VENV_DIR:+$VENV_DIR/pip.conf}" "${XDG_CONFIG_HOME:-$HOME/.config}/pip/pip.conf" "$HOME/.pip/pip.conf" "$HOME/Library/Application Support/pip/pip.conf" /etc/xdg/pip/pip.conf /etc/pip.conf
else
set -- "${UV_CONFIG_FILE:-}" "$(_mirror_uv_project_config)" "${XDG_CONFIG_HOME:-$HOME/.config}/uv/uv.toml" /etc/xdg/uv/uv.toml /etc/uv/uv.toml
fi
_mic_xdg=${XDG_CONFIG_DIRS:-}
while [ -n "$_mic_xdg" ]; do
set -- "$@" "${_mic_xdg%%:*}/$_mic_suffix"
case "$_mic_xdg" in *:*) _mic_xdg=${_mic_xdg#*:} ;; *) _mic_xdg="" ;; esac
done
for _mic_file in "$@"; do
if [ -f "$_mic_file" ] && grep -Eq "^[[:space:]]*($_mic_key)" "$_mic_file" 2>/dev/null; then
return 0
fi
done
return 1
}
_mirror_probe_all() {
_mpa_dir="$1"
_mpa_secs="$2"
shift 2
_mpa_pids=""
for _mpa_name in "$@"; do
_mirror_probe "$(_mirror_url "$_mpa_name")" "$_mpa_secs" 1048575 > "$_mpa_dir/$_mpa_name" &
_mpa_pids="$_mpa_pids $!"
done
for _mpa_pid in $_mpa_pids; do
wait "$_mpa_pid" || true
done
}
_mirror_vars() {
case "$1" in
pypi)
[ "$_mf_uv" = false ] || echo "UV_DEFAULT_INDEX=$_MIRROR_PYPI"
[ "$_mf_pip" = false ] || echo "PIP_INDEX_URL=$_MIRROR_PYPI" ;;
unsynced)
# Only for one rerun: uv's unsafe-first-match fetches every package from every index, and fails outright when one is unreachable.
[ "$_mf_uv" = false ] || echo "UV_DEFAULT_INDEX=https://pypi.org/simple UV_INDEX=$_MIRROR_PYPI UV_INDEX_STRATEGY=${UV_INDEX_STRATEGY:-unsafe-first-match}"
[ "$_mf_pip" = false ] || echo "PIP_EXTRA_INDEX_URL=https://pypi.org/simple PIP_INDEX_URL=$_MIRROR_PYPI" ;;
torch) echo "UNSLOTH_PYTORCH_MIRROR=$_MIRROR_CERNET/pytorch/whl" ;;
node) echo "UNSLOTH_NODE_MIRROR=$_MIRROR_NPM/-/binary/node" ;;
npm) echo "UNSLOTH_NPM_REGISTRY=$_MIRROR_NPM" ;;
python) echo "UV_PYTHON_INSTALL_MIRROR=$_MIRROR_PYTHON" ;;
uvbin) echo "UNSLOTH_UV_WHEEL_MIRROR=$_MIRROR_CERNET/pypi/web" ;;
esac
}
_mirror_name() {
case "$1" in
pypi) echo "PyPI" ;;
unsynced) echo "The PyPI mirror" ;;
torch) echo "download.pytorch.org" ;;
node) echo "nodejs.org" ;;
npm) echo "registry.npmjs.org" ;;
python) echo "releases.astral.sh (Python builds)" ;;
uvbin) echo "releases.astral.sh (uv)" ;;
esac
}
_mirror_use() {
_mu_to=""
for _mu_pair in $(_mirror_vars "$1"); do
export "$_mu_pair"
[ -n "$_mu_to" ] || _mu_to=${_mu_pair#*=}
done
step "mirror" "$(_mirror_name "$1") is $2 ($(($3 / 1024)) KB/s, mirror $(($4 / 1024)) KB/s); using $_mu_to" "$C_WARN"
_mf_switched="$_mf_switched $1"
[ "$1 $2" != "pypi slow" ] || _mf_unsynced=true
}
_mirror_take() {
_MT_PAIRS=""
_mt_spare=""
for _mt_entry in ${_UNSLOTH_MIRROR_SPARE:-}; do
if [ "${_mt_entry%%|*}" = "$1" ]; then
_MT_PAIRS=$(printf '%s' "${_mt_entry#*|}" | tr '|' ' ')
else
_mt_spare="$_mt_spare $_mt_entry"
fi
done
[ -n "$_MT_PAIRS" ] || return 1
export _UNSLOTH_MIRROR_SPARE="${_mt_spare# }"
_mt_to=${_MT_PAIRS%% *}
step "mirror" "$(_mirror_name "$1") failed; retrying through ${_mt_to#*=}" "$C_WARN"
}
_mirror_switch() {
_mirror_take "$1" || return 1
for _ms_pair in $_MT_PAIRS; do export "$_ms_pair"; done
}
_mirror_failed_host() {
if ! grep -Eqi 'error sending request|timed out|network timeout|idle timeout|connection (reset|refused|closed|aborted)|network aborted|broken pipe|dns error|failed to lookup address|name resolution|nodename nor servname|network is unreachable|error decoding response body|end of file before message length|unexpected eof|tls handshake|sslerror|certificate verify failed|server error|service unavailable|bad gateway|gateway time-?out|too many requests|max retries exceeded|remotedisconnected|incompleteread|econnreset|etimedout|eidletimeout|eai_again|enotfound|econnrefused|socket hang up' "$1" 2>/dev/null; then
grep -Eqi 'only [^ ]+ (.* )?(is|are) available|no versions? of|not found in the package registry|could not find a version that satisfies|no matching distribution found' "$1" 2>/dev/null || return 1
echo unsynced
return 0
fi
if grep -Eq 'download(-r2)?\.pytorch\.org' "$1"; then echo torch
elif grep -q 'python-build-standalone' "$1"; then echo python
elif grep -q 'registry\.npmjs\.org' "$1"; then echo npm
elif grep -Eq 'pypi\.org|pythonhosted\.org' "$1"; then echo pypi
elif [ -n "${2:-}" ] && ! grep -Eq 'https?://' "$1"; then echo "$2"
else return 1
fi
}
# No network call: a mainland China time zone, or a resolver from a mainland public DNS or cloud (the addresses below).
_mirror_in_china() {
_mcn_tz=${TZ:-}
[ -n "$_mcn_tz" ] || _mcn_tz=$(cat /etc/timezone 2>/dev/null) || true
[ -n "$_mcn_tz" ] || _mcn_tz=$(readlink /etc/localtime 2>/dev/null) || true
case "${_mcn_tz#:}" in
*Asia/Shanghai|*Asia/Chongqing|*Asia/Chungking|*Asia/Harbin|*Asia/Urumqi|*Asia/Kashgar|PRC|*/PRC) return 0 ;;
esac
grep -Eqs '^[[:space:]]*nameserver[[:space:]]+(223\.5\.5\.5|223\.6\.6\.6|119\.29\.29\.29|114\.114\.11[45]\.11[0459]|182\.254\.116\.116|119\.28\.28\.28|180\.76\.76\.76|1\.2\.4\.8|210\.2\.4\.8|100\.100\.2\.13[68]|183\.60\.8[23]\.(19|98))[[:space:]]*$' /etc/resolv.conf /run/systemd/resolve/resolv.conf
}
# Decided once per process; when off, a retry state inherited from a parent is dropped so nothing downstream acts on it.
_mirror_enabled() {
if [ -z "${_mirror_on:-}" ]; then
case "${UNSLOTH_MIRROR_FALLBACK:-}" in
0|false|False|FALSE|no|off) _mirror_on=no ;;
1|true|True|TRUE|yes|on) _mirror_on=yes ;;
*) if _mirror_in_china; then _mirror_on=yes; else _mirror_on=no; fi ;;
esac
[ "$_mirror_on" = yes ] || unset _UNSLOTH_MIRROR_SPARE
fi
[ "$_mirror_on" = yes ]
}
_mirror_fallback() {
_mirror_enabled || return 0
[ -z "${_UNSLOTH_MIRROR_PROBED:-}" ] || return 0
command -v curl >/dev/null 2>&1 || return 0
[ "${1:-}" = spare ] || export _UNSLOTH_MIRROR_PROBED=1
_mf_uv=true
_mf_pip=true
_mf_switched=""
_mf_unsynced=false
_mirror_configured uv && _mf_uv=false
_mirror_configured pip && _mf_pip=false
_mf_hosts=""
if [ "$_mf_uv" = true ] || [ "$_mf_pip" = true ]; then
_mf_hosts="pypi"
fi
[ -n "${UNSLOTH_PYTORCH_MIRROR:-}${UNSLOTH_TORCH_INDEX_URL:-}" ] || _mf_hosts="$_mf_hosts torch"
[ -n "${UNSLOTH_NODE_MIRROR:-}" ] || _mf_hosts="$_mf_hosts node"
[ -n "${UNSLOTH_NPM_REGISTRY:-}${NPM_CONFIG_REGISTRY:-}${npm_config_registry:-}" ] || _mf_hosts="$_mf_hosts npm"
_mirror_configured python || _mf_hosts="$_mf_hosts python"
[ -n "${UNSLOTH_UV_WHEEL_MIRROR:-}${UV_DOWNLOAD_URL:-}${INSTALLER_DOWNLOAD_URL:-}${UV_INSTALLER_GHE_BASE_URL:-}${UV_INSTALLER_GITHUB_BASE_URL:-}" ] || _mf_hosts="$_mf_hosts uvbin"
[ -n "$_mf_hosts" ] || return 0
# UV_OFFLINE (uv's spellings) asked for no network: arm the retries, probe nothing.
_mf_uvo=${UV_OFFLINE:-}
_mf_uvo=${_mf_uvo#"${_mf_uvo%%[![:space:]]*}"}
_mf_uvo=${_mf_uvo%"${_mf_uvo##*[![:space:]]}"}
case "${1:-}/$_mf_uvo" in
spare/* | */1 | */[Tt] | */[Tt][Rr][Uu][Ee] | */[Yy] | */[Yy][Ee][Ss] | */[Oo][Nn]) _mirror_spare_export; return 0 ;;
esac
_mf_dir=$(mktemp -d 2>/dev/null) || return 0
_mf_pids=""
_mirror_index_probe $_mf_hosts
for _mf_name in $(for _mf_host in $_mf_hosts; do _mirror_default "$_mf_host"; done | sort -u); do
_mirror_probe "$(_mirror_url "$_mf_name")" 1.5 1048575 > "$_mf_dir/$_mf_name"
done
_mirror_index_wait
_mf_slow=""
for _mf_host in $_mf_hosts; do
read -r _mf_code _mf_bps < "$_mf_dir/$(_mirror_default "$_mf_host")"
_mirror_index_ok "$_mf_host" || _mf_code=000
case "$_mf_code" in
000|3??) _mf_slow="$_mf_slow $_mf_host" ;;
2??) [ "$_mf_bps" -ge "$_MIRROR_MIN_BPS" ] || _mf_slow="$_mf_slow $_mf_host" ;;
esac
done
if [ -n "$_mf_slow" ]; then
_mirror_index_probe $_mf_slow $(for _mf_host in $_mf_slow; do echo "cernet-$_mf_host"; done)
_mirror_probe_all "$_mf_dir" 4 $(for _mf_host in $_mf_slow; do _mirror_default "$_mf_host"; _mirror_source "$_mf_host"; done | sort -u)
_mirror_index_wait
for _mf_host in $_mf_slow; do
read -r _mf_code _mf_bps < "$_mf_dir/$(_mirror_default "$_mf_host")"
read -r _mf_mcode _mf_mbps < "$_mf_dir/$(_mirror_source "$_mf_host")"
_mirror_index_ok "$_mf_host" || _mf_code=000
_mirror_index_ok "cernet-$_mf_host" || _mf_mcode=000
case "$_mf_code" in
2??) _mf_how=slow ;;
*) _mf_how=blocked; _mf_bps=0 ;;
esac
case "$_mf_mcode" in
2??) [ "$_mf_bps" -lt "$_MIRROR_MIN_BPS" ] && [ "$_mf_mbps" -gt "$_mf_bps" ] && _mirror_use "$_mf_host" "$_mf_how" "$_mf_bps" "$_mf_mbps" ;;
esac
done
if [ -n "$_mf_switched" ]; then
substep "Set UNSLOTH_MIRROR_FALLBACK=0 to always use the default hosts."
fi
fi
rm -rf "$_mf_dir"
_mirror_spare_export
}
_mirror_spare_export() {
_mf_spare=""
for _mf_host in $_mf_hosts; do
case " $_mf_switched " in *" $_mf_host "*) continue ;; esac
_mf_entry=""
for _mf_pair in $(_mirror_vars "$_mf_host"); do
_mf_entry="$_mf_entry|$_mf_pair"
done
[ -z "$_mf_entry" ] || _mf_spare="$_mf_spare $_mf_host$_mf_entry"
done
if [ "$_mf_unsynced" = true ]; then
_mf_spare="$_mf_spare unsynced"
for _mf_pair in $(_mirror_vars unsynced); do
_mf_spare="$_mf_spare|$_mf_pair"
done
fi
export _UNSLOTH_MIRROR_SPARE="${_mf_spare# }"
}
# ── END mirror fallback ──
# ── Install uv ──
tauri_log "STEP" "Installing uv package manager"
# 0.13.4 is the first uv whose bundled roots drop the compromised emSign CAs (GHSA-hq85-5cpj-mr62); anything older can be served forged PyPI wheels. It also clears 0.9.3, the first uv whose managed-Python manifest carries CPython 3.13.9 (older tops out at 3.13.8, which cannot import torch, see PYTHON_SKIP).
UV_MIN_VERSION="0.13.4"
# The floor before this raised it. An offline host may keep a uv between the two, since those installs worked without touching the network; below it the installer rejected the uv outright and still has to, or it proceeds on a uv missing flags it is about to be handed (--default-index, --torch-backend).
UV_OFFLINE_MIN_VERSION="0.8.16"
: "${UV_COMPILE_BYTECODE_TIMEOUT:=180}"
export UV_COMPILE_BYTECODE_TIMEOUT
: "${UV_HTTP_RETRIES:=5}"
export UV_HTTP_RETRIES
: "${UV_HTTP_TIMEOUT:=180}"
export UV_HTTP_TIMEOUT
if [ "$OS" = "macos" ]; then
: "${UV_SYSTEM_CERTS:=1}"
: "${UV_NATIVE_TLS:=$UV_SYSTEM_CERTS}"
fi
[ -n "${UV_SYSTEM_CERTS:-}" ] && export UV_SYSTEM_CERTS
[ -n "${UV_NATIVE_TLS:-}" ] && export UV_NATIVE_TLS
_mirror_fallback
version_ge() {
# returns 0 if $1 >= $2
_a=$1
_b=$2
while [ -n "$_a" ] || [ -n "$_b" ]; do
_a_part=${_a%%.*}
_b_part=${_b%%.*}
[ "$_a" = "$_a_part" ] && _a="" || _a=${_a#*.}
[ "$_b" = "$_b_part" ] && _b="" || _b=${_b#*.}
[ -z "$_a_part" ] && _a_part=0
[ -z "$_b_part" ] && _b_part=0
if [ "$_a_part" -gt "$_b_part" ]; then
return 0
fi
if [ "$_a_part" -lt "$_b_part" ]; then
return 1
fi
done
return 0
}
# 3.13.8: python/cpython#139783 breaks inspect.getsourcelines, so `import torch` fails.
PYTHON_SKIP="3.13.8"
# Skips exist only because torch cannot import; --no-torch installs ignore them.
_python_skip_applies() {
[ "$SKIP_TORCH" != true ]
}
_python_is_skipped() {
_python_skip_applies || return 1
for _bad in $PYTHON_SKIP; do
[ "$1" = "$_bad" ] && return 0
done
return 1
}
# Exclude skipped patches with a PEP 440 range ("!=3.13.8", not ">=3.13.9") so an offline
# host can still use a cached 3.13.7.
_python_request() { # requested version -> what uv is asked for
_python_skip_applies || { echo "$1"; return 0; }
case "$1" in
[0-9]*.[0-9]*.*|*/*|*\\*) echo "$1"; return 0 ;;
[0-9]*.[0-9]*) ;;
*) echo "$1"; return 0 ;;
esac
_req_minor=${1#*.}
case "$_req_minor" in
''|*[!0-9]*) echo "$1"; return 0 ;;
esac
_req=">=$1,<${1%%.*}.$((_req_minor + 1))"
for _bad in $PYTHON_SKIP; do
case "$_bad" in
"$1".*) _req="$_req,!=$_bad" ;;
esac
done
echo "$_req"
}
_uv_version_ok() { # uv command, floor (defaults to UV_MIN_VERSION)
_floor=${2:-$UV_MIN_VERSION}
_raw=$("$1" --version 2>/dev/null | awk '{print $2}') || return 1
[ -n "$_raw" ] || return 1
_ver=${_raw%%[-+]*}
case "$_ver" in
''|*[!0-9.]*) return 1 ;;
esac
version_ge "$_ver" "$_floor" || return 1
# Prerelease of the exact minimum (e.g. 0.7.14-rc1) is still below stable 0.7.14
[ "$_ver" = "$_floor" ] && [ "$_raw" != "$_ver" ] && return 1
return 0
}
# ── uv from a pinned release ──
# Mirrors Install-UvFromRelease in install.ps1; bumping the version means bumping every hash (<asset>.sha256) and every _uv_pinned_wheel entry.
UV_PINNED_VERSION="0.13.5"
# sha256 of astral's versioned install.sh for UV_PINNED_VERSION (the release's uv-installer.sh asset), checked before the
# unpinned-host fallback runs it.
UV_INSTALLER_SH_SHA256="1c6e7f025499664633ac2dce43a210800510e9ef0625c52e034a8afdf8bf177a"
# Prints the glibc minor or nothing; an unconfirmed host must fall back to astral's musl build.
_uv_glibc_minor() {
_ugm_line=$( (ldd --version 2>/dev/null || true) | head -1 )
case "$_ugm_line" in *[Mm]usl*) return 1 ;; esac
_ugm_ver=$(printf '%s\n' "$_ugm_line" | awk '{print $NF}')
case "$_ugm_ver" in
2.[0-9]*) : ;;
*) _ugm_ver=$(getconf GNU_LIBC_VERSION 2>/dev/null | awk '{print $NF}') ;;
esac
case "$_ugm_ver" in 2.[0-9]*) : ;; *) return 1 ;; esac
_ugm_minor=${_ugm_ver#2.}
_ugm_minor=${_ugm_minor%%.*}
case "$_ugm_minor" in "" | *[!0-9]*) return 1 ;; esac
echo "$_ugm_minor"
return 0
}
_uv_pinned_asset() {
_upa_os=$(uname -s 2>/dev/null || echo unknown)
_upa_arch=$(uname -m 2>/dev/null || echo unknown)
case "$_upa_os" in
Linux)
[ "$(getconf LONG_BIT 2>/dev/null || echo 0)" = "64" ] || return 1
_upa_glibc=$(_uv_glibc_minor) || return 1
case "$_upa_arch" in
x86_64|amd64)
[ "$_upa_glibc" -ge 17 ] 2>/dev/null || return 1
echo "uv-x86_64-unknown-linux-gnu.tar.gz f68d291c75783122dad58ab77def0e13f3233f358731db5d6d7e8715603b928f" ;;
aarch64|arm64)
[ "$_upa_glibc" -ge 28 ] 2>/dev/null || return 1
echo "uv-aarch64-unknown-linux-gnu.tar.gz 4bb69cf95ed13f23713bb7bc09f241896ea0c75b7c557e615a20604b82f9c403" ;;
*) return 1 ;;
esac
;;
Darwin)
# Under Rosetta, astral ships the native arm64 build; match it.
if [ "$_upa_arch" = "x86_64" ] && [ "$(sysctl -n hw.optional.arm64 2>/dev/null)" = "1" ]; then
_upa_arch=arm64
fi
case "$_upa_arch" in
x86_64)
echo "uv-x86_64-apple-darwin.tar.gz b0f52b0e3945b4107d89024ea38e625683011fbfc05a0d98f9fa2d6dcee28c66" ;;
arm64|aarch64)
echo "uv-aarch64-apple-darwin.tar.gz 743f85035a1996ecb350659e01cd9faea9709829be09b1b4b03c73eaf3c9cd73" ;;
*) return 1 ;;
esac
;;
*) return 1 ;;
esac
return 0
}
_uv_pinned_wheel() {
case "$1" in
uv-x86_64-unknown-linux-gnu.tar.gz)
echo "packages/3a/ad/3c2a81d66d1839063662e5b6c0002ec31f8ee987dbc5295747627ca6160a/uv-0.13.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl fa8d26e08e34c29c0c4e820cf3181757086d547240b4f4d31ff1d8bd810ca946" ;;
uv-aarch64-unknown-linux-gnu.tar.gz)
echo "packages/4c/92/d83d019c63c1ccc6ff17df2bfe76270412f9766e7e71aa4d4ccbf018683f/uv-0.13.5-py3-none-manylinux_2_28_aarch64.whl 91e58e304b63b6c91bac5561459ab0c3ea24f17bc01ea9359d69fed72142978f" ;;
uv-x86_64-apple-darwin.tar.gz)
echo "packages/e3/2a/dcae794203261e49564bb9ff7c4b64a093f69ac73476cc8ce54c1deed3a1/uv-0.13.5-py3-none-macosx_10_12_x86_64.whl 337a14829bb499e114989df4f908f134ff37f1fb6c33497541cfb05b28080e39" ;;
uv-aarch64-apple-darwin.tar.gz)
echo "packages/d2/26/9ac45211123244ea68be852775093af487a64debc13621a2de239f8ebe73/uv-0.13.5-py3-none-macosx_11_0_arm64.whl a42c85fa8156e1e5e2cecdbc77bb818ea1dcf5f78b8e2a8f84c7fe4f16f0702e" ;;
*) return 1 ;;
esac
}
_uv_unzip() {
if command -v unzip >/dev/null 2>&1 && unzip -qo "$1" -d "$2" >/dev/null 2>&1; then return 0; fi
case "$(tar --version 2>/dev/null)" in
*bsdtar*) tar -xf "$1" -C "$2" 2>/dev/null && return 0 ;;
esac
command -v python3 >/dev/null 2>&1 &&
python3 -m zipfile -e "$1" "$2" >/dev/null 2>&1
}
_uv_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" 2>/dev/null | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" 2>/dev/null | awk '{print $1}'
fi
}
# Hang-proof liveness probe: no stdin and a ceiling (timeout -k, or a watchdog on macOS).
_uv_signal_target() {
# bash reads a bare negative pid as a signal spec and dash refuses `--`; the sign picks the form.
case "$2" in
-*) kill "-$1" -- "$2" 2>/dev/null || : ;;
*) kill "-$1" "$2" 2>/dev/null || : ;;
esac
}
# TERM, then KILL, as `timeout -k` does. $1 target, $2 pid to watch, $3 grace seconds.
_uv_probe_terminate() {
_upt_grace=0
_uv_signal_target TERM "$1"
while [ "$_upt_grace" -lt "$3" ] && kill -0 "$2" 2>/dev/null; do
sleep 1
_upt_grace=$((_upt_grace + 1))
done
if kill -0 "$2" 2>/dev/null; then _uv_signal_target KILL "$1"; fi
unset _upt_grace
}
_uv_probe_exec() {
_upe_secs="${_UV_PROBE_SECONDS:-20}"
if command -v timeout >/dev/null 2>&1 && timeout -k 1 5 true >/dev/null 2>&1; then
timeout -k 5 "$_upe_secs" "$1" --version >/dev/null 2>&1 </dev/null
return $?
fi
# Monitor mode gives the probe its own process group so signals reach its children.
_upe_monitor=off
case "$-" in *m*) _upe_monitor=on ;; esac
[ "$_upe_monitor" = on ] || set -m 2>/dev/null || :
"$1" --version >/dev/null 2>&1 </dev/null &
_upe_pid=$!
[ "$_upe_monitor" = on ] || set +m 2>/dev/null || :
_upe_target="$_upe_pid"
if command -v ps >/dev/null 2>&1; then
_upe_pgid=$(ps -o pgid= -p "$_upe_pid" 2>/dev/null)
_upe_self=$(ps -o pgid= -p $$ 2>/dev/null)
_upe_pgid=${_upe_pgid##* }
_upe_self=${_upe_self##* }
case "$_upe_pgid$_upe_self" in
''|*[!0-9]*) : ;;
*) [ "$_upe_pgid" = "$_upe_self" ] || _upe_target="-$_upe_pgid" ;;
esac
fi
# Published so the HUP/INT/TERM handlers kill the probe instead of orphaning it.
_UV_PROBE_TARGET="$_upe_target"
_UV_PROBE_PID="$_upe_pid"
_upe_waited=0
while kill -0 "$_upe_pid" 2>/dev/null; do
if [ "$_upe_waited" -ge "$_upe_secs" ]; then
_uv_probe_terminate "$_upe_target" "$_upe_pid" 5
wait "$_upe_pid" 2>/dev/null
_UV_PROBE_TARGET=""
_UV_PROBE_PID=""
unset _upe_pid _upe_waited _upe_target _upe_pgid _upe_self
return 124
fi
sleep 1
_upe_waited=$((_upe_waited + 1))
done
wait "$_upe_pid"
_upe_rc=$?
_UV_PROBE_TARGET=""
_UV_PROBE_PID=""
unset _upe_pid _upe_waited _upe_target _upe_pgid _upe_self
return $_upe_rc
}
_uv_install_pinned() {
_UIP_UNFETCHED=false
_uip_spec=$(_uv_pinned_asset) || return 1
[ -n "$_uip_spec" ] || return 1
_uip_asset=${_uip_spec%% *}
_uip_want=${_uip_spec##* }
command -v tar >/dev/null 2>&1 || return 1
if [ -z "$(_uv_sha256 /dev/null)" ]; then return 1; fi
_uip_dest=""
for _uip_candidate in "${_STUDIO_UV_DIR:-}" "${UV_INSTALL_DIR:-}" "${UV_UNMANAGED_INSTALL:-}" "${XDG_BIN_HOME:-}"; do
if [ -n "$_uip_candidate" ]; then _uip_dest="$_uip_candidate"; break; fi
done
if [ -z "$_uip_dest" ] && [ -n "${XDG_DATA_HOME:-}" ]; then _uip_dest="$XDG_DATA_HOME/../bin"; fi
if [ -z "$_uip_dest" ]; then
[ -n "${HOME:-}" ] || return 1
_uip_dest="$HOME/.local/bin"
fi
_uip_work=$(mktemp -d 2>/dev/null) || return 1
_UIP_WORK="$_uip_work"
_uip_rc=1
# A configured mirror is exclusive, as for astral; download() has no timeout.
if [ -n "${UV_DOWNLOAD_URL:-}" ]; then
_uip_bases="${UV_DOWNLOAD_URL%/}"
elif [ -n "${INSTALLER_DOWNLOAD_URL:-}" ]; then
_uip_bases="${INSTALLER_DOWNLOAD_URL%/}"
elif [ -n "${UV_INSTALLER_GHE_BASE_URL:-}" ]; then
_uip_bases="${UV_INSTALLER_GHE_BASE_URL%/}/astral-sh/uv/releases/download/$UV_PINNED_VERSION"
elif [ -n "${UV_INSTALLER_GITHUB_BASE_URL:-}" ]; then
_uip_bases="${UV_INSTALLER_GITHUB_BASE_URL%/}/astral-sh/uv/releases/download/$UV_PINNED_VERSION"
elif [ -n "${UNSLOTH_UV_WHEEL_MIRROR:-}" ]; then
_uip_bases=""
if _uip_wheel=$(_uv_pinned_wheel "$_uip_asset"); then
_uip_path=${_uip_wheel% *}
_uip_bases="${UNSLOTH_UV_WHEEL_MIRROR%/}/${_uip_path%/*}"
_uip_asset=${_uip_path##*/}
_uip_want=${_uip_wheel##* }
fi
else
_uip_bases="https://releases.astral.sh/github/uv/releases/download/$UV_PINNED_VERSION
https://github.com/astral-sh/uv/releases/download/$UV_PINNED_VERSION"
fi
_UIP_UNFETCHED=true
for _uip_base in $_uip_bases; do
if ! download "$_uip_base/$_uip_asset" "$_uip_work/$_uip_asset" 2>/dev/null; then continue; fi
_UIP_UNFETCHED=false
_uip_got=$(_uv_sha256 "$_uip_work/$_uip_asset")
if [ "$_uip_got" != "$_uip_want" ]; then
# Not tauri_log: the app shows unknown [TAURI:*] markers verbatim in its progress UI.
if _is_verbose; then
echo "uv archive digest mismatch from $_uip_base, trying the next source" >&2
fi
continue
fi
case "$_uip_asset" in
*.whl) _uv_unzip "$_uip_work/$_uip_asset" "$_uip_work" || continue ;;
*) tar -xzf "$_uip_work/$_uip_asset" -C "$_uip_work" 2>/dev/null || continue ;;
esac
mkdir -p "$_uip_dest" 2>/dev/null || break
_uip_placed=0
# uv and uvx ship as a set: stage both, then publish both, or touch nothing.
_uip_ready=1
for _uip_exe in uv uvx; do
if [ -d "$_uip_dest/$_uip_exe" ]; then _uip_ready=0; break; fi
_uip_src=$(find "$_uip_work" -type f -name "$_uip_exe" 2>/dev/null | head -1)
if [ -z "$_uip_src" ] || [ ! -f "$_uip_src" ]; then _uip_ready=0; break; fi
# Rename, not cp: cp writes through a symlinked destination. mktemp avoids racing installers.
_uip_stage=$(mktemp "$_uip_dest/.$_uip_exe.XXXXXX" 2>/dev/null) || { _uip_ready=0; break; }
if [ "$_uip_exe" = "uv" ]; then _UIP_STAGE="$_uip_stage"; else _UIP_STAGE2="$_uip_stage"; fi
if ! cp -f "$_uip_src" "$_uip_stage" 2>/dev/null; then _uip_ready=0; break; fi
# 0755, not +x: under umask 077, +x leaves uv unusable for other accounts.
chmod 0755 "$_uip_stage" 2>/dev/null || true
# Validate before publishing: the rename destroys the incumbent (noexec, missing loader).
if [ "$_uip_exe" = "uv" ] && ! _uv_probe_exec "$_uip_stage"; then _uip_ready=0; break; fi
done
if [ "$_uip_ready" = "1" ] &&
mv -f "$_UIP_STAGE" "$_uip_dest/uv" 2>/dev/null &&
mv -f "$_UIP_STAGE2" "$_uip_dest/uvx" 2>/dev/null; then
_uip_placed=1
fi
rm -f "$_UIP_STAGE" "$_UIP_STAGE2" 2>/dev/null || true
_UIP_STAGE=""
_UIP_STAGE2=""
# The staged binary already answered --version above, before it replaced anything.
if [ "$_uip_placed" = "1" ] && [ -x "$_uip_dest/uv" ]; then
export PATH="$_uip_dest:$PATH"
_UNSLOTH_UV_BIN_DIR="$_uip_dest"
_uip_rc=0
fi
break
done
rm -rf "$_uip_work"
_UIP_WORK=""
_UIP_STAGE=""
_UIP_STAGE2=""
# Nothing is unwound on failure: deleting could remove a working uv the host already had.
return "$_uip_rc"
}
# Only installed when no usable uv is on PATH; never written to a shell profile.
_STUDIO_UV_DIR=""
_STUDIO_UV_STALE=false
if [ "$_STUDIO_KEEPS_TOOLS" = true ] && [ -z "${UV_INSTALL_DIR:-}${UV_UNMANAGED_INSTALL:-}${XDG_BIN_HOME:-}" ]; then
_STUDIO_UV_DIR="$STUDIO_HOME/uv"
if [ -x "$_STUDIO_UV_DIR/uv" ]; then
# Studio puts this one first at runtime, so a stale copy is replaced even when PATH has a good uv.
if _uv_version_ok "$_STUDIO_UV_DIR/uv"; then export PATH="$_STUDIO_UV_DIR:$PATH"; else _STUDIO_UV_STALE=true; fi
fi
fi
if ! command -v uv >/dev/null 2>&1 || ! _uv_version_ok uv || [ "$_STUDIO_UV_STALE" = true ]; then
# Raising the floor pulled every 0.8.16-0.13.3 host into this block, and those installs used to succeed without touching the network, so a download failure must not be fatal for them. An unreadable version counts as present: that is a minimal image without awk, not an old uv.
_uv_present_before=false
if command -v uv >/dev/null 2>&1; then
# `|| _uv_prev_ver=`: without awk the pipeline exits 127 and set -e would kill the install.
_uv_prev_ver=$(uv --version 2>/dev/null | awk '{print $2}' 2>/dev/null) \
|| _uv_prev_ver=""
if [ -z "$_uv_prev_ver" ] || _uv_version_ok uv "$UV_OFFLINE_MIN_VERSION"; then
_uv_present_before=true
fi
fi
substep "installing uv package manager..."
_uv_refreshed=true
if command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1; then
# Pinned release first: fetch a digest-checked data file rather than download-run-delete a remote script. See tests/studio/test_installer_av_shapes.py (AV_SHAPES_RECORD)
if _uv_install_pinned || { [ "$_UIP_UNFETCHED" = true ] && _mirror_switch uvbin && _uv_install_pinned; }; then
:
else
# Unpinned hosts keep the path they have always had: a wrong triple breaks the install outright, which costs more than the fallback's score.
# The versioned installer installs the same pinned release (and checks its per-asset sha256), not whatever is latest.
_uv_tmp=$(mktemp)
if download "https://astral.sh/uv/$UV_PINNED_VERSION/install.sh" "$_uv_tmp"; then
# A host with no sha256 tool runs it as before; anything else must be the exact pinned script.
# A hasher that fails (sha256sum without awk) counts as no hasher, not as an exit under set -e.
_uv_inst_sum=$(_uv_sha256 "$_uv_tmp" 2>/dev/null) || _uv_inst_sum=""
if [ -n "$_uv_inst_sum" ] && [ "$_uv_inst_sum" != "$UV_INSTALLER_SH_SHA256" ]; then
substep "uv installer script failed its sha256 check; not running it"
_uv_refreshed=false
elif [ -n "$_STUDIO_UV_DIR" ]; then
# astral's unmanaged mode: that directory only, no profile edits, no receipt.
if (export UV_UNMANAGED_INSTALL="$_STUDIO_UV_DIR"; run_maybe_quiet sh "$_uv_tmp" </dev/null); then
_UNSLOTH_UV_BIN_DIR="$_STUDIO_UV_DIR"
else
_uv_refreshed=false
fi
else
run_maybe_quiet sh "$_uv_tmp" </dev/null || _uv_refreshed=false
fi
else
_uv_refreshed=false
fi
rm -f "$_uv_tmp"
fi
else
_uv_refreshed=false
fi
# Offline, a stale Studio-private uv is kept the same way, under the same offline floor.
if [ "$_uv_refreshed" = false ] && [ "$_STUDIO_UV_STALE" = true ] \
&& _uv_version_ok "$_STUDIO_UV_DIR/uv" "$UV_OFFLINE_MIN_VERSION"; then
export PATH="$_STUDIO_UV_DIR:$PATH"
_uv_present_before=true
fi
if [ "$_uv_refreshed" = false ] && [ "$_uv_present_before" = false ]; then
tauri_log "ERROR" "Could not install uv"
step "error" "could not download uv, and none is installed" "$C_ERR"
substep "Check the network, or install uv manually: https://docs.astral.sh/uv/"
exit 1
fi
# Kept a uv below the floor: its bundled roots still trust the compromised emSign CA, so the OS
# store decides instead, where there is one (UV_NATIVE_TLS too: uv before 0.11 reads only that).
# A download that failed while online lands here too; UV_SYSTEM_CERTS=0 opts out.
if [ "$_uv_refreshed" = false ] && [ "${UV_SYSTEM_CERTS:-1}" != 0 ] \
&& { [ "$(uname -s)" = Darwin ] || [ -r /etc/ssl/certs/ca-certificates.crt ] || [ -r /etc/pki/tls/certs/ca-bundle.crt ]; }; then
export UV_SYSTEM_CERTS="${UV_SYSTEM_CERTS:-1}" UV_NATIVE_TLS="${UV_NATIVE_TLS:-1}"
fi
if [ -f "$HOME/.local/bin/env" ]; then
. "$HOME/.local/bin/env"
fi
export PATH="$HOME/.local/bin:$PATH"
# And put the pinned destination back in front. UV_INSTALL_DIR and friends can put uv somewhere other than ~/.local/bin, and both the line above and astral's env file prepend ~/.local/bin, so a stale uv there would shadow the pinned one we just verified.
if [ -n "${_UNSLOTH_UV_BIN_DIR:-}" ] && [ "$_UNSLOTH_UV_BIN_DIR" != "$HOME/.local/bin" ]; then
export PATH="$_UNSLOTH_UV_BIN_DIR:$PATH"
fi
fi
_configure_uv_cache
# ── Create venv (migrate old layout if possible, otherwise fresh) ──
tauri_log "STEP" "Creating virtual environment"
mkdir -p "$STUDIO_HOME"
_MIGRATED=false
_PREV_TORCH_VER=""
_EXISTING_INSTALL=false
# The release being replaced, shown next to the new one at the end (#9910).
_PREV_PACKAGE_VERSION=""
if [ -x "$VENV_DIR/bin/python" ] || _dir_has_entries "$VENV_DIR"; then
# why: matching guard to the .venv branch below -- in env-mode $STUDIO_HOME is user-chosen, so refuse to nuke an existing unsloth_studio that lacks Unsloth sentinels; accept the in-VENV marker so partial-install retries are not blocked. The root marker goes through _claim_sentinel, not -f: the claim refuses to write one through a link, so reading one through a link here would undo that. The older sentinels keep -f, since bin/unsloth is legitimately a symlink into the venv.
if [ "$_STUDIO_HOME_REDIRECT" = "env" ] \
&& ! _claim_sentinel "$STUDIO_HOME/.unsloth-studio-owned" \
&& [ ! -f "$VENV_DIR/.unsloth-studio-owned" ] \
&& [ ! -f "$STUDIO_HOME/share/studio.conf" ] \
&& [ ! -f "$STUDIO_HOME/bin/unsloth" ]; then
echo "ERROR: $VENV_DIR already exists but does not look like an Unsloth Studio install." >&2
echo " Move it aside or choose an empty UNSLOTH_STUDIO_HOME." >&2
exit 1
fi
_EXISTING_INSTALL=true
# Record the old torch before the venv moves aside. Read version.py first: `import torch` can
# hang on a wedged Intel driver.
_PREV_TORCH_VER=""
for _prev_tv in "$VENV_DIR"/lib/python*/site-packages/torch/version.py; do
[ -f "$_prev_tv" ] || continue
_PREV_TORCH_VER=$(sed -n "s/^__version__ = '\([^']*\)'.*/\1/p" "$_prev_tv" | head -n 1)
break
done
# _run_bounded the fallback: without version.py it hits `import torch`, which can wedge.
[ -n "$_PREV_TORCH_VER" ] || _PREV_TORCH_VER=$(_run_bounded "$VENV_DIR/bin/python" -I -c \
"import torch; print(torch.__version__)" 2>/dev/null | tail -n 1 || true)
_PREV_PACKAGE_VERSION=$(_dist_version_from_disk "$VENV_DIR" "$PACKAGE_NAME")
[ -z "$_PREV_PACKAGE_VERSION" ] || substep "found $PACKAGE_NAME $_PREV_PACKAGE_VERSION"
# New layout already exists — replace only after preserving rollback copy, unless the caller asked for no copy at all, in which case this line would be contradicted by the "discarded" one _start_studio_venv_replacement prints a moment later. install.ps1 varies its twin the same way.
if [ "${_NO_ROLLBACK:-false}" = true ]; then
substep "moving the existing environment aside..."
else
substep "preserving existing environment for rollback..."
fi
if ! _start_studio_venv_replacement "$VENV_DIR"; then
echo "ERROR: could not move $VENV_DIR aside to reinstall." >&2
echo " Check that $STUDIO_HOME is writable, or move $VENV_DIR yourself and re-run." >&2
exit 1
fi
elif [ "$_STUDIO_HOME_REDIRECT" != "env" ] && [ -x "$STUDIO_HOME/.venv/bin/python" ]; then
substep "found legacy Unsloth environment, validating..."
_EXISTING_INSTALL=true
for _prev_tv in "$STUDIO_HOME"/.venv/lib/python*/site-packages/torch/version.py; do
[ -f "$_prev_tv" ] || continue
_PREV_TORCH_VER=$(sed -n "s/^__version__ = '\([^']*\)'.*/\1/p" "$_prev_tv" | head -n 1)
break
done
_legacy_ok=false
if [ "$SKIP_TORCH" = true ]; then
if "$STUDIO_HOME/.venv/bin/python" -c "import sys; print(sys.executable)" >/dev/null 2>&1; then
_legacy_ok=true
fi
elif "$STUDIO_HOME/.venv/bin/python" -I -c "
import torch
device = 'cuda' if torch.cuda.is_available() else 'cpu'
A = torch.ones((10, 10), device=device)
B = torch.ones((10, 10), device=device)
C = torch.ones((10, 10), device=device)
D = A + B
E = D @ C
torch.testing.assert_close(torch.unique(E), torch.tensor((20,), device=E.device, dtype=E.dtype))
" >/dev/null 2>&1; then
_legacy_ok=true
fi
if [ "$_legacy_ok" = true ]; then
echo "✅ Legacy environment is healthy — migrating..."
# `mv` into an existing directory nests it (#9479), so clear the empty target first.
if [ -L "$VENV_DIR" ]; then
rm -f "$VENV_DIR"
elif [ -d "$VENV_DIR" ] && ! rmdir "$VENV_DIR" 2>/dev/null; then
echo "ERROR: $VENV_DIR is in the way of the legacy migration." >&2
echo " Move it aside and re-run." >&2
exit 1
fi
mv "$STUDIO_HOME/.venv" "$VENV_DIR"
echo " Moved ~/.unsloth/studio/.venv → $VENV_DIR"
_MIGRATED=true
else
echo "⚠️ Legacy environment failed validation — creating fresh environment"
_invalid_venv="$STUDIO_HOME/.venv.invalid.$(date +%Y%m%d%H%M%S 2>/dev/null || echo time).$$"
mv "$STUDIO_HOME/.venv" "$_invalid_venv" 2>/dev/null || true
fi
elif [ "$_STUDIO_HOME_REDIRECT" != "env" ] && _dir_has_entries "$STUDIO_HOME/.venv"; then
_EXISTING_INSTALL=true
for _prev_tv in "$STUDIO_HOME"/.venv/lib/python*/site-packages/torch/version.py; do
[ -f "$_prev_tv" ] || continue
_PREV_TORCH_VER=$(sed -n "s/^__version__ = '\([^']*\)'.*/\1/p" "$_prev_tv" | head -n 1)
break
done
fi
if [ "$SKIP_TORCH" = true ] && [ "$MAC_INTEL" = true ] && [ -z "$_USER_PYTHON" ] && [ -x "$VENV_DIR/bin/python" ]; then
_PY_MM=$("$VENV_DIR/bin/python" -c \
"import sys; print('{}.{}'.format(*sys.version_info[:2]))" 2>/dev/null || echo "")
if [ "$_PY_MM" != "3.12" ]; then
echo " Recreating Intel Mac environment with Python 3.12 (was $_PY_MM)..."
rm -rf "$VENV_DIR"
fi
fi
# uv runs install_name_tool on macOS, which pops the CLT dialog without dev tools
# (astral-sh/uv#14893). Do not execute it or xcrun to probe.
_macos_has_selected_install_name_tool() {
_uvv_developer_dir=$(xcode-select -p 2>/dev/null) || return 1
[ -n "$_uvv_developer_dir" ] && [ -d "$_uvv_developer_dir" ] || return 1
for _uvv_tool in \
"$_uvv_developer_dir/usr/bin/install_name_tool" \
"$_uvv_developer_dir/Toolchains/XcodeDefault.xctoolchain/usr/bin/install_name_tool"; do
[ -x "$_uvv_tool" ] || continue
if [ -e /usr/bin/install_name_tool ] \
&& [ "$_uvv_tool" -ef /usr/bin/install_name_tool ] 2>/dev/null; then
continue
fi
return 0
done
return 1
}
# Without a real install_name_tool, shim a failing one for uv only, so uv keeps its warning path.
_run_uv_venv() { # label, uv-venv args...
_uvv_label="$1"
shift
if [ "$OS" != "macos" ] || _macos_has_selected_install_name_tool; then
run_install_cmd "$_uvv_label" uv venv "$@"
return $?
fi
_UV_INSTALL_NAME_TOOL_SHIM_DIR=$(mktemp -d \
"${TMPDIR:-/tmp}/unsloth-uv-install-name-tool.XXXXXX") || {
echo "ERROR: could not create the temporary macOS uv guard." >&2
tauri_stream_log stderr "ERROR_OUTPUT" "$_uvv_label failed (temporary guard)"
return 1
}
if ! printf '%s\n' '#!/bin/sh' 'exit 1' \
> "$_UV_INSTALL_NAME_TOOL_SHIM_DIR/install_name_tool" \
|| ! chmod +x "$_UV_INSTALL_NAME_TOOL_SHIM_DIR/install_name_tool"; then
echo "ERROR: could not prepare the temporary macOS uv guard." >&2
rm -rf "$_UV_INSTALL_NAME_TOOL_SHIM_DIR" 2>/dev/null || true
_UV_INSTALL_NAME_TOOL_SHIM_DIR=""
tauri_stream_log stderr "ERROR_OUTPUT" "$_uvv_label failed (temporary guard)"
return 1
fi
if run_install_cmd "$_uvv_label" env \
PATH="$_UV_INSTALL_NAME_TOOL_SHIM_DIR:$PATH" uv venv "$@"; then
_uvv_status=0
else
_uvv_status=$?
fi
rm -rf "$_UV_INSTALL_NAME_TOOL_SHIM_DIR" 2>/dev/null || true
_UV_INSTALL_NAME_TOOL_SHIM_DIR=""
return "$_uvv_status"
}
# arm64 CPython so uv does not reuse a cached x86_64 (Rosetta) build; torch has no macOS x86_64
# wheels. only-managed avoids executing /usr/bin/python3 (CLT dialog); retry unflagged offline.
_uv_venv_arm64() { # label
_run_uv_venv "$1" "$VENV_DIR" \
--python-preference only-managed \
--python "cpython-${PYTHON_VERSION}-macos-aarch64-none" \
|| _run_uv_venv "$1 (system Python)" "$VENV_DIR" \
--python "cpython-${PYTHON_VERSION}-macos-aarch64-none"
}
# Fedora sets python-downloads = "manual"; install the interpreter explicitly, then retry.
_uv_venv_requested() { # label
_uvvr_label="$1"
_uvvr_req="$(_python_request "$PYTHON_VERSION")"
_UV_VENV_CAPTURE_DIR=""
if ! command -v tee >/dev/null 2>&1 \
|| ! _UV_VENV_CAPTURE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/unsloth-uv-venv.XXXXXX") \
|| ! mkfifo "$_UV_VENV_CAPTURE_DIR/out_pipe" "$_UV_VENV_CAPTURE_DIR/err_pipe"; then
[ -n "$_UV_VENV_CAPTURE_DIR" ] && rm -rf "$_UV_VENV_CAPTURE_DIR" || true
_UV_VENV_CAPTURE_DIR=""
_run_uv_venv "$_uvvr_label" "$VENV_DIR" --python "$_uvvr_req"
return $?
fi
_uvvr_out="$_UV_VENV_CAPTURE_DIR/out"
_uvvr_err="$_UV_VENV_CAPTURE_DIR/err"
tee -u /dev/null </dev/null >/dev/null 2>&1 && _uvvr_tee_u=-u || _uvvr_tee_u=
tee $_uvvr_tee_u "$_uvvr_out" < "$_UV_VENV_CAPTURE_DIR/out_pipe" &
_uvvr_tee_out=$!
tee $_uvvr_tee_u "$_uvvr_err" < "$_UV_VENV_CAPTURE_DIR/err_pipe" >&2 &
_uvvr_tee_err=$!
if _run_uv_venv "$_uvvr_label" "$VENV_DIR" --python "$_uvvr_req" \
>"$_UV_VENV_CAPTURE_DIR/out_pipe" 2>"$_UV_VENV_CAPTURE_DIR/err_pipe"; then
_uvvr_status=0
else
_uvvr_status=$?
fi
wait "$_uvvr_tee_out" "$_uvvr_tee_err" 2>/dev/null || true
if [ "$_uvvr_status" -eq 0 ]; then
rm -rf "$_UV_VENV_CAPTURE_DIR"
_UV_VENV_CAPTURE_DIR=""
return 0
fi
if grep -q "Python downloads are set to 'manual'" "$_uvvr_out" "$_uvvr_err" 2>/dev/null \
|| grep -q "python-downloads" "$_uvvr_out" "$_uvvr_err" 2>/dev/null; then
rm -rf "$_UV_VENV_CAPTURE_DIR"
_UV_VENV_CAPTURE_DIR=""
run_install_cmd "$_uvvr_label (managed Python)" \
uv python install "$_uvvr_req" || return $?
_run_uv_venv "$_uvvr_label" "$VENV_DIR" --python "$_uvvr_req" || return $?
return 0
fi
rm -rf "$_UV_VENV_CAPTURE_DIR"
_UV_VENV_CAPTURE_DIR=""
return "$_uvvr_status"
}
if [ ! -x "$VENV_DIR/bin/python" ]; then
step "venv" "creating Python ${PYTHON_VERSION} virtual environment"
substep "$VENV_DIR"
if [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ] && [ -z "$_USER_PYTHON" ]; then
_uv_venv_arm64 "create venv"
else
_uv_venv_requested "create venv"
fi
fi
if [ -x "$VENV_DIR/bin/python" ]; then
: > "$VENV_DIR/.unsloth-studio-owned" 2>/dev/null || true
fi
# Two independent Apple Silicon venv guards (Rosetta x86_64, the 3.13.8 torch bug); re-inspect, not elif.
if [ -z "$_USER_PYTHON" ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then
_inspect_venv() {
"$VENV_DIR/bin/python" -c \
"import platform, sys; print(platform.machine(), '{}.{}.{}'.format(*sys.version_info[:3]))" \
2>/dev/null || echo " "
}
_info=$(_inspect_venv)
_VENV_ARCH=${_info%% *}
_PY_VER=${_info##* }
if [ -z "$_VENV_ARCH" ] && [ -x "$VENV_DIR/bin/python" ]; then
# file -L first: lipo is a CLT shim and running it can pop a GUI dialog.
_archs=$(file -L "$VENV_DIR/bin/python" 2>/dev/null \
|| lipo -archs "$VENV_DIR/bin/python" 2>/dev/null || true)
case "$_archs" in
*arm64*) _VENV_ARCH=arm64 ;;
*x86_64*) _VENV_ARCH=x86_64 ;;
esac
fi
if [ "$_VENV_ARCH" = "x86_64" ]; then
echo " WARNING: venv was created with an x86_64 (Rosetta) Python on Apple Silicon."
echo " Recreating venv with native arm64 Python ${PYTHON_VERSION}..."
_discard_venv_for_recreate "$VENV_DIR"
_uv_venv_arm64 "recreate venv (arm64)"
if [ -x "$VENV_DIR/bin/python" ]; then
: > "$VENV_DIR/.unsloth-studio-owned" 2>/dev/null || true
fi
_info=$(_inspect_venv)
_VENV_ARCH=${_info%% *}
_PY_VER=${_info##* }
fi
if _python_is_skipped "$_PY_VER"; then
echo " WARNING: Python $_PY_VER cannot import torch."
echo " Recreating venv with Python 3.12..."
_discard_venv_for_recreate "$VENV_DIR"
PYTHON_VERSION="3.12"
_uv_venv_arm64 "recreate venv"
if [ -x "$VENV_DIR/bin/python" ]; then
: > "$VENV_DIR/.unsloth-studio-owned" 2>/dev/null || true
fi
fi
fi
# The request above only decides what a NEW venv gets. A venv from an earlier run, on any platform, can still hold a skipped interpreter, and reusing it is how the reported installs stayed broken across re-runs. Honour --python.
if [ -z "$_USER_PYTHON" ] && [ -x "$VENV_DIR/bin/python" ]; then
_PY_VER=$("$VENV_DIR/bin/python" -c \
'import sys; print("{}.{}.{}".format(*sys.version_info[:3]))' 2>/dev/null || echo "")
if _python_is_skipped "$_PY_VER"; then
echo " WARNING: Python $_PY_VER cannot import torch."
echo " Recreating venv..."
_discard_venv_for_recreate "$VENV_DIR"
_uv_venv_requested "recreate venv"
if [ -x "$VENV_DIR/bin/python" ]; then
: > "$VENV_DIR/.unsloth-studio-owned" 2>/dev/null || true
fi
fi
fi
if [ -x "$VENV_DIR/bin/python" ]; then
step "venv" "using environment"
substep "${VENV_DIR}"
fi
# Bump all three ceilings when the next minor is validated; ROCm floors below stay literal.
_TORCH_CEILING="2.12.0"
_TORCHVISION_CEILING="0.27.0"
_TORCHAUDIO_CEILING="2.12.0"
# Default torch constraint; tightened for Python 3.13+ on arm64 macOS (no cp313 wheels below 2.6).
# torch 2.4 is supported without torchcodec: install_python_stack.py's codec table starts at 2.5 (#10598).
TORCH_CONSTRAINT="torch>=2.4,<${_TORCH_CEILING}"
if [ "$SKIP_TORCH" = false ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then
_PY_MINOR=$("$VENV_DIR/bin/python" -c \
"import sys; print(sys.version_info.minor)" 2>/dev/null || echo "0")
if [ "$_PY_MINOR" -ge 13 ] 2>/dev/null; then
TORCH_CONSTRAINT="torch>=2.6,<${_TORCH_CEILING}"
fi
fi
# Companions bounded to torch's window: torchaudio 2.11 dropped its torch pin, so it can drift.
TORCHVISION_CONSTRAINT="torchvision>=0.19,<${_TORCHVISION_CEILING}"
TORCHAUDIO_CONSTRAINT="torchaudio>=2.4,<${_TORCHAUDIO_CEILING}"
_CU130_TORCH_CEILING="2.15.0"
_CU130_NEW_INSTALL_TORCH="torch>=2.13.0,<2.14.0"
# ── Resolve repo root (for --local installs) ──
_REPO_ROOT="$(cd "$(dirname "$0" 2>/dev/null || echo ".")" && pwd)"
# Trust adjacent scripts only for an explicit --local run from the file itself; a piped install's
# _REPO_ROOT is the caller's cwd, where a planted file would run.
_REPO_IS_CHECKOUT=0
case "$0" in
*/install.sh|install.sh)
[ "$STUDIO_LOCAL_INSTALL" = true ] && [ -r "$0" ] && _REPO_IS_CHECKOUT=1 ;;
esac
_ZOO_REF="${UNSLOTH_ZOO_REF:-main}"
_ZOO_GIT_SPEC="unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo@${_ZOO_REF}"
# ── Helper: find no-torch-runtime.txt (local repo or site-packages) ──
_find_no_torch_runtime() {
if [ "$_REPO_IS_CHECKOUT" = "1" ] && [ -f "$_REPO_ROOT/studio/backend/requirements/no-torch-runtime.txt" ]; then
echo "$_REPO_ROOT/studio/backend/requirements/no-torch-runtime.txt"
return
fi
_rt=$(find "$VENV_DIR" -path "*/studio/backend/requirements/no-torch-runtime.txt" -print -quit 2>/dev/null || echo "")
if [ -n "$_rt" ]; then
echo "$_rt"
return
fi
}
# ── AMD ROCm GPU detection helper ──
_ensure_rocm_probe_env() {
export HSA_ENABLE_DXG_DETECTION="${HSA_ENABLE_DXG_DETECTION:-1}"
if ! command -v rocminfo >/dev/null 2>&1 && [ -x /opt/rocm/bin/rocminfo ]; then
PATH="$PATH:/opt/rocm/bin"
fi
}
# True when the run was told to install ROCm torch even with CUDA usable (#10450).
# Mirrors install_python_stack._rocm_torch_explicitly_requested; keep the two in step.
_rocm_torch_explicitly_requested() {
case "$(printf '%s' "${UNSLOTH_FORCE_ROCM_TORCH:-}" \
| sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | tr '[:upper:]' '[:lower:]')" in
1|true|yes|on) return 0 ;;
*) return 1 ;;
esac
}
# The NVIDIA veto lives inside this function because tests/sh lifts probes one function at a time.
_has_amd_rocm_gpu() {
_ensure_rocm_probe_env
if [ "${1:-}" != "ignore-nvidia" ] && _has_usable_nvidia_gpu && \
! _rocm_torch_explicitly_requested; then
return 1
fi
if command -v rocminfo >/dev/null 2>&1 && \
rocminfo 2>/dev/null | awk '/Name:[[:space:]]*gfx[1-9][0-9]/{found=1} END{exit !found}'; then
return 0
elif command -v amd-smi >/dev/null 2>&1 && \
amd-smi list 2>/dev/null | awk '/^GPU[[:space:]]*[:\[][[:space:]]*[0-9]/{ found=1 } END{ exit !found }'; then
return 0
elif [ -e /dev/kfd ] && \
awk '/vendor_id/ && $2 == 4098 { found = 1 } END { exit !found }' \
/sys/class/kfd/kfd/topology/nodes/*/properties 2>/dev/null; then
# vendor_id 4098 (0x1002) is AMD; KFD CPU nodes report 0 and NVIDIA's open module 4318.
return 0
fi
return 1
}
# Hardware-only check: _infer_linux_amd_gfx_arch returns a declared arch before probing.
_amd_hardware_corroborated() {
_amd_gpu_present_via_pci && return 0
# On WSL neither /dev/dxg nor librocdxg names a vendor, hence "physical" mode.
if [ -e /dev/dxg ] || grep -qi microsoft /proc/version 2>/dev/null; then
for _ahc_d in /opt/rocm/lib /opt/rocm/lib64 /opt/rocm-*/lib /opt/rocm-*/lib64; do
{ [ -e "$_ahc_d/librocdxg.so" ] || [ -e "$_ahc_d/librocdxg.so.1" ]; } || continue
_has_usable_nvidia_gpu || return 0
[ -n "$(_probe_amd_gfx_arch physical 2>/dev/null)" ] && return 0
return 1
done
fi
return 1
}
# Whether any index carries kernels for this arch (gfx1010 has none).
# Mirrors _gfx_has_a_wheel_route / _GENERIC_ROCM_WHEEL_GFX in install_python_stack.py.
_amd_gfx_has_wheel_route() {
_amd_arch_index_family_for_gfx "$1" >/dev/null 2>&1 && return 0
case "$1" in
gfx900|gfx906|gfx908|gfx90a|gfx942|gfx950) : ;;
gfx1030|gfx1100|gfx1101|gfx1102|gfx1150|gfx1151|gfx1200|gfx1201) : ;;
*) return 1 ;;
esac
_amd_generic_tag_carries_gfx "$1"
}
# Whether the generic wheel for this host's ROCm version covers the arch; unreadable is NO.
# Mirrors _GENERIC_WHEEL_GFX_MIN_ROCM.
_amd_generic_tag_carries_gfx() {
case "$1" in
gfx950|gfx1150|gfx1151) _agtc_min_major=7; _agtc_min_minor=0 ;;
gfx1200|gfx1201) _agtc_min_major=6; _agtc_min_minor=4 ;;
*) return 0 ;;
esac
_agtc_tag=$(_detect_rocm_version_tag 2>/dev/null) || _agtc_tag=""
_agtc_ver=${_agtc_tag#rocm}
_agtc_major=$(printf '%s' "$_agtc_ver" | cut -d. -f1)
_agtc_minor=$(printf '%s' "$_agtc_ver" | cut -d. -f2)
case "$_agtc_major$_agtc_minor" in
''|*[!0-9]*) return 1 ;;
esac
[ "$_agtc_major" -gt "$_agtc_min_major" ] && return 0
[ "$_agtc_major" -eq "$_agtc_min_major" ] && [ "$_agtc_minor" -ge "$_agtc_min_minor" ] && return 0
return 1
}
# ${VAR+x}: a set-but-empty mask hides every device. Mirrors _visible_masks_select_no_gpu.
_amd_visible_masks_select_no_gpu() {
if [ -n "${HIP_VISIBLE_DEVICES+x}" ]; then
_avm_hip=HIP_VISIBLE_DEVICES
else
_avm_hip=CUDA_VISIBLE_DEVICES
fi
for _avm_var in ROCR_VISIBLE_DEVICES "$_avm_hip"; do
eval "_avm_set=\${$_avm_var+x}"
eval "_avm_val=\${$_avm_var:-}"
[ -n "$_avm_set" ] || continue
case "$(printf '%s' "$_avm_val" | tr -d '[:space:]')" in
''|-1) return 0 ;;
esac
done
return 1
}
# Survivors are the prefix of resolvable ordinals; ROCr also stops at a repeated ordinal.
_amd_mask_survivors() {
printf '%s\n' "$1" | _ams_vis="$2" _ams_layer="${3:-}" awk '
BEGIN { vis = ENVIRON["_ams_vis"]; layer = ENVIRON["_ams_layer"] }
NF { vals[n++] = $0 }
END {
count = split(vis, want, ",")
for (i = 1; i <= count; i++) {
gsub(/^[ \t]+|[ \t]+$/, "", want[i])
if (want[i] !~ /^[0-9]+$/) break
idx = want[i] + 0
if (idx >= n) break
if (layer == "rocr" && (idx in seen)) break
seen[idx] = 1
print vals[idx]
}
}'
}
# ROCr filters and renumbers beneath HIP; the first survivor wins.
_amd_runtime_gfx_target() {
_argt_list="$1"
if [ -n "${ROCR_VISIBLE_DEVICES:-}" ]; then
_argt_list=$(_amd_mask_survivors "$_argt_list" "$ROCR_VISIBLE_DEVICES" rocr)
[ -n "$_argt_list" ] || return 0
fi
_argt_hip="${HIP_VISIBLE_DEVICES:-}"
if [ -z "${HIP_VISIBLE_DEVICES+x}" ]; then
_argt_hip="${CUDA_VISIBLE_DEVICES:-}"
fi
if [ -n "$_argt_hip" ]; then
_argt_list=$(_amd_mask_survivors "$_argt_list" "$_argt_hip")
[ -n "$_argt_list" ] || return 0
fi
printf '%s\n' "$_argt_list" | awk 'NF { print; exit }'
}
# Mirror of _SHADOWING_INTEGRATED_GFX, held to it by tests/studio/install/test_rocm_arch_table_parity.py.
_amd_gfx_is_shadowing_integrated() {
case "$1" in
gfx90c|gfx1013|gfx1033|gfx1035|gfx1036|gfx1103|gfx1153) return 0 ;;
esac
return 1
}
# The card to install for when enumeration put an integrated GPU first (#7776): one arch picks
# the wheel family, so letting the APU decide strands the discrete card. gfx906 is never a
# candidate, since naming it on a mixed host strands BOTH cards. A third argument of "torch"
# also defers a Strix APU (gfx1150-gfx1152) to a routable discrete card: its per-arch wheels
# carry no other arch. Strix stays off the shadowing table, so llama.cpp and a lone Strix keep
# it. Mirrors _STRIX_APU_GFX in _runtime_gfx_target (install_python_stack.py).
_amd_prefer_discrete_gfx() {
_apdg_devs="$1"
_apdg_sel="$2"
_apdg_strix=""
case "${3:-}:$_apdg_sel" in
torch:gfx1150|torch:gfx1151|torch:gfx1152) _apdg_strix=1 ;;
esac
# Strix defers only to a generic rocm7.13+ index (AMD's Strix fixes); otherwise it keeps its wheels.
if [ -n "$_apdg_strix" ]; then
case "${_torch_index_leaf:-}" in
rocm[0-9]*.[0-9]*) _rocm_leaf_below "$_torch_index_leaf" 7 13 && _apdg_strix="" ;;
*) _apdg_strix="" ;;
esac
fi
# A readable HSA override spoofs every agent, the dGPU included: keep the Strix wheels.
if [ -n "$_apdg_strix" ] && [ -n "${HSA_OVERRIDE_GFX_VERSION:-}" ] && \
[ -n "$(_hsa_override_gfx_arch "$HSA_OVERRIDE_GFX_VERSION" 2>/dev/null)" ]; then
_apdg_strix=""
fi
if [ -z "$_apdg_strix" ] && ! _amd_gfx_is_shadowing_integrated "$_apdg_sel"; then
printf '%s' "$_apdg_sel"
return 0
fi
if [ -n "${HIP_VISIBLE_DEVICES+x}" ] || [ -n "${ROCR_VISIBLE_DEVICES+x}" ] || \
[ -n "${CUDA_VISIBLE_DEVICES+x}" ]; then
printf '%s' "$_apdg_sel"
return 0
fi
_apdg_others=$(printf '%s\n' "$_apdg_devs" | awk 'NF' | while IFS= read -r _apdg_g; do
_amd_gfx_is_shadowing_integrated "$_apdg_g" && continue
[ "$_apdg_g" = gfx906 ] && continue
# No `case` here: bash 3.2 (macOS /bin/sh) misparses its `)` inside `$(...)`.
if [ -n "$_apdg_strix" ] && { [ "$_apdg_g" = gfx1150 ] || [ "$_apdg_g" = gfx1151 ] || \
[ "$_apdg_g" = gfx1152 ]; }; then
continue
fi
printf '%s\n' "$_apdg_g"
done)
_apdg_pick=$(printf '%s\n' "$_apdg_others" | awk 'NF' | while IFS= read -r _apdg_g; do
_amd_gfx_has_wheel_route "$_apdg_g" && printf '%s\n' "$_apdg_g"
done | awk 'NF { print; exit }')
if [ -z "$_apdg_pick" ] && ! _amd_gfx_has_wheel_route "$_apdg_sel"; then
_apdg_pick=$(printf '%s\n' "$_apdg_others" | awk 'NF { print; exit }')
fi
[ -n "$_apdg_pick" ] || _apdg_pick="$_apdg_sel"
printf '%s' "$_apdg_pick"
}
_amd_request_has_a_wheel_route() {
_AMD_REQUEST_TARGET_GFX=""
# Only a probe-resolved target may clear a safety gate; a declared one just routes.
_AMD_REQUEST_TARGET_SOURCE=""
_amd_visible_masks_select_no_gpu && return 1
# A declared arch decides, but only after hardware corroborates there is an AMD GPU.
_arwr_decl=$(printf '%s' "${UNSLOTH_ROCM_GFX_ARCH:-}" \
| tr '[:upper:]' '[:lower:]' | sed 's/:.*$//' | tr -d '[:space:]')
if [ -n "$_arwr_decl" ]; then
_amd_hardware_corroborated || _kfd_gfx_targets 2>/dev/null | grep -q . || return 1
if [ -n "${HIP_VISIBLE_DEVICES+x}" ] || [ -n "${ROCR_VISIBLE_DEVICES+x}" ] || \
[ -n "${CUDA_VISIBLE_DEVICES+x}" ]; then
_arwr_dd=$(_amd_ordered_gfx_devices 2>/dev/null | sed 's/:.*$//' \
| tr '[:upper:]' '[:lower:]' | awk 'NF')
[ -n "$_arwr_dd" ] || _arwr_dd=$(_kfd_gfx_targets 2>/dev/null | sed 's/:.*$//' \
| tr '[:upper:]' '[:lower:]' | awk 'NF')
if [ -n "$_arwr_dd" ] && [ -z "$(_amd_runtime_gfx_target "$_arwr_dd")" ]; then
return 1
fi
fi
if _amd_gfx_has_wheel_route "$_arwr_decl"; then
_AMD_REQUEST_TARGET_GFX="$_arwr_decl"
_AMD_REQUEST_TARGET_SOURCE=declared
return 0
fi
return 1
fi
_arwr_all=$(_probe_amd_gfx_arch physical 2>/dev/null || true)
[ -n "$_arwr_all" ] || _arwr_all=$(_kfd_gfx_targets 2>/dev/null || true)
if [ -z "$_arwr_all" ]; then
_amd_hardware_corroborated || return 1
_arwr_all=$(_infer_linux_amd_gfx_arch 2>/dev/null || true)
fi
_arwr_archs=$(printf '%s\n' "$_arwr_all" | sed 's/:.*$//' \
| tr '[:upper:]' '[:lower:]' | awk 'NF')
[ -n "$_arwr_archs" ] || return 1
# Physical count before masks: gfx906's rocm6.3 tag requires it to be the sole arch.
_arwr_count=$(printf '%s\n' "$_arwr_archs" | sort -u | wc -l | tr -d ' ')
# Fails closed: a wrong yes replaces a working CUDA stack with kernel-less wheels.
_arwr_devs=$(_amd_ordered_gfx_devices 2>/dev/null | sed 's/:.*$//' \
| tr '[:upper:]' '[:lower:]' | awk 'NF')
if [ -z "$_arwr_devs" ]; then
_arwr_kfd=$(_kfd_gfx_targets 2>/dev/null | sed 's/:.*$//' \
| tr '[:upper:]' '[:lower:]' | awk 'NF')
if [ -n "$_arwr_kfd" ] && \
[ "$(printf '%s\n' "$_arwr_kfd" | awk 'NF' | wc -l | tr -d ' ')" \
= "$(printf '%s\n' "$_arwr_archs" | awk 'NF' | wc -l | tr -d ' ')" ]; then
_arwr_devs="$_arwr_kfd"
fi
fi
if [ -n "$_arwr_devs" ]; then
_arwr_sel=$(_amd_runtime_gfx_target "$_arwr_devs")
elif [ "$_arwr_count" -eq 1 ]; then
_arwr_sel=$(_amd_runtime_gfx_target "$_arwr_archs")
else
return 1
fi
[ -n "$_arwr_sel" ] || return 1
_arwr_pref="$_arwr_devs"
[ -n "$_arwr_pref" ] || _arwr_pref="$_arwr_archs"
_arwr_sel=$(_amd_prefer_discrete_gfx "$_arwr_pref" "$_arwr_sel")
[ "$_arwr_sel" = gfx906 ] && [ "$_arwr_count" -gt 1 ] && return 1
_amd_gfx_has_wheel_route "$_arwr_sel" || return 1
_AMD_REQUEST_TARGET_GFX="$_arwr_sel"
_AMD_REQUEST_TARGET_SOURCE=probe
return 0
}
# Single answer to "does NVIDIA win" so index selection and reroutes agree (#10450).
_nvidia_gpu_wins_over_amd() {
_has_usable_nvidia_gpu || return 1
if _rocm_torch_explicitly_requested && _amd_request_has_a_wheel_route; then
return 1
fi
return 0
}
_pci_devices_root() { printf '%s' /sys/bus/pci/devices; }
# First XPU-capable Intel display device id; same allowlist as hardware.py _INTEL_XPU_PCI_*.
_intel_xpu_gpu_id() {
for _ix_vendor in "$(_pci_devices_root)"/*/vendor; do
[ -r "$_ix_vendor" ] || continue
read -r _ix_v < "$_ix_vendor" 2>/dev/null || continue
[ "$_ix_v" = "0x8086" ] || continue
_ix_dir="${_ix_vendor%vendor}"
read -r _ix_c < "${_ix_dir}class" 2>/dev/null || continue
case "$_ix_c" in 0x03*) ;; *) continue ;; esac
read -r _ix_d < "${_ix_dir}device" 2>/dev/null || continue
_ix_d=$(printf '%s' "$_ix_d" | tr '[:upper:]' '[:lower:]')
case "$_ix_d" in
0x7d55|0x7d51|0x64a0|0xb080|0xb081|0xb082|0xb083|0xb084|0xb085|0xb086|0xb087) printf '%s' "$_ix_d"; return 0 ;;
# PVC (Data Center GPU Max) by exact id: 0x0BE0-0x0BE5 nearby is Cedar Trail (gma500).
0x0bd0|0x0bd4|0x0bd5|0x0bd6|0x0bd7|0x0bd8|0x0bd9|0x0bda|0x0bdb|0x0b69|0x0b6e) printf '%s' "$_ix_d"; return 0 ;;
esac
_ix_n=$(printf '%d' "$_ix_d" 2>/dev/null) || continue
if { [ "$_ix_n" -ge 22160 ] && [ "$_ix_n" -le 22210 ]; } || # 0x5690-0x56C2
{ [ "$_ix_n" -ge 57856 ] && [ "$_ix_n" -le 58111 ]; }; then # 0xE200-0xE2FF
printf '%s' "$_ix_d"; return 0
fi
done
return 1
}
# Declines beside any AMD silicon: the runtime-less AMD reroute owns a */cpu index there.
_intel_xpu_auto_gpu_id() {
[ "${UNSLOTH_DISABLE_XPU_AUTO:-0}" = 1 ] && return 1
[ -n "${UNSLOTH_ROCM_GFX_ARCH:-}" ] && return 1
# Level Zero / SYCL device-filter indices need not follow PCI order, so any filter leaves the choice to a pin.
_ix_var=""
[ -n "${SYCL_DEVICE_FILTER+x}" ] && _ix_var=SYCL_DEVICE_FILTER
[ -n "${SYCL_DEVICE_ALLOWLIST+x}" ] && _ix_var=SYCL_DEVICE_ALLOWLIST
[ -n "${ONEAPI_DEVICE_SELECTOR+x}" ] && _ix_var=ONEAPI_DEVICE_SELECTOR
# Level Zero reads an empty or "default" mask as unset (compute-runtime isAffinityMaskSet).
case "${ZE_AFFINITY_MASK-default}" in ""|default) ;; *) _ix_var=ZE_AFFINITY_MASK ;; esac
if [ -n "$_ix_var" ]; then
echo "[INFO] $_ix_var is set -- skipping the Intel XPU auto route; set UNSLOTH_TORCH_INDEX_FAMILY=xpu to install XPU PyTorch." >&2
return 1
fi
_amd_hardware_corroborated && return 1
_intel_xpu_gpu_id
}
# Returns 0 if an AMD display GPU is on the PCI bus even when ROCm cannot use it (a Strix Halo iGPU with no /dev/kfd). Only sharpens the "no GPU detected" hint. vendor 0x1002 = AMD/ATI; class 0x03* = display controller.
_amd_gpu_present_via_pci() {
[ -d /sys/bus/pci/devices ] || return 1
for _pci_vendor in /sys/bus/pci/devices/*/vendor; do
[ -r "$_pci_vendor" ] || continue
read -r _v < "$_pci_vendor" 2>/dev/null || continue
[ "$_v" = "0x1002" ] || continue
_cls="${_pci_vendor%vendor}class"
[ -r "$_cls" ] || continue
read -r _c < "$_cls" 2>/dev/null || continue
case "$_c" in 0x03*) return 0 ;; esac
done
return 1
}
_amd_candidate_nodes() {
printf '%s\n' /dev/kfd /dev/dri/renderD*
}
# Unreadable is not the same answer as "not AMD". Mirrors utils/hardware/amd.py::_render_node_vendor.
_amd_render_node_vendor() {
_arnv_file="/sys/class/drm/${1##*/}/device/vendor"
[ -r "$_arnv_file" ] || return 1
read -r _arnv_vendor < "$_arnv_file" 2>/dev/null || return 1
printf '%s' "$_arnv_vendor"
}
# AMD device nodes that exist but this user cannot open (root:render 0660). AMD-owned only:
# render nodes are root:render for every vendor.
_amd_nodes_closed_to_this_user() {
_anctu_amd_in_topology=""
_amd_candidate_nodes | while IFS= read -r _node; do
[ -e "$_node" ] || continue
{ [ -r "$_node" ] && [ -w "$_node" ]; } && continue
if [ "$_node" = /dev/kfd ]; then
# An unreadable KFD topology is not evidence of another vendor; DRM confirms instead.
# Mirrors utils/hardware/amd.py::amd_nodes_closed_to_this_user.
_anctu_kfd_state=0
_kfd_topology_amd_state || _anctu_kfd_state=$?
if [ "$_anctu_kfd_state" -eq 1 ]; then
continue
elif [ "$_anctu_kfd_state" -ne 0 ]; then
_a_confirmed_amd_render_node_exists || continue
fi
elif _node_vendor=$(_amd_render_node_vendor "$_node"); then
[ "$_node_vendor" = "0x1002" ] || continue
else
# Unknown sysfs vendor is not "not AMD"; the world-readable KFD topology answers instead.
if [ -z "$_anctu_amd_in_topology" ]; then
if _kfd_topology_has_an_amd_gpu; then
_anctu_amd_in_topology=yes
else
_anctu_amd_in_topology=no
fi
fi
[ "$_anctu_amd_in_topology" = yes ] || continue
fi
printf '%s\n' "$_node"
done
}
_an_amd_render_node_is_open() {
for _anro_node in /dev/dri/renderD*; do
[ -e "$_anro_node" ] || continue
_anro_vendor_file="/sys/class/drm/${_anro_node##*/}/device/vendor"
[ -r "$_anro_vendor_file" ] || continue
read -r _anro_vendor < "$_anro_vendor_file" 2>/dev/null || continue
[ "$_anro_vendor" = "0x1002" ] || continue
{ [ -r "$_anro_node" ] && [ -w "$_anro_node" ]; } && return 0
done
return 1
}
# Exit 0 = KFD names an AMD GPU, 1 = read and none, 2 = unreadable (masked /sys/class/kfd).
# Mirrors utils/hardware/amd.py::_kfd_topology_amd_state.
_kfd_topology_amd_state() {
_ktas=$(awk '
FNR == 1 { read_one = 1 }
/vendor_id/ && $2 == 4098 { found = 1 }
END { print (read_one + 0) ":" (found + 0) }
' /sys/class/kfd/kfd/topology/nodes/*/properties 2>/dev/null) || _ktas=""
case "$_ktas" in
*:1) return 0 ;;
1:0) return 1 ;;
*) return 2 ;;
esac
}
_kfd_node_is_amds() {
_kina_state=0
_kfd_topology_amd_state || _kina_state=$?
[ "$_kina_state" -ne 1 ]
}
# AMD silicon behind a missing /dev/kfd; if KFD is unreadable, a render node must be CONFIRMED AMD.
_amd_silicon_behind_a_missing_kfd() {
if _kfd_topology_has_an_amd_gpu; then return 0; fi
_asbmk_state=0
_kfd_topology_amd_state || _asbmk_state=$?
[ "$_asbmk_state" -eq 2 ] || return 1
_a_confirmed_amd_render_node_exists
}
# Strict: an unreadable vendor does not count. Mirrors amd.py::_a_confirmed_amd_render_node_exists.
_a_confirmed_amd_render_node_exists() {
for _acarne_node in /dev/dri/renderD*; do
[ -e "$_acarne_node" ] || continue
_acarne_vendor=$(_amd_render_node_vendor "$_acarne_node") || continue
[ "$_acarne_vendor" = "0x1002" ] && return 0
done
return 1
}
_kfd_topology_has_an_amd_gpu() {
_kthag=0
_kfd_topology_amd_state || _kthag=$?
[ "$_kthag" -eq 0 ]
}
# Unknown vendor counts as present. Mirrors utils/hardware/amd.py::_amd_render_node_exists.
_amd_render_node_present() {
_arnp_unknown=false
for _arnp_node in /dev/dri/renderD*; do
[ -e "$_arnp_node" ] || continue
_arnp_vendor_file="/sys/class/drm/${_arnp_node##*/}/device/vendor"
if [ -r "$_arnp_vendor_file" ] && \
read -r _arnp_vendor < "$_arnp_vendor_file" 2>/dev/null; then
[ "$_arnp_vendor" = "0x1002" ] && return 0
else
_arnp_unknown=true
fi
done
[ "$_arnp_unknown" = true ]
}
# NSS names can hold backslashes or spaces; quote like Python shlex.quote for paste-safe commands.
_shell_quote() {
case "$1" in
"") printf "''" ;;
*[!A-Za-z0-9_@%+=:,./-]*)
printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" ;;
*) printf '%s' "$1" ;;
esac
}
# One line per node: join:NAME, gid:N (no group entry, usermod cannot take a GID), mode:PATH.
_amd_node_repairs() {
for _anr_node in $1; do
# With an ACL (ls shows "+") the group bits are the mask, so they cannot prescribe membership.
# shellcheck disable=SC2012 # not parsing a file LIST: this reads column 11 of the
# mode string for one named node; find -printf cannot report the ACL "+".
case "$(ls -ld "$_anr_node" 2>/dev/null | cut -c11)" in
+) printf 'acl:%s\n' "$_anr_node"; continue ;;
esac
# The group name goes last: NSS controls it and it may contain the separator.
stat -c '%a|%g|%u|%n|%G' "$_anr_node" 2>/dev/null || true
done | awk -F'|' -v self="$(id -u 2>/dev/null || echo -1)" \
-v mygids=" $(id -G 2>/dev/null) " '
/^acl:/ { print; next }
{
# mode|gid|uid|path|group-name. The name is last and is rejoined from every
# remaining field, so a name carrying the separator is recovered whole rather
# than silently truncated at its first pipe.
gname = $5
for (i = 6; i <= NF; i++) { gname = gname "|" $i }
# A name that cannot be pasted on as ONE group is treated as no name at all,
# and the node is reported by its GID instead. usermod -G takes a
# comma-separated list, so a group genuinely named "render,sudo" is two groups
# to it and the privileged test below, which compares whole names, walks
# straight past it. Quoting is the wrong layer: the splitting happens inside
# usermod, after the shell has handed it a single argument. The charset is
# groupadd(8) portable plus the trailing $ of a Samba machine account, and
# mirrors _GROUP_NAME_RE in utils/hardware/amd.py.
if (gname !~ /^[A-Za-z_][A-Za-z0-9_.-]*[$]?$/) { gname = "" }
# POSIX resolves the owner class EXCLUSIVELY once the uid matches, so on a
# node this account owns the group bits are never consulted and no membership
# opens it however they read. The repair there is the mode.
if (self != -1 && $3 + 0 == self + 0) {
# Unless the OWNER digit already grants rw: the node is known shut, so
# the mode is not what denies it. Same external denial as the
# already-a-member branch, by owner class. Mirrors amd.py external.
# Padded first: stat %a drops leading zeros, so mode 060 prints "60" and
# an owner index of 0 is not an error -- gawk and mawk return the GROUP
# digit, busybox "", so the answer would vary by which awk the host ships.
# The group read counts from the right, so it is unaffected.
perm = $1
while (length(perm) < 3) { perm = "0" perm }
u = substr(perm, length(perm) - 2, 1) + 0
if (u == 6 || u == 7) { print "external:" $4; next }
print "owner:" $4; next
}
# A record whose GID did not come back as a number is one this cannot reason
# about at all, so it is reported as a node no membership opens rather than
# branched on. Unreachable while stat answers, and the direction that cannot
# invent a repair if it ever stops.
if ($2 !~ /^[0-9]+$/) { print "mode:" $4; next }
# Neither the owner nor in the owning group leaves this account in the OTHER
# class, which POSIX resolves exclusively too: if its digit already grants rw
# the mode is not what denies a node already known shut, and no chmod or
# usermod moves it. Mirrors the other-class branch in amd.py. Guarded on
# membership, since a member is in the group class and the already branch
# names the group; above the group-digit test, which would otherwise print a
# mode repair for a node the mode is not blocking. The other digit is the
# LAST, so unlike the owner one it needs no padding to be read.
o = substr($1, length($1), 1) + 0
if ((o == 6 || o == 7) && mygids !~ (" " $2 " ")) { print "external:" $4; next }
# Group digit of the octal mode; read AND write, since HIP and the Vulkan
# loader both open the node read-write.
g = substr($1, length($1) - 1, 1) + 0
if (g != 6 && g != 7) { print "mode:" $4; next }
# Joining one of these hands over a great deal besides the GPU, so a node
# owned by one is a udev misconfiguration to report, not a membership to
# prescribe. gid 0 as well as the name, since a renamed root group is still
# root. Mirrors _PRIVILEGED_GROUPS in utils/hardware/amd.py. ABOVE the unnamed
# test: stat prints UNKNOWN for a gid the group database cannot name, so a
# minimal container with no entry for gid 0 filed the node as an ordinary
# unnamed GID and prescribed groupadd -g 0 plus usermod into root.
if ($2 + 0 == 0 || gname ~ /^(root|wheel|sudo|admin|adm|disk|kmem|shadow|docker|lxd)$/) {
pname = gname
if (pname == "" || pname ~ /^UNKNOWN/) { pname = "root" }
if (!pseen[pname]++) print "privileged:" pname
next
}
# The node is already known shut, so a group this account is ALREADY in is
# not what denies it: a container device cgroup or an LSM is, and usermod
# would exit 0 and change nothing. Read from `id -G`, space-padded so 100
# cannot match 1001. Above the unnamed branch for the same reason.
if (mygids ~ (" " $2 " ")) {
held = gname
if (held == "" || held ~ /^UNKNOWN/) { held = $2 }
if (!aseen[held]++) print "already:" held
next
}
if (gname == "" || gname ~ /^UNKNOWN/) { if (!gseen[$2]++) print "gid:" $2; next }
if (!nseen[gname]++) print "join:" gname
}'
}
_amd_probe_arches() {
printf '%s\n' "$1" | sed 's/:.*$//' | tr '[:upper:]' '[:lower:]' | awk 'NF' | sort -u
}
_amd_agreed_index_family() {
_aif_family=""
for _aif_a in $(_amd_probe_arches "$1"); do
_aif_f=$(_amd_arch_index_family_for_gfx "$_aif_a") || return 1
[ -z "$_aif_family" ] || [ "$_aif_f" = "$_aif_family" ] || return 1
_aif_family="$_aif_f"
done
[ -n "$_aif_family" ] || return 1
printf '%s\n' "$_aif_family"
}
# setup.sh prefers UNSLOTH_ROCM_GFX_ARCH, so do not name an arbitrary family member.
_amd_sole_index_arch() {
_sia=$(_amd_probe_arches "$1")
[ -n "$_sia" ] || return 1
[ "$(printf '%s\n' "$_sia" | awk 'END{print NR}')" -eq 1 ] || return 1
_amd_arch_index_family_for_gfx "$_sia" >/dev/null 2>&1 || return 1
printf '%s\n' "$_sia"
}
# Mirrors install.ps1 $archFamilyMap.
_amd_arch_index_family_for_gfx() {
case "$1" in
gfx1201|gfx1200) echo gfx120X-all ;;
gfx1151) echo gfx1151 ;;
gfx1150) echo gfx1150 ;;
gfx1152) echo gfx1152 ;;
gfx1103|gfx1102|gfx1101|gfx1100) echo gfx110X-all ;;
gfx1036|gfx1035|gfx1034|gfx1033|gfx1032|gfx1031|gfx1030) echo gfx103X-all ;;
gfx90a) echo gfx90a ;;
gfx908) echo gfx908 ;;
*) return 1 ;;
esac
}
# Kept in sync with install.ps1 nameArchTable.
_infer_amd_gfx_arch_from_gpu_name() {
case "$1" in
*9070*|*9080*|*"R9700"*) echo gfx1201 ;;
*9060*) echo gfx1200 ;;
*"8065S"*|*"8060S"*|*"8050S"*|*"8040S"*|*"Strix Halo"*|*"Ryzen AI Max"*|*"AI Max"*) echo gfx1151 ;;
*"890M"*|*"880M"*|*"Strix Point"*|*"HX 37"*|*"AI 9 HX"*|*"AI 9 36"*) echo gfx1150 ;;
*"860M"*|*"840M"*|*"Krackan"*|*"AI 7 35"*|*"AI 5 34"*|*"AI 7 PRO 35"*|*"AI 5 33"*) echo gfx1152 ;;
*"RX 7600"*|*"RX 7700S"*|*"RX 7650"*|*"PRO W7600"*|*"PRO W7500"*) echo gfx1102 ;;
*"RX 7800"*|*"RX 7700"*|*"PRO W7700"*|*"PRO V710"*) echo gfx1101 ;;
*"RX 7900"*|*"PRO W7900"*|*"PRO W7800"*) echo gfx1100 ;;
*"780M"*|*"760M"*|*"740M"*|*"Phoenix"*|*"Hawk Point"*|*"Z1 Extreme"*|*"Z2 Extreme"*) echo gfx1103 ;;
*"RX 6950"*|*"RX 6900"*|*"RX 6850"*|*"RX 6800"*|*"RX 6750"*|*"RX 6700"*|*"PRO W6800"*|*"PRO W6900"*) echo gfx1030 ;;
*"RX 6650"*|*"RX 6600"*|*"PRO W6600"*|*"PRO W6650"*) echo gfx1032 ;;
*"RX 6550"*|*"RX 6500"*|*"RX 6450"*|*"RX 6400"*|*"RX 6300"*|*"PRO W6400"*|*"PRO W6500"*|*"PRO W6300"*) echo gfx1034 ;;
*) return 1 ;;
esac
}
# Messaging only, never routes (#8529). ORDER IS LOAD-BEARING: RDNA 1 arms before Polaris,
# since *"RX 570"* would match "RX 5700 XT". Polaris 11/12 deliberately left out.
_infer_unsupported_amd_gfx_arch_from_gpu_name() {
case "$1" in
*"Radeon Pro V520"*|*"Radeon Pro 5600M"*) echo gfx1011 ;; # RDNA 1
*"RX 5700"*|*"RX 5600"*|*"Radeon Pro 5600 XT"*|*"Radeon Pro 5700"*|*"Radeon Pro W5700"*) echo gfx1010 ;; # RDNA 1 (Navi 10)
*"RX 5500"*|*"RX 5300"*|*"Radeon Pro W5500"*|*"Radeon Pro W5300"*) echo gfx1012 ;; # RDNA 1 (Navi 14)
*"RX 470"|*"RX 470"[!0]*|*"RX 480"|*"RX 480"[!0]*|*"RX 570"|*"RX 570"[!0]*|*"RX 580"|*"RX 580"[!0]*|*"RX 590"|*"RX 590"[!0]*|*"Radeon Pro WX 7100"*|*"Radeon Pro WX 5100"*) echo gfx803 ;; # Polaris 10/20/30
*) return 1 ;;
esac
}
_infer_linux_unsupported_amd_gfx_arch() {
command -v lspci >/dev/null 2>&1 || return 1
if [ -n "${_LSPCI_MEMO:-}" ] && [ -f "$_LSPCI_MEMO" ]; then
_unsup_disp=$(cat "$_LSPCI_MEMO")
else
_unsup_raw=$(lspci -nn 2>/dev/null) && _unsup_ok=1 || _unsup_ok=""
_unsup_disp=$(printf '%s\n' "$_unsup_raw" | grep -E 'VGA compatible controller|3D controller|Display controller' | grep -E 'AMD|ATI' || true)
# Only a successful query is kept, written whole (temp file + rename) so a failed write leaves no memo.
if [ -n "$_unsup_ok" ] && [ -n "${_LSPCI_MEMO:-}" ]; then
{ printf '%s\n' "$_unsup_disp" > "$_LSPCI_MEMO.tmp" && mv -f "$_LSPCI_MEMO.tmp" "$_LSPCI_MEMO"; } 2>/dev/null || rm -f "$_LSPCI_MEMO.tmp" 2>/dev/null || true
fi
fi
while IFS= read -r _unsup_ln; do
[ -n "$_unsup_ln" ] || continue
if _unsup_gfx=$(_infer_unsupported_amd_gfx_arch_from_gpu_name "$_unsup_ln"); then
echo "$_unsup_gfx"
return 0
fi
done <<EOF
$_unsup_disp
EOF
return 1
}
# Best-effort gfx inference when ROCm tools cannot see the GPU (unslothai#7301).
_infer_linux_amd_gfx_arch() {
if [ -n "${UNSLOTH_ROCM_GFX_ARCH:-}" ]; then
printf '%s\n' "$(printf '%s' "$UNSLOTH_ROCM_GFX_ARCH" | tr '[:upper:]' '[:lower:]')"
return 0
fi
_gpu_evidence=""
# Krackan Point 2 names overlap the gfx1152 patterns below, so both scans test them first (mirrors install_python_stack.py).
_krackan2_ere='820M|Krackan ?(Point ?)?2|AI 5 (PRO )?330'
if [ -e /dev/dxg ] || grep -qi microsoft /proc/version 2>/dev/null; then
for _d in /opt/rocm/lib /opt/rocm/lib64 /opt/rocm-*/lib /opt/rocm-*/lib64; do
{ [ -e "$_d/librocdxg.so" ] || [ -e "$_d/librocdxg.so.1" ]; } && _rocdxg=1 && break
done
[ -n "${_rocdxg:-}" ] || return 1
_gpu_evidence=1
elif _amd_gpu_present_via_pci; then
_gpu_evidence=1
fi
if [ -n "$_gpu_evidence" ] && grep -qiE 'Ryzen AI Max|Radeon 80[0-9][05]S|Strix Halo' /proc/cpuinfo 2>/dev/null; then
echo gfx1151
return 0
fi
if [ -n "$_gpu_evidence" ] && grep -qiE '890M|880M|Strix Point|HX 37[05]|AI 9 HX|AI 9 36[05]' /proc/cpuinfo 2>/dev/null; then
echo gfx1150
return 0
fi
# Krackan Point 2 has no Linux torch wheel, so a discrete AMD card lspci names wins over it.
_k2=""
if [ -n "$_gpu_evidence" ] && grep -qiE "$_krackan2_ere" /proc/cpuinfo 2>/dev/null; then
_k2=1
fi
if [ -z "$_k2" ] && [ -n "$_gpu_evidence" ] && grep -qiE '860M|840M|Krackan|AI 7 35[05]|AI 5 34[05]|AI 7 PRO 35|AI 5 33' /proc/cpuinfo 2>/dev/null; then
echo gfx1152
return 0
fi
if command -v lspci >/dev/null 2>&1; then
# A non-AMD controller can enumerate first (Intel/ASPEED before an AMD dGPU), so scan every display-class line and take the first AMD one that maps. The vendor guard is case-SENSITIVE (a -i "ATI" would match "CorporATIon" on every Intel/NVIDIA line); whole-line matching also survives the 0000: PCI domain prefix.
# Read once per install: the callers run in command substitutions, so the answer is kept in a file (_LSPCI_MEMO).
if [ -n "${_LSPCI_MEMO:-}" ] && [ -f "$_LSPCI_MEMO" ]; then
_amd_disp=$(cat "$_LSPCI_MEMO")
else
_amd_raw=$(lspci -nn 2>/dev/null) && _amd_ok=1 || _amd_ok=""
_amd_disp=$(printf '%s\n' "$_amd_raw" | grep -E 'VGA compatible controller|3D controller|Display controller' | grep -E 'AMD|ATI' || true)
# Only a successful query is kept, written whole (temp file + rename) so a failed write leaves no memo.
if [ -n "$_amd_ok" ] && [ -n "${_LSPCI_MEMO:-}" ]; then
{ printf '%s\n' "$_amd_disp" > "$_LSPCI_MEMO.tmp" && mv -f "$_LSPCI_MEMO.tmp" "$_LSPCI_MEMO"; } 2>/dev/null || rm -f "$_LSPCI_MEMO.tmp" 2>/dev/null || true
fi
fi
while IFS= read -r _ln; do
[ -n "$_ln" ] || continue
# A Krackan Point 2 line is only the fallback: a covered adapter later still answers.
if printf '%s\n' "$_ln" | grep -Eqi "$_krackan2_ere"; then
_k2=1
continue
fi
if _gfx=$(_infer_amd_gfx_arch_from_gpu_name "$_ln"); then
# With the Krackan Point 2 CPU, its own iGPU line (Krackan names) is not a peer.
if [ -n "${_k2:-}" ] && { [ "$_gfx" = gfx1152 ] || [ "$_gfx" = gfx1153 ]; }; then
continue
fi
echo "$_gfx"
return 0
fi
done <<EOF
$_amd_disp
EOF
fi
if [ -n "${_k2:-}" ]; then
echo gfx1153
return 0
fi
return 1
}
# ROCr builds gfx<major><minor><stepping hex> (9.0.10 -> gfx90a).
# Kept in sync with _hsa_override_gfx_arch in studio/install_python_stack.py.
_hsa_override_gfx_arch() {
printf '%s' "${1:-}" | awk '
{
gsub(/^[[:space:]]+|[[:space:]]+$/, "")
if ($0 !~ /^[0-9]+\.[0-9]+\.[0-9]+$/) exit
split($0, p, ".")
maj = p[1] + 0; min = p[2] + 0; step = p[3] + 0
# Steppings are a single hex nibble; wider is not a real target.
if (maj <= 0 || min > 9 || step > 15) exit
printf "gfx%d%d%x", maj, min, step
}'
}
# KFD gfx_target_version is immune to HSA_OVERRIDE_GFX_VERSION (#7331).
# Kept in sync with _kfd_gfx_targets in studio/install_python_stack.py.
_kfd_gfx_targets() {
[ -d /sys/class/kfd/kfd/topology/nodes ] || return 0
for _kfd_node in /sys/class/kfd/kfd/topology/nodes/*/properties; do
[ -r "$_kfd_node" ] || continue
awk '
/^[[:space:]]*vendor_id[[:space:]]/ { vendor = $2 }
/^[[:space:]]*gfx_target_version[[:space:]]/ { gtv = $2 + 0 }
END {
if (vendor != 4098 || gtv <= 0) exit
maj = int(gtv / 10000) % 100
min = int(gtv / 100) % 100
step = gtv % 100
if (maj <= 0 || min > 9 || step > 15) exit
printf "gfx%d%d%x\n", maj, min, step
}' "$_kfd_node" 2>/dev/null || true
done
return 0
}
# Pair each GPU gfx id with its own marketing name. Keep in sync with studio/setup.sh.
_rocminfo_gpu_records() {
awk '
# Split at the first colon so embedded colons survive.
function value(line, v) {
v = line
sub(/^[^:]*:[[:space:]]*/, "", v)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", v)
return v
}
/^[[:space:]]*Name:/ {
# Keep a slot for a nameless GPU.
if (gfx != "" && !named) { print gfx "|"; gpus++ }
gfx = ""; named = 0
name = value($0)
# Accept target suffixes such as gfx90a:sramecc+, but reject ISA names.
if (match(name, /^gfx[1-9][0-9a-z][0-9a-z][0-9a-z]?/)) {
rest = substr(name, RLENGTH + 1)
if (rest == "" || rest ~ /^[^0-9a-z]/) gfx = substr(name, 1, RLENGTH)
}
next
}
/^[[:space:]]*Marketing Name:/ {
mkt = value($0)
if (gfx != "" && !named) { print gfx "|" mkt; gpus++; named = 1 }
else if (first == "") first = mkt
next
}
END {
if (gfx != "" && !named) { print gfx "|"; gpus++ }
if (gpus == 0 && first != "") print "|" first
}
'
}
_amd_smi_hip_order() {
# POSIX awk forbids a newline in a -v value (fatal under gawk --posix), so records arrive
# on stdin before the map, sentinel-separated. Line 1 names the index space.
{ printf '%s\n' "$1"; echo "@@hip-map@@"; cat; } | awk '
function value(line, v) {
v = line
sub(/^[^:]*:[[:space:]]*/, "", v)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", v)
return v
}
function keep( i) { print "discovery"; for (i = 1; i <= r; i++) print rec[i] }
!split_seen && $0 == "@@hip-map@@" { split_seen = 1; next }
!split_seen { if ($0 != "") rec[++r] = $0; next }
/^[[:space:]]*GPU:[[:space:]]*[0-9]/ { n++; hip[n] = -1; next }
n && tolower($0) ~ /hip.?id/ {
if (hip[n] < 0) { v = value($0); if (v ~ /^[0-9]+$/) hip[n] = v + 0 }
next
}
END {
# All or nothing, like get_hip_id_by_gpu_index: a partial or colliding map is not 1:1.
if (r == 0 || n != r) { keep(); exit }
for (i = 1; i <= n; i++) {
if (hip[i] < 0 || hip[i] >= r || (hip[i] in used)) { keep(); exit }
used[hip[i]] = 1
out[hip[i]] = rec[i]
}
print "hip"
for (i = 0; i < r; i++) print out[i]
}
'
}
# amd-smi uses KFD discovery order, masks use HIP order; `amd-smi list -e` maps them.
# Unknown arches keep their slot so later ordinals do not shift. Keep in sync with studio/setup.sh.
_gfx_arch_slots() {
awk -F'|' '
NF { rec[n++] = $1; if ($1 != "") any = 1 }
END {
if (!any) exit
for (i = 0; i < n; i++) print (rec[i] == "" ? "unknown" : rec[i])
}
'
}
# One record per adapter in `GPU: N` order. Keep in sync with studio/setup.sh.
_amd_smi_gpu_records() {
awk '
function value(line, v) {
v = line
sub(/^[^:]*:[[:space:]]*/, "", v)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", v)
return v
}
function flush() {
if (started) print gfx "|" mkt
gfx = ""; mkt = ""
}
# amd-smi upper-cases every key (amdsmi_logger.py _capitalize_keys), so match
# case-folded. Two header shapes: `GPU: 0` opens a keyed block with the arch later,
# `GPU[0] : gfx1100` IS the record. Matching only the first answered no arch at all.
/^[[:space:]]*GPU[[:space:]]*[:\[][[:space:]]*[0-9]/ {
flush(); started = 1
if (match($0, /gfx[1-9][0-9a-z][0-9a-z][0-9a-z]?/)) gfx = substr($0, RSTART, RLENGTH)
next
}
!started { next }
tolower($0) ~ /market.?name/ { if (mkt == "") mkt = value($0); next }
tolower($0) ~ /target.?graphics.?version/ {
v = value($0)
if (gfx == "" && v ~ /^gfx[1-9][0-9a-z][0-9a-z][0-9a-z]?$/) gfx = v
next
}
END { flush() }
'
}
# Physical arch under an HSA_OVERRIDE_GFX_VERSION spoof (#7331), or nothing. Requires corroboration
# (KFD, then unspoofed rocminfo). Kept in sync with _hsa_spoofed_physical_gfx in install_python_stack.py.
_hsa_spoofed_physical_gfx() {
_hsp_inferred="${1:-}"
_hsp_probed_all="${2:-}"
[ -n "${HSA_OVERRIDE_GFX_VERSION:-}" ] || return 0
case "$_hsp_inferred" in
gfx1151|gfx1150|gfx1152) : ;;
*) return 0 ;;
esac
_hsp_n=$(printf '%s\n' "$_hsp_probed_all" | awk 'NF && !seen[$0]++ { n++ } END { print n + 0 }')
[ "${_hsp_n:-0}" -ne 1 ] && return 0
_hsp_probed=$(printf '%s\n' "$_hsp_probed_all" | awk 'NF { print; exit }')
[ -n "$_hsp_probed" ] || return 0
[ "$_hsp_probed" = "$_hsp_inferred" ] && return 0
[ "$(_hsa_override_gfx_arch "$HSA_OVERRIDE_GFX_VERSION")" = "$_hsp_probed" ] || return 0
echo " [WARN] HSA_OVERRIDE_GFX_VERSION=$HSA_OVERRIDE_GFX_VERSION is set; ROCm reports" >&2
echo " [WARN] $_hsp_probed but this host's product name is $_hsp_inferred. Checking for a spoof." >&2
_hsp_kfd=$(_kfd_gfx_targets | awk 'NF')
if [ -n "$_hsp_kfd" ]; then
if [ "$_hsp_kfd" = "$_hsp_inferred" ]; then
echo " [WARN] KFD topology sysfs reports $_hsp_inferred -- $_hsp_probed is a spoof." >&2
printf '%s\n' "$_hsp_inferred"
else
echo " [WARN] The kernel does not corroborate a spoof; keeping $_hsp_probed." >&2
fi
return 0
fi
# Re-probe without the override and masks; an unchanged name means real silicon.
_hsp_re=""
if command -v rocminfo >/dev/null 2>&1; then
_hsp_re=$( (unset HSA_OVERRIDE_GFX_VERSION ROCR_VISIBLE_DEVICES HIP_VISIBLE_DEVICES; \
rocminfo 2>/dev/null) | grep -oE 'gfx[1-9][0-9a-z]{2,3}' | awk 'NF && !seen[$0]++' || true)
fi
if [ -z "$_hsp_re" ] && command -v amd-smi >/dev/null 2>&1; then
_hsp_re=$( (unset HSA_OVERRIDE_GFX_VERSION ROCR_VISIBLE_DEVICES HIP_VISIBLE_DEVICES; \
amd-smi list 2>/dev/null) | grep -oE 'gfx[1-9][0-9a-z]{2,3}' | awk 'NF && !seen[$0]++' || true)
if [ -z "$_hsp_re" ]; then
_hsp_re=$( (unset HSA_OVERRIDE_GFX_VERSION ROCR_VISIBLE_DEVICES HIP_VISIBLE_DEVICES; \
amd-smi static --asic 2>/dev/null) | grep -oE 'gfx[1-9][0-9a-z]{2,3}' | awk 'NF && !seen[$0]++' || true)
fi
fi
if [ -z "$_hsp_re" ]; then
echo " [WARN] Nothing left to re-probe and no KFD sysfs; keeping $_hsp_probed." >&2
elif [ "$_hsp_re" = "$_hsp_inferred" ]; then
echo " [WARN] $_hsp_inferred reported with HSA_OVERRIDE_GFX_VERSION unset -- spoof confirmed." >&2
printf '%s\n' "$_hsp_inferred"
else
echo " [WARN] the re-probe does not corroborate a spoof; keeping $_hsp_probed." >&2
fi
return 0
}
# Prints gfx token(s) or nothing and always returns 0 (set -e). Tool probes run with masks cleared
# (#7314); "physical" also strips HSA_OVERRIDE_GFX_VERSION (unslothai#7331).
# shellcheck disable=SC2086 # $_pg_strip is a LIST of names for unset; quoting it would
# unset one variable whose name contains spaces.
_probe_amd_gfx_arch() {
_ensure_rocm_probe_env
case "${1:-}" in
physical) _pg_strip="ROCR_VISIBLE_DEVICES HIP_VISIBLE_DEVICES HSA_OVERRIDE_GFX_VERSION" ;;
*) _pg_strip="ROCR_VISIBLE_DEVICES HIP_VISIBLE_DEVICES" ;;
esac
if [ "${1:-}" = "physical" ]; then
_pg=""
else
_pg=$(printf '%s' "${UNSLOTH_ROCM_GFX_ARCH:-}" | tr '[:upper:]' '[:lower:]')
fi
if [ -z "$_pg" ] && command -v rocminfo >/dev/null 2>&1; then
_pg=$( (unset $_pg_strip; rocminfo 2>/dev/null) | grep -oE 'gfx[1-9][0-9a-z]{2,3}' || true)
fi
if [ -z "$_pg" ] && command -v amd-smi >/dev/null 2>&1; then
_pg=$( (unset $_pg_strip; amd-smi list 2>/dev/null) | grep -oE 'gfx[1-9][0-9a-z]{2,3}' || true)
if [ -z "$_pg" ]; then
_pg=$( (unset $_pg_strip; amd-smi static --asic 2>/dev/null) | grep -oE 'gfx[1-9][0-9a-z]{2,3}' || true)
fi
fi
printf '%s\n' "$_pg"
}
# One gfx per GPU in ROCr order. Twin of install_python_stack._detect_amd_gfx_codes(dedup = False).
_amd_ordered_gfx_devices() {
_ensure_rocm_probe_env
command -v rocminfo >/dev/null 2>&1 || return 0
(unset ROCR_VISIBLE_DEVICES HIP_VISIBLE_DEVICES HSA_OVERRIDE_GFX_VERSION; rocminfo 2>/dev/null) \
| _rocminfo_gpu_records | sed 's/|.*$//' | awk 'NF'
}
# Physical inventory (ignores CUDA_VISIBLE_DEVICES): the wheel must support the host.
# Shared decision with install.ps1 / setup.ps1 / install_python_stack.py.
_nvidia_cu126_verdict() {
if [ -n "${2:-}" ]; then
_ncv_caps=$2
else
[ -n "$1" ] || return 0
_ncv_caps=$(_run_bounded "$1" --query-gpu=compute_cap --format=csv,noheader,nounits 2>/dev/null) || return 0
fi
printf '%s\n' "$_ncv_caps" | awk '
{ gsub(/^[[:space:]]+|[[:space:]]+$/, "") } # match the .Trim()/.strip() siblings
/^[0-9]+\.[0-9]+$/ {
split($0, _sm, ".")
_n = (_sm[1] * 10) + _sm[2]
seen = 1
if (_n < 75) legacy = 1
if (_n < 50 || _n > 90) outside_cu126 = 1
next
}
/./ { unreadable = 1 }
END {
if (!seen || unreadable || !legacy) exit
print outside_cu126 ? "uncovered" : "cu126"
}
'
}
_cap_cuda_family_for_pre_turing() {
case "$_ARCH" in
x86_64|amd64) ;;
*) printf '%s\n' "$1"; return ;;
esac
case "$1" in
cu128|cu130) ;;
*) printf '%s\n' "$1"; return ;;
esac
case "$(_nvidia_cu126_verdict "$2" "${3:-}")" in
cu126)
echo "[WARN] Pre-Turing NVIDIA GPUs (sm_<75) are present -- selecting cu126, because PyTorch 2.11's $1 wheels start at sm_75." >&2
printf '%s\n' "cu126"
return
;;
uncovered)
echo "[WARN] This host mixes pre-Turing NVIDIA GPUs with GPUs that cu126 cannot serve; no PyTorch 2.11 CUDA family covers both." >&2
echo "[WARN] Keeping $1, so the pre-Turing GPUs will be unusable. Set UNSLOTH_TORCH_INDEX_FAMILY=cu126 to choose the other way." >&2
;;
esac
printf '%s\n' "$1"
}
# ── ROCm version sources ──
# One helper per source, each returning 0 unconditionally: under set -e a failing source would kill the installer before the actionable warning at the end of the ROCm branch. Every source that execs runs through _run_bounded, since highest-wins consults all five and a single wedged probe would hang the installer; a timed-out probe just declined to answer.
_rocm_tag_from_amd_smi() {
command -v amd-smi >/dev/null 2>&1 || return 0
_run_bounded amd-smi version 2>/dev/null | awk -F'ROCm version: ' \
'NF>1{v=$2; sub(/[ \t|].*$/, "", v); if (v ~ /^[0-9]+\.[0-9]+/) {split(v,a,"."); print "rocm"a[1]"."a[2]} exit}' || return 0
}
_rocm_tag_from_version_file() {
[ -r /opt/rocm/.info/version ] || return 0
awk -F. '{print "rocm"$1"."$2; exit}' /opt/rocm/.info/version || return 0
}
_rocm_sdk_install_hint() {
if command -v pacman >/dev/null 2>&1; then
echo "sudo pacman -S rocm-hip-sdk"
elif command -v dnf >/dev/null 2>&1; then
echo "sudo dnf install rocm-hip rocm-runtime (rpm-ostree install ... on atomic images)"
elif command -v zypper >/dev/null 2>&1; then
echo "sudo zypper install rocm-hip"
elif command -v apt-get >/dev/null 2>&1; then
echo "see https://rocm.docs.amd.com/en/latest/deploy/linux/index.html for the AMD apt repo"
else
echo "https://rocm.docs.amd.com/en/latest/deploy/linux/index.html"
fi
}
_rocm_tag_from_hipconfig() {
_rt_hipconfig=""
if command -v hipconfig >/dev/null 2>&1; then
_rt_hipconfig=hipconfig
elif [ -x "${ROCM_PATH:-/opt/rocm}/bin/hipconfig" ]; then
_rt_hipconfig="${ROCM_PATH:-/opt/rocm}/bin/hipconfig"
else
return 0
fi
_run_bounded "$_rt_hipconfig" --version 2>/dev/null \
| awk 'NR==1 && /^[0-9]/{split($1,a,"."); if(a[1]+0>0){print "rocm"a[1]"."a[2]}}' || return 0
}
_rocm_tag_from_dpkg() {
command -v dpkg-query >/dev/null 2>&1 || return 0
# Require status "installed": removed-but-not-purged packages still report a version.
# `|| true`: dpkg-query exits nonzero when either package is absent.
{ _run_bounded dpkg-query -W -f='${Package} ${Status} ${Version}\n' rocm-core libhsa-runtime64-1 2>/dev/null || true; } \
| awk '
$4 == "installed" && $5 != "" {
v = $5
sub(/^[0-9]+:/, "", v)
split(v, a, /[.-]/)
if (a[1] !~ /^[0-9]+$/ || a[2] !~ /^[0-9]+$/) next
if ($1 == "rocm-core") _core[_nc++] = "rocm" a[1] "." a[2]
else _hsa[_nh++] = "rocm" a[1] "." a[2]
}
END {
if (_nc) { for (_i = 0; _i < _nc; _i++) print _core[_i] }
else { for (_i = 0; _i < _nh; _i++) print _hsa[_i] }
}
' || return 0
}
_rocm_tag_from_rpm() {
command -v rpm >/dev/null 2>&1 || return 0
# Bounded: rpm -q can hang on stale db locks or dnf; query all names once, emit every version.
_rt_ver=$(_run_bounded rpm -q --qf '%{VERSION}\n' rocm-core rocm-runtime rocm-hip 2>/dev/null \
| awk '/^[0-9]/{print}') || return 0
[ -n "$_rt_ver" ] || return 0
printf '%s\n' "$_rt_ver" | awk -F'[.-]' 'NF{print "rocm"$1"."$2}' || return 0
}
_highest_rocm_tag() {
awk '
/^rocm[0-9]+\.[0-9]+$/ {
split(substr($0, 5), a, ".")
maj = a[1] + 0; min = a[2] + 0
if (maj < 1) next
if (!seen || maj > best_maj || (maj == best_maj && min > best_min)) {
best_maj = maj; best_min = min; seen = 1
}
}
END { if (seen) printf "rocm%d.%d\n", best_maj, best_min }
'
}
# Take the highest source, not the first: split packaging ships stale components (#8402).
_ROCM_TAG_MEMO=""
_detect_rocm_version_tag() {
if [ -n "${_ROCM_TAG_MEMO:-}" ] && [ -f "$_ROCM_TAG_MEMO" ]; then
cat "$_ROCM_TAG_MEMO"
return
fi
_rt_readings=$({
_rocm_tag_from_amd_smi
_rocm_tag_from_version_file
_rocm_tag_from_hipconfig
_rocm_tag_from_dpkg
_rocm_tag_from_rpm
} 2>/dev/null) || _rt_readings=""
_rt_best=$(printf '%s\n' "$_rt_readings" | _highest_rocm_tag) || _rt_best=""
if [ -n "$_rt_best" ]; then
_rt_seen=$(printf '%s\n' "$_rt_readings" \
| grep '^rocm[1-9][0-9]*\.[0-9][0-9]*$' | sort -u | tr '\n' ' ') || _rt_seen=""
case "$_rt_seen" in
""|"$_rt_best ") : ;;
*) echo "[WARN] ROCm version sources disagree (${_rt_seen% }) -- using the highest, $_rt_best." >&2 ;;
esac
fi
if [ -n "${_ROCM_TAG_MEMO:-}" ]; then
printf '%s\n' "$_rt_best" > "$_ROCM_TAG_MEMO" 2>/dev/null || true
fi
printf '%s\n' "$_rt_best"
}
# The generic bitsandbytes ROCm wheel is built for the rocm6.4 ABI. Keep the
# published ROCm leaf mapping in get_torch_index_url intact for explicit and
# legacy callers, but floor automatic generic selections to this compatible tag.
_ROCM_BNB_GENERIC_FLOOR_TAG="rocm6.4"
_rocm_bnb_compatible_generic_tag() {
case "$1" in
rocm6.0|rocm6.0.*|rocm6.1|rocm6.1.*|rocm6.2|rocm6.2.*|rocm6.3|rocm6.3.*)
printf '%s\n' "$_ROCM_BNB_GENERIC_FLOOR_TAG"
;;
*)
printf '%s\n' "$1"
;;
esac
}
# ── Detect GPU and choose PyTorch index URL ──
# Mirrors Get-TorchIndexUrl in install.ps1. CPU-only hosts get the cpu index (auto picks unsloth==2024.8).
get_torch_index_url() {
_AMD_REQUEST_TARGET_GFX=""
_AMD_REQUEST_TARGET_SOURCE=""
_base="${UNSLOTH_PYTORCH_MIRROR:-https://download.pytorch.org/whl}"
_base="${_base%/}"
# An explicit override skips ALL GPU probing: the URL is verbatim, _FAMILY is its leaf.
_url="${UNSLOTH_TORCH_INDEX_URL:-}"
_url="${_url#"${_url%%[![:space:]]*}"}"; _url="${_url%"${_url##*[![:space:]]}"}"
if [ -n "$_url" ]; then
_url=$(_trim_index_path_slashes "$_url")
echo "$_url"; return
fi
_family="${UNSLOTH_TORCH_INDEX_FAMILY:-}"
_family="${_family#"${_family%%[![:space:]]*}"}"; _family="${_family%"${_family##*[![:space:]]}"}"
if [ -n "$_family" ]; then
while [ "${_family#/}" != "$_family" ]; do _family="${_family#/}"; done
while [ "${_family%/}" != "$_family" ]; do _family="${_family%/}"; done
echo "$_base/$_family"; return
fi
case "$(uname -s)" in Darwin) echo "$_base/cpu"; return ;; esac
_smi=""
_nvidia_detected=0
if _has_usable_nvidia_gpu; then
_nvidia_detected=1
if command -v nvidia-smi >/dev/null 2>&1; then
_smi="nvidia-smi"
elif [ -x "/usr/bin/nvidia-smi" ]; then
_smi="/usr/bin/nvidia-smi"
fi
fi
# Here too: UNSLOTH_TORCH_BACKEND=cuda would make _ensure_rocm_torch return early (#10450).
if [ "$_nvidia_detected" -eq 1 ] && _rocm_torch_explicitly_requested && \
_amd_request_has_a_wheel_route; then
echo "[INFO] UNSLOTH_FORCE_ROCM_TORCH is set and an AMD GPU is present -- selecting ROCm PyTorch over CUDA." >&2
echo "[INFO] One torch install serves one vendor: the NVIDIA card will not be available to training until this is unset and the installer re-run." >&2
_nvidia_detected=0
fi
if [ "$_nvidia_detected" -eq 0 ]; then
case "$(uname -m)" in
x86_64|amd64) : ;;
*) echo "$_base/cpu"; return ;;
esac
if ! _has_amd_rocm_gpu; then
if _ix_gpu=$(_intel_xpu_auto_gpu_id); then
echo "[INFO] Intel GPU ($_ix_gpu) detected -- selecting XPU PyTorch (UNSLOTH_DISABLE_XPU_AUTO=1 to keep CPU)." >&2
echo "$_base/xpu"; return
fi
echo "$_base/cpu"; return
fi
_amd_gfx_probe=$(_probe_amd_gfx_arch)
if [ -z "$_amd_gfx_probe" ]; then
if _amd_inferred_gfx=$(_infer_linux_amd_gfx_arch 2>/dev/null) && \
[ -n "$_amd_inferred_gfx" ] && \
_amd_arch_index_family_for_gfx "$_amd_inferred_gfx" >/dev/null 2>&1; then
echo "[WARN] AMD GPU detected but rocminfo/amd-smi can't read its gfx arch -- inferring $_amd_inferred_gfx from hardware IDs." >&2
echo "$_base/cpu"; return
fi
# Repairing rocminfo cannot help here: the arch would read fine and still have no wheels (#8529). Advice only, same CPU index either way.
# gfx1153 is inferred above (no index family), so it gets this warning without a name match.
if { [ "${_amd_inferred_gfx:-}" = gfx1153 ] && _amd_unsup_gfx=gfx1153; } || \
_amd_unsup_gfx=$(_infer_linux_unsupported_amd_gfx_arch 2>/dev/null); then
# Scoped to the card named, never to the host: a second AMD GPU here may well have wheels, and nothing has looked at it yet.
echo "[WARN] AMD GPU detected ($_amd_unsup_gfx) -- Unsloth has no ROCm PyTorch wheels for that arch, installing CPU PyTorch." >&2
echo "[WARN] This is expected on this GPU; repairing rocminfo/amd-smi or setting UNSLOTH_ROCM_GFX_ARCH will not give it ROCm PyTorch." >&2
# Torch ends here, llama.cpp does not. `export` is load-bearing: a bare assignment never reaches the re-run (the #8458 mistake).
if [ "$_amd_unsup_gfx" = gfx1153 ]; then
echo "[INFO] GGUF chat still uses this GPU: a llama.cpp ROCm build when one covers gfx1153 or hipcc can build it, otherwise the Vulkan build." >&2
else
echo "[INFO] GGUF chat can still use this GPU through Vulkan: export UNSLOTH_LLAMA_CPP_BACKEND=vulkan and re-run this installer (it selects the llama.cpp bundle at install time)." >&2
fi
# Only arches TheRock actually builds, so Polaris is never pointed at
# wheels that do not exist. Untested: nothing routes here on its own.
if _amd_therock_extra=$(_therock_device_extra_for_gfx "$_amd_unsup_gfx" 2>/dev/null); then
echo "[INFO] Untested: AMD's TheRock publishes nightly $_amd_unsup_gfx wheels. To try them, export both and re-run:" >&2
echo "[INFO] export UNSLOTH_TORCH_INDEX_URL='$THEROCK_MIRROR'" >&2
echo "[INFO] export UNSLOTH_TORCH_EXTRA=$_amd_therock_extra" >&2
fi
echo "$_base/cpu"; return
fi
echo "[WARN] AMD GPU detected but its gfx arch can't be read (rocminfo/amd-smi missing or not enumerating the GPU) -- installing CPU-only PyTorch." >&2
echo "[WARN] For GPU PyTorch, install or repair rocminfo/amd-smi (e.g. sudo pacman -S rocm-hip-sdk) and re-run this installer." >&2
echo "$_base/cpu"; return
fi
# Archs measured to compute INCORRECTLY under ROCm route to CPU instead. Not "everything AMD does not list": unsloth serves gfx906 and gfx1031-gfx1036 on purpose (#7277), while gfx1033 (Van Gogh) installs ROCm wheels and then computes wrong answers (studio/ROCM_RDNA2_APU.md). PRESENCE, not selection: picking the runtime's GPU needs the mask layering _runtime_gfx_target() implements, so a mixed host takes the cpu index and keeps the UNSLOTH_TORCH_INDEX_URL escape hatch. Inline, not a helper: harnesses extract get_torch_index_url alone. "physical" mode so no override hides the silicon; KFD first, since amdkfd writes gfx_target_version from the kernel.
_amd_gfx_gate_probe=$(_probe_amd_gfx_arch physical 2>/dev/null || true)
[ -n "$_amd_gfx_gate_probe" ] || _amd_gfx_gate_probe=$(_kfd_gfx_targets 2>/dev/null || true)
if [ -z "$_amd_gfx_gate_probe" ] && [ -n "${HSA_OVERRIDE_GFX_VERSION:-}" ]; then
# HSA_OVERRIDE_GFX_VERSION only, not UNSLOTH_ROCM_GFX_ARCH (a declared arch for #7301 hosts).
echo "[WARN] HSA_OVERRIDE_GFX_VERSION is set and this host cannot confirm its real arch (no unspoofed rocminfo, amd-smi or KFD topology)." >&2
echo "[WARN] Installing CPU-only PyTorch rather than trusting the spoofed name: gfx1033 (Van Gogh) computes incorrect results under ROCm (studio/ROCM_RDNA2_APU.md)." >&2
echo "[WARN] Unset HSA_OVERRIDE_GFX_VERSION so the arch can be read, or pin UNSLOTH_TORCH_INDEX_URL to choose deliberately." >&2
echo "$_base/cpu"; return
fi
[ -n "$_amd_gfx_gate_probe" ] || _amd_gfx_gate_probe="$_amd_gfx_probe"
_amd_gfx_tokens=" $(printf '%s\n' "$_amd_gfx_gate_probe" | sed 's/:.*$//' \
| tr '[:upper:]' '[:lower:]' | tr '\n' ' ')"
_amd_gfx_bad_arch=false
case "$_amd_gfx_tokens" in
*" gfx1033 "*) _amd_gfx_bad_arch=true ;;
esac
case "${_AMD_REQUEST_TARGET_GFX:-}" in
""|gfx1033) : ;;
*)
# if/fi, not `[ ... ] &&`: a failing last test would abort under set -e.
if [ "${_AMD_REQUEST_TARGET_SOURCE:-}" = probe ]; then
_amd_gfx_bad_arch=false
fi
;;
esac
if [ "$_amd_gfx_bad_arch" = true ]; then
echo "[WARN] AMD gfx1033 (Van Gogh) computes incorrect results under ROCm -- installing CPU-only PyTorch." >&2
echo "[WARN] ROCm wheels install on it but training diverges to NaN and gradcheck fails; forward math is fine." >&2
echo "[WARN] Details: studio/ROCM_RDNA2_APU.md. Override with UNSLOTH_TORCH_INDEX_URL if you want ROCm anyway." >&2
echo "$_base/cpu"; return
fi
# end of the miscomputing-arch gate -- tests/sh/test_rocm_bad_arch_gate.sh lifts
# the block between the header comment above and this line, so keep both exact.
# RDNA 1 / Polaris / gfx1153 / gfx90c (Renoir-Barcelo iGPU) read fine and still have no wheels: no pytorch.org tag and no
# AMD per-arch index carries their kernels (at most the opt-in multi-arch index does), so the generic tag below installed a multi-GB
# torch that faults on its first GPU op (#8529). Only when EVERY probed arch is one, so
# a covered peer keeps its wheels. A readable HSA_OVERRIDE_GFX_VERSION is the arch the
# runtime runs, whatever amd-smi reads, so it alone is judged unless an arch is declared.
_amd_unsup_src="$_amd_gfx_probe"
_amd_hsa_gfx=$(_hsa_override_gfx_arch "${HSA_OVERRIDE_GFX_VERSION:-}" 2>/dev/null) || _amd_hsa_gfx=""
[ -n "$_amd_hsa_gfx" ] && [ -z "${UNSLOTH_ROCM_GFX_ARCH:-}" ] && _amd_unsup_src="$_amd_hsa_gfx"
_amd_unsup_all=$(printf '%s\n' "$_amd_unsup_src" | sed 's/:.*$//' | tr '[:upper:]' '[:lower:]' \
| awk 'NF { if ($1 !~ /^gfx(1010|1011|1012|1153|803|90c)$/) bad = 1; else if (!seen[$1]++) l = l (l ? ", " : "") $1 }
END { if (!bad && l != "") print l }')
# An existing ROCm torch on gfx90c is run via HSA_OVERRIDE_GFX_VERSION=9.0.0: updates keep it.
if [ "$_amd_unsup_all" = gfx90c ]; then
case "${_PREV_TORCH_VER:-}" in
*+rocm*)
echo "[INFO] Keeping ROCm PyTorch on gfx90c: this environment already runs ${_PREV_TORCH_VER}. Start Unsloth Studio with HSA_OVERRIDE_GFX_VERSION=9.0.0 to use the GPU." >&2
_amd_unsup_all="" ;;
esac
fi
if [ -n "$_amd_unsup_all" ]; then
echo "[WARN] AMD GPU detected ($_amd_unsup_all) -- Unsloth has no ROCm PyTorch wheels for that arch, installing CPU PyTorch." >&2
echo "[WARN] The ROCm PyTorch wheels carry no kernels for it: they would install, then fail on the first GPU operation." >&2
if [ "$_amd_unsup_all" = gfx1153 ]; then
echo "[INFO] GGUF chat still uses this GPU: a llama.cpp ROCm build when one covers gfx1153 or hipcc can build it, otherwise the Vulkan build." >&2
else
echo "[INFO] GGUF chat can still use this GPU through Vulkan: export UNSLOTH_LLAMA_CPP_BACKEND=vulkan and re-run this installer (it selects the llama.cpp bundle at install time)." >&2
fi
echo "[INFO] If you run this GPU with HSA_OVERRIDE_GFX_VERSION, export it and re-run this installer to keep ROCm PyTorch." >&2
if [ "$_amd_unsup_all" = gfx90c ]; then
echo "[INFO] gfx90c can run ROCm PyTorch's gfx900 kernels: export HSA_OVERRIDE_GFX_VERSION=9.0.0 (also when you start Unsloth Studio) and re-run this installer. Unsloth does not test this." >&2
fi
if _amd_therock_extra=$(_therock_device_extra_for_gfx "${_amd_unsup_all%%,*}" 2>/dev/null); then
echo "[INFO] Untested: AMD's TheRock publishes nightly ${_amd_unsup_all%%,*} wheels. To try them, export both and re-run:" >&2
echo "[INFO] export UNSLOTH_TORCH_INDEX_URL='$THEROCK_MIRROR'" >&2
echo "[INFO] export UNSLOTH_TORCH_EXTRA=$_amd_therock_extra" >&2
fi
echo "$_base/cpu"; return
fi
_rocm_tag=""
_rocm_tag=$(_detect_rocm_version_tag) || _rocm_tag=""
case "$_rocm_tag" in
rocm[1-9]*.[0-9]*) : ;;
*) _rocm_tag="" ;;
esac
if [ -n "$_rocm_tag" ]; then
case "$_rocm_tag" in
rocm[1-5].*)
echo "[WARN] ROCm $_rocm_tag detected but PyTorch ROCm wheels require ROCm 6.0+ -- falling back to CPU-only PyTorch" >&2
echo "[WARN] $_rocm_tag is the HIGHEST version detected from usable ROCm sources; where dpkg has no rocm-core (Debian) the installed libhsa-runtime64-1 is read instead." >&2
echo "[WARN] Upgrade ROCm: https://rocm.docs.amd.com/en/latest/deploy/linux/index.html" >&2
echo "[WARN] If this host really runs ROCm 6.0+ and only its packaging says otherwise, pin the wheels and re-run:" >&2
echo "[WARN] UNSLOTH_TORCH_INDEX_FAMILY=rocm6.4 (a PyTorch wheel leaf: rocm6.0-6.4, rocm7.0-7.2)" >&2
echo "[WARN] UNSLOTH_TORCH_INDEX_URL=<full index URL> (takes precedence, used verbatim)" >&2
echo "$_base/cpu"; return ;;
esac
# 6.5+ clips to rocm6.4, 7.3+ caps to rocm7.2. Leading ( on every arm: bash 3.2 ends $(...) at a bare `)`.
_rocm_index=$(case "$_rocm_tag" in
(rocm6.0|rocm6.0.*) echo "$_base/rocm6.0" ;;
(rocm6.1|rocm6.1.*) echo "$_base/rocm6.1" ;;
(rocm6.2|rocm6.2.*) echo "$_base/rocm6.2" ;;
(rocm6.3|rocm6.3.*) echo "$_base/rocm6.3" ;;
(rocm6.4|rocm6.4.*) echo "$_base/rocm6.4" ;;
(rocm7.0|rocm7.0.*) echo "$_base/rocm7.0" ;;
(rocm7.1|rocm7.1.*) echo "$_base/rocm7.1" ;;
(rocm7.2|rocm7.2.*) echo "$_base/rocm7.2" ;;
(rocm6.*)
echo "$_base/rocm6.4" ;;
(*)
echo "$_base/rocm7.2" ;;
esac)
_rocm_selected_tag=${_rocm_index##*/}
# Automatic generic 6.0-6.3 selections floor to rocm6.4, the oldest ROCm the bitsandbytes
# wheel ships a library for (#10273). A sole gfx906 keeps the literal tag for its legacy
# route and BNB skip. Normalize like _is_gfx906_bnb_skip: rocminfo names each agent twice
# and an override keeps its ISA suffix; a mixed host dedupes to two arches and floors.
_bnb_floor_gfx=$(printf '%s\n' "$_amd_gfx_probe" \
| sed 's/:.*$//' | tr -d '[:blank:]' | awk 'NF && !seen[$0]++')
case "$_bnb_floor_gfx" in
(gfx906) : ;;
(*) _rocm_index="$_base/$(_rocm_bnb_compatible_generic_tag "$_rocm_selected_tag")" ;;
esac
# No UNSLOTH_TORCH_INDEX_FAMILY hint: newer leaves have nothing inside _TORCH_CEILING (#10657).
if [ "$_rocm_tag" != "$_rocm_selected_tag" ]; then
echo "[INFO] No validated PyTorch for ROCm ${_rocm_tag#rocm}; capping to the $_rocm_selected_tag index (its wheels bundle their own runtime, so this is expected)." >&2
fi
echo "$_rocm_index"
return
fi
_amd_gfx_family=$(_amd_agreed_index_family "$_amd_gfx_probe") || _amd_gfx_family=""
if [ -n "$_amd_gfx_family" ]; then
_amd_gfx_first=$(_amd_sole_index_arch "$_amd_gfx_probe") || _amd_gfx_first=""
if [ -n "${UNSLOTH_ROCM_GFX_ARCH:-}" ]; then
echo "[WARN] AMD GPU detected with no readable ROCm version, but UNSLOTH_ROCM_GFX_ARCH=${_amd_gfx_first:-$_amd_gfx_family} is set -- routing to AMD per-arch wheels." >&2
else
echo "[WARN] AMD ${_amd_gfx_first:-$_amd_gfx_family} detected but no ROCm version could be read -- routing to AMD per-arch wheels, which do not need one." >&2
fi
echo "$_base/cpu"; return
fi
echo "[WARN] AMD GPU detected, but no ROCm version could be read to select the matching GPU PyTorch build -- falling back to CPU-only PyTorch." >&2
if [ -d "${ROCM_PATH:-/opt/rocm}" ]; then
echo "[WARN] ${ROCM_PATH:-/opt/rocm} exists, so ROCm is likely installed but not reporting a version this installer can read." >&2
echo "[WARN] Pin the wheels and re-run: UNSLOTH_TORCH_INDEX_FAMILY=rocm6.4 (a PyTorch wheel leaf: rocm6.0-6.4, rocm7.0-7.2)" >&2
else
echo "[WARN] Install the ROCm/HIP SDK, then re-run this installer:" >&2
echo "[WARN] $(_rocm_sdk_install_hint)" >&2
fi
echo "[WARN] Version sources checked: amd-smi, /opt/rocm/.info/version, hipconfig, dpkg, rpm (Debian runtime package: libhsa-runtime64-1)." >&2
echo "$_base/cpu"; return
fi
_smi_rc=0
_smi_out=$(export LC_ALL=C; _run_bounded "$_smi" 2>/dev/null) || _smi_rc=$?
if [ "$_smi_rc" = "124" ]; then
echo "[INFO] nvidia-smi did not answer within 10s; retrying with a 45s limit..." >&2
_smi_rc=0
_smi_out=$(export LC_ALL=C; _run_bounded --secs 45 "$_smi" 2>/dev/null) || _smi_rc=$?
[ "$_smi_rc" = "124" ] && _smi=""
fi
_cuda_ver=$(printf '%s\n' "$_smi_out" \
| sed -n \
-e 's/.*CUDA UMD Version:[[:space:]]*\([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' \
-e 's/.*CUDA Version:[[:space:]]*\([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' \
| head -1)
_inventory_caps=""
_cuda_from_driver=""
if [ -n "${UNSLOTH_PYTORCH_MIRROR:-}" ]; then _pin_hint="UNSLOTH_TORCH_INDEX_FAMILY="
else _pin_hint="UNSLOTH_TORCH_INDEX_URL=$_base/"
fi
if [ -z "$_cuda_ver" ]; then
if _inventory=$(_nvidia_library_inventory) && [ -n "$_inventory" ]; then
_cuda_ver=${_inventory%% *}
_inventory_caps=$(printf '%s' "${_inventory#* }" | tr ',' '\n')
elif _cuda_ver=$(_nvidia_driver_cuda_version) && [ -n "$_cuda_ver" ]; then
_cuda_from_driver=1
fi
fi
# Jetson < CUDA 13: SBSA wheels see no GPU (#4856). Inline: test harnesses extract this function alone.
if { [ -e /etc/nv_tegra_release ] || grep -qa 'nvidia,tegra' /proc/device-tree/compatible 2>/dev/null; } && \
{ [ -z "$_cuda_ver" ] || [ "${_cuda_ver%%.*}" -lt 13 ]; }; then
echo "[WARN] NVIDIA Jetson (Tegra) detected${_cuda_ver:+ with CUDA $_cuda_ver}: the generic aarch64 CUDA PyTorch wheels do not run on it -- installing CPU-only PyTorch." >&2
echo "[WARN] Training runs on the CPU. A JetPack PyTorch build for this venv's Python, if you install one, is kept by updates;" >&2
echo "[WARN] NVIDIA's own wheels target JetPack's system Python (https://docs.nvidia.com/deeplearning/frameworks/install-pytorch-jetson-platform/)." >&2
echo "$_base/cpu"; return
fi
if [ -z "$_cuda_ver" ]; then
echo "[WARN] Could not determine CUDA version from nvidia-smi, defaulting to cu126" >&2
echo "[WARN] cu126 has no kernels for Blackwell (sm_100 / sm_120). To choose the wheel yourself, re-run with" >&2
echo "[WARN] ${_pin_hint}cu128 (or cu130 on a driver that supports CUDA 13)" >&2
echo "$_base/cu126"; return
fi
_major=${_cuda_ver%%.*}
_minor=${_cuda_ver#*.}
if [ "$_major" -ge 13 ]; then _cuda_tag=cu130
elif [ "$_major" -eq 12 ] && [ "$_minor" -ge 8 ]; then _cuda_tag=cu128
elif [ "$_major" -eq 12 ] && [ "$_minor" -ge 6 ]; then _cuda_tag=cu126
elif [ "$_major" -ge 12 ]; then _cuda_tag=cu124
elif [ "$_major" -ge 11 ]; then _cuda_tag=cu118
else echo "$_base/cpu"; return; fi
_cuda_tag=$(_cap_cuda_family_for_pre_turing "$_cuda_tag" "$_smi" "$_inventory_caps")
if [ -n "$_cuda_from_driver" ]; then
echo "[WARN] nvidia-smi and the NVIDIA driver libraries did not answer in time; the driver supports CUDA $_cuda_ver." >&2
echo "[WARN] Selecting the $_cuda_tag PyTorch wheels from the driver version alone. If that is wrong for this GPU, re-run with" >&2
echo "[WARN] ${_pin_hint}cu126 (Maxwell to Hopper, sm_50-90)" >&2
echo "[WARN] ${_pin_hint}cu128 (Turing and newer, including Blackwell)" >&2
fi
echo "$_base/$_cuda_tag"
}
# ── Torch flavor helpers (to repair a stale CPU / wrong-CUDA wheel) ──
# The xpu arm is load-bearing: without it +xpu reads as cpu and reinstalls every run.
_torch_flavor_tag() {
case "$1" in
*+cu[0-9]*) printf '%s\n' "$1" | sed -n 's/.*+\(cu[0-9][0-9]*\).*/\1/p' ;;
*+rocm*) echo "rocm" ;;
*+xpu*) echo "xpu" ;;
*+cpu*) echo "cpu" ;;
"") echo "" ;;
*) echo "cpu" ;;
esac
}
_torch_index_url_leaf() {
_tl_u="${1%%\?*}"
_tl_u="${_tl_u%%#*}"
while [ -n "$_tl_u" ] && [ "${_tl_u%/}" != "$_tl_u" ]; do
_tl_u="${_tl_u%/}"
done
printf '%s' "${_tl_u##*/}" | tr '[:upper:]' '[:lower:]'
}
_torch_index_url_is_rocm() {
case "$(_torch_index_url_leaf "${1:-}")" in
rocm*|gfx*) return 0 ;;
*) return 1 ;;
esac
}
# HIP reads CUDA_VISIBLE_DEVICES when HIP_VISIBLE_DEVICES is unset, so "" hides the AMD card.
_warn_if_cuda_mask_hides_amd() {
_cvd_hides_nvidia || return 0
[ -z "${HIP_VISIBLE_DEVICES+x}" ] || return 0
_torch_index_url_is_rocm "${1:-}" || return 0
_amd_gpu_present_via_pci || return 0
( unset CUDA_VISIBLE_DEVICES; _has_usable_nvidia_gpu ) >/dev/null 2>&1 || return 0
echo "" >&2
echo "[WARN] CUDA_VISIBLE_DEVICES=\"$CUDA_VISIBLE_DEVICES\" hid the NVIDIA GPU, so ROCm torch was selected." >&2
echo "[WARN] ROCm reads CUDA_VISIBLE_DEVICES too: left set, it also hides the AMD GPU and" >&2
echo "[WARN] Unsloth Studio / Desktop will run CPU-only. Unset it before launching" >&2
echo "[WARN] (HIP_VISIBLE_DEVICES=0 picks the AMD card), and reinstall with" >&2
echo "[WARN] UNSLOTH_FORCE_ROCM_TORCH=1 rather than the mask to ask for ROCm torch." >&2
echo "" >&2
}
_is_pip_rocm_family_leaf() {
case "$1" in
gfx[0-9]*) return 0 ;;
rocm[0-9]*)
_rocm_rest="${1#rocm}"
case "$_rocm_rest" in
*.*.*) return 1 ;;
*.*)
_rocm_minor="${_rocm_rest#*.}"
case "${_rocm_rest%%.*}" in "" | *[!0-9]*) return 1 ;; esac
case "$_rocm_minor" in "" | *[!0-9]*) return 1 ;; esac
;;
*[!0-9]*) return 1 ;;
esac
return 0
;;
*) return 1 ;;
esac
}
_torch_release_in_window() {
_trw_con="$2"
case "$_trw_con" in
"torch>="*",<"*) ;;
*) echo "no"; return ;;
esac
_trw_floor="${_trw_con#torch>=}"; _trw_floor="${_trw_floor%%,*}"
_trw_ceil="${_trw_con##*,<}"
_v_maj="${1%%.*}"; _v_rest="${1#*.}"; _v_min="${_v_rest%%.*}"
_f_maj="${_trw_floor%%.*}"; _f_rest="${_trw_floor#*.}"; _f_min="${_f_rest%%.*}"
_c_maj="${_trw_ceil%%.*}"; _c_rest="${_trw_ceil#*.}"; _c_min="${_c_rest%%.*}"
for _trw_n in "$_v_maj" "$_v_min" "$_f_maj" "$_f_min" "$_c_maj" "$_c_min"; do
case "$_trw_n" in ''|*[!0-9]*) echo "no"; return ;; esac
done
if [ "$_v_maj" -gt "$_f_maj" ] || { [ "$_v_maj" -eq "$_f_maj" ] && [ "$_v_min" -ge "$_f_min" ]; }; then
if [ "$_v_maj" -lt "$_c_maj" ] || { [ "$_v_maj" -eq "$_c_maj" ] && [ "$_v_min" -lt "$_c_min" ]; }; then
echo "yes"
return
fi
fi
echo "no"
}
# "yes" when $1 is the cu130 index on Linux/WSL x86_64 or aarch64 with a Python 3.13 venv: the only route the torch 2.13/2.14 prebuilt wheels cover.
_cu130_torch213_route() {
[ "$(_cu130_torch213_platform "$1")" = "yes" ] || { echo "no"; return; }
[ "$(_cu130_torch213_host_ok)" = "yes" ] || { echo "no"; return; }
_pypi_unsloth_admits_torch "2.13.0"
}
# New installs only: torch 2.13 has no cp313t wheel, and the kernel wheels need glibc 2.35.
_cu130_torch213_host_ok() {
_cth_gil=$("$VENV_DIR/bin/python" -c "import sysconfig; print('gil-disabled' if sysconfig.get_config_var('Py_GIL_DISABLED') else 'gil-enabled')" 2>/dev/null || echo "")
[ "$_cth_gil" = "gil-enabled" ] || { echo "no"; return; }
_cth_glibc=$(_uv_glibc_minor) || { echo "no"; return; }
[ "$_cth_glibc" -ge 35 ] 2>/dev/null && echo "yes" || echo "no"
}
# The local half of the route, with no network: it alone decides the preservation window, so a
# PyPI outage on a re-run can never shrink it and downgrade an existing 2.13/2.14 install.
_cu130_torch213_platform() {
[ "$(_torch_index_url_leaf "$1")" = "cu130" ] || { echo "no"; return; }
case "$OS" in linux|wsl) ;; *) echo "no"; return ;; esac
case "$_ARCH" in x86_64|amd64|aarch64|arm64) ;; *) echo "no"; return ;; esac
_ctr_py=$("$VENV_DIR/bin/python" -c "import sys; print('%d.%d' % sys.version_info[:2])" 2>/dev/null || echo "")
[ "$_ctr_py" = "3.13" ] && echo "yes" || echo "no"
}
# `studio update` runs the installed release's setup, which would downgrade a newer torch;
# any failure answers "no".
_pypi_unsloth_admits_torch() {
_pua_off=$(printf '%s' "${UV_OFFLINE:-}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
case "$_pua_off" in 1|t|true|y|yes|on) echo "no"; return ;; esac
if [ -n "${UV_EXCLUDE_NEWER:-}${UV_EXCLUDE_NEWER_PACKAGE:-}" ] || _mirror_configured uv; then
echo "no"
return
fi
_pua_url="${UNSLOTH_PYPI_JSON_URL:-https://pypi.org/pypi/unsloth/json}"
_pua_out=$(_run_bounded --secs 20 "$VENV_DIR/bin/python" - "$_pua_url" "$1" 2>/dev/null <<'PY' || true
import json, re, sys, urllib.request
url, want = sys.argv[1], sys.argv[2]
def rel(v):
return tuple(int(x) for x in re.findall(r"\d+", v)[:3]) + (0,) * (3 - len(re.findall(r"\d+", v)[:3]))
try:
with urllib.request.urlopen(url, timeout = 15) as r:
reqs = json.load(r)["info"].get("requires_dist") or []
specs = [q for q in reqs if re.match(r"^torch\s*[<>=!~(]", q) and "extra ==" not in q]
if len(specs) != 1:
raise ValueError(specs)
ops = {"<": lambda a, b: a < b, "<=": lambda a, b: a <= b, ">": lambda a, b: a > b,
">=": lambda a, b: a >= b, "==": lambda a, b: a == b, "!=": lambda a, b: a != b}
body = specs[0].split(";", 1)[0][len("torch"):].strip().strip("()")
ok = True
for part in filter(None, (p.strip() for p in body.split(","))):
m = re.fullmatch(r"(<=|>=|==|!=|<|>)\s*([0-9][0-9.]*)", part)
if m is None:
raise ValueError(part)
ok = ok and ops[m.group(1)](rel(want), rel(m.group(2)))
print("yes" if ok else "no")
except Exception:
print("no")
PY
)
[ "$(printf '%s' "$_pua_out" | tail -n 1)" = "yes" ] && echo "yes" || echo "no"
}
# torchaudio 2.11 is the last release (stable ABI), so newer torch minors pair with it.
_torchaudio_for_torch_minor() {
if [ "$1" -ge 12 ] 2>/dev/null; then
echo "torchaudio==2.11.*"
else
echo "torchaudio==2.$1.*"
fi
}
_previous_torch_pin() {
_ptp_ver="$1"
_ptp_con="$2"
[ -n "$_ptp_ver" ] || { echo ""; return; }
[ "${UNSLOTH_TORCH_UPGRADE:-0}" = "1" ] && [ "${3:-}" != "keep" ] && { echo ""; return; }
_ptp_base="${_ptp_ver%%+*}"
case "$_ptp_base" in
*[!0-9.]* | *..* | .* | *.) echo ""; return ;;
[0-9]*.[0-9]*) ;;
*) echo ""; return ;;
esac
[ "$(_torch_release_in_window "$_ptp_base" "$_ptp_con")" = "yes" ] || { echo ""; return; }
echo "torch==$_ptp_base"
}
THEROCK_MIRROR="${UNSLOTH_THEROCK_MIRROR:-https://rocm.nightlies.amd.com/whl-multi-arch/}"
# The device extra TheRock publishes for a gfx, or non-zero when it builds none. Only arches
# Unsloth's indexes do not cover, so it never competes with get_torch_index_url; gfx803 is
# absent because TheRock has no Polaris target, gfx90c because its torch has no device-gfx90c extra.
_therock_device_extra_for_gfx() {
case "$1" in
gfx1010|gfx1011|gfx1012|gfx1153) echo "device-$1" ;;
*) return 1 ;;
esac
}
_torch_spec_with_extra() {
_tswe_spec="$1"
[ -n "${_TORCH_EXTRA:-}" ] || { printf '%s' "$_tswe_spec"; return; }
_tswe_name="${_tswe_spec%%[<>=~!]*}"
_tswe_rest="${_tswe_spec#"$_tswe_name"}"
case "$_tswe_name" in
*"]")
printf '%s,%s]%s' "${_tswe_name%?}" "$_TORCH_EXTRA" "$_tswe_rest"
;;
*)
printf '%s[%s]%s' "$_tswe_name" "$_TORCH_EXTRA" "$_tswe_rest"
;;
esac
}
_install_torch_default_index() {
if [ -n "$_PREV_TORCH_PIN" ]; then
_itdi_base="${_PREV_TORCH_PIN#torch==}"
_itdi_minor="${_itdi_base#*.}"
_itdi_minor="${_itdi_minor%%.*}"
_itdi_tv="torchvision"
_itdi_ta="torchaudio"
case "$_itdi_base" in
2.*)
_itdi_tv="torchvision==0.$((_itdi_minor + 15)).*"
_itdi_ta=$(_torchaudio_for_torch_minor "$_itdi_minor")
;;
esac
if ! run_install_cmd_retry "install PyTorch (kept release)" uv pip install --python "$_VENV_PY" "$(_torch_spec_with_extra "$TORCH_CONSTRAINT")" "$(_torch_spec_with_extra "$_itdi_tv")" "$_itdi_ta" \
--default-index "$TORCH_INDEX_URL" "$@"; then
substep "[WARN] $_PREV_TORCH_PIN is not installable from $(_strip_index_url_credentials "$TORCH_INDEX_URL") -- installing the newest supported release instead" "$C_WARN"
TORCH_CONSTRAINT="$_PREV_FALLBACK_CONSTRAINT"
_PREV_TORCH_PIN=""
run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" "$(_torch_spec_with_extra "$TORCH_CONSTRAINT")" "$(_torch_spec_with_extra "$TORCHVISION_CONSTRAINT")" "$TORCHAUDIO_CONSTRAINT" \
--default-index "$TORCH_INDEX_URL" "$@"
fi
else
run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" "$(_torch_spec_with_extra "$TORCH_CONSTRAINT")" "$(_torch_spec_with_extra "$TORCHVISION_CONSTRAINT")" "$TORCHAUDIO_CONSTRAINT" \
--default-index "$TORCH_INDEX_URL" "$@"
fi
}
_expected_torch_flavor_tag() {
_leaf=$(_torch_index_url_leaf "$1")
case "$_leaf" in
cu[0-9]*)
case "${_leaf#cu}" in
*[!0-9]*) echo "" ;;
*) echo "$_leaf" ;;
esac
;;
cpu) echo "cpu" ;;
xpu) echo "xpu" ;;
*)
if _is_pip_rocm_family_leaf "$_leaf"; then echo "rocm"; else echo ""; fi
;;
esac
}
# xpu reads the version from disk: `import torch` can hang on a wedged Intel driver.
_installed_torch_version_for_tag() {
if [ "$1" = "xpu" ]; then
for _itv in "$VENV_DIR"/lib/python*/site-packages/torch/version.py; do
[ -f "$_itv" ] || continue
sed -n "s/^__version__ = '\([^']*\)'.*/\1/p" "$_itv" | head -n 1
return
done
return
fi
"$_VENV_PY" -I -c "import torch; print(torch.__version__)" 2>/dev/null || true
}
_torch_index_repairable() {
_leaf=$(_torch_index_url_leaf "$1")
case "$_leaf" in
cu[0-9]*) echo "yes" ;;
xpu) echo "yes" ;;
*)
if _is_pip_rocm_family_leaf "$_leaf"; then echo "yes"; else echo "no"; fi
;;
esac
}
_strip_index_url_credentials() {
_sic_url="$1"
case "$_sic_url" in
*://*) ;;
*) printf '%s' "$_sic_url"; return ;;
esac
_sic_scheme="${_sic_url%%://*}"
_sic_rest="${_sic_url#*://}"
_sic_rest="${_sic_rest%%\?*}"
_sic_rest="${_sic_rest%%#*}"
_sic_auth="${_sic_rest%%/*}"
case "$_sic_auth" in
*@*) _sic_host="${_sic_auth##*@}" ;;
*) _sic_host="$_sic_auth" ;;
esac
if [ "$_sic_auth" = "$_sic_rest" ]; then
printf '%s://%s' "$_sic_scheme" "$_sic_host"
else
printf '%s://%s/%s' "$_sic_scheme" "$_sic_host" "${_sic_rest#*/}"
fi
}
_radeon_host_ver_not_older() {
[ -n "$1" ] || return 1
[ -n "$2" ] || return 0
_rh_maj=${1%%.*}; _rh_rest=${1#*.}; _rh_min=${_rh_rest%%.*}
_rl_maj=${2%%.*}; _rl_rest=${2#*.}; _rl_min=${_rl_rest%%.*}
case "$_rh_maj$_rh_min$_rl_maj$_rl_min" in *[!0-9]*) return 1 ;; esac
if [ "$_rh_maj" -gt "$_rl_maj" ]; then return 0; fi
if [ "$_rh_maj" -lt "$_rl_maj" ]; then return 1; fi
[ "$_rh_min" -ge "$_rl_min" ]
}
get_radeon_wheel_url() {
case "$(uname -s)" in Linux) ;; *) echo ""; return ;; esac
_full_ver=""
_resolved_tag="${1:-}"
_resolved_ver=""
case "$_resolved_tag" in
rocm[1-9]*.[0-9]*)
_resolved_ver=$(printf '%s\n' "$_resolved_tag" \
| awk '/^rocm[1-9][0-9]*\.[0-9][0-9]*$/ {sub(/^rocm/, ""); print; exit}')
;;
esac
_host_ver=$({ command -v amd-smi >/dev/null 2>&1 && \
_run_bounded amd-smi version 2>/dev/null | awk -F'ROCm version: ' \
'NF>1{if(match($2,/[0-9]+\.[0-9]+(\.[0-9]+)?/)){print substr($2,RSTART,RLENGTH); ok=1; exit}} END{exit !ok}'; } || \
{ [ -r /opt/rocm/.info/version ] && \
awk 'match($0,/[0-9]+\.[0-9]+(\.[0-9]+)?/){print substr($0,RSTART,RLENGTH); found=1; exit} END{exit !found}' /opt/rocm/.info/version; } || \
{ command -v hipconfig >/dev/null 2>&1 && \
_run_bounded hipconfig --version 2>/dev/null | awk 'NR==1 && match($0,/[0-9]+\.[0-9]+(\.[0-9]+)?/){print substr($0,RSTART,RLENGTH); found=1} END{exit !found}'; }) 2>/dev/null || _host_ver=""
if _radeon_host_ver_not_older "$_host_ver" "$_resolved_ver"; then
_full_ver="$_host_ver"
else
_full_ver="$_resolved_ver"
fi
case "$_full_ver" in
[1-9]*.[0-9]*.[0-9]*) : ;;
[1-9]*.[0-9]*) : ;;
*) echo ""; return ;;
esac
echo "https://repo.radeon.com/rocm/manylinux/rocm-rel-${_full_ver}/"
}
# ── Radeon repo wheel selection helpers ──────────────────────────────────────
_RADEON_LISTING=""
_RADEON_PYTAG=""
_RADEON_BASE_URL=""
_RADEON_HOST_ANSWERED=false
_radeon_fetch_listing() {
_RADEON_BASE_URL="$1"
_RADEON_PYTAG=$("$_VENV_PY" -c "
import sys
print('cp{}{}'.format(sys.version_info.major, sys.version_info.minor))
" 2>/dev/null) || return 1
_radeon_http=""
_radeon_rc=0
if command -v curl >/dev/null 2>&1; then
_RADEON_LISTING=$(curl -fsSL --max-time 20 -w '\n%{http_code}' "$_RADEON_BASE_URL" 2>/dev/null) || _radeon_rc=$?
_radeon_nl='
'
_radeon_http=${_RADEON_LISTING##*"$_radeon_nl"}
_RADEON_LISTING=${_RADEON_LISTING%"$_radeon_nl"*}
_RADEON_LISTING=$(printf '%s' "$_RADEON_LISTING")
elif command -v wget >/dev/null 2>&1; then
_RADEON_LISTING=$(wget -qO- --timeout=20 "$_RADEON_BASE_URL" 2>/dev/null) || _radeon_rc=$?
fi
[ "$_radeon_rc" -eq 0 ] || _RADEON_LISTING=""
# Only 404 and 410 mean no such release; curl -f exits 22 on 429/5xx too, wget 8 on any error.
case "$_radeon_http" in
404|410) [ "$_RADEON_HOST_ANSWERED" = inconclusive ] || _RADEON_HOST_ANSWERED=true ;;
*) [ -n "$_RADEON_LISTING" ] || _RADEON_HOST_ANSWERED=inconclusive ;;
esac
[ -n "$_RADEON_LISTING" ] || return 1
}
_pick_radeon_wheel() {
_pkg="$1"
_ver_prefix="${2:-}"
[ -n "$_RADEON_LISTING" ] || return 1
[ -n "$_RADEON_PYTAG" ] || return 1
_tag="$_RADEON_PYTAG"
_href=$(printf '%s\n' "$_RADEON_LISTING" \
| awk -v pkg="$_pkg" -v tag="$_tag" -v ver_prefix="$_ver_prefix" '
BEGIN { max_pad = ""; max_url = "" }
{
line = $0
while (match(line, /href="[^"]*"/)) {
# Strip the leading href=" (6 chars) and trailing " (1 char)
url = substr(line, RSTART + 6, RLENGTH - 7)
line = substr(line, RSTART + RLENGTH)
# Extract basename, strip query / fragment
n = split(url, p, "/")
base = p[n]
sub(/[?#].*/, "", base)
prefix = pkg "-" ver_prefix
# Match cpXY-cpXY or cpXY-abi3 with any linux x86_64
# platform tag (linux_x86_64, manylinux_2_28_x86_64,
# manylinux2014_x86_64, etc.)
if (substr(base, 1, length(prefix)) == prefix &&
index(base, "-" tag "-") > 0 &&
match(base, /x86_64\.whl$/)) {
# Extract the version component (first
# dotted-number run) and pad each piece so a
# plain lexical comparison gives us the newest.
if (match(base, /[0-9]+\.[0-9]+(\.[0-9]+)?/)) {
ver = substr(base, RSTART, RLENGTH)
m = split(ver, v, ".")
pad = ""
for (i = 1; i <= m; i++)
pad = pad sprintf("%08d", v[i])
if (pad > max_pad) {
max_pad = pad
max_url = url
}
}
}
}
}
END { if (max_url != "") print max_url }')
[ -z "$_href" ] && return 1
case "$_href" in
http*) printf '%s\n' "$_href" ;;
*) printf '%s\n' "${_RADEON_BASE_URL%/}/${_href#/}" ;;
esac
}
# True when torch $1 (X.Y) can run torch.compile on the Python of Radeon wheel tag $2 (cpXY):
# Dynamo reached 3.13 in torch 2.6 and 3.14 in 2.10. rocm-rel-6.4's only cp313 torch is 2.5.1,
# which imports fine and then fails the first training step.
_radeon_torch_compiles_for_pytag() {
case "$2" in
cp313) _rtc_need=6 ;;
cp314) _rtc_need=10 ;;
*) return 0 ;;
esac
_rtc_major="${1%%.*}"
_rtc_minor="${1#*.}"
_rtc_minor="${_rtc_minor%%.*}"
case "$_rtc_major$_rtc_minor" in
''|*[!0-9]*) return 1 ;;
esac
[ "$_rtc_major" -gt 2 ] && return 0
[ "$_rtc_major" -eq 2 ] && [ "$_rtc_minor" -ge "$_rtc_need" ]
}
# ── ROCm-on-WSL bootstrap for AMD Strix Halo (gfx1151) ───────────────────────
# Idempotent, no-op without librocdxg, best-effort; sudo-tee when not root.
_rocm_wsl_dropin_stale() {
grep -qF 'lib:${LD_LIBRARY_PATH:-}' /etc/profile.d/unsloth-rocm-wsl.sh 2>/dev/null
}
# The helper pinned by _ROCM_WSL_HELPER_REF can predate the fix and leave the old line in ~/.bashrc.
_repair_rocm_wsl_bashrc() {
[ -n "${HOME:-}" ] && grep -qF 'lib:${LD_LIBRARY_PATH:-}' "${HOME}/.bashrc" 2>/dev/null || return 0
sed --follow-symlinks -i '/^# >>> Unsloth ROCm-on-WSL/,/^# <<< Unsloth ROCm-on-WSL/ s#lib:\${LD_LIBRARY_PATH:-}#lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}#' "${HOME}/.bashrc" 2>/dev/null || true
}
_persist_rocm_wsl_dropin() {
[ -e /opt/rocm/lib/librocdxg.so ] || [ -e /opt/rocm/lib64/librocdxg.so ] || return 0
_rw_rocm=/opt/rocm
export HSA_ENABLE_DXG_DETECTION=1
case ":${PATH}:" in
*":${_rw_rocm}/bin:"*) ;;
*) export PATH="${_rw_rocm}/bin:${PATH}" ;;
esac
export LD_LIBRARY_PATH="${_rw_rocm}/lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
if [ -r /etc/profile.d/unsloth-rocm-wsl.sh ]; then
_rocm_wsl_dropin_stale || return 0
# An older drop-in left a trailing ':' (the cwd) on an empty LD_LIBRARY_PATH: fix that line, keep the rest.
_rw_fix='s#lib:\${LD_LIBRARY_PATH:-}#lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}#'
if [ "$(id -u)" = "0" ]; then
sed --follow-symlinks -i "$_rw_fix" /etc/profile.d/unsloth-rocm-wsl.sh 2>/dev/null || true
# A re-install never stops at an unexplained password prompt: passwordless sudo first, then (only
# on a terminal) say why before sudo asks; with no terminal, say how to finish it instead. Never under
# --tauri: Desktop runs us in a background process group, so a terminal it inherited would stop us.
elif ! { command -v sudo >/dev/null 2>&1 && {
sudo -n sed --follow-symlinks -i "$_rw_fix" /etc/profile.d/unsloth-rocm-wsl.sh </dev/null >/dev/null 2>&1 ||
{ [ "${TAURI_MODE:-false}" != true ] && _can_read_tty &&
substep "Fixing an old LD_LIBRARY_PATH line in /etc/profile.d/unsloth-rocm-wsl.sh (sudo may ask for your password)." &&
sudo sed --follow-symlinks -i "$_rw_fix" /etc/profile.d/unsloth-rocm-wsl.sh </dev/null >/dev/null; }
}; }; then
substep "/etc/profile.d/unsloth-rocm-wsl.sh has an old LD_LIBRARY_PATH line; run 'sudo -v' and re-run the installer to fix it." "$C_WARN"
fi
return 0
fi
_rw_dropin="$(
printf '# >>> Unsloth ROCm-on-WSL (gfx1151) >>>\n'
printf 'export HSA_ENABLE_DXG_DETECTION=1\n'
printf 'export PATH="%s/bin:${PATH}"\n' "${_rw_rocm}"
printf 'export LD_LIBRARY_PATH="%s/lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"\n' "${_rw_rocm}"
printf '# <<< Unsloth ROCm-on-WSL (gfx1151) <<<\n'
)"
if [ "$(id -u)" = "0" ]; then
printf '%s\n' "$_rw_dropin" > /etc/profile.d/unsloth-rocm-wsl.sh 2>/dev/null || true
elif command -v sudo >/dev/null 2>&1; then
printf '%s\n' "$_rw_dropin" | sudo tee /etc/profile.d/unsloth-rocm-wsl.sh >/dev/null 2>&1 || true
fi
}
_maybe_bootstrap_rocm_wsl() {
[ "${OS:-}" = "wsl" ] || return 0
[ "${SKIP_TORCH:-false}" = "false" ] || return 0
[ "${UNSLOTH_SKIP_ROCM_WSL_SETUP:-0}" = "1" ] && return 0
if _has_usable_nvidia_gpu; then return 0; fi
_ensure_rocm_probe_env
if command -v rocminfo >/dev/null 2>&1 && \
rocminfo 2>/dev/null | awk '/Name:[[:space:]]*gfx[1-9]/ && !/generic/{found=1} END{exit !found}'; then
_persist_rocm_wsl_dropin
_repair_rocm_wsl_bashrc
return 0
fi
[ -e /dev/dxg ] || return 0
if ! grep -qiE 'Ryzen AI Max|Radeon 80[0-9][05]S|Strix Halo' /proc/cpuinfo 2>/dev/null \
&& ! _wsl_amd_gpu_name >/dev/null 2>&1; then
return 0
fi
command -v bash >/dev/null 2>&1 || return 0
if [ -e /opt/rocm/lib/librocdxg.so ] || [ -e /opt/rocm/lib64/librocdxg.so ]; then
if [ -r /etc/profile.d/unsloth-rocm-wsl.sh ] && ! _rocm_wsl_dropin_stale; then
# shellcheck disable=SC1091
. /etc/profile.d/unsloth-rocm-wsl.sh || true
else
# librocdxg present but the env drop-in is gone (uninstall dropped it) or stale.
_persist_rocm_wsl_dropin
fi
_repair_rocm_wsl_bashrc
return 0
fi
echo ""
_rw_gpu="$(_wsl_amd_gpu_name 2>/dev/null || true)"; [ -n "$_rw_gpu" ] || _rw_gpu="an AMD GPU"
substep "Detected ${_rw_gpu} in WSL with no ROCm runtime yet." "$C_WARN"
substep "Setting up ROCm-on-WSL (ROCm 7.2 + librocdxg) automatically to enable this GPU."
substep "One-time, uses sudo and a large download. (skip: re-run with UNSLOTH_SKIP_ROCM_WSL_SETUP=1)"
# Locate the helper: prefer the copy shipped beside install.sh, else fetch it. The local copy counts only for a --local checkout run, since this executes with no prompt and _REPO_ROOT may otherwise be the caller's cwd. PINNED, never a branch: this runs unattended and installs with sudo, so a moving ref would turn any rewrite of that branch into root code on every affected WSL box. Bump it whenever the helper changes; lagging only means an older helper, and the gate below rejects one too old to be safe.
_ROCM_WSL_HELPER_REF="8f3d1c01e6c543f45f9a698f45bc48b5a262c928"
# librocdxg pin (v1.2.2), forwarded to the helper. The ref IS the commit, so an older helper that ignores the SHA still resolves this exact revision: its `--branch <sha>` attempt fails and the full clone plus checkout land on it. Kept equal to the helper's defaults; a test enforces that. A user-set ref wins and, with no SHA of its own, turns the helper's check off rather than failing against our pin.
_rw_dxg_ref="${UNSLOTH_LIBROCDXG_REF:-}"
_rw_dxg_sha="${UNSLOTH_LIBROCDXG_SHA:-}"
if [ -z "$_rw_dxg_ref" ]; then
_rw_dxg_ref="4955d12888a3ec57057f1cf8660c2485e415e74c"
[ -n "$_rw_dxg_sha" ] || _rw_dxg_sha="$_rw_dxg_ref"
fi
if [ -n "$_rw_dxg_sha" ]; then
_rw_dxg_ref="$_rw_dxg_sha"
fi
_rw_helper="${_REPO_ROOT:-.}/scripts/install_rocm_wsl_strixhalo.sh"
_rw_tmp=""
if [ "$_REPO_IS_CHECKOUT" != "1" ] || [ ! -r "$_rw_helper" ]; then
# Never a fixed /tmp name: this runs elevated and another user could own it.
if ! _rw_tmp="$(mktemp 2>/dev/null)" || [ -z "$_rw_tmp" ]; then
substep "Could not create a private temp file for the ROCm-on-WSL helper; using CPU fallback." "$C_WARN"
return 0
fi
if download "https://raw.githubusercontent.com/unslothai/unsloth/${_ROCM_WSL_HELPER_REF}/scripts/install_rocm_wsl_strixhalo.sh" "$_rw_tmp" 2>/dev/null; then
_rw_helper="$_rw_tmp"
else
substep "Could not fetch the ROCm-on-WSL helper; using CPU fallback." "$C_WARN"
[ -n "$_rw_tmp" ] && rm -f "$_rw_tmp"
return 0
fi
fi
# Run only a helper declaring the contract, so a missing pinned ref fails closed.
if ! grep -q "^UNSLOTH_ROCM_WSL_HELPER_CONTRACT=2$" "$_rw_helper" 2>/dev/null; then
substep "ROCm-on-WSL helper predates the pinned-source check; using CPU fallback." "$C_WARN"
[ -n "$_rw_tmp" ] && rm -f "$_rw_tmp"
return 0
fi
# Runs automatically by default (opt out: UNSLOTH_SKIP_ROCM_WSL_SETUP=1). Under Tauri only with
# UNSLOTH_ROCM_WSL_AUTO=1.
_rw_go=1
if [ "${TAURI_MODE:-false}" = "true" ] && [ "${UNSLOTH_ROCM_WSL_AUTO:-0}" != "1" ]; then
tauri_log "ROCM_WSL_AVAILABLE" "strixhalo"
substep "Enable the GPU from the desktop app (or set UNSLOTH_ROCM_WSL_AUTO=1)." "$C_WARN"
_rw_go=0
fi
if [ "$_rw_go" = "1" ]; then
if UNSLOTH_WSL_SMOKE_TEST=0 \
UNSLOTH_LIBROCDXG_REF="$_rw_dxg_ref" UNSLOTH_LIBROCDXG_SHA="$_rw_dxg_sha" \
bash "$_rw_helper"; then
# Pull the helper's persisted env into THIS shell so detection (rocminfo) now enumerates the GPU and routes to gfx1151.
if [ -r /etc/profile.d/unsloth-rocm-wsl.sh ] && ! _rocm_wsl_dropin_stale; then
# shellcheck disable=SC1091
. /etc/profile.d/unsloth-rocm-wsl.sh || true
else
# The pinned helper may write the old drop-in: rewrite it (and export) here.
_persist_rocm_wsl_dropin
fi
_repair_rocm_wsl_bashrc
substep "ROCm-on-WSL ready; continuing with GPU install." "$C_OK"
else
substep "ROCm-on-WSL setup did not complete; falling back to CPU-only." "$C_WARN"
fi
fi
[ -n "$_rw_tmp" ] && rm -f "$_rw_tmp"
return 0
}
_torch_index_pinned=false
_ti_url_trim="${UNSLOTH_TORCH_INDEX_URL:-}"
_ti_url_trim="${_ti_url_trim#"${_ti_url_trim%%[![:space:]]*}"}"; _ti_url_trim="${_ti_url_trim%"${_ti_url_trim##*[![:space:]]}"}"
_ti_family_trim="${UNSLOTH_TORCH_INDEX_FAMILY:-}"
_ti_family_trim="${_ti_family_trim#"${_ti_family_trim%%[![:space:]]*}"}"; _ti_family_trim="${_ti_family_trim%"${_ti_family_trim##*[![:space:]]}"}"
if [ -n "$_ti_url_trim" ] || [ -n "$_ti_family_trim" ]; then
_torch_index_pinned=true
fi
[ "$_torch_index_pinned" = true ] || _maybe_bootstrap_rocm_wsl || true
# A file outlives the command substitution. mktemp -d, never $$: a predictable /tmp path can be
# pre-created as a symlink.
_ROCM_TAG_MEMO_DIR=$(mktemp -d "${TMPDIR:-/tmp}/unsloth-rocm.XXXXXX" 2>/dev/null) \
&& _ROCM_TAG_MEMO="$_ROCM_TAG_MEMO_DIR/tag" || _ROCM_TAG_MEMO=""
# The AMD display-class lspci lines, shared by _infer_linux_amd_gfx_arch and _infer_linux_unsupported_amd_gfx_arch.
_LSPCI_MEMO=""
[ -z "$_ROCM_TAG_MEMO_DIR" ] || _LSPCI_MEMO="$_ROCM_TAG_MEMO_DIR/lspci"
# UNSLOTH_TORCH_EXTRA selects a build on indexes that discriminate by package extra
# (TheRock's whl-multi-arch). Gated on a pinned index: no index this script picks by itself
# publishes extras, so an unpinned extra could only break a working resolve.
_TORCH_EXTRA=""
_te_trim="${UNSLOTH_TORCH_EXTRA:-}"
_te_trim="${_te_trim#"${_te_trim%%[![:space:]]*}"}"; _te_trim="${_te_trim%"${_te_trim##*[![:space:]]}"}"
if [ -n "$_te_trim" ]; then
if [ "$_torch_index_pinned" = true ]; then
_TORCH_EXTRA="$_te_trim"
else
echo "[WARN] UNSLOTH_TORCH_EXTRA=$_te_trim ignored: it needs UNSLOTH_TORCH_INDEX_URL or _FAMILY set too." >&2
fi
fi
if [ "$_torch_index_pinned" = false ] && [ "$SKIP_TORCH" = false ]; then
_has_usable_nvidia_gpu >/dev/null 2>&1 || true
fi
TORCH_INDEX_URL=$(get_torch_index_url)
# Runtime-less reroute to AMD per-arch wheels when the gfx is inferable (unslothai#7301, #7314).
# A readable gfx with an unsupported ROCm version stays on cpu; an unreadable version is rerouted.
_amd_no_rocm_version_reroute=false
_amd_probed_gfx_first=""
# Read before the branches: a closed node is invisible to every probe (#10466).
# `|| true`: a diagnostic must never abort the install under set -e.
_closed_amd_nodes="$(_amd_nodes_closed_to_this_user || true)"
case "$TORCH_INDEX_URL" in
*/cpu)
if [ "$_torch_index_pinned" = false ] && [ "$SKIP_TORCH" = false ] && \
[ -z "${UNSLOTH_ROCM_GFX_ARCH:-}" ] && \
! _nvidia_gpu_wins_over_amd && _has_amd_rocm_gpu; then
_amd_probe_out=$(_probe_amd_gfx_arch)
_amd_spoof_inferred=$(_infer_linux_amd_gfx_arch 2>/dev/null || true)
_amd_spoof_physical=$(_hsa_spoofed_physical_gfx "$_amd_spoof_inferred" "$_amd_probe_out")
if [ -n "${_amd_spoof_physical:-}" ]; then
_amd_probe_out="$_amd_spoof_physical"
fi
if [ -z "${_amd_spoof_physical:-}" ] && [ -n "${HSA_OVERRIDE_GFX_VERSION:-}" ] && \
[ "$(_kfd_gfx_targets | awk 'NF { n++ } END { print n + 0 }')" -gt 1 ]; then
_amd_probe_out=""
fi
_amd_probed_family=$(_amd_agreed_index_family "$_amd_probe_out") \
|| _amd_probed_family=""
_amd_probed_gfx_first=$(_amd_sole_index_arch "$_amd_probe_out") \
|| _amd_probed_gfx_first=""
_amd_reroute_target=""
if _rocm_torch_explicitly_requested && _amd_request_has_a_wheel_route; then
_amd_reroute_target="${_AMD_REQUEST_TARGET_GFX:-}"
fi
_amd_reroute_bad_arch=false
# A measured-bad arch anywhere disqualifies the whole family (gfx1033 shares gfx103X-all).
case " $(printf '%s\n' "$_amd_probe_out" | sed 's/:.*$//' \
| tr '[:upper:]' '[:lower:]' | tr '\n' ' ')" in
*" gfx1033 "*) _amd_reroute_bad_arch=true ;;
esac
case "$_amd_reroute_target" in
""|gfx1033) : ;;
*)
if [ "${_AMD_REQUEST_TARGET_SOURCE:-}" = probe ]; then
_amd_reroute_bad_arch=false
fi
;;
esac
if [ "$_amd_reroute_bad_arch" = true ]; then
echo "[WARN] AMD gfx1033 (Van Gogh) is in the probed inventory -- not routing torch to the shared $_amd_probed_family index (studio/ROCM_RDNA2_APU.md)." >&2
_amd_probed_family=""
_amd_probed_gfx_first=""
fi
if [ "$_amd_reroute_bad_arch" = false ] && [ -n "$_amd_reroute_target" ] && \
[ "${_AMD_REQUEST_TARGET_SOURCE:-}" = probe ]; then
_amd_target_family=$(_amd_arch_index_family_for_gfx "$_amd_reroute_target") \
|| _amd_target_family=""
if [ -n "$_amd_target_family" ]; then
_amd_probed_family="$_amd_target_family"
_amd_probed_gfx_first="$_amd_reroute_target"
fi
fi
if [ -n "${_amd_probed_family:-}" ] && \
[ -z "$(_detect_rocm_version_tag 2>/dev/null)" ]; then
_amd_no_rocm_version_reroute=true
fi
fi
;;
esac
if [ "$_torch_index_pinned" = false ] && [ "$SKIP_TORCH" = false ] && \
! _nvidia_gpu_wins_over_amd && \
{ [ -n "${UNSLOTH_ROCM_GFX_ARCH:-}" ] || ! _has_amd_rocm_gpu || \
[ -z "$(_probe_amd_gfx_arch)" ] || \
[ "${_amd_no_rocm_version_reroute:-false}" = true ]; } && \
case "$(uname -s)" in Linux) true ;; *) false ;; esac && \
case "$_ARCH" in x86_64|amd64) true ;; *) false ;; esac; then
case "$TORCH_INDEX_URL" in
*/cpu)
_linux_inferred_gfx=$(_infer_linux_amd_gfx_arch 2>/dev/null || true)
_linux_inferred_gfx=$(_amd_sole_index_arch "$_linux_inferred_gfx") \
|| _linux_inferred_gfx=""
if [ "${_amd_no_rocm_version_reroute:-false}" = true ]; then
_linux_inferred_gfx="${_amd_probed_gfx_first:-}"
fi
# Re-check the gfx1033 gate here: this reroute can bypass it. Also unset UNSLOTH_ROCM_GFX_ARCH
# so setup.sh does not request a HIP build. Keyed on the physical inventory.
_amd_reroute_physical=$(_probe_amd_gfx_arch physical 2>/dev/null || true)
[ -n "$_amd_reroute_physical" ] || _amd_reroute_physical=$(_kfd_gfx_targets 2>/dev/null || true)
case " $(printf '%s\n' "$_amd_reroute_physical" | sed 's/:.*$//' \
| tr '[:upper:]' '[:lower:]' | tr '\n' ' ')" in
*" gfx1033 "*) _linux_inferred_gfx="gfx1033" ;;
esac
case "${_linux_inferred_gfx%%:*}" in
gfx1033)
echo "[WARN] AMD gfx1033 (Van Gogh) computes incorrect results under ROCm -- keeping CPU-only PyTorch (studio/ROCM_RDNA2_APU.md)." >&2
_linux_inferred_gfx=""
unset UNSLOTH_ROCM_GFX_ARCH ;;
esac
_amd_family=""
if [ "${_amd_no_rocm_version_reroute:-false}" = true ] && \
[ -n "$_linux_inferred_gfx" ]; then
_amd_family="${_amd_probed_family:-}"
elif [ -n "$_linux_inferred_gfx" ]; then
_amd_family=$(_amd_arch_index_family_for_gfx "$_linux_inferred_gfx") || _amd_family=""
elif [ "${_amd_no_rocm_version_reroute:-false}" = true ] && \
[ -z "${_amd_probed_gfx_first:-}" ]; then
_amd_family="${_amd_probed_family:-}"
fi
if [ -n "$_amd_family" ]; then
_amd_mirror="${UNSLOTH_AMD_ROCM_MIRROR:-https://repo.amd.com/rocm/whl}"
while [ "${_amd_mirror%/}" != "$_amd_mirror" ]; do
_amd_mirror="${_amd_mirror%/}"
done
TORCH_INDEX_URL="${_amd_mirror}/${_amd_family}/"
if [ -n "$_linux_inferred_gfx" ]; then
export UNSLOTH_ROCM_GFX_ARCH="$_linux_inferred_gfx"
fi
if [ "${_amd_no_rocm_version_reroute:-false}" = true ] && \
[ -n "${_amd_spoof_physical:-}" ]; then
unset HSA_OVERRIDE_GFX_VERSION
echo " [WARN] Clearing HSA_OVERRIDE_GFX_VERSION for the rest of this install:" >&2
echo " [WARN] these wheels carry $_linux_inferred_gfx kernels, so the runtime has" >&2
echo " [WARN] to report the real arch. Remove the export from your shell profile" >&2
echo " [WARN] (~/.bashrc, ~/.profile) as well, or the next terminal restores it." >&2
fi
case "$_amd_family" in
gfx120X-all|gfx1151|gfx1150|gfx1152|gfx103X-all|gfx110X-all)
TORCH_CONSTRAINT="torch>=2.11.0,<2.12.0"
TORCHVISION_CONSTRAINT="torchvision>=0.26.0,<0.27.0"
TORCHAUDIO_CONSTRAINT="torchaudio>=2.11.0,<2.12.0"
;;
esac
echo "" >&2
if [ "${_amd_no_rocm_version_reroute:-false}" = true ]; then
echo " [WARN] AMD ${_linux_inferred_gfx:-$_amd_family} detected, but no ROCm version could be read (checked amd-smi, /opt/rocm/.info/version, hipconfig, dpkg, rpm)." >&2
echo " [WARN] The per-arch index is keyed on the arch alone, so the version is not needed." >&2
elif _has_amd_rocm_gpu; then
echo " [WARN] AMD GPU visible via the kernel driver (KFD) but rocminfo/amd-smi can't read its gfx arch; using $_linux_inferred_gfx." >&2
else
echo " [WARN] ROCm runtime not visible (/dev/kfd, rocminfo, amd-smi) but $_linux_inferred_gfx inferred." >&2
fi
echo " [WARN] Routing to AMD arch-specific wheels ($(_strip_index_url_credentials "$TORCH_INDEX_URL"))." >&2
echo " [WARN] These wheels bundle their own ROCm runtime; install the kernel stack for native compute:" >&2
echo " [WARN] https://unsloth.ai/docs/get-started/install/amd" >&2
if [ -n "$_linux_inferred_gfx" ]; then
echo " [WARN] Tip: set UNSLOTH_ROCM_GFX_ARCH=$_linux_inferred_gfx to skip inference next time." >&2
else
echo " [WARN] Two AMD GPUs of different archs share this wheel family; set UNSLOTH_ROCM_GFX_ARCH to name the one llama.cpp should build for." >&2
fi
echo "" >&2
fi
;;
esac
fi
if [ "$_torch_index_pinned" = false ] && [ "$SKIP_TORCH" = false ] && \
_rocm_torch_explicitly_requested && _has_usable_nvidia_gpu; then
if ! _torch_index_url_is_rocm "$TORCH_INDEX_URL"; then
_cuda_fallback_index=$(UNSLOTH_FORCE_ROCM_TORCH=0 get_torch_index_url)
if [ -n "$_cuda_fallback_index" ] && \
[ "$_cuda_fallback_index" != "$TORCH_INDEX_URL" ]; then
echo "[WARN] UNSLOTH_FORCE_ROCM_TORCH is set, but no ROCm wheel index could be selected for this host." >&2
echo "[WARN] Keeping the CUDA build rather than installing CPU PyTorch on a machine with a working NVIDIA GPU." >&2
TORCH_INDEX_URL="$_cuda_fallback_index"
fi
fi
fi
if [ "$SKIP_TORCH" = false ]; then
_warn_if_cuda_mask_hides_amd "$TORCH_INDEX_URL"
fi
# Export the resolved torch backend ("cuda", "rocm", "xpu" or "cpu") so setup.sh and install_python_stack.py know what was chosen here and can skip ROCm-specific repair steps. Classify on the FINAL path segment only: a custom UNSLOTH_PYTORCH_MIRROR whose base path happens to contain "rocm" or "gfx" must not mislabel a cu*/cpu index as ROCm (radeon repo URLs end in rocm-rel-X.Y/, Strix overrides in gfxNNNN/, so the trailing slash is stripped first). Lowercase the leaf so every gfx*/rocm*/cu* arm matches regardless of case (the canonical AMD RDNA4 leaf is gfx120X-all). CUDA is branded only on a real cu[0-9]* leaf, so a mirror leaf (/current) does NOT commit a CUDA backend; an unknown leaf leaves the var unset so the stack probes the GPU. Query and fragment are dropped first, then ALL trailing slashes, in lockstep with the shared _torch_index_url_leaf extractor.
_torch_index_leaf="${TORCH_INDEX_URL%%\?*}"
_torch_index_leaf="${_torch_index_leaf%%#*}"
while [ -n "$_torch_index_leaf" ] && [ "${_torch_index_leaf%/}" != "$_torch_index_leaf