Merge remote-tracking branch 'origin/main' into dbb-merge-main

# Conflicts:
#	.gitignore
#	tests/studio/run_real_mlx_smoke.py
This commit is contained in:
Daniel Han 2026-06-26 05:37:15 +00:00
commit c0abb0ab6a
805 changed files with 87144 additions and 17270 deletions

View file

@ -1,8 +0,0 @@
# Commits listed here are skipped by `git blame` so that bulk, whitespace-only
# changes don't obscure the real authorship of a line.
#
# GitHub honors this file automatically. To use it locally, run once:
# git config blame.ignoreRevsFile .git-blame-ignore-revs
# chore(studio/frontend): normalize line endings to LF
c50b8ab910f5aa56dd7ae0022d2c7b96bfe3384a

502
.github/scripts/agent-guides-drive.sh vendored Executable file
View file

@ -0,0 +1,502 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
#
# Drive one coding agent against the running `unsloth run` server for the
# Local Agent Guides CI. All failures from here are failure class (c)
# "guide drift": the server preflight already passed and the agent CLI
# already installed, so a failure here means the documented recipe in
# unsloth_cli/commands/connect.py no longer produces a working flow.
#
# Self-updating: for the 5 agents with a connect.py recipe we obtain the
# exact env + command from `unsloth connect <agent> --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
# distinct message.
#
# Usage:
# agent-guides-drive.sh connection <agent>
# agent-guides-drive.sh file-edit <agent>
# agent-guides-drive.sh attribution-ab claude
#
# Required env (exported by serve-unsloth-run.sh):
# UNSLOTH_BASE_URL UNSLOTH_API_KEY UNSLOTH_MODEL_ID
# UNSLOTH_LLAMA_LOG_DIR AGENT_INVOKE_TIMEOUT UNSLOTH_SEED
set -uo pipefail
MODE="${1:?usage: agent-guides-drive.sh <mode> <agent>}"
AGENT="${2:?usage: agent-guides-drive.sh <mode> <agent>}"
: "${UNSLOTH_BASE_URL:?serve step did not export UNSLOTH_BASE_URL}"
: "${UNSLOTH_API_KEY:?serve step did not export UNSLOTH_API_KEY}"
: "${UNSLOTH_MODEL_ID:?serve step did not export UNSLOTH_MODEL_ID}"
# Determinism (seed/temp) is applied at the server level by
# serve-unsloth-run.sh --extra; agents inherit it through the API.
TIMEOUT="${AGENT_INVOKE_TIMEOUT:-180}"
# Claude refuses --dangerously-skip-permissions outside a sandbox; the CI runner
# IS the sandbox, so declare it (mirrors unslothai/scripts launcher.sh). Harmless
# to the other agents, which ignore it.
export IS_SANDBOX=1
# Absolute paths anchored at the repo root (this script lives in
# .github/scripts/). Everything writes here regardless of the current working
# directory, so the file-edit mode can `cd` into a scratch work dir without
# breaking log/redaction writes.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
LOGS_DIR="$REPO_ROOT/logs"
REDACTED_DIR="$REPO_ROOT/redacted-configs"
WORKDIR_BASE="$REPO_ROOT/agent-workdir"
CACHE_HELPER="$SCRIPT_DIR/assert-prompt-cache.sh"
mkdir -p "$LOGS_DIR" "$REDACTED_DIR"
CONNECT_REF="unsloth_cli/commands/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 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
# via `claude -p /context`, the default prompt is ~28k tokens of which ~18k is
# "System tools" alone. --allowedTools/--disallowedTools only gate PERMISSION to
# call a tool; they do NOT remove its schema from what is sent to the model, so
# the earlier whitelist left the full ~18k in the prompt and CPU prefill
# (~16 tok/s) overran claude's own request timeout into a retry loop. --tools is
# the flag that restricts which schemas are sent. (The ~8k "Memory files" chunk
# is auto-loaded CLAUDE.md; the unsloth repo ships none, so it is 0 in CI.)
#
# Connection probe: --tools "" sends ZERO tool schemas, leaving ~20 tokens total
# (a one-line --system-prompt-file + the user turn), which prefills instantly.
CLAUDE_CONNECT_FLAGS=(
--system-prompt-file "$SCRIPT_DIR/ci-connect-prompt.txt"
--tools ""
)
# File-edit: the task needs the file/shell tools, so send only those schemas
# (~2.3k tokens vs ~18k for the full set).
CLAUDE_EDIT_FLAGS=(
--system-prompt-file "$SCRIPT_DIR/ci-min-system-prompt.txt"
--tools "Bash,Edit,Write,Read"
)
guide_fail() {
echo "::error::[guide drift] agent=${AGENT}: $* (preflight passed + install OK, so the documented flow in ${CONNECT_REF} drifted)." >&2
exit 1
}
# Redact the API key from any file we are about to keep as an artifact.
# Portable across GNU sed (Linux runners) and BSD sed (macOS), so the
# redaction is never silently skipped.
redact() {
local f
for f in "$@"; do
[ -f "$f" ] || continue
if sed --version >/dev/null 2>&1; then
sed -i "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$f" 2>/dev/null || true
else
sed -i '' "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$f" 2>/dev/null || true
fi
done
}
# A reply must be non-empty and free of connection/auth errors.
assert_reply() {
local out="$1"
if [ ! -s "$out" ]; then
guide_fail "agent produced an EMPTY reply"
fi
if grep -qiE 'connection refused|connection error|econnrefused|fetch failed|http 4[0-9][0-9]|unauthorized|invalid api key|authentication failed' "$out"; then
guide_fail "agent reply contained a connection/auth error: $(grep -iE 'connection|unauthorized|auth|http 4' "$out" | head -1)"
fi
echo "[$AGENT] reply (first 20 lines):"
head -20 "$out"
}
# Run a command under a hard timeout; map 124 to a guide-drift hang message.
run_timed() { # $1=outfile, rest=command
local out="$1"; shift
timeout "$TIMEOUT" "$@" > "$out" 2>&1
local rc=$?
if [ "$rc" -eq 124 ]; then
redact "$out" # guide_fail exits below, so scrub the transcript here too
echo "[$AGENT] last 40 lines before timeout:"; tail -40 "$out" 2>/dev/null || true
guide_fail "invoke timed out after ${TIMEOUT}s (headless-TTY hang -- the recipe likely needs a non-interactive/print flag)"
fi
return "$rc"
}
# ── 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 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 connect.py's config
# writers as a side effect (it writes ~/.codex, ~/.claude, etc.).
parse_connect() {
local raw="$LOGS_DIR/connect-${AGENT}.txt"
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 "$raw"
CONNECT_ENV="$(grep -E '^(export |unset )' "$raw" || true)"
# The launch command is the last non-export, non-status line. connect.py
# prints "Studio <url> · model <id>" 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 connect.py changes
# (env-var rename, wire_api flip, attribution setting drop) also fail/flag.
crosscheck_contract() {
local raw="$LOGS_DIR/connect-${AGENT}.txt"
case "$AGENT" in
codex)
grep -q 'UNSLOTH_STUDIO_AUTH_TOKEN' "$raw" \
|| 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 (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 (connect.py _HERMES_ENV_KEY)"
[ -f "$HOME/.hermes/config.yaml" ] && cp "$HOME/.hermes/config.yaml" "$REDACTED_DIR/hermes-config.yaml"
;;
openclaw)
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 "$HOME/.openclaw/openclaw.json" "$REDACTED_DIR/openclaw.json"
fi
;;
opencode)
[ -f "$HOME/.config/opencode/opencode.json" ] && cp "$HOME/.config/opencode/opencode.json" "$REDACTED_DIR/opencode.json"
;;
esac
redact "$REDACTED_DIR"/* 2>/dev/null || true
}
# Heavyweight agents (hermes, openclaw) bake a large system prompt + tool JSON
# schemas into every request, which a CPU runner cannot prefill before the invoke
# timeout. As with claude's --tools, we shrink the request from the agent's own
# config: zero tools for the connection probe collapses the prompt to a few
# hundred tokens, since both CLIs gate the bulk of their prompt on having tools.
# Hermes: an explicit empty cli toolset disables all tools (and drops the
# tool-gated guidance blocks), so -z sends ~300 tokens instead of thousands.
# hermes 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
# 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
# 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}')"
for cand in "$shebang" python3 python "$(dirname "$(command -v unsloth)")/python"; do
[ -n "$cand" ] || continue
{ [ -x "$cand" ] || command -v "$cand" >/dev/null 2>&1; } || continue
if "$cand" -c 'import yaml' 2>/dev/null; then py="$cand"; break; fi
done
[ -n "$py" ] || guide_fail "could not find a python with PyYAML to patch ~/.hermes/config.yaml"
echo "[hermes] patching config with $py"
"$py" - "$1" <<'PY'
import os, sys
import yaml
mode = sys.argv[1]
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):
ts = cfg["platform_toolsets"] = {}
if mode == "none":
ts["cli"] = [] # explicit empty list -> zero tools (not "defaults")
else:
ts.pop("cli", None) # file-edit needs real tools -> restore defaults
with open(p, "w") as fh:
yaml.safe_dump(cfg, fh, sort_keys=False)
print(f"[hermes] platform_toolsets.cli = {ts.get('cli', 'default')}")
PY
}
# OpenClaw: 'openclaw agent' has no tool/prompt flags, so we define a 'ci' agent
# in openclaw.json. tools.deny ["*"] sends zero tool schemas (deny always wins)
# for the connection probe; contextInjection "never" + defaults.skipBootstrap
# drop the auto-injected AGENTS.md/SOUL.md bootstrap (the bulk of the prompt) for
# both modes. --agent must reference a defined agent, so write it before invoking.
patch_openclaw_agent() { # $1 = notools|tools
python3 - "$1" <<'PY'
import os, sys, json
mode = sys.argv[1]
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
lst = [a for a in agents.get("list", []) if a.get("id") != "ci"]
agent = {"id": "ci", "contextInjection": "never"}
if mode == "notools":
agent["tools"] = {"deny": ["*"]}
lst.append(agent)
agents["list"] = lst
with open(p, "w") as fh:
json.dump(cfg, fh, indent=2)
print(f"[openclaw] agent ci tools = {agent.get('tools', 'default')}")
PY
}
# Build an invoke script that applies 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 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)"
{
echo "set -uo pipefail"
echo "$CONNECT_ENV"
# Append extra args (the prompt / flags) to the launch command verbatim.
printf '%s' "$CONNECT_CMD"
local a
for a in "$@"; do printf ' %q' "$a"; done
printf '\n'
} > "$real"
# Upload a REDACTED copy of the script, but EXECUTE the un-redacted one from a
# temp path outside the artifact dir. Redacting the script we run would turn
# the real `export TOKEN=sk-...` line into `export TOKEN=<REDACTED>`, which is
# invalid bash (the `<`/`>` are redirections) and silently breaks every agent.
# Writing the redacted copy up front keeps the key out of the artifact even if
# the run times out (run_timed exits before returning here).
cp "$real" "$script"; redact "$script"
echo "[$AGENT] invoking (timeout ${TIMEOUT}s): $CONNECT_CMD $*"
run_timed "$out" bash "$real"
local rc=$?
rm -f "$real"
redact "$out" # the transcript can echo the token; scrub before upload
return "$rc"
}
# ═════════════════════════════════════════════════════════════════════════
case "$MODE" in
# ── connection: trivial prompt, assert a non-empty, error-free reply ────
connection)
PROMPT='Reply with exactly the single word: pong'
OUT="$LOGS_DIR/${AGENT}-connection.txt"
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).
rc=$?
[ "$rc" -eq 0 ] || guide_fail "the documented launch command exited non-zero (rc=$rc) -- see the transcript above"
assert_reply "$OUT"
echo "[$AGENT] connection OK"
;;
# ── file-edit: deterministic 2-turn hello.py test (Qwen3.5-2B) ──────────
file-edit)
WORK="$WORKDIR_BASE/${AGENT}"
rm -rf "$WORK"; mkdir -p "$WORK"
OUT1="$LOGS_DIR/${AGENT}-fileedit-turn1.txt"
OUT2="$LOGS_DIR/${AGENT}-fileedit-turn2.txt"
T1='Create a file named hello.py in the current directory whose entire contents are a single line: print("Hello"). Do not run it.'
T2='Run hello.py with python and show me the exact output.'
# The 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.
cd "$WORK" || guide_fail "could not enter work dir $WORK"
invoke_turn() { # $1=outfile $2=continue? $3=prompt
local out="$1" cont="$2" prompt="$3"
case "$AGENT" in
pi) 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
# nothing). IS_SANDBOX=1 (exported above) authorizes it.
if [ "$cont" = "continue" ]; then
invoke_via_connect "$out" "${CLAUDE_EDIT_FLAGS[@]}" --dangerously-skip-permissions -p --continue "$prompt"
else
invoke_via_connect "$out" "${CLAUDE_EDIT_FLAGS[@]}" --dangerously-skip-permissions -p "$prompt"
fi ;;
codex)
# --dangerously-bypass-approvals-and-sandbox gives codex exec
# workspace-write (default is read-only -> cannot create hello.py) and
# skips the bubblewrap sandbox that the runner lacks.
if [ "$cont" = "continue" ]; then
invoke_via_connect "$out" exec --dangerously-bypass-approvals-and-sandbox resume --last "$prompt"
else
invoke_via_connect "$out" exec --dangerously-bypass-approvals-and-sandbox "$prompt"
fi ;;
opencode) invoke_via_connect "$out" run "$prompt" ;;
hermes) invoke_via_connect "$out" -z "$prompt" ;;
openclaw) invoke_via_connect "$out" agent --local --agent ci \
--model "unsloth/${UNSLOTH_MODEL_ID}" --message "$prompt" ;;
*) invoke_via_connect "$out" "$prompt" ;;
esac
}
# Turn 1: create hello.py.
invoke_turn "$OUT1" fresh "$T1"
# Fail on a non-zero agent exit before trusting side effects: an agent can
# error out (API/tool failure) yet leave a plausible file/transcript behind,
# which would otherwise slip past the assertions below (mirrors connection).
rc=$?
[ "$rc" -eq 0 ] || { echo "[$AGENT] turn-1 transcript:"; tail -40 "$OUT1" 2>/dev/null || true; \
guide_fail "turn 1 (create hello.py) exited non-zero (rc=$rc)"; }
# Hard assertions on the side effect (the real test): file + content + run.
if [ ! -f hello.py ]; then
echo "[$AGENT] turn-1 transcript:"; tail -40 "$OUT1" 2>/dev/null || true
guide_fail "turn 1 did not create hello.py"
fi
grep -q 'Hello' hello.py || guide_fail "hello.py does not contain 'Hello'"
RUN_OUT="$(python3 hello.py 2>&1 || true)"
[ "$RUN_OUT" = "Hello" ] || guide_fail "python3 hello.py printed '$RUN_OUT', expected exactly 'Hello'"
echo "[$AGENT] turn 1 OK (file created, prints 'Hello')"
# Turn 2: same cwd + session continuation; assert the agent's run output
# contains Hello. Narration drift is WARN-only, missing output is a hard fail.
invoke_turn "$OUT2" continue "$T2"
rc=$?
[ "$rc" -eq 0 ] || { echo "[$AGENT] turn-2 transcript:"; tail -60 "$OUT2" 2>/dev/null || true; \
guide_fail "turn 2 (run hello.py) exited non-zero (rc=$rc)"; }
if grep -q 'Hello' "$OUT2"; then
echo "[$AGENT] turn 2 OK (run output contains 'Hello')"
else
echo "[$AGENT] turn-2 transcript:"; tail -60 "$OUT2" 2>/dev/null || true
guide_fail "turn 2 run/bash output did not contain 'Hello'"
fi
cd "$REPO_ROOT" || true
echo "[$AGENT] file-edit OK"
;;
# ── attribution-ab: Claude Code KV-cache HIT vs MISS ────────────────────
attribution-ab)
[ "$AGENT" = "claude" ] || guide_fail "attribution-ab only applies to claude"
# The llama-server log filename uses the INTERNAL random llama.cpp port,
# not STUDIO_PORT, so we never glob by port: assert-prompt-cache.sh picks
# the newest llama-*.log and we slice it by a byte offset (`mark`) captured
# right before the measured turn, so an earlier turn's reuse can't leak in.
LLAMA_LOG_DIR="${UNSLOTH_LLAMA_LOG_DIR:-$HOME/.unsloth/studio/logs/llama-server}"
export LLAMA_LOG_DIR
parse_connect # writes ~/.claude/settings.json (header=0) + env
crosscheck_contract
PROMPT='Reply with exactly the single word: pong'
# 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: 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
echo "[claude] attribution A/B OK (header=0 HIT, header=1 MISS)"
;;
*)
echo "agent-guides-drive.sh: unknown mode '$MODE'" >&2
exit 2
;;
esac

105
.github/scripts/agent-guides-install.sh vendored Executable file
View file

@ -0,0 +1,105 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
#
# Install one coding-agent CLI for the Local Agent Guides CI. Isolated as
# failure class (b) "agent package install failed": npm/curl flakiness here
# is the single biggest source of false reds, so installs retry with
# backoff and the only ::error:: this script can emit is class (b). The
# install recipes mirror the install_hint strings in
# unsloth_cli/commands/connect.py at HEAD.
#
# Usage: agent-guides-install.sh <agent>
# agent in: claude codex hermes openclaw opencode pi
set -uo pipefail
AGENT="${1:?usage: agent-guides-install.sh <agent>}"
mkdir -p logs
LOG="logs/install-${AGENT}.log"
install_fail() {
echo "::error::[agent install failed] agent=${AGENT}: $* (class (b): the agent CLI did not install; not a server or guide problem)." >&2
echo "---- tail $LOG ----" >&2
tail -60 "$LOG" 2>/dev/null || true
exit 1
}
# npm registry flakiness is common in CI; retry 3x with linear backoff.
npm_retry() {
local pkg="$1" i
for i in 1 2 3; do
if npm install -g "$pkg" >> "$LOG" 2>&1; then
return 0
fi
echo "[install] npm install -g $pkg attempt $i failed; backing off $((i * 10))s" | tee -a "$LOG"
sleep "$((i * 10))"
done
return 1
}
# curl|bash installers, retried at the curl layer. We download to a temp file
# first and only execute on a fully successful fetch, so a truncated download
# (network hiccup mid-stream) can never run a half-written installer.
curl_bash() {
local url="$1"; shift
local i tmp
tmp="$(mktemp)"
for i in 1 2 3; do
if curl -fsSL --retry 3 --retry-delay 5 "$url" -o "$tmp" 2>>"$LOG" \
&& bash "$tmp" "$@" >> "$LOG" 2>&1; then
rm -f "$tmp"
return 0
fi
echo "[install] curl|bash $url attempt $i failed; backing off $((i * 10))s" | tee -a "$LOG"
sleep "$((i * 10))"
done
rm -f "$tmp"
return 1
}
echo "[install] agent=$AGENT (log=$LOG)"
case "$AGENT" in
claude)
# 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)
# connect.py install_hint: npm install -g @openai/codex
npm_retry "@openai/codex" || install_fail "npm install -g @openai/codex failed"
;;
opencode)
# connect.py install_hint: npm install -g opencode-ai
npm_retry "opencode-ai" || install_fail "npm install -g opencode-ai failed"
;;
openclaw)
# 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 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)
# 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 \
|| install_fail "hermes installer failed"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
;;
pi)
# 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'"
;;
esac
echo "[install] OK for $AGENT"

238
.github/scripts/assert-prompt-cache.sh vendored Executable file
View file

@ -0,0 +1,238 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
#
# Prompt-cache (KV-cache prefix reuse) detection, two strategies in one helper:
#
# mode=api A 2-turn /v1/chat/completions probe. Turn 2 prepends turn 1 +
# its reply, so the shared prefix must be served from llama.cpp's
# KV cache. Asserts usage.prompt_tokens_details.cached_tokens > 0
# on turn 2. This is the OpenAI-dialect server cache sanity.
# WHY this works on chat completions: the chat path forwards
# llama-server's real cached_tokens through
# studio/backend/routes/inference.py:482-489 (_prompt_tokens_details)
# into prompt_tokens_details (inference.py:519).
#
# mode=log Read the llama-server log and decide HIT vs MISS from the
# prompt-reprocessing trace. WHY the log (not the API field):
# the Anthropic /v1/messages path builds AnthropicUsage(
# input_tokens=..., output_tokens=...) at inference.py:8787-8790
# / :8829-8832 and NEVER sets cache_read_input_tokens, which
# therefore stays at its model default of 0
# (studio/backend/models/inference.py:1655). So an Anthropic-path
# client (Claude Code, OpenClaw is openai-completions but Claude
# Code is the canonical Anthropic agent) can get a real KV-cache
# hit that the API usage field reports as 0. The only ground
# truth for the Anthropic path is the llama-server log.
#
# Log location (verified): studio/backend/core/inference/llama_cpp.py:4363-4365
# _swa_cache_path().parent/"logs"/"llama-server"/llama-<ts>[label]-port-<P>[-try<N>].log
# _swa_cache_path() => $UNSLOTH_STUDIO_HOME|$STUDIO_HOME or ~/.unsloth/studio
# (llama_cpp.py:337-340). So default: ~/.unsloth/studio/logs/llama-server/.
#
# <P> is the INTERNAL llama-server port (self._find_free_port(),
# 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-<STUDIO_PORT>`
# glob would never match). We pick the newest llama-*.log instead.
#
# Usage:
# assert-prompt-cache.sh api BASE_URL API_KEY
# assert-prompt-cache.sh log EXPECT # EXPECT = HIT | MISS
# # reads MARKER_BEFORE/MARKER_AFTER
# # byte offsets from env (see below)
# assert-prompt-cache.sh mark # print current log size to stdout
# # (use to bracket a turn)
#
# Env for mode=log:
# LLAMA_LOG_DIR override the log dir (default ~/.unsloth/studio/logs/llama-server)
# CACHE_LOG_FROM byte offset to start scanning the newest log from (so we
# only look at the trace produced by THIS turn). Default 0.
#
# Exit codes: 0 = assertion held; 1 = assertion failed (::error:: emitted).
set -uo pipefail
MODE="${1:?usage: assert-prompt-cache.sh api|log|mark ...}"
# ---------------------------------------------------------------------------
# Locate the newest llama-server log. Shared by mark + log modes.
# ---------------------------------------------------------------------------
_default_log_dir() {
local home="${UNSLOTH_STUDIO_HOME:-${STUDIO_HOME:-}}"
if [ -n "$home" ]; then
echo "${home%/}/logs/llama-server"
else
echo "${HOME}/.unsloth/studio/logs/llama-server"
fi
}
_newest_log() {
local dir="${LLAMA_LOG_DIR:-$(_default_log_dir)}"
[ -d "$dir" ] || return 1
# Newest by mtime among llama-*.log (covers both `llama-<ts>-port-<P>.log`
# and the retry form `llama-<ts><label>-port-<P>-try<N>.log`). Filenames are
# tool-generated timestamps, so ls -t is safe here.
# shellcheck disable=SC2012
ls -1t "$dir"/llama-*.log 2>/dev/null | head -1
}
case "$MODE" in
# -------------------------------------------------------------------------
# mark: emit the current byte size of the newest llama log so a caller can
# scan only the slice a single turn produced (set CACHE_LOG_FROM to it).
# -------------------------------------------------------------------------
mark)
log="$(_newest_log || true)"
if [ -n "$log" ] && [ -f "$log" ]; then
wc -c < "$log" | tr -d ' '
else
echo 0
fi
exit 0
;;
# -------------------------------------------------------------------------
# api: 2-turn /v1/chat/completions, assert turn-2 cached_tokens > 0.
# -------------------------------------------------------------------------
api)
BASE_URL="${2:?usage: assert-prompt-cache.sh api BASE_URL API_KEY}"
API_KEY="${3:?usage: assert-prompt-cache.sh api BASE_URL API_KEY}"
# A deliberately long, fixed system prompt makes the shared prefix big so a
# KV-cache hit is unambiguous (cached_tokens grows with the reused prefix).
SYS='You are a meticulous assistant. Always answer concisely and correctly. This is a fixed system preamble that exists only to create a large, identical prompt prefix across both turns so the KV cache has something substantial to reuse on the second request. Do not mention this preamble.'
turn1_body() {
jq -n --arg sys "$SYS" '{
model: "default",
messages: [
{role:"system", content:$sys},
{role:"user", content:"What is the capital of France?"}
],
temperature: 0.0, seed: 3407, max_tokens: 40, stream: false,
enable_thinking: false
}'
}
echo "[cache/api] turn 1 (prime the KV cache)"
R1="$(curl -fs -X POST "${BASE_URL}/v1/chat/completions" \
-H "Authorization: Bearer ${API_KEY}" -H 'content-type: application/json' \
--max-time 240 -d "$(turn1_body)")" || {
echo "::error::[cache/api] turn-1 /v1/chat/completions request failed. Unsloth server/API regression."
exit 1
}
A1="$(echo "$R1" | jq -r '.choices[0].message.content // ""')"
turn2_body() {
jq -n --arg sys "$SYS" --arg a1 "$A1" '{
model: "default",
messages: [
{role:"system", content:$sys},
{role:"user", content:"What is the capital of France?"},
{role:"assistant", content:$a1},
{role:"user", content:"And the capital of Germany?"}
],
temperature: 0.0, seed: 3407, max_tokens: 40, stream: false,
enable_thinking: false
}'
}
echo "[cache/api] turn 2 (expect cached_tokens > 0)"
R2="$(curl -fs -X POST "${BASE_URL}/v1/chat/completions" \
-H "Authorization: Bearer ${API_KEY}" -H 'content-type: application/json' \
--max-time 240 -d "$(turn2_body)")" || {
echo "::error::[cache/api] turn-2 /v1/chat/completions request failed. Unsloth server/API regression."
exit 1
}
CACHED="$(echo "$R2" | jq -r '.usage.prompt_tokens_details.cached_tokens // 0')"
PROMPT_TOK="$(echo "$R2" | jq -r '.usage.prompt_tokens // 0')"
echo "[cache/api] turn-2 usage: prompt_tokens=${PROMPT_TOK} cached_tokens=${CACHED}"
if [ -z "$CACHED" ] || ! [ "$CACHED" -gt 0 ] 2>/dev/null; then
echo "::error::[cache/api] turn-2 usage.prompt_tokens_details.cached_tokens=${CACHED}, expected > 0. The server is not surfacing llama.cpp KV-cache hits on /v1/chat/completions. Check studio/backend/routes/inference.py:482-489 (_prompt_tokens_details) and :519. Full turn-2 usage:"
echo "$R2" | jq -c '.usage' 2>/dev/null || echo "$R2"
exit 1
fi
echo "[cache/api] PASS server cache sanity (cached_tokens=${CACHED} > 0)"
exit 0
;;
# -------------------------------------------------------------------------
# log: classify the newest llama-server log (from CACHE_LOG_FROM bytes on)
# as HIT or MISS and compare to EXPECT.
# -------------------------------------------------------------------------
log)
EXPECT="${2:?usage: assert-prompt-cache.sh log HIT|MISS}"
FROM="${CACHE_LOG_FROM:-0}"
log="$(_newest_log || true)"
if [ -z "$log" ] || [ ! -f "$log" ]; then
echo "::error::[cache/log] no llama-server log under ${LLAMA_LOG_DIR:-$(_default_log_dir)}. Cannot read KV-cache trace. (Path contract: studio/backend/core/inference/llama_cpp.py:4363-4365.)"
exit 1
fi
echo "[cache/log] reading $log from byte $FROM"
# Scan only the slice produced after FROM.
slice="$(tail -c "+$((FROM + 1))" "$log" 2>/dev/null || cat "$log")"
# ---- HIT detectors (most-specific first) -----------------------------
# 1. Modern + legacy "re-used N tokens" / "reused N" (N>0). Primary signal
# per the design brief.
reused_n="$(printf '%s\n' "$slice" \
| grep -aoiE 're-?used[^0-9]*([0-9]+)' \
| grep -aoE '[0-9]+' | sort -rn | head -1 || true)"
# 2. "kv cache rm [START, end)" with START>0 => prefix [0,START) reused.
cache_rm_start="$(printf '%s\n' "$slice" \
| grep -aoiE 'kv cache rm \[[0-9]+' \
| grep -aoE '[0-9]+' | sort -rn | head -1 || true)"
# 3. "n_past = N" with N>0 after a prompt-processing line (prefix kept).
n_past_n="$(printf '%s\n' "$slice" \
| grep -aoiE 'n_past[^0-9]*([0-9]+)' \
| grep -aoE '[0-9]+' | sort -rn | head -1 || true)"
# 4. tokens_cached / tokens from cache (some builds).
tok_cached="$(printf '%s\n' "$slice" \
| grep -aoiE 'tokens_cached[^0-9]*([0-9]+)' \
| grep -aoE '[0-9]+' | sort -rn | head -1 || true)"
# ---- MISS detectors --------------------------------------------------
# Explicit forced full re-processing (SWA / recurrent) or kv cache rm [0,.
forced_full=0
if printf '%s\n' "$slice" | grep -aqiE 'forcing full prompt re-?processing|kv cache rm \[0,'; then
forced_full=1
fi
HIT=0
why=""
if [ -n "$reused_n" ] && [ "$reused_n" -gt 0 ] 2>/dev/null; then
HIT=1; why="re-used=$reused_n"
elif [ -n "$cache_rm_start" ] && [ "$cache_rm_start" -gt 0 ] 2>/dev/null; then
HIT=1; why="kv-cache-rm-start=$cache_rm_start"
elif [ -n "$tok_cached" ] && [ "$tok_cached" -gt 0 ] 2>/dev/null; then
HIT=1; why="tokens_cached=$tok_cached"
elif [ "$forced_full" = "0" ] && [ -n "$n_past_n" ] && [ "$n_past_n" -gt 0 ] 2>/dev/null; then
# n_past>0 is the weakest signal; only trust it if nothing forced a full
# reprocess. (On a cold slot n_past tracks total processed, so it is a
# last-resort fallback per the brief.)
HIT=1; why="n_past=$n_past_n(fallback)"
fi
[ "$HIT" = "1" ] || why="${why:-no-reuse-markers (forced_full=$forced_full)}"
OBSERVED="MISS"; [ "$HIT" = "1" ] && OBSERVED="HIT"
echo "[cache/log] observed=$OBSERVED expected=$EXPECT ($why)"
if [ "$OBSERVED" != "$EXPECT" ]; then
echo "::error::[cache/log] KV-cache observed=$OBSERVED but expected=$EXPECT ($why). See the attribution A/B note in the workflow."
echo "---- llama-server log slice (last 60 lines) ----"
printf '%s\n' "$slice" | tail -60
exit 1
fi
echo "[cache/log] PASS ($OBSERVED == $EXPECT)"
exit 0
;;
*)
echo "::error::unknown mode '$MODE' (want api|log|mark)"
exit 1
;;
esac

1
.github/scripts/ci-connect-prompt.txt vendored Normal file
View file

@ -0,0 +1 @@
You are a helpful assistant in a CI connectivity check. Answer the user directly in plain text. Do not use any tools, do not take any actions, and do not explain. Just reply with the answer.

View file

@ -0,0 +1 @@
You are a coding assistant running non-interactively in a CI smoke test. Use the available file-editing and shell tools to complete the user's request directly and concisely. Do not ask questions or explain; just do the task.

View file

@ -1,4 +1,6 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
#
# Download a single file from a Hugging Face repo with a stall-retry
# watchdog. Used by the Studio CI workflows so a hung hf-xet transfer

172
.github/scripts/serve-unsloth-run.sh vendored Executable file
View file

@ -0,0 +1,172 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
#
# Boot `unsloth run --disable-tools` in the background, wait for it to be
# healthy, parse the minted API key from the banner, and resolve the
# /v1/models id. Exports everything downstream steps need into $GITHUB_ENV
# (or prints it when run outside Actions). Factored out of the workflow so
# the failure-isolation logic lives in one shellcheck-clean place.
#
# Usage:
# serve-unsloth-run.sh --model REPO --gguf-variant VAR --port PORT \
# [--gguf-file PATH] [--extra "--seed 3407 --temp 0"] \
# [--log-dir logs] [--health-timeout 300]
#
# Why a helper and not inline YAML
# --------------------------------
# * Every `unsloth run` invocation here is the *Unsloth server* under test.
# A failure to come up healthy is class (a) "server/API regression" and
# must be reported with a distinct `::error::` BEFORE any agent runs.
# * The banner is the documented contract a human copies from. We parse the
# exact `API Key:` line printed by unsloth_cli/commands/studio.py
# (` API Key: <key>` non-silent, `API Key: <key>` silent) so a
# silent change to that line is also caught.
# * `unsloth run` re-execs into the studio venv ($STUDIO_HOME/unsloth_studio),
# so in CI after `install.sh --local` it runs the PR's repo code.
#
# Outputs written to $GITHUB_ENV (and echoed):
# UNSLOTH_API_KEY the sk-unsloth-* key minted on the banner
# UNSLOTH_STUDIO_URL http://127.0.0.1:<PORT> (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
# UNSLOTH_SERVER_PID pid of the backgrounded `unsloth run`
# UNSLOTH_LLAMA_LOG_DIR ~/.unsloth/studio/logs/llama-server
set -uo pipefail
# ── arg parse ────────────────────────────────────────────────────────────
MODEL=""
GGUF_VARIANT=""
GGUF_FILE=""
PORT=""
EXTRA=""
LOG_DIR="logs"
HEALTH_TIMEOUT="300"
while [ "$#" -gt 0 ]; do
case "$1" in
--model) MODEL="$2"; shift 2 ;;
--gguf-variant) GGUF_VARIANT="$2"; shift 2 ;;
--gguf-file) GGUF_FILE="$2"; shift 2 ;;
--port) PORT="$2"; shift 2 ;;
--extra) EXTRA="$2"; shift 2 ;;
--log-dir) LOG_DIR="$2"; shift 2 ;;
--health-timeout) HEALTH_TIMEOUT="$2"; shift 2 ;;
*) echo "serve-unsloth-run.sh: unknown arg '$1'" >&2; exit 2 ;;
esac
done
[ -n "$PORT" ] || { echo "serve-unsloth-run.sh: --port is required" >&2; exit 2; }
if [ -z "$MODEL" ] && [ -z "$GGUF_FILE" ]; then
echo "serve-unsloth-run.sh: one of --model or --gguf-file is required" >&2
exit 2
fi
mkdir -p "$LOG_DIR"
SERVER_LOG="$LOG_DIR/unsloth-run-${PORT}.log"
BASE_URL="http://127.0.0.1:${PORT}"
STUDIO_HOME_DIR="${STUDIO_HOME:-$HOME/.unsloth/studio}"
LLAMA_LOG_DIR="${STUDIO_HOME_DIR}/logs/llama-server"
# Emit a key=value pair to $GITHUB_ENV when set, always echo for local runs.
emit() {
echo "$1=$2"
if [ -n "${GITHUB_ENV:-}" ]; then
echo "$1=$2" >> "$GITHUB_ENV"
fi
}
server_fail() {
echo "::error::Unsloth server/API regression: $*" >&2
echo "---- last 200 lines of $SERVER_LOG ----" >&2
tail -200 "$SERVER_LOG" 2>/dev/null || true
exit 1
}
# ── port collision guard ─────────────────────────────────────────────────
# A leftover listener (or a parallel matrix cell that wandered onto our port)
# would make us attach to the wrong server and mask a real regression. Fail
# fast instead.
if command -v ss >/dev/null 2>&1; then
if ss -tln 2>/dev/null | grep -q ":${PORT}\b"; then
server_fail "port ${PORT} already has a listener before we started (collision)"
fi
fi
# ── build the command ────────────────────────────────────────────────────
# `unsloth run` == alias of `unsloth studio run`. --disable-tools is REQUIRED
# (passthrough mode) so the agent's own tools relay instead of the server's.
# --no-cloudflare keeps us off the network (loopback bind, no tunnel attempt).
CMD=(unsloth run -H 127.0.0.1 -p "$PORT" --disable-tools --no-cloudflare)
if [ -n "$GGUF_FILE" ]; then
CMD+=(--model "$GGUF_FILE")
else
CMD+=(--model "$MODEL")
[ -n "$GGUF_VARIANT" ] && CMD+=(--gguf-variant "$GGUF_VARIANT")
fi
# Determinism knobs + any caller passthrough (e.g. --seed 3407 --temp 0).
# shellcheck disable=SC2206 # intentional word-split of caller-controlled flags
[ -n "$EXTRA" ] && CMD+=($EXTRA)
echo "[serve] launching: ${CMD[*]}"
echo "[serve] server log: $SERVER_LOG"
# Run detached, no controlling TTY (setsid avoids any TTY-prompt hang and
# detaches from this step's process group so the job's teardown is clean).
setsid "${CMD[@]}" > "$SERVER_LOG" 2>&1 < /dev/null &
SERVER_PID=$!
emit UNSLOTH_SERVER_PID "$SERVER_PID"
# ── wait for /api/health == healthy ──────────────────────────────────────
HEALTHY=0
for _ in $(seq 1 "$HEALTH_TIMEOUT"); do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
server_fail "process exited before becoming healthy (pid $SERVER_PID)"
fi
if curl -fs "${BASE_URL}/api/health" -o "$LOG_DIR/health-${PORT}.json" 2>/dev/null; then
if jq -e '.status == "healthy"' "$LOG_DIR/health-${PORT}.json" >/dev/null 2>&1; then
HEALTHY=1
break
fi
fi
sleep 1
done
[ "$HEALTHY" = "1" ] || server_fail "did not report /api/health healthy within ${HEALTH_TIMEOUT}s"
echo "[serve] /api/health healthy"
# ── parse the API key from the banner ────────────────────────────────────
# Match both the non-silent " API Key: <key>" and silent "API Key: <key>"
# forms. We do NOT trust a fixed column count; we take the sk-unsloth-* token.
API_KEY=""
for _ in $(seq 1 30); do
API_KEY="$(grep -aoE 'sk-unsloth-[A-Za-z0-9_-]+' "$SERVER_LOG" 2>/dev/null | head -1 || true)"
[ -n "$API_KEY" ] && break
sleep 1
done
if [ -z "$API_KEY" ]; then
# Fallback: take whatever follows an "API Key:" label, in case the key
# prefix scheme changes. Still a parse-fragility guard, not silent.
API_KEY="$(grep -aE 'API Key:' "$SERVER_LOG" 2>/dev/null \
| sed -E 's/.*API Key:[[:space:]]*//' | head -1 || true)"
fi
[ -n "$API_KEY" ] || server_fail "could not parse an API key from the banner (banner-parse fragility -- check the 'API Key:' line in unsloth_cli/commands/studio.py)"
echo "::add-mask::${API_KEY}"
emit UNSLOTH_API_KEY "$API_KEY"
# ── resolve /v1/models id ────────────────────────────────────────────────
if ! curl -fs "${BASE_URL}/v1/models" \
-H "Authorization: Bearer ${API_KEY}" -o "$LOG_DIR/models-${PORT}.json" 2>/dev/null; then
server_fail "/v1/models did not respond (or rejected the banner key)"
fi
MODEL_ID="$(jq -r '.data[0].id // empty' "$LOG_DIR/models-${PORT}.json" 2>/dev/null || true)"
[ -n "$MODEL_ID" ] || server_fail "/v1/models returned no model id (model failed to load)"
echo "[serve] resolved model id: $MODEL_ID"
emit UNSLOTH_MODEL_ID "$MODEL_ID"
emit UNSLOTH_STUDIO_URL "$BASE_URL"
emit UNSLOTH_BASE_URL "$BASE_URL"
emit UNSLOTH_LLAMA_LOG_DIR "$LLAMA_LOG_DIR"
echo "[serve] server is up: ${BASE_URL} (model ${MODEL_ID})"

