Compare commits

..

2 commits

Author SHA1 Message Date
pre-commit-ci[bot]
873cb0be55 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-02 12:52:53 +00:00
danielhanchen
2c8aacc55a Compressed export: fail fast when transformers exceeds the llm-compressor ceiling
llm-compressor 0.10.x/0.12.x pins transformers<=4.57.6. Models that require a newer
transformers (e.g. Qwen3.5, which needs the transformers 5.3.0 sidecar) cannot be
FP8/FP4 quantized at all: the compressed-export subprocess imports
transformers.modeling_utils.TORCH_INIT_FUNCTIONS, which was removed in transformers 5.x,
so it dies with a cryptic ImportError, and only after the expensive 16bit merge has
already been written.

Detect this in _unsloth_save_compressed_tensors before installing llm-compressor or
merging, and raise a clear, actionable error (export to GGUF or 16-bit instead). Unsloth
Studio surfaces it as a clean 400 in the export panel rather than a confusing traceback,
and no full 16bit checkpoint is written for a model that can never be quantized this way.

- Add _LLM_COMPRESSOR_MAX_TRANSFORMERS = "4.57.6" next to _LLM_COMPRESSOR_SPEC, to bump
  in lockstep with a future llm-compressor that supports newer transformers.
- Add _transformers_exceeds_llm_compressor_ceiling() helper: packaging.version compare,
  fail-open on any parse error so a real quantization attempt still surfaces its own error.
- Gate _unsloth_save_compressed_tensors on it before install/merge.
2026-07-02 12:52:03 +00:00
1354 changed files with 25694 additions and 275424 deletions

2
.gitattributes vendored
View file

@ -6,7 +6,7 @@
# them when run in WSL/Linux (e.g. `set -e` -> "set: Illegal option -"). # them when run in WSL/Linux (e.g. `set -e` -> "set: Illegal option -").
*.sh text eol=lf *.sh text eol=lf
# Normalize Unsloth frontend sources to LF. Scoped to the frontend tree (rather # Normalize Studio frontend sources to LF. Scoped to the frontend tree (rather
# than repo-wide *.ts/*.tsx/... rules) so the policy can't force LF on files # than repo-wide *.ts/*.tsx/... rules) so the policy can't force LF on files
# elsewhere. text=auto lets Git detect and leave binary assets (logos, fonts) # elsewhere. text=auto lets Git detect and leave binary assets (logos, fonts)
# untouched while text files (.ts/.tsx/.json/.html/.svg/...) are stored as LF. # untouched while text files (.ts/.tsx/.json/.html/.svg/...) are stored as LF.

View file

@ -6,12 +6,12 @@
# Local Agent Guides CI. All failures from here are failure class (c) # Local Agent Guides CI. All failures from here are failure class (c)
# "guide drift": the server preflight already passed and the agent CLI # "guide drift": the server preflight already passed and the agent CLI
# already installed, so a failure here means the documented recipe in # already installed, so a failure here means the documented recipe in
# unsloth_cli/commands/start.py no longer produces a working flow. # unsloth_cli/commands/connect.py no longer produces a working flow.
# #
# Self-updating: for all six agents (claude, codex, hermes, openclaw, # Self-updating: for the 5 agents with a connect.py recipe we obtain the
# opencode, pi) we obtain the exact env + command from # exact env + command from `unsloth connect <agent> --no-launch` and run
# `unsloth start <agent> --no-launch` and run THAT, so a recipe change is # THAT, so a recipe change is exercised automatically. Pi (no connect.py
# exercised automatically. # command at HEAD) is driven by a hand-written recipe.
# #
# Every agent invocation is wrapped in `timeout` so a headless-TTY prompt # 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 # can never hang the runner -- a timeout is reported as guide drift with a
@ -36,23 +36,6 @@ AGENT="${2:?usage: agent-guides-drive.sh <mode> <agent>}"
# Determinism (seed/temp) is applied at the server level by # Determinism (seed/temp) is applied at the server level by
# serve-unsloth-run.sh --extra; agents inherit it through the API. # serve-unsloth-run.sh --extra; agents inherit it through the API.
TIMEOUT="${AGENT_INVOKE_TIMEOUT:-180}" TIMEOUT="${AGENT_INVOKE_TIMEOUT:-180}"
# opencode is the slow outlier. Unlike the print-mode agents (claude -p, codex
# exec) it runs a full turn AND a separate small_model call to name the session,
# so one connection reply takes ~8 min on a CPU-served 4B -- right at the shared
# 600s cap, so the cell flaked when a run drifted past a ~480s success. Give it
# headroom (still well under the 40-min job budget); the fast agents keep the
# tight cap that still catches a real headless-TTY hang.
case "$AGENT" in
opencode)
# Double it, but only for a bare-integer seconds value. A GNU timeout(1)
# duration suffix (s/m/h/d, including floats like 0.5s) is left unchanged so
# the arithmetic never sees a non-number; timeout(1) parses it directly.
case "$TIMEOUT" in
*[!0-9]*) ;;
*) TIMEOUT=$(( TIMEOUT * 2 )) ;;
esac
;;
esac
# Claude refuses --dangerously-skip-permissions outside a sandbox; the CI runner # Claude refuses --dangerously-skip-permissions outside a sandbox; the CI runner
# IS the sandbox, so declare it (mirrors unslothai/scripts launcher.sh). Harmless # IS the sandbox, so declare it (mirrors unslothai/scripts launcher.sh). Harmless
@ -70,14 +53,14 @@ REDACTED_DIR="$REPO_ROOT/redacted-configs"
WORKDIR_BASE="$REPO_ROOT/agent-workdir" WORKDIR_BASE="$REPO_ROOT/agent-workdir"
CACHE_HELPER="$SCRIPT_DIR/assert-prompt-cache.sh" CACHE_HELPER="$SCRIPT_DIR/assert-prompt-cache.sh"
mkdir -p "$LOGS_DIR" "$REDACTED_DIR" mkdir -p "$LOGS_DIR" "$REDACTED_DIR"
CONNECT_REF="unsloth_cli/commands/start.py" CONNECT_REF="unsloth_cli/commands/connect.py"
# Prefill-shrinking flags for Claude Code. The heavyweight agents send # Prefill-shrinking flags for Claude Code. The heavyweight agents send
# multi-thousand-token system prompts + full tool schemas, which on a CPU-only # 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). # 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) # 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 # and restricting tools cuts the prefill to a few hundred tokens so it completes
# quickly on CPU. These only shape the request size; the start.py recipe # quickly on CPU. These only shape the request size; the connect.py recipe
# (endpoint, auth, model) is still exercised end to end. # (endpoint, auth, model) is still exercised end to end.
# #
# The bulk of Claude Code's prompt is the built-in tool JSON schemas: measured # The bulk of Claude Code's prompt is the built-in tool JSON schemas: measured
@ -122,13 +105,6 @@ redact() {
done done
} }
# Print a file to the log with the key scrubbed, without mutating it (the raw file is
# still needed to parse the real env). Use this instead of `cat` for any transcript that
# carries an `export UNSLOTH_API_KEY=...` line, so a live key never reaches Actions logs.
cat_redacted() {
sed "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$1"
}
# A reply must be non-empty and free of connection/auth errors. # A reply must be non-empty and free of connection/auth errors.
assert_reply() { assert_reply() {
local out="$1" local out="$1"
@ -155,98 +131,91 @@ run_timed() { # $1=outfile, rest=command
return "$rc" return "$rc"
} }
# Read a value from an `export VAR=...` line in the connect --no-launch output. # ── Pi: no connect.py command at HEAD -> hand-written recipe ──────────────
# `unsloth start` writes each agent's session config off the user's ~ and points write_pi_config() {
# at it through a relocation env var (CODEX_HOME / OPENCODE_CONFIG / if unsloth connect pi --help >/dev/null 2>&1; then
# OPENCLAW_CONFIG_PATH), so the contract checks read the path from here. # Tripwire: once a real recipe exists, the hand-written config would mask any
raw_env() { # $1 = var name -> value (one shlex-quote layer stripped) # drift in it, defeating the point of this CI. Fail hard so the cell is
local raw="$LOGS_DIR/connect-${AGENT}.txt" # migrated to the self-updating `unsloth connect pi --no-launch` path.
local v; v="$(sed -n "s/^export $1=//p" "$raw" | tail -1)" 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)"
v="${v#\'}"; v="${v%\'}"; printf '%s' "$v" fi
mkdir -p "$HOME/.pi/agent"
python3 - "$UNSLOTH_BASE_URL" "$UNSLOTH_API_KEY" "$UNSLOTH_MODEL_ID" <<'PY'
import json, os, sys
base, key, model = sys.argv[1], sys.argv[2], sys.argv[3]
cfg = {"providers": {"unsloth": {
"api": "openai-completions",
"baseUrl": f"{base}/v1",
"apiKey": key,
"models": [{"id": model}],
}}}
path = os.path.expanduser("~/.pi/agent/models.json")
with open(path, "w") as fh:
json.dump(cfg, fh, indent=2)
PY
cp "$HOME/.pi/agent/models.json" "$REDACTED_DIR/pi-models.json" 2>/dev/null || true
redact "$REDACTED_DIR/pi-models.json"
} }
# ── 5-agent start.py path: parse env + command from --no-launch ───────── # ── 5-agent connect.py path: parse env + command from --no-launch ─────────
# Populates globals CONNECT_ENV (export/unset lines) and CONNECT_CMD (the # Populates globals CONNECT_ENV (export/unset lines) and CONNECT_CMD (the
# launch command on the last printed line), and runs start.py's config # launch command on the last printed line), and runs connect.py's config
# writers as a side effect (it writes each agent's relocated session config). # writers as a side effect (it writes ~/.codex, ~/.claude, etc.).
parse_connect() { parse_connect() {
local raw="$LOGS_DIR/connect-${AGENT}.txt" local raw="$LOGS_DIR/connect-${AGENT}.txt"
# CONNECT_YOLO=1 adds --yolo. opencode/openclaw gate tool approval through their if ! unsloth connect "$AGENT" --no-launch --api-key "$UNSLOTH_API_KEY" > "$raw" 2>&1; then
# config (which now prompts by default), so the file-edit test opts into auto-approval cat "$raw"
# here, the same intent as claude/codex's per-call bypass flags. guide_fail "'unsloth connect ${AGENT} --no-launch' exited non-zero"
local yolo=()
[ -n "${CONNECT_YOLO:-}" ] && yolo=(--yolo)
if ! unsloth start "$AGENT" --no-launch "${yolo[@]}" --api-key "$UNSLOTH_API_KEY" > "$raw" 2>&1; then
cat_redacted "$raw"
guide_fail "'unsloth start ${AGENT} --no-launch' exited non-zero"
fi fi
echo "[$AGENT] connect --no-launch printed:"; cat_redacted "$raw" echo "[$AGENT] connect --no-launch printed:"; cat "$raw"
CONNECT_ENV="$(grep -E '^(export |unset )' "$raw" || true)" CONNECT_ENV="$(grep -E '^(export |unset )' "$raw" || true)"
# The launch command is the last non-export, non-status line. start.py # The launch command is the last non-export, non-status line. connect.py
# prints "Unsloth <url> · model <id>" and "Updated ..." status lines first. # prints "Studio <url> · model <id>" and "Updated ..." status lines first.
CONNECT_CMD="$(grep -vE '^(export |unset |Unsloth |Updated |Disabled |Warning|Loading)' "$raw" \ CONNECT_CMD="$(grep -vE '^(export |unset |Studio |Updated |Disabled |Warning|Loading)' "$raw" \
| grep -E '[^[:space:]]' | tail -1)" | grep -E '[^[:space:]]' | tail -1)"
[ -n "$CONNECT_CMD" ] || guide_fail "could not parse a launch command from connect --no-launch output" [ -n "$CONNECT_CMD" ] || guide_fail "could not parse a launch command from connect --no-launch output"
redact "$raw" redact "$raw"
} }
# Cross-check the documented contract knobs so silent start.py changes # Cross-check the documented contract knobs so silent connect.py changes
# (env-var rename, wire_api flip, attribution setting drop) also fail/flag. # (env-var rename, wire_api flip, attribution setting drop) also fail/flag.
crosscheck_contract() { crosscheck_contract() {
local raw="$LOGS_DIR/connect-${AGENT}.txt" local raw="$LOGS_DIR/connect-${AGENT}.txt"
local cfg home
case "$AGENT" in case "$AGENT" in
codex) codex)
grep -q 'UNSLOTH_STUDIO_AUTH_TOKEN' "$raw" \ grep -q 'UNSLOTH_STUDIO_AUTH_TOKEN' "$raw" \
|| guide_fail "Codex env key is no longer UNSLOTH_STUDIO_AUTH_TOKEN (start.py _CODEX_ENV_KEY)" || guide_fail "Codex env key is no longer UNSLOTH_STUDIO_AUTH_TOKEN (connect.py _CODEX_ENV_KEY)"
home="$(raw_env CODEX_HOME)" if [ -f "$HOME/.codex/config.toml" ]; then
# An empty relocation var would make cfg "/config.toml" and silently grep -q 'wire_api = "responses"' "$HOME/.codex/config.toml" \
# skip the [ -f ] contract check below; fail loudly instead. || guide_fail "Codex wire_api is no longer \"responses\" in ~/.codex/config.toml"
[ -n "$home" ] || guide_fail "CODEX_HOME missing from connect output (start.py codex())" cp "$HOME/.codex/config.toml" "$REDACTED_DIR/codex-config.toml"
cfg="$home/config.toml"
if [ -f "$cfg" ]; then
grep -q 'wire_api = "responses"' "$cfg" \
|| guide_fail "Codex wire_api is no longer \"responses\" in \$CODEX_HOME/config.toml"
cp "$cfg" "$REDACTED_DIR/codex-config.toml"
fi fi
grep -q 'codex --oss --profile unsloth_api' "$raw" \ grep -q 'codex --oss --profile unsloth_api' "$raw" \
|| echo "::warning::Codex launch command changed from 'codex --oss --profile unsloth_api'" || echo "::warning::Codex launch command changed from 'codex --oss --profile unsloth_api'"
;; ;;
claude) claude)
grep -q 'ANTHROPIC_AUTH_TOKEN' "$raw" \ grep -q 'ANTHROPIC_AUTH_TOKEN' "$raw" \
|| guide_fail "Claude no longer exports ANTHROPIC_AUTH_TOKEN (start.py claude())" || guide_fail "Claude no longer exports ANTHROPIC_AUTH_TOKEN (connect.py claude())"
grep -q 'CLAUDE_CODE_ATTRIBUTION_HEADER' "$raw" \ if [ -f "$HOME/.claude/settings.json" ]; then
|| echo "::warning::CLAUDE_CODE_ATTRIBUTION_HEADER no longer set for the session (start.py claude())" 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) hermes)
grep -q 'UNSLOTH_API_KEY' "$raw" \ grep -q 'UNSLOTH_API_KEY' "$raw" \
|| guide_fail "Hermes env key is no longer UNSLOTH_API_KEY (start.py _HERMES_ENV_KEY)" || guide_fail "Hermes env key is no longer UNSLOTH_API_KEY (connect.py _HERMES_ENV_KEY)"
home="$(raw_env HERMES_HOME)" [ -f "$HOME/.hermes/config.yaml" ] && cp "$HOME/.hermes/config.yaml" "$REDACTED_DIR/hermes-config.yaml"
[ -n "$home" ] || guide_fail "HERMES_HOME missing from connect output (start.py hermes())"
cfg="$home/config.yaml"
[ -f "$cfg" ] && cp "$cfg" "$REDACTED_DIR/hermes-config.yaml"
;; ;;
openclaw) openclaw)
cfg="$(raw_env OPENCLAW_CONFIG_PATH)" if [ -f "$HOME/.openclaw/openclaw.json" ]; then
if [ -n "$cfg" ] && [ -f "$cfg" ]; then grep -q '"openai-completions"' "$HOME/.openclaw/openclaw.json" \
grep -q '"openai-completions"' "$cfg" \
|| echo "::warning::OpenClaw provider api is no longer 'openai-completions' (write_openclaw_config)" || echo "::warning::OpenClaw provider api is no longer 'openai-completions' (write_openclaw_config)"
cp "$cfg" "$REDACTED_DIR/openclaw.json" cp "$HOME/.openclaw/openclaw.json" "$REDACTED_DIR/openclaw.json"
fi fi
;; ;;
opencode) opencode)
cfg="$(raw_env OPENCODE_CONFIG)" [ -f "$HOME/.config/opencode/opencode.json" ] && cp "$HOME/.config/opencode/opencode.json" "$REDACTED_DIR/opencode.json"
[ -n "$cfg" ] && [ -f "$cfg" ] && cp "$cfg" "$REDACTED_DIR/opencode.json"
;;
pi)
# Pi has no config-dir env var; the session is HOME-relocated, and the
# provider config lives at $HOME/.pi/agent/models.json.
cfg="$(raw_env HOME)/.pi/agent/models.json"
if [ -f "$cfg" ]; then
grep -q '"openai-completions"' "$cfg" \
|| echo "::warning::Pi provider api is no longer 'openai-completions' (write_pi_config)"
cp "$cfg" "$REDACTED_DIR/pi-models.json"
fi
;; ;;
esac esac
redact "$REDACTED_DIR"/* 2>/dev/null || true redact "$REDACTED_DIR"/* 2>/dev/null || true
@ -260,23 +229,16 @@ crosscheck_contract() {
# Hermes: an explicit empty cli toolset disables all tools (and drops the # Hermes: an explicit empty cli toolset disables all tools (and drops the
# tool-gated guidance blocks), so -z sends ~300 tokens instead of thousands. # tool-gated guidance blocks), so -z sends ~300 tokens instead of thousands.
# Hermes enables its default cli toolset when the session config does not pin one, # hermes ships a DEFAULT config.yaml that already has a populated
# so we must set platform_toolsets.cli explicitly to [] (not just append) to get # platform_toolsets, and `unsloth connect` merges into it, so we must override
# zero tools. That needs a YAML parser, and the runner's bare python3 has no # cli (not just append). That needs a YAML parser, and the runner's bare
# PyYAML -- but the venv that ships `unsloth` does (start.py imports yaml), so run # python3 has no PyYAML -- but the venv that ships `unsloth` does (connect.py
# the patch with that interpreter. We patch the relocated $HERMES_HOME/config.yaml # imports yaml), so run the patch with that interpreter.
# that `unsloth start` printed, not the user's ~/.hermes.
# (-z reads platform_toolsets.cli; --ignore-rules is a no-op under -z.) # (-z reads platform_toolsets.cli; --ignore-rules is a no-op under -z.)
patch_hermes_tools() { # $1 = none|default patch_hermes_tools() { # $1 = none|default
# Check the raw var BEFORE appending /config.yaml: the joined path is never
# empty, so the old guard could not fire and the patcher would die on
# "/config.yaml" with a bare traceback instead of this clear failure.
local home; home="$(raw_env HERMES_HOME)"
[ -n "$home" ] || guide_fail "Hermes HERMES_HOME missing from connect output (start.py hermes())"
local cfg; cfg="$home/config.yaml"
# Find a python that can import yaml. The runner's bare python3 cannot, but the # 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 # interpreter in the `unsloth` console-script shebang provably can (it runs
# start.py's write_hermes_config, which imports yaml). Try that first, then # connect.py's write_hermes_config, which imports yaml). Try that first, then
# any python on PATH, then the venv sibling, picking the first with PyYAML. # any python on PATH, then the venv sibling, picking the first with PyYAML.
local cand py="" shebang local cand py="" shebang
shebang="$(head -1 "$(command -v unsloth)" 2>/dev/null | sed -n 's/^#![[:space:]]*//p' | awk '{print $1}')" shebang="$(head -1 "$(command -v unsloth)" 2>/dev/null | sed -n 's/^#![[:space:]]*//p' | awk '{print $1}')"
@ -285,13 +247,13 @@ patch_hermes_tools() { # $1 = none|default
{ [ -x "$cand" ] || command -v "$cand" >/dev/null 2>&1; } || continue { [ -x "$cand" ] || command -v "$cand" >/dev/null 2>&1; } || continue
if "$cand" -c 'import yaml' 2>/dev/null; then py="$cand"; break; fi if "$cand" -c 'import yaml' 2>/dev/null; then py="$cand"; break; fi
done done
[ -n "$py" ] || guide_fail "could not find a python with PyYAML to patch the hermes session config" [ -n "$py" ] || guide_fail "could not find a python with PyYAML to patch ~/.hermes/config.yaml"
echo "[hermes] patching $cfg with $py" echo "[hermes] patching config with $py"
"$py" - "$1" "$cfg" <<'PY' "$py" - "$1" <<'PY'
import os, sys import os, sys
import yaml import yaml
mode = sys.argv[1] mode = sys.argv[1]
p = sys.argv[2] p = os.path.expanduser("~/.hermes/config.yaml")
cfg = (yaml.safe_load(open(p)) or {}) if os.path.exists(p) else {} cfg = (yaml.safe_load(open(p)) or {}) if os.path.exists(p) else {}
ts = cfg.get("platform_toolsets") ts = cfg.get("platform_toolsets")
if not isinstance(ts, dict): if not isinstance(ts, dict):
@ -312,14 +274,10 @@ PY
# drop the auto-injected AGENTS.md/SOUL.md bootstrap (the bulk of the prompt) for # 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. # both modes. --agent must reference a defined agent, so write it before invoking.
patch_openclaw_agent() { # $1 = notools|tools patch_openclaw_agent() { # $1 = notools|tools
# OpenClaw reads its config from the relocated OPENCLAW_CONFIG_PATH that python3 - "$1" <<'PY'
# `unsloth start` printed, so patch THAT file (not the user's ~/.openclaw).
local cfg; cfg="$(raw_env OPENCLAW_CONFIG_PATH)"
[ -n "$cfg" ] || guide_fail "OpenClaw OPENCLAW_CONFIG_PATH missing from connect output (start.py openclaw())"
python3 - "$1" "$cfg" <<'PY'
import os, sys, json import os, sys, json
mode = sys.argv[1] mode = sys.argv[1]
p = sys.argv[2] p = os.path.expanduser("~/.openclaw/openclaw.json")
cfg = json.load(open(p)) if os.path.exists(p) else {} cfg = json.load(open(p)) if os.path.exists(p) else {}
agents = cfg.setdefault("agents", {}) agents = cfg.setdefault("agents", {})
agents.setdefault("defaults", {})["skipBootstrap"] = True agents.setdefault("defaults", {})["skipBootstrap"] = True
@ -335,24 +293,20 @@ print(f"[openclaw] agent ci tools = {agent.get('tools', 'default')}")
PY PY
} }
# Build an invoke script that applies start.py's env then runs the launch # Build an invoke script that applies connect.py's env then runs the launch
# command (with extra args appended) under bash. We do NOT eval connect's env # 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 # into this shell; we write it into a one-shot script so the export/unset
# semantics are exactly what start.py printed. The script path is absolute # semantics are exactly what connect.py printed. The script path is absolute
# so it is valid even when the caller has cd'd into a scratch work dir. # 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 invoke_via_connect() { # $1=outfile, rest=extra args appended to the command
local out="$1"; shift local out="$1"; shift
local script="$LOGS_DIR/invoke-${AGENT}.sh" local script="$LOGS_DIR/invoke-${AGENT}.sh"
local real; real="$(mktemp)" local real; real="$(mktemp)"
# CONNECT_ENV_EXTRA / CONNECT_CMD_OVERRIDE let a caller (attribution-ab) flip a
# session knob without editing the user's config; empty -> use what start.py emitted.
local cmd="${CONNECT_CMD_OVERRIDE:-$CONNECT_CMD}"
{ {
echo "set -uo pipefail" echo "set -uo pipefail"
echo "$CONNECT_ENV" echo "$CONNECT_ENV"
[ -n "${CONNECT_ENV_EXTRA:-}" ] && echo "$CONNECT_ENV_EXTRA"
# Append extra args (the prompt / flags) to the launch command verbatim. # Append extra args (the prompt / flags) to the launch command verbatim.
printf '%s' "$cmd" printf '%s' "$CONNECT_CMD"
local a local a
for a in "$@"; do printf ' %q' "$a"; done for a in "$@"; do printf ' %q' "$a"; done
printf '\n' printf '\n'
@ -364,9 +318,7 @@ invoke_via_connect() { # $1=outfile, rest=extra args appended to the command
# Writing the redacted copy up front keeps the key out of the artifact even if # 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). # the run times out (run_timed exits before returning here).
cp "$real" "$script"; redact "$script" cp "$real" "$script"; redact "$script"
# The connect one-liner now carries the key as an inline env assignment; scrub it on echo "[$AGENT] invoking (timeout ${TIMEOUT}s): $CONNECT_CMD $*"
# the way to the log (the executed $real keeps the live value).
echo "[$AGENT] invoking (timeout ${TIMEOUT}s): ${cmd//${UNSLOTH_API_KEY}/<REDACTED>} $*"
run_timed "$out" bash "$real" run_timed "$out" bash "$real"
local rc=$? local rc=$?
rm -f "$real" rm -f "$real"
@ -380,23 +332,27 @@ case "$MODE" in
connection) connection)
PROMPT='Reply with exactly the single word: pong' PROMPT='Reply with exactly the single word: pong'
OUT="$LOGS_DIR/${AGENT}-connection.txt" OUT="$LOGS_DIR/${AGENT}-connection.txt"
parse_connect if [ "$AGENT" = "pi" ]; then
crosscheck_contract write_pi_config
# claude/codex run in print mode via the flags start.py emits run_timed "$OUT" pi -p --provider unsloth --model "$UNSLOTH_MODEL_ID" "$PROMPT"
# (claude -p / codex exec). For agents whose default subcommand prints else
# to stdout we pass the prompt through ctx.args. parse_connect
case "$AGENT" in crosscheck_contract
claude) invoke_via_connect "$OUT" "${CLAUDE_CONNECT_FLAGS[@]}" -p "$PROMPT" ;; # claude/codex run in print mode via the flags connect.py emits
codex) invoke_via_connect "$OUT" exec --dangerously-bypass-approvals-and-sandbox "$PROMPT" ;; # (claude -p / codex exec). For agents whose default subcommand prints
opencode) invoke_via_connect "$OUT" run "$PROMPT" ;; # to stdout we pass the prompt through ctx.args.
pi) invoke_via_connect "$OUT" -p "$PROMPT" ;; case "$AGENT" in
hermes) patch_hermes_tools none claude) invoke_via_connect "$OUT" "${CLAUDE_CONNECT_FLAGS[@]}" -p "$PROMPT" ;;
invoke_via_connect "$OUT" -z "$PROMPT" ;; codex) invoke_via_connect "$OUT" exec --dangerously-bypass-approvals-and-sandbox "$PROMPT" ;;
openclaw) patch_openclaw_agent notools opencode) invoke_via_connect "$OUT" run "$PROMPT" ;;
CONNECT_CMD_OVERRIDE=openclaw invoke_via_connect "$OUT" agent --local --agent ci \ hermes) patch_hermes_tools none
--model "unsloth/${UNSLOTH_MODEL_ID}" --message "$PROMPT" ;; invoke_via_connect "$OUT" -z "$PROMPT" ;;
*) invoke_via_connect "$OUT" "$PROMPT" ;; openclaw) patch_openclaw_agent notools
esac 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 # 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 # printed something: a benign-looking "command not found" / usage dump would
# otherwise slip past assert_reply (which only flags empty/error-keyword text). # otherwise slip past assert_reply (which only flags empty/error-keyword text).
@ -415,21 +371,22 @@ case "$MODE" in
T1='Create a file named hello.py in the current directory whose entire contents are a single line: print("Hello"). Do not run it.' 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.' T2='Run hello.py with python and show me the exact output.'
# The start.py recipe writers + crosscheck must see the repo; run them # The connect.py recipe writers + crosscheck must see the repo; run them
# from the repo root BEFORE cd-ing into the scratch work dir. opencode/openclaw # from the repo root BEFORE cd-ing into the scratch work dir.
# gate tool approval through their config (prompting by default), so file-edit if [ "$AGENT" != "pi" ]; then
# opts them into auto-approval to run edits/commands headlessly. parse_connect
case "$AGENT" in opencode|openclaw) CONNECT_YOLO=1 ;; esac crosscheck_contract
parse_connect # File-edit needs real tools, so we cannot zero them as in connection.
crosscheck_contract # hermes keeps default tools; openclaw still strips its AGENTS.md/SOUL.md
# File-edit needs real tools, so we cannot zero them as in connection. # bootstrap (the largest prompt chunk) via the 'ci' agent. The scratch work
# hermes keeps default tools; openclaw still strips its AGENTS.md/SOUL.md # dir is empty, so no project context files are auto-loaded either.
# bootstrap (the largest prompt chunk) via the 'ci' agent. The scratch work case "$AGENT" in
# dir is empty, so no project context files are auto-loaded either. hermes) patch_hermes_tools default ;;
case "$AGENT" in openclaw) patch_openclaw_agent tools ;;
hermes) patch_hermes_tools default ;; esac
openclaw) patch_openclaw_agent tools ;; else
esac write_pi_config
fi
# Drive from inside the work dir so the agent edits files there. All log # 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. # writes use absolute $LOGS_DIR, so cwd does not matter for them.
@ -438,14 +395,7 @@ case "$MODE" in
invoke_turn() { # $1=outfile $2=continue? $3=prompt invoke_turn() { # $1=outfile $2=continue? $3=prompt
local out="$1" cont="$2" prompt="$3" local out="$1" cont="$2" prompt="$3"
case "$AGENT" in case "$AGENT" in
pi) pi) run_timed "$out" pi -p --provider unsloth --model "$UNSLOTH_MODEL_ID" "$prompt" ;;
# Pi continues the previous session with -c; provider/model come from
# the parsed `unsloth start pi` recipe (CONNECT_CMD), not hardcoded here.
if [ "$cont" = "continue" ]; then
invoke_via_connect "$out" -p --continue "$prompt"
else
invoke_via_connect "$out" -p "$prompt"
fi ;;
claude) claude)
# --dangerously-skip-permissions lets headless claude actually use the # --dangerously-skip-permissions lets headless claude actually use the
# Write/Bash tools (otherwise it blocks on an approval prompt and emits # Write/Bash tools (otherwise it blocks on an approval prompt and emits
@ -466,7 +416,7 @@ case "$MODE" in
fi ;; fi ;;
opencode) invoke_via_connect "$out" run "$prompt" ;; opencode) invoke_via_connect "$out" run "$prompt" ;;
hermes) invoke_via_connect "$out" -z "$prompt" ;; hermes) invoke_via_connect "$out" -z "$prompt" ;;
openclaw) CONNECT_CMD_OVERRIDE=openclaw invoke_via_connect "$out" agent --local --agent ci \ openclaw) invoke_via_connect "$out" agent --local --agent ci \
--model "unsloth/${UNSLOTH_MODEL_ID}" --message "$prompt" ;; --model "unsloth/${UNSLOTH_MODEL_ID}" --message "$prompt" ;;
*) invoke_via_connect "$out" "$prompt" ;; *) invoke_via_connect "$out" "$prompt" ;;
esac esac
@ -516,180 +466,33 @@ case "$MODE" in
# right before the measured turn, so an earlier turn's reuse can't leak in. # right before the measured turn, so an earlier turn's reuse can't leak in.
LLAMA_LOG_DIR="${UNSLOTH_LLAMA_LOG_DIR:-$HOME/.unsloth/studio/logs/llama-server}" LLAMA_LOG_DIR="${UNSLOTH_LLAMA_LOG_DIR:-$HOME/.unsloth/studio/logs/llama-server}"
export LLAMA_LOG_DIR export LLAMA_LOG_DIR
parse_connect # prints session env + suppression flags (no ~/.claude write) parse_connect # writes ~/.claude/settings.json (header=0) + env
crosscheck_contract crosscheck_contract
PROMPT='Reply with exactly the single word: pong' PROMPT='Reply with exactly the single word: pong'
# Phase A: the suppression start.py ships (CLAUDE_CODE_ATTRIBUTION_HEADER=0 + # Phase A: header DISABLED (=0, the documented setting) -> expect a HIT on
# --exclude-dynamic-system-prompt-sections + --settings overlay) -> expect a # the continued turn. connect.py's ensure_claude_attribution_header() set 0.
# HIT on the continued turn, since the system-prompt prefix is stable.
invoke_via_connect "$LOGS_DIR/claude-ab-hit-1.txt" -p "$PROMPT" # turn 1 primes 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 FROM_HIT="$(bash "$CACHE_HELPER" mark)" # offset before turn 2
invoke_via_connect "$LOGS_DIR/claude-ab-hit-2.txt" -p --continue "$PROMPT again" invoke_via_connect "$LOGS_DIR/claude-ab-hit-2.txt" -p --continue "$PROMPT again"
CACHE_LOG_FROM="$FROM_HIT" bash "$CACHE_HELPER" log HIT CACHE_LOG_FROM="$FROM_HIT" bash "$CACHE_HELPER" log HIT
# Phase B: vanilla Claude with the header ENABLED -> expect a MISS. We flip # Phase B: header ENABLED -> expect a MISS. The header prepends a
# the env var to 1 and strip the suppression flags from the launch command # per-request-changing attribution line to the system prompt, so the shared
# (without them the dynamic attribution line is included and changes every # prefix changes every turn and the KV cache is invalidated (~90% slower);
# turn, so the shared prefix moves and the KV cache is invalidated, ~90% # this is exactly what the guide flag prevents.
# slower). This is session-only: nothing is written to ~/.claude. python3 - <<'PY'
CONNECT_ENV_EXTRA='export CLAUDE_CODE_ATTRIBUTION_HEADER=1' import json, os
CONNECT_CMD_OVERRIDE="$(printf '%s' "$CONNECT_CMD" \ p = os.path.expanduser("~/.claude/settings.json")
| sed -E "s/ --exclude-dynamic-system-prompt-sections//; s/ --settings '[^']*'//")" 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" invoke_via_connect "$LOGS_DIR/claude-ab-miss-1.txt" -p "$PROMPT"
FROM_MISS="$(bash "$CACHE_HELPER" mark)" FROM_MISS="$(bash "$CACHE_HELPER" mark)"
invoke_via_connect "$LOGS_DIR/claude-ab-miss-2.txt" -p --continue "$PROMPT again" invoke_via_connect "$LOGS_DIR/claude-ab-miss-2.txt" -p --continue "$PROMPT again"
CACHE_LOG_FROM="$FROM_MISS" bash "$CACHE_HELPER" log MISS CACHE_LOG_FROM="$FROM_MISS" bash "$CACHE_HELPER" log MISS
unset CONNECT_ENV_EXTRA CONNECT_CMD_OVERRIDE echo "[claude] attribution A/B OK (header=0 HIT, header=1 MISS)"
echo "[claude] attribution A/B OK (suppressed HIT, header=1 MISS)"
;;
# ── resume: does a launched agent's session survive exit and resume? ────
# Unlike the other modes, this drives the real LAUNCH path (`unsloth start
# <agent> ...`, the interactive default), not the --no-launch recipe. That
# path relocates each agent's home to a throwaway temp dir wiped on exit, so
# a session cannot be resumed -- unless --persist routes it to the stable
# Unsloth agents dir instead. We run one headless turn per pass and check
# whether the turn left a session in a persistent store (deterministic, no
# reliance on the model recalling anything), for a baseline pass and a
# --persist pass, and assert the expected split for this agent.
resume)
CODEWORD="PLATYPUS7"
T1="Remember this codeword for later: ${CODEWORD}. Reply with just the word OK."
T2="What codeword did I ask you to remember? Reply with just that word."
WORK="$WORKDIR_BASE/${AGENT}-resume"
# STABLE_HOME: the stable dir that --no-launch (and --persist) relocate to.
# Read it from a --no-launch probe (which also writes the agent's config
# there). codex/pi relocate their whole home/HOME here; opencode/claude keep
# their session data in a fixed user dir, so STABLE_HOME stays empty for them.
parse_connect
case "$AGENT" in
codex) STABLE_HOME="$(raw_env CODEX_HOME)" ;;
pi) STABLE_HOME="$(raw_env HOME)" ;;
*) STABLE_HOME="" ;;
esac
# The persistent stores a session would land in if it were NOT wiped. We
# count files here before/after each turn; a positive delta means the
# session persisted (is resumable), zero means it went to a wiped temp dir.
resume_tracked_dirs() {
case "$AGENT" in
codex) printf '%s\n' "$HOME/.codex" ;;
opencode) printf '%s\n' "$HOME/.local/share/opencode" "$HOME/.config/opencode" ;;
claude) printf '%s\n' "$HOME/.claude" ;;
pi) printf '%s\n' "$HOME/.pi" ;;
*) : ;;
esac
[ -n "$STABLE_HOME" ] && printf '%s\n' "$STABLE_HOME"
}
count_session_files() {
local total=0 d n
while IFS= read -r d; do
[ -n "$d" ] && [ -d "$d" ] || continue
n="$(find "$d" -type f 2>/dev/null | wc -l)"; total=$((total + n))
done < <(resume_tracked_dirs)
echo "$total"
}
# The headless first-turn subcommand per agent (mirrors file-edit's map),
# forwarded verbatim through the launch path as passthrough args.
set_t1_cmd() {
case "$AGENT" in
claude) T1_CMD=("${CLAUDE_CONNECT_FLAGS[@]}" -p "$T1") ;;
codex) T1_CMD=(exec "$T1") ;;
opencode) T1_CMD=(run "$T1") ;;
pi) T1_CMD=(-p "$T1") ;;
*) guide_fail "resume mode does not cover agent '$AGENT'" ;;
esac
}
# Run one headless turn through the launch path. $1=outfile, $2="" or
# "--persist", rest = the agent subcommand. --yolo auto-approves so no tool
# prompt can hang; --api-key attaches to the already-served CI model.
launch_turn() {
local out="$1" rflag="$2"; shift 2
local flag=(); [ -n "$rflag" ] && flag=("$rflag")
run_timed "$out" unsloth start "$AGENT" "${flag[@]}" --yolo \
--api-key "$UNSLOTH_API_KEY" "$@"
local rc=$?
redact "$out"
return "$rc"
}
# One pass: fresh work dir, one planting turn, set RESULT to PERSISTED/WIPED
# from the session-store delta. Runs in the main shell (not a command
# substitution) so a hang's guide_fail actually fails the job and the
# progress lines reach the CI log. $1 = "" (baseline) or "--persist".
RESULT=""
run_pass() {
local rflag="$1" label="baseline"
[ -n "$rflag" ] && label="resume"
rm -rf "$WORK"; mkdir -p "$WORK"
set_t1_cmd
local out="$LOGS_DIR/${AGENT}-resume-${label}.txt"
local before after rc
before="$(count_session_files)"
pushd "$WORK" >/dev/null || guide_fail "could not enter work dir $WORK"
launch_turn "$out" "$rflag" "${T1_CMD[@]}"; rc=$?
popd >/dev/null || true
after="$(count_session_files)"
echo "[$AGENT] ${label}: session files ${before} -> ${after} (rc=${rc})"
# The turn must succeed for the delta to mean anything: an agent that writes a
# session file then errors would otherwise be misread as PERSISTED. Mirror the
# file-edit mode and fail the pass on a non-zero launch (the flagship codex recall
# below stays WARN-only, driven by its own launch_turn calls).
[ "$rc" -eq 0 ] || { echo "[$AGENT] ${label} transcript (tail):"; tail -30 "$out" 2>/dev/null || true; \
guide_fail "resume ${label} turn for ${AGENT} exited non-zero (rc=${rc})"; }
if [ "$after" -gt "$before" ]; then RESULT="PERSISTED"; else RESULT="WIPED"; fi
}
run_pass ""; BASELINE="$RESULT"
# Only the temp-dir agents (codex/pi) need the --persist pass to prove the fix.
# opencode/claude persist either way, so the baseline already proves it and a
# second full CPU turn only risks a timeout; skip it for them.
case "$AGENT" in
codex|pi) run_pass "--persist"; RESUME="$RESULT" ;;
*) RESUME="n/a (persists either way)" ;;
esac
# Expected: codex/pi relocate their whole home to the temp dir, so a plain
# launch is WIPED and only --persist PERSISTS. opencode/claude keep their
# session data in a fixed user dir, so the baseline already PERSISTS.
case "$AGENT" in
codex|pi) EXPECT_BASELINE="WIPED" ;;
opencode|claude) EXPECT_BASELINE="PERSISTED" ;;
esac
echo "──────────────────────────────────────────────"
echo "[$AGENT] RESUME EXPERIMENT"
echo " baseline (unsloth start ${AGENT}): ${BASELINE} (expected ${EXPECT_BASELINE})"
echo " with --persist (unsloth start ${AGENT} --persist): ${RESUME}"
echo "──────────────────────────────────────────────"
[ "$BASELINE" = "$EXPECT_BASELINE" ] \
|| guide_fail "baseline resume behavior for ${AGENT} was ${BASELINE}, expected ${EXPECT_BASELINE}"
case "$AGENT" in
codex|pi)
[ "$RESUME" = "PERSISTED" ] \
|| guide_fail "--persist did not persist ${AGENT}'s session (got ${RESUME}); the session dir is still not stable" ;;
esac
# Flagship behavioral proof (codex only, WARN-only): after a --persist plant,
# resume the session and check the model actually recalls the codeword. A
# miss is not a failure (the CI model is small); the mechanism gate above is
# the real assertion.
if [ "$AGENT" = "codex" ]; then
rm -rf "$WORK"; mkdir -p "$WORK"
( cd "$WORK" && launch_turn "$LOGS_DIR/codex-resume-plant.txt" "--persist" exec "$T1" ) || true
( cd "$WORK" && launch_turn "$LOGS_DIR/codex-resume-recall.txt" "--persist" exec resume --last "$T2" ) || true
if grep -q "$CODEWORD" "$LOGS_DIR/codex-resume-recall.txt" 2>/dev/null; then
echo "[codex] behavioral recall HIT: resumed session remembered ${CODEWORD}"
else
echo "::warning::[codex] behavioral recall MISS (small CI model); mechanism gate still passed"
fi
fi
echo "[$AGENT] resume OK"
;; ;;
*) *)

View file

@ -7,7 +7,7 @@
# is the single biggest source of false reds, so installs retry with # 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 # backoff and the only ::error:: this script can emit is class (b). The
# install recipes mirror the install_hint strings in # install recipes mirror the install_hint strings in
# unsloth_cli/commands/start.py at HEAD. # unsloth_cli/commands/connect.py at HEAD.
# #
# Usage: agent-guides-install.sh <agent> # Usage: agent-guides-install.sh <agent>
# agent in: claude codex hermes openclaw opencode pi # agent in: claude codex hermes openclaw opencode pi
@ -25,14 +25,13 @@ install_fail() {
} }
# npm registry flakiness is common in CI; retry 3x with linear backoff. # npm registry flakiness is common in CI; retry 3x with linear backoff.
# Extra npm flags may precede the package (e.g. npm_retry --ignore-scripts pkg).
npm_retry() { npm_retry() {
local i local pkg="$1" i
for i in 1 2 3; do for i in 1 2 3; do
if npm install -g "$@" >> "$LOG" 2>&1; then if npm install -g "$pkg" >> "$LOG" 2>&1; then
return 0 return 0
fi fi
echo "[install] npm install -g $* attempt $i failed; backing off $((i * 10))s" | tee -a "$LOG" echo "[install] npm install -g $pkg attempt $i failed; backing off $((i * 10))s" | tee -a "$LOG"
sleep "$((i * 10))" sleep "$((i * 10))"
done done
return 1 return 1
@ -61,30 +60,30 @@ curl_bash() {
echo "[install] agent=$AGENT (log=$LOG)" echo "[install] agent=$AGENT (log=$LOG)"
case "$AGENT" in case "$AGENT" in
claude) claude)
# start.py install_hint: curl -fsSL https://claude.ai/install.sh | bash # connect.py install_hint: curl -fsSL https://claude.ai/install.sh | bash
curl_bash "https://claude.ai/install.sh" || install_fail "claude installer failed" curl_bash "https://claude.ai/install.sh" || install_fail "claude installer failed"
# The installer drops the binary under ~/.local/bin. # The installer drops the binary under ~/.local/bin.
echo "$HOME/.local/bin" >> "$GITHUB_PATH" echo "$HOME/.local/bin" >> "$GITHUB_PATH"
;; ;;
codex) codex)
# start.py install_hint: npm install -g @openai/codex # connect.py install_hint: npm install -g @openai/codex
npm_retry "@openai/codex" || install_fail "npm install -g @openai/codex failed" npm_retry "@openai/codex" || install_fail "npm install -g @openai/codex failed"
;; ;;
opencode) opencode)
# start.py install_hint: npm install -g opencode-ai # connect.py install_hint: npm install -g opencode-ai
npm_retry "opencode-ai" || install_fail "npm install -g opencode-ai failed" npm_retry "opencode-ai" || install_fail "npm install -g opencode-ai failed"
;; ;;
openclaw) openclaw)
# start.py install_hint: curl -fsSL https://openclaw.ai/install.sh | bash # connect.py install_hint: curl -fsSL https://openclaw.ai/install.sh | bash
# npm is the more deterministic path in CI and matches the agent's docs; # npm is the more deterministic path in CI and matches the agent's docs;
# fall back to the start.py curl installer if the npm tag is missing. # fall back to the connect.py curl installer if the npm tag is missing.
if ! npm_retry "openclaw@latest"; then if ! npm_retry "openclaw@latest"; then
curl_bash "https://openclaw.ai/install.sh" || install_fail "openclaw install failed (npm + curl)" curl_bash "https://openclaw.ai/install.sh" || install_fail "openclaw install failed (npm + curl)"
echo "$HOME/.local/bin" >> "$GITHUB_PATH" echo "$HOME/.local/bin" >> "$GITHUB_PATH"
fi fi
;; ;;
hermes) hermes)
# start.py install_hint: # connect.py install_hint:
# curl -fsSL .../NousResearch/hermes-agent/main/scripts/install.sh | bash # curl -fsSL .../NousResearch/hermes-agent/main/scripts/install.sh | bash
curl_bash "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh" \ curl_bash "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh" \
--non-interactive --skip-setup --skip-browser --no-skills \ --non-interactive --skip-setup --skip-browser --no-skills \
@ -92,13 +91,11 @@ case "$AGENT" in
echo "$HOME/.local/bin" >> "$GITHUB_PATH" echo "$HOME/.local/bin" >> "$GITHUB_PATH"
;; ;;
pi) pi)
# start.py install_hint: npm install -g --ignore-scripts @earendil-works/pi-coding-agent # No connect.py recipe; the agent's documented package name. The CLI moved
# (--ignore-scripts matches Pi's documented recipe; exercising the exact hint # from the now-deprecated @mariozechner scope to @earendil-works (the old
# catches guide drift). The CLI moved from the now-deprecated @mariozechner # scope is frozen, so installing it would test a stale Pi against the API).
# scope to @earendil-works (the old scope is frozen, so installing it would npm_retry "@earendil-works/pi-coding-agent" \
# test a stale Pi against the API). || install_fail "npm install -g @earendil-works/pi-coding-agent failed"
npm_retry --ignore-scripts "@earendil-works/pi-coding-agent" \
|| install_fail "npm install -g --ignore-scripts @earendil-works/pi-coding-agent failed"
;; ;;
*) *)
install_fail "unknown agent '$AGENT'" install_fail "unknown agent '$AGENT'"

View file

@ -2,7 +2,7 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# #
# Assert Unsloth installed a llama.cpp that loads and runs on THIS macOS. Tests # Assert Studio installed a llama.cpp that loads and runs on THIS macOS. Tests
# the contract that matters (binaries load and their minimum-OS is <= this host) # the contract that matters (binaries load and their minimum-OS is <= this host)
# instead of the old "did install.sh fall back to a source build?" grep, since a # instead of the old "did install.sh fall back to a source build?" grep, since a
# source build with a correct deployment target is a valid outcome. # source build with a correct deployment target is a valid outcome.

View file

@ -31,7 +31,7 @@
# (llama_cpp.py:337-340). So default: ~/.unsloth/studio/logs/llama-server/. # (llama_cpp.py:337-340). So default: ~/.unsloth/studio/logs/llama-server/.
# #
# <P> is the INTERNAL llama-server port (self._find_free_port(), # <P> is the INTERNAL llama-server port (self._find_free_port(),
# llama_cpp.py:3489 / :4641) -- a RANDOM port, NOT the Unsloth port. So we must # llama_cpp.py:3489 / :4641) -- a RANDOM port, NOT the Studio port. So we must
# NOT filter the log glob by STUDIO_PORT (the brief's `port-<STUDIO_PORT>` # 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. # glob would never match). We pick the newest llama-*.log instead.
# #

View file

@ -3,7 +3,7 @@
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 # 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 # Download a single file from a Hugging Face repo with a stall-retry
# watchdog. Used by the Unsloth CI workflows so a hung hf-xet transfer # watchdog. Used by the Studio CI workflows so a hung hf-xet transfer
# kills + retries instead of silently consuming the job's timeout. # kills + retries instead of silently consuming the job's timeout.
# #
# Usage: hf-download-with-retry.sh REPO FILE LOCAL_DIR # Usage: hf-download-with-retry.sh REPO FILE LOCAL_DIR
@ -35,7 +35,7 @@ REPO="${1:?usage: hf-download-with-retry.sh REPO FILE [LOCAL_DIR]}"
FILE="${2:?usage: hf-download-with-retry.sh REPO FILE [LOCAL_DIR]}" FILE="${2:?usage: hf-download-with-retry.sh REPO FILE [LOCAL_DIR]}"
# LOCAL_DIR is optional. If empty, hf falls back to HF_HUB_CACHE # LOCAL_DIR is optional. If empty, hf falls back to HF_HUB_CACHE
# (~/.cache/huggingface/hub) which is the desired path for callers # (~/.cache/huggingface/hub) which is the desired path for callers
# that populate HF_HOME for a downstream Unsloth model load. # that populate HF_HOME for a downstream Studio model load.
LOCAL_DIR="${3:-}" LOCAL_DIR="${3:-}"
# Stall threshold per attempt, in seconds. Override with # Stall threshold per attempt, in seconds. Override with

View file

@ -1,70 +0,0 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
set -euo pipefail
port="${1:?usage: $0 PORT BROWSER [CHANNEL]}"
browser="${2:?usage: $0 PORT BROWSER [CHANNEL]}"
channel="${3:-}"
slug="$browser${channel:+-$channel}"
artifact_dir="logs/playwright-permissions-$slug"
server_log="logs/studio-permissions-$slug.log"
studio_home="${UNSLOTH_STUDIO_HOME:-$HOME/.unsloth/studio}"
set --
if [ -n "${STUDIO_PERMISSION_FRONTEND:-}" ]; then
set -- -f "$STUDIO_PERMISSION_FRONTEND"
fi
mkdir -p "$artifact_dir"
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
rm -rf "$studio_home/auth"
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$port" "$@" \
>"$server_log" 2>&1 &
studio_pid=$!
cleanup() {
kill "$studio_pid" 2>/dev/null || true
wait "$studio_pid" 2>/dev/null || true
}
trap cleanup EXIT
healthy=0
for _ in $(seq 1 180); do
if curl -fs "http://127.0.0.1:$port/api/health" >/dev/null; then
healthy=1
break
fi
if ! kill -0 "$studio_pid" 2>/dev/null; then
tail -100 "$server_log" || true
exit 1
fi
sleep 1
done
if [ "$healthy" -ne 1 ]; then
tail -100 "$server_log" || true
exit 1
fi
old_password=$(cat "$studio_home/auth/.bootstrap_password")
new_password="CIPerm-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
echo "::add-mask::$old_password"
echo "::add-mask::$new_password"
fi
export BASE_URL="http://127.0.0.1:$port"
export STUDIO_OLD_PW="$old_password"
export STUDIO_NEW_PW="$new_password"
export STUDIO_UI_STRICT=1
export STUDIO_UI_PERMISSION_ONLY=1
export STUDIO_UI_WALL_TIMEOUT_S=240
export STUDIO_PLAYWRIGHT_BROWSER="$browser"
export PW_ART_DIR="$artifact_dir"
if [ -n "$channel" ]; then
export STUDIO_PLAYWRIGHT_CHANNEL="$channel"
else
unset STUDIO_PLAYWRIGHT_CHANNEL || true
fi
python tests/studio/playwright_chat_ui.py

View file

@ -27,7 +27,7 @@
# #
# Outputs written to $GITHUB_ENV (and echoed): # Outputs written to $GITHUB_ENV (and echoed):
# UNSLOTH_API_KEY the sk-unsloth-* key minted on the banner # UNSLOTH_API_KEY the sk-unsloth-* key minted on the banner
# UNSLOTH_STUDIO_URL http://127.0.0.1:<PORT> (so `unsloth start` # UNSLOTH_STUDIO_URL http://127.0.0.1:<PORT> (so `unsloth connect`
# finds THIS server, not the hardcoded :8888) # finds THIS server, not the hardcoded :8888)
# UNSLOTH_BASE_URL same as UNSLOTH_STUDIO_URL (alias for clarity) # UNSLOTH_BASE_URL same as UNSLOTH_STUDIO_URL (alias for clarity)
# UNSLOTH_MODEL_ID the canonical id reported by /v1/models # UNSLOTH_MODEL_ID the canonical id reported by /v1/models

View file

@ -7,7 +7,7 @@
# #
# Why a separate workflow: # Why a separate workflow:
# - studio-backend-ci.yml's "Repo tests (CPU)" job already auto-discovers # - studio-backend-ci.yml's "Repo tests (CPU)" job already auto-discovers
# tests/ minus tests/qlora, tests/saving, tests/utils, tests/sh. The 17 # tests/ minus tests/qlora, tests/saving, tests/utils, tests/sh. The 16
# Bucket-A tests below live inside those --ignore dirs (CPU-runnable but # Bucket-A tests below live inside those --ignore dirs (CPU-runnable but
# historically excluded with their GPU siblings); pulling them out into # historically excluded with their GPU siblings); pulling them out into
# a sibling job keeps the existing 760-passed baseline stable while we # a sibling job keeps the existing 760-passed baseline stable while we
@ -268,13 +268,10 @@ jobs:
tests/saving/test_save_shell_injection.py \ tests/saving/test_save_shell_injection.py \
tests/saving/test_patch_saving_none_tokenizer.py \ tests/saving/test_patch_saving_none_tokenizer.py \
tests/saving/test_fix_sentencepiece_gguf_robustness.py \ tests/saving/test_fix_sentencepiece_gguf_robustness.py \
tests/saving/test_fix_sentencepiece_tokenizer_guard.py \
tests/saving/test_compressed_export_schemes.py \ tests/saving/test_compressed_export_schemes.py \
tests/saving/test_export_api_surface.py \ tests/saving/test_export_api_surface.py \
tests/saving/test_export_dispatch.py \ tests/saving/test_export_dispatch.py \
tests/saving/test_imatrix_export.py \ tests/saving/test_imatrix_export.py \
tests/saving/test_gguf_single_pass_export.py \
tests/saving/test_offline_gguf_vlm_tokenizer_7481.py \
tests/utils/test_attention_masks.py \ tests/utils/test_attention_masks.py \
tests/utils/test_trunc_normal_patch.py \ tests/utils/test_trunc_normal_patch.py \
tests/python/test_fast_language_model_text_only.py tests/python/test_fast_language_model_text_only.py
@ -360,23 +357,18 @@ jobs:
tests/saving/test_save_shell_injection.py \ tests/saving/test_save_shell_injection.py \
tests/saving/test_patch_saving_none_tokenizer.py \ tests/saving/test_patch_saving_none_tokenizer.py \
tests/saving/test_fix_sentencepiece_gguf_robustness.py \ tests/saving/test_fix_sentencepiece_gguf_robustness.py \
tests/saving/test_fix_sentencepiece_tokenizer_guard.py \
tests/saving/test_compressed_export_schemes.py \ tests/saving/test_compressed_export_schemes.py \
tests/saving/test_export_api_surface.py \ tests/saving/test_export_api_surface.py \
tests/saving/test_export_dispatch.py \ tests/saving/test_export_dispatch.py \
tests/saving/test_imatrix_export.py \ tests/saving/test_imatrix_export.py \
tests/saving/test_gguf_single_pass_export.py \
tests/saving/test_offline_gguf_vlm_tokenizer_7481.py \
tests/utils/test_attention_masks.py \ tests/utils/test_attention_masks.py \
tests/utils/test_trunc_normal_patch.py \ tests/utils/test_trunc_normal_patch.py \
tests/python/test_fast_language_model_text_only.py \ tests/python/test_fast_language_model_text_only.py \
tests/test_bad_mappings_redirect.py \ --deselect 'tests/utils/test_attention_masks.py::test_run_attention_flash_varlen_receives_window_and_softcap'
tests/test_prefetch_snapshot_scope.py \ # The deselected test monkeypatches flash_attn_varlen_func, which is
tests/test_gemma_2b_mapper_key.py \ # only bound on the module when `flash_attn` is importable. flash_attn
tests/test_raw_text_json_loading.py # requires CUDA + dev toolchain, which the CPU-only ubuntu-latest
# test_run_attention_flash_varlen_receives_window_and_softcap was deselected # runner does not have. The other Bucket-A tests pass cleanly.
# until attention_dispatch.py predefined flash_attn_varlen_func as None; it
# monkeypatches that name, so it no longer needs flash_attn on this runner.
- name: unsloth_zoo @ ${{ env.UNSLOTH_ZOO_REF }} — full pytest (CPU) - name: unsloth_zoo @ ${{ env.UNSLOTH_ZOO_REF }} — full pytest (CPU)
# 106 of 111 test_* in unsloth_zoo are CPU-only. The two CUDA-skip # 106 of 111 test_* in unsloth_zoo are CPU-only. The two CUDA-skip
@ -2130,7 +2122,7 @@ jobs:
pip show unsloth_zoo pip show unsloth_zoo
echo "::endgroup::" echo "::endgroup::"
echo "Consolidated job done. Coverage:" echo "Consolidated job done. Coverage:"
echo " - 17 unsloth Bucket-A tests under tests/saving/ + tests/utils/" echo " - 16 unsloth Bucket-A tests under tests/saving/ + tests/utils/"
echo " - unsloth_zoo @ ${UNSLOTH_ZOO_REF} pytest tests/ (5 GPU cases deselected)" echo " - unsloth_zoo @ ${UNSLOTH_ZOO_REF} pytest tests/ (5 GPU cases deselected)"
echo " - unsloth_zoo.compiler.test_apply_fused_lm_head" echo " - unsloth_zoo.compiler.test_apply_fused_lm_head"

View file

@ -1,16 +1,18 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Runs installer parity and autostart opt-out tests across all three platforms. # Runs tests/python/test_cross_platform_parity.py on Windows and macOS.
# #
# Why: the parity test guards that install.sh and install.ps1 stay in sync. # Why: that test is the guard that install.sh and install.ps1 stay in
# It originally ran only on ubuntu-latest through studio-backend-ci.yml. # sync, but today it only runs on ubuntu-latest (auto-discovered by
# On Windows, Path.read_text() defaults to the cp1252 locale encoding, so a # studio-backend-ci.yml's "Repo tests (CPU)" job). The test reads both
# non-cp1252 byte in install.sh raises UnicodeDecodeError even though Linux # installer scripts, and on Windows Path.read_text() defaults to the
# and macOS default to UTF-8. The reads were pinned to encoding="utf-8" in # cp1252 locale encoding, so a non-cp1252 byte in install.sh (it already
# #6166; this matrix keeps that from silently regressing. Pure pytest, no GPU, # contains a U+274C) raises UnicodeDecodeError there even though Linux and
# sub-second, so the matrix is cheap. Linux also runs the POSIX rollback test # macOS default to UTF-8. The reads were pinned to encoding="utf-8" in
# under dash, matching the supported curl-to-sh installer path. # #6166; this job keeps that from silently regressing by exercising the
# test on the platforms it claims parity for. Pure pytest, no GPU,
# sub-second, so the matrix is cheap.
name: Cross-platform parity name: Cross-platform parity
@ -19,20 +21,14 @@ on:
paths: paths:
- 'install.sh' - 'install.sh'
- 'install.ps1' - 'install.ps1'
- 'tests/test_installer_skip_autostart.py'
- 'tests/python/test_cross_platform_parity.py' - 'tests/python/test_cross_platform_parity.py'
- 'tests/sh/test_install_rollback_lifecycle.sh'
- 'tests/studio/test_install_rollback_lifecycle.ps1'
- '.github/workflows/cross-platform-parity-ci.yml' - '.github/workflows/cross-platform-parity-ci.yml'
push: push:
branches: [main] branches: [main]
paths: paths:
- 'install.sh' - 'install.sh'
- 'install.ps1' - 'install.ps1'
- 'tests/test_installer_skip_autostart.py'
- 'tests/python/test_cross_platform_parity.py' - 'tests/python/test_cross_platform_parity.py'
- 'tests/sh/test_install_rollback_lifecycle.sh'
- 'tests/studio/test_install_rollback_lifecycle.ps1'
- '.github/workflows/cross-platform-parity-ci.yml' - '.github/workflows/cross-platform-parity-ci.yml'
workflow_dispatch: workflow_dispatch:
@ -49,7 +45,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
os: [ubuntu-latest, windows-latest, macos-latest] os: [windows-latest, macos-latest]
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
@ -61,18 +57,5 @@ jobs:
python-version: '3.12' python-version: '3.12'
cache: 'pip' cache: 'pip'
- run: python -m pip install -U pip pytest - run: python -m pip install -U pip pytest
- name: Cross-platform parity tests - name: Cross-platform parity test
env: run: python -m pytest tests/python/test_cross_platform_parity.py -q
UNSLOTH_NO_TORCH: '1'
run: >-
python -m pytest
tests/python/test_cross_platform_parity.py
tests/test_installer_skip_autostart.py
-q
- name: PowerShell rollback lifecycle tests
if: runner.os == 'Windows'
shell: pwsh
run: pwsh -NoProfile -File tests/studio/test_install_rollback_lifecycle.ps1
- name: POSIX rollback lifecycle tests
if: runner.os == 'Linux'
run: sh tests/sh/test_install_rollback_lifecycle.sh

View file

@ -13,10 +13,10 @@
# committed YAML / JSON config. # committed YAML / JSON config.
# #
# TypeScript and Rust are NOT duplicated here on purpose: # TypeScript and Rust are NOT duplicated here on purpose:
# - Unsloth Frontend CI runs `npm run typecheck` (= `tsc --noEmit`) # - Studio Frontend CI runs `npm run typecheck` (= `tsc --noEmit`)
# and `npm run build` (vite/swc) on every studio/frontend/** # and `npm run build` (vite/swc) on every studio/frontend/**
# change, which is a full TS AST + type check. # change, which is a full TS AST + type check.
# - Unsloth Tauri CI runs `tauri build --debug --no-bundle` on # - Studio Tauri CI runs `tauri build --debug --no-bundle` on
# every studio/src-tauri/** or studio/frontend/** change, which # every studio/src-tauri/** or studio/frontend/** change, which
# compiles the Rust crate (= cargo check + cargo build). # compiles the Rust crate (= cargo check + cargo build).
# Each is a stricter check than a parse-only step would be, so a # Each is a stricter check than a parse-only step would be, so a

View file

@ -6,27 +6,29 @@
# Detects when our local-agent setup recipes drift out of sync with # Detects when our local-agent setup recipes drift out of sync with
# `unsloth run`. Boots a real `unsloth run --disable-tools` server and # `unsloth run`. Boots a real `unsloth run --disable-tools` server and
# drives the coding agents end to end through the *exact* recipes defined # drives the coding agents end to end through the *exact* recipes defined
# in unsloth_cli/commands/start.py (the in-repo source of truth -- there # in unsloth_cli/commands/connect.py (the in-repo source of truth -- there
# is no docs/ tree). Wherever start.py has a recipe we drive the agent # is no docs/ tree). Wherever connect.py has a recipe we drive the agent
# via `unsloth start <agent> --no-launch` and execute what it prints, so # via `unsloth connect <agent> --no-launch` and execute what it prints, so
# the test self-updates against start.py and catches silent recipe drift. # the test self-updates against connect.py and catches silent recipe drift.
# #
# Source-of-truth files this workflow guards: # Source-of-truth files this workflow guards:
# unsloth_cli/commands/start.py the `unsloth start <agent>` recipes # unsloth_cli/commands/connect.py the `unsloth connect <agent>` recipes
# unsloth_cli/commands/studio.py the `unsloth run` banner (API Key line) # unsloth_cli/commands/studio.py the `unsloth run` banner (API Key line)
# #
# Failure taxonomy (each surfaced with a distinct ::error:: + the agent name # Failure taxonomy (each surfaced with a distinct ::error:: + the agent name
# + the start.py location, so a red X is immediately triageable): # + the connect.py location, so a red X is immediately triageable):
# (a) Unsloth server/API regression -- the dialect HTTP preflight fails # (a) Unsloth server/API regression -- the dialect HTTP preflight fails
# BEFORE the agent runs (or the server never becomes healthy). # BEFORE the agent runs (or the server never becomes healthy).
# (b) Agent package install failed -- npm/curl install of the CLI failed. # (b) Agent package install failed -- npm/curl install of the CLI failed.
# (c) Guide drift -- preflight passed + install ok, but # (c) Guide drift -- preflight passed + install ok, but
# the documented `unsloth start` flow produced no/garbled output. # the documented `unsloth connect` flow produced no/garbled output.
# #
# Agents covered (6): claude, codex, hermes, openclaw, opencode, pi. # Agents covered (6): claude, codex, hermes, openclaw, opencode, pi.
# - All six have a `unsloth start <agent>` recipe, so each cell obtains its # - claude/codex/hermes/openclaw/opencode have a connect.py recipe.
# env + command from `unsloth start <agent> --no-launch` and runs THAT # - pi has NO `unsloth connect pi` command in connect.py at HEAD; it is
# (self-updating: a recipe change is exercised automatically). # 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 name: Local Agent Guides CI
@ -81,7 +83,7 @@ jobs:
# ═════════════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════════════
# Job 1: connection # Job 1: connection
# Per-agent: serve gemma-3-270m, HTTP-preflight the agent's dialect, # Per-agent: serve gemma-3-270m, HTTP-preflight the agent's dialect,
# install the agent, run `unsloth start <agent> --no-launch`, execute # install the agent, run `unsloth connect <agent> --no-launch`, execute
# the emitted recipe with a trivial prompt, assert a non-empty reply. # 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 # Runs on PR + weekly + dispatch. Each matrix cell is its own runner so
# it serves exactly one model on its own port. # it serves exactly one model on its own port.
@ -101,9 +103,7 @@ jobs:
env: env:
# gemma-4-E4B (128K context, capable enough to drive every agent for a # gemma-4-E4B (128K context, capable enough to drive every agent for a
# trivial reply; the 270m model produced empty/failed responses for # trivial reply; the 270m model produced empty/failed responses for
# codex/openclaw). Hermes' 64K context floor no longer constrains the model # codex/openclaw and is below hermes' 64K context floor). Served as a flat
# choice: write_hermes_config claims the floor for smaller windows and
# scales compaction back to the real window. Served as a flat
# GGUF file (the -MTP- repo ships no separate draft, so this is plain 4B). # GGUF file (the -MTP- repo ships no separate draft, so this is plain 4B).
GGUF_REPO: unsloth/gemma-4-E4B-it-GGUF GGUF_REPO: unsloth/gemma-4-E4B-it-GGUF
GGUF_FILE: gemma-4-E4B-it-UD-Q4_K_XL.gguf GGUF_FILE: gemma-4-E4B-it-UD-Q4_K_XL.gguf
@ -154,7 +154,7 @@ jobs:
path: gguf-cache path: gguf-cache
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1 key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
- name: Install Unsloth (--local, --no-torch) - name: Install Studio (--local, --no-torch)
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Gated off PR (see note above); public GGUF still downloads. # Gated off PR (see note above); public GGUF still downloads.
@ -167,9 +167,7 @@ jobs:
# ── boot the server under test (factored helper) ────────────────── # ── boot the server under test (factored helper) ──────────────────
- name: Serve unsloth run --disable-tools (gemma-4-E4B) - name: Serve unsloth run --disable-tools (gemma-4-E4B)
run: | run: |
# Wipe, not reset-password: since #7573 the reset rotates in place and unsloth studio reset-password
# prints the new passphrase, which would land unmasked in the job log.
rm -rf ~/.unsloth/studio/auth
bash .github/scripts/serve-unsloth-run.sh \ bash .github/scripts/serve-unsloth-run.sh \
--gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \ --gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \
--port "$STUDIO_PORT" --log-dir logs \ --port "$STUDIO_PORT" --log-dir logs \
@ -211,7 +209,7 @@ jobs:
;; ;;
*) *)
# OpenAI Chat Completions dialect (hermes/opencode/pi/openclaw). # OpenAI Chat Completions dialect (hermes/opencode/pi/openclaw).
# OpenClaw's start.py recipe writes an "openai-completions" # OpenClaw's connect.py recipe writes an "openai-completions"
# provider (write_openclaw_config), so it uses this path, not # provider (write_openclaw_config), so it uses this path, not
# /v1/messages. # /v1/messages.
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/chat/completions" \ code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/chat/completions" \
@ -229,13 +227,13 @@ jobs:
AGENT: ${{ matrix.agent }} AGENT: ${{ matrix.agent }}
run: bash .github/scripts/agent-guides-install.sh "$AGENT" run: bash .github/scripts/agent-guides-install.sh "$AGENT"
# ── (c) drive the agent via start.py and assert a reply ────────── # ── (c) drive the agent via connect.py and assert a reply ──────────
# For the 5 agents with a start.py recipe we run # For the 5 agents with a connect.py recipe we run
# `unsloth start <agent> --no-launch`, eval its env/unset exports, # `unsloth connect <agent> --no-launch`, eval its env/unset exports,
# then run the printed command with a hard timeout (no headless-TTY # 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 # hang). Pi has no connect recipe, so it is driven by hand and the
# cell asserts that absence is the (known) reason. # cell asserts that absence is the (known) reason.
- name: Drive ${{ matrix.agent }} via unsloth start (class-c isolation) - name: Drive ${{ matrix.agent }} via unsloth connect (class-c isolation)
env: env:
AGENT: ${{ matrix.agent }} AGENT: ${{ matrix.agent }}
run: bash .github/scripts/agent-guides-drive.sh connection "$AGENT" run: bash .github/scripts/agent-guides-drive.sh connection "$AGENT"
@ -250,15 +248,13 @@ jobs:
# `API Key: <key>`) into logs/unsloth-run-<port>.log, and the upload # `API Key: <key>`) into logs/unsloth-run-<port>.log, and the upload
# step publishes all of logs/, so scrubbing only studio-logs would leak # step publishes all of logs/, so scrubbing only studio-logs would leak
# the bearer token in the retained artifact. # the bearer token in the retained artifact.
# Sweep EVERY uploaded path, not just logs/ -- redacted-configs/ and
# agent-workdir/ are published by the same upload step.
if [ -n "${UNSLOTH_API_KEY:-}" ]; then if [ -n "${UNSLOTH_API_KEY:-}" ]; then
grep -rlF "$UNSLOTH_API_KEY" logs redacted-configs agent-workdir 2>/dev/null | while IFS= read -r f; do grep -rlF "$UNSLOTH_API_KEY" logs 2>/dev/null | while IFS= read -r f; do
sed -i "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$f" 2>/dev/null || true sed -i "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$f" 2>/dev/null || true
done done
fi fi
- name: Stop Unsloth - name: Stop Studio
if: always() if: always()
run: | run: |
# Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make # Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make
@ -361,7 +357,7 @@ jobs:
path: gguf-cache path: gguf-cache
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1 key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
- name: Install Unsloth (--local, --no-torch) - name: Install Studio (--local, --no-torch)
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Gated off PR (see note above); public GGUF still downloads. # Gated off PR (see note above); public GGUF still downloads.
@ -373,7 +369,7 @@ jobs:
- name: Serve unsloth run --disable-tools (gemma-4-E4B) - name: Serve unsloth run --disable-tools (gemma-4-E4B)
run: | run: |
rm -rf ~/.unsloth/studio/auth unsloth studio reset-password
bash .github/scripts/serve-unsloth-run.sh \ bash .github/scripts/serve-unsloth-run.sh \
--gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \ --gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \
--port "$STUDIO_PORT" --log-dir logs \ --port "$STUDIO_PORT" --log-dir logs \
@ -442,15 +438,13 @@ jobs:
# `API Key: <key>`) into logs/unsloth-run-<port>.log, and the upload # `API Key: <key>`) into logs/unsloth-run-<port>.log, and the upload
# step publishes all of logs/, so scrubbing only studio-logs would leak # step publishes all of logs/, so scrubbing only studio-logs would leak
# the bearer token in the retained artifact. # the bearer token in the retained artifact.
# Sweep EVERY uploaded path, not just logs/ -- redacted-configs/ and
# agent-workdir/ are published by the same upload step.
if [ -n "${UNSLOTH_API_KEY:-}" ]; then if [ -n "${UNSLOTH_API_KEY:-}" ]; then
grep -rlF "$UNSLOTH_API_KEY" logs redacted-configs agent-workdir 2>/dev/null | while IFS= read -r f; do grep -rlF "$UNSLOTH_API_KEY" logs 2>/dev/null | while IFS= read -r f; do
sed -i "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$f" 2>/dev/null || true sed -i "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$f" 2>/dev/null || true
done done
fi fi
- name: Stop Unsloth - name: Stop Studio
if: always() if: always()
run: | run: |
# Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make # Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make
@ -473,176 +467,6 @@ jobs:
redacted-configs/ redacted-configs/
retention-days: 7 retention-days: 7
# ═════════════════════════════════════════════════════════════════════
# Job: resume
# Does a conversation started with `unsloth start <agent>` survive exit
# and resume? This drives the REAL launch path (not the --no-launch
# recipe the other jobs use). A plain launch relocates the agent home to
# a temp dir wiped on exit, so codex/pi cannot resume; --persist routes the
# session to the stable Unsloth agents dir so it persists. opencode/claude
# keep their session data in a fixed user dir, so they persist either way.
# Dispatch-only: it is an end-to-end experiment, not a PR gate.
# ═════════════════════════════════════════════════════════════════════
resume:
name: resume (${{ matrix.agent }})
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
# codex/pi relocate their whole home (resume broken without --persist);
# opencode/claude keep session data in a fixed dir (resume already works).
# One agent from each class proves the split end to end; openclaw/hermes
# share codex's relocation mechanism and are covered by the unit tests.
agent: [codex, opencode, claude, pi]
env:
GGUF_REPO: unsloth/gemma-4-E4B-it-GGUF
GGUF_FILE: gemma-4-E4B-it-UD-Q4_K_XL.gguf
STUDIO_PORT: '18904'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Linux deps for llama.cpp prebuilt
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libcurl4-openssl-dev libssl-dev jq
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
cache: 'pip'
- name: Restore GGUF model file
id: cache-gguf
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
continue-on-error: true
with:
path: gguf-cache
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
- name: Download GGUF if cache miss
id: download-gguf
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
python -m pip install --upgrade huggingface_hub
mkdir -p gguf-cache
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" gguf-cache
- name: Save GGUF model file
if: always() && steps.download-gguf.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: gguf-cache
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
- name: Install Unsloth (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
mkdir -p logs
set -o pipefail
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
- name: Serve unsloth run --disable-tools (gemma-4-E4B)
run: |
rm -rf ~/.unsloth/studio/auth
bash .github/scripts/serve-unsloth-run.sh \
--gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \
--port "$STUDIO_PORT" --log-dir logs \
--extra "--seed $UNSLOTH_SEED --temp 0" \
--health-timeout 900
- name: Preflight the agent's API dialect (class-a isolation)
env:
AGENT: ${{ matrix.agent }}
run: |
set -uo pipefail
B="$UNSLOTH_BASE_URL"; K="$UNSLOTH_API_KEY"
preflight_fail() {
echo "::error::[server/API regression] agent=$AGENT: $* (preflight failed BEFORE install/connect). Endpoint contract lives in studio/backend/routes/**.";
exit 1
}
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/models" \
-H "Authorization: Bearer $K") || true
[ "$code" = "200" ] || preflight_fail "/v1/models returned HTTP $code"
case "$AGENT" in
claude)
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/messages" \
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
--max-time 120 \
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true
[ "$code" = "200" ] || preflight_fail "/v1/messages returned HTTP $code"
;;
codex)
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/responses" \
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
--max-time 120 \
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"input\":\"Hi\",\"max_output_tokens\":16,\"stream\":true}") || true
[ "$code" = "200" ] || preflight_fail "/v1/responses returned HTTP $code"
;;
*)
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/chat/completions" \
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
--max-time 120 \
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true
[ "$code" = "200" ] || preflight_fail "/v1/chat/completions returned HTTP $code"
;;
esac
echo "preflight OK for $AGENT"
- name: Install agent CLI (class-b isolation)
env:
AGENT: ${{ matrix.agent }}
run: bash .github/scripts/agent-guides-install.sh "$AGENT"
- name: Resume experiment (launch path)
env:
AGENT: ${{ matrix.agent }}
run: bash .github/scripts/agent-guides-drive.sh resume "$AGENT"
- name: Collect server logs (debug)
if: always()
run: |
mkdir -p logs/studio-logs
cp -r "$HOME/.unsloth/studio/logs/." logs/studio-logs/ 2>/dev/null || true
if [ -n "${UNSLOTH_API_KEY:-}" ]; then
grep -rlF "$UNSLOTH_API_KEY" logs redacted-configs agent-workdir 2>/dev/null | while IFS= read -r f; do
sed -i "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$f" 2>/dev/null || true
done
fi
- name: Stop Unsloth
if: always()
run: |
if [ -n "${UNSLOTH_SERVER_PID:-}" ] && [ "${UNSLOTH_SERVER_PID}" != "0" ]; then
kill "${UNSLOTH_SERVER_PID}" 2>/dev/null || true
fi
sleep 2
ss -tln 2>/dev/null | grep ":${STUDIO_PORT}" || true
- name: Upload logs
if: always()
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: resume-${{ matrix.agent }}-log
path: |
logs/
agent-workdir/
redacted-configs/
retention-days: 7
# ═════════════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════════════
# Job 3: prompt-cache # Job 3: prompt-cache
# (a) curl 2-turn /v1/chat/completions: assert turn-2 cached_tokens > 0 # (a) curl 2-turn /v1/chat/completions: assert turn-2 cached_tokens > 0
@ -708,7 +532,7 @@ jobs:
path: hf-cache path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
- name: Install Unsloth (--local, --no-torch) - name: Install Studio (--local, --no-torch)
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Gated off PR (see note above); public GGUF still downloads. # Gated off PR (see note above); public GGUF still downloads.
@ -720,7 +544,7 @@ jobs:
- name: Serve unsloth run --disable-tools (gemma-3-270m) - name: Serve unsloth run --disable-tools (gemma-3-270m)
run: | run: |
rm -rf ~/.unsloth/studio/auth unsloth studio reset-password
bash .github/scripts/serve-unsloth-run.sh \ bash .github/scripts/serve-unsloth-run.sh \
--model "$GGUF_REPO" --gguf-variant "$GGUF_VARIANT" \ --model "$GGUF_REPO" --gguf-variant "$GGUF_VARIANT" \
--port "$STUDIO_PORT" --log-dir logs \ --port "$STUDIO_PORT" --log-dir logs \
@ -758,15 +582,13 @@ jobs:
# `API Key: <key>`) into logs/unsloth-run-<port>.log, and the upload # `API Key: <key>`) into logs/unsloth-run-<port>.log, and the upload
# step publishes all of logs/, so scrubbing only studio-logs would leak # step publishes all of logs/, so scrubbing only studio-logs would leak
# the bearer token in the retained artifact. # the bearer token in the retained artifact.
# Sweep EVERY uploaded path, not just logs/ -- redacted-configs/ and
# agent-workdir/ are published by the same upload step.
if [ -n "${UNSLOTH_API_KEY:-}" ]; then if [ -n "${UNSLOTH_API_KEY:-}" ]; then
grep -rlF "$UNSLOTH_API_KEY" logs redacted-configs agent-workdir 2>/dev/null | while IFS= read -r f; do grep -rlF "$UNSLOTH_API_KEY" logs 2>/dev/null | while IFS= read -r f; do
sed -i "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$f" 2>/dev/null || true sed -i "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$f" 2>/dev/null || true
done done
fi fi
- name: Stop Unsloth - name: Stop Studio
if: always() if: always()
run: | run: |
# Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make # Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make

View file

@ -60,11 +60,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@v4
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 - uses: actions/setup-python@v5
with: with:
python-version: '3.12' python-version: '3.12'

View file

@ -130,7 +130,7 @@ jobs:
# MLX support landed after the most recent unsloth-zoo PyPI # MLX support landed after the most recent unsloth-zoo PyPI
# release; the wheel still raises NotImplementedError on # release; the wheel still raises NotImplementedError on
# Apple Silicon when device_type.get_device_type() runs # Apple Silicon when device_type.get_device_type() runs
# unguarded. Unsloth's own install.sh overlays unsloth-zoo # unguarded. Studio's own install.sh overlays unsloth-zoo
# from git main for the same reason. Pulling deps lets pip # from git main for the same reason. Pulling deps lets pip
# resolve the platform-conditional MLX-only wheels (mlx, # resolve the platform-conditional MLX-only wheels (mlx,
# mlx-lm, mlx-vlm gated on darwin+arm64 in unsloth-zoo's # mlx-lm, mlx-vlm gated on darwin+arm64 in unsloth-zoo's
@ -317,13 +317,13 @@ jobs:
echo echo
done done
# Validates the macOS prebuilt path Unsloth's setup.sh uses (#5963): install the # Validates the macOS prebuilt path Studio's setup.sh uses (#5963): install the
# unslothai/llama.cpp fork's latest release, download a small public GGUF, and # unslothai/llama.cpp fork's latest release, download a small public GGUF, and
# check llama-server /completion end to end. Split and placed last so the # check llama-server /completion end to end. Split and placed last so the
# untrusted binary runs only in the final smoke step, after every HF_TOKEN step, # untrusted binary runs only in the final smoke step, after every HF_TOKEN step,
# leaving no token-bearing step or shared workspace for a tampered prebuilt to # leaving no token-bearing step or shared workspace for a tampered prebuilt to
# corrupt. GH_TOKEN: releases API; HF_TOKEN (withheld on PR): probe + GGUF fetch. # corrupt. GH_TOKEN: releases API; HF_TOKEN (withheld on PR): probe + GGUF fetch.
- name: Unsloth prebuilt llama.cpp install + GGUF download (Mac M1) - name: Studio prebuilt llama.cpp install + GGUF download (Mac M1)
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -344,12 +344,12 @@ jobs:
# Final step: runs the downloaded binaries with no secrets present, and clears # Final step: runs the downloaded binaries with no secrets present, and clears
# the GitHub Actions command files so a tampered prebuilt cannot influence the job. # the GitHub Actions command files so a tampered prebuilt cannot influence the job.
- name: Unsloth prebuilt llama.cpp GGUF inference smoke (Mac M1) - name: Studio prebuilt llama.cpp GGUF inference smoke (Mac M1)
run: | run: |
set -euo pipefail set -euo pipefail
unset GITHUB_ENV GITHUB_PATH GITHUB_OUTPUT GITHUB_STEP_SUMMARY unset GITHUB_ENV GITHUB_PATH GITHUB_OUTPUT GITHUB_STEP_SUMMARY
INSTALL_DIR="$HOME/.unsloth-studio-prebuilt-test/llama.cpp" INSTALL_DIR="$HOME/.unsloth-studio-prebuilt-test/llama.cpp"
# Unsloth bundles only llama-server + llama-quantize (not llama-cli); # Studio bundles only llama-server + llama-quantize (not llama-cli);
# inference goes through llama-server's HTTP /completion endpoint. # inference goes through llama-server's HTTP /completion endpoint.
LLAMA_SERVER="$INSTALL_DIR/build/bin/llama-server" LLAMA_SERVER="$INSTALL_DIR/build/bin/llama-server"
LLAMA_QUANT="$INSTALL_DIR/build/bin/llama-quantize" LLAMA_QUANT="$INSTALL_DIR/build/bin/llama-quantize"
@ -400,4 +400,4 @@ jobs:
tail -40 /tmp/llama-server.log tail -40 /tmp/llama-server.log
exit 1 exit 1
fi fi
echo "OK: Unsloth prebuilt llama.cpp on Mac M1 + GGUF /completion works" echo "OK: Studio prebuilt llama.cpp on Mac M1 + GGUF /completion works"

View file

@ -1,78 +0,0 @@
# This workflow uses actions that are not certified by GitHub. They are provided
# by a third-party and are governed by separate terms of service, privacy
# policy, and support documentation.
name: Scorecard supply-chain security
on:
# For Branch-Protection check. Only the default branch is supported. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
branch_protection_rule:
# To guarantee Maintained check is occasionally updated. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
schedule:
- cron: '21 20 * * 0'
push:
branches: [ "main" ]
# Declare default permissions as read only.
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
# `publish_results: true` only works when run from the default branch. conditional can be removed if disabled.
if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request'
permissions:
# Needed to upload the results to code-scanning dashboard.
security-events: write
# Needed to publish results and get a badge (see publish_results below).
id-token: write
# Uncomment the permissions below if installing in a private repository.
# contents: read
# actions: read
steps:
- name: "Checkout code"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1
with:
results_file: results.sarif
results_format: sarif
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
# - you want to enable the Branch-Protection check on a *public* repository, or
# - you are installing Scorecard on a *private* repository
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
# Public repositories:
# - Publish results to OpenSSF REST API for easy access by consumers
# - Allows the repository to include the Scorecard badge.
# - See https://github.com/ossf/scorecard-action#publishing-results.
# For private repositories:
# - `publish_results` will always be set to `false`, regardless
# of the value entered here.
publish_results: true
# (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore
# file_mode: git
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: SARIF file
path: results.sarif
retention-days: 5
# Upload the results to GitHub's code scanning dashboard (optional).
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif

View file

@ -4,7 +4,7 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
studio_version: studio_version:
description: 'Unsloth version tag to release (for example, v0.1.39-beta)' description: 'Studio version tag to release (for example, v0.1.39-beta)'
type: string type: string
required: true required: true
pypi_version: pypi_version:
@ -19,19 +19,6 @@ on:
permissions: permissions:
contents: read contents: read
env:
DESKTOP_RELEASE_NOTES: |
Desktop app for Unsloth Studio.
**macOS**: Download the Apple Silicon `.dmg`.
**Windows**: Download the `-setup.exe` installer.
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
concurrency: concurrency:
group: release-desktop-${{ github.repository }} group: release-desktop-${{ github.repository }}
cancel-in-progress: false cancel-in-progress: false
@ -69,7 +56,7 @@ jobs:
if not studio_version: if not studio_version:
sys.exit('studio_version is required, for example v0.1.39-beta') sys.exit('studio_version is required, for example v0.1.39-beta')
if re.fullmatch(r'v?20\d{2}\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?', studio_version): if re.fullmatch(r'v?20\d{2}\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?', studio_version):
sys.exit(f'studio_version must be an Unsloth SemVer tag, not a date-style backend version: {studio_version}') sys.exit(f'studio_version must be a Studio SemVer tag, not a date-style backend version: {studio_version}')
semver_tag = re.compile( semver_tag = re.compile(
r'^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)' r'^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
@ -146,7 +133,7 @@ jobs:
print(f'pypi_version={pypi_version}', file=output) print(f'pypi_version={pypi_version}', file=output)
PY PY
- name: Verify PyPI package and Unsloth stamp - name: Verify PyPI package and Studio stamp
shell: bash shell: bash
env: env:
STUDIO_VERSION: ${{ steps.prepare.outputs.studio_version }} STUDIO_VERSION: ${{ steps.prepare.outputs.studio_version }}
@ -211,7 +198,7 @@ jobs:
fi fi
python3 scripts/stamp_studio_release.py --verify-dist "$RUNNER_TEMP/pypi-unsloth-dist" --expected "$STUDIO_VERSION" python3 scripts/stamp_studio_release.py --verify-dist "$RUNNER_TEMP/pypi-unsloth-dist" --expected "$STUDIO_VERSION"
else else
echo "scripts/stamp_studio_release.py not found; release-desktop requires #5308 to verify the PyPI Unsloth stamp." >&2 echo "scripts/stamp_studio_release.py not found; release-desktop requires #5308 to verify the PyPI Studio stamp." >&2
exit 1 exit 1
fi fi
@ -308,6 +295,14 @@ jobs:
PY PY
build: build:
# TODO: split into a "build (no secrets)" + "publish (secrets)" job pair
# with actions/upload-artifact handoff so the matrix build cannot
# publish a Release on its own. The current matrix runs across
# Linux/macOS/Windows in a single job, so the split needs artefact
# collection across the OS matrix and is out of scope for this
# hardening pass.
permissions:
contents: write # tauri-apps/tauri-action creates / uploads a GitHub Release
strategy: strategy:
fail-fast: false fail-fast: false
max-parallel: 1 max-parallel: 1
@ -316,21 +311,15 @@ jobs:
- platform: macos-latest - platform: macos-latest
args: '--target aarch64-apple-darwin' args: '--target aarch64-apple-darwin'
label: macOS (Apple Silicon) label: macOS (Apple Silicon)
artifact: macos-aarch64
release_arch: aarch64
# - platform: macos-latest # - platform: macos-latest
# args: '--target x86_64-apple-darwin' # args: '--target x86_64-apple-darwin'
# label: macOS (Intel) # label: macOS (Intel)
- platform: ubuntu-22.04 - platform: ubuntu-22.04
args: '' args: ''
label: Linux (x64) label: Linux (x64)
artifact: linux-x64
release_arch: x64
- platform: windows-latest - platform: windows-latest
args: '' args: ''
label: Windows (x64) label: Windows (x64)
artifact: windows-x64
release_arch: x64
name: Build ${{ matrix.label }} name: Build ${{ matrix.label }}
needs: prepare-version needs: prepare-version
@ -476,18 +465,41 @@ jobs:
if (chmodIdx !== -1 && sha256Idx > chmodIdx) { if (chmodIdx !== -1 && sha256Idx > chmodIdx) {
throw new Error('Desktop Linux release must verify the linuxdeploy digest before chmod +x'); throw new Error('Desktop Linux release must verify the linuxdeploy digest before chmod +x');
} }
const releaseBody = process.env.DESKTOP_RELEASE_NOTES; const releaseBodies = [];
if (!releaseBody) { for (let i = 0; i < lines.length; i += 1) {
throw new Error('DESKTOP_RELEASE_NOTES must not be empty'); const match = lines[i].match(/^(\s*)releaseBody:\s*\|\s*$/);
if (!match) continue;
const baseIndent = match[1].length;
const bodyLines = [];
i += 1;
for (; i < lines.length; i += 1) {
const line = lines[i];
if (line.trim() === '') {
bodyLines.push('');
continue;
}
const indent = line.match(/^\s*/)[0].length;
if (indent <= baseIndent) {
i -= 1;
break;
}
bodyLines.push(line.slice(baseIndent + 2));
}
releaseBodies.push(bodyLines.join('\n'));
} }
if (/\brpm\b|\.rpm/i.test(releaseBody)) { if (releaseBodies.length === 0) {
throw new Error('Desktop release body must not advertise RPM packages'); throw new Error('Expected at least one desktop release body');
} }
if (/AppImage.*universal|universal.*AppImage/i.test(releaseBody)) { for (const body of releaseBodies) {
throw new Error('Desktop release body must not advertise AppImage as universal'); if (/\brpm\b|\.rpm/i.test(body)) {
} throw new Error('Desktop release body must not advertise RPM packages');
if (!/AppImage.*experimental/i.test(releaseBody)) { }
throw new Error('Desktop release body must mark AppImage as experimental'); 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 JS
@ -632,33 +644,48 @@ jobs:
dest="$tools_dir/linuxdeploy-x86_64.AppImage" dest="$tools_dir/linuxdeploy-x86_64.AppImage"
curl -fsSL "$LINUXDEPLOY_URL" -o "$dest" curl -fsSL "$LINUXDEPLOY_URL" -o "$dest"
# Verify the digest BEFORE the binary is ever marked executable. The # Verify the digest BEFORE the binary is ever marked executable. The
# next step builds the AppImage with the Tauri signing key, so a # next step builds the AppImage with the Tauri signing key and a
# substituted linuxdeploy that ran here could exfiltrate signing # contents:write GITHUB_TOKEN in scope, so a substituted linuxdeploy
# material or tamper with release artifacts. Fail closed on any # that ran here could exfiltrate signing material or tamper with
# mismatch. # published release artifacts. Fail closed on any mismatch.
echo "${LINUXDEPLOY_SHA256} ${dest}" | sha256sum -c - echo "${LINUXDEPLOY_SHA256} ${dest}" | sha256sum -c -
chmod +x "$dest" chmod +x "$dest"
# ── Linux: build + sign ── # ── Linux: build + sign + upload ──
- name: Build Linux app - name: Build Linux app
id: build_linux
if: matrix.platform == 'ubuntu-22.04' if: matrix.platform == 'ubuntu-22.04'
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
XDG_CACHE_HOME: ${{ runner.temp }}/tauri-tools-cache XDG_CACHE_HOME: ${{ runner.temp }}/tauri-tools-cache
with: with:
projectPath: studio projectPath: studio
tauriScript: npx --prefix . tauri tauriScript: npx --prefix . tauri
tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
releaseBody: |
Desktop app for Unsloth Studio.
**macOS**: Download the Apple Silicon `.dmg`.
**Windows**: Download the `-setup.exe` installer.
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
releaseDraft: ${{ inputs.draft }}
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
args: -v ${{ matrix.args }} args: -v ${{ matrix.args }}
# ── macOS: build + sign + notarize ── # ── macOS: build + sign + notarize + upload ──
- name: Build macOS app - name: Build macOS app
id: build_macos
if: matrix.platform == 'macos-latest' if: matrix.platform == 'macos-latest'
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
@ -668,14 +695,29 @@ jobs:
with: with:
projectPath: studio projectPath: studio
tauriScript: npx --prefix . tauri tauriScript: npx --prefix . tauri
tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
releaseBody: |
Desktop app for Unsloth Studio.
**macOS**: Download the Apple Silicon `.dmg`.
**Windows**: Download the `-setup.exe` installer.
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
releaseDraft: ${{ inputs.draft }}
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
args: -v ${{ matrix.args }} args: -v ${{ matrix.args }}
# ── Windows: build + sign ── # ── Windows: build + sign + upload ──
- name: Build Windows app - name: Build Windows app
id: build_windows
if: matrix.platform == 'windows-latest' if: matrix.platform == 'windows-latest'
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@ -686,252 +728,44 @@ jobs:
with: with:
projectPath: studio projectPath: studio
tauriScript: npx --prefix . tauri tauriScript: npx --prefix . tauri
tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
releaseBody: |
Desktop app for Unsloth Studio.
**macOS**: Download the Apple Silicon `.dmg`.
**Windows**: Download the `-setup.exe` installer.
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
releaseDraft: ${{ inputs.draft }}
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
args: -v ${{ matrix.args }} args: -v ${{ matrix.args }}
- name: Stage release assets # Release process note: only non-draft workflow runs advance the public
shell: bash # desktop-latest updater channel. Draft builds are for private review; if a
env: # draft is manually published later, this channel intentionally remains
ARTIFACT_PATHS: ${{ steps.build_linux.outputs.artifactPaths || steps.build_macos.outputs.artifactPaths || steps.build_windows.outputs.artifactPaths }} # unchanged until a narrow manual channel-publish flow is added or a public
RELEASE_ARCH: ${{ matrix.release_arch }} # desktop release is created by running this workflow with draft=false.
run: | publish-updater-channel:
set -euo pipefail name: Publish desktop updater channel
if command -v python3 >/dev/null 2>&1; then
PYTHON=python3
else
PYTHON=python
fi
"$PYTHON" <<'PY'
import json
import os
import pathlib
import re
import shutil
import sys
import unicodedata
raw_paths = os.environ.get('ARTIFACT_PATHS', '')
try:
artifact_paths = json.loads(raw_paths)
except json.JSONDecodeError as error:
sys.exit(f'Invalid tauri-action artifactPaths output: {error}')
if not isinstance(artifact_paths, list) or not artifact_paths:
sys.exit('tauri-action did not return any release artifacts')
destination = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-assets')
destination.mkdir(parents=True, exist_ok=True)
staged = []
for raw_path in artifact_paths:
source = pathlib.Path(raw_path)
if not source.is_file():
continue
name = source.name
for extension in ('.app.tar.gz.sig', '.app.tar.gz'):
if name.endswith(extension):
name = f'{name[:-len(extension)]}_{os.environ["RELEASE_ARCH"]}{extension}'
break
name = unicodedata.normalize('NFD', name)
name = ''.join(character for character in name if not unicodedata.combining(character))
name = re.sub(r'[ ()\[\]{}]', '.', name)
while '..' in name:
name = name.replace('..', '.')
target = destination / name
if target.exists():
sys.exit(f'Duplicate staged release asset name: {name}')
shutil.copy2(source, target)
staged.append(name)
if not staged:
sys.exit('No release files were staged')
print('Staged release assets:')
print('\n'.join(sorted(staged)))
PY
- name: Upload signed release assets
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: desktop-release-${{ matrix.artifact }}
path: ${{ runner.temp }}/desktop-release-assets/*
if-no-files-found: error
compression-level: 0
retention-days: 1
# Only this job gets write access; builds hand off signed files via artifacts.
# Draft runs do not advance the public desktop-latest channel.
publish-release:
name: Publish desktop release
needs: [prepare-version, build] needs: [prepare-version, build]
if: ${{ !inputs.draft }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write # create the versioned Release and replace updater-channel metadata contents: write
env: env:
GH_REPO: ${{ github.repository }} GH_REPO: ${{ github.repository }}
APP_VERSION: ${{ needs.prepare-version.outputs.app_version }} APP_VERSION: ${{ needs.prepare-version.outputs.app_version }}
PYPI_VERSION: ${{ needs.prepare-version.outputs.pypi_version }}
STUDIO_VERSION: ${{ needs.prepare-version.outputs.studio_version }} STUDIO_VERSION: ${{ needs.prepare-version.outputs.studio_version }}
DESKTOP_RELEASE_TAG: ${{ needs.prepare-version.outputs.desktop_release_tag }} DESKTOP_RELEASE_TAG: ${{ needs.prepare-version.outputs.desktop_release_tag }}
DESKTOP_PRERELEASE: ${{ needs.prepare-version.outputs.prerelease }} DESKTOP_PRERELEASE: ${{ needs.prepare-version.outputs.prerelease }}
steps: steps:
- name: Harden runner (audit)
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
with:
egress-policy: audit
- name: Download signed release assets
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: desktop-release-*
path: ${{ runner.temp }}/desktop-release-assets
merge-multiple: true
- name: Validate release asset set
shell: bash
run: |
set -euo pipefail
python3 <<'PY'
import pathlib
import os
import sys
asset_dir = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-assets')
files = [path for path in asset_dir.iterdir() if path.is_file()]
required_suffixes = (
'.dmg',
'.app.tar.gz',
'.app.tar.gz.sig',
'.deb',
'.AppImage',
'.AppImage.sig',
'-setup.exe',
'-setup.exe.sig',
)
for suffix in required_suffixes:
matches = [path for path in files if path.name.endswith(suffix)]
if len(matches) != 1:
sys.exit(f'Expected exactly one {suffix} release asset, found {len(matches)}')
if any(path.name == 'latest.json' for path in files):
sys.exit('Build artifacts must not supply latest.json')
print('\n'.join(sorted(path.name for path in files)))
PY
- name: Create or validate versioned release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_DRAFT: ${{ inputs.draft }}
run: |
set -euo pipefail
notes_file="$RUNNER_TEMP/desktop-release-notes.md"
printf '%s\n' "$DESKTOP_RELEASE_NOTES" > "$notes_file"
release_json="$RUNNER_TEMP/versioned-release.json"
# REST tag lookup omits drafts; `gh release view` also checks pending tags.
if gh release view "$DESKTOP_RELEASE_TAG" \
--json tagName,isDraft,isPrerelease > "$release_json" 2>/dev/null; then
python3 <<'PY'
import json
import os
import pathlib
import sys
release = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'versioned-release.json').read_text())
expected_draft = os.environ['RELEASE_DRAFT'].lower() == 'true'
expected_prerelease = os.environ['DESKTOP_PRERELEASE'].lower() == 'true'
if release.get('tagName') != os.environ['DESKTOP_RELEASE_TAG']:
sys.exit('Existing desktop release tag does not match the requested tag')
if bool(release.get('isDraft')) != expected_draft:
sys.exit('Existing desktop release draft state does not match the workflow input')
if bool(release.get('isPrerelease')) != expected_prerelease:
sys.exit('Existing desktop release prerelease state does not match the requested version')
PY
else
release_flags=(
--title "Unsloth Studio (Desktop) ${STUDIO_VERSION}"
--notes-file "$notes_file"
--target "$GITHUB_SHA"
)
if [ "$RELEASE_DRAFT" = "true" ]; then
release_flags+=(--draft)
fi
if [ "$DESKTOP_PRERELEASE" = "true" ]; then
release_flags+=(--prerelease)
fi
gh release create "$DESKTOP_RELEASE_TAG" "${release_flags[@]}"
fi
- name: Publish versioned release assets
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh release upload "$DESKTOP_RELEASE_TAG" "$RUNNER_TEMP/desktop-release-assets"/* --clobber
- name: Generate and publish versioned updater metadata
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
python3 <<'PY'
import datetime
import json
import os
import pathlib
import sys
import urllib.parse
asset_dir = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-assets')
files = [path for path in asset_dir.iterdir() if path.is_file()]
def exactly_one(suffix: str) -> pathlib.Path:
matches = [path for path in files if path.name.endswith(suffix)]
if len(matches) != 1:
sys.exit(f'Expected exactly one {suffix} updater asset, found {len(matches)}')
return matches[0]
def entry(signature_suffix: str) -> dict[str, str]:
signature_path = exactly_one(signature_suffix)
bundle_name = signature_path.name.removesuffix('.sig')
bundle_path = asset_dir / bundle_name
if not bundle_path.is_file():
sys.exit(f'Missing updater bundle for {signature_path.name}: {bundle_name}')
encoded_tag = urllib.parse.quote(os.environ['DESKTOP_RELEASE_TAG'], safe='')
encoded_name = urllib.parse.quote(bundle_name, safe='')
return {
'signature': signature_path.read_text(),
'url': (
f'https://github.com/{os.environ["GITHUB_REPOSITORY"]}/releases/download/'
f'{encoded_tag}/{encoded_name}'
),
}
darwin = entry('.app.tar.gz.sig')
linux = entry('.AppImage.sig')
windows = entry('.exe.sig')
notes = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-notes.md').read_text()
metadata = {
'version': os.environ['APP_VERSION'],
# App version is SemVer; CHANGELOG.md is keyed by the backend release.
'pypi_version': os.environ['PYPI_VERSION'],
'notes': notes,
'pub_date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
'platforms': {
'darwin-aarch64': darwin,
'darwin-aarch64-app': darwin,
'linux-x86_64': linux,
'linux-x86_64-appimage': linux,
'windows-x86_64': windows,
'windows-x86_64-nsis': windows,
},
}
output = pathlib.Path(os.environ['RUNNER_TEMP'], 'latest.json')
output.write_text(json.dumps(metadata, indent=2) + '\n')
PY
gh release upload "$DESKTOP_RELEASE_TAG" "$RUNNER_TEMP/latest.json" --clobber
- name: Download versioned updater metadata - name: Download versioned updater metadata
if: ${{ !inputs.draft }}
shell: bash shell: bash
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
@ -956,7 +790,6 @@ jobs:
test -s "$RUNNER_TEMP/desktop-updater/latest.json" test -s "$RUNNER_TEMP/desktop-updater/latest.json"
- name: Validate versioned updater metadata - name: Validate versioned updater metadata
if: ${{ !inputs.draft }}
shell: bash shell: bash
run: | run: |
python3 <<'PY' python3 <<'PY'
@ -1016,7 +849,6 @@ jobs:
PY PY
- name: Ensure desktop updater channel release - name: Ensure desktop updater channel release
if: ${{ !inputs.draft }}
shell: bash shell: bash
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
@ -1049,7 +881,6 @@ jobs:
PY PY
- name: Prevent updater channel downgrade - name: Prevent updater channel downgrade
if: ${{ !inputs.draft }}
shell: bash shell: bash
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
@ -1140,7 +971,6 @@ jobs:
PY PY
- name: Publish desktop updater channel metadata - name: Publish desktop updater channel metadata
if: ${{ !inputs.draft }}
shell: bash shell: bash
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}

View file

@ -2,8 +2,8 @@
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Multi-language supply-chain audit. Triggers: # Multi-language supply-chain audit. Triggers:
# - PRs touching any dependency manifest (Python / npm / Cargo), a # - PRs touching any dependency manifest (Python / npm / Cargo) or
# scanner or its allowlist baseline, or this workflow file, # this workflow file,
# - push to main / pip, # - push to main / pip,
# - nightly @ 04:13 UTC so newly-published advisories surface even # - nightly @ 04:13 UTC so newly-published advisories surface even
# when no PR opens, # when no PR opens,
@ -36,8 +36,8 @@
# - unsloth `huggingfacenotorch` extras (the canonical install path # - unsloth `huggingfacenotorch` extras (the canonical install path
# for fine-tuning users; pulls transformers / peft / accelerate / # for fine-tuning users; pulls transformers / peft / accelerate /
# trl / datasets / diffusers / sentence-transformers / etc.) # trl / datasets / diffusers / sentence-transformers / etc.)
# - all six Unsloth backend requirements files # - all six Studio backend requirements files
# - Unsloth frontend (npm) and Tauri shell (cargo) # - Studio frontend (npm) and Tauri shell (cargo)
# Each Python step builds a filtered dep list from pyproject.toml + # Each Python step builds a filtered dep list from pyproject.toml +
# requirements/*.txt before auditing. We do NOT install any of these # requirements/*.txt before auditing. We do NOT install any of these
# -- pip-audit resolves through PyPI metadata, scan_packages.py # -- pip-audit resolves through PyPI metadata, scan_packages.py
@ -57,9 +57,7 @@ on:
- 'studio/src-tauri/Cargo.lock' - 'studio/src-tauri/Cargo.lock'
- 'pyproject.toml' - 'pyproject.toml'
- 'scripts/scan_packages.py' - 'scripts/scan_packages.py'
- 'scripts/scan_packages_baseline.json'
- 'scripts/scan_npm_packages.py' - 'scripts/scan_npm_packages.py'
- 'scripts/scan_npm_packages_baseline.json'
- '.github/workflows/security-audit.yml' - '.github/workflows/security-audit.yml'
push: push:
branches: [main, pip] branches: [main, pip]
@ -218,7 +216,7 @@ jobs:
# on the runner). A comment line is left in place so the # on the runner). A comment line is left in place so the
# skipped specs are obvious in the artifact. # skipped specs are obvious in the artifact.
# The `huggingface` extra is `huggingfacenotorch` plus torch / # The `huggingface` extra is `huggingfacenotorch` plus torch /
# torchvision / triton, deliberately skipped: Unsloth backend # torchvision / triton, deliberately skipped: Studio backend
# already pins a torch and the +cu* / +cpu local-version tags # already pins a torch and the +cu* / +cpu local-version tags
# trip up the PyPI resolver in `-r` mode. # trip up the PyPI resolver in `-r` mode.
run: | run: |
@ -253,7 +251,7 @@ jobs:
# `-r requirements.txt` resolves the requirements through pip's # `-r requirements.txt` resolves the requirements through pip's
# dependency resolver against PyPI metadata and audits the # dependency resolver against PyPI metadata and audits the
# resolved tree without ever executing setup.py / install # resolved tree without ever executing setup.py / install
# hooks. Way faster than installing the full Unsloth runtime # hooks. Way faster than installing the full Studio runtime
# and -- critically -- safer: an attacker who has compromised # and -- critically -- safer: an attacker who has compromised
# a transitive dep cannot run code in this job. # a transitive dep cannot run code in this job.
# #
@ -326,9 +324,9 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY" } >> "$GITHUB_STEP_SUMMARY"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# npm: Unsloth frontend # npm: Studio frontend
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
- name: npm audit (Unsloth frontend) - name: npm audit (Studio frontend)
# `npm audit` resolves the lockfile through the npmjs.com # `npm audit` resolves the lockfile through the npmjs.com
# advisory DB. `--audit-level=high` filters the noise floor # advisory DB. `--audit-level=high` filters the noise floor
# to only HIGH and CRITICAL. We do NOT pass --omit=dev: a # to only HIGH and CRITICAL. We do NOT pass --omit=dev: a
@ -342,7 +340,7 @@ jobs:
# Always also write the full JSON for grep-ability. # Always also write the full JSON for grep-ability.
npm audit --json > ../../logs-npm-audit.json || true npm audit --json > ../../logs-npm-audit.json || true
{ {
echo "## npm audit (Unsloth frontend)" echo "## npm audit (Studio frontend)"
echo echo
echo '```' echo '```'
tail -200 ../../logs-npm-audit.txt tail -200 ../../logs-npm-audit.txt
@ -350,9 +348,9 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY" } >> "$GITHUB_STEP_SUMMARY"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# cargo: Unsloth Tauri shell # cargo: Studio Tauri shell
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
- name: cargo audit (Unsloth Tauri) - name: cargo audit (Studio Tauri)
# `--deny warnings` would make the job fail on any advisory. # `--deny warnings` would make the job fail on any advisory.
# Keep non-blocking initially; drop continue-on-error after # Keep non-blocking initially; drop continue-on-error after
# the baseline closes. # the baseline closes.
@ -362,7 +360,7 @@ jobs:
set +e set +e
cargo audit | tee ../../logs-cargo-audit.txt cargo audit | tee ../../logs-cargo-audit.txt
{ {
echo "## cargo audit (Unsloth Tauri)" echo "## cargo audit (Studio Tauri)"
echo echo
echo '```' echo '```'
tail -200 ../../logs-cargo-audit.txt tail -200 ../../logs-cargo-audit.txt
@ -559,7 +557,7 @@ jobs:
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# CycloneDX SBOM. Lets downstream consumers audit what's # CycloneDX SBOM. Lets downstream consumers audit what's
# actually shipped in unsloth wheels and the Unsloth backend # actually shipped in unsloth wheels and the Studio backend
# runtime. Generates one JSON file per requirements input plus # runtime. Generates one JSON file per requirements input plus
# a combined SBOM keyed off pyproject.toml; uploads as a build # a combined SBOM keyed off pyproject.toml; uploads as a build
# artifact (and a future step can attest it via SLSA). # artifact (and a future step can attest it via SLSA).
@ -740,7 +738,7 @@ jobs:
# `--with-deps` makes the scan transitive: every package the # `--with-deps` makes the scan transitive: every package the
# declared set resolves to gets fetched and pattern-scanned, not # declared set resolves to gets fetched and pattern-scanned, not
# just the top-level pins. Resolving the full transitive closure # just the top-level pins. Resolving the full transitive closure
# of the unsloth + Unsloth dep tree downloads several hundred # of the unsloth + Studio dep tree downloads several hundred
# archives, hence the longer timeout. # archives, hence the longer timeout.
# #
# Sharded across runners for wall-clock parallelism. Each shard # Sharded across runners for wall-clock parallelism. Each shard
@ -749,7 +747,7 @@ jobs:
# composition tries to balance load: # composition tries to balance load:
# - hf-stack: pyproject extras + no-torch-runtime # - hf-stack: pyproject extras + no-torch-runtime
# (~150 archives, transformers/peft/accelerate/...) # (~150 archives, transformers/peft/accelerate/...)
# - studio: FastAPI/Unsloth backend + overrides + extras-no-deps # - studio: FastAPI/Studio backend + overrides + extras-no-deps
# (~150 archives, smaller scientific stack) # (~150 archives, smaller scientific stack)
# - extras: the heavy openai-whisper / scikit-learn / librosa # - extras: the heavy openai-whisper / scikit-learn / librosa
# stack (~250 archives, dominant cost) # stack (~250 archives, dominant cost)
@ -964,7 +962,7 @@ jobs:
# documented at scripts/scan_npm_packages.py top-of-file. The # documented at scripts/scan_npm_packages.py top-of-file. The
# script is stdlib-only so adding it does not increase the # script is stdlib-only so adding it does not increase the
# transitive supply-chain surface. # transitive supply-chain surface.
name: npm scan-packages (Unsloth frontend tarballs) name: npm scan-packages (Studio frontend tarballs)
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
needs: [] needs: []
@ -1173,7 +1171,7 @@ jobs:
with: with:
python-version: '3.12' python-version: '3.12'
- name: Install Unsloth frontend deps (--ignore-scripts) - name: Install Studio frontend deps (--ignore-scripts)
# `npm audit signatures` requires node_modules to be populated. # `npm audit signatures` requires node_modules to be populated.
# `--ignore-scripts` is mandatory: this is exactly the lever the # `--ignore-scripts` is mandatory: this is exactly the lever the
# new-install-script gate below protects against, and we must # new-install-script gate below protects against, and we must

View file

@ -1,156 +0,0 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Measures where Studio's startup time goes, on each platform.
#
# Nothing recorded a number before: main.py logs "lifespan startup completed in X ms"
# and studio_test_kit polls /healthz, but both throw the elapsed time away. A first
# local run (Linux, warm cache, 18-core server) put `import main` at 5.7-6.6s BEFORE
# the server can bind, dominated by eager module-level imports pulled in by routes:
# torch ~1.9s self, unsloth_zoo ~0.8s, routes ~0.6s, transformers ~0.5s.
#
# Not a gate yet: --max-healthz-seconds exists, but a budget should come from
# observed numbers rather than a guess.
name: Startup profile
on:
pull_request:
paths:
# The measured import graph is the whole backend tree: main.py imports auth,
# core, hub, loggers, models, picker, routes and utils at module scope.
- 'studio/backend/**'
- '!studio/backend/tests/**'
# The launch phase spawns `unsloth studio --api-only`, so the CLI counts too.
- 'unsloth_cli/**'
- 'studio/src-tauri/src/preflight**'
# The profiler hardcodes the desktop argv that process.rs::backend_args builds,
# so a change there must schedule a run or the two silently diverge.
- 'studio/src-tauri/src/process.rs'
- 'scripts/profile_startup.py'
- '.github/workflows/startup-profile-ci.yml'
# The job profiles whatever `install.sh --local` built: the installers pick the
# venv's Python and the dependency specs, and pyproject's include list is what
# makes --local overlay studio.backend*.
- 'install.sh'
- 'install.ps1'
- 'pyproject.toml'
# --local also runs the checkout's setup scripts (install.sh picks
# $_REPO_ROOT/studio/setup.sh, the editable install resolves setup.ps1 to the
# repo), and both call install_python_stack.py, which picks the dependencies.
- 'studio/setup.sh'
- 'studio/setup.ps1'
- 'studio/install_python_stack.py'
workflow_dispatch:
inputs:
repeats:
description: 'launch repeats per OS (median reported)'
type: string
default: '3'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
profile:
name: startup ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 60
continue-on-error: true
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-14, windows-latest]
env:
UNSLOTH_STUDIO_HOME: ${{ github.workspace }}/.studio-home
# A wildcard bind calls ifconfig.me on the startup path; loopback times our code.
UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK: '1'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install Studio
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -o pipefail
mkdir -p logs
# --local is load-bearing: it overlays the checkout, so the profiled server
# is this diff. Without it install.sh resolves unsloth from PyPI.
if [ "${{ runner.os }}" = "Windows" ]; then
pwsh -NoProfile -File ./install.ps1 --local 2>&1 | tee logs/install.log
else
bash install.sh --local 2>&1 | tee logs/install.log
fi
- name: Profile startup
shell: bash
run: |
BIN="$UNSLOTH_STUDIO_HOME/unsloth_studio/bin/unsloth"
[ -x "$BIN" ] || BIN="$UNSLOTH_STUDIO_HOME/unsloth_studio/Scripts/unsloth.exe"
[ -x "$BIN" ] || BIN=""
# Profile imports with the INSTALLED interpreter: that venv is what launches.
PY="$UNSLOTH_STUDIO_HOME/unsloth_studio/bin/python"
[ -x "$PY" ] || PY="$UNSLOTH_STUDIO_HOME/unsloth_studio/Scripts/python.exe"
[ -x "$PY" ] || PY="$(command -v python3 || command -v python)"
python3 scripts/profile_startup.py \
--python "$PY" \
${BIN:+--bin "$BIN"} \
--repeats "${{ inputs.repeats || '3' }}" \
--json "startup-${{ matrix.os }}.json" 2>&1 | tee logs/profile.log
- name: Summary
if: always()
shell: bash
run: |
f="startup-${{ matrix.os }}.json"
[ -f "$f" ] || { echo "no profile produced"; exit 0; }
python3 - "$f" >> "$GITHUB_STEP_SUMMARY" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
print(f"### {d['platform']} / {d['machine']} (py {d['python']}, {d['cpu_count']} cpu)\n")
imp = d.get("imports", {})
# Gate on ok: a failed `import main` still leaves rows, so a total can lie.
if imp.get("ok"):
print(f"**`import main`: {imp['total_seconds']}s**\n")
print("| package | self ms |")
print("|---|---:|")
for k, v in list(imp.get("self_by_package_ms", {}).items())[:8]:
print(f"| {k} | {v} |")
print()
else:
print("**`import main` failed - no valid import profile**\n")
print("```\n" + (imp.get("error") or "")[-1500:] + "\n```\n")
lau = d.get("launch") or {}
runs = len(lau.get("runs") or [])
failed = lau.get("failed_runs") or 0
if lau.get("healthz_median_seconds") is not None:
# The aggregates cover only the runs that reached healthz, so flag the
# failures: bare numbers would read as a normal fast startup.
note = f" _({runs - failed} of {runs} launches; {failed} never became healthy)_" if failed else ""
print(f"**time to a healthy port: {lau['healthz_median_seconds']}s median, "
f"{lau['healthz_max_seconds']}s max**{note}\n")
elif lau.get("skipped"):
print(f"_launch phase skipped: {lau['skipped']}_\n")
elif runs:
print(f"**no launch measurement: all {runs} launches failed to become healthy**\n")
PY
- name: Upload profile
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: startup-profile-${{ matrix.os }}
path: |
startup-*.json
logs/
retention-days: 14
if-no-files-found: warn

View file

@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Unsloth API & Auth Tests -- HTTP-level integration tests for the # Studio API & Auth Tests -- HTTP-level integration tests for the
# FastAPI surface. No Playwright, no model UI; tests/studio/test_studio_api_smoke.py # FastAPI surface. No Playwright, no model UI; tests/studio/test_studio_api_smoke.py
# runs ~30 s and asserts: # runs ~30 s and asserts:
# - CORS hardening (no wildcard + credentials, no bootstrap leak) # - CORS hardening (no wildcard + credentials, no bootstrap leak)
@ -15,7 +15,7 @@
# Reuses the GGUF cache key from studio-ui-smoke.yml so the model # Reuses the GGUF cache key from studio-ui-smoke.yml so the model
# download is one cache-hit on the second job. # download is one cache-hit on the second job.
name: Unsloth API CI name: Studio API CI
on: on:
pull_request: pull_request:
@ -40,7 +40,7 @@ permissions:
jobs: jobs:
api-smoke: api-smoke:
name: Unsloth API & Auth Tests name: Studio API & Auth Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 12 timeout-minutes: 12
env: env:
@ -98,7 +98,7 @@ jobs:
path: hf-cache path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
- name: Install Unsloth (--local, --no-torch) - name: Install Studio (--local, --no-torch)
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
@ -111,10 +111,9 @@ jobs:
- name: Install pyjwt for the JWT-expiry forge test - name: Install pyjwt for the JWT-expiry forge test
run: pip install 'pyjwt>=2.6' run: pip install 'pyjwt>=2.6'
- name: Reset auth + boot Unsloth (API-only) - name: Reset auth + boot Studio (API-only)
run: | run: |
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
mkdir -p logs mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 & > logs/studio.log 2>&1 &
@ -145,7 +144,7 @@ jobs:
echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV" echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV"
echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV" echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"
- name: Run Unsloth API & Auth tests - name: Run Studio API & Auth tests
# The script is named WITHOUT a `test_` prefix so it isn't # The script is named WITHOUT a `test_` prefix so it isn't
# auto-collected by pytest in Backend CI's `tests/` walk # auto-collected by pytest in Backend CI's `tests/` walk
# (which doesn't set BASE_URL and would crash at import). # (which doesn't set BASE_URL and would crash at import).
@ -154,7 +153,7 @@ jobs:
STUDIO_AUTH_DIR: /home/runner/.unsloth/studio/auth STUDIO_AUTH_DIR: /home/runner/.unsloth/studio/auth
run: python tests/studio/studio_api_smoke.py run: python tests/studio/studio_api_smoke.py
- name: Stop Unsloth - name: Stop Studio
if: always() if: always()
run: | run: |
kill "${STUDIO_PID}" 2>/dev/null || true kill "${STUDIO_PID}" 2>/dev/null || true

View file

@ -30,13 +30,6 @@ on:
- 'unsloth/**' - 'unsloth/**'
- 'unsloth_cli/**' - 'unsloth_cli/**'
- 'tests/**' - 'tests/**'
# The root installers: tests/sh/*.sh and tests/studio/install/* assert
# against these two files, so a change here must run the suite that
# covers it. Without them an install-only edit (the shape most AMD/ROCm
# routing fixes take) skipped Backend CI entirely.
- 'install.sh'
- 'install.ps1'
- 'scripts/**'
- 'pyproject.toml' - 'pyproject.toml'
- '.github/workflows/studio-backend-ci.yml' - '.github/workflows/studio-backend-ci.yml'
push: push:
@ -71,14 +64,13 @@ jobs:
- name: Install backend test dependencies (CPU only) - name: Install backend test dependencies (CPU only)
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
# Unsloth's declared backend deps: # Studio's declared backend deps:
pip install -r studio/backend/requirements/studio.txt pip install -r studio/backend/requirements/studio.txt
# Extras that studio.txt does not list but the import chain needs # Extras that studio.txt does not list but the import chain needs
# (python-multipart for FastAPI form/file uploads, sqlalchemy/cryptography # (python-multipart for FastAPI form/file uploads, sqlalchemy/cryptography
# for the auth DB, yaml/jinja2 for utils.models.model_config, psutil for # for the auth DB, yaml/jinja2 for utils.models.model_config, etc.):
# the orphan-cleanup process scan, etc.):
pip install \ pip install \
python-multipart aiofiles sqlalchemy cryptography psutil \ python-multipart aiofiles sqlalchemy cryptography \
pyyaml jinja2 mammoth unpdf requests \ pyyaml jinja2 mammoth unpdf requests \
'numpy<3' pytest pytest-asyncio httpx 'numpy<3' pytest pytest-asyncio httpx
# Torch CPU + transformers are required by a chunk of the backend test # Torch CPU + transformers are required by a chunk of the backend test
@ -141,7 +133,7 @@ jobs:
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r studio/backend/requirements/studio.txt pip install -r studio/backend/requirements/studio.txt
pip install \ pip install \
python-multipart aiofiles sqlalchemy cryptography psutil \ python-multipart aiofiles sqlalchemy cryptography \
pyyaml jinja2 mammoth unpdf requests typer \ pyyaml jinja2 mammoth unpdf requests typer \
'numpy<3' pytest pytest-asyncio httpx 'numpy<3' pytest pytest-asyncio httpx
# torchvision: unsloth_zoo.vision_utils imports it at module scope. # torchvision: unsloth_zoo.vision_utils imports it at module scope.
@ -200,7 +192,6 @@ jobs:
--ignore=tests/sh \ --ignore=tests/sh \
--ignore=tests/studio/test_hardware_dispatch_matrix.py \ --ignore=tests/studio/test_hardware_dispatch_matrix.py \
--ignore=tests/studio/test_is_mlx_dispatch_gate.py \ --ignore=tests/studio/test_is_mlx_dispatch_gate.py \
--ignore=tests/studio/test_xpu_spoof_pipeline.py \
--ignore=tests/vllm_compat \ --ignore=tests/vllm_compat \
--ignore=tests/version_compat \ --ignore=tests/version_compat \
-m 'not server and not e2e' \ -m 'not server and not e2e' \
@ -213,53 +204,34 @@ jobs:
env: env:
PYTHONPATH: ${{ github.workspace }}/studio PYTHONPATH: ${{ github.workspace }}/studio
UNSLOTH_COMPILE_DISABLE: '1' UNSLOTH_COMPILE_DISABLE: '1'
# These files mutate hardware.py module globals at runtime via the # These two files mutate hardware.py module globals at runtime
# spoof fixtures (CUDA/ROCm/XPU/MLX/CPU), which leaks state into any # via the spoof fixtures, which leaks state into any other test
# other test that imports hardware. Run them in their own pytest # that imports hardware. Run them in their own pytest invocation
# invocation so the leak does not cross file boundaries. # so the leak does not cross file boundaries.
run: | run: |
python -m pytest -q --tb=short \ python -m pytest -q --tb=short \
tests/studio/test_hardware_dispatch_matrix.py \ tests/studio/test_hardware_dispatch_matrix.py \
tests/studio/test_is_mlx_dispatch_gate.py \ tests/studio/test_is_mlx_dispatch_gate.py
tests/studio/test_xpu_spoof_pipeline.py
- name: CLI tests (unsloth_cli)
# unsloth_cli/tests had no CI at all: `unsloth_cli/**` was only a paths
# trigger and a ruff target, so 673 tests covering the studio launcher,
# the pre-exposure gate and the auth secret writers ran nowhere, and
# four of them had been failing on main unnoticed.
# Own step, not folded into the tests/ discovery above: pyproject's
# testpaths is tests/, and this suite needs no PYTHONPATH or CUDA spoof
# (it self-bootstraps sys.path and imports neither unsloth nor torch).
run: python -m pytest unsloth_cli/tests -q --tb=short
- name: Shell installer tests - name: Shell installer tests
# Auto-discovered rather than allowlisted. The old hardcoded list had # Subset that does not depend on a writable / pristine install.sh
# silently fallen seven files behind tests/run_all.sh, including # tree; test_install_host_defaults.sh checks install.ps1 layout
# test_strixhalo_wsl_reroute.sh -- the only shell coverage of the ROCm # which has drifted (separate followup).
# WSL reroute -- so that suite never ran on a PR. Skips are explicit,
# each with a reason, and tests/studio/test_ci_shell_suite_coverage.py
# fails if this step stops discovering the directory or the skip list
# grows without one.
#
# Skipped:
# test_install_host_defaults.sh: asserts an install.ps1 layout that
# has drifted (separate followup).
# test_install_rollback_lifecycle.sh: already runs on both platforms
# in cross-platform-parity-ci.yml.
run: | run: |
set -e set -e
skip="test_install_host_defaults.sh test_install_rollback_lifecycle.sh" for s in \
found=0 tests/sh/test_get_torch_index_url.sh \
for s in tests/sh/test_*.sh; do tests/sh/test_mac_intel_compat.sh \
case " $skip " in tests/sh/test_node_decision.sh \
*" $(basename "$s") "*) echo "skipping $s (see workflow comment)"; continue ;; tests/sh/test_studio_home_node_dir.sh \
esac tests/sh/test_system_node_readonly.sh \
found=$((found + 1)) tests/sh/test_nvcc_meets_llama_minimum.sh \
tests/sh/test_resolve_cuda_archs.sh \
tests/sh/test_tauri_install_exit_order.sh \
tests/sh/test_torch_constraint.sh \
tests/sh/test_torch_flavor.sh; do
echo "::group::$s" echo "::group::$s"
bash "$s" bash "$s"
echo "::endgroup::" echo "::endgroup::"
done done
[ "$found" -gt 0 ] || { echo "::error::no shell tests discovered under tests/sh"; exit 1; }
echo "ran $found shell installer test files"

View file

@ -1,76 +0,0 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Runs studio/backend/tests/test_export_capability.py on Linux, Windows and macOS.
#
# export_capability() is per-OS (is_apple_silicon() and the PyTorch-import probe differ per
# platform) and the export backend must import without PyTorch, so this confirms the gating and
# import-safety on hosted Windows/macOS. Hosted runners have no GPU/MLX, so a real accelerator
# export is validated separately. No GPU / model / llama.cpp: the tests mock the probes and block
# torch/unsloth, so the job installs only a CPU PyTorch plus import deps.
name: Unsloth export capability
on:
pull_request:
paths:
- 'studio/backend/utils/hardware/hardware.py'
- 'studio/backend/core/export/export.py'
- 'studio/backend/routes/export.py'
- 'studio/backend/main.py'
- 'studio/backend/tests/test_export_capability.py'
- '.github/workflows/studio-export-capability-ci.yml'
push:
branches: [main]
paths:
- 'studio/backend/utils/hardware/hardware.py'
- 'studio/backend/core/export/export.py'
- 'studio/backend/routes/export.py'
- 'studio/backend/main.py'
- 'studio/backend/tests/test_export_capability.py'
- '.github/workflows/studio-export-capability-ci.yml'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
capability:
name: capability (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 20
env:
# No accelerator on hosted runners; keep detection on the CPU path.
CUDA_VISIBLE_DEVICES: ""
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
cache: 'pip'
- name: Upgrade pip
run: python -m pip install --upgrade pip
- name: Install CPU PyTorch
# CPU wheel index so every OS gets a CPU build; keep PyPI as an extra index so torch's
# transitive deps still resolve (matching the other workflows in this repo).
run: python -m pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple "torch>=2.4,<2.13"
- name: Install backend import deps
# Enough to import utils.hardware and core.export.export; NOT unsloth (needs a GPU, and
# the import-safety test blocks it) or triton/llama.cpp (Linux-only / native builds).
run: python -m pip install
transformers peft accelerate safetensors huggingface_hub datasets
sentencepiece protobuf fastapi starlette structlog psutil
python-multipart pydantic httpx "numpy<3" pytest
- name: Export capability + import-safety tests
working-directory: studio/backend
run: python -m pytest tests/test_export_capability.py -q

View file

@ -133,13 +133,10 @@ jobs:
- name: Typecheck - name: Typecheck
run: npm run typecheck run: npm run typecheck
- name: Unit tests
run: npm test
- name: Build - name: Build
run: npm run build run: npm run build
- name: Built bundle must not contain Unsloth's unstable_Provider call site - name: Built bundle must not contain Studio's unstable_Provider call site
run: | run: |
set -e set -e
JS=$(ls dist/assets/index-*.js | head -1) JS=$(ls dist/assets/index-*.js | head -1)
@ -147,7 +144,7 @@ jobs:
echo "main bundle: $JS" echo "main bundle: $JS"
echo "unstable_Provider: hits=$HITS (assistant-ui internals contribute up to 3)" echo "unstable_Provider: hits=$HITS (assistant-ui internals contribute up to 3)"
if [ "$HITS" -gt 3 ]; then if [ "$HITS" -gt 3 ]; then
echo "::error file=studio/frontend/src/features/chat/runtime-provider.tsx::Unsloth bundle still passes unstable_Provider through useRemoteThreadListRuntime; this is the 2026.5.1 chat-history regression. Pass adapters directly into useLocalRuntime instead." echo "::error file=studio/frontend/src/features/chat/runtime-provider.tsx::Studio bundle still passes unstable_Provider through useRemoteThreadListRuntime; this is the 2026.5.1 chat-history regression. Pass adapters directly into useLocalRuntime instead."
exit 1 exit 1
fi fi

View file

@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Three end-to-end smoke jobs that boot a freshly-installed Unsloth and # Three end-to-end smoke jobs that boot a freshly-installed Studio and
# exercise the surfaces real users hit through the OpenAI / Anthropic # exercise the surfaces real users hit through the OpenAI / Anthropic
# SDKs and curl. Each job picks the smallest model that exercises the # SDKs and curl. Each job picks the smallest model that exercises the
# behaviour under test, primes HF_HOME via actions/cache, and shares # behaviour under test, primes HF_HOME via actions/cache, and shares
@ -27,7 +27,7 @@
# All three jobs run in parallel. Total wall time is dominated by job 3 # All three jobs run in parallel. Total wall time is dominated by job 3
# on a cold cache; warm cache cuts that to ~3 min. # on a cold cache; warm cache cuts that to ~3 min.
name: Unsloth GGUF CI name: Studio GGUF CI
on: on:
pull_request: pull_request:
@ -112,7 +112,7 @@ jobs:
path: hf-cache path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
- name: Install Unsloth (--local, --no-torch) - name: Install Studio (--local, --no-torch)
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
@ -125,10 +125,9 @@ jobs:
- name: Install OpenAI + Anthropic Python SDKs - name: Install OpenAI + Anthropic Python SDKs
run: pip install 'openai>=1.50' 'anthropic>=0.40' run: pip install 'openai>=1.50' 'anthropic>=0.40'
- name: Reset auth + boot Unsloth (API-only) - name: Reset auth + boot Studio (API-only)
run: | run: |
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
mkdir -p logs mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 & > logs/studio.log 2>&1 &
@ -143,7 +142,7 @@ jobs:
fi fi
sleep 1 sleep 1
done done
echo "Unsloth did not become healthy in 180s" echo "Studio did not become healthy in 180s"
tail -200 logs/studio.log tail -200 logs/studio.log
exit 1 exit 1
@ -230,11 +229,11 @@ jobs:
return replies return replies
def run_anthropic(): def run_anthropic():
# Two SDK quirks vs. Unsloth: # Two SDK quirks vs. Studio:
# 1. base_url must NOT include /v1 -- the SDK appends # 1. base_url must NOT include /v1 -- the SDK appends
# /v1/messages itself; otherwise the request hits # /v1/messages itself; otherwise the request hits
# /v1/v1/messages and 405s. # /v1/v1/messages and 405s.
# 2. The SDK sends `x-api-key` by default, but Unsloth's # 2. The SDK sends `x-api-key` by default, but Studio's
# auth layer is HTTPBearer-only. Override via # auth layer is HTTPBearer-only. Override via
# default_headers so Authorization: Bearer ... is # default_headers so Authorization: Bearer ... is
# sent instead. # sent instead.
@ -277,7 +276,7 @@ jobs:
print( print(
f"[{label}] WARN non-determinism at temperature=0.0 across " f"[{label}] WARN non-determinism at temperature=0.0 across "
f"{len(determinism_failures)} of {len(first)} turn(s); " f"{len(determinism_failures)} of {len(first)} turn(s); "
f"small-quant model drift, not an Unsloth regression. " f"small-quant model drift, not a Studio regression. "
f"Details: " + " | ".join(determinism_failures) f"Details: " + " | ".join(determinism_failures)
) )
# Sanity: turn-2 reply should mention the earlier question, and # Sanity: turn-2 reply should mention the earlier question, and
@ -291,7 +290,7 @@ jobs:
print(f"[{label}] {status_word} -- 4 turns, history grounded ('paris' present)") print(f"[{label}] {status_word} -- 4 turns, history grounded ('paris' present)")
PY PY
- name: Stop Unsloth - name: Stop Studio
if: always() if: always()
run: | run: |
kill "${STUDIO_PID}" 2>/dev/null || true kill "${STUDIO_PID}" 2>/dev/null || true
@ -324,7 +323,7 @@ jobs:
# store xet chunks + blobs + snapshots = ~4 GiB compressed -- # store xet chunks + blobs + snapshots = ~4 GiB compressed --
# 4-5x file-size inflation, dominated by xet chunks. Use main's # 4-5x file-size inflation, dominated by xet chunks. Use main's
# `--local-dir gguf-cache` pattern to cache the flat .gguf only. # `--local-dir gguf-cache` pattern to cache the flat .gguf only.
# Unsloth's /api/inference/load accepts either a HF repo (which # Studio's /api/inference/load accepts either a HF repo (which
# uses HF_HOME) or an absolute file path; passing the absolute # uses HF_HOME) or an absolute file path; passing the absolute
# path keeps the test off HF_HOME entirely so the cache size # path keeps the test off HF_HOME entirely so the cache size
# tracks the GGUF file 1:1. The OpenAI/Anth and JSON+images # tracks the GGUF file 1:1. The OpenAI/Anth and JSON+images
@ -381,7 +380,7 @@ jobs:
path: gguf-cache path: gguf-cache
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1 key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
- name: Install Unsloth (--local, --no-torch) - name: Install Studio (--local, --no-torch)
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
@ -391,7 +390,7 @@ jobs:
set -o pipefail set -o pipefail
bash install.sh --local --no-torch 2>&1 | tee logs/install.log bash install.sh --local --no-torch 2>&1 | tee logs/install.log
- name: Reset auth + boot Unsloth (API-only, default tool policy) - name: Reset auth + boot Studio (API-only, default tool policy)
# We deliberately use the API-only mode rather than # We deliberately use the API-only mode rather than
# `unsloth studio run` because the latter calls # `unsloth studio run` because the latter calls
# `set_tool_policy(...)` with a resolved bool: on loopback the # `set_tool_policy(...)` with a resolved bool: on loopback the
@ -401,7 +400,7 @@ jobs:
# tool_policy=None so each request's `enable_tools` field is # tool_policy=None so each request's `enable_tools` field is
# honoured. # honoured.
run: | run: |
rm -rf ~/.unsloth/studio/auth unsloth studio reset-password
mkdir -p logs mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 & > logs/studio.log 2>&1 &
@ -445,8 +444,6 @@ jobs:
python - <<'PY' python - <<'PY'
import json import json
import os import os
import time
import urllib.error
import urllib.request import urllib.request
BASE = os.environ["BASE_URL"] BASE = os.environ["BASE_URL"]
@ -467,26 +464,10 @@ jobs:
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
) )
# Shared CI runners stall sporadically, so retry transport-level with urllib.request.urlopen(req, timeout = timeout) as resp:
# failures only; HTTP status errors surface immediately. Bounded return resp.status, json.loads(resp.read().decode())
# to fit the job's timeout-minutes: short probes get 3 full
# attempts, long probes one retry capped at 300s (a healthy
# server answers a retry quickly; a stalled one never does).
attempts = 3 if timeout <= 300 else 2
for attempt in range(attempts):
try:
t = timeout if attempt == 0 else min(timeout, 300)
with urllib.request.urlopen(req, timeout = t) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
if attempt == attempts - 1:
raise
print(f"[retry] {path}: {exc!r}", flush = True)
time.sleep(15)
def post_sse(path, body, *, timeout = 600, retries = 1, complete_on = None): def post_sse(path, body, *, timeout = 600):
"""POST a streaming request and accumulate the assistant """POST a streaming request and accumulate the assistant
text deltas. The server-side agentic loop ALWAYS returns text deltas. The server-side agentic loop ALWAYS returns
SSE regardless of the request's `stream` field, so any SSE regardless of the request's `stream` field, so any
@ -502,22 +483,6 @@ jobs:
invocation markers / tool output, since invocation markers / tool output, since
`delta.content` alone is not evidence `delta.content` alone is not evidence
that the tool path executed. that the tool path executed.
A shared CI runner can stall the stream transport (the
connection opening, or a mid-stream read) even when Unsloth
is healthy, so retry a stall once with a fresh request
capped at 300s. A stall means the stream did NOT complete,
so partial events are normally NOT returned (an early
tool_start with no tool_end is not proof the tool loop
finished). The one exception is `complete_on`: an optional
predicate over the events collected so far -- when a stall
happens after it is already satisfied (the tool ran and
produced its result before the trailing read timed out),
those events are returned rather than discarded, so the
stall-after-answer case still counts. HTTP status errors
surface immediately; a stall that yields no completed result
across all attempts re-raises so the caller can rotate to
the next seed.
""" """
body = {**body, "stream": True} body = {**body, "stream": True}
data = json.dumps(body).encode() data = json.dumps(body).encode()
@ -530,45 +495,26 @@ jobs:
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
) )
for attempt in range(retries + 1): parts = []
parts = [] events = []
events = [] with urllib.request.urlopen(req, timeout = timeout) as resp:
t = timeout if attempt == 0 else min(timeout, 300) for raw in resp:
try: line = raw.decode().strip()
with urllib.request.urlopen(req, timeout = t) as resp: if not line.startswith("data: "):
for raw in resp: continue
line = raw.decode().strip() payload = line[6:]
if not line.startswith("data: "): if payload == "[DONE]":
continue break
payload = line[6:] events.append(payload)
if payload == "[DONE]": try:
break chunk = json.loads(payload)
events.append(payload) except json.JSONDecodeError:
try: continue
chunk = json.loads(payload) for choice in chunk.get("choices", []):
except json.JSONDecodeError: delta = choice.get("delta", {}) or {}
continue if delta.get("content"):
for choice in chunk.get("choices", []): parts.append(delta["content"])
delta = choice.get("delta", {}) or {} return "".join(parts), events
if delta.get("content"):
parts.append(delta["content"])
return "".join(parts), events
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
# A stall after the tool already produced its result is
# the case this probe exists to tolerate: keep those
# events. But a stall with only an early tool_start (no
# completed output) is not proof the tool loop finished,
# so it must not pass -- retry once, then raise so
# _run_tool_probe rotates to the next seed.
if complete_on is not None and complete_on(events):
print(f"[retry-sse] {path}: {exc!r}; keeping {len(events)} completed events", flush = True)
return "".join(parts), events
if attempt == retries:
raise
print(f"[retry-sse] {path}: {exc!r}", flush = True)
time.sleep(15)
_STUDIO_TOOL_TYPES = { _STUDIO_TOOL_TYPES = {
"tool_start", "tool_end", "tool_use", "tool_result", "tool_start", "tool_end", "tool_use", "tool_result",
@ -576,11 +522,11 @@ jobs:
def _tool_invoked(events): def _tool_invoked(events):
"""Structural check: True iff some SSE payload is a real """Structural check: True iff some SSE payload is a real
tool envelope (Unsloth tool_start/tool_end, Anthropic tool envelope (Studio tool_start/tool_end, Anthropic
tool_use/tool_result, OpenAI non-empty delta.tool_calls / tool_use/tool_result, OpenAI non-empty delta.tool_calls /
message.tool_calls / finish_reason='tool_calls' / message.tool_calls / finish_reason='tool_calls' /
role:'tool' / function_call). tool_status is NOT role:'tool' / function_call). tool_status is NOT
evidence: Unsloth emits empty tool_status events on evidence: Studio emits empty tool_status events on
iteration boundaries even when no tool ran. iteration boundaries even when no tool ran.
""" """
for raw in events: for raw in events:
@ -699,61 +645,23 @@ jobs:
attempt has structural invocation evidence. WARN (not attempt has structural invocation evidence. WARN (not
FAIL) if invoked but no attempt produces the expected FAIL) if invoked but no attempt produces the expected
literal in tool_end.result -- small-quant Qwen3.5-2B can literal in tool_end.result -- small-quant Qwen3.5-2B can
emit OpenAI tool_calls deltas without Unsloth's GGUF emit OpenAI tool_calls deltas without Studio's GGUF
agentic loop intercepting them, and that GGUF-vs-OpenAI agentic loop intercepting them, and that GGUF-vs-OpenAI
format mismatch is out of scope for #5642. format mismatch is out of scope for #5642.
""" """
attempts_log = [] attempts_log = []
best = None best = None
# Cap the wall-clock spent rotating through stalled seeds so a
# persistent no-data wedge fails fast (clean assertion) instead
# of being killed by the job's timeout-minutes. A healthy or
# merely degenerate round answers in seconds, so all seeds still
# run in the normal case; only stalls consume the budget.
probe_deadline = time.monotonic() + 300
for attempt_i in range(max_attempts): for attempt_i in range(max_attempts):
# Cap each read by the budget still remaining (not just a flat
# 180s) and skip an attempt too small to finish, so the whole
# rotation stays within ~300s -- two probes then fit the job's
# timeout-minutes even if every seed stalls.
remaining = int(probe_deadline - time.monotonic())
if attempt_i and remaining < 30:
print(f"[tools] {label}: seed-rotation budget spent after {attempt_i} attempts", flush = True)
break
attempt_seed = SEED + attempt_i attempt_seed = SEED + attempt_i
try: content, events = post_sse("/v1/chat/completions", {
# Bounded per-attempt timeout, no inner retry -- the seed "messages": [{"role": "user", "content": prompt}],
# loop IS the retry, so a stall raises quickly and rotates "enable_tools": True,
# rather than spending post_sse's full 600+300s. complete_on "enabled_tools": enabled,
# keeps a stall that already produced the tool result (only "session_id": f"{session}-att{attempt_i}",
# the trailing read timed out) instead of discarding it. "temperature": TOOL_PROBE_TEMP,
content, events = post_sse("/v1/chat/completions", { "seed": attempt_seed,
"messages": [{"role": "user", "content": prompt}], "max_tokens": 600,
"enable_tools": True, })
"permission_mode": "full",
"enabled_tools": enabled,
"session_id": f"{session}-att{attempt_i}",
"temperature": TOOL_PROBE_TEMP,
"seed": attempt_seed,
"max_tokens": 600,
}, timeout = min(180, remaining), retries = 0,
complete_on = lambda ev: _tool_invoked(ev) and _tool_output_contains(ev, *needles))
except urllib.error.HTTPError:
# HTTPError subclasses URLError, so re-raise a real 4xx/5xx
# here instead of letting the transport-stall handler below
# swallow it and rotate seeds -- an endpoint status failure
# must surface, not be masked as missing tool evidence.
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
# A transport stall that outlived post_sse's own retry:
# log it as a failed attempt and rotate to the next seed
# rather than sinking the whole probe on one bad stream.
attempts_log.append({
"attempt": attempt_i, "seed": attempt_seed,
"transport_error": repr(exc),
})
print(f"[tools] retry {label} attempt {attempt_i}: transport {exc!r}", flush = True)
continue
invoked = _tool_invoked(events) invoked = _tool_invoked(events)
produced = _tool_output_contains(events, *needles) produced = _tool_output_contains(events, *needles)
attempts_log.append({ attempts_log.append({
@ -812,21 +720,17 @@ jobs:
# because (a) the search may legitimately return no results, # because (a) the search may legitimately return no results,
# and (b) DuckDuckGo upstream blocks GHA IP ranges often # and (b) DuckDuckGo upstream blocks GHA IP ranges often
# enough that requiring a tool_call marker would create # enough that requiring a tool_call marker would create
# red-herring failures from infra rather than from Unsloth. # red-herring failures from infra rather than from Studio.
try: try:
# Best-effort and bounded: a single 180s attempt keeps a stall
# from eating the job's timeout-minutes (it already WARNs, so a
# retry buys nothing).
content, events = post_sse("/v1/chat/completions", { content, events = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}], "messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}],
"enable_tools": True, "enable_tools": True,
"permission_mode": "full",
"enabled_tools": ["web_search"], "enabled_tools": ["web_search"],
"session_id": "ci-tool-calling-web", "session_id": "ci-tool-calling-web",
"temperature": 0.0, "temperature": 0.0,
"seed": SEED, "seed": SEED,
"max_tokens": 400, "max_tokens": 400,
}, timeout = 180, retries = 0) })
print( print(
f"[tools] PASS web_search stream ({len(content)} chars in content, " f"[tools] PASS web_search stream ({len(content)} chars in content, "
f"{len(events)} raw events)" f"{len(events)} raw events)"
@ -835,7 +739,7 @@ jobs:
print(f"[tools] WARN web_search probe failed (non-blocking): {exc}") print(f"[tools] WARN web_search probe failed (non-blocking): {exc}")
# ── 5. Thinking on / off ───────────────────────────────────── # ── 5. Thinking on / off ─────────────────────────────────────
# Unsloth strips think blocks from message.content for tools-mode # Studio strips think blocks from message.content for tools-mode
# responses, so we toggle plain chat (no enable_tools) and look # responses, so we toggle plain chat (no enable_tools) and look
# at the surfaced reasoning_content / message.thinking field. # at the surfaced reasoning_content / message.thinking field.
def thinking_call(enable): def thinking_call(enable):
@ -849,7 +753,7 @@ jobs:
}) })
assert status == 200 assert status == 200
msg = data["choices"][0]["message"] msg = data["choices"][0]["message"]
# Unsloth surfaces thinking via reasoning_content (OpenAI # Studio surfaces thinking via reasoning_content (OpenAI
# extension). Fall back to inline <think> markers for # extension). Fall back to inline <think> markers for
# robustness across template versions. # robustness across template versions.
raw = (msg.get("content") or "") + (msg.get("reasoning_content") or "") raw = (msg.get("content") or "") + (msg.get("reasoning_content") or "")
@ -869,7 +773,7 @@ jobs:
print(f"[tools] PASS thinking on/off (on={len(on_text)} chars, off={len(off_text)} chars)") print(f"[tools] PASS thinking on/off (on={len(on_text)} chars, off={len(off_text)} chars)")
PY PY
- name: Stop Unsloth - name: Stop Studio
if: always() if: always()
run: | run: |
kill "${STUDIO_PID}" 2>/dev/null || true kill "${STUDIO_PID}" 2>/dev/null || true
@ -961,7 +865,7 @@ jobs:
path: hf-cache path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-${{ env.MMPROJ_FILE }}-v2 key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-${{ env.MMPROJ_FILE }}-v2
- name: Install Unsloth (--local, --no-torch) - name: Install Studio (--local, --no-torch)
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
@ -974,12 +878,12 @@ jobs:
- name: Install OpenAI + Anthropic Python SDKs - name: Install OpenAI + Anthropic Python SDKs
run: pip install 'openai>=1.50' 'anthropic>=0.40' run: pip install 'openai>=1.50' 'anthropic>=0.40'
- name: Reset auth + boot Unsloth (API-only) - name: Reset auth + boot Studio (API-only)
# See Job 2's comment: API-only mode keeps tool_policy=None so # See Job 2's comment: API-only mode keeps tool_policy=None so
# response_format requests aren't routed through the agentic # response_format requests aren't routed through the agentic
# tool loop. # tool loop.
run: | run: |
rm -rf ~/.unsloth/studio/auth unsloth studio reset-password
mkdir -p logs mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 & > logs/studio.log 2>&1 &
@ -1034,8 +938,6 @@ jobs:
import base64 import base64
import json import json
import os import os
import time
import urllib.error
import urllib.request import urllib.request
from openai import OpenAI from openai import OpenAI
from anthropic import Anthropic from anthropic import Anthropic
@ -1054,36 +956,20 @@ jobs:
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
) )
# Shared CI runners stall sporadically, so retry transport-level with urllib.request.urlopen(req, timeout = timeout) as resp:
# failures only; HTTP status errors surface immediately. Bounded return resp.status, json.loads(resp.read().decode())
# to fit the job's timeout-minutes: short probes get 3 full
# attempts, long probes one retry capped at 300s (a healthy
# server answers a retry quickly; a stalled one never does).
attempts = 3 if timeout <= 300 else 2
for attempt in range(attempts):
try:
t = timeout if attempt == 0 else min(timeout, 300)
with urllib.request.urlopen(req, timeout = t) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
if attempt == attempts - 1:
raise
print(f"[retry] {path}: {exc!r}", flush = True)
time.sleep(15)
# ── 1. response_format = json_object (JSON mode) ───────────── # ── 1. response_format = json_object (JSON mode) ─────────────
# llama.cpp's HTTP server supports OpenAI-compatible JSON # llama.cpp's HTTP server supports OpenAI-compatible JSON
# mode: `response_format: {"type": "json_object"}` constrains # mode: `response_format: {"type": "json_object"}` constrains
# the model to emit syntactically-valid JSON. We use raw HTTP # the model to emit syntactically-valid JSON. We use raw HTTP
# rather than the OpenAI SDK so that the field shape Unsloth # rather than the OpenAI SDK so that the field shape Studio
# forwards to llama-server is unambiguous (the SDK rewrites # forwards to llama-server is unambiguous (the SDK rewrites
# response_format depending on which variant it recognises). # response_format depending on which variant it recognises).
# We deliberately do NOT pass a strict JSON schema -- on # We deliberately do NOT pass a strict JSON schema -- on
# small Gemma-4 quants the GBNF-from-schema path occasionally # small Gemma-4 quants the GBNF-from-schema path occasionally
# produces empty output, and JSON mode is the surface we care # produces empty output, and JSON mode is the surface we care
# about exposing through Unsloth. # about exposing through Studio.
status, data = post("/v1/chat/completions", { status, data = post("/v1/chat/completions", {
"model": "default", "model": "default",
"messages": [ "messages": [
@ -1113,7 +999,7 @@ jobs:
print(f"[json] PASS json_object -> {parsed}") print(f"[json] PASS json_object -> {parsed}")
# ── 2. OpenAI image_url (data URI base64) ─────────────────── # ── 2. OpenAI image_url (data URI base64) ───────────────────
# 64x64 solid-red PNG. stb_image (used by Unsloth's image # 64x64 solid-red PNG. stb_image (used by Studio's image
# normaliser at routes/inference.py:3410) rejects 4x4 or # normaliser at routes/inference.py:3410) rejects 4x4 or
# smaller PNGs as truncated, so we go up to 64x64 -- still # smaller PNGs as truncated, so we go up to 64x64 -- still
# tiny in token cost. The assertion is loose: any non-empty # tiny in token cost. The assertion is loose: any non-empty
@ -1149,9 +1035,9 @@ jobs:
print("[image/openai] PASS image_url accepted, non-empty response") print("[image/openai] PASS image_url accepted, non-empty response")
# ── 3. Anthropic source/base64 image ──────────────────────── # ── 3. Anthropic source/base64 image ────────────────────────
# Two SDK quirks vs. Unsloth: base_url must NOT include /v1 # Two SDK quirks vs. Studio: base_url must NOT include /v1
# (the SDK appends it itself; otherwise /v1/v1/messages -> 405), # (the SDK appends it itself; otherwise /v1/v1/messages -> 405),
# and Unsloth's auth is HTTPBearer-only so the SDK's default # and Studio's auth is HTTPBearer-only so the SDK's default
# x-api-key header is ignored -- send Authorization: Bearer # x-api-key header is ignored -- send Authorization: Bearer
# via default_headers. # via default_headers.
anthropic = Anthropic( anthropic = Anthropic(
@ -1185,7 +1071,7 @@ jobs:
print("[image/anthropic] PASS source/base64 accepted, non-empty response") print("[image/anthropic] PASS source/base64 accepted, non-empty response")
PY PY
- name: Stop Unsloth - name: Stop Studio
if: always() if: always()
run: | run: |
kill "${STUDIO_PID}" 2>/dev/null || true kill "${STUDIO_PID}" 2>/dev/null || true

View file

@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# #
# Event-loop regression test for the Unsloth model-load orchestrator. # Event-loop regression test for the Studio model-load orchestrator.
# Pins down issue #5642 (Win10 UI freeze on model load): the /load # Pins down issue #5642 (Win10 UI freeze on model load): the /load
# route calls LlamaCppBackend.detect_audio_type synchronously, blocking # route calls LlamaCppBackend.detect_audio_type synchronously, blocking
# the FastAPI event loop on a chain of sync httpx.Client.post() probes. # the FastAPI event loop on a chain of sync httpx.Client.post() probes.
@ -14,7 +14,7 @@
# danielhanchen/unsloth-staging-2 (Ubuntu / macOS / Windows all # danielhanchen/unsloth-staging-2 (Ubuntu / macOS / Windows all
# green at PR time). # green at PR time).
name: Unsloth load-orchestrator CI name: Studio load-orchestrator CI
on: on:
pull_request: pull_request:

View file

@ -33,7 +33,7 @@ permissions:
jobs: jobs:
api-smoke: api-smoke:
name: Unsloth API & Auth Tests name: Studio API & Auth Tests
runs-on: macos-14 runs-on: macos-14
timeout-minutes: 25 timeout-minutes: 25
env: env:
@ -83,7 +83,7 @@ jobs:
path: hf-cache path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
- name: Install Unsloth (--local, --no-torch) - name: Install Studio (--local, --no-torch)
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
@ -99,10 +99,9 @@ jobs:
- name: Install pyjwt for the JWT-expiry forge test - name: Install pyjwt for the JWT-expiry forge test
run: pip install 'pyjwt>=2.6' run: pip install 'pyjwt>=2.6'
- name: Reset auth + boot Unsloth (API-only) - name: Reset auth + boot Studio (API-only)
run: | run: |
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
mkdir -p logs mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 & > logs/studio.log 2>&1 &
@ -130,13 +129,13 @@ jobs:
echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV" echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV"
echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV" echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"
- name: Run Unsloth API & Auth tests - name: Run Studio API & Auth tests
env: env:
BASE_URL: http://127.0.0.1:18895 BASE_URL: http://127.0.0.1:18895
STUDIO_AUTH_DIR: /Users/runner/.unsloth/studio/auth STUDIO_AUTH_DIR: /Users/runner/.unsloth/studio/auth
run: python tests/studio/studio_api_smoke.py run: python tests/studio/studio_api_smoke.py
- name: Stop Unsloth - name: Stop Studio
if: always() if: always()
run: | run: |
kill "${STUDIO_PID}" 2>/dev/null || true kill "${STUDIO_PID}" 2>/dev/null || true

View file

@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Three end-to-end smoke jobs that boot a freshly-installed Unsloth and # Three end-to-end smoke jobs that boot a freshly-installed Studio and
# exercise the surfaces real users hit through the OpenAI / Anthropic # exercise the surfaces real users hit through the OpenAI / Anthropic
# SDKs and curl. Each job picks the smallest model that exercises the # SDKs and curl. Each job picks the smallest model that exercises the
# behaviour under test, primes a model cache via actions/cache, and # behaviour under test, primes a model cache via actions/cache, and
@ -108,7 +108,7 @@ jobs:
path: hf-cache path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
- name: Install Unsloth (--local, --no-torch) - name: Install Studio (--local, --no-torch)
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
@ -124,10 +124,9 @@ jobs:
- name: Install OpenAI + Anthropic Python SDKs - name: Install OpenAI + Anthropic Python SDKs
run: pip install 'openai>=1.50' 'anthropic>=0.40' run: pip install 'openai>=1.50' 'anthropic>=0.40'
- name: Reset auth + boot Unsloth (API-only) - name: Reset auth + boot Studio (API-only)
run: | run: |
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
mkdir -p logs mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 & > logs/studio.log 2>&1 &
@ -142,7 +141,7 @@ jobs:
fi fi
sleep 1 sleep 1
done done
echo "Unsloth did not become healthy in 180s" echo "Studio did not become healthy in 180s"
tail -200 logs/studio.log tail -200 logs/studio.log
exit 1 exit 1
@ -229,11 +228,11 @@ jobs:
return replies return replies
def run_anthropic(): def run_anthropic():
# Two SDK quirks vs. Unsloth: # Two SDK quirks vs. Studio:
# 1. base_url must NOT include /v1 -- the SDK appends # 1. base_url must NOT include /v1 -- the SDK appends
# /v1/messages itself; otherwise the request hits # /v1/messages itself; otherwise the request hits
# /v1/v1/messages and 405s. # /v1/v1/messages and 405s.
# 2. The SDK sends `x-api-key` by default, but Unsloth's # 2. The SDK sends `x-api-key` by default, but Studio's
# auth layer is HTTPBearer-only. Override via # auth layer is HTTPBearer-only. Override via
# default_headers so Authorization: Bearer ... is # default_headers so Authorization: Bearer ... is
# sent instead. # sent instead.
@ -284,7 +283,7 @@ jobs:
print(f"[{label}] OK -- 4 turns, run1 == run2, history grounded") print(f"[{label}] OK -- 4 turns, run1 == run2, history grounded")
PY PY
- name: Stop Unsloth - name: Stop Studio
if: always() if: always()
run: | run: |
kill "${STUDIO_PID}" 2>/dev/null || true kill "${STUDIO_PID}" 2>/dev/null || true
@ -364,7 +363,7 @@ jobs:
path: gguf-cache path: gguf-cache
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1 key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
- name: Install Unsloth (--local, --no-torch) - name: Install Studio (--local, --no-torch)
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
@ -377,7 +376,7 @@ jobs:
- name: Assert llama.cpp loads on this macOS - name: Assert llama.cpp loads on this macOS
run: bash .github/scripts/assert-llama-loads.sh run: bash .github/scripts/assert-llama-loads.sh
- name: Reset auth + boot Unsloth (API-only, default tool policy) - name: Reset auth + boot Studio (API-only, default tool policy)
# We deliberately use the API-only mode rather than # We deliberately use the API-only mode rather than
# `unsloth studio run` because the latter calls # `unsloth studio run` because the latter calls
# `set_tool_policy(...)` with a resolved bool: on loopback the # `set_tool_policy(...)` with a resolved bool: on loopback the
@ -387,7 +386,7 @@ jobs:
# tool_policy=None so each request's `enable_tools` field is # tool_policy=None so each request's `enable_tools` field is
# honoured. # honoured.
run: | run: |
rm -rf ~/.unsloth/studio/auth unsloth studio reset-password
mkdir -p logs mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 & > logs/studio.log 2>&1 &
@ -431,8 +430,6 @@ jobs:
python - <<'PY' python - <<'PY'
import json import json
import os import os
import time
import urllib.error
import urllib.request import urllib.request
BASE = os.environ["BASE_URL"] BASE = os.environ["BASE_URL"]
@ -453,41 +450,14 @@ jobs:
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
) )
# Shared CI runners stall sporadically, so retry transport-level with urllib.request.urlopen(req, timeout = timeout) as resp:
# failures only; HTTP status errors surface immediately. Bounded return resp.status, json.loads(resp.read().decode())
# to fit the job's timeout-minutes: short probes get 3 full
# attempts, long probes one retry capped at 300s (a healthy
# server answers a retry quickly; a stalled one never does).
attempts = 3 if timeout <= 300 else 2
for attempt in range(attempts):
try:
t = timeout if attempt == 0 else min(timeout, 300)
with urllib.request.urlopen(req, timeout = t) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
if attempt == attempts - 1:
raise
print(f"[retry] {path}: {exc!r}", flush = True)
time.sleep(15)
def post_sse(path, body, *, timeout = 600, retries = 1, soft = False): def post_sse(path, body, *, timeout = 600):
"""POST a streaming request and accumulate the assistant """POST a streaming request and accumulate the assistant
text deltas. The server-side agentic loop ALWAYS returns text deltas. The server-side agentic loop ALWAYS returns
SSE regardless of the request's `stream` field, so any SSE regardless of the request's `stream` field, so any
call with enable_tools=true must use this helper. call with enable_tools=true must use this helper."""
A shared CI runner can stall the stream transport (the
connection opening, or a mid-stream read) even when Unsloth
is healthy, so harden the read three ways: retry a stall
once with a fresh request capped at 300s; return any text
already streamed before a stall (a stall on the trailing
tokens, after the answer arrived, still counts); and when
every attempt yields nothing, a hard call re-raises while a
soft call (the best-effort server-side tool probes) returns
None so the caller can WARN instead of sinking the whole
job. HTTP status errors always surface immediately."""
body = {**body, "stream": True} body = {**body, "stream": True}
data = json.dumps(body).encode() data = json.dumps(body).encode()
req = urllib.request.Request( req = urllib.request.Request(
@ -499,43 +469,24 @@ jobs:
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
) )
for attempt in range(retries + 1): parts = []
parts = [] with urllib.request.urlopen(req, timeout = timeout) as resp:
t = timeout if attempt == 0 else min(timeout, 300) for raw in resp:
try: line = raw.decode().strip()
with urllib.request.urlopen(req, timeout = t) as resp: if not line.startswith("data: "):
for raw in resp: continue
line = raw.decode().strip() payload = line[6:]
if not line.startswith("data: "): if payload == "[DONE]":
continue break
payload = line[6:] try:
if payload == "[DONE]": chunk = json.loads(payload)
break except json.JSONDecodeError:
try: continue
chunk = json.loads(payload) for choice in chunk.get("choices", []):
except json.JSONDecodeError: delta = choice.get("delta", {}) or {}
continue if delta.get("content"):
for choice in chunk.get("choices", []): parts.append(delta["content"])
delta = choice.get("delta", {}) or {} return "".join(parts)
if delta.get("content"):
parts.append(delta["content"])
return "".join(parts)
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
# Text already streamed is a valid signal -- keep it
# rather than re-running a heavy generation.
if parts:
joined = "".join(parts)
print(f"[retry-sse] {path}: {exc!r}; keeping {len(joined)} partial chars", flush = True)
return joined
if attempt == retries:
if soft:
print(f"[tools] WARN {path}: SSE transport stalled with no data ({exc!r}) -- non-blocking", flush = True)
return None
raise
print(f"[retry-sse] {path}: {exc!r}", flush = True)
time.sleep(15)
# ── 1. Standard OpenAI function calling ────────────────────── # ── 1. Standard OpenAI function calling ──────────────────────
weather_tool = { weather_tool = {
@ -575,11 +526,11 @@ jobs:
assert status == 200, f"tool call status {status}: {data}" assert status == 200, f"tool call status {status}: {data}"
choice = data["choices"][0] choice = data["choices"][0]
tool_calls = (choice.get("message") or {}).get("tool_calls") or [] tool_calls = (choice.get("message") or {}).get("tool_calls") or []
# Unsloth's contract: when tool_choice='required', llama.cpp's # Studio's contract: when tool_choice='required', llama.cpp's
# grammar should force a tool_calls payload. On Mac that # grammar should force a tool_calls payload. On Mac that
# contract is sometimes broken by the underlying quant; the # contract is sometimes broken by the underlying quant; the
# PASS path is "tool_calls present + correct schema", the # PASS path is "tool_calls present + correct schema", the
# WARN path documents Unsloth still returned 200 with a # WARN path documents Studio still returned 200 with a
# well-formed choices[] envelope. # well-formed choices[] envelope.
if tool_calls: if tool_calls:
tc = tool_calls[0] tc = tool_calls[0]
@ -606,23 +557,16 @@ jobs:
# macos-14 free runner is ~10 tok/s on Qwen3.5-2B Q4_K_XL; # macos-14 free runner is ~10 tok/s on Qwen3.5-2B Q4_K_XL;
# cap max_tokens tightly so each SSE round stays under ~30s # cap max_tokens tightly so each SSE round stays under ~30s
# even when the model stalls in a degenerate output state. # even when the model stalls in a degenerate output state.
# retries=0 on the best-effort probes: this job's 25-minute cap
# allows a 10-minute model load, so a no-data stall must be a
# single 180s attempt (not 180+15+180s) to leave room for the
# thinking checks. A soft/best-effort probe only WARNs anyway.
content = post_sse("/v1/chat/completions", { content = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": "What is 123 * 456? Use the python tool to compute it and tell me the number."}], "messages": [{"role": "user", "content": "What is 123 * 456? Use the python tool to compute it and tell me the number."}],
"enable_tools": True, "enable_tools": True,
"permission_mode": "full",
"enabled_tools": ["python"], "enabled_tools": ["python"],
"session_id": "ci-tool-calling-py", "session_id": "ci-tool-calling-py",
"temperature": TEMP, "temperature": TEMP,
"seed": SEED, "seed": SEED,
"max_tokens": 128, "max_tokens": 128,
}, timeout = 180, retries = 0, soft = True) }, timeout = 180)
if content is None: if "56088" in content or "56,088" in content:
print("[tools] WARN python tool: SSE transport stalled after retries -- non-blocking")
elif "56088" in content or "56,088" in content:
print(f"[tools] PASS python tool ({len(content)} chars, found 56088)") print(f"[tools] PASS python tool ({len(content)} chars, found 56088)")
else: else:
# Empty stream is a known Mac-quant degeneracy too; log # Empty stream is a known Mac-quant degeneracy too; log
@ -649,19 +593,18 @@ jobs:
content = post_sse("/v1/chat/completions", { content = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}], "messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}],
"enable_tools": True, "enable_tools": True,
"permission_mode": "full",
"enabled_tools": ["web_search"], "enabled_tools": ["web_search"],
"session_id": "ci-tool-calling-web", "session_id": "ci-tool-calling-web",
"temperature": TEMP, "temperature": TEMP,
"seed": SEED, "seed": SEED,
"max_tokens": 96, "max_tokens": 96,
}, timeout = 180, retries = 0) }, timeout = 180)
print(f"[tools] PASS web_search stream ({len(content)} chars)") print(f"[tools] PASS web_search stream ({len(content)} chars)")
except Exception as exc: except Exception as exc:
print(f"[tools] WARN web_search probe failed (non-blocking): {exc}") print(f"[tools] WARN web_search probe failed (non-blocking): {exc}")
# ── 4. Thinking on / off ───────────────────────────────────── # ── 4. Thinking on / off ─────────────────────────────────────
# Unsloth strips think blocks from message.content for tools-mode # Studio strips think blocks from message.content for tools-mode
# responses, so we toggle plain chat (no enable_tools) and look # responses, so we toggle plain chat (no enable_tools) and look
# at the surfaced reasoning_content / message.thinking field. # at the surfaced reasoning_content / message.thinking field.
def thinking_call(enable): def thinking_call(enable):
@ -679,7 +622,7 @@ jobs:
}, timeout = 180) }, timeout = 180)
assert status == 200 assert status == 200
msg = data["choices"][0]["message"] msg = data["choices"][0]["message"]
# Unsloth surfaces thinking via reasoning_content (OpenAI # Studio surfaces thinking via reasoning_content (OpenAI
# extension). Fall back to inline <think> markers for # extension). Fall back to inline <think> markers for
# robustness across template versions. # robustness across template versions.
raw = (msg.get("content") or "") + (msg.get("reasoning_content") or "") raw = (msg.get("content") or "") + (msg.get("reasoning_content") or "")
@ -705,7 +648,7 @@ jobs:
print(f"[tools] PASS thinking on/off (on={len(on_text)} chars, off={len(off_text)} chars)") print(f"[tools] PASS thinking on/off (on={len(on_text)} chars, off={len(off_text)} chars)")
PY PY
- name: Stop Unsloth - name: Stop Studio
if: always() if: always()
run: | run: |
kill "${STUDIO_PID}" 2>/dev/null || true kill "${STUDIO_PID}" 2>/dev/null || true
@ -811,7 +754,7 @@ jobs:
path: gguf-cache path: gguf-cache
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-${{ env.MMPROJ_FILE }}-v2 key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-${{ env.MMPROJ_FILE }}-v2
- name: Install Unsloth (--local, --no-torch) - name: Install Studio (--local, --no-torch)
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
@ -827,12 +770,12 @@ jobs:
- name: Install OpenAI + Anthropic Python SDKs - name: Install OpenAI + Anthropic Python SDKs
run: pip install 'openai>=1.50' 'anthropic>=0.40' run: pip install 'openai>=1.50' 'anthropic>=0.40'
- name: Reset auth + boot Unsloth (API-only) - name: Reset auth + boot Studio (API-only)
# See Job 2's comment: API-only mode keeps tool_policy=None so # See Job 2's comment: API-only mode keeps tool_policy=None so
# response_format requests aren't routed through the agentic # response_format requests aren't routed through the agentic
# tool loop. # tool loop.
run: | run: |
rm -rf ~/.unsloth/studio/auth unsloth studio reset-password
mkdir -p logs mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 & > logs/studio.log 2>&1 &
@ -882,8 +825,6 @@ jobs:
import base64 import base64
import json import json
import os import os
import time
import urllib.error
import urllib.request import urllib.request
from openai import OpenAI from openai import OpenAI
from anthropic import Anthropic from anthropic import Anthropic
@ -907,36 +848,20 @@ jobs:
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
) )
# Shared CI runners stall sporadically, so retry transport-level with urllib.request.urlopen(req, timeout = timeout) as resp:
# failures only; HTTP status errors surface immediately. Bounded return resp.status, json.loads(resp.read().decode())
# to fit the job's timeout-minutes: short probes get 3 full
# attempts, long probes one retry capped at 300s (a healthy
# server answers a retry quickly; a stalled one never does).
attempts = 3 if timeout <= 300 else 2
for attempt in range(attempts):
try:
t = timeout if attempt == 0 else min(timeout, 300)
with urllib.request.urlopen(req, timeout = t) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
if attempt == attempts - 1:
raise
print(f"[retry] {path}: {exc!r}", flush = True)
time.sleep(15)
# ── 1. response_format = json_object (JSON mode) ───────────── # ── 1. response_format = json_object (JSON mode) ─────────────
# llama.cpp's HTTP server supports OpenAI-compatible JSON # llama.cpp's HTTP server supports OpenAI-compatible JSON
# mode: `response_format: {"type": "json_object"}` constrains # mode: `response_format: {"type": "json_object"}` constrains
# the model to emit syntactically-valid JSON. We use raw HTTP # the model to emit syntactically-valid JSON. We use raw HTTP
# rather than the OpenAI SDK so that the field shape Unsloth # rather than the OpenAI SDK so that the field shape Studio
# forwards to llama-server is unambiguous (the SDK rewrites # forwards to llama-server is unambiguous (the SDK rewrites
# response_format depending on which variant it recognises). # response_format depending on which variant it recognises).
# We deliberately do NOT pass a strict JSON schema -- on # We deliberately do NOT pass a strict JSON schema -- on
# small Gemma-4 quants the GBNF-from-schema path occasionally # small Gemma-4 quants the GBNF-from-schema path occasionally
# produces empty output, and JSON mode is the surface we care # produces empty output, and JSON mode is the surface we care
# about exposing through Unsloth. # about exposing through Studio.
status, data = post("/v1/chat/completions", { status, data = post("/v1/chat/completions", {
"model": "default", "model": "default",
"messages": [ "messages": [
@ -1008,7 +933,7 @@ jobs:
) )
# ── 2. OpenAI image_url (data URI base64) ─────────────────── # ── 2. OpenAI image_url (data URI base64) ───────────────────
# 64x64 solid-red PNG. stb_image (used by Unsloth's image # 64x64 solid-red PNG. stb_image (used by Studio's image
# normaliser at routes/inference.py:3410) rejects 4x4 or # normaliser at routes/inference.py:3410) rejects 4x4 or
# smaller PNGs as truncated, so we go up to 64x64 -- still # smaller PNGs as truncated, so we go up to 64x64 -- still
# tiny in token cost. The assertion is loose: any non-empty # tiny in token cost. The assertion is loose: any non-empty
@ -1024,11 +949,11 @@ jobs:
# The Mac prebuilt llama.cpp server has a known crash when # The Mac prebuilt llama.cpp server has a known crash when
# processing image inputs alongside the gemma-4-E2B mmproj # processing image inputs alongside the gemma-4-E2B mmproj
# (server disconnects mid-completion). This is upstream # (server disconnects mid-completion). This is upstream
# llama.cpp behaviour, not Unsloth. Wrap both SDK calls in # llama.cpp behaviour, not Studio. Wrap both SDK calls in
# try/except so an upstream crash registers as a WARN rather # try/except so an upstream crash registers as a WARN rather
# than failing the whole job. Unsloth's contract (OpenAI/ # than failing the whole job. Studio's contract (OpenAI/
# Anthropic image fields are accepted and forwarded) is # Anthropic image fields are accepted and forwarded) is
# validated by the request body Unsloth constructs, not by # validated by the request body Studio constructs, not by
# whether llama.cpp can decode it on Mac Metal. # whether llama.cpp can decode it on Mac Metal.
client = OpenAI(base_url = f"{BASE}/v1", api_key = KEY) client = OpenAI(base_url = f"{BASE}/v1", api_key = KEY)
try: try:
@ -1054,14 +979,14 @@ jobs:
except Exception as exc: except Exception as exc:
print( print(
f"[image/openai] WARN image_url SDK call raised: {type(exc).__name__}: " f"[image/openai] WARN image_url SDK call raised: {type(exc).__name__}: "
f"{exc}. Likely upstream llama.cpp Mac+vision crash, NOT an Unsloth " f"{exc}. Likely upstream llama.cpp Mac+vision crash, NOT a Studio "
f"regression. Unsloth successfully forwarded the request." f"regression. Studio successfully forwarded the request."
) )
# ── 3. Anthropic source/base64 image ──────────────────────── # ── 3. Anthropic source/base64 image ────────────────────────
# Two SDK quirks vs. Unsloth: base_url must NOT include /v1 # Two SDK quirks vs. Studio: base_url must NOT include /v1
# (the SDK appends it itself; otherwise /v1/v1/messages -> 405), # (the SDK appends it itself; otherwise /v1/v1/messages -> 405),
# and Unsloth's auth is HTTPBearer-only so the SDK's default # and Studio's auth is HTTPBearer-only so the SDK's default
# x-api-key header is ignored -- send Authorization: Bearer # x-api-key header is ignored -- send Authorization: Bearer
# via default_headers. # via default_headers.
anthropic = Anthropic( anthropic = Anthropic(
@ -1100,11 +1025,11 @@ jobs:
print( print(
f"[image/anthropic] WARN anthropic image SDK call raised: " f"[image/anthropic] WARN anthropic image SDK call raised: "
f"{type(exc).__name__}: {exc}. Likely upstream llama.cpp Mac+vision " f"{type(exc).__name__}: {exc}. Likely upstream llama.cpp Mac+vision "
f"crash, NOT an Unsloth regression." f"crash, NOT a Studio regression."
) )
PY PY
- name: Stop Unsloth - name: Stop Studio
if: always() if: always()
run: | run: |
kill "${STUDIO_PID}" 2>/dev/null || true kill "${STUDIO_PID}" 2>/dev/null || true

View file

@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Proves Unsloth's llama.cpp install loads on every supported macOS. The heavy # Proves Studio's llama.cpp install loads on every supported macOS. The heavy
# app smokes stay single-OS; this matrix covers the OS-version dimension cheaply # app smokes stay single-OS; this matrix covers the OS-version dimension cheaply
# (install.sh + binary-load assert). Regression guard for the macOS-version # (install.sh + binary-load assert). Regression guard for the macOS-version
# selection in studio/install_llama_prebuilt.py. # selection in studio/install_llama_prebuilt.py.
@ -60,7 +60,7 @@ jobs:
with: with:
python-version: '3.12' python-version: '3.12'
- name: Install Unsloth (--local, --no-torch) - name: Install Studio (--local, --no-torch)
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.

View file

@ -19,7 +19,6 @@ on:
- 'install.sh' - 'install.sh'
- 'pyproject.toml' - 'pyproject.toml'
- 'tests/studio/**' - 'tests/studio/**'
- '.github/scripts/run-studio-permission-browser.sh'
- '.github/workflows/studio-mac-ui-smoke.yml' - '.github/workflows/studio-mac-ui-smoke.yml'
push: push:
branches: [main, pip] branches: [main, pip]
@ -84,7 +83,7 @@ jobs:
path: hf-cache path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
- name: Install Unsloth (--local, --no-torch) - name: Install Studio (--local, --no-torch)
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
@ -97,7 +96,7 @@ jobs:
- name: Assert llama.cpp loads on this macOS - name: Assert llama.cpp loads on this macOS
run: bash .github/scripts/assert-llama-loads.sh run: bash .github/scripts/assert-llama-loads.sh
- name: Install Playwright browsers - name: Install Playwright + Chromium
# No --with-deps on Mac: that flag installs Linux apt packages. # No --with-deps on Mac: that flag installs Linux apt packages.
# GitHub-hosted macos-14 ships the system frameworks Chromium # GitHub-hosted macos-14 ships the system frameworks Chromium
# needs already. # needs already.
@ -113,7 +112,7 @@ jobs:
# in-script retry recover from any residual flakes. # in-script retry recover from any residual flakes.
run: | run: |
pip install 'playwright>=1.55,<1.58' pip install 'playwright>=1.55,<1.58'
python -m playwright install chromium webkit python -m playwright install chromium
- name: Patch Playwright pipeTransport.js to tolerate malformed JSON - name: Patch Playwright pipeTransport.js to tolerate malformed JSON
# In Playwright 1.55-1.58, pipeTransport.js does # In Playwright 1.55-1.58, pipeTransport.js does
@ -144,10 +143,9 @@ jobs:
print(f"pipeTransport.js: patched JSON.parse calls in {path}") print(f"pipeTransport.js: patched JSON.parse calls in {path}")
PY PY
- name: Reset auth + boot Unsloth - name: Reset auth + boot Studio
run: | run: |
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
mkdir -p logs mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 & > logs/studio.log 2>&1 &
@ -190,8 +188,8 @@ jobs:
# dies mid-test, (2) Chromium net::ERR_NO_BUFFER_SPACE when the # dies mid-test, (2) Chromium net::ERR_NO_BUFFER_SPACE when the
# runner's kernel briefly runs out of socket buffers, and (3) a # runner's kernel briefly runs out of socket buffers, and (3) a
# goto 'interrupted by another navigation' when the SPA auth # goto 'interrupted by another navigation' when the SPA auth
# guard redirects mid-navigation. The retry FULLY resets Unsloth # guard redirects mid-navigation. The retry FULLY resets Studio
# (kill, wipe auth, reboot, wait /api/health, re-export # (kill, reset-password, reboot, wait /api/health, re-export
# bootstrap pw) before re-running the script. A real test failure # bootstrap pw) before re-running the script. A real test failure
# (assertion / timeout) does NOT match any pattern so it bypasses # (assertion / timeout) does NOT match any pattern so it bypasses
# retry and surfaces immediately. # retry and surfaces immediately.
@ -211,10 +209,10 @@ jobs:
|| grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_attempt_${attempt}.log \ || grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_attempt_${attempt}.log \
|| grep -q "interrupted by another navigation" logs/playwright_attempt_${attempt}.log; } \ || grep -q "interrupted by another navigation" logs/playwright_attempt_${attempt}.log; } \
&& [ "$attempt" -lt "$max_attempts" ]; then && [ "$attempt" -lt "$max_attempts" ]; then
echo "::warning::Playwright flake on attempt ${attempt}; resetting Unsloth and retrying..." echo "::warning::Playwright flake on attempt ${attempt}; resetting Studio and retrying..."
kill "${STUDIO_PID}" 2>/dev/null || true kill "${STUDIO_PID}" 2>/dev/null || true
sleep 2 sleep 2
rm -rf ~/.unsloth/studio/auth unsloth studio reset-password
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> "logs/studio_retry_${attempt}.log" 2>&1 & > "logs/studio_retry_${attempt}.log" 2>&1 &
STUDIO_PID=$! STUDIO_PID=$!
@ -240,19 +238,15 @@ jobs:
exit "$rc" exit "$rc"
done done
- name: Stop Unsloth (chat-ui ends with Shutdown click; this is belt-and-suspenders) - name: Stop Studio (chat-ui ends with Shutdown click; this is belt-and-suspenders)
if: always() if: always()
run: | run: |
kill "${STUDIO_PID}" 2>/dev/null || true kill "${STUDIO_PID}" 2>/dev/null || true
sleep 2 sleep 2
- name: Cross-browser permission controls - name: Reset auth + boot Studio for extra UI tests (port 18897)
run: | run: |
bash .github/scripts/run-studio-permission-browser.sh 18895 webkit unsloth studio reset-password
- name: Reset auth + boot Unsloth for extra UI tests (port 18897)
run: |
rm -rf ~/.unsloth/studio/auth
mkdir -p logs mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \
> logs/studio_extra.log 2>&1 & > logs/studio_extra.log 2>&1 &
@ -277,7 +271,7 @@ jobs:
echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV" echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV"
echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV" echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV"
- name: Drive Compare/Recipes/Export/Unsloth/Settings with Playwright - name: Drive Compare/Recipes/Export/Studio/Settings with Playwright
env: env:
BASE_URL: http://127.0.0.1:18897 BASE_URL: http://127.0.0.1:18897
STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }} STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }}
@ -306,10 +300,10 @@ jobs:
|| grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_extra_attempt_${attempt}.log \ || grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_extra_attempt_${attempt}.log \
|| grep -q "interrupted by another navigation" logs/playwright_extra_attempt_${attempt}.log; } \ || grep -q "interrupted by another navigation" logs/playwright_extra_attempt_${attempt}.log; } \
&& [ "$attempt" -lt "$max_attempts" ]; then && [ "$attempt" -lt "$max_attempts" ]; then
echo "::warning::Playwright flake on attempt ${attempt}; resetting Unsloth and retrying..." echo "::warning::Playwright flake on attempt ${attempt}; resetting Studio and retrying..."
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
sleep 2 sleep 2
rm -rf ~/.unsloth/studio/auth unsloth studio reset-password
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \
> "logs/studio_extra_retry_${attempt}.log" 2>&1 & > "logs/studio_extra_retry_${attempt}.log" 2>&1 &
STUDIO_EXTRA_PID=$! STUDIO_EXTRA_PID=$!
@ -333,7 +327,7 @@ jobs:
exit "$rc" exit "$rc"
done done
- name: Stop second Unsloth - name: Stop second Studio
if: always() if: always()
run: | run: |
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
@ -349,7 +343,5 @@ jobs:
logs/studio_extra.log logs/studio_extra.log
logs/install.log logs/install.log
logs/playwright logs/playwright
logs/playwright-permissions-*
logs/playwright_extra logs/playwright_extra
logs/studio-permissions-*.log
retention-days: 7 retention-days: 7

View file

@ -4,15 +4,15 @@
# Mac counterpart to studio-update-smoke.yml. Verifies that on a real # Mac counterpart to studio-update-smoke.yml. Verifies that on a real
# Apple Silicon (macos-14, M1) runner: # Apple Silicon (macos-14, M1) runner:
# #
# 1. install.sh --local --no-torch installs Unsloth AND auto-fetches # 1. install.sh --local --no-torch installs Studio AND auto-fetches
# the prebuilt llama.cpp Mac binary (llama-bNNNN-bin-macos-arm64 # the prebuilt llama.cpp Mac binary (llama-bNNNN-bin-macos-arm64
# from ggml-org/llama.cpp). Hitting the source-build fallback is # from ggml-org/llama.cpp). Hitting the source-build fallback is
# treated as an Unsloth bug -- Unsloth must always pick the # treated as an Unsloth bug -- Studio must always pick the
# prebuilt on Mac. # prebuilt on Mac.
# 2. unsloth studio update --local is idempotent. Two consecutive # 2. unsloth studio update --local is idempotent. Two consecutive
# runs both report "prebuilt up to date and validated", no # runs both report "prebuilt up to date and validated", no
# source-build fallback. # source-build fallback.
# 3. The installed Unsloth still boots and /api/health returns # 3. The installed Studio still boots and /api/health returns
# healthy after the update path. # healthy after the update path.
name: Mac Studio Update CI name: Mac Studio Update CI
@ -42,7 +42,7 @@ permissions:
jobs: jobs:
update-idempotency: update-idempotency:
name: Unsloth Updating Tests name: Studio Updating Tests
runs-on: macos-14 runs-on: macos-14
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
@ -59,7 +59,7 @@ jobs:
python-version: '3.12' python-version: '3.12'
cache: 'pip' cache: 'pip'
- name: Install Unsloth (--local, --no-torch) - name: Install Studio (--local, --no-torch)
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
@ -106,7 +106,7 @@ jobs:
grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update2.log grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update2.log
echo "second update was clean" echo "second update was clean"
- name: Boot Unsloth briefly to confirm the install is still usable - name: Boot Studio briefly to confirm the install is still usable
run: | run: |
mkdir -p logs mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18891 \ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18891 \
@ -123,13 +123,13 @@ jobs:
sleep 1 sleep 1
done done
if [ -z "$HEALTHY" ]; then if [ -z "$HEALTHY" ]; then
echo "Unsloth failed to come up after \`update\`" echo "Studio failed to come up after \`update\`"
tail -200 logs/studio.log tail -200 logs/studio.log
kill "$PID" 2>/dev/null || true kill "$PID" 2>/dev/null || true
exit 1 exit 1
fi fi
kill "$PID" 2>/dev/null || true kill "$PID" 2>/dev/null || true
echo "post-update Unsloth /api/health OK" echo "post-update Studio /api/health OK"
- name: Uninstall and verify clean - name: Uninstall and verify clean
# Round-trip through scripts/uninstall.sh on real macOS. As a side # Round-trip through scripts/uninstall.sh on real macOS. As a side

View file

@ -12,7 +12,7 @@
# stay in release-desktop.yml (manual `workflow_dispatch`) because they need # stay in release-desktop.yml (manual `workflow_dispatch`) because they need
# code-signing secrets and ~30 min of runner time each. # code-signing secrets and ~30 min of runner time each.
name: Unsloth Tauri CI name: Studio Tauri CI
on: on:
pull_request: pull_request:
@ -91,16 +91,6 @@ jobs:
npm run build npm run build
test -f dist/index.html test -f dist/index.html
# The crate carries ~100 unit tests (native_file_dialogs, preflight,
# install, desktop_auth, ...) that nothing ran until now: this workflow
# only ever built. Run them here, where the toolchain and the WebKit dev
# packages are already installed, so a broken assertion fails the PR
# instead of sitting unnoticed. `--no-fail-fast` reports every failing
# test in one run rather than stopping at the first.
- name: Rust unit tests (studio/src-tauri)
working-directory: studio/src-tauri
run: cargo test --no-fail-fast
- name: Tauri debug build (Linux, no bundle, no codesign) - name: Tauri debug build (Linux, no bundle, no codesign)
# `--debug` + `--no-bundle` keeps this lean: compiles the Rust crate, # `--debug` + `--no-bundle` keeps this lean: compiles the Rust crate,
# confirms the frontend dist is wired into Tauri, but skips the AppImage # confirms the frontend dist is wired into Tauri, but skips the AppImage

View file

@ -1,8 +1,8 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# End-to-end Unsloth chat UI smoke via Playwright + Chromium against a # End-to-end Studio chat UI smoke via Playwright + Chromium against a
# headless Linux runner. Boots Unsloth with the smallest GGUF # headless Linux runner. Boots Studio with the smallest GGUF
# (gemma-3-270m-it UD-Q4_K_XL, ~254 MiB), drives the actual frontend # (gemma-3-270m-it UD-Q4_K_XL, ~254 MiB), drives the actual frontend
# bundle, and asserts the full bootstrap-password / change-password / # bundle, and asserts the full bootstrap-password / change-password /
# send-message / persist-on-reload journey works end to end. # send-message / persist-on-reload journey works end to end.
@ -14,7 +14,7 @@
# frontend-only CI happily pass while the actual user-visible UI is # frontend-only CI happily pass while the actual user-visible UI is
# broken (cf. the 2026.5.1 chat-history release). # broken (cf. the 2026.5.1 chat-history release).
name: Unsloth UI CI name: Studio UI CI
on: on:
pull_request: pull_request:
@ -27,7 +27,6 @@ on:
# The Playwright test files themselves -- a PR that ONLY edits # The Playwright test files themselves -- a PR that ONLY edits
# the test must still trigger UI CI. # the test must still trigger UI CI.
- 'tests/studio/**' - 'tests/studio/**'
- '.github/scripts/run-studio-permission-browser.sh'
- '.github/workflows/studio-ui-smoke.yml' - '.github/workflows/studio-ui-smoke.yml'
push: push:
branches: [main, pip] branches: [main, pip]
@ -98,7 +97,7 @@ jobs:
path: hf-cache path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
- name: Install Unsloth (--local, --no-torch) - name: Install Studio (--local, --no-torch)
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
@ -108,15 +107,17 @@ jobs:
set -o pipefail set -o pipefail
bash install.sh --local --no-torch 2>&1 | tee logs/install.log bash install.sh --local --no-torch 2>&1 | tee logs/install.log
- name: Install Playwright browsers - name: Install Playwright + Chromium
run: | run: |
pip install 'playwright>=1.45' pip install 'playwright>=1.45'
python -m playwright install --with-deps chromium firefox webkit # --with-deps installs the OS-level runtime libs Chromium
# needs (libnss3, libxkbcommon, etc.). About 30 s on a
# warm runner.
python -m playwright install --with-deps chromium
- name: Reset auth + boot Unsloth - name: Reset auth + boot Studio
run: | run: |
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
mkdir -p logs mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 & > logs/studio.log 2>&1 &
@ -146,7 +147,7 @@ jobs:
# NEW + NEW2 are generated freshly per CI run via secrets.token_urlsafe # NEW + NEW2 are generated freshly per CI run via secrets.token_urlsafe
# rather than hardcoded. If a workflow gets compromised, the # rather than hardcoded. If a workflow gets compromised, the
# attacker can't replay a known-good rotated password against # attacker can't replay a known-good rotated password against
# any future / parallel Unsloth install -- the rotated value # any future / parallel Studio install -- the rotated value
# only ever exists for the lifetime of this single job, masked # only ever exists for the lifetime of this single job, masked
# in the log via ::add-mask::. # in the log via ::add-mask::.
run: | run: |
@ -164,37 +165,31 @@ jobs:
env: env:
BASE_URL: http://127.0.0.1:18892 BASE_URL: http://127.0.0.1:18892
# The test file lives in the repo so it can be run locally # The test file lives in the repo so it can be run locally
# against a freshly-installed Unsloth (BASE_URL=...; STUDIO_OLD_PW= # against a freshly-installed Studio (BASE_URL=...; STUDIO_OLD_PW=
# $(cat ~/.unsloth/studio/auth/.bootstrap_password); python ...). # $(cat ~/.unsloth/studio/auth/.bootstrap_password); python ...).
PW_ART_DIR: logs/playwright PW_ART_DIR: logs/playwright
# Strict mode: in CI a missing button / nav / dialog must # Strict mode: in CI a missing button / nav / dialog must
# FAIL the test. Locally the test still runs against partial # FAIL the test. Locally the test still runs against partial
# Unsloth installs without STUDIO_UI_STRICT. # Studio installs without STUDIO_UI_STRICT.
STUDIO_UI_STRICT: '1' STUDIO_UI_STRICT: '1'
run: | run: |
mkdir -p logs/playwright mkdir -p logs/playwright
python tests/studio/playwright_chat_ui.py python tests/studio/playwright_chat_ui.py
- name: Stop Unsloth (chat-ui ends with Shutdown click; this is belt-and-suspenders) - name: Stop Studio (chat-ui ends with Shutdown click; this is belt-and-suspenders)
if: always() if: always()
run: | run: |
kill "${STUDIO_PID}" 2>/dev/null || true kill "${STUDIO_PID}" 2>/dev/null || true
sleep 2 sleep 2
- name: Cross-browser permission controls
run: |
bash .github/scripts/run-studio-permission-browser.sh 18893 firefox
bash .github/scripts/run-studio-permission-browser.sh 18893 webkit
bash .github/scripts/run-studio-permission-browser.sh 18893 chromium chrome
# The chat UI test ends by clicking the Shutdown menuitem, which # The chat UI test ends by clicking the Shutdown menuitem, which
# leaves the server dead. The extra UI test (Compare / Recipes / # leaves the server dead. The extra UI test (Compare / Recipes /
# Export / Unsloth / Settings) needs a fresh Unsloth, so we boot a # Export / Studio / Settings) needs a fresh Studio, so we boot a
# second one on a different port. Boot is fast (~3-5s on the # second one on a different port. Boot is fast (~3-5s on the
# warm install we already did) so this adds little wall time. # warm install we already did) so this adds little wall time.
- name: Reset auth + boot Unsloth for extra UI tests (port 18894) - name: Reset auth + boot Studio for extra UI tests (port 18894)
run: | run: |
rm -rf ~/.unsloth/studio/auth unsloth studio reset-password
mkdir -p logs mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18894 \ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18894 \
> logs/studio_extra.log 2>&1 & > logs/studio_extra.log 2>&1 &
@ -219,7 +214,7 @@ jobs:
echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV" echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV"
echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV" echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV"
- name: Drive Compare/Recipes/Export/Unsloth/Settings with Playwright - name: Drive Compare/Recipes/Export/Studio/Settings with Playwright
env: env:
BASE_URL: http://127.0.0.1:18894 BASE_URL: http://127.0.0.1:18894
STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }} STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }}
@ -232,75 +227,18 @@ jobs:
mkdir -p logs/playwright_extra mkdir -p logs/playwright_extra
python tests/studio/playwright_extra_ui.py python tests/studio/playwright_extra_ui.py
- name: UI font size scaling regression (Playwright) - name: Stop second Studio
env:
BASE_URL: http://127.0.0.1:18894
STUDIO_PW: ${{ env.STUDIO_EXTRA_NEW_PW }}
PW_ART_DIR: logs/playwright_fontscale
run: |
mkdir -p logs/playwright_fontscale
python tests/studio/playwright_ui_font_scale.py
- name: Stop second Unsloth
if: always() if: always()
run: | run: |
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
sleep 2 sleep 2
# Model-picker per-model-config regression (PR #7207 re-land of #6647).
# Fourth Unsloth on its own port; loads the tiny GGUF and drives the
# picker's run-settings surface: Context Length persists across a reload,
# Reset clears the stored override (never pins it), and the infra models
# (RAG embedder + llama.cpp probe) stay hidden from the picker.
- name: Reset auth + boot Unsloth for model-config tests (port 18898)
run: |
rm -rf ~/.unsloth/studio/auth
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18898 \
> logs/studio_modelcfg.log 2>&1 &
echo "STUDIO_MODELCFG_PID=$!" >> "$GITHUB_ENV"
- name: Wait for /api/health on 18898
run: |
for i in $(seq 1 180); do
if curl -fs "http://127.0.0.1:18898/api/health" > /tmp/health4.json; then
jq -e '.status == "healthy"' /tmp/health4.json && break
fi
sleep 1
done
jq -e '.status == "healthy"' /tmp/health4.json
- name: Pass bootstrap pw for model-config test
run: |
NEW="CIModelCfg-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
echo "::add-mask::$NEW"
echo "STUDIO_MODELCFG_NEW_PW=$NEW" >> "$GITHUB_ENV"
- name: Drive model-picker per-model-config with Playwright
env:
BASE_URL: http://127.0.0.1:18898
STUDIO_NEW_PW: ${{ env.STUDIO_MODELCFG_NEW_PW }}
PW_ART_DIR: logs/playwright_modelcfg
STUDIO_UI_STRICT: '1'
GGUF_REPO: ${{ env.GGUF_REPO }}
GGUF_VARIANT: ${{ env.GGUF_VARIANT }}
STUDIO_MODEL_HINT: gemma-3-270m
run: |
mkdir -p logs/playwright_modelcfg
python tests/studio/playwright_model_config.py
- name: Stop fourth Unsloth
if: always()
run: |
kill "${STUDIO_MODELCFG_PID}" 2>/dev/null || true
sleep 2
# IME + multilingual paste regression (issue #5318 / PR #5327). # IME + multilingual paste regression (issue #5318 / PR #5327).
# Third Unsloth on its own port so a hang here cannot poison the # Third Studio on its own port so a hang here cannot poison the
# earlier UI tests. No GGUF -- the bug surface is the composer. # earlier UI tests. No GGUF -- the bug surface is the composer.
- name: Reset auth + boot Unsloth for IME / i18n tests (port 18896) - name: Reset auth + boot Studio for IME / i18n tests (port 18896)
run: | run: |
rm -rf ~/.unsloth/studio/auth unsloth studio reset-password
mkdir -p logs mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18896 \ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18896 \
> logs/studio_ime.log 2>&1 & > logs/studio_ime.log 2>&1 &
@ -318,7 +256,7 @@ jobs:
- name: Pass bootstrap pw for IME / i18n test - name: Pass bootstrap pw for IME / i18n test
# IME smoke does the change-password against the bootstrap that # IME smoke does the change-password against the bootstrap that
# Unsloth's frontend injects into the page, so it only needs the # Studio's frontend injects into the page, so it only needs the
# NEW password. # NEW password.
run: | run: |
NEW="CIIme-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')" NEW="CIIme-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
@ -335,7 +273,7 @@ jobs:
mkdir -p logs/playwright_ime mkdir -p logs/playwright_ime
python tests/studio/playwright_chat_ime_i18n.py python tests/studio/playwright_chat_ime_i18n.py
- name: Stop third Unsloth - name: Stop third Studio
if: always() if: always()
run: | run: |
kill "${STUDIO_IME_PID}" 2>/dev/null || true kill "${STUDIO_IME_PID}" 2>/dev/null || true
@ -355,15 +293,10 @@ jobs:
path: | path: |
logs/studio.log logs/studio.log
logs/studio_extra.log logs/studio_extra.log
logs/studio_modelcfg.log
logs/studio_ime.log logs/studio_ime.log
logs/install.log logs/install.log
logs/server-logs/ logs/server-logs/
logs/playwright logs/playwright
logs/playwright-permissions-*
logs/playwright_extra logs/playwright_extra
logs/playwright_fontscale
logs/playwright_modelcfg
logs/playwright_ime logs/playwright_ime
logs/studio-permissions-*.log
retention-days: 7 retention-days: 7

View file

@ -9,7 +9,7 @@
# This catches regressions in setup.sh's update path that the existing # This catches regressions in setup.sh's update path that the existing
# GGUF / wheel jobs would miss because they only invoke install.sh once. # GGUF / wheel jobs would miss because they only invoke install.sh once.
name: Unsloth Update CI name: Studio Update CI
on: on:
pull_request: pull_request:
@ -36,7 +36,7 @@ permissions:
jobs: jobs:
update-idempotency: update-idempotency:
name: Unsloth Updating Tests name: Studio Updating Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
@ -63,7 +63,7 @@ jobs:
# post-step then fatal-errors with "Cache folder path is # post-step then fatal-errors with "Cache folder path is
# retrieved for pip but doesn't exist on disk". # retrieved for pip but doesn't exist on disk".
- name: Install Unsloth (--local, --no-torch) - name: Install Studio (--local, --no-torch)
# Pass the workflow token so the llama.cpp prebuilt installer's # Pass the workflow token so the llama.cpp prebuilt installer's
# GitHub-API call to list releases isn't rate-limited (60/hr # GitHub-API call to list releases isn't rate-limited (60/hr
# unauthenticated). Without this, three consecutive install + # unauthenticated). Without this, three consecutive install +
@ -122,7 +122,7 @@ jobs:
grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update2.log grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update2.log
echo "second update was clean" echo "second update was clean"
- name: Boot Unsloth briefly to confirm the install is still usable - name: Boot Studio briefly to confirm the install is still usable
# If `update --local` accidentally broke the venv or wiped the # If `update --local` accidentally broke the venv or wiped the
# llama-server binary, the server would fail to start here. # llama-server binary, the server would fail to start here.
run: | run: |
@ -138,53 +138,13 @@ jobs:
sleep 1 sleep 1
done done
if ! jq -e '.status == "healthy"' /tmp/health.json 2>/dev/null; then if ! jq -e '.status == "healthy"' /tmp/health.json 2>/dev/null; then
echo "Unsloth failed to come up after `update`" echo "Studio failed to come up after `update`"
tail -200 logs/studio.log tail -200 logs/studio.log
kill "$PID" 2>/dev/null || true kill "$PID" 2>/dev/null || true
exit 1 exit 1
fi fi
kill "$PID" 2>/dev/null || true kill "$PID" 2>/dev/null || true
echo "post-update Unsloth /api/health OK" echo "post-update Studio /api/health OK"
- name: A complete install reports itself complete
run: |
set -o pipefail
unsloth studio verify-install
unsloth studio desktop-capabilities --json | tee /tmp/caps.json
jq -e '.studio_install_ok == true' /tmp/caps.json
jq -e '.desktop_manageability_version >= 2' /tmp/caps.json
- name: An incomplete install must not report itself ready
# An installer killed part-way leaves a working CLI but no studio.txt
# deps, which the old preflight called ManagedReady. The manifest is
# written last, so removing it reproduces that state.
run: |
set -o pipefail
# install.sh's default root, resolved explicitly: `python` on PATH
# here is setup-python's, not the managed venv.
MANIFEST="$HOME/.unsloth/studio/unsloth_studio/unsloth_install_manifest.json"
test -f "$MANIFEST" || { echo "::error::installer never wrote $MANIFEST"; exit 1; }
rm -f "$MANIFEST"
unsloth studio desktop-capabilities --json | tee /tmp/caps_bad.json
jq -e '.studio_install_ok == false' /tmp/caps_bad.json
if unsloth studio verify-install; then
echo "::error::verify-install passed on an install with no manifest"
exit 1
fi
echo "incomplete install correctly reported not-ready"
- name: Update repairs an incomplete install
# `--local` bypasses setup.sh's PyPI version compare, so this asserts
# the repair OUTCOME. The non-local fast path the desktop Repair button
# uses is covered by tests/studio/install/test_setup_fast_path_guard.py.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -o pipefail
unsloth studio update --local 2>&1 | tee logs/update_repair.log
unsloth studio verify-install
unsloth studio desktop-capabilities --json | jq -e '.studio_install_ok == true'
echo "update repaired the incomplete install"
- name: Uninstall and verify clean - name: Uninstall and verify clean
# Round-trip the installer through scripts/uninstall.sh: confirms the # Round-trip the installer through scripts/uninstall.sh: confirms the

View file

@ -9,7 +9,7 @@
# (Section 6) is Linux-only and short-circuits on non-POSIX; the rest # (Section 6) is Linux-only and short-circuits on non-POSIX; the rest
# is platform-portable. # is platform-portable.
name: Windows Unsloth API CI name: Windows Studio API CI
on: on:
pull_request: pull_request:
@ -34,7 +34,7 @@ permissions:
jobs: jobs:
api-smoke: api-smoke:
name: Unsloth API & Auth Tests name: Studio API & Auth Tests
runs-on: windows-latest runs-on: windows-latest
timeout-minutes: 30 timeout-minutes: 30
defaults: defaults:
@ -105,7 +105,7 @@ jobs:
# studio-windows-update-smoke.yml for the full rationale -- # studio-windows-update-smoke.yml for the full rationale --
# creating an empty studio/frontend/dist trips setup.ps1's # creating an empty studio/frontend/dist trips setup.ps1's
# mtime-based staleness check into "frontend up to date, skip # mtime-based staleness check into "frontend up to date, skip
# rebuild" and Unsloth boots with an empty dist directory. # rebuild" and Studio boots with an empty dist directory.
# Add-MpPreference accepts paths that do not yet exist. # Add-MpPreference accepts paths that do not yet exist.
foreach ($p in @( foreach ($p in @(
"$env:USERPROFILE\.unsloth", "$env:USERPROFILE\.unsloth",
@ -121,7 +121,7 @@ jobs:
} }
} }
- name: Install Unsloth (--local, --no-torch) - name: Install Studio (--local, --no-torch)
shell: pwsh shell: pwsh
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -161,7 +161,7 @@ jobs:
echo "install.ps1 installed the Windows prebuilt llama.cpp:" echo "install.ps1 installed the Windows prebuilt llama.cpp:"
cat "$INFO" cat "$INFO"
- name: Add Unsloth shim to GITHUB_PATH - name: Add Studio shim to GITHUB_PATH
# install.ps1's User-PATH update doesn't propagate to a # install.ps1's User-PATH update doesn't propagate to a
# running Git Bash session; export the shim dir so the # running Git Bash session; export the shim dir so the
# next `unsloth ...` invocation finds it. # next `unsloth ...` invocation finds it.
@ -177,10 +177,9 @@ jobs:
- name: Install pyjwt for the JWT-expiry forge test - name: Install pyjwt for the JWT-expiry forge test
run: python -m pip install 'pyjwt>=2.6' run: python -m pip install 'pyjwt>=2.6'
- name: Reset auth + boot Unsloth (API-only) - name: Reset auth + boot Studio (API-only)
run: | run: |
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
mkdir -p logs mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 & > logs/studio.log 2>&1 &
@ -208,7 +207,7 @@ jobs:
echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV" echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV"
echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV" echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"
- name: Run Unsloth API & Auth tests - name: Run Studio API & Auth tests
# Do NOT pin STUDIO_AUTH_DIR here. The Mac/Linux mirrors # Do NOT pin STUDIO_AUTH_DIR here. The Mac/Linux mirrors
# hardcode runner-specific paths (/Users/runner/..., # hardcode runner-specific paths (/Users/runner/...,
# /home/runner/...), but on Windows the path is # /home/runner/...), but on Windows the path is
@ -220,7 +219,7 @@ jobs:
BASE_URL: http://127.0.0.1:18895 BASE_URL: http://127.0.0.1:18895
run: python tests/studio/studio_api_smoke.py run: python tests/studio/studio_api_smoke.py
- name: Stop Unsloth - name: Stop Studio
if: always() if: always()
run: | run: |
kill "${STUDIO_PID}" 2>/dev/null || true kill "${STUDIO_PID}" 2>/dev/null || true

View file

@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Three end-to-end smoke jobs that boot a freshly-installed Unsloth and # Three end-to-end smoke jobs that boot a freshly-installed Studio and
# exercise the surfaces real users hit through the OpenAI / Anthropic # exercise the surfaces real users hit through the OpenAI / Anthropic
# SDKs and curl, on the FREE windows-latest runner. Each job picks the # SDKs and curl, on the FREE windows-latest runner. Each job picks the
# smallest model that exercises the behaviour under test, primes # smallest model that exercises the behaviour under test, primes
@ -16,7 +16,7 @@
# Qwen3-VL-2B-Instruct UD-IQ2_XXS + mmproj-F16 (~1.4 GiB total). # Qwen3-VL-2B-Instruct UD-IQ2_XXS + mmproj-F16 (~1.4 GiB total).
# Within the 14 GB windows-latest SSD budget. # Within the 14 GB windows-latest SSD budget.
name: Windows Unsloth GGUF CI name: Windows Studio GGUF CI
on: on:
pull_request: pull_request:
@ -57,7 +57,7 @@ jobs:
STUDIO_PORT: '18888' STUDIO_PORT: '18888'
HF_HOME: ${{ github.workspace }}/hf-cache HF_HOME: ${{ github.workspace }}/hf-cache
# Force UTF-8 for stdio (Windows defaults to cp1252; hf # Force UTF-8 for stdio (Windows defaults to cp1252; hf
# download / Unsloth CLI print "✓" checkmarks and crash # download / Studio CLI print "✓" checkmarks and crash
# otherwise). # otherwise).
PYTHONIOENCODING: utf-8 PYTHONIOENCODING: utf-8
PYTHONUTF8: '1' PYTHONUTF8: '1'
@ -160,7 +160,7 @@ jobs:
# studio-windows-update-smoke.yml for the full rationale -- # studio-windows-update-smoke.yml for the full rationale --
# creating an empty studio/frontend/dist trips setup.ps1's # creating an empty studio/frontend/dist trips setup.ps1's
# mtime-based staleness check into "frontend up to date, skip # mtime-based staleness check into "frontend up to date, skip
# rebuild" and Unsloth boots with an empty dist directory. # rebuild" and Studio boots with an empty dist directory.
# Add-MpPreference accepts paths that do not yet exist. # Add-MpPreference accepts paths that do not yet exist.
foreach ($p in @( foreach ($p in @(
"$env:USERPROFILE\.unsloth", "$env:USERPROFILE\.unsloth",
@ -176,7 +176,7 @@ jobs:
} }
} }
- name: Install Unsloth (--local, --no-torch) - name: Install Studio (--local, --no-torch)
shell: pwsh shell: pwsh
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -214,7 +214,7 @@ jobs:
echo "install.ps1 installed the Windows prebuilt llama.cpp:" echo "install.ps1 installed the Windows prebuilt llama.cpp:"
cat "$INFO" cat "$INFO"
- name: Add Unsloth shim to GITHUB_PATH - name: Add Studio shim to GITHUB_PATH
run: | run: |
SHIM_DIR=~/.unsloth/studio/bin SHIM_DIR=~/.unsloth/studio/bin
if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then
@ -227,10 +227,9 @@ jobs:
- name: Install OpenAI + Anthropic Python SDKs - name: Install OpenAI + Anthropic Python SDKs
run: python -m pip install 'openai>=1.50' 'anthropic>=0.40' run: python -m pip install 'openai>=1.50' 'anthropic>=0.40'
- name: Reset auth + boot Unsloth (API-only) - name: Reset auth + boot Studio (API-only)
run: | run: |
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
mkdir -p logs mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 & > logs/studio.log 2>&1 &
@ -245,7 +244,7 @@ jobs:
fi fi
sleep 1 sleep 1
done done
echo "Unsloth did not become healthy in 180s" echo "Studio did not become healthy in 180s"
tail -200 logs/studio.log tail -200 logs/studio.log
exit 1 exit 1
@ -282,7 +281,7 @@ jobs:
# Retry the load step a few times so a transient TCP RST during # Retry the load step a few times so a transient TCP RST during
# llama-server warm-up (Windows runner image churn, # llama-server warm-up (Windows runner image churn,
# windows-latest -> windows-2025-vs2026 rollout) doesn't fail # windows-latest -> windows-2025-vs2026 rollout) doesn't fail
# the whole job. The Unsloth backend's _wait_for_health now # the whole job. The Studio backend's _wait_for_health now
# catches httpx.ReadError too; this retry layer covers the # catches httpx.ReadError too; this retry layer covers the
# cases the backend can't recover from on its own. # cases the backend can't recover from on its own.
LOAD_OK=0 LOAD_OK=0
@ -383,15 +382,15 @@ jobs:
print(f"[{label}] OK -- 4 turns, run1 == run2, history grounded") print(f"[{label}] OK -- 4 turns, run1 == run2, history grounded")
PY PY
- name: Stop Unsloth - name: Stop Studio
if: always() if: always()
# Run as cmd so we are not running through the Git Bash shell; # Run as cmd so we are not running through the Git Bash shell;
# Git Bash on windows-latest has been observed to exit 143 # Git Bash on windows-latest has been observed to exit 143
# (SIGTERM) from any inline kill/sleep block, masking a green # (SIGTERM) from any inline kill/sleep block, masking a green
# test run. The runner reclaims the Unsloth child process at # test run. The runner reclaims the Studio child process at
# job end either way, so just emit a marker and exit 0. # job end either way, so just emit a marker and exit 0.
shell: cmd shell: cmd
run: echo Stop Unsloth (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end) run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
- name: Collect llama-server logs - name: Collect llama-server logs
if: always() if: always()
@ -399,10 +398,10 @@ jobs:
# copy must not fail an otherwise-green job. # copy must not fail an otherwise-green job.
continue-on-error: true continue-on-error: true
shell: bash shell: bash
# Copy llama-server's own stdout/stderr (teed by Unsloth under # Copy llama-server's own stdout/stderr (teed by Studio under
# ~/.unsloth/studio/logs/llama-server/) into the workspace so # ~/.unsloth/studio/logs/llama-server/) into the workspace so
# upload-artifact can pick it up. Crucial for diagnosing a # upload-artifact can pick it up. Crucial for diagnosing a
# subprocess crash where Unsloth's traceback only shows the # subprocess crash where Studio's traceback only shows the
# symptom (httpx ReadError) but not the cause. # symptom (httpx ReadError) but not the cause.
run: | run: |
mkdir -p logs/llama-server mkdir -p logs/llama-server
@ -440,14 +439,14 @@ jobs:
# (211 s on first run; subsequent runs hit the cache, but the # (211 s on first run; subsequent runs hit the cache, but the
# one-time cost recurs every time the cache key bumps). Use # one-time cost recurs every time the cache key bumps). Use
# main's `--local-dir gguf-cache` pattern: cache the flat .gguf # main's `--local-dir gguf-cache` pattern: cache the flat .gguf
# only, pass an absolute path to Unsloth's /api/inference/load. # only, pass an absolute path to Studio's /api/inference/load.
# The OpenAI/Anth and JSON+images jobs still cover the # The OpenAI/Anth and JSON+images jobs still cover the
# gguf_variant resolution path. # gguf_variant resolution path.
GGUF_REPO: unsloth/Qwen3.5-2B-GGUF GGUF_REPO: unsloth/Qwen3.5-2B-GGUF
GGUF_FILE: Qwen3.5-2B-UD-Q4_K_XL.gguf GGUF_FILE: Qwen3.5-2B-UD-Q4_K_XL.gguf
STUDIO_PORT: '18898' STUDIO_PORT: '18898'
# Force UTF-8 for stdio (Windows defaults to cp1252; hf # Force UTF-8 for stdio (Windows defaults to cp1252; hf
# download / Unsloth CLI print "✓" checkmarks and crash # download / Studio CLI print "✓" checkmarks and crash
# otherwise). # otherwise).
PYTHONIOENCODING: utf-8 PYTHONIOENCODING: utf-8
PYTHONUTF8: '1' PYTHONUTF8: '1'
@ -508,7 +507,7 @@ jobs:
# studio-windows-update-smoke.yml for the full rationale -- # studio-windows-update-smoke.yml for the full rationale --
# creating an empty studio/frontend/dist trips setup.ps1's # creating an empty studio/frontend/dist trips setup.ps1's
# mtime-based staleness check into "frontend up to date, skip # mtime-based staleness check into "frontend up to date, skip
# rebuild" and Unsloth boots with an empty dist directory. # rebuild" and Studio boots with an empty dist directory.
# Add-MpPreference accepts paths that do not yet exist. # Add-MpPreference accepts paths that do not yet exist.
foreach ($p in @( foreach ($p in @(
"$env:USERPROFILE\.unsloth", "$env:USERPROFILE\.unsloth",
@ -524,7 +523,7 @@ jobs:
} }
} }
- name: Install Unsloth (--local, --no-torch) - name: Install Studio (--local, --no-torch)
shell: pwsh shell: pwsh
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -562,7 +561,7 @@ jobs:
echo "install.ps1 installed the Windows prebuilt llama.cpp:" echo "install.ps1 installed the Windows prebuilt llama.cpp:"
cat "$INFO" cat "$INFO"
- name: Add Unsloth shim to GITHUB_PATH - name: Add Studio shim to GITHUB_PATH
run: | run: |
SHIM_DIR=~/.unsloth/studio/bin SHIM_DIR=~/.unsloth/studio/bin
if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then
@ -572,9 +571,9 @@ jobs:
fi fi
cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH" cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH"
- name: Reset auth + boot Unsloth (API-only, default tool policy) - name: Reset auth + boot Studio (API-only, default tool policy)
run: | run: |
rm -rf ~/.unsloth/studio/auth unsloth studio reset-password
mkdir -p logs mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 & > logs/studio.log 2>&1 &
@ -608,7 +607,7 @@ jobs:
# raw string, but we cannot embed `\a` etc. in JSON without # raw string, but we cannot embed `\a` etc. in JSON without
# JSON-string-escaping every backslash. Replace `\` with `/` # JSON-string-escaping every backslash. Replace `\` with `/`
# via bash parameter expansion -- pathlib.Path on Windows # via bash parameter expansion -- pathlib.Path on Windows
# accepts forward slashes natively, so Unsloth's loader sees # accepts forward slashes natively, so Studio's loader sees
# a normal path. # a normal path.
GGUF_PATH="${GITHUB_WORKSPACE//\\//}/gguf-cache/${GGUF_FILE}" GGUF_PATH="${GITHUB_WORKSPACE//\\//}/gguf-cache/${GGUF_FILE}"
ls -lh "$GGUF_PATH" ls -lh "$GGUF_PATH"
@ -635,8 +634,6 @@ jobs:
python - <<'PY' python - <<'PY'
import json import json
import os import os
import time
import urllib.error
import urllib.request import urllib.request
BASE = os.environ["BASE_URL"] BASE = os.environ["BASE_URL"]
@ -659,41 +656,10 @@ jobs:
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
) )
# Shared CI runners stall sporadically, so retry transport-level with urllib.request.urlopen(req, timeout = timeout) as resp:
# failures only; HTTP status errors surface immediately. Bounded return resp.status, json.loads(resp.read().decode())
# to fit the job's timeout-minutes: short probes get 3 full
# attempts, long probes one retry capped at 300s (a healthy
# server answers a retry quickly; a stalled one never does).
attempts = 3 if timeout <= 300 else 2
for attempt in range(attempts):
try:
t = timeout if attempt == 0 else min(timeout, 300)
with urllib.request.urlopen(req, timeout = t) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
if attempt == attempts - 1:
raise
print(f"[retry] {path}: {exc!r}", flush = True)
time.sleep(15)
def post_sse(path, body, *, timeout = 600, retries = 1, soft = False): def post_sse(path, body, *, timeout = 600):
# The server-side agentic loop always answers over SSE. A
# shared CI runner can stall the stream transport (the
# connection opening, or a mid-stream read) even when Unsloth
# is healthy, so harden the read three ways:
# * retry a transport stall once with a fresh request,
# capped at 300s (a healthy server answers a retry
# quickly, a wedged one never does);
# * return any text already streamed before a stall, so a
# stall on the trailing tokens -- after the answer
# arrived -- still counts;
# * when every attempt yields nothing, a hard call
# re-raises while a soft call (the best-effort
# server-side tool probes) returns None so the caller
# can WARN instead of sinking the whole job.
# HTTP status errors always surface immediately.
body = {**body, "stream": True} body = {**body, "stream": True}
data = json.dumps(body).encode() data = json.dumps(body).encode()
req = urllib.request.Request( req = urllib.request.Request(
@ -705,43 +671,24 @@ jobs:
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
) )
for attempt in range(retries + 1): parts = []
parts = [] with urllib.request.urlopen(req, timeout = timeout) as resp:
t = timeout if attempt == 0 else min(timeout, 300) for raw in resp:
try: line = raw.decode().strip()
with urllib.request.urlopen(req, timeout = t) as resp: if not line.startswith("data: "):
for raw in resp: continue
line = raw.decode().strip() payload = line[6:]
if not line.startswith("data: "): if payload == "[DONE]":
continue break
payload = line[6:] try:
if payload == "[DONE]": chunk = json.loads(payload)
break except json.JSONDecodeError:
try: continue
chunk = json.loads(payload) for choice in chunk.get("choices", []):
except json.JSONDecodeError: delta = choice.get("delta", {}) or {}
continue if delta.get("content"):
for choice in chunk.get("choices", []): parts.append(delta["content"])
delta = choice.get("delta", {}) or {} return "".join(parts)
if delta.get("content"):
parts.append(delta["content"])
return "".join(parts)
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
# Text already streamed is a valid signal -- keep it
# rather than re-running a heavy generation.
if parts:
joined = "".join(parts)
print(f"[retry-sse] {path}: {exc!r}; keeping {len(joined)} partial chars", flush = True)
return joined
if attempt == retries:
if soft:
print(f"[tools] WARN {path}: SSE transport stalled with no data ({exc!r}) -- non-blocking", flush = True)
return None
raise
print(f"[retry-sse] {path}: {exc!r}", flush = True)
time.sleep(15)
# ── 1. Standard OpenAI function calling ────────────────────── # ── 1. Standard OpenAI function calling ──────────────────────
weather_tool = { weather_tool = {
@ -784,24 +731,16 @@ jobs:
) )
# ── 2. Server-side python tool ─────────────────────────────── # ── 2. Server-side python tool ───────────────────────────────
# Bound each soft probe to a single 180s attempt (timeout=180,
# retries=0): this job runs two of them back-to-back under a
# 30-minute cap, so the default 600+15+300s per stall could hit
# the workflow timeout before the thinking checks run. A soft
# probe only WARNs anyway, so a retry buys nothing.
content = post_sse("/v1/chat/completions", { content = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": "What is 123 * 456? Use the python tool to compute it and tell me the number."}], "messages": [{"role": "user", "content": "What is 123 * 456? Use the python tool to compute it and tell me the number."}],
"enable_tools": True, "enable_tools": True,
"permission_mode": "full",
"enabled_tools": ["python"], "enabled_tools": ["python"],
"session_id": "ci-tool-calling-py", "session_id": "ci-tool-calling-py",
"temperature": TEMP, "temperature": TEMP,
"seed": SEED, "seed": SEED,
"max_tokens": 600, "max_tokens": 600,
}, timeout = 180, retries = 0, soft = True) })
if content is None: if "56088" in content or "56,088" in content:
print("[tools] WARN python tool: SSE transport stalled after retries -- non-blocking")
elif "56088" in content or "56,088" in content:
print(f"[tools] PASS python tool ({len(content)} chars, found 56088)") print(f"[tools] PASS python tool ({len(content)} chars, found 56088)")
else: else:
assert content, "python tool: SSE stream empty" assert content, "python tool: SSE stream empty"
@ -818,16 +757,13 @@ jobs:
content = post_sse("/v1/chat/completions", { content = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": "Use the terminal tool to run `echo hello-bash-tool` and tell me the exact output."}], "messages": [{"role": "user", "content": "Use the terminal tool to run `echo hello-bash-tool` and tell me the exact output."}],
"enable_tools": True, "enable_tools": True,
"permission_mode": "full",
"enabled_tools": ["terminal"], "enabled_tools": ["terminal"],
"session_id": "ci-tool-calling-bash", "session_id": "ci-tool-calling-bash",
"temperature": TEMP, "temperature": TEMP,
"seed": SEED, "seed": SEED,
"max_tokens": 600, "max_tokens": 600,
}, timeout = 180, retries = 0, soft = True) })
if content is None: if "hello-bash-tool" in content:
print("[tools] WARN terminal tool: SSE transport stalled after retries -- non-blocking")
elif "hello-bash-tool" in content:
print(f"[tools] PASS terminal tool ({len(content)} chars)") print(f"[tools] PASS terminal tool ({len(content)} chars)")
else: else:
assert content, "terminal tool: SSE stream empty" assert content, "terminal tool: SSE stream empty"
@ -843,13 +779,12 @@ jobs:
content = post_sse("/v1/chat/completions", { content = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}], "messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}],
"enable_tools": True, "enable_tools": True,
"permission_mode": "full",
"enabled_tools": ["web_search"], "enabled_tools": ["web_search"],
"session_id": "ci-tool-calling-web", "session_id": "ci-tool-calling-web",
"temperature": TEMP, "temperature": TEMP,
"seed": SEED, "seed": SEED,
"max_tokens": 400, "max_tokens": 400,
}, timeout = 180, retries = 0) })
print(f"[tools] PASS web_search stream ({len(content)} chars)") print(f"[tools] PASS web_search stream ({len(content)} chars)")
except Exception as exc: except Exception as exc:
print(f"[tools] WARN web_search probe failed (non-blocking): {exc}") print(f"[tools] WARN web_search probe failed (non-blocking): {exc}")
@ -883,15 +818,15 @@ jobs:
print(f"[tools] PASS thinking on/off (on={len(on_text)} chars, off={len(off_text)} chars)") print(f"[tools] PASS thinking on/off (on={len(on_text)} chars, off={len(off_text)} chars)")
PY PY
- name: Stop Unsloth - name: Stop Studio
if: always() if: always()
# Run as cmd so we are not running through the Git Bash shell; # Run as cmd so we are not running through the Git Bash shell;
# Git Bash on windows-latest has been observed to exit 143 # Git Bash on windows-latest has been observed to exit 143
# (SIGTERM) from any inline kill/sleep block, masking a green # (SIGTERM) from any inline kill/sleep block, masking a green
# test run. The runner reclaims the Unsloth child process at # test run. The runner reclaims the Studio child process at
# job end either way, so just emit a marker and exit 0. # job end either way, so just emit a marker and exit 0.
shell: cmd shell: cmd
run: echo Stop Unsloth (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end) run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
- name: Collect llama-server logs - name: Collect llama-server logs
if: always() if: always()
@ -899,10 +834,10 @@ jobs:
# copy must not fail an otherwise-green job. # copy must not fail an otherwise-green job.
continue-on-error: true continue-on-error: true
shell: bash shell: bash
# Copy llama-server's own stdout/stderr (teed by Unsloth under # Copy llama-server's own stdout/stderr (teed by Studio under
# ~/.unsloth/studio/logs/llama-server/) into the workspace so # ~/.unsloth/studio/logs/llama-server/) into the workspace so
# upload-artifact can pick it up. Crucial for diagnosing a # upload-artifact can pick it up. Crucial for diagnosing a
# subprocess crash where Unsloth's traceback only shows the # subprocess crash where Studio's traceback only shows the
# symptom (httpx ReadError) but not the cause. # symptom (httpx ReadError) but not the cause.
run: | run: |
mkdir -p logs/llama-server mkdir -p logs/llama-server
@ -940,7 +875,7 @@ jobs:
STUDIO_PORT: '18899' STUDIO_PORT: '18899'
HF_HOME: ${{ github.workspace }}/hf-cache HF_HOME: ${{ github.workspace }}/hf-cache
# Force UTF-8 for stdio (Windows defaults to cp1252; hf # Force UTF-8 for stdio (Windows defaults to cp1252; hf
# download / Unsloth CLI print "✓" checkmarks and crash # download / Studio CLI print "✓" checkmarks and crash
# otherwise). # otherwise).
PYTHONIOENCODING: utf-8 PYTHONIOENCODING: utf-8
PYTHONUTF8: '1' PYTHONUTF8: '1'
@ -1006,7 +941,7 @@ jobs:
# studio-windows-update-smoke.yml for the full rationale -- # studio-windows-update-smoke.yml for the full rationale --
# creating an empty studio/frontend/dist trips setup.ps1's # creating an empty studio/frontend/dist trips setup.ps1's
# mtime-based staleness check into "frontend up to date, skip # mtime-based staleness check into "frontend up to date, skip
# rebuild" and Unsloth boots with an empty dist directory. # rebuild" and Studio boots with an empty dist directory.
# Add-MpPreference accepts paths that do not yet exist. # Add-MpPreference accepts paths that do not yet exist.
foreach ($p in @( foreach ($p in @(
"$env:USERPROFILE\.unsloth", "$env:USERPROFILE\.unsloth",
@ -1022,7 +957,7 @@ jobs:
} }
} }
- name: Install Unsloth (--local, --no-torch) - name: Install Studio (--local, --no-torch)
shell: pwsh shell: pwsh
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -1060,7 +995,7 @@ jobs:
echo "install.ps1 installed the Windows prebuilt llama.cpp:" echo "install.ps1 installed the Windows prebuilt llama.cpp:"
cat "$INFO" cat "$INFO"
- name: Add Unsloth shim to GITHUB_PATH - name: Add Studio shim to GITHUB_PATH
run: | run: |
SHIM_DIR=~/.unsloth/studio/bin SHIM_DIR=~/.unsloth/studio/bin
if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then
@ -1073,9 +1008,9 @@ jobs:
- name: Install OpenAI + Anthropic Python SDKs - name: Install OpenAI + Anthropic Python SDKs
run: python -m pip install 'openai>=1.50' 'anthropic>=0.40' run: python -m pip install 'openai>=1.50' 'anthropic>=0.40'
- name: Reset auth + boot Unsloth (API-only) - name: Reset auth + boot Studio (API-only)
run: | run: |
rm -rf ~/.unsloth/studio/auth unsloth studio reset-password
mkdir -p logs mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 & > logs/studio.log 2>&1 &
@ -1128,8 +1063,6 @@ jobs:
import base64 import base64
import json import json
import os import os
import time
import urllib.error
import urllib.request import urllib.request
from openai import OpenAI from openai import OpenAI
from anthropic import Anthropic from anthropic import Anthropic
@ -1149,24 +1082,8 @@ jobs:
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
) )
# Shared CI runners stall sporadically, so retry transport-level with urllib.request.urlopen(req, timeout = timeout) as resp:
# failures only; HTTP status errors surface immediately. Bounded return resp.status, json.loads(resp.read().decode())
# to fit the job's timeout-minutes: short probes get 3 full
# attempts, long probes one retry capped at 300s (a healthy
# server answers a retry quickly; a stalled one never does).
attempts = 3 if timeout <= 300 else 2
for attempt in range(attempts):
try:
t = timeout if attempt == 0 else min(timeout, 300)
with urllib.request.urlopen(req, timeout = t) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
if attempt == attempts - 1:
raise
print(f"[retry] {path}: {exc!r}", flush = True)
time.sleep(15)
# ── 1. response_format = json_object (JSON mode) ───────────── # ── 1. response_format = json_object (JSON mode) ─────────────
status, data = post("/v1/chat/completions", { status, data = post("/v1/chat/completions", {
@ -1263,7 +1180,7 @@ jobs:
except Exception as exc: except Exception as exc:
print( print(
f"[image/openai] WARN image_url SDK call raised: {type(exc).__name__}: " f"[image/openai] WARN image_url SDK call raised: {type(exc).__name__}: "
f"{exc}. Unsloth successfully forwarded the request; failure here is " f"{exc}. Studio successfully forwarded the request; failure here is "
f"upstream llama.cpp vision behaviour." f"upstream llama.cpp vision behaviour."
) )
@ -1304,19 +1221,19 @@ jobs:
print( print(
f"[image/anthropic] WARN anthropic image SDK call raised: " f"[image/anthropic] WARN anthropic image SDK call raised: "
f"{type(exc).__name__}: {exc}. Likely upstream llama.cpp vision " f"{type(exc).__name__}: {exc}. Likely upstream llama.cpp vision "
f"behaviour, NOT an Unsloth regression." f"behaviour, NOT a Studio regression."
) )
PY PY
- name: Stop Unsloth - name: Stop Studio
if: always() if: always()
# Run as cmd so we are not running through the Git Bash shell; # Run as cmd so we are not running through the Git Bash shell;
# Git Bash on windows-latest has been observed to exit 143 # Git Bash on windows-latest has been observed to exit 143
# (SIGTERM) from any inline kill/sleep block, masking a green # (SIGTERM) from any inline kill/sleep block, masking a green
# test run. The runner reclaims the Unsloth child process at # test run. The runner reclaims the Studio child process at
# job end either way, so just emit a marker and exit 0. # job end either way, so just emit a marker and exit 0.
shell: cmd shell: cmd
run: echo Stop Unsloth (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end) run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
- name: Collect llama-server logs - name: Collect llama-server logs
if: always() if: always()
@ -1324,10 +1241,10 @@ jobs:
# copy must not fail an otherwise-green job. # copy must not fail an otherwise-green job.
continue-on-error: true continue-on-error: true
shell: bash shell: bash
# Copy llama-server's own stdout/stderr (teed by Unsloth under # Copy llama-server's own stdout/stderr (teed by Studio under
# ~/.unsloth/studio/logs/llama-server/) into the workspace so # ~/.unsloth/studio/logs/llama-server/) into the workspace so
# upload-artifact can pick it up. Crucial for diagnosing a # upload-artifact can pick it up. Crucial for diagnosing a
# subprocess crash where Unsloth's traceback only shows the # subprocess crash where Studio's traceback only shows the
# symptom (httpx ReadError) but not the cause. # symptom (httpx ReadError) but not the cause.
run: | run: |
mkdir -p logs/llama-server mkdir -p logs/llama-server
@ -1349,7 +1266,7 @@ jobs:
# ── folded from studio-windows-no-vs-smoke.yml: install + run with no Visual Studio ── # ── folded from studio-windows-no-vs-smoke.yml: install + run with no Visual Studio ──
no-vs-cpu: no-vs-cpu:
name: Unsloth install + inference without Visual Studio name: Studio install + inference without Visual Studio
runs-on: windows-latest runs-on: windows-latest
timeout-minutes: 35 timeout-minutes: 35
defaults: defaults:
@ -1417,75 +1334,42 @@ jobs:
try { Add-MpPreference -ExclusionPath $p -ErrorAction Stop } catch { } try { Add-MpPreference -ExclusionPath $p -ErrorAction Stop } catch { }
} }
- name: Prepare no-build-tools simulation - name: Hide Visual Studio + CMake (simulate a host with no build tools)
shell: pwsh shell: pwsh
run: | run: |
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$root = Join-Path $env:GITHUB_WORKSPACE 'no-build-tools' # A Program Files dir can hold a transient handle (Defender / MSBuild node)
$pf = Join-Path $root 'ProgramFiles' # so Rename-Item intermittently fails with "Access is denied"; retry to ride it out.
$pfx86 = Join-Path $root 'ProgramFilesx86' function Rename-WithRetry($Path, $NewName) {
New-Item -ItemType Directory -Force -Path $pf, $pfx86 | Out-Null for ($i = 1; $i -le 6; $i++) {
try { Rename-Item -LiteralPath $Path -NewName $NewName -ErrorAction Stop; return }
$blocked = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) catch { if ($i -eq 6) { throw }; Start-Sleep -Seconds 3 }
foreach ($tool in @('cmake', 'cl.exe')) {
foreach ($cmd in (Get-Command $tool -All -ErrorAction SilentlyContinue)) {
if ($cmd.Source) {
$dir = Split-Path -Parent $cmd.Source
if ($dir) {
[void] $blocked.Add(
[Environment]::ExpandEnvironmentVariables($dir).Trim().Trim('"').TrimEnd('\'))
}
}
} }
} }
# Normalized comparison so registry spellings (trailing slash, # Rename the Visual Studio install roots (incl. the Installer that holds
# unexpanded %VAR%) still match. # vswhere.exe) so Find-VsBuildTools' vswhere + filesystem scan both miss.
function Test-Blocked([string]$p) { foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) {
$n = [Environment]::ExpandEnvironmentVariables($p).Trim().Trim('"').TrimEnd('\') if (Test-Path -LiteralPath $d) {
return $blocked.Contains($n) Rename-WithRetry $d ((Split-Path $d -Leaf) + '.vsoff')
Write-Host "Hid VS: $d"
}
} }
# Surgically rename each cmake executable on PATH (not its parent dir --
$pathParts = $env:Path -split [IO.Path]::PathSeparator | # cmake can share a dir with other shims) so Get-Command cmake fails.
Where-Object { $_ -and -not (Test-Blocked $_) } $hidden = @()
$noBuildToolsPath = $pathParts -join [IO.Path]::PathSeparator foreach ($c in (Get-Command cmake -All -ErrorAction SilentlyContinue)) {
if ($c.Source -and (Test-Path -LiteralPath $c.Source)) {
# install.ps1's Refresh-SessionPath and setup.ps1's Refresh-Environment Rename-WithRetry $c.Source ((Split-Path $c.Source -Leaf) + '.off')
# rebuild the session Path from these scopes mid-install, so filter $hidden += $c.Source
# them too. Originals are saved for the cleanup step. Write-Host "Hid cmake: $($c.Source)"
foreach ($scope in @('Machine', 'User')) { }
$orig = [Environment]::GetEnvironmentVariable('Path', $scope)
if (-not $orig) { continue }
Set-Content -LiteralPath (Join-Path $root "orig-path-$scope.txt") -Value $orig -NoNewline
$kept = ($orig -split ';' | Where-Object { $_ -and -not (Test-Blocked $_) }) -join ';'
[Environment]::SetEnvironmentVariable('Path', $kept, $scope)
Write-Host "Filtered $scope Path scope."
}
"NO_BUILD_TOOLS_PROGRAMFILES=$pf" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"NO_BUILD_TOOLS_PROGRAMFILES_X86=$pfx86" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"NO_BUILD_TOOLS_PATH<<NO_BUILD_TOOLS_PATH_EOF" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
$noBuildToolsPath | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"NO_BUILD_TOOLS_PATH_EOF" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Write-Host "ProgramFiles simulation root: $pf"
Write-Host "ProgramFiles(x86) simulation root: $pfx86"
if ($blocked.Count -gt 0) {
Write-Host "Removed build-tool PATH dirs:"
$blocked | Sort-Object | ForEach-Object { Write-Host " $_" }
} else {
Write-Host "No cmake or cl.exe PATH dirs found to remove."
} }
("HIDDEN_CMAKE=" + ($hidden -join '|')) | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Assert Visual Studio + CMake are genuinely undetectable - name: Assert Visual Studio + CMake are genuinely undetectable
shell: pwsh shell: pwsh
run: | run: |
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
# Set in-script: the runner does not apply step-level env keys with
# parentheses (`ProgramFiles(x86)`), so vswhere still found VS.
if (-not $env:NO_BUILD_TOOLS_PROGRAMFILES) { Write-Error "NO_BUILD_TOOLS_* env missing (Prepare step did not run?)"; exit 1 }
$env:ProgramFiles = $env:NO_BUILD_TOOLS_PROGRAMFILES
${env:ProgramFiles(x86)} = $env:NO_BUILD_TOOLS_PROGRAMFILES_X86
$env:Path = $env:NO_BUILD_TOOLS_PATH
. (Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1/Get-FunctionSource.ps1') . (Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1/Get-FunctionSource.ps1')
$setup = Join-Path $env:GITHUB_WORKSPACE 'studio/setup.ps1' $setup = Join-Path $env:GITHUB_WORKSPACE 'studio/setup.ps1'
foreach ($fn in @('Resolve-VsGeneratorFromLabel', 'Find-VsBuildTools')) { foreach ($fn in @('Resolve-VsGeneratorFromLabel', 'Find-VsBuildTools')) {
@ -1503,17 +1387,13 @@ jobs:
python -m pip install torch --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple python -m pip install torch --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple
python -c "import torch; print('torch', torch.__version__, 'cuda?', torch.cuda.is_available())" python -c "import torch; print('torch', torch.__version__, 'cuda?', torch.cuda.is_available())"
- name: Install Unsloth (--local, --no-torch) with no build tools present - name: Install Studio (--local, --no-torch) with no build tools present
shell: pwsh shell: pwsh
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
# Set in-script (see the assert step); child processes inherit these.
$env:ProgramFiles = $env:NO_BUILD_TOOLS_PROGRAMFILES
${env:ProgramFiles(x86)} = $env:NO_BUILD_TOOLS_PROGRAMFILES_X86
$env:Path = $env:NO_BUILD_TOOLS_PATH
New-Item -ItemType Directory -Force -Path logs | Out-Null New-Item -ItemType Directory -Force -Path logs | Out-Null
$ProgressPreference = 'SilentlyContinue' $ProgressPreference = 'SilentlyContinue'
& ./install.ps1 --local --no-torch *>&1 | Tee-Object -FilePath logs/install.log & ./install.ps1 --local --no-torch *>&1 | Tee-Object -FilePath logs/install.log
@ -1539,15 +1419,15 @@ jobs:
echo "Prebuilt installed with no build tools:" echo "Prebuilt installed with no build tools:"
cat "$INFO" cat "$INFO"
- name: Add Unsloth shim to GITHUB_PATH - name: Add Studio shim to GITHUB_PATH
run: | run: |
SHIM_DIR=~/.unsloth/studio/bin SHIM_DIR=~/.unsloth/studio/bin
[ -f "$SHIM_DIR/unsloth.exe" ] || { echo "::error::unsloth.exe shim not found"; ls -la ~/.unsloth/studio/ || true; exit 1; } [ -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" cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH"
- name: Reset auth + boot Unsloth (API-only) - name: Reset auth + boot Studio (API-only)
run: | run: |
rm -rf ~/.unsloth/studio/auth unsloth studio reset-password
mkdir -p logs mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 & > logs/studio.log 2>&1 &
@ -1600,24 +1480,24 @@ jobs:
[ -n "$CONTENT" ] && [ "$CONTENT" != "null" ] || { echo "::error::empty completion"; exit 1; } [ -n "$CONTENT" ] && [ "$CONTENT" != "null" ] || { echo "::error::empty completion"; exit 1; }
echo "Inference OK without Visual Studio: $CONTENT" echo "Inference OK without Visual Studio: $CONTENT"
- name: Clean no-build-tools simulation - name: Restore Visual Studio + CMake
if: always() if: always()
shell: pwsh shell: pwsh
run: | run: |
$root = Join-Path $env:GITHUB_WORKSPACE 'no-build-tools' foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) {
foreach ($scope in @('Machine', 'User')) { $off = "$d.vsoff"
$saved = Join-Path $root "orig-path-$scope.txt" if (Test-Path -LiteralPath $off) { Rename-Item -LiteralPath $off -NewName (Split-Path $d -Leaf); Write-Host "Restored $d" }
if (Test-Path -LiteralPath $saved) { }
[Environment]::SetEnvironmentVariable('Path', (Get-Content -LiteralPath $saved -Raw), $scope) if ($env:HIDDEN_CMAKE) {
Write-Host "Restored $scope Path scope." foreach ($src in ($env:HIDDEN_CMAKE -split '\|')) {
if ($src -and (Test-Path -LiteralPath "$src.off")) { Rename-Item -LiteralPath "$src.off" -NewName (Split-Path $src -Leaf) }
} }
} }
Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue
- name: Stop Unsloth - name: Stop Studio
if: always() if: always()
shell: cmd shell: cmd
run: echo Stop Unsloth (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end) run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
- name: Collect llama-server logs - name: Collect llama-server logs
if: always() if: always()
@ -1660,34 +1540,21 @@ jobs:
with: with:
python-version: '3.12' python-version: '3.12'
- name: Prepare no-build-tools simulation - name: Hide Visual Studio
shell: pwsh shell: pwsh
run: | run: |
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$root = Join-Path $env:GITHUB_WORKSPACE 'no-build-tools' # Retry the rename: a Program Files dir can hold a transient handle that
$pf = Join-Path $root 'ProgramFiles' # makes Rename-Item intermittently fail with "Access is denied".
$pfx86 = Join-Path $root 'ProgramFilesx86' function Rename-WithRetry($Path, $NewName) {
New-Item -ItemType Directory -Force -Path $pf, $pfx86 | Out-Null for ($i = 1; $i -le 6; $i++) {
try { Rename-Item -LiteralPath $Path -NewName $NewName -ErrorAction Stop; return }
$blocked = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) catch { if ($i -eq 6) { throw }; Start-Sleep -Seconds 3 }
foreach ($tool in @('cmake', 'cl.exe')) {
foreach ($cmd in (Get-Command $tool -All -ErrorAction SilentlyContinue)) {
if ($cmd.Source) {
$dir = Split-Path -Parent $cmd.Source
if ($dir) { [void] $blocked.Add($dir) }
}
} }
} }
foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) {
$pathParts = $env:Path -split [IO.Path]::PathSeparator | if (Test-Path -LiteralPath $d) { Rename-WithRetry $d ((Split-Path $d -Leaf) + '.vsoff'); Write-Host "Hid VS: $d" }
Where-Object { $_ -and -not $blocked.Contains($_) } }
$noBuildToolsPath = $pathParts -join [IO.Path]::PathSeparator
"NO_BUILD_TOOLS_PROGRAMFILES=$pf" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"NO_BUILD_TOOLS_PROGRAMFILES_X86=$pfx86" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"NO_BUILD_TOOLS_PATH<<NO_BUILD_TOOLS_PATH_EOF" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
$noBuildToolsPath | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"NO_BUILD_TOOLS_PATH_EOF" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Windows CUDA and ROCm prebuilts exist in unslothai/llama.cpp (what GPU users download, no VS) - name: Windows CUDA and ROCm prebuilts exist in unslothai/llama.cpp (what GPU users download, no VS)
env: env:
@ -1710,34 +1577,25 @@ jobs:
echo "Windows CUDA and ROCm prebuilts are available -- GPU users get them without compiling." echo "Windows CUDA and ROCm prebuilts are available -- GPU users get them without compiling."
- name: The prebuilt resolver runs without Visual Studio - name: The prebuilt resolver runs without Visual Studio
shell: pwsh
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
$ErrorActionPreference = 'Stop'
# pwsh: bash cannot export `ProgramFiles(x86)`; set in-script so the
# python child inherits the overrides.
$env:ProgramFiles = $env:NO_BUILD_TOOLS_PROGRAMFILES
${env:ProgramFiles(x86)} = $env:NO_BUILD_TOOLS_PROGRAMFILES_X86
$env:Path = $env:NO_BUILD_TOOLS_PATH
# Resolver-only (no GPU on hosted runners, so the host resolves to the # Resolver-only (no GPU on hosted runners, so the host resolves to the
# CPU bundle). The point is that resolution needs no compiler/VS. # CPU bundle). The point is that resolution needs no compiler/VS.
python -m pip install --upgrade huggingface_hub python -m pip install --upgrade huggingface_hub
if ($LASTEXITCODE -ne 0) { Write-Host "::error::pip install huggingface_hub failed"; exit 1 } python studio/install_llama_prebuilt.py --resolve-prebuilt latest --output-format json > /tmp/resolve.json || {
python studio/install_llama_prebuilt.py --resolve-prebuilt latest --output-format json > resolve.json echo "::error::resolver exited non-zero"; cat /tmp/resolve.json || true; exit 1; }
if ($LASTEXITCODE -ne 0) { cat /tmp/resolve.json
Write-Host "::error::resolver exited non-zero" echo "Prebuilt resolver ran with no Visual Studio present."
if (Test-Path resolve.json) { Get-Content resolve.json }
exit 1
}
Get-Content resolve.json
Write-Host "Prebuilt resolver ran with no Visual Studio present."
- name: Clean no-build-tools simulation - name: Restore Visual Studio
if: always() if: always()
shell: pwsh shell: pwsh
run: | run: |
Remove-Item -LiteralPath (Join-Path $env:GITHUB_WORKSPACE 'no-build-tools') -Recurse -Force -ErrorAction SilentlyContinue foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) {
$off = "$d.vsoff"
if (Test-Path -LiteralPath $off) { Rename-Item -LiteralPath $off -NewName (Split-Path $d -Leaf); Write-Host "Restored $d" }
}
# ── folded from studio-setup-ps1-vs2026.yml: setup.ps1 unit tests + real-VS detection + vcredist ── # ── folded from studio-setup-ps1-vs2026.yml: setup.ps1 unit tests + real-VS detection + vcredist ──
pester: pester:
@ -1752,13 +1610,6 @@ jobs:
- name: Install Pester v5 - name: Install Pester v5
shell: pwsh shell: pwsh
run: | run: |
# PSGallery is intermittently absent from the repository list on GitHub's Windows
# runners, which makes `Set-PSRepository PSGallery` fail with "No repository with the
# name 'PSGallery' was found." Re-register the default gallery first so the policy
# change and module install below always have a repository to target.
if (-not (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
Register-PSRepository -Default -ErrorAction SilentlyContinue
}
Set-PSRepository PSGallery -InstallationPolicy Trusted Set-PSRepository PSGallery -InstallationPolicy Trusted
Install-Module Pester -MinimumVersion 5.5.0 -Force -SkipPublisherCheck -Scope CurrentUser Install-Module Pester -MinimumVersion 5.5.0 -Force -SkipPublisherCheck -Scope CurrentUser
Import-Module Pester -MinimumVersion 5.5.0 Import-Module Pester -MinimumVersion 5.5.0
@ -1889,11 +1740,8 @@ jobs:
# (step/substep -> Write-StudioStdoutMirror / Get-StudioAnsi). # (step/substep -> Write-StudioStdoutMirror / Get-StudioAnsi).
$script:StudioVtOk = $false $script:StudioVtOk = $false
$script:UnslothVerbose = $false $script:UnslothVerbose = $false
# Get-HostMachineArch is reached only on the absent path, where
# Test-VCRedistInstalled consults it before trusting the System32 DLL, so
# part A passes without it and only the clean-box part fails.
foreach ($fn in @('Get-StudioAnsi', 'Write-StudioStdoutMirror', 'step', 'substep', foreach ($fn in @('Get-StudioAnsi', 'Write-StudioStdoutMirror', 'step', 'substep',
'Invoke-SetupCommand', 'Refresh-Environment', 'Get-HostMachineArch', 'Invoke-SetupCommand', 'Refresh-Environment',
'Test-VCRedistInstalled', 'Ensure-VCRedist')) { 'Test-VCRedistInstalled', 'Ensure-VCRedist')) {
$src = Get-FunctionSource -Path $setup -Name $fn $src = Get-FunctionSource -Path $setup -Name $fn
if (-not $src) { throw "Function '$fn' not found in setup.ps1" } if (-not $src) { throw "Function '$fn' not found in setup.ps1" }

View file

@ -4,11 +4,11 @@
# Windows counterpart to studio-ui-smoke.yml / studio-mac-ui-smoke.yml. # Windows counterpart to studio-ui-smoke.yml / studio-mac-ui-smoke.yml.
# Same Playwright + Chromium end-to-end chat UI flow + extra UI flow, # Same Playwright + Chromium end-to-end chat UI flow + extra UI flow,
# but on the FREE windows-latest runner so we catch Windows-specific # but on the FREE windows-latest runner so we catch Windows-specific
# regressions in the install path (install.ps1), the Unsloth CLI's # regressions in the install path (install.ps1), the Studio CLI's
# Windows process-management branches, and the llama.cpp prebuilt's # Windows process-management branches, and the llama.cpp prebuilt's
# Windows HTTP layer. # Windows HTTP layer.
name: Windows Unsloth UI CI name: Windows Studio UI CI
on: on:
pull_request: pull_request:
@ -19,7 +19,6 @@ on:
- 'install.ps1' - 'install.ps1'
- 'pyproject.toml' - 'pyproject.toml'
- 'tests/studio/**' - 'tests/studio/**'
- '.github/scripts/run-studio-permission-browser.sh'
- '.github/workflows/studio-windows-ui-smoke.yml' - '.github/workflows/studio-windows-ui-smoke.yml'
push: push:
branches: [main, pip] branches: [main, pip]
@ -50,7 +49,7 @@ jobs:
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
STUDIO_PORT: '18896' STUDIO_PORT: '18896'
HF_HOME: ${{ github.workspace }}/hf-cache HF_HOME: ${{ github.workspace }}/hf-cache
# Force UTF-8 for stdio so Python tools (hf download, Unsloth # Force UTF-8 for stdio so Python tools (hf download, Studio
# CLI, etc.) can print Unicode characters like the success # CLI, etc.) can print Unicode characters like the success
# checkmark "✓". Windows defaults to cp1252 / charmap and # checkmark "✓". Windows defaults to cp1252 / charmap and
# any tool that prints "OK ✓" hits a UnicodeEncodeError. # any tool that prints "OK ✓" hits a UnicodeEncodeError.
@ -122,7 +121,7 @@ jobs:
# studio-windows-update-smoke.yml for the full rationale -- # studio-windows-update-smoke.yml for the full rationale --
# creating an empty studio/frontend/dist trips setup.ps1's # creating an empty studio/frontend/dist trips setup.ps1's
# mtime-based staleness check into "frontend up to date, skip # mtime-based staleness check into "frontend up to date, skip
# rebuild" and Unsloth boots with an empty dist directory. # rebuild" and Studio boots with an empty dist directory.
# Add-MpPreference accepts paths that do not yet exist. # Add-MpPreference accepts paths that do not yet exist.
foreach ($p in @( foreach ($p in @(
"$env:USERPROFILE\.unsloth", "$env:USERPROFILE\.unsloth",
@ -149,7 +148,7 @@ jobs:
Set-Content -LiteralPath (Join-Path $appDir 'launch-studio.vbs') -Value 'WScript.Echo "legacy"' -Encoding Unicode Set-Content -LiteralPath (Join-Path $appDir 'launch-studio.vbs') -Value 'WScript.Echo "legacy"' -Encoding Unicode
Write-Host "seeded legacy launch-studio.vbs at $appDir" Write-Host "seeded legacy launch-studio.vbs at $appDir"
- name: Install Unsloth (--local, --no-torch) - name: Install Studio (--local, --no-torch)
# install.ps1 is the supported Windows installer. install.sh # install.ps1 is the supported Windows installer. install.sh
# has no Windows branch (apt-get / brew calls). The PS1 # has no Windows branch (apt-get / brew calls). The PS1
# script's `Install-UnslothStudio @args` line at the bottom # script's `Install-UnslothStudio @args` line at the bottom
@ -206,7 +205,7 @@ jobs:
echo "install.ps1 installed the Windows prebuilt llama.cpp:" echo "install.ps1 installed the Windows prebuilt llama.cpp:"
cat "$INFO" cat "$INFO"
- name: Assert Unsloth launcher chain (no VBS, hidden PowerShell shortcut) - name: Assert Studio launcher chain (no VBS, hidden PowerShell shortcut)
# The shortcut launch path is otherwise untested here (the steps below # The shortcut launch path is otherwise untested here (the steps below
# boot `unsloth studio` directly). Guard against re-introducing the VBS # boot `unsloth studio` directly). Guard against re-introducing the VBS
# that tripped Kaspersky HEUR:Trojan.VBS.Agent.gen and against the .lnk # that tripped Kaspersky HEUR:Trojan.VBS.Agent.gen and against the .lnk
@ -235,7 +234,7 @@ jobs:
} }
Write-Host "launcher chain OK (no VBS; hidden powershell over launch-studio.ps1)" Write-Host "launcher chain OK (no VBS; hidden powershell over launch-studio.ps1)"
- name: Launch Unsloth via the shortcut and assert health - name: Launch Studio via the shortcut and assert health
# Run the exact command the .lnk stores (hidden PowerShell over # Run the exact command the .lnk stores (hidden PowerShell over
# launch-studio.ps1) and confirm it brings the backend up. This is the # launch-studio.ps1) and confirm it brings the backend up. This is the
# only step that proves the shortcut launch is not silently broken. # only step that proves the shortcut launch is not silently broken.
@ -266,10 +265,10 @@ jobs:
$owner = (Get-NetTCPConnection -LocalPort $foundPort -State Listen -ErrorAction Stop | Select-Object -First 1).OwningProcess $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 } if ($owner) { taskkill /PID $owner /T /F 2>$null | Out-Null }
} catch {} } catch {}
if (-not $foundPort) { throw "Unsloth did not become healthy when launched via the shortcut" } if (-not $foundPort) { throw "Studio did not become healthy when launched via the shortcut" }
Write-Host "Unsloth healthy on port $foundPort (launched via the shortcut)" Write-Host "Studio healthy on port $foundPort (launched via the shortcut)"
- name: Add Unsloth shim to GITHUB_PATH - name: Add Studio shim to GITHUB_PATH
# install.ps1 puts unsloth.exe at $StudioHome\bin\unsloth.exe # install.ps1 puts unsloth.exe at $StudioHome\bin\unsloth.exe
# and adds that dir to the User PATH via the Windows registry. # and adds that dir to the User PATH via the Windows registry.
# Registry-level PATH updates don't propagate to a running # Registry-level PATH updates don't propagate to a running
@ -285,7 +284,7 @@ jobs:
fi fi
# GITHUB_PATH wants Windows-style paths; convert via cygpath. # GITHUB_PATH wants Windows-style paths; convert via cygpath.
cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH" cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH"
echo "Added Unsloth shim dir to PATH: $(cygpath -w "$SHIM_DIR")" echo "Added Studio shim dir to PATH: $(cygpath -w "$SHIM_DIR")"
- name: Install Playwright + Chromium - name: Install Playwright + Chromium
# No --with-deps on Windows: that flag installs Linux apt # No --with-deps on Windows: that flag installs Linux apt
@ -295,10 +294,9 @@ jobs:
python -m pip install 'playwright>=1.45' python -m pip install 'playwright>=1.45'
python -m playwright install chromium python -m playwright install chromium
- name: Reset auth + boot Unsloth - name: Reset auth + boot Studio
run: | run: |
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. unsloth studio reset-password
rm -rf ~/.unsloth/studio/auth
mkdir -p logs mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 & > logs/studio.log 2>&1 &
@ -341,19 +339,15 @@ jobs:
mkdir -p logs/playwright mkdir -p logs/playwright
python tests/studio/playwright_chat_ui.py python tests/studio/playwright_chat_ui.py
- name: Stop Unsloth (chat-ui ends with Shutdown click; this is belt-and-suspenders) - name: Stop Studio (chat-ui ends with Shutdown click; this is belt-and-suspenders)
if: always() if: always()
run: | run: |
kill "${STUDIO_PID}" 2>/dev/null || true kill "${STUDIO_PID}" 2>/dev/null || true
sleep 2 sleep 2
- name: Edge permission controls - name: Reset auth + boot Studio for extra UI tests (port 18897)
run: | run: |
bash .github/scripts/run-studio-permission-browser.sh 18895 chromium msedge unsloth studio reset-password
- name: Reset auth + boot Unsloth for extra UI tests (port 18897)
run: |
rm -rf ~/.unsloth/studio/auth
mkdir -p logs mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \
> logs/studio_extra.log 2>&1 & > logs/studio_extra.log 2>&1 &
@ -378,7 +372,7 @@ jobs:
echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV" echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV"
echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV" echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV"
- name: Drive Compare/Recipes/Export/Unsloth/Settings with Playwright - name: Drive Compare/Recipes/Export/Studio/Settings with Playwright
env: env:
BASE_URL: http://127.0.0.1:18897 BASE_URL: http://127.0.0.1:18897
STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }} STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }}
@ -392,7 +386,7 @@ jobs:
mkdir -p logs/playwright_extra mkdir -p logs/playwright_extra
python tests/studio/playwright_extra_ui.py python tests/studio/playwright_extra_ui.py
- name: Stop second Unsloth - name: Stop second Studio
if: always() if: always()
run: | run: |
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
@ -408,7 +402,5 @@ jobs:
logs/studio_extra.log logs/studio_extra.log
logs/install.log logs/install.log
logs/playwright logs/playwright
logs/playwright-permissions-*
logs/playwright_extra logs/playwright_extra
logs/studio-permissions-*.log
retention-days: 7 retention-days: 7

View file

@ -5,19 +5,19 @@
# studio-mac-update-smoke.yml. Verifies that on the FREE # studio-mac-update-smoke.yml. Verifies that on the FREE
# windows-latest runner: # windows-latest runner:
# #
# 1. install.ps1 --local --no-torch installs Unsloth AND auto-fetches # 1. install.ps1 --local --no-torch installs Studio AND auto-fetches
# the prebuilt llama.cpp Windows binary (app-<tag>-windows-x64-cpu # the prebuilt llama.cpp Windows binary (llama-bNNNN-bin-win-cpu-
# from unslothai/llama.cpp). Hitting the source-build fallback is # x64 from ggml-org/llama.cpp). Hitting the source-build fallback
# treated as an Unsloth bug -- Unsloth must always pick the # is treated as an Unsloth bug -- Studio must always pick the
# prebuilt on Windows. # prebuilt on Windows.
# 2. unsloth studio update --local is idempotent. Two consecutive # 2. unsloth studio update --local is idempotent. Two consecutive
# runs both report "prebuilt up to date and validated", no # runs both report "prebuilt up to date and validated", no
# source-build fallback. The CLI's _find_setup_script picks # source-build fallback. The CLI's _find_setup_script picks
# setup.ps1 on Windows automatically. # setup.ps1 on Windows automatically.
# 3. The installed Unsloth still boots and /api/health returns # 3. The installed Studio still boots and /api/health returns
# healthy after the update path. # healthy after the update path.
name: Windows Unsloth Update CI name: Windows Studio Update CI
on: on:
pull_request: pull_request:
@ -45,7 +45,7 @@ permissions:
jobs: jobs:
update-idempotency: update-idempotency:
name: Unsloth Updating Tests name: Studio Updating Tests
runs-on: windows-latest runs-on: windows-latest
timeout-minutes: 30 timeout-minutes: 30
defaults: defaults:
@ -53,7 +53,7 @@ jobs:
shell: bash shell: bash
env: env:
# Force UTF-8 for stdio (Windows defaults to cp1252; hf # Force UTF-8 for stdio (Windows defaults to cp1252; hf
# download / Unsloth CLI print "✓" checkmarks and crash # download / Studio CLI print "✓" checkmarks and crash
# otherwise). # otherwise).
PYTHONIOENCODING: utf-8 PYTHONIOENCODING: utf-8
PYTHONUTF8: '1' PYTHONUTF8: '1'
@ -90,7 +90,7 @@ jobs:
# reuses the existing Node with no download. # reuses the existing Node with no download.
# #
# (2) Defender. windows-latest's real-time scan opens / hashes # (2) Defender. windows-latest's real-time scan opens / hashes
# every file Unsloth writes during install (Vite output = # every file Studio writes during install (Vite output =
# thousands of small chunks, uv pip = wheel-extraction = # thousands of small chunks, uv pip = wheel-extraction =
# thousands of small files). The latency dominates the # thousands of small files). The latency dominates the
# 200 s frontend build and the 90 s deps install. Adding # 200 s frontend build and the 90 s deps install. Adding
@ -109,7 +109,7 @@ jobs:
# setup.ps1 line 1281-1296's mtime-based "is the frontend # setup.ps1 line 1281-1296's mtime-based "is the frontend
# stale?" check into "up to date, skip rebuild", because the # stale?" check into "up to date, skip rebuild", because the
# newly-created dist's mtime is younger than every source # newly-created dist's mtime is younger than every source
# file. Unsloth then boots with an empty dist and 500s on # file. Studio then boots with an empty dist and 500s on
# GET / with FileNotFoundError: dist\index.html. See run # GET / with FileNotFoundError: dist\index.html. See run
# 25546676715 / job 74984469728. # 25546676715 / job 74984469728.
# Add-MpPreference accepts paths that do not yet exist; the # Add-MpPreference accepts paths that do not yet exist; the
@ -129,7 +129,7 @@ jobs:
} }
} }
- name: Install Unsloth (--local, --no-torch) - name: Install Studio (--local, --no-torch)
shell: pwsh shell: pwsh
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -168,7 +168,7 @@ jobs:
echo "install.ps1 installed the Windows prebuilt llama.cpp:" echo "install.ps1 installed the Windows prebuilt llama.cpp:"
cat "$INFO" cat "$INFO"
- name: Add Unsloth shim to GITHUB_PATH - name: Add Studio shim to GITHUB_PATH
run: | run: |
SHIM_DIR=~/.unsloth/studio/bin SHIM_DIR=~/.unsloth/studio/bin
if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then
@ -198,31 +198,6 @@ jobs:
fi fi
echo "update path took the prebuilt fast path" echo "update path took the prebuilt fast path"
- name: Update must keep the --no-torch install GGUF-only
run: |
# `unsloth studio update` exports no UNSLOTH_NO_TORCH, so setup.ps1 has
# to recover the mode from the install manifest. Without that it reads
# the missing torch as a stale venv and tries to delete the venv it is
# running out of, and the shared dependency pass pulls torch back in.
# The skip line only prints when the dependency pass actually runs, so
# don't demand it if the fast path short-circuited that pass.
if grep -q "running ordered dependency installation" logs/update.log \
&& ! grep -q "skipping direct PyTorch and Triton installation (no-torch mode)" logs/update.log; then
echo "::error::studio update left no-torch mode; it would reinstall PyTorch."
grep -iE "no-torch|stale venv|PyTorch" logs/update.log | tail -40
exit 1
fi
PY="$HOME/.unsloth/studio/unsloth_studio/Scripts/python.exe"
if [ ! -f "$PY" ]; then
echo "::error::studio venv interpreter missing at $PY"
exit 1
fi
if "$PY" -c "import torch" 2>/dev/null; then
echo "::error::torch was reinstalled into the --no-torch venv."
exit 1
fi
echo "update preserved no-torch mode"
- name: Second update must also be a no-op - name: Second update must also be a no-op
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -237,7 +212,7 @@ jobs:
grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update2.log grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update2.log
echo "second update was clean" echo "second update was clean"
- name: Boot Unsloth briefly to confirm the install is still usable - name: Boot Studio briefly to confirm the install is still usable
run: | run: |
mkdir -p logs mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18891 \ UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18891 \
@ -264,13 +239,13 @@ jobs:
sleep 1 sleep 1
done done
if [ -z "$HEALTHY" ]; then if [ -z "$HEALTHY" ]; then
echo "Unsloth failed to come up after \`update\`" echo "Studio failed to come up after \`update\`"
tail -200 logs/studio.log tail -200 logs/studio.log
kill "$PID" 2>/dev/null || true kill "$PID" 2>/dev/null || true
exit 1 exit 1
fi fi
kill "$PID" 2>/dev/null || true kill "$PID" 2>/dev/null || true
echo "post-update Unsloth /api/health OK" echo "post-update Studio /api/health OK"
- name: Uninstall and verify clean - name: Uninstall and verify clean
# Round-trip through scripts/uninstall.ps1 against the default # Round-trip through scripts/uninstall.ps1 against the default

View file

@ -285,92 +285,6 @@ jobs:
tests/vllm_compat/test_extended_module_imports.py \ tests/vllm_compat/test_extended_module_imports.py \
-v --tb=short -v --tb=short
# Fake-CUDA GRPO/SFT/DPO patch run against REAL TRL (latest + main). Unlike
# the static symbol/source greps above, this drives unsloth's actual
# source-transform patchers (models/rl.py + rl_replacements.py) on a CPU-only
# runner under the tests/conftest.py spoof harness -- no GPU, no training.
# Catches structural TRL drift the greps miss (e.g. TRL 1.7.0's 2->3-tuple
# per-token-logps return, restructured PEFT ref-adapter block) by asserting
# the generated Unsloth trainer still satisfies the transform contracts.
grpo-fake-run:
name: GRPO fake-run (latest + main TRL, CPU spoof)
runs-on: ubuntu-latest
timeout-minutes: 18
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
path: unsloth
- name: Clone unsloth-zoo @ main
run: |
for attempt in 1 2 3; do
rm -rf "$RUNNER_TEMP/unsloth-zoo"
if git clone --depth=1 https://github.com/unslothai/unsloth-zoo \
"$RUNNER_TEMP/unsloth-zoo"; then
break
fi
if [ "$attempt" -eq 3 ]; then
echo "::error::git clone unsloth-zoo failed after 3 attempts"
exit 1
fi
delay=$((5 * attempt))
echo "::warning::clone failed (attempt $attempt/3), retrying in ${delay}s..."
sleep "$delay"
done
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
cache: 'pip'
- name: Install CPU torch + ecosystem + TRL latest
run: |
python -m pip install --upgrade pip
pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \
'torch>=2.4,<2.11' 'torchvision<0.26' 'torchcodec<0.10'
# Ecosystem floors unsloth needs; TRL itself is installed last so it
# can pull the transformers/peft it requires.
pip install \
'transformers>=4.57' 'peft>=0.18.0' 'accelerate>=1.0' 'datasets>=3.4,<5' \
'bitsandbytes>=0.45.5' sentencepiece protobuf safetensors numpy 'pytest>=8' \
'huggingface_hub>=0.34' tqdm packaging psutil triton Pillow
pip install --upgrade trl
pip install --no-deps -e "$RUNNER_TEMP/unsloth-zoo"
pip install --no-deps -e ./unsloth
- name: Fake-run vs TRL latest
env:
UNSLOTH_IS_PRESENT: '1'
UNSLOTH_COMPILE_DISABLE: '1'
# Disable dynamo/inductor at the process level, before conftest.py's early
# `import unsloth`, so the GRPO hot path never compiles on the GPU-less runner
# (defense in depth; the CPU fake-train also flips this at runtime).
TORCHDYNAMO_DISABLE: '1'
TORCH_COMPILE_DISABLE: '1'
PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python
run: |
cd unsloth
python -c "import trl; print('Resolved TRL', trl.__version__)"
PYTHONPATH=. python -m pytest \
tests/version_compat/test_trl_grpo_fake_run.py \
tests/version_compat/test_trl_fake_train_cpu.py \
-v --tb=short
# `main` is scheduled/dispatch-only so PR jobs stay fast and a bleeding-edge
# TRL break does not red every PR. github.event_name is valid in a step if.
- name: Fake-run vs TRL main (scheduled / dispatch only)
if: ${{ github.event_name != 'pull_request' }}
env:
UNSLOTH_IS_PRESENT: '1'
UNSLOTH_COMPILE_DISABLE: '1'
TORCHDYNAMO_DISABLE: '1'
TORCH_COMPILE_DISABLE: '1'
PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python
run: |
pip install --upgrade "git+https://github.com/huggingface/trl"
cd unsloth
python -c "import trl; print('Resolved TRL', trl.__version__)"
PYTHONPATH=. python -m pytest \
tests/version_compat/test_trl_grpo_fake_run.py \
tests/version_compat/test_trl_fake_train_cpu.py \
-v --tb=short
# Daily-only: same suites but with --strict on importable upstream # Daily-only: same suites but with --strict on importable upstream
# tags. Schedule-only so PR jobs stay fast; cron tolerates a flake. # tags. Schedule-only so PR jobs stay fast; cron tolerates a flake.
daily-fresh-fetch: daily-fresh-fetch:

View file

@ -3,7 +3,7 @@
# Builds the PyPI wheel from the PR branch, then verifies the built wheel # Builds the PyPI wheel from the PR branch, then verifies the built wheel
# actually contains what we expect to ship and does NOT contain the broken # actually contains what we expect to ship and does NOT contain the broken
# Unsloth bundle that 2026.5.1 published. This is the single workflow that # Studio bundle that 2026.5.1 published. This is the single workflow that
# would have blocked the 2026.5.1 release before twine upload. # would have blocked the 2026.5.1 release before twine upload.
# #
# Verified locally end-to-end against this branch: # Verified locally end-to-end against this branch:
@ -12,7 +12,7 @@
# lockfile shipped, frontend dist shipped, # lockfile shipped, frontend dist shipped,
# no node_modules in wheel, no bun.lock in wheel, # no node_modules in wheel, no bun.lock in wheel,
# main bundle has unstable_Provider hits=1 (assistant-ui internals only). # main bundle has unstable_Provider hits=1 (assistant-ui internals only).
# - Unsloth backend imports cleanly from the installed wheel with the # - Studio backend imports cleanly from the installed wheel with the
# lightweight dep set below. # lightweight dep set below.
name: Wheel CI name: Wheel CI
@ -101,7 +101,7 @@ jobs:
hits = data.count("unstable_Provider:") hits = data.count("unstable_Provider:")
print(f"main bundle: {js[0]}") print(f"main bundle: {js[0]}")
print(f"unstable_Provider hits: {hits} (>=4 indicates 2026.5.1 regression)") print(f"unstable_Provider hits: {hits} (>=4 indicates 2026.5.1 regression)")
checks["bundle has no Unsloth unstable_Provider call site"] = (hits < 4) checks["bundle has no Studio unstable_Provider call site"] = (hits < 4)
print() print()
for k, v in checks.items(): for k, v in checks.items():
@ -109,7 +109,7 @@ jobs:
sys.exit(0 if all(checks.values()) else 1) sys.exit(0 if all(checks.values()) else 1)
PY PY
- name: Unsloth backend import smoke - name: Studio backend import smoke
# Imports `studio.backend.main:app` from the freshly-installed wheel in # Imports `studio.backend.main:app` from the freshly-installed wheel in
# a clean venv. This catches the class of bug that 2026.5.1 shipped with: # a clean venv. This catches the class of bug that 2026.5.1 shipped with:
# frontend dist missing, package-lock.json missing, or the wheel's Python # frontend dist missing, package-lock.json missing, or the wheel's Python
@ -125,32 +125,7 @@ jobs:
/tmp/v/bin/pip install --no-deps dist/unsloth-*.whl /tmp/v/bin/pip install --no-deps dist/unsloth-*.whl
# Run from /tmp so Python imports the installed package, not the source tree. # Run from /tmp so Python imports the installed package, not the source tree.
cd /tmp cd /tmp
/tmp/v/bin/python -c "from studio.backend.main import app; print('Unsloth backend OK:', app.title)" /tmp/v/bin/python -c "from studio.backend.main import app; print('Studio backend OK:', app.title)"
- name: CLI without the Studio stack guides instead of tracebacking
# The smoke above installs studio.txt first, so it cannot catch a wheel
# that ships studio/ without declaring what it imports (#4701, #5260,
# #7147). Drop only structlog to reuse that venv without a re-download.
run: |
set -eu
/tmp/v/bin/pip uninstall -y structlog >/dev/null
cd /tmp
status=0
for args in "export ./nope ./out" "list-checkpoints"; do
echo "--- unsloth $args"
out=$(/tmp/v/bin/unsloth $args 2>&1 || true)
printf '%s\n' "$out"
case "$out" in
*Traceback*)
echo "FAIL: raw traceback instead of guidance"; status=1 ;;
esac
case "$out" in
*'unsloth studio update'*) ;;
*) echo "FAIL: no remediation in the message"; status=1 ;;
esac
done
/tmp/v/bin/pip install -q structlog >/dev/null
exit "$status"
- name: Upload wheel on failure - name: Upload wheel on failure
if: failure() if: failure()

8
.gitignore vendored
View file

@ -11,8 +11,6 @@ outputs/
exports/ exports/
/datasets/ /datasets/
studio/backend/assets/datasets/ studio/backend/assets/datasets/
# Generated async worker / reviewer transcripts (never part of the product).
studio/backend/async_task_outputs/
unsloth_training_checkpoints/ unsloth_training_checkpoints/
*.gguf *.gguf
*.safetensors *.safetensors
@ -208,9 +206,6 @@ tmp/
**/node_modules/ **/node_modules/
auth.db auth.db
# Packaging snapshot of the root CHANGELOG.md (written by build.sh)
studio/CHANGELOG.md
# Tauri local build/generated output # Tauri local build/generated output
studio/src-tauri/target/ studio/src-tauri/target/
studio/src-tauri/gen/ studio/src-tauri/gen/
@ -241,5 +236,4 @@ package-lock.json
!studio/package-lock.json !studio/package-lock.json
llama.cpp/ llama.cpp/
# Stray "~" dir some tools create from a literal ~ TMPDIR; never part of the repo. # Stray "~" dir some tools create from a literal ~ TMPDIR; never part of the repo.
~/ /~/
/temp/

View file

@ -1,88 +0,0 @@
# Changelog
Release notes for Unsloth and Unsloth Studio.
Unsloth Studio reads this file to show release notes inside the "New Unsloth
version" update popup. Edit it here and the popup picks the change up on the
next update check, with no release or rebuild required.
## Format
Every release is a level-2 heading whose first token is the version, optionally
followed by a date:
```md
## 2026.7.6 - 2026-07-22
```
`## [2026.7.6] - 2026-07-22` and `## v2026.7.6` also work. Everything under a
heading, up to the next level-2 heading, is that release's notes and renders as
Markdown in the popup.
Notes are matched to one exact version. When Studio offers an update to
`2026.7.6` it renders the `2026.7.6` section and nothing else. If that section
is missing, the popup links out to the online changelog rather than showing
notes from an unrelated release, so a new version needs its own section here
before its notes can appear.
Keep the newest release at the top. Lead each bullet with the change itself:
the collapsed popup highlights the first sentence and dims the rest.
`## Unreleased` is ignored by the popup, so it is safe to stage notes there and
rename the heading at release time.
<!-- Add new releases directly below this line. -->
## Unreleased
## 2026.7.5
### What's Changed
- AMD support is here. Train, run RL, chat with and deploy 500+ models on
Radeon, Instinct, Ryzen and data center GPUs across Windows, WSL and Linux,
up to 2x faster with 70% less VRAM and no accuracy loss.
- Intel XPU support lands in Studio, so Arc and Data Center GPUs run chat and
training alongside the NVIDIA, AMD and Apple paths.
- Local speech to text dictation runs fully offline, with slim Whisper bundles
and a picker for custom models.
- DoRA training is available in Studio, selectable next to LoRA and full
fine-tuning in the training tab.
- The update popup previews release notes inline, pulled from this file and
matched to the exact version being offered.
### AMD, 23 July update
Our AMD collaboration, custom Triton kernels and math algorithms bring local
training and inference to AMD hardware. The 23 July update builds on the
[AMD release](https://github.com/unslothai/unsloth/releases/tag/v0.1.501-beta):
- RDNA2 and Gorgon Halo are supported, and the installer no longer fails to
detect GPUs on Strix Halo and other AMD cards.
- RDNA4 handling is better, and HIP and ROCm failures are caught and fixed
automatically instead of stopping the install.
- Unified memory safetensors loading is 2x faster, with much faster gradient
checkpointing on unified memory devices.
- Voice dictation through whisper.cpp has preliminary support.
- Rollback environments left by installs no longer eat 5GB of disk. They are
cleaned up automatically.
Optimized ROCm builds cover GGUF and safetensors inference, and ROCm
compatibility is improved for MI300X and MI325X. Full guide:
[unsloth.ai/docs/basics/amd](https://unsloth.ai/docs/basics/amd).
### Running larger models
- Automatic GPU placement, or pick exactly which GPUs and layers to use.
- Move MoE expert layers into system memory so larger models fit.
- Split a model across several GPUs, or use tensor parallelism.
- Hardware settings are saved per model and quant.
### Also in this release
- Remote access with `unsloth studio --secure` over free HTTPS via Cloudflare.
- Web search reads PDF papers and manuals, and parallel tool calls, reasoning
output and tool retries are more reliable.
- The model download location is configurable, so weights can live on a second
drive instead of the default cache.
- Stalled Hugging Face XET downloads retry over standard HTTP, and existing
GGUF files are reused instead of downloaded again.

View file

@ -1,2 +0,0 @@
include _changelog_build.py
include CHANGELOG.md

122
README.md
View file

@ -11,7 +11,6 @@ Unsloth Studio lets you run and train models locally.
<p align="center"> <p align="center">
<a href="#-features">Features</a> • <a href="#-features">Features</a> •
<a href="#-unsloth-news">News</a> •
<a href="#-install">Quickstart</a> • <a href="#-install">Quickstart</a> •
<a href="#-free-notebooks">Notebooks</a> • <a href="#-free-notebooks">Notebooks</a> •
<a href="https://unsloth.ai/docs">Documentation</a> <a href="https://unsloth.ai/docs">Documentation</a>
@ -48,51 +47,15 @@ Unsloth Studio (Beta) lets you run and train text, [audio](https://unsloth.ai/do
* [Auto set inference settings](https://unsloth.ai/docs/new/studio/chat#auto-parameter-tuning) and customize chat templates. * [Auto set inference settings](https://unsloth.ai/docs/new/studio/chat#auto-parameter-tuning) and customize chat templates.
* We work directly with teams behind [gpt-oss](https://docs.unsloth.ai/new/gpt-oss-how-to-run-and-fine-tune#unsloth-fixes-for-gpt-oss), [Qwen3](https://www.reddit.com/r/LocalLLaMA/comments/1kaodxu/qwen3_unsloth_dynamic_ggufs_128k_context_bug_fixes/), [Llama 4](https://github.com/ggml-org/llama.cpp/pull/12889), [Mistral](https://huggingface.co/mistralai/Mistral-Medium-3.5-128B/discussions/18), [Gemma 1-3](https://news.ycombinator.com/item?id=39671146), and [Phi-4](https://unsloth.ai/blog/phi4), where weve fixed bugs that improve model accuracy. * We work directly with teams behind [gpt-oss](https://docs.unsloth.ai/new/gpt-oss-how-to-run-and-fine-tune#unsloth-fixes-for-gpt-oss), [Qwen3](https://www.reddit.com/r/LocalLLaMA/comments/1kaodxu/qwen3_unsloth_dynamic_ggufs_128k_context_bug_fixes/), [Llama 4](https://github.com/ggml-org/llama.cpp/pull/12889), [Mistral](https://huggingface.co/mistralai/Mistral-Medium-3.5-128B/discussions/18), [Gemma 1-3](https://news.ycombinator.com/item?id=39671146), and [Phi-4](https://unsloth.ai/blog/phi4), where weve fixed bugs that improve model accuracy.
* Chat with images, audio, PDFs, code, DOCX and more. [Connect API providers](https://unsloth.ai/docs/integrations/connections) (OpenAI, Anthropic) or servers (vLLM, Ollama). * Chat with images, audio, PDFs, code, DOCX and more. [Connect API providers](https://unsloth.ai/docs/integrations/connections) (OpenAI, Anthropic) or servers (vLLM, Ollama).
* [**Compare any two models**](https://unsloth.ai/docs/new/studio/chat#model-arena) side by side with the same prompt.
* **OpenAI/Anthropic-compatible APIs**: Serve local models through `/v1/chat/completions`, `/v1/responses` and `/v1/messages`.
* **Connect local models to agents**: Use `unsloth start` with Claude Code, Codex, Hermes and more.
* **Web/PDF search** can read PDF papers, manuals and other PDF results.
* **GGUF hardware controls**: Choose GPUs/layers, offload MoE experts, use multi-GPU or Tensor Parallelism.
* The opt-in **MCP control endpoint** lets AI clients manage models, training, recipes and exports.
### Training ### Training
* Train and RL **500+ models** up to **2x faster** with **70% less VRAM**; MoE up to **12x faster**. * Train and RL **500+ models** up to **2x faster** with up to **70% less VRAM**, with no accuracy loss.
* Train and run RL on [AMD GPUs](https://unsloth.ai/docs/basics/amd) across Windows, WSL and Linux. * Custom Triton and mathematical **kernels**. See some collabs we did with [PyTorch](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/fp8-reinforcement-learning) and [Hugging Face](https://unsloth.ai/docs/new/faster-moe).
* **Data Recipes**: [Auto-create datasets](https://unsloth.ai/docs/new/studio/data-recipe) from **PDF, CSV, DOCX** etc. Edit data in a visual-node workflow. * **Data Recipes**: [Auto-create datasets](https://unsloth.ai/docs/new/studio/data-recipe) from **PDF, CSV, DOCX** etc. Edit data in a visual-node workflow.
* **[Reinforcement Learning](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide)** uses **80% less VRAM** for GRPO, FP8 and vision RL, with 7x longer contexts. * **[Reinforcement Learning](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide)** (RL): The most efficient [RL](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide) library, using **80% less VRAM** for GRPO, [FP8](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/fp8-reinforcement-learning) etc.
* [**Long-context training**](https://unsloth.ai/docs/new/3x-faster-training-packing): **3x faster**, 30% less VRAM and 500K+ context. * Supports full fine-tuning, RL, pretraining, 4-bit, 16-bit and, FP8 training.
* Supports LoRA/QLoRA, full fine-tuning, RL, pretraining, 4-bit, 16-bit and FP8.
* Custom Triton and mathematical **kernels** built with PyTorch and Hugging Face.
* **Observability**: Monitor training live, track loss and GPU usage and customize graphs. * **Observability**: Monitor training live, track loss and GPU usage and customize graphs.
* [Multi-GPU](https://unsloth.ai/docs/basics/multi-gpu-training-with-unsloth) training is supported, with major improvements coming soon. * [Multi-GPU](https://unsloth.ai/docs/basics/multi-gpu-training-with-unsloth) training is supported, with major improvements coming soon.
## 🚀 Unsloth Start
[Unsloth Start](https://unsloth.ai/docs/integrations/unsloth-start) connects [Claude Code](https://unsloth.ai/docs/basics/claude-code), [Codex](https://unsloth.ai/docs/basics/codex) and other agents to local models with one command.
Start Unsloth, load a model, open your project folder, then run:
```bash
unsloth start claude
```
Replace `claude` with any supported agent:
| Agent | Command |
| --- | --- |
| Claude Code | `unsloth start claude` |
| OpenAI Codex | `unsloth start codex` |
| Hermes Agent | `unsloth start hermes` |
| OpenClaw | `unsloth start openclaw` |
| OpenCode | `unsloth start opencode` |
| Pi Coding Agent | `unsloth start pi` |
Claude Code, Codex, OpenCode and Pi can keep their current model and use Unsloth as a local
subagent:
```bash
unsloth start claude --as-subagent --model unsloth/model-GGUF:quant
```
## 📥 Install ## 📥 Install
Unsloth can be used in two ways: through **[Unsloth Studio](https://unsloth.ai/docs/new/studio/)**, the web UI, or through **Unsloth Core**, the code-based version. Each has different requirements. Unsloth can be used in two ways: through **[Unsloth Studio](https://unsloth.ai/docs/new/studio/)**, the web UI, or through **Unsloth Core**, the code-based version. Each has different requirements.
@ -102,8 +65,7 @@ Unsloth Studio (Beta) works on **Windows, Linux, WSL** and **macOS**.
* **CPU:** Supported for Chat and Data Recipes currently * **CPU:** Supported for Chat and Data Recipes currently
* **NVIDIA:** Training works on RTX 30/40/50, Blackwell, DGX Spark, Station and more * **NVIDIA:** Training works on RTX 30/40/50, Blackwell, DGX Spark, Station and more
* **macOS:** Training, MLX and GGUF inference are ALL supported. * **macOS:** Training, MLX and GGUF inference are ALL supported.
* **AMD:** Training, RL, chat and deployment work on Windows, WSL and Linux. [Read the AMD guide](https://unsloth.ai/docs/basics/amd). * **AMD:** Chat + Data works. Train with [Unsloth Core](#unsloth-core-code-based). Studio support is out soon.
* **Vulkan:** GGUF inference is supported on [compatible GPUs, including Intel GPUs](https://github.com/unslothai/unsloth/pull/5819). Vulkan accelerates GGUF inference only; training still requires a supported PyTorch or MLX backend.
* **Multi-GPU:** Available now, with a major upgrade on the way * **Multi-GPU:** Available now, with a major upgrade on the way
#### macOS, Linux, WSL: #### macOS, Linux, WSL:
@ -112,35 +74,19 @@ curl -fsSL https://unsloth.ai/install.sh | sh
``` ```
Use the same command to update. Use the same command to update.
To force the Vulkan llama.cpp backend, set `UNSLOTH_FORCE_VULKAN=1` **before installing or updating**. The setting selects the llama.cpp binary bundle, so setting it only when launching Studio cannot replace an existing CPU bundle:
```bash
export UNSLOTH_FORCE_VULKAN=1
curl -fsSL https://unsloth.ai/install.sh | sh
```
#### Windows: #### Windows:
```powershell ```powershell
irm https://unsloth.ai/install.ps1 | iex irm https://unsloth.ai/install.ps1 | iex
``` ```
Use the same command to update. Use the same command to update.
To force the Vulkan llama.cpp backend, set the environment variable before running the installer or updater:
```powershell
$env:UNSLOTH_FORCE_VULKAN=1
irm https://unsloth.ai/install.ps1 | iex
```
Re-running the current installer replaces a previously selected CPU bundle when the backend differs. A separate Vulkan SDK is not required; the GPU driver must provide a working Vulkan runtime.
#### Launch #### Launch
```bash ```bash
unsloth studio -p 8888 unsloth studio -p 8888
``` ```
For LAN or cloud access, add `-H 0.0.0.0` (raw port only; add `--cloudflare` for a public URL). By default, Unsloth is accessible only locally. For cloud or global access, add `-H 0.0.0.0`. By default, Unsloth is accessible only locally.
To reach Unsloth over HTTPS, use `unsloth studio --secure`. Unsloth stays bound to localhost and is reached only through a free Cloudflare tunnel, which publishes it at a public `https://*.trycloudflare.com` URL (it fails closed if the tunnel can't start, so the raw port is never exposed). This makes Unsloth reachable from the internet, so anyone with the link and API key can use it and run code: keep your API key private (see Remote access below). To reach Studio over HTTPS, use `unsloth studio --secure`. Studio stays bound to localhost and is reached only through a free Cloudflare tunnel, which publishes it at a public `https://*.trycloudflare.com` URL (it fails closed if the tunnel can't start, so the raw port is never exposed). This makes Studio reachable from the internet, so anyone with the link and API key can use it and run code: keep your API key private (see Remote access below).
#### Docker #### Docker
Use our [Docker image](https://hub.docker.com/r/unsloth/unsloth) ```unsloth/unsloth``` container. Run: Use our [Docker image](https://hub.docker.com/r/unsloth/unsloth) ```unsloth/unsloth``` container. Run:
@ -176,7 +122,7 @@ You can use the same Docker image as Unsloth Studio.
#### AMD, Intel: #### AMD, Intel:
For RTX 50x, B200, 6000 GPUs: `uv pip install unsloth --torch-backend=auto`. Read our guides for: [Blackwell](https://unsloth.ai/docs/blog/fine-tuning-llms-with-blackwell-rtx-50-series-and-unsloth) and [DGX Spark](https://unsloth.ai/docs/blog/fine-tuning-llms-with-nvidia-dgx-spark-and-unsloth). <br> For RTX 50x, B200, 6000 GPUs: `uv pip install unsloth --torch-backend=auto`. Read our guides for: [Blackwell](https://unsloth.ai/docs/blog/fine-tuning-llms-with-blackwell-rtx-50-series-and-unsloth) and [DGX Spark](https://unsloth.ai/docs/blog/fine-tuning-llms-with-nvidia-dgx-spark-and-unsloth). <br>
To install Unsloth on **AMD** and **Intel** GPUs, follow our [AMD Guide](https://unsloth.ai/docs/basics/amd) and [Intel Guide](https://unsloth.ai/docs/get-started/install/intel). To install Unsloth on **AMD** and **Intel** GPUs, follow our [AMD Guide](https://unsloth.ai/docs/get-started/install/amd) and [Intel Guide](https://unsloth.ai/docs/get-started/install/intel).
## 📒 Free Notebooks ## 📒 Free Notebooks
@ -202,20 +148,13 @@ Read our [guide](https://unsloth.ai/docs/get-started/fine-tuning-llms-guide). Ad
- See detailed documentation for Unsloth [here](https://unsloth.ai/docs) - See detailed documentation for Unsloth [here](https://unsloth.ai/docs)
## 🦥 Unsloth News ## 🦥 Unsloth News
- **AMD training**: Train, run RL, chat and deploy on AMD GPUs across Windows, WSL and Linux. [Guide](https://unsloth.ai/docs/basics/amd) - **Connections**: Connect any API provider (OpenAI, Anthropic) or server (vLLM, Ollama). [Guide](https://unsloth.ai/docs/integrations/connections)
- **GGUF hardware controls**: Choose GPU/layer placement, offload MoE experts and use multi-GPU or Tensor Parallelism. [#6414](https://github.com/unslothai/unsloth/pull/6414) - **MTP**: Run Qwen3.6 MTP in Unsloth. MTP settings are autoset specific to your hardware. [Guide](https://unsloth.ai/docs/models/qwen3.6#mtp-guide)
- **Local models for any agent**: Use `unsloth start` with Claude Code, Codex, Hermes, OpenCode, OpenClaw, Pi and more through Unsloth's OpenAI- and Anthropic-compatible APIs. [Guide](https://unsloth.ai/docs/basics/api) - **API inference endpoint**: Deploy and run local LLMs in Claude Code, Codex tools. [Guide](https://unsloth.ai/docs/basics/api)
- **MCP control endpoint**: Let compatible clients manage models, training, recipes, checkpoints and exports. [#7191](https://github.com/unslothai/unsloth/pull/7191) - **Qwen3.6**: Qwen3.6-35B-A3B can now be trained and run in Unsloth Studio. [Blog](https://unsloth.ai/docs/models/qwen3.6)
- **Local inference reliability**: Resume long chats faster, recover stalled downloads and reuse existing GGUF files. [#7204](https://github.com/unslothai/unsloth/pull/7204) • [#6858](https://github.com/unslothai/unsloth/pull/6858) • [#7209](https://github.com/unslothai/unsloth/pull/7209) - **Gemma 4**: Run and train Googles new models directly in Unsloth. [Blog](https://unsloth.ai/docs/models/gemma-4)
- **New models**: [Qwen-AgentWorld](https://huggingface.co/unsloth/Qwen-AgentWorld-35B-A3B-GGUF), [Ornith](https://huggingface.co/unsloth/models?search=ornith), [Kimi K2.7 Code](https://unsloth.ai/docs/models/kimi-k2.7-code) and [MiniMax M3](https://unsloth.ai/docs/models/minimax-m3)
- **GLM-5.2**: Run Z.ai's 744B-parameter, 1M-context open model locally with Unsloth Dynamic GGUFs. [Guide](https://unsloth.ai/docs/models/glm-5.2)
- **DeepSeek-V4**: Run DeepSeek-V4-Flash locally with corrected multi-turn and tool-calling behavior. [Guide](https://unsloth.ai/docs/models/deepseek-v4)
- **DiffusionGemma**: Run and fine-tune Google's diffusion language model with 1.8x faster inference in Unsloth Studio. [Guide](https://unsloth.ai/docs/models/diffusiongemma)
- **Qwen3.6**: Run and train Qwen3.6 with MTP for 1.4-2.2x faster inference and NVFP4 quants for supported GPUs. [Guide](https://unsloth.ai/docs/models/qwen3.6)
- **Gemma 4**: Run and train Gemma 4 text, image and audio models with QAT, MTP, GGUF and MLX support. [Guide](https://unsloth.ai/docs/models/gemma-4)
- **MCP servers**: Connect local models to files, apps, databases and external tools through Model Context Protocol. [Guide](https://unsloth.ai/docs/basics/mcp)
- **Connections**: Mix local models with API providers (OpenAI, Anthropic) or servers (vLLM, Ollama) in the same interface. [Guide](https://unsloth.ai/docs/integrations/connections)
- **Introducing Unsloth Studio**: our new web UI for running and training LLMs. [Blog](https://unsloth.ai/docs/new/studio) - **Introducing Unsloth Studio**: our new web UI for running and training LLMs. [Blog](https://unsloth.ai/docs/new/studio)
- **Qwen3.5** - 0.8B, 2B, 4B, 9B, 27B, 35-A3B, 112B-A10B are now supported. [Guide + notebooks](https://unsloth.ai/docs/models/qwen3.5/fine-tune)
- Train **MoE LLMs 12x faster** with 35% less VRAM - DeepSeek, GLM, Qwen and gpt-oss. [Blog](https://unsloth.ai/docs/new/faster-moe) - Train **MoE LLMs 12x faster** with 35% less VRAM - DeepSeek, GLM, Qwen and gpt-oss. [Blog](https://unsloth.ai/docs/new/faster-moe)
- **Embedding models**: Unsloth now supports ~1.8-3.3x faster embedding fine-tuning. [Blog](https://unsloth.ai/docs/new/embedding-finetuning) • [Notebooks](https://unsloth.ai/docs/get-started/unsloth-notebooks#embedding-models) - **Embedding models**: Unsloth now supports ~1.8-3.3x faster embedding fine-tuning. [Blog](https://unsloth.ai/docs/new/embedding-finetuning) • [Notebooks](https://unsloth.ai/docs/get-started/unsloth-notebooks#embedding-models)
- New **7x longer context RL** vs. all other setups, via our new batching algorithms. [Blog](https://unsloth.ai/docs/new/grpo-long-context) - New **7x longer context RL** vs. all other setups, via our new batching algorithms. [Blog](https://unsloth.ai/docs/new/grpo-long-context)
@ -269,31 +208,16 @@ unsloth studio -p 8888
#### Remote access: `--secure` (HTTPS tunnel) vs raw port #### 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: By default `unsloth studio` binds to `127.0.0.1` (this machine only). To reach it from another device, pick one of:
- `--secure` (recommended): serve **only** through a free Cloudflare HTTPS link. Unsloth stays bound to localhost and the tunnel provides the public URL; it fails closed (does not start) if the tunnel can't come up, so the raw port is never exposed. - `--secure` (recommended): serve **only** through a free Cloudflare HTTPS link. Studio stays bound to localhost and the tunnel provides the public URL; it fails closed (does not start) if the tunnel can't come up, so the raw port is never exposed.
```bash ```bash
unsloth studio --secure -p 8888 unsloth studio --secure -p 8888
``` ```
- `-H 0.0.0.0`: bind the raw port on all network interfaces, reachable from anywhere on the network (subject to your firewall). It does not create a public internet URL; add `--cloudflare` to also publish an internet-reachable `https://*.trycloudflare.com` link even behind a firewall. Only use this on a network you trust. - `-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 ```bash
unsloth studio -H 0.0.0.0 -p 8888 unsloth studio -H 0.0.0.0 -p 8888
``` ```
The Cloudflare tunnel is **off by default**: `-H 0.0.0.0` exposes the raw port only, not a public internet URL. Pair the wildcard bind with `--cloudflare` (`unsloth studio -H 0.0.0.0 --cloudflare`) to also publish a public `https://*.trycloudflare.com` link, or prefer `--secure` (above), which keeps the raw port private. `--cloudflare` has no effect on a loopback bind.
On a wildcard bind Unsloth works out the address to share by asking `ifconfig.me` for the public IP, then asks `check-host.net` whether that port is reachable so it can tell you if a firewall is in the way. Both contact a third party. Set `UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK=1` to skip them; the banner then shows the LAN address and no reachability line. 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.
The first time Unsloth is published on a public URL (`--secure` or `--cloudflare`) with the auto-generated admin password still in place, it asks for a new admin password in the terminal (masked input with confirmation) before the public link goes up. Without an attached terminal it warns instead and keeps the bootstrap deadline: Unsloth shuts down after `UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT` (default 1 hour) unless the password is changed in the web UI.
For headless setups that cannot answer that prompt, set the initial admin password non-interactively with `--password` (only takes effect when no password is set yet; if one already exists it is a hard error, so rotate later with `unsloth studio reset-password`):
```bash
unsloth studio --secure --password 'your-strong-password' # visible in `ps`/history
UNSLOTH_STUDIO_PASSWORD='your-strong-password' unsloth studio --secure # via env var
printf '%s\n' 'your-strong-password' | unsloth studio --secure --password - # via stdin
```
A literal `--password VALUE` is visible in the process list and shell history, so prefer the `UNSLOTH_STUDIO_PASSWORD` env var or `--password -` (stdin) for automation. This applies to any launch (public or a headless `-H 0.0.0.0` bind), and the password is set in the parent before the server binds, so it never reaches a re-executed child process.
Server-side tools (web search, Python and terminal code execution) run as your user and are on by default. Anyone who can reach the server with the API key can run code on this machine, so keep your API key private and pass `--disable-tools` when exposing Unsloth.
#### Advanced launch options #### 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`. Installer options can be passed as environment variables. On macOS, Linux and WSL place the variable after the pipe so the shell passes it to `sh`; on Windows set it with `$env:` before piping to `iex`.
@ -306,14 +230,6 @@ curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_NO_TORCH=1 sh
$env:UNSLOTH_NO_TORCH=1; irm https://unsloth.ai/install.ps1 | iex $env:UNSLOTH_NO_TORCH=1; irm https://unsloth.ai/install.ps1 | iex
``` ```
Skip the post-install prompt that starts Unsloth (useful for automated installs):
```bash
curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_SKIP_AUTOSTART=1 sh
```
```powershell
$env:UNSLOTH_SKIP_AUTOSTART=1; irm https://unsloth.ai/install.ps1 | iex
```
Pin the Python version: Pin the Python version:
```bash ```bash
curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_PYTHON=3.12 sh curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_PYTHON=3.12 sh
@ -342,9 +258,9 @@ UNSLOTH_NPM_REGISTRY=https://artifactory.example.com/api/npm/npm/ ./install.sh -
```powershell ```powershell
$env:UNSLOTH_NPM_REGISTRY='https://artifactory.example.com/api/npm/npm/'; .\install.ps1 --local $env:UNSLOTH_NPM_REGISTRY='https://artifactory.example.com/api/npm/npm/'; .\install.ps1 --local
``` ```
It is threaded as `--registry` into the Unsloth frontend `npm`/`bun` installs; the supply-chain locks (7-day `min-release-age`, exact version pins) stay in force. It is threaded as `--registry` into the Studio frontend `npm`/`bun` installs; the supply-chain locks (7-day `min-release-age`, exact version pins) stay in force.
Cap Unsloth's native CPU thread pools on high-core hosts: `UNSLOTH_CPU_THREADS=8 unsloth studio -p 8888`. Cap Studio's native CPU thread pools on high-core hosts: `UNSLOTH_CPU_THREADS=8 unsloth studio -p 8888`.
#### Uninstall #### Uninstall
The recommended way to fully remove Unsloth Studio is the matching uninstall script for your OS. It stops any running servers, removes the install dir, the launcher data dir, the desktop shortcut, and any platform-specific entries (macOS `.app` bundle + Launch Services on Mac; Start Menu, `HKCU\Software\Unsloth` registry key and user `PATH` entries on Windows): The recommended way to fully remove Unsloth Studio is the matching uninstall script for your OS. It stops any running servers, removes the install dir, the launcher data dir, the desktop shortcut, and any platform-specific entries (macOS `.app` bundle + Launch Services on Mac; Start Menu, `HKCU\Software\Unsloth` registry key and user `PATH` entries on Windows):

View file

@ -1,36 +0,0 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Snapshot CHANGELOG.md into the studio package at build time.
CHANGELOG.md at the repo root stays the one file to edit. Copying it here,
rather than in build.sh, means every packaging path ships it, so release notes
still render when the popup cannot reach GitHub."""
from __future__ import annotations
import shutil
from pathlib import Path
from setuptools.command.build_py import build_py as _build_py
ROOT = Path(__file__).resolve().parent
SOURCE = ROOT / "CHANGELOG.md"
SNAPSHOT = ROOT / "studio" / "CHANGELOG.md"
class build_py(_build_py):
def run(self) -> None:
# Beside the sources only if writable (PEP 517 may build an immutable
# checkout); into the staging directory always.
if SOURCE.is_file():
try:
shutil.copyfile(SOURCE, SNAPSHOT)
except OSError:
pass
super().run()
if not SOURCE.is_file():
return
staged = Path(self.build_lib) / "studio" / "CHANGELOG.md"
staged.parent.mkdir(parents = True, exist_ok = True)
shutil.copyfile(SOURCE, staged)

View file

@ -4,9 +4,9 @@
set -euo pipefail set -euo pipefail
# PyPI/Unsloth release publishing must use `./build.sh publish` (or an # PyPI/Studio release publishing must use `./build.sh publish` (or an
# equivalent stamp -> build -> verify-dist -> upload flow) so packaged Unsloth # equivalent stamp -> build -> verify-dist -> upload flow) so packaged Studio
# artifacts include the display-only Unsloth release version. # artifacts include the display-only Studio release version.
# 1. Build frontend (Vite outputs to dist/) # 1. Build frontend (Vite outputs to dist/)
cd studio/frontend cd studio/frontend
@ -87,7 +87,7 @@ cd ../..
# 2. Clean old artifacts # 2. Clean old artifacts
rm -rf build dist *.egg-info rm -rf build dist *.egg-info
# 3. Stamp display-only Unsloth release metadata for packaged builds. # 3. Stamp display-only Studio release metadata for packaged builds.
_STUDIO_BUILD_INFO="studio/backend/utils/_studio_release_build.py" _STUDIO_BUILD_INFO="studio/backend/utils/_studio_release_build.py"
_STUDIO_BUILD_INFO_BACKUP="$(mktemp)" _STUDIO_BUILD_INFO_BACKUP="$(mktemp)"
cp "$_STUDIO_BUILD_INFO" "$_STUDIO_BUILD_INFO_BACKUP" cp "$_STUDIO_BUILD_INFO" "$_STUDIO_BUILD_INFO_BACKUP"
@ -103,13 +103,9 @@ else
STUDIO_STAMPED_VERSION="$(python scripts/stamp_studio_release.py)" STUDIO_STAMPED_VERSION="$(python scripts/stamp_studio_release.py)"
fi fi
# 4. Build wheel/sdist. _changelog_build.py snapshots CHANGELOG.md into the studio # 4. Build wheel/sdist
# package so release notes render offline.
python -m build python -m build
# Drop the snapshot so a source checkout never serves a stale copy.
rm -f studio/CHANGELOG.md
if [ "${1:-}" = "publish" ]; then if [ "${1:-}" = "publish" ]; then
python scripts/stamp_studio_release.py --verify-dist dist --expected "$STUDIO_STAMPED_VERSION" python scripts/stamp_studio_release.py --verify-dist dist --expected "$STUDIO_STAMPED_VERSION"
fi fi

File diff suppressed because it is too large Load diff

1852
install.sh

File diff suppressed because it is too large Load diff

View file

@ -25,17 +25,11 @@ classifiers = [
"Topic :: Scientific/Engineering :: Artificial Intelligence", "Topic :: Scientific/Engineering :: Artificial Intelligence",
] ]
dependencies = [ dependencies = [
"typer>=0.12.0", "typer",
"rich", "rich",
"pydantic", "pydantic",
"pyyaml", "pyyaml",
"nest-asyncio", "nest-asyncio",
# Every CLI command imports studio.backend.*, which reaches structlog at
# module level. The rest of the server stack lives in the studio extra.
"structlog>=24.1.0",
# unsloth_cli/__init__.py reaches click via commands/start.py, so every
# command needs it. typer supplied it until 0.27 dropped the dependency.
"click>=8.0",
] ]
[project.scripts] [project.scripts]
@ -47,14 +41,8 @@ version = {attr = "unsloth.models._utils.__version__"}
[tool.setuptools] [tool.setuptools]
include-package-data = true include-package-data = true
[tool.setuptools.cmdclass]
# Snapshots CHANGELOG.md into studio/ so every build path ships it.
build_py = "_changelog_build.build_py"
[tool.setuptools.package-data] [tool.setuptools.package-data]
unsloth_cli = ["codex_fallback_prompt.md", "pi_subagent.ts"]
studio = [ studio = [
"CHANGELOG.md",
"*.sh", "*.sh",
"*.ps1", "*.ps1",
"*.bat", "*.bat",
@ -79,40 +67,13 @@ include = ["unsloth*", "unsloth_cli*", "studio", "studio.backend*"]
exclude = ["images*", "tests*", "*.node_modules", "*.node_modules.*"] exclude = ["images*", "tests*", "*.node_modules", "*.node_modules.*"]
[project.optional-dependencies] [project.optional-dependencies]
# Studio's server stack, mirroring studio/backend/requirements/studio.txt.
# test_studio_extra_matches_requirements.py catches drift.
studio = [
"typer",
"fastapi",
"uvicorn",
"pydantic",
"packaging",
"matplotlib==3.10.9",
"pandas",
"nest_asyncio",
"datasets==4.3.0",
"pyjwt",
"huggingface-hub==0.36.2",
"structlog>=24.1.0",
"diceware",
"ddgs",
"cryptography>=42.0.0",
"boto3>=1.34.0",
"httpx>=0.27.0",
"fastmcp>=3.0.2",
"sqlite-vec==0.1.9",
"pymupdf==1.27.2.3",
"pymupdf4llm==0.3.4",
"python-docx==1.2.0",
]
triton = [ triton = [
"triton>=3.0.0 ; ('linux' in sys_platform)", "triton>=3.0.0 ; ('linux' in sys_platform)",
"triton-windows ; (sys_platform == 'win32') and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", "triton-windows ; (sys_platform == 'win32') and (platform_machine == 'AMD64' or platform_machine == 'x86_64')",
] ]
huggingfacenotorch = [ huggingfacenotorch = [
"unsloth_zoo>=2026.7.6", "unsloth_zoo>=2026.6.7",
"wheel>=0.42.0", "wheel>=0.42.0",
"packaging", "packaging",
"numpy", "numpy",
@ -131,25 +92,9 @@ huggingfacenotorch = [
"trl>=0.18.2,!=0.19.0,<=0.24.0", "trl>=0.18.2,!=0.19.0,<=0.24.0",
"sentence-transformers", "sentence-transformers",
] ]
# torchcodec backend for Gemma audio / datasets>=4 (#7225).
# Pick the audio-torch* pin matching your torch minor (see TORCH_TORCHCODEC).
# torchcodec publishes no sdist and only manylinux_2_28_x86_64, macosx_*_arm64
# and win_amd64 wheels, so Linux aarch64, Windows ARM64 and Intel Mac have
# nothing to resolve and pip fails the whole install rather than skipping audio.
# Gate on the platforms that have a wheel, matching
# PLATFORM_LACKS_TORCHCODEC_WHEEL in studio/install_python_stack.py.
audio-torch210 = [
"torchcodec>=0.10.0,<0.11.0 ; python_version >= '3.10' and (((sys_platform == 'linux' or sys_platform == 'win32') and (platform_machine == 'x86_64' or platform_machine == 'AMD64')) or (sys_platform == 'darwin' and platform_machine == 'arm64'))",
]
audio-torch290 = [
"torchcodec>=0.8.0,<0.10.0 ; python_version >= '3.10' and (((sys_platform == 'linux' or sys_platform == 'win32') and (platform_machine == 'x86_64' or platform_machine == 'AMD64')) or (sys_platform == 'darwin' and platform_machine == 'arm64'))",
]
audio-torch280 = [
"torchcodec>=0.6.0,<0.8.0 ; python_version >= '3.9' and (((sys_platform == 'linux' or sys_platform == 'win32') and (platform_machine == 'x86_64' or platform_machine == 'AMD64')) or (sys_platform == 'darwin' and platform_machine == 'arm64'))",
]
huggingface = [ huggingface = [
"unsloth[huggingfacenotorch]", "unsloth[huggingfacenotorch]",
"unsloth_zoo>=2026.7.6", "unsloth_zoo>=2026.6.7",
"torchvision", "torchvision",
"unsloth[triton]", "unsloth[triton]",
] ]
@ -586,19 +531,16 @@ cu126-torch2100 = [
"unsloth[huggingface]", "unsloth[huggingface]",
"bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0", "bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0",
"unsloth[cu126onlytorch2100]", "unsloth[cu126onlytorch2100]",
"unsloth[audio-torch210]",
] ]
cu128-torch2100 = [ cu128-torch2100 = [
"unsloth[huggingface]", "unsloth[huggingface]",
"bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0", "bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0",
"unsloth[cu128onlytorch2100]", "unsloth[cu128onlytorch2100]",
"unsloth[audio-torch210]",
] ]
cu130-torch2100 = [ cu130-torch2100 = [
"unsloth[huggingface]", "unsloth[huggingface]",
"bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0", "bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0",
"unsloth[cu130onlytorch2100]", "unsloth[cu130onlytorch2100]",
"unsloth[audio-torch210]",
] ]
kaggle = [ kaggle = [
"unsloth[huggingface]", "unsloth[huggingface]",
@ -637,7 +579,7 @@ colab-ampere-torch220 = [
"flash-attn>=2.6.3 ; ('linux' in sys_platform)", "flash-attn>=2.6.3 ; ('linux' in sys_platform)",
] ]
colab-new = [ colab-new = [
"unsloth_zoo>=2026.7.6", "unsloth_zoo>=2026.6.7",
"packaging", "packaging",
"tyro", "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", "transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.5.0",
@ -888,19 +830,16 @@ cu126-ampere-torch2100 = [
"unsloth[huggingface]", "unsloth[huggingface]",
"bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0", "bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0",
"unsloth[cu126onlytorch2100]", "unsloth[cu126onlytorch2100]",
"unsloth[audio-torch210]",
] ]
cu128-ampere-torch2100 = [ cu128-ampere-torch2100 = [
"unsloth[huggingface]", "unsloth[huggingface]",
"bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0", "bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0",
"unsloth[cu128onlytorch2100]", "unsloth[cu128onlytorch2100]",
"unsloth[audio-torch210]",
] ]
cu130-ampere-torch2100 = [ cu130-ampere-torch2100 = [
"unsloth[huggingface]", "unsloth[huggingface]",
"bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0", "bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0",
"unsloth[cu130onlytorch2100]", "unsloth[cu130onlytorch2100]",
"unsloth[audio-torch210]",
] ]
flashattentiontorch260abiFALSEcu12x = [ flashattentiontorch260abiFALSEcu12x = [
"flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.6cxx11abiFALSE-cp39-cp39-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.9'", "flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.6cxx11abiFALSE-cp39-cp39-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.9'",
@ -1185,8 +1124,7 @@ intelgputorch210 = [
"torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.25.0%2Bxpu-cp313-cp313-win_amd64.whl#sha256=1c4b44b36a557f7381e3076fb8843366742238648441d607c8d049c6da0f8886 ; sys_platform == 'win32' and python_version == '3.13' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", "torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.25.0%2Bxpu-cp313-cp313-win_amd64.whl#sha256=1c4b44b36a557f7381e3076fb8843366742238648441d607c8d049c6da0f8886 ; sys_platform == 'win32' and python_version == '3.13' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')",
] ]
intel-gpu-torch210 = [ intel-gpu-torch210 = [
"unsloth[intelgputorch210]", "unsloth[intelgputorch210]"
"unsloth[audio-torch210]",
] ]
intelgputorch2110 = [ intelgputorch2110 = [
"unsloth_zoo[intelgpu]", "unsloth_zoo[intelgpu]",
@ -1267,11 +1205,8 @@ intel = [
] ]
amd = [ amd = [
"unsloth[huggingfacenotorch]", "unsloth[huggingfacenotorch]",
# 4-bit decode is unreliable on ROCm before 0.50.0, the first PyPI release "bitsandbytes>=0.49.1 ; ('linux' in sys_platform) and (platform_machine == 'AMD64' or platform_machine == 'x86_64' or platform_machine == 'aarch64')",
# carrying the full path: blocksize/warp decoupling (bnb #1887), fused SIMT "bitsandbytes>=0.49.1 ; (sys_platform == 'win32') and (platform_machine == 'AMD64' or platform_machine == 'x86_64')",
# GEMM on RDNA (#1979), RDNA3/4 workgroup fix (#2012).
"bitsandbytes>=0.50.0 ; ('linux' in sys_platform) and (platform_machine == 'AMD64' or platform_machine == 'x86_64' or platform_machine == 'aarch64')",
"bitsandbytes>=0.50.0 ; (sys_platform == 'win32') and (platform_machine == 'AMD64' or platform_machine == 'x86_64')",
] ]
rocm702-torch280 = [ rocm702-torch280 = [
"unsloth[amd]", "unsloth[amd]",
@ -1343,7 +1278,6 @@ rocm72-torch2100 = [
"torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.2/torchvision-0.25.0%2Brocm7.2.0.git82df5f59-cp311-cp311-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'", "torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.2/torchvision-0.25.0%2Brocm7.2.0.git82df5f59-cp311-cp311-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'",
"torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.2/torchvision-0.25.0%2Brocm7.2.0.git82df5f59-cp312-cp312-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'", "torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.2/torchvision-0.25.0%2Brocm7.2.0.git82df5f59-cp312-cp312-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'",
"torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.2/torchvision-0.25.0%2Brocm7.2.0.git82df5f59-cp313-cp313-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'", "torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.2/torchvision-0.25.0%2Brocm7.2.0.git82df5f59-cp313-cp313-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'",
"unsloth[audio-torch210]",
] ]
rocm711-torch2100 = [ rocm711-torch2100 = [
"unsloth[amd]", "unsloth[amd]",
@ -1362,7 +1296,6 @@ rocm711-torch2100 = [
"torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.1.1/torchvision-0.25.0%2Brocm7.1.1.git82df5f59-cp311-cp311-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'", "torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.1.1/torchvision-0.25.0%2Brocm7.1.1.git82df5f59-cp311-cp311-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'",
"torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.1.1/torchvision-0.25.0%2Brocm7.1.1.git82df5f59-cp312-cp312-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'", "torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.1.1/torchvision-0.25.0%2Brocm7.1.1.git82df5f59-cp312-cp312-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'",
"torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.1.1/torchvision-0.25.0%2Brocm7.1.1.git82df5f59-cp313-cp313-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'", "torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.1.1/torchvision-0.25.0%2Brocm7.1.1.git82df5f59-cp313-cp313-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'",
"unsloth[audio-torch210]",
] ]
[project.urls] [project.urls]

View file

@ -1,71 +0,0 @@
#!/bin/sh
# Build whisper.cpp's whisper-server for Studio's GGUF dictation engine.
#
# Installs into the managed Studio home so the backend's binary discovery
# (core/inference/stt_ggml_sidecar.py::find_whisper_server_binary) picks it up:
# <UNSLOTH_STUDIO_HOME>/whisper.cpp/build/bin/whisper-server (custom home)
# ~/.unsloth/whisper.cpp/build/bin/whisper-server (default)
#
# Usage:
# ./scripts/build_whisper_cpp.sh # build the pinned tag
# WHISPER_CPP_TAG=v1.9.0 ./scripts/build_whisper_cpp.sh
#
# Requires: git, cmake, a C/C++ toolchain (the same prerequisites as a
# llama.cpp source build). GPU backends are auto-detected by whisper.cpp's
# CMake (Metal on macOS; set GGML_CUDA=1 to force a CUDA build on Linux).
set -eu
WHISPER_CPP_SOURCE="${WHISPER_CPP_SOURCE:-https://github.com/ggml-org/whisper.cpp}"
WHISPER_CPP_TAG="${WHISPER_CPP_TAG:-v1.9.1}"
STUDIO_HOME="${UNSLOTH_STUDIO_HOME:-${STUDIO_HOME:-}}"
CUSTOM_STUDIO_HOME=false
if [ -n "$STUDIO_HOME" ]; then
CUSTOM_STUDIO_HOME=true
INSTALL_DIR="$STUDIO_HOME/whisper.cpp"
else
INSTALL_DIR="$HOME/.unsloth/whisper.cpp"
fi
command -v git >/dev/null 2>&1 || { echo "ERROR: git is required" >&2; exit 1; }
command -v cmake >/dev/null 2>&1 || { echo "ERROR: cmake is required" >&2; exit 1; }
# Same policy as studio/setup.sh's _assert_studio_owned_or_absent: never delete
# a directory under a custom Studio home unless Studio itself created it (the
# marker file below). Protects a user-managed whisper.cpp/src from rm -rf.
STUDIO_OWNED_MARKER=".unsloth-studio-owned"
if [ "$CUSTOM_STUDIO_HOME" = true ] && [ -e "$INSTALL_DIR" ] && \
[ ! -f "$INSTALL_DIR/$STUDIO_OWNED_MARKER" ]; then
echo "ERROR: $INSTALL_DIR already exists and is not marked as an Unsloth-owned whisper.cpp build tree." >&2
echo " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." >&2
exit 1
fi
echo "==> Building whisper.cpp ($WHISPER_CPP_TAG) into $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
: > "$INSTALL_DIR/$STUDIO_OWNED_MARKER"
if [ ! -d "$INSTALL_DIR/src/.git" ]; then
rm -rf "$INSTALL_DIR/src"
git clone --depth 1 --branch "$WHISPER_CPP_TAG" "$WHISPER_CPP_SOURCE" "$INSTALL_DIR/src"
else
git -C "$INSTALL_DIR/src" fetch --depth 1 origin "$WHISPER_CPP_TAG"
git -C "$INSTALL_DIR/src" checkout FETCH_HEAD
fi
CMAKE_FLAGS="-DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF"
if [ "${GGML_CUDA:-0}" = "1" ]; then
CMAKE_FLAGS="$CMAKE_FLAGS -DGGML_CUDA=ON"
fi
# shellcheck disable=SC2086
cmake -S "$INSTALL_DIR/src" -B "$INSTALL_DIR/src/build" $CMAKE_FLAGS
NCPU="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)"
cmake --build "$INSTALL_DIR/src/build" --config Release --target whisper-server -j"$NCPU"
mkdir -p "$INSTALL_DIR/build/bin"
cp "$INSTALL_DIR/src/build/bin/whisper-server" "$INSTALL_DIR/build/bin/whisper-server"
echo "==> Installed $INSTALL_DIR/build/bin/whisper-server"
"$INSTALL_DIR/build/bin/whisper-server" --help >/dev/null 2>&1 && echo "==> Binary runs OK"

View file

@ -3,14 +3,13 @@
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# #
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# Enable ROCm-on-WSL for AMD GPUs (Strix Halo/Point APUs AND discrete Radeon RX # Enable ROCm-on-WSL for AMD Strix Halo (Radeon 8060S / gfx1151)
# 7000/9000). Verified on gfx1151 (Radeon 8060S) and gfx1200 (Radeon RX 9060 XT).
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# install.sh routes the detected arch to the right ROCm wheels once a runtime exists; # install.sh already routes gfx1151 to the right ROCm wheels once a ROCm runtime
# what it does NOT do is install AMD's ROCm userspace + the WSL DXG bridge (librocdxg). # is present; what it does NOT do is install AMD's ROCm userspace + the WSL DXG
# This helper does that Linux-side prerequisite on Ubuntu 24.04 WSL2, invoked by # bridge. This helper automates that Linux-side prerequisite on Ubuntu 24.04
# install.sh when it sees an AMD GPU via /dev/dxg but no ROCm yet. Arch-agnostic: the # WSL2 and is invoked by install.sh when it sees a Strix Halo APU in WSL (via
# arch is auto-detected from rocminfo (override UNSLOTH_WSL_GFX=gfx1200). Idempotent. # /dev/dxg) but no ROCm runtime yet. Fully idempotent (re-run just re-verifies).
# #
# Manual, admin-gated Windows prerequisite: an AMD Adrenalin driver with # Manual, admin-gated Windows prerequisite: an AMD Adrenalin driver with
# production ROCDXG/WSL support (26.2.2+). install.ps1 offers to update it. Once # production ROCDXG/WSL support (26.2.2+). install.ps1 offers to update it. Once
@ -35,12 +34,10 @@ set -euo pipefail
# ── Tunables (override via env) ────────────────────────────────────────────── # ── Tunables (override via env) ──────────────────────────────────────────────
ROCM_VER="${UNSLOTH_WSL_ROCM_VER:-7.2.1}" # ROCm release to install ROCM_VER="${UNSLOTH_WSL_ROCM_VER:-7.2.1}" # ROCm release to install
# GPU arch: empty = auto-detect from rocminfo after install (override UNSLOTH_WSL_GFX=gfx1200). GFX="gfx1151"
# The ROCm + librocdxg setup is arch-agnostic; only verify + the smoke test need the arch.
GFX="${UNSLOTH_WSL_GFX:-}"
LIBROCDXG_REF="${UNSLOTH_LIBROCDXG_REF:-develop}" # ROCm/librocdxg git ref to build LIBROCDXG_REF="${UNSLOTH_LIBROCDXG_REF:-develop}" # ROCm/librocdxg git ref to build
# AMD's wheel index for the (optional) smoke test; resolved after arch detection. # AMD's gfx1151 wheel index (same one install.sh uses); only for the smoke test.
TORCH_INDEX="" TORCH_INDEX="${UNSLOTH_AMD_ROCM_MIRROR:-https://repo.amd.com/rocm/whl}/${GFX}/"
# Optional torch smoke test (throwaway venv). OFF by default: install.sh installs # Optional torch smoke test (throwaway venv). OFF by default: install.sh installs
# torch itself into the real venv right after, so a duplicate download is wasteful. # torch itself into the real venv right after, so a duplicate download is wasteful.
SMOKE_TEST="${UNSLOTH_WSL_SMOKE_TEST:-0}" SMOKE_TEST="${UNSLOTH_WSL_SMOKE_TEST:-0}"
@ -219,16 +216,16 @@ fi
echo "${ROCM_DIR}/lib" | $SUDO tee /etc/ld.so.conf.d/rocm.conf >/dev/null echo "${ROCM_DIR}/lib" | $SUDO tee /etc/ld.so.conf.d/rocm.conf >/dev/null
$SUDO ldconfig $SUDO ldconfig
# ── Step 4: persist environment (system-wide so Unsloth's worker inherits it) ── # ── Step 4: persist environment (system-wide so Studio's worker inherits it) ──
say "Persisting ROCm-on-WSL environment" say "Persisting ROCm-on-WSL environment"
_envfile="/etc/profile.d/unsloth-rocm-wsl.sh" _envfile="/etc/profile.d/unsloth-rocm-wsl.sh"
$SUDO tee "$_envfile" >/dev/null <<EOF $SUDO tee "$_envfile" >/dev/null <<EOF
# >>> Unsloth ROCm-on-WSL >>> # >>> Unsloth ROCm-on-WSL (gfx1151) >>>
export HSA_ENABLE_DXG_DETECTION=1 export HSA_ENABLE_DXG_DETECTION=1
export TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL=1 export TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL=1
export PATH="${ROCM_DIR}/bin:\${PATH}" export PATH="${ROCM_DIR}/bin:\${PATH}"
export LD_LIBRARY_PATH="${ROCM_DIR}/lib:\${LD_LIBRARY_PATH:-}" export LD_LIBRARY_PATH="${ROCM_DIR}/lib:\${LD_LIBRARY_PATH:-}"
# <<< Unsloth ROCm-on-WSL <<< # <<< Unsloth ROCm-on-WSL (gfx1151) <<<
EOF EOF
# also drop into ~/.bashrc for interactive shells # also drop into ~/.bashrc for interactive shells
if [ -n "${HOME:-}" ] && ! grep -q "Unsloth ROCm-on-WSL" "${HOME}/.bashrc" 2>/dev/null; then if [ -n "${HOME:-}" ] && ! grep -q "Unsloth ROCm-on-WSL" "${HOME}/.bashrc" 2>/dev/null; then
@ -240,50 +237,32 @@ export PATH="${ROCM_DIR}/bin:${PATH}"
export LD_LIBRARY_PATH="${ROCM_DIR}/lib:${LD_LIBRARY_PATH:-}" export LD_LIBRARY_PATH="${ROCM_DIR}/lib:${LD_LIBRARY_PATH:-}"
# ── Step 5: verify the runtime enumerates the GPU ──────────────────────────── # ── Step 5: verify the runtime enumerates the GPU ────────────────────────────
say "Verifying rocminfo enumerates the GPU over DXG" say "Verifying rocminfo sees ${GFX}"
# Capture rocminfo into a var BEFORE grepping: piping into `grep -q` SIGPIPEs # Capture rocminfo into a var BEFORE grepping: piping into `grep -q` SIGPIPEs
# rocminfo on first match, which under `set -o pipefail` turns a successful match # rocminfo on first match, which under `set -o pipefail` turns a successful match
# into a pipeline failure. # into a pipeline failure. Match the gfx1151 ISA "Name:" agent exactly (not a
# broad gfx1[0-9]) so a generic fallback ISA or unrelated RDNA GPU can't pass.
_rocminfo_out="$(rocminfo 2>/dev/null || true)" _rocminfo_out="$(rocminfo 2>/dev/null || true)"
# GPU agents advertise an ISA "Name: gfxNNNN". Match gfx[1-9] (excludes gfx000, the CPU if ! printf '%s\n' "$_rocminfo_out" | grep -qE "Name:[[:space:]]*${GFX}([^0-9]|$)"; then
# agent), drop the "gfx*-generic" fallback ISA, and take the first real GPU arch.
_detected_gfx="$(printf '%s\n' "$_rocminfo_out" | grep -E 'Name:[[:space:]]*gfx[1-9]' | grep -v 'generic' | grep -oE 'gfx[1-9][0-9a-z]*' | head -1 || true)"
if [ -z "$_detected_gfx" ]; then
printf '%s\n' "$_rocminfo_out" | head -25 >&2 || true printf '%s\n' "$_rocminfo_out" | head -25 >&2 || true
die "rocminfo did not enumerate any GPU agent. Most common cause: the Windows AMD driver predates production ROCDXG -- update Adrenalin (install.ps1 offers this), reboot, and re-run." die "rocminfo did not enumerate a ${GFX} GPU agent. Most common cause: the Windows AMD driver predates production ROCDXG -- update Adrenalin (install.ps1 offers this), reboot, and re-run."
fi fi
# Honour a caller-pinned arch (sanity-check via a consuming grep, not grep -q: under
# pipefail -q would SIGPIPE printf on large output and misreport the arch); else adopt.
if [ -n "$GFX" ] && ! printf '%s\n' "$_rocminfo_out" | grep -E "Name:[[:space:]]*${GFX}([^0-9]|$)" >/dev/null; then
die "rocminfo enumerated '${_detected_gfx}' but not the requested UNSLOTH_WSL_GFX='${GFX}'."
fi
GFX="${GFX:-$_detected_gfx}"
# Display-only summary: best-effort (|| true) so head's early pipe-close under # Display-only summary: best-effort (|| true) so head's early pipe-close under
# `set -o pipefail` can't fail the bootstrap after verification already passed. # `set -o pipefail` can't fail the bootstrap after verification already passed.
printf '%s\n' "$_rocminfo_out" | grep -E 'Marketing Name|Device Type|Compute Unit' | grep -iE "Radeon|GPU|Compute" | head -3 || true printf '%s\n' "$_rocminfo_out" | grep -E 'Marketing Name|Device Type|Compute Unit' | grep -iE "Radeon|GPU|Compute" | head -3 || true
note "ROCm-on-WSL runtime is live for ${GFX}." note "ROCm-on-WSL runtime is live for ${GFX}."
# ── Step 6 (optional): torch smoke test from AMD's per-arch wheel index ─────── # ── Step 6 (optional): torch smoke test from the gfx1151 index ───────────────
if [ "$SMOKE_TEST" = "1" ]; then if [ "$SMOKE_TEST" = "1" ]; then
say "Smoke-testing PyTorch on ${GFX} (throwaway venv)" say "Smoke-testing PyTorch on ${GFX} (throwaway venv)"
# Map the detected arch to AMD's repo.amd.com wheel family index.
case "$GFX" in
gfx1200|gfx1201) _fam="gfx120X-all" ;;
gfx1100|gfx1101|gfx1102|gfx1103) _fam="gfx110X-all" ;;
*) _fam="$GFX" ;; # gfx1150/gfx1151/gfx90a: own index
esac
TORCH_INDEX="${UNSLOTH_AMD_ROCM_MIRROR:-https://repo.amd.com/rocm/whl}/${_fam}/"
_venv="${HOME}/.unsloth/rocm-smoketest" _venv="${HOME}/.unsloth/rocm-smoketest"
rm -rf "$_venv"; python3 -m venv "$_venv" rm -rf "$_venv"; python3 -m venv "$_venv"
"$_venv/bin/pip" install --quiet --upgrade pip "$_venv/bin/pip" install --quiet --upgrade pip
# AMD arch index is primary (torch + triton); PyPI only an extra for pure-py # gfx1151 index is primary (torch + triton); PyPI only an extra for pure-py
# deps. The constraint keeps pip on the ROCm wheel, not a newer PyPI CUDA torch. # deps. The constraint keeps pip on the ROCm wheel, not a newer PyPI CUDA torch.
"$_venv/bin/pip" install --index-url "$TORCH_INDEX" \ "$_venv/bin/pip" install --index-url "$TORCH_INDEX" \
--extra-index-url https://pypi.org/simple "$TORCH_CONSTRAINT" || \ --extra-index-url https://pypi.org/simple "$TORCH_CONSTRAINT" || \
die "torch install from ${TORCH_INDEX} failed." die "torch install from ${TORCH_INDEX} failed."
# WSL: torch's bundled ROCr must load the DXG bridge -- drop librocdxg into torch/lib.
_tlib="$("$_venv/bin/python" -c 'import torch,os;print(os.path.join(os.path.dirname(torch.__file__),"lib"))' 2>/dev/null || true)"
[ -d "$_tlib" ] && cp -f "${ROCM_DIR}"/lib/librocdxg.so* "$_tlib"/ 2>/dev/null || true
"$_venv/bin/python" - <<'PY' "$_venv/bin/python" - <<'PY'
import torch import torch
ok = torch.cuda.is_available() ok = torch.cuda.is_available()

View file

@ -52,14 +52,14 @@ def _normalise_on(on_field):
def _load_workflow(path: Path): def _load_workflow(path: Path):
try: try:
return yaml.safe_load(path.read_text(encoding = "utf-8")) return yaml.safe_load(path.read_text())
except Exception as exc: except Exception as exc:
print(f"ERROR: failed to parse {path}: {exc}", file = sys.stderr) print(f"ERROR: failed to parse {path}: {exc}", file = sys.stderr)
sys.exit(2) sys.exit(2)
def _extract_cache_keys(path: Path) -> list[str]: def _extract_cache_keys(path: Path) -> list[str]:
text = path.read_text(encoding = "utf-8") text = path.read_text()
keys: list[str] = [] keys: list[str] = []
for m in re.finditer(r"(?:^|\n)\s*key:\s*([^\n]+)", text): for m in re.finditer(r"(?:^|\n)\s*key:\s*([^\n]+)", text):
keys.append(m.group(1).strip()) keys.append(m.group(1).strip())
@ -104,7 +104,7 @@ def main() -> int:
for t in RESTRICTED_TRIGGERS: for t in RESTRICTED_TRIGGERS:
if t in triggers: if t in triggers:
text = path.read_text(encoding = "utf-8") text = path.read_text()
if "lint:workflow_triggers-allow-workflow_run" not in text: if "lint:workflow_triggers-allow-workflow_run" not in text:
findings.append( findings.append(
f"{path.name}: RESTRICTED trigger '{t}' requires an " f"{path.name}: RESTRICTED trigger '{t}' requires an "

View file

@ -2,7 +2,7 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Lockfile supply-chain audit for the Unsloth frontend and Tauri shell. """Lockfile supply-chain audit for the Studio frontend and Tauri shell.
Runs BEFORE `npm ci` / `cargo fetch` in CI. Refuses to proceed when a Runs BEFORE `npm ci` / `cargo fetch` in CI. Refuses to proceed when a
lockfile contains patterns indicating supply-chain injection (npm lockfile contains patterns indicating supply-chain injection (npm
@ -294,7 +294,7 @@ CARGO_REGISTRY_SOURCE = "registry+https://github.com/rust-lang/crates.io-index"
# Cargo non-registry source allowlist: `(crate_name, exact_source_string)`. # Cargo non-registry source allowlist: `(crate_name, exact_source_string)`.
# Both must match verbatim; bumping the pinned SHA forces a re-review. # Both must match verbatim; bumping the pinned SHA forces a re-review.
# Unsloth's Tauri shell pulls `fix-path-env` from git because it is not # Studio's Tauri shell pulls `fix-path-env` from git because it is not
# published to crates.io; commit c4c45d5 was reviewed when it landed. # published to crates.io; commit c4c45d5 was reviewed when it landed.
CARGO_SOURCE_ALLOWLIST: tuple[tuple[str, str], ...] = ( CARGO_SOURCE_ALLOWLIST: tuple[tuple[str, str], ...] = (
( (

View file

@ -95,8 +95,8 @@ COLAB_ORACLE_BASE_URL = "https://raw.githubusercontent.com/googlecolab/backend-i
# Source: pytorch/torchcodec compatibility matrix on its README. # Source: pytorch/torchcodec compatibility matrix on its README.
TORCH_TORCHCODEC: dict[str, set[str]] = { TORCH_TORCHCODEC: dict[str, set[str]] = {
"2.10": {"0.10"}, "2.10": {"0.10"},
"2.9": {"0.8", "0.9"}, "2.9": {"0.7", "0.8", "0.9"},
"2.8": {"0.6", "0.7"}, "2.8": {"0.6"},
"2.7": {"0.3", "0.4", "0.5"}, "2.7": {"0.3", "0.4", "0.5"},
"2.6": {"0.2", "0.3"}, "2.6": {"0.2", "0.3"},
"2.5": {"0.1", "0.2"}, "2.5": {"0.1", "0.2"},

View file

@ -1,377 +0,0 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Measure where Unsloth Studio's startup time goes, per platform.
Nothing measured this before: the backend logs "lifespan startup completed in X ms"
but no test or CI job asserted a budget, and studio_test_kit discards the elapsed
time of its /healthz poll. A first local run (Linux, warm cache, fast server CPU)
found `import main` alone costs 6.6s before the server can bind, dominated by eager
module-level imports pulled in by the `routes` package:
torch 1930 ms self
unsloth_zoo 914 ms self
routes 779 ms self
transformers 524 ms self
Phases measured:
import `python -X importtime -c "import main"`, top cumulative + per-package self
spawn process start -> first byte on stdout
healthz process start -> /api/health (or /healthz) answers 200
lifespan the backend's own "lifespan startup completed in X ms" log line
Usage:
python scripts/profile_startup.py --repeats 3 --json out.json
python scripts/profile_startup.py --import-only # no server, no port needed
Exit code is 0 unless --max-healthz-seconds is given and exceeded.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import platform
import re
import shutil
import socket
import statistics
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
BACKEND = REPO_ROOT / "studio" / "backend"
_IMPORTTIME_RE = re.compile(r"import time:\s+(\d+)\s+\|\s+(\d+)\s+\|(\s*)(\S.*)")
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return int(s.getsockname()[1])
def profile_imports(python: str, top: int = 15) -> dict:
"""Cumulative and self import cost for the backend's module graph.
Run in a subprocess with -X importtime: the numbers are only meaningful for a
cold interpreter, and importing in-process would measure a warm sys.modules.
"""
proc = subprocess.run(
[python, "-X", "importtime", "-c", "import sys; sys.path.insert(0, '.'); import main"],
cwd = BACKEND,
capture_output = True,
text = True,
timeout = 900,
)
rows = []
for line in proc.stderr.splitlines():
m = _IMPORTTIME_RE.match(line)
if m:
rows.append((int(m.group(1)), int(m.group(2)), m.group(4).strip()))
if not rows:
return {"ok": False, "error": (proc.stderr or proc.stdout)[-2000:]}
if proc.returncode != 0:
# Rows survive up to the failure, so any total from a partial graph is wrong.
return {
"ok": False,
"error": (proc.stderr or proc.stdout)[-2000:],
"partial_rows": len(rows),
}
by_cum = sorted(rows, key = lambda r: -r[1])
# Total comes from the `main` row, not by_cum[0]: -X importtime also prints the
# interpreter's own startup graph (`site`), which can outrank a trivial main.
main_row = next((r for r in reversed(rows) if r[2] == "main"), None)
if main_row is None:
return {
"ok": False,
"error": "no `import main` row in -X importtime output\n"
+ (proc.stderr or proc.stdout)[-2000:],
}
self_by_pkg: dict[str, int] = {}
for self_us, _cum, name in rows:
pkg = name.split(".")[0]
self_by_pkg[pkg] = self_by_pkg.get(pkg, 0) + self_us
return {
"ok": True,
"total_seconds": round(main_row[1] / 1e6, 3),
"top_cumulative": [
{"module": n, "seconds": round(c / 1e6, 3)} for _s, c, n in by_cum[:top]
],
"self_by_package_ms": {
k: round(v / 1000) for k, v in sorted(self_by_pkg.items(), key = lambda x: -x[1])[:top]
},
}
def _terminate_tree(proc: subprocess.Popen) -> None:
"""Stop the server AND its children, which on Windows are a separate process.
CI profiles `Scripts/unsloth.exe`, a distlib launcher stub that CreateProcess's
the venv python and waits, so terminate() reaps the stub only: the real backend
keeps the inherited stdout handle, the reader thread never sees EOF, and
--repeats strands one server per iteration on the shared UNSLOTH_STUDIO_HOME.
taskkill /T walks the tree, as unsloth_cli/commands/start.py already does.
"""
if proc.poll() is not None:
return
if os.name == "nt":
try:
killed = subprocess.run(
["taskkill", "/PID", str(proc.pid), "/T", "/F"],
capture_output = True,
timeout = 30,
check = False,
)
if killed.returncode == 0:
return
except Exception:
# taskkill missing or timed out; fall through so the stub still dies.
pass
# check=False: a nonzero taskkill does not raise, so fall through as well.
proc.terminate()
def profile_launch(
bin_path: str,
port: int,
timeout_s: int = 300,
) -> dict:
"""Spawn the backend the way the desktop app does and time it to first 200."""
log_lines: list[str] = []
first_byte: list[float] = []
t0 = time.perf_counter()
proc = subprocess.Popen(
[bin_path, "studio", "--api-only", "-H", "127.0.0.1", "-p", str(port)],
cwd = REPO_ROOT,
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
bufsize = 1,
)
def _drain() -> None:
# Runs alongside the health polling: the first read timestamps the spawn
# phase, and an undrained pipe blocks the backend before it binds.
for line in proc.stdout:
if not first_byte:
first_byte.append(time.perf_counter() - t0)
log_lines.append(line.rstrip("\n"))
reader = threading.Thread(target = _drain, daemon = True)
reader.start()
t_healthz = None
deadline = t0 + timeout_s
try:
while time.perf_counter() < deadline:
if proc.poll() is not None:
break
if t_healthz is None:
for url in (
f"http://127.0.0.1:{port}/api/health",
f"http://127.0.0.1:{port}/healthz",
):
try:
with urllib.request.urlopen(url, timeout = 2) as r:
if r.status == 200:
t_healthz = time.perf_counter() - t0
break
except (urllib.error.URLError, OSError, TimeoutError):
pass
if t_healthz is not None:
break
time.sleep(0.25)
finally:
_terminate_tree(proc)
try:
# Safe: the reader drains the pipe, so the child cannot block on write().
proc.wait(timeout = 30)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
reader.join(timeout = 10)
t_first_byte = first_byte[0] if first_byte else None
lifespan_ms = None
for line in log_lines:
m = re.search(r"lifespan startup completed in ([\d.]+)ms", line)
if m:
lifespan_ms = float(m.group(1))
return {
"spawn_seconds": round(t_first_byte, 3) if t_first_byte is not None else None,
"healthz_seconds": round(t_healthz, 3) if t_healthz is not None else None,
"lifespan_ms": lifespan_ms,
"reached_healthz": t_healthz is not None,
"log_tail": log_lines[-25:],
}
def python_version_of(python: str) -> str:
"""Version of the interpreter that runs the imports, not the one running us.
--python points at the installed Studio venv while this script runs under the
runner's system python, so platform.python_version() would label it wrong.
"""
if python == sys.executable:
return platform.python_version()
try:
proc = subprocess.run(
[python, "-c", "import platform; print(platform.python_version())"],
capture_output = True,
text = True,
timeout = 60,
)
if proc.returncode == 0 and proc.stdout.strip():
return proc.stdout.strip()
except (OSError, subprocess.SubprocessError):
pass
return "unknown"
def find_bin() -> str | None:
home = os.environ.get("UNSLOTH_STUDIO_HOME") or str(Path.home() / ".unsloth" / "studio")
names = ["unsloth.exe", "unsloth"] if platform.system() == "Windows" else ["unsloth"]
subdirs = ["unsloth_studio/Scripts", "unsloth_studio/bin", "bin", "Scripts"]
for sd in subdirs:
for n in names:
p = Path(home) / sd / n
if p.exists():
return str(p)
return shutil.which("unsloth")
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(
description = __doc__, formatter_class = argparse.RawDescriptionHelpFormatter
)
ap.add_argument(
"--repeats",
type = int,
default = 1,
help = "launch repeats; the median is reported (imports are measured once)",
)
ap.add_argument(
"--python",
default = sys.executable,
help = "interpreter used for the import profile (default: this one)",
)
ap.add_argument("--bin", help = "path to the unsloth CLI (default: autodetect)")
ap.add_argument(
"--import-only",
action = "store_true",
help = "skip the server phases (no install needed beyond the deps)",
)
ap.add_argument(
"--max-healthz-seconds",
type = float,
help = "fail if the median time to a healthy port exceeds this",
)
ap.add_argument("--json", help = "write the full report here")
a = ap.parse_args(argv)
# range(0) launches nothing, leaving the budget check with nothing to fail on.
if a.repeats < 1:
ap.error("--repeats must be at least 1")
# Same reason: --import-only never launches anything.
if a.import_only and a.max_healthz_seconds is not None:
ap.error("--max-healthz-seconds cannot be combined with --import-only")
# nan and inf parse fine as floats but `med > budget` is then always False,
# so the gate would report success without ever bounding anything.
if a.max_healthz_seconds is not None and not math.isfinite(a.max_healthz_seconds):
ap.error("--max-healthz-seconds must be a finite number")
report: dict = {
"platform": platform.system().lower(),
"machine": platform.machine(),
"python": python_version_of(a.python),
"cpu_count": os.cpu_count(),
}
print("== import graph ==")
report["imports"] = profile_imports(a.python)
imp = report["imports"]
if imp.get("ok"):
print(f" import main: {imp['total_seconds']}s")
for row in imp["top_cumulative"][:8]:
print(f" {row['seconds']:7.3f}s {row['module']}")
print(" self time by package (ms):")
for k, v in list(imp["self_by_package_ms"].items())[:8]:
print(f" {v:8} ms {k}")
else:
print(f" FAILED: {imp.get('error', '')[:400]}")
if not a.import_only:
bin_path = a.bin or find_bin()
if not bin_path:
print(
"== launch == skipped: no unsloth CLI found "
"(set UNSLOTH_STUDIO_HOME or pass --bin)"
)
report["launch"] = {"skipped": "no unsloth CLI found"}
else:
print(f"== launch == {bin_path}")
runs = []
for i in range(a.repeats):
r = profile_launch(bin_path, _free_port())
runs.append(r)
print(
f" run {i + 1}: healthz={r['healthz_seconds']}s "
f"lifespan={r['lifespan_ms']}ms reached={r['reached_healthz']}"
)
got = [r["healthz_seconds"] for r in runs if r["healthz_seconds"] is not None]
report["launch"] = {
"runs": runs,
"failed_runs": sum(1 for r in runs if not r["reached_healthz"]),
"healthz_median_seconds": round(statistics.median(got), 3) if got else None,
"healthz_max_seconds": round(max(got), 3) if got else None,
}
if got:
print(
f" median time to healthy port: {report['launch']['healthz_median_seconds']}s"
)
if a.json:
Path(a.json).write_text(json.dumps(report, indent = 2), encoding = "utf-8")
print(f"\nwrote {a.json}")
if a.max_healthz_seconds is not None:
launch = report.get("launch") or {}
med = launch.get("healthz_median_seconds")
failed = launch.get("failed_runs") or 0
if failed:
# Failed launches fail the budget; dropping them would keep only the fast ones.
print(
f"::error::startup regression: {failed} of {len(launch.get('runs') or [])} "
f"launches never became healthy within the timeout"
)
return 1
if med is None:
# Nothing measured: exiting 0 would pass a requested budget without a
# single health request, so fail closed.
print(
"::error::startup regression: no healthz measurement, so the "
f"{a.max_healthz_seconds}s budget was never checked "
f"({launch.get('skipped') or 'launch phase produced no runs'})"
)
return 1
elif med > a.max_healthz_seconds:
print(
f"::error::startup regression: {med}s median to a healthy port "
f"exceeds the {a.max_healthz_seconds}s budget"
)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View file

@ -62,7 +62,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
# Hard caps (deliberately conservative; npm tarballs in this repo are # Hard caps (deliberately conservative; npm tarballs in this repo are
# all well under these limits, so a packaging spike is noticeable). # all well under these limits, so a packaging spike is noticeable).
# ───────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────
# Caps calibrated against the real Unsloth frontend transitive closure: # Caps calibrated against the real Studio frontend transitive closure:
# - typescript.js is 9.1 MB (TS compiler bundled into one file) # - typescript.js is 9.1 MB (TS compiler bundled into one file)
# - mermaid 11.x dist/mermaid.js.map is ~12 MB (sourcemap) # - mermaid 11.x dist/mermaid.js.map is ~12 MB (sourcemap)
# - lightningcss-linux-x64-{gnu,musl}.node is 10 MB # - lightningcss-linux-x64-{gnu,musl}.node is 10 MB

File diff suppressed because one or more lines are too long

View file

@ -2,7 +2,7 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Stamp and verify display-only Unsloth release metadata for builds.""" """Stamp and verify display-only Studio release metadata for builds."""
from __future__ import annotations from __future__ import annotations
@ -50,7 +50,7 @@ MAX_VERSION_LENGTH = 64
PLACEHOLDER = """# SPDX-License-Identifier: AGPL-3.0-only PLACEHOLDER = """# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
\"\"\"Build-stamped Unsloth release metadata. \"\"\"Build-stamped Studio release metadata.
Release builds may rewrite this module in the build workspace before creating Release builds may rewrite this module in the build workspace before creating
Python artifacts. Keep the committed value neutral so source checkouts do not Python artifacts. Keep the committed value neutral so source checkouts do not
@ -145,7 +145,7 @@ def build_info_source(version: str | None) -> str:
return f'''# SPDX-License-Identifier: AGPL-3.0-only return f'''# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Build-stamped Unsloth release metadata.""" """Build-stamped Studio release metadata."""
STUDIO_RELEASE_VERSION = {literal} STUDIO_RELEASE_VERSION = {literal}
''' '''
@ -168,7 +168,7 @@ def stamp(require_release: bool) -> int:
version, source = resolve_version() version, source = resolve_version()
if version is not None and not is_valid_version(version): if version is not None and not is_valid_version(version):
print( print(
f"Invalid Unsloth release version from {source}: {version!r}", f"Invalid Studio release version from {source}: {version!r}",
file = sys.stderr, file = sys.stderr,
) )
return 2 return 2
@ -196,9 +196,9 @@ def stamp(require_release: bool) -> int:
if version is None: if version is None:
if require_release: if require_release:
print( print(
"No Unsloth release version available. Set " "No Studio release version available. Set "
"UNSLOTH_STUDIO_RELEASE_VERSION, build from a GitHub tag, " "UNSLOTH_STUDIO_RELEASE_VERSION, build from a GitHub tag, "
"or run from an exact local Unsloth release tag.", "or run from an exact local Studio release tag.",
file = sys.stderr, file = sys.stderr,
) )
return 2 return 2
@ -207,7 +207,7 @@ def stamp(require_release: bool) -> int:
return 0 return 0
_atomic_write_text(BUILD_INFO_PATH, build_info_source(version), encoding = "utf-8") _atomic_write_text(BUILD_INFO_PATH, build_info_source(version), encoding = "utf-8")
print(f"Stamping Unsloth release version {version} from {source}", file = sys.stderr) print(f"Stamping Studio release version {version} from {source}", file = sys.stderr)
print(version) print(version)
return 0 return 0
@ -233,7 +233,7 @@ def _read_sdist_member(path: Path) -> str | None:
def verify_dist(expected: str, dist_dir: Path) -> int: def verify_dist(expected: str, dist_dir: Path) -> int:
if not is_valid_version(expected): if not is_valid_version(expected):
print(f"Invalid expected Unsloth release version: {expected!r}", file = sys.stderr) print(f"Invalid expected Studio release version: {expected!r}", file = sys.stderr)
return 2 return 2
artifacts = list(dist_dir.glob("*.whl")) + list(dist_dir.glob("*.tar.gz")) artifacts = list(dist_dir.glob("*.whl")) + list(dist_dir.glob("*.tar.gz"))
@ -251,14 +251,14 @@ def verify_dist(expected: str, dist_dir: Path) -> int:
if content is None: if content is None:
failures.append(f"{artifact.name}: missing {BUILD_INFO_SUFFIX}") failures.append(f"{artifact.name}: missing {BUILD_INFO_SUFFIX}")
elif expected_line not in content: elif expected_line not in content:
failures.append(f"{artifact.name}: Unsloth release version mismatch") failures.append(f"{artifact.name}: Studio release version mismatch")
if failures: if failures:
for failure in failures: for failure in failures:
print(failure, file = sys.stderr) print(failure, file = sys.stderr)
return 2 return 2
print(f"Verified Unsloth release version {expected} in {len(artifacts)} artifact(s)") print(f"Verified Studio release version {expected} in {len(artifacts)} artifact(s)")
return 0 return 0

View file

@ -83,7 +83,7 @@ function Uninstall-UnslothStudio {
} }
} }
# A path is an Unsloth-owned root iff one of install.ps1's sentinels exists: # A path is a Studio-owned root iff one of install.ps1's sentinels exists:
# <root>\share\studio.conf, <root>\unsloth_studio\.unsloth-studio-owned, # <root>\share\studio.conf, <root>\unsloth_studio\.unsloth-studio-owned,
# or <root>\bin\unsloth.exe. # or <root>\bin\unsloth.exe.
function _IsStudioRoot { function _IsStudioRoot {
@ -164,7 +164,7 @@ function Uninstall-UnslothStudio {
return $p return $p
} }
# Discover non-default Unsloth roots from env vars + studio.conf files. # Discover non-default Studio roots from env vars + studio.conf files.
# Mirrors install.ps1's precedence: UNSLOTH_STUDIO_HOME wins, STUDIO_HOME # Mirrors install.ps1's precedence: UNSLOTH_STUDIO_HOME wins, STUDIO_HOME
# is ignored when both are set, so uninstalling install A doesn't also # is ignored when both are set, so uninstalling install A doesn't also
# delete install B if the user has a stale STUDIO_HOME pointing at B. # delete install B if the user has a stale STUDIO_HOME pointing at B.
@ -207,7 +207,7 @@ function Uninstall-UnslothStudio {
# Return $true iff the PID's image path lives under one of $KnownRoots. # Return $true iff the PID's image path lives under one of $KnownRoots.
# Prevents killing an unrelated process that happens to listen on a stale # Prevents killing an unrelated process that happens to listen on a stale
# Unsloth port. # Studio port.
function _PidUnderKnownRoot { function _PidUnderKnownRoot {
param([int]$Pid_, [string[]]$KnownRoots) param([int]$Pid_, [string[]]$KnownRoots)
if (-not $KnownRoots -or $KnownRoots.Count -eq 0) { return $false } if (-not $KnownRoots -or $KnownRoots.Count -eq 0) { return $false }
@ -223,8 +223,8 @@ function Uninstall-UnslothStudio {
return $false return $false
} }
# Stop an Unsloth backend whose port is recorded in <DataDir>\studio.port. # Stop a Studio backend whose port is recorded in <DataDir>\studio.port.
# Only kills if the listening PID's exe path is under a known Unsloth root. # Only kills if the listening PID's exe path is under a known Studio root.
function _StopByPortFile { function _StopByPortFile {
param([string]$PortFile, [string[]]$KnownRoots) param([string]$PortFile, [string[]]$KnownRoots)
if (-not (Test-Path -LiteralPath $PortFile -PathType Leaf)) { return } if (-not (Test-Path -LiteralPath $PortFile -PathType Leaf)) { return }
@ -372,7 +372,7 @@ function Uninstall-UnslothStudio {
continue continue
} }
if (-not (_IsStudioRoot $r)) { if (-not (_IsStudioRoot $r)) {
_Substep "refusing to remove non-Unsloth path: $r" "Yellow" _Substep "refusing to remove non-Studio path: $r" "Yellow"
continue continue
} }
_RemovePath $r _RemovePath $r
@ -436,7 +436,7 @@ function Uninstall-UnslothStudio {
$entries = $rawPath -split ';' $entries = $rawPath -split ';'
$kept = New-Object System.Collections.ArrayList $kept = New-Object System.Collections.ArrayList
$removedAny = $false $removedAny = $false
# Only remove PATH entries that live inside an Unsloth root we # Only remove PATH entries that live inside a Studio root we
# actually own (default or env-mode). A literal substring # actually own (default or env-mode). A literal substring
# match on `unsloth_studio` would clobber unrelated user # match on `unsloth_studio` would clobber unrelated user
# virtualenvs that happen to share the name. # virtualenvs that happen to share the name.

View file

@ -12,7 +12,7 @@
set -e set -e
# Stop an Unsloth server via its PID file (written by install.sh's _spawn_terminal). # Stop a Studio server via its PID file (written by install.sh's _spawn_terminal).
_kill_pid_file() { _kill_pid_file() {
_pid_file="$1" _pid_file="$1"
[ -f "$_pid_file" ] || return 0 [ -f "$_pid_file" ] || return 0
@ -47,7 +47,7 @@ _pkill_studio() {
command -v pkill >/dev/null 2>&1 || return 0 command -v pkill >/dev/null 2>&1 || return 0
# Scope fallback patterns to the install roots we are removing so a # Scope fallback patterns to the install roots we are removing so a
# different Unsloth install (different UNSLOTH_STUDIO_HOME) is not touched. # different Studio install (different UNSLOTH_STUDIO_HOME) is not touched.
_kill_roots="$HOME/.unsloth/studio" _kill_roots="$HOME/.unsloth/studio"
_roots_from_conf=$(_custom_studio_roots 2>/dev/null || true) _roots_from_conf=$(_custom_studio_roots 2>/dev/null || true)
[ -n "$_roots_from_conf" ] && _kill_roots="$_kill_roots [ -n "$_roots_from_conf" ] && _kill_roots="$_kill_roots
@ -89,7 +89,7 @@ _remove_path() {
fi fi
} }
# Accept as Unsloth root only if Unsloth sentinels exist (matches install.sh's # Accept as Studio root only if Studio sentinels exist (matches install.sh's
# env-mode ownership guard at install.sh:1358-1361). A bare unsloth_studio/ # env-mode ownership guard at install.sh:1358-1361). A bare unsloth_studio/
# directory is NOT enough -- require the install-time owner marker so a user # directory is NOT enough -- require the install-time owner marker so a user
# directory that happens to contain a folder named "unsloth_studio" is safe. # directory that happens to contain a folder named "unsloth_studio" is safe.
@ -175,8 +175,8 @@ _custom_studio_roots() {
_from_conf "$HOME/.local/share/unsloth/studio.conf" _from_conf "$HOME/.local/share/unsloth/studio.conf"
} }
# Remove $HOME/.local/bin/unsloth only if it's an Unsloth-managed symlink. # Remove $HOME/.local/bin/unsloth only if it's a Studio-managed symlink.
# Unsloth's install.sh writes this as a symlink into the studio venv # Studio's install.sh writes this as a symlink into the studio venv
# (install.sh: `ln -sfn "$VENV_DIR/bin/unsloth" "$_shim_path"`). A # (install.sh: `ln -sfn "$VENV_DIR/bin/unsloth" "$_shim_path"`). A
# pip-installed `unsloth` CLI is a regular file — leave it alone to avoid # pip-installed `unsloth` CLI is a regular file — leave it alone to avoid
# wiping an unrelated install. # wiping an unrelated install.
@ -206,7 +206,7 @@ _custom_studio_roots | while IFS= read -r _custom_root; do
continue continue
fi fi
if ! _is_studio_root "$_custom_root"; then if ! _is_studio_root "$_custom_root"; then
echo " refusing to remove non-Unsloth path: $_custom_root" >&2 echo " refusing to remove non-Studio path: $_custom_root" >&2
continue continue
fi fi
_remove_path "$_custom_root" _remove_path "$_custom_root"
@ -234,7 +234,7 @@ _remove_path "$HOME/.unsloth/rocm-smoketest"
# Drop ~/.unsloth only if now empty (rmdir refuses non-empty, so user content is kept). # Drop ~/.unsloth only if now empty (rmdir refuses non-empty, so user content is kept).
rmdir "$HOME/.unsloth" 2>/dev/null || true rmdir "$HOME/.unsloth" 2>/dev/null || true
_remove_path "$HOME/.local/share/unsloth" _remove_path "$HOME/.local/share/unsloth"
# CLI shim: only the symlink Unsloth created, never a pip-installed file. # CLI shim: only the symlink Studio created, never a pip-installed file.
_remove_cli_shim _remove_cli_shim
echo "Removing desktop shortcut and launcher lock..." echo "Removing desktop shortcut and launcher lock..."

View file

@ -564,12 +564,6 @@ def compare(before_src: str, after_src: str, path: str) -> list[tuple[str, str]]
for n, tids in b["module_import_targets"].items(): for n, tids in b["module_import_targets"].items():
if tids & after_used: if tids & after_used:
continue # resolved -> fine continue # resolved -> fine
# `from __future__ import ...` is a compiler directive, not a runtime
# binding: the name (`annotations`, ...) is never loaded, so it can never
# "resolve" to a use. Skip it so a legitimately-added future import
# (e.g. `annotations` for lazy PEP 604 `X | None` on py3.9) is not flagged.
if all(t.startswith("from:__future__:") for t in tids):
continue
newly_added = bool(tids - before_module_targets) newly_added = bool(tids - before_module_targets)
was_used_before = bool(tids & before_used) was_used_before = bool(tids & before_used)
if newly_added or was_used_before: if newly_added or was_used_before:
@ -594,23 +588,9 @@ def compare(before_src: str, after_src: str, path: str) -> list[tuple[str, str]]
# package object and only *add* submodule attributes (e.g. adding # package object and only *add* submodule attributes (e.g. adding
# `import urllib.error` next to `import urllib.request`). Nothing the name # `import urllib.error` next to `import urllib.request`). Nothing the name
# resolved to before is lost, so no reference is re-pointed -- skip it. # resolved to before is lost, so no reference is re-pointed -- skip it.
#
# A deliberate *relocation* is also benign and must not block: when a name
# keeps its spelling but its import source is moved A -> B in THIS diff (the
# old `from A import x` is removed at module level and a new `from B import x`
# is added), the swap is intentional, not a silent re-point to a pre-existing
# different object. This mirrors the relocation tolerance already applied to
# TARGET-MISSING. The dangerous case -- the name now resolving to a target
# that already existed before (shadow/clash) -- is NOT exempted.
removed_module_targets = before_module_targets - after_module_targets
for key, tafter in b["target_by_use"].items(): for key, tafter in b["target_by_use"].items():
tbefore = a["target_by_use"].get(key) tbefore = a["target_by_use"].get(key)
if tbefore and tbefore != tafter and (tbefore - tafter): if tbefore and tbefore != tafter and (tbefore - tafter):
lost = tbefore - tafter
gained = tafter - tbefore
relocated = lost <= removed_module_targets and gained <= added_module_targets
if relocated:
continue
findings.append( findings.append(
( (
"BLOCKER", "BLOCKER",

View file

@ -1,34 +0,0 @@
# Unsloth Studio MCP server
Unsloth can expose a local MCP server so an MCP client can inspect models and
GPU state, validate recipes, start or stop training, inspect recipe output, and
export a loaded model.
The server is disabled by default. Enable it for a local Unsloth process with:
```bash
UNSLOTH_STUDIO_ENABLE_MCP=1 \
UNSLOTH_STUDIO_MCP_TOKEN='use-a-local-secret' \
unsloth studio
```
The endpoint is `http://127.0.0.1:8888/mcp/` when Unsloth uses its default port
(a request to `/mcp` redirects to the canonical `/mcp/`). Use the actual Unsloth
port when it is configured differently.
The high-impact tools are:
- `studio_status` and `list_local_models` for discovery
- `get_training_status`, `start_training`, `stop_training`, and `list_training_runs`
- `validate_recipe`, `get_recipe_job_status`, and `get_recipe_job_dataset`
- `load_checkpoint` and `export_gguf`
`start_training` accepts the same fields as the Unsloth `TrainingStartRequest`.
The request is validated by the existing Pydantic model before a subprocess is
started. Export paths use the existing Unsloth validation as well.
The endpoint always requires `UNSLOTH_STUDIO_MCP_TOKEN` and checks an exact
Bearer token for both HTTP and WebSocket connections. Keep it on localhost
unless the deployment has an authenticated reverse proxy. The MCP endpoint is
intentionally opt-in because tools can consume GPU memory, write model
artifacts, and stop active work.

View file

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

View file

@ -3,7 +3,7 @@
Source: google/gemma-4-31B-it HF discussion/PR #118 (adds the preserve_thinking Source: google/gemma-4-31B-it HF discussion/PR #118 (adds the preserve_thinking
flag plus null-rendering, string-arguments validation, balanced turn tags, empty flag plus null-rendering, string-arguments validation, balanced turn tags, empty
messages handling, and OpenAI image_url/input_audio aliases). messages handling, and OpenAI image_url/input_audio aliases).
Unsloth-local changes vs PR #118: Studio-local changes vs PR #118:
1. preserve_thinking defaults to false (see SETUP block below). 1. preserve_thinking defaults to false (see SETUP block below).
2. The empty "<|channel>thought\n<channel|>" block on enable_thinking=false is 2. The empty "<|channel>thought\n<channel|>" block on enable_thinking=false is
NOT emitted. Google ships a distinct template for E2B/E4B (google/gemma-4-E2B-it, NOT emitted. Google ships a distinct template for E2B/E4B (google/gemma-4-E2B-it,

View file

@ -3,7 +3,7 @@
Source: google/gemma-4-31B-it HF discussion/PR #118 (adds the preserve_thinking Source: google/gemma-4-31B-it HF discussion/PR #118 (adds the preserve_thinking
flag plus null-rendering, string-arguments validation, balanced turn tags, empty flag plus null-rendering, string-arguments validation, balanced turn tags, empty
messages handling, and OpenAI image_url/input_audio aliases). messages handling, and OpenAI image_url/input_audio aliases).
Unsloth-local change: preserve_thinking defaults to false (see SETUP block below). Studio-local change: preserve_thinking defaults to false (see SETUP block below).
Applied to unsloth/gemma-4-*-GGUF models so the embedded GGUF template does not Applied to unsloth/gemma-4-*-GGUF models so the embedded GGUF template does not
need re-downloading. Keep in sync with upstream if PR #118 changes. need re-downloading. Keep in sync with upstream if PR #118 changes.
-#} -#}

View file

@ -30,7 +30,6 @@ lora:
vision_all_linear: false vision_all_linear: false
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
finetune_vision_layers: true finetune_vision_layers: true
finetune_language_layers: true finetune_language_layers: true
finetune_attention_modules: true finetune_attention_modules: true

View file

@ -235,13 +235,6 @@
"min_p": 0.1, "min_p": 0.1,
"repetition_penalty": 1.0 "repetition_penalty": 1.0
}, },
"deepseek-v4": {
"temperature": 1.0,
"top_p": 1.0,
"top_k": -1,
"min_p": 0.0,
"repetition_penalty": 1.0
},
"deepseek-r1": { "deepseek-r1": {
"temperature": 0.6, "temperature": 0.6,
"top_p": 0.95, "top_p": 0.95,
@ -401,7 +394,7 @@
"phi-4", "phi-3", "phi-4", "phi-3",
"mistral-nemo", "mistral-small", "mistral-large", "magistral", "ministral", "mistral-nemo", "mistral-small", "mistral-large", "magistral", "ministral",
"devstral", "pixtral", "devstral", "pixtral",
"deepseek-v4", "deepseek-r1", "deepseek-v3", "deepseek-ocr", "deepseek-r1", "deepseek-v3", "deepseek-ocr",
"glm-5", "glm-4", "glm-5", "glm-4",
"nemotron", "nemotron",
"minimax-m2.7", "minimax-m2.5", "minimax", "minimax-m2.7", "minimax-m2.5", "minimax",

View file

@ -30,7 +30,6 @@ lora:
vision_all_linear: false vision_all_linear: false
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
finetune_vision_layers: true finetune_vision_layers: true
finetune_language_layers: true finetune_language_layers: true
finetune_attention_modules: true finetune_attention_modules: true

View file

@ -33,7 +33,6 @@ lora:
- "down_proj" - "down_proj"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
finetune_vision_layers: true finetune_vision_layers: true
finetune_language_layers: true finetune_language_layers: true
finetune_attention_modules: true finetune_attention_modules: true

View file

@ -34,7 +34,6 @@ lora:
- "down_proj" - "down_proj"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
logging: logging:
enable_wandb: false enable_wandb: false

View file

@ -30,7 +30,6 @@ lora:
- "query" - "query"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
logging: logging:
enable_wandb: false enable_wandb: false

View file

@ -30,7 +30,6 @@ lora:
- "value" - "value"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
logging: logging:
enable_wandb: false enable_wandb: false

View file

@ -33,7 +33,6 @@ lora:
- "down_proj" - "down_proj"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
logging: logging:
enable_wandb: false enable_wandb: false

View file

@ -29,7 +29,6 @@ lora:
- "Wqkv" - "Wqkv"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
logging: logging:
enable_wandb: false enable_wandb: false

View file

@ -34,7 +34,6 @@ lora:
- "down_proj" - "down_proj"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
logging: logging:
enable_wandb: false enable_wandb: false

View file

@ -35,7 +35,6 @@ lora:
- "down_proj" - "down_proj"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
finetune_vision_layers: true finetune_vision_layers: true
finetune_language_layers: true finetune_language_layers: true
finetune_attention_modules: true finetune_attention_modules: true

View file

@ -34,7 +34,6 @@ lora:
- "down_proj" - "down_proj"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
logging: logging:
enable_wandb: false enable_wandb: false

View file

@ -35,7 +35,6 @@ lora:
- "down_proj" - "down_proj"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
logging: logging:
enable_wandb: false enable_wandb: false

View file

@ -35,7 +35,6 @@ lora:
- "down_proj" - "down_proj"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
logging: logging:
enable_wandb: false enable_wandb: false

View file

@ -33,7 +33,6 @@ lora:
- "down_proj" - "down_proj"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
logging: logging:
enable_wandb: false enable_wandb: false

View file

@ -34,7 +34,6 @@ lora:
- "down_proj" - "down_proj"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
logging: logging:
enable_wandb: false enable_wandb: false

View file

@ -35,7 +35,6 @@ lora:
- "down_proj" - "down_proj"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
logging: logging:
enable_wandb: false enable_wandb: false

View file

@ -29,7 +29,6 @@ lora:
- "all-linear" - "all-linear"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
finetune_vision_layers: true finetune_vision_layers: true
finetune_language_layers: true finetune_language_layers: true
finetune_attention_modules: true finetune_attention_modules: true

View file

@ -29,7 +29,6 @@ lora:
- "all-linear" - "all-linear"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
finetune_vision_layers: true finetune_vision_layers: true
finetune_language_layers: true finetune_language_layers: true
finetune_attention_modules: true finetune_attention_modules: true

View file

@ -29,7 +29,6 @@ lora:
- "all-linear" - "all-linear"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
finetune_vision_layers: true finetune_vision_layers: true
finetune_language_layers: true finetune_language_layers: true
finetune_attention_modules: true finetune_attention_modules: true

View file

@ -29,7 +29,6 @@ lora:
- "all-linear" - "all-linear"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
finetune_vision_layers: true finetune_vision_layers: true
finetune_language_layers: true finetune_language_layers: true
finetune_attention_modules: true finetune_attention_modules: true

View file

@ -29,7 +29,6 @@ lora:
- "all-linear" - "all-linear"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
finetune_vision_layers: true finetune_vision_layers: true
finetune_language_layers: true finetune_language_layers: true
finetune_attention_modules: true finetune_attention_modules: true

View file

@ -26,7 +26,6 @@ lora:
- "all-linear" - "all-linear"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
finetune_vision_layers: true finetune_vision_layers: true
finetune_language_layers: true finetune_language_layers: true
finetune_attention_modules: true finetune_attention_modules: true

View file

@ -26,7 +26,6 @@ lora:
- "all-linear" - "all-linear"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
finetune_vision_layers: true finetune_vision_layers: true
finetune_language_layers: true finetune_language_layers: true
finetune_attention_modules: true finetune_attention_modules: true

View file

@ -26,7 +26,6 @@ lora:
- "all-linear" - "all-linear"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
finetune_vision_layers: true finetune_vision_layers: true
finetune_language_layers: true finetune_language_layers: true
finetune_attention_modules: true finetune_attention_modules: true

View file

@ -26,7 +26,6 @@ lora:
- "all-linear" - "all-linear"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
finetune_vision_layers: true finetune_vision_layers: true
finetune_language_layers: true finetune_language_layers: true
finetune_attention_modules: true finetune_attention_modules: true

View file

@ -26,7 +26,6 @@ lora:
- "all-linear" - "all-linear"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
finetune_vision_layers: true finetune_vision_layers: true
finetune_language_layers: true finetune_language_layers: true
finetune_attention_modules: true finetune_attention_modules: true

View file

@ -26,7 +26,6 @@ lora:
- "all-linear" - "all-linear"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
finetune_vision_layers: true finetune_vision_layers: true
finetune_language_layers: true finetune_language_layers: true
finetune_attention_modules: true finetune_attention_modules: true

View file

@ -26,7 +26,6 @@ lora:
- "all-linear" - "all-linear"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
finetune_vision_layers: true finetune_vision_layers: true
finetune_language_layers: true finetune_language_layers: true
finetune_attention_modules: true finetune_attention_modules: true

View file

@ -26,7 +26,6 @@ lora:
- "all-linear" - "all-linear"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
finetune_vision_layers: true finetune_vision_layers: true
finetune_language_layers: true finetune_language_layers: true
finetune_attention_modules: true finetune_attention_modules: true

View file

@ -35,7 +35,6 @@ lora:
- "down_proj" - "down_proj"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
logging: logging:
enable_wandb: false enable_wandb: false

View file

@ -35,7 +35,6 @@ lora:
- "down_proj" - "down_proj"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
logging: logging:
enable_wandb: false enable_wandb: false

View file

@ -37,7 +37,6 @@ lora:
- "shared_mlp.output_linear" - "shared_mlp.output_linear"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
logging: logging:
enable_wandb: false enable_wandb: false

View file

@ -37,7 +37,6 @@ lora:
- "shared_mlp.output_linear" - "shared_mlp.output_linear"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
logging: logging:
enable_wandb: false enable_wandb: false

View file

@ -29,7 +29,6 @@ lora:
- "all-linear" - "all-linear"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
finetune_vision_layers: true finetune_vision_layers: true
finetune_language_layers: true finetune_language_layers: true
finetune_attention_modules: true finetune_attention_modules: true

View file

@ -34,7 +34,6 @@ lora:
- "down_proj" - "down_proj"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
logging: logging:
enable_wandb: false enable_wandb: false

View file

@ -35,7 +35,6 @@ lora:
- "down_proj" - "down_proj"
use_rslora: false use_rslora: false
use_loftq: false use_loftq: false
use_dora: false
logging: logging:
enable_wandb: false enable_wandb: false

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