View file

@ -2204,12 +2204,13 @@ jobs:
pip install -e "$RUNNER_TEMP/unsloth-zoo" --no-deps
pip show unsloth_zoo
- name: llama.cpp install via unsloth_zoo.llama_cpp + `llama-cli --help` smoke
- name: llama.cpp install via unsloth_zoo.llama_cpp + CLI `--help` smoke
# Exercise the canonical `unsloth_zoo.llama_cpp.install_llama_cpp`
# flow that GGUF export uses at runtime: clone ggml-org/llama.cpp
# into ~/.unsloth/llama.cpp, build the LLAMA_CPP_TARGETS list
# (llama-quantize, llama-cli, llama-mtmd-cli, llama-gguf-split,
# llama-server) via cmake, then run `llama-cli --help`.
# llama-server) via cmake, then run `--help` on whichever CLI
# inference binary the build actually produced.
#
# This replaces the previous "download upstream prebuilt zip"
# approach, which silently exited 0 with the message
@ -2218,6 +2219,18 @@ jobs:
# matched their current asset names). The build path is the same
# one Unsloth users hit in production via `model.save_pretrained_gguf`.
#
# We do NOT hard-require `llama-cli` specifically: upstream
# ggml-org/llama.cpp moved the cli/server/ui targets behind the
# `LLAMA_BUILD_SERVER` cmake option (tools/CMakeLists.txt) and the
# set of binaries that survive a given checkout drifts over time
# (e.g. a recent build root shipped llama-server + llama-quantize
# + llama-diffusion-cli but no llama-cli). The durable contract is
# "install_llama_cpp produced a working CLI inference binary AND a
# working quantizer", so we --help-probe the first of
# llama-cli / llama-mtmd-cli / llama-server that exists. If a
# future llama.cpp restores llama-cli it is first in the list and
# is preferred, so this stays backwards compatible.
#
# Wall-time budget: ~3-5 min cold, dominated by cmake build of
# 5 targets on the runner's 4 cores. Apt-package install is
# handled by `install_llama_cpp` itself via its
@ -2252,8 +2265,9 @@ jobs:
print(f"Build targets: {LLAMA_CPP_TARGETS}")
# install_llama_cpp returns (quantizer_path, converter_script_path).
# The quantizer's directory is the `llama.cpp` install root, which
# also holds llama-cli after build/bin/llama-* gets copied up
# (llama_cpp.py:867-871).
# also holds the CLI inference binaries after build/bin/llama-* gets
# copied up (llama_cpp.py:1450-1454; on Windows they stay in
# build/bin/Release/).
quantizer, converter = install_llama_cpp(print_output=True)
assert quantizer and os.path.exists(quantizer), (
f"install_llama_cpp returned quantizer={quantizer!r} but file missing"
@ -2262,25 +2276,54 @@ jobs:
f"install_llama_cpp returned converter={converter!r} but missing"
)
install_root = os.path.dirname(quantizer)
cli = os.path.join(install_root, "llama-cli")
assert os.path.exists(cli), (
f"llama-cli not found at {cli!r} after build. Build root contents: "
f"{sorted(p for p in os.listdir(install_root) if p.startswith('llama-'))[:20]}"
)
assert os.access(cli, os.X_OK), f"{cli!r} not executable"
# `llama-cli --help` exits non-zero on some builds; the contract
# is that recognizable help text appears on stdout/stderr.
is_windows = sys.platform == "win32"
exe = ".exe" if is_windows else ""
# Search both the copied-up root and the Windows build/bin/Release/
# location the quantizer might already live in.
search_dirs = [install_root]
win_release = os.path.join(install_root, "build", "bin", "Release")
if win_release not in search_dirs:
search_dirs.append(win_release)
# Any of these proves a working llama.cpp CLI inference binary was
# built. Order = preference: llama-cli is canonical (restored first
# if upstream brings it back), then the multimodal CLI, then the
# server (always built whenever cli would be, behind LLAMA_BUILD_SERVER).
cli_names = [f"llama-cli{exe}", f"llama-mtmd-cli{exe}", f"llama-server{exe}"]
cli = None
cli_name = None
for name in cli_names:
for d in search_dirs:
candidate = os.path.join(d, name)
if os.path.exists(candidate) and (is_windows or os.access(candidate, os.X_OK)):
cli, cli_name = candidate, name
break
if cli is not None:
break
if cli is None:
found = []
for d in search_dirs:
if os.path.isdir(d):
found += [p for p in os.listdir(d) if p.startswith("llama-")]
raise AssertionError(
f"No CLI inference binary ({', '.join(cli_names)}) found after "
f"build in {search_dirs}. Build root contents: {sorted(set(found))[:20]}"
)
print(f"Using CLI inference binary: {cli_name} -> {cli}")
# `--help` exits non-zero on some builds; the contract is that
# recognizable help text appears on stdout/stderr. llama-server
# exposes a different flag set than llama-cli, so accept its
# tokens too (e.g. --host / --port / "server").
proc = subprocess.run(
[cli, "--help"], capture_output=True, text=True, timeout=30,
)
combined = (proc.stdout or "") + (proc.stderr or "")
print("--- llama-cli --help (first 30 lines) ---")
print(f"--- {cli_name} --help (first 30 lines) ---")
print("\n".join(combined.splitlines()[:30]))
assert any(
tok in combined.lower()
for tok in ("usage", "--help", "--model", "-m,")
for tok in ("usage", "--help", "--model", "-m,", "--host", "--port", "server")
), (
f"llama-cli --help produced no recognizable help text. "
f"{cli_name} --help produced no recognizable help text. "
f"exit={proc.returncode}\nstdout: {proc.stdout[:400]!r}\n"
f"stderr: {proc.stderr[:400]!r}"
)
@ -2296,7 +2339,7 @@ jobs:
f"stderr: {q.stderr[:400]!r}"
)
print(
f"\nOK: install_llama_cpp produced a working llama-cli at {cli} "
f"\nOK: install_llama_cpp produced a working {cli_name} at {cli} "
f"and llama-quantize at {quantizer}."
)
PY

View file

@ -0,0 +1,611 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Local Agent Guides CI
# =====================
# Detects when our local-agent setup recipes drift out of sync with
# `unsloth run`. Boots a real `unsloth run --disable-tools` server and
# drives the coding agents end to end through the *exact* recipes defined
# in unsloth_cli/commands/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 <agent> --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/connect.py the `unsloth connect <agent>` 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 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 connect` flow produced no/garbled output.
#
# Agents covered (6): claude, codex, hermes, openclaw, opencode, pi.
# - 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
on:
# Off-peak weekly, deliberately a NON-:00 minute to dodge the top-of-hour
# GitHub-hosted-runner stampede.
schedule:
- cron: '37 7 * * 1'
workflow_dispatch:
pull_request:
paths:
- 'unsloth_cli/**'
- 'studio/backend/routes/**'
# Contracts this workflow asserts that live outside routes/**: the
# /api/health endpoint, the llama-server KV-cache log behavior, and the
# request/response schemas the agent dialects depend on.
- 'studio/backend/main.py'
- 'studio/backend/core/inference/llama_cpp.py'
- 'studio/backend/models/**'
- 'install.sh'
- '.github/workflows/local-agent-guides-ci.yml'
- '.github/scripts/serve-unsloth-run.sh'
- '.github/scripts/assert-prompt-cache.sh'
- '.github/scripts/agent-guides-install.sh'
- '.github/scripts/agent-guides-drive.sh'
- '.github/scripts/ci-connect-prompt.txt'
- '.github/scripts/ci-min-system-prompt.txt'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
# Secret handling on pull_request: these jobs check out and run PR-controlled code
# (install.sh, .github/scripts/**), so HF_TOKEN (an external HF credential) is gated
# off pull_request at each step below -- public GGUF repos still download anonymously.
# GH_TOKEN (GITHUB_TOKEN) is kept: it is the job-scoped contents:read token and
# install_llama_prebuilt.py needs it for the GitHub releases API (else 403s).
env:
# Determinism precedent (studio-inference-smoke.yml): temp 0 + fixed seed.
UNSLOTH_SEED: '3407'
# A single invoke must never hang the runner on a headless TTY prompt. With
# prefill-shrinking flags (minimal system prompt + restricted tools) a turn on
# a 4B model finishes in a couple of minutes on CPU; this also caps how long a
# still-large-prompt agent burns before failing. Well under the 6h job cap.
AGENT_INVOKE_TIMEOUT: '600'
jobs:
# ═════════════════════════════════════════════════════════════════════
# Job 1: connection
# Per-agent: serve gemma-3-270m, HTTP-preflight the agent's dialect,
# install the agent, run `unsloth connect <agent> --no-launch`, execute
# the emitted recipe with a trivial prompt, assert a non-empty reply.
# Runs on PR + weekly + dispatch. Each matrix cell is its own runner so
# it serves exactly one model on its own port.
# ═════════════════════════════════════════════════════════════════════
connection:
name: connection (${{ matrix.agent }})
runs-on: ubuntu-latest
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
agent: [claude, codex, hermes, openclaw, opencode, pi]
include:
# OpenClaw needs Node 24; everything else is happy on 22.
- agent: openclaw
node: '24'
env:
# gemma-4-E4B (128K context, capable enough to drive every agent for a
# trivial reply; the 270m model produced empty/failed responses for
# codex/openclaw 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
STUDIO_PORT: '18901'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Linux deps for llama.cpp prebuilt
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libcurl4-openssl-dev libssl-dev jq
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: ${{ matrix.node || '22' }}
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
cache: 'pip'
- name: Restore GGUF model file
id: cache-gguf
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
continue-on-error: true
with:
path: gguf-cache
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
- name: Download GGUF if cache miss
id: download-gguf
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
env:
# Gated off PR (see note above); public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p gguf-cache
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" gguf-cache
- name: Save GGUF model file
if: always() && steps.download-gguf.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: gguf-cache
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Gated off PR (see note above); public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
# ── boot the server under test (factored helper) ──────────────────
- name: Serve unsloth run --disable-tools (gemma-4-E4B)
run: |
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 \
--extra "--seed $UNSLOTH_SEED --temp 0" \
--health-timeout 900
# ── (a) server/API preflight: prove the dialect works BEFORE the agent ─
# Distinct error class. If this step fails it is a SERVER regression,
# not the agent's or the guide's fault, and the agent steps never run.
- name: Preflight the agent's API dialect (class-a isolation)
env:
AGENT: ${{ matrix.agent }}
run: |
set -uo pipefail
B="$UNSLOTH_BASE_URL"; K="$UNSLOTH_API_KEY"
preflight_fail() {
echo "::error::[server/API regression] agent=$AGENT: $* (preflight failed BEFORE install/connect; this is class (a), not guide drift). Endpoint contract lives in studio/backend/routes/**.";
exit 1
}
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/models" \
-H "Authorization: Bearer $K") || true
[ "$code" = "200" ] || preflight_fail "/v1/models returned HTTP $code"
case "$AGENT" in
claude)
# Anthropic Messages dialect.
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/messages" \
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
--max-time 120 \
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true
[ "$code" = "200" ] || preflight_fail "/v1/messages returned HTTP $code"
;;
codex)
# Codex always streams /v1/responses.
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/responses" \
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
--max-time 120 \
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"input\":\"Hi\",\"max_output_tokens\":16,\"stream\":true}") || true
[ "$code" = "200" ] || preflight_fail "/v1/responses returned HTTP $code"
;;
*)
# OpenAI Chat Completions dialect (hermes/opencode/pi/openclaw).
# OpenClaw's 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" \
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
--max-time 120 \
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true
[ "$code" = "200" ] || preflight_fail "/v1/chat/completions returned HTTP $code"
;;
esac
echo "preflight OK for $AGENT"
# ── (b) install the agent CLI (hardened npm/curl, retried) ─────────
- name: Install agent CLI (class-b isolation)
env:
AGENT: ${{ matrix.agent }}
run: bash .github/scripts/agent-guides-install.sh "$AGENT"
# ── (c) drive the agent via connect.py and assert a reply ──────────
# For the 5 agents with a connect.py recipe we run
# `unsloth connect <agent> --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 connect (class-c isolation)
env:
AGENT: ${{ matrix.agent }}
run: bash .github/scripts/agent-guides-drive.sh connection "$AGENT"
- name: Collect server logs (debug)
if: always()
run: |
mkdir -p logs/studio-logs
cp -r "$HOME/.unsloth/studio/logs/." logs/studio-logs/ 2>/dev/null || true
# Redact the key across the WHOLE logs/ tree, not just studio-logs:
# serve-unsloth-run.sh records the `unsloth run` banner (which prints
# `API Key: <key>`) into logs/unsloth-run-<port>.log, and the upload
# step publishes all of logs/, so scrubbing only studio-logs would leak
# the bearer token in the retained artifact.
if [ -n "${UNSLOTH_API_KEY:-}" ]; then
grep -rlF "$UNSLOTH_API_KEY" logs 2>/dev/null | while IFS= read -r f; do
sed -i "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$f" 2>/dev/null || true
done
fi
- name: Stop Studio
if: always()
run: |
# Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make
# `kill 0` signal this step's whole process group and abort cleanup.
if [ -n "${UNSLOTH_SERVER_PID:-}" ] && [ "${UNSLOTH_SERVER_PID}" != "0" ]; then
kill "${UNSLOTH_SERVER_PID}" 2>/dev/null || true
fi
sleep 2
ss -tln 2>/dev/null | grep ":${STUDIO_PORT}" || true
- name: Upload logs
if: always()
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: connection-${{ matrix.agent }}-log
path: |
logs/
redacted-configs/
retention-days: 7
# ═════════════════════════════════════════════════════════════════════
# Job 2: file-edit
# The deterministic 2-turn hello.py test on Qwen3.5-4B (smaller models
# can't reliably drive the heavyweight agents' edit flows). Weekly +
# dispatch only -- it is the slow, model-heavy job and must not gate PRs.
# ═════════════════════════════════════════════════════════════════════
file-edit:
name: file-edit (${{ matrix.agent }})
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 60
# hermes and openclaw drive a multi-turn tool loop that a CPU-only runner
# cannot finish in time (e.g. openclaw holds its 300s session-write-lock past
# expiry; each turn re-prefills the tool prompt at ~16 tok/s). Their endpoint
# wiring + generation are already hard-gated by the connection job, so the
# file-edit cell is best-effort here -- it still runs and uploads logs, but a
# timeout does not fail the workflow. Drop best_effort (or move e2e to a GPU
# runner) to make it blocking again.
continue-on-error: ${{ matrix.best_effort || false }}
strategy:
fail-fast: false
matrix:
agent: [claude, codex, hermes, openclaw, opencode, pi]
include:
- agent: openclaw
node: '24'
best_effort: true
- agent: hermes
best_effort: true
env:
# gemma-4-E4B served as a flat GGUF file (cache size tracks the .gguf 1:1,
# no xet-chunk inflation; the -MTP- repo ships no separate draft file).
GGUF_REPO: unsloth/gemma-4-E4B-it-GGUF
GGUF_FILE: gemma-4-E4B-it-UD-Q4_K_XL.gguf
STUDIO_PORT: '18902'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Linux deps for llama.cpp prebuilt
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libcurl4-openssl-dev libssl-dev jq
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: ${{ matrix.node || '22' }}
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
cache: 'pip'
- name: Restore GGUF model file
id: cache-gguf
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
continue-on-error: true
with:
path: gguf-cache
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
- name: Download GGUF if cache miss
id: download-gguf
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
env:
# Gated off PR (see note above); public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p gguf-cache
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" gguf-cache
- name: Save GGUF model file
if: always() && steps.download-gguf.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: gguf-cache
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Gated off PR (see note above); public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
- name: Serve unsloth run --disable-tools (gemma-4-E4B)
run: |
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 \
--extra "--seed $UNSLOTH_SEED --temp 0" \
--health-timeout 900
- name: Preflight the agent's API dialect (class-a isolation)
env:
AGENT: ${{ matrix.agent }}
run: |
set -uo pipefail
B="$UNSLOTH_BASE_URL"; K="$UNSLOTH_API_KEY"
preflight_fail() {
echo "::error::[server/API regression] agent=$AGENT: $* (preflight failed BEFORE install/connect; this is class (a), not guide drift). Endpoint contract lives in studio/backend/routes/**.";
exit 1
}
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/models" \
-H "Authorization: Bearer $K") || true
[ "$code" = "200" ] || preflight_fail "/v1/models returned HTTP $code"
# Probe the same dialect the agent will use, so a streaming/messages
# regression in the weekly run is reported as class (a) here instead of
# surfacing later as guide drift (mirrors the connection job).
case "$AGENT" in
claude)
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/messages" \
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
--max-time 120 \
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true
[ "$code" = "200" ] || preflight_fail "/v1/messages returned HTTP $code"
;;
codex)
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/responses" \
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
--max-time 120 \
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"input\":\"Hi\",\"max_output_tokens\":16,\"stream\":true}") || true
[ "$code" = "200" ] || preflight_fail "/v1/responses returned HTTP $code"
;;
*)
# OpenAI Chat Completions dialect (hermes/opencode/pi/openclaw).
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/chat/completions" \
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
--max-time 120 \
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true
[ "$code" = "200" ] || preflight_fail "/v1/chat/completions returned HTTP $code"
;;
esac
echo "preflight OK for $AGENT"
- name: Install agent CLI (class-b isolation)
env:
AGENT: ${{ matrix.agent }}
run: bash .github/scripts/agent-guides-install.sh "$AGENT"
- name: 2-turn hello.py test (class-c isolation)
env:
AGENT: ${{ matrix.agent }}
run: bash .github/scripts/agent-guides-drive.sh file-edit "$AGENT"
- name: Collect server logs (debug)
if: always()
run: |
mkdir -p logs/studio-logs
cp -r "$HOME/.unsloth/studio/logs/." logs/studio-logs/ 2>/dev/null || true
# Redact the key across the WHOLE logs/ tree, not just studio-logs:
# serve-unsloth-run.sh records the `unsloth run` banner (which prints
# `API Key: <key>`) into logs/unsloth-run-<port>.log, and the upload
# step publishes all of logs/, so scrubbing only studio-logs would leak
# the bearer token in the retained artifact.
if [ -n "${UNSLOTH_API_KEY:-}" ]; then
grep -rlF "$UNSLOTH_API_KEY" logs 2>/dev/null | while IFS= read -r f; do
sed -i "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$f" 2>/dev/null || true
done
fi
- name: Stop Studio
if: always()
run: |
# Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make
# `kill 0` signal this step's whole process group and abort cleanup.
if [ -n "${UNSLOTH_SERVER_PID:-}" ] && [ "${UNSLOTH_SERVER_PID}" != "0" ]; then
kill "${UNSLOTH_SERVER_PID}" 2>/dev/null || true
fi
sleep 2
ss -tln 2>/dev/null | grep ":${STUDIO_PORT}" || true
- name: Upload logs
if: always()
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: file-edit-${{ matrix.agent }}-log
path: |
logs/
agent-workdir/
redacted-configs/
retention-days: 7
# ═════════════════════════════════════════════════════════════════════
# Job 3: prompt-cache
# (a) curl 2-turn /v1/chat/completions: assert turn-2 cached_tokens > 0
# (server prompt-cache sanity).
# (b) Claude Code attribution A/B: with CLAUDE_CODE_ATTRIBUTION_HEADER=0
# expect a llama-server KV-cache HIT on turn 2; without it expect a
# MISS. If it inverts, the guide flag is stale.
# PR + weekly + dispatch (cheap, gemma-3-270m).
# ═════════════════════════════════════════════════════════════════════
prompt-cache:
name: prompt-cache (gemma-3-270m)
runs-on: ubuntu-latest
timeout-minutes: 25
env:
GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
GGUF_VARIANT: UD-Q4_K_XL
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
STUDIO_PORT: '18903'
HF_HOME: ${{ github.workspace }}/hf-cache
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Linux deps for llama.cpp prebuilt
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libcurl4-openssl-dev libssl-dev jq
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
cache: 'pip'
- name: Restore HF_HOME for ${{ env.GGUF_REPO }}
id: cache-hf
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
continue-on-error: true
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
- name: Prime HF_HOME with the GGUF
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
# Gated off PR (see note above); public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE"
- name: Save HF_HOME for ${{ env.GGUF_REPO }}
if: always() && steps.prime-hf.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Gated off PR (see note above); public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
- name: Serve unsloth run --disable-tools (gemma-3-270m)
run: |
unsloth studio reset-password
bash .github/scripts/serve-unsloth-run.sh \
--model "$GGUF_REPO" --gguf-variant "$GGUF_VARIANT" \
--port "$STUDIO_PORT" --log-dir logs \
--extra "--seed $UNSLOTH_SEED --temp 0"
# (a) server prompt-cache sanity on the OpenAI chat path. The helper runs
# the 2-turn probe internally (turn 2 reuses turn 1's prefix) and asserts
# turn-2 usage.prompt_tokens_details.cached_tokens > 0. This is the hard
# gate -- it proves llama.cpp KV reuse is surfaced on /v1/chat/completions.
- name: Server prompt-cache sanity (cached_tokens > 0)
run: bash .github/scripts/assert-prompt-cache.sh api "$UNSLOTH_BASE_URL" "$UNSLOTH_API_KEY"
- name: Install Claude Code (class-b isolation)
env:
AGENT: claude
run: bash .github/scripts/agent-guides-install.sh claude
# (b) Claude attribution A/B against the llama-server log. This is the most
# environment-sensitive check (it depends on the bundled llama.cpp's
# slot-reuse log wording and on claude --continue reusing the prefix), so
# it is non-blocking until calibrated on the first scheduled run; the
# server cache sanity above is the hard gate. The step still prints the
# observed HIT/MISS so drift is visible in the log + artifacts.
- name: Claude attribution A/B (HIT with header=0, MISS without)
continue-on-error: true
run: bash .github/scripts/agent-guides-drive.sh attribution-ab claude
- name: Collect server logs (debug)
if: always()
run: |
mkdir -p logs/studio-logs
cp -r "$HOME/.unsloth/studio/logs/." logs/studio-logs/ 2>/dev/null || true
# Redact the key across the WHOLE logs/ tree, not just studio-logs:
# serve-unsloth-run.sh records the `unsloth run` banner (which prints
# `API Key: <key>`) into logs/unsloth-run-<port>.log, and the upload
# step publishes all of logs/, so scrubbing only studio-logs would leak
# the bearer token in the retained artifact.
if [ -n "${UNSLOTH_API_KEY:-}" ]; then
grep -rlF "$UNSLOTH_API_KEY" logs 2>/dev/null | while IFS= read -r f; do
sed -i "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$f" 2>/dev/null || true
done
fi
- name: Stop Studio
if: always()
run: |
# Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make
# `kill 0` signal this step's whole process group and abort cleanup.
if [ -n "${UNSLOTH_SERVER_PID:-}" ] && [ "${UNSLOTH_SERVER_PID}" != "0" ]; then
kill "${UNSLOTH_SERVER_PID}" 2>/dev/null || true
fi
sleep 2
ss -tln 2>/dev/null | grep ":${STUDIO_PORT}" || true
- name: Upload logs
if: always()
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: prompt-cache-log
path: |
logs/
redacted-configs/
retention-days: 7

View file

@ -241,7 +241,8 @@ jobs:
# non-zero binary exit is an Unsloth/Studio bug.
- name: Studio prebuilt llama.cpp install + GGUF inference (Mac M1)
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
# 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
@ -332,7 +333,8 @@ jobs:
# train_metrics.json so we can detect regressions across CI runs.
- name: MLX export round-trip — TRAIN + SAVE 3 formats
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
UNSLOTH_COMPILE_DISABLE: '1'
run: |
mkdir -p mlx_workdir
@ -348,7 +350,8 @@ jobs:
# the saved dir.
- name: MLX export round-trip — RELOAD LoRA (fresh process)
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
UNSLOTH_COMPILE_DISABLE: '1'
run: |
python tests/studio/run_real_mlx_smoke.py reload \
@ -357,7 +360,8 @@ jobs:
- name: MLX export round-trip — RELOAD merged_16bit (fresh process)
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
UNSLOTH_COMPILE_DISABLE: '1'
run: |
python tests/studio/run_real_mlx_smoke.py reload \
@ -372,7 +376,8 @@ jobs:
# LoRA + merged_16bit assertions remain the gating signal.
- name: MLX export round-trip — RELOAD GGUF via llama-cli (fresh process)
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
if python -c "import json,sys; m=json.load(open('mlx_workdir/train_metrics.json')); sys.exit(0 if m.get('gguf_supported') else 1)"; then
python tests/studio/run_real_mlx_smoke.py reload \

View file

@ -353,7 +353,7 @@ jobs:
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
# ── Node.js ──
- name: Setup Node.js
@ -406,9 +406,65 @@ jobs:
if (config.bundle?.linux?.rpm) {
throw new Error('bundle.linux.rpm must not be configured');
}
if (config.bundle?.linux?.appimage?.bundleMediaFramework !== false) {
throw new Error('Linux AppImage bundleMediaFramework must stay false');
}
const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8');
const lines = workflow.split(/\r?\n/);
const linuxInstallLines = lines.filter((line) => line.includes('sudo apt-get install'));
const ayatanaPackage = ['libayatana', 'appindicator3-dev'].join('-');
if (linuxInstallLines.some((line) => line.includes(ayatanaPackage))) {
throw new Error('Desktop Linux release must not install the Ayatana appindicator dev package');
}
if (!linuxInstallLines.some((line) => line.includes('libappindicator3-dev'))) {
throw new Error('Desktop Linux release must install libappindicator3-dev');
}
const linuxdeployLines = lines.filter((line) => line.includes('github.com/linuxdeploy/linuxdeploy/releases/download'));
if (!linuxdeployLines.some((line) => line.includes('1-alpha-20250213-2/linuxdeploy-x86_64.AppImage'))) {
throw new Error('Desktop Linux release must pin linuxdeploy 1-alpha-20250213-2');
}
// A pinned version/path is reproducibility, not integrity: the asset
// can be replaced after upload. Require the immutable SHA-256 digest
// to be pinned AND verified before chmod +x. Scope every check to the
// real "Pin linuxdeploy for AppImage" step so this guard cannot
// satisfy itself; a file-wide scan would match the guard's own code.
const expectedLinuxdeployDigest = '4648f278ab3ef31f819e67c30d50f462640e5365a77637d7e6f2ad9fd0b4522a';
const isComment = (line) => {
const trimmed = line.trim();
return trimmed.startsWith('#') || trimmed.startsWith('//');
};
const stepStart = lines.findIndex((line) => /^\s*- name: Pin linuxdeploy for AppImage\s*$/.test(line));
if (stepStart === -1) {
throw new Error('Desktop Linux release must keep the "Pin linuxdeploy for AppImage" step');
}
const stepIndent = lines[stepStart].search(/\S/);
let stepEnd = lines.length;
for (let i = stepStart + 1; i < lines.length; i += 1) {
const line = lines[i];
if (line.trim() === '') continue;
const indent = line.search(/\S/);
// The next sibling step ('- ...') at the same indent, or any dedent
// below the step, ends this step's block.
if (indent < stepIndent || (indent === stepIndent && /^\s*-\s/.test(line))) {
stepEnd = i;
break;
}
}
const stepLines = lines.slice(stepStart, stepEnd);
const digestEnvRe = /^\s*LINUXDEPLOY_SHA256:\s*["']([0-9a-f]{64})["']\s*$/;
const digestEnvLine = stepLines.find((line) => digestEnvRe.test(line));
if (!digestEnvLine || digestEnvLine.match(digestEnvRe)[1] !== expectedLinuxdeployDigest) {
throw new Error('Desktop Linux release must pin the linuxdeploy SHA-256 digest in the LINUXDEPLOY_SHA256 env');
}
const sha256Idx = stepLines.findIndex((line) => !isComment(line) && line.includes('sha256sum -c'));
if (sha256Idx === -1) {
throw new Error('Desktop Linux release must verify the linuxdeploy digest with sha256sum -c before use');
}
const chmodIdx = stepLines.findIndex((line) => !isComment(line) && /chmod\s+\+x/.test(line));
if (chmodIdx !== -1 && sha256Idx > chmodIdx) {
throw new Error('Desktop Linux release must verify the linuxdeploy digest before chmod +x');
}
const releaseBodies = [];
for (let i = 0; i < lines.length; i += 1) {
const match = lines[i].match(/^(\s*)releaseBody:\s*\|\s*$/);
@ -438,6 +494,12 @@ jobs:
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
@ -562,6 +624,33 @@ jobs:
Get-Command trusted-signing-cli -ErrorAction SilentlyContinue || Write-Output "trusted-signing-cli NOT in PATH"
trusted-signing-cli --version || Write-Output "trusted-signing-cli failed to run"
# ── Linux: pin AppImage packaging toolchain ──
- name: Pin linuxdeploy for AppImage
if: matrix.platform == 'ubuntu-22.04'
shell: bash
env:
# Pinning the versioned release path is reproducibility, not
# integrity: a GitHub release asset can be replaced (or its delivery
# path compromised) after upload. The SHA-256 below is the immutable
# digest of this exact asset and is the integrity gate. If linuxdeploy
# publishes a new build under this tag, this run fails closed and the
# digest must be re-pinned deliberately.
LINUXDEPLOY_URL: "https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20250213-2/linuxdeploy-x86_64.AppImage"
LINUXDEPLOY_SHA256: "4648f278ab3ef31f819e67c30d50f462640e5365a77637d7e6f2ad9fd0b4522a"
run: |
set -euo pipefail
tools_dir="$RUNNER_TEMP/tauri-tools-cache/tauri"
mkdir -p "$tools_dir"
dest="$tools_dir/linuxdeploy-x86_64.AppImage"
curl -fsSL "$LINUXDEPLOY_URL" -o "$dest"
# Verify the digest BEFORE the binary is ever marked executable. The
# next step builds the AppImage with the Tauri signing key 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 + upload ──
- name: Build Linux app
if: matrix.platform == 'ubuntu-22.04'
@ -570,6 +659,7 @@ jobs:
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
@ -580,9 +670,10 @@ jobs:
**macOS**: Download the Apple Silicon `.dmg`.
**Windows**: Download the `-setup.exe` installer.
**Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal).
**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 }}
@ -611,9 +702,10 @@ jobs:
**macOS**: Download the Apple Silicon `.dmg`.
**Windows**: Download the `-setup.exe` installer.
**Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal).
**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 }}
@ -643,9 +735,10 @@ jobs:
**macOS**: Download the Apple Silicon `.dmg`.
**Windows**: Download the `-setup.exe` installer.
**Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal).
**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 }}

View file

@ -434,7 +434,7 @@ jobs:
# ─────────────────────────────────────────────────────────────
# Semgrep: design-flaw detection (catches what regex-pattern
# scanning of malicious authors cannot first-party logic bugs
# scanning of malicious authors cannot, e.g. first-party logic bugs
# like langchain-core CVE-2025-68664 dumps/dumpd injection,
# n8n CVE-2025-68668 _pyodide.eval_code sandbox escape, marimo
# CVE-2026-39987 unauth WebSocket).
@ -849,10 +849,13 @@ jobs:
grep -q "Standalone pre-install package scanner" scripts/scan_packages.py
- name: Scan declared + transitive Python deps
# scan_packages.py exits 1 on CRITICAL/HIGH findings, 0 on
# clean. We swallow the exit because the baseline isn't
# triaged yet; surface the findings in the workflow summary.
# Drop continue-on-error after the first clean run on main.
# scan_packages.py exits 1 on NON-baselined CRITICAL/HIGH
# findings, 0 otherwise. It scans code-only (docstrings and
# comments are blanked first) and suppresses reviewed
# known-good findings via scripts/scan_packages_baseline.json,
# so legitimate-library noise no longer red-fails the gate.
# The step stays advisory until SCAN_ENFORCE=1 (see env below);
# then PIPESTATUS propagates the scanner's exit code.
#
# `--with-deps` walks PyPI metadata to enumerate every
# transitive dep the declared set would install, then scans
@ -869,6 +872,14 @@ jobs:
# downloads in exchange for wall-clock parallelism.
env:
SHARD_FILES: ${{ matrix.shard.files }}
# Enforcement switch. "1" = blocking: a non-baselined CRITICAL/HIGH
# fails the build. scan_packages.py scans code-only (docstrings/comments
# stripped), fetches sdist-only packages directly from PyPI (no build)
# so every shard resolves, and honors the reviewed allowlist at
# scripts/scan_packages_baseline.json, so only NON-baselined
# CRITICAL/HIGH cause its exit 1. The committed baseline makes all three
# shards exit 0 today; set this back to "0" to return to advisory.
SCAN_ENFORCE: "1"
run: |
set +e
mkdir -p logs
@ -884,12 +895,14 @@ jobs:
fi
done
echo "::endgroup::"
rc=0
if [ ${#REQ_ARGS[@]} -eq 0 ]; then
echo "[security-audit] shard ${{ matrix.shard.id }}: no PyPI specs, nothing to scan" \
| tee "$LOG"
else
python scripts/scan_packages.py --with-deps "${REQ_ARGS[@]}" \
2>&1 | tee "$LOG"
rc=${PIPESTATUS[0]}
fi
{
echo "## scan_packages :: shard ${{ matrix.shard.id }}"
@ -897,11 +910,19 @@ jobs:
echo "### Files in this shard"
for f in $SHARD_FILES; do echo "- audit-reqs/$f.txt"; done
echo
echo "scan_packages.py exit code: $rc (enforce=$SCAN_ENFORCE)"
echo
echo '### Findings (tail)'
echo '```'
tail -200 "$LOG"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# Advisory by default; blocking once SCAN_ENFORCE=1 and the baseline
# is committed. PIPESTATUS is captured above so `tee` does not mask the
# scanner's exit code.
if [ "$SCAN_ENFORCE" = "1" ]; then
exit "$rc"
fi
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
@ -975,24 +996,37 @@ jobs:
python3 -c "import ast; ast.parse(open('scripts/scan_npm_packages.py').read())"
- name: Scan npm tarballs (declared + transitive, no install)
# The script exits 1 on HIGH/CRITICAL findings; we capture the
# full log and surface it in the step summary either way. It
# never runs `npm install`, never executes anything from a
# downloaded tarball, and only fetches from registry.npmjs.org.
# Initially non-blocking so the baseline can settle; drop
# continue-on-error once the baseline is clean for a week.
# scan_npm_packages.py exits 1 on NON-baselined HIGH/CRITICAL
# findings, 0 otherwise. It scans code-only (JS/TS comments are
# blanked first) and honors a reviewed allowlist at
# scripts/scan_npm_packages_baseline.json. It never runs
# `npm install`, never executes anything from a downloaded
# tarball, and only fetches from registry.npmjs.org. The npm
# corpus is clean (the baseline is empty), so the gate is
# enforcing (SCAN_ENFORCE=1) and any new finding fails the build.
env:
SCAN_ENFORCE: "1"
run: |
set -o pipefail
set +e
LOG=logs-scan-npm.txt
python3 scripts/scan_npm_packages.py 2>&1 | tee "$LOG"
rc=${PIPESTATUS[0]}
{
echo "## scan_npm_packages"
echo
echo "scan_npm_packages.py exit code: $rc (enforce=$SCAN_ENFORCE)"
echo
echo '### Findings (tail)'
echo '```'
tail -300 "$LOG"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# Blocking: the npm corpus is clean, so any non-baselined
# HIGH/CRITICAL is new and should fail the build. PIPESTATUS is
# captured above so `tee` does not mask the scanner's exit code.
if [ "$SCAN_ENFORCE" = "1" ]; then
exit "$rc"
fi
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()

View file

@ -83,7 +83,8 @@ jobs:
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
@ -100,7 +101,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail

View file

@ -222,9 +222,14 @@ jobs:
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; do
tests/sh/test_torch_constraint.sh \
tests/sh/test_torch_flavor.sh; do
echo "::group::$s"
bash "$s"
echo "::endgroup::"

View file

@ -97,7 +97,8 @@ jobs:
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
@ -114,7 +115,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail
@ -317,7 +319,7 @@ jobs:
timeout-minutes: 25
env:
# Tool calling is the highest-volume GGUF in this workflow
# (Qwen3.5-2B at IQ3_XXS = ~890 MiB). Caching HF_HOME would
# (Qwen3.5-2B at Q4_K_XL = ~1.28 GiB). Caching HF_HOME would
# store xet chunks + blobs + snapshots = ~4 GiB compressed --
# 4-5x file-size inflation, dominated by xet chunks. Use main's
# `--local-dir gguf-cache` pattern to cache the flat .gguf only.
@ -326,8 +328,11 @@ jobs:
# path keeps the test off HF_HOME entirely so the cache size
# tracks the GGUF file 1:1. The OpenAI/Anth and JSON+images
# jobs still cover the gguf_variant resolution path.
# Q4_K_XL, not IQ3_XXS: at IQ3_XXS this model emits malformed
# tool calls that llama-server's peg-native parser rejects with a
# 500. Mac/Windows already use Q4_K_XL for the same reason.
GGUF_REPO: unsloth/Qwen3.5-2B-GGUF
GGUF_FILE: Qwen3.5-2B-UD-IQ3_XXS.gguf
GGUF_FILE: Qwen3.5-2B-UD-Q4_K_XL.gguf
STUDIO_PORT: '18889'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@ -361,7 +366,8 @@ jobs:
id: download-gguf
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p gguf-cache
@ -377,7 +383,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail
@ -772,6 +779,9 @@ jobs:
kill "${STUDIO_PID}" 2>/dev/null || true
sleep 2
ss -tln | grep ":${STUDIO_PORT}" || true
# Capture backend + llama-server logs so a 500 has a server-side traceback.
mkdir -p logs/server-logs
cp -r ~/.unsloth/studio/logs/. logs/server-logs/ 2>/dev/null || true
- name: Upload logs
# Always upload so green runs are still reviewable.
@ -784,6 +794,7 @@ jobs:
path: |
logs/studio.log
logs/install.log
logs/server-logs/
retention-days: 7
# ─────────────────────────────────────────────────────────────────────
@ -838,7 +849,8 @@ jobs:
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
@ -856,7 +868,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail

View file

@ -68,7 +68,8 @@ jobs:
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
@ -85,7 +86,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail

View file

@ -91,7 +91,8 @@ jobs:
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
@ -110,7 +111,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail
@ -346,7 +348,8 @@ jobs:
id: download-gguf
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p gguf-cache
@ -363,7 +366,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail
@ -725,7 +729,8 @@ jobs:
# Authenticated + parallel: shared macos-14 NAT egress stalls
# multi-GB anonymous downloads.
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p gguf-cache
@ -752,7 +757,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail

View file

@ -63,7 +63,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail

View file

@ -68,7 +68,8 @@ jobs:
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
@ -85,7 +86,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail

View file

@ -62,7 +62,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail
@ -74,7 +75,8 @@ jobs:
- name: First update should be a no-op (prebuilt already validated)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
set -o pipefail
unsloth studio update --local 2>&1 | tee logs/update.log
@ -93,7 +95,8 @@ jobs:
- name: Second update must also be a no-op
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
set -o pipefail
unsloth studio update --local 2>&1 | tee logs/update2.log

View file

@ -47,7 +47,7 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libayatana-appindicator3-dev \
libwebkit2gtk-4.1-dev libappindicator3-dev \
librsvg2-dev libxdo-dev libssl-dev patchelf
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0

View file

@ -82,7 +82,8 @@ jobs:
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
@ -99,7 +100,8 @@ jobs:
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail
@ -276,6 +278,10 @@ jobs:
run: |
kill "${STUDIO_IME_PID}" 2>/dev/null || true
sleep 2
# Capture backend + llama-server logs (all three Studios share this
# dir) so a stray 500 has a server-side traceback.
mkdir -p logs/server-logs
cp -r ~/.unsloth/studio/logs/. logs/server-logs/ 2>/dev/null || true
- name: Upload Playwright artifacts
# Always upload so a green run's screenshots stay reviewable --
@ -289,6 +295,7 @@ jobs:
logs/studio_extra.log
logs/studio_ime.log
logs/install.log
logs/server-logs/
logs/playwright
logs/playwright_extra
logs/playwright_ime

View file

@ -71,7 +71,8 @@ jobs:
# prebuilt path falls back to source build.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail
@ -86,7 +87,8 @@ jobs:
# idempotency regressed.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
set -o pipefail
unsloth studio update --local 2>&1 | tee logs/update.log
@ -109,7 +111,8 @@ jobs:
# the first one.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
set -o pipefail
unsloth studio update --local 2>&1 | tee logs/update2.log

View file

@ -75,7 +75,8 @@ jobs:
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
@ -124,7 +125,8 @@ jobs:
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
New-Item -ItemType Directory -Force -Path logs | Out-Null
# *>&1 captures Write-Host (Information stream) output;

View file

@ -26,6 +26,7 @@ on:
- 'unsloth_cli/**'
- 'install.ps1'
- 'pyproject.toml'
- 'tests/studio_setup_ps1/**'
- '.github/workflows/studio-windows-inference-smoke.yml'
push:
branches: [main, pip]
@ -65,17 +66,34 @@ jobs:
with:
persist-credentials: false
# Fast GPU-free gate: parse setup.ps1 and run the Resolve-CudaToolkit unit
# test (deferred Windows CUDA Toolkit check) before the heavy GGUF smoke.
- name: setup.ps1 unit test (Resolve-CudaToolkit)
# Fast GPU-free gate: parse install.ps1 + setup.ps1 and run the PowerShell
# unit tests (CUDA-toolkit + torch-flavor helpers) before the heavy GGUF smoke.
- name: PowerShell installer unit tests
shell: pwsh
run: |
foreach ($f in @('install.ps1', 'studio/setup.ps1')) {
$errs = $null
[void][System.Management.Automation.Language.Parser]::ParseFile(
(Resolve-Path $f).Path, [ref]$null, [ref]$errs)
if ($errs) { $errs | ForEach-Object { $_.ToString() }; exit 1 }
Write-Host "$f parsed with no errors"
}
pwsh -NoProfile -File tests/studio/test_resolve_cuda_toolkit.ps1
pwsh -NoProfile -File tests/studio/test_torch_flavor.ps1
pwsh -NoProfile -File tests/studio/test_node_decision.ps1
pwsh -NoProfile -File tests/studio/test_node_probe_guard.ps1
# uninstall.ps1: native uninstall must keep the shared unsloth.ico while a
# WSL shortcut still references it (dual install), else that shortcut blanks.
- name: uninstall.ps1 unit test (dual-install icon preserve)
shell: pwsh
run: |
$errs = $null
[void][System.Management.Automation.Language.Parser]::ParseFile(
(Resolve-Path studio/setup.ps1).Path, [ref]$null, [ref]$errs)
(Resolve-Path scripts/uninstall.ps1).Path, [ref]$null, [ref]$errs)
if ($errs) { $errs | ForEach-Object { $_.ToString() }; exit 1 }
Write-Host "setup.ps1 parsed with no errors"
pwsh -NoProfile -File tests/studio/test_resolve_cuda_toolkit.ps1
Write-Host "uninstall.ps1 parsed with no errors"
pwsh -NoProfile -File tests/studio/test_uninstall_dual_install_icon.ps1
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
@ -109,7 +127,8 @@ jobs:
# described above (outcome != success).
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
@ -161,7 +180,8 @@ jobs:
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
New-Item -ItemType Directory -Force -Path logs | Out-Null
# *>&1 captures Write-Host (Information stream) output;
@ -458,7 +478,8 @@ jobs:
id: download-gguf
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p gguf-cache
@ -506,7 +527,8 @@ jobs:
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
New-Item -ItemType Directory -Force -Path logs | Out-Null
# *>&1 captures Write-Host (Information stream) output;
@ -888,7 +910,8 @@ jobs:
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
@ -938,7 +961,8 @@ jobs:
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
New-Item -ItemType Directory -Force -Path logs | Out-Null
# *>&1 captures Write-Host (Information stream) output;
@ -1239,3 +1263,539 @@ jobs:
logs/install.log
logs/llama-server/*.log
retention-days: 7
# ── folded from studio-windows-no-vs-smoke.yml: install + run with no Visual Studio ──
no-vs-cpu:
name: Studio install + inference without Visual Studio
runs-on: windows-latest
timeout-minutes: 35
defaults:
run:
shell: bash
env:
GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
GGUF_VARIANT: UD-Q4_K_XL
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
STUDIO_PORT: '18820'
HF_HOME: ${{ github.workspace }}/hf-cache
PYTHONIOENCODING: utf-8
PYTHONUTF8: '1'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
- name: Restore HF_HOME for ${{ env.GGUF_REPO }}
id: cache-hf
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
continue-on-error: true
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
- name: Prime HF_HOME with the GGUF
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE"
bash .github/scripts/hf-download-with-retry.sh ggml-org/models tinyllamas/stories260K.gguf
- name: Save HF_HOME for ${{ env.GGUF_REPO }}
if: always() && steps.prime-hf.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
- name: Pre-install Windows tweaks (npm 11 + Defender exclusions)
shell: pwsh
run: |
$ProgressPreference = 'SilentlyContinue'
npm install -g 'npm@^11' 2>&1 | Out-Host
foreach ($p in @(
"$env:USERPROFILE\.unsloth",
"$env:USERPROFILE\AppData\Local\uv",
"$env:GITHUB_WORKSPACE\studio\frontend\node_modules",
"$env:GITHUB_WORKSPACE\studio\frontend\dist"
)) {
try { Add-MpPreference -ExclusionPath $p -ErrorAction Stop } catch { }
}
- name: Hide Visual Studio + CMake (simulate a host with no build tools)
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
# 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-Item -LiteralPath $d -NewName ((Split-Path $d -Leaf) + '.vsoff')
Write-Host "Hid VS: $d"
}
}
# Surgically rename each cmake executable on PATH (not its parent dir --
# cmake can share a dir with other shims) so Get-Command cmake fails.
$hidden = @()
foreach ($c in (Get-Command cmake -All -ErrorAction SilentlyContinue)) {
if ($c.Source -and (Test-Path -LiteralPath $c.Source)) {
Rename-Item -LiteralPath $c.Source -NewName ((Split-Path $c.Source -Leaf) + '.off')
$hidden += $c.Source
Write-Host "Hid cmake: $($c.Source)"
}
}
("HIDDEN_CMAKE=" + ($hidden -join '|')) | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Assert Visual Studio + CMake are genuinely undetectable
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
. (Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1/Get-FunctionSource.ps1')
$setup = Join-Path $env:GITHUB_WORKSPACE 'studio/setup.ps1'
foreach ($fn in @('Resolve-VsGeneratorFromLabel', 'Find-VsBuildTools')) {
. ([scriptblock]::Create((Get-FunctionSource -Path $setup -Name $fn)))
}
$vs = Find-VsBuildTools
if ($vs) { Write-Error "Find-VsBuildTools still detects VS: $($vs.Generator) @ $($vs.InstallPath)"; exit 1 }
if (Get-Command cmake -ErrorAction SilentlyContinue) { Write-Error "cmake is still on PATH"; exit 1 }
if (Get-Command cl.exe -ErrorAction SilentlyContinue) { Write-Error "cl.exe is still on PATH"; exit 1 }
Write-Host "Confirmed: no Visual Studio, no cmake, no cl.exe."
- name: PyTorch CPU wheel installs and imports (no Visual Studio)
run: |
python -m pip install --upgrade pip
python -m pip install torch --index-url https://download.pytorch.org/whl/cpu
python -c "import torch; print('torch', torch.__version__, 'cuda?', torch.cuda.is_available())"
- name: Install Studio (--local, --no-torch) with no build tools present
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
New-Item -ItemType Directory -Force -Path logs | Out-Null
$ProgressPreference = 'SilentlyContinue'
& ./install.ps1 --local --no-torch *>&1 | Tee-Object -FilePath logs/install.log
- name: Assert prebuilt used AND no build tools were installed
run: |
LLAMA_DIR=~/.unsloth/llama.cpp
INFO="$LLAMA_DIR/UNSLOTH_PREBUILT_INFO.json"
BIN="$LLAMA_DIR/build/bin/Release/llama-server.exe"
fail=0
if grep -q "falling back to source build" logs/install.log; then
echo "::error::install.ps1 fell back to source-build llama.cpp without VS."; fail=1
fi
# The deferred build-tool installs must NOT run on the prebuilt path.
for pat in "Kitware.CMake" "Microsoft.VisualStudio.2022.BuildTools" "installing via winget"; do
if grep -qi "$pat" logs/install.log; then
echo "::error::unexpected build-tool install on the prebuilt path: '$pat'"; fail=1
fi
done
[ -f "$INFO" ] || { echo "::error::no UNSLOTH_PREBUILT_INFO.json"; ls -la "$LLAMA_DIR" || true; fail=1; }
[ -f "$BIN" ] || { echo "::error::no llama-server.exe"; ls -la "$LLAMA_DIR/build/bin" || true; fail=1; }
if [ "$fail" != "0" ]; then grep -iE "cmake|visual studio|prebuilt|source build" logs/install.log | tail -60; exit 1; fi
echo "Prebuilt installed with no build tools:"
cat "$INFO"
- name: Add 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 Studio (API-only)
run: |
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 &
echo "STUDIO_PID=$!" >> "$GITHUB_ENV"
- name: Wait for /api/health, log in, load the GGUF
run: |
for i in $(seq 1 180); do
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
jq -e '.status == "healthy"' /tmp/health.json && break
fi
sleep 1
done
jq -e '.status == "healthy"' /tmp/health.json || { tail -200 logs/studio.log; exit 1; }
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
NEW="CINoVS-$(python -c 'import secrets; print(secrets.token_urlsafe(12))')"
echo "::add-mask::$OLD"
echo "::add-mask::$NEW"
OLD_TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
-H 'content-type: application/json' \
-d "{\"username\":\"unsloth\",\"password\":\"$OLD\"}" | jq -r .access_token)
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/change-password" \
-H "Authorization: Bearer $OLD_TOKEN" -H 'content-type: application/json' \
-d "{\"current_password\":\"$OLD\",\"new_password\":\"$NEW\"}" > /dev/null
TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
-H 'content-type: application/json' \
-d "{\"username\":\"unsloth\",\"password\":\"$NEW\"}" | jq -r .access_token)
echo "API_KEY=$TOKEN" >> "$GITHUB_ENV"
LOAD_OK=0
for attempt in 1 2 3; do
HTTP=$(curl -s -o /tmp/load.json -w '%{http_code}' \
-X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
--max-time 600 \
-d "{\"model_path\":\"$GGUF_REPO\",\"gguf_variant\":\"$GGUF_VARIANT\",\"is_lora\":false,\"max_seq_length\":2048}")
if [ "$HTTP" = "200" ]; then LOAD_OK=1; break; fi
echo "::warning::/api/inference/load attempt $attempt returned $HTTP"; cat /tmp/load.json || true; sleep 10
done
[ "$LOAD_OK" = "1" ] || { echo "::error::/api/inference/load failed 3 attempts"; exit 22; }
jq '{status, display_name, is_gguf}' /tmp/load.json
- name: Inference works via the prebuilt llama.cpp (no VS)
run: |
RESP=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/v1/chat/completions" \
-H "Authorization: Bearer $API_KEY" -H 'content-type: application/json' \
--max-time 240 \
-d '{"model":"default","messages":[{"role":"user","content":"What is 1+1? Answer briefly."}],"temperature":0,"max_tokens":32,"stream":false}')
echo "$RESP" | jq '.choices[0].message' || { echo "$RESP"; exit 1; }
CONTENT=$(echo "$RESP" | jq -r '.choices[0].message.content')
[ -n "$CONTENT" ] && [ "$CONTENT" != "null" ] || { echo "::error::empty completion"; exit 1; }
echo "Inference OK without Visual Studio: $CONTENT"
- name: Restore Visual Studio + CMake
if: always()
shell: pwsh
run: |
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) }
}
}
- name: Stop Studio
if: always()
shell: cmd
run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
- name: Collect llama-server logs
if: always()
continue-on-error: true
run: |
mkdir -p logs/llama-server
cp -v ~/.unsloth/studio/logs/llama-server/*.log logs/llama-server/ 2>/dev/null || echo "no llama-server logs"
- name: Upload logs
if: always()
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: windows-no-vs-cpu-log
path: |
logs/install.log
logs/studio.log
logs/llama-server/*.log
retention-days: 7
# ─────────────────────────────────────────────────────────────────────
# Job B: the GPU (CUDA) prebuilt path is also VS-free (resolve/availability)
# ─────────────────────────────────────────────────────────────────────
no-vs-gpu-resolve:
name: GPU prebuilt resolves without Visual Studio
runs-on: windows-latest
timeout-minutes: 15
defaults:
run:
shell: bash
env:
PYTHONIOENCODING: utf-8
PYTHONUTF8: '1'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
- name: Hide Visual Studio
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) {
if (Test-Path -LiteralPath $d) { Rename-Item -LiteralPath $d -NewName ((Split-Path $d -Leaf) + '.vsoff'); Write-Host "Hid VS: $d" }
}
- name: Windows CUDA and ROCm prebuilts exist in unslothai/llama.cpp (what GPU users download, no VS)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
curl -fsSL -H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/unslothai/llama.cpp/releases/latest" > /tmp/rel.json
echo "release: $(jq -r .tag_name /tmp/rel.json)"
ASSETS=$(jq -r '.assets[].name' /tmp/rel.json)
echo "$ASSETS" | grep -iE 'windows-x64-cuda[0-9]' || {
echo "::error::no Windows x64 CUDA prebuilt asset found in unslothai/llama.cpp latest release"
echo "$ASSETS"; exit 1; }
# AMD parity: hosted runners have no AMD GPU, so the resolver step below
# can't exercise the ROCm path (it resolves to CPU). Pin the per-gfx
# Windows ROCm bundles here so a release that drops them fails loudly --
# the AMD no-VS guarantee otherwise rides only on shared resolver code.
echo "$ASSETS" | grep -iE 'windows-x64-rocm-gfx' || {
echo "::error::no Windows x64 ROCm (per-gfx) prebuilt asset found in unslothai/llama.cpp latest release"
echo "$ASSETS"; exit 1; }
echo "Windows CUDA and ROCm prebuilts are available -- GPU users get them without compiling."
- name: The prebuilt resolver runs without Visual Studio
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Resolver-only (no GPU on hosted runners, so the host resolves to the
# CPU bundle). The point is that resolution needs no compiler/VS.
python -m pip install --upgrade huggingface_hub
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: Restore Visual Studio
if: always()
shell: pwsh
run: |
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:
name: setup.ps1 unit tests (VS 2026 / CMake guard)
runs-on: windows-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install Pester v5
shell: pwsh
run: |
Set-PSRepository PSGallery -InstallationPolicy Trusted
Install-Module Pester -MinimumVersion 5.5.0 -Force -SkipPublisherCheck -Scope CurrentUser
Import-Module Pester -MinimumVersion 5.5.0
Get-Module Pester | Select-Object Name, Version | Format-Table
- name: Run Pester suite
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$testDir = Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1'
if (-not (Test-Path $testDir)) {
Write-Error "Test directory not found: $testDir"
exit 1
}
$cfg = New-PesterConfiguration
$cfg.Run.Path = $testDir
$cfg.Run.Exit = $true # non-zero exit => job fails
$cfg.Run.Throw = $true # also throw on test failure / 0 tests
$cfg.TestResult.Enabled = $true
$cfg.TestResult.OutputFormat = 'NUnitXml'
$cfg.TestResult.OutputPath = Join-Path $env:GITHUB_WORKSPACE 'pester-results.xml'
$cfg.Output.Verbosity = 'Detailed'
Invoke-Pester -Configuration $cfg
- name: Upload Pester results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pester-results-setup-ps1
path: pester-results.xml
if-no-files-found: warn
vs-integration:
# Real detection against the VS installed on the runner image (no mocks).
name: real-VS detection (${{ matrix.label }})
strategy:
fail-fast: false
matrix:
include:
- { os: windows-2022, label: 'VS 2022', expectGen: 'Visual Studio 17 2022', expectToolset: 'v170' }
- { os: windows-2025-vs2026, label: 'VS 2026', expectGen: 'Visual Studio 18 2026', expectToolset: 'v180' }
runs-on: ${{ matrix.os }}
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Detect the real Visual Studio with setup.ps1 functions
shell: pwsh
env:
EXPECT_GEN: ${{ matrix.expectGen }}
EXPECT_TOOLSET: ${{ matrix.expectToolset }}
run: |
$ErrorActionPreference = 'Stop'
. (Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1/Get-FunctionSource.ps1')
$setup = Join-Path $env:GITHUB_WORKSPACE 'studio/setup.ps1'
foreach ($fn in @('Resolve-VsGeneratorFromLabel', 'Get-VcBuildCustomizationsDir', 'Find-VsBuildTools')) {
. ([scriptblock]::Create((Get-FunctionSource -Path $setup -Name $fn)))
}
# Ground truth from the real vswhere (independent of our code), for visibility.
$vsw = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
if (Test-Path $vsw) {
$year = (& $vsw -latest -property catalog_productLineVersion 2>$null | Select-Object -First 1)
$path = (& $vsw -latest -property installationPath 2>$null | Select-Object -First 1)
Write-Host "Real vswhere: productLineVersion='$year' installPath='$path'"
} else {
Write-Host "vswhere not present at $vsw (relying on filesystem fallback)"
}
# Our detection must find the real VS and report the expected generator.
$r = Find-VsBuildTools
if (-not $r) { throw "Find-VsBuildTools returned null on a host with real $env:EXPECT_GEN" }
Write-Host "Find-VsBuildTools -> Generator='$($r.Generator)' Source='$($r.Source)' InstallPath='$($r.InstallPath)'"
if ($r.Generator -ne $env:EXPECT_GEN) {
throw "Detection mismatch: got '$($r.Generator)', expected '$env:EXPECT_GEN'"
}
if (-not (Test-Path $r.InstallPath)) { throw "Detected InstallPath does not exist: $($r.InstallPath)" }
# Toolset path derivation must match the expected v-number...
$bc = Get-VcBuildCustomizationsDir -VsInstallPath $r.InstallPath -Generator $r.Generator
$derived = Split-Path (Split-Path $bc -Parent) -Leaf # e.g. v170 / v180
Write-Host "Get-VcBuildCustomizationsDir -> '$bc' (toolset='$derived')"
if ($derived -ne $env:EXPECT_TOOLSET) {
throw "Toolset mismatch: derived '$derived', expected '$env:EXPECT_TOOLSET'"
}
# ...and that v-number is a real folder on the VS install (where CUDA's
# BuildCustomizations would land).
$vcRoot = Join-Path $r.InstallPath 'MSBuild\Microsoft\VC'
if (Test-Path $vcRoot) {
$realToolsets = @((Get-ChildItem -Path $vcRoot -Directory -ErrorAction SilentlyContinue).Name)
Write-Host "Real VC toolset dirs: $($realToolsets -join ', ')"
if ($realToolsets -notcontains $derived) {
throw "Derived toolset '$derived' is not present on the real $env:EXPECT_GEN install (have: $($realToolsets -join ', '))"
}
Write-Host "OK: toolset '$derived' exists on the real VS install."
} else {
Write-Warning "VC MSBuild root absent ($vcRoot) - C++ workload not installed; skipping on-disk toolset check."
}
Write-Host "PASS: real $env:EXPECT_GEN detected correctly with toolset '$derived'."
vcredist-clean-box:
# Validate Test-VCRedistInstalled + Ensure-VCRedist on a throwaway runner:
# present on the stock image, fires on a clean box (signals removed restorably),
# then a literal uninstall/reinstall round trip. Always restored before the end.
name: VC++ runtime detect + install round-trip (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [windows-latest, windows-2025-vs2026]
runs-on: ${{ matrix.os }}
timeout-minutes: 20
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Detect present, fire on a clean box, and round-trip the install
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
. (Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1/Get-FunctionSource.ps1')
$setup = Join-Path $env:GITHUB_WORKSPACE 'studio/setup.ps1'
# Dot-source the guard + the logging closure it reaches
# (step/substep -> Write-StudioStdoutMirror / Get-StudioAnsi).
$script:StudioVtOk = $false
$script:UnslothVerbose = $false
foreach ($fn in @('Get-StudioAnsi', 'Write-StudioStdoutMirror', 'step', 'substep',
'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" }
. ([scriptblock]::Create($src))
}
$regKeys = @(
'HKLM\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64',
'HKLM\SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\x64'
)
function Show-GroundTruth {
$dll = Join-Path $env:SystemRoot 'System32\vcruntime140_1.dll'
Write-Host (" System32\vcruntime140_1.dll present: {0}" -f (Test-Path $dll))
foreach ($k in $regKeys) {
$r = Get-ItemProperty -Path "HKLM:\$($k.Substring(5))" -ErrorAction SilentlyContinue
if ($r) { Write-Host (" {0}: Installed={1} {2}.{3}" -f $k, $r.Installed, $r.Major, $r.Minor) }
else { Write-Host (" {0}: (absent)" -f $k) }
}
}
Write-Host '== A. Detection on the stock runner (expect present) =='
Show-GroundTruth
if (-not (Test-VCRedistInstalled)) { throw 'Test-VCRedistInstalled reported ABSENT on a stock runner that ships the VC++ runtime (detection regression).' }
Write-Host ' Test-VCRedistInstalled -> present OK'
Write-Host '== B. Genuinely clean box (restorable): detection must FIRE =='
$scratch = Join-Path $env:RUNNER_TEMP 'cleanwin'
New-Item -ItemType Directory -Force -Path (Join-Path $scratch 'System32') | Out-Null
$backup = Join-Path $env:RUNNER_TEMP 'vcreg_backup'
New-Item -ItemType Directory -Force -Path $backup | Out-Null
$origSysRoot = $env:SystemRoot
try {
for ($i = 0; $i -lt $regKeys.Count; $i++) {
reg query $regKeys[$i] *> $null
if ($LASTEXITCODE -eq 0) {
reg export $regKeys[$i] (Join-Path $backup "$i.reg") /y *> $null
reg delete $regKeys[$i] /f *> $null
}
}
$env:SystemRoot = $scratch
if (Test-VCRedistInstalled) { throw 'Detection still PRESENT after both signals were removed (it would never trigger an install on a clean box).' }
Write-Host ' Test-VCRedistInstalled -> absent OK (detection fires on a clean box)'
} finally {
$env:SystemRoot = $origSysRoot
for ($i = 0; $i -lt $regKeys.Count; $i++) {
$f = Join-Path $backup "$i.reg"
if (Test-Path $f) { reg import $f *> $null }
}
}
Show-GroundTruth
if (-not (Test-VCRedistInstalled)) { throw 'Detection did not recover after restoring the registry (test restore bug).' }
Write-Host '== C. Literal uninstall on this throwaway VM (official installer), observe detection =='
$exe = Join-Path $env:RUNNER_TEMP 'vc_redist.x64.exe'
Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vc_redist.x64.exe' -OutFile $exe
Start-Process -FilePath $exe -ArgumentList '/uninstall', '/quiet', '/norestart' -Wait
Show-GroundTruth
Write-Host (" Test-VCRedistInstalled after uninstall -> {0}" -f (Test-VCRedistInstalled))
if (Test-VCRedistInstalled) {
Write-Host ' Note: the Visual Studio on this image ref-counts the runtime, so the package'
Write-Host ' uninstall is a no-op here; section B already proved detection on a clean box.'
}
Write-Host '== D. Restore via Ensure-VCRedist (winget product path), installer fallback if needed =='
Ensure-VCRedist
if (-not (Test-VCRedistInstalled)) {
Write-Host ' winget path did not restore it; using the official installer to close the round trip.'
Start-Process -FilePath $exe -ArgumentList '/install', '/quiet', '/norestart' -Wait
}
Show-GroundTruth
if (-not (Test-VCRedistInstalled)) { throw 'VC++ runtime could not be restored after the uninstall round-trip.' }
Write-Host ' Test-VCRedistInstalled -> present OK'
Write-Host 'PASS: detection is correct on a real install, fires on a clean box, and the install round-trip restores the runtime.'

View file

@ -91,7 +91,8 @@ jobs:
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache
@ -136,6 +137,17 @@ jobs:
}
}
- name: Seed a legacy launch-studio.vbs (upgrade-cleanup check)
# Simulate a pre-hardening install so the post-install assertion below
# proves the installer DELETES an existing launch-studio.vbs (the exact
# Kaspersky-flagged file), not merely stops generating it.
shell: pwsh
run: |
$appDir = Join-Path $env:LOCALAPPDATA 'Unsloth Studio'
New-Item -ItemType Directory -Force -Path $appDir | Out-Null
Set-Content -LiteralPath (Join-Path $appDir 'launch-studio.vbs') -Value 'WScript.Echo "legacy"' -Encoding Unicode
Write-Host "seeded legacy launch-studio.vbs at $appDir"
- name: Install Studio (--local, --no-torch)
# install.ps1 is the supported Windows installer. install.sh
# has no Windows branch (apt-get / brew calls). The PS1
@ -144,7 +156,8 @@ jobs:
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
New-Item -ItemType Directory -Force -Path logs | Out-Null
# *>&1 redirects ALL PowerShell streams (stdout, stderr,
@ -192,6 +205,69 @@ jobs:
echo "install.ps1 installed the Windows prebuilt llama.cpp:"
cat "$INFO"
- 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
# pointing anywhere other than hidden PowerShell over launch-studio.ps1.
shell: pwsh
run: |
$appDir = Join-Path $env:LOCALAPPDATA 'Unsloth Studio'
if (Test-Path -LiteralPath (Join-Path $appDir 'launch-studio.vbs')) {
throw "regression: launch-studio.vbs exists (the Kaspersky VBS-FP shape)"
}
if (-not (Test-Path -LiteralPath (Join-Path $appDir 'launch-studio.ps1'))) {
throw "missing launch-studio.ps1 in $appDir"
}
$lnk = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Unsloth Studio.lnk'
if (-not (Test-Path -LiteralPath $lnk)) {
$lnk = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\Unsloth Studio.lnk'
}
if (-not (Test-Path -LiteralPath $lnk)) { throw "no Unsloth Studio.lnk on Desktop or Start Menu" }
$sc = (New-Object -ComObject WScript.Shell).CreateShortcut($lnk)
Write-Host "shortcut target: $($sc.TargetPath)"
Write-Host "shortcut args: $($sc.Arguments)"
if ($sc.TargetPath -match 'wscript\.exe$') { throw "shortcut still targets wscript.exe (VBS host)" }
if ($sc.TargetPath -notmatch 'powershell\.exe$') { throw "unexpected shortcut target: $($sc.TargetPath)" }
if ($sc.Arguments -notmatch '-WindowStyle Hidden') {
throw "shortcut must launch windowless (-WindowStyle Hidden)"
}
Write-Host "launcher chain OK (no VBS; hidden powershell over launch-studio.ps1)"
- name: Launch 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.
# Default port range is 8888-8908; the later UI tests use 18896/18897, so
# there is no conflict, and we tear this server down before they boot.
shell: pwsh
run: |
$lnk = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Unsloth Studio.lnk'
if (-not (Test-Path -LiteralPath $lnk)) {
$lnk = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\Unsloth Studio.lnk'
}
$sc = (New-Object -ComObject WScript.Shell).CreateShortcut($lnk)
Write-Host "launching: $($sc.TargetPath) $($sc.Arguments)"
Start-Process -FilePath $sc.TargetPath -ArgumentList $sc.Arguments -WorkingDirectory $sc.WorkingDirectory
$foundPort = 0
foreach ($i in 1..180) {
foreach ($port in 8888..8908) {
try {
$r = Invoke-RestMethod -Uri "http://127.0.0.1:$port/api/health" -TimeoutSec 1
if ($r.status -eq 'healthy' -and $r.service -eq 'Unsloth UI Backend') { $foundPort = $port; break }
} catch {}
}
if ($foundPort) { break }
Start-Sleep -Seconds 1
}
# Tear down the shortcut-launched server before the main UI tests boot.
try {
$owner = (Get-NetTCPConnection -LocalPort $foundPort -State Listen -ErrorAction Stop | Select-Object -First 1).OwningProcess
if ($owner) { taskkill /PID $owner /T /F 2>$null | Out-Null }
} catch {}
if (-not $foundPort) { throw "Studio did not become healthy when launched via the shortcut" }
Write-Host "Studio healthy on port $foundPort (launched via the shortcut)"
- 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.

View file

@ -79,15 +79,15 @@ jobs:
# Two surgical fixes against measured Windows-only install
# waste (vs Mac/Linux on the same SHA):
#
# (1) npm. setup.ps1 line 1109-1145 requires Node 22.12+ (or
# 20.19+ / 23+) AND npm >=11 because Vite 8 needs both.
# (1) npm. setup.ps1's Get-NodeDecision requires Node 22.12+
# (or 20.19+ / 23+) AND npm >=11 because Vite 8 needs both.
# actions/setup-node@v4 with `node-version: '22'` lands
# Node 22.22.2 + the npm 10.9.7 it bundles, so the npm
# check fails and setup.ps1 falls through to the
# "winget install Node.js LTS" branch -- a ~35 s reinstall
# of Node we don't need. `npm install -g npm@^11` updates
# the bundled npm in-place in ~5 s, which makes setup.ps1
# short-circuit on the existing Node.
# Node 22.22.2 + the npm 10.9.7 it bundles, so the decision
# is "bundled" and setup.ps1 downloads an isolated Node (~30
# MB) we don't need on a runner that already has a fine Node.
# `npm install -g npm@^11` updates the runner's npm in-place
# in ~5 s, flipping the decision to "system" so setup.ps1
# reuses the existing Node with no download.
#
# (2) Defender. windows-latest's real-time scan opens / hashes
# every file Studio writes during install (Vite output =
@ -133,7 +133,8 @@ jobs:
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
New-Item -ItemType Directory -Force -Path logs | Out-Null
# *>&1 captures Write-Host (Information stream) output;
@ -180,7 +181,8 @@ jobs:
- name: First update should be a no-op (prebuilt already validated)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
set -o pipefail
unsloth studio update --local 2>&1 | tee logs/update.log
@ -199,7 +201,8 @@ jobs:
- name: Second update must also be a no-op
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
set -o pipefail
unsloth studio update --local 2>&1 | tee logs/update2.log

2
.gitignore vendored
View file

@ -237,3 +237,5 @@ package-lock.json
llama.cpp/
async_task_outputs/
individual_reviews/
# Stray "~" dir some tools create from a literal ~ TMPDIR; never part of the repo.
/~/

View file

@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.16
rev: v0.15.18
hooks:
- id: ruff
args:

View file

@ -86,6 +86,8 @@ unsloth studio -p 8888
```
For cloud or global access, add `-H 0.0.0.0`. By default, Unsloth is accessible only locally.
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:
```bash
@ -162,13 +164,19 @@ Read our [guide](https://unsloth.ai/docs/get-started/fine-tuning-llms-guide). Ad
## 📥 Advanced Installation
The below advanced instructions are for Unsloth Studio. For Unsloth Core advanced installation, [view our docs](https://unsloth.ai/docs/get-started/install/pip-install#advanced-pip-installation).
#### Developer installs: macOS, Linux, WSL:
#### Developer / Nightly / Experimental installs: macOS, Linux, WSL:
The developer install builds from the `main` branch, which is the latest (nightly) source.
```bash
git clone https://github.com/unslothai/unsloth
cd unsloth
./install.sh --local
unsloth studio -p 8888
```
To install into an isolated location (its own virtual env, `auth/`, `studio.db`, cache and llama.cpp build), set `UNSLOTH_STUDIO_HOME` and pass it again at launch:
```bash
UNSLOTH_STUDIO_HOME="$PWD/.studio" ./install.sh --local
UNSLOTH_STUDIO_HOME="$PWD/.studio" unsloth studio -p 8888
```
Then to update :
```bash
cd unsloth && git pull
@ -176,7 +184,8 @@ cd unsloth && git pull
unsloth studio -p 8888
```
#### Developer installs: Windows PowerShell:
#### Developer / Nightly / Experimental installs: Windows PowerShell:
The developer install builds from the `main` branch, which is the latest (nightly) source.
```powershell
git clone https://github.com/unslothai/unsloth.git
cd unsloth
@ -184,40 +193,31 @@ Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
.\install.ps1 --local
unsloth studio -p 8888
```
To install into an isolated location (its own virtual env, `auth/`, `studio.db`, cache and llama.cpp build), set `UNSLOTH_STUDIO_HOME` and pass it again at launch:
```powershell
$env:UNSLOTH_STUDIO_HOME="$PWD\.studio"; .\install.ps1 --local
$env:UNSLOTH_STUDIO_HOME="$PWD\.studio"; unsloth studio -p 8888
```
Then to update :
```bash
cd unsloth && git pull
./install.sh --local
unsloth studio -p 8888
```
#### Nightly: MacOS, Linux, WSL:
```bash
git clone https://github.com/unslothai/unsloth
cd unsloth
git checkout nightly
./install.sh --local
unsloth studio -p 8888
```
Then to launch every time:
```bash
unsloth studio -p 8888
```
#### Nightly: Windows:
Run in Windows Powershell:
```powershell
git clone https://github.com/unslothai/unsloth.git
cd unsloth
git checkout nightly
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
cd unsloth; git pull
.\install.ps1 --local
unsloth studio -p 8888
```
Then to launch every time:
#### Remote access: `--secure` (HTTPS tunnel) vs raw port
By default `unsloth studio` binds to `127.0.0.1` (this machine only). To reach it from another device, pick one of:
- `--secure` (recommended): serve **only** through a free Cloudflare HTTPS link. 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 -p 8888
unsloth studio --secure -p 8888
```
- `-H 0.0.0.0`: bind the raw port on all network interfaces, reachable from anywhere on the network. Only use this on a trusted network.
```bash
unsloth studio -H 0.0.0.0 -p 8888
```
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`.
@ -246,6 +246,15 @@ curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_STUDIO_HOME=/abs/path sh
$env:UNSLOTH_STUDIO_HOME='C:\path'; irm https://unsloth.ai/install.ps1 | iex
```
Point the frontend build at a corporate npm mirror/proxy with `UNSLOTH_NPM_REGISTRY` (for the developer install behind a firewall that blocks `registry.npmjs.org`):
```bash
UNSLOTH_NPM_REGISTRY=https://artifactory.example.com/api/npm/npm/ ./install.sh --local
```
```powershell
$env:UNSLOTH_NPM_REGISTRY='https://artifactory.example.com/api/npm/npm/'; .\install.ps1 --local
```
It is threaded as `--registry` into the Studio frontend `npm`/`bun` installs; the supply-chain locks (7-day `min-release-age`, exact version pins) stay in force.
Cap Studio's native CPU thread pools on high-core hosts: `UNSLOTH_CPU_THREADS=8 unsloth studio -p 8888`.
#### Uninstall

View file

@ -1,4 +1,6 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
set -euo pipefail
@ -33,10 +35,19 @@ _restore_gitignores() {
}
trap _restore_gitignores EXIT
# Corporate-mirror / proxy escape hatch (#6491). When UNSLOTH_NPM_REGISTRY is set we
# thread it as `--registry <url>` into the installs (overrides frontend/.npmrc's pinned
# registry for both bun and npm; min-release-age / save-exact stay in force). Empty
# array (the default) expands to nothing under `set -u`.
_NPM_REGISTRY_ARGS=()
if [ -n "${UNSLOTH_NPM_REGISTRY:-}" ]; then
_NPM_REGISTRY_ARGS=(--registry "$UNSLOTH_NPM_REGISTRY")
fi
# Use bun for install if available (faster), fall back to npm.
_install_ok=false
if command -v bun &>/dev/null; then
if bun install; then
if bun install "${_NPM_REGISTRY_ARGS[@]+"${_NPM_REGISTRY_ARGS[@]}"}"; then
_install_ok=true
else
echo "⚠ bun install failed, falling back to npm"
@ -44,8 +55,10 @@ if command -v bun &>/dev/null; then
fi
fi
if [ "$_install_ok" != "true" ]; then
if ! npm install; then
if ! npm install "${_NPM_REGISTRY_ARGS[@]+"${_NPM_REGISTRY_ARGS[@]}"}"; then
echo "❌ ERROR: package install failed" >&2
echo " If you are behind a corporate firewall/proxy, set UNSLOTH_NPM_REGISTRY to your mirror and retry, e.g.:" >&2
echo " UNSLOTH_NPM_REGISTRY=https://your-mirror.example/api/npm/ ./build.sh" >&2
exit 1
fi
fi

View file

@ -482,6 +482,37 @@ function Install-UnslothStudio {
}
}
# Retry Invoke-InstallCommand on transient uv download failures with backoff.
# Returns the last exit code on permanent failure so rollback still fires.
function Invoke-InstallCommandRetry {
param(
[Parameter(Mandatory = $true, Position = 0)][ScriptBlock]$Command,
[string]$Label = "install step"
)
# Sanitize overrides to a default of 3 (a typo must not disable retries; =1 disables).
# TryParse with bounds avoids an Int32 overflow throw. Bounds: 1..100 retries, 0..3600s.
$maxAttempts = 3
$parsedAttempts = 0
if ([int]::TryParse($env:UNSLOTH_INSTALL_RETRIES, [ref]$parsedAttempts) -and $parsedAttempts -ge 1 -and $parsedAttempts -le 100) {
$maxAttempts = $parsedAttempts
}
$delay = 3
$parsedDelay = 0
if ([int]::TryParse($env:UNSLOTH_INSTALL_RETRY_DELAY, [ref]$parsedDelay) -and $parsedDelay -ge 0 -and $parsedDelay -le 3600) {
$delay = $parsedDelay
}
$attempt = 1
while ($true) {
$code = Invoke-InstallCommand $Command
if ($code -eq 0) { return 0 }
if ($attempt -ge $maxAttempts) { return $code }
substep ("retrying ""$Label"" after transient failure (attempt $($attempt + 1)/$maxAttempts, waiting ${delay}s)...") "Yellow"
Start-Sleep -Seconds $delay
$attempt++
$delay = $delay * 2
}
}
function New-StudioShortcuts {
param(
[Parameter(Mandatory = $true)][string]$UnslothExePath
@ -506,7 +537,6 @@ function Install-UnslothStudio {
}
$appDir = $StudioDataDir
$launcherPs1 = Join-Path $appDir "launch-studio.ps1"
$launcherVbs = Join-Path $appDir "launch-studio.vbs"
$desktopDir = [Environment]::GetFolderPath("Desktop")
$desktopLink = if ($desktopDir -and $desktopDir.Trim()) {
Join-Path $desktopDir "Unsloth Studio.lnk"
@ -799,19 +829,30 @@ exit 0
# even when install.ps1 is executed from PowerShell 7.
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
[System.IO.File]::WriteAllText($launcherPs1, $launcherContent, $utf8Bom)
# shell.Run(cmd, 0, ...) already hides the window, so -WindowStyle Hidden
# is redundant; omitting it trims an AV-heuristic token (Kaspersky FP).
$vbsContent = @"
Set shell = CreateObject("WScript.Shell")
cmd = "powershell -NoProfile -ExecutionPolicy Bypass -File ""$launcherPs1"""
shell.Run cmd, 0, False
"@
# WSH handles UTF-16LE reliably for .vbs files with non-ASCII paths.
Set-Content -LiteralPath $launcherVbs -Value $vbsContent -Encoding Unicode -Force
# No .vbs launcher is written. A WScript.Shell .vbs that spawns a hidden
# ExecutionPolicy-Bypass PowerShell is exactly the shape VBS-dropper
# heuristics score (e.g. Kaspersky HEUR:Trojan.VBS.Agent.gen). The .lnk
# shortcuts instead point straight at powershell.exe running
# launch-studio.ps1 with a hidden window (selected below).
# Delete any launch-studio.vbs left by a pre-hardening install. New
# installs no longer generate it, but an upgrade that merely stopped
# generating it would leave the exact file AV flags on disk, so remove
# it explicitly. Covers default and env-mode installs (same $appDir).
$legacyLauncherVbs = Join-Path $appDir "launch-studio.vbs"
if (Test-Path -LiteralPath $legacyLauncherVbs) {
Remove-Item -LiteralPath $legacyLauncherVbs -Force -ErrorAction SilentlyContinue
}
# Prefer bundled icon from local clone/dev installs.
# If not available, best-effort download from raw GitHub.
# We only attach the icon if the resulting file has a valid ICO header.
# Snapshot the existing icon first so we can tell whether it actually
# changed and gate the heavier icon-cache refresh on a real change.
$preIconHash = $null
if (Test-Path -LiteralPath $iconPath) {
try { $preIconHash = (Get-FileHash -LiteralPath $iconPath -Algorithm SHA256).Hash } catch {}
}
$hasValidIcon = $false
if ($bundledIcon -and (Test-Path -LiteralPath $bundledIcon)) {
try {
@ -847,6 +888,24 @@ shell.Run cmd, 0, False
}
}
# Did the icon content actually change vs the previous install?
# Only a real change (or a first/removed icon) should trigger the heavy
# refresh; a no-op reinstall with no icon at all must not.
$iconChanged = $false
if ($hasValidIcon) {
if (-not $preIconHash) {
$iconChanged = $true
} else {
try {
$postIconHash = (Get-FileHash -LiteralPath $iconPath -Algorithm SHA256).Hash
$iconChanged = ($postIconHash -ne $preIconHash)
} catch { $iconChanged = $true }
}
} elseif ($preIconHash) {
# A previously present icon was removed or invalidated.
$iconChanged = $true
}
# Env-mode: skip persistent Desktop / Start Menu .lnk shortcuts
# that may point at a deleted workspace; launcher + icon stay.
if ($StudioRedirectMode -eq 'env') {
@ -854,8 +913,22 @@ shell.Run cmd, 0, False
return
}
$wscriptExe = Join-Path $env:SystemRoot "System32\wscript.exe"
$shortcutArgs = "//B //Nologo `"$launcherVbs`""
# Whether this is effectively a first install (no pre-existing .lnk).
# Used to gate the heavier icon-cache refresh below so a no-op reinstall
# does not repeatedly clear caches / restart StartMenuExperienceHost --
# a behavioral cluster AV heuristics can score as dropper-like.
$firstInstall = -not (
($desktopLink -and (Test-Path -LiteralPath $desktopLink)) -or
($startMenuLink -and (Test-Path -LiteralPath $startMenuLink))
)
# Launch transport for the shortcuts: powershell.exe runs
# launch-studio.ps1 with a hidden window. We deliberately avoid a
# .vbs/WScript.Shell wrapper -- that script-engine shape is what AV
# VBS-dropper heuristics score (Kaspersky HEUR:Trojan.VBS.Agent.gen).
$powershellForLnk = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe"
$shortcutTarget = $powershellForLnk
$shortcutArgs = "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$launcherPs1`""
try {
$wshell = New-Object -ComObject WScript.Shell
@ -865,9 +938,11 @@ shell.Run cmd, 0, False
if (-not $linkPath -or [string]::IsNullOrWhiteSpace($linkPath)) { continue }
try {
$shortcut = $wshell.CreateShortcut($linkPath)
$shortcut.TargetPath = $wscriptExe
$shortcut.TargetPath = $shortcutTarget
$shortcut.Arguments = $shortcutArgs
$shortcut.WorkingDirectory = $appDir
# Start minimized so the brief PowerShell console flash is muted.
$shortcut.WindowStyle = 7
$shortcut.Description = "Launch Unsloth Studio"
if ($hasValidIcon) {
$shortcut.IconLocation = "$iconPath,0"
@ -881,15 +956,13 @@ shell.Run cmd, 0, False
}
if ($createdShortcutCount -gt 0) {
substep "Created Unsloth Studio shortcut"
# Force Explorer to re-read each new shortcut's icon so it renders
# immediately instead of a stale/generic entry (a same-name .lnk
# recreated across reinstalls keeps Explorer's cached per-item icon).
# The reliable, non-disruptive fix (no explorer restart) is a per-item
# SHChangeNotify SHCNE_UPDATEITEM + SHCNF_PATHW per .lnk; the global
# SHCNE_ASSOCCHANGED broadcast alone does NOT recover a stale item.
# Also clear the on-disk icon cache (covers heavier staleness).
try { & "$env:SystemRoot\System32\ie4uinit.exe" -ClearIconCache 2>$null } catch {}
try { & "$env:SystemRoot\System32\ie4uinit.exe" -show 2>$null } catch {}
# Always do the cheap, non-disruptive per-item refresh so a
# rewritten same-name .lnk renders with its new target/icon
# immediately (a same-name .lnk recreated across reinstalls keeps
# Explorer's cached per-item icon). The reliable fix (no explorer
# restart) is a per-item SHChangeNotify SHCNE_UPDATEITEM +
# SHCNF_PATHW per .lnk; the global SHCNE_ASSOCCHANGED broadcast
# alone does NOT recover a stale item.
try {
Add-Type -Namespace UnslothShell -Name IconRefresh -MemberDefinition '[System.Runtime.InteropServices.DllImport("shell32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)] public static extern void SHChangeNotify(int eventId, uint flags, string item1, System.IntPtr item2);' -ErrorAction SilentlyContinue
# SHCNE_UPDATEITEM (0x00002000) + SHCNF_PATHW (0x0005) per shortcut
@ -899,21 +972,31 @@ shell.Run cmd, 0, False
# SHCNE_ASSOCCHANGED (0x08000000) global refresh (belt-and-suspenders)
[UnslothShell.IconRefresh]::SHChangeNotify(0x08000000, 0, $null, [System.IntPtr]::Zero)
} catch {}
# Win11's Start Menu (StartMenuExperienceHost) keeps its OWN
# pre-rendered tile-icon cache that ie4uinit/explorer restart do NOT
# invalidate, so a rewritten same-name shortcut shows the old tile
# until the host restarts. Drop only the render caches (NEVER
# start2.bin -- the pinned layout) and let the host rebuild.
# Best-effort; Win10 has no such host (Test-Path skips it).
try {
$smehTemp = Join-Path $env:LOCALAPPDATA "Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\TempState"
if (Test-Path -LiteralPath $smehTemp) {
Get-ChildItem -LiteralPath $smehTemp -Filter "TileCache_*" -ErrorAction SilentlyContinue |
Remove-Item -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath (Join-Path $smehTemp "StartUnifiedTileModelCache.dat") -Force -ErrorAction SilentlyContinue
Stop-Process -Name StartMenuExperienceHost -Force -ErrorAction SilentlyContinue
}
} catch {}
# Heavier on-disk icon-cache clear + StartMenuExperienceHost tile
# rebuild only when the icon actually changed or this is a first
# install. Running "clear icon cache + kill StartMenuExperienceHost"
# on every no-op reinstall is a dropper-like behavioral cluster and
# is unnecessary when the icon is unchanged (the per-item notify
# above already refreshes the rewritten shortcut).
if ($firstInstall -or $iconChanged) {
try { & "$env:SystemRoot\System32\ie4uinit.exe" -ClearIconCache 2>$null } catch {}
try { & "$env:SystemRoot\System32\ie4uinit.exe" -show 2>$null } catch {}
# Win11's Start Menu (StartMenuExperienceHost) keeps its OWN
# pre-rendered tile-icon cache that ie4uinit/explorer restart do NOT
# invalidate, so a rewritten same-name shortcut shows the old tile
# until the host restarts. Drop only the render caches (NEVER
# start2.bin -- the pinned layout) and let the host rebuild.
# Best-effort; Win10 has no such host (Test-Path skips it).
try {
$smehTemp = Join-Path $env:LOCALAPPDATA "Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\TempState"
if (Test-Path -LiteralPath $smehTemp) {
Get-ChildItem -LiteralPath $smehTemp -Filter "TileCache_*" -ErrorAction SilentlyContinue |
Remove-Item -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath (Join-Path $smehTemp "StartUnifiedTileModelCache.dat") -Force -ErrorAction SilentlyContinue
Stop-Process -Name StartMenuExperienceHost -Force -ErrorAction SilentlyContinue
}
} catch {}
}
} else {
substep "no Unsloth Studio shortcuts were created" "Yellow"
}
@ -1181,7 +1264,7 @@ shell.Run cmd, 0, False
# ── Install uv ──
Write-TauriLog "STEP" "Installing uv package manager"
$UvMinVersion = "0.7.22"
$UvMinVersion = "0.8.16"
function Test-UvVersionOk {
$cmd = Get-Command uv -ErrorAction SilentlyContinue
if (-not $cmd) { return $false }
@ -1252,6 +1335,15 @@ shell.Run cmd, 0, False
$env:UV_COMPILE_BYTECODE_TIMEOUT = "180"
}
# uv >= 0.8.16 retries HTTP/2 streaming body errors; raise retries and read
# timeout for large wheel downloads. User-provided values are preserved.
if (-not $env:UV_HTTP_RETRIES) {
$env:UV_HTTP_RETRIES = "5"
}
if (-not $env:UV_HTTP_TIMEOUT) {
$env:UV_HTTP_TIMEOUT = "180"
}
# ── Create venv (migrate old layout if possible, otherwise fresh) ──
# Pass the resolved executable path to uv so it does not re-resolve
# a version string back to a conda interpreter.
@ -1531,29 +1623,87 @@ shell.Run cmd, 0, False
if (-not $HasNvidiaSmi) {
# hipinfo: PATH first, then HIP_PATH/ROCM_PATH bin fallback (mirrors NVIDIA smi path resolution).
# AMD HIP SDK sets HIP_PATH but may not add the bin dir to PATH depending on install type.
$hipinfoExe = Get-Command hipinfo -ErrorAction SilentlyContinue
if (-not $hipinfoExe) {
$hipRoot = if ($env:HIP_PATH) { $env:HIP_PATH } elseif ($env:ROCM_PATH) { $env:ROCM_PATH } else { $null }
$hipEnvLabel = if ($env:HIP_PATH) { "HIP_PATH" } else { "ROCM_PATH" }
if ($hipRoot) {
$hipinfoCandidate = Join-Path $hipRoot "bin\hipinfo.exe"
if (Test-Path $hipinfoCandidate) {
Write-Host " [WARN] hipinfo not on PATH -- located via ${hipEnvLabel}: $hipinfoCandidate" -ForegroundColor Yellow
Write-Host " Add '$(Join-Path $hipRoot 'bin')' to your PATH to suppress this warning" -ForegroundColor Yellow
Write-Host " Quick fix: [Environment]::SetEnvironmentVariable('PATH',`$env:PATH+';$(Join-Path $hipRoot 'bin')','User')" -ForegroundColor Yellow
$hipinfoExe = [PSCustomObject]@{ Source = $hipinfoCandidate }
} else {
Write-Host " [WARN] ${hipEnvLabel}=$hipRoot is set but hipinfo.exe not found at $hipinfoCandidate" -ForegroundColor Yellow
Write-Host " HIP SDK install may be incomplete -- re-install from:" -ForegroundColor Yellow
Write-Host " https://rocm.docs.amd.com/en/latest/deploy/windows/index.html" -ForegroundColor Yellow
# Ignore the venv hipInfo.exe (AMD wheel, on PATH): not a HIP SDK, so
# amd-smi would still auto-elevate. Cf. _path_inside_venv().
function Test-HipinfoIsVenvInternal {
param([AllowNull()][string]$HipinfoPath)
if ([string]::IsNullOrWhiteSpace($HipinfoPath)) { return $false }
# Also derive the venv from the setup python + default Studio home, so
# the venv hipInfo is caught when VenvDir/VIRTUAL_ENV are unset.
$venvRoots = @()
if ($env:VIRTUAL_ENV) { $venvRoots += $env:VIRTUAL_ENV }
$vd = Get-Variable -Name VenvDir -ValueOnly -ErrorAction SilentlyContinue
if ($vd) { $venvRoots += $vd }
if ($env:UNSLOTH_SETUP_PYTHON) {
try { $venvRoots += (Split-Path -Parent (Split-Path -Parent $env:UNSLOTH_SETUP_PYTHON)) } catch {}
}
if ($env:USERPROFILE) { $venvRoots += (Join-Path $env:USERPROFILE ".unsloth\studio\unsloth_studio") }
# A custom 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) {
# Expand a leading ~ like the canonical resolver; else GetFullPath
# keeps the literal ~ (cwd-relative) and the hipInfo escapes the filter.
if (($studioHomeEnv -eq "~" -or $studioHomeEnv -like "~/*" -or $studioHomeEnv -like "~\*") -and -not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) {
# A bare "~" leaves an empty child path; Join-Path rejects that on
# PS 5.1, so use USERPROFILE directly and only join a real remainder.
$studioHomeRest = $studioHomeEnv.Substring(1).TrimStart('/', '\')
$studioHomeEnv = if ($studioHomeRest) { Join-Path $env:USERPROFILE $studioHomeRest } else { $env:USERPROFILE }
}
$venvRoots += (Join-Path $studioHomeEnv "unsloth_studio")
}
try { $hip = [System.IO.Path]::GetFullPath($HipinfoPath).TrimEnd('\', '/') } catch { return $false }
foreach ($root in $venvRoots) {
if ([string]::IsNullOrWhiteSpace($root)) { continue }
try { $r = [System.IO.Path]::GetFullPath($root).TrimEnd('\', '/') } catch { continue }
# Skip a bare drive root (e.g. a non-venv UNSLOTH_SETUP_PYTHON like
# C:\Python311\python.exe yields C:) -- it would match every path on that drive.
if ($r -match '^[a-zA-Z]:$') { continue }
if ($hip.Equals($r, [System.StringComparison]::OrdinalIgnoreCase) -or
$hip.StartsWith($r + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) {
return $true
}
}
return $false
}
# Scan all hipinfo and keep the first non-venv one (the venv copy from the
# bnb fix could shadow a real HIP SDK's). -CommandType Application matches
# only real executables, not a user alias/function named hipinfo.
$hipinfoExe = Get-Command hipinfo -CommandType Application -All -ErrorAction SilentlyContinue |
Where-Object { -not (Test-HipinfoIsVenvInternal $_.Source) } |
Select-Object -First 1
if (-not $hipinfoExe) {
# Iterate the env roots (mirrors the Python list) and take the first non-venv
# bin\hipinfo.exe, so a venv-internal HIP_PATH can't mask a real SDK in ROCM_PATH.
$hipMissingLabel = $null; $hipMissingRoot = $null; $hipMissingCandidate = $null
foreach ($hipEnvLabel in @("HIP_PATH", "HIP_PATH_57", "ROCM_PATH")) {
$hipRoot = [Environment]::GetEnvironmentVariable($hipEnvLabel)
if ([string]::IsNullOrWhiteSpace($hipRoot)) { continue }
$hipinfoCandidate = Join-Path $hipRoot "bin\hipinfo.exe"
if (-not (Test-Path $hipinfoCandidate)) {
if (-not $hipMissingLabel) { $hipMissingLabel = $hipEnvLabel; $hipMissingRoot = $hipRoot; $hipMissingCandidate = $hipinfoCandidate }
continue
}
if (Test-HipinfoIsVenvInternal $hipinfoCandidate) { continue } # venv copy (AMD wheel): not a HIP SDK
Write-Host " [WARN] hipinfo not on PATH -- located via ${hipEnvLabel}: $hipinfoCandidate" -ForegroundColor Yellow
Write-Host " Add '$(Join-Path $hipRoot 'bin')' to your PATH to suppress this warning" -ForegroundColor Yellow
Write-Host " Quick fix: [Environment]::SetEnvironmentVariable('PATH',`$env:PATH+';$(Join-Path $hipRoot 'bin')','User')" -ForegroundColor Yellow
$hipinfoExe = [PSCustomObject]@{ Source = $hipinfoCandidate }
break
}
if ((-not $hipinfoExe) -and $hipMissingLabel) {
Write-Host " [WARN] ${hipMissingLabel}=$hipMissingRoot is set but hipinfo.exe not found at $hipMissingCandidate" -ForegroundColor Yellow
Write-Host " HIP SDK install may be incomplete -- re-install from:" -ForegroundColor Yellow
Write-Host " https://rocm.docs.amd.com/en/latest/deploy/windows/index.html" -ForegroundColor Yellow
}
}
if ($hipinfoExe) {
$HipSdkInstalled = $true # binary found → SDK is installed regardless of device state
try {
$hipOut = & $hipinfoExe.Source 2>&1 | Out-String
if ($LASTEXITCODE -eq 0 -and $hipOut -match "(?i)gcnArchName") {
if ($hipOut -match "(?i)gcnArchName") {
# hipinfo can crash after printing gcnArchName (#6043).
# Once the arch is printed, keep the ROCm wheel path.
$HasROCm = $true
$_hipAllArches = @([regex]::Matches($hipOut, "(?im)^\s*gcnArchName\s*:\s*(\S+)") | ForEach-Object { ($_.Groups[1].Value -split ':')[0].Trim().ToLower() })
$_hipVisIdx = if ($env:HIP_VISIBLE_DEVICES -match '^\d') { [int]($env:HIP_VISIBLE_DEVICES -split ',')[0] } elseif ($env:ROCR_VISIBLE_DEVICES -match '^\d') { [int]($env:ROCR_VISIBLE_DEVICES -split ',')[0] } else { 0 }
@ -1563,8 +1713,13 @@ shell.Run cmd, 0, False
} else {
$ROCmGpuLabel = "AMD ROCm"
}
if ($LASTEXITCODE -ne 0) {
Write-Host " [INFO] hipinfo exited with code $LASTEXITCODE but reported gcnArchName -- treating as ROCm-capable (see #6043)" -ForegroundColor Cyan
}
} elseif ($LASTEXITCODE -ne 0) {
# hipinfo ran but returned a HIP runtime error (e.g. "no ROCm-capable device detected")
# hipinfo ran but returned a HIP runtime error without any gcnArchName
# output (e.g. "no ROCm-capable device detected"), or crashed before
# printing device info.
$firstLine = ($hipOut -split '\r?\n' | Where-Object { $_.Trim() } | Select-Object -First 1)
Write-Host " [WARN] hipinfo returned a HIP runtime error (exit $LASTEXITCODE)" -ForegroundColor Yellow
Write-Host " $firstLine" -ForegroundColor Yellow
@ -1625,11 +1780,10 @@ shell.Run cmd, 0, False
} catch {}
}
# ── Arch resolution: env-var override → name inference ──────────────
# Runs even when the hipinfo/amd-smi probe could NOT confirm a runtime
# ($HasROCm false): the gfx arch inferred from the WMI GPU name lets the
# studio setup forward --rocm-gfx and pull a GPU-accelerated ROCm
# llama.cpp, which bundles its own ROCm runtime. PyTorch's ROCm wheels
# still require a confirmed HIP SDK -- they stay gated on $HasROCm below.
# Runs even when the probe can't confirm a runtime ($HasROCm false): the
# WMI-name gfx arch drives both ROCm llama.cpp and torch. repo.amd.com
# wheels bundle their own runtime (no HIP SDK), so a mapped arch installs
# ROCm torch directly below -- no wasted CPU base.
if (-not $ROCmGfxArch) {
# 1. Manual override: set UNSLOTH_ROCM_GFX_ARCH=gfx1151 before running.
if ($env:UNSLOTH_ROCM_GFX_ARCH) {
@ -1809,6 +1963,64 @@ shell.Run cmd, 0, False
substep "could not determine CUDA version from nvidia-smi, defaulting to cu126" "Yellow"
return "$baseUrl/cu126"
}
# ── Torch flavor helpers (to repair a stale CPU / wrong-CUDA wheel) ──
# torch.__version__ -> flavor tag (cuXXX / rocm / cpu); untagged wheel = cpu,
# matching setup.ps1's stale-venv parse.
function ConvertTo-TorchFlavorTag {
param([string]$TorchVersion)
if (-not $TorchVersion) { return $null }
if ($TorchVersion -match '\+(cu\d+)') { return $Matches[1] }
if ($TorchVersion -match '\+rocm') { return 'rocm' }
if ($TorchVersion -match '\+cpu') { return 'cpu' }
return 'cpu'
}
# Expected tag from the index leaf: cuXXX / cpu / rocm ($ROCmIndexUrl or a
# gfx* leaf -> rocm). $null on an unknown leaf (odd mirror) so repair no-ops.
function Get-ExpectedTorchFlavorTag {
param([string]$TorchIndexUrl, [string]$ROCmIndexUrl)
if (-not [string]::IsNullOrWhiteSpace($ROCmIndexUrl)) { return 'rocm' }
if ([string]::IsNullOrWhiteSpace($TorchIndexUrl)) { return $null }
$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' }
if ($leaf -match '^gfx') { return 'rocm' }
return $null
}
# Installed torch flavor tag in $PythonExe's venv, or $null if absent. Uses
# ProcessStartInfo (not &) so stderr doesn't trip $ErrorActionPreference.
function Get-InstalledTorchTag {
param([string]$PythonExe)
if (-not $PythonExe -or -not (Test-Path -LiteralPath $PythonExe)) { return $null }
try {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $PythonExe
$psi.Arguments = '-c "import torch; print(torch.__version__)"'
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($psi)
# Drain BOTH streams async, then WaitForExit. A synchronous ReadToEnd()
# before the wait would block forever if a wedged "import torch" never
# closes stdout; leaving the redirected stderr undrained would deadlock a
# child that floods it past the pipe buffer. Async reads let a noisy-but-
# exiting probe finish, while a truly hung one still hits the 30s timeout
# and is killed -- bounded either way.
$outTask = $proc.StandardOutput.ReadToEndAsync()
$errTask = $proc.StandardError.ReadToEndAsync()
$finished = $proc.WaitForExit(30000)
if (-not $finished) { try { $proc.Kill() } catch {}; return $null }
$torchVer = $outTask.GetAwaiter().GetResult().Trim()
[void]$errTask.GetAwaiter().GetResult()
if ($proc.ExitCode -ne 0 -or -not $torchVer) { return $null }
return ConvertTo-TorchFlavorTag $torchVer
} catch { return $null }
}
$TorchIndexUrl = Get-TorchIndexUrl
# ── GPU arch → newest compatible Windows ROCm wheel release ──
@ -1820,7 +2032,7 @@ shell.Run cmd, 0, False
# Override with UNSLOTH_ROCM_WINDOWS_MIRROR for air-gapped / mirror installs.
$ROCmIndexUrl = $null
$ROCmTorchFloor = $null
if ($HasROCm -and $TorchIndexUrl -like "*/cpu" -and -not $SkipTorch) {
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
@ -1843,6 +2055,17 @@ shell.Run cmd, 0, False
"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"
}
# Companion ranges track the torch ceiling so pip resolves a consistent
# trio on AMD's per-arch index (each published independently). Mirrors
# setup.ps1 / install_python_stack.py; bump all three together for 2.12.x.
$torchvisionFloorMap = @{
"gfx1201" = "torchvision>=0.26.0,<0.27.0"; "gfx1200" = "torchvision>=0.26.0,<0.27.0"
"gfx1151" = "torchvision>=0.26.0,<0.27.0"; "gfx1150" = "torchvision>=0.26.0,<0.27.0"
}
$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"
}
$archFamily = if ($ROCmGfxArch -and $archFamilyMap.ContainsKey($ROCmGfxArch)) { $archFamilyMap[$ROCmGfxArch] } else { $null }
if ($archFamily) {
$ROCmIndexUrl = "$amdIndexBase/$archFamily/"
@ -1871,10 +2094,10 @@ shell.Run cmd, 0, False
if (-not $SkipTorch -and -not $ROCmIndexUrl -and $TorchIndexUrl -like "*/cpu") {
Write-Host ""
if ($ROCmGfxArch) {
# Known AMD arch: install.ps1 lays down CPU PyTorch as a base, then
# setup.ps1 swaps in AMD's bundled-runtime GPU ROCm wheels (no HIP SDK).
substep "Installing CPU PyTorch as a base -- Studio setup installs GPU ROCm" "Cyan"
substep "wheels for $ROCmGfxArch next (bundled runtime; HIP SDK not required)." "Cyan"
# Only an unmapped arch reaches here (a mapped one set $ROCmIndexUrl
# above). No ROCm torch wheels for this arch (e.g. RDNA2 gfx103X) -> CPU.
substep "Installing CPU PyTorch -- no ROCm PyTorch wheels are available for $ROCmGfxArch." "Yellow"
substep "PyTorch (training and Transformers inference) runs on CPU on this GPU." "Yellow"
} else {
if ($HipSdkInstalled -and -not $HasROCm) {
substep "Installing CPU-only PyTorch (HIP SDK found but GPU not ROCm-accessible)." "Yellow"
@ -1928,21 +2151,21 @@ shell.Run cmd, 0, False
if ($SkipTorch) {
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.7" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated no-torch)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.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
# is --no-deps). All transitive deps are torch-free.
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython pydantic }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install pydantic" { uv pip install --python $VenvPython pydantic }
}
if ($baseInstallExit -eq 0) {
$NoTorchReq = Find-NoTorchRuntimeFile
if ($NoTorchReq) {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps -r $NoTorchReq }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install no-torch runtime deps" { uv pip install --python $VenvPython --no-deps -r $NoTorchReq }
}
}
} else {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.7" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated)" { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" }
}
if ($baseInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
@ -1956,7 +2179,7 @@ shell.Run cmd, 0, False
return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit)
}
substep "overlaying unsloth-zoo from git main..."
$zooOverlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth-zoo "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" }
$zooOverlayExit = Invoke-InstallCommandRetry -Label "overlay unsloth-zoo (git main)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth-zoo "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" }
if ($zooOverlayExit -ne 0) {
Write-Host "[ERROR] Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" $zooOverlayExit)
@ -1969,15 +2192,34 @@ shell.Run cmd, 0, False
Write-TauriLog "STEP" "Installing PyTorch (AMD ROCm Windows)"
substep "installing PyTorch from $ROCmIndexUrl..."
$torchSpec = if ($ROCmTorchFloor) { $ROCmTorchFloor } else { "torch" }
$torchInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --force-reinstall --index-url $ROCmIndexUrl $torchSpec torchvision torchaudio }
# Pin the companions to match $torchSpec; bare names can resolve an
# ABI-incompatible torchvision/torchaudio on AMD's per-arch index.
$visionSpec = if ($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) {
Write-Host "[ERROR] Failed to install AMD ROCm PyTorch (exit code $torchInstallExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to install AMD ROCm PyTorch (exit code $torchInstallExit)" $torchInstallExit)
# Transient AMD-index failure: fall back to a CPU base 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 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)
}
# CPU base is in; drop the ROCm expectation so the flavor-repair
# block below won't retry the just-failed index and abort. setup.ps1
# reinstalls ROCm afterwards (recomputes its own index URL).
$ROCmIndexUrl = $null
$ROCmTorchFloor = $null
}
} else {
Write-TauriLog "STEP" "Installing PyTorch"
substep "installing PyTorch ($TorchIndexUrl)..."
$torchInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $TorchIndexUrl }
$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)
@ -1989,21 +2231,21 @@ shell.Run cmd, 0, False
if ($SkipTorch) {
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.6.7" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (no-torch)" { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" }
if ($baseInstallExit -eq 0) {
# Same pydantic-with-deps trick as the migrated branch.
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython pydantic }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install pydantic" { uv pip install --python $VenvPython pydantic }
}
if ($baseInstallExit -eq 0) {
$NoTorchReq = Find-NoTorchRuntimeFile
if ($NoTorchReq) {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps -r $NoTorchReq }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install no-torch runtime deps" { uv pip install --python $VenvPython --no-deps -r $NoTorchReq }
}
}
} elseif ($StudioLocalInstall) {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.7" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" }
} else {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth" { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" }
}
if ($baseInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
@ -2018,7 +2260,7 @@ shell.Run cmd, 0, False
return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit)
}
substep "overlaying unsloth-zoo from git main..."
$zooOverlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth-zoo "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" }
$zooOverlayExit = Invoke-InstallCommandRetry -Label "overlay unsloth-zoo (git main)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth-zoo "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" }
if ($zooOverlayExit -ne 0) {
Write-Host "[ERROR] Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" $zooOverlayExit)
@ -2029,7 +2271,7 @@ shell.Run cmd, 0, False
Write-TauriLog "STEP" "Installing unsloth"
substep "installing unsloth (this may take a few minutes)..."
if ($StudioLocalInstall) {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.6.7" --torch-backend=auto }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (auto torch backend)" { uv pip install --python $VenvPython "unsloth-zoo>=2026.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)
@ -2041,13 +2283,13 @@ shell.Run cmd, 0, False
return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit)
}
substep "overlaying unsloth-zoo from git main..."
$zooOverlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth-zoo "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" }
$zooOverlayExit = Invoke-InstallCommandRetry -Label "overlay unsloth-zoo (git main)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth-zoo "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" }
if ($zooOverlayExit -ne 0) {
Write-Host "[ERROR] Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" $zooOverlayExit)
}
} else {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --torch-backend=auto -- "$PackageName" }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (auto torch backend)" { uv pip install --python $VenvPython --torch-backend=auto -- "$PackageName" }
if ($baseInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
@ -2055,6 +2297,54 @@ shell.Run cmd, 0, False
}
}
# ── 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 --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
if ($expectedTorchTag -and $expectedTorchTag -ne 'cpu') {
$installedTorchTag = Get-InstalledTorchTag -PythonExe $VenvPython
if ($installedTorchTag -and $installedTorchTag -ne $expectedTorchTag) {
if ($expectedTorchTag -eq 'rocm' -and $ROCmIndexUrl) {
# AMD: a migrated venv can keep a stale CPU torch the fresh ROCm path
# would have force-reinstalled. Repair from the same repo.amd.com index.
$rocmSpec = if ($ROCmTorchFloor) { $ROCmTorchFloor } else { "torch" }
# Pin companions like the fresh ROCm path (bare names can pull an
# ABI-incompatible torchvision/torchaudio from the per-arch index).
$visionSpec = if ($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 { 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)
}
$installedTorchTag = Get-InstalledTorchTag -PythonExe $VenvPython
} elseif ($expectedTorchTag -ne 'rocm') {
# CUDA: stale +cpu (or wrong cuXXX) against a CUDA index -> reinstall triplet.
substep "PyTorch flavor mismatch (installed $installedTorchTag, need $expectedTorchTag) -- reinstalling correct build..." "Yellow"
$torchFixExit = Invoke-InstallCommand { 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)
}
$installedTorchTag = Get-InstalledTorchTag -PythonExe $VenvPython
}
}
# Safety net (incl. AMD): GPU build expected but still CPU -> warn loudly.
if ($installedTorchTag -eq 'cpu') {
Write-Host ""
Write-Host " [WARN] PyTorch is CPU-only but a $expectedTorchTag GPU build was expected for this machine." -ForegroundColor Yellow
Write-Host " [WARN] Training and GPU inference will run on CPU until this is fixed." -ForegroundColor Yellow
Write-Host " [WARN] Re-run this installer, or reinstall the GPU build manually for your GPU." -ForegroundColor Yellow
}
}
}
# Overlay Tauri-bundled studio fixes that may be ahead of PyPI. Skipped
# for --local: the editable install above already makes _PACKAGE_ROOT in
# unsloth_cli/commands/studio.py resolve to the repo (PEP 660 __file__).
@ -2102,7 +2392,9 @@ shell.Run cmd, 0, False
# ── Run studio setup ──
# setup.ps1 will handle installing Git, CMake, Visual Studio Build Tools,
# CUDA Toolkit, Node.js, and other dependencies automatically via winget.
# CUDA Toolkit, and other dependencies automatically via winget. Node.js is
# NOT installed via winget -- setup.ps1 uses an isolated Node it manages and
# never touches the system Node/npm.
Write-TauriLog "STEP" "Running studio setup"
step "setup" "running unsloth studio setup..."
$UnslothExe = Join-Path $VenvDir "Scripts\unsloth.exe"
@ -2308,6 +2600,7 @@ shell.Run cmd, 0, False
step "launch" "to start later, run:"
substep "unsloth studio -p 8888"
substep "(add -H 0.0.0.0 to allow network / cloud access)"
substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
Write-Host ""
}
} else {
@ -2328,6 +2621,7 @@ shell.Run cmd, 0, False
substep "unsloth studio -p 8888"
}
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 ""
}
}

View file

@ -163,6 +163,40 @@ run_install_cmd() {
return $_rc
}
# Retry run_install_cmd on transient uv download failures with backoff. Returns
# the last exit code on permanent failure so the set -e rollback trap still fires.
: "${UNSLOTH_INSTALL_RETRIES:=3}"
: "${UNSLOTH_INSTALL_RETRY_DELAY:=3}"
run_install_cmd_retry() {
_ricr_label="$1"
# Sanitize overrides to a default of 3 (a typo must not disable retries; =1 disables).
# Length guard precedes the numeric test so a huge value can't overflow `[ -ge ]`.
# 0?* rejects leading-zero delays ("08"/"09" break the later $((delay*2)) as octal);
# bare "0" stays valid. Bounds: 1..100 retries, 0..3600s base delay.
case "$UNSLOTH_INSTALL_RETRIES" in
''|*[!0-9]*|0) _ricr_max=3 ;;
*) if [ "${#UNSLOTH_INSTALL_RETRIES}" -le 3 ] && [ "$UNSLOTH_INSTALL_RETRIES" -ge 1 ] 2>/dev/null && [ "$UNSLOTH_INSTALL_RETRIES" -le 100 ] 2>/dev/null; then _ricr_max=$UNSLOTH_INSTALL_RETRIES; else _ricr_max=3; fi ;;
esac
case "$UNSLOTH_INSTALL_RETRY_DELAY" in
''|*[!0-9]*|0?*) _ricr_delay=3 ;;
*) if [ "${#UNSLOTH_INSTALL_RETRY_DELAY}" -le 4 ] && [ "$UNSLOTH_INSTALL_RETRY_DELAY" -ge 0 ] 2>/dev/null && [ "$UNSLOTH_INSTALL_RETRY_DELAY" -le 3600 ] 2>/dev/null; then _ricr_delay=$UNSLOTH_INSTALL_RETRY_DELAY; else _ricr_delay=3; fi ;;
esac
_ricr_attempt=1
while :; do
# AND-OR (not `if`) preserves the real failure code: $? after a non-taken
# `if` is 0 in sh/dash/bash, which would break the rollback path.
run_install_cmd "$@" && return 0
_ricr_rc=$?
if [ "$_ricr_attempt" -ge "$_ricr_max" ]; then
return "$_ricr_rc"
fi
substep "retrying \"$_ricr_label\" after transient failure (attempt $((_ricr_attempt + 1))/$_ricr_max, waiting ${_ricr_delay}s)..." "$C_WARN"
sleep "$_ricr_delay" || true
_ricr_attempt=$((_ricr_attempt + 1))
_ricr_delay=$((_ricr_delay * 2))
done
}
# 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
@ -413,8 +447,12 @@ _on_install_exit() {
if [ "$_status" -ne 0 ]; then
_restore_studio_venv_replacement
fi
[ -n "${_UV_OVERRIDE_TMPDIR:-}" ] && rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true
exit "$_status"
}
# 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=""
trap _on_install_exit EXIT
# ── Helper: download a URL to a file (supports curl and wget) ──
@ -1227,6 +1265,10 @@ if (-not \$targetExe) { exit 1 }
# native install if one exists) so the WSL shortcut shows the proper icon.
\$iconDir = Join-Path \$env:LOCALAPPDATA 'Unsloth Studio'
\$iconPath = Join-Path \$iconDir 'unsloth.ico'
\$preIconHash = \$null
if (Test-Path -LiteralPath \$iconPath) {
try { \$preIconHash = (Get-FileHash -LiteralPath \$iconPath -Algorithm SHA256).Hash } catch {}
}
if (-not (Test-Path -LiteralPath \$iconPath)) {
try {
New-Item -ItemType Directory -Force -Path \$iconDir | Out-Null
@ -1242,9 +1284,11 @@ if (Test-Path -LiteralPath \$iconPath) {
(Join-Path \$env:APPDATA 'Microsoft\Windows\Start Menu\Programs')
)
\$created = @()
\$firstShortcut = \$false
foreach (\$dir in \$locations) {
if (-not \$dir -or -not (Test-Path \$dir)) { continue }
\$linkPath = Join-Path \$dir '$_css_lnk_name_ps'
if (-not (Test-Path -LiteralPath \$linkPath)) { \$firstShortcut = \$true }
\$shortcut = \$WshShell.CreateShortcut(\$linkPath)
\$shortcut.TargetPath = \$targetExe
\$shortcut.Arguments = '$_css_sc_args_ps'
@ -1253,27 +1297,43 @@ foreach (\$dir in \$locations) {
\$shortcut.Save()
\$created += \$linkPath
}
# Force Explorer to re-read EACH new shortcut's icon so it renders immediately
# instead of a stale/blank (generic) icon. The reliable, NON-disruptive fix
# (no explorer restart) is a PER-ITEM SHChangeNotify(SHCNE_UPDATEITEM,
# SHCNF_PATHW, <lnk>) -- the global SHCNE_ASSOCCHANGED alone does not recover a
# stale item. Also clear the on-disk icon cache for heavier staleness.
try { & "\$env:SystemRoot\System32\ie4uinit.exe" -ClearIconCache } catch {}
try { & "\$env:SystemRoot\System32\ie4uinit.exe" -show } catch {}
\$iconChanged = \$false
if (\$hasIcon) {
if (-not \$preIconHash) {
\$iconChanged = \$true
} else {
try {
\$postIconHash = (Get-FileHash -LiteralPath \$iconPath -Algorithm SHA256).Hash
\$iconChanged = (\$postIconHash -ne \$preIconHash)
} catch { \$iconChanged = \$true }
}
} elseif (\$preIconHash) {
\$iconChanged = \$true
}
# Per-item refresh always (cheap, non-disruptive) so the rewritten .lnk renders
# immediately instead of a stale/blank (generic) icon. The reliable fix (no
# explorer restart) is a PER-ITEM SHChangeNotify(SHCNE_UPDATEITEM, SHCNF_PATHW,
# <lnk>) -- the global SHCNE_ASSOCCHANGED alone does not recover a stale item.
try {
Add-Type -Namespace UnslothShell -Name IconRefresh -MemberDefinition '[System.Runtime.InteropServices.DllImport("shell32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)] public static extern void SHChangeNotify(int e, uint f, string a, System.IntPtr b);' -ErrorAction SilentlyContinue
foreach (\$p in \$created) { try { [UnslothShell.IconRefresh]::SHChangeNotify(0x00002000, 0x0005, \$p, [System.IntPtr]::Zero) } catch {} }
[UnslothShell.IconRefresh]::SHChangeNotify(0x08000000, 0, \$null, [System.IntPtr]::Zero)
} catch {}
# Win11 Start Menu keeps its own tile-icon cache (preserve start2.bin).
try {
\$smeh = Join-Path \$env:LOCALAPPDATA 'Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\TempState'
if (Test-Path -LiteralPath \$smeh) {
Get-ChildItem -LiteralPath \$smeh -Filter 'TileCache_*' -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath (Join-Path \$smeh 'StartUnifiedTileModelCache.dat') -Force -ErrorAction SilentlyContinue
Stop-Process -Name StartMenuExperienceHost -Force -ErrorAction SilentlyContinue
}
} catch {}
# Heavier on-disk icon-cache clear + StartMenuExperienceHost tile rebuild
# (preserve start2.bin) only on first install or a real icon change, so a no-op
# WSL reinstall does not run a dropper-like clear-cache + kill cluster each time.
if (\$created.Count -gt 0 -and (\$firstShortcut -or \$iconChanged)) {
try { & "\$env:SystemRoot\System32\ie4uinit.exe" -ClearIconCache } catch {}
try { & "\$env:SystemRoot\System32\ie4uinit.exe" -show } catch {}
try {
\$smeh = Join-Path \$env:LOCALAPPDATA 'Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\TempState'
if (Test-Path -LiteralPath \$smeh) {
Get-ChildItem -LiteralPath \$smeh -Filter 'TileCache_*' -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath (Join-Path \$smeh 'StartUnifiedTileModelCache.dat') -Force -ErrorAction SilentlyContinue
Stop-Process -Name StartMenuExperienceHost -Force -ErrorAction SilentlyContinue
}
} catch {}
}
WSLPS1_EOF
# Convert WSL path to Windows path for powershell.exe
@ -1371,6 +1431,25 @@ fi
if [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then
_OVERRIDES_FILE="$(cd "$(dirname "$0" 2>/dev/null || echo ".")" && pwd)/studio/backend/requirements/single-env/overrides-darwin-arm64.txt"
if [ -f "$_OVERRIDES_FILE" ]; then
# uv splits UV_OVERRIDE on whitespace, so a repo path with whitespace
# truncates it and aborts every later uv call (issue #6503). Hand uv a copy.
case "$_OVERRIDES_FILE" in
*[[:space:]]*)
_UV_OVERRIDE_TMPDIR=$(mktemp -d 2>/dev/null) || _UV_OVERRIDE_TMPDIR=""
case "$_UV_OVERRIDE_TMPDIR" in
"") ;;
*[[:space:]]*) rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true; _UV_OVERRIDE_TMPDIR="" ;;
*)
if cp "$_OVERRIDES_FILE" "$_UV_OVERRIDE_TMPDIR/overrides-darwin-arm64.txt" 2>/dev/null; then
_OVERRIDES_FILE="$_UV_OVERRIDE_TMPDIR/overrides-darwin-arm64.txt"
else
rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true
_UV_OVERRIDE_TMPDIR=""
fi
;;
esac
;;
esac
export UV_OVERRIDE="$_OVERRIDES_FILE"
fi
fi
@ -1383,18 +1462,110 @@ elif [ "$OS" = "macos" ]; then
fi
tauri_diag_marker "$_TAURI_INITIAL_GPU_BRANCH" "none"
# ── Check system dependencies ──
# cmake and git are needed by unsloth studio setup to build the GGUF inference
# engine (llama.cpp). build-essential and libcurl-dev are also needed on Linux.
tauri_log "STEP" "Checking system dependencies"
MISSING=""
# 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
[ "${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
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
fi
_rr_ver=""
[ -r /etc/os-release ] && _rr_ver=$(. /etc/os-release 2>/dev/null; printf '%s' "${VERSION_ID:-}")
# The bootstrap (scripts/install_rocm_wsl_strixhalo.sh) dies on any VERSION_ID but
# 24.04 and pins the noble repo, so 24.04 is the sole GPU-supported target; leave a
# 24.04 user alone. (Working ROCm on other versions was caught by librocdxg above.)
case "$_rr_ver" in 24.04) return 0 ;; esac
# Distro is now unsupported. If we can't reroute to a 24.04 target, stay CPU-only
# AND skip the later origin-distro ROCm bootstrap (it ignores distro version, so it
# would otherwise install ROCm into 26.04 etc.).
command -v wsl.exe >/dev/null 2>&1 || { UNSLOTH_SKIP_ROCM_WSL_SETUP=1; return 0; }
# Route only to an installed Ubuntu-24.04 (bootstrap's only target). Match the whole
# line (one distro per line from wsl.exe -l -q), not a substring, so "Ubuntu-24.04-test"
# can't masquerade as it and then fail `wsl -d`.
# || true: no match is expected, not an error (script runs under set -e).
_rr_distros=$(wsl.exe -l -q 2>/dev/null | tr -d '\000\r')
_rr_target=$(printf '%s\n' "$_rr_distros" | grep -ixF "Ubuntu-24.04" | head -n1) || true
[ -n "$_rr_target" ] || {
substep "ROCm-on-WSL (GPU) needs Ubuntu 24.04; this distro is Ubuntu ${_rr_ver:-unknown}." "$C_WARN"
substep "No Ubuntu-24.04 WSL distro found; staying CPU-only. Install Ubuntu-24.04 and re-run there for GPU." "$C_WARN"
UNSLOTH_SKIP_ROCM_WSL_SETUP=1
return 0
}
command -v cmake >/dev/null 2>&1 || MISSING="$MISSING cmake"
command -v git >/dev/null 2>&1 || MISSING="$MISSING git"
echo ""
substep "ROCm-on-WSL (GPU) needs Ubuntu 24.04; this distro is Ubuntu ${_rr_ver:-unknown}." "$C_WARN"
substep "Found an existing $_rr_target distro -- continuing the GPU install there." "$C_OK"
# A --local checkout can't be replayed via curl|sh (the repo isn't in the target
# distro), so tell the user to re-run there rather than silently run a different install.
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
substep "This is a --local install; re-run it from $_rr_target instead:" "$C_WARN"
substep " wsl -d $_rr_target -- bash -lc 'cd <your checkout> && ./install.sh --local'" "$C_WARN"
substep "Continuing CPU-only in Ubuntu ${_rr_ver:-this distro} for now." "$C_WARN"
# Unsupported distro, can't reroute a --local checkout: skip the origin ROCm bootstrap.
UNSLOTH_SKIP_ROCM_WSL_SETUP=1
return 0
fi
# Forward the caller's options/env (custom package/python/home) so the rerouted
# install matches what was asked for, not a default install.
_rr_q() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
_rr_exports="set -o pipefail; export UNSLOTH_WSL_REROUTED=1"
[ "$_STUDIO_HOME_REDIRECT" = "env" ] && _rr_exports="$_rr_exports; export UNSLOTH_STUDIO_HOME=$(_rr_q "$STUDIO_HOME")"
# Forward explicit ROCm-bootstrap consent (e.g. Tauri) so the child auto-enables the
# GPU instead of falling back to the desktop-app prompt path.
[ "${UNSLOTH_ROCM_WSL_AUTO:-0}" = "1" ] && _rr_exports="$_rr_exports; export UNSLOTH_ROCM_WSL_AUTO=1"
_rr_args=""
[ "$PACKAGE_NAME" != "unsloth" ] && _rr_args="$_rr_args --package $(_rr_q "$PACKAGE_NAME")"
[ -n "$_USER_PYTHON" ] && _rr_args="$_rr_args --python $(_rr_q "$_USER_PYTHON")"
[ "$_VERBOSE" = true ] && _rr_args="$_rr_args --verbose"
[ "$TAURI_MODE" = true ] && _rr_args="$_rr_args --tauri"
if [ -n "${UNSLOTH_WSL_REROUTE_CMD:-}" ]; then
_rr_cmd="$UNSLOTH_WSL_REROUTE_CMD" # user took full control
elif [ -n "$_rr_args" ]; then
_rr_cmd="curl -fsSL https://unsloth.ai/install.sh | sh -s --$_rr_args"
else
_rr_cmd="curl -fsSL https://unsloth.ai/install.sh | sh"
fi
# pipefail so a failed curl in `curl | sh` isn't masked by sh exiting 0 on empty
# input (which would wrongly report success and exit 0 the parent installer).
_rr_rc=0
wsl.exe -d "$_rr_target" -- bash -lc "$_rr_exports; $_rr_cmd" || _rr_rc=$?
if [ "$_rr_rc" -eq 0 ]; then
exit 0
fi
# In Tauri mode the child uses exit 2 ([TAURI:NEED_SUDO]) to ask the desktop app to
# elevate for the target distro; the child already printed the NEED_SUDO line, so
# propagate the code instead of masking it as a reroute failure and dropping to CPU.
if [ "$TAURI_MODE" = true ] && [ "$_rr_rc" -eq 2 ]; then
exit 2
fi
substep "Could not auto-continue in $_rr_target; run it yourself:" "$C_WARN"
substep " wsl -d $_rr_target -- bash -lc 'curl -fsSL https://unsloth.ai/install.sh | sh'"
substep "Continuing CPU-only in Ubuntu ${_rr_ver:-this distro} for now." "$C_WARN"
# Reroute failed; don't let the later bootstrap install ROCm into this unsupported
# distro -- stay CPU-only.
UNSLOTH_SKIP_ROCM_WSL_SETUP=1
return 0
}
_maybe_reroute_strixhalo_to_2404 || true
# ── Check system dependencies ──
# 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"
case "$OS" in
macos)
# Xcode Command Line Tools provide the C/C++ compiler
# 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."
@ -1403,8 +1574,19 @@ case "$OS" in
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)
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"
@ -1412,27 +1594,12 @@ case "$OS" in
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"
;;
esac
MISSING=$(echo "$MISSING" | sed 's/^ *//')
if [ -n "$MISSING" ]; then
echo ""
step "deps" "missing: $MISSING" "$C_WARN"
substep "These are needed to build the GGUF inference engine."
case "$OS" in
macos)
if ! command -v brew >/dev/null 2>&1; then
echo ""
echo " Homebrew is required to install them."
echo " Install Homebrew from https://brew.sh then re-run this script."
exit 1
fi
brew install $MISSING </dev/null
;;
linux|wsl)
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
@ -1447,21 +1614,28 @@ if [ -n "$MISSING" ]; then
echo " openSUSE: sudo zypper install cmake git gcc gcc-c++ make libcurl-devel"
exit 1
fi
;;
esac
echo ""
else
step "deps" "all system dependencies found"
fi
echo ""
else
step "deps" "all system dependencies found"
fi
;;
esac
# ── Install uv ──
tauri_log "STEP" "Installing uv package manager"
UV_MIN_VERSION="0.7.22"
UV_MIN_VERSION="0.8.16"
# When bytecode compilation is enabled, large installs can exceed uv's 60s default on slow machines. Default to 180s, preserving overrides ("0" disables).
: "${UV_COMPILE_BYTECODE_TIMEOUT:=180}"
export UV_COMPILE_BYTECODE_TIMEOUT
# uv >= 0.8.16 retries HTTP/2 streaming body errors; raise retries and read
# timeout for large wheel downloads. ":=" preserves any user override.
: "${UV_HTTP_RETRIES:=5}"
export UV_HTTP_RETRIES
: "${UV_HTTP_TIMEOUT:=180}"
export UV_HTTP_TIMEOUT
version_ge() {
# returns 0 if $1 >= $2
_a=$1
@ -1937,6 +2111,45 @@ get_torch_index_url() {
else echo "$_base/cpu"; fi
}
# ── Torch flavor helpers (to repair a stale CPU / wrong-CUDA wheel) ──
# torch.__version__ ($1) -> flavor tag (cuXXX / rocm / cpu); untagged wheel = cpu.
_torch_flavor_tag() {
case "$1" in
*+cu[0-9]*) printf '%s\n' "$1" | sed -n 's/.*+\(cu[0-9][0-9]*\).*/\1/p' ;;
*+rocm*) echo "rocm" ;;
*+cpu*) echo "cpu" ;;
"") echo "" ;;
*) echo "cpu" ;;
esac
}
# 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() {
_u="${1%/}"
_leaf="${_u##*/}"
case "$_leaf" in
cu[0-9]*) echo "$_leaf" ;;
cpu) echo "cpu" ;;
rocm*|gfx*) echo "rocm" ;;
*) echo "" ;;
esac
}
# 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 --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() {
_u="${1%/}"
_leaf="${_u##*/}"
case "$_leaf" in
cu[0-9]*|rocm[0-9]*|gfx*) echo "yes" ;;
*) echo "no" ;;
esac
}
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/,
@ -2368,6 +2581,9 @@ elif case "$TORCH_INDEX_URL" in */rocm*|*/gfx*) true ;; *) false ;; esac; then
substep "ROCm: $_rocm_root"
[ -n "$_gpu_rocm_ver" ] && substep "hipconfig: $_gpu_rocm_ver"
[ -n "$_gpu_disp_mkt" ] && [ -n "$_gpu_disp_gfx" ] && substep "GPU: $_gpu_disp_mkt"
elif [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then
# Apple Silicon: PyTorch gets Metal (MPS) acceleration over unified memory, so not CPU-only.
step "gpu" "Apple Silicon (Metal, unified memory)"
else
step "gpu" "none (CPU-only)" "$C_WARN"
fi
@ -2430,28 +2646,28 @@ if [ "$_MIGRATED" = true ]; then
# PyPI metadata still declares torch as a hard dep), then install
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps
# to prevent transitive torch resolution.
run_install_cmd "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
run_install_cmd_retry "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
--reinstall-package unsloth --reinstall-package unsloth-zoo \
"unsloth>=2026.6.7" unsloth-zoo
"unsloth>=2026.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.
run_install_cmd "install pydantic (with deps for compatible core)" \
run_install_cmd_retry "install pydantic (with deps for compatible core)" \
uv pip install --python "$_VENV_PY" pydantic
_NO_TORCH_RT="$(_find_no_torch_runtime)"
if [ -n "$_NO_TORCH_RT" ]; then
run_install_cmd "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT"
run_install_cmd_retry "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT"
fi
else
run_install_cmd "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \
run_install_cmd_retry "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \
--reinstall-package unsloth --reinstall-package unsloth-zoo \
"unsloth>=2026.6.7" unsloth-zoo
"unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7"
fi
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
substep "overlaying local repo (editable)..."
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
substep "overlaying unsloth-zoo from git main..."
run_install_cmd "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \
run_install_cmd_retry "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \
--no-deps --reinstall-package unsloth-zoo \
"unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo"
fi
@ -2460,13 +2676,13 @@ if [ "$_MIGRATED" = true ]; then
# fresh reinstall.
if [ "$SKIP_TORCH" = false ]; then
case "$TORCH_INDEX_URL" in
*/rocm*)
*/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 "repair ROCm torch" uv pip install --python "$_VENV_PY" \
run_install_cmd_retry "repair ROCm torch" uv pip install --python "$_VENV_PY" \
"$TORCH_CONSTRAINT" torchvision torchaudio \
--index-url "$TORCH_INDEX_URL" \
--force-reinstall
@ -2592,7 +2808,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
if [ -z "$_torch_whl" ] || [ -z "$_tv_whl" ] || [ -z "$_ta_whl" ] || \
[ "$_radeon_versions_match" != true ]; then
substep "[WARN] Radeon repo lacks a compatible wheel set for this Python; falling back to ROCm index ($TORCH_INDEX_URL)" "$C_WARN"
run_install_cmd "install PyTorch" uv pip install --python "$_VENV_PY" \
run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" \
"$TORCH_CONSTRAINT" torchvision torchaudio \
--index-url "$TORCH_INDEX_URL"
else
@ -2604,30 +2820,30 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
# filelock / sympy / networkx which are not in the
# Radeon listing.
if [ -n "$_tri_whl" ]; then
run_install_cmd "install triton + PyTorch" uv pip install --python "$_VENV_PY" \
run_install_cmd_retry "install triton + PyTorch" uv pip install --python "$_VENV_PY" \
--find-links "$_RADEON_BASE_URL" \
"$_tri_whl" "$_torch_whl" "$_tv_whl" "$_ta_whl"
else
run_install_cmd "install PyTorch" uv pip install --python "$_VENV_PY" \
run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" \
--find-links "$_RADEON_BASE_URL" \
"$_torch_whl" "$_tv_whl" "$_ta_whl"
fi
fi
else
substep "[WARN] Radeon repo unavailable; falling back to ROCm index ($TORCH_INDEX_URL)" "$C_WARN"
run_install_cmd "install PyTorch" uv pip install --python "$_VENV_PY" \
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"
run_install_cmd "install PyTorch" uv pip install --python "$_VENV_PY" \
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 ($TORCH_INDEX_URL)..."
run_install_cmd "install PyTorch" uv pip install --python "$_VENV_PY" "$TORCH_CONSTRAINT" torchvision torchaudio \
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).
@ -2636,7 +2852,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
# which is only useful once torch is present for training.
if [ "$SKIP_TORCH" = false ]; then
case "$TORCH_INDEX_URL" in
*/rocm*)
*/rocm*|*/gfx*)
_install_bnb_rocm "install bitsandbytes (AMD)" "$_VENV_PY"
;;
esac
@ -2647,46 +2863,46 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
if [ "$SKIP_TORCH" = true ]; then
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
run_install_cmd "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
run_install_cmd_retry "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
--upgrade-package unsloth --upgrade-package unsloth-zoo \
"unsloth>=2026.6.7" unsloth-zoo
"unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7"
# Same pydantic-with-deps trick as the migrated branch.
run_install_cmd "install pydantic (with deps for compatible core)" \
run_install_cmd_retry "install pydantic (with deps for compatible core)" \
uv pip install --python "$_VENV_PY" pydantic
_NO_TORCH_RT="$(_find_no_torch_runtime)"
if [ -n "$_NO_TORCH_RT" ]; then
run_install_cmd "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT"
run_install_cmd_retry "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT"
fi
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
substep "overlaying local repo (editable)..."
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
substep "overlaying unsloth-zoo from git main..."
run_install_cmd "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \
run_install_cmd_retry "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \
--no-deps --reinstall-package unsloth-zoo \
"unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo"
fi
elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then
run_install_cmd "install unsloth (local)" uv pip install --python "$_VENV_PY" \
--upgrade-package unsloth "unsloth>=2026.6.7" unsloth-zoo
run_install_cmd_retry "install unsloth (local)" uv pip install --python "$_VENV_PY" \
--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..."
run_install_cmd "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \
run_install_cmd_retry "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \
--no-deps --reinstall-package unsloth-zoo \
"unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo"
else
run_install_cmd "install unsloth" uv pip install --python "$_VENV_PY" \
run_install_cmd_retry "install unsloth" uv pip install --python "$_VENV_PY" \
--upgrade-package unsloth -- "$PACKAGE_NAME"
fi
# AMD ROCm: repair torch if the unsloth/unsloth-zoo install pulled in
# CUDA torch from PyPI, overwriting the ROCm wheels installed in Step 1.
if [ "$SKIP_TORCH" = false ]; then
case "$TORCH_INDEX_URL" in
*/rocm*)
*/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 "repair ROCm torch" uv pip install --python "$_VENV_PY" \
run_install_cmd_retry "repair ROCm torch" uv pip install --python "$_VENV_PY" \
"$TORCH_CONSTRAINT" torchvision torchaudio \
--index-url "$TORCH_INDEX_URL" \
--force-reinstall
@ -2699,15 +2915,50 @@ else
tauri_log "STEP" "Installing Unsloth"
substep "installing unsloth (this may take a few minutes)..."
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.6.7" --torch-backend=auto
run_install_cmd_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" "unsloth-zoo>=2026.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..."
run_install_cmd "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \
run_install_cmd_retry "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \
--no-deps --reinstall-package unsloth-zoo \
"unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo"
else
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" --torch-backend=auto -- "$PACKAGE_NAME"
run_install_cmd_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" --torch-backend=auto -- "$PACKAGE_NAME"
fi
fi
# ── Enforce the installed torch flavor matches the detected GPU build ──
# PEP 440 ignores the +cpu/+cuXXX/+rocm local label in a version range, so uv
# keeps a stale torch==X+cpu against a GPU index and the venv silently trains on
# CPU. Reinstall the right wheel triplet when a GPU build is expected; if it
# can't be reinstalled, warn loudly. --no-torch / CPU-only / macOS: no-op.
if [ "$SKIP_TORCH" = false ] && [ -n "${TORCH_INDEX_URL:-}" ]; then
_expected_torch_tag=$(_expected_torch_flavor_tag "$TORCH_INDEX_URL")
# Only act when a GPU build is expected (cuXXX / rocm); cpu and unknown skip.
if [ -n "$_expected_torch_tag" ] && [ "$_expected_torch_tag" != "cpu" ]; then
_installed_torch_ver=$("$_VENV_PY" -c "import torch; print(torch.__version__)" 2>/dev/null || true)
_installed_torch_tag=""
[ -n "$_installed_torch_ver" ] && _installed_torch_tag=$(_torch_flavor_tag "$_installed_torch_ver")
# Repair when flavor is wrong AND the index is plain --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..."
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=""
[ -n "$_installed_torch_ver" ] && _installed_torch_tag=$(_torch_flavor_tag "$_installed_torch_ver")
fi
# Safety net (incl. AMD/WSL): GPU build expected but still CPU -> warn loudly.
if [ "$_installed_torch_tag" = "cpu" ]; then
substep "[WARN] PyTorch is CPU-only but a $_expected_torch_tag GPU build was expected for this machine." "$C_WARN"
substep "[WARN] Training and GPU inference will run on CPU until this is fixed." "$C_WARN"
substep "[WARN] Re-run this installer, or reinstall the GPU build manually:" "$C_WARN"
substep "[WARN] uv pip install --python \"$_VENV_PY\" \"$TORCH_CONSTRAINT\" torchvision torchaudio --index-url $TORCH_INDEX_URL --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio" "$C_WARN"
fi
fi
fi
@ -2902,10 +3153,11 @@ echo ""
if [ -t 1 ]; then
echo ""
printf " Start Unsloth Studio now? [Y/n] "
# No readable answer (closed/EOF tty) defaults to no; Enter is still yes.
if [ -r /dev/tty ]; then
read -r _reply </dev/tty || _reply="y"
read -r _reply </dev/tty || _reply="n"
else
_reply="y"
_reply="n"
fi
case "${_reply:-y}" in
[Yy]*|"")
@ -2913,8 +3165,12 @@ if [ -t 1 ]; then
# Detach stdin from the `curl | sh` pipe: as a foreground server the
# studio would otherwise drain the rest of this piped script, leaving
# the shell to die parsing the now-truncated tail (`unexpected fi`).
"$VENV_DIR/bin/unsloth" studio -p 8888 </dev/null
_LAUNCH_EXIT=$?
# trap '' INT: wait for studio's shutdown instead of racing the prompt.
# Subshell resets INT so the child still gets Ctrl+C (no inherited ignore).
trap '' INT
# `|| ...`: capture the exit code without set -e aborting first.
_LAUNCH_EXIT=0
(trap - INT; exec "$VENV_DIR/bin/unsloth" studio -p 8888 </dev/null) || _LAUNCH_EXIT=$?
if [ "$_LAUNCH_EXIT" -ne 0 ] && [ "$_MIGRATED" = true ]; then
echo ""
echo "⚠️ Unsloth Studio failed to start after migration."
@ -2931,6 +3187,7 @@ if [ -t 1 ]; then
step "launch" "to start later, run:"
substep "unsloth studio -p 8888"
substep "(add -H 0.0.0.0 to allow network / cloud access)"
substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
echo ""
;;
esac
@ -2952,5 +3209,6 @@ else
substep "unsloth studio -p 8888"
fi
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)"
echo ""
fi

View file

@ -46,6 +46,7 @@ studio = [
"*.sh",
"*.ps1",
"*.bat",
"node_prebuilt_pins.json",
"frontend/dist/**/*",
"frontend/*.json",
"frontend/*.ts",
@ -56,6 +57,7 @@ studio = [
"backend/requirements/**/*",
"backend/plugins/**/*",
"backend/assets/**/*.jinja",
"backend/assets/**/*.html",
"backend/core/data_recipe/oxc-validator/*.json",
"backend/core/data_recipe/oxc-validator/*.mjs",
]
@ -71,7 +73,7 @@ triton = [
]
huggingfacenotorch = [
"unsloth_zoo>=2026.6.5",
"unsloth_zoo>=2026.6.7",
"wheel>=0.42.0",
"packaging",
"numpy",
@ -92,7 +94,7 @@ huggingfacenotorch = [
]
huggingface = [
"unsloth[huggingfacenotorch]",
"unsloth_zoo>=2026.6.5",
"unsloth_zoo>=2026.6.7",
"torchvision",
"unsloth[triton]",
]
@ -582,7 +584,7 @@ colab-ampere-torch220 = [
"flash-attn>=2.6.3 ; ('linux' in sys_platform)",
]
colab-new = [
"unsloth_zoo>=2026.6.5",
"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",

View file

@ -412,7 +412,7 @@ BLOCKED_NPM_VERSIONS: dict[str, set[str]] = {
"@uipath/functions-tool": {"1.0.1"},
"@uipath/access-policy-sdk": {"0.3.1"},
"@uipath/platform-tool": {"1.0.1"},
# Mini Shai-Hulud May-12 wave: @mistralai/* (npm) separate from PyPI mistralai
# Mini Shai-Hulud May-12 wave: @mistralai/* (npm), separate from PyPI mistralai
# (https://www.aikido.dev/blog/mini-shai-hulud-is-back-tanstack-compromised).
"@mistralai/mistralai": {"2.2.2", "2.2.3", "2.2.4"},
"@mistralai/mistralai-gcp": {"1.7.1", "1.7.2", "1.7.3"},
@ -916,6 +916,204 @@ def _evidence(
LIFECYCLE_HOOKS = ("preinstall", "install", "postinstall", "prepare")
# ─────────────────────────────────────────────────────────────────────
# Code-only scanning for JS/TS sources. Blank `//` and `/* */` comments
# before matching (the top FP source: scary strings in JSDoc/changelog
# comments), tracking string/template/regex context so a `//` inside
# "http://..." is not mistaken for a comment. Strings are NOT blanked
# (droppers hide payloads there). Fail open on lexer confusion: the raw
# text is still scanned. JS sibling of scan_packages.py::_strip_noncode.
# ─────────────────────────────────────────────────────────────────────
_JS_FAMILY_SUFFIXES = (".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx")
# Keywords after which a `/` begins a regex literal (not division).
_REGEX_PRECEDING_KEYWORDS = frozenset(
{
"return",
"typeof",
"instanceof",
"in",
"of",
"new",
"delete",
"void",
"throw",
"yield",
"await",
"do",
"else",
"case",
}
)
_IDENT_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$")
def _slash_is_regex(prev_tok: str) -> bool:
"""Disambiguate a lone ``/``: regex literal vs division operator.
Biased toward regex when ambiguous -- regex state never blanks, so a
wrong guess only costs FP reduction (or a fail-open), never a missed
detection.
"""
if prev_tok == "":
return True # start of file -> expression position
if prev_tok in _REGEX_PRECEDING_KEYWORDS:
return True
last = prev_tok[-1]
if last.isalnum() or last in "_$)]":
return False # previous token ends a value -> division
return True # operators, punctuation, `{`, `}` -> regex (safe bias)
def _strip_js_noncode(text: str) -> str:
"""Blank JS/TS comments, preserving byte geometry. Fail-open on confusion."""
if "//" not in text and "/*" not in text:
return text # nothing to strip
n = len(text)
out = list(text)
nl = ("\n", "\r")
def _blank(a: int, b: int) -> None:
for k in range(a, b):
if out[k] not in nl:
out[k] = " "
state = "code"
prev_tok = ""
tmpl_stack: list[str] = []
i = 0
try:
while i < n:
c = text[i]
nxt = text[i + 1] if i + 1 < n else ""
if state == "code":
if c == "/" and nxt == "/":
start = i
i += 2
while i < n and text[i] not in nl:
i += 1
_blank(start, i)
continue
if c == "/" and nxt == "*":
start = i
i += 2
closed = False
while i < n:
if text[i] == "*" and i + 1 < n and text[i + 1] == "/":
i += 2
closed = True
break
i += 1
if not closed:
return text # unterminated block comment
_blank(start, i)
continue
if c == "'":
state = "sq"
i += 1
continue
if c == '"':
state = "dq"
i += 1
continue
if c == "`":
state = "tmpl"
i += 1
continue
if c == "/":
if _slash_is_regex(prev_tok):
state = "regex"
i += 1
continue
prev_tok = "/"
i += 1
continue
if c.isspace():
i += 1
continue
if c in _IDENT_CHARS:
j = i
while j < n and text[j] in _IDENT_CHARS:
j += 1
prev_tok = text[i:j]
i = j
continue
if c == "}" and tmpl_stack:
state = tmpl_stack.pop()
i += 1
continue
prev_tok = c
i += 1
continue
elif state in ("sq", "dq"):
q = "'" if state == "sq" else '"'
if c == "\\":
i += 2
continue
if c == q:
state = "code"
prev_tok = "_v"
i += 1
continue
if c in nl:
return text # unterminated string literal
i += 1
continue
elif state == "tmpl":
if c == "\\":
i += 2
continue
if c == "`":
state = "code"
prev_tok = "_v"
i += 1
continue
if c == "$" and nxt == "{":
tmpl_stack.append("tmpl")
state = "code"
prev_tok = "{"
i += 2
continue
i += 1
continue
elif state == "regex":
if c == "\\":
i += 2
continue
if c == "[":
state = "regex_cc"
i += 1
continue
if c == "/":
state = "code"
prev_tok = "_v"
i += 1
continue
if c in nl:
return text # unterminated regex literal
i += 1
continue
elif state == "regex_cc":
if c == "\\":
i += 2
continue
if c == "]":
state = "regex"
i += 1
continue
if c in nl:
return text
i += 1
continue
else:
return text
if state != "code" or tmpl_stack:
return text # unterminated construct -> fail open
except Exception:
return text
return "".join(out)
def scan_package_json(pkg: PackageEntry, rel: str, text: str) -> list[Finding]:
findings: list[Finding] = []
try:
@ -1042,6 +1240,14 @@ def _host_in_outbound_context(text: str, host: str) -> bool:
def scan_text_blob(pkg: PackageEntry, rel: str, text: str) -> list[Finding]:
findings: list[Finding] = []
# Code-only scanning for JS/TS sources: blank comments before matching so
# an IOC host / `eval(atob)` example / campaign marker quoted in a comment
# cannot manufacture a false positive. Assigned string literals (where real
# droppers hide base64 payloads) are preserved. Non-JS text (json/yaml/sh/
# py/html) is scanned as-is -- this lexer only understands JS comments.
if rel.lower().endswith(_JS_FAMILY_SUFFIXES):
text = _strip_js_noncode(text)
# IOC substrings (literal, case-sensitive).
for needle, (sev, why) in KNOWN_IOC_STRINGS.items():
if needle in text:
@ -1236,6 +1442,131 @@ def scan_one(pkg: PackageEntry, workspace: Path) -> tuple[list[Finding], str | N
pass
# ─────────────────────────────────────────────────────────────────────
# Baseline allowlist: triaged known-good HIGH/CRITICAL findings so the gate
# can enforce without red-failing on rare legitimate-library behavior.
# Matched on ``(normalized package, package-relative path, pattern)`` -- not
# evidence text -- so a version bump does not reopen a finding, but a *new*
# kind of finding in a listed file is a different pattern and still fails.
# Mirrors scan_packages.py. Regenerate with ``--write-baseline``.
# ─────────────────────────────────────────────────────────────────────
_DEFAULT_BASELINE_PATH = str(Path(__file__).resolve().parent / "scan_npm_packages_baseline.json")
# Bumped when the entry-key semantics change. 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:
"""``@scope/pkg@1.2.3`` / ``pkg@1.2.3`` -> name without the version.
The version is the LAST ``@``-separated field; a leading ``@`` (scope)
is preserved. Lower-cased (npm names are case-insensitive). Sentinels
like ``<root>`` / ``<lockfile>`` pass through unchanged.
"""
s = (display or "").strip()
at = s.rfind("@")
if at > 0: # >0 so a leading @scope is not treated as the version sep
s = s[:at]
return s.lower()
_NPM_TARBALL_ROOT = "package/"
def _relpath_in_package(filename: str) -> str:
"""Path within the published package, stable across version bumps. npm
tarballs root every file at ``package/``; strip it so the key is the real
source path (``dist/index.js``) and a new file with the same basename in a
different directory is not silently suppressed."""
f = (filename or "").replace("\\", "/")
return f[len(_NPM_TARBALL_ROOT) :] if f.startswith(_NPM_TARBALL_ROOT) else f
def _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 _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:
data = json.load(fh)
except FileNotFoundError:
return set()
except (OSError, json.JSONDecodeError) as exc:
print(f" [WARN] could not read baseline {path}: {exc}", file = sys.stderr)
return set()
entries = data.get("entries", [])
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]] = set()
for e in entries:
try:
keys.add((_norm_pkg_name(e["package"]), _relpath_in_package(e["file"]), e["pattern"]))
except (KeyError, TypeError):
continue
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]] = set()
for f in sorted(findings, key = lambda f: (_SEVERITY_RANK[f.severity], f.package)):
if _SEVERITY_RANK[f.severity] > threshold_rank:
continue
key = _finding_key(f)
if key in seen:
continue
seen.add(key)
entries.append(
{
"package": _norm_pkg_name(f.package),
"file": _relpath_in_package(f.filename),
"pattern": f.pattern,
"severity": f.severity,
"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/severity are for review "
"only. Regenerate with --write-baseline AFTER reviewing every line."
),
"version": _BASELINE_SCHEMA_VERSION,
"entries": entries,
}
with open(path, "w", encoding = "utf-8") as fh:
json.dump(doc, fh, indent = 2, sort_keys = False)
fh.write("\n")
print(f" Wrote {len(entries)} baseline entr(y/ies) to {path}")
return len(entries)
def _partition_baseline(
findings: list[Finding], baseline: set[tuple[str, str, str]]
) -> tuple[list[Finding], list[Finding]]:
"""Split findings into (active, suppressed) by allowlist membership."""
if not baseline:
return list(findings), []
active, suppressed = [], []
for f in findings:
(suppressed if _finding_key(f) in baseline else active).append(f)
return active, suppressed
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description = "Pre-install npm tarball content scanner.",
@ -1263,6 +1594,30 @@ def main(argv: list[str] | None = None) -> int:
"Medium and below print but exit 0."
),
)
parser.add_argument(
"--baseline",
metavar = "FILE",
default = None,
help = (
"Allowlist JSON of triaged known-good findings to suppress. "
"Defaults to scan_npm_packages_baseline.json next to this script "
"if present."
),
)
parser.add_argument(
"--no-baseline",
action = "store_true",
help = "Ignore the auto-discovered baseline allowlist.",
)
parser.add_argument(
"--write-baseline",
metavar = "FILE",
default = None,
help = (
"Write the current at/above-threshold findings to FILE as an "
"allowlist, then exit 0. Review every entry before committing it."
),
)
args = parser.parse_args(argv)
lockfile = Path(args.lockfile).resolve()
@ -1341,7 +1696,46 @@ def main(argv: list[str] | None = None) -> int:
"critical": CRITICAL,
}[args.fail_on]
threshold_rank = _SEVERITY_RANK[threshold]
blocking = [f for f in all_findings if _SEVERITY_RANK[f.severity] <= threshold_rank]
# --write-baseline: persist the full current at/above-threshold set as the
# new allowlist (ignoring any loaded baseline), then exit 0. A hard error
# means the scan was incomplete, so warn -- a baseline baked from a partial
# run would silently allow whatever failed to download.
if args.write_baseline:
if hard_errors:
print(
f" [WARN] {len(hard_errors)} hard error(s): baseline may be "
"incomplete (some packages did not scan).",
file = sys.stderr,
)
_write_baseline(args.write_baseline, all_findings, threshold_rank)
return 0
# Baseline allowlist: suppress triaged, known-good findings so the CI gate
# can be enforcing without red-failing on legitimate-library noise.
if args.no_baseline:
baseline_path = None
elif args.baseline:
baseline_path = args.baseline
elif os.path.isfile(_DEFAULT_BASELINE_PATH):
baseline_path = _DEFAULT_BASELINE_PATH
else:
baseline_path = None
baseline = _load_baseline(baseline_path) if baseline_path else set()
active, suppressed = _partition_baseline(all_findings, baseline)
if suppressed:
crit_s = sum(1 for f in suppressed if f.severity == CRITICAL)
high_s = sum(1 for f in suppressed if f.severity == HIGH)
print(
f"\n[scan-npm] {len(suppressed)} finding(s) suppressed by baseline "
f"{baseline_path} ({crit_s} CRITICAL, {high_s} HIGH).",
flush = True,
)
# Exit code: 1 on a hard error, or a NON-baselined finding at/above the
# threshold. This is the signal CI gates on once the baseline is clean.
blocking = [f for f in active if _SEVERITY_RANK[f.severity] <= threshold_rank]
if hard_errors or blocking:
if blocking:
print(

View file

@ -0,0 +1,5 @@
{
"_comment": "scan_npm_packages.py allowlist. Each entry is a HIGH/CRITICAL finding manually judged benign. Matched on (package, package-relative path, pattern); evidence/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": []
}

View file

@ -33,10 +33,24 @@ Examples:
python scan_packages.py --fix -r requirements.txt
python scan_packages.py --fix --max-search 20 -r requirements.txt
# Triage to a baseline once, then gate on anything NEW
python scan_packages.py -r requirements.txt --write-baseline scripts/scan_packages_baseline.json
python scan_packages.py -r requirements.txt # auto-loads the baseline, exits 0 if only baselined findings remain
False positives:
.py files are scanned code-only: comments and bare docstrings/doctests are
blanked before pattern matching (line numbers preserved), so prose, usage
examples and `>>>` doctests cannot trip a finding. Residual findings that
are genuine library behavior (a HTTP client reading HF_TOKEN, a vendored
test fixture) are suppressed via a reviewed baseline allowlist, matched on
(package, 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 CRITICAL or HIGH findings
1 -- CRITICAL or HIGH findings detected
2 -- no packages specified
0 -- no non-baselined CRITICAL or HIGH findings (or --write-baseline)
1 -- non-baselined CRITICAL or HIGH findings detected
2 -- no packages specified, or scan incomplete (pip download failure)
"""
import argparse
@ -50,6 +64,8 @@ import subprocess
import sys
import tarfile
import tempfile
import tokenize
import urllib.parse
import urllib.request
import zipfile
from dataclasses import dataclass, field
@ -213,17 +229,26 @@ RE_ARCHIVE_STAGING = re.compile(
)
# Anti-analysis / sandbox evasion / debugger detection
# NB: deliberately does NOT include a bare ``platform.system() ... Linux/Windows
# /Darwin`` branch. Under re.DOTALL that matched across the whole file -- any
# cross-platform library (typer, packaging, pandas, pymupdf, ...) trips it -- so
# it had ~zero precision and only generated false positives. OS detection alone
# is not an anti-analysis signal; the debugger/VM/long-sleep signals below are.
RE_ANTI_ANALYSIS = re.compile(
r"\bptrace\b"
r"|\bsys\s*\.\s*gettrace\s*\("
r"|\bsys\s*\.\s*settrace\b"
r"|\bTracerPid\b"
r"|\b/proc/self/status\b"
# /proc/self/status is read to scrape TracerPid for anti-debug. A leading
# \b here is unsatisfiable (\b never holds between a non-word boundary and
# "/"), so the old pattern was dead; a lookbehind that only forbids a
# preceding word char or path separator lets `open("/proc/self/status")`
# and `cat /proc/self/status` match while avoiding mid-path partials.
r"|(?<![\w/])/proc/self/status\b"
r"|\bIsDebuggerPresent\b"
r"|\bvirtualbox\b.*\bhardware\b"
r"|\bvmware\b.*\bdetect\b"
r"|\btime\.sleep\s*\(\s*(?:[3-9]\d{2,}|[1-9]\d{3,})\s*\)" # long sleep (anti-sandbox)
r"|\bplatform\.\s*system\b.*\bif\b.*\b(?:Linux|Windows|Darwin)\b",
r"|\btime\.sleep\s*\(\s*(?:[3-9]\d{2,}|[1-9]\d{3,})\s*\)", # long sleep (anti-sandbox)
re.IGNORECASE | re.DOTALL,
)
@ -493,9 +518,159 @@ def check_pth_file(content: str, filename: str, package: str) -> list[Finding]:
return findings
# A STRING after one of these tokens (and before a NEWLINE) is a bare
# docstring/doctest/prose statement -- the dominant FP source -- so we blank it.
# A string after `=` or `(` is real code and is never blanked.
_LINE_START_TOKENS = frozenset({tokenize.NEWLINE, tokenize.NL, tokenize.INDENT, tokenize.DEDENT})
def _is_fstring(tok_string: str) -> bool:
"""True if a STRING token is an f-string (3.10/3.11 emit one STRING token).
A bare f-string statement evaluates its expressions at import, so unlike an
inert docstring it must never be blanked.
"""
q = min((tok_string.find(c) for c in "'\"" if c in tok_string), default = -1)
return q > 0 and "f" in tok_string[:q].lower()
def _strip_noncode(content: str, blank_comments: bool = True) -> str:
"""Blank comments and bare docstrings so IOC patterns see code only.
Removed regions become spaces (newlines kept) so line numbers stay exact for
_extract_evidence. Fails open on tokenizer errors (the raw text is still
fully scanned, so a real detection is never lost). ``blank_comments=False``
keeps comments (only strings/docstrings blanked) to isolate the span that
exec() could actually run.
"""
try:
toks = list(tokenize.generate_tokens(io.StringIO(content).readline))
except (tokenize.TokenError, IndentationError, SyntaxError, ValueError):
return content
spans: list[tuple[int, int, int, int]] = [] # (srow, scol, erow, ecol)
prev_significant = tokenize.NEWLINE # start-of-file behaves like a new line
n = len(toks)
for i, tok in enumerate(toks):
ttype = tok.type
if ttype == tokenize.COMMENT:
if blank_comments:
spans.append((*tok.start, *tok.end))
continue # transparent; never advances prev_significant
if (
ttype == tokenize.STRING
and prev_significant in _LINE_START_TOKENS
and not _is_fstring(tok.string) # f-strings execute; never blank them
):
# Bare string only if it is the whole statement: next significant
# token must close the logical line.
j = i + 1
while j < n and toks[j].type in (tokenize.COMMENT, tokenize.NL):
j += 1
if j < n and toks[j].type == tokenize.NEWLINE:
spans.append((*tok.start, *tok.end))
prev_significant = ttype
continue
if ttype in (
tokenize.NL,
tokenize.NEWLINE,
tokenize.INDENT,
tokenize.DEDENT,
tokenize.ENCODING,
):
prev_significant = ttype
continue
prev_significant = ttype
if not spans:
return content
buf = content.splitlines(keepends = True)
for srow, scol, erow, ecol in spans:
for row in range(srow, erow + 1):
line = buf[row - 1]
if line.endswith("\n"):
body, nl = line[:-1], "\n"
elif line.endswith("\r"):
body, nl = line[:-1], "\r"
else:
body, nl = line, ""
start = scol if row == srow else 0
end = ecol if row == erow else len(body)
end = min(end, len(body))
if start < end:
body = body[:start] + (" " * (end - start)) + body[end:]
buf[row - 1] = body + nl
return "".join(buf)
# Payload carriers that are suspicious when hidden in a blanked region (a
# docstring/string) of a file that can dynamically execute strings.
_HIDDEN_PAYLOAD_PATTERNS = (
(RE_LARGE_BLOB, "large base64 blob"),
(RE_EMBEDDED_KEYS, "embedded key material"),
(RE_MAY12_IOC, "Shai-Hulud IOC string"),
(RE_OBFUSCATION, "marshal/compile/obfuscation"),
)
def _hidden_payload_findings(
original: str, stripped: str, filename: str, package: str
) -> list[Finding]:
"""Flag payloads that live only in the blanked (docstring/string) region of
a file that contains exec/eval. Such a string is invisible to code-only
scanning yet ``exec(__doc__)`` / ``exec(<str>)`` could still run it."""
if not RE_EXEC_EVAL.search(stripped):
return []
# Only docstrings/strings run via exec(__doc__)/exec(<str>); comments cannot.
# Isolate that span: keep comments as real code, take what string-blanking
# removed (length-preserved, so offsets stay exact for _extract_evidence).
code = _strip_noncode(original, blank_comments = False)
removed = "".join(o if o != s else " " for o, s in zip(original, code))
out = []
def _hidden(pat):
# Carrier present in a blanked region but NOT in real code. A carrier in
# real code is already caught by the normal check, so restricting to
# blanked-only avoids re-flagging legitimate in-code constants.
return bool(pat.search(removed)) and not pat.search(stripped)
for pat, label in _HIDDEN_PAYLOAD_PATTERNS:
if _hidden(pat):
out.append(
Finding(
HIGH,
package,
filename,
"exec/eval with payload hidden in a docstring/string",
f"{label}: {_extract_evidence(removed, pat)}",
)
)
# Fetch-then-run dropper: a network call AND an os/subprocess exec that both
# live in the blanked region. Search the removed span directly (not "absent
# from real code") so a benign visible network/subprocess call cannot mask
# the docstring payload.
if RE_NETWORK.search(removed) and RE_SUBPROCESS.search(removed):
out.append(
Finding(
HIGH,
package,
filename,
"exec/eval with hidden network+exec payload",
f"network+exec: {_extract_evidence(removed, RE_SUBPROCESS)}",
)
)
return out
def check_py_file(content: str, filename: str, package: str) -> list[Finding]:
"""Run all .py-specific checks."""
findings = []
# Code-only scanning: strip comments/docstrings up front so prose, doctests
# and usage examples cannot manufacture false positives. Aligns with the
# Hugging Face Hub model (ClamAV/picklescan: low-FP, signature/structural).
original = content
content = _strip_noncode(content)
findings = _hidden_payload_findings(original, content, filename, package)
basename = os.path.basename(filename)
is_setup = basename in ("setup.py", "setup.cfg")
is_init = basename == "__init__.py"
@ -937,7 +1112,13 @@ def _extract_evidence(
pattern: re.Pattern,
max_matches: int = 3,
) -> str:
"""Pull matching lines as evidence snippets."""
"""Pull matching lines as evidence snippets.
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()
matches = []
for i, line in enumerate(lines, 1):
@ -948,7 +1129,17 @@ def _extract_evidence(
matches.append(f"L{i}: {snippet}")
if len(matches) >= max_matches:
break
return " | ".join(matches) if matches else ""
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}: <multiline match>"
return ""
# Non-Python checkers
@ -1390,6 +1581,394 @@ _PIP_DOWNLOAD_PIN_FLAGS = [
_RE_PKG_NAME_SANITIZE = re.compile(r"[^A-Za-z0-9._-]")
# sdist fallback. `--only-binary :all:` never builds an sdist (no setup.py
# exec), but a wheel-less project then can't be fetched at all and one such
# package fails the whole --with-deps resolve (exit 2) -- a coverage hole. So on
# resolve failure we drop to per-spec and fetch any sdist-only package's raw
# tarball from the PyPI JSON API for scan_archive() to read statically: no pip,
# no build, same no-exec guarantee. Transport failures are still exit 2; only
# "no wheel" is downgraded to a direct fetch.
# How many levels of indirect-dep recovery to chase (a wheel dep whose own child
# is sdist-only, and so on). Bounded with dedup so recovery always terminates.
_MAX_DEP_FOLLOWUP_DEPTH = 2
_SDIST_DOWNLOAD_TIMEOUT = 180
# Never fetch an archive larger than we would be willing to scan (iter_archive_files cap).
_MAX_SDIST_BYTES = HARD_MAX_TOTAL_BYTES
# Direct sdist bytes only ever come from PyPI's own CDN; refuse anything else.
_TRUSTED_PYPI_HOSTS = frozenset({"files.pythonhosted.org", "pypi.org", "pypi.python.org"})
def _spec_pin_version(spec: str) -> str | None:
"""Return the ``==X.Y.Z`` pin from a spec, or None if unpinned."""
m = _RE_PYPI_SPEC_VERSION.search(spec)
return m.group(1) if m else None
def _pypi_json(name: str, version: str | None = None) -> dict | None:
"""Fetch PyPI metadata JSON (read-only HTTPS GET, no exec); None on error.
With ``version`` it fetches that release's document, whose ``requires_dist``
is accurate for the pin (the project-level doc describes only the latest)."""
url = "https://pypi.org/pypi/" + urllib.parse.quote(name, safe = "")
if version:
url += "/" + urllib.parse.quote(version, safe = "")
url += "/json"
try:
req = urllib.request.Request(url, headers = {"Accept": "application/json"})
with urllib.request.urlopen(req, timeout = 30) as resp:
if getattr(resp, "status", 200) != 200:
return None
data = resp.read(16 * 1024 * 1024) # metadata is small; cap regardless
return json.loads(data.decode("utf-8", errors = "replace"))
except Exception:
return None
def _release_files(meta: dict, version: str | None) -> list[dict]:
"""Files for a pinned version, else the latest release's. A pin that is
absent or empty returns [] (never the latest) so a yanked/bad pin fails
closed instead of a different artifact being scanned in its place."""
if version is not None:
return meta.get("releases", {}).get(version) or []
return meta.get("urls", []) or []
def _release_has_wheel(meta: dict, version: str | None) -> bool:
"""True if the (pinned or latest) release publishes any bdist_wheel."""
return any(f.get("packagetype") == "bdist_wheel" for f in _release_files(meta, version))
def _is_trusted_pypi_url(url: str) -> bool:
"""Only download sdist bytes from PyPI's own hosts, over HTTPS."""
try:
parsed = urllib.parse.urlparse(url)
except Exception:
return False
return parsed.scheme == "https" and parsed.hostname in _TRUSTED_PYPI_HOSTS
_MARKER_ENV_VARS = (
"sys_platform",
"platform_system",
"platform_machine",
"platform_release",
"platform_version",
"platform_python_implementation",
"os_name",
"python_version",
"python_full_version",
"implementation_name",
"implementation_version",
)
def _marker_holds_by_default(marker: str) -> bool:
"""Keep (scan) a dep unless its marker is purely ``extra``-gated. The scanner
runs on one OS/Python but a package may be installed on another, so a marker
that can be true on a different target (``sys_platform == 'win32'``,
``python_version == '3.13'``) is always kept; only a marker depending solely
on ``extra`` and false with no extra requested is dropped. Conservative: on
any uncertainty, keep (over-scan, never silently skip)."""
m = marker.strip()
if not m or "extra" not in m:
return True # no extra gate: installed by default on some target -> scan
if any(v in m for v in _MARKER_ENV_VARS):
return True # also platform/python gated: true on some target -> scan
# Pure extra marker: decide by evaluating with no extra requested.
try:
from packaging.markers import Marker, default_environment
env = default_environment()
env["extra"] = ""
return bool(Marker(m).evaluate(env))
except Exception:
# packaging missing/unparseable: drop only a pure positive extra-equality.
return re.fullmatch(r"\s*extra\s*==\s*['\"][^'\"]+['\"]\s*", m) is None
def _requires_dist_names(meta: dict) -> list[str]:
"""Transitive dep specs (name + version specifier) from metadata, to recover
a sdist-only package's tree. The specifier is kept so a pinned malicious
version is fetched, not latest. Drops deps whose marker cannot hold for a
default install."""
info = meta.get("info", {}) or {}
reqs = info.get("requires_dist") or []
specs: list[str] = []
for r in reqs:
if not isinstance(r, str):
continue
head = r
if ";" in r:
head, marker = r.split(";", 1)
if not _marker_holds_by_default(marker):
continue
if not _RE_NAME.match(head.strip()):
continue
# "torch (>=1.10)" / "torch >=1.10" -> "torch>=1.10" (pip-friendly).
specs.append(re.sub(r"\s+", "", head).replace("(", "").replace(")", ""))
return specs
def _requires_dist_for(
name: str,
version: str | None,
project_meta: dict,
errors: list[str] | None = None,
) -> list[str]:
"""Declared deps for the pinned version, read from that release's metadata
(its ``requires_dist`` can differ from latest). Unpinned uses the
project-level (latest) document. A pinned version whose own metadata cannot
be fetched returns [] (never latest's deps) and, when ``errors`` is given,
records an incomplete-scan error so a partial tree is not read as "no deps"."""
if not version:
return _requires_dist_names(project_meta)
vmeta = _pypi_json(name, version)
if vmeta is None:
msg = f"metadata fetch failed for pinned {name}=={version}; dependency scan incomplete"
if errors is None:
print(f" [WARN] {msg}", file = sys.stderr)
else:
errors.append(msg)
return []
return _requires_dist_names(vmeta)
def _download_sdist_direct(
name: str,
version: str | None,
dest: str,
*,
meta: dict | None = None,
) -> tuple[str | None, str | None]:
"""Fetch a project's sdist tarball directly from PyPI (no pip, no build).
Returns ``(filepath, error)``, one non-None. Suffix preserved for the archive
reader; bounded by ``_MAX_SDIST_BYTES`` and restricted to PyPI's CDN.
"""
if meta is None:
meta = _pypi_json(name)
if meta is None:
return None, f"PyPI metadata fetch failed for {name}"
picked: tuple[str, str] | None = None
for f in _release_files(meta, version):
if f.get("packagetype") == "sdist" and f.get("url") and f.get("filename"):
picked = (f["filename"], f["url"])
break
if picked is None:
return None, f"no sdist published for {name} (version={version or 'latest'})"
fname, url = picked
if not _is_trusted_pypi_url(url):
return None, f"refusing non-PyPI sdist URL for {name}: {url[:80]}"
# basename + sanitize keeps the path inside dest; the char class preserves
# the real `.tar.gz` / `.zip` suffix so the archive reader picks the format.
safe_fname = _RE_PKG_NAME_SANITIZE.sub("_", os.path.basename(fname)) or "sdist.tar.gz"
out = os.path.join(dest, safe_fname)
try:
req = urllib.request.Request(url, headers = {"Accept": "application/octet-stream"})
with urllib.request.urlopen(req, timeout = _SDIST_DOWNLOAD_TIMEOUT) as resp:
if getattr(resp, "status", 200) != 200:
return None, f"sdist HTTP {getattr(resp, 'status', '?')} for {name}"
data = resp.read(_MAX_SDIST_BYTES + 1)
if len(data) > _MAX_SDIST_BYTES:
return None, f"sdist for {name} exceeds {_MAX_SDIST_BYTES} byte cap"
with open(out, "wb") as fh:
fh.write(data)
print(
f" [INFO] fetched sdist directly (no build) for {name}: {safe_fname}",
file = sys.stderr,
)
return out, None
except Exception as exc:
return None, f"sdist download failed for {name}: {type(exc).__name__}: {str(exc)[:120]}"
def _pip_download_with_deps(
specs: list[str],
dest: str,
env: dict,
*,
timeout: int = 600,
) -> tuple[int, str]:
"""One `pip download --with-deps --only-binary :all:` call. Returns (rc, stderr)."""
cmd = [
sys.executable,
"-m",
"pip",
"download",
*_PIP_DOWNLOAD_PIN_FLAGS,
"--dest",
dest,
] + list(specs)
try:
proc = subprocess.run(cmd, capture_output = True, text = True, timeout = timeout, env = env)
return proc.returncode, proc.stderr or ""
except subprocess.TimeoutExpired:
return 124, "pip download (with deps) timed out"
def _collect_flat_dir(dest: str, results: list[tuple[str, str]]) -> None:
"""Append every archive in a flat dest dir as (pkg_name, path)."""
for fname in sorted(os.listdir(dest)):
fpath = os.path.join(dest, fname)
if os.path.isfile(fpath):
pkg_name = fname.split("-")[0].replace("_", "-").lower()
results.append((pkg_name, fpath))
def _resolve_per_spec_with_deps(
specs: list[str], dest: str, env: dict, download_errors: list[str]
) -> None:
"""Fallback when the bulk --with-deps resolve fails: resolve each spec alone.
A still-failing spec is probed against PyPI: sdist-only -> direct fetch (deps
recovered one level); wheel-present but tree-unresolvable -> a --no-deps fetch
of just that package. Only a genuine fetch failure errors (caller exits 2);
unfetchable indirect deps are warned, since the named package is still scanned.
"""
sdist_dep_followups: list[str] = []
for spec in specs:
name = _extract_pkg_name(spec)
version = _spec_pin_version(spec)
cmd = [
sys.executable,
"-m",
"pip",
"download",
*_PIP_DOWNLOAD_PIN_FLAGS,
"--dest",
dest,
spec,
]
try:
proc = subprocess.run(cmd, capture_output = True, text = True, timeout = 300, env = env)
except subprocess.TimeoutExpired:
download_errors.append(f"per-spec --with-deps timed out for {spec}")
continue
if proc.returncode == 0:
continue # archives landed in dest; collected by the caller
meta = _pypi_json(name)
if meta is not None and not _release_has_wheel(meta, version):
fpath, serr = _download_sdist_direct(name, version, dest, meta = meta)
if fpath is None:
download_errors.append(serr or f"sdist fetch failed for {name}")
continue
sdist_dep_followups.extend(_requires_dist_for(name, version, meta, download_errors))
continue
# Has a wheel but the full transitive tree won't co-resolve
# (ResolutionImpossible) -- typically a package the requirement file
# installs with --no-deps by design (e.g. descript-audio-codec, whose
# own pins conflict). Fetch just the package itself with --no-deps so it
# is still scanned; its conflicting deps are out of scope here (the file
# excludes them on purpose). Only a genuine fetch failure is an error.
nd_cmd = [
sys.executable,
"-m",
"pip",
"download",
"--no-deps",
*_PIP_DOWNLOAD_PIN_FLAGS,
"--dest",
dest,
spec,
]
try:
nd = subprocess.run(nd_cmd, capture_output = True, text = True, timeout = 180, env = env)
except subprocess.TimeoutExpired:
download_errors.append(f"per-spec --no-deps timed out for {spec}")
continue
if nd.returncode == 0:
print(
f" [INFO] {name}: full tree unresolvable; scanned the package "
f"alone (--no-deps), recovering deps individually.",
file = sys.stderr,
)
# The --with-deps failure may have been a sdist-only TRANSITIVE dep,
# which --no-deps skips. Recover the declared deps so that class is
# still scanned (each is fetched as a wheel or direct sdist below).
if meta is not None:
sdist_dep_followups.extend(_requires_dist_for(name, version, meta, download_errors))
continue
# --no-deps also failed: last-ditch sdist fetch at the pinned version.
if meta is not None:
fpath, _serr = _download_sdist_direct(name, version, dest, meta = meta)
if fpath is not None:
continue
download_errors.append(
f"per-spec failed for {spec} (with-deps and --no-deps): " f"{nd.stderr.strip()[:240]}"
)
# Recover the transitive deps of sdist-only packages. A depth-bounded,
# deduped worklist so a wheel dep whose own child is sdist-only is itself
# fetched (--no-deps) and scanned -- not silently dropped -- and that child
# is then recovered in turn. `dep` carries the version specifier so a pinned
# version is fetched.
seen: set[str] = set()
worklist: list[tuple[str, int]] = [(d, 0) for d in sdist_dep_followups]
while worklist:
dep, depth = worklist.pop()
dep_name = _extract_pkg_name(dep)
key = _norm_pkg(dep_name)
if key in seen:
continue
seen.add(key)
dep_ver = _spec_pin_version(dep)
cmd = [
sys.executable,
"-m",
"pip",
"download",
*_PIP_DOWNLOAD_PIN_FLAGS,
"--dest",
dest,
dep,
]
try:
proc = subprocess.run(cmd, capture_output = True, text = True, timeout = 300, env = env)
except subprocess.TimeoutExpired:
print(f" [WARN] dep download timed out for {dep}", file = sys.stderr)
continue
if proc.returncode == 0:
continue
meta = _pypi_json(dep_name)
if meta is None:
print(f" [WARN] could not resolve indirect dep {dep}; skipping", file = sys.stderr)
continue
if not _release_has_wheel(meta, dep_ver):
fpath, serr = _download_sdist_direct(dep_name, dep_ver, dest, meta = meta)
if fpath is None:
print(f" [WARN] could not fetch sdist dep {dep}: {serr}", file = sys.stderr)
elif depth < _MAX_DEP_FOLLOWUP_DEPTH:
worklist.extend((d, depth + 1) for d in _requires_dist_for(dep_name, dep_ver, meta))
continue
# Wheel published but its tree won't co-resolve (a sdist-only child).
# Fetch the dep alone so it is scanned, then chase its own declared deps.
nd_cmd = [
sys.executable,
"-m",
"pip",
"download",
"--no-deps",
*_PIP_DOWNLOAD_PIN_FLAGS,
"--dest",
dest,
dep,
]
try:
nd = subprocess.run(nd_cmd, capture_output = True, text = True, timeout = 180, env = env)
except subprocess.TimeoutExpired:
print(f" [WARN] dep --no-deps timed out for {dep}", file = sys.stderr)
continue
if nd.returncode == 0:
if depth < _MAX_DEP_FOLLOWUP_DEPTH:
worklist.extend((d, depth + 1) for d in _requires_dist_for(dep_name, dep_ver, meta))
continue
fpath, _serr = _download_sdist_direct(dep_name, dep_ver, dest, meta = meta)
if fpath is None:
print(f" [WARN] could not resolve indirect dep {dep}; skipping", file = sys.stderr)
elif depth < _MAX_DEP_FOLLOWUP_DEPTH:
worklist.extend((d, depth + 1) for d in _requires_dist_for(dep_name, dep_ver, meta))
def download_packages(
specs: list[str],
dest: str,
@ -1403,49 +1982,36 @@ def download_packages(
summaries. A non-empty ``download_errors`` MUST make the caller exit
non-zero so a partial scan can't masquerade as "0 findings, all clean".
with_deps=True downloads the full transitive tree in one pip call (flat dir);
with_deps=False (default) downloads each spec individually with --no-deps.
with_deps=True downloads the full transitive tree (flat dir); a bulk resolve
failure (sdist-only package or version conflict) degrades to per-spec
resolution + direct sdist fetch rather than blanking the shard.
with_deps=False (default) downloads each spec individually with --no-deps,
also falling back to a direct sdist fetch when no wheel exists.
"""
results: list[tuple[str, str]] = []
download_errors: list[str] = []
env = _pip_download_env()
if with_deps:
# Single pip download for all specs + transitive deps. `--only-binary
# :all:` refuses sdists so we never execute setup.py for metadata.
os.makedirs(dest, exist_ok = True)
cmd = [
sys.executable,
"-m",
"pip",
"download",
*_PIP_DOWNLOAD_PIN_FLAGS,
"--dest",
dest,
] + specs
try:
proc = subprocess.run(
cmd,
capture_output = True,
text = True,
timeout = 600, # transitive resolution is slow
env = env,
# Fast path: resolve + download the whole transitive tree in one call.
# `--only-binary :all:` refuses sdists so we never build for metadata.
rc, stderr = _pip_download_with_deps(specs, dest, env)
if rc != 0:
# Atomic resolve failed -- a sdist-only package, or a cross-package
# version conflict (ResolutionImpossible). Degrade to per-spec
# resolution so one bad spec can't blank the shard, then direct-fetch
# any sdist-only holdouts (no build). Genuine failures still record an
# error so the caller exits 2.
print(
f" [INFO] bulk --with-deps resolve failed "
f"({stderr.strip()[:160]}); falling back to per-spec resolution "
f"for {len(specs)} spec(s).",
file = sys.stderr,
)
if proc.returncode != 0:
msg = f"pip download (with deps) failed: " f"{proc.stderr.strip()[:500]}"
print(f" [ERROR] {msg}", file = sys.stderr)
download_errors.append(msg)
except subprocess.TimeoutExpired:
msg = "pip download (with deps) timed out"
print(f" [ERROR] {msg}", file = sys.stderr)
download_errors.append(msg)
# Collect every archive that landed in dest
for fname in sorted(os.listdir(dest)):
fpath = os.path.join(dest, fname)
if os.path.isfile(fpath):
pkg_name = fname.split("-")[0].replace("_", "-").lower()
results.append((pkg_name, fpath))
_resolve_per_spec_with_deps(specs, dest, env, download_errors)
# Collect everything that landed (bulk OR per-spec OR direct sdist).
_collect_flat_dir(dest, results)
else:
for spec in specs:
raw_name = _extract_pkg_name(spec)
@ -1465,22 +2031,25 @@ def download_packages(
spec,
]
try:
proc = subprocess.run(
cmd,
capture_output = True,
text = True,
timeout = 120,
env = env,
)
if proc.returncode != 0:
msg = f"pip download failed for {spec}: " f"{proc.stderr.strip()[:500]}"
print(f" [ERROR] {msg}", file = sys.stderr)
download_errors.append(msg)
continue
proc = subprocess.run(cmd, capture_output = True, text = True, timeout = 120, env = env)
except subprocess.TimeoutExpired:
msg = f"pip download timed out for {spec}"
print(f" [ERROR] {msg}", file = sys.stderr)
download_errors.append(msg)
download_errors.append(f"pip download timed out for {spec}")
continue
if proc.returncode != 0:
# No wheel? Direct-fetch the sdist (no build) before erroring.
name = _extract_pkg_name(spec)
version = _spec_pin_version(spec)
meta = _pypi_json(name)
if meta is not None and not _release_has_wheel(meta, version):
fpath, serr = _download_sdist_direct(name, version, pkg_dir, meta = meta)
if fpath is not None:
results.append((spec, fpath))
continue
download_errors.append(serr or f"sdist fetch failed for {name}")
continue
download_errors.append(
f"pip download failed for {spec}: {proc.stderr.strip()[:300]}"
)
continue
for fname in os.listdir(pkg_dir):
@ -1722,8 +2291,10 @@ def find_safe_version(
scan_dir = os.path.join(tmpdir, f"{name}_{ver}")
os.makedirs(scan_dir, exist_ok = True)
downloaded = download_packages([spec], scan_dir)
downloaded, download_errors = download_packages([spec], scan_dir)
if not downloaded:
for err in download_errors:
print(f" [WARN] {err}", file = sys.stderr)
continue
clean = True
@ -1856,9 +2427,12 @@ def _run_fix(critical_pkgs: set[str], entries: list[dict], max_search: int) -> N
# If no pinned version, download to find what pip resolves
dl_dir = os.path.join(tmpdir, f"resolve_{pkg_name}")
os.makedirs(dl_dir, exist_ok = True)
downloaded = download_packages([pkg_name], dl_dir)
downloaded, download_errors = download_packages([pkg_name], dl_dir)
if downloaded:
current_ver = get_downloaded_version(downloaded[0][1])
else:
for err in download_errors:
print(f" [WARN] {err}", file = sys.stderr)
shutil.rmtree(dl_dir, ignore_errors = True)
if not current_ver:
@ -1940,6 +2514,113 @@ def _find_requirements_files(root: str) -> list[str]:
return sorted(results)
# Baseline allowlist: triaged known-good CRITICAL/HIGH findings so the gate can
# enforce without drowning in legitimate-library noise. Matched on
# ``(package, 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"
)
def _norm_pkg(name: str) -> str:
"""PEP 503-style normalization so requests/Requests/req_uests collapse."""
return re.sub(r"[-_.]+", "-", (name or "").strip().lower())
# Leading "<name>-<version>/" archive root of an sdist member, which carries the
# version. Stripping it (but keeping the rest of the path) gives a key that is
# stable across version bumps yet still distinguishes same-named files.
_RE_SDIST_ROOT = re.compile(r"^[^/]+-\d[^/]*/")
def _relpath_in_package(filename: str) -> str:
"""Package-relative path: drop an sdist's version-carrying archive root.
Wheel members are already package-relative (``numba/cuda/utils.py``); sdist
members sit under ``numba-0.60.0/...``, so strip that one leading segment.
"""
return _RE_SDIST_ROOT.sub("", filename, count = 1)
def _finding_key(f: Finding) -> tuple[str, str, str]:
"""Stable allowlist key: normalized package, package-relative path, check.
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)
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:
data = json.load(fh)
except FileNotFoundError:
return set()
except (OSError, json.JSONDecodeError) as exc:
print(f" [WARN] could not read baseline {path}: {exc}", file = sys.stderr)
return set()
keys: set[tuple[str, str, str]] = set()
for e in data.get("entries", []):
try:
keys.add((_norm_pkg(e["package"]), _relpath_in_package(e["file"]), e["check"]))
except (KeyError, TypeError):
continue
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]] = set()
for f in sorted(findings, key = lambda f: SEVERITY_ORDER.get(f.severity, 99)):
if f.severity not in (CRITICAL, HIGH):
continue
key = _finding_key(f)
if key in seen:
continue
seen.add(key)
entries.append(
{
"package": f.package,
"file": _relpath_in_package(f.filename),
"check": f.check,
"severity": f.severity,
"evidence": f.evidence[: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/severity are for review only. Regenerate with "
"--write-baseline AFTER reviewing every line."
),
"version": 1,
"entries": entries,
}
with open(path, "w", encoding = "utf-8") as fh:
json.dump(doc, fh, indent = 2, sort_keys = False)
fh.write("\n")
print(f" Wrote {len(entries)} baseline entr(y/ies) to {path}")
def _partition_baseline(
findings: list[Finding], baseline: set[tuple[str, str, str]]
) -> tuple[list[Finding], list[Finding]]:
"""Split findings into (active, suppressed) by allowlist membership."""
if not baseline:
return list(findings), []
active, suppressed = [], []
for f in findings:
(suppressed if _finding_key(f) in baseline else active).append(f)
return active, suppressed
# Main
@ -1986,6 +2667,30 @@ def main() -> int:
metavar = "N",
help = "Max older versions to scan when searching for safe version (default: 10)",
)
parser.add_argument(
"--baseline",
metavar = "FILE",
default = None,
help = (
"Allowlist JSON of triaged known-good findings to suppress. "
f"Defaults to {os.path.basename(_DEFAULT_BASELINE_PATH)} next to this "
"script if present."
),
)
parser.add_argument(
"--no-baseline",
action = "store_true",
help = "Ignore the auto-discovered baseline allowlist.",
)
parser.add_argument(
"--write-baseline",
metavar = "FILE",
default = None,
help = (
"Write the current CRITICAL/HIGH findings to FILE as an allowlist, "
"then exit 0. Review every entry before committing it."
),
)
args = parser.parse_args()
# --scan-dir: auto-discover requirements files
@ -2066,11 +2771,34 @@ def main() -> int:
finally:
shutil.rmtree(tmpdir, ignore_errors = True)
print_findings(all_findings)
# Baseline allowlist: suppress triaged, known-good findings so the CI gate
# can be enforcing without red-failing on legitimate-library noise.
if args.no_baseline:
baseline_path = None
elif args.baseline:
baseline_path = args.baseline
elif os.path.isfile(_DEFAULT_BASELINE_PATH):
baseline_path = _DEFAULT_BASELINE_PATH
else:
baseline_path = None
baseline = _load_baseline(baseline_path) if baseline_path else set()
# --fix mode: auto-search for safe versions
if args.fix and all_findings:
critical_pkgs = {f.package for f in all_findings if f.severity == CRITICAL}
active, suppressed = _partition_baseline(all_findings, baseline)
print_findings(active)
if suppressed:
crit_s = sum(1 for f in suppressed if f.severity == CRITICAL)
high_s = sum(1 for f in suppressed if f.severity == HIGH)
med_s = sum(1 for f in suppressed if f.severity == MEDIUM)
print(
f"\n {len(suppressed)} finding(s) suppressed by baseline "
f"{baseline_path} "
f"({crit_s} CRITICAL, {high_s} HIGH, {med_s} MEDIUM)."
)
# --fix mode: auto-search for safe versions (only real, non-baselined ones)
if args.fix and active:
critical_pkgs = {f.package for f in active if f.severity == CRITICAL}
if critical_pkgs:
print(
f"\n --fix: Searching for safe versions of {len(critical_pkgs)} CRITICAL package(s)..."
@ -2079,6 +2807,7 @@ def main() -> int:
# Surface pip-download failures BEFORE the exit code so a partial download
# can't masquerade as "0 findings, all clean" (silent-failure hardening 4).
# Also keeps us from writing a baseline from an incomplete scan.
if download_errors:
print(
f"\n {'=' * 72}\n"
@ -2095,8 +2824,16 @@ def main() -> int:
)
return 2
# Exit code: 1 if any CRITICAL or HIGH
if any(f.severity in (CRITICAL, HIGH) for f in all_findings):
# --write-baseline: persist the full current CRITICAL/HIGH set as the new
# allowlist (ignoring any loaded baseline), then exit 0. Only reached once
# the scan is known complete.
if args.write_baseline:
_write_baseline(args.write_baseline, all_findings)
return 0
# Exit code: 1 only if a NON-baselined CRITICAL or HIGH remains. This is the
# signal CI gates on once the baseline reaches a clean run.
if any(f.severity in (CRITICAL, HIGH) for f in active):
return 1
return 0

File diff suppressed because it is too large Load diff

View file

@ -22,15 +22,64 @@ function Uninstall-UnslothStudio {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return }
if (-not (Test-Path -LiteralPath $Path)) { return }
for ($attempt = 1; $attempt -le 3; $attempt++) {
for ($attempt = 1; $attempt -le 4; $attempt++) {
try {
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop
} catch {
if ($attempt -lt 4) { Start-Sleep -Milliseconds 700; continue }
_Substep "could not remove: $Path ($($_.Exception.Message))" "Yellow"
return
}
# Remove-Item -Recurse can report success yet leave a transiently-locked
# child (e.g. unsloth.ico in Explorer's icon cache); verify + retry so we
# never falsely claim "removed" or orphan the dir.
if (-not (Test-Path -LiteralPath $Path)) {
_Substep "removed: $Path" "Green"
return
} catch {
if ($attempt -lt 3) { Start-Sleep -Milliseconds 700; continue }
_Substep "could not remove: $Path ($($_.Exception.Message))" "Yellow"
}
if ($attempt -lt 4) { Start-Sleep -Milliseconds 700; continue }
_Substep "still present (files held open): $Path" "Yellow"
}
}
# Remove the shared data dir, but keep unsloth.ico if a WSL shortcut still points
# at it (else that shortcut blanks); uninstall.sh drops it when WSL is removed.
function _RemoveDataDirKeepingWslIcon {
param(
[string]$DataDir,
# WSL-shortcut search dirs; default Start Menu + Desktop, overridable for tests.
[string[]]$ShortcutDirs = $null
)
if ([string]::IsNullOrWhiteSpace($DataDir)) { return }
if (-not (Test-Path -LiteralPath $DataDir)) { return }
# $null = not passed (use defaults); test $null not truthiness so an explicit
# @() is honored (-not @() is $true).
if ($null -eq $ShortcutDirs) {
# Guard $env:APPDATA: it can be unset in service/CI Windows contexts, where
# an unguarded Join-Path emits a noisy parameter-binding error.
$ShortcutDirs = @()
if (-not [string]::IsNullOrWhiteSpace($env:APPDATA)) {
$ShortcutDirs += Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs"
}
try {
$desktop = [Environment]::GetFolderPath("Desktop")
if (-not [string]::IsNullOrWhiteSpace($desktop)) { $ShortcutDirs += $desktop }
} catch {}
}
$wslShortcuts = @()
foreach ($d in $ShortcutDirs) {
if ($d -and (Test-Path -LiteralPath $d)) {
$wslShortcuts += Get-ChildItem -LiteralPath $d -Filter "Unsloth Studio (WSL*.lnk" -ErrorAction SilentlyContinue
}
}
if (@($wslShortcuts).Count -eq 0) {
_RemovePath $DataDir
return
}
# A WSL shortcut survives: drop everything except its shared icon.
_Substep "keeping $(Join-Path $DataDir 'unsloth.ico') for the WSL shortcut" "Gray"
Get-ChildItem -LiteralPath $DataDir -Force -ErrorAction SilentlyContinue | ForEach-Object {
if ($_.Name -ne "unsloth.ico") { _RemovePath $_.FullName }
}
}
@ -287,6 +336,9 @@ function Uninstall-UnslothStudio {
$defaultUnslothHome = if ($env:USERPROFILE) { Join-Path $env:USERPROFILE ".unsloth" } else { $null }
$defaultLlamaCpp = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome "llama.cpp" } else { $null }
$defaultCache = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome ".cache" } else { $null }
# Isolated Node.js runtime (install_node_prebuilt.py), a sibling of studio in
# default mode. No-op in env/custom mode (nested under the custom root) and absent.
$defaultNode = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome "node" } else { $null }
# llama.cpp atomic-install staging root (install_llama_prebuilt.py .staging,
# sibling of the install dir). Usually pruned after activate, but an interrupted
# build can leave a "<name>.staging-XXXX" tree; removing it lets the empty-dir
@ -310,7 +362,7 @@ function Uninstall-UnslothStudio {
_StopStudioProcesses -KnownRoots $knownRoots
# Also stop anything holding a handle on the exact paths we delete (llama-server,
# the CLI shim, an mp-fork python with a venv DLL) so the dir delete isn't refused.
_StopProcessesLockingRoots -Roots (@($knownRoots) + @($defaultDataDir, $defaultLlamaCpp, $defaultCache))
_StopProcessesLockingRoots -Roots (@($knownRoots) + @($defaultDataDir, $defaultLlamaCpp, $defaultCache, $defaultNode))
# ── Remove custom-root install trees ──
_Step "Removing data and install directories..."
@ -328,12 +380,18 @@ function Uninstall-UnslothStudio {
# Default install dir (always at %USERPROFILE%\.unsloth\studio when present).
if ($defaultStudioHome) { _RemovePath $defaultStudioHome }
# Default data dir.
if ($defaultDataDir) { _RemovePath $defaultDataDir }
if ($defaultDataDir) { _RemoveDataDirKeepingWslIcon $defaultDataDir }
# Default-mode shared llama.cpp build + cache (siblings of studio under
# ~/.unsloth). No-op in env/custom mode and when absent.
if ($defaultLlamaCpp) { _RemovePath $defaultLlamaCpp }
if ($defaultCache) { _RemovePath $defaultCache }
# Isolated Node.js runtime (sibling of studio under ~/.unsloth). No-op in env/
# custom mode (nested under the custom root, removed with it) and when absent.
if ($defaultNode) { _RemovePath $defaultNode }
if ($defaultStaging) { _RemovePath $defaultStaging }
# llama.cpp install lock (serializes the shared build); a stray lock keeps
# ~/.unsloth from being pruned below. No-op in env/custom mode and when absent.
if ($defaultUnslothHome) { _RemovePath (Join-Path $defaultUnslothHome ".llama.cpp.install.lock") }
# Drop ~/.unsloth itself, but ONLY if now empty -- never nuke unrelated content.
if ($defaultUnslothHome -and (Test-Path -LiteralPath $defaultUnslothHome) -and
-not (Get-ChildItem -LiteralPath $defaultUnslothHome -Force -ErrorAction SilentlyContinue)) {
@ -362,6 +420,11 @@ function Uninstall-UnslothStudio {
}
} catch { }
# Re-sweep: the first pass may have left unsloth.ico locked by Explorer/SMEH for
# the native shortcut; that handle is now freed. (A surviving WSL shortcut still
# keeps the icon -- see the helper.)
if ($defaultDataDir -and (Test-Path -LiteralPath $defaultDataDir)) { _RemoveDataDirKeepingWslIcon $defaultDataDir }
# ── Clean user PATH and registry backup ──
_Step "Cleaning user PATH and registry..."
try {

View file

@ -217,10 +217,16 @@ _remove_path "$HOME/.unsloth/studio"
# when absent. A user-set UNSLOTH_LLAMA_CPP_PATH is intentionally kept.
_remove_path "$HOME/.unsloth/llama.cpp"
_remove_path "$HOME/.unsloth/.cache"
# Isolated Node.js runtime (install_node_prebuilt.py), a sibling of studio in
# default mode. No-op in env/custom mode (nested under the custom root) and absent.
_remove_path "$HOME/.unsloth/node"
# llama.cpp atomic-install staging root (install_llama_prebuilt.py .staging).
# Normally pruned after activate, but an interrupted build can leave it behind;
# removing it lets the rmdir below succeed. No-op in env/custom mode and absent.
_remove_path "$HOME/.unsloth/.staging"
# llama.cpp install lock (serializes the shared build); a stray one keeps ~/.unsloth
# from being pruned below. No-op in env/custom mode and when absent.
_remove_path "$HOME/.unsloth/.llama.cpp.install.lock"
# ROCm-on-WSL helper artifacts (librocdxg build clone + smoke-test venv). No-op
# where they don't exist; removing them lets the rmdir below succeed.
_remove_path "$HOME/.unsloth/librocdxg"
@ -298,11 +304,50 @@ case "$_os" in
Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue
} catch { }
}
}
# Keep the shared icon while any Unsloth shortcut still uses it (native
# install or another WSL distro); drop it only with the last one.
$iconInUse = $false;
foreach ($d in $dirs) {
if (-not $d -or -not (Test-Path -LiteralPath $d)) { continue }
if (Get-ChildItem -LiteralPath $d -Filter "Unsloth Studio*.lnk" -ErrorAction SilentlyContinue) { $iconInUse = $true; break }
}
# Guard LOCALAPPDATA: empty on a service/SYSTEM account makes
# Join-Path throw, aborting the icon cleanup (mirror uninstall.ps1).
if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
$iconDir = Join-Path $env:LOCALAPPDATA "Unsloth Studio";
$ico = Join-Path $iconDir "unsloth.ico";
if ((-not $iconInUse) -and (Test-Path -LiteralPath $ico)) { Remove-Item -LiteralPath $ico -Force -ErrorAction SilentlyContinue }
if ((Test-Path -LiteralPath $iconDir) -and -not (Get-ChildItem -LiteralPath $iconDir -Force -ErrorAction SilentlyContinue)) { Remove-Item -LiteralPath $iconDir -Recurse -Force -ErrorAction SilentlyContinue }
}' >/dev/null 2>&1 || true
fi
# Fallback when powershell.exe can't run (interop disabled): remove the
# WSL .lnk files via drvfs. The "Unsloth Studio (WSL..." name is
# WSL-specific, so a native install's "Unsloth Studio.lnk" never matches.
# Remove $1's shared unsloth.ico only if no Unsloth shortcut (native install
# or another WSL distro) still uses it, then drop the dir if empty. Reciprocal
# of uninstall.ps1's _RemoveDataDirKeepingWslIcon (keeps the icon for a
# surviving WSL shortcut when the native side is removed).
_drop_shared_icon_if_unused() {
_du="$1"
_icodir="$_du/AppData/Local/Unsloth Studio"
_icon_in_use=0
for _sd in \
"$_du/Desktop" \
"$_du/OneDrive/Desktop" \
"$_du"/OneDrive*/Desktop \
"$_du/AppData/Roaming/Microsoft/Windows/Start Menu/Programs"; do
[ -d "$_sd" ] || continue
for _any in "$_sd"/"Unsloth Studio"*.lnk; do
[ -e "$_any" ] && { _icon_in_use=1; break; }
done
[ "$_icon_in_use" = "1" ] && break
done
if [ "$_icon_in_use" = "0" ]; then
[ -f "$_icodir/unsloth.ico" ] && rm -f "$_icodir/unsloth.ico" 2>/dev/null || true
fi
[ -d "$_icodir" ] && rmdir "$_icodir" 2>/dev/null || true
}
# Fallback when powershell.exe can't run (interop disabled): remove WSL .lnk
# files via drvfs. The "Unsloth Studio (WSL..." name is WSL-specific, so a
# native install's "Unsloth Studio.lnk" never matches.
if [ "$_ps_ran" = "0" ]; then
for _drive in /mnt/c /mnt/d /mnt/e; do
[ -d "$_drive/Users" ] || continue
@ -325,6 +370,8 @@ case "$_os" in
done
fi
done
# Drop the shared icon only when no shortcut still needs it.
_drop_shared_icon_if_unused "$_udir"
done
done
fi

View file

@ -581,9 +581,16 @@ def compare(before_src: str, after_src: str, path: str) -> list[tuple[str, str]]
)
# 3. TARGET-CHANGED (same scope+name resolves to a different import target)
# Only a *swap* is dangerous: a BEFORE target that is no longer reachable in
# AFTER means a reference was silently re-pointed. A pure superset growth
# (tbefore <= tafter) is the benign `import pkg.subA` + `import pkg.subB`
# case: both statements bind the same top-level name `pkg` to the same
# package object and only *add* submodule attributes (e.g. adding
# `import urllib.error` next to `import urllib.request`). Nothing the name
# resolved to before is lost, so no reference is re-pointed -- skip it.
for key, tafter in b["target_by_use"].items():
tbefore = a["target_by_use"].get(key)
if tbefore and tbefore != tafter:
if tbefore and tbefore != tafter and (tbefore - tafter):
findings.append(
(
"BLOCKER",

View file

@ -277,6 +277,13 @@
"min_p": 0.01,
"repetition_penalty": 1.0
},
"minimax-m2.7": {
"temperature": 1.0,
"top_p": 0.95,
"top_k": 40,
"min_p": 0.01,
"repetition_penalty": 1.0
},
"minimax-m2.5": {
"temperature": 1.0,
"top_p": 0.95,
@ -390,7 +397,7 @@
"deepseek-r1", "deepseek-v3", "deepseek-ocr",
"glm-5", "glm-4",
"nemotron",
"minimax-m2.5", "minimax",
"minimax-m2.7", "minimax-m2.5", "minimax",
"gpt-oss", "granite-4",
"kimi-k2", "kimi",
"lfm2", "smollm", "olmo", "falcon", "ernie", "seed", "grok", "mimo"

View file

@ -2,7 +2,6 @@
# Used for models without specific configurations
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -48,7 +47,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 0.7
top_p: 0.95
top_k: -1

View file

@ -3,7 +3,6 @@
# Also applies to: unsloth/ERNIE-4.5-21B-A3B-PT
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -42,6 +41,3 @@ logging:
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false

View file

@ -4,7 +4,6 @@
# added inference parameters from unsloth notebook
training:
trust_remote_code: true
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -49,7 +48,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: true
temperature: 1.5
min_p: 0.1

View file

@ -3,7 +3,6 @@
# Also applies to: tiiuae/Falcon-H1-0.5B-Instruct, unsloth/Falcon-H1-0.5B-Instruct
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -42,6 +41,3 @@ logging:
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false

View file

@ -4,7 +4,6 @@
# added inference parameters from Ollama
training:
trust_remote_code: false
max_seq_length: 4096
# num_epochs: 4
num_epochs: 0
@ -45,6 +44,5 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 0
top_p: 0.9

View file

@ -4,7 +4,6 @@
# added inference parameters from unsloth guides
training:
trust_remote_code: false
max_seq_length: 4096
# num_epochs: 4
num_epochs: 0
@ -45,7 +44,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.0
top_k: 64
top_p: 0.95

View file

@ -2,7 +2,6 @@
# Based on Gemma2_(9B)-Alpaca.ipynb (same defaults for larger models)
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -41,6 +40,3 @@ logging:
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false

View file

@ -3,7 +3,6 @@
# Also applies to: unsloth/gemma-2-2b-bnb-4bit, google/gemma-2-2b
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -42,6 +41,3 @@ logging:
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false

View file

@ -4,7 +4,6 @@
# added inference parameters from unsloth guides
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -45,7 +44,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.0
top_k: 64
top_p: 0.95

View file

@ -4,7 +4,6 @@
# added inference parameters from unsloth guides
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -43,7 +42,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.0
top_k: 64
top_p: 0.95

View file

@ -4,7 +4,6 @@
# added inference parameters from unsloth guides
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -43,7 +42,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.0
top_k: 64
top_p: 0.95

View file

@ -4,7 +4,6 @@
# added inference parameters from unsloth guides
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 2
num_epochs: 0
@ -43,7 +42,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.0
top_k: 64
top_p: 0.95

View file

@ -4,7 +4,6 @@
# added inference parameters from unsloth guides
training:
trust_remote_code: false
max_seq_length: 1024
# num_epochs: 4
num_epochs: 0
@ -45,7 +44,6 @@ logging:
audio_input: true
inference:
trust_remote_code: false
temperature: 1.0
top_k: 64
top_p: 0.95

View file

@ -4,7 +4,6 @@
# added inference parameters from unsloth guides
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 2
num_epochs: 0
@ -45,7 +44,6 @@ logging:
audio_input: true
inference:
trust_remote_code: false
temperature: 1.0
top_k: 64
top_p: 0.95

View file

@ -2,7 +2,6 @@
# Also applies to: google/gemma-4-26B-A4B-it, unsloth/gemma-4-26B-A4B-it-GGUF
training:
trust_remote_code: false
max_seq_length: 2048
num_epochs: 0
learning_rate: 2e-4
@ -40,7 +39,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.0
top_p: 0.95
top_k: 64

View file

@ -2,7 +2,6 @@
# Also applies to: google/gemma-4-26B-A4B
training:
trust_remote_code: false
max_seq_length: 2048
num_epochs: 0
learning_rate: 2e-4
@ -40,7 +39,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.0
top_p: 0.95
top_k: 64

View file

@ -2,7 +2,6 @@
# Also applies to: google/gemma-4-31B-it, unsloth/gemma-4-31B-it-GGUF
training:
trust_remote_code: false
max_seq_length: 2048
num_epochs: 0
learning_rate: 2e-4
@ -40,7 +39,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.0
top_p: 0.95
top_k: 64

View file

@ -2,7 +2,6 @@
# Also applies to: google/gemma-4-31B
training:
trust_remote_code: false
max_seq_length: 2048
num_epochs: 0
learning_rate: 2e-4
@ -40,7 +39,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.0
top_p: 0.95
top_k: 64

View file

@ -2,7 +2,6 @@
# Also applies to: google/gemma-4-E2B-it, unsloth/gemma-4-E2B-it-GGUF
training:
trust_remote_code: false
max_seq_length: 2048
num_epochs: 0
learning_rate: 2e-4
@ -40,7 +39,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.0
top_p: 0.95
top_k: 64

View file

@ -2,7 +2,6 @@
# Also applies to: google/gemma-4-E2B
training:
trust_remote_code: false
max_seq_length: 2048
num_epochs: 0
learning_rate: 2e-4
@ -40,7 +39,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.0
top_p: 0.95
top_k: 64

View file

@ -2,7 +2,6 @@
# Also applies to: google/gemma-4-E4B-it, unsloth/gemma-4-E4B-it-GGUF
training:
trust_remote_code: false
max_seq_length: 2048
num_epochs: 0
learning_rate: 2e-4
@ -40,7 +39,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.0
top_p: 0.95
top_k: 64

View file

@ -2,7 +2,6 @@
# Also applies to: google/gemma-4-E4B
training:
trust_remote_code: false
max_seq_length: 2048
num_epochs: 0
learning_rate: 2e-4
@ -40,7 +39,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.0
top_p: 0.95
top_k: 64

View file

@ -4,7 +4,6 @@
# added inference parameters from unsloth guides
training:
trust_remote_code: false
max_seq_length: 4096
# num_epochs: 4
num_epochs: 0
@ -45,7 +44,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.0
top_p: 1.0
top_k: 0

View file

@ -4,7 +4,6 @@
# added inference parameters from unsloth guides
training:
trust_remote_code: false
max_seq_length: 1024
# num_epochs: 4
num_epochs: 0
@ -45,7 +44,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.0
top_p: 1.0
top_k: 0

View file

@ -4,7 +4,6 @@
# added inference parameters from unsloth guides
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -47,7 +46,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 0.0
top_p: 1.0
top_k: 0

View file

@ -4,7 +4,6 @@
# added inference parameters from unsloth guides
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -47,7 +46,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 0.0
top_p: 1.0
top_k: 0

View file

@ -4,7 +4,6 @@
# added inference parameters from unsloth notebook
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -43,7 +42,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.5
min_p: 0.1

View file

@ -3,7 +3,6 @@
# Also applies to: unsloth/Llama-3.2-1B-Instruct-unsloth-bnb-4bit, meta-llama/Llama-3.2-1B-Instruct, unsloth/Llama-3.2-1B-Instruct-bnb-4bit, RedHatAI/Llama-3.2-1B-Instruct-FP8, unsloth/Llama-3.2-1B-Instruct-FP8-Block, unsloth/Llama-3.2-1B-Instruct-FP8-Dynamic
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 5
num_epochs: 0
@ -42,6 +41,3 @@ logging:
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false

View file

@ -4,7 +4,6 @@
# added inference parameters from unsloth notebook
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -45,7 +44,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.5
min_p: 0.1

View file

@ -4,7 +4,6 @@
# added inference parameters from unsloth notebook
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -45,7 +44,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.5
min_p: 0.1

View file

@ -3,7 +3,6 @@
# Also applies to: unsloth/Meta-Llama-3.1-8B-bnb-4bit, unsloth/Meta-Llama-3.1-8B-unsloth-bnb-4bit, meta-llama/Meta-Llama-3.1-8B, unsloth/Meta-Llama-3.1-8B, unsloth/Meta-Llama-3.1-70B, meta-llama/Meta-Llama-3.1-70B, unsloth/Meta-Llama-3.1-405B-bnb-4bit, meta-llama/Meta-Llama-3.1-405B
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -42,6 +41,3 @@ logging:
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false

View file

@ -3,7 +3,6 @@
# Also applies to: "unsloth/Meta-Llama-3.1-8B-Instruct-unsloth-bnb-4bit", "meta-llama/Meta-Llama-3.1-8B-Instruct", "unsloth/Meta-Llama-3.1-8B-Instruct","RedHatAI/Llama-3.1-8B-Instruct-FP8","unsloth/Llama-3.1-8B-Instruct-FP8-Block","unsloth/Llama-3.1-8B-Instruct-FP8-Dynamic"
training:
trust_remote_code: false
max_seq_length: 8192
# num_epochs: 4
num_epochs: 0
@ -42,6 +41,3 @@ logging:
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false

View file

@ -3,7 +3,6 @@
# Also applies to: unsloth/llama-3-8b-Instruct, meta-llama/Meta-Llama-3-8B-Instruct
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -42,6 +41,3 @@ logging:
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false

View file

@ -3,7 +3,6 @@
# Also applies to: unsloth/llama-3-8b, meta-llama/Meta-Llama-3-8B
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -42,6 +41,3 @@ logging:
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false

View file

@ -4,7 +4,6 @@
# added inference parameters from unsloth notebook
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -40,7 +39,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.2
top_p: 1.2

View file

@ -4,7 +4,6 @@
# added inference parameters from unsloth guides
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -49,7 +48,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 0.7
min_p: 0.01
top_p: 0.95

View file

@ -4,7 +4,6 @@
# added inference parameters from unsloth guides
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -49,7 +48,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 0.15
top_p: 0.95

View file

@ -3,7 +3,6 @@
# Also applies to: "unsloth/Mistral-Nemo-Base-2407", "mistralai/Mistral-Nemo-Base-2407", "unsloth/Mistral-Nemo-Instruct-2407-bnb-4bit", "unsloth/Mistral-Nemo-Instruct-2407", "mistralai/Mistral-Nemo-Instruct-2407",
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -42,6 +41,3 @@ logging:
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false

View file

@ -3,7 +3,6 @@
# Also applies to: unsloth/Mistral-Small-Instruct-2409-bnb-4bit, mistralai/Mistral-Small-Instruct-2409
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -42,6 +41,3 @@ logging:
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false

View file

@ -4,7 +4,6 @@
# added inference parameters from unsloth notebook
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -43,7 +42,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.5
min_p: 0.1

View file

@ -3,7 +3,6 @@
# Also applies to: unsloth/mistral-7b-instruct-v0.3, mistralai/Mistral-7B-Instruct-v0.3
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -42,6 +41,3 @@ logging:
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false

View file

@ -2,7 +2,6 @@
# Based on Mistral_v0.3_(7B)-Alpaca.ipynb
# Also applies to: "unsloth/mistral-7b-v0.3", "mistralai/Mistral-7B-v0.3",
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -41,6 +40,3 @@ logging:
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false

View file

@ -6,7 +6,6 @@
audio_type: dac
training:
trust_remote_code: false
eval_steps: 0
max_seq_length: 2048
# num_epochs: 4
@ -43,7 +42,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 0.4
top_k: 40
top_p: 0.9

View file

@ -6,7 +6,6 @@
audio_type: bicodec
training:
trust_remote_code: false
eval_steps: 0
max_seq_length: 2048
# num_epochs: 4
@ -48,7 +47,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 0.8
top_k: 50
top_p: 1.0

View file

@ -5,7 +5,6 @@
audio_type: csm
training:
trust_remote_code: false
eval_steps: 0
max_seq_length: 2048
# num_epochs: 4
@ -45,6 +44,3 @@ logging:
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false

View file

@ -3,7 +3,6 @@
# Also applies to: unsloth/GLM-4.7-Flash-unsloth-bnb-4bit, unsloth/GLM-4.7-Flash-bnb-4bit, THUDM/GLM-4.7-Flash
training:
trust_remote_code: true
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -45,7 +44,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: true
temperature: 0.7
top_p: 0.8
top_k: 20

View file

@ -4,7 +4,6 @@
# added inference parameters from unsloth notebook
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -39,7 +38,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 0.3
min_p: 0.15

View file

@ -4,7 +4,6 @@
# added inference parameters from unsloth guides
training:
trust_remote_code: true
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -47,7 +46,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: true
temperature: 1.0
top_p: 1.0

View file

@ -4,7 +4,6 @@
# added inference parameters from unsloth notebook
training:
trust_remote_code: true
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -49,7 +48,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: true
temperature: 1.5
min_p: 0.1

View file

@ -2,7 +2,6 @@
# Based on bert_classification.ipynb
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 1
num_epochs: 0
@ -41,6 +40,3 @@ logging:
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false

View file

@ -6,7 +6,6 @@
audio_type: snac
training:
trust_remote_code: false
eval_steps: 0
max_seq_length: 2048
# num_epochs: 4
@ -48,7 +47,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 0.6
top_p: 0.95

View file

@ -3,7 +3,6 @@
# Also applies to: TinyLlama/TinyLlama-1.1B-intermediate-step-1431k-3T
training:
trust_remote_code: false
max_seq_length: 4096
# num_epochs: 1
num_epochs: 0
@ -42,6 +41,3 @@ logging:
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false

View file

@ -6,7 +6,6 @@ audio_type: whisper
audio_input: true
training:
trust_remote_code: false
eval_steps: 5
max_seq_length: 448
# num_epochs: 4
@ -41,6 +40,3 @@ logging:
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false

View file

@ -3,7 +3,6 @@
# Also applies to: "unsloth/Phi-3-medium-4k-instruct-bnb-4bit", "microsoft/Phi-3-medium-4k-instruct",
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -42,6 +41,3 @@ logging:
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false

View file

@ -3,7 +3,6 @@
# Also applies to: "unsloth/Phi-3.5-mini-instruct-bnb-4bit", "microsoft/Phi-3.5-mini-instruct"
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -42,6 +41,3 @@ logging:
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false

View file

@ -4,7 +4,6 @@
# added inference parameters from unsloth guides
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -45,7 +44,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 0.8
top_p: 0.95

View file

@ -4,7 +4,6 @@
# MoE model - includes gate_up_proj for MoE layers
training:
trust_remote_code: false
max_seq_length: 2048
# num_epochs: 4
num_epochs: 0
@ -46,7 +45,6 @@ logging:
log_frequency: 10
inference:
trust_remote_code: false
temperature: 0.6
top_k: 20
top_p: 0.95

Some files were not shown because too many files have changed in this diff Show more