Compare commits

..

2 commits

Author SHA1 Message Date
danielhanchen
74b1fa7513 studio/frontend: merge code-block CSS rules per gemini #5629 review
Gemini medium-priority review on #5629 suggested folding the two
`[data-streamdown="code-block"]` rules into one selector block so the
applied styles are visible in one place. Move `max-width: 100%` +
`overflow-x: hidden` into the existing
`content-visibility / contain-intrinsic-size` rule alongside the
explanatory comment, keeping the inner `<pre>` overflow-x rule next to
it for readability. No specificity / !important changes.
2026-05-19 17:35:25 +00:00
danielhanchen
a3b3ce6a6c studio/frontend: scroll long lines inside code blocks instead of overflowing
Cycle-33 probe (`scripts/r6_code_block_wrap_probe.py`) renders an
assistant message with four code fences containing unbreakable runs:
a 200-char hex string, a single-line curl with many flags, a JSON
value holding a long URL, and a 300-char ascii blob. All four blocks
measure with `overflow-x: visible`, `white-space: pre`, `word-break:
normal` -- streamdown's default <pre> styling -- so content escapes
the bubble laterally and reflows the chat column instead of staying
contained.

Three of the four blocks had scrollWidth > 2x clientWidth (1696,
2177, 2447 px inside a 710 px column).

Add two scoped rules in `index.css` alongside the existing
streamdown hardening section:

  - The inner `<pre>` switches to `overflow-x: auto` so a horizontal
    scrollbar appears on the code block itself.
  - The outer `[data-streamdown="code-block"]` wrapper gets
    `max-width: 100%; overflow-x: hidden` so any residual leak can't
    widen the bubble even if a downstream library tweak undoes the
    pre styling.

Code indentation is preserved (no wrap), the scrollbar appears only
when needed, and the rest of the chat column layout stays put.
2026-05-19 17:10:40 +00:00
1965 changed files with 59538 additions and 544892 deletions

11
.gitattributes vendored
View file

@ -1,13 +1,2 @@
# Normalize Python files to LF line endings # Normalize Python files to LF line endings
*.py text eol=lf *.py text eol=lf
# Always check out shell scripts with LF endings. Without this rule a Windows
# clone (core.autocrlf=true) rewrites them to CRLF, and the trailing \r breaks
# them when run in WSL/Linux (e.g. `set -e` -> "set: Illegal option -").
*.sh text eol=lf
# Normalize Unsloth frontend sources to LF. Scoped to the frontend tree (rather
# than repo-wide *.ts/*.tsx/... rules) so the policy can't force LF on files
# elsewhere. text=auto lets Git detect and leave binary assets (logos, fonts)
# untouched while text files (.ts/.tsx/.json/.html/.svg/...) are stored as LF.
studio/frontend/** text=auto eol=lf

20
.github/CODEOWNERS vendored
View file

@ -6,10 +6,10 @@
/unsloth/models/rl_replacements.py @Datta0 @pluesclues @danielhanchen /unsloth/models/rl_replacements.py @Datta0 @pluesclues @danielhanchen
/unsloth/trainer.py @danielhanchen /unsloth/trainer.py @danielhanchen
/unsloth/models/sentence_transformer.py @Etherll @danielhanchen /unsloth/models/sentence_transformer.py @Etherll @danielhanchen
/unsloth/save.py @danielhanchen /unsloth/save.py @rolandtannous @danielhanchen
/unsloth/tokenizer_utils.py @mmathew23 @danielhanchen /unsloth/tokenizer_utils.py @mmathew23 @danielhanchen
/unsloth/chat_templates.py @danielhanchen /unsloth/chat_templates.py @rolandtannous @danielhanchen
/unsloth/ollama_template_mappers.py @danielhanchen /unsloth/ollama_template_mappers.py @rolandtannous @danielhanchen
/unsloth/kernels/moe/*.py @Datta0 /unsloth/kernels/moe/*.py @Datta0
/unsloth/import_fixes.py @danielhanchen /unsloth/import_fixes.py @danielhanchen
/unsloth/device_type.py @danielhanchen /unsloth/device_type.py @danielhanchen
@ -45,14 +45,14 @@
/unsloth/utils/hf_hub.py @mmathew23 /unsloth/utils/hf_hub.py @mmathew23
/unsloth/utils/packing.py @mmathew23 /unsloth/utils/packing.py @mmathew23
/cli/ @Manan17 /cli/ @rolandtannous @Manan17
/studio/frontend/ @Shine1i @Manan17 /studio/frontend/ @Shine1i @rolandtannous @Manan17
/studio/frontend/public/ @Shine1i /studio/frontend/public/ @Shine1i
/studio/backend/ /studio/backend/ @rolandtannous
/studio/backend/core/data_recipe/ /studio/backend/core/data_recipe/ @rolandtannous
/studio/backend/tests/ @danielhanchen /studio/backend/tests/ @rolandtannous @danielhanchen
/tests/ @danielhanchen /tests/ @rolandtannous @danielhanchen
/scripts/ @danielhanchen /scripts/ @rolandtannous @danielhanchen
# Snapshot data for the notebook linter / Colab oracle. Drift in these # Snapshot data for the notebook linter / Colab oracle. Drift in these
# files changes the pin floor for every Unsloth notebook, so refreshes # files changes the pin floor for every Unsloth notebook, so refreshes

View file

@ -1,699 +0,0 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
#
# Drive one coding agent against the running `unsloth run` server for the
# Local Agent Guides CI. All failures from here are failure class (c)
# "guide drift": the server preflight already passed and the agent CLI
# already installed, so a failure here means the documented recipe in
# unsloth_cli/commands/start.py no longer produces a working flow.
#
# Self-updating: for all six agents (claude, codex, hermes, openclaw,
# opencode, pi) we obtain the exact env + command from
# `unsloth start <agent> --no-launch` and run THAT, so a recipe change is
# exercised automatically.
#
# Every agent invocation is wrapped in `timeout` so a headless-TTY prompt
# can never hang the runner -- a timeout is reported as guide drift with a
# distinct message.
#
# Usage:
# agent-guides-drive.sh connection <agent>
# agent-guides-drive.sh file-edit <agent>
# agent-guides-drive.sh attribution-ab claude
#
# Required env (exported by serve-unsloth-run.sh):
# UNSLOTH_BASE_URL UNSLOTH_API_KEY UNSLOTH_MODEL_ID
# UNSLOTH_LLAMA_LOG_DIR AGENT_INVOKE_TIMEOUT UNSLOTH_SEED
set -uo pipefail
MODE="${1:?usage: agent-guides-drive.sh <mode> <agent>}"
AGENT="${2:?usage: agent-guides-drive.sh <mode> <agent>}"
: "${UNSLOTH_BASE_URL:?serve step did not export UNSLOTH_BASE_URL}"
: "${UNSLOTH_API_KEY:?serve step did not export UNSLOTH_API_KEY}"
: "${UNSLOTH_MODEL_ID:?serve step did not export UNSLOTH_MODEL_ID}"
# Determinism (seed/temp) is applied at the server level by
# serve-unsloth-run.sh --extra; agents inherit it through the API.
TIMEOUT="${AGENT_INVOKE_TIMEOUT:-180}"
# opencode is the slow outlier. Unlike the print-mode agents (claude -p, codex
# exec) it runs a full turn AND a separate small_model call to name the session,
# so one connection reply takes ~8 min on a CPU-served 4B -- right at the shared
# 600s cap, so the cell flaked when a run drifted past a ~480s success. Give it
# headroom (still well under the 40-min job budget); the fast agents keep the
# tight cap that still catches a real headless-TTY hang.
case "$AGENT" in
opencode)
# Double it, but only for a bare-integer seconds value. A GNU timeout(1)
# duration suffix (s/m/h/d, including floats like 0.5s) is left unchanged so
# the arithmetic never sees a non-number; timeout(1) parses it directly.
case "$TIMEOUT" in
*[!0-9]*) ;;
*) TIMEOUT=$(( TIMEOUT * 2 )) ;;
esac
;;
esac
# Claude refuses --dangerously-skip-permissions outside a sandbox; the CI runner
# IS the sandbox, so declare it (mirrors unslothai/scripts launcher.sh). Harmless
# to the other agents, which ignore it.
export IS_SANDBOX=1
# Absolute paths anchored at the repo root (this script lives in
# .github/scripts/). Everything writes here regardless of the current working
# directory, so the file-edit mode can `cd` into a scratch work dir without
# breaking log/redaction writes.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
LOGS_DIR="$REPO_ROOT/logs"
REDACTED_DIR="$REPO_ROOT/redacted-configs"
WORKDIR_BASE="$REPO_ROOT/agent-workdir"
CACHE_HELPER="$SCRIPT_DIR/assert-prompt-cache.sh"
mkdir -p "$LOGS_DIR" "$REDACTED_DIR"
CONNECT_REF="unsloth_cli/commands/start.py"
# Prefill-shrinking flags for Claude Code. The heavyweight agents send
# multi-thousand-token system prompts + full tool schemas, which on a CPU-only
# runner is minutes of prefill per model round-trip (~16 tok/s for a 4B model).
# Replacing the ~5.7k default system prompt with a tiny one (--system-prompt-file)
# and restricting tools cuts the prefill to a few hundred tokens so it completes
# quickly on CPU. These only shape the request size; the start.py recipe
# (endpoint, auth, model) is still exercised end to end.
#
# The bulk of Claude Code's prompt is the built-in tool JSON schemas: measured
# via `claude -p /context`, the default prompt is ~28k tokens of which ~18k is
# "System tools" alone. --allowedTools/--disallowedTools only gate PERMISSION to
# call a tool; they do NOT remove its schema from what is sent to the model, so
# the earlier whitelist left the full ~18k in the prompt and CPU prefill
# (~16 tok/s) overran claude's own request timeout into a retry loop. --tools is
# the flag that restricts which schemas are sent. (The ~8k "Memory files" chunk
# is auto-loaded CLAUDE.md; the unsloth repo ships none, so it is 0 in CI.)
#
# Connection probe: --tools "" sends ZERO tool schemas, leaving ~20 tokens total
# (a one-line --system-prompt-file + the user turn), which prefills instantly.
CLAUDE_CONNECT_FLAGS=(
--system-prompt-file "$SCRIPT_DIR/ci-connect-prompt.txt"
--tools ""
)
# File-edit: the task needs the file/shell tools, so send only those schemas
# (~2.3k tokens vs ~18k for the full set).
CLAUDE_EDIT_FLAGS=(
--system-prompt-file "$SCRIPT_DIR/ci-min-system-prompt.txt"
--tools "Bash,Edit,Write,Read"
)
guide_fail() {
echo "::error::[guide drift] agent=${AGENT}: $* (preflight passed + install OK, so the documented flow in ${CONNECT_REF} drifted)." >&2
exit 1
}
# Redact the API key from any file we are about to keep as an artifact.
# Portable across GNU sed (Linux runners) and BSD sed (macOS), so the
# redaction is never silently skipped.
redact() {
local f
for f in "$@"; do
[ -f "$f" ] || continue
if sed --version >/dev/null 2>&1; then
sed -i "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$f" 2>/dev/null || true
else
sed -i '' "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$f" 2>/dev/null || true
fi
done
}
# Print a file to the log with the key scrubbed, without mutating it (the raw file is
# still needed to parse the real env). Use this instead of `cat` for any transcript that
# carries an `export UNSLOTH_API_KEY=...` line, so a live key never reaches Actions logs.
cat_redacted() {
sed "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$1"
}
# A reply must be non-empty and free of connection/auth errors.
assert_reply() {
local out="$1"
if [ ! -s "$out" ]; then
guide_fail "agent produced an EMPTY reply"
fi
if grep -qiE 'connection refused|connection error|econnrefused|fetch failed|http 4[0-9][0-9]|unauthorized|invalid api key|authentication failed' "$out"; then
guide_fail "agent reply contained a connection/auth error: $(grep -iE 'connection|unauthorized|auth|http 4' "$out" | head -1)"
fi
echo "[$AGENT] reply (first 20 lines):"
head -20 "$out"
}
# Run a command under a hard timeout; map 124 to a guide-drift hang message.
run_timed() { # $1=outfile, rest=command
local out="$1"; shift
timeout "$TIMEOUT" "$@" > "$out" 2>&1
local rc=$?
if [ "$rc" -eq 124 ]; then
redact "$out" # guide_fail exits below, so scrub the transcript here too
echo "[$AGENT] last 40 lines before timeout:"; tail -40 "$out" 2>/dev/null || true
guide_fail "invoke timed out after ${TIMEOUT}s (headless-TTY hang -- the recipe likely needs a non-interactive/print flag)"
fi
return "$rc"
}
# Read a value from an `export VAR=...` line in the connect --no-launch output.
# `unsloth start` writes each agent's session config off the user's ~ and points
# at it through a relocation env var (CODEX_HOME / OPENCODE_CONFIG /
# OPENCLAW_CONFIG_PATH), so the contract checks read the path from here.
raw_env() { # $1 = var name -> value (one shlex-quote layer stripped)
local raw="$LOGS_DIR/connect-${AGENT}.txt"
local v; v="$(sed -n "s/^export $1=//p" "$raw" | tail -1)"
v="${v#\'}"; v="${v%\'}"; printf '%s' "$v"
}
# ── 5-agent start.py path: parse env + command from --no-launch ─────────
# Populates globals CONNECT_ENV (export/unset lines) and CONNECT_CMD (the
# launch command on the last printed line), and runs start.py's config
# writers as a side effect (it writes each agent's relocated session config).
parse_connect() {
local raw="$LOGS_DIR/connect-${AGENT}.txt"
# CONNECT_YOLO=1 adds --yolo. opencode/openclaw gate tool approval through their
# config (which now prompts by default), so the file-edit test opts into auto-approval
# here, the same intent as claude/codex's per-call bypass flags.
local yolo=()
[ -n "${CONNECT_YOLO:-}" ] && yolo=(--yolo)
if ! unsloth start "$AGENT" --no-launch "${yolo[@]}" --api-key "$UNSLOTH_API_KEY" > "$raw" 2>&1; then
cat_redacted "$raw"
guide_fail "'unsloth start ${AGENT} --no-launch' exited non-zero"
fi
echo "[$AGENT] connect --no-launch printed:"; cat_redacted "$raw"
CONNECT_ENV="$(grep -E '^(export |unset )' "$raw" || true)"
# The launch command is the last non-export, non-status line. start.py
# prints "Unsloth <url> · model <id>" and "Updated ..." status lines first.
CONNECT_CMD="$(grep -vE '^(export |unset |Unsloth |Updated |Disabled |Warning|Loading)' "$raw" \
| grep -E '[^[:space:]]' | tail -1)"
[ -n "$CONNECT_CMD" ] || guide_fail "could not parse a launch command from connect --no-launch output"
redact "$raw"
}
# Cross-check the documented contract knobs so silent start.py changes
# (env-var rename, wire_api flip, attribution setting drop) also fail/flag.
crosscheck_contract() {
local raw="$LOGS_DIR/connect-${AGENT}.txt"
local cfg home
case "$AGENT" in
codex)
grep -q 'UNSLOTH_STUDIO_AUTH_TOKEN' "$raw" \
|| guide_fail "Codex env key is no longer UNSLOTH_STUDIO_AUTH_TOKEN (start.py _CODEX_ENV_KEY)"
home="$(raw_env CODEX_HOME)"
# An empty relocation var would make cfg "/config.toml" and silently
# skip the [ -f ] contract check below; fail loudly instead.
[ -n "$home" ] || guide_fail "CODEX_HOME missing from connect output (start.py codex())"
cfg="$home/config.toml"
if [ -f "$cfg" ]; then
grep -q 'wire_api = "responses"' "$cfg" \
|| guide_fail "Codex wire_api is no longer \"responses\" in \$CODEX_HOME/config.toml"
cp "$cfg" "$REDACTED_DIR/codex-config.toml"
fi
grep -q 'codex --oss --profile unsloth_api' "$raw" \
|| echo "::warning::Codex launch command changed from 'codex --oss --profile unsloth_api'"
;;
claude)
grep -q 'ANTHROPIC_AUTH_TOKEN' "$raw" \
|| guide_fail "Claude no longer exports ANTHROPIC_AUTH_TOKEN (start.py claude())"
grep -q 'CLAUDE_CODE_ATTRIBUTION_HEADER' "$raw" \
|| echo "::warning::CLAUDE_CODE_ATTRIBUTION_HEADER no longer set for the session (start.py claude())"
;;
hermes)
grep -q 'UNSLOTH_API_KEY' "$raw" \
|| guide_fail "Hermes env key is no longer UNSLOTH_API_KEY (start.py _HERMES_ENV_KEY)"
home="$(raw_env HERMES_HOME)"
[ -n "$home" ] || guide_fail "HERMES_HOME missing from connect output (start.py hermes())"
cfg="$home/config.yaml"
[ -f "$cfg" ] && cp "$cfg" "$REDACTED_DIR/hermes-config.yaml"
;;
openclaw)
cfg="$(raw_env OPENCLAW_CONFIG_PATH)"
if [ -n "$cfg" ] && [ -f "$cfg" ]; then
grep -q '"openai-completions"' "$cfg" \
|| echo "::warning::OpenClaw provider api is no longer 'openai-completions' (write_openclaw_config)"
cp "$cfg" "$REDACTED_DIR/openclaw.json"
fi
;;
opencode)
cfg="$(raw_env OPENCODE_CONFIG)"
[ -n "$cfg" ] && [ -f "$cfg" ] && cp "$cfg" "$REDACTED_DIR/opencode.json"
;;
pi)
# Pi has no config-dir env var; the session is HOME-relocated, and the
# provider config lives at $HOME/.pi/agent/models.json.
cfg="$(raw_env HOME)/.pi/agent/models.json"
if [ -f "$cfg" ]; then
grep -q '"openai-completions"' "$cfg" \
|| echo "::warning::Pi provider api is no longer 'openai-completions' (write_pi_config)"
cp "$cfg" "$REDACTED_DIR/pi-models.json"
fi
;;
esac
redact "$REDACTED_DIR"/* 2>/dev/null || true
}
# Heavyweight agents (hermes, openclaw) bake a large system prompt + tool JSON
# schemas into every request, which a CPU runner cannot prefill before the invoke
# timeout. As with claude's --tools, we shrink the request from the agent's own
# config: zero tools for the connection probe collapses the prompt to a few
# hundred tokens, since both CLIs gate the bulk of their prompt on having tools.
# Hermes: an explicit empty cli toolset disables all tools (and drops the
# tool-gated guidance blocks), so -z sends ~300 tokens instead of thousands.
# Hermes enables its default cli toolset when the session config does not pin one,
# so we must set platform_toolsets.cli explicitly to [] (not just append) to get
# zero tools. That needs a YAML parser, and the runner's bare python3 has no
# PyYAML -- but the venv that ships `unsloth` does (start.py imports yaml), so run
# the patch with that interpreter. We patch the relocated $HERMES_HOME/config.yaml
# that `unsloth start` printed, not the user's ~/.hermes.
# (-z reads platform_toolsets.cli; --ignore-rules is a no-op under -z.)
patch_hermes_tools() { # $1 = none|default
# Check the raw var BEFORE appending /config.yaml: the joined path is never
# empty, so the old guard could not fire and the patcher would die on
# "/config.yaml" with a bare traceback instead of this clear failure.
local home; home="$(raw_env HERMES_HOME)"
[ -n "$home" ] || guide_fail "Hermes HERMES_HOME missing from connect output (start.py hermes())"
local cfg; cfg="$home/config.yaml"
# Find a python that can import yaml. The runner's bare python3 cannot, but the
# interpreter in the `unsloth` console-script shebang provably can (it runs
# start.py's write_hermes_config, which imports yaml). Try that first, then
# any python on PATH, then the venv sibling, picking the first with PyYAML.
local cand py="" shebang
shebang="$(head -1 "$(command -v unsloth)" 2>/dev/null | sed -n 's/^#![[:space:]]*//p' | awk '{print $1}')"
for cand in "$shebang" python3 python "$(dirname "$(command -v unsloth)")/python"; do
[ -n "$cand" ] || continue
{ [ -x "$cand" ] || command -v "$cand" >/dev/null 2>&1; } || continue
if "$cand" -c 'import yaml' 2>/dev/null; then py="$cand"; break; fi
done
[ -n "$py" ] || guide_fail "could not find a python with PyYAML to patch the hermes session config"
echo "[hermes] patching $cfg with $py"
"$py" - "$1" "$cfg" <<'PY'
import os, sys
import yaml
mode = sys.argv[1]
p = sys.argv[2]
cfg = (yaml.safe_load(open(p)) or {}) if os.path.exists(p) else {}
ts = cfg.get("platform_toolsets")
if not isinstance(ts, dict):
ts = cfg["platform_toolsets"] = {}
if mode == "none":
ts["cli"] = [] # explicit empty list -> zero tools (not "defaults")
else:
ts.pop("cli", None) # file-edit needs real tools -> restore defaults
with open(p, "w") as fh:
yaml.safe_dump(cfg, fh, sort_keys=False)
print(f"[hermes] platform_toolsets.cli = {ts.get('cli', 'default')}")
PY
}
# OpenClaw: 'openclaw agent' has no tool/prompt flags, so we define a 'ci' agent
# in openclaw.json. tools.deny ["*"] sends zero tool schemas (deny always wins)
# for the connection probe; contextInjection "never" + defaults.skipBootstrap
# drop the auto-injected AGENTS.md/SOUL.md bootstrap (the bulk of the prompt) for
# both modes. --agent must reference a defined agent, so write it before invoking.
patch_openclaw_agent() { # $1 = notools|tools
# OpenClaw reads its config from the relocated OPENCLAW_CONFIG_PATH that
# `unsloth start` printed, so patch THAT file (not the user's ~/.openclaw).
local cfg; cfg="$(raw_env OPENCLAW_CONFIG_PATH)"
[ -n "$cfg" ] || guide_fail "OpenClaw OPENCLAW_CONFIG_PATH missing from connect output (start.py openclaw())"
python3 - "$1" "$cfg" <<'PY'
import os, sys, json
mode = sys.argv[1]
p = sys.argv[2]
cfg = json.load(open(p)) if os.path.exists(p) else {}
agents = cfg.setdefault("agents", {})
agents.setdefault("defaults", {})["skipBootstrap"] = True
lst = [a for a in agents.get("list", []) if a.get("id") != "ci"]
agent = {"id": "ci", "contextInjection": "never"}
if mode == "notools":
agent["tools"] = {"deny": ["*"]}
lst.append(agent)
agents["list"] = lst
with open(p, "w") as fh:
json.dump(cfg, fh, indent=2)
print(f"[openclaw] agent ci tools = {agent.get('tools', 'default')}")
PY
}
# Build an invoke script that applies start.py's env then runs the launch
# command (with extra args appended) under bash. We do NOT eval connect's env
# into this shell; we write it into a one-shot script so the export/unset
# semantics are exactly what start.py printed. The script path is absolute
# so it is valid even when the caller has cd'd into a scratch work dir.
invoke_via_connect() { # $1=outfile, rest=extra args appended to the command
local out="$1"; shift
local script="$LOGS_DIR/invoke-${AGENT}.sh"
local real; real="$(mktemp)"
# CONNECT_ENV_EXTRA / CONNECT_CMD_OVERRIDE let a caller (attribution-ab) flip a
# session knob without editing the user's config; empty -> use what start.py emitted.
local cmd="${CONNECT_CMD_OVERRIDE:-$CONNECT_CMD}"
{
echo "set -uo pipefail"
echo "$CONNECT_ENV"
[ -n "${CONNECT_ENV_EXTRA:-}" ] && echo "$CONNECT_ENV_EXTRA"
# Append extra args (the prompt / flags) to the launch command verbatim.
printf '%s' "$cmd"
local a
for a in "$@"; do printf ' %q' "$a"; done
printf '\n'
} > "$real"
# Upload a REDACTED copy of the script, but EXECUTE the un-redacted one from a
# temp path outside the artifact dir. Redacting the script we run would turn
# the real `export TOKEN=sk-...` line into `export TOKEN=<REDACTED>`, which is
# invalid bash (the `<`/`>` are redirections) and silently breaks every agent.
# Writing the redacted copy up front keeps the key out of the artifact even if
# the run times out (run_timed exits before returning here).
cp "$real" "$script"; redact "$script"
# The connect one-liner now carries the key as an inline env assignment; scrub it on
# the way to the log (the executed $real keeps the live value).
echo "[$AGENT] invoking (timeout ${TIMEOUT}s): ${cmd//${UNSLOTH_API_KEY}/<REDACTED>} $*"
run_timed "$out" bash "$real"
local rc=$?
rm -f "$real"
redact "$out" # the transcript can echo the token; scrub before upload
return "$rc"
}
# ═════════════════════════════════════════════════════════════════════════
case "$MODE" in
# ── connection: trivial prompt, assert a non-empty, error-free reply ────
connection)
PROMPT='Reply with exactly the single word: pong'
OUT="$LOGS_DIR/${AGENT}-connection.txt"
parse_connect
crosscheck_contract
# claude/codex run in print mode via the flags start.py emits
# (claude -p / codex exec). For agents whose default subcommand prints
# to stdout we pass the prompt through ctx.args.
case "$AGENT" in
claude) invoke_via_connect "$OUT" "${CLAUDE_CONNECT_FLAGS[@]}" -p "$PROMPT" ;;
codex) invoke_via_connect "$OUT" exec --dangerously-bypass-approvals-and-sandbox "$PROMPT" ;;
opencode) invoke_via_connect "$OUT" run "$PROMPT" ;;
pi) invoke_via_connect "$OUT" -p "$PROMPT" ;;
hermes) patch_hermes_tools none
invoke_via_connect "$OUT" -z "$PROMPT" ;;
openclaw) patch_openclaw_agent notools
CONNECT_CMD_OVERRIDE=openclaw invoke_via_connect "$OUT" agent --local --agent ci \
--model "unsloth/${UNSLOTH_MODEL_ID}" --message "$PROMPT" ;;
*) invoke_via_connect "$OUT" "$PROMPT" ;;
esac
# A non-zero exit from the documented launch command is drift even if it
# printed something: a benign-looking "command not found" / usage dump would
# otherwise slip past assert_reply (which only flags empty/error-keyword text).
rc=$?
[ "$rc" -eq 0 ] || guide_fail "the documented launch command exited non-zero (rc=$rc) -- see the transcript above"
assert_reply "$OUT"
echo "[$AGENT] connection OK"
;;
# ── file-edit: deterministic 2-turn hello.py test (Qwen3.5-2B) ──────────
file-edit)
WORK="$WORKDIR_BASE/${AGENT}"
rm -rf "$WORK"; mkdir -p "$WORK"
OUT1="$LOGS_DIR/${AGENT}-fileedit-turn1.txt"
OUT2="$LOGS_DIR/${AGENT}-fileedit-turn2.txt"
T1='Create a file named hello.py in the current directory whose entire contents are a single line: print("Hello"). Do not run it.'
T2='Run hello.py with python and show me the exact output.'
# The start.py recipe writers + crosscheck must see the repo; run them
# from the repo root BEFORE cd-ing into the scratch work dir. opencode/openclaw
# gate tool approval through their config (prompting by default), so file-edit
# opts them into auto-approval to run edits/commands headlessly.
case "$AGENT" in opencode|openclaw) CONNECT_YOLO=1 ;; esac
parse_connect
crosscheck_contract
# File-edit needs real tools, so we cannot zero them as in connection.
# hermes keeps default tools; openclaw still strips its AGENTS.md/SOUL.md
# bootstrap (the largest prompt chunk) via the 'ci' agent. The scratch work
# dir is empty, so no project context files are auto-loaded either.
case "$AGENT" in
hermes) patch_hermes_tools default ;;
openclaw) patch_openclaw_agent tools ;;
esac
# Drive from inside the work dir so the agent edits files there. All log
# writes use absolute $LOGS_DIR, so cwd does not matter for them.
cd "$WORK" || guide_fail "could not enter work dir $WORK"
invoke_turn() { # $1=outfile $2=continue? $3=prompt
local out="$1" cont="$2" prompt="$3"
case "$AGENT" in
pi)
# Pi continues the previous session with -c; provider/model come from
# the parsed `unsloth start pi` recipe (CONNECT_CMD), not hardcoded here.
if [ "$cont" = "continue" ]; then
invoke_via_connect "$out" -p --continue "$prompt"
else
invoke_via_connect "$out" -p "$prompt"
fi ;;
claude)
# --dangerously-skip-permissions lets headless claude actually use the
# Write/Bash tools (otherwise it blocks on an approval prompt and emits
# nothing). IS_SANDBOX=1 (exported above) authorizes it.
if [ "$cont" = "continue" ]; then
invoke_via_connect "$out" "${CLAUDE_EDIT_FLAGS[@]}" --dangerously-skip-permissions -p --continue "$prompt"
else
invoke_via_connect "$out" "${CLAUDE_EDIT_FLAGS[@]}" --dangerously-skip-permissions -p "$prompt"
fi ;;
codex)
# --dangerously-bypass-approvals-and-sandbox gives codex exec
# workspace-write (default is read-only -> cannot create hello.py) and
# skips the bubblewrap sandbox that the runner lacks.
if [ "$cont" = "continue" ]; then
invoke_via_connect "$out" exec --dangerously-bypass-approvals-and-sandbox resume --last "$prompt"
else
invoke_via_connect "$out" exec --dangerously-bypass-approvals-and-sandbox "$prompt"
fi ;;
opencode) invoke_via_connect "$out" run "$prompt" ;;
hermes) invoke_via_connect "$out" -z "$prompt" ;;
openclaw) CONNECT_CMD_OVERRIDE=openclaw invoke_via_connect "$out" agent --local --agent ci \
--model "unsloth/${UNSLOTH_MODEL_ID}" --message "$prompt" ;;
*) invoke_via_connect "$out" "$prompt" ;;
esac
}
# Turn 1: create hello.py.
invoke_turn "$OUT1" fresh "$T1"
# Fail on a non-zero agent exit before trusting side effects: an agent can
# error out (API/tool failure) yet leave a plausible file/transcript behind,
# which would otherwise slip past the assertions below (mirrors connection).
rc=$?
[ "$rc" -eq 0 ] || { echo "[$AGENT] turn-1 transcript:"; tail -40 "$OUT1" 2>/dev/null || true; \
guide_fail "turn 1 (create hello.py) exited non-zero (rc=$rc)"; }
# Hard assertions on the side effect (the real test): file + content + run.
if [ ! -f hello.py ]; then
echo "[$AGENT] turn-1 transcript:"; tail -40 "$OUT1" 2>/dev/null || true
guide_fail "turn 1 did not create hello.py"
fi
grep -q 'Hello' hello.py || guide_fail "hello.py does not contain 'Hello'"
RUN_OUT="$(python3 hello.py 2>&1 || true)"
[ "$RUN_OUT" = "Hello" ] || guide_fail "python3 hello.py printed '$RUN_OUT', expected exactly 'Hello'"
echo "[$AGENT] turn 1 OK (file created, prints 'Hello')"
# Turn 2: same cwd + session continuation; assert the agent's run output
# contains Hello. Narration drift is WARN-only, missing output is a hard fail.
invoke_turn "$OUT2" continue "$T2"
rc=$?
[ "$rc" -eq 0 ] || { echo "[$AGENT] turn-2 transcript:"; tail -60 "$OUT2" 2>/dev/null || true; \
guide_fail "turn 2 (run hello.py) exited non-zero (rc=$rc)"; }
if grep -q 'Hello' "$OUT2"; then
echo "[$AGENT] turn 2 OK (run output contains 'Hello')"
else
echo "[$AGENT] turn-2 transcript:"; tail -60 "$OUT2" 2>/dev/null || true
guide_fail "turn 2 run/bash output did not contain 'Hello'"
fi
cd "$REPO_ROOT" || true
echo "[$AGENT] file-edit OK"
;;
# ── attribution-ab: Claude Code KV-cache HIT vs MISS ────────────────────
attribution-ab)
[ "$AGENT" = "claude" ] || guide_fail "attribution-ab only applies to claude"
# The llama-server log filename uses the INTERNAL random llama.cpp port,
# not STUDIO_PORT, so we never glob by port: assert-prompt-cache.sh picks
# the newest llama-*.log and we slice it by a byte offset (`mark`) captured
# right before the measured turn, so an earlier turn's reuse can't leak in.
LLAMA_LOG_DIR="${UNSLOTH_LLAMA_LOG_DIR:-$HOME/.unsloth/studio/logs/llama-server}"
export LLAMA_LOG_DIR
parse_connect # prints session env + suppression flags (no ~/.claude write)
crosscheck_contract
PROMPT='Reply with exactly the single word: pong'
# Phase A: the suppression start.py ships (CLAUDE_CODE_ATTRIBUTION_HEADER=0 +
# --exclude-dynamic-system-prompt-sections + --settings overlay) -> expect a
# HIT on the continued turn, since the system-prompt prefix is stable.
invoke_via_connect "$LOGS_DIR/claude-ab-hit-1.txt" -p "$PROMPT" # turn 1 primes
FROM_HIT="$(bash "$CACHE_HELPER" mark)" # offset before turn 2
invoke_via_connect "$LOGS_DIR/claude-ab-hit-2.txt" -p --continue "$PROMPT again"
CACHE_LOG_FROM="$FROM_HIT" bash "$CACHE_HELPER" log HIT
# Phase B: vanilla Claude with the header ENABLED -> expect a MISS. We flip
# the env var to 1 and strip the suppression flags from the launch command
# (without them the dynamic attribution line is included and changes every
# turn, so the shared prefix moves and the KV cache is invalidated, ~90%
# slower). This is session-only: nothing is written to ~/.claude.
CONNECT_ENV_EXTRA='export CLAUDE_CODE_ATTRIBUTION_HEADER=1'
CONNECT_CMD_OVERRIDE="$(printf '%s' "$CONNECT_CMD" \
| sed -E "s/ --exclude-dynamic-system-prompt-sections//; s/ --settings '[^']*'//")"
invoke_via_connect "$LOGS_DIR/claude-ab-miss-1.txt" -p "$PROMPT"
FROM_MISS="$(bash "$CACHE_HELPER" mark)"
invoke_via_connect "$LOGS_DIR/claude-ab-miss-2.txt" -p --continue "$PROMPT again"
CACHE_LOG_FROM="$FROM_MISS" bash "$CACHE_HELPER" log MISS
unset CONNECT_ENV_EXTRA CONNECT_CMD_OVERRIDE
echo "[claude] attribution A/B OK (suppressed HIT, header=1 MISS)"
;;
# ── resume: does a launched agent's session survive exit and resume? ────
# Unlike the other modes, this drives the real LAUNCH path (`unsloth start
# <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"
;;
*)
echo "agent-guides-drive.sh: unknown mode '$MODE'" >&2
exit 2
;;
esac

View file

@ -1,108 +0,0 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
#
# Install one coding-agent CLI for the Local Agent Guides CI. Isolated as
# failure class (b) "agent package install failed": npm/curl flakiness here
# is the single biggest source of false reds, so installs retry with
# backoff and the only ::error:: this script can emit is class (b). The
# install recipes mirror the install_hint strings in
# unsloth_cli/commands/start.py at HEAD.
#
# Usage: agent-guides-install.sh <agent>
# agent in: claude codex hermes openclaw opencode pi
set -uo pipefail
AGENT="${1:?usage: agent-guides-install.sh <agent>}"
mkdir -p logs
LOG="logs/install-${AGENT}.log"
install_fail() {
echo "::error::[agent install failed] agent=${AGENT}: $* (class (b): the agent CLI did not install; not a server or guide problem)." >&2
echo "---- tail $LOG ----" >&2
tail -60 "$LOG" 2>/dev/null || true
exit 1
}
# npm registry flakiness is common in CI; retry 3x with linear backoff.
# Extra npm flags may precede the package (e.g. npm_retry --ignore-scripts pkg).
npm_retry() {
local i
for i in 1 2 3; do
if npm install -g "$@" >> "$LOG" 2>&1; then
return 0
fi
echo "[install] npm install -g $* attempt $i failed; backing off $((i * 10))s" | tee -a "$LOG"
sleep "$((i * 10))"
done
return 1
}
# curl|bash installers, retried at the curl layer. We download to a temp file
# first and only execute on a fully successful fetch, so a truncated download
# (network hiccup mid-stream) can never run a half-written installer.
curl_bash() {
local url="$1"; shift
local i tmp
tmp="$(mktemp)"
for i in 1 2 3; do
if curl -fsSL --retry 3 --retry-delay 5 "$url" -o "$tmp" 2>>"$LOG" \
&& bash "$tmp" "$@" >> "$LOG" 2>&1; then
rm -f "$tmp"
return 0
fi
echo "[install] curl|bash $url attempt $i failed; backing off $((i * 10))s" | tee -a "$LOG"
sleep "$((i * 10))"
done
rm -f "$tmp"
return 1
}
echo "[install] agent=$AGENT (log=$LOG)"
case "$AGENT" in
claude)
# start.py install_hint: curl -fsSL https://claude.ai/install.sh | bash
curl_bash "https://claude.ai/install.sh" || install_fail "claude installer failed"
# The installer drops the binary under ~/.local/bin.
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
;;
codex)
# start.py install_hint: npm install -g @openai/codex
npm_retry "@openai/codex" || install_fail "npm install -g @openai/codex failed"
;;
opencode)
# start.py install_hint: npm install -g opencode-ai
npm_retry "opencode-ai" || install_fail "npm install -g opencode-ai failed"
;;
openclaw)
# start.py install_hint: curl -fsSL https://openclaw.ai/install.sh | bash
# npm is the more deterministic path in CI and matches the agent's docs;
# fall back to the start.py curl installer if the npm tag is missing.
if ! npm_retry "openclaw@latest"; then
curl_bash "https://openclaw.ai/install.sh" || install_fail "openclaw install failed (npm + curl)"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
fi
;;
hermes)
# start.py install_hint:
# curl -fsSL .../NousResearch/hermes-agent/main/scripts/install.sh | bash
curl_bash "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh" \
--non-interactive --skip-setup --skip-browser --no-skills \
|| install_fail "hermes installer failed"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
;;
pi)
# start.py install_hint: npm install -g --ignore-scripts @earendil-works/pi-coding-agent
# (--ignore-scripts matches Pi's documented recipe; exercising the exact hint
# catches guide drift). The CLI moved from the now-deprecated @mariozechner
# scope to @earendil-works (the old scope is frozen, so installing it would
# test a stale Pi against the API).
npm_retry --ignore-scripts "@earendil-works/pi-coding-agent" \
|| install_fail "npm install -g --ignore-scripts @earendil-works/pi-coding-agent failed"
;;
*)
install_fail "unknown agent '$AGENT'"
;;
esac
echo "[install] OK for $AGENT"

View file

@ -1,57 +0,0 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
#
# Assert Unsloth installed a llama.cpp that loads and runs on THIS macOS. Tests
# the contract that matters (binaries load and their minimum-OS is <= this host)
# instead of the old "did install.sh fall back to a source build?" grep, since a
# source build with a correct deployment target is a valid outcome.
set -uo pipefail
UNSLOTH_HOME="${STUDIO_HOME:-$HOME/.unsloth}"
LLAMA_DIR="${LLAMA_CPP_DIR:-$UNSLOTH_HOME/llama.cpp}"
BIN_DIR="$LLAMA_DIR/build/bin"
fail() {
echo "::error::$*"
if [ -f logs/install.log ]; then
echo "---- install.log (llama.cpp lines) ----"
grep -E "llama-prebuilt|llama\.cpp|macos prebuilt|falling back" logs/install.log | tail -80 || true
fi
exit 1
}
SERVER="$(find "$LLAMA_DIR" -type f -name 'llama-server' 2>/dev/null | head -1)"
QUANT="$(find "$LLAMA_DIR" -type f -name 'llama-quantize' 2>/dev/null | head -1)"
[ -n "$SERVER" ] || fail "llama-server not found under $LLAMA_DIR after install"
[ -n "$QUANT" ] || fail "llama-quantize not found under $LLAMA_DIR after install"
HOST_VER="$(sw_vers -productVersion 2>/dev/null || echo '0')"
HOST_MAJOR="${HOST_VER%%.*}"
# Static minimum-OS check on every Mach-O we ship. vtool ships with the Xcode
# command line tools, which GitHub macOS runners always have; if it is somehow
# missing we skip the static check and rely on the runtime launch below.
if command -v vtool >/dev/null 2>&1; then
while IFS= read -r macho; do
[ -n "$macho" ] || continue
minos="$(vtool -show-build "$macho" 2>/dev/null | awk '/minos/{print $2; exit}')"
[ -n "$minos" ] || continue
min_major="${minos%%.*}"
if [ "$min_major" -gt "$HOST_MAJOR" ] 2>/dev/null; then
fail "$(basename "$macho") is built for macOS $minos but this runner is macOS $HOST_VER (prebuilt is newer than the host)"
fi
done < <(find "$BIN_DIR" -type f \( -name '*.dylib' -o -name 'llama-server' -o -name 'llama-quantize' \) 2>/dev/null)
fi
# Runtime launch: --version forces dyld to load every linked dylib (including
# libggml-metal.dylib). A missing Metal symbol or too-new binary fails here.
if ! "$SERVER" --version >/tmp/llama-server-version.txt 2>&1; then
echo "---- llama-server --version output ----"
cat /tmp/llama-server-version.txt || true
fail "llama-server failed to launch on macOS $HOST_VER (dyld load / symbol error)"
fi
echo "llama.cpp load validation passed on macOS $HOST_VER"
echo " server: $SERVER"
sed -n '1,4p' /tmp/llama-server-version.txt 2>/dev/null || true

View file

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

View file

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

View file

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

View file

@ -1,9 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
# #
# Download a single file from a Hugging Face repo with a stall-retry # 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 +33,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

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

View file

@ -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
@ -209,7 +209,7 @@ jobs:
'peft>=0.18,<0.20' 'accelerate>=0.34,<2' \ 'peft>=0.18,<0.20' 'accelerate>=0.34,<2' \
ipython ipython
# torchvision: unsloth_zoo.vision_utils imports it at module scope. # torchvision: unsloth_zoo.vision_utils imports it at module scope.
pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \ pip install --index-url https://download.pytorch.org/whl/cpu \
'torch>=2.4,<2.11' 'torchvision<0.26' 'torch>=2.4,<2.11' 'torchvision<0.26'
# transformers + trl from the matrix combo. # transformers + trl from the matrix combo.
pip install "$RESOLVED_TRANSFORMERS_SPEC" pip install "$RESOLVED_TRANSFORMERS_SPEC"
@ -268,16 +268,8 @@ 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_export_api_surface.py \
tests/saving/test_export_dispatch.py \
tests/saving/test_imatrix_export.py \
tests/saving/test_gguf_single_pass_export.py \
tests/saving/test_offline_gguf_vlm_tokenizer_7481.py \
tests/utils/test_attention_masks.py \ tests/utils/test_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
python -m pytest --collect-only -q "$RUNNER_TEMP/unsloth-zoo/tests/" python -m pytest --collect-only -q "$RUNNER_TEMP/unsloth-zoo/tests/"
- name: import_fixes drift detectors (18 tests, HARD GATE) - name: import_fixes drift detectors (18 tests, HARD GATE)
@ -340,62 +332,35 @@ jobs:
run: | run: |
python -m pytest -v --tb=short tests/test_callback_signature_drift.py python -m pytest -v --tb=short tests/test_callback_signature_drift.py
- name: generation correctness guards (HARD GATE)
# Deterministic CPU guards, each validated to fail on its pre-fix code:
# leftpad = batched left-padded generation (#1066/#3699, fixed by
# #2216 + #4100; staging proof: unsloth-staging-2 PRs 170/172);
# rope_scaling_drift = config.rope_scaling dropped by replaced rotary
# classes (#2405). AST checks run first so import breakage cannot mask them.
run: |
python -m pytest -v --tb=short \
tests/utils/test_prepare_inputs_leftpad.py \
tests/utils/test_rope_scaling_drift.py
- name: unsloth Bucket-A — CPU tests not in Repo tests (CPU) - name: unsloth Bucket-A — CPU tests not in Repo tests (CPU)
# CPU tests across 6 files under tests/saving/, tests/utils/, tests/python/ # 16 tests across 5 files. They live inside tests/saving/ and
# that Repo tests (CPU) --ignores. AST/protobuf/regex plus tiny CPU model # tests/utils/, both of which Repo tests (CPU) excludes via --ignore
# loads; run cleanly here (transformers/torch installed). # because their sibling files need real GPUs / real HF weights.
# The five files below are pure-Python + AST/protobuf/regex tests
# that run cleanly on CPU. Env inherited from the job block.
run: | run: |
python -m pytest -q --tb=short \ python -m pytest -q --tb=short \
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_export_api_surface.py \
tests/saving/test_export_dispatch.py \
tests/saving/test_imatrix_export.py \
tests/saving/test_gguf_single_pass_export.py \
tests/saving/test_offline_gguf_vlm_tokenizer_7481.py \
tests/utils/test_attention_masks.py \ tests/utils/test_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 \ --deselect 'tests/utils/test_attention_masks.py::test_run_attention_flash_varlen_receives_window_and_softcap'
tests/test_bad_mappings_redirect.py \ # The deselected test monkeypatches flash_attn_varlen_func, which is
tests/test_prefetch_snapshot_scope.py \ # only bound on the module when `flash_attn` is importable. flash_attn
tests/test_gemma_2b_mapper_key.py \ # requires CUDA + dev toolchain, which the CPU-only ubuntu-latest
tests/test_raw_text_json_loading.py # runner does not have. The other 15 Bucket-A tests pass cleanly.
# test_run_attention_flash_varlen_receives_window_and_softcap was deselected
# until attention_dispatch.py predefined flash_attn_varlen_func as None; it
# monkeypatches that name, so it no longer needs flash_attn on this runner.
- name: unsloth_zoo @ ${{ env.UNSLOTH_ZOO_REF }} — full pytest (CPU) - 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
# cases below auto-skip on a GPU-less runner; deselect them # cases below auto-skip on a GPU-less runner; deselect them
# explicitly so the no-CUDA outcome is "deselected", not "skipped", # explicitly so the no-CUDA outcome is "deselected", not "skipped",
# making intent visible in the report. Env inherited from job block. # making intent visible in the report. Env inherited from job block.
#
# test_get_peft_model_passes_finetune_last_n_layers_through is
# deselected because unsloth_zoo/mlx/loader.py at line 2972 calls
# model.trainable_parameters() on the fake-model fixture, which
# the test never stubbed; this fails on every platform regardless
# of CUDA. Tracked upstream as an unsloth_zoo bug; deselecting
# here unblocks unsloth CI until the loader fixture is fixed.
working-directory: ${{ runner.temp }}/unsloth-zoo working-directory: ${{ runner.temp }}/unsloth-zoo
run: | run: |
python -m pytest -q --tb=short tests/ \ python -m pytest -q --tb=short tests/ \
--deselect tests/test_unsloth_zoo_lora_merge.py::test_active_merge_device_returns_string_on_cuda_host \ --deselect tests/test_unsloth_zoo_lora_merge.py::test_active_merge_device_returns_string_on_cuda_host \
--deselect tests/test_unsloth_zoo_lora_merge.py::test_merge_lora_moves_cpu_inputs_to_active_device \ --deselect tests/test_unsloth_zoo_lora_merge.py::test_merge_lora_moves_cpu_inputs_to_active_device
--deselect tests/test_mlx_finetune_last_n_layers.py::test_get_peft_model_passes_finetune_last_n_layers_through
- name: unsloth_zoo — test_apply_fused_lm_head (lives in compiler.py) - name: unsloth_zoo — test_apply_fused_lm_head (lives in compiler.py)
# `test_apply_fused_lm_head` lives at unsloth_zoo/compiler.py:1983, # `test_apply_fused_lm_head` lives at unsloth_zoo/compiler.py:1983,
@ -1017,10 +982,8 @@ jobs:
# First seen on transformers >=5,<6; each represents a slow # First seen on transformers >=5,<6; each represents a slow
# or recursive source-rewriter path the zoo can address. # or recursive source-rewriter path the zoo can address.
"beit": "TimeoutError: compile exceeds per-model budget", "beit": "TimeoutError: compile exceeds per-model budget",
"deepseek_ocr2": "TimeoutError: compile exceeds per-model budget",
"sam": "TimeoutError: compile exceeds per-model budget", "sam": "TimeoutError: compile exceeds per-model budget",
"sam_hq": "TimeoutError: compile exceeds per-model budget", "sam_hq": "TimeoutError: compile exceeds per-model budget",
"deepseek_ocr2": "TimeoutError: compile exceeds per-model budget",
} }
@ -2130,7 +2093,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"
@ -2182,7 +2145,7 @@ jobs:
python -m pip install --upgrade pip python -m pip install --upgrade pip
# Match the matrix job's torch path so unsloth_zoo's # Match the matrix job's torch path so unsloth_zoo's
# `import torch` resolves to the same CPU build. # `import torch` resolves to the same CPU build.
pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \ pip install --index-url https://download.pytorch.org/whl/cpu \
'torch>=2.4,<2.11' 'torchvision<0.26' 'torch>=2.4,<2.11' 'torchvision<0.26'
pip install \ pip install \
'numpy<3' protobuf sentencepiece \ 'numpy<3' protobuf sentencepiece \
@ -2220,13 +2183,12 @@ jobs:
pip install -e "$RUNNER_TEMP/unsloth-zoo" --no-deps pip install -e "$RUNNER_TEMP/unsloth-zoo" --no-deps
pip show unsloth_zoo pip show unsloth_zoo
- name: llama.cpp install via unsloth_zoo.llama_cpp + CLI `--help` smoke - name: llama.cpp install via unsloth_zoo.llama_cpp + `llama-cli --help` smoke
# Exercise the canonical `unsloth_zoo.llama_cpp.install_llama_cpp` # Exercise the canonical `unsloth_zoo.llama_cpp.install_llama_cpp`
# flow that GGUF export uses at runtime: clone ggml-org/llama.cpp # flow that GGUF export uses at runtime: clone ggml-org/llama.cpp
# into ~/.unsloth/llama.cpp, build the LLAMA_CPP_TARGETS list # into ~/.unsloth/llama.cpp, build the LLAMA_CPP_TARGETS list
# (llama-quantize, llama-cli, llama-mtmd-cli, llama-gguf-split, # (llama-quantize, llama-cli, llama-mtmd-cli, llama-gguf-split,
# llama-server) via cmake, then run `--help` on whichever CLI # llama-server) via cmake, then run `llama-cli --help`.
# inference binary the build actually produced.
# #
# This replaces the previous "download upstream prebuilt zip" # This replaces the previous "download upstream prebuilt zip"
# approach, which silently exited 0 with the message # approach, which silently exited 0 with the message
@ -2235,18 +2197,6 @@ jobs:
# matched their current asset names). The build path is the same # matched their current asset names). The build path is the same
# one Unsloth users hit in production via `model.save_pretrained_gguf`. # one Unsloth users hit in production via `model.save_pretrained_gguf`.
# #
# We do NOT hard-require `llama-cli` specifically: upstream
# ggml-org/llama.cpp moved the cli/server/ui targets behind the
# `LLAMA_BUILD_SERVER` cmake option (tools/CMakeLists.txt) and the
# set of binaries that survive a given checkout drifts over time
# (e.g. a recent build root shipped llama-server + llama-quantize
# + llama-diffusion-cli but no llama-cli). The durable contract is
# "install_llama_cpp produced a working CLI inference binary AND a
# working quantizer", so we --help-probe the first of
# llama-cli / llama-mtmd-cli / llama-server that exists. If a
# future llama.cpp restores llama-cli it is first in the list and
# is preferred, so this stays backwards compatible.
#
# Wall-time budget: ~3-5 min cold, dominated by cmake build of # Wall-time budget: ~3-5 min cold, dominated by cmake build of
# 5 targets on the runner's 4 cores. Apt-package install is # 5 targets on the runner's 4 cores. Apt-package install is
# handled by `install_llama_cpp` itself via its # handled by `install_llama_cpp` itself via its
@ -2281,9 +2231,8 @@ jobs:
print(f"Build targets: {LLAMA_CPP_TARGETS}") print(f"Build targets: {LLAMA_CPP_TARGETS}")
# install_llama_cpp returns (quantizer_path, converter_script_path). # install_llama_cpp returns (quantizer_path, converter_script_path).
# The quantizer's directory is the `llama.cpp` install root, which # The quantizer's directory is the `llama.cpp` install root, which
# also holds the CLI inference binaries after build/bin/llama-* gets # also holds llama-cli after build/bin/llama-* gets copied up
# copied up (llama_cpp.py:1450-1454; on Windows they stay in # (llama_cpp.py:867-871).
# build/bin/Release/).
quantizer, converter = install_llama_cpp(print_output=True) quantizer, converter = install_llama_cpp(print_output=True)
assert quantizer and os.path.exists(quantizer), ( assert quantizer and os.path.exists(quantizer), (
f"install_llama_cpp returned quantizer={quantizer!r} but file missing" f"install_llama_cpp returned quantizer={quantizer!r} but file missing"
@ -2292,54 +2241,25 @@ jobs:
f"install_llama_cpp returned converter={converter!r} but missing" f"install_llama_cpp returned converter={converter!r} but missing"
) )
install_root = os.path.dirname(quantizer) install_root = os.path.dirname(quantizer)
is_windows = sys.platform == "win32" cli = os.path.join(install_root, "llama-cli")
exe = ".exe" if is_windows else "" assert os.path.exists(cli), (
# Search both the copied-up root and the Windows build/bin/Release/ f"llama-cli not found at {cli!r} after build. Build root contents: "
# location the quantizer might already live in. f"{sorted(p for p in os.listdir(install_root) if p.startswith('llama-'))[:20]}"
search_dirs = [install_root] )
win_release = os.path.join(install_root, "build", "bin", "Release") assert os.access(cli, os.X_OK), f"{cli!r} not executable"
if win_release not in search_dirs: # `llama-cli --help` exits non-zero on some builds; the contract
search_dirs.append(win_release) # is that recognizable help text appears on stdout/stderr.
# Any of these proves a working llama.cpp CLI inference binary was
# built. Order = preference: llama-cli is canonical (restored first
# if upstream brings it back), then the multimodal CLI, then the
# server (always built whenever cli would be, behind LLAMA_BUILD_SERVER).
cli_names = [f"llama-cli{exe}", f"llama-mtmd-cli{exe}", f"llama-server{exe}"]
cli = None
cli_name = None
for name in cli_names:
for d in search_dirs:
candidate = os.path.join(d, name)
if os.path.exists(candidate) and (is_windows or os.access(candidate, os.X_OK)):
cli, cli_name = candidate, name
break
if cli is not None:
break
if cli is None:
found = []
for d in search_dirs:
if os.path.isdir(d):
found += [p for p in os.listdir(d) if p.startswith("llama-")]
raise AssertionError(
f"No CLI inference binary ({', '.join(cli_names)}) found after "
f"build in {search_dirs}. Build root contents: {sorted(set(found))[:20]}"
)
print(f"Using CLI inference binary: {cli_name} -> {cli}")
# `--help` exits non-zero on some builds; the contract is that
# recognizable help text appears on stdout/stderr. llama-server
# exposes a different flag set than llama-cli, so accept its
# tokens too (e.g. --host / --port / "server").
proc = subprocess.run( proc = subprocess.run(
[cli, "--help"], capture_output=True, text=True, timeout=30, [cli, "--help"], capture_output=True, text=True, timeout=30,
) )
combined = (proc.stdout or "") + (proc.stderr or "") combined = (proc.stdout or "") + (proc.stderr or "")
print(f"--- {cli_name} --help (first 30 lines) ---") print("--- llama-cli --help (first 30 lines) ---")
print("\n".join(combined.splitlines()[:30])) print("\n".join(combined.splitlines()[:30]))
assert any( assert any(
tok in combined.lower() tok in combined.lower()
for tok in ("usage", "--help", "--model", "-m,", "--host", "--port", "server") for tok in ("usage", "--help", "--model", "-m,")
), ( ), (
f"{cli_name} --help produced no recognizable help text. " f"llama-cli --help produced no recognizable help text. "
f"exit={proc.returncode}\nstdout: {proc.stdout[:400]!r}\n" f"exit={proc.returncode}\nstdout: {proc.stdout[:400]!r}\n"
f"stderr: {proc.stderr[:400]!r}" f"stderr: {proc.stderr[:400]!r}"
) )
@ -2355,7 +2275,7 @@ jobs:
f"stderr: {q.stderr[:400]!r}" f"stderr: {q.stderr[:400]!r}"
) )
print( print(
f"\nOK: install_llama_cpp produced a working {cli_name} at {cli} " f"\nOK: install_llama_cpp produced a working llama-cli at {cli} "
f"and llama-quantize at {quantizer}." f"and llama-quantize at {quantizer}."
) )
PY PY

View file

@ -1,78 +0,0 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Runs installer parity and autostart opt-out tests across all three platforms.
#
# Why: the parity test guards that install.sh and install.ps1 stay in sync.
# It originally ran only on ubuntu-latest through studio-backend-ci.yml.
# On Windows, Path.read_text() defaults to the cp1252 locale encoding, so a
# non-cp1252 byte in install.sh raises UnicodeDecodeError even though Linux
# and macOS default to UTF-8. The reads were pinned to encoding="utf-8" in
# #6166; this matrix keeps that from silently regressing. Pure pytest, no GPU,
# sub-second, so the matrix is cheap. Linux also runs the POSIX rollback test
# under dash, matching the supported curl-to-sh installer path.
name: Cross-platform parity
on:
pull_request:
paths:
- 'install.sh'
- 'install.ps1'
- 'tests/test_installer_skip_autostart.py'
- 'tests/python/test_cross_platform_parity.py'
- 'tests/sh/test_install_rollback_lifecycle.sh'
- 'tests/studio/test_install_rollback_lifecycle.ps1'
- '.github/workflows/cross-platform-parity-ci.yml'
push:
branches: [main]
paths:
- 'install.sh'
- 'install.ps1'
- 'tests/test_installer_skip_autostart.py'
- 'tests/python/test_cross_platform_parity.py'
- 'tests/sh/test_install_rollback_lifecycle.sh'
- 'tests/studio/test_install_rollback_lifecycle.ps1'
- '.github/workflows/cross-platform-parity-ci.yml'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
parity:
name: parity (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 10
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'
- run: python -m pip install -U pip pytest
- name: Cross-platform parity tests
env:
UNSLOTH_NO_TORCH: '1'
run: >-
python -m pytest
tests/python/test_cross_platform_parity.py
tests/test_installer_skip_autostart.py
-q
- name: PowerShell rollback lifecycle tests
if: runner.os == 'Windows'
shell: pwsh
run: pwsh -NoProfile -File tests/studio/test_install_rollback_lifecycle.ps1
- name: POSIX rollback lifecycle tests
if: runner.os == 'Linux'
run: sh tests/sh/test_install_rollback_lifecycle.sh

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
@ -79,64 +79,6 @@ jobs:
run: | run: |
ruff check unsloth unsloth_cli studio tests cli.py unsloth-cli.py ruff check unsloth unsloth_cli studio tests cli.py unsloth-cli.py
- name: Import-hoist verifier self-test
# scripts/verify_import_hoist.py is a scope-aware (LEGB) AST
# resolver that gates import-hoisting / alias-rename refactors
# against two bugs ruff and pyflakes both miss:
# 1. dangling alias -- `from a import b as _b` hoisted to
# `from a import b` but a leftover `_b` reference now
# resolves to nothing (or to some other module-level `_b`).
# 2. rename clash -- `_b -> b` silently re-points at a
# different object already named `b` in that scope.
# This step runs the tool's 8 negative-control cases so a
# regression in the verifier itself fails before we trust it on
# a diff. Hermetic, stdlib-only, sub-second. Hard gate.
run: |
python scripts/verify_import_hoist.py --self-test
- name: Import-hoist / alias-rename safety (changed Python files)
# Runs the verifier in compare mode on every in-place-modified
# .py in the PR: parses each file BEFORE (base branch) and AFTER
# (this diff), resolves every name load, and fails on a BLOCKER
# (dangling alias / rename clash / re-pointed import). INFO
# findings (a helper relocated to another file) do not fail.
#
# --diff-filter=M (in-place edits only) is deliberate: that is
# exactly where a hoist refactor lives, and it skips brand-new
# files whose re-export imports would otherwise look "unused".
#
# Diff against the true merge-base, not the base tip. A two-dot
# diff against the tip re-lints every file the base branch
# changed after the PR branched, comparing newer base code
# (BEFORE) against the PR's older snapshot (AFTER) - a
# time-reversed comparison that flags the base branch's own
# refactors as blockers on PRs that never touched those files.
# The compare API returns the merge-base without needing local
# history, and fetching that single commit by SHA keeps the
# shallow (fetch-depth: 1) clone.
if: github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
run: |
MERGE_BASE=$(gh api \
"repos/${{ github.repository }}/compare/${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}" \
--jq .merge_base_commit.sha)
git fetch --no-tags --depth=1 origin "$MERGE_BASE"
mapfile -t CHANGED < <(
git diff --name-only --diff-filter=M \
"$MERGE_BASE" HEAD -- '*.py' \
| grep -vE '(^|/)(unsloth_compiled_cache|node_modules|build|dist)/' || true
)
if [ "${#CHANGED[@]}" -eq 0 ]; then
echo "no in-place-modified Python files to check"
exit 0
fi
printf 'merge base: %s\n' "$MERGE_BASE"
printf 'checking %d file(s):\n' "${#CHANGED[@]}"
printf ' %s\n' "${CHANGED[@]}"
python scripts/verify_import_hoist.py \
--before "$MERGE_BASE" --after HEAD "${CHANGED[@]}"
- name: No leftover debugger / pdb / breakpoint calls - name: No leftover debugger / pdb / breakpoint calls
# Catches the "I'll just stick a breakpoint() here" mistake # Catches the "I'll just stick a breakpoint() here" mistake
# before it ships. AST-based so commented-out debugger # before it ships. AST-based so commented-out debugger

View file

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

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
@ -163,7 +163,7 @@ jobs:
'pytest==9.0.3' \ 'pytest==9.0.3' \
'pytest-asyncio==1.3.0' \ 'pytest-asyncio==1.3.0' \
'httpx==0.28.1' 'httpx==0.28.1'
pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \ pip install --index-url https://download.pytorch.org/whl/cpu \
'torch==2.10.0' 'torch==2.10.0'
# github.com occasionally 500s on the git fetch; retry the # github.com occasionally 500s on the git fetch; retry the
# zoo install so a single upstream blip does not fail CI. # zoo install so a single upstream blip does not fail CI.
@ -231,126 +231,68 @@ jobs:
tests/studio/test_is_mlx_dispatch_gate.py \ tests/studio/test_is_mlx_dispatch_gate.py \
tests/studio/test_mlx_training_worker_behaviors.py tests/studio/test_mlx_training_worker_behaviors.py
# Real MLX training + inference smoke test. Trains # Studio prebuilt llama.cpp install + GGUF inference. Drives the
# unsloth/gemma-3-270m-it for 7 deterministic LoRA steps # exact path Studio's setup.sh takes on macOS: invokes
# (batch_size=2, gradient_accumulation_steps=3) on a single # studio/install_llama_prebuilt.py with --published-repo
# repeated row ("<<HELLO!!>> My name is Unsloth!"), then saves # ggml-org/llama.cpp and --published-release-tag b9049 (the
# the trained model in 3 export formats. The `train` subcommand # latest llama.cpp release at the time this step was added; bump
# captures per-phase timing + peak GPU + peak RSS into # via UNSLOTH_LLAMA_TAG / DEFAULT_LLAMA_TAG when refreshing).
# train_metrics.json so we can detect regressions across CI runs. # The installer downloads llama-b9049-bin-macos-arm64.tar.gz,
- name: MLX export round-trip — TRAIN + SAVE 3 formats # which is the universal Apple Silicon (arm64) build -- the
env: # same artifact works on M1/M2/M3/M4 because llama.cpp compiles
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. # against the ARMv8.2 baseline.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} #
UNSLOTH_COMPILE_DISABLE: '1' # The b9049 release also publishes:
run: | # - llama-b9049-bin-macos-arm64-kleidiai.tar.gz
mkdir -p mlx_workdir # KleidiAI dispatches at runtime; on M1 it falls back where
# Authenticate llama.cpp's release-API lookup (anonymous 403s on rate-limit); # ISA features (e.g. I8MM) are missing, so this asset also
# read-only GITHUB_TOKEN scoped here only, never to steps that run binaries. # runs on M1 -- Studio just doesn't choose it by default.
GH_TOKEN="${{ secrets.GITHUB_TOKEN }}" GITHUB_TOKEN="${{ secrets.GITHUB_TOKEN }}" \ # - llama-b9049-bin-macos-x64.tar.gz
python tests/studio/run_real_mlx_smoke.py train \ # Intel-only; would only run on M1 via Rosetta 2 emulation,
--workdir "$PWD/mlx_workdir" # which we explicitly avoid.
# - iOS XCFramework
# Each reload step runs in a FRESH Python process to confirm # iOS-app build artifact, unrelated to a macOS desktop CI.
# the cold-start path users would hit in production also works #
# (not just the in-memory continuation of a still-running # After install, downloads a small published GGUF
# trainer). FastMLXModel.from_pretrained gets called from # (unsloth/gemma-3-270m-it-GGUF, Q4_K_M) from HuggingFace and
# scratch; mx.random is re-seeded; per-step timing + peak # runs the prebuilt llama-cli on it. Asserts the prompt echo
# memory are emitted to {format}_reload_metrics.json next to # appears in stdout. If the install fails OR the binary exits
# the saved dir. # non-zero, that's an Unsloth/Studio bug.
- name: MLX export round-trip — RELOAD LoRA (fresh process) - name: Studio prebuilt llama.cpp install + GGUF inference (Mac M1)
env:
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
UNSLOTH_COMPILE_DISABLE: '1'
run: |
python tests/studio/run_real_mlx_smoke.py reload \
--format lora \
--dir "$PWD/mlx_workdir/lora"
- name: MLX export round-trip — RELOAD merged_16bit (fresh process)
env:
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
UNSLOTH_COMPILE_DISABLE: '1'
run: |
python tests/studio/run_real_mlx_smoke.py reload \
--format merged \
--dir "$PWD/mlx_workdir/merged_16bit"
# GGUF reload uses the llama-cli binary that save_pretrained_gguf
# built. If save_pretrained_gguf was skipped during train (e.g.
# llama.cpp's convert_hf_to_gguf asserts on the model's tokenizer
# vocab -- a downstream llama.cpp limitation, not an unsloth_zoo
# bug), this step emits a workflow warning and exits 0 so the
# LoRA + merged_16bit assertions remain the gating signal.
- name: MLX export round-trip — RELOAD GGUF via llama-cli (fresh process)
env:
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
if python -c "import json,sys; m=json.load(open('mlx_workdir/train_metrics.json')); sys.exit(0 if m.get('gguf_supported') else 1)"; then
python tests/studio/run_real_mlx_smoke.py reload \
--format gguf \
--dir "$PWD/mlx_workdir/gguf"
else
REASON=$(python -c "import json; m=json.load(open('mlx_workdir/train_metrics.json')); print(m.get('gguf_skip_reason') or 'unknown')")
echo "::warning title=GGUF round-trip skipped::${REASON}"
echo "GGUF export was skipped during the train phase. Reason:"
echo " ${REASON}"
echo "Continuing without failing the job; the LoRA + merged_16bit"
echo "reload assertions are still gating this PR."
fi
# Print all metrics JSON files so regressions are visible in the
# job log. always() so we get telemetry even if a reload step
# asserted gibberish.
- name: MLX export round-trip — aggregate metrics
if: always()
run: |
for f in mlx_workdir/train_metrics.json \
mlx_workdir/lora_reload_metrics.json \
mlx_workdir/merged_reload_metrics.json \
mlx_workdir/gguf_reload_metrics.json; do
echo "=== $f ==="
cat "$f" 2>/dev/null || echo "(missing)"
echo
done
# Validates the macOS prebuilt path Unsloth's setup.sh uses (#5963): install the
# unslothai/llama.cpp fork's latest release, download a small public GGUF, and
# check llama-server /completion end to end. Split and placed last so the
# untrusted binary runs only in the final smoke step, after every HF_TOKEN step,
# leaving no token-bearing step or shared workspace for a tampered prebuilt to
# corrupt. GH_TOKEN: releases API; HF_TOKEN (withheld on PR): probe + GGUF fetch.
- name: Unsloth prebuilt llama.cpp install + GGUF download (Mac M1)
env: env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# install_llama_prebuilt.py hits the GitHub releases API to
# resolve the asset URL. Anonymous calls share the runner-IP
# rate-limit bucket and 403 quickly -- pass the workflow's
# automatic GITHUB_TOKEN to bump us to the 5000/hr authenticated
# bucket.
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
set -euo pipefail set -euo pipefail
INSTALL_DIR="$HOME/.unsloth-studio-prebuilt-test/llama.cpp" INSTALL_DIR="$HOME/.unsloth-studio-prebuilt-test/llama.cpp"
rm -rf "$INSTALL_DIR" rm -rf "$INSTALL_DIR"
# Download only -- no llama-quantize / llama-server launch in this step. # --simple-policy is required when --published-repo points
# at upstream ggml-org/llama.cpp; that repo doesn't ship the
# llama-prebuilt-manifest.json asset Studio's default policy
# expects, so the simple platform-specific policy maps
# Darwin+arm64 -> bin-macos-arm64 directly. studio/setup.sh
# passes both --published-repo ggml-org/llama.cpp AND
# --simple-policy automatically on macOS, so this CI step
# exercises the same code path users hit when they run
# `curl -fsSL https://unsloth.ai/install.sh | sh`.
python studio/install_llama_prebuilt.py \ python studio/install_llama_prebuilt.py \
--install-dir "$INSTALL_DIR" \ --install-dir "$INSTALL_DIR" \
--published-repo unslothai/llama.cpp --published-repo ggml-org/llama.cpp \
mkdir -p /tmp/ggufs --published-release-tag b9049 \
bash .github/scripts/hf-download-with-retry.sh \ --simple-policy
'unsloth/gemma-3-270m-it-GGUF' \
'gemma-3-270m-it-Q4_K_M.gguf' \
/tmp/ggufs
# Final step: runs the downloaded binaries with no secrets present, and clears # Studio bundles only llama-server + llama-quantize from the
# the GitHub Actions command files so a tampered prebuilt cannot influence the job. # prebuilt (not llama-cli) -- inference goes through
- name: Unsloth prebuilt llama.cpp GGUF inference smoke (Mac M1) # llama-server's HTTP /completion endpoint. Validate both:
run: | # llama-quantize --help proves the dynamic libs link, then
set -euo pipefail # spin up llama-server and POST a /completion request on a
unset GITHUB_ENV GITHUB_PATH GITHUB_OUTPUT GITHUB_STEP_SUMMARY # tiny published GGUF.
INSTALL_DIR="$HOME/.unsloth-studio-prebuilt-test/llama.cpp"
# Unsloth bundles only llama-server + llama-quantize (not llama-cli);
# inference goes through llama-server's HTTP /completion endpoint.
LLAMA_SERVER="$INSTALL_DIR/build/bin/llama-server" LLAMA_SERVER="$INSTALL_DIR/build/bin/llama-server"
LLAMA_QUANT="$INSTALL_DIR/build/bin/llama-quantize" LLAMA_QUANT="$INSTALL_DIR/build/bin/llama-quantize"
[ -x "$LLAMA_SERVER" ] || { echo "::error::llama-server missing at $LLAMA_SERVER"; find "$INSTALL_DIR/build" -type f | head -40; exit 1; } [ -x "$LLAMA_SERVER" ] || { echo "::error::llama-server missing at $LLAMA_SERVER"; find "$INSTALL_DIR/build" -type f | head -40; exit 1; }
@ -359,6 +301,12 @@ jobs:
echo "llama-quantize: $LLAMA_QUANT" echo "llama-quantize: $LLAMA_QUANT"
"$LLAMA_QUANT" --help >/dev/null && echo " llama-quantize loads OK" "$LLAMA_QUANT" --help >/dev/null && echo " llama-quantize loads OK"
mkdir -p /tmp/ggufs
bash .github/scripts/hf-download-with-retry.sh \
'unsloth/gemma-3-270m-it-GGUF' \
'gemma-3-270m-it-Q4_K_M.gguf' \
/tmp/ggufs
PORT=18080 PORT=18080
echo "=== starting llama-server on 127.0.0.1:$PORT ===" echo "=== starting llama-server on 127.0.0.1:$PORT ==="
"$LLAMA_SERVER" \ "$LLAMA_SERVER" \
@ -400,4 +348,83 @@ 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"
# Real MLX training + inference smoke test. Trains
# unsloth/gemma-3-270m-it for 7 deterministic LoRA steps
# (batch_size=2, gradient_accumulation_steps=3) on a single
# repeated row ("<<HELLO!!>> My name is Unsloth!"), then saves
# the trained model in 3 export formats. The `train` subcommand
# captures per-phase timing + peak GPU + peak RSS into
# train_metrics.json so we can detect regressions across CI runs.
- name: MLX export round-trip — TRAIN + SAVE 3 formats
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
UNSLOTH_COMPILE_DISABLE: '1'
run: |
mkdir -p mlx_workdir
python tests/studio/run_real_mlx_smoke.py train \
--workdir "$PWD/mlx_workdir"
# Each reload step runs in a FRESH Python process to confirm
# the cold-start path users would hit in production also works
# (not just the in-memory continuation of a still-running
# trainer). FastMLXModel.from_pretrained gets called from
# scratch; mx.random is re-seeded; per-step timing + peak
# memory are emitted to {format}_reload_metrics.json next to
# the saved dir.
- name: MLX export round-trip — RELOAD LoRA (fresh process)
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
UNSLOTH_COMPILE_DISABLE: '1'
run: |
python tests/studio/run_real_mlx_smoke.py reload \
--format lora \
--dir "$PWD/mlx_workdir/lora"
- name: MLX export round-trip — RELOAD merged_16bit (fresh process)
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
UNSLOTH_COMPILE_DISABLE: '1'
run: |
python tests/studio/run_real_mlx_smoke.py reload \
--format merged \
--dir "$PWD/mlx_workdir/merged_16bit"
# GGUF reload uses the llama-cli binary that save_pretrained_gguf
# built. If save_pretrained_gguf was skipped during train (e.g.
# llama.cpp's convert_hf_to_gguf asserts on the model's tokenizer
# vocab -- a downstream llama.cpp limitation, not an unsloth_zoo
# bug), this step emits a workflow warning and exits 0 so the
# LoRA + merged_16bit assertions remain the gating signal.
- name: MLX export round-trip — RELOAD GGUF via llama-cli (fresh process)
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
if python -c "import json,sys; m=json.load(open('mlx_workdir/train_metrics.json')); sys.exit(0 if m.get('gguf_supported') else 1)"; then
python tests/studio/run_real_mlx_smoke.py reload \
--format gguf \
--dir "$PWD/mlx_workdir/gguf"
else
REASON=$(python -c "import json; m=json.load(open('mlx_workdir/train_metrics.json')); print(m.get('gguf_skip_reason') or 'unknown')")
echo "::warning title=GGUF round-trip skipped::${REASON}"
echo "GGUF export was skipped during the train phase. Reason:"
echo " ${REASON}"
echo "Continuing without failing the job; the LoRA + merged_16bit"
echo "reload assertions are still gating this PR."
fi
# Print all metrics JSON files so regressions are visible in the
# job log. always() so we get telemetry even if a reload step
# asserted gibberish.
- name: MLX export round-trip — aggregate metrics
if: always()
run: |
for f in mlx_workdir/train_metrics.json \
mlx_workdir/lora_reload_metrics.json \
mlx_workdir/merged_reload_metrics.json \
mlx_workdir/gguf_reload_metrics.json; do
echo "=== $f ==="
cat "$f" 2>/dev/null || echo "(missing)"
echo
done

View file

@ -263,7 +263,7 @@ jobs:
# unsloth_zoo.vision_utils imports PIL at module top, and the # unsloth_zoo.vision_utils imports PIL at module top, and the
# easiest way to get a torch-compatible PIL on a CPU runner is # easiest way to get a torch-compatible PIL on a CPU runner is
# to let torchvision pull the right Pillow version. # to let torchvision pull the right Pillow version.
pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \ pip install --index-url https://download.pytorch.org/whl/cpu \
'torch>=2.8,<2.11' 'torchvision<0.26' 'torch>=2.8,<2.11' 'torchvision<0.26'
# Pin to the same versions update_all_notebooks.py installs in # Pin to the same versions update_all_notebooks.py installs in
# generated notebooks. Keep these in lockstep with PIN_TRL / # generated notebooks. Keep these in lockstep with PIN_TRL /
@ -285,15 +285,7 @@ jobs:
# The PR-time CI must validate the code in this PR; PyPI unsloth # The PR-time CI must validate the code in this PR; PyPI unsloth
# may lag the in-repo CPU-torch fallback in unsloth/kernels/utils.py # may lag the in-repo CPU-torch fallback in unsloth/kernels/utils.py
# (lines 162-170) that handles missing torch._C._cuda_getCurrentRawStream. # (lines 162-170) that handles missing torch._C._cuda_getCurrentRawStream.
# unsloth_zoo from git main mirrors every other CI (Core / MLX / pip install --no-deps unsloth_zoo
# install.sh) so PR-time validation sees the same zoo HEAD.
for attempt in 1 2 3; do
if pip install --no-deps "unsloth_zoo @ git+https://github.com/unslothai/unsloth-zoo"; then
break
fi
[ "$attempt" -eq 3 ] && { echo "::error::unsloth_zoo install failed after 3 attempts"; exit 1; }
sleep $((5 * attempt))
done
pip install --no-deps -e ./unsloth pip install --no-deps -e ./unsloth
- name: Convert notebooks for AST scan - name: Convert notebooks for AST scan

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
@ -364,7 +353,7 @@ jobs:
if: matrix.platform == 'ubuntu-22.04' if: matrix.platform == 'ubuntu-22.04'
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
# ── Node.js ── # ── Node.js ──
- name: Setup Node.js - name: Setup Node.js
@ -417,77 +406,38 @@ jobs:
if (config.bundle?.linux?.rpm) { if (config.bundle?.linux?.rpm) {
throw new Error('bundle.linux.rpm must not be configured'); throw new Error('bundle.linux.rpm must not be configured');
} }
if (config.bundle?.linux?.appimage?.bundleMediaFramework !== false) {
throw new Error('Linux AppImage bundleMediaFramework must stay false');
}
const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8'); const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8');
const lines = workflow.split(/\r?\n/); const lines = workflow.split(/\r?\n/);
const linuxInstallLines = lines.filter((line) => line.includes('sudo apt-get install')); const releaseBodies = [];
const ayatanaPackage = ['libayatana', 'appindicator3-dev'].join('-'); for (let i = 0; i < lines.length; i += 1) {
if (linuxInstallLines.some((line) => line.includes(ayatanaPackage))) { const match = lines[i].match(/^(\s*)releaseBody:\s*\|\s*$/);
throw new Error('Desktop Linux release must not install the Ayatana appindicator dev package'); if (!match) continue;
} const baseIndent = match[1].length;
if (!linuxInstallLines.some((line) => line.includes('libappindicator3-dev'))) { const bodyLines = [];
throw new Error('Desktop Linux release must install libappindicator3-dev'); i += 1;
} for (; i < lines.length; i += 1) {
const linuxdeployLines = lines.filter((line) => line.includes('github.com/linuxdeploy/linuxdeploy/releases/download')); const line = lines[i];
if (!linuxdeployLines.some((line) => line.includes('1-alpha-20250213-2/linuxdeploy-x86_64.AppImage'))) { if (line.trim() === '') {
throw new Error('Desktop Linux release must pin linuxdeploy 1-alpha-20250213-2'); bodyLines.push('');
} continue;
// A pinned version/path is reproducibility, not integrity: the asset }
// can be replaced after upload. Require the immutable SHA-256 digest const indent = line.match(/^\s*/)[0].length;
// to be pinned AND verified before chmod +x. Scope every check to the if (indent <= baseIndent) {
// real "Pin linuxdeploy for AppImage" step so this guard cannot i -= 1;
// satisfy itself; a file-wide scan would match the guard's own code. break;
const expectedLinuxdeployDigest = '4648f278ab3ef31f819e67c30d50f462640e5365a77637d7e6f2ad9fd0b4522a'; }
const isComment = (line) => { bodyLines.push(line.slice(baseIndent + 2));
const trimmed = line.trim();
return trimmed.startsWith('#') || trimmed.startsWith('//');
};
const stepStart = lines.findIndex((line) => /^\s*- name: Pin linuxdeploy for AppImage\s*$/.test(line));
if (stepStart === -1) {
throw new Error('Desktop Linux release must keep the "Pin linuxdeploy for AppImage" step');
}
const stepIndent = lines[stepStart].search(/\S/);
let stepEnd = lines.length;
for (let i = stepStart + 1; i < lines.length; i += 1) {
const line = lines[i];
if (line.trim() === '') continue;
const indent = line.search(/\S/);
// The next sibling step ('- ...') at the same indent, or any dedent
// below the step, ends this step's block.
if (indent < stepIndent || (indent === stepIndent && /^\s*-\s/.test(line))) {
stepEnd = i;
break;
} }
releaseBodies.push(bodyLines.join('\n'));
} }
const stepLines = lines.slice(stepStart, stepEnd); if (releaseBodies.length === 0) {
const digestEnvRe = /^\s*LINUXDEPLOY_SHA256:\s*["']([0-9a-f]{64})["']\s*$/; throw new Error('Expected at least one desktop release body');
const digestEnvLine = stepLines.find((line) => digestEnvRe.test(line));
if (!digestEnvLine || digestEnvLine.match(digestEnvRe)[1] !== expectedLinuxdeployDigest) {
throw new Error('Desktop Linux release must pin the linuxdeploy SHA-256 digest in the LINUXDEPLOY_SHA256 env');
} }
const sha256Idx = stepLines.findIndex((line) => !isComment(line) && line.includes('sha256sum -c')); for (const body of releaseBodies) {
if (sha256Idx === -1) { if (/\brpm\b|\.rpm/i.test(body)) {
throw new Error('Desktop Linux release must verify the linuxdeploy digest with sha256sum -c before use'); throw new Error('Desktop release body must not advertise RPM packages');
} }
const chmodIdx = stepLines.findIndex((line) => !isComment(line) && /chmod\s+\+x/.test(line));
if (chmodIdx !== -1 && sha256Idx > chmodIdx) {
throw new Error('Desktop Linux release must verify the linuxdeploy digest before chmod +x');
}
const releaseBody = process.env.DESKTOP_RELEASE_NOTES;
if (!releaseBody) {
throw new Error('DESKTOP_RELEASE_NOTES must not be empty');
}
if (/\brpm\b|\.rpm/i.test(releaseBody)) {
throw new Error('Desktop release body must not advertise RPM packages');
}
if (/AppImage.*universal|universal.*AppImage/i.test(releaseBody)) {
throw new Error('Desktop release body must not advertise AppImage as universal');
}
if (!/AppImage.*experimental/i.test(releaseBody)) {
throw new Error('Desktop release body must mark AppImage as experimental');
} }
JS JS
@ -601,7 +551,7 @@ jobs:
- name: Install trusted-signing-cli - name: Install trusted-signing-cli
if: matrix.platform == 'windows-latest' if: matrix.platform == 'windows-latest'
run: | run: |
cargo install trusted-signing-cli --version 0.10.0 --locked cargo install trusted-signing-cli --version 0.9.0 --locked
echo "$env:USERPROFILE\.cargo\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append echo "$env:USERPROFILE\.cargo\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
# ── Windows: verify signing CLI is accessible ── # ── Windows: verify signing CLI is accessible ──
@ -612,53 +562,39 @@ jobs:
Get-Command trusted-signing-cli -ErrorAction SilentlyContinue || Write-Output "trusted-signing-cli NOT in PATH" Get-Command trusted-signing-cli -ErrorAction SilentlyContinue || Write-Output "trusted-signing-cli NOT in PATH"
trusted-signing-cli --version || Write-Output "trusted-signing-cli failed to run" trusted-signing-cli --version || Write-Output "trusted-signing-cli failed to run"
# ── Linux: pin AppImage packaging toolchain ── # ── Linux: build + sign + upload ──
- name: Pin linuxdeploy for AppImage
if: matrix.platform == 'ubuntu-22.04'
shell: bash
env:
# Pinning the versioned release path is reproducibility, not
# integrity: a GitHub release asset can be replaced (or its delivery
# path compromised) after upload. The SHA-256 below is the immutable
# digest of this exact asset and is the integrity gate. If linuxdeploy
# publishes a new build under this tag, this run fails closed and the
# digest must be re-pinned deliberately.
LINUXDEPLOY_URL: "https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20250213-2/linuxdeploy-x86_64.AppImage"
LINUXDEPLOY_SHA256: "4648f278ab3ef31f819e67c30d50f462640e5365a77637d7e6f2ad9fd0b4522a"
run: |
set -euo pipefail
tools_dir="$RUNNER_TEMP/tauri-tools-cache/tauri"
mkdir -p "$tools_dir"
dest="$tools_dir/linuxdeploy-x86_64.AppImage"
curl -fsSL "$LINUXDEPLOY_URL" -o "$dest"
# Verify the digest BEFORE the binary is ever marked executable. The
# next step builds the AppImage with the Tauri signing key, so a
# substituted linuxdeploy that ran here could exfiltrate signing
# material or tamper with release artifacts. Fail closed on any
# mismatch.
echo "${LINUXDEPLOY_SHA256} ${dest}" | sha256sum -c -
chmod +x "$dest"
# ── Linux: build + sign ──
- name: Build Linux app - 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
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` (Ubuntu/Debian) or `.AppImage` (universal).
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
releaseDraft: ${{ inputs.draft }}
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
args: -v ${{ matrix.args }} 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 +604,28 @@ 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` (Ubuntu/Debian) or `.AppImage` (universal).
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
releaseDraft: ${{ inputs.draft }}
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
args: -v ${{ matrix.args }} 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 +636,43 @@ 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` (Ubuntu/Debian) or `.AppImage` (universal).
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
releaseDraft: ${{ inputs.draft }}
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
args: -v ${{ matrix.args }} 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 +697,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 +756,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 +788,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 +878,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]
@ -74,31 +72,6 @@ concurrency:
permissions: permissions:
contents: read contents: read
# ──────────────────────────────────────────────────────────────────────
# Network-resilience knobs, applied to every job/step. These add retries
# and backoff ONLY; they do not relax a single integrity check. cargo
# still resolves against Cargo.lock (--locked), pip still verifies the
# wheels it downloads, npm still enforces package-lock integrity, the
# harden-runner egress allowlists below are unchanged, and every action
# stays SHA-pinned. The advisory-audit run on 2026-05-29 red-failed when
# one crates.io tarball fetch hit "Recv failure: Connection reset by
# peer" (curl 56); cargo's default of 3 retries over an HTTP/2-multiplexed
# connection did not recover. The settings below make that class of
# transient fault self-heal instead of failing the whole run.
env:
# pip: raise the built-in retry count and per-connection timeout.
PIP_RETRIES: "10"
PIP_DEFAULT_TIMEOUT: "60"
# cargo: retry network ops and disable HTTP/2 multiplexing -- the
# documented mitigation for the curl-56 connection resets above.
CARGO_NET_RETRY: "10"
CARGO_HTTP_MULTIPLEXING: "false"
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
# npm: retry registry fetches with capped exponential backoff.
NPM_CONFIG_FETCH_RETRIES: "5"
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: "2000"
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "60000"
jobs: jobs:
# ───────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────
# Combined advisory-DB audit: pip-audit + npm audit + cargo audit # Combined advisory-DB audit: pip-audit + npm audit + cargo audit
@ -167,7 +140,7 @@ jobs:
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @ 2026-03-27 - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @ 2026-03-27
- uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1
with: with:
workspaces: studio/src-tauri -> target workspaces: studio/src-tauri -> target
@ -180,23 +153,8 @@ jobs:
# crashes with a TOML parse error on that file. # crashes with a TOML parse error on that file.
# npm audit is bundled with the node toolchain, no install. # npm audit is bundled with the node toolchain, no install.
run: | run: |
retry() { # retry <max-attempts> <command...> with exponential backoff python -m pip install --upgrade pip 'pip-audit>=2.7'
local max="$1"; shift cargo install --locked --version '^0.22' cargo-audit
local n=1 delay=5
until "$@"; do
if [ "$n" -ge "$max" ]; then
echo "::error::command failed after ${n} attempts: $*" >&2
return 1
fi
echo "attempt ${n}/${max} failed; retrying in ${delay}s: $*" >&2
sleep "$delay"; n=$((n + 1)); delay=$((delay * 2))
done
}
retry 5 python -m pip install --upgrade pip 'pip-audit>=2.7'
# --locked keeps the resolved tree identical to Cargo.lock; the
# CARGO_NET_* env above plus this outer loop survive transient
# crates.io connection resets without weakening that guarantee.
retry 5 cargo install --locked --version '^0.22' cargo-audit
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# Python: pip-audit # Python: pip-audit
@ -218,7 +176,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 +211,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 +284,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 +300,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 +308,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 +320,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
@ -372,60 +330,32 @@ jobs:
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# OSV-Scanner: cross-ecosystem advisory DB (PyPI + npm + cargo) # OSV-Scanner: cross-ecosystem advisory DB (PyPI + npm + cargo)
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
- name: Download + verify OSV-Scanner
# Split out from the scan below so binary integrity is a HARD gate:
# a checksum mismatch (swapped release asset, the Trivy-style pivot
# this workflow refuses) fails the job instead of being swallowed by
# the scan step's continue-on-error. A download still failing after
# retries is transient, so we skip the scan rather than red-fail.
# SHA-256 verified BEFORE chmod +x / exec. Bump OSV_SHA256 in lockstep
# with OSV_VERSION (value from the release's osv-scanner_SHA256SUMS).
run: |
set -euo pipefail
OSV_VERSION="v2.0.2"
OSV_SHA256="3abcfd7126c453a00421487e721b296e0cb68085bd431d6cef60872774170fc8"
if ! curl --proto '=https' --tlsv1.2 -fsSL \
--retry 5 --retry-delay 3 --retry-connrefused --retry-all-errors \
-o /tmp/osv-scanner \
"https://github.com/google/osv-scanner/releases/download/${OSV_VERSION}/osv-scanner_linux_amd64"; then
echo "::warning::osv-scanner download failed after retries; skipping scan" >&2
rm -f /tmp/osv-scanner
exit 0 # transient availability: do not red-fail the job
fi
if ! echo "${OSV_SHA256} /tmp/osv-scanner" | sha256sum -c -; then
echo "::error::osv-scanner checksum mismatch; refusing to execute" >&2
rm -f /tmp/osv-scanner
exit 1 # integrity failure: hard-fail
fi
chmod +x /tmp/osv-scanner
/tmp/osv-scanner --version
- name: OSV-Scanner (PyPI + npm + cargo, cross-ecosystem advisories) - name: OSV-Scanner (PyPI + npm + cargo, cross-ecosystem advisories)
# OSV's advisory feed is a superset of GitHub-Advisory + RustSec # OSV's advisory feed is a superset of GitHub-Advisory + RustSec
# + npm advisories; running it alongside the per-ecosystem audit # + npm advisories; running it alongside the per-ecosystem audit
# tools catches CVEs that haven't propagated to the per-ecosystem # tools catches CVEs that haven't propagated to the per-ecosystem
# DBs yet (e.g. langchain-core CVE-2025-68664 was on OSV before # DBs yet (e.g. langchain-core CVE-2025-68664 was on OSV before
# GitHub Advisory). Single binary, one transitive resolver, all # GitHub Advisory). Single binary, one transitive resolver, all
# three lockfile types in one pass. Binary is checksum-verified in # three lockfile types in one pass. Non-blocking until baselines
# the step above; only the advisory scan stays non-blocking until # close.
# baselines close.
continue-on-error: true continue-on-error: true
run: | run: |
set +e set +e
if [ ! -x /tmp/osv-scanner ]; then # OSV-Scanner ships a raw binary (no tarball) in v2.x.
echo "osv-scanner unavailable this run; skipping scan" | tee logs-osv-scanner.txt curl -fsSL -o /tmp/osv-scanner \
else https://github.com/google/osv-scanner/releases/download/v2.0.2/osv-scanner_linux_amd64
/tmp/osv-scanner scan source \ chmod +x /tmp/osv-scanner
--lockfile=studio/frontend/package-lock.json \ /tmp/osv-scanner --version
--lockfile=studio/src-tauri/Cargo.lock \ /tmp/osv-scanner scan source \
--lockfile=requirements.txt:audit-reqs/unsloth-deps.txt \ --lockfile=studio/frontend/package-lock.json \
--lockfile=requirements.txt:audit-reqs/studio.txt \ --lockfile=studio/src-tauri/Cargo.lock \
--lockfile=requirements.txt:audit-reqs/no-torch-runtime.txt \ --lockfile=requirements.txt:audit-reqs/unsloth-deps.txt \
--lockfile=requirements.txt:audit-reqs/overrides.txt \ --lockfile=requirements.txt:audit-reqs/studio.txt \
--lockfile=requirements.txt:audit-reqs/extras.txt \ --lockfile=requirements.txt:audit-reqs/no-torch-runtime.txt \
--lockfile=requirements.txt:audit-reqs/extras-no-deps.txt \ --lockfile=requirements.txt:audit-reqs/overrides.txt \
--format=table 2>&1 | tee logs-osv-scanner.txt --lockfile=requirements.txt:audit-reqs/extras.txt \
fi --lockfile=requirements.txt:audit-reqs/extras-no-deps.txt \
--format=table 2>&1 | tee logs-osv-scanner.txt
{ {
echo "## OSV-Scanner (cross-ecosystem)" echo "## OSV-Scanner (cross-ecosystem)"
echo echo
@ -436,7 +366,7 @@ jobs:
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# Semgrep: design-flaw detection (catches what regex-pattern # Semgrep: design-flaw detection (catches what regex-pattern
# scanning of malicious authors cannot, e.g. first-party logic bugs # scanning of malicious authors cannot first-party logic bugs
# like langchain-core CVE-2025-68664 dumps/dumpd injection, # like langchain-core CVE-2025-68664 dumps/dumpd injection,
# n8n CVE-2025-68668 _pyodide.eval_code sandbox escape, marimo # n8n CVE-2025-68668 _pyodide.eval_code sandbox escape, marimo
# CVE-2026-39987 unauth WebSocket). # CVE-2026-39987 unauth WebSocket).
@ -559,7 +489,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 +670,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 +679,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)
@ -851,13 +781,10 @@ jobs:
grep -q "Standalone pre-install package scanner" scripts/scan_packages.py grep -q "Standalone pre-install package scanner" scripts/scan_packages.py
- name: Scan declared + transitive Python deps - name: Scan declared + transitive Python deps
# scan_packages.py exits 1 on NON-baselined CRITICAL/HIGH # scan_packages.py exits 1 on CRITICAL/HIGH findings, 0 on
# findings, 0 otherwise. It scans code-only (docstrings and # clean. We swallow the exit because the baseline isn't
# comments are blanked first) and suppresses reviewed # triaged yet; surface the findings in the workflow summary.
# known-good findings via scripts/scan_packages_baseline.json, # Drop continue-on-error after the first clean run on main.
# so legitimate-library noise no longer red-fails the gate.
# The step stays advisory until SCAN_ENFORCE=1 (see env below);
# then PIPESTATUS propagates the scanner's exit code.
# #
# `--with-deps` walks PyPI metadata to enumerate every # `--with-deps` walks PyPI metadata to enumerate every
# transitive dep the declared set would install, then scans # transitive dep the declared set would install, then scans
@ -874,14 +801,6 @@ jobs:
# downloads in exchange for wall-clock parallelism. # downloads in exchange for wall-clock parallelism.
env: env:
SHARD_FILES: ${{ matrix.shard.files }} SHARD_FILES: ${{ matrix.shard.files }}
# Enforcement switch. "1" = blocking: a non-baselined CRITICAL/HIGH
# fails the build. scan_packages.py scans code-only (docstrings/comments
# stripped), fetches sdist-only packages directly from PyPI (no build)
# so every shard resolves, and honors the reviewed allowlist at
# scripts/scan_packages_baseline.json, so only NON-baselined
# CRITICAL/HIGH cause its exit 1. The committed baseline makes all three
# shards exit 0 today; set this back to "0" to return to advisory.
SCAN_ENFORCE: "1"
run: | run: |
set +e set +e
mkdir -p logs mkdir -p logs
@ -897,14 +816,12 @@ jobs:
fi fi
done done
echo "::endgroup::" echo "::endgroup::"
rc=0
if [ ${#REQ_ARGS[@]} -eq 0 ]; then if [ ${#REQ_ARGS[@]} -eq 0 ]; then
echo "[security-audit] shard ${{ matrix.shard.id }}: no PyPI specs, nothing to scan" \ echo "[security-audit] shard ${{ matrix.shard.id }}: no PyPI specs, nothing to scan" \
| tee "$LOG" | tee "$LOG"
else else
python scripts/scan_packages.py --with-deps "${REQ_ARGS[@]}" \ python scripts/scan_packages.py --with-deps "${REQ_ARGS[@]}" \
2>&1 | tee "$LOG" 2>&1 | tee "$LOG"
rc=${PIPESTATUS[0]}
fi fi
{ {
echo "## scan_packages :: shard ${{ matrix.shard.id }}" echo "## scan_packages :: shard ${{ matrix.shard.id }}"
@ -912,19 +829,11 @@ jobs:
echo "### Files in this shard" echo "### Files in this shard"
for f in $SHARD_FILES; do echo "- audit-reqs/$f.txt"; done for f in $SHARD_FILES; do echo "- audit-reqs/$f.txt"; done
echo echo
echo "scan_packages.py exit code: $rc (enforce=$SCAN_ENFORCE)"
echo
echo '### Findings (tail)' echo '### Findings (tail)'
echo '```' echo '```'
tail -200 "$LOG" tail -200 "$LOG"
echo '```' echo '```'
} >> "$GITHUB_STEP_SUMMARY" } >> "$GITHUB_STEP_SUMMARY"
# Advisory by default; blocking once SCAN_ENFORCE=1 and the baseline
# is committed. PIPESTATUS is captured above so `tee` does not mask the
# scanner's exit code.
if [ "$SCAN_ENFORCE" = "1" ]; then
exit "$rc"
fi
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always() if: always()
@ -964,7 +873,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: []
@ -998,37 +907,24 @@ jobs:
python3 -c "import ast; ast.parse(open('scripts/scan_npm_packages.py').read())" python3 -c "import ast; ast.parse(open('scripts/scan_npm_packages.py').read())"
- name: Scan npm tarballs (declared + transitive, no install) - name: Scan npm tarballs (declared + transitive, no install)
# scan_npm_packages.py exits 1 on NON-baselined HIGH/CRITICAL # The script exits 1 on HIGH/CRITICAL findings; we capture the
# findings, 0 otherwise. It scans code-only (JS/TS comments are # full log and surface it in the step summary either way. It
# blanked first) and honors a reviewed allowlist at # never runs `npm install`, never executes anything from a
# scripts/scan_npm_packages_baseline.json. It never runs # downloaded tarball, and only fetches from registry.npmjs.org.
# `npm install`, never executes anything from a downloaded # Initially non-blocking so the baseline can settle; drop
# tarball, and only fetches from registry.npmjs.org. The npm # continue-on-error once the baseline is clean for a week.
# corpus is clean (the baseline is empty), so the gate is
# enforcing (SCAN_ENFORCE=1) and any new finding fails the build.
env:
SCAN_ENFORCE: "1"
run: | run: |
set +e set -o pipefail
LOG=logs-scan-npm.txt LOG=logs-scan-npm.txt
python3 scripts/scan_npm_packages.py 2>&1 | tee "$LOG" python3 scripts/scan_npm_packages.py 2>&1 | tee "$LOG"
rc=${PIPESTATUS[0]}
{ {
echo "## scan_npm_packages" echo "## scan_npm_packages"
echo echo
echo "scan_npm_packages.py exit code: $rc (enforce=$SCAN_ENFORCE)"
echo
echo '### Findings (tail)' echo '### Findings (tail)'
echo '```' echo '```'
tail -300 "$LOG" tail -300 "$LOG"
echo '```' echo '```'
} >> "$GITHUB_STEP_SUMMARY" } >> "$GITHUB_STEP_SUMMARY"
# Blocking: the npm corpus is clean, so any non-baselined
# HIGH/CRITICAL is new and should fail the build. PIPESTATUS is
# captured above so `tee` does not mask the scanner's exit code.
if [ "$SCAN_ENFORCE" = "1" ]; then
exit "$rc"
fi
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always() if: always()
@ -1173,29 +1069,13 @@ 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
# not run any third-party hook to set up the audit. # not run any third-party hook to set up the audit.
working-directory: studio/frontend working-directory: studio/frontend
run: | run: npm ci --ignore-scripts
retry() { # retry <max-attempts> <command...> with exponential backoff
local max="$1"; shift
local n=1 delay=5
until "$@"; do
if [ "$n" -ge "$max" ]; then
echo "::error::command failed after ${n} attempts: $*" >&2
return 1
fi
echo "attempt ${n}/${max} failed; retrying in ${delay}s: $*" >&2
sleep "$delay"; n=$((n + 1)); delay=$((delay * 2))
done
}
# --ignore-scripts is mandatory here (no third-party hook runs);
# the retry only re-attempts the registry fetch, it never relaxes
# that flag or the package-lock integrity check npm ci enforces.
retry 5 npm ci --ignore-scripts
- name: npm audit signatures (informational) - name: npm audit signatures (informational)
# Surfaces unsigned / mis-signed packages from the npm # Surfaces unsigned / mis-signed packages from the npm

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:
@ -77,32 +77,28 @@ jobs:
path: hf-cache path: hf-cache
# Same key as studio-ui-smoke.yml so the two jobs share a # Same key as studio-ui-smoke.yml so the two jobs share a
# single GGUF download across CI. # single GGUF download across CI.
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
- name: Prime HF_HOME with the GGUF - name: Prime HF_HOME with the GGUF
id: prime-hf id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success' if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env: env:
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
python -m pip install --upgrade huggingface_hub python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache mkdir -p hf-cache
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE"
bash .github/scripts/hf-download-with-retry.sh ggml-org/models tinyllamas/stories260K.gguf
- name: Save HF_HOME for ${{ env.GGUF_REPO }} - name: Save HF_HOME for ${{ env.GGUF_REPO }}
if: always() && steps.prime-hf.outcome == 'success' if: always() && steps.prime-hf.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
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 }}-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.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
mkdir -p logs mkdir -p logs
set -o pipefail set -o pipefail
@ -111,10 +107,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 +140,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 +149,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,20 +64,19 @@ 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
# suite (gpu_selection, kv_cache_estimation, utils). CPU-only torch # suite (gpu_selection, kv_cache_estimation, utils). CPU-only torch
# keeps the install ~250 MB / ~1 min on a clean runner. # keeps the install ~250 MB / ~1 min on a clean runner.
pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple 'torch>=2.4,<2.11' pip install --index-url https://download.pytorch.org/whl/cpu 'torch>=2.4,<2.11'
pip install 'transformers>=4.51,<5.5' pip install 'transformers>=4.51,<5.5'
- name: Backend tests - name: Backend tests
@ -141,30 +133,20 @@ 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.
pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \ pip install --index-url https://download.pytorch.org/whl/cpu \
'torch>=2.4,<2.11' 'torchvision<0.26' 'torch>=2.4,<2.11' 'torchvision<0.26'
pip install 'transformers>=4.51,<5.5' pip install 'transformers>=4.51,<5.5'
# bitsandbytes: hard import in unsloth/models/_utils.py. Recent # bitsandbytes: hard import in unsloth/models/_utils.py. Recent
# versions ship a CPU build that imports cleanly on Linux. # versions ship a CPU build that imports cleanly on Linux.
pip install 'bitsandbytes>=0.45' pip install 'bitsandbytes>=0.45'
# unsloth.device_type imports unsloth_zoo.utils.Version at module # unsloth.device_type imports unsloth_zoo.utils.Version at module
# scope, so the conftest preload needs unsloth_zoo. Pull from # scope, so the conftest preload needs unsloth_zoo even though
# git main so this job sees the same zoo HEAD as Core / MLX / # it is an optional dep of unsloth.
# install.sh do (otherwise a fix on zoo main hides until release). pip install 'unsloth_zoo>=2026.5.1'
# No --no-deps: matches prior `pip install 'unsloth_zoo>=2026.5.1'`
# behaviour so triton etc. still come in for the Repo tests CPU
# collection imports.
for attempt in 1 2 3; do
if pip install "unsloth_zoo @ git+https://github.com/unslothai/unsloth-zoo"; then
break
fi
[ "$attempt" -eq 3 ] && { echo "::error::unsloth_zoo install failed after 3 attempts"; exit 1; }
sleep $((5 * attempt))
done
pip install -e . --no-deps pip install -e . --no-deps
- name: Repo tests (CPU, auto-discovered) - name: Repo tests (CPU, auto-discovered)
@ -200,7 +182,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 +194,28 @@ 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_tauri_install_exit_order.sh \
*" $(basename "$s") "*) echo "skipping $s (see workflow comment)"; continue ;; tests/sh/test_torch_constraint.sh; do
esac
found=$((found + 1))
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

@ -17,8 +17,6 @@ on:
- 'studio/frontend/**' - 'studio/frontend/**'
- 'scripts/check_frontend_dep_removal.py' - 'scripts/check_frontend_dep_removal.py'
- 'tests/studio/test_frontend_dep_removal.py' - 'tests/studio/test_frontend_dep_removal.py'
- 'scripts/sync_allow_scripts_pins.py'
- 'tests/studio/test_sync_allow_scripts_pins.py'
- '.github/workflows/studio-frontend-ci.yml' - '.github/workflows/studio-frontend-ci.yml'
push: push:
branches: [main, pip] branches: [main, pip]
@ -62,19 +60,6 @@ jobs:
with: with:
node-version: '22' node-version: '22'
# node 22 bundles npm 10.x, which predates allowScripts. Move to the
# 11.x line and fail loudly if the gate is still missing, so the
# strict flag below can never silently degrade into a warning.
- name: Upgrade npm to 11.x (allowScripts enforcement)
working-directory: ${{ github.workspace }}
run: |
npm install -g npm@^11 --no-fund --no-audit
V=$(npm -v)
case "$V" in
11.1[6-9].*|11.[2-9][0-9].*|1[2-9].*) echo "npm $V has allowScripts" ;;
*) echo "::error::npm $V lacks allowScripts (need >=11.16)"; exit 1 ;;
esac
# Run the structural lockfile scan BEFORE npm ci. A compromised # Run the structural lockfile scan BEFORE npm ci. A compromised
# tarball runs its `prepare` / `postinstall` during `npm ci`, # tarball runs its `prepare` / `postinstall` during `npm ci`,
# so any catch has to fire upstream of that. The scanner is # so any catch has to fire upstream of that. The scanner is
@ -83,23 +68,14 @@ jobs:
working-directory: ${{ github.workspace }} working-directory: ${{ github.workspace }}
run: python3 scripts/lockfile_supply_chain_audit.py run: python3 scripts/lockfile_supply_chain_audit.py
# Dependency bumps strand the version-pinned allowScripts entries.
# The paired pre-commit hook auto-fixes PRs; this is the backstop.
- name: allowScripts pins must match the lockfile
working-directory: ${{ github.workspace }}
run: |
python3 tests/studio/test_sync_allow_scripts_pins.py
python3 scripts/sync_allow_scripts_pins.py --check
- name: Lockfile must agree with package.json (npm ci is strict) - name: Lockfile must agree with package.json (npm ci is strict)
# The vite 8 chain (rolldown, lightningcss, tailwind oxide) ships napi # Lifecycle scripts (esbuild native-binary postinstall, etc.) are
# binaries with no install scripts. The only script-bearing deps are # required for `vite build`. The pre-install lockfile structural
# covered by `allowScripts` in package.json (npm >=11.16, default in # audit (lockfile_supply_chain_audit.py) is the practical defence
# npm 12). The pre-install lockfile audit above stays the first line # against the npm postinstall-dropper class -- it fires BEFORE any
# of defence -- it fires before any tarball can run code. # tarball runs, on the injection pattern itself rather than an
# --strict-allow-scripts: any unreviewed install script hard-fails # advisory-DB lookup.
# the job; the sync hook keeps the pins fresh after bumps. run: npm ci --no-fund --no-audit
run: npm ci --strict-allow-scripts --no-fund --no-audit
- name: npm ci must not have modified the working tree - name: npm ci must not have modified the working tree
working-directory: ${{ github.workspace }} working-directory: ${{ github.workspace }}
@ -133,13 +109,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 +120,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
@ -20,14 +20,14 @@
# enable_tools / enabled_tools, and enable_thinking on/off. # enable_tools / enabled_tools, and enable_thinking on/off.
# #
# 3. JSON, images # 3. JSON, images
# Qwen3-VL-2B-Instruct UD-Q4_K_XL (~1.1 GiB) + mmproj-F16 (~780 MiB). # gemma-4-E2B-it UD-IQ3_XXS (~2.4 GiB) + mmproj-F16 (~986 MiB).
# response_format JSON-schema decoding and OpenAI image_url # response_format JSON-schema decoding and OpenAI image_url
# (data URI) plus Anthropic source/base64 image inputs. # (data URI) plus Anthropic source/base64 image inputs.
# #
# 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:
@ -91,32 +91,28 @@ jobs:
continue-on-error: true continue-on-error: true
with: with:
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 }}-v1
- name: Prime HF_HOME with the GGUF - name: Prime HF_HOME with the GGUF
id: prime-hf id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success' if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env: env:
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
python -m pip install --upgrade huggingface_hub python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache mkdir -p hf-cache
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE"
bash .github/scripts/hf-download-with-retry.sh ggml-org/models tinyllamas/stories260K.gguf
- name: Save HF_HOME for ${{ env.GGUF_REPO }} - name: Save HF_HOME for ${{ env.GGUF_REPO }}
if: always() && steps.prime-hf.outcome == 'success' if: always() && steps.prime-hf.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
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 }}-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.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
mkdir -p logs mkdir -p logs
set -o pipefail set -o pipefail
@ -125,10 +121,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 +138,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 +225,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.
@ -261,24 +256,12 @@ jobs:
for label, runner in (("openai", run_openai), ("anthropic", run_anthropic)): for label, runner in (("openai", run_openai), ("anthropic", run_anthropic)):
first = runner() first = runner()
second = runner() second = runner()
determinism_failures = []
for i, (a, b) in enumerate(zip(first, second), start = 1): for i, (a, b) in enumerate(zip(first, second), start = 1):
print(f"[{label} turn {i}] {a!r}") print(f"[{label} turn {i}] {a!r}")
# Both runs must be non-empty; small-quant drift assert a, f"{label}: empty turn {i} response"
# across runs is WARN-only (grounding asserts below assert a == b, (
# are the stronger signal). f"{label} non-deterministic at turn {i} with temperature=0.0:\n"
assert a, f"{label}: empty turn {i} response in first run" f" run1: {a!r}\n run2: {b!r}"
assert b, f"{label}: empty turn {i} response in second run"
if a.strip() != b.strip():
determinism_failures.append(
f"turn {i}: run1={a!r} run2={b!r}"
)
if determinism_failures:
print(
f"[{label}] WARN non-determinism at temperature=0.0 across "
f"{len(determinism_failures)} of {len(first)} turn(s); "
f"small-quant model drift, not an Unsloth regression. "
f"Details: " + " | ".join(determinism_failures)
) )
# Sanity: turn-2 reply should mention the earlier question, and # Sanity: turn-2 reply should mention the earlier question, and
# turn-4 reply should mention Paris (model echoes the city it # turn-4 reply should mention Paris (model echoes the city it
@ -287,11 +270,10 @@ jobs:
joined = " ".join(first).lower() joined = " ".join(first).lower()
assert "1" in first[0], f"{label}: turn-1 answer should contain '1', got {first[0]!r}" assert "1" in first[0], f"{label}: turn-1 answer should contain '1', got {first[0]!r}"
assert "paris" in joined, f"{label}: expected 'paris' somewhere in the four-turn transcript: {first}" assert "paris" in joined, f"{label}: expected 'paris' somewhere in the four-turn transcript: {first}"
status_word = "PASS" if not determinism_failures else "PASS (with drift)" print(f"[{label}] OK -- 4 turns, run1 == run2, history grounded")
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
@ -301,8 +283,6 @@ jobs:
- name: Upload logs - name: Upload logs
# Always upload so green runs are still reviewable. # Always upload so green runs are still reviewable.
if: always() if: always()
# Diagnostic only: a transient artifact-service drop must not fail a green job.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: openai-anthropic-log name: openai-anthropic-log
@ -320,20 +300,17 @@ jobs:
timeout-minutes: 25 timeout-minutes: 25
env: env:
# Tool calling is the highest-volume GGUF in this workflow # Tool calling is the highest-volume GGUF in this workflow
# (Qwen3.5-2B at Q4_K_XL = ~1.28 GiB). Caching HF_HOME would # (Qwen3.5-2B at IQ3_XXS = ~890 MiB). Caching HF_HOME would
# 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
# jobs still cover the gguf_variant resolution path. # jobs still cover the gguf_variant resolution path.
# Q4_K_XL, not IQ3_XXS: at IQ3_XXS this model emits malformed
# tool calls that llama-server's peg-native parser rejects with a
# 500. Mac/Windows already use Q4_K_XL for the same reason.
GGUF_REPO: unsloth/Qwen3.5-2B-GGUF GGUF_REPO: unsloth/Qwen3.5-2B-GGUF
GGUF_FILE: Qwen3.5-2B-UD-Q4_K_XL.gguf GGUF_FILE: Qwen3.5-2B-UD-IQ3_XXS.gguf
STUDIO_PORT: '18889' STUDIO_PORT: '18889'
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@ -367,8 +344,7 @@ jobs:
id: download-gguf id: download-gguf
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success' if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
env: env:
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
python -m pip install --upgrade huggingface_hub python -m pip install --upgrade huggingface_hub
mkdir -p gguf-cache mkdir -p gguf-cache
@ -381,17 +357,15 @@ 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.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
mkdir -p logs mkdir -p logs
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 +375,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 +419,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,58 +439,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, 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
call with enable_tools=true must use this helper. call with enable_tools=true must use this helper."""
Returns (content, raw_payloads):
content -- concatenated assistant delta.content
raw_payloads -- list of every raw "data: ..." event
payload (JSON strings). Callers asserting
that a server-side tool actually ran (and
not just that the model emitted some
text) should grep raw_payloads for tool
invocation markers / tool output, since
`delta.content` alone is not evidence
that the tool path executed.
A shared CI runner can stall the stream transport (the
connection opening, or a mid-stream read) even when Unsloth
is healthy, so retry a stall once with a fresh request
capped at 300s. A stall means the stream did NOT complete,
so partial events are normally NOT returned (an early
tool_start with no tool_end is not proof the tool loop
finished). The one exception is `complete_on`: an optional
predicate over the events collected so far -- when a stall
happens after it is already satisfied (the tool ran and
produced its result before the trailing read timed out),
those events are returned rather than discarded, so the
stall-after-answer case still counts. HTTP status errors
surface immediately; a stall that yields no completed result
across all attempts re-raises so the caller can rotate to
the next seed.
"""
body = {**body, "stream": True} body = {**body, "stream": True}
data = json.dumps(body).encode() data = json.dumps(body).encode()
req = urllib.request.Request( req = urllib.request.Request(
@ -530,132 +458,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:
events = [] for raw in resp:
t = timeout if attempt == 0 else min(timeout, 300) line = raw.decode().strip()
try: if not line.startswith("data: "):
with urllib.request.urlopen(req, timeout = t) as resp:
for raw in resp:
line = raw.decode().strip()
if not line.startswith("data: "):
continue
payload = line[6:]
if payload == "[DONE]":
break
events.append(payload)
try:
chunk = json.loads(payload)
except json.JSONDecodeError:
continue
for choice in chunk.get("choices", []):
delta = choice.get("delta", {}) or {}
if delta.get("content"):
parts.append(delta["content"])
return "".join(parts), events
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
# A stall after the tool already produced its result is
# the case this probe exists to tolerate: keep those
# events. But a stall with only an early tool_start (no
# completed output) is not proof the tool loop finished,
# so it must not pass -- retry once, then raise so
# _run_tool_probe rotates to the next seed.
if complete_on is not None and complete_on(events):
print(f"[retry-sse] {path}: {exc!r}; keeping {len(events)} completed events", flush = True)
return "".join(parts), events
if attempt == retries:
raise
print(f"[retry-sse] {path}: {exc!r}", flush = True)
time.sleep(15)
_STUDIO_TOOL_TYPES = {
"tool_start", "tool_end", "tool_use", "tool_result",
}
def _tool_invoked(events):
"""Structural check: True iff some SSE payload is a real
tool envelope (Unsloth tool_start/tool_end, Anthropic
tool_use/tool_result, OpenAI non-empty delta.tool_calls /
message.tool_calls / finish_reason='tool_calls' /
role:'tool' / function_call). tool_status is NOT
evidence: Unsloth emits empty tool_status events on
iteration boundaries even when no tool ran.
"""
for raw in events:
try:
ev = json.loads(raw)
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(ev, dict):
continue
if ev.get("type") in _STUDIO_TOOL_TYPES:
return True
for choice in ev.get("choices", []) or []:
if not isinstance(choice, dict):
continue continue
if choice.get("finish_reason") == "tool_calls": payload = line[6:]
return True if payload == "[DONE]":
for src_key in ("delta", "message"): break
src = choice.get(src_key) or {} try:
if not isinstance(src, dict): chunk = json.loads(payload)
continue except json.JSONDecodeError:
tc = src.get("tool_calls") continue
if isinstance(tc, list) and tc: for choice in chunk.get("choices", []):
return True delta = choice.get("delta", {}) or {}
if src.get("function_call"): if delta.get("content"):
return True parts.append(delta["content"])
if src.get("role") == "tool": return "".join(parts)
return True
for item in ev.get("output", []) or []:
if isinstance(item, dict) and item.get("type") in {
"tool_call", "function_call", "tool_use",
}:
return True
content = ev.get("content")
if isinstance(content, list):
for blk in content:
if isinstance(blk, dict) and blk.get("type") in {
"tool_use", "tool_result",
}:
return True
return False
def _tool_output_contains(events, *needles):
"""True iff any tool_end.result / tool_result.content /
tool-role message content contains a needle. Inspects
the tool's own output, not the model's narration."""
for raw in events:
try:
ev = json.loads(raw)
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(ev, dict):
continue
if ev.get("type") == "tool_end":
result = ev.get("result")
if isinstance(result, str) and any(n in result for n in needles if n):
return True
if ev.get("type") == "tool_result":
content = ev.get("content")
if isinstance(content, str) and any(n in content for n in needles if n):
return True
if isinstance(content, list):
for blk in content:
if isinstance(blk, dict):
text = blk.get("text") or blk.get("content")
if isinstance(text, str) and any(n in text for n in needles if n):
return True
for choice in ev.get("choices", []) or []:
delta = (choice or {}).get("delta") or {}
msg = (choice or {}).get("message") or {}
for src in (delta, msg):
if src.get("role") == "tool":
content = src.get("content") or ""
if isinstance(content, str) and any(n in content for n in needles if n):
return True
return False
# ── 1. Standard OpenAI function calling ────────────────────── # ── 1. Standard OpenAI function calling ──────────────────────
weather_tool = { weather_tool = {
@ -689,153 +509,60 @@ jobs:
assert args.get("city"), f"missing city arg: {args}" assert args.get("city"), f"missing city arg: {args}"
print(f"[tools] PASS function calling -> {tc['function']['name']}({args})") print(f"[tools] PASS function calling -> {tc['function']['name']}({args})")
# T=0 = deterministic argmax in llama.cpp; T>0 lets seed
# rotation explore distinct trajectories on retry.
TOOL_PROBE_TEMP = 0.4
def _run_tool_probe(*, label, prompt, enabled, session, needles,
max_attempts = 4):
"""Drive a server-side tool with retries. Hard FAIL if no
attempt has structural invocation evidence. WARN (not
FAIL) if invoked but no attempt produces the expected
literal in tool_end.result -- small-quant Qwen3.5-2B can
emit OpenAI tool_calls deltas without Unsloth's GGUF
agentic loop intercepting them, and that GGUF-vs-OpenAI
format mismatch is out of scope for #5642.
"""
attempts_log = []
best = None
# Cap the wall-clock spent rotating through stalled seeds so a
# persistent no-data wedge fails fast (clean assertion) instead
# of being killed by the job's timeout-minutes. A healthy or
# merely degenerate round answers in seconds, so all seeds still
# run in the normal case; only stalls consume the budget.
probe_deadline = time.monotonic() + 300
for attempt_i in range(max_attempts):
# Cap each read by the budget still remaining (not just a flat
# 180s) and skip an attempt too small to finish, so the whole
# rotation stays within ~300s -- two probes then fit the job's
# timeout-minutes even if every seed stalls.
remaining = int(probe_deadline - time.monotonic())
if attempt_i and remaining < 30:
print(f"[tools] {label}: seed-rotation budget spent after {attempt_i} attempts", flush = True)
break
attempt_seed = SEED + attempt_i
try:
# Bounded per-attempt timeout, no inner retry -- the seed
# loop IS the retry, so a stall raises quickly and rotates
# rather than spending post_sse's full 600+300s. complete_on
# keeps a stall that already produced the tool result (only
# the trailing read timed out) instead of discarding it.
content, events = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": prompt}],
"enable_tools": True,
"permission_mode": "full",
"enabled_tools": enabled,
"session_id": f"{session}-att{attempt_i}",
"temperature": TOOL_PROBE_TEMP,
"seed": attempt_seed,
"max_tokens": 600,
}, timeout = min(180, remaining), retries = 0,
complete_on = lambda ev: _tool_invoked(ev) and _tool_output_contains(ev, *needles))
except urllib.error.HTTPError:
# HTTPError subclasses URLError, so re-raise a real 4xx/5xx
# here instead of letting the transport-stall handler below
# swallow it and rotate seeds -- an endpoint status failure
# must surface, not be masked as missing tool evidence.
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
# A transport stall that outlived post_sse's own retry:
# log it as a failed attempt and rotate to the next seed
# rather than sinking the whole probe on one bad stream.
attempts_log.append({
"attempt": attempt_i, "seed": attempt_seed,
"transport_error": repr(exc),
})
print(f"[tools] retry {label} attempt {attempt_i}: transport {exc!r}", flush = True)
continue
invoked = _tool_invoked(events)
produced = _tool_output_contains(events, *needles)
attempts_log.append({
"attempt": attempt_i, "seed": attempt_seed,
"n_events": len(events),
"tool_invoked": invoked, "tool_output_contains": produced,
"content_len": len(content),
})
if invoked and produced:
print(f"[tools] PASS {label} attempt {attempt_i}")
return content, events, attempts_log
if invoked and best is None:
best = (content, events)
print(f"[tools] retry {label} attempt {attempt_i}: invoked={invoked} output_ok={produced} events={len(events)}")
if best is not None:
print(f"[tools] WARN {label}: invoked but no tool_end.result match (small-quant flake). Attempts: {attempts_log}")
content, events = best
return content, events, attempts_log
raise AssertionError(
f"{label}: no structural tool-invocation evidence across "
f"{max_attempts} attempts. enable_tools may be silently "
f"ignored. Attempts: {attempts_log}"
)
# ── 2. Server-side python tool ─────────────────────────────── # ── 2. Server-side python tool ───────────────────────────────
content, events, _attempts = _run_tool_probe( # 123 * 456 = 56088. The agentic loop streams SSE; we
label = "python tool", # accumulate the assistant text and look for the answer. We
prompt = "What is 123 * 456? Use the python tool to compute it and tell me the number.", # accept "56088" or "56,088" since the model may format it.
enabled = ["python"], content = post_sse("/v1/chat/completions", {
session = "ci-tool-calling-py", "messages": [{"role": "user", "content": "What is 123 * 456? Use the python tool to compute it and tell me the number."}],
needles = ("56088", "56,088"), "enable_tools": True,
"enabled_tools": ["python"],
"session_id": "ci-tool-calling-py",
"temperature": 0.0,
"seed": SEED,
"max_tokens": 600,
})
assert "56088" in content or "56,088" in content, (
f"expected 56088 in python-tool answer, got: {content!r}"
) )
if "56088" in content or "56,088" in content: print(f"[tools] PASS python tool ({len(content)} chars)")
print(f"[tools] python tool narration OK")
else:
print(f"[tools] python tool narration drifted -- content={content!r}")
# ── 3. Server-side bash (terminal) tool ────────────────────── # ── 3. Server-side bash (terminal) tool ──────────────────────
content, events, _attempts = _run_tool_probe( content = post_sse("/v1/chat/completions", {
label = "bash/terminal tool", "messages": [{"role": "user", "content": "Use the terminal tool to run `echo hello-bash-tool` and tell me the exact output."}],
prompt = "Use the terminal tool to run `echo hello-bash-tool` and tell me the exact output.", "enable_tools": True,
enabled = ["terminal"], "enabled_tools": ["terminal"],
session = "ci-tool-calling-bash", "session_id": "ci-tool-calling-bash",
needles = ("hello-bash-tool",), "temperature": 0.0,
"seed": SEED,
"max_tokens": 600,
})
assert "hello-bash-tool" in content, (
f"expected 'hello-bash-tool' in terminal-tool answer, got: {content!r}"
) )
if "hello-bash-tool" in content: print(f"[tools] PASS bash/terminal tool ({len(content)} chars)")
print(f"[tools] bash/terminal narration OK")
else:
print(f"[tools] bash/terminal narration dropped literal -- content={content!r}")
# ── 4. Server-side web_search tool ─────────────────────────── # ── 4. Server-side web_search tool ───────────────────────────
# DuckDuckGo is flaky from CI runners and small Qwen3.5-2B # DuckDuckGo is flaky from CI runners and small Qwen3.5-2B
# may not actually search. Only assert that the SSE stream # may not actually search. Only assert that the SSE stream
# opens and yields any data; HTTP / parser failures already # opens and yields any data; HTTP / parser failures already
# raise above. Tool-invocation strictness is relaxed here # raise above.
# because (a) the search may legitimately return no results,
# and (b) DuckDuckGo upstream blocks GHA IP ranges often
# enough that requiring a tool_call marker would create
# red-herring failures from infra rather than from Unsloth.
try: try:
# Best-effort and bounded: a single 180s attempt keeps a stall content = post_sse("/v1/chat/completions", {
# from eating the job's timeout-minutes (it already WARNs, so a
# retry buys nothing).
content, events = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}], "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)")
f"[tools] PASS web_search stream ({len(content)} chars in content, "
f"{len(events)} raw events)"
)
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}")
# ── 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 +576,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,28 +596,22 @@ 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
sleep 2 sleep 2
ss -tln | grep ":${STUDIO_PORT}" || true ss -tln | grep ":${STUDIO_PORT}" || true
# Capture backend + llama-server logs so a 500 has a server-side traceback.
mkdir -p logs/server-logs
cp -r ~/.unsloth/studio/logs/. logs/server-logs/ 2>/dev/null || true
- name: Upload logs - name: Upload logs
# Always upload so green runs are still reviewable. # Always upload so green runs are still reviewable.
if: always() if: always()
# Diagnostic only: a transient artifact-service drop must not fail a green job.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: tool-calling-log name: tool-calling-log
path: | path: |
logs/studio.log logs/studio.log
logs/install.log logs/install.log
logs/server-logs/
retention-days: 7 retention-days: 7
# ───────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────
@ -901,15 +622,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
env: env:
GGUF_REPO: unsloth/Qwen3-VL-2B-Instruct-GGUF GGUF_REPO: unsloth/gemma-4-E2B-it-GGUF
# UD-Q4_K_XL, not UD-IQ2_XXS: at 2-bit the temp-0 answer to the JSON GGUF_VARIANT: UD-IQ3_XXS
# step's capital-of-France probe flips with the host's SIMD kernels GGUF_FILE: gemma-4-E2B-it-UD-IQ3_XXS.gguf
# (GitHub runners deterministically answered France while other CPUs
# answer Paris; seeds do not rescue it, 1/5 Paris at temp 0.7). The
# Q4 quant answered Paris 13/13 across temps and seeds on the same
# runners, so the hard Paris assertion below stays reliable.
GGUF_VARIANT: UD-Q4_K_XL
GGUF_FILE: Qwen3-VL-2B-Instruct-UD-Q4_K_XL.gguf
MMPROJ_FILE: mmproj-F16.gguf MMPROJ_FILE: mmproj-F16.gguf
STUDIO_PORT: '18890' STUDIO_PORT: '18890'
HF_HOME: ${{ github.workspace }}/hf-cache HF_HOME: ${{ github.workspace }}/hf-cache
@ -939,33 +654,29 @@ jobs:
continue-on-error: true continue-on-error: true
with: with:
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 }}-v1
- name: Prime HF_HOME with the GGUF + mmproj - name: Prime HF_HOME with the GGUF + mmproj
id: prime-hf id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success' if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env: env:
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
python -m pip install --upgrade huggingface_hub python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache mkdir -p hf-cache
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE"
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$MMPROJ_FILE" bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$MMPROJ_FILE"
bash .github/scripts/hf-download-with-retry.sh ggml-org/models tinyllamas/stories260K.gguf
- name: Save HF_HOME for ${{ env.GGUF_REPO }} (model + mmproj) - name: Save HF_HOME for ${{ env.GGUF_REPO }} (model + mmproj)
if: always() && steps.prime-hf.outcome == 'success' if: always() && steps.prime-hf.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
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 }}-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.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
mkdir -p logs mkdir -p logs
set -o pipefail set -o pipefail
@ -974,12 +685,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 &
@ -1008,23 +719,13 @@ jobs:
-H 'content-type: application/json' \ -H 'content-type: application/json' \
-d "{\"username\":\"unsloth\",\"password\":\"$NEW\"}" | jq -r .access_token) -d "{\"username\":\"unsloth\",\"password\":\"$NEW\"}" | jq -r .access_token)
echo "API_KEY=$TOKEN" >> "$GITHUB_ENV" echo "API_KEY=$TOKEN" >> "$GITHUB_ENV"
# Retry: llama-server startup can race process teardown after a # Load the GGUF (mmproj is auto-detected via the HF repo
# failed attempt. Keep curl out of a pipe so HTTP failures are not # lookup, the cached file is pulled out of HF_HOME).
# masked by jq. curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \
LOAD_OK=0 -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
for attempt in 1 2 3; do --max-time 900 \
HTTP=$(curl -s -o /tmp/load.json -w '%{http_code}' \ -d "{\"model_path\":\"$GGUF_REPO\",\"gguf_variant\":\"$GGUF_VARIANT\",\"is_lora\":false,\"max_seq_length\":2048}" \
-X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \ | jq '{status, display_name, is_vision}'
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
--max-time 900 \
-d "{\"model_path\":\"$GGUF_REPO\",\"gguf_variant\":\"$GGUF_VARIANT\",\"is_lora\":false,\"max_seq_length\":2048}")
if [ "$HTTP" = "200" ]; then LOAD_OK=1; break; fi
echo "::warning::/api/inference/load attempt $attempt returned $HTTP; response:"
cat /tmp/load.json || true
sleep 10
done
[ "$LOAD_OK" = "1" ] || { echo "::error::/api/inference/load failed 3 attempts"; exit 22; }
jq '{status, display_name, is_vision}' /tmp/load.json
- name: JSON schema decoding + image input - name: JSON schema decoding + image input
env: env:
@ -1034,8 +735,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 +753,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 +796,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 +832,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 +868,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
@ -1195,8 +878,6 @@ jobs:
- name: Upload logs - name: Upload logs
# Always upload so green runs are still reviewable. # Always upload so green runs are still reviewable.
if: always() if: always()
# Diagnostic only: a transient artifact-service drop must not fail a green job.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: json-images-log name: json-images-log

View file

@ -1,68 +0,0 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
#
# Event-loop regression test for the Unsloth model-load orchestrator.
# Pins down issue #5642 (Win10 UI freeze on model load): the /load
# route calls LlamaCppBackend.detect_audio_type synchronously, blocking
# the FastAPI event loop on a chain of sync httpx.Client.post() probes.
#
# The suite stands up a stdlib fake llama-server + a tiny FastAPI app
# via uvicorn and asserts that detect_audio_type runs via
# asyncio.to_thread so concurrent /api/inference/load-progress polling
# stays responsive. CPU-only, no torch, no real llama.cpp binary, no
# GPU -- the matching cross-OS staging proof lives on
# danielhanchen/unsloth-staging-2 (Ubuntu / macOS / Windows all
# green at PR time).
name: Unsloth load-orchestrator CI
on:
pull_request:
paths:
- 'studio/backend/routes/inference.py'
- 'studio/backend/core/inference/llama_cpp.py'
- 'tests/studio/load_freeze/**'
- '.github/workflows/studio-load-orchestrator-ci.yml'
push:
branches: [main]
paths:
- 'studio/backend/routes/inference.py'
- 'studio/backend/core/inference/llama_cpp.py'
- 'tests/studio/load_freeze/**'
- '.github/workflows/studio-load-orchestrator-ci.yml'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
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: Install minimal deps (no torch, no unsloth)
# The test stubs `loggers` and `structlog`, imports
# core.inference.llama_cpp directly, and drives a small
# FastAPI app. Nothing here pulls torch or any GPU code,
# so the entire job typically completes in well under 60 s.
run: |
python -m pip install --upgrade pip
python -m pip install \
'pytest>=8' \
'httpx>=0.27,<1' \
'fastapi>=0.110,<1' \
'uvicorn>=0.30,<1' \
'anyio>=4'
- name: Run load-orchestrator tests
run: python -m pytest -v --tb=short tests/studio/load_freeze/

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:
@ -62,47 +62,47 @@ jobs:
continue-on-error: true continue-on-error: true
with: with:
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 }}-v1
- name: Prime HF_HOME with the GGUF - name: Prime HF_HOME with the GGUF
id: prime-hf id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success' if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env: env:
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
python -m pip install --upgrade huggingface_hub python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache mkdir -p hf-cache
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE"
bash .github/scripts/hf-download-with-retry.sh ggml-org/models tinyllamas/stories260K.gguf
- name: Save HF_HOME for ${{ env.GGUF_REPO }} - name: Save HF_HOME for ${{ env.GGUF_REPO }}
if: always() && steps.prime-hf.outcome == 'success' if: always() && steps.prime-hf.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
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 }}-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.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
mkdir -p logs mkdir -p logs
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: Assert llama.cpp loads on this macOS - name: Assert install.sh used the Mac llama.cpp prebuilt
run: bash .github/scripts/assert-llama-loads.sh run: |
if grep -q "falling back to source build" logs/install.log; then
echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon."
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
exit 1
fi
- 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 +130,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
@ -85,19 +85,17 @@ jobs:
continue-on-error: true continue-on-error: true
with: with:
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 }}-v1
- name: Prime HF_HOME with the GGUF - name: Prime HF_HOME with the GGUF
id: prime-hf id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success' if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env: env:
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
python -m pip install --upgrade huggingface_hub python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache mkdir -p hf-cache
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE"
bash .github/scripts/hf-download-with-retry.sh ggml-org/models tinyllamas/stories260K.gguf
# Save partial caches on cancel/timeout -- hf download resumes by # Save partial caches on cancel/timeout -- hf download resumes by
# content hash. `outcome != skipped` keeps cache-hit a no-op. # content hash. `outcome != skipped` keeps cache-hit a no-op.
@ -106,28 +104,30 @@ jobs:
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
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 }}-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.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
mkdir -p logs mkdir -p logs
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: Assert llama.cpp loads on this macOS - name: Assert install.sh used the Mac llama.cpp prebuilt
run: bash .github/scripts/assert-llama-loads.sh run: |
if grep -q "falling back to source build" logs/install.log; then
echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon."
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
exit 1
fi
- 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 +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
@ -229,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.
@ -263,14 +263,7 @@ jobs:
for i, (a, b) in enumerate(zip(first, second), start = 1): for i, (a, b) in enumerate(zip(first, second), start = 1):
print(f"[{label} turn {i}] {a!r}") print(f"[{label} turn {i}] {a!r}")
assert a, f"{label}: empty turn {i} response" assert a, f"{label}: empty turn {i} response"
# Compare on stripped content: llama-server can vary assert a == b, (
# trailing whitespace (specifically a final '\n') between
# otherwise-identical greedy runs depending on the
# batch-flush boundary at which the stream is closed. The
# generated tokens are identical; only the trailing
# whitespace differs. Keep the raw repr in the failure
# message so a real divergence is still legible.
assert a.strip() == b.strip(), (
f"{label} non-deterministic at turn {i} with temperature=0.0:\n" f"{label} non-deterministic at turn {i} with temperature=0.0:\n"
f" run1: {a!r}\n run2: {b!r}" f" run1: {a!r}\n run2: {b!r}"
) )
@ -284,7 +277,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
@ -294,8 +287,6 @@ jobs:
- name: Upload logs - name: Upload logs
# Always upload so green runs are still reviewable. # Always upload so green runs are still reviewable.
if: always() if: always()
# Diagnostic only: a transient artifact-service drop must not fail a green job.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: openai-anthropic-log name: openai-anthropic-log
@ -349,8 +340,7 @@ jobs:
id: download-gguf id: download-gguf
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success' if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
env: env:
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
python -m pip install --upgrade huggingface_hub python -m pip install --upgrade huggingface_hub
mkdir -p gguf-cache mkdir -p gguf-cache
@ -364,20 +354,23 @@ 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.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
mkdir -p logs mkdir -p logs
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: Assert llama.cpp loads on this macOS - name: Assert install.sh used the Mac llama.cpp prebuilt
run: bash .github/scripts/assert-llama-loads.sh run: |
if grep -q "falling back to source build" logs/install.log; then
echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon."
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
exit 1
fi
- 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 +380,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 +424,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 +444,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 +463,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 +520,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 +551,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 +587,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 +616,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 +642,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
@ -715,8 +652,6 @@ jobs:
- name: Upload logs - name: Upload logs
# Always upload so green runs are still reviewable. # Always upload so green runs are still reviewable.
if: always() if: always()
# Diagnostic only: a transient artifact-service drop must not fail a green job.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: tool-calling-log name: tool-calling-log
@ -786,8 +721,7 @@ jobs:
# Authenticated + parallel: shared macos-14 NAT egress stalls # Authenticated + parallel: shared macos-14 NAT egress stalls
# multi-GB anonymous downloads. # multi-GB anonymous downloads.
env: env:
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
python -m pip install --upgrade huggingface_hub python -m pip install --upgrade huggingface_hub
mkdir -p gguf-cache mkdir -p gguf-cache
@ -811,28 +745,31 @@ 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.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
mkdir -p logs mkdir -p logs
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: Assert llama.cpp loads on this macOS - name: Assert install.sh used the Mac llama.cpp prebuilt
run: bash .github/scripts/assert-llama-loads.sh run: |
if grep -q "falling back to source build" logs/install.log; then
echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon."
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
exit 1
fi
- 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 +819,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 +842,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 +927,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 +943,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 +973,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 +1019,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
@ -1114,8 +1033,6 @@ jobs:
- name: Upload logs - name: Upload logs
# Always upload so green runs are still reviewable. # Always upload so green runs are still reviewable.
if: always() if: always()
# Diagnostic only: a transient artifact-service drop must not fail a green job.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: json-images-log name: json-images-log

View file

@ -1,82 +0,0 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Proves Unsloth's llama.cpp install loads on every supported macOS. The heavy
# app smokes stay single-OS; this matrix covers the OS-version dimension cheaply
# (install.sh + binary-load assert). Regression guard for the macOS-version
# selection in studio/install_llama_prebuilt.py.
name: Mac Studio Install Matrix CI
on:
pull_request:
paths:
- 'studio/install_llama_prebuilt.py'
- 'studio/setup.sh'
- 'install.sh'
- '.github/scripts/assert-llama-loads.sh'
- '.github/workflows/studio-mac-install-matrix.yml'
push:
branches: [main, pip]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
install-load:
name: Install + load (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 25
continue-on-error: ${{ matrix.experimental }}
strategy:
fail-fast: false
matrix:
include:
- os: macos-14 # Apple Silicon, macOS 14 Sonoma
experimental: false
- os: macos-15 # Apple Silicon, macOS 15 Sequoia
experimental: false
- os: macos-26 # Apple Silicon, macOS 26 Tahoe
experimental: false
- os: macos-15-intel # Intel x86_64, macOS 15 (informational)
experimental: true
- os: macos-26-intel # Intel x86_64, macOS 26 (last Intel macOS)
experimental: true
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
- name: Install Unsloth (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: |
mkdir -p logs
set -o pipefail
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
- name: Assert llama.cpp loads on this macOS
run: bash .github/scripts/assert-llama-loads.sh
- name: Upload install log
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: mac-install-matrix-${{ matrix.os }}-log
path: logs/install.log
retention-days: 7

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]
@ -63,41 +62,42 @@ jobs:
continue-on-error: true continue-on-error: true
with: with:
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 }}-v1
- name: Prime HF_HOME with the GGUF - name: Prime HF_HOME with the GGUF
id: prime-hf id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success' if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env: env:
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
python -m pip install --upgrade huggingface_hub python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache mkdir -p hf-cache
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE"
bash .github/scripts/hf-download-with-retry.sh ggml-org/models tinyllamas/stories260K.gguf
- name: Save HF_HOME for ${{ env.GGUF_REPO }} - name: Save HF_HOME for ${{ env.GGUF_REPO }}
if: always() && steps.prime-hf.outcome == 'success' if: always() && steps.prime-hf.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
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 }}-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.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
mkdir -p logs mkdir -p logs
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: Assert llama.cpp loads on this macOS - name: Assert install.sh used the Mac llama.cpp prebuilt
run: bash .github/scripts/assert-llama-loads.sh run: |
if grep -q "falling back to source build" logs/install.log; then
echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon."
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
exit 1
fi
- 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 +113,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 +144,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 &
@ -187,14 +186,13 @@ jobs:
# Retry up to 3 times to absorb known macos-14 free-runner # Retry up to 3 times to absorb known macos-14 free-runner
# flakes: (1) Playwright Node 24 pipeTransport.js 'Unexpected # flakes: (1) Playwright Node 24 pipeTransport.js 'Unexpected
# end of JSON input' crash when the Chromium browser process # end of JSON input' crash when the Chromium browser process
# dies mid-test, (2) Chromium net::ERR_NO_BUFFER_SPACE when the # dies mid-test, and (2) Chromium net::ERR_NO_BUFFER_SPACE
# runner's kernel briefly runs out of socket buffers, and (3) a # when the runner's kernel briefly runs out of socket buffers.
# goto 'interrupted by another navigation' when the SPA auth # The retry FULLY resets Studio (kill, reset-password, reboot,
# guard redirects mid-navigation. The retry FULLY resets Unsloth # wait /api/health, re-export bootstrap pw) before re-running
# (kill, wipe auth, reboot, wait /api/health, re-export # the script. A real test failure (assertion / timeout) does
# bootstrap pw) before re-running the script. A real test failure # NOT match either pattern so it bypasses retry and surfaces
# (assertion / timeout) does NOT match any pattern so it bypasses # immediately.
# retry and surfaces immediately.
run: | run: |
mkdir -p logs/playwright mkdir -p logs/playwright
attempt=1 attempt=1
@ -207,14 +205,13 @@ jobs:
if [ "$rc" -eq 0 ]; then if [ "$rc" -eq 0 ]; then
break break
fi fi
if { grep -q "Unexpected end of JSON input" logs/playwright_attempt_${attempt}.log \ if { grep -q "Unexpected end of JSON input" logs/playwright_attempt_${attempt}.log \
|| grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_attempt_${attempt}.log \ || grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_attempt_${attempt}.log; } \
|| grep -q "interrupted by another navigation" logs/playwright_attempt_${attempt}.log; } \
&& [ "$attempt" -lt "$max_attempts" ]; then && [ "$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 +237,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 +270,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 }}
@ -288,8 +281,8 @@ jobs:
STUDIO_UI_TURN_TIMEOUT_MS: '540000' STUDIO_UI_TURN_TIMEOUT_MS: '540000'
GGUF_REPO: ${{ env.GGUF_REPO }} GGUF_REPO: ${{ env.GGUF_REPO }}
GGUF_VARIANT: ${{ env.GGUF_VARIANT }} GGUF_VARIANT: ${{ env.GGUF_VARIANT }}
# Same flake-retry shape as "Drive the chat UI with Playwright" -- catches # Same flake-retry shape as "Drive the chat UI with Playwright"
# pipeTransport JSON crash, ERR_NO_BUFFER_SPACE, and nav interrupts. # -- catches pipeTransport JSON crash and ERR_NO_BUFFER_SPACE.
run: | run: |
mkdir -p logs/playwright_extra mkdir -p logs/playwright_extra
attempt=1 attempt=1
@ -302,14 +295,13 @@ jobs:
if [ "$rc" -eq 0 ]; then if [ "$rc" -eq 0 ]; then
break break
fi fi
if { grep -q "Unexpected end of JSON input" logs/playwright_extra_attempt_${attempt}.log \ if { grep -q "Unexpected end of JSON input" logs/playwright_extra_attempt_${attempt}.log \
|| grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_extra_attempt_${attempt}.log \ || grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_extra_attempt_${attempt}.log; } \
|| grep -q "interrupted by another navigation" logs/playwright_extra_attempt_${attempt}.log; } \
&& [ "$attempt" -lt "$max_attempts" ]; then && [ "$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 +325,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 +341,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
@ -21,7 +21,7 @@ on:
pull_request: pull_request:
paths: paths:
- 'install.sh' - 'install.sh'
- 'scripts/uninstall.sh' - 'uninstall.sh'
- 'studio/setup.sh' - 'studio/setup.sh'
- 'studio/install_python_stack.py' - 'studio/install_python_stack.py'
- 'studio/install_llama_prebuilt.py' - 'studio/install_llama_prebuilt.py'
@ -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,24 +59,33 @@ 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.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
mkdir -p logs mkdir -p logs
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: Assert llama.cpp loads on this macOS - name: Assert install.sh used the Mac llama.cpp prebuilt
run: bash .github/scripts/assert-llama-loads.sh run: |
# Mac install must take the prebuilt path. Source-build
# fallback here is an Unsloth bug.
if grep -q "falling back to source build" logs/install.log; then
echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon."
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
exit 1
fi
if ! grep -qE "prebuilt installed and validated|prebuilt up to date and validated|bin-macos-arm64" logs/install.log; then
echo "::error::no Mac prebuilt llama.cpp marker in install.log."
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
exit 1
fi
echo "install.sh installed the Mac prebuilt llama.cpp"
- name: First update should be a no-op (prebuilt already validated) - name: First update should be a no-op (prebuilt already validated)
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.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
set -o pipefail set -o pipefail
unsloth studio update --local 2>&1 | tee logs/update.log unsloth studio update --local 2>&1 | tee logs/update.log
@ -95,8 +104,6 @@ jobs:
- 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 }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
set -o pipefail set -o pipefail
unsloth studio update --local 2>&1 | tee logs/update2.log unsloth studio update --local 2>&1 | tee logs/update2.log
@ -106,7 +113,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,29 +130,29 @@ 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 uninstall.sh on real macOS. As a side effect
# effect this exercises the macOS-only .app bundle + Launch Services # this exercises the macOS-only .app bundle + Launch Services
# removal path (~/Applications/Unsloth Studio.app, lsregister -u) # removal path (~/Applications/Unsloth Studio.app, lsregister -u)
# which is not testable from a Linux runner. Skips gracefully if # which is not testable from a Linux runner. Skips gracefully if
# scripts/uninstall.sh has not landed yet (lets this workflow merge # uninstall.sh has not landed yet (lets this workflow merge
# before #5497). # before #5497).
run: | run: |
set -o pipefail set -o pipefail
if [ ! -f scripts/uninstall.sh ]; then if [ ! -f uninstall.sh ]; then
echo "scripts/uninstall.sh not present in this tree; skipping round-trip" echo "uninstall.sh not present in this tree; skipping round-trip"
: > logs/uninstall.log : > logs/uninstall.log
exit 0 exit 0
fi fi
sh scripts/uninstall.sh 2>&1 | tee logs/uninstall.log sh uninstall.sh 2>&1 | tee logs/uninstall.log
leak=0 leak=0
for p in \ for p in \
"$HOME/.unsloth/studio" \ "$HOME/.unsloth/studio" \
@ -159,8 +166,8 @@ jobs:
fi fi
done done
[ "$leak" -eq 0 ] || exit 1 [ "$leak" -eq 0 ] || exit 1
sh scripts/uninstall.sh 2>&1 | tail -5 sh uninstall.sh 2>&1 | tail -5
sh scripts/uninstall.sh 2>&1 | tail -5 sh uninstall.sh 2>&1 | tail -5
echo "PASS: mac install -> update -> uninstall round-trip clean" echo "PASS: mac install -> update -> uninstall round-trip clean"
- name: Upload update logs - name: Upload update logs

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:
@ -47,7 +47,7 @@ jobs:
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y \ sudo apt-get install -y \
libwebkit2gtk-4.1-dev libappindicator3-dev \ libwebkit2gtk-4.1-dev libayatana-appindicator3-dev \
librsvg2-dev libxdo-dev libssl-dev patchelf librsvg2-dev libxdo-dev libssl-dev patchelf
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@ -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]
@ -77,46 +76,44 @@ jobs:
continue-on-error: true continue-on-error: true
with: with:
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 }}-v1
- name: Prime HF_HOME with the GGUF - name: Prime HF_HOME with the GGUF
id: prime-hf id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success' if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env: env:
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
python -m pip install --upgrade huggingface_hub python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache mkdir -p hf-cache
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE"
bash .github/scripts/hf-download-with-retry.sh ggml-org/models tinyllamas/stories260K.gguf
- name: Save HF_HOME for ${{ env.GGUF_REPO }} - name: Save HF_HOME for ${{ env.GGUF_REPO }}
if: always() && steps.prime-hf.outcome == 'success' if: always() && steps.prime-hf.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
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 }}-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.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
mkdir -p logs mkdir -p logs
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 +143,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 +161,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 +210,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 +223,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 +252,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,15 +269,11 @@ 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
sleep 2 sleep 2
# Capture backend + llama-server logs (all three Studios share this
# dir) so a stray 500 has a server-side traceback.
mkdir -p logs/server-logs
cp -r ~/.unsloth/studio/logs/. logs/server-logs/ 2>/dev/null || true
- name: Upload Playwright artifacts - name: Upload Playwright artifacts
# Always upload so a green run's screenshots stay reviewable -- # Always upload so a green run's screenshots stay reviewable --
@ -355,15 +285,9 @@ 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/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,13 +9,13 @@
# 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:
paths: paths:
- 'install.sh' - 'install.sh'
- 'scripts/uninstall.sh' - 'uninstall.sh'
- 'studio/setup.sh' - 'studio/setup.sh'
- 'studio/install_python_stack.py' - 'studio/install_python_stack.py'
- 'studio/install_llama_prebuilt.py' - 'studio/install_llama_prebuilt.py'
@ -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 +
@ -71,8 +71,6 @@ jobs:
# prebuilt path falls back to source build. # prebuilt path falls back to source build.
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.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
mkdir -p logs mkdir -p logs
set -o pipefail set -o pipefail
@ -87,8 +85,6 @@ jobs:
# idempotency regressed. # idempotency regressed.
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.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
set -o pipefail set -o pipefail
unsloth studio update --local 2>&1 | tee logs/update.log unsloth studio update --local 2>&1 | tee logs/update.log
@ -111,8 +107,6 @@ jobs:
# the first one. # the first one.
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.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
set -o pipefail set -o pipefail
unsloth studio update --local 2>&1 | tee logs/update2.log unsloth studio update --local 2>&1 | tee logs/update2.log
@ -122,7 +116,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,71 +132,31 @@ 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 uninstall.sh: confirms the
# uninstaller actually finds and removes everything install.sh + # uninstaller actually finds and removes everything install.sh +
# update wrote. Safety-guard scenarios (refuse-$HOME etc.) belong # update wrote. Safety-guard scenarios (refuse-$HOME etc.) belong
# in a separate fast smoke job; this is the happy-path cleanup # in a separate fast smoke job; this is the happy-path cleanup
# assertion that catches regressions where install.sh starts # assertion that catches regressions where install.sh starts
# writing to a new location and scripts/uninstall.sh hasn't caught up. # writing to a new location and uninstall.sh hasn't caught up.
# Skips gracefully if scripts/uninstall.sh has not landed yet (lets # Skips gracefully if uninstall.sh has not landed yet (lets this
# this workflow merge before #5497). # workflow merge before #5497).
run: | run: |
set -o pipefail set -o pipefail
if [ ! -f scripts/uninstall.sh ]; then if [ ! -f uninstall.sh ]; then
echo "scripts/uninstall.sh not present in this tree; skipping round-trip" echo "uninstall.sh not present in this tree; skipping round-trip"
: > logs/uninstall.log : > logs/uninstall.log
exit 0 exit 0
fi fi
sh scripts/uninstall.sh 2>&1 | tee logs/uninstall.log sh uninstall.sh 2>&1 | tee logs/uninstall.log
leak=0 leak=0
for p in \ for p in \
"$HOME/.unsloth/studio" \ "$HOME/.unsloth/studio" \
@ -217,8 +171,8 @@ jobs:
done done
[ "$leak" -eq 0 ] || exit 1 [ "$leak" -eq 0 ] || exit 1
# Idempotent: re-runs exit 0 on an empty $HOME. # Idempotent: re-runs exit 0 on an empty $HOME.
sh scripts/uninstall.sh 2>&1 | tail -5 sh uninstall.sh 2>&1 | tail -5
sh scripts/uninstall.sh 2>&1 | tail -5 sh uninstall.sh 2>&1 | tail -5
echo "PASS: install -> update -> uninstall round-trip clean" echo "PASS: install -> update -> uninstall round-trip clean"
- name: Upload update logs - name: Upload update logs

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:
@ -69,26 +69,24 @@ jobs:
continue-on-error: true continue-on-error: true
with: with:
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 }}-v1
- name: Prime HF_HOME with the GGUF - name: Prime HF_HOME with the GGUF
id: prime-hf id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success' if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env: env:
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
python -m pip install --upgrade huggingface_hub python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache mkdir -p hf-cache
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE"
bash .github/scripts/hf-download-with-retry.sh ggml-org/models tinyllamas/stories260K.gguf
- name: Save HF_HOME for ${{ env.GGUF_REPO }} - name: Save HF_HOME for ${{ env.GGUF_REPO }}
if: always() && steps.prime-hf.outcome == 'success' if: always() && steps.prime-hf.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
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 }}-v1
- name: Pre-install Windows tweaks (npm 11 + Defender exclusions) - name: Pre-install Windows tweaks (npm 11 + Defender exclusions)
shell: pwsh shell: pwsh
@ -105,7 +103,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,12 +119,10 @@ 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 }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
New-Item -ItemType Directory -Force -Path logs | Out-Null New-Item -ItemType Directory -Force -Path logs | Out-Null
# *>&1 captures Write-Host (Information stream) output; # *>&1 captures Write-Host (Information stream) output;
@ -161,7 +157,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.
@ -174,13 +170,26 @@ jobs:
fi fi
cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH" cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH"
- name: Patch Studio venv with full typer / pydantic dep trees
# Belt-and-suspenders: install.ps1's --no-deps install of
# no-torch-runtime.txt drops typer's and pydantic's runtime
# deps unless explicitly pinned. Re-install the ones whose
# deps don't pull torch.
run: |
STUDIO_PY=~/.unsloth/studio/unsloth_studio/Scripts/python.exe
if [ ! -f "$STUDIO_PY" ]; then
echo "::error::Studio venv python not at $STUDIO_PY"
ls -la ~/.unsloth/studio/ || true
exit 1
fi
"$STUDIO_PY" -m pip install --upgrade typer pydantic huggingface_hub
- 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 +217,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 +229,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

File diff suppressed because it is too large Load diff

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.
@ -86,26 +85,24 @@ jobs:
continue-on-error: true continue-on-error: true
with: with:
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 }}-v1
- name: Prime HF_HOME with the GGUF - name: Prime HF_HOME with the GGUF
id: prime-hf id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success' if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env: env:
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads. HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
python -m pip install --upgrade huggingface_hub python -m pip install --upgrade huggingface_hub
mkdir -p hf-cache mkdir -p hf-cache
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE"
bash .github/scripts/hf-download-with-retry.sh ggml-org/models tinyllamas/stories260K.gguf
- name: Save HF_HOME for ${{ env.GGUF_REPO }} - name: Save HF_HOME for ${{ env.GGUF_REPO }}
if: always() && steps.prime-hf.outcome == 'success' if: always() && steps.prime-hf.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
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 }}-v1
- name: Pre-install Windows tweaks (npm 11 + Defender exclusions) - name: Pre-install Windows tweaks (npm 11 + Defender exclusions)
shell: pwsh shell: pwsh
@ -122,7 +119,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",
@ -138,18 +135,7 @@ jobs:
} }
} }
- name: Seed a legacy launch-studio.vbs (upgrade-cleanup check) - name: Install Studio (--local, --no-torch)
# Simulate a pre-hardening install so the post-install assertion below
# proves the installer DELETES an existing launch-studio.vbs (the exact
# Kaspersky-flagged file), not merely stops generating it.
shell: pwsh
run: |
$appDir = Join-Path $env:LOCALAPPDATA 'Unsloth Studio'
New-Item -ItemType Directory -Force -Path $appDir | Out-Null
Set-Content -LiteralPath (Join-Path $appDir 'launch-studio.vbs') -Value 'WScript.Echo "legacy"' -Encoding Unicode
Write-Host "seeded legacy launch-studio.vbs at $appDir"
- name: Install Unsloth (--local, --no-torch)
# install.ps1 is the supported Windows installer. install.sh # 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
@ -157,8 +143,6 @@ jobs:
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.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
New-Item -ItemType Directory -Force -Path logs | Out-Null New-Item -ItemType Directory -Force -Path logs | Out-Null
# *>&1 redirects ALL PowerShell streams (stdout, stderr, # *>&1 redirects ALL PowerShell streams (stdout, stderr,
@ -206,70 +190,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: Add Studio shim to GITHUB_PATH
# The shortcut launch path is otherwise untested here (the steps below
# boot `unsloth studio` directly). Guard against re-introducing the VBS
# that tripped Kaspersky HEUR:Trojan.VBS.Agent.gen and against the .lnk
# pointing anywhere other than hidden PowerShell over launch-studio.ps1.
shell: pwsh
run: |
$appDir = Join-Path $env:LOCALAPPDATA 'Unsloth Studio'
if (Test-Path -LiteralPath (Join-Path $appDir 'launch-studio.vbs')) {
throw "regression: launch-studio.vbs exists (the Kaspersky VBS-FP shape)"
}
if (-not (Test-Path -LiteralPath (Join-Path $appDir 'launch-studio.ps1'))) {
throw "missing launch-studio.ps1 in $appDir"
}
$lnk = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Unsloth Studio.lnk'
if (-not (Test-Path -LiteralPath $lnk)) {
$lnk = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\Unsloth Studio.lnk'
}
if (-not (Test-Path -LiteralPath $lnk)) { throw "no Unsloth Studio.lnk on Desktop or Start Menu" }
$sc = (New-Object -ComObject WScript.Shell).CreateShortcut($lnk)
Write-Host "shortcut target: $($sc.TargetPath)"
Write-Host "shortcut args: $($sc.Arguments)"
if ($sc.TargetPath -match 'wscript\.exe$') { throw "shortcut still targets wscript.exe (VBS host)" }
if ($sc.TargetPath -notmatch 'powershell\.exe$') { throw "unexpected shortcut target: $($sc.TargetPath)" }
if ($sc.Arguments -notmatch '-WindowStyle Hidden') {
throw "shortcut must launch windowless (-WindowStyle Hidden)"
}
Write-Host "launcher chain OK (no VBS; hidden powershell over launch-studio.ps1)"
- name: Launch Unsloth via the shortcut and assert health
# Run the exact command the .lnk stores (hidden PowerShell over
# launch-studio.ps1) and confirm it brings the backend up. This is the
# only step that proves the shortcut launch is not silently broken.
# Default port range is 8888-8908; the later UI tests use 18896/18897, so
# there is no conflict, and we tear this server down before they boot.
shell: pwsh
run: |
$lnk = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Unsloth Studio.lnk'
if (-not (Test-Path -LiteralPath $lnk)) {
$lnk = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\Unsloth Studio.lnk'
}
$sc = (New-Object -ComObject WScript.Shell).CreateShortcut($lnk)
Write-Host "launching: $($sc.TargetPath) $($sc.Arguments)"
Start-Process -FilePath $sc.TargetPath -ArgumentList $sc.Arguments -WorkingDirectory $sc.WorkingDirectory
$foundPort = 0
foreach ($i in 1..180) {
foreach ($port in 8888..8908) {
try {
$r = Invoke-RestMethod -Uri "http://127.0.0.1:$port/api/health" -TimeoutSec 1
if ($r.status -eq 'healthy' -and $r.service -eq 'Unsloth UI Backend') { $foundPort = $port; break }
} catch {}
}
if ($foundPort) { break }
Start-Sleep -Seconds 1
}
# Tear down the shortcut-launched server before the main UI tests boot.
try {
$owner = (Get-NetTCPConnection -LocalPort $foundPort -State Listen -ErrorAction Stop | Select-Object -First 1).OwningProcess
if ($owner) { taskkill /PID $owner /T /F 2>$null | Out-Null }
} catch {}
if (-not $foundPort) { throw "Unsloth did not become healthy when launched via the shortcut" }
Write-Host "Unsloth healthy on port $foundPort (launched via the shortcut)"
- name: Add Unsloth shim to GITHUB_PATH
# install.ps1 puts unsloth.exe at $StudioHome\bin\unsloth.exe # 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 +206,21 @@ 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: Patch Studio venv with full typer / pydantic dep trees
# Belt-and-suspenders: install.ps1's --no-deps install of
# no-torch-runtime.txt drops typer's and pydantic's runtime
# deps unless explicitly pinned. Re-install the ones whose
# deps don't pull torch.
run: |
STUDIO_PY=~/.unsloth/studio/unsloth_studio/Scripts/python.exe
if [ ! -f "$STUDIO_PY" ]; then
echo "::error::Studio venv python not at $STUDIO_PY"
ls -la ~/.unsloth/studio/ || true
exit 1
fi
"$STUDIO_PY" -m pip install --upgrade typer pydantic huggingface_hub
- 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 +230,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 +275,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 +308,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 +322,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 +338,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,25 +5,25 @@
# 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:
paths: paths:
- 'install.ps1' - 'install.ps1'
- 'scripts/uninstall.ps1' - 'uninstall.ps1'
- 'studio/setup.ps1' - 'studio/setup.ps1'
- 'studio/setup.bat' - 'studio/setup.bat'
- 'studio/install_python_stack.py' - 'studio/install_python_stack.py'
@ -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'
@ -79,18 +79,18 @@ jobs:
# Two surgical fixes against measured Windows-only install # Two surgical fixes against measured Windows-only install
# waste (vs Mac/Linux on the same SHA): # waste (vs Mac/Linux on the same SHA):
# #
# (1) npm. setup.ps1's Get-NodeDecision requires Node 22.12+ # (1) npm. setup.ps1 line 1109-1145 requires Node 22.12+ (or
# (or 20.19+ / 23+) AND npm >=11 because Vite 8 needs both. # 20.19+ / 23+) AND npm >=11 because Vite 8 needs both.
# actions/setup-node@v4 with `node-version: '22'` lands # actions/setup-node@v4 with `node-version: '22'` lands
# Node 22.22.2 + the npm 10.9.7 it bundles, so the decision # Node 22.22.2 + the npm 10.9.7 it bundles, so the npm
# is "bundled" and setup.ps1 downloads an isolated Node (~30 # check fails and setup.ps1 falls through to the
# MB) we don't need on a runner that already has a fine Node. # "winget install Node.js LTS" branch -- a ~35 s reinstall
# `npm install -g npm@^11` updates the runner's npm in-place # of Node we don't need. `npm install -g npm@^11` updates
# in ~5 s, flipping the decision to "system" so setup.ps1 # the bundled npm in-place in ~5 s, which makes setup.ps1
# reuses the existing Node with no download. # short-circuit on the existing Node.
# #
# (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,12 +129,10 @@ 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 }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
New-Item -ItemType Directory -Force -Path logs | Out-Null New-Item -ItemType Directory -Force -Path logs | Out-Null
# *>&1 captures Write-Host (Information stream) output; # *>&1 captures Write-Host (Information stream) output;
@ -168,7 +166,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
@ -178,11 +176,35 @@ jobs:
fi fi
cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH" cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH"
- name: Patch Studio venv with full typer / pydantic dep trees
# install.ps1 runs `uv pip install --no-deps -r
# no-torch-runtime.txt` to keep torch out of transitive
# resolution from accelerate/peft/trl. That also drops
# typer's and pydantic's runtime deps unless they're
# explicitly pinned in no-torch-runtime.txt. We pin the
# known ones (click, shellingham, annotated-doc, rich,
# pydantic-core, annotated-types, typing-inspection, ...)
# but typer / pydantic minor versions can introduce new
# transitive deps that are NOT in our pin list.
#
# Belt-and-suspenders: re-install typer + pydantic +
# huggingface_hub WITH their deps into the Studio venv.
# `pip install --upgrade` only adds missing packages; it
# never down-shifts an installed version. Cannot pull
# torch (none of typer / pydantic / huggingface_hub depend
# on it).
run: |
STUDIO_PY=~/.unsloth/studio/unsloth_studio/Scripts/python.exe
if [ ! -f "$STUDIO_PY" ]; then
echo "::error::Studio venv python not at $STUDIO_PY"
ls -la ~/.unsloth/studio/ || true
exit 1
fi
"$STUDIO_PY" -m pip install --upgrade typer pydantic huggingface_hub
- name: First update should be a no-op (prebuilt already validated) - name: First update should be a no-op (prebuilt already validated)
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.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
set -o pipefail set -o pipefail
unsloth studio update --local 2>&1 | tee logs/update.log unsloth studio update --local 2>&1 | tee logs/update.log
@ -198,36 +220,9 @@ 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 }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
run: | run: |
set -o pipefail set -o pipefail
unsloth studio update --local 2>&1 | tee logs/update2.log unsloth studio update --local 2>&1 | tee logs/update2.log
@ -237,7 +232,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,31 +259,30 @@ 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 uninstall.ps1 against the default install
# install tree at %USERPROFILE%\.unsloth\studio. Catches # tree at %USERPROFILE%\.unsloth\studio. Catches regressions
# regressions where install.ps1 starts writing under a new key # where install.ps1 starts writing under a new key (registry,
# (registry, Start Menu, %APPDATA%) and scripts/uninstall.ps1 has # Start Menu, %APPDATA%) and uninstall.ps1 has not been updated
# not been updated to match. Skips gracefully if # to match. Skips gracefully if uninstall.ps1 has not landed yet
# scripts/uninstall.ps1 has not landed yet (lets this workflow # (lets this workflow merge before #5513).
# merge before #5513).
shell: pwsh shell: pwsh
run: | run: |
New-Item -ItemType Directory -Force -Path logs | Out-Null New-Item -ItemType Directory -Force -Path logs | Out-Null
if (-not (Test-Path "$PWD\scripts\uninstall.ps1")) { if (-not (Test-Path "$PWD\uninstall.ps1")) {
Write-Host "scripts/uninstall.ps1 not present in this tree; skipping round-trip" Write-Host "uninstall.ps1 not present in this tree; skipping round-trip"
"" | Set-Content logs/uninstall.log "" | Set-Content logs/uninstall.log
exit 0 exit 0
} }
pwsh -NoProfile -File "$PWD\scripts\uninstall.ps1" *>&1 | Tee-Object -FilePath logs/uninstall.log pwsh -NoProfile -File "$PWD\uninstall.ps1" *>&1 | Tee-Object -FilePath logs/uninstall.log
$leak = 0 $leak = 0
foreach ($p in @( foreach ($p in @(
"$env:USERPROFILE\.unsloth\studio", "$env:USERPROFILE\.unsloth\studio",
@ -302,8 +296,8 @@ jobs:
} }
if ($leak -gt 0) { exit 1 } if ($leak -gt 0) { exit 1 }
# Idempotency. # Idempotency.
pwsh -NoProfile -File "$PWD\scripts\uninstall.ps1" *>&1 | Select-Object -Last 5 pwsh -NoProfile -File "$PWD\uninstall.ps1" *>&1 | Select-Object -Last 5
pwsh -NoProfile -File "$PWD\scripts\uninstall.ps1" *>&1 | Select-Object -Last 5 pwsh -NoProfile -File "$PWD\uninstall.ps1" *>&1 | Select-Object -Last 5
Write-Host "PASS: windows install -> update -> uninstall round-trip clean" Write-Host "PASS: windows install -> update -> uninstall round-trip clean"
- name: Upload update logs - name: Upload update logs

View file

@ -242,7 +242,7 @@ jobs:
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
# CPU torch (vllm/peft/st all depend on it). # CPU torch (vllm/peft/st all depend on it).
pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \ pip install --index-url https://download.pytorch.org/whl/cpu \
'torch>=2.4,<2.11' 'torchvision<0.26' 'torchcodec<0.10' 'torch>=2.4,<2.11' 'torchvision<0.26' 'torchcodec<0.10'
# torchcodec is a hard requirement on transformers 5.x: # torchcodec is a hard requirement on transformers 5.x:
# transformers/audio_utils.py:55 does # transformers/audio_utils.py:55 does
@ -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/
@ -240,6 +235,3 @@ package-lock.json
!studio/backend/core/data_recipe/oxc-validator/package-lock.json !studio/backend/core/data_recipe/oxc-validator/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.
~/
/temp/

View file

@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.18 rev: v0.15.13
hooks: hooks:
- id: ruff - id: ruff
args: args:
@ -14,20 +14,5 @@ repos:
entry: scripts/run_ruff_format.py entry: scripts/run_ruff_format.py
language: python language: python
types: [python] types: [python]
# Mirror ruff's [tool.ruff] extend-exclude so this hook does not
# half-process files ruff itself skips (which produced churn).
exclude: '(chat_templates|ollama_template_mappers|_auto_install|mapper)\.py$'
additional_dependencies: additional_dependencies:
- ruff==0.6.9 - ruff==0.6.9
# Re-pins allowScripts entries after dependency bumps. pre-commit.ci
# pushes the fix to PR branches, Dependabot's included, so stale pins
# heal without a human in the loop.
- id: sync-allow-scripts-pins
name: Sync allowScripts pins with the frontend lockfile
# `python <script>` not a direct exec: autofix commits can drop the
# executable bit, which kills shebang-style entries.
entry: python scripts/sync_allow_scripts_pins.py
args: [--fix]
language: python
files: ^studio/frontend/(package\.json|package-lock\.json)$
pass_filenames: false

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

@ -27,9 +27,3 @@ Your support extends beyond code:
Finally, please be mindful of our [Code of Conduct](https://github.com/unslothai/unsloth/blob/main/CODE_OF_CONDUCT.md) to ensure a welcoming and inclusive environment for everyone. Finally, please be mindful of our [Code of Conduct](https://github.com/unslothai/unsloth/blob/main/CODE_OF_CONDUCT.md) to ensure a welcoming and inclusive environment for everyone.
Thank you so much for reading and we hope you have lots of fun using Unsloth! 🦥 Thank you so much for reading and we hope you have lots of fun using Unsloth! 🦥
## Pull Request Guidelines
- Keep PRs focused on a single change
- Include a concise description and motivation
- Link related issues when applicable

View file

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

227
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>
@ -46,53 +45,17 @@ Unsloth Studio (Beta) lets you run and train text, [audio](https://unsloth.ai/do
* **[Code execution](https://unsloth.ai/docs/new/studio/chat#code-execution)**: lets LLMs test code in Claude artifacts and sandbox environments * **[Code execution](https://unsloth.ai/docs/new/studio/chat#code-execution)**: lets LLMs test code in Claude artifacts and sandbox environments
* **[API inference endpoint](https://unsloth.ai/docs/basics/api)**: Deploy and run local LLMs in Claude Code, Codex tools with Unsloth * **[API inference endpoint](https://unsloth.ai/docs/basics/api)**: Deploy and run local LLMs in Claude Code, Codex tools with Unsloth
* [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](models/tutorials/devstral-how-to-run-and-fine-tune.md), [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). * Upload images, audio, PDFs, code, DOCX and more file types to chat with.
* [**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.
@ -101,46 +64,31 @@ 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:** Currently supports chat and Data Recipes. **MLX training** is coming very soon
* **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. * **Coming soon:** Training support for Apple MLX, AMD, and Intel.
* **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:
```bash ```bash
curl -fsSL https://unsloth.ai/install.sh | sh curl -fsSL https://unsloth.ai/install.sh | sh
``` ```
Use the same command to update.
To force the Vulkan llama.cpp backend, set `UNSLOTH_FORCE_VULKAN=1` **before installing or updating**. The setting selects the llama.cpp binary bundle, so setting it only when launching Studio cannot replace an existing CPU bundle:
```bash
export UNSLOTH_FORCE_VULKAN=1
curl -fsSL https://unsloth.ai/install.sh | sh
```
#### Windows: #### Windows:
```powershell ```powershell
irm https://unsloth.ai/install.ps1 | iex irm https://unsloth.ai/install.ps1 | iex
``` ```
Use the same command to update.
To force the Vulkan llama.cpp backend, set the environment variable before running the installer or updater:
```powershell
$env:UNSLOTH_FORCE_VULKAN=1
irm https://unsloth.ai/install.ps1 | iex
```
Re-running the current installer replaces a previously selected CPU bundle when the backend differs. A separate Vulkan SDK is not required; the GPU driver must provide a working Vulkan runtime.
#### Launch #### 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 VMs or LAN access, add `-H 0.0.0.0` to bind on all interfaces.
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). #### Update
To update, use the same install commands as above. Or run (does not work on Windows):
```bash
unsloth studio update
```
#### 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 +124,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 +150,11 @@ 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) - **API inference endpoint**: Deploy and run local LLMs in Claude Code, Codex tools. [Guide](https://unsloth.ai/docs/basics/api)
- **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) - **Qwen3.6**: Qwen3.6-35B-A3B can now be trained and run in Unsloth Studio. [Blog](https://unsloth.ai/docs/models/qwen3.6)
- **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) - **Gemma 4**: Run and train Googles new models directly in Unsloth. [Blog](https://unsloth.ai/docs/models/gemma-4)
- **MCP control endpoint**: Let compatible clients manage models, training, recipes, checkpoints and exports. [#7191](https://github.com/unslothai/unsloth/pull/7191)
- **Local inference reliability**: Resume long chats faster, recover stalled downloads and reuse existing GGUF files. [#7204](https://github.com/unslothai/unsloth/pull/7204) • [#6858](https://github.com/unslothai/unsloth/pull/6858) • [#7209](https://github.com/unslothai/unsloth/pull/7209)
- **New models**: [Qwen-AgentWorld](https://huggingface.co/unsloth/Qwen-AgentWorld-35B-A3B-GGUF), [Ornith](https://huggingface.co/unsloth/models?search=ornith), [Kimi K2.7 Code](https://unsloth.ai/docs/models/kimi-k2.7-code) and [MiniMax M3](https://unsloth.ai/docs/models/minimax-m3)
- **GLM-5.2**: Run Z.ai's 744B-parameter, 1M-context open model locally with Unsloth Dynamic GGUFs. [Guide](https://unsloth.ai/docs/models/glm-5.2)
- **DeepSeek-V4**: Run DeepSeek-V4-Flash locally with corrected multi-turn and tool-calling behavior. [Guide](https://unsloth.ai/docs/models/deepseek-v4)
- **DiffusionGemma**: Run and fine-tune Google's diffusion language model with 1.8x faster inference in Unsloth Studio. [Guide](https://unsloth.ai/docs/models/diffusiongemma)
- **Qwen3.6**: Run and train Qwen3.6 with MTP for 1.4-2.2x faster inference and NVFP4 quants for supported GPUs. [Guide](https://unsloth.ai/docs/models/qwen3.6)
- **Gemma 4**: Run and train Gemma 4 text, image and audio models with QAT, MTP, GGUF and MLX support. [Guide](https://unsloth.ai/docs/models/gemma-4)
- **MCP servers**: Connect local models to files, apps, databases and external tools through Model Context Protocol. [Guide](https://unsloth.ai/docs/basics/mcp)
- **Connections**: Mix local models with API providers (OpenAI, Anthropic) or servers (vLLM, Ollama) in the same interface. [Guide](https://unsloth.ai/docs/integrations/connections)
- **Introducing Unsloth Studio**: our new web UI for running and training LLMs. [Blog](https://unsloth.ai/docs/new/studio) - **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)
@ -225,28 +164,19 @@ Read our [guide](https://unsloth.ai/docs/get-started/fine-tuning-llms-guide). Ad
## 📥 Advanced Installation ## 📥 Advanced Installation
The below advanced instructions are for Unsloth Studio. For Unsloth Core advanced installation, [view our docs](https://unsloth.ai/docs/get-started/install/pip-install#advanced-pip-installation). The below advanced instructions are for Unsloth Studio. For Unsloth Core advanced installation, [view our docs](https://unsloth.ai/docs/get-started/install/pip-install#advanced-pip-installation).
#### Developer / Nightly / Experimental installs: macOS, Linux, WSL: #### Developer installs: macOS, Linux, WSL:
The developer install builds from the `main` branch, which is the latest (nightly) source.
```bash ```bash
git clone https://github.com/unslothai/unsloth git clone https://github.com/unslothai/unsloth
cd unsloth cd unsloth
./install.sh --local ./install.sh --local
unsloth studio -p 8888 unsloth studio -p 8888
``` ```
To install into an isolated location (its own virtual env, `auth/`, `studio.db`, cache and llama.cpp build), set `UNSLOTH_STUDIO_HOME` and pass it again at launch:
```bash
UNSLOTH_STUDIO_HOME="$PWD/.studio" ./install.sh --local
UNSLOTH_STUDIO_HOME="$PWD/.studio" unsloth studio -p 8888
```
Then to update : Then to update :
```bash ```bash
cd unsloth && git pull unsloth studio update
./install.sh --local
unsloth studio -p 8888
``` ```
#### Developer / Nightly / Experimental installs: Windows PowerShell: #### Developer installs: Windows PowerShell:
The developer install builds from the `main` branch, which is the latest (nightly) source.
```powershell ```powershell
git clone https://github.com/unslothai/unsloth.git git clone https://github.com/unslothai/unsloth.git
cd unsloth cd unsloth
@ -254,103 +184,44 @@ Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
.\install.ps1 --local .\install.ps1 --local
unsloth studio -p 8888 unsloth studio -p 8888
``` ```
To install into an isolated location (its own virtual env, `auth/`, `studio.db`, cache and llama.cpp build), set `UNSLOTH_STUDIO_HOME` and pass it again at launch:
```powershell
$env:UNSLOTH_STUDIO_HOME="$PWD\.studio"; .\install.ps1 --local
$env:UNSLOTH_STUDIO_HOME="$PWD\.studio"; unsloth studio -p 8888
```
Then to update : Then to update :
```powershell ```bash
cd unsloth; git pull unsloth studio update
.\install.ps1 --local ```
#### Nightly: MacOS, Linux, WSL:
```bash
git clone https://github.com/unslothai/unsloth
cd unsloth
git checkout nightly
./install.sh --local
unsloth studio -p 8888
```
Then to launch every time:
```bash
unsloth studio -p 8888 unsloth studio -p 8888
``` ```
#### Remote access: `--secure` (HTTPS tunnel) vs raw port #### Nightly: Windows:
By default `unsloth studio` binds to `127.0.0.1` (this machine only). To reach it from another device, pick one of: Run in Windows Powershell:
- `--secure` (recommended): serve **only** through a free Cloudflare HTTPS link. Unsloth stays bound to localhost and the tunnel provides the public URL; it fails closed (does not start) if the tunnel can't come up, so the raw port is never exposed.
```bash ```bash
unsloth studio --secure -p 8888 git clone https://github.com/unslothai/unsloth.git
cd unsloth
git checkout nightly
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
.\install.ps1 --local
unsloth studio -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. Then to launch every time:
```bash ```bash
unsloth studio -H 0.0.0.0 -p 8888 unsloth studio -p 8888
``` ```
The Cloudflare tunnel is **off by default**: `-H 0.0.0.0` exposes the raw port only, not a public internet URL. Pair the wildcard bind with `--cloudflare` (`unsloth studio -H 0.0.0.0 --cloudflare`) to also publish a public `https://*.trycloudflare.com` link, or prefer `--secure` (above), which keeps the raw port private. `--cloudflare` has no effect on a loopback bind.
On a wildcard bind Unsloth works out the address to share by asking `ifconfig.me` for the public IP, then asks `check-host.net` whether that port is reachable so it can tell you if a firewall is in the way. Both contact a third party. Set `UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK=1` to skip them; the banner then shows the LAN address and no reachability line.
The first time Unsloth is published on a public URL (`--secure` or `--cloudflare`) with the auto-generated admin password still in place, it asks for a new admin password in the terminal (masked input with confirmation) before the public link goes up. Without an attached terminal it warns instead and keeps the bootstrap deadline: Unsloth shuts down after `UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT` (default 1 hour) unless the password is changed in the web UI.
For headless setups that cannot answer that prompt, set the initial admin password non-interactively with `--password` (only takes effect when no password is set yet; if one already exists it is a hard error, so rotate later with `unsloth studio reset-password`):
```bash
unsloth studio --secure --password 'your-strong-password' # visible in `ps`/history
UNSLOTH_STUDIO_PASSWORD='your-strong-password' unsloth studio --secure # via env var
printf '%s\n' 'your-strong-password' | unsloth studio --secure --password - # via stdin
```
A literal `--password VALUE` is visible in the process list and shell history, so prefer the `UNSLOTH_STUDIO_PASSWORD` env var or `--password -` (stdin) for automation. This applies to any launch (public or a headless `-H 0.0.0.0` bind), and the password is set in the parent before the server binds, so it never reaches a re-executed child process.
Server-side tools (web search, Python and terminal code execution) run as your user and are on by default. Anyone who can reach the server with the API key can run code on this machine, so keep your API key private and pass `--disable-tools` when exposing Unsloth.
#### Advanced launch options
Installer options can be passed as environment variables. On macOS, Linux and WSL place the variable after the pipe so the shell passes it to `sh`; on Windows set it with `$env:` before piping to `iex`.
Skip PyTorch (GGUF-only mode):
```bash
curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_NO_TORCH=1 sh
```
```powershell
$env:UNSLOTH_NO_TORCH=1; irm https://unsloth.ai/install.ps1 | iex
```
Skip the post-install prompt that starts Unsloth (useful for automated installs):
```bash
curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_SKIP_AUTOSTART=1 sh
```
```powershell
$env:UNSLOTH_SKIP_AUTOSTART=1; irm https://unsloth.ai/install.ps1 | iex
```
Pin the Python version:
```bash
curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_PYTHON=3.12 sh
```
```powershell
$env:UNSLOTH_PYTHON='3.12'; irm https://unsloth.ai/install.ps1 | iex
```
Install to a custom location with `UNSLOTH_STUDIO_HOME`:
```bash
curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_STUDIO_HOME=/abs/path sh
```
```powershell
$env:UNSLOTH_STUDIO_HOME='C:\path'; irm https://unsloth.ai/install.ps1 | iex
```
On macOS, the installer defaults to the system certificate store (`UV_SYSTEM_CERTS=1`) so uv trusts the CAs in your Keychain, needed behind TLS-inspecting proxies (Cisco Umbrella, Zscaler, etc.). Opt out with:
```bash
curl -fsSL https://unsloth.ai/install.sh | UV_SYSTEM_CERTS=0 sh
```
Point the frontend build at a corporate npm mirror/proxy with `UNSLOTH_NPM_REGISTRY` (for the developer install behind a firewall that blocks `registry.npmjs.org`):
```bash
UNSLOTH_NPM_REGISTRY=https://artifactory.example.com/api/npm/npm/ ./install.sh --local
```
```powershell
$env:UNSLOTH_NPM_REGISTRY='https://artifactory.example.com/api/npm/npm/'; .\install.ps1 --local
```
It is threaded as `--registry` into the Unsloth frontend `npm`/`bun` installs; the supply-chain locks (7-day `min-release-age`, exact version pins) stay in force.
Cap Unsloth's native CPU thread pools on high-core hosts: `UNSLOTH_CPU_THREADS=8 unsloth studio -p 8888`.
#### Uninstall #### 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):
* **MacOS, WSL, Linux:** `curl -fsSL https://raw.githubusercontent.com/unslothai/unsloth/main/scripts/uninstall.sh | sh` * **MacOS, WSL, Linux:** `curl -fsSL https://unsloth.ai/uninstall.sh | sh`
* **Windows (PowerShell):** `irm https://raw.githubusercontent.com/unslothai/unsloth/main/scripts/uninstall.ps1 | iex` * **Windows (PowerShell):** `irm https://unsloth.ai/uninstall.ps1 | iex`
If you only want to drop the install dir and keep the launcher/shortcut for a later reinstall, you can instead run `rm -rf ~/.unsloth/studio` (Mac/Linux/WSL) or `Remove-Item -Recurse -Force "$HOME\.unsloth\studio"` (Windows). The model cache at `~/.cache/huggingface` is not touched by any of these. If you only want to drop the install dir and keep the launcher/shortcut for a later reinstall, you can instead run `rm -rf ~/.unsloth/studio` (Mac/Linux/WSL) or `Remove-Item -Recurse -Force "$HOME\.unsloth\studio"` (Windows). The model cache at `~/.cache/huggingface` is not touched by any of these.

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

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

2763
install.sh

File diff suppressed because it is too large Load diff

View file

@ -25,17 +25,10 @@ classifiers = [
"Topic :: Scientific/Engineering :: Artificial Intelligence", "Topic :: Scientific/Engineering :: Artificial Intelligence",
] ]
dependencies = [ dependencies = [
"typer>=0.12.0", "typer",
"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,18 +40,11 @@ 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",
"node_prebuilt_pins.json",
"frontend/dist/**/*", "frontend/dist/**/*",
"frontend/*.json", "frontend/*.json",
"frontend/*.ts", "frontend/*.ts",
@ -68,8 +54,6 @@ studio = [
"frontend/.git*", "frontend/.git*",
"backend/requirements/**/*", "backend/requirements/**/*",
"backend/plugins/**/*", "backend/plugins/**/*",
"backend/assets/**/*.jinja",
"backend/assets/**/*.html",
"backend/core/data_recipe/oxc-validator/*.json", "backend/core/data_recipe/oxc-validator/*.json",
"backend/core/data_recipe/oxc-validator/*.mjs", "backend/core/data_recipe/oxc-validator/*.mjs",
] ]
@ -79,40 +63,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.5.3",
"wheel>=0.42.0", "wheel>=0.42.0",
"packaging", "packaging",
"numpy", "numpy",
@ -131,25 +88,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.5.3",
"torchvision", "torchvision",
"unsloth[triton]", "unsloth[triton]",
] ]
@ -310,6 +251,10 @@ cu118onlytorch270 = [
"xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.30-cp310-cp310-manylinux_2_28_x86_64.whl ; python_version=='3.10' and ('linux' in sys_platform)", "xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.30-cp310-cp310-manylinux_2_28_x86_64.whl ; python_version=='3.10' and ('linux' in sys_platform)",
"xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.30-cp311-cp311-manylinux_2_28_x86_64.whl ; python_version=='3.11' and ('linux' in sys_platform)", "xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.30-cp311-cp311-manylinux_2_28_x86_64.whl ; python_version=='3.11' and ('linux' in sys_platform)",
"xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.30-cp312-cp312-manylinux_2_28_x86_64.whl ; python_version=='3.12' and ('linux' in sys_platform)", "xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.30-cp312-cp312-manylinux_2_28_x86_64.whl ; python_version=='3.12' and ('linux' in sys_platform)",
"xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.30-cp39-cp39-win_amd64.whl ; python_version=='3.9' and (sys_platform == 'win32')",
"xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.30-cp310-cp310-win_amd64.whl ; python_version=='3.10' and (sys_platform == 'win32')",
"xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.30-cp311-cp311-win_amd64.whl ; python_version=='3.11' and (sys_platform == 'win32')",
"xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.30-cp312-cp312-win_amd64.whl ; python_version=='3.12' and (sys_platform == 'win32')",
] ]
cu126onlytorch270 = [ cu126onlytorch270 = [
"xformers @ https://download.pytorch.org/whl/cu126/xformers-0.0.30-cp39-cp39-manylinux_2_28_x86_64.whl ; python_version=='3.9' and ('linux' in sys_platform)", "xformers @ https://download.pytorch.org/whl/cu126/xformers-0.0.30-cp39-cp39-manylinux_2_28_x86_64.whl ; python_version=='3.9' and ('linux' in sys_platform)",
@ -333,6 +278,7 @@ cu128onlytorch270 = [
] ]
cu118onlytorch271 = [ cu118onlytorch271 = [
"xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.31.post1-cp39-abi3-manylinux_2_28_x86_64.whl ; ('linux' in sys_platform)", "xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.31.post1-cp39-abi3-manylinux_2_28_x86_64.whl ; ('linux' in sys_platform)",
"xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.31.post1-cp39-abi3-win_amd64.whl ; (sys_platform == 'win32')",
] ]
cu126onlytorch271 = [ cu126onlytorch271 = [
"xformers @ https://download.pytorch.org/whl/cu126/xformers-0.0.31.post1-cp39-abi3-manylinux_2_28_x86_64.whl ; ('linux' in sys_platform)", "xformers @ https://download.pytorch.org/whl/cu126/xformers-0.0.31.post1-cp39-abi3-manylinux_2_28_x86_64.whl ; ('linux' in sys_platform)",
@ -586,19 +532,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 +580,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.5.3",
"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 +831,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'",
@ -935,12 +875,14 @@ flashattentiontorch240abiFALSEcu12x = [
"flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiFALSE-cp310-cp310-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.10'", "flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiFALSE-cp310-cp310-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.10'",
"flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiFALSE-cp311-cp311-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.11'", "flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiFALSE-cp311-cp311-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.11'",
"flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiFALSE-cp312-cp312-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.12'", "flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiFALSE-cp312-cp312-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.12'",
"flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiFALSE-cp313-cp313-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.13'",
] ]
flashattentiontorch240abiTRUEcu12x = [ flashattentiontorch240abiTRUEcu12x = [
"flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiTRUE-cp39-cp39-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.9'", "flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiTRUE-cp39-cp39-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.9'",
"flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiTRUE-cp310-cp310-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.10'", "flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiTRUE-cp310-cp310-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.10'",
"flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiTRUE-cp311-cp311-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.11'", "flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiTRUE-cp311-cp311-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.11'",
"flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiTRUE-cp312-cp312-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.12'", "flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiTRUE-cp312-cp312-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.12'",
"flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiTRUE-cp313-cp313-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.13'",
] ]
intelgputorch260 = [ intelgputorch260 = [
"unsloth_zoo[intelgpu]", "unsloth_zoo[intelgpu]",
@ -1185,8 +1127,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]",
@ -1229,14 +1170,14 @@ intelgputorch2120 = [
"unsloth_zoo[intelgpu]", "unsloth_zoo[intelgpu]",
"unsloth[huggingfacenotorch]", "unsloth[huggingfacenotorch]",
"triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=81ff0eb0c4fc8e19d2510b28c3e1d9382a3c7d6fdaf6a9f9631a93a030d841cf ; platform_system == 'Linux' and python_version == '3.10' and platform_machine == 'x86_64'", "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=844d981cb1b3948085e8cfa62c74de9f100259f6131959aa70be49123b88ae81 ; platform_system == 'Linux' and python_version == '3.10' and platform_machine == 'x86_64'",
"triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=55574a68d275b85cd4d5cbf185084bae019ebf09c3f43b0bd2831b14935ec8e7 ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'", "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=a16b1d00e94ad87d62af3512e390348b8656419598004100c56028bf494f086b ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'",
"triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=a31c058c5c2e78ebe490a2e69f2f50caec6b1307ac096e944f116fdc06819d9a ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'", "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=4e46e71e077cf483404a4c17ce40d71c5f0e13a81459139d4346ca427b1dd455 ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'",
"triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=e701a31efa0334775f357c98716f3821775aa944219f7888e13c2dfe2daabe2a ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'", "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=4fdaed1bafc51d3a2834656a3420a6686a74ea226508765a49bf15d58ff3a930 ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'",
"triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp310-cp310-win_amd64.whl#sha256=0d7730651c3e52fbf3a430cc201455f0c6600dc72e681aec495f131ea44f341a ; sys_platform == 'win32' and python_version == '3.10' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp310-cp310-win_amd64.whl#sha256=2778b46b22e9fa0916398db299a125027a1b2331c1173b3dd2b9e2cab6263a31 ; sys_platform == 'win32' and python_version == '3.10' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')",
"triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp311-cp311-win_amd64.whl#sha256=8f4a63de73e3d632098f93c8f0bd77244958a47d7c5f728b8ff35f8a91fdb983 ; sys_platform == 'win32' and python_version == '3.11' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp311-cp311-win_amd64.whl#sha256=ad5b147d04ee0d40f3d4d32f85f5aa3a3beb6cd5799ca026d3d7f4afa3d9e24f ; sys_platform == 'win32' and python_version == '3.11' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')",
"triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp312-cp312-win_amd64.whl#sha256=6589ece3adc2b1ab88d90ff1267afc25df5c7b868f0b633e732cac70df36cbde ; sys_platform == 'win32' and python_version == '3.12' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp312-cp312-win_amd64.whl#sha256=d9482063af2a308543f23333e32edd738ea87cbb33ade68afda9ae0fd704ccd9 ; sys_platform == 'win32' and python_version == '3.12' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')",
"triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp313-cp313-win_amd64.whl#sha256=2fdf001a9b0575e8b1827127259bb9b13bf36e659882be74c2dfab46597d3e7a ; sys_platform == 'win32' and python_version == '3.13' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')", "triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp313-cp313-win_amd64.whl#sha256=5d4d67f0deb1e851c01b293e602b8dcddad26ca2be61221cee3dc0e1aa0cdefd ; sys_platform == 'win32' and python_version == '3.13' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')",
"torch @ https://download.pytorch.org/whl/xpu/torch-2.12.0%2Bxpu-cp310-cp310-linux_x86_64.whl#sha256=e8923cd1fe560472904b1461b745d2f1826bb9c1bc0808225d5f28a450e4d553 ; platform_system == 'Linux' and python_version == '3.10' and platform_machine == 'x86_64'", "torch @ https://download.pytorch.org/whl/xpu/torch-2.12.0%2Bxpu-cp310-cp310-linux_x86_64.whl#sha256=e8923cd1fe560472904b1461b745d2f1826bb9c1bc0808225d5f28a450e4d553 ; platform_system == 'Linux' and python_version == '3.10' and platform_machine == 'x86_64'",
"torch @ https://download.pytorch.org/whl/xpu/torch-2.12.0%2Bxpu-cp311-cp311-linux_x86_64.whl#sha256=f7c082b2fc9b61def594d30ea57762dc4a8bc7111a9a9593953ed948de242e28 ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'", "torch @ https://download.pytorch.org/whl/xpu/torch-2.12.0%2Bxpu-cp311-cp311-linux_x86_64.whl#sha256=f7c082b2fc9b61def594d30ea57762dc4a8bc7111a9a9593953ed948de242e28 ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'",
@ -1267,11 +1208,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 +1281,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 +1299,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]
@ -1372,7 +1308,6 @@ repository = "https://github.com/unslothai/unsloth"
[tool.ruff] [tool.ruff]
target-version = "py311" target-version = "py311"
line-length = 100
force-exclude = true force-exclude = true
extend-exclude = [ extend-exclude = [
"*chat_templates.py", "*chat_templates.py",

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

@ -43,7 +43,7 @@ DEP_FIELDS = (
"optionalDependencies", "optionalDependencies",
) )
# Files where seeing a package name does NOT count as usage. # Sources where seeing a package name does NOT count as usage.
EXPECTED_NOISE_FILES = { EXPECTED_NOISE_FILES = {
"studio/frontend/package.json", "studio/frontend/package.json",
"studio/frontend/package-lock.json", "studio/frontend/package-lock.json",
@ -51,15 +51,19 @@ EXPECTED_NOISE_FILES = {
"studio/backend/core/data_recipe/oxc-validator/package-lock.json", "studio/backend/core/data_recipe/oxc-validator/package-lock.json",
} }
# File types where a quoted string can be a module specifier. # Only quoted-string occurrences in these file types can be module specifiers.
JS_LIKE_EXT = re.compile(r"\.(ts|tsx|js|jsx|mjs|cjs|html|htm|css|scss|sass|json|jsonc)$") JS_LIKE_EXT = re.compile(
# Files where JS import patterns could be a real module reference (.mdx is r"\.(ts|tsx|js|jsx|mjs|cjs|html|htm|css|scss|sass|json|jsonc)$"
)
# Files where JS-syntactic import patterns (static/dynamic/require/re-export)
# could be a real module reference. Markdown gets a separate gate (.mdx is
# real ESM; .md code fences are not). # real ESM; .md code fences are not).
SCRIPT_LIKE_EXT = re.compile(r"\.(ts|tsx|js|jsx|mjs|cjs|mdx)$") SCRIPT_LIKE_EXT = re.compile(r"\.(ts|tsx|js|jsx|mjs|cjs|mdx)$")
STYLE_EXT = re.compile(r"\.(css|scss|sass)$") STYLE_EXT = re.compile(r"\.(css|scss|sass)$")
HTML_EXT = re.compile(r"\.(html|htm)$") HTML_EXT = re.compile(r"\.(html|htm)$")
TS_LIKE_EXT = re.compile(r"\.(ts|tsx|mts|cts|mdx)$") TS_LIKE_EXT = re.compile(r"\.(ts|tsx|mts|cts|mdx)$")
# Files where a removed package's CLI binary could be invoked. # Files where a removed package's CLI binary could be invoked (npx, bunx,
# yarn dlx, pnpm exec, or a bare `pkg --flag` shell call).
COMMAND_LIKE_EXT = re.compile(r"(\.(ya?ml|sh|ps1|bat)$|(^|/)Dockerfile[^/]*$)") COMMAND_LIKE_EXT = re.compile(r"(\.(ya?ml|sh|ps1|bat)$|(^|/)Dockerfile[^/]*$)")
GREP_INCLUDES = [ GREP_INCLUDES = [
@ -100,7 +104,7 @@ GREP_EXCLUDES = [
"--exclude-dir=venv", "--exclude-dir=venv",
] ]
# A pip-installed playwright ref is the PyPI package, not npm. # A pip-installed playwright reference is the PyPI package, not npm.
PIP_PLAYWRIGHT = re.compile( PIP_PLAYWRIGHT = re.compile(
r"(pip\s+install\s+['\"]?playwright" r"(pip\s+install\s+['\"]?playwright"
r"|python\s+-m\s+playwright" r"|python\s+-m\s+playwright"
@ -151,8 +155,9 @@ def all_decl_names(pkg: dict) -> set[str]:
def _resolve_install_path(parent_path: str, name: str, pkgs: dict) -> str | None: def _resolve_install_path(parent_path: str, name: str, pkgs: dict) -> str | None:
"""Walk up the nested node_modules chain from `parent_path` to find where """Walk up the nested node_modules chain from `parent_path` to find
`name` resolves, mirroring Node module resolution.""" where `name` actually resolves. Mirrors Node module resolution.
"""
parts = parent_path.split("/node_modules/") parts = parent_path.split("/node_modules/")
for i in range(len(parts), 0, -1): for i in range(len(parts), 0, -1):
prefix = "/node_modules/".join(parts[:i]) prefix = "/node_modules/".join(parts[:i])
@ -165,8 +170,11 @@ def _resolve_install_path(parent_path: str, name: str, pkgs: dict) -> str | None
def _deps_of(meta: dict) -> dict: def _deps_of(meta: dict) -> dict:
"""Deps npm actually installs. Optional peers are skipped: they can't keep """Deps npm actually installs. Optional peers are skipped: npm only
a removed top-level dep reachable on their own.""" installs them when another package declares the same dep, so for the
purpose of "is this package still reachable" they cannot keep a
removed top-level dep alive on their own.
"""
out = {} out = {}
for field in ("dependencies", "optionalDependencies"): for field in ("dependencies", "optionalDependencies"):
out.update(meta.get(field) or {}) out.update(meta.get(field) or {})
@ -179,8 +187,10 @@ def _deps_of(meta: dict) -> dict:
def reachable_from_head(head_pkg: dict, lock: dict) -> set[str]: def reachable_from_head(head_pkg: dict, lock: dict) -> set[str]:
"""BFS the lockfile dep graph from `head_pkg`'s top-level deps. Returns the """BFS the lockfile dep graph starting from `head_pkg`'s top-level
surviving install paths, excluding stale (orphaned) lockfile entries.""" declared deps. Returns the set of lockfile install paths that survive.
Stale lockfile entries (orphaned by the new package.json) are excluded.
"""
pkgs = lock.get("packages", {}) pkgs = lock.get("packages", {})
if not pkgs: if not pkgs:
return set() return set()
@ -207,17 +217,23 @@ def reachable_from_head(head_pkg: dict, lock: dict) -> set[str]:
def classify(pkg: str, file: str, content: str) -> str | None: def classify(pkg: str, file: str, content: str) -> str | None:
"""Return why `content` references `pkg`, or None. """Return why `content` references `pkg`, or None.
`content` may span multiple lines (multi-line imports/exports use re.DOTALL). `content` may span multiple lines (for multi-line imports/exports);
Bare-spec regexes word-boundary the package name so `foobar` doesn't match each pattern uses re.DOTALL where it matters. The bare-spec
`foo`. File-type gating restricts JS patterns to .ts/.tsx/.js/.jsx/.mjs/ regexes use a word-boundary check on the package name so that
.cjs/.mdx, CSS to .css/.scss/.sass, HTML to .html/.htm, so a snippet inside `foobar` does not match `foo`.
a Python fixture or Markdown code block isn't mistaken for real npm usage.
File-type gating: JS-syntactic patterns only fire on .ts/.tsx/.js/.jsx/
.mjs/.cjs/.mdx files, so an `import x from "pkg"` snippet inside a
Python test fixture or a Markdown code block is not mistaken for a
real npm usage. CSS patterns only fire on .css/.scss/.sass. HTML
patterns only fire on .html/.htm.
""" """
if file in EXPECTED_NOISE_FILES: if file in EXPECTED_NOISE_FILES:
return None return None
esc = re.escape(pkg) esc = re.escape(pkg)
# Subpath gate: pkg must be followed by quote, `/`, or end-of-string. # Subpath gate: after the package name, the next char must be either
# the closing quote, `/`, or end-of-string. Prevents foo matching foobar.
sub = r"(?:/[^'\"`]*)?" sub = r"(?:/[^'\"`]*)?"
flags_dotall = re.DOTALL | re.MULTILINE flags_dotall = re.DOTALL | re.MULTILINE
@ -227,51 +243,68 @@ def classify(pkg: str, file: str, content: str) -> str | None:
is_html = bool(HTML_EXT.search(file)) is_html = bool(HTML_EXT.search(file))
is_ts = bool(TS_LIKE_EXT.search(file)) is_ts = bool(TS_LIKE_EXT.search(file))
# Gate out Python fixtures, Markdown code blocks, shell snippets, etc. # If the file is none of script / style / html / json (which is the
# quoted-string fallback surface) and is not an mdx file, no classify
# rule applies. This is what gates out Python fixtures, Markdown code
# blocks, shell snippets, etc.
is_json = file.endswith(".json") or file.endswith(".jsonc") is_json = file.endswith(".json") or file.endswith(".jsonc")
if not (is_script or is_style or is_html or is_json): if not (is_script or is_style or is_html or is_json):
return None return None
# CSS @import first so it doesn't collide with side-effect-import below. # CSS @import is checked first so it does not collide with the
# side-effect-import regex below.
if is_style and re.search(rf"@import\s+['\"]{esc}{sub}['\"]", content): if is_style and re.search(rf"@import\s+['\"]{esc}{sub}['\"]", content):
return "css_import" return "css_import"
# Static imports, including multi-line `import { ... } from "pkg"`. # Static imports: handle multi-line `import { ... } from "pkg"` by
# allowing arbitrary content (newlines included) between `import`
# and `from`. The non-greedy match plus the required `from` keeps
# this scoped to a single statement.
if is_script and re.search( if is_script and re.search(
rf"(?<!@)\bimport\b[^;'\"]*?\bfrom\s+['\"]{esc}{sub}['\"]", rf"(?<!@)\bimport\b[^;'\"]*?\bfrom\s+['\"]{esc}{sub}['\"]",
content, content,
flags_dotall, flags_dotall,
): ):
return "static_import" return "static_import"
# Side-effect import `import "pkg"` (no `from`); lookbehind rules out @import. # Side-effect import: `import "pkg"` (no `from`). The negative
# lookbehind rules out CSS `@import` lines.
if is_script and re.search(rf"(?<!@)\bimport\s+['\"]{esc}{sub}['\"]", content): if is_script and re.search(rf"(?<!@)\bimport\s+['\"]{esc}{sub}['\"]", content):
return "side_effect_import" return "side_effect_import"
# Dynamic import: `import("pkg")` and `await import("pkg")`. # Dynamic import: `import("pkg")` and `await import("pkg")`.
if is_script and re.search(rf"\bimport\(\s*['\"]{esc}{sub}['\"]\s*\)", content): if is_script and re.search(rf"\bimport\(\s*['\"]{esc}{sub}['\"]\s*\)", content):
return "dynamic_import" return "dynamic_import"
# require / require.resolve # require / require.resolve
if is_script and re.search(rf"\brequire(?:\.resolve)?\(\s*['\"]{esc}{sub}['\"]\s*\)", content): if is_script and re.search(
rf"\brequire(?:\.resolve)?\(\s*['\"]{esc}{sub}['\"]\s*\)", content
):
return "require" return "require"
# Re-exports: `export * from`, `export { x } from`, `export type { Foo } from`. # Re-exports: `export * from "pkg"`, `export { x } from "pkg"`,
# `export type { Foo } from "pkg"`. Multi-line supported.
if is_script and re.search( if is_script and re.search(
rf"\bexport\b[^;'\"]*?\bfrom\s+['\"]{esc}{sub}['\"]", rf"\bexport\b[^;'\"]*?\bfrom\s+['\"]{esc}{sub}['\"]",
content, content,
flags_dotall, flags_dotall,
): ):
return "re_export" return "re_export"
# HTML script / link. Match pkg as a complete path segment so # HTML script / link. Match the package name as a complete path
# `/node_modules/foo-extra/...` is not treated as usage of `foo`. # segment bounded by a quote / `#` / `?` or a subpath `/`, so
# `/node_modules/foo-extra/...` is NOT treated as usage of `foo`.
html_pkg = rf"{esc}(?:/[^'\"#?]*)?(?=['\"#?])" html_pkg = rf"{esc}(?:/[^'\"#?]*)?(?=['\"#?])"
if is_html and re.search(rf"<script[^>]*src\s*=\s*['\"][^'\"]*/{html_pkg}", content): if is_html and re.search(
rf"<script[^>]*src\s*=\s*['\"][^'\"]*/{html_pkg}", content
):
return "html_script" return "html_script"
if is_html and re.search(rf"<link[^>]*href\s*=\s*['\"][^'\"]*/{html_pkg}", content): if is_html and re.search(rf"<link[^>]*href\s*=\s*['\"][^'\"]*/{html_pkg}", content):
return "html_link" return "html_link"
# TypeScript triple-slash # TypeScript triple-slash
if is_ts and re.search(rf"///\s*<reference\s+types\s*=\s*['\"]{esc}{sub}['\"]", content): if is_ts and re.search(
rf"///\s*<reference\s+types\s*=\s*['\"]{esc}{sub}['\"]", content
):
return "tsc_triple_slash" return "tsc_triple_slash"
# new URL("pkg/...", import.meta.url) # new URL("pkg/...", import.meta.url)
if is_script and re.search(rf"\bnew\s+URL\(\s*['\"]{esc}{sub}['\"]", content): if is_script and re.search(rf"\bnew\s+URL\(\s*['\"]{esc}{sub}['\"]", content):
return "new_url" return "new_url"
# CSS url(...), quoted and unquoted, bounded so `pkg-extra` doesn't match. # CSS url(...). Accept quoted ("pkg/x") AND unquoted (pkg/x) variants,
# bounded by a path-segment lookahead so `pkg-extra` does not match.
if is_style and re.search( if is_style and re.search(
rf"\burl\(\s*['\"]?(?:[^)'\"\s]+/)?{esc}(?:/[^)'\"`]*)?['\"]?\s*\)", rf"\burl\(\s*['\"]?(?:[^)'\"\s]+/)?{esc}(?:/[^)'\"`]*)?['\"]?\s*\)",
content, content,
@ -284,18 +317,20 @@ def classify(pkg: str, file: str, content: str) -> str | None:
if is_script and re.search(rf"@import\(\s*['\"]{esc}{sub}['\"]\s*\)", content): if is_script and re.search(rf"@import\(\s*['\"]{esc}{sub}['\"]\s*\)", content):
return "jsdoc_import" return "jsdoc_import"
# Bare quoted-string fallback (config plugin lists, vite aliases, # Bare quoted-string fallback (config plugin lists, vite aliases,
# tsconfig paths, biome plugin arrays, shadcn registries). # tsconfig paths, biome config plugin arrays, shadcn registries).
if not JS_LIKE_EXT.search(file): if not JS_LIKE_EXT.search(file):
return None return None
# pkg must be followed by `'`, `"`, or `/` so `foo` doesn't match `foobar`. # Boundary: pkg must be followed by `'`, `"`, or `/` to avoid
# matching `foo` inside `foobar`.
if re.search(rf"['\"]{esc}(?:['\"]|/)", content): if re.search(rf"['\"]{esc}(?:['\"]|/)", content):
return "string_literal" return "string_literal"
return None return None
def lockfile_root_sync(head_pkg: dict, head_lock: dict) -> list[str]: def lockfile_root_sync(head_pkg: dict, head_lock: dict) -> list[str]:
"""Warn if package-lock.json's <root> dep map disagrees with package.json """Return a list of warnings if package-lock.json's <root> dep map
(i.e. npm install was not re-run).""" disagrees with package.json (i.e., npm install was not re-run).
"""
warnings = [] warnings = []
if not head_lock: if not head_lock:
return warnings return warnings
@ -323,8 +358,10 @@ def lockfile_root_sync(head_pkg: dict, head_lock: dict) -> list[str]:
def types_orphan_warnings(head_pkg: dict) -> list[str]: def types_orphan_warnings(head_pkg: dict) -> list[str]:
"""Flag @types/<X> deps where <X> is no longer declared in package.json, """Flag @types/<X> deps where <X> is no longer declared anywhere
which leaves dangling type packages.""" in package.json. Removing X without also dropping @types/X leaves
dangling type packages.
"""
decl = set() decl = set()
for f in DEP_FIELDS: for f in DEP_FIELDS:
decl.update((head_pkg.get(f) or {}).keys()) decl.update((head_pkg.get(f) or {}).keys())
@ -332,7 +369,9 @@ def types_orphan_warnings(head_pkg: dict) -> list[str]:
for name in decl: for name in decl:
if not name.startswith("@types/"): if not name.startswith("@types/"):
continue continue
# @types/scope__pkg provides types for @scope/pkg. # @types/foo provides types for `foo`
# @types/foo-bar provides types for `foo-bar`
# @types/scope__pkg provides types for `@scope/pkg`
target = name[len("@types/") :] target = name[len("@types/") :]
if "__" in target: if "__" in target:
scope, sub = target.split("__", 1) scope, sub = target.split("__", 1)
@ -355,7 +394,8 @@ _PKG_JSON_SKIP_KEYS = {
"bundledDependencies", "bundledDependencies",
} }
# Top-level fields whose contents are never package references. # Top-level fields whose contents are never package references. We walk
# everything else recursively.
_PKG_JSON_OPAQUE_KEYS = { _PKG_JSON_OPAQUE_KEYS = {
"browserslist", # browser queries "browserslist", # browser queries
"keywords", # free-form strings "keywords", # free-form strings
@ -397,12 +437,20 @@ _PKG_JSON_OPAQUE_KEYS = {
def package_json_extra_refs(pkg: dict, target: str) -> list[str]: def package_json_extra_refs(pkg: dict, target: str) -> list[str]:
"""Walk package.json (except dep declaration blocks) and return citations """Walk every key/value in package.json EXCEPT the dep declaration
for string values or dict keys equal to `target` (or `target/subpath`). blocks, and return citations for string values or dict keys that
equal `target` (or `target/subpath`).
Catches refs that public dep-checkers commonly miss: overrides/resolutions/ Catches the patterns the public dep-checker tools commonly miss:
pnpm.overrides keys, pnpm.patchedDependencies, peerDependenciesMeta, - `overrides` / `resolutions` / `pnpm.overrides` keys
prettier, eslintConfig.extends, stylelint, babel, jest, commitlint, etc. - `pnpm.patchedDependencies` keys
- `peerDependenciesMeta` keys
- `prettier`: "@my/prettier-config"
- `eslintConfig.extends`: ["..."] / "..."
- `stylelint.extends` / `stylelint.plugins`
- `babel.presets` / `babel.plugins`
- `jest.preset` / `jest.setupFiles` / `jest.transform`
- `commitlint.extends`, `renovate.extends`, `remarkConfig.plugins`
""" """
target_sub = target + "/" target_sub = target + "/"
cites: list[str] = [] cites: list[str] = []
@ -413,11 +461,14 @@ def package_json_extra_refs(pkg: dict, target: str) -> list[str]:
def walk(obj: object, path: str) -> None: def walk(obj: object, path: str) -> None:
if isinstance(obj, dict): if isinstance(obj, dict):
for k, v in obj.items(): for k, v in obj.items():
# Skip top-level dep declaration fields entirely.
if path == "" and k in _PKG_JSON_SKIP_KEYS: if path == "" and k in _PKG_JSON_SKIP_KEYS:
continue continue
# Top-level fields whose contents are never package refs.
if path == "" and k in _PKG_JSON_OPAQUE_KEYS: if path == "" and k in _PKG_JSON_OPAQUE_KEYS:
continue continue
# Inside overrides/resolutions/etc., the KEY is a package ref. # Inside `overrides` / `resolutions` / etc., the KEY itself
# is a package reference.
if matches(k): if matches(k):
cites.append(f"{path}.{k}" if path else k) cites.append(f"{path}.{k}" if path else k)
walk(v, f"{path}.{k}" if path else k) walk(v, f"{path}.{k}" if path else k)
@ -433,8 +484,9 @@ def package_json_extra_refs(pkg: dict, target: str) -> list[str]:
def build_bin_to_pkg(head_lock: dict) -> dict[str, str]: def build_bin_to_pkg(head_lock: dict) -> dict[str, str]:
"""Map a binary name (e.g. 'vite', 'eslint') to its providing package, """Map a binary name (e.g. 'vite', 'tsc', 'eslint') to the package
from each lockfile entry's `bin` field.""" that provides it. Built from each lockfile entry's `bin` field.
"""
out: dict[str, str] = {} out: dict[str, str] = {}
if not head_lock: if not head_lock:
return out return out
@ -453,47 +505,70 @@ def build_bin_to_pkg(head_lock: dict) -> dict[str, str]:
_SCRIPT_TOKENIZE = re.compile(r"\s*(?:&&|\|\||;|\|(?!\|))\s*") _SCRIPT_TOKENIZE = re.compile(r"\s*(?:&&|\|\||;|\|(?!\|))\s*")
# Wrappers that delegate to a real CLI in the same shell word list; we skip # Wrappers that delegate to a real CLI in the same shell word list.
# past them and their flags to find the wrapped bin. Script-name wrappers # After stripping env prefixes and (optionally) `npx`/`pnpm exec`/`yarn dlx`/
# (concurrently, npm-run-all, turbo, nx) are excluded: they reference script # `bunx`, if the leading token is one of these we advance past the
# names, so the real bin lives in the target script's chunk we already tokenize. # wrapper's own flags and any further env-prefix tokens, then re-check.
# `cross-env` is the common one; `dotenv-cli` / `dotenvx` use `--` as a
# separator. Wrappers that operate on named npm-scripts (concurrently,
# npm-run-all, run-s, run-p, wireit, turbo, nx) intentionally aren't
# here -- they reference script names, not bin names, so the real bin
# is in the *target* script's chunk which we already tokenize.
_SCRIPT_WRAPPERS = {"cross-env", "dotenv", "dotenvx", "env-cmd"} _SCRIPT_WRAPPERS = {"cross-env", "dotenv", "dotenvx", "env-cmd"}
_ENV_PREFIX_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") _ENV_PREFIX_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
def _next_real_bin(words: list[str], idx: int) -> str | None: def _next_real_bin(words: list[str], idx: int) -> str | None:
"""Walk `words` from `idx`, peeling env-prefix tokens, the package-manager """Walk `words` from `idx`, peeling env-prefix tokens, the leading
runner (npx, pnpm exec, etc.), and known wrapper bins. Return the next package-manager runner (`npx`, `pnpm exec`, etc.), and the known
real CLI binary, or None. Bounded by the chunk's word count.""" wrapper bins. Return the next token that looks like the real CLI
binary, or None if the chunk has nothing to look up.
Recursion depth is bounded by the chunk's word count, so the loop
cannot run away on a pathological wrapper chain.
"""
seen_wrappers: set[str] = set() seen_wrappers: set[str] = set()
while idx < len(words): while idx < len(words):
# 1. env-prefix run `FOO=bar BAZ="a b" cmd ...` (shlex pre-collapsed). # 1. env-prefix run: `FOO=bar BAZ="a b" cmd ...`. shlex has
# already collapsed quoted values into one word, so this
# tokenizer is safe for them.
while idx < len(words) and _ENV_PREFIX_RE.match(words[idx]): while idx < len(words) and _ENV_PREFIX_RE.match(words[idx]):
idx += 1 idx += 1
if idx >= len(words): if idx >= len(words):
return None return None
first = words[idx] first = words[idx]
# 2. Package-manager runner (npx/pnpm exec/yarn dlx/bunx): strip and # 2. Package-manager runner: `npx <pkg> args`, `pnpm exec <pkg>`,
# continue so the wrapped command re-enters the unwrap loop. # `yarn dlx <pkg>`, `bunx <pkg>`. Strip and continue (so the
# wrapped command goes through the same unwrap loop).
if first in {"npx", "pnpx", "bunx"} and idx + 1 < len(words): if first in {"npx", "pnpx", "bunx"} and idx + 1 < len(words):
idx += 1 idx += 1
continue continue
if first in {"pnpm", "yarn"} and idx + 2 < len(words) and words[idx + 1] in {"exec", "dlx"}: if (
first in {"pnpm", "yarn"}
and idx + 2 < len(words)
and words[idx + 1] in {"exec", "dlx"}
):
idx += 2 idx += 2
continue continue
# 3. Wrapper bin (cross-env, dotenv): skip its flags and env prefixes. # 3. Wrapper bin (cross-env, dotenv, etc.). Skip the wrapper's
bin_token = first.removeprefix("./node_modules/.bin/").removeprefix("node_modules/.bin/") # own flags and any subsequent env-prefix tokens, then re-loop.
bin_token = first.removeprefix("./node_modules/.bin/").removeprefix(
"node_modules/.bin/"
)
if bin_token in _SCRIPT_WRAPPERS and bin_token not in seen_wrappers: if bin_token in _SCRIPT_WRAPPERS and bin_token not in seen_wrappers:
seen_wrappers.add(bin_token) seen_wrappers.add(bin_token)
idx += 1 idx += 1
# dotenv/dotenvx use `-e <file>` flags and an optional `--`. # cross-env / env-cmd: no flags; just more env-prefix tokens.
# dotenv / dotenvx: skip `-e <file>` style flags and the
# optional `--` separator before the wrapped command.
while idx < len(words): while idx < len(words):
tok = words[idx] tok = words[idx]
if tok.startswith("-") and tok != "--": if tok.startswith("-") and tok != "--":
idx += 1 idx += 1
# `-e .env`: also skip the flag's argument. # `-e .env` style: also skip the flag's argument
# when it does not look like another flag.
if ( if (
idx < len(words) idx < len(words)
and not words[idx].startswith("-") and not words[idx].startswith("-")
@ -510,13 +585,21 @@ def _next_real_bin(words: list[str], idx: int) -> str | None:
return None return None
def scripts_bin_refs(head_pkg: dict, bin_to_pkg: dict[str, str]) -> dict[str, list[str]]: def scripts_bin_refs(
"""Return `{package_name: ['scripts.X: cmd', ...]}` for every package head_pkg: dict, bin_to_pkg: dict[str, str]
referenced via its bin name in package.json scripts. ) -> dict[str, list[str]]:
"""Return `{package_name: ['scripts.X: cmd', ...]}` listing every
package referenced via its bin name in package.json scripts.
Each script is split on shell separators; `_next_real_bin()` unwraps env Each script value is split on shell separators (`&&`, `||`, `;`,
prefixes, package-manager runners, and wrapper bins so `cross-env CI=1 `|`). Within each chunk, `_next_real_bin()` unwraps env prefixes,
biome check` credits `biome`. Uses shlex.split so quoted env values survive. package-manager runners (`npx` / `pnpm exec` / `yarn dlx` / `bunx`),
and wrapper bins like `cross-env` / `dotenv` so that
`cross-env CI=1 biome check` correctly credits `biome` to its
declaring package.
Tokenization uses shlex.split so quoted env values
(`FOO="a b" biome`) survive unbroken.
""" """
import shlex import shlex
@ -532,7 +615,7 @@ def scripts_bin_refs(head_pkg: dict, bin_to_pkg: dict[str, str]) -> dict[str, li
try: try:
words = shlex.split(chunk, posix = True) words = shlex.split(chunk, posix = True)
except ValueError: except ValueError:
# Unbalanced quotes: fall back to plain split. # Unbalanced quotes -- fall back to plain split.
words = chunk.split() words = chunk.split()
if not words: if not words:
continue continue
@ -546,8 +629,11 @@ def scripts_bin_refs(head_pkg: dict, bin_to_pkg: dict[str, str]) -> dict[str, li
def tsconfig_compiler_types_refs() -> set[str]: def tsconfig_compiler_types_refs() -> set[str]:
"""Return package names in tsconfig*.json compilerOptions.types arrays. """Read studio/frontend/tsconfig*.json and return the set of
These are implicitly loaded by tsc and count as real uses.""" package names referenced in compilerOptions.types arrays. These are
implicitly loaded by tsc and count as a real use even though they
have no explicit import.
"""
out: set[str] = set() out: set[str] = set()
base = REPO_ROOT / "studio/frontend" base = REPO_ROOT / "studio/frontend"
for name in ("tsconfig.json", "tsconfig.app.json", "tsconfig.node.json"): for name in ("tsconfig.json", "tsconfig.app.json", "tsconfig.node.json"):
@ -565,16 +651,30 @@ def tsconfig_compiler_types_refs() -> set[str]:
for t in types: for t in types:
if not isinstance(t, str): if not isinstance(t, str):
continue continue
# `vite/client` resolves to the `vite` package. # `vite/client` resolves to `vite` package.
pkg = t.split("/", 1)[0] if not t.startswith("@") else "/".join(t.split("/", 2)[:2]) pkg = (
t.split("/", 1)[0]
if not t.startswith("@")
else "/".join(t.split("/", 2)[:2])
)
out.add(pkg) out.add(pkg)
return out return out
def enumerate_dep_usage(head_pkg: dict, head_lock: dict) -> dict[str, list]: def enumerate_dep_usage(head_pkg: dict, head_lock: dict) -> dict[str, list]:
"""For every declared dep, classify usage into a dict of package-name lists: """For every declared dep, classify whether it appears used. Returns
used, unused, type_pkg_kept (@types/X with X declared), type_pkg_orphan a dict with these categories:
(@types/X with X gone). `unused` is a CANDIDATE list; verify before deletion. - used: has at least one detected usage in src/,
config files, scripts.bin, package.json
field refs, or tsconfig types
- unused: no detected usage anywhere
- type_pkg_kept: @types/X where X is still declared
- type_pkg_orphan: @types/X where X is no longer declared
(or X is removed) -- candidate for removal
Each entry is the package name. The categorisation is opinionated;
`unused` is a CANDIDATE list, not a guarantee. The caller should
verify before deletion.
""" """
decl = all_decl_names(head_pkg) decl = all_decl_names(head_pkg)
bin_to_pkg = build_bin_to_pkg(head_lock) if head_lock else {} bin_to_pkg = build_bin_to_pkg(head_lock) if head_lock else {}
@ -603,8 +703,11 @@ def enumerate_dep_usage(head_pkg: dict, head_lock: dict) -> dict[str, list]:
# Real-source-usage check # Real-source-usage check
hits = find_usage(name) hits = find_usage(name)
used = bool(hits) used = bool(hits)
# CLI usage in shell/workflow/Dockerfile. Skipped for @types/* (no CLI # CLI usage in shell / workflow / Dockerfile surfaces. Skip for
# binary; the bare-name bin candidate would false-match the runtime). # `@types/*` packages because they never expose a CLI binary and
# the unscoped-tail bin name candidate would scan workflow files
# for the bare runtime name (a removed `@types/foo` would look
# for invocations of `foo`).
if not used and not name.startswith("@types/") and find_command_usage(name): if not used and not name.startswith("@types/") and find_command_usage(name):
used = True used = True
# Bin scripts # Bin scripts
@ -624,15 +727,28 @@ def enumerate_dep_usage(head_pkg: dict, head_lock: dict) -> dict[str, list]:
def find_imports_without_decl(head_pkg: dict) -> list[tuple[str, int, str]]: def find_imports_without_decl(head_pkg: dict) -> list[tuple[str, int, str]]:
"""Reverse check: find bare-specifier imports in studio/frontend/src with """Reverse check: find bare-specifier imports in studio/frontend/src
no matching package.json dep (import added but dep declaration forgotten). that don't correspond to any declared package.json dep. Catches the
Covers import/require/dynamic-import shapes. Returns (file, line, spec). case where someone adds an import but forgets the dep declaration.
Returns (file, line, spec) tuples.
Match shapes covered:
import "pkg"
import Foo from "pkg"
import { Foo } from "pkg"
import type { Foo } from "pkg"
const x = require("pkg")
const x = await import("pkg")
""" """
decl = set() decl = set()
for f in DEP_FIELDS: for f in DEP_FIELDS:
decl.update((head_pkg.get(f) or {}).keys()) decl.update((head_pkg.get(f) or {}).keys())
# Exclude relative paths and the `@/` alias by requiring the specifier's # Also: anything tsconfig path-aliases (just '@/...' here) is internal.
# first char to be neither `.` nor `/`. Capture group is the specifier. # The capture group is the specifier; the leading alternation accepts
# any of: `from "..."`, bare side-effect `import "..."`,
# `import("..."), or `require("...")`. We exclude relative paths and
# the `@/` alias prefix by requiring the first char of the specifier
# to be neither `.` nor `/`.
pattern = ( pattern = (
r"(?:\bfrom\s+|" r"(?:\bfrom\s+|"
r"\bimport\s+(?:\(\s*)?|" r"\bimport\s+(?:\(\s*)?|"
@ -658,7 +774,7 @@ def find_imports_without_decl(head_pkg: dict) -> list[tuple[str, int, str]]:
file, ln, content = m.group(1), int(m.group(2)), m.group(3) file, ln, content = m.group(1), int(m.group(2)), m.group(3)
for spec_match in re.finditer(pattern, content): for spec_match in re.finditer(pattern, content):
spec = spec_match.group(1) spec = spec_match.group(1)
# Resolve to package name (strip subpath). # Resolve to package name (strip subpath)
if spec.startswith("@"): if spec.startswith("@"):
parts = spec.split("/", 2) parts = spec.split("/", 2)
pkg_name = "/".join(parts[:2]) if len(parts) >= 2 else spec pkg_name = "/".join(parts[:2]) if len(parts) >= 2 else spec
@ -666,7 +782,7 @@ def find_imports_without_decl(head_pkg: dict) -> list[tuple[str, int, str]]:
pkg_name = spec.split("/", 1)[0] pkg_name = spec.split("/", 1)[0]
if pkg_name in decl: if pkg_name in decl:
continue continue
# Internal aliases like '@/foo' or builtin names. # Internal aliases like '@/foo' or starts with builtin names
if pkg_name == "@": if pkg_name == "@":
continue continue
if pkg_name in { if pkg_name in {
@ -704,17 +820,20 @@ _file_lines_cache: dict[str, list[str]] = {}
def _read_file(path: str) -> list[str]: def _read_file(path: str) -> list[str]:
if path not in _file_lines_cache: if path not in _file_lines_cache:
try: try:
_file_lines_cache[path] = Path(path).read_text(errors = "replace").splitlines() _file_lines_cache[path] = (
Path(path).read_text(errors = "replace").splitlines()
)
except (OSError, UnicodeDecodeError): except (OSError, UnicodeDecodeError):
_file_lines_cache[path] = [] _file_lines_cache[path] = []
return _file_lines_cache[path] return _file_lines_cache[path]
def find_usage(pkg: str) -> list[Hit]: def find_usage(pkg: str) -> list[Hit]:
"""Return real usages of `pkg` (pip-playwright filtered separately). """Return real usages of `pkg`. Filters pip-playwright separately.
For each grep hit, also feed a multi-line window into classify() so For each filename returned by grep, also feed a multi-line window
multi-line imports get picked up. around the matching line into classify() so multi-line imports
(`import {\n a\n} from "pkg"`) get picked up.
""" """
rows = grep_repo(re.escape(pkg)) rows = grep_repo(re.escape(pkg))
hits = [] hits = []
@ -725,8 +844,10 @@ def find_usage(pkg: str) -> list[Hit]:
# Try the single-line classify first. # Try the single-line classify first.
kind = classify(pkg, file, content) kind = classify(pkg, file, content)
if not kind: if not kind:
# Multi-line window (25 lines each side) so Prettier's # Multi-line window: a generous 25 lines above + the line +
# one-import-per-line formatting still pairs `import` with `from`. # 25 below so Prettier's one-import-per-line formatting for
# 12-20+ named imports still includes the `import` keyword
# in the same window as the `from "pkg"` clause.
lines = _read_file(file) lines = _read_file(file)
lo = max(0, lineno - 26) lo = max(0, lineno - 26)
hi = min(len(lines), lineno + 25) hi = min(len(lines), lineno + 25)
@ -742,21 +863,28 @@ def find_usage(pkg: str) -> list[Hit]:
def _candidate_bin_names(pkg: str) -> set[str]: def _candidate_bin_names(pkg: str) -> set[str]:
"""Bin names a removed package's CLI could be invoked under. Most npm CLIs """Names a removed package's CLI could be invoked under in shell
use the package name; scoped ones expose an unscoped bin (@biomejs/biome -> scripts and workflow files. Most npm CLIs use the package name
biome).""" (`vite`, `eslint`, `playwright`); scoped CLI packages commonly
expose an unscoped binary name (`@biomejs/biome` -> `biome`).
"""
return {pkg, pkg.rsplit("/", 1)[-1]} return {pkg, pkg.rsplit("/", 1)[-1]}
def find_command_usage(pkg: str) -> list[Hit]: def find_command_usage(pkg: str) -> list[Hit]:
"""Find package CLI invocations in shell/workflow/Dockerfile surfaces (npx, """Find package CLI invocations in shell / workflow / Dockerfile
bunx, pnpm exec, yarn dlx, or bare `pkg --flag`). Bounded to surfaces: `npx pkg`, `bunx pkg`, `pnpm exec pkg`, `yarn dlx pkg`,
COMMAND_LIKE_EXT so `npx foo` in a TS fixture isn't mistaken for real use. or a bare `pkg --flag`. Returns Hit("command_bin").
Detection is bounded to COMMAND_LIKE_EXT files so a JS string that
happens to contain `npx foo` inside a TS test fixture is not
mistaken for a real invocation.
""" """
bins = sorted(_candidate_bin_names(pkg), key = len, reverse = True) bins = sorted(_candidate_bin_names(pkg), key = len, reverse = True)
esc_bins = "|".join(re.escape(b) for b in bins) esc_bins = "|".join(re.escape(b) for b in bins)
# grep ERE pattern. Built without f-strings to avoid clashing with the # grep ERE pattern (POSIX classes for whitespace/word boundaries).
# POSIX `[[:space:]]` literals. # Build without f-strings to avoid f-string-vs-{} confusion with the
# POSIX `[[:space:]]` literals and trailing `})}` boundary class.
grep_pat = ( grep_pat = (
r"(^|[[:space:]:;&|(\[])" r"(^|[[:space:]:;&|(\[])"
r"(npx[[:space:]]+|pnpm[[:space:]]+exec[[:space:]]+" r"(npx[[:space:]]+|pnpm[[:space:]]+exec[[:space:]]+"
@ -788,8 +916,10 @@ def find_command_usage(pkg: str) -> list[Hit]:
def types_target_name(pkg: str) -> str | None: def types_target_name(pkg: str) -> str | None:
"""Strip `@types/` and decode scope-encoding to the runtime package name """Strip `@types/` prefix and decode the npm scope-encoding so the
(`@types/foo__bar` -> `@foo/bar`). None for non-@types packages.""" return value matches the runtime package name. `@types/foo` -> `foo`,
`@types/foo__bar` -> `@foo/bar`. Returns None for non-@types packages.
"""
if not pkg.startswith("@types/"): if not pkg.startswith("@types/"):
return None return None
target = pkg[len("@types/") :] target = pkg[len("@types/") :]
@ -800,8 +930,11 @@ def types_target_name(pkg: str) -> str | None:
def find_types_runtime_usage(pkg: str, tsc_types: set[str]) -> list[Hit]: def find_types_runtime_usage(pkg: str, tsc_types: set[str]) -> list[Hit]:
"""For a removed `@types/X`, find usages of `X` itself (triple-slash """For a removed `@types/X`, find usages of `X` itself: explicit
reference, tsconfig types, runtime import). If any exist, @types/X stays.""" `/// <reference types="X" />`, `tsconfig.compilerOptions.types: ["X"]`,
and runtime `import "X"` shapes. The whole point of `@types/X` is to
type one of those; if any are present, the type package must stay.
"""
target = types_target_name(pkg) target = types_target_name(pkg)
if target is None: if target is None:
return [] return []
@ -819,14 +952,18 @@ def find_types_runtime_usage(pkg: str, tsc_types: set[str]) -> list[Hit]:
def main() -> int: def main() -> int:
p = argparse.ArgumentParser(description = __doc__, formatter_class = argparse.RawTextHelpFormatter) p = argparse.ArgumentParser(
description = __doc__, formatter_class = argparse.RawTextHelpFormatter
)
p.add_argument( p.add_argument(
"--base", "--base",
default = "origin/main", default = "origin/main",
help = "git ref to diff against (default: origin/main). " help = "git ref to diff against (default: origin/main). "
"Examples: HEAD~1, main, a-tag, a-sha.", "Examples: HEAD~1, main, a-tag, a-sha.",
) )
p.add_argument("--base-pkg", help = "optional override: read base package.json from this path") p.add_argument(
"--base-pkg", help = "optional override: read base package.json from this path"
)
p.add_argument( p.add_argument(
"--base-lock", "--base-lock",
help = "optional override: read base package-lock.json from this path. " help = "optional override: read base package-lock.json from this path. "
@ -886,9 +1023,10 @@ def main() -> int:
return 2 return 2
head_lock = read_pkg_file(head_lock_path) head_lock = read_pkg_file(head_lock_path)
# Base lockfile is best-effort: only used to recover the bin -> package # Base lockfile is best-effort. We use it only to recover the
# mapping for packages the PR removes, so a scripts.biome cite still fires # bin -> package mapping for packages the PR is removing -- so a
# when @biomejs/biome is dropped from the head lockfile. # `scripts.biome:check` cite still fires when `@biomejs/biome` is
# being dropped and the head lockfile no longer has it.
if args.base_lock: if args.base_lock:
base_lock_path = Path(args.base_lock) base_lock_path = Path(args.base_lock)
base_lock = read_pkg_file(base_lock_path) if base_lock_path.exists() else {} base_lock = read_pkg_file(base_lock_path) if base_lock_path.exists() else {}
@ -899,8 +1037,9 @@ def main() -> int:
head_names = all_decl_names(head_pkg) head_names = all_decl_names(head_pkg)
removed = sorted(base_names - head_names) removed = sorted(base_names - head_names)
# Hygiene checks compute up front so they run on both the removal-present # All hygiene checks compute up front so they can run on both the
# and removal-empty paths (so --strict fails on hygiene-only issues). # removal-present and removal-empty paths (so `--strict` actually
# fails when only hygiene issues exist).
sync_warns = lockfile_root_sync(head_pkg, head_lock) sync_warns = lockfile_root_sync(head_pkg, head_lock)
types_warns = types_orphan_warnings(head_pkg) types_warns = types_orphan_warnings(head_pkg)
missing_imports = find_imports_without_decl(head_pkg) missing_imports = find_imports_without_decl(head_pkg)
@ -918,7 +1057,9 @@ def main() -> int:
print(f" - {w}") print(f" - {w}")
print() print()
if missing_imports: if missing_imports:
print(f"Imports without a matching package.json dep ({len(missing_imports)}):") print(
f"Imports without a matching package.json dep ({len(missing_imports)}):"
)
for file, ln, spec in missing_imports[:20]: for file, ln, spec in missing_imports[:20]:
print(f" - {file}:{ln} imports '{spec}'") print(f" - {file}:{ln} imports '{spec}'")
print() print()
@ -956,14 +1097,19 @@ def main() -> int:
return 1 return 1
return 0 return 0
print(f"Checking {len(removed)} removed package(s) from studio/frontend/package.json") print(
f"Checking {len(removed)} removed package(s) from studio/frontend/package.json"
)
print(f"Base: {args.base} Head: working tree") print(f"Base: {args.base} Head: working tree")
print() print()
reachable_paths = reachable_from_head(head_pkg, head_lock) if head_lock else set() reachable_paths = reachable_from_head(head_pkg, head_lock) if head_lock else set()
# bin -> package map from the head lockfile, layering base-lockfile entries # bin -> package map: start from the head lockfile, then layer the
# for removed packages so scripts.biome still flags when @biomejs/biome is # base lockfile's entries on top for packages this PR is removing.
# dropped (head lockfile no longer maps it). # A correct removal updates the head lockfile to drop node_modules/foo,
# so build_bin_to_pkg(head_lock) loses the mapping; we recover it
# from the base lockfile so `scripts.biome:check` still flags as a
# usage when `@biomejs/biome` is being dropped.
bin_to_pkg = build_bin_to_pkg(head_lock) if head_lock else {} bin_to_pkg = build_bin_to_pkg(head_lock) if head_lock else {}
base_bin_to_pkg = build_bin_to_pkg(base_lock) if base_lock else {} base_bin_to_pkg = build_bin_to_pkg(base_lock) if base_lock else {}
removed_set = set(removed) removed_set = set(removed)
@ -975,12 +1121,15 @@ def main() -> int:
def reachable_install_paths(name: str) -> tuple[str | None, list[str]]: def reachable_install_paths(name: str) -> tuple[str | None, list[str]]:
"""Return (top_level_path, nested_paths). top_level is what bare """Return (top_level_path, nested_paths). top_level is what bare
`import "name"` resolves to; nested copies are only visible inside `import "name"` from src/ actually resolves to; nested copies are
their parent package.""" only visible inside the parent package that nested them.
"""
top = f"node_modules/{name}" top = f"node_modules/{name}"
top_path = top if top in reachable_paths else None top_path = top if top in reachable_paths else None
nested = sorted( nested = sorted(
p for p in reachable_paths if p != top and p.endswith(f"/node_modules/{name}") p
for p in reachable_paths
if p != top and p.endswith(f"/node_modules/{name}")
) )
return top_path, nested return top_path, nested
@ -989,7 +1138,8 @@ def main() -> int:
hits = find_usage(name) hits = find_usage(name)
# CLI invocations in shell scripts / workflows / Dockerfiles. # CLI invocations in shell scripts / workflows / Dockerfiles.
hits.extend(find_command_usage(name)) hits.extend(find_command_usage(name))
# @types/X is "used" if X is referenced as a type or runtime import. # @types/X is "used" if X is referenced as a type or as a
# runtime import elsewhere in the repo.
hits.extend(find_types_runtime_usage(name, tsc_types)) hits.extend(find_types_runtime_usage(name, tsc_types))
for cite in script_refs.get(name, []): for cite in script_refs.get(name, []):
hits.append(Hit("studio/frontend/package.json", 0, "script_bin", cite)) hits.append(Hit("studio/frontend/package.json", 0, "script_bin", cite))
@ -997,8 +1147,9 @@ def main() -> int:
hits.append(Hit("studio/frontend/package.json", 0, "pkg_json_field", cite)) hits.append(Hit("studio/frontend/package.json", 0, "pkg_json_field", cite))
top, nested = reachable_install_paths(name) top, nested = reachable_install_paths(name)
importable_top_level = top is not None importable_top_level = top is not None
# Bare specifier `name` resolves ONLY to top-level node_modules/<name>; # Source imports of bare specifier `name` resolve ONLY to top-level
# nested copies are invisible to src/ files. # node_modules/<name>. Nested copies under another package are
# invisible to src/ files.
if hits and not importable_top_level: if hits and not importable_top_level:
status = "FAIL" status = "FAIL"
elif hits and importable_top_level: elif hits and importable_top_level:
@ -1026,7 +1177,9 @@ def main() -> int:
_print_hygiene() _print_hygiene()
if failures: if failures:
print(f"FAIL: {len(failures)} removed package(s) still referenced and not resolvable") print(
f"FAIL: {len(failures)} removed package(s) still referenced and not resolvable"
)
for name, _ in failures: for name, _ in failures:
print(f" - {name}") print(f" - {name}")
return 1 return 1

View file

@ -4,18 +4,33 @@
"""Diff two `package-lock.json` files and flag NEW install-script deps. """Diff two `package-lock.json` files and flag NEW install-script deps.
A `"hasInstallScript": true` package runs preinstall/install/postinstall A package with `"hasInstallScript": true` runs `preinstall` / `install` /
hooks on every `npm ci` -- the lever behind recent npm supply-chain `postinstall` lifecycle hooks every time `npm ci` lays it down. Every
compromises (attacker publishes a malicious version of a trusted dep). npm supply-chain compromise of the last 18 months (Shai-Hulud,
This refuses to land a newly-introduced install-script dep without a TanStack, axios-style, ArmorCode hijacks) leveraged exactly this lever:
maintainer eyeball; pre-existing ones are not re-flagged. the attacker publishes a new malicious version of a dep we already
trust, and the post-install hook runs the next time CI installs.
Supports lockfileVersion 1 (recursive `dependencies`) and 2/3 (flat This scanner refuses to allow a newly-introduced install-script dep to
`packages` with `node_modules/.../node_modules/...` nesting). For each land without a maintainer eyeball on the lifecycle script body.
new entry we best-effort fetch the registry metadata to recover the Existing install-script deps are NOT re-flagged -- if `node-gyp` has
postinstall command body; the finding is still emitted if unreachable. been in the lockfile since day one, it's not part of this PR's threat
model. Only new entries are surfaced.
Exit codes: 0 = none; 1 = one or more (on stderr); 2 = internal error. Supports lockfileVersion 1 (`dependencies` key, recursive), 2 and 3
(flat `packages` key with `node_modules/<a>/node_modules/<b>` nesting
for transitive entries). For each NEW install-script package we
attempt a stdlib-only fetch of
`https://registry.npmjs.org/<name>/<version>` to recover the actual
postinstall command body. If the network is blocked we still emit the
finding -- the lifecycle command body is informational, not
load-bearing.
Exit codes
==========
0 no newly-added install-script deps
1 one or more newly-added install-script deps; listed on stderr
2 internal error (missing lockfile, malformed JSON, etc.)
""" """
from __future__ import annotations from __future__ import annotations
@ -38,7 +53,9 @@ HIGH = "HIGH"
class Finding: class Finding:
__slots__ = ("severity", "name", "version", "kind", "detail") __slots__ = ("severity", "name", "version", "kind", "detail")
def __init__(self, severity: str, name: str, version: str, kind: str, detail: str) -> None: def __init__(
self, severity: str, name: str, version: str, kind: str, detail: str
) -> None:
self.severity = severity self.severity = severity
self.name = name self.name = name
self.version = version self.version = version
@ -53,14 +70,21 @@ class Finding:
) )
# ─────────────────────────────────────────────────────────────────────
# Lockfile parsing. # Lockfile parsing.
# ─────────────────────────────────────────────────────────────────────
def _strip_nm_prefix(key: str) -> str: def _strip_nm_prefix(key: str) -> str:
"""Convert a v2/v3 `packages` key into a bare package name (leaf after last `node_modules/`).""" """Convert a v2/v3 `packages` key into a bare package name.
`node_modules/foo` -> `foo`; `node_modules/foo/node_modules/bar` ->
`bar`. The empty key (`""`) is the project root and returns "".
"""
if not key: if not key:
return "" return ""
# LAST node_modules/ segment so transitives map to their leaf name. # Use the LAST `node_modules/` segment so transitives map to their
# leaf name, matching how npm install resolves a postinstall.
marker = "node_modules/" marker = "node_modules/"
idx = key.rfind(marker) idx = key.rfind(marker)
if idx == -1: if idx == -1:
@ -69,9 +93,14 @@ def _strip_nm_prefix(key: str) -> str:
def _collect_install_script_entries(lock: dict) -> dict[str, str]: def _collect_install_script_entries(lock: dict) -> dict[str, str]:
"""Return {name@version: name} for entries with hasInstallScript (v2/v3) or a lifecycle script (v1). """Walk a parsed lockfile and return {package_name: version} for
every entry with `hasInstallScript: true` (v2/v3) OR a
non-empty `scripts.preinstall|install|postinstall` (v1).
Keyed by name@version so dup copies at different versions aren't lost. The same package may appear at multiple versions in a single
lockfile (de-duplicated copies under different parents); we key by
`name@version` so we don't lose either copy. Returns a dict keyed
by `name@version` -> the same string for convenience.
""" """
seen: dict[str, str] = {} seen: dict[str, str] = {}
version = lock.get("lockfileVersion") version = lock.get("lockfileVersion")
@ -91,7 +120,10 @@ def _collect_install_script_entries(lock: dict) -> dict[str, str]:
ver = entry.get("version") or "<unversioned>" ver = entry.get("version") or "<unversioned>"
seen[f"{name}@{ver}"] = name seen[f"{name}@{ver}"] = name
# v1 has no hasInstallScript flag; detect lifecycle scripts directly. # v1 also embeds a `dependencies` tree; v2/v3 carry both for
# backwards-compat but `packages` is canonical for them. For v1
# there is no `hasInstallScript` flag, so look for a non-empty
# `scripts.preinstall|install|postinstall` directly.
def _walk_v1(deps: dict, depth: int = 0) -> None: def _walk_v1(deps: dict, depth: int = 0) -> None:
if depth > 64 or not isinstance(deps, dict): if depth > 64 or not isinstance(deps, dict):
return return
@ -103,6 +135,8 @@ def _collect_install_script_entries(lock: dict) -> dict[str, str]:
isinstance(scripts, dict) and scripts.get(hook) isinstance(scripts, dict) and scripts.get(hook)
for hook in ("preinstall", "install", "postinstall") for hook in ("preinstall", "install", "postinstall")
) )
# v1 also sets `requires` only on the parent, no flag, so
# the lifecycle-script presence is the only signal.
if lifecycle: if lifecycle:
ver = entry.get("version") or "<unversioned>" ver = entry.get("version") or "<unversioned>"
seen[f"{name}@{ver}"] = name seen[f"{name}@{ver}"] = name
@ -123,11 +157,19 @@ def _load_lockfile(path: Path) -> dict:
raise ValueError(f"{path}: not valid JSON: {exc}") from exc raise ValueError(f"{path}: not valid JSON: {exc}") from exc
# ─────────────────────────────────────────────────────────────────────
# Registry lookup for the postinstall command body (best-effort). # Registry lookup for the postinstall command body (best-effort).
# ─────────────────────────────────────────────────────────────────────
def _fetch_registry_scripts(name: str, version: str) -> dict[str, str] | None: def _fetch_registry_scripts(name: str, version: str) -> dict[str, str] | None:
"""Return {hook: command} for lifecycle hooks in registry metadata; None on any error (never raises).""" """Return {hook: command} for any of preinstall / install /
postinstall published in the registry metadata for this name@ver.
Returns None on any error (network blocked, 404, malformed JSON).
Never raises; the caller treats absence as "could not enrich, emit
finding anyway".
"""
safe_name = urllib.parse.quote(name, safe = "@/") safe_name = urllib.parse.quote(name, safe = "@/")
url = f"{REGISTRY_BASE}{safe_name}/{urllib.parse.quote(version)}" url = f"{REGISTRY_BASE}{safe_name}/{urllib.parse.quote(version)}"
try: try:
@ -150,7 +192,9 @@ def _fetch_registry_scripts(name: str, version: str) -> dict[str, str] | None:
return keep or None return keep or None
# ─────────────────────────────────────────────────────────────────────
# Diff. # Diff.
# ─────────────────────────────────────────────────────────────────────
def diff_new_install_scripts(base_lock: dict, head_lock: dict) -> list[Finding]: def diff_new_install_scripts(base_lock: dict, head_lock: dict) -> list[Finding]:
@ -161,7 +205,10 @@ def diff_new_install_scripts(base_lock: dict, head_lock: dict) -> list[Finding]:
if key in base: if key in base:
continue # pre-existing install-script dep; not in scope continue # pre-existing install-script dep; not in scope
name = head[key] name = head[key]
version = key[len(name) + 1 :] if key.startswith(name + "@") else "<unversioned>" # key is "name@version"; rsplit("@", 1) handles scoped names.
version = (
key[len(name) + 1 :] if key.startswith(name + "@") else "<unversioned>"
)
scripts = _fetch_registry_scripts(name, version) scripts = _fetch_registry_scripts(name, version)
if scripts: if scripts:
detail = "; ".join(f"{h}={cmd!r}" for h, cmd in scripts.items()) detail = "; ".join(f"{h}={cmd!r}" for h, cmd in scripts.items())
@ -183,13 +230,16 @@ def diff_new_install_scripts(base_lock: dict, head_lock: dict) -> list[Finding]:
return findings return findings
# ─────────────────────────────────────────────────────────────────────
# CLI. # CLI.
# ─────────────────────────────────────────────────────────────────────
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description = ( description = (
"Diff two package-lock.json files and refuse any newly-added install-script dep." "Diff two package-lock.json files and refuse any newly-"
"added install-script dep."
), ),
) )
parser.add_argument( parser.add_argument(

View file

@ -1,10 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Ensure keyword arguments use spaces around '=', prune redundant pass statements, """Ensure keyword arguments use spaces around '=', prune redundant pass statements."""
drop the blank line after a short indented import block, merge adjacent same-line
string literals, normalize def-signature magic commas (pre-ruff) so a def with
>= 3 params and a default goes one-per-line while everything else stays
collapsible, and collapse a short multi-line assert onto one line (pre-ruff) by
stripping the magic trailing comma that holds it open."""
from __future__ import annotations from __future__ import annotations
@ -20,8 +15,13 @@ from pathlib import Path
def _atomic_write_text(path: Path, data: str, encoding: str) -> None: def _atomic_write_text(path: Path, data: str, encoding: str) -> None:
"""Write ``data`` to ``path`` atomically via same-dir tmp + fsync + os.replace, """Write ``data`` to ``path`` atomically.
so a crash mid-write leaves either the old or full new content, never a truncation."""
Stages a tmp file in the same directory (so it's on the same
filesystem as the destination), fsyncs, then `os.replace`s into
place. A crash mid-write therefore leaves either the previous
content or the fully new content -- never a truncated source file.
"""
dirpath = str(path.parent) or "." dirpath = str(path.parent) or "."
fd, tmp_path = tempfile.mkstemp(prefix=".kwargs_fix.", dir=dirpath) fd, tmp_path = tempfile.mkstemp(prefix=".kwargs_fix.", dir=dirpath)
try: try:
@ -123,7 +123,9 @@ def remove_redundant_passes(text: str) -> tuple[str, bool]:
lines = text.splitlines(keepends=True) lines = text.splitlines(keepends=True)
changed = False changed = False
for node in sorted(redundant, key=lambda item: (item.lineno, item.col_offset), reverse=True): for node in sorted(
redundant, key=lambda item: (item.lineno, item.col_offset), reverse=True
):
start = node.lineno - 1 start = node.lineno - 1
end = (node.end_lineno or node.lineno) - 1 end = (node.end_lineno or node.lineno) - 1
if start >= len(lines): if start >= len(lines):
@ -137,7 +139,7 @@ def remove_redundant_passes(text: str) -> tuple[str, bool]:
lines[start] = segment if segment.strip() else "" lines[start] = segment if segment.strip() else ""
continue continue
# Fall-back for unexpected multi-line 'pass'. # Defensive fall-back for unexpected multi-line 'pass'.
prefix = lines[start][: node.col_offset] prefix = lines[start][: node.col_offset]
lines[start] = prefix if prefix.strip() else "" lines[start] = prefix if prefix.strip() else ""
for idx in range(start + 1, end): for idx in range(start + 1, end):
@ -158,441 +160,7 @@ def remove_redundant_passes(text: str) -> tuple[str, bool]:
return "".join(result_lines), changed return "".join(result_lines), changed
def remove_blank_after_short_import(text: str) -> tuple[str, bool]: def process_file(path: Path) -> bool:
"""Drop blank line(s) after an import block in a small nested suite.
In an indented suite of <= 3 statements (never module level), when consecutive
imports are followed across blank lines (nothing else) by another statement,
remove those blanks. A comment in the gap blocks the rule. Removing blank lines
never changes the AST.
"""
try:
tree = ast.parse(text)
except SyntaxError:
return text, False
lines = text.splitlines(keepends=True)
import_types = (ast.Import, ast.ImportFrom)
drop: set[int] = set() # 1-based physical line numbers to delete
def suites_of(node: ast.AST) -> list[list[ast.stmt]]:
if isinstance(node, ast.Module):
return [] # module-level import spacing is left alone
out: list[list[ast.stmt]] = []
for attr in ("body", "orelse", "finalbody"):
val = getattr(node, attr, None)
if isinstance(val, list) and val and all(isinstance(s, ast.stmt) for s in val):
out.append(val)
return out
for node in ast.walk(tree):
for suite in suites_of(node):
if len(suite) > 3: # only small blocks
continue
i = 0
while i < len(suite):
if not isinstance(suite[i], import_types):
i += 1
continue
j = i
while j + 1 < len(suite) and isinstance(suite[j + 1], import_types):
j += 1
if j + 1 < len(suite): # an import block followed by another statement
last_imp, nxt = suite[j], suite[j + 1]
gap = range((last_imp.end_lineno or last_imp.lineno) + 1, nxt.lineno)
nums = [n for n in gap if 1 <= n <= len(lines)]
if nums and all(lines[n - 1].strip() == "" for n in nums):
drop.update(nums)
i = j + 1
if not drop:
return text, False
kept = [ln for idx, ln in enumerate(lines, start=1) if idx not in drop]
return "".join(kept), True
_STRING_TRIVIA = (tokenize.NL, tokenize.NEWLINE, tokenize.COMMENT, tokenize.INDENT, tokenize.DEDENT)
_DEF_MIN_PARAMS_FOR_MULTILINE = 3 # signatures with < this many params stay one line
def _def_specs_by_line(tree: ast.AST) -> dict[int, tuple[int, bool]]:
"""Map each def keyword line to (param count, has-any-default).
``*`` / ``/`` markers aren't counted. A default exists if any positional default
is present or any keyword-only default is not ``None`` (``None`` in ``kw_defaults``
means a required keyword-only arg).
"""
out: dict[int, tuple[int, bool]] = {}
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
a = node.args
count = (
len(a.posonlyargs)
+ len(a.args)
+ len(a.kwonlyargs)
+ (1 if a.vararg else 0)
+ (1 if a.kwarg else 0)
)
has_default = bool(a.defaults) or any(d is not None for d in a.kw_defaults)
out[node.lineno] = (count, has_default)
return out
def normalize_def_trailing_comma(text: str) -> tuple[str, bool]:
"""Force a def signature one-per-line iff >= 3 params AND a default; else collapsible.
A qualifying signature gets a magic trailing comma added (ruff wraps it
one-per-line); every other signature has its trailing comma stripped so ruff
collapses it when it fits. Def parameter lists only, never call sites or
collection literals. Run BEFORE ruff format. Never changes the AST (re-checked).
"""
try:
tree = ast.parse(text)
toks = list(tokenize.generate_tokens(io.StringIO(text).readline))
except (tokenize.TokenError, IndentationError, SyntaxError):
return text, False
specs = _def_specs_by_line(tree)
n = len(toks)
edits: list[tuple[int, int, str]] = [] # (row, col, "del" | "ins")
i = 0
while i < n:
t = toks[i]
if t.type == tokenize.NAME and t.string == "def" and t.start[0] in specs:
cnt, has_default = specs[t.start[0]]
force_multiline = cnt >= _DEF_MIN_PARAMS_FOR_MULTILINE and has_default
j = i + 1
while j < n and not (toks[j].type == tokenize.OP and toks[j].string == "("):
if toks[j].type == tokenize.NEWLINE:
break
j += 1
if j < n and toks[j].type == tokenize.OP and toks[j].string == "(":
depth = 0
k = j
while k < n:
tk = toks[k]
if tk.type == tokenize.OP and tk.string == "(":
depth += 1
elif tk.type == tokenize.OP and tk.string == ")":
depth -= 1
if depth == 0:
m = k - 1
while m > j and toks[m].type in _STRING_TRIVIA:
m -= 1
last = toks[m]
has_comma = last.type == tokenize.OP and last.string == ","
empty = m == j # nothing between ( and )
if force_multiline and not has_comma and not empty:
edits.append((last.end[0], last.end[1], "ins"))
elif not force_multiline and has_comma:
edits.append((last.start[0], last.start[1], "del"))
break
k += 1
i = k + 1
continue
i += 1
if not edits:
return text, False
lines = text.splitlines(keepends=True)
for row, col, kind in sorted(edits, reverse=True):
ln = lines[row - 1]
if kind == "del":
if col < len(ln) and ln[col] == ",":
lines[row - 1] = ln[:col] + ln[col + 1 :]
else: # ins
lines[row - 1] = ln[:col] + "," + ln[col:]
out = "".join(lines)
try:
if ast.dump(ast.parse(out)) != ast.dump(ast.parse(text)):
return text, False
except SyntaxError:
return text, False
return out, True
def _split_string_token(s: str) -> tuple[str, str, str] | None:
"""Split a string literal source into (prefix, quote, body).
``prefix`` is the letters before the opening quote, ``quote`` the delimiter,
``body`` everything between. ``None`` if not a recognizable string literal.
"""
i = 0
while i < len(s) and s[i] not in ("'", '"'):
i += 1
if i >= len(s):
return None
prefix, rest = s[:i], s[i:]
for q in ('"""', "'''", '"', "'"):
if rest.startswith(q) and rest.endswith(q) and len(rest) >= 2 * len(q):
return prefix, q, rest[len(q) : len(rest) - len(q)]
return None
# A "piece" is one string literal in source: a plain STRING token, or a whole
# f-string spanning FSTRING_START..FSTRING_END. (kind, (row, col0), (row, col1), raw)
def _string_pieces(
toks: list[tokenize.TokenInfo], lines: list[str]
) -> list[tuple[str, tuple[int, int], tuple[int, int], str | None]]:
pieces: list[tuple[str, tuple[int, int], tuple[int, int], str | None]] = []
n = len(toks)
def raw_of(start: tuple[int, int], end: tuple[int, int]) -> str | None:
if start[0] != end[0]: # only single-physical-line pieces are mergeable
return None
return lines[start[0] - 1][start[1] : end[1]]
i = 0
while i < n:
t = toks[i]
if t.type == tokenize.STRING:
pieces.append(("str", t.start, t.end, raw_of(t.start, t.end)))
i += 1
elif t.type == tokenize.FSTRING_START:
depth = 0
j = i
while j < n: # walk to the matching FSTRING_END (f-strings can nest)
if toks[j].type == tokenize.FSTRING_START:
depth += 1
elif toks[j].type == tokenize.FSTRING_END:
depth -= 1
if depth == 0:
break
j += 1
end = toks[j].end
pieces.append(("f", t.start, end, raw_of(t.start, end)))
i = j + 1
else:
pieces.append(("other", t.start, t.end, None))
i += 1
return pieces
def _merge_string_run(pieces: list[tuple[str, str]]) -> str | None:
"""Merge a run of adjacent string pieces into one literal's source text.
``pieces`` is ``(kind, raw_source)`` with kind ``"str"`` or ``"f"``. Bytes are
left side-by-side (``None``); a run with no f-string merges plain/raw/unicode
sharing one prefix+quote by body concatenation; a run mixing an f-string with a
plain string (no bytes, no raw) folds into one f-string with plain braces escaped.
Runs of only f-strings are left alone. Caller re-checks the AST and drops a
differing change, so subtle cases are caught.
"""
parsed = []
for kind, raw in pieces:
pqb = _split_string_token(raw)
if pqb is None:
return None
prefix, quote, body = pqb
if "b" in prefix.lower():
return None # bytes: leave side-by-side
parsed.append((kind, prefix, quote, body))
if len({p[2] for p in parsed}) != 1:
return None # mixed quote style: not a safe textual merge
quote = parsed[0][2]
if not any(p[0] == "f" for p in parsed):
# No f-string: merge plain/raw/unicode sharing one prefix by concatenation.
if len({p[1].lower() for p in parsed}) != 1:
return None
return f"{parsed[0][1]}{quote}{''.join(p[3] for p in parsed)}{quote}"
# f-string fold only when a plain string is glued onto an f-string; a run of
# only f-strings is left side-by-side (folding long ones would force ruff to
# re-wrap the surrounding statement).
if all(p[0] == "f" for p in parsed):
return None
# raw mixed with f is too subtle (backslash + brace escaping) -> skip.
if any("r" in p[1].lower() for p in parsed):
return None
body = "".join(
b if kind == "f" else b.replace("{", "{{").replace("}", "}}")
for kind, _pfx, _q, b in parsed
)
return f"f{quote}{body}{quote}"
_LINE_LENGTH = 100 # ruff line-length; an f-fold must not push a statement past it
def _enclosing_stmt(tree: ast.AST, row: int) -> ast.stmt | None:
"""The innermost statement whose physical-line span contains ``row``."""
best: tuple[ast.stmt, int] | None = None
for node in ast.walk(tree):
if isinstance(node, ast.stmt):
lo = node.lineno
hi = node.end_lineno or lo
if lo <= row <= hi and (best is None or hi - lo < best[1]):
best = (node, hi - lo)
return best[0] if best else None
def _fold_collapses(
tree: ast.AST, lines: list[str], row: int, c0: int, c1: int, merged: str
) -> bool:
"""Whether an f-string fold at ``row[c0:c1]`` -> ``merged`` is safe to apply.
Only ``assert`` wraps awkwardly when a message folds (ruff parenthesizes the
condition once it no longer fits one line); every other construct wraps
acceptably so is always allowed. An ``assert`` fold is allowed only if already
one line, or its estimated folded one-line length fits the line length.
"""
stmt = _enclosing_stmt(tree, row)
if not isinstance(stmt, ast.Assert):
return True
lo, hi = stmt.lineno, stmt.end_lineno or stmt.lineno
if lo == hi:
return True
seg = []
for k in range(lo, hi + 1):
ln = lines[k - 1].rstrip("\n")
if k == row:
ln = ln[:c0] + merged + ln[c1:]
seg.append(ln)
indent = len(seg[0]) - len(seg[0].lstrip())
# Conservative over-estimate: join continuation lines with a single space
# (ruff joins bracketed wraps with none), so borderline cases skip the fold.
joined = " ".join(s.strip() for s in seg)
return indent + len(joined) <= _LINE_LENGTH
def merge_adjacent_string_literals(text: str) -> tuple[str, bool]:
"""Merge adjacent string literals on ONE physical line into a single literal.
Plain/raw/unicode runs merge by concatenation; an f-string + plain string folds
into one f-string (plain braces escaped) only while the statement still fits one
line. Runs of only f-strings, and bytes, are left side-by-side. The file AST is
re-checked and a differing change dropped, so meaning never changes.
"""
try:
toks = list(tokenize.generate_tokens(io.StringIO(text).readline))
tree = ast.parse(text)
except (tokenize.TokenError, IndentationError, SyntaxError):
return text, False
lines = text.splitlines(keepends=True)
pieces = _string_pieces(toks, lines)
# Group consecutive mergeable pieces (str/f, single line, same physical line).
runs: list[list[tuple[str, tuple[int, int], tuple[int, int], str]]] = []
cur: list[tuple[str, tuple[int, int], tuple[int, int], str]] = []
for kind, start, end, raw in pieces:
if kind in ("str", "f") and raw is not None:
if cur and cur[-1][2][0] != start[0]:
if len(cur) >= 2:
runs.append(cur)
cur = []
cur.append((kind, start, end, raw))
else:
if len(cur) >= 2:
runs.append(cur)
cur = []
if len(cur) >= 2:
runs.append(cur)
if not runs:
return text, False
edits = []
for run in runs:
merged = _merge_string_run([(kind, raw) for kind, _s, _e, raw in run])
if merged is None:
continue
row, c0, c1 = run[0][1][0], run[0][1][1], run[-1][2][1]
# An f-string fold must not push its statement onto extra lines; a plain
# concatenation always collapses cleanly so it skips this check.
if any(kind == "f" for kind, _s, _e, _r in run) and not _fold_collapses(
tree, lines, row, c0, c1, merged
):
continue
edits.append((row, c0, c1, merged))
if not edits:
return text, False
for row, c0, c1, repl in sorted(edits, key=lambda e: (e[0], e[1]), reverse=True):
ln = lines[row - 1]
lines[row - 1] = ln[:c0] + repl + ln[c1:]
out = "".join(lines)
try:
if ast.dump(ast.parse(text)) != ast.dump(ast.parse(out)):
return text, False
except SyntaxError:
return text, False
return out, True
def collapse_short_asserts(text: str) -> tuple[str, bool]:
"""Collapse a multi-line ``assert`` onto one line when it would fit.
When the statement's estimated one-line length fits, strip the magic trailing
commas (comma before a closer) holding it open so ruff rejoins it. Run BEFORE
ruff format. Skips asserts with a comment (would oscillate). Stripping is
non-semantic except for a one-element tuple; AST is re-checked and changing
asserts left alone.
"""
try:
tree = ast.parse(text)
toks = list(tokenize.generate_tokens(io.StringIO(text).readline))
except (tokenize.TokenError, IndentationError, SyntaxError):
return text, False
lines = text.splitlines(keepends=True)
multiline = [
(n.lineno, n.end_lineno)
for n in ast.walk(tree)
if isinstance(n, ast.Assert) and (n.end_lineno or n.lineno) > n.lineno
]
if not multiline:
return text, False
comment_rows = {t.start[0] for t in toks if t.type == tokenize.COMMENT}
targets = [] # (lo, hi) spans whose one-line form fits and have no comment
for lo, hi in multiline:
if any(lo <= r <= hi for r in comment_rows):
continue # a comment would keep ruff multi-line -> never collapses
seg = [lines[k].rstrip("\n") for k in range(lo - 1, hi)]
indent = len(seg[0]) - len(seg[0].lstrip())
# Over-estimate (join with a space; keep the comma) so a "fits" verdict
# is always at least as long as ruff's real one-line output -> no fight.
if indent + len(" ".join(s.strip() for s in seg)) <= _LINE_LENGTH:
targets.append((lo, hi))
if not targets:
return text, False
# Trailing commas (a ',' whose next significant token is a closer), grouped
# by the target assert they belong to.
sig = [t for t in toks if t.type not in _STRING_TRIVIA]
by_target: dict[tuple[int, int], list[tuple[int, int]]] = defaultdict(list)
for i, t in enumerate(sig):
if t.type == tokenize.OP and t.string == ",":
nxt = sig[i + 1] if i + 1 < len(sig) else None
if nxt and nxt.type == tokenize.OP and nxt.string in (")", "]", "}"):
for lo, hi in targets:
if lo <= t.start[0] <= hi:
by_target[(lo, hi)].append(t.start)
break
if not by_target:
return text, False
base_dump = ast.dump(tree)
working = lines[:]
changed = False
for positions in by_target.values(): # apply per assert; skip any that break AST
trial = working[:]
for row, col in sorted(positions, reverse=True):
ln = trial[row - 1]
if col < len(ln) and ln[col] == ",":
trial[row - 1] = ln[:col] + ln[col + 1 :]
try:
if ast.dump(ast.parse("".join(trial))) == base_dump:
working, changed = trial, True
except SyntaxError:
pass
return ("".join(working), True) if changed else (text, False)
def process_file(path: Path, pre: bool = False) -> bool:
try: try:
with tokenize.open(path) as handle: with tokenize.open(path) as handle:
original = handle.read() original = handle.read()
@ -601,23 +169,9 @@ def process_file(path: Path, pre: bool = False) -> bool:
print(f"Failed to read {path}: {exc}", file=sys.stderr) print(f"Failed to read {path}: {exc}", file=sys.stderr)
return False return False
if pre:
# Pre-ruff: normalize def-signature magic commas (>=3 params + a default
# add so ruff forces one-per-line; everything else strips so ruff
# collapses), and strip the magic trailing comma from a short multi-line
# assert so ruff joins it onto one line. Everything else runs post-ruff.
updated, normalized = normalize_def_trailing_comma(original)
updated, collapsed = collapse_short_asserts(updated)
if normalized or collapsed:
_atomic_write_text(path, updated, encoding)
return True
return False
updated, changed = enforce_spacing(original) updated, changed = enforce_spacing(original)
updated, blanked = remove_blank_after_short_import(updated)
updated, merged = merge_adjacent_string_literals(updated)
updated, removed = remove_redundant_passes(updated) updated, removed = remove_redundant_passes(updated)
if changed or blanked or merged or removed: if changed or removed:
_atomic_write_text(path, updated, encoding) _atomic_write_text(path, updated, encoding)
return True return True
return False return False
@ -626,11 +180,6 @@ def process_file(path: Path, pre: bool = False) -> bool:
def main(argv: list[str]) -> int: def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("files", nargs="+", help="Python files to fix") parser.add_argument("files", nargs="+", help="Python files to fix")
parser.add_argument(
"--pre",
action="store_true",
help="pre-ruff pass: normalize def-signature commas + collapse short multi-line asserts",
)
args = parser.parse_args(argv) args = parser.parse_args(argv)
touched: list[Path] = [] touched: list[Path] = []
@ -643,7 +192,7 @@ def main(argv: list[str]) -> int:
continue continue
if not path.exists() or path.is_dir(): if not path.exists() or path.is_dir():
continue continue
if process_file(path, pre=args.pre): if process_file(path):
touched.append(path) touched.append(path)
if touched: if touched:

View file

@ -1,6 +1,4 @@
#!/bin/bash #!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
set -euo pipefail set -euo pipefail
# ============================================================ # ============================================================

View file

@ -1,6 +1,4 @@
#!/bin/bash #!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
set -euo pipefail set -euo pipefail
# ============================================================ # ============================================================

View file

@ -1,310 +0,0 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
#
# ──────────────────────────────────────────────────────────────────────────────
# Enable ROCm-on-WSL for AMD GPUs (Strix Halo/Point APUs AND discrete Radeon RX
# 7000/9000). Verified on gfx1151 (Radeon 8060S) and gfx1200 (Radeon RX 9060 XT).
# ──────────────────────────────────────────────────────────────────────────────
# install.sh routes the detected arch to the right ROCm wheels once a runtime exists;
# what it does NOT do is install AMD's ROCm userspace + the WSL DXG bridge (librocdxg).
# This helper does that Linux-side prerequisite on Ubuntu 24.04 WSL2, invoked by
# install.sh when it sees an AMD GPU via /dev/dxg but no ROCm yet. Arch-agnostic: the
# arch is auto-detected from rocminfo (override UNSLOTH_WSL_GFX=gfx1200). Idempotent.
#
# Manual, admin-gated Windows prerequisite: an AMD Adrenalin driver with
# production ROCDXG/WSL support (26.2.2+). install.ps1 offers to update it. Once
# installed + rebooted, /dev/dxg is exposed to WSL and this script builds the rest.
#
# HOW ROCDXG WORKS (and why older /usr/lib/wsl/lib notes are wrong): librocdxg.so
# is AMD's user-mode bridge between the Linux HSA runtime and the Windows driver
# over /dev/dxg. The STANDARD hsa-rocr runtime (NOT the gone "roc4wsl" package)
# loads it when HSA_ENABLE_DXG_DETECTION=1. No hsa/rocm libs need injecting into
# /usr/lib/wsl/lib (it holds only d3d12/dxcore), yet rocminfo enumerates gfx1151
# fine -- so we gate on /dev/dxg, not on WSL lib injection.
#
# KNOWN CAVEAT (ROCm/ROCm#6022): librocdxg can cap usable ROCm VRAM at the WSL
# VM's RAM (.wslconfig [wsl2] memory=) on some BIOS UMA layouts, and amd-smi
# doesn't work in WSL. On OOM below capacity, raise memory= (then wsl --shutdown)
# and watch GPU use from Windows. Large-UMA BIOS exposes the full pool regardless.
#
# Verified on Ryzen AI Max+ PRO 395 / Radeon 8060S (gfx1151) with ROCm 7.2.1 +
# Ubuntu 24.04 + WSL2 + Adrenalin. These pins MOVE; bump + re-verify on newer ROCm.
# ──────────────────────────────────────────────────────────────────────────────
set -euo pipefail
# ── Tunables (override via env) ──────────────────────────────────────────────
ROCM_VER="${UNSLOTH_WSL_ROCM_VER:-7.2.1}" # ROCm release to install
# GPU arch: empty = auto-detect from rocminfo after install (override UNSLOTH_WSL_GFX=gfx1200).
# The ROCm + librocdxg setup is arch-agnostic; only verify + the smoke test need the arch.
GFX="${UNSLOTH_WSL_GFX:-}"
LIBROCDXG_REF="${UNSLOTH_LIBROCDXG_REF:-develop}" # ROCm/librocdxg git ref to build
# AMD's wheel index for the (optional) smoke test; resolved after arch detection.
TORCH_INDEX=""
# Optional torch smoke test (throwaway venv). OFF by default: install.sh installs
# torch itself into the real venv right after, so a duplicate download is wasteful.
SMOKE_TEST="${UNSLOTH_WSL_SMOKE_TEST:-0}"
# REQUIRED constraint -- without it pip prefers PyPI's newer CUDA torch over the
# gfx1151 ROCm wheel. 2.11 carries AMD's real gfx1151 fix (matches install.sh).
TORCH_CONSTRAINT="${UNSLOTH_WSL_TORCH_CONSTRAINT:-torch>=2.11.0,<2.12.0}"
ROCM_DIR="" # resolved after install
say() { printf '\n\033[1;36m== %s\033[0m\n' "$*"; }
note() { printf ' %s\n' "$*"; }
die() { printf '\n\033[1;31m[BLOCKED] %s\033[0m\n' "$*" >&2; exit 1; }
# sudo only if not already root (WSL distros often run as root)
SUDO=""
if [ "$(id -u)" -ne 0 ]; then
command -v sudo >/dev/null 2>&1 || die "Need root or sudo to install ROCm."
SUDO="sudo"
fi
# ── Windows 11 SDK (headers for the librocdxg build) ─────────────────────────
# librocdxg's cmake build needs the Windows SDK 'shared' headers, which live on
# the Windows HOST under C:\Program Files (x86)\Windows Kits\10\Include\<ver>\.
_WIN_SDK_INC_BASE="/mnt/c/Program Files (x86)/Windows Kits/10/Include"
# Print the newest installed SDK include dir with 'shared' headers, or nothing.
# find + read loop (not `for ... in $(ls)`) since the base path has a space.
_find_win_sdk() {
[ -d "$_WIN_SDK_INC_BASE" ] || return 0
while IFS= read -r _inc; do
[ -n "$_inc" ] || continue
if [ -d "$_inc/shared" ]; then printf '%s' "$_inc"; return 0; fi
done < <(find "$_WIN_SDK_INC_BASE" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -Vr)
return 0
}
# Best-effort: install the Windows 11 SDK on the Windows HOST via winget so the
# build has its headers with no manual step. Elevates -> ONE UAC prompt; headers
# appear under /mnt/c immediately (no reboot). Never fatal -- failure falls
# through to a manual-install message. Opt out: UNSLOTH_SKIP_WIN_SDK_INSTALL=1.
_install_windows_sdk_via_winget() {
[ "${UNSLOTH_SKIP_WIN_SDK_INSTALL:-0}" = "1" ] && { note "Skipping Windows SDK auto-install (UNSLOTH_SKIP_WIN_SDK_INSTALL=1)."; return 0; }
command -v powershell.exe >/dev/null 2>&1 || return 0
# `command -v` succeeds even with WSL interop OFF (.exe on PATH but fails
# with "Exec format error"); verify it actually executes.
powershell.exe -NoProfile -Command "exit 0" >/dev/null 2>&1 || return 0
if ! powershell.exe -NoProfile -Command "if (Get-Command winget -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }" >/dev/null 2>&1; then
note "winget not available on the Windows host -- cannot auto-install the Windows SDK."
return 0
fi
say "Installing the Windows 11 SDK on the Windows host via winget"
note "librocdxg needs its headers. Approve the UAC prompt on the Windows desktop."
note "One-time (~1-3 GB download); opt out with UNSLOTH_SKIP_WIN_SDK_INSTALL=1."
# Newest SDK first, then a fallback. Header presence is the source of truth
# (re-check each attempt), not winget's exit code. </dev/null so winget never
# consumes a piped `curl | sh` stdin.
for _sdk_id in Microsoft.WindowsSDK.10.0.26100 Microsoft.WindowsSDK.10.0.22621; do
note "winget install ${_sdk_id} ..."
# --source winget: pin the community source so a broken default msstore
# source (the cert failure this PR fixes) can't abort SDK resolution.
powershell.exe -NoProfile -Command "winget install --id ${_sdk_id} -e --source winget --accept-source-agreements --accept-package-agreements --disable-interactivity" </dev/null || true
if [ -n "$(_find_win_sdk)" ]; then
note "Windows SDK headers present after install."
return 0
fi
done
note "Automatic Windows SDK install did not complete."
return 0
}
# ── PREFLIGHT ────────────────────────────────────────────────────────────────
say "Preflight checks"
# shellcheck disable=SC1091
. /etc/os-release 2>/dev/null || true
if [ "${VERSION_ID:-}" != "24.04" ]; then
die "This targets Ubuntu 24.04 (found '${VERSION_ID:-unknown}'). AMD's ROCm-on-WSL supports 24.04; create a dedicated distro: wsl --install Ubuntu-24.04 (do not run on 26.04 -- ROCm 7.2 does not target it yet)."
fi
if [ ! -e /dev/dxg ]; then
die "/dev/dxg missing -- WSL GPU paravirtualization not present. Ensure this is WSL2 (not WSL1) on a recent Windows build, and that an AMD GPU + ROCDXG-capable Adrenalin driver is installed on the Windows host (then reboot)."
fi
note "Ubuntu 24.04 + /dev/dxg present."
# Don't block on hsa/rocm libs in /usr/lib/wsl/lib: a working ROCDXG setup
# doesn't need them (only d3d12/dxcore). Real readiness is checked via rocminfo.
# ── Step 1: build/runtime prerequisites ──────────────────────────────────────
say "Installing build prerequisites"
export DEBIAN_FRONTEND=noninteractive
$SUDO apt-get update -y
# `make` is explicit: cmake shells out to it but Ubuntu only *recommends* it, so
# minimal images lack it and the librocdxg `make -j` build would fail.
$SUDO apt-get install -y cmake make gcc g++ git wget gpg ca-certificates python3-venv python3-pip
# ── Step 2: ROCm ${ROCM_VER} userspace (no DKMS -- WSL uses the Windows driver) ─
say "Installing ROCm ${ROCM_VER} userspace"
if ! command -v rocminfo >/dev/null 2>&1 && [ ! -x /opt/rocm/bin/rocminfo ]; then
# Direct apt-repo install (leaner than amdgpu-install; repo is indexed by
# ROCm version, e.g. .../apt/7.2.1).
$SUDO mkdir -p /etc/apt/keyrings
wget -qO- https://repo.radeon.com/rocm/rocm.gpg.key \
| gpg --dearmor | $SUDO tee /etc/apt/keyrings/rocm.gpg >/dev/null
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/rocm.gpg] https://repo.radeon.com/rocm/apt/${ROCM_VER} noble main" \
| $SUDO tee /etc/apt/sources.list.d/rocm.list >/dev/null
printf 'Package: *\nPin: release o=repo.radeon.com\nPin-Priority: 600\n' \
| $SUDO tee /etc/apt/preferences.d/rocm-pin-600 >/dev/null
$SUDO apt-get update -y
# rocm-libs pulls everything torch links at runtime (rocblas, hipblas,
# miopen-hip, rccl, ...); hsa-rocr + rocminfo come as deps. Large (~5 GB
# download / ~23 GB installed).
$SUDO apt-get install -y rocm-libs rocminfo hip-runtime-amd
else
note "ROCm already present -- skipping apt install."
fi
# Resolve the real ROCm dir and ensure the canonical /opt/rocm symlink. apt lays
# ROCm under /opt/rocm-<ver> and rocm-core symlinks /opt/rocm -> that; repair if
# an earlier partial run left /opt/rocm as a real dir blocking the symlink.
_real="$(ls -d /opt/rocm-* 2>/dev/null | sort -V | tail -1 || true)"
if [ -n "$_real" ] && [ ! -L /opt/rocm ] && [ -d /opt/rocm ]; then
# /opt/rocm is a real dir blocking the symlink. Only treat it as a removable
# stray stub if it's NOT a real ROCm install (a real one has bin/rocminfo /
# bin/hipcc / .info/version) -- this protects a user's pre-existing ROCm. Even
# then we MOVE IT ASIDE, never rm -rf, so a wrong guess can't lose data.
if [ -e /opt/rocm/bin/rocminfo ] || [ -e /opt/rocm/bin/hipcc ] || [ -e /opt/rocm/.info/version ]; then
note "/opt/rocm is a real ROCm install -- leaving it untouched (will install librocdxg into it)."
else
note "Moving stray /opt/rocm stub aside -> $_real (not deleting it)"
$SUDO cp -an /opt/rocm/. "$_real"/ 2>/dev/null || true
$SUDO mv /opt/rocm "/opt/rocm.unsloth-stub-bak.$(date +%s)" 2>/dev/null || true
[ -e /opt/rocm ] || $SUDO ln -s "$_real" /opt/rocm
fi
elif [ -n "$_real" ] && [ ! -e /opt/rocm ]; then
$SUDO ln -s "$_real" /opt/rocm
fi
if [ -L /opt/rocm ] || [ -d /opt/rocm ]; then ROCM_DIR="/opt/rocm"; else ROCM_DIR="$_real"; fi
{ [ -n "$ROCM_DIR" ] && [ -d "$ROCM_DIR" ]; } || die "ROCm not found under /opt after install."
note "ROCm at ${ROCM_DIR}"
# ── Step 3: build librocdxg (DXG <-> HSA bridge; not yet an apt package) ──────
say "Building librocdxg (${LIBROCDXG_REF})"
if [ -e "${ROCM_DIR}/lib/librocdxg.so" ]; then
note "librocdxg already installed -- skipping build."
else
# Discover the newest installed Win11 SDK (version differs per machine). If
# absent, auto-install via winget (one UAC prompt) and re-discover; only if
# that ALSO fails do we stop with manual instructions.
_win_sdk="$(_find_win_sdk)"
if [ -z "$_win_sdk" ]; then
note "Windows 11 SDK headers not found -- attempting automatic install..."
_install_windows_sdk_via_winget
_win_sdk="$(_find_win_sdk)"
fi
[ -n "$_win_sdk" ] || die "Windows 11 SDK headers not found under 'C:\\Program Files (x86)\\Windows Kits\\10\\Include\\*\\shared', and the automatic winget install did not complete. Install it on the Windows host (e.g. 'winget install Microsoft.WindowsSDK.10.0.26100') and re-run."
note "Windows SDK: ${_win_sdk}"
_src="${HOME}/.unsloth/librocdxg"
rm -rf "$_src"
git clone --depth 1 --branch "$LIBROCDXG_REF" https://github.com/ROCm/librocdxg.git "$_src" \
|| git clone "https://github.com/ROCm/librocdxg.git" "$_src"
(
cd "$_src"
git checkout "$LIBROCDXG_REF" 2>/dev/null || true
mkdir -p build && cd build
cmake .. -DWIN_SDK="${_win_sdk}/shared"
make -j"$(nproc)"
$SUDO make install
)
fi
# Ensure soname symlinks resolve to whatever version was built (e.g. 1.2.0).
_dxg_real="$(ls -1 "${ROCM_DIR}"/lib/librocdxg.so.*.* 2>/dev/null | sort -V | tail -1 || true)"
if [ -n "$_dxg_real" ]; then
_dxg_base="$(basename "$_dxg_real")" # librocdxg.so.1.2.0
_dxg_major="$(printf '%s' "$_dxg_base" | sed -E 's/librocdxg\.so\.([0-9]+).*/\1/')"
$SUDO ln -sf "$_dxg_base" "${ROCM_DIR}/lib/librocdxg.so.${_dxg_major}"
$SUDO ln -sf "librocdxg.so.${_dxg_major}" "${ROCM_DIR}/lib/librocdxg.so"
fi
echo "${ROCM_DIR}/lib" | $SUDO tee /etc/ld.so.conf.d/rocm.conf >/dev/null
$SUDO ldconfig
# ── Step 4: persist environment (system-wide so Unsloth's worker inherits it) ──
say "Persisting ROCm-on-WSL environment"
_envfile="/etc/profile.d/unsloth-rocm-wsl.sh"
$SUDO tee "$_envfile" >/dev/null <<EOF
# >>> Unsloth ROCm-on-WSL >>>
export HSA_ENABLE_DXG_DETECTION=1
export TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL=1
export PATH="${ROCM_DIR}/bin:\${PATH}"
export LD_LIBRARY_PATH="${ROCM_DIR}/lib:\${LD_LIBRARY_PATH:-}"
# <<< Unsloth ROCm-on-WSL <<<
EOF
# also drop into ~/.bashrc for interactive shells
if [ -n "${HOME:-}" ] && ! grep -q "Unsloth ROCm-on-WSL" "${HOME}/.bashrc" 2>/dev/null; then
cat "$_envfile" >> "${HOME}/.bashrc"
fi
# export into the current process so verification below works immediately
export HSA_ENABLE_DXG_DETECTION=1
export PATH="${ROCM_DIR}/bin:${PATH}"
export LD_LIBRARY_PATH="${ROCM_DIR}/lib:${LD_LIBRARY_PATH:-}"
# ── Step 5: verify the runtime enumerates the GPU ────────────────────────────
say "Verifying rocminfo enumerates the GPU over DXG"
# Capture rocminfo into a var BEFORE grepping: piping into `grep -q` SIGPIPEs
# rocminfo on first match, which under `set -o pipefail` turns a successful match
# into a pipeline failure.
_rocminfo_out="$(rocminfo 2>/dev/null || true)"
# GPU agents advertise an ISA "Name: gfxNNNN". Match gfx[1-9] (excludes gfx000, the CPU
# agent), drop the "gfx*-generic" fallback ISA, and take the first real GPU arch.
_detected_gfx="$(printf '%s\n' "$_rocminfo_out" | grep -E 'Name:[[:space:]]*gfx[1-9]' | grep -v 'generic' | grep -oE 'gfx[1-9][0-9a-z]*' | head -1 || true)"
if [ -z "$_detected_gfx" ]; then
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."
fi
# Honour a caller-pinned arch (sanity-check via a consuming grep, not grep -q: under
# pipefail -q would SIGPIPE printf on large output and misreport the arch); else adopt.
if [ -n "$GFX" ] && ! printf '%s\n' "$_rocminfo_out" | grep -E "Name:[[:space:]]*${GFX}([^0-9]|$)" >/dev/null; then
die "rocminfo enumerated '${_detected_gfx}' but not the requested UNSLOTH_WSL_GFX='${GFX}'."
fi
GFX="${GFX:-$_detected_gfx}"
# Display-only summary: best-effort (|| true) so head's early pipe-close under
# `set -o pipefail` can't fail the bootstrap after verification already passed.
printf '%s\n' "$_rocminfo_out" | grep -E 'Marketing Name|Device Type|Compute Unit' | grep -iE "Radeon|GPU|Compute" | head -3 || true
note "ROCm-on-WSL runtime is live for ${GFX}."
# ── Step 6 (optional): torch smoke test from AMD's per-arch wheel index ───────
if [ "$SMOKE_TEST" = "1" ]; then
say "Smoke-testing PyTorch on ${GFX} (throwaway venv)"
# Map the detected arch to AMD's repo.amd.com wheel family index.
case "$GFX" in
gfx1200|gfx1201) _fam="gfx120X-all" ;;
gfx1100|gfx1101|gfx1102|gfx1103) _fam="gfx110X-all" ;;
*) _fam="$GFX" ;; # gfx1150/gfx1151/gfx90a: own index
esac
TORCH_INDEX="${UNSLOTH_AMD_ROCM_MIRROR:-https://repo.amd.com/rocm/whl}/${_fam}/"
_venv="${HOME}/.unsloth/rocm-smoketest"
rm -rf "$_venv"; python3 -m venv "$_venv"
"$_venv/bin/pip" install --quiet --upgrade pip
# AMD arch index is primary (torch + triton); PyPI only an extra for pure-py
# deps. The constraint keeps pip on the ROCm wheel, not a newer PyPI CUDA torch.
"$_venv/bin/pip" install --index-url "$TORCH_INDEX" \
--extra-index-url https://pypi.org/simple "$TORCH_CONSTRAINT" || \
die "torch install from ${TORCH_INDEX} failed."
# WSL: torch's bundled ROCr must load the DXG bridge -- drop librocdxg into torch/lib.
_tlib="$("$_venv/bin/python" -c 'import torch,os;print(os.path.join(os.path.dirname(torch.__file__),"lib"))' 2>/dev/null || true)"
[ -d "$_tlib" ] && cp -f "${ROCM_DIR}"/lib/librocdxg.so* "$_tlib"/ 2>/dev/null || true
"$_venv/bin/python" - <<'PY'
import torch
ok = torch.cuda.is_available()
print("torch:", torch.__version__, "| cuda(rocm) available:", ok)
if ok:
print("device:", torch.cuda.get_device_name(0))
free, total = torch.cuda.mem_get_info(0)
print(f"mem: free={free/1e9:.1f} GB total={total/1e9:.1f} GB")
import time
a = torch.randn(4096, 4096, device="cuda", dtype=torch.float16)
b = torch.randn(4096, 4096, device="cuda", dtype=torch.float16)
torch.cuda.synchronize(); t0 = time.time()
for _ in range(10): c = a @ b
torch.cuda.synchronize()
print(f"matmul ok ({(time.time()-t0)/10*1e3:.1f} ms/iter)")
raise SystemExit(0 if ok else 1)
PY
rm -rf "$_venv"
fi
say "Done."
note "ROCm-on-WSL is ready for ${GFX}. If you ran this standalone, install Unsloth"
note "in THIS distro and it will detect the GPU automatically:"
note " curl -fsSL https://unsloth.ai/install.sh | sh"

View file

@ -4,19 +4,37 @@
"""Refuse dangerous GitHub Actions trigger patterns at PR time. """Refuse dangerous GitHub Actions trigger patterns at PR time.
Bans patterns behind the TanStack GHSA-g7cv-rxg3-hmpx compromise: Two patterns are banned outright, both of which powered the TanStack
GHSA-g7cv-rxg3-hmpx supply-chain compromise:
1. `pull_request_target` -- runs a fork's workflow against the base 1. `pull_request_target` -- runs a fork's workflow YAML against the
repo's secrets/permissions; use `pull_request` instead. BASE repository's secrets and permissions. The fork can inject
2. `workflow_run` chained to a PR-triggered workflow -- same trust arbitrary code into the base context. The TanStack worm used this
boundary problem one hop later (poisoned artifacts/caches run with to land base-context execution from a fork PR. There is essentially
elevated permissions). no safe use of this trigger for a public open-source project;
3. Cache keys shared between PR-triggered and publish/release/push `pull_request` is the safe alternative.
workflows -- a fork PR could poison a cache the publish workflow
restores. Partition the key namespaces.
Exit codes: 0 = no findings, 1 = findings (listed on stderr). 2. `workflow_run` chained to a PR-triggered workflow -- carries the
Run from repo root: python3 scripts/lint_workflow_triggers.py same trust boundary problem one hop later. If a PR-triggered
workflow can poison artifacts/caches and a `workflow_run` trigger
fires off the result with elevated permissions, the attacker still
reaches the trusted context.
3. Shared cache keys between PR-triggered workflows and publish /
release / push-triggered workflows. The TanStack worm poisoned the
Actions cache from a fork PR and the legitimate release workflow
then restored the poisoned cache. Cache keys must be partitioned
so that nothing a PR can write is ever read by a workflow that
holds secrets.
Exit codes
==========
0 no findings
1 one or more findings; stderr lists each with file path
Run from repo root:
python3 scripts/lint_workflow_triggers.py
""" """
from __future__ import annotations from __future__ import annotations
@ -29,7 +47,9 @@ from pathlib import Path
try: try:
import yaml import yaml
except ImportError: except ImportError:
print("ERROR: PyYAML is required. Install with 'pip install pyyaml'", file = sys.stderr) print(
"ERROR: PyYAML is required. Install with 'pip install pyyaml'", file = sys.stderr
)
sys.exit(2) sys.exit(2)
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
@ -52,14 +72,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 +124,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 "
@ -133,7 +153,9 @@ def main() -> int:
) )
if findings: if findings:
print("Workflow trigger lint failed with the following issues:", file = sys.stderr) print(
"Workflow trigger lint failed with the following issues:", file = sys.stderr
)
for f in findings: for f in findings:
print(f" - {f}", file = sys.stderr) print(f" - {f}", file = sys.stderr)
return 1 return 1

View file

@ -2,24 +2,67 @@
# 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 that indicate the kind of supply-chain
Shai-Hulud waves, cargo crates.io brand-squats). injection seen in the npm Shai-Hulud waves and the cargo
crates.io brand-squat attempts.
Checks package-lock.json (lockfileVersion 2/3): `resolved` URL must be What it checks
the npm registry (direct git/github/file refs are the injection vector); ==============
`integrity` SHA must be present; known IOC substrings grepped from the
body. Checks Cargo.lock: `source` must be the crates.io registry index;
known cargo IOC substrings.
Exit codes: 0 = clean (or skip env var set to a justification >=5 chars, studio/frontend/package-lock.json (lockfileVersion 2 or 3):
not '1'/'true'); 1 = findings; 2 = internal error.
Only PARSES the lockfiles, never executes or networks. Complements (not 1. `resolved` URL origin. Every entry must resolve through
replaces) `npm audit` / OSV-Scanner / the advisory-DB pipeline. Fires `https://registry.npmjs.org/`. Direct GitHub-hosted dependencies
before any third-party install script runs on the runner. (`git+ssh://`, `git+https://`, `github:owner/repo#sha`,
`file:`, `http://`) are refused -- npm's TanStack incident used
exactly this vector to land an unaudited GitHub commit hash as
an optional dependency.
2. `integrity` field presence. Every non-workspace entry must carry
an `integrity` SHA. A missing integrity means the registry can
swap the tarball after lockfile generation and CI will not
notice.
3. Known IOC strings. A hardcoded set of indicator-of-compromise
substrings is grepped across the entire lockfile body (file
names, dependency keys, URLs). The list is updated as new
campaigns surface. Catching one means the local install was
about to pull a publicly-known malicious release.
studio/src-tauri/Cargo.lock:
4. `source` field origin. Every entry with a `source` must point at
`registry+https://github.com/rust-lang/crates.io-index`. Direct
git sources (`git+https://...`) and `path+...` for cross-crate
paths warrant manual review and are flagged.
5. Known cargo IOC strings. Same idea as (3), separate list.
Exit codes
==========
0 no findings, or an opt-out env var (UNSLOTH_LOCKFILE_AUDIT_SKIP)
is set to a justification string (>=5 chars, not '1'/'true'/etc).
A value like '1' or 'true' is now REJECTED loudly and the audit
runs normally
1 one or more findings; stderr lists them with file path and line
number where derivable
2 internal error (missing dependency, malformed JSON, etc.)
Operational stance
==================
This scanner only PARSES the lockfiles -- it never executes anything
in them, never resolves anything against the network. Safe to run
ahead of every `npm ci`. The IOC list is short by design; this
complements (not replaces) `npm audit`, OSV-Scanner, and the
advisory-DB pipeline in `.github/workflows/security-audit.yml`. The
shape of the catch is "we refuse to proceed because the lockfile
itself is shaped wrong", which fires before any third-party install
script gets a chance to run on the runner.
""" """
from __future__ import annotations from __future__ import annotations
@ -34,9 +77,14 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
# Known IOC strings (case-sensitive substring match). Each is tied to a # ─────────────────────────────────────────────────────────────────────
# public advisory; speculative/generic patterns would false-positive on # Known IOC strings (case-sensitive substring match).
# upgrades. # ─────────────────────────────────────────────────────────────────────
#
# Keep these short and FACTUAL. Each entry is tied to a public advisory
# and is the literal string an attacker would have to embed for the
# attack to work. Adding speculative or generic patterns here would
# generate false positives on dependency upgrades.
NPM_IOC_STRINGS: tuple[str, ...] = ( NPM_IOC_STRINGS: tuple[str, ...] = (
# Shai-Hulud TanStack wave -- May 11, 2026 (GHSA-g7cv-rxg3-hmpx). # Shai-Hulud TanStack wave -- May 11, 2026 (GHSA-g7cv-rxg3-hmpx).
"router_init.js", "router_init.js",
@ -280,22 +328,36 @@ BLOCKED_NPM_VERSIONS: dict[str, set[str]] = {
} }
CARGO_IOC_STRINGS: tuple[str, ...] = ( CARGO_IOC_STRINGS: tuple[str, ...] = (
# Empty by default; the `source` origin check catches the structural # Reserved for future cargo-side incidents. Empty by default --
# pattern. Reserved for future cargo-side incidents. # `source` origin check below catches the structural pattern.
) )
# ─────────────────────────────────────────────────────────────────────
# Allowed lockfile origins. # Allowed lockfile origins.
# ─────────────────────────────────────────────────────────────────────
NPM_REGISTRY_PREFIX = "https://registry.npmjs.org/" NPM_REGISTRY_PREFIX = "https://registry.npmjs.org/"
# Tarballs are also fetched from this mirror on some GH Actions cached
# runs (npm rewrites the resolved URL on cache hit). Allow either.
NPM_REGISTRY_PREFIXES_ALLOWED: tuple[str, ...] = (NPM_REGISTRY_PREFIX,) NPM_REGISTRY_PREFIXES_ALLOWED: tuple[str, ...] = (NPM_REGISTRY_PREFIX,)
CARGO_REGISTRY_SOURCE = "registry+https://github.com/rust-lang/crates.io-index" CARGO_REGISTRY_SOURCE = "registry+https://github.com/rust-lang/crates.io-index"
# Cargo non-registry source allowlist: `(crate_name, exact_source_string)`. # ─────────────────────────────────────────────────────────────────────
# Both must match verbatim; bumping the pinned SHA forces a re-review. # Cargo non-registry source allowlist.
# Unsloth's Tauri shell pulls `fix-path-env` from git because it is not # ─────────────────────────────────────────────────────────────────────
# published to crates.io; commit c4c45d5 was reviewed when it landed. #
# Each entry is `(crate_name, exact_source_string)`. The crate must
# match by name AND the source must match the full pinned-SHA string
# verbatim. Bumping the commit SHA forces a re-review here: the
# scanner fires until the new SHA is appended.
#
# Studio's Tauri shell pulls `fix-path-env` directly from
# tauri-apps/fix-path-env-rs because the crate is not published to
# crates.io. The pinned commit (c4c45d5) was reviewed at the time it
# landed; future bumps need explicit approval.
CARGO_SOURCE_ALLOWLIST: tuple[tuple[str, str], ...] = ( CARGO_SOURCE_ALLOWLIST: tuple[tuple[str, str], ...] = (
( (
"fix-path-env", "fix-path-env",
@ -305,6 +367,11 @@ CARGO_SOURCE_ALLOWLIST: tuple[tuple[str, str], ...] = (
) )
# ─────────────────────────────────────────────────────────────────────
# Finding container.
# ─────────────────────────────────────────────────────────────────────
class Finding: class Finding:
__slots__ = ("path", "package", "kind", "detail") __slots__ = ("path", "package", "kind", "detail")
@ -323,19 +390,27 @@ class Finding:
def _gha_escape(text: str) -> str: def _gha_escape(text: str) -> str:
"""Escape a string for a GH Actions `::warning::`/`::error::` message. """Escape a string for use in a GitHub Actions `::warning::` /
`::error::` workflow command message. GH Actions truncates
GH Actions truncates at the first newline unless `\\n`/`\\r` are annotation messages at the first newline unless `\\n` is
escaped as `%0A`/`%0D`. `%` must be replaced first to avoid escaped as `%0A`; carriage returns and the percent sign need
double-encoding the subsequent escapes. matching escapes per the workflow-commands spec. Order matters:
`%` must be replaced first so the subsequent `%0A` / `%0D`
sequences are not double-encoded.
""" """
return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
# ─────────────────────────────────────────────────────────────────────
# package-lock.json audit.
# ─────────────────────────────────────────────────────────────────────
def audit_npm_lockfile(path: Path) -> list[Finding]: def audit_npm_lockfile(path: Path) -> list[Finding]:
findings: list[Finding] = [] findings: list[Finding] = []
if not path.exists(): if not path.exists():
# Missing lockfile is a config error, not a clean audit. # A missing requested lockfile is a config error, not a clean
# audit; surface it so a deleted default cannot pass silently.
findings.append( findings.append(
Finding( Finding(
path = str(path), path = str(path),
@ -352,7 +427,8 @@ def audit_npm_lockfile(path: Path) -> list[Finding]:
try: try:
raw = path.read_text(encoding = "utf-8") raw = path.read_text(encoding = "utf-8")
except OSError as exc: except OSError as exc:
# Surface as a finding instead of crashing CI with a traceback. # Permission denied, is-a-directory, broken-pipe etc. -- surface
# as a finding instead of crashing CI with a raw traceback.
findings.append( findings.append(
Finding( Finding(
path = str(path), path = str(path),
@ -388,7 +464,9 @@ def audit_npm_lockfile(path: Path) -> list[Finding]:
packages = lock.get("packages") or {} packages = lock.get("packages") or {}
for key, entry in packages.items(): for key, entry in packages.items():
# Empty key "" is the project root (no `resolved`); skip it. # The empty key "" is the project root; workspace entries use
# keys like "node_modules/foo" or "studio/frontend/sub-pkg".
# Skip the project root (it has no `resolved`).
if key == "": if key == "":
continue continue
if entry.get("link"): if entry.get("link"):
@ -396,8 +474,12 @@ def audit_npm_lockfile(path: Path) -> list[Finding]:
continue continue
resolved = entry.get("resolved") resolved = entry.get("resolved")
# Entries nested in another package's node_modules are bundled # Entries living inside another package's `node_modules/`
# fold-ins covered by the parent's integrity; treat as transparent. # tree are bundled fold-ins -- the parent's tarball ships
# their source verbatim and the parent's `integrity` covers
# the whole subtree. npm represents them in lockfileVersion 3
# as nested entries with no `resolved` and no `integrity` of
# their own. Treat them as transparent to this audit.
nested = key.count("/node_modules/") >= 1 nested = key.count("/node_modules/") >= 1
# 1. resolved-URL origin. # 1. resolved-URL origin.
@ -459,14 +541,18 @@ def audit_npm_lockfile(path: Path) -> list[Finding]:
path = str(path), path = str(path),
package = key, package = key,
kind = "blocked-known-malicious", kind = "blocked-known-malicious",
detail = (f"{pkg_name}@{version} is on the BLOCKED_NPM_VERSIONS list"), detail = (
f"{pkg_name}@{version} is on the " "BLOCKED_NPM_VERSIONS list"
),
) )
) )
# 4. Known IOC strings: scan the raw body to catch fields the # 4. Known IOC strings: scan the raw file body so we hit fields the
# structural pass doesn't enumerate (scripts, optional deps, etc.). # structural pass above doesn't enumerate (scripts, optional
# dependencies, etc.). Cheap and complete.
for ioc in NPM_IOC_STRINGS: for ioc in NPM_IOC_STRINGS:
if ioc in raw: if ioc in raw:
# Best-effort line number lookup.
line_no = _first_line_containing(raw, ioc) line_no = _first_line_containing(raw, ioc)
findings.append( findings.append(
Finding( Finding(
@ -491,7 +577,14 @@ def _first_line_containing(text: str, needle: str) -> int | None:
return None return None
# Cargo.lock is TOML; parsed with stdlib tomllib (Python 3.11+). # ─────────────────────────────────────────────────────────────────────
# Cargo.lock audit.
# ─────────────────────────────────────────────────────────────────────
# Cargo.lock is TOML; parse with stdlib tomllib (Python 3.11+). The
# studio's Tauri shell already requires a modern toolchain so this is
# always available where CI runs.
_PACKAGE_HEADER = re.compile(r"^\[\[package\]\]\s*$") _PACKAGE_HEADER = re.compile(r"^\[\[package\]\]\s*$")
@ -615,15 +708,24 @@ def audit_cargo_lockfile(path: Path) -> list[Finding]:
return findings return findings
# ─────────────────────────────────────────────────────────────────────
# CLI.
# ─────────────────────────────────────────────────────────────────────
# Finding kinds split into BLOCKING vs ADVISORY for the default run mode. # Finding kinds split into BLOCKING vs ADVISORY for the default run mode.
# Blocking = public attack indicators (known-malicious version, IOC # Blocking findings come from public supply-chain attack indicators (a
# string). Advisory = structural anomalies that warn but don't block. # version we know is malicious, a string an attacker would have to embed
# --strict makes every finding blocking. # for an attack to work). Advisory findings are structural lockfile
# anomalies (missing integrity, non-default registry, etc.) -- they
# WARN the maintainer but do not block merges. Pass --strict to make
# every finding blocking (PR-5479-style behavior for opt-in adopters).
BLOCKING_KINDS: frozenset[str] = frozenset( BLOCKING_KINDS: frozenset[str] = frozenset(
{ {
"blocked-known-malicious", "blocked-known-malicious",
"known-ioc-string", "known-ioc-string",
# A structurally broken lockfile might hide a real attack. # Internal-failure kinds: a structurally broken lockfile MIGHT
# be hiding a real attack, so we keep these blocking too.
"malformed-lockfile", "malformed-lockfile",
"missing-lockfile", "missing-lockfile",
"unreadable-lockfile", "unreadable-lockfile",
@ -663,7 +765,10 @@ def main(argv: list[str] | None = None) -> int:
"--cargo-lockfile", "--cargo-lockfile",
action = "append", action = "append",
default = None, default = None,
help = ("Path to a Cargo.lock (repeatable). Default: studio/src-tauri/Cargo.lock."), help = (
"Path to a Cargo.lock (repeatable). "
"Default: studio/src-tauri/Cargo.lock."
),
) )
parser.add_argument( parser.add_argument(
"--strict", "--strict",
@ -679,9 +784,15 @@ def main(argv: list[str] | None = None) -> int:
) )
args = parser.parse_args(argv) args = parser.parse_args(argv)
# Require a real justification (>=5 chars, not a boolean-shaped token) # SF4: require a real justification (e.g. JIRA ticket id) for the
# for the skip env var. An invalid value warns and falls through to # skip env var. Treat the trivially-set values ("1", "true", "yes",
# run the audit (fail-safe); a valid one warns and skips with rc=0. # "on", empty) as INVALID -- they look like accidental flips and
# silently bypassed the supply-chain audit. A valid value is a
# non-empty string >=5 chars after stripping that does not match
# any of the boolean-shaped tokens above. An invalid value emits a
# loud GitHub Actions warning to stderr and FALLS THROUGH to run
# the audit normally (fail-safe). A valid value emits a warning
# naming the reason and skips with rc=0 (compat).
_skip_raw = os.environ.get("UNSLOTH_LOCKFILE_AUDIT_SKIP") _skip_raw = os.environ.get("UNSLOTH_LOCKFILE_AUDIT_SKIP")
if _skip_raw is not None: if _skip_raw is not None:
_skip = _skip_raw.strip() _skip = _skip_raw.strip()
@ -703,7 +814,8 @@ def main(argv: list[str] | None = None) -> int:
return 0 return 0
root = Path(args.root).resolve() root = Path(args.root).resolve()
# Explicit flags scope the scan; defaults apply only to no-args CI. # Explicit --npm-lockfile/--cargo-lockfile scopes the scan to those
# paths; defaults apply only to the no-args CI invocation.
_user_explicit = args.npm_lockfile is not None or args.cargo_lockfile is not None _user_explicit = args.npm_lockfile is not None or args.cargo_lockfile is not None
if _user_explicit: if _user_explicit:
npm_paths = [root / p for p in (args.npm_lockfile or ())] npm_paths = [root / p for p in (args.npm_lockfile or ())]
@ -728,9 +840,11 @@ def main(argv: list[str] | None = None) -> int:
) )
return 0 return 0
# Split into blocking (known-malicious / IOC / structurally broken) # Split findings into blocking (known-malicious / IOC / structurally
# and advisory (everything else). Default mode prints advisories # broken) and advisory (everything else, e.g. missing integrity on a
# without changing the exit code; --strict makes all blocking. # registry-published tarball). In default mode advisory findings are
# printed but do not change the exit code; --strict treats every
# finding as blocking.
blocking = [f for f in all_findings if f.kind in BLOCKING_KINDS] blocking = [f for f in all_findings if f.kind in BLOCKING_KINDS]
advisory = [f for f in all_findings if f.kind not in BLOCKING_KINDS] advisory = [f for f in all_findings if f.kind not in BLOCKING_KINDS]
@ -745,8 +859,12 @@ def main(argv: list[str] | None = None) -> int:
file = sys.stderr, file = sys.stderr,
) )
for f in advisory: for f in advisory:
# GH Actions warning annotation; _gha_escape collapses the # Surface in GitHub Actions UI as a warning annotation when run
# multi-line Finding onto one line so it renders fully in the UI. # under Actions; harmless prefix elsewhere. GH Actions
# truncates annotation messages at the first newline unless
# newlines are escaped as `%0A`, so the full multi-line
# Finding (kind + path + package + detail) only renders in
# the UI after _gha_escape collapses it onto one line.
print(f"::warning::{_gha_escape(str(f))}", file = sys.stderr) print(f"::warning::{_gha_escape(str(f))}", file = sys.stderr)
print(file = sys.stderr) print(file = sys.stderr)
@ -763,7 +881,9 @@ def main(argv: list[str] | None = None) -> int:
file = sys.stderr, file = sys.stderr,
) )
for f in blocking: for f in blocking:
# Same %-encoding rationale as the advisory branch above. # Same %-encoding rationale as the advisory branch above: the
# GH Actions annotation is truncated at the first newline
# unless the message is escaped.
print(f"::error::{_gha_escape(str(f))}", file = sys.stderr) print(f"::error::{_gha_escape(str(f))}", file = sys.stderr)
print(file = sys.stderr) print(file = sys.stderr)
print( print(

View file

@ -22,14 +22,19 @@ import urllib.parse
from pathlib import Path from pathlib import Path
# Allowlist of hosts for raw notebook fetches; anything else rejected before urlopen. # Hosts we are willing to fetch raw notebook JSON from. Anything else
# is rejected before `urlopen` so a typoed / hostile URL cannot pull
# code from arbitrary infrastructure.
_ALLOWED_NOTEBOOK_HOSTS = { _ALLOWED_NOTEBOOK_HOSTS = {
"raw.githubusercontent.com", "raw.githubusercontent.com",
"gist.githubusercontent.com", "gist.githubusercontent.com",
} }
# Metacharacters that mean a `!cmd` line can't be a flat argv -> keep shell=True + review marker. # Shell metacharacters that imply the cell's `!cmd` line cannot be
# parsed as a flat argv. If any of these appears, `shlex.split` would
# either fail or, worse, silently strip the operator -- so we keep
# `shell=True` for that command and emit a review marker.
_SHELL_METACHARS_RE = re.compile(r"\$\(|`|\|\||\||&&|>>?|<<?|\*|\?|;") _SHELL_METACHARS_RE = re.compile(r"\$\(|`|\|\||\||&&|>>?|<<?|\*|\?|;")
@ -41,8 +46,12 @@ def needs_fstring(cmd: str) -> bool:
def github_blob_to_raw(url: str) -> str: def github_blob_to_raw(url: str) -> str:
"""Convert GitHub blob URL to raw URL.""" """Convert GitHub blob URL to raw URL."""
# github.com/user/repo/blob/branch/path -> raw.githubusercontent.com/user/repo/branch/path # https://github.com/user/repo/blob/branch/path
# Exact host match (not substring) so attacker.example.com/github.com/blob/... is not rewritten. # -> https://raw.githubusercontent.com/user/repo/branch/path
# Compare the parsed host exactly (not as a substring) so a URL
# like https://attacker.example.com/github.com/blob/... does NOT
# get rewritten to a github raw URL. Closes CodeQL alert
# py/incomplete-url-substring-sanitization.
parsed = urllib.parse.urlparse(url) parsed = urllib.parse.urlparse(url)
if parsed.netloc != "github.com" or "/blob/" not in parsed.path: if parsed.netloc != "github.com" or "/blob/" not in parsed.path:
return url return url
@ -54,12 +63,18 @@ def github_blob_to_raw(url: str) -> str:
def download_notebook(url: str) -> tuple[str, str]: def download_notebook(url: str) -> tuple[str, str]:
"""Download notebook from URL. Returns (content, filename).""" """Download notebook from URL. Returns (content, filename)."""
# Convert blob URL to raw if needed
raw_url = github_blob_to_raw(url) raw_url = github_blob_to_raw(url)
# Extract filename from URL
parsed = urllib.parse.urlparse(raw_url) parsed = urllib.parse.urlparse(raw_url)
filename = os.path.basename(urllib.parse.unquote(parsed.path)) filename = os.path.basename(urllib.parse.unquote(parsed.path))
# Host allowlist: refuse to fetch from anything we don't recognise. # Host allowlist. Refuse to fetch from anywhere the campaign IOC
# tables flag (or just anywhere we don't recognise). The blob->raw
# conversion above only emits `raw.githubusercontent.com`, so a
# rejection here means the caller hand-typed a URL pointing
# somewhere we don't trust.
host = parsed.hostname host = parsed.hostname
if host not in _ALLOWED_NOTEBOOK_HOSTS: if host not in _ALLOWED_NOTEBOOK_HOSTS:
raise ValueError( raise ValueError(
@ -67,6 +82,7 @@ def download_notebook(url: str) -> tuple[str, str]:
f"{sorted(_ALLOWED_NOTEBOOK_HOSTS)}" f"{sorted(_ALLOWED_NOTEBOOK_HOSTS)}"
) )
# Download
print(f"Downloading {url}...") print(f"Downloading {url}...")
with urllib.request.urlopen(raw_url, timeout = 60) as response: with urllib.request.urlopen(raw_url, timeout = 60) as response:
content = response.read().decode("utf-8") content = response.read().decode("utf-8")
@ -81,18 +97,29 @@ def is_url(path: str) -> bool:
def replace_colab_paths(source: str) -> str: def replace_colab_paths(source: str) -> str:
"""Replace Colab-specific /content/ paths with current working directory.""" """Replace Colab-specific /content/ paths with current working directory."""
# Replace /content/ with f-string using _WORKING_DIR
source = source.replace('"/content/', 'f"{_WORKING_DIR}/') source = source.replace('"/content/', 'f"{_WORKING_DIR}/')
source = source.replace("'/content/", "f'{_WORKING_DIR}/") source = source.replace("'/content/", "f'{_WORKING_DIR}/")
return source return source
def _emit_shell_command(indent: str, full_cmd: str, *, allow_shell: bool) -> list[str]: def _emit_shell_command(indent: str, full_cmd: str, *, allow_shell: bool) -> list[str]:
"""Render a `!cmd` notebook line as Python statements. """Render a `!cmd` notebook line as one or more Python statements.
f-string interpolation, shell metacharacters, or multiline force When the command body is f-string-interpolated, contains shell
shell=True (shlex.split would drop operators), flagged with a metacharacters, or spans multiple lines, falling back to
WARNING comment. Otherwise emit shell=False argv form. allow_shell `shell=True` is the only correct option -- `shlex.split` would
False makes shell=True emission a hard error. either drop operators or fail outright. We surface that with a
`# WARNING: shell=True; reviewed for hostile input` comment so a
reviewer cannot miss it.
Otherwise we emit `subprocess.run(shlex.split(cmd), shell=False)`
so the converted script is not a re-injection vector if the
notebook ever interpolates user-controlled data.
`allow_shell` defaults to True at the CLI for backwards
compatibility. Setting it to False makes `shell=True` emission a
hard error (no surprise behaviour).
""" """
needs_f = needs_fstring(full_cmd) needs_f = needs_fstring(full_cmd)
has_meta = bool(_SHELL_METACHARS_RE.search(full_cmd)) has_meta = bool(_SHELL_METACHARS_RE.search(full_cmd))
@ -117,6 +144,7 @@ def _emit_shell_command(indent: str, full_cmd: str, *, allow_shell: bool) -> lis
stmt = f"{indent}subprocess.run({f_prefix}{full_cmd!r}, shell=True)" stmt = f"{indent}subprocess.run({f_prefix}{full_cmd!r}, shell=True)"
return [warn, stmt] return [warn, stmt]
# Shell-safe argv form.
return [f"{indent}subprocess.run(shlex.split({full_cmd!r}), shell=False)"] return [f"{indent}subprocess.run(shlex.split({full_cmd!r}), shell=False)"]
@ -131,10 +159,12 @@ def convert_cell_to_python(source: str, *, allow_shell: bool = True) -> str:
stripped = line.strip() stripped = line.strip()
indent = line[: len(line) - len(line.lstrip())] indent = line[: len(line) - len(line.lstrip())]
# Skip %%capture
if stripped.startswith("%%capture"): if stripped.startswith("%%capture"):
i += 1 i += 1
continue continue
# Handle %%file magic
if stripped.startswith("%%file "): if stripped.startswith("%%file "):
filename = stripped[7:].strip() filename = stripped[7:].strip()
file_lines = [] file_lines = []
@ -148,6 +178,7 @@ def convert_cell_to_python(source: str, *, allow_shell: bool = True) -> str:
result.append(f'{indent} _f.write("""{file_content}""")') result.append(f'{indent} _f.write("""{file_content}""")')
continue continue
# Handle ! shell commands
if stripped.startswith("!"): if stripped.startswith("!"):
cmd_lines = [stripped[1:]] cmd_lines = [stripped[1:]]
while cmd_lines[-1].rstrip().endswith("\\") and i + 1 < len(lines): while cmd_lines[-1].rstrip().endswith("\\") and i + 1 < len(lines):
@ -155,7 +186,9 @@ def convert_cell_to_python(source: str, *, allow_shell: bool = True) -> str:
cmd_lines.append(lines[i].strip()) cmd_lines.append(lines[i].strip())
full_cmd = "\n".join(cmd_lines) full_cmd = "\n".join(cmd_lines)
result.extend(_emit_shell_command(indent, full_cmd, allow_shell = allow_shell)) result.extend(
_emit_shell_command(indent, full_cmd, allow_shell = allow_shell)
)
# %cd path -> os.chdir(path) # %cd path -> os.chdir(path)
elif stripped.startswith("%cd "): elif stripped.startswith("%cd "):
@ -277,16 +310,23 @@ def convert_notebook_to_script(
content = f.read() content = f.read()
source_name = source source_name = source
# Generate output filename
output_filename = filename.replace(".ipynb", ".py") output_filename = filename.replace(".ipynb", ".py")
output_filename = output_filename.replace("(", "").replace(")", "").replace("-", "_") # Clean up filename
output_filename = (
output_filename.replace("(", "").replace(")", "").replace("-", "_")
)
# Add output directory if specified
if output_dir: if output_dir:
output_path = os.path.join(output_dir, output_filename) output_path = os.path.join(output_dir, output_filename)
else: else:
output_path = output_filename output_path = output_filename
# Convert
script = convert_notebook(content, source_name, allow_shell = allow_shell) script = convert_notebook(content, source_name, allow_shell = allow_shell)
# Write output
with open(output_path, "w", encoding = "utf-8") as f: with open(output_path, "w", encoding = "utf-8") as f:
f.write(script) f.write(script)
@ -297,7 +337,9 @@ def convert_notebook_to_script(
def main(): def main():
import argparse import argparse
class Formatter(argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter): class Formatter(
argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter
):
pass pass
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@ -311,9 +353,17 @@ Examples:
python notebook_to_python.py https://github.com/unslothai/notebooks/blob/main/nb/Oute_TTS_(1B).ipynb python notebook_to_python.py https://github.com/unslothai/notebooks/blob/main/nb/Oute_TTS_(1B).ipynb
""", """,
) )
parser.add_argument("notebooks", nargs = "+", help = "Notebook files or URLs to convert.") parser.add_argument(
parser.add_argument("-o", "--output", dest = "output_dir", default = ".", help = "Output directory.") "notebooks", nargs = "+", help = "Notebook files or URLs to convert."
# Default True for backwards compat; pass --no-allow-shell for untrusted notebooks. )
parser.add_argument(
"-o", "--output", dest = "output_dir", default = ".", help = "Output directory."
)
# Default True for backwards compatibility: existing Colab notebooks
# routinely use pipes / redirection / interpolation in `!cmd` lines
# and the converted script needs to keep working. Operators who
# convert untrusted notebooks should pass --no-allow-shell to force
# a hard error on every metacharacter-bearing cell.
parser.add_argument( parser.add_argument(
"--allow-shell", "--allow-shell",
dest = "allow_shell", dest = "allow_shell",
@ -331,9 +381,14 @@ Examples:
args = parser.parse_args() args = parser.parse_args()
# Create output directory if needed
os.makedirs(args.output_dir, exist_ok = True) os.makedirs(args.output_dir, exist_ok = True)
# Track per-notebook failures; continue the loop and exit 1 if any failed. # SF2: track per-notebook failures so a CI invocation that converts
# 10 notebooks but silently fails on 3 is no longer reported as
# success. Each failure is collected and the loop continues so the
# caller sees the full set; final exit status is 1 if anything
# failed.
failures: list[tuple[str, str]] = [] failures: list[tuple[str, str]] = []
ok = 0 ok = 0
total = len(args.notebooks) total = len(args.notebooks)

View file

@ -49,9 +49,12 @@ from typing import Any, Iterable, Iterator
def _atomic_write_bytes(path: pathlib.Path, data: bytes) -> None: def _atomic_write_bytes(path: pathlib.Path, data: bytes) -> None:
"""Atomic write (see scripts/scan_packages.py::update_req_file). A crash """Atomic write helper. See `scripts/scan_packages.py::update_req_file`.
between mkstemp and os.replace leaves the prior file intact, so a
half-downloaded cache file can't poison later runs.""" A crash between `mkstemp` and `os.replace` leaves the prior file
untouched, so a half-downloaded PyPI metadata cache file cannot
poison subsequent runs of the validator.
"""
path.parent.mkdir(parents = True, exist_ok = True) path.parent.mkdir(parents = True, exist_ok = True)
dirpath = str(path.parent) or "." dirpath = str(path.parent) or "."
fd, tmp_path = tempfile.mkstemp(prefix = ".nb_val.", dir = dirpath) fd, tmp_path = tempfile.mkstemp(prefix = ".nb_val.", dir = dirpath)
@ -78,16 +81,20 @@ COLAB_PIP_FREEZE_URL = (
) )
COLAB_FALLBACK_FILE = DATA_DIR / "colab_pip_freeze.gpu.txt" COLAB_FALLBACK_FILE = DATA_DIR / "colab_pip_freeze.gpu.txt"
# Oracle files snapshotted from googlecolab/backend-info. The colab-diff # Oracle files we snapshot from googlecolab/backend-info. The diff
# subcommand surfaces NEW/REMOVED/CHANGED entries so upstream Colab base # subcommand fetches each, compares against the committed snapshot,
# image rotations land in CI within ~24h, giving R-INST-002/003/004/005 # and surfaces NEW / REMOVED / CHANGED entries so upstream Colab base
# earlier signal. # image rotations land in CI within ~24h instead of when a notebook
# breaks. Every rule in this validator that resolves against the
# Colab preinstall (R-INST-002/003/004/005) gets earlier signal.
COLAB_ORACLE_FILES: dict[str, str] = { COLAB_ORACLE_FILES: dict[str, str] = {
"pip-freeze.gpu.txt": "colab_pip_freeze.gpu.txt", "pip-freeze.gpu.txt": "colab_pip_freeze.gpu.txt",
"apt-list-gpu.txt": "colab_apt_list.gpu.txt", "apt-list-gpu.txt": "colab_apt_list.gpu.txt",
"os-info-gpu.txt": "colab_os_info.gpu.txt", "os-info-gpu.txt": "colab_os_info.gpu.txt",
} }
COLAB_ORACLE_BASE_URL = "https://raw.githubusercontent.com/googlecolab/backend-info/main/" COLAB_ORACLE_BASE_URL = (
"https://raw.githubusercontent.com/googlecolab/backend-info/main/"
)
# ----- Compat tables. PRs add rows as new releases land. ----- # # ----- Compat tables. PRs add rows as new releases land. ----- #
@ -95,8 +102,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"},
@ -140,8 +147,9 @@ class Finding:
def iter_notebooks( def iter_notebooks(
notebooks_dir: pathlib.Path, include_templates: bool = False notebooks_dir: pathlib.Path, include_templates: bool = False
) -> Iterator[pathlib.Path]: ) -> Iterator[pathlib.Path]:
"""Yield user-facing .ipynb files under nb/ and kaggle/. """Yield user-facing .ipynb files under nb/ and kaggle/. Pass
include_templates=True also walks original_template/ (for convert).""" include_templates=True to also walk original_template/ (used by the
convert subcommand which doesn't lint install cells)."""
subs = ("nb", "kaggle") subs = ("nb", "kaggle")
if include_templates: if include_templates:
subs = ("nb", "kaggle", "original_template") subs = ("nb", "kaggle", "original_template")
@ -187,13 +195,18 @@ def install_cells(nb: dict[str, Any]) -> list[tuple[int, str]]:
if first and first[0].strip().startswith("%%capture"): if first and first[0].strip().startswith("%%capture"):
out.append((i, src)) out.append((i, src))
continue continue
if re.search(r"^[ \t]*!\s*(uv\s+)?pip\s+(install|uninstall)\b", src, re.MULTILINE): if re.search(
r"^[ \t]*!\s*(uv\s+)?pip\s+(install|uninstall)\b", src, re.MULTILINE
):
out.append((i, src)) out.append((i, src))
return out return out
# Colab oracle only applies to notebooks that run on Colab; AMD, Kaggle, # Notebook target environment. The Colab oracle (pip-freeze.gpu.txt) only
# DGX-Spark have their own preinstalls and the Colab-vs-cell rules don't apply. # applies to notebooks that actually run on Colab; AMD-Dev-Cloud,
# Kaggle, HuggingFace-Course, and DGX-Spark notebooks have their own
# preinstalled environments and the Colab-vs-cell rules are not
# applicable to them.
def target_environment(notebook_name: str) -> str: def target_environment(notebook_name: str) -> str:
parts = pathlib.PurePath(notebook_name).parts parts = pathlib.PurePath(notebook_name).parts
base = parts[-1] if parts else notebook_name base = parts[-1] if parts else notebook_name
@ -318,7 +331,9 @@ def parse_pip_line(line: str, line_no: int = 0) -> PipInvocation | None:
if t in ("install", "uninstall"): if t in ("install", "uninstall"):
continue continue
packages.append(t) packages.append(t)
return PipInvocation(tool = tool, flags = flags, packages = packages, raw = line, line_no = line_no) return PipInvocation(
tool = tool, flags = flags, packages = packages, raw = line, line_no = line_no
)
def _glue_line_continuations(text: str) -> list[tuple[int, str]]: def _glue_line_continuations(text: str) -> list[tuple[int, str]]:
@ -403,7 +418,9 @@ def pypi_metadata(name: str, version: str) -> dict[str, Any] | None:
return data return data
def transitive_constraint(name: str, version: str, target: str) -> tuple[str | None, list[str]]: def transitive_constraint(
name: str, version: str, target: str
) -> tuple[str | None, list[str]]:
"""Return (raw_specifier_string_or_None, list_of_(op,version) tuples) """Return (raw_specifier_string_or_None, list_of_(op,version) tuples)
for the constraint that `name==version` places on `target`. for the constraint that `name==version` places on `target`.
""" """
@ -457,12 +474,19 @@ def constraint_satisfied(version: str, ops: list[tuple[str, str]]) -> bool:
def resolved_set(install_cell: str, colab: dict[str, str]) -> dict[str, str]: def resolved_set(install_cell: str, colab: dict[str, str]) -> dict[str, str]:
"""Merge install-cell constraints with Colab pip-freeze (cell wins). """Merge install-cell explicit constraints with Colab pip-freeze. Cell
wins.
Resolution order per package: (1) exact `==V` pin, (2) upper-bound `<=V` Resolution order per package, when more than one form is present:
(pip picks the highest allowed = V), (3) Colab fallback. Lower-bound `>=V` 1. Exact `==V` pin in any install line (definitive).
is intentionally NOT reflected (it doesn't lower an already-higher Colab 2. Upper-bound `<=V` constraint (pip picks the highest
version); R-INST-003 models that via `_install_cell_lower_bound`. allowed; that's V).
3. Colab pip-freeze fallback.
The lower-bound `>=V` is intentionally NOT reflected here a `>=V`
by itself doesn't change the resolved version when a higher
Colab-preinstalled version is already in scope. (R-INST-003 calls
`_install_cell_lower_bound` separately to model that case.)
""" """
out = dict(colab) out = dict(colab)
pinned: set[str] = set() pinned: set[str] = set()
@ -477,7 +501,10 @@ def resolved_set(install_cell: str, colab: dict[str, str]) -> dict[str, str]:
out[sp.name] = ver out[sp.name] = ver
pinned.add(sp.name) pinned.add(sp.name)
elif op == "<=" and sp.name not in pinned: elif op == "<=" and sp.name not in pinned:
if sp.name not in upper_bounds or cmp_versions(ver, upper_bounds[sp.name]) < 0: if (
sp.name not in upper_bounds
or cmp_versions(ver, upper_bounds[sp.name]) < 0
):
upper_bounds[sp.name] = ver upper_bounds[sp.name] = ver
# Apply upper bounds where Colab's preinstall violates them. # Apply upper bounds where Colab's preinstall violates them.
for name, ub in upper_bounds.items(): for name, ub in upper_bounds.items():
@ -492,7 +519,9 @@ def resolved_set(install_cell: str, colab: dict[str, str]) -> dict[str, str]:
# ----- Rules ----- # # ----- Rules ----- #
def rule_inst_001_git_plus(install_cell: str, file: str, cell_idx: int) -> list[Finding]: def rule_inst_001_git_plus(
install_cell: str, file: str, cell_idx: int
) -> list[Finding]:
findings: list[Finding] = [] findings: list[Finding] = []
for inv in iter_pip_invocations(install_cell): for inv in iter_pip_invocations(install_cell):
if any("git+" in p for p in inv.packages) or "git+" in inv.raw: if any("git+" in p for p in inv.packages) or "git+" in inv.raw:
@ -527,7 +556,8 @@ def rule_inst_002_no_deps_transitive(
v = explicit_pin(sp) v = explicit_pin(sp)
if v is None: if v is None:
continue continue
# Check transitive constraints on a curated short list of pkgs. # Check transitive constraints on a curated short list of pkgs we
# care about (transformers/peft/trl/accelerate/torchao/torchcodec).
for target in ( for target in (
"tokenizers", "tokenizers",
"torchao", "torchao",
@ -558,9 +588,10 @@ def rule_inst_002_no_deps_transitive(
def _install_cell_lower_bound(install_cell: str, target: str) -> str | None: def _install_cell_lower_bound(install_cell: str, target: str) -> str | None:
"""Return the highest lower bound any install line places on `target` """Return the highest LOWER bound that any install line places on `target`,
(treating `==V` as both bounds), or None. Used by R-INST-003 so a or None if no constraint is present. Treats `==V` as both lower and upper.
`torchao>=0.16.0` line satisfies the floor without a `==` pin.""" Used by R-INST-003: a `pip install torchao>=0.16.0` line is enough to
satisfy a `torchao>=0.16.0` floor even though it's not a `==` pin."""
best: str | None = None best: str | None = None
for inv in iter_pip_invocations(install_cell): for inv in iter_pip_invocations(install_cell):
for raw in inv.packages: for raw in inv.packages:
@ -634,17 +665,20 @@ def rule_inst_004_torchcodec_torch(
def rule_inst_005_transformers_tokenizers( def rule_inst_005_transformers_tokenizers(
install_cell: str, colab: dict[str, str], file: str, cell_idx: int install_cell: str, colab: dict[str, str], file: str, cell_idx: int
) -> list[Finding]: ) -> list[Finding]:
"""Fires only when transformers is installed with `--no-deps` (otherwise """Fires only when transformers is installed with `--no-deps`. Without
pip resolves tokenizers transitively and flagging would be a false `--no-deps`, pip resolves the correct tokenizers transitively, so the
positive). Targets the PR #261b/#264 pattern: `--no-deps transformers==X` rule would be a false positive (this is the case for older notebooks
next to a Colab `tokenizers` outside transformers's window.""" that pin `transformers==4.51.3` but rely on pip's transitive resolver).
The rule targets the exact pattern PR #261b / #264 fixed:
`pip install --no-deps transformers==X` next to a Colab preinstall
`tokenizers` outside transformers's window."""
findings: list[Finding] = [] findings: list[Finding] = []
res = resolved_set(install_cell, colab) res = resolved_set(install_cell, colab)
tf = res.get("transformers") tf = res.get("transformers")
tok = res.get("tokenizers") tok = res.get("tokenizers")
if not tf or tok is None: if not tf or tok is None:
return findings return findings
# Find the transformers pin and check for --no-deps. # Find the install line that pins transformers and check for --no-deps.
transformers_line_no_deps = False transformers_line_no_deps = False
for inv in iter_pip_invocations(install_cell): for inv in iter_pip_invocations(install_cell):
for raw in inv.packages: for raw in inv.packages:
@ -680,7 +714,9 @@ def rule_inst_005_transformers_tokenizers(
_RE_DOUBLE_BANG = re.compile(r"^[ \t]*!{2,}\s*pip\b", re.MULTILINE) _RE_DOUBLE_BANG = re.compile(r"^[ \t]*!{2,}\s*pip\b", re.MULTILINE)
def rule_inst_006_double_bang(install_cell: str, file: str, cell_idx: int) -> list[Finding]: def rule_inst_006_double_bang(
install_cell: str, file: str, cell_idx: int
) -> list[Finding]:
findings: list[Finding] = [] findings: list[Finding] = []
for m in _RE_DOUBLE_BANG.finditer(install_cell): for m in _RE_DOUBLE_BANG.finditer(install_cell):
line_no = install_cell.count("\n", 0, m.start()) + 1 line_no = install_cell.count("\n", 0, m.start()) + 1
@ -703,9 +739,11 @@ def rule_inst_006_double_bang(install_cell: str, file: str, cell_idx: int) -> li
class _APIScanner(ast.NodeVisitor): class _APIScanner(ast.NodeVisitor):
"""Scan user-facing code cells for known deprecated patterns. R-API-001 """Scan user-facing code cells for known deprecated patterns. R-API-001
(`for_training`/`for_inference`) is intentionally absent: those helpers are (`for_training`/`for_inference`) is intentionally absent: those helpers
still live as of 2026-05 (PR #221 removed them cosmetically, not as a are still part of the live unsloth surface as of 2026-05; PR #221 removed
deprecation). R-API-004 catches actual removals dynamically.""" the calls cosmetically from Vision notebooks but did not deprecate the
methods. R-API-004 (live API surface diff) catches actual removals
dynamically without us hand-coding them."""
def __init__(self, file: str, cell_idx: int): def __init__(self, file: str, cell_idx: int):
self.file = file self.file = file
@ -713,10 +751,14 @@ class _APIScanner(ast.NodeVisitor):
self.findings: list[Finding] = [] self.findings: list[Finding] = []
def visit_Call(self, node: ast.Call) -> None: def visit_Call(self, node: ast.Call) -> None:
# SFTConfig with suboptimal optim (R-API-003). # SFTConfig with suboptimal optim (R-API-003).
# NOTE: PR #221 also stripped gradient_checkpointing kwargs from some # NOTE: PR #221 also stripped `gradient_checkpointing` /
# vision notebooks, but they're still accepted by live TRL (trl==0.25.1) # `gradient_checkpointing_kwargs` from a handful of vision notebooks,
# so that was cosmetic. We don't flag them; R-API-004 catches real drift. # but those kwargs are still accepted by live TRL (verified against
# trl==0.25.1 in the unsloth workspace) so removing them was
# cosmetic, not a deprecation. We do NOT flag them. R-API-004 (live
# API surface diff in the api subcommand) is the right way to catch
# actual TRL signature drift.
if isinstance(node.func, ast.Name) and node.func.id == "SFTConfig": if isinstance(node.func, ast.Name) and node.func.id == "SFTConfig":
for kw in node.keywords: for kw in node.keywords:
if ( if (
@ -771,10 +813,16 @@ POLICY_CLAUSES_DEFAULT = [
] ]
def extract_policy_clauses(update_script: pathlib.Path) -> list[tuple[str, re.Pattern[str], Any]]: def extract_policy_clauses(
"""Best-effort scan of update_all_notebooks.py for canonical phrases; update_script: pathlib.Path,
falls back to POLICY_CLAUSES_DEFAULT (which we use directly today). The ) -> list[tuple[str, re.Pattern[str], Any]]:
permissive regexes avoid false positives on template rewords.""" """Best-effort: scan update_all_notebooks.py for canonical phrases used by
multiple templates. Falls back to POLICY_CLAUSES_DEFAULT.
Today we use POLICY_CLAUSES_DEFAULT directly; the regex form is
intentionally permissive so a template-side reword (e.g. comment changes)
doesn't cause false positives. New clauses become 1-line PRs to this list.
"""
return list(POLICY_CLAUSES_DEFAULT) return list(POLICY_CLAUSES_DEFAULT)
@ -831,15 +879,24 @@ def cmd_drift(args: argparse.Namespace) -> int:
print(f"FAIL: {update_script} not found", file = sys.stderr) print(f"FAIL: {update_script} not found", file = sys.stderr)
return 2 return 2
# Stash any pre-existing dirty state, run the updater, diff, restore. # Stash any pre-existing dirty state, run the updater, diff, restore.
head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd = nbdir).decode().strip() head = (
subprocess.check_output(["git", "rev-parse", "HEAD"], cwd = nbdir)
.decode()
.strip()
)
subprocess.run( subprocess.run(
["git", "-C", str(nbdir), "stash", "--include-untracked"], ["git", "-C", str(nbdir), "stash", "--include-untracked"],
check = False, check = False,
capture_output = True, capture_output = True,
) )
# The restore MUST run even on SystemExit/KeyboardInterrupt, else the # SF3: the restore MUST run even on SystemExit / KeyboardInterrupt /
# working tree stays rolled back into the stash. A bare try/finally keeps # segfault-propagated exception, otherwise the user's working tree
# the original exception while still running the cleanup (stash pop). # silently stays rolled back into the stash. A bare try/finally
# (NOT try/except/finally) preserves the original exception and
# still runs the cleanup. The pre-existing try/except around
# `subprocess.run` of the updater is folded inside the new outer
# try so its early returns still happen, but the stash pop is
# protected.
findings: list[Finding] = [] findings: list[Finding] = []
rc: int rc: int
try: try:
@ -884,7 +941,8 @@ def cmd_drift(args: argparse.Namespace) -> int:
) )
rc = 0 if not findings else 1 rc = 0 if not findings else 1
finally: finally:
# Restore the working tree (both commands run regardless of exit path). # Restore the working tree. Both commands MUST run regardless of
# how the try block exited (including SystemExit/KeyboardInterrupt).
subprocess.run( subprocess.run(
["git", "-C", str(nbdir), "checkout", "."], ["git", "-C", str(nbdir), "checkout", "."],
check = False, check = False,
@ -932,7 +990,9 @@ def cmd_convert(args: argparse.Namespace) -> int:
hint = proc.stderr[-200:].strip(), hint = proc.stderr[-200:].strip(),
) )
) )
print(f"converted {len(notebooks) - len(failed)}/{len(notebooks)} notebooks to {out}") print(
f"converted {len(notebooks) - len(failed)}/{len(notebooks)} notebooks to {out}"
)
_emit(failed) _emit(failed)
return 0 if not failed else 1 return 0 if not failed else 1
@ -942,7 +1002,11 @@ def cmd_convert(args: argparse.Namespace) -> int:
def cmd_lint(args: argparse.Namespace) -> int: def cmd_lint(args: argparse.Namespace) -> int:
nbdir = pathlib.Path(args.notebooks_dir).resolve() nbdir = pathlib.Path(args.notebooks_dir).resolve()
colab_path = pathlib.Path(args.colab_pin).resolve() if args.colab_pin else COLAB_FALLBACK_FILE colab_path = (
pathlib.Path(args.colab_pin).resolve()
if args.colab_pin
else COLAB_FALLBACK_FILE
)
colab = parse_pip_freeze(colab_path) colab = parse_pip_freeze(colab_path)
if not colab: if not colab:
print( print(
@ -967,24 +1031,31 @@ def cmd_lint(args: argparse.Namespace) -> int:
continue continue
rel = str(path.relative_to(nbdir)) rel = str(path.relative_to(nbdir))
env = target_environment(rel) env = target_environment(rel)
# Colab oracle applies only to Colab notebooks; other targets get the # The Colab oracle is the source of truth ONLY for Colab notebooks.
# environment-agnostic rules only (their preinstalls aren't tracked). # Other targets (amd / kaggle / dgx_spark) have their own runtime
# preinstall sets that aren't tracked here yet, so we apply the
# environment-agnostic rules and skip the Colab-specific ones.
oracle = colab if env == "colab" else {} oracle = colab if env == "colab" else {}
cells = install_cells(nb) cells = install_cells(nb)
# Per-cell forbid-pattern checks. # Per-cell rules: forbid-pattern checks scoped to a single line.
for idx, cell in cells: for idx, cell in cells:
findings += rule_inst_001_git_plus(cell, rel, idx) findings += rule_inst_001_git_plus(cell, rel, idx)
findings += rule_inst_006_double_bang(cell, rel, idx) findings += rule_inst_006_double_bang(cell, rel, idx)
# Whole-notebook rules: install steps may span multiple cells, so merge # Whole-notebook rules: a notebook's install steps are sometimes split
# before resolving compat against Colab. # across multiple cells (initial install + post-install bumps). Merge
# all install cells before resolving compat against Colab.
merged = "\n".join(c for _, c in cells) merged = "\n".join(c for _, c in cells)
if env == "colab" and merged: if env == "colab" and merged:
first_cell = cells[0][0] if cells else None first_cell = cells[0][0] if cells else None
findings += rule_inst_003_peft_torchao(merged, oracle, rel, first_cell) findings += rule_inst_003_peft_torchao(merged, oracle, rel, first_cell)
findings += rule_inst_004_torchcodec_torch(merged, oracle, rel, first_cell) findings += rule_inst_004_torchcodec_torch(merged, oracle, rel, first_cell)
findings += rule_inst_005_transformers_tokenizers(merged, oracle, rel, first_cell) findings += rule_inst_005_transformers_tokenizers(
merged, oracle, rel, first_cell
)
if not args.no_pypi: if not args.no_pypi:
findings += rule_inst_002_no_deps_transitive(merged, oracle, rel, first_cell) findings += rule_inst_002_no_deps_transitive(
merged, oracle, rel, first_cell
)
findings += scan_user_cells(nb, rel) findings += scan_user_cells(nb, rel)
_emit(findings) _emit(findings)
return 0 if not any(f.severity == "error" for f in findings) else 1 return 0 if not any(f.severity == "error" for f in findings) else 1
@ -1107,7 +1178,8 @@ def _parse_apt_lines(text: str) -> dict[str, str]:
def _parse_os_lines(text: str) -> dict[str, str]: def _parse_os_lines(text: str) -> dict[str, str]:
"""Free-form `<tool> <version>` lines -> {tool_lower: rest}.""" """Free-form `<tool> <version>` lines. Skip comments. The key is the
first token lower-cased; the value is the rest of the line."""
out: dict[str, str] = {} out: dict[str, str] = {}
for line in text.splitlines(): for line in text.splitlines():
line = line.strip() line = line.strip()
@ -1144,9 +1216,10 @@ def _diff_oracle(
def cmd_colab_diff(args: argparse.Namespace) -> int: def cmd_colab_diff(args: argparse.Namespace) -> int:
"""Diff each Colab oracle file against its committed snapshot and print """Fetch every Colab oracle file in COLAB_ORACLE_FILES, diff against
NEW/REMOVED/CHANGED. Advisory (rc=0) by default; --strict makes any diff the committed snapshot, and print NEW / REMOVED / CHANGED. Advisory
rc=1 so the daily cron fails loudly on upstream rotation.""" by default (rc=0); --strict promotes any diff to rc=1 so the daily
cron can fail loudly when upstream rotates."""
snapshot_dir = pathlib.Path(args.snapshot_dir).resolve() snapshot_dir = pathlib.Path(args.snapshot_dir).resolve()
any_diff = False any_diff = False
for upstream_name, snapshot_name in COLAB_ORACLE_FILES.items(): for upstream_name, snapshot_name in COLAB_ORACLE_FILES.items():
@ -1159,7 +1232,9 @@ def cmd_colab_diff(args: argparse.Namespace) -> int:
print(f"::warning::colab-diff: could not fetch {url}: {e}") print(f"::warning::colab-diff: could not fetch {url}: {e}")
continue continue
if not snap_path.exists(): if not snap_path.exists():
print(f"::warning::colab-diff: no committed snapshot at {snap_path}; skipping") print(
f"::warning::colab-diff: no committed snapshot at {snap_path}; skipping"
)
continue continue
snapshot_text = snap_path.read_text(encoding = "utf-8", errors = "replace") snapshot_text = snap_path.read_text(encoding = "utf-8", errors = "replace")
parser = _COLAB_ORACLE_PARSERS[upstream_name] parser = _COLAB_ORACLE_PARSERS[upstream_name]

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

@ -1,7 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Run a pre-pass (normalize def-signature magic commas + collapse short """Run `ruff format` followed by kwarg spacing enforcement."""
multi-line asserts), then `ruff format`, then the kwarg-spacing / import /
string-merge post-pass."""
from __future__ import annotations from __future__ import annotations
@ -17,20 +15,12 @@ def main(argv: list[str]) -> int:
if not files: if not files:
return 0 return 0
spacing_script = HERE / "enforce_kwargs_spacing.py"
# Pre-ruff: normalize def-signature magic commas and strip the magic comma
# from short multi-line asserts so ruff wraps/joins accordingly.
pre_cmd = [sys.executable, str(spacing_script), "--pre", *files]
pre_proc = subprocess.run(pre_cmd)
if pre_proc.returncode != 0:
return pre_proc.returncode
ruff_cmd = [sys.executable, "-m", "ruff", "format", *files] ruff_cmd = [sys.executable, "-m", "ruff", "format", *files]
ruff_proc = subprocess.run(ruff_cmd) ruff_proc = subprocess.run(ruff_cmd)
if ruff_proc.returncode != 0: if ruff_proc.returncode != 0:
return ruff_proc.returncode return ruff_proc.returncode
spacing_script = HERE / "enforce_kwargs_spacing.py"
spacing_cmd = [sys.executable, str(spacing_script), *files] spacing_cmd = [sys.executable, str(spacing_script), *files]
spacing_proc = subprocess.run(spacing_cmd) spacing_proc = subprocess.run(spacing_cmd)
return spacing_proc.returncode return spacing_proc.returncode

File diff suppressed because it is too large Load diff

View file

@ -1,5 +0,0 @@
{
"_comment": "scan_npm_packages.py allowlist. Each entry is a HIGH/CRITICAL finding manually judged benign. Matched on (package, package-relative path, pattern, evidence hash); a new payload under an already-listed package/path/pattern reopens instead of riding the entry. severity is for review only. Regenerate with --write-baseline AFTER reviewing every line. EMPTY by design: a full scan of studio/frontend/package-lock.json (915 packages) produced 0 findings, so nothing needs suppressing and the CI gate can run enforcing (SCAN_ENFORCE=1) as-is. If a future dependency adds a reviewed-benign HIGH/CRITICAL, add it here rather than weakening a pattern.",
"version": 3,
"entries": []
}

File diff suppressed because it is too large Load diff

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
@ -17,13 +17,12 @@ import zipfile
from pathlib import Path from pathlib import Path
def _atomic_write_text( def _atomic_write_text(path: Path, data: str, encoding: str = "utf-8") -> None:
path: Path, """Atomic version of ``Path.write_text``.
data: str,
encoding: str = "utf-8", A crash or signal mid-write leaves the prior file intact; the
) -> None: Studio build never reads a partial ``_studio_release_build.py``.
"""Atomic ``Path.write_text``: a crash mid-write leaves the prior file """
intact, so the build never reads a partial ``_studio_release_build.py``."""
dirpath = str(path.parent) or "." dirpath = str(path.parent) or "."
path.parent.mkdir(parents = True, exist_ok = True) path.parent.mkdir(parents = True, exist_ok = True)
fd, tmp_path = tempfile.mkstemp(prefix = ".stamp_studio.", dir = dirpath) fd, tmp_path = tempfile.mkstemp(prefix = ".stamp_studio.", dir = dirpath)
@ -42,7 +41,9 @@ def _atomic_write_text(
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
BUILD_INFO_PATH = REPO_ROOT / "studio" / "backend" / "utils" / "_studio_release_build.py" BUILD_INFO_PATH = (
REPO_ROOT / "studio" / "backend" / "utils" / "_studio_release_build.py"
)
BUILD_INFO_SUFFIX = "studio/backend/utils/_studio_release_build.py" BUILD_INFO_SUFFIX = "studio/backend/utils/_studio_release_build.py"
VERSION_RE = re.compile(r"^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.][0-9A-Za-z.-]*)?$") VERSION_RE = re.compile(r"^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.][0-9A-Za-z.-]*)?$")
GIT_DESCRIBE_SUFFIX_RE = re.compile(r"-\d+-g[0-9A-Fa-f]+(?:-dirty)?$") GIT_DESCRIBE_SUFFIX_RE = re.compile(r"-\d+-g[0-9A-Fa-f]+(?:-dirty)?$")
@ -50,7 +51,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 +146,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 +169,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 +197,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 +208,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 +234,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 +252,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

@ -1,143 +0,0 @@
#!/usr/bin/env python3
"""Keep `allowScripts` pins in studio/frontend/package.json in sync with
package-lock.json.
`npm approve-scripts` writes version-pinned entries ("pkg@1.2.3": true).
A dependency bump strands the pin, so the approval (or denial) silently
stops matching and the package's install scripts fall back to
"unreviewed". This tool re-pins existing entries to the versions the
lockfile actually resolves; it never adds or removes entries, so
approving a brand-new script-bearing package stays a human decision.
Usage:
python scripts/sync_allow_scripts_pins.py --check # CI: exit 1 on drift
python scripts/sync_allow_scripts_pins.py --fix # rewrite package.json
Pinned keys follow npm's allowScripts grammar: "name@1.2.3" or
"name@1.2.3 || 1.2.4". Bare names (no version) match every version and
are left alone. Entries whose range is not an exact-version disjunction
(wildcards, tags) are left alone too.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_DIR = REPO_ROOT / "studio" / "frontend"
EXACT_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?$")
def split_spec(key: str) -> tuple[str, str | None]:
"""'@scope/name@1.2.3' -> ('@scope/name', '1.2.3'); bare names -> (key, None)."""
if key.startswith("@"):
rest = key[1:]
if "@" not in rest:
return key, None
name, rng = rest.split("@", 1)
return "@" + name, rng
if "@" not in key:
return key, None
name, rng = key.split("@", 1)
return name, rng
def is_exact_disjunction(rng: str) -> bool:
parts = [p.strip() for p in rng.split("||")]
return all(EXACT_VERSION_RE.match(p) for p in parts) and bool(parts)
def version_sort_key(version: str) -> tuple:
release = version.split("-", 1)[0].split("+", 1)[0]
return tuple(int(x) for x in release.split(".")), version
def script_versions_from_lock(lock: dict) -> dict[str, list[str]]:
"""Map package name -> sorted versions that carry install scripts."""
out: dict[str, set[str]] = {}
for path, meta in (lock.get("packages") or {}).items():
if not path or not meta.get("hasInstallScript"):
continue
name = path.rsplit("node_modules/", 1)[-1]
version = meta.get("version")
if name and version:
out.setdefault(name, set()).add(version)
return {n: sorted(vs, key = version_sort_key) for n, vs in out.items()}
def desired_key(name: str, versions: list[str]) -> str:
return f"{name}@{' || '.join(versions)}"
def compute_renames(policy: dict, lock_versions: dict[str, list[str]]) -> dict[str, str]:
renames: dict[str, str] = {}
for key in policy:
name, rng = split_spec(key)
if rng is None or not is_exact_disjunction(rng):
continue # bare name or non-exact spec: matches by name, never stale
versions = lock_versions.get(name)
if not versions:
continue # package gone or script-free now: stale pin is inert
want = desired_key(name, versions)
if key != want:
renames[key] = want
return renames
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description = __doc__)
mode = ap.add_mutually_exclusive_group(required = True)
mode.add_argument("--check", action = "store_true", help = "exit 1 if pins are stale")
mode.add_argument("--fix", action = "store_true", help = "rewrite package.json in place")
ap.add_argument(
"--dir",
type = Path,
default = DEFAULT_DIR,
help = "directory holding package.json + package-lock.json",
)
args = ap.parse_args(argv)
pkg_path = args.dir / "package.json"
lock_path = args.dir / "package-lock.json"
if not pkg_path.exists() or not lock_path.exists():
print(f"sync-allow-scripts: nothing to do ({args.dir} has no package.json + lockfile)")
return 0
pkg = json.loads(pkg_path.read_text(encoding = "utf-8"))
policy = pkg.get("allowScripts")
if not isinstance(policy, dict) or not policy:
print("sync-allow-scripts: no allowScripts policy in package.json, nothing to do")
return 0
lock = json.loads(lock_path.read_text(encoding = "utf-8"))
renames = compute_renames(policy, script_versions_from_lock(lock))
if not renames:
print(f"sync-allow-scripts: {len(policy)} allowScripts entries in sync with the lockfile")
return 0
for old, new in renames.items():
print(f' stale pin: "{old}" -> "{new}"')
if args.check:
print(
"sync-allow-scripts: pins are stale; run "
"`python scripts/sync_allow_scripts_pins.py --fix` and commit the result"
)
return 1
pkg["allowScripts"] = {renames.get(k, k): v for k, v in policy.items()}
pkg_path.write_text(json.dumps(pkg, indent = 2, ensure_ascii = False) + "\n", encoding = "utf-8")
print(
f"sync-allow-scripts: re-pinned {len(renames)} entr{'y' if len(renames) == 1 else 'ies'} in {pkg_path}"
)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,427 +0,0 @@
#!/usr/bin/env sh
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
#
# Unsloth Studio uninstaller (macOS / Linux / WSL).
# Stops running servers and removes install dir, launcher data,
# CLI shim, desktop shortcut, .app bundle, and Launch Services entry.
# Honors custom roots set via UNSLOTH_STUDIO_HOME / STUDIO_HOME at
# install time (read back from studio.conf).
#
# Usage: curl -fsSL https://raw.githubusercontent.com/unslothai/unsloth/main/scripts/uninstall.sh | sh
set -e
# Stop an Unsloth server via its PID file (written by install.sh's _spawn_terminal).
_kill_pid_file() {
_pid_file="$1"
[ -f "$_pid_file" ] || return 0
_pid=$(sed -n '1s/[^0-9].*//p' "$_pid_file" 2>/dev/null || true)
if [ -n "$_pid" ] && kill -0 "$_pid" 2>/dev/null; then
kill -TERM "$_pid" 2>/dev/null || true
# Wait up to 10s for graceful shutdown.
_i=0
while kill -0 "$_pid" 2>/dev/null && [ "$_i" -lt 20 ]; do
sleep 0.5
_i=$((_i + 1))
done
kill -0 "$_pid" 2>/dev/null && kill -KILL "$_pid" 2>/dev/null || true
fi
rm -f "$_pid_file" 2>/dev/null || true
}
# BRE-escape a path so it can be embedded in a pkill -f regex.
_pkill_escape() {
printf '%s' "$1" | sed -e 's:[][\\.^$*+?{|}()/]:\\&:g'
}
_pkill_studio() {
# Prefer PID files written by _spawn_terminal so we only touch our own installs.
for _data_dir in "$HOME/.local/share/unsloth" $(_custom_studio_data_dirs); do
[ -d "$_data_dir" ] || continue
for _pf in "$_data_dir"/studio-*.pid; do
[ -f "$_pf" ] && _kill_pid_file "$_pf"
done
done
command -v pkill >/dev/null 2>&1 || return 0
# Scope fallback patterns to the install roots we are removing so a
# different Unsloth install (different UNSLOTH_STUDIO_HOME) is not touched.
_kill_roots="$HOME/.unsloth/studio"
_roots_from_conf=$(_custom_studio_roots 2>/dev/null || true)
[ -n "$_roots_from_conf" ] && _kill_roots="$_kill_roots
$_roots_from_conf"
printf '%s\n' "$_kill_roots" | while IFS= read -r _root; do
[ -n "$_root" ] || continue
[ -d "$_root" ] || continue
_re=$(_pkill_escape "$_root")
# `unsloth studio` (default port) + `-p N` + `--port N` forms, all
# anchored on the install root's venv path.
for _pat in \
"${_re}/unsloth_studio/bin/[^ ]* studio( |\$|.*-p[ =][0-9])" \
"${_re}/unsloth_studio/bin/[^ ]* studio.*--port[ =][0-9]" \
"${_re}/.*studio/backend/run\.py"
do
pkill -TERM -f "$_pat" 2>/dev/null || true
done
done
sleep 0.5
printf '%s\n' "$_kill_roots" | while IFS= read -r _root; do
[ -n "$_root" ] || continue
[ -d "$_root" ] || continue
_re=$(_pkill_escape "$_root")
for _pat in \
"${_re}/unsloth_studio/bin/[^ ]* studio( |\$|.*-p[ =][0-9])" \
"${_re}/unsloth_studio/bin/[^ ]* studio.*--port[ =][0-9]" \
"${_re}/.*studio/backend/run\.py"
do
pkill -KILL -f "$_pat" 2>/dev/null || true
done
done
}
_remove_path() {
_p="$1"
if [ -e "$_p" ] || [ -L "$_p" ]; then
rm -rf "$_p" 2>/dev/null && echo " removed: $_p" || echo " could not remove: $_p" >&2
fi
}
# Accept as Unsloth root only if Unsloth sentinels exist (matches install.sh's
# env-mode ownership guard at install.sh:1358-1361). A bare unsloth_studio/
# directory is NOT enough -- require the install-time owner marker so a user
# directory that happens to contain a folder named "unsloth_studio" is safe.
_is_studio_root() {
_r="$1"
[ -n "$_r" ] || return 1
[ -f "$_r/share/studio.conf" ] && return 0
[ -f "$_r/unsloth_studio/.unsloth-studio-owned" ] && return 0
if [ -L "$_r/bin/unsloth" ]; then
_t=$(readlink "$_r/bin/unsloth" 2>/dev/null || true)
case "$_t" in *unsloth_studio/bin/unsloth) return 0 ;; esac
fi
return 1
}
# Hard deny list: never delete /, $HOME, $HOME's parent, or system paths.
_is_unsafe_root() {
_r="$1"
[ -z "$_r" ] && return 0
case "$_r" in /|""|"$HOME"|"$HOME/") return 0 ;; esac
case "$_r" in /bin|/sbin|/etc|/usr|/usr/*|/var|/var/*|/opt|/opt/*|/Library|/Library/*|/System|/System/*|/Applications|/Applications/*) return 0 ;; esac
_parent=$(dirname "$HOME" 2>/dev/null || echo "")
[ -n "$_parent" ] && [ "$_r" = "$_parent" ] && return 0
return 1
}
# Print share/ dirs of known custom roots (where PID files live).
_custom_studio_data_dirs() {
_custom_studio_roots 2>/dev/null | while IFS= read -r _r; do
[ -d "$_r/share" ] && printf '%s\n' "$_r/share"
done
}
# Resolve a custom install root from any of:
# 1. UNSLOTH_STUDIO_HOME / STUDIO_HOME env vars at uninstall time
# 2. Default-mode studio.conf at $HOME/.local/share/unsloth/studio.conf
# 3. Env-mode studio.conf at $<root>/share/studio.conf (discovered via 1)
# install.sh writes UNSLOTH_EXE='<root>/unsloth_studio/bin/unsloth', so
# the install root is three dirnames up. Prints each discovered non-default
# root on its own line; the caller iterates and de-duplicates.
_custom_studio_roots() {
_seen=""
_emit() {
_r="$1"
[ -z "$_r" ] && return 0
# Tilde expansion (env vars are not subject to it on quoted assignment),
# matches install.sh's _resolve_studio_destinations. The literal "~/"
# pattern is intentional; SC2088 is a false positive here.
# shellcheck disable=SC2088
case "$_r" in
"~") _r="$HOME" ;;
"~/"*) _r="$HOME/${_r#'~/'}" ;;
esac
# Canonicalize so syntactic variants ($HOME/../$USER, trailing slash)
# resolve to the same path and hit the _is_unsafe_root deny list.
# shellcheck disable=SC1007
_canon=$(CDPATH= cd -P -- "$_r" 2>/dev/null && pwd -P)
[ -n "$_canon" ] && _r="$_canon"
case "$_r" in "$HOME/.unsloth/studio"|/|"") return 0 ;; esac
case ":$_seen:" in *":$_r:"*) return 0 ;; esac
_seen="$_seen:$_r"
printf '%s\n' "$_r"
}
_from_conf() {
[ -f "$1" ] || return 0
# Tolerate paths containing apostrophes (install.sh emits '\'' for them).
_exe=$(sed -n "s/^UNSLOTH_EXE='\(.*\)'\$/\1/p" "$1" | head -n1)
_exe=$(printf '%s' "$_exe" | sed "s/'\\\\''/'/g")
[ -n "$_exe" ] || return 0
_emit "$(dirname "$(dirname "$(dirname "$_exe")")")"
}
# Mirror install.sh's precedence: UNSLOTH_STUDIO_HOME wins, STUDIO_HOME is
# ignored when both are set. Otherwise uninstalling install A could also
# delete install B if the user has STUDIO_HOME left over from B.
if [ -n "${UNSLOTH_STUDIO_HOME:-}" ]; then
_emit "$UNSLOTH_STUDIO_HOME"
_from_conf "$UNSLOTH_STUDIO_HOME/share/studio.conf"
elif [ -n "${STUDIO_HOME:-}" ]; then
_emit "$STUDIO_HOME"
_from_conf "$STUDIO_HOME/share/studio.conf"
fi
# Default-mode conf.
_from_conf "$HOME/.local/share/unsloth/studio.conf"
}
# Remove $HOME/.local/bin/unsloth only if it's an Unsloth-managed symlink.
# Unsloth's install.sh writes this as a symlink into the studio venv
# (install.sh: `ln -sfn "$VENV_DIR/bin/unsloth" "$_shim_path"`). A
# pip-installed `unsloth` CLI is a regular file — leave it alone to avoid
# wiping an unrelated install.
_remove_cli_shim() {
_shim="$HOME/.local/bin/unsloth"
[ -L "$_shim" ] || return 0
_target=$(readlink "$_shim" 2>/dev/null || true)
case "$_target" in
*/unsloth_studio/bin/unsloth) _remove_path "$_shim" ;;
*) ;;
esac
}
_uid=$(id -u 2>/dev/null || echo 0)
_os=$(uname 2>/dev/null || echo unknown)
_is_wsl=0
[ "$_os" = "Linux" ] && grep -qi microsoft /proc/version 2>/dev/null && _is_wsl=1
echo "Stopping any running Unsloth Studio servers..."
_pkill_studio
echo "Removing data and install directories..."
_custom_studio_roots | while IFS= read -r _custom_root; do
[ -n "$_custom_root" ] || continue
if _is_unsafe_root "$_custom_root"; then
echo " refusing to remove unsafe path: $_custom_root" >&2
continue
fi
if ! _is_studio_root "$_custom_root"; then
echo " refusing to remove non-Unsloth path: $_custom_root" >&2
continue
fi
_remove_path "$_custom_root"
done
_remove_path "$HOME/.unsloth/studio"
# Default-mode shared llama.cpp build + cache are siblings of studio (not removed
# by deleting it). No-op in env/custom mode (they nest under the custom root) and
# when absent. A user-set UNSLOTH_LLAMA_CPP_PATH is intentionally kept.
_remove_path "$HOME/.unsloth/llama.cpp"
_remove_path "$HOME/.unsloth/.cache"
# Isolated Node.js runtime (install_node_prebuilt.py), a sibling of studio in
# default mode. No-op in env/custom mode (nested under the custom root) and absent.
_remove_path "$HOME/.unsloth/node"
# llama.cpp atomic-install staging root (install_llama_prebuilt.py .staging).
# Normally pruned after activate, but an interrupted build can leave it behind;
# removing it lets the rmdir below succeed. No-op in env/custom mode and absent.
_remove_path "$HOME/.unsloth/.staging"
# llama.cpp install lock (serializes the shared build); a stray one keeps ~/.unsloth
# from being pruned below. No-op in env/custom mode and when absent.
_remove_path "$HOME/.unsloth/.llama.cpp.install.lock"
# ROCm-on-WSL helper artifacts (librocdxg build clone + smoke-test venv). No-op
# where they don't exist; removing them lets the rmdir below succeed.
_remove_path "$HOME/.unsloth/librocdxg"
_remove_path "$HOME/.unsloth/rocm-smoketest"
# Drop ~/.unsloth only if now empty (rmdir refuses non-empty, so user content is kept).
rmdir "$HOME/.unsloth" 2>/dev/null || true
_remove_path "$HOME/.local/share/unsloth"
# CLI shim: only the symlink Unsloth created, never a pip-installed file.
_remove_cli_shim
echo "Removing desktop shortcut and launcher lock..."
# install.sh creates Desktop/Unsloth Studio as a symlink. If the user has an
# unrelated regular directory by that name, leave it alone.
_desktop_link="$HOME/Desktop/Unsloth Studio"
if [ -L "$_desktop_link" ] || [ ! -e "$_desktop_link" ]; then
_remove_path "$_desktop_link"
else
echo " refusing to remove non-symlink Desktop path: $_desktop_link" >&2
fi
_remove_path "$HOME/Desktop/unsloth-studio.desktop"
# Locks are namespaced per-uid; env-mode adds an extra suffix.
_lock_glob="${XDG_RUNTIME_DIR:-/tmp}/unsloth-studio-launcher-${_uid}"
for _lock in "$_lock_glob".lock "$_lock_glob"-*.lock; do
[ -e "$_lock" ] && _remove_path "$_lock"
done
case "$_os" in
Darwin)
echo "Removing macOS .app bundle and Launch Services entry..."
_remove_path "$HOME/Applications/Unsloth Studio.app"
_lsr="/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister"
if [ -x "$_lsr" ]; then
"$_lsr" -u "$HOME/Applications/Unsloth Studio.app" 2>/dev/null || true
fi
;;
Linux)
if [ "$_is_wsl" = "1" ]; then
echo "Removing WSL Windows-side shortcuts..."
# install.sh creates per-distro 'Unsloth Studio (WSL - <distro>).lnk'
# on the Windows Desktop + Start Menu via powershell.exe. Scope removal
# to THIS distro (passed as $args[0]) so a multi-distro install keeps the
# other distros' launchers; the TARGET=wsl.exe check still spares a
# native install's "Unsloth Studio.lnk". Prefer powershell.exe; test it
# can EXECUTE (`command -v` succeeds even with interop OFF -- .exe then
# fails "Exec format error", common on systemd-enabled distros).
_wsl_distro="${WSL_DISTRO_NAME:-}"
_ps_ran=0
if command -v powershell.exe >/dev/null 2>&1 && \
powershell.exe -NoProfile -Command "exit 0" >/dev/null 2>&1; then
_ps_ran=1
# Inject the distro into the command: a -Command string does not
# receive trailing tokens as $args. WSL distro names are safe to
# embed (no quotes/$/backtick).
# shellcheck disable=SC2016
powershell.exe -NoProfile -Command '$distro = "'"$_wsl_distro"'";
$dirs = @(
[Environment]::GetFolderPath("Desktop"),
(Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs")
);
$ws = New-Object -ComObject WScript.Shell;
foreach ($d in $dirs) {
if (-not $d -or -not (Test-Path -LiteralPath $d)) { continue }
Get-ChildItem -LiteralPath $d -Filter "Unsloth Studio*.lnk" -ErrorAction SilentlyContinue | ForEach-Object {
try {
$sc = $ws.CreateShortcut($_.FullName);
if ("$($sc.TargetPath) $($sc.Arguments)" -notmatch "wsl\.exe") { return }
# When the distro is known, require the per-distro
# name for this distro or its -d "<distro>" argument
# so launchers for other distros are not removed.
if ($distro) {
$nameMatch = ($_.Name -eq "Unsloth Studio (WSL - $distro).lnk");
$argMatch = ($sc.Arguments -match ("-d\s+`"?" + [regex]::Escape($distro) + "`"?"));
if (-not ($nameMatch -or $argMatch)) { return }
}
Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue
} catch { }
}
}
# Keep the shared icon while any Unsloth shortcut still uses it (native
# install or another WSL distro); drop it only with the last one.
$iconInUse = $false;
foreach ($d in $dirs) {
if (-not $d -or -not (Test-Path -LiteralPath $d)) { continue }
if (Get-ChildItem -LiteralPath $d -Filter "Unsloth Studio*.lnk" -ErrorAction SilentlyContinue) { $iconInUse = $true; break }
}
# Guard LOCALAPPDATA: empty on a service/SYSTEM account makes
# Join-Path throw, aborting the icon cleanup (mirror uninstall.ps1).
if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
$iconDir = Join-Path $env:LOCALAPPDATA "Unsloth Studio";
$ico = Join-Path $iconDir "unsloth.ico";
if ((-not $iconInUse) -and (Test-Path -LiteralPath $ico)) { Remove-Item -LiteralPath $ico -Force -ErrorAction SilentlyContinue }
if ((Test-Path -LiteralPath $iconDir) -and -not (Get-ChildItem -LiteralPath $iconDir -Force -ErrorAction SilentlyContinue)) { Remove-Item -LiteralPath $iconDir -Recurse -Force -ErrorAction SilentlyContinue }
}' >/dev/null 2>&1 || true
fi
# Remove $1's shared unsloth.ico only if no Unsloth shortcut (native install
# or another WSL distro) still uses it, then drop the dir if empty. Reciprocal
# of uninstall.ps1's _RemoveDataDirKeepingWslIcon (keeps the icon for a
# surviving WSL shortcut when the native side is removed).
_drop_shared_icon_if_unused() {
_du="$1"
_icodir="$_du/AppData/Local/Unsloth Studio"
_icon_in_use=0
for _sd in \
"$_du/Desktop" \
"$_du/OneDrive/Desktop" \
"$_du"/OneDrive*/Desktop \
"$_du/AppData/Roaming/Microsoft/Windows/Start Menu/Programs"; do
[ -d "$_sd" ] || continue
for _any in "$_sd"/"Unsloth Studio"*.lnk; do
[ -e "$_any" ] && { _icon_in_use=1; break; }
done
[ "$_icon_in_use" = "1" ] && break
done
if [ "$_icon_in_use" = "0" ]; then
[ -f "$_icodir/unsloth.ico" ] && rm -f "$_icodir/unsloth.ico" 2>/dev/null || true
fi
[ -d "$_icodir" ] && rmdir "$_icodir" 2>/dev/null || true
}
# Fallback when powershell.exe can't run (interop disabled): remove WSL .lnk
# files via drvfs. The "Unsloth Studio (WSL..." name is WSL-specific, so a
# native install's "Unsloth Studio.lnk" never matches.
if [ "$_ps_ran" = "0" ]; then
for _drive in /mnt/c /mnt/d /mnt/e; do
[ -d "$_drive/Users" ] || continue
for _udir in "$_drive"/Users/*; do
[ -d "$_udir" ] || continue
for _scdir in \
"$_udir/Desktop" \
"$_udir/OneDrive/Desktop" \
"$_udir"/OneDrive*/Desktop \
"$_udir/AppData/Roaming/Microsoft/Windows/Start Menu/Programs"; do
[ -d "$_scdir" ] || continue
if [ -n "$_wsl_distro" ]; then
# Exact per-distro name (no glob) so other distros survive.
_lnk="$_scdir/Unsloth Studio (WSL - ${_wsl_distro}).lnk"
[ -e "$_lnk" ] && rm -f "$_lnk" 2>/dev/null && echo " removed: $_lnk" || true
else
# Distro unknown: fall back to the broad WSL prefix.
for _lnk in "$_scdir"/"Unsloth Studio (WSL"*.lnk; do
[ -e "$_lnk" ] && rm -f "$_lnk" 2>/dev/null && echo " removed: $_lnk" || true
done
fi
done
# Drop the shared icon only when no shortcut still needs it.
_drop_shared_icon_if_unused "$_udir"
done
done
fi
# ── ROCm-on-WSL config (install_rocm_wsl_strixhalo.sh) ──
# Remove Unsloth's own ROCDXG config (the env it persisted). The system
# ROCm userspace is a shared prereq (like CUDA) and is LEFT IN PLACE by
# default; set UNSLOTH_UNINSTALL_ROCM=1 to remove it too.
echo "Removing ROCm-on-WSL config..."
_sudo=""
if [ "$_uid" != "0" ] && command -v sudo >/dev/null 2>&1; then _sudo="sudo"; fi
$_sudo rm -f /etc/profile.d/unsloth-rocm-wsl.sh 2>/dev/null || true
if [ -f "$HOME/.bashrc" ] && grep -q "Unsloth ROCm-on-WSL" "$HOME/.bashrc" 2>/dev/null; then
_bk=$(mktemp 2>/dev/null || echo "$HOME/.bashrc.unsloth.tmp")
if sed '/# >>> Unsloth ROCm-on-WSL/,/# <<< Unsloth ROCm-on-WSL/d' "$HOME/.bashrc" > "$_bk" 2>/dev/null; then
cat "$_bk" > "$HOME/.bashrc" 2>/dev/null || true
echo " cleaned ROCm-on-WSL block from ~/.bashrc"
fi
rm -f "$_bk" 2>/dev/null || true
fi
if [ "${UNSLOTH_UNINSTALL_ROCM:-0}" = "1" ]; then
echo " removing system ROCm (UNSLOTH_UNINSTALL_ROCM=1)..."
$_sudo rm -f /etc/apt/sources.list.d/rocm.list /etc/apt/preferences.d/rocm-pin-600 \
/etc/apt/keyrings/rocm.gpg /etc/ld.so.conf.d/rocm.conf 2>/dev/null || true
$_sudo sh -c 'rm -rf /opt/rocm /opt/rocm-*' 2>/dev/null || true
if command -v ldconfig >/dev/null 2>&1; then $_sudo ldconfig 2>/dev/null || true; fi
elif [ -d /opt/rocm ]; then
echo " Note: ROCm userspace (/opt/rocm*) left in place (shared prereq)."
echo " Remove it by re-running with UNSLOTH_UNINSTALL_ROCM=1, or manually:"
echo " sudo rm -rf /opt/rocm /opt/rocm-* && sudo ldconfig"
fi
fi
echo "Removing Linux .desktop entry..."
_remove_path "$HOME/.local/share/applications/unsloth-studio.desktop"
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
fi
;;
esac
echo ""
echo "Unsloth Studio uninstalled."
echo "Note: Hugging Face model cache at ~/.cache/huggingface was left in place."
echo "Remove it manually with 'rm -rf ~/.cache/huggingface/hub' if desired."
# Env-mode installs leave no breadcrumb in $HOME, so a custom root can
# only be located if the user re-exports the variable. Print a hint when
# neither var is set so the bare `curl | sh` flow doesn't silently miss.
if [ -z "${UNSLOTH_STUDIO_HOME:-}" ] && [ -z "${STUDIO_HOME:-}" ]; then
echo ""
echo "If you installed Unsloth Studio with UNSLOTH_STUDIO_HOME or STUDIO_HOME"
echo "pointing at a custom directory, re-run this script with the same variable"
echo "set to also remove that install tree, e.g.:"
echo " UNSLOTH_STUDIO_HOME=/your/path sh -c \"\$(curl -fsSL https://raw.githubusercontent.com/unslothai/unsloth/main/scripts/uninstall.sh)\""
fi

View file

@ -57,7 +57,9 @@ def _git_show(rev: str, path: str) -> str:
def _strip_docstrings(tree: ast.AST) -> ast.AST: def _strip_docstrings(tree: ast.AST) -> ast.AST:
"""Remove docstrings; empty bodies become ``pass`` so unparse stays valid.""" """Remove every string-literal docstring (Module / FunctionDef /
AsyncFunctionDef / ClassDef). Empty body becomes ``pass`` so
ast.unparse stays valid."""
for node in ast.walk(tree): for node in ast.walk(tree):
if isinstance( if isinstance(
node, node,
@ -85,8 +87,9 @@ def _normalize_py(src: str) -> str:
def _strip_shell_comments(s: str) -> str: def _strip_shell_comments(s: str) -> str:
"""Strip shell comments and collapse blank lines. Heuristic: skips lines """Strip pure-comment lines and inline trailing comments from a shell
with an odd quote count (open string).""" snippet, then collapse runs of blank lines. Heuristic only: leaves a
line untouched if it has an odd quote count (open string)."""
out = [] out = []
for line in s.splitlines(): for line in s.splitlines():
stripped = line.lstrip() stripped = line.lstrip()
@ -113,7 +116,9 @@ def _strip_shell_comments(s: str) -> str:
def _normalize_yaml_run_strings(obj: Any) -> Any: def _normalize_yaml_run_strings(obj: Any) -> Any:
"""Strip shell comments from any multi-line string (``run: |`` body).""" """Walk the parsed YAML object; for any multi-line string (i.e. a
``run: |`` script body), strip shell comments. Returns a normalised
copy."""
if isinstance(obj, dict): if isinstance(obj, dict):
return {k: _normalize_yaml_run_strings(v) for k, v in obj.items()} return {k: _normalize_yaml_run_strings(v) for k, v in obj.items()}
if isinstance(obj, list): if isinstance(obj, list):
@ -123,15 +128,12 @@ def _normalize_yaml_run_strings(obj: Any) -> Any:
return obj return obj
def _walk_yaml_diff( def _walk_yaml_diff(b: Any, a: Any, prefix: str = "") -> None:
b: Any,
a: Any,
prefix: str = "",
) -> None:
"""Print a path-keyed summary of the first structural / scalar diff.""" """Print a path-keyed summary of the first structural / scalar diff."""
if type(b) is not type(a): if type(b) is not type(a):
print( print(
f" type-diff at {prefix or '/'}: " f"{type(b).__name__} -> {type(a).__name__}", f" type-diff at {prefix or '/'}: "
f"{type(b).__name__} -> {type(a).__name__}",
) )
return return
if isinstance(b, dict): if isinstance(b, dict):

View file

@ -1,839 +0,0 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Deterministic, scope-aware verifier for import-hoisting / alias-rename refactors.
The risk when moving `from a import b as _b` (or `import b as _b`) to module top
and normalizing `_b` -> `b` is twofold:
1. DANGLING ALIAS - a `_b` reference is left un-normalized; it now resolves to
nothing (NameError) or, worse, to some *other* module-level `_b`.
2. RENAME CLASH - `_b` was an alias on purpose because `b` already meant
something else in that scope; normalizing `_b` -> `b` silently re-points the
reference at the wrong object (no NameError, no pyflakes warning).
This tool parses BEFORE (a git ref, default origin/main) and AFTER (default HEAD)
for each file, builds a real LEGB scope model (functions, classes, lambdas,
comprehensions, global/nonlocal, args, walrus, star-imports), and resolves every
Name load to its binding. It then compares, PER SCOPE:
* UNRESOLVED-NEW : loads that resolve to nothing in AFTER but did in BEFORE
(or are newly present) -> catches dangling aliases.
* TARGET-MISSING : an import *target* (e.g. module `glob`, or
`importlib.metadata.version`) that a function resolved to
in BEFORE but no longer resolves to in AFTER -> catches a
function that lost access to a module it still uses.
Robust to alias renames because it compares the *target*,
not the local name.
* TARGET-CHANGED : a load whose resolved import target differs BEFORE vs
AFTER -> catches a rename that re-points to a different
module (the clash case).
* AMBIGUOUS-BIND : a name bound by BOTH an import and a non-import in the same
scope in AFTER (and not in BEFORE) -> the "alias was on
purpose / now collides" smell.
* MODULE-DUP-IMPORT: a module-level name imported and also defined/assigned at
module level (introduced by the change).
* NEW-UNUSED-IMPORT: a module-level import added in AFTER that nothing resolves
to (informational; re-exports are a known false positive).
Usage:
verify_import_hoist.py [--before REF] [--after REF] <file>... # compare
verify_import_hoist.py --self-test # prove it catches bugs
Exit code 1 if any non-informational finding.
"""
from __future__ import annotations
import argparse
import ast
import builtins
import re as _re_mod
import subprocess
import sys
from dataclasses import dataclass, field
_BUILTINS = set(dir(builtins)) | {
"__file__",
"__name__",
"__doc__",
"__package__",
"__spec__",
"__loader__",
"__builtins__",
"__class__",
"__annotations__",
"__dict__",
"__qualname__",
"__module__",
"__path__",
"__debug__",
"__import__",
"NotImplemented",
"Ellipsis",
"copyright",
"credits",
"license",
"help",
"exit",
"quit",
"__build_class__",
"__cached__",
"reveal_type",
"reveal_locals",
}
# ---------------------------------------------------------------- scope model
@dataclass
class Binding:
kind: str # 'import' | 'importfrom' | 'def' | 'class' | 'other'
target: str | None = None # canonical import target id, else None
@dataclass
class Scope:
kind: str # 'module' | 'function' | 'class' | 'lambda' | 'comp'
qualname: str
parent: "Scope | None"
bindings: dict[str, list[Binding]] = field(default_factory = dict)
globals: set[str] = field(default_factory = set)
nonlocals: set[str] = field(default_factory = set)
star_import: bool = False
def add(self, name: str, b: Binding) -> None:
self.bindings.setdefault(name, []).append(b)
def _import_target(node: ast.AST, alias: ast.alias) -> tuple[str, str]:
"""Return (bound_name, canonical_target_id) for one import alias."""
if isinstance(node, ast.Import):
bound = alias.asname or alias.name.split(".")[0]
return bound, f"import:{alias.name}"
# ImportFrom
bound = alias.asname or alias.name
mod = ("." * (node.level or 0)) + (node.module or "")
return bound, f"from:{mod}:{alias.name}"
class _Builder(ast.NodeVisitor):
"""Builds the scope tree + bindings, and records every (scope, Name-load)."""
def __init__(self):
self.module = Scope("module", "<module>", None)
self.uses: list[tuple[Scope, str, int]] = [] # hard loads
# annotations: count as "used" but never as "unresolved" (forward refs)
self.soft_uses: list[tuple[Scope, str, int]] = []
def _visit_annotation(self, node, scope: Scope) -> None:
"""Record annotation names as SOFT uses: an import used only in an annotation
counts as used, but a forward-ref name is never 'unresolved'."""
if node is None:
return
for n in ast.walk(node):
if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load):
self.soft_uses.append((scope, n.id, n.lineno))
# -- binding helpers --
def _bind_targets(self, scope: Scope, target: ast.AST) -> None:
for n in ast.walk(target):
if isinstance(n, ast.Name) and isinstance(n.ctx, (ast.Store, ast.Del)):
self._bind_name(scope, n.id, Binding("other"))
elif isinstance(n, ast.Starred):
pass
def _bind_name(self, scope: Scope, name: str, b: Binding) -> None:
if name in scope.globals:
self.module.add(name, b)
elif name in scope.nonlocals:
p = scope.parent
while p is not None and p.kind not in ("function", "lambda"):
p = p.parent
(p or self.module).add(name, b)
else:
scope.add(name, b)
# -- generic dispatch within a scope --
def _visit_body(self, stmts, scope: Scope) -> None:
for s in stmts:
self._visit_stmt(s, scope)
def _visit_stmt(self, node: ast.AST, scope: Scope) -> None:
if isinstance(node, (ast.Import, ast.ImportFrom)):
star = isinstance(node, ast.ImportFrom) and any(a.name == "*" for a in node.names)
if star:
scope.star_import = True
for alias in node.names:
if alias.name == "*":
continue
bound, target = _import_target(node, alias)
kind = "import" if isinstance(node, ast.Import) else "importfrom"
self._bind_name(scope, bound, Binding(kind, target))
return
if isinstance(node, ast.Global):
scope.globals.update(node.names)
return
if isinstance(node, ast.Nonlocal):
scope.nonlocals.update(node.names)
return
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
self._bind_name(scope, node.name, Binding("def"))
# decorators / defaults evaluate in the ENCLOSING scope
for d in node.decorator_list:
self._visit_expr(d, scope)
self._visit_arg_defaults(node.args, scope)
child = Scope("function", f"{scope.qualname}.{node.name}", scope)
self._bind_type_params(node, child)
self._bind_args(node.args, child)
# arg + return annotations: soft uses
for a in self._all_args(node.args):
self._visit_annotation(a.annotation, child)
self._visit_annotation(getattr(node, "returns", None), child)
self._visit_body(node.body, child)
return
if isinstance(node, ast.ClassDef):
self._bind_name(scope, node.name, Binding("class"))
for d in node.decorator_list:
self._visit_expr(d, scope)
for b in node.bases:
self._visit_expr(b, scope)
for kw in node.keywords:
self._visit_expr(kw.value, scope)
child = Scope("class", f"{scope.qualname}.{node.name}", scope)
self._bind_type_params(node, child)
self._visit_body(node.body, child)
return
if isinstance(node, ast.Match):
self._visit_expr(node.subject, scope)
for case in node.cases:
self._bind_pattern(case.pattern, scope)
if case.guard is not None:
self._visit_expr(case.guard, scope)
self._visit_body(case.body, scope)
return
if isinstance(node, getattr(ast, "TryStar", ())): # py3.11 except*
self._visit_body(node.body, scope)
for h in node.handlers:
if h.type is not None:
self._visit_expr(h.type, scope)
if h.name:
self._bind_name(scope, h.name, Binding("other"))
self._visit_body(h.body, scope)
self._visit_body(node.orelse, scope)
self._visit_body(node.finalbody, scope)
return
if isinstance(node, getattr(ast, "TypeAlias", ())): # py3.12 `type X = ...`
if isinstance(node.name, ast.Name):
self._bind_name(scope, node.name.id, Binding("other"))
self._visit_annotation(node.value, scope)
return
if isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign)):
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
val = node.value
if val is not None:
self._visit_expr(val, scope)
if isinstance(node, ast.AnnAssign) and node.annotation is not None:
self._visit_annotation(node.annotation, scope)
for t in targets:
self._bind_targets(scope, t)
# AugAssign target is also a load
if isinstance(node, ast.AugAssign):
self._record_loads(t, scope)
return
if isinstance(node, (ast.For, ast.AsyncFor)):
self._visit_expr(node.iter, scope)
self._bind_targets(scope, node.target)
self._visit_body(node.body, scope)
self._visit_body(node.orelse, scope)
return
if isinstance(node, (ast.With, ast.AsyncWith)):
for item in node.items:
self._visit_expr(item.context_expr, scope)
if item.optional_vars is not None:
self._bind_targets(scope, item.optional_vars)
self._visit_body(node.body, scope)
return
if isinstance(node, ast.Try):
self._visit_body(node.body, scope)
for h in node.handlers:
if h.type is not None:
self._visit_expr(h.type, scope)
if h.name:
self._bind_name(scope, h.name, Binding("other"))
self._visit_body(h.body, scope)
self._visit_body(node.orelse, scope)
self._visit_body(node.finalbody, scope)
return
# generic statement: visit all child expressions/stmts in same scope
for child in ast.iter_child_nodes(node):
if isinstance(child, ast.stmt):
self._visit_stmt(child, scope)
else:
self._visit_expr(child, scope)
# -- expressions --
def _visit_arg_defaults(self, args: ast.arguments, scope: Scope) -> None:
for d in list(args.defaults) + [d for d in args.kw_defaults if d is not None]:
self._visit_expr(d, scope)
def _all_args(self, args: ast.arguments) -> list[ast.arg]:
out = list(args.posonlyargs) + list(args.args) + list(args.kwonlyargs)
if args.vararg:
out.append(args.vararg)
if args.kwarg:
out.append(args.kwarg)
return out
def _bind_args(self, args: ast.arguments, scope: Scope) -> None:
for a in self._all_args(args):
scope.add(a.arg, Binding("other"))
def _bind_type_params(self, node, scope: Scope) -> None:
for tp in getattr(node, "type_params", []) or []:
name = getattr(tp, "name", None)
if isinstance(name, str):
scope.add(name, Binding("other"))
self._visit_annotation(getattr(tp, "bound", None), scope)
self._visit_annotation(getattr(tp, "default_value", None), scope)
def _bind_pattern(self, pat, scope: Scope) -> None:
if pat is None:
return
if isinstance(pat, ast.MatchValue):
self._visit_expr(pat.value, scope)
elif isinstance(pat, ast.MatchSingleton):
pass
elif isinstance(pat, ast.MatchSequence):
for p in pat.patterns:
self._bind_pattern(p, scope)
elif isinstance(pat, ast.MatchStar):
if pat.name:
self._bind_name(scope, pat.name, Binding("other"))
elif isinstance(pat, ast.MatchMapping):
for k in pat.keys:
self._visit_expr(k, scope)
for p in pat.patterns:
self._bind_pattern(p, scope)
if pat.rest:
self._bind_name(scope, pat.rest, Binding("other"))
elif isinstance(pat, ast.MatchClass):
self._visit_expr(pat.cls, scope)
for p in pat.patterns:
self._bind_pattern(p, scope)
for p in pat.kwd_patterns:
self._bind_pattern(p, scope)
elif isinstance(pat, ast.MatchAs):
self._bind_pattern(pat.pattern, scope)
if pat.name:
self._bind_name(scope, pat.name, Binding("other"))
elif isinstance(pat, ast.MatchOr):
for p in pat.patterns:
self._bind_pattern(p, scope)
def _record_loads(self, node: ast.AST, scope: Scope) -> None:
for n in ast.walk(node):
if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load):
self.uses.append((scope, n.id, n.lineno))
def _visit_expr(self, node: ast.AST, scope: Scope) -> None:
if isinstance(node, ast.Name):
if isinstance(node.ctx, ast.Load):
self.uses.append((scope, node.id, node.lineno))
elif isinstance(node.ctx, (ast.Store, ast.Del)):
self._bind_name(scope, node.id, Binding("other"))
return
if isinstance(node, ast.Lambda):
self._visit_arg_defaults(node.args, scope)
child = Scope("lambda", f"{scope.qualname}.<lambda>", scope)
self._bind_args(node.args, child)
self._visit_expr(node.body, child)
return
if isinstance(node, (ast.ListComp, ast.SetComp, ast.GeneratorExp, ast.DictComp)):
child = Scope("comp", f"{scope.qualname}.<comp>", scope)
for i, gen in enumerate(node.generators):
# first iterable evaluates in the enclosing scope
self._visit_expr(gen.iter, scope if i == 0 else child)
self._bind_targets(child, gen.target)
for cond in gen.ifs:
self._visit_expr(cond, child)
if isinstance(node, ast.DictComp):
self._visit_expr(node.key, child)
self._visit_expr(node.value, child)
else:
self._visit_expr(node.elt, child)
return
if isinstance(node, ast.NamedExpr): # walrus binds in enclosing scope
self._visit_expr(node.value, scope)
if isinstance(node.target, ast.Name):
self._bind_name(scope, node.target.id, Binding("other"))
return
for child in ast.iter_child_nodes(node):
if isinstance(child, ast.stmt):
self._visit_stmt(child, scope)
else:
self._visit_expr(child, scope)
def run(self, tree: ast.Module) -> None:
self._visit_body(tree.body, self.module)
# ---------------------------------------------------------------- resolution
def _any_star(scope: Scope) -> bool:
c = scope
while c is not None:
if c.star_import:
return True
c = c.parent
return False
def _resolve(scope: Scope, name: str):
"""LEGB resolution. Returns (status, bindings); status in
{'local','import','other','builtin','star','unresolved'}."""
start = scope
if name in scope.globals:
chain = [_module_of(scope)]
elif name in scope.nonlocals:
chain = _enclosing_functions(scope)
else:
chain = _legb_chain(scope)
for i, sc in enumerate(chain):
if sc is None:
continue
if name in sc.bindings:
binds = sc.bindings[name]
if any(b.kind in ("import", "importfrom") for b in binds):
return "import", binds
return "other", binds
if name in _BUILTINS:
return "builtin", []
if _any_star(start):
return "star", []
return "unresolved", []
def _module_of(scope: Scope) -> Scope:
while scope.parent is not None:
scope = scope.parent
return scope
def _enclosing_functions(scope: Scope) -> list[Scope]:
out = []
p = scope.parent
while p is not None:
if p.kind in ("function", "lambda"):
out.append(p)
p = p.parent
out.append(_module_of(scope))
return out
def _legb_chain(scope: Scope) -> list[Scope]:
"""Immediate scope, then enclosing scopes skipping class scopes, then module."""
chain = [scope]
p = scope.parent
while p is not None:
if p.kind != "class" or p.parent is None: # skip class scopes, keep module
if p.kind != "class":
chain.append(p)
p = p.parent
return chain
# ---------------------------------------------------------------- analysis
def _analyze(src: str):
tree = ast.parse(src)
b = _Builder()
b.run(tree)
# Per-scope: unresolved load names + import targets it resolves to.
unresolved: dict[str, set[str]] = {}
targets_by_scope: dict[str, set[str]] = {}
target_by_use: dict[tuple[str, str], set[str]] = {}
for scope, name, _ln in b.uses:
status, binds = _resolve(scope, name)
if status == "unresolved":
unresolved.setdefault(scope.qualname, set()).add(name)
elif status == "import":
tids = {bd.target for bd in binds if bd.target}
targets_by_scope.setdefault(scope.qualname, set()).update(tids)
target_by_use.setdefault((scope.qualname, name), set()).update(tids)
# soft uses (annotations): contribute to "used" only, never "unresolved"
for scope, name, _ln in b.soft_uses:
status, binds = _resolve(scope, name)
if status == "import":
tids = {bd.target for bd in binds if bd.target}
targets_by_scope.setdefault(scope.qualname, set()).update(tids)
# module-level binding info for clash checks
module = b.module
module_imports = {
n: bs
for n, bs in module.bindings.items()
if any(x.kind in ("import", "importfrom") for x in bs)
}
module_dup = {
n
for n, bs in module.bindings.items()
if any(x.kind in ("import", "importfrom") for x in bs)
and any(x.kind not in ("import", "importfrom") for x in bs)
}
# ambiguous: any scope where a name is bound by import AND non-import
ambiguous: dict[str, set[str]] = {}
def walk_scopes(scope: Scope):
for n, bs in scope.bindings.items():
if any(x.kind in ("import", "importfrom") for x in bs) and any(
x.kind not in ("import", "importfrom") for x in bs
):
ambiguous.setdefault(scope.qualname, set()).add(n)
# scope tree isn't stored; approximate with module only.
walk_scopes(module)
return {
"unresolved": unresolved,
"targets_by_scope": targets_by_scope,
"target_by_use": target_by_use,
"module_import_targets": {
n: {x.target for x in bs if x.target} for n, bs in module_imports.items()
},
"module_dup": module_dup,
"ambiguous": ambiguous,
}
def _git_show(ref: str, path: str) -> str | None:
try:
return subprocess.run(
["git", "show", f"{ref}:{path}"], capture_output = True, text = True, check = True
).stdout
except subprocess.CalledProcessError:
return None
def compare(before_src: str, after_src: str, path: str) -> list[tuple[str, str]]:
"""Return list of (severity, message). severity in BLOCKER/WARN/INFO.
Blocker signals (precise, no relocation false-positives):
UNRESOLVED-NEW - a load became undefined (dangling alias / removed import).
NEW-UNUSED-HOIST - a module-level import added by this change is resolved by
NO load (un-normalized alias or wrong rename target).
TARGET-CHANGED - same (scope, name) load resolves to a different import
target before vs after (a same-name re-point).
"""
a = _analyze(before_src)
b = _analyze(after_src)
findings: list[tuple[str, str]] = []
def used_targets(analysis) -> set[str]:
out: set[str] = set()
for tids in analysis["targets_by_scope"].values():
out |= tids
return out
before_used = used_targets(a)
after_used = used_targets(b)
before_module_targets: set[str] = set()
for tids in a["module_import_targets"].values():
before_module_targets |= tids
after_module_targets: set[str] = set()
for tids in b["module_import_targets"].values():
after_module_targets |= tids
added_module_targets = after_module_targets - before_module_targets
# 1. UNRESOLVED-NEW
for scope, names in b["unresolved"].items():
new = names - a["unresolved"].get(scope, set())
for n in sorted(new):
findings.append(
(
"BLOCKER",
f"{path}: UNRESOLVED-NEW '{n}' in scope {scope} "
f"(undefined after change -> dangling alias / removed import)",
)
)
# 2. HOISTED-IMPORT-UNUSED (core botched-hoist / wrong-rename signal)
# A module-level import in AFTER that NO load resolves to, that was either
# newly added by this change OR actually used before. Excludes relocation
# (import removed) and stable pre-existing re-exports.
for n, tids in b["module_import_targets"].items():
if tids & after_used:
continue # resolved -> fine
# `from __future__ import ...` is a compiler directive, not a runtime
# binding: the name (`annotations`, ...) is never loaded, so it can never
# "resolve" to a use. Skip it so a legitimately-added future import
# (e.g. `annotations` for lazy PEP 604 `X | None` on py3.9) is not flagged.
if all(t.startswith("from:__future__:") for t in tids):
continue
newly_added = bool(tids - before_module_targets)
was_used_before = bool(tids & before_used)
if newly_added or was_used_before:
why = (
"added but unused"
if newly_added
else "was used before, now unused (references re-pointed)"
)
findings.append(
(
"BLOCKER",
f"{path}: HOISTED-IMPORT-UNUSED '{n}' ({sorted(tids)}) "
f"{why} -> un-normalized alias or wrong rename target?",
)
)
# 3. TARGET-CHANGED (same scope+name resolves to a different import target)
# Only a *swap* is dangerous: a BEFORE target that is no longer reachable in
# AFTER means a reference was silently re-pointed. A pure superset growth
# (tbefore <= tafter) is the benign `import pkg.subA` + `import pkg.subB`
# case: both statements bind the same top-level name `pkg` to the same
# package object and only *add* submodule attributes (e.g. adding
# `import urllib.error` next to `import urllib.request`). Nothing the name
# resolved to before is lost, so no reference is re-pointed -- skip it.
#
# A deliberate *relocation* is also benign and must not block: when a name
# keeps its spelling but its import source is moved A -> B in THIS diff (the
# old `from A import x` is removed at module level and a new `from B import x`
# is added), the swap is intentional, not a silent re-point to a pre-existing
# different object. This mirrors the relocation tolerance already applied to
# TARGET-MISSING. The dangerous case -- the name now resolving to a target
# that already existed before (shadow/clash) -- is NOT exempted.
removed_module_targets = before_module_targets - after_module_targets
for key, tafter in b["target_by_use"].items():
tbefore = a["target_by_use"].get(key)
if tbefore and tbefore != tafter and (tbefore - tafter):
lost = tbefore - tafter
gained = tafter - tbefore
relocated = lost <= removed_module_targets and gained <= added_module_targets
if relocated:
continue
findings.append(
(
"BLOCKER",
f"{path}: TARGET-CHANGED name '{key[1]}' in {key[0]} "
f"{sorted(tbefore)} -> {sorted(tafter)} (rename re-points module)",
)
)
# 4. MODULE-DUP-IMPORT introduced
for n in sorted(b["module_dup"] - a["module_dup"]):
findings.append(
(
"WARN",
f"{path}: MODULE-DUP-IMPORT '{n}' bound by import AND non-import "
f"at module level (possible clash)",
)
)
# 5. AMBIGUOUS-BIND introduced (module scope)
for scope, names in b["ambiguous"].items():
new = names - a["ambiguous"].get(scope, set())
for n in sorted(new):
findings.append(("WARN", f"{path}: AMBIGUOUS-BIND '{n}' import+non-import in {scope}"))
# 6. TARGET-MISSING (informational): a scope stopped resolving to an import
# target. Real bugs are covered above; remaining cases are relocated code.
for scope, tbefore in a["targets_by_scope"].items():
tafter = b["targets_by_scope"].get(scope, set())
for t in sorted(tbefore - tafter):
relocated = (
""
if t in added_module_targets
else " [target not re-added here -> likely relocated/deleted]"
)
findings.append(("INFO", f"{path}: TARGET-MISSING {t} in scope {scope}{relocated}"))
return findings
# ---------------------------------------------------------------- self-test
_SELF_TESTS = {
"dangling_alias": (
# before: inline aliased import, used as _b
"import os\ndef f():\n import glob as _b\n return _b.glob('*')\n",
# after: hoisted to canonical, but reference NOT normalized -> _b dangles
"import os\nimport glob\ndef f():\n return _b.glob('*')\n",
"BLOCKER",
),
"rename_clash": (
# before: _b is a deliberate alias; `b` already means something else
"import re as _b\nb = 123\ndef f():\n return _b.compile('x'), b\n",
# after: someone normalized _b -> b ; now f().b is the int, re is lost
"import re\nb = 123\ndef f():\n return b.compile('x'), b\n",
"BLOCKER", # TARGET-MISSING from:.. or import:re in f
),
"clean_rename": (
"def f():\n import glob as _g\n return _g.glob('*')\n",
"import glob\ndef f():\n return glob.glob('*')\n",
None, # expect NO blocker
),
"clean_dedup_redundant": (
"import sys\ndef f():\n import sys\n return sys.argv\n",
"import sys\ndef f():\n return sys.argv\n",
None,
),
"from_import_dangling": (
# from-import alias left un-normalized
"def f():\n from importlib.metadata import version as _v\n return _v('x')\n",
"from importlib.metadata import version\ndef f():\n return _v('x')\n",
"BLOCKER",
),
"local_var_clash": (
# _b renamed to b, but b is a LOCAL var in f -> import silently unused
"def f(b):\n import re as _b\n return _b.compile(b)\n",
"import re\ndef f(b):\n return b.compile(b)\n", # 'b' is the param, not the module
"BLOCKER",
),
"substring_safe": (
# correct _copy->copy rename while config_copy var exists: NO false positive
"def f(config):\n"
" import copy as _copy\n"
" config_copy = _copy.deepcopy(config)\n"
" return config_copy\n",
"import copy\n"
"def f(config):\n"
" config_copy = copy.deepcopy(config)\n"
" return config_copy\n",
None,
),
"attr_access_not_a_use": (
# x._b is attribute access, not a use of name _b; removing import _b is fine
"import os\ndef f(x):\n import sys as _b\n return x._b + _b.argv[0]\n",
"import os\nimport sys\ndef f(x):\n return x._b + sys.argv[0]\n",
None,
),
}
def _self_test() -> int:
ok = True
for name, (before, after, expect) in _SELF_TESTS.items():
findings = compare(before, after, f"<{name}>")
blockers = [m for sev, m in findings if sev == "BLOCKER"]
got = "BLOCKER" if blockers else None
passed = got == expect
ok = ok and passed
print(f"[{'PASS' if passed else 'FAIL'}] {name}: expect={expect} got={got}")
for sev, m in findings:
print(f" ({sev}) {m}")
print("\nSELF-TEST:", "ALL PASS" if ok else "FAILURES")
return 0 if ok else 1
def _pyflakes_undefined(path: str) -> set[str] | None:
"""Return the set of names pyflakes reports as 'undefined name' for `path`,
or None if pyflakes failed to run/parse the file."""
try:
proc = subprocess.run(
[sys.executable, "-m", "pyflakes", path], capture_output = True, text = True
)
except Exception:
return None
if "syntax error" in (proc.stdout + proc.stderr).lower():
return None
names = set()
for line in proc.stdout.splitlines():
m = _re_mod.search(r"undefined name '([^']+)'", line)
if m:
names.add(m.group(1))
return names
def audit_files(paths: list[str]) -> int:
"""Single-version robustness audit: confirm the analyzer doesn't crash, then
cross-check its 'unresolved' names against pyflakes. A name the resolver flags
that pyflakes accepts is a tool false positive."""
n_files = n_err = n_fp = n_syntax = 0
fp_detail: dict[str, set[str]] = {}
err_detail: dict[str, str] = {}
for path in paths:
n_files += 1
try:
src = open(path, encoding = "utf-8").read()
except Exception as e: # unreadable
n_err += 1
err_detail[path] = f"read: {e}"
continue
try:
res = _analyze(src)
except SyntaxError:
n_syntax += 1
continue
except Exception as e: # analyzer crash -> robustness bug
n_err += 1
err_detail[path] = f"{type(e).__name__}: {e}"
continue
tool_unresolved = set()
for names in res["unresolved"].values():
tool_unresolved |= names
if not tool_unresolved:
continue
pf = _pyflakes_undefined(path)
if pf is None:
continue # pyflakes couldn't adjudicate; skip cross-check
false_pos = tool_unresolved - pf
if false_pos:
n_fp += 1
fp_detail[path] = false_pos
print(f"audited files : {n_files}")
print(f"syntax-skipped : {n_syntax}")
print(f"analyzer errors : {n_err}")
for p, e in sorted(err_detail.items()):
print(f" ERROR {p}: {e}")
print(f"false-positive files: {n_fp} (resolver flagged a name pyflakes accepts)")
for p, names in sorted(fp_detail.items()):
print(f" FP {p}: {sorted(names)}")
ok = n_err == 0 and n_fp == 0
print(
"\nAUDIT:",
"ROBUST (no crashes, no false positives vs pyflakes)" if ok else "NEEDS WORK (see above)",
)
return 0 if ok else 1
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--before", default = "origin/main")
ap.add_argument("--after", default = "HEAD")
ap.add_argument("--self-test", action = "store_true")
ap.add_argument(
"--audit",
action = "store_true",
help = "single-version robustness audit on filesystem paths",
)
ap.add_argument("files", nargs = "*")
args = ap.parse_args()
if args.self_test:
return _self_test()
if args.audit:
return audit_files(args.files)
any_blocker = False
for path in args.files:
before = _git_show(args.before, path)
after = _git_show(args.after, path)
if after is None:
print(f"SKIP {path}: not found at {args.after}")
continue
if before is None:
before = "" # new file
findings = compare(before, after, path)
blockers = [f for f in findings if f[0] == "BLOCKER"]
warns = [f for f in findings if f[0] == "WARN"]
infos = [f for f in findings if f[0] == "INFO"]
status = "CLEAN" if not blockers and not warns else ("BLOCKERS" if blockers else "WARNINGS")
print(f"\n=== {path}: {status} ===")
for sev, m in blockers + warns + infos:
print(f" [{sev}] {m}")
any_blocker = any_blocker or bool(blockers)
print("\nOVERALL:", "FAIL (blockers found)" if any_blocker else "PASS (no blockers)")
return 1 if any_blocker else 0
if __name__ == "__main__":
sys.exit(main())

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,153 @@
{ {
"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, time\n",
"sys.path.insert(0, \"/content/unsloth/studio/backend\")\n",
"from colab import start\n",
"start()"
]
},
{
"cell_type": "code",
"source": [
"from google.colab import output\n",
"output.serve_kernel_port_as_iframe(8888, height = 1200, width = \"100%\")\n",
"for _ in range(10000): time.sleep(300), print(\"=\", end = \"\")"
],
"metadata": {
"id": "wb9UELh--XzX"
},
"id": "wb9UELh--XzX",
"execution_count": null,
"outputs": []
},
{
"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

@ -4,16 +4,17 @@
""" """
Compatibility shim for Anaconda/conda-forge Python builds. Compatibility shim for Anaconda/conda-forge Python builds.
Anaconda puts distributor metadata between pipes in sys.version, e.g. Anaconda modifies sys.version to include distributor metadata between pipe
'3.12.4 | packaged by Anaconda, Inc. | (main, ...) [MSC ...]'. The regex in characters, e.g. '3.12.4 | packaged by Anaconda, Inc. | (main, ...) [MSC ...]'.
platform._sys_version() can't parse this and raises ValueError (cpython#102396, Python's platform._sys_version() has a hardcoded regex that cannot parse this,
closed as "not planned"). raising ValueError. CPython closed this as "not planned" (cpython#102396).
We seed platform._sys_version_cache so the stdlib parser never sees the bad This module seeds platform._sys_version_cache so the stdlib parser never sees
string, fixing the import chain: the problematic string, fixing the import chain:
structlog -> rich.pretty -> attrs._compat -> platform.python_implementation() structlog -> rich.pretty -> attrs._compat -> platform.python_implementation()
Import before any library that may trigger that chain. Idempotent. Import this module before any library imports that may trigger the above chain.
Safe to import multiple times (no-op if cache is already seeded or no pipes).
""" """
import platform import platform
@ -22,17 +23,18 @@ import sys
def _seed_sys_version_cache() -> None: def _seed_sys_version_cache() -> None:
"""Parse a cleaned sys.version and seed the cache once.""" """One-shot cache prime: parse a cleaned sys.version and seed the cache."""
raw = sys.version raw = sys.version
# Strip paired |...| segments (Anaconda, conda-forge metadata) # Strip paired |...| segments (Anaconda, conda-forge metadata)
cleaned = re.sub(r"\s*\|[^|]*\|\s*", " ", raw).strip() cleaned = re.sub(r"\s*\|[^|]*\|\s*", " ", raw).strip()
# Pipe-strip can leave two consecutive (...) groups; drop the second. # Format B: "ver (build) | label | (build_dup) \n[compiler]"
# After pipe-strip, two consecutive (...) groups remain; drop the second.
cleaned = re.sub(r"(\([^)]*\))\s+\([^)]*\)", r"\1", cleaned) cleaned = re.sub(r"(\([^)]*\))\s+\([^)]*\)", r"\1", cleaned)
if "|" in cleaned: if "|" in cleaned:
# Unpaired pipe left: keep version + everything from "(" onward # Unpaired pipe remaining -- keep version + everything from "(" onward
m = re.match(r"([\w.+]+)\s*", cleaned) m = re.match(r"([\w.+]+)\s*", cleaned)
p = cleaned.find("(") p = cleaned.find("(")
if m and p > 0: if m and p > 0:
@ -41,12 +43,13 @@ def _seed_sys_version_cache() -> None:
if cleaned == raw: if cleaned == raw:
return # Nothing to fix return # Nothing to fix
# Parse the cleaned string through the real stdlib parser
try: try:
result = platform._sys_version(cleaned) result = platform._sys_version(cleaned)
except ValueError: except ValueError:
return # Still unparsable; don't make things worse return # Cleaning didn't produce a parseable string; don't make things worse
# Seed the cache so future calls with the raw string skip parsing # Seed the cache so future calls with the raw string skip parsing entirely
cache = getattr(platform, "_sys_version_cache", None) cache = getattr(platform, "_sys_version_cache", None)
if isinstance(cache, dict): if isinstance(cache, dict):
cache[raw] = result cache[raw] = result

View file

@ -1,397 +0,0 @@
{#-
Gemma 4 chat template (E2B / E4B edge variant), vendored for Unsloth Studio.
Source: google/gemma-4-31B-it HF discussion/PR #118 (adds the preserve_thinking
flag plus null-rendering, string-arguments validation, balanced turn tags, empty
messages handling, and OpenAI image_url/input_audio aliases).
Unsloth-local changes vs PR #118:
1. preserve_thinking defaults to false (see SETUP block below).
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,
google/gemma-4-E4B-it) that omits it; only the 12b/26B-A4B/31B family emits it.
This file matches the E2B/E4B behavior; gemma-4.jinja keeps the larger-model one.
Applied to unsloth/gemma-4-E2B-it-GGUF and unsloth/gemma-4-E4B-it-GGUF so the
embedded GGUF template does not need re-downloading.
-#}
{%- macro format_parameters(properties, required, filter_keys=false) -%}
{%- set standard_keys = ['description', 'type', 'properties', 'required', 'nullable'] -%}
{%- set ns = namespace(found_first=false) -%}
{%- for key, value in properties | dictsort -%}
{%- set add_comma = false -%}
{%- if not filter_keys or key not in standard_keys -%}
{%- if ns.found_first %},{% endif -%}
{%- set ns.found_first = true -%}
{{ key }}:{
{%- if value['description'] -%}
description:<|"|>{{ value['description'] }}<|"|>
{%- set add_comma = true -%}
{%- endif -%}
{%- if value['type'] | upper == 'STRING' -%}
{%- if value['enum'] -%}
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
enum:{{ format_argument(value['enum']) }}
{%- endif -%}
{%- elif value['type'] | upper == 'ARRAY' -%}
{%- if value['items'] is mapping and value['items'] -%}
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
items:{
{%- set ns_items = namespace(found_first=false) -%}
{%- for item_key, item_value in value['items'] | dictsort -%}
{%- if item_value is not none -%}
{%- if ns_items.found_first %},{% endif -%}
{%- set ns_items.found_first = true -%}
{%- if item_key == 'properties' -%}
properties:{
{%- if item_value is mapping -%}
{{- format_parameters(item_value, value['items']['required'] | default([])) -}}
{%- endif -%}
}
{%- elif item_key == 'required' -%}
required:[
{%- for req_item in item_value -%}
<|"|>{{- req_item -}}<|"|>
{%- if not loop.last %},{% endif -%}
{%- endfor -%}
]
{%- elif item_key == 'type' -%}
{%- if item_value is string -%}
type:{{ format_argument(item_value | upper) }}
{%- else -%}
type:{{ format_argument(item_value | map('upper') | list) }}
{%- endif -%}
{%- else -%}
{{ item_key }}:{{ format_argument(item_value) }}
{%- endif -%}
{%- endif -%}
{%- endfor -%}
}
{%- endif -%}
{%- endif -%}
{%- if value['nullable'] %}
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
nullable:true
{%- endif -%}
{%- if value['type'] | upper == 'OBJECT' -%}
{%- if value['properties'] is defined and value['properties'] is mapping -%}
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
properties:{
{{- format_parameters(value['properties'], value['required'] | default([])) -}}
}
{%- elif value is mapping -%}
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
properties:{
{{- format_parameters(value, value['required'] | default([]), filter_keys=true) -}}
}
{%- endif -%}
{%- if value['required'] -%}
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
required:[
{%- for item in value['required'] | default([]) -%}
<|"|>{{- item -}}<|"|>
{%- if not loop.last %},{% endif -%}
{%- endfor -%}
]
{%- endif -%}
{%- endif -%}
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
type:<|"|>{{ value['type'] | upper }}<|"|>}
{%- endif -%}
{%- endfor -%}
{%- endmacro -%}
{%- macro format_function_declaration(tool_data) -%}
declaration:{{- tool_data['function']['name'] -}}{description:<|"|>{{- tool_data['function']['description'] -}}<|"|>
{%- set params = tool_data['function']['parameters'] -%}
{%- if params -%}
,parameters:{
{%- if params['properties'] -%}
properties:{ {{- format_parameters(params['properties'], params['required']) -}} },
{%- endif -%}
{%- if params['required'] -%}
required:[
{%- for item in params['required'] -%}
<|"|>{{- item -}}<|"|>
{{- ',' if not loop.last -}}
{%- endfor -%}
],
{%- endif -%}
{%- if params['type'] -%}
type:<|"|>{{- params['type'] | upper -}}<|"|>}
{%- endif -%}
{%- endif -%}
{%- if 'response' in tool_data['function'] -%}
{%- set response_declaration = tool_data['function']['response'] -%}
,response:{
{%- if response_declaration['description'] -%}
description:<|"|>{{- response_declaration['description'] -}}<|"|>,
{%- endif -%}
{%- if response_declaration['type'] | upper == 'OBJECT' -%}
type:<|"|>{{- response_declaration['type'] | upper -}}<|"|>}
{%- endif -%}
{%- endif -%}
}
{%- endmacro -%}
{%- macro format_argument(argument, escape_keys=True) -%}
{%- if argument is none -%}
{{- 'null' -}}
{%- elif argument is string -%}
{{- '<|"|>' + argument + '<|"|>' -}}
{%- elif argument is boolean -%}
{{- 'true' if argument else 'false' -}}
{%- elif argument is mapping -%}
{{- '{' -}}
{%- set ns = namespace(found_first=false) -%}
{%- for key, value in argument | dictsort -%}
{%- if ns.found_first %},{% endif -%}
{%- set ns.found_first = true -%}
{%- if escape_keys -%}
{{- '<|"|>' + key + '<|"|>' -}}
{%- else -%}
{{- key -}}
{%- endif -%}
:{{- format_argument(value, escape_keys=escape_keys) -}}
{%- endfor -%}
{{- '}' -}}
{%- elif argument is sequence -%}
{{- '[' -}}
{%- for item in argument -%}
{{- format_argument(item, escape_keys=escape_keys) -}}
{%- if not loop.last %},{% endif -%}
{%- endfor -%}
{{- ']' -}}
{%- else -%}
{{- argument -}}
{%- endif -%}
{%- endmacro -%}
{%- macro strip_thinking(text) -%}
{%- set ns = namespace(result='') -%}
{%- for part in text.split('<channel|>') -%}
{%- if '<|channel>' in part -%}
{%- set ns.result = ns.result + part.split('<|channel>')[0] -%}
{%- else -%}
{%- set ns.result = ns.result + part -%}
{%- endif -%}
{%- endfor -%}
{{- ns.result | trim -}}
{%- endmacro -%}
{%- macro format_tool_response_block(tool_name, response) -%}
{{- '<|tool_response>' -}}
{%- if response is mapping -%}
{{- 'response:' + tool_name + '{' -}}
{%- for key, value in response | dictsort -%}
{{- key -}}:{{- format_argument(value, escape_keys=False) -}}
{%- if not loop.last %},{% endif -%}
{%- endfor -%}
{{- '}' -}}
{%- else -%}
{{- 'response:' + tool_name + '{value:' + format_argument(response, escape_keys=False) + '}' -}}
{%- endif -%}
{{- '<tool_response|>' -}}
{%- endmacro -%}
{#- ===== SETUP ===== -#}
{%- set ns = namespace(prev_message_type=None, prev_non_tool_role=None) -%}
{%- set loop_messages = messages -%}
{%- set enable_thinking = enable_thinking | default(false) -%}
{#- Unsloth Studio: preserve_thinking defaults OFF (upstream PR #118 defaults true). -#}
{%- set preserve_thinking = preserve_thinking | default(false) -%}
{{- bos_token -}}
{#- Handle System/Tool Definitions Block -#}
{%- if enable_thinking or tools or (messages and messages[0]['role'] in ['system', 'developer']) -%}
{{- '<|turn>system\n' -}}
{#- Inject Thinking token at the very top of the FIRST system turn -#}
{%- if enable_thinking -%}
{{- '<|think|>\n' -}}
{%- set ns.prev_message_type = 'think' -%}
{%- endif -%}
{%- if messages and messages[0]['role'] in ['system', 'developer'] -%}
{%- if messages[0]['content'] is string -%}
{{- messages[0]['content'] | trim -}}
{%- elif messages[0]['content'] is sequence -%}
{%- for item in messages[0]['content'] -%}
{{- item['text'] | trim + ' '-}}
{%- endfor -%}
{%- endif -%}
{%- set loop_messages = messages[1:] -%}
{%- endif -%}
{%- if tools -%}
{%- for tool in tools %}
{{- '<|tool>' -}}
{{- format_function_declaration(tool) | trim -}}
{{- '<tool|>' -}}
{%- endfor %}
{%- set ns.prev_message_type = 'tool' -%}
{%- endif -%}
{{- '<turn|>\n' -}}
{%- endif %}
{#- Pre-scan: find last user message index for reasoning guard -#}
{%- set ns_turn = namespace(last_user_idx=-1) -%}
{%- for i in range(loop_messages | length) -%}
{%- if loop_messages[i]['role'] == 'user' -%}
{%- set ns_turn.last_user_idx = i -%}
{%- endif -%}
{%- endfor -%}
{#- Loop through messages -#}
{%- for message in loop_messages -%}
{%- if message['role'] != 'tool' -%}
{%- set ns.prev_message_type = None -%}
{%- set role = 'model' if message['role'] == 'assistant' else message['role'] -%}
{#- Detect continuation using tracked state - O(1) instead of O(n) backward scan -#}
{%- set continue_same_model_turn = (role == 'model' and ns.prev_non_tool_role == 'assistant') -%}
{%- if not continue_same_model_turn -%}
{{- '<|turn>' + role + '\n' }}
{%- endif -%}
{#- Render reasoning/reasoning_content as thinking channel (tool-call turns only) -#}
{%- set thinking_text = message.get('reasoning') or message.get('reasoning_content') -%}
{%- set thinking_gate = (loop.index0 > ns_turn.last_user_idx) or preserve_thinking -%}
{%- if thinking_text and thinking_gate and message.get('tool_calls') -%}
{{- '<|channel>thought\n' + thinking_text + '\n<channel|>' -}}
{%- endif -%}
{%- if message.get('tool_calls') -%}
{%- for tool_call in message.get('tool_calls') -%}
{%- set function = tool_call['function'] -%}
{{- '<|tool_call>call:' + function['name'] + '{' -}}
{%- if function['arguments'] is mapping -%}
{%- set ns_args = namespace(found_first=false) -%}
{%- for key, value in function['arguments'] | dictsort -%}
{%- if ns_args.found_first %},{% endif -%}
{%- set ns_args.found_first = true -%}
{{- key -}}:{{- format_argument(value, escape_keys=False) -}}
{%- endfor -%}
{%- elif function['arguments'] is none -%}
{%- else -%}
{{- raise_exception(
"chat_template: tool_calls[].function.arguments must be a "
"JSON object (mapping), not a string. Deserialize arguments "
"before passing to the template."
) -}}
{%- endif -%}
{{- '}<tool_call|>' -}}
{%- endfor -%}
{%- set ns.prev_message_type = 'tool_call' -%}
{%- endif -%}
{%- set ns_tr_out = namespace(flag=false) -%}
{%- if message.get('tool_responses') -%}
{#- Legacy: tool_responses embedded on the assistant message (Google/Gemma native) -#}
{%- for tool_response in message.get('tool_responses') -%}
{{- format_tool_response_block(tool_response['name'] | default('unknown', true), tool_response['response']) -}}
{%- set ns_tr_out.flag = true -%}
{%- set ns.prev_message_type = 'tool_response' -%}
{%- endfor -%}
{%- elif message.get('tool_calls') -%}
{#- OpenAI Chat Completions: forward-scan consecutive role:tool messages -#}
{%- set ns_tool_scan = namespace(stopped=false) -%}
{%- for k in range(loop.index0 + 1, loop_messages | length) -%}
{%- if ns_tool_scan.stopped -%}
{%- elif loop_messages[k]['role'] != 'tool' -%}
{%- set ns_tool_scan.stopped = true -%}
{%- else -%}
{%- set follow = loop_messages[k] -%}
{#- Resolve tool_call_id to function name -#}
{%- set ns_tname = namespace(name=follow.get('name') or 'unknown') -%}
{%- for tc in message.get('tool_calls') -%}
{%- if tc.get('id') == follow.get('tool_call_id') -%}
{%- set ns_tname.name = tc['function']['name'] -%}
{%- endif -%}
{%- endfor -%}
{#- Handle content as string or content-parts array -#}
{%- set tool_body = follow.get('content') -%}
{%- if tool_body is string -%}
{{- format_tool_response_block(ns_tname.name, tool_body) -}}
{%- elif tool_body is sequence and tool_body is not string -%}
{%- set ns_txt = namespace(s='') -%}
{%- for part in tool_body -%}
{%- if part.get('type') == 'text' -%}
{%- set ns_txt.s = ns_txt.s + (part.get('text') | default('')) -%}
{%- endif -%}
{%- endfor -%}
{{- format_tool_response_block(ns_tname.name, ns_txt.s) -}}
{%- for part in tool_body -%}
{%- if part.get('type') in ['image', 'image_url'] -%}
{{- '<|image|>' -}}
{%- elif part.get('type') in ['audio', 'input_audio'] -%}
{{- '<|audio|>' -}}
{%- elif part.get('type') == 'video' -%}
{{- '<|video|>' -}}
{%- endif -%}
{%- endfor -%}
{%- else -%}
{{- format_tool_response_block(ns_tname.name, tool_body) -}}
{%- endif -%}
{%- set ns_tr_out.flag = true -%}
{%- set ns.prev_message_type = 'tool_response' -%}
{%- endif -%}
{%- endfor -%}
{%- endif -%}
{%- set captured_content -%}
{%- if message.get('content') is string -%}
{%- if role == 'model' -%}
{{- strip_thinking(message['content']) -}}
{%- else -%}
{{- message['content'] | trim -}}
{%- endif -%}
{%- elif message.get('content') is sequence -%}
{%- for item in message['content'] -%}
{%- if item.get('type') == 'text' -%}
{%- if role == 'model' -%}
{{- strip_thinking(item['text']) -}}
{%- else -%}
{{- item['text'] | trim -}}
{%- endif -%}
{%- elif item.get('type') in ['image', 'image_url'] -%}
{{- '<|image|>' -}}
{%- elif item.get('type') in ['audio', 'input_audio'] -%}
{{- '<|audio|>' -}}
{%- elif item.get('type') == 'video' -%}
{{- '<|video|>' -}}
{%- endif -%}
{%- endfor -%}
{%- endif -%}
{%- endset -%}
{{- captured_content -}}
{%- set has_content = captured_content | trim | length > 0 -%}
{#- Forward-scan: find next non-tool message role for continuation detection -#}
{%- set next_nt = namespace(role=None, found=false) -%}
{%- for j in range(loop.index0 + 1, loop_messages | length) -%}
{%- if not next_nt.found -%}
{%- if loop_messages[j]['role'] != 'tool' -%}
{%- set next_nt.role = loop_messages[j]['role'] -%}
{%- set next_nt.found = true -%}
{%- endif -%}
{%- endif -%}
{%- endfor -%}
{%- set continues_into_next = (
role == 'model'
and next_nt.role == 'assistant'
and not message.get('tool_calls')
and not ns_tr_out.flag
) -%}
{%- if ns.prev_message_type == 'tool_call' and not ns_tr_out.flag -%}
{{- '<|tool_response>' -}}
{%- elif continues_into_next -%}
{{- '\n' -}}
{%- elif not (ns_tr_out.flag and not has_content) -%}
{{- '<turn|>\n' -}}
{%- endif -%}
{#- Track previous non-tool role for next iteration (avoids O(n) backward scan) -#}
{%- set ns.prev_non_tool_role = message['role'] -%}
{%- endif -%}
{%- endfor -%}
{%- if add_generation_prompt -%}
{%- if ns.prev_message_type != 'tool_response' and ns.prev_message_type != 'tool_call' -%}
{{- '<|turn>model\n' -}}
{%- endif -%}
{#- E2B/E4B do NOT emit an empty thought block when enable_thinking is false
(unlike the 12b/26B-A4B/31B family); see header. -#}
{%- endif -%}

View file

@ -1,397 +0,0 @@
{#-
Gemma 4 chat template, vendored for Unsloth Studio.
Source: google/gemma-4-31B-it HF discussion/PR #118 (adds the preserve_thinking
flag plus null-rendering, string-arguments validation, balanced turn tags, empty
messages handling, and OpenAI image_url/input_audio aliases).
Unsloth-local change: preserve_thinking defaults to false (see SETUP block below).
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.
-#}
{%- macro format_parameters(properties, required, filter_keys=false) -%}
{%- set standard_keys = ['description', 'type', 'properties', 'required', 'nullable'] -%}
{%- set ns = namespace(found_first=false) -%}
{%- for key, value in properties | dictsort -%}
{%- set add_comma = false -%}
{%- if not filter_keys or key not in standard_keys -%}
{%- if ns.found_first %},{% endif -%}
{%- set ns.found_first = true -%}
{{ key }}:{
{%- if value['description'] -%}
description:<|"|>{{ value['description'] }}<|"|>
{%- set add_comma = true -%}
{%- endif -%}
{%- if value['type'] | upper == 'STRING' -%}
{%- if value['enum'] -%}
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
enum:{{ format_argument(value['enum']) }}
{%- endif -%}
{%- elif value['type'] | upper == 'ARRAY' -%}
{%- if value['items'] is mapping and value['items'] -%}
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
items:{
{%- set ns_items = namespace(found_first=false) -%}
{%- for item_key, item_value in value['items'] | dictsort -%}
{%- if item_value is not none -%}
{%- if ns_items.found_first %},{% endif -%}
{%- set ns_items.found_first = true -%}
{%- if item_key == 'properties' -%}
properties:{
{%- if item_value is mapping -%}
{{- format_parameters(item_value, value['items']['required'] | default([])) -}}
{%- endif -%}
}
{%- elif item_key == 'required' -%}
required:[
{%- for req_item in item_value -%}
<|"|>{{- req_item -}}<|"|>
{%- if not loop.last %},{% endif -%}
{%- endfor -%}
]
{%- elif item_key == 'type' -%}
{%- if item_value is string -%}
type:{{ format_argument(item_value | upper) }}
{%- else -%}
type:{{ format_argument(item_value | map('upper') | list) }}
{%- endif -%}
{%- else -%}
{{ item_key }}:{{ format_argument(item_value) }}
{%- endif -%}
{%- endif -%}
{%- endfor -%}
}
{%- endif -%}
{%- endif -%}
{%- if value['nullable'] %}
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
nullable:true
{%- endif -%}
{%- if value['type'] | upper == 'OBJECT' -%}
{%- if value['properties'] is defined and value['properties'] is mapping -%}
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
properties:{
{{- format_parameters(value['properties'], value['required'] | default([])) -}}
}
{%- elif value is mapping -%}
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
properties:{
{{- format_parameters(value, value['required'] | default([]), filter_keys=true) -}}
}
{%- endif -%}
{%- if value['required'] -%}
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
required:[
{%- for item in value['required'] | default([]) -%}
<|"|>{{- item -}}<|"|>
{%- if not loop.last %},{% endif -%}
{%- endfor -%}
]
{%- endif -%}
{%- endif -%}
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
type:<|"|>{{ value['type'] | upper }}<|"|>}
{%- endif -%}
{%- endfor -%}
{%- endmacro -%}
{%- macro format_function_declaration(tool_data) -%}
declaration:{{- tool_data['function']['name'] -}}{description:<|"|>{{- tool_data['function']['description'] -}}<|"|>
{%- set params = tool_data['function']['parameters'] -%}
{%- if params -%}
,parameters:{
{%- if params['properties'] -%}
properties:{ {{- format_parameters(params['properties'], params['required']) -}} },
{%- endif -%}
{%- if params['required'] -%}
required:[
{%- for item in params['required'] -%}
<|"|>{{- item -}}<|"|>
{{- ',' if not loop.last -}}
{%- endfor -%}
],
{%- endif -%}
{%- if params['type'] -%}
type:<|"|>{{- params['type'] | upper -}}<|"|>}
{%- endif -%}
{%- endif -%}
{%- if 'response' in tool_data['function'] -%}
{%- set response_declaration = tool_data['function']['response'] -%}
,response:{
{%- if response_declaration['description'] -%}
description:<|"|>{{- response_declaration['description'] -}}<|"|>,
{%- endif -%}
{%- if response_declaration['type'] | upper == 'OBJECT' -%}
type:<|"|>{{- response_declaration['type'] | upper -}}<|"|>}
{%- endif -%}
{%- endif -%}
}
{%- endmacro -%}
{%- macro format_argument(argument, escape_keys=True) -%}
{%- if argument is none -%}
{{- 'null' -}}
{%- elif argument is string -%}
{{- '<|"|>' + argument + '<|"|>' -}}
{%- elif argument is boolean -%}
{{- 'true' if argument else 'false' -}}
{%- elif argument is mapping -%}
{{- '{' -}}
{%- set ns = namespace(found_first=false) -%}
{%- for key, value in argument | dictsort -%}
{%- if ns.found_first %},{% endif -%}
{%- set ns.found_first = true -%}
{%- if escape_keys -%}
{{- '<|"|>' + key + '<|"|>' -}}
{%- else -%}
{{- key -}}
{%- endif -%}
:{{- format_argument(value, escape_keys=escape_keys) -}}
{%- endfor -%}
{{- '}' -}}
{%- elif argument is sequence -%}
{{- '[' -}}
{%- for item in argument -%}
{{- format_argument(item, escape_keys=escape_keys) -}}
{%- if not loop.last %},{% endif -%}
{%- endfor -%}
{{- ']' -}}
{%- else -%}
{{- argument -}}
{%- endif -%}
{%- endmacro -%}
{%- macro strip_thinking(text) -%}
{%- set ns = namespace(result='') -%}
{%- for part in text.split('<channel|>') -%}
{%- if '<|channel>' in part -%}
{%- set ns.result = ns.result + part.split('<|channel>')[0] -%}
{%- else -%}
{%- set ns.result = ns.result + part -%}
{%- endif -%}
{%- endfor -%}
{{- ns.result | trim -}}
{%- endmacro -%}
{%- macro format_tool_response_block(tool_name, response) -%}
{{- '<|tool_response>' -}}
{%- if response is mapping -%}
{{- 'response:' + tool_name + '{' -}}
{%- for key, value in response | dictsort -%}
{{- key -}}:{{- format_argument(value, escape_keys=False) -}}
{%- if not loop.last %},{% endif -%}
{%- endfor -%}
{{- '}' -}}
{%- else -%}
{{- 'response:' + tool_name + '{value:' + format_argument(response, escape_keys=False) + '}' -}}
{%- endif -%}
{{- '<tool_response|>' -}}
{%- endmacro -%}
{#- ===== SETUP ===== -#}
{%- set ns = namespace(prev_message_type=None, prev_non_tool_role=None) -%}
{%- set loop_messages = messages -%}
{%- set enable_thinking = enable_thinking | default(false) -%}
{#- Unsloth Studio: preserve_thinking defaults OFF (upstream PR #118 defaults true). -#}
{%- set preserve_thinking = preserve_thinking | default(false) -%}
{{- bos_token -}}
{#- Handle System/Tool Definitions Block -#}
{%- if enable_thinking or tools or (messages and messages[0]['role'] in ['system', 'developer']) -%}
{{- '<|turn>system\n' -}}
{#- Inject Thinking token at the very top of the FIRST system turn -#}
{%- if enable_thinking -%}
{{- '<|think|>\n' -}}
{%- set ns.prev_message_type = 'think' -%}
{%- endif -%}
{%- if messages and messages[0]['role'] in ['system', 'developer'] -%}
{%- if messages[0]['content'] is string -%}
{{- messages[0]['content'] | trim -}}
{%- elif messages[0]['content'] is sequence -%}
{%- for item in messages[0]['content'] -%}
{{- item['text'] | trim + ' '-}}
{%- endfor -%}
{%- endif -%}
{%- set loop_messages = messages[1:] -%}
{%- endif -%}
{%- if tools -%}
{%- for tool in tools %}
{{- '<|tool>' -}}
{{- format_function_declaration(tool) | trim -}}
{{- '<tool|>' -}}
{%- endfor %}
{%- set ns.prev_message_type = 'tool' -%}
{%- endif -%}
{{- '<turn|>\n' -}}
{%- endif %}
{#- Pre-scan: find last user message index for reasoning guard -#}
{%- set ns_turn = namespace(last_user_idx=-1) -%}
{%- for i in range(loop_messages | length) -%}
{%- if loop_messages[i]['role'] == 'user' -%}
{%- set ns_turn.last_user_idx = i -%}
{%- endif -%}
{%- endfor -%}
{#- Loop through messages -#}
{%- for message in loop_messages -%}
{%- if message['role'] != 'tool' -%}
{%- set ns.prev_message_type = None -%}
{%- set role = 'model' if message['role'] == 'assistant' else message['role'] -%}
{#- Detect continuation using tracked state - O(1) instead of O(n) backward scan -#}
{%- set continue_same_model_turn = (role == 'model' and ns.prev_non_tool_role == 'assistant') -%}
{%- if not continue_same_model_turn -%}
{{- '<|turn>' + role + '\n' }}
{%- endif -%}
{#- Render reasoning/reasoning_content as thinking channel (tool-call turns only) -#}
{%- set thinking_text = message.get('reasoning') or message.get('reasoning_content') -%}
{%- set thinking_gate = (loop.index0 > ns_turn.last_user_idx) or preserve_thinking -%}
{%- if thinking_text and thinking_gate and message.get('tool_calls') -%}
{{- '<|channel>thought\n' + thinking_text + '\n<channel|>' -}}
{%- endif -%}
{%- if message.get('tool_calls') -%}
{%- for tool_call in message.get('tool_calls') -%}
{%- set function = tool_call['function'] -%}
{{- '<|tool_call>call:' + function['name'] + '{' -}}
{%- if function['arguments'] is mapping -%}
{%- set ns_args = namespace(found_first=false) -%}
{%- for key, value in function['arguments'] | dictsort -%}
{%- if ns_args.found_first %},{% endif -%}
{%- set ns_args.found_first = true -%}
{{- key -}}:{{- format_argument(value, escape_keys=False) -}}
{%- endfor -%}
{%- elif function['arguments'] is none -%}
{%- else -%}
{{- raise_exception(
"chat_template: tool_calls[].function.arguments must be a "
"JSON object (mapping), not a string. Deserialize arguments "
"before passing to the template."
) -}}
{%- endif -%}
{{- '}<tool_call|>' -}}
{%- endfor -%}
{%- set ns.prev_message_type = 'tool_call' -%}
{%- endif -%}
{%- set ns_tr_out = namespace(flag=false) -%}
{%- if message.get('tool_responses') -%}
{#- Legacy: tool_responses embedded on the assistant message (Google/Gemma native) -#}
{%- for tool_response in message.get('tool_responses') -%}
{{- format_tool_response_block(tool_response['name'] | default('unknown', true), tool_response['response']) -}}
{%- set ns_tr_out.flag = true -%}
{%- set ns.prev_message_type = 'tool_response' -%}
{%- endfor -%}
{%- elif message.get('tool_calls') -%}
{#- OpenAI Chat Completions: forward-scan consecutive role:tool messages -#}
{%- set ns_tool_scan = namespace(stopped=false) -%}
{%- for k in range(loop.index0 + 1, loop_messages | length) -%}
{%- if ns_tool_scan.stopped -%}
{%- elif loop_messages[k]['role'] != 'tool' -%}
{%- set ns_tool_scan.stopped = true -%}
{%- else -%}
{%- set follow = loop_messages[k] -%}
{#- Resolve tool_call_id to function name -#}
{%- set ns_tname = namespace(name=follow.get('name') or 'unknown') -%}
{%- for tc in message.get('tool_calls') -%}
{%- if tc.get('id') == follow.get('tool_call_id') -%}
{%- set ns_tname.name = tc['function']['name'] -%}
{%- endif -%}
{%- endfor -%}
{#- Handle content as string or content-parts array -#}
{%- set tool_body = follow.get('content') -%}
{%- if tool_body is string -%}
{{- format_tool_response_block(ns_tname.name, tool_body) -}}
{%- elif tool_body is sequence and tool_body is not string -%}
{%- set ns_txt = namespace(s='') -%}
{%- for part in tool_body -%}
{%- if part.get('type') == 'text' -%}
{%- set ns_txt.s = ns_txt.s + (part.get('text') | default('')) -%}
{%- endif -%}
{%- endfor -%}
{{- format_tool_response_block(ns_tname.name, ns_txt.s) -}}
{%- for part in tool_body -%}
{%- if part.get('type') in ['image', 'image_url'] -%}
{{- '<|image|>' -}}
{%- elif part.get('type') in ['audio', 'input_audio'] -%}
{{- '<|audio|>' -}}
{%- elif part.get('type') == 'video' -%}
{{- '<|video|>' -}}
{%- endif -%}
{%- endfor -%}
{%- else -%}
{{- format_tool_response_block(ns_tname.name, tool_body) -}}
{%- endif -%}
{%- set ns_tr_out.flag = true -%}
{%- set ns.prev_message_type = 'tool_response' -%}
{%- endif -%}
{%- endfor -%}
{%- endif -%}
{%- set captured_content -%}
{%- if message.get('content') is string -%}
{%- if role == 'model' -%}
{{- strip_thinking(message['content']) -}}
{%- else -%}
{{- message['content'] | trim -}}
{%- endif -%}
{%- elif message.get('content') is sequence -%}
{%- for item in message['content'] -%}
{%- if item.get('type') == 'text' -%}
{%- if role == 'model' -%}
{{- strip_thinking(item['text']) -}}
{%- else -%}
{{- item['text'] | trim -}}
{%- endif -%}
{%- elif item.get('type') in ['image', 'image_url'] -%}
{{- '<|image|>' -}}
{%- elif item.get('type') in ['audio', 'input_audio'] -%}
{{- '<|audio|>' -}}
{%- elif item.get('type') == 'video' -%}
{{- '<|video|>' -}}
{%- endif -%}
{%- endfor -%}
{%- endif -%}
{%- endset -%}
{{- captured_content -}}
{%- set has_content = captured_content | trim | length > 0 -%}
{#- Forward-scan: find next non-tool message role for continuation detection -#}
{%- set next_nt = namespace(role=None, found=false) -%}
{%- for j in range(loop.index0 + 1, loop_messages | length) -%}
{%- if not next_nt.found -%}
{%- if loop_messages[j]['role'] != 'tool' -%}
{%- set next_nt.role = loop_messages[j]['role'] -%}
{%- set next_nt.found = true -%}
{%- endif -%}
{%- endif -%}
{%- endfor -%}
{%- set continues_into_next = (
role == 'model'
and next_nt.role == 'assistant'
and not message.get('tool_calls')
and not ns_tr_out.flag
) -%}
{%- if ns.prev_message_type == 'tool_call' and not ns_tr_out.flag -%}
{{- '<|tool_response>' -}}
{%- elif continues_into_next -%}
{{- '\n' -}}
{%- elif not (ns_tr_out.flag and not has_content) -%}
{{- '<turn|>\n' -}}
{%- endif -%}
{#- Track previous non-tool role for next iteration (avoids O(n) backward scan) -#}
{%- set ns.prev_non_tool_role = message['role'] -%}
{%- endif -%}
{%- endfor -%}
{%- if add_generation_prompt -%}
{%- if ns.prev_message_type != 'tool_response' and ns.prev_message_type != 'tool_call' -%}
{{- '<|turn>model\n' -}}
{%- endif -%}
{%- if not enable_thinking -%}
{#- Suppress thinking - but not when awaiting tool responses -#}
{%- if ns.prev_message_type != 'tool_call' -%}
{{- '<|channel>thought\n<channel|>' -}}
{%- endif -%}
{%- endif -%}
{%- endif -%}

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,
@ -284,13 +277,6 @@
"min_p": 0.01, "min_p": 0.01,
"repetition_penalty": 1.0 "repetition_penalty": 1.0
}, },
"minimax-m2.7": {
"temperature": 1.0,
"top_p": 0.95,
"top_k": 40,
"min_p": 0.01,
"repetition_penalty": 1.0
},
"minimax-m2.5": { "minimax-m2.5": {
"temperature": 1.0, "temperature": 1.0,
"top_p": 0.95, "top_p": 0.95,
@ -401,10 +387,10 @@
"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.5", "minimax",
"gpt-oss", "granite-4", "gpt-oss", "granite-4",
"kimi-k2", "kimi", "kimi-k2", "kimi",
"lfm2", "smollm", "olmo", "falcon", "ernie", "seed", "grok", "mimo" "lfm2", "smollm", "olmo", "falcon", "ernie", "seed", "grok", "mimo"

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

@ -2,6 +2,7 @@
# Used for models without specific configurations # Used for models without specific configurations
training: training:
trust_remote_code: false
max_seq_length: 2048 max_seq_length: 2048
# num_epochs: 4 # num_epochs: 4
num_epochs: 0 num_epochs: 0
@ -33,7 +34,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
@ -48,6 +48,7 @@ logging:
log_frequency: 10 log_frequency: 10
inference: inference:
trust_remote_code: false
temperature: 0.7 temperature: 0.7
top_p: 0.95 top_p: 0.95
top_k: -1 top_k: -1

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

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

View file

@ -4,6 +4,7 @@
# added inference parameters from unsloth notebook # added inference parameters from unsloth notebook
training: training:
trust_remote_code: true
max_seq_length: 2048 max_seq_length: 2048
# num_epochs: 4 # num_epochs: 4
num_epochs: 0 num_epochs: 0
@ -35,7 +36,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
@ -49,6 +49,7 @@ logging:
log_frequency: 10 log_frequency: 10
inference: inference:
trust_remote_code: true
temperature: 1.5 temperature: 1.5
min_p: 0.1 min_p: 0.1

View file

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

View file

@ -4,6 +4,7 @@
# added inference parameters from Ollama # added inference parameters from Ollama
training: training:
trust_remote_code: false
max_seq_length: 4096 max_seq_length: 4096
# num_epochs: 4 # num_epochs: 4
num_epochs: 0 num_epochs: 0
@ -35,7 +36,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
@ -45,5 +45,6 @@ logging:
log_frequency: 10 log_frequency: 10
inference: inference:
trust_remote_code: false
temperature: 0 temperature: 0
top_p: 0.9 top_p: 0.9

View file

@ -4,6 +4,7 @@
# added inference parameters from unsloth guides # added inference parameters from unsloth guides
training: training:
trust_remote_code: false
max_seq_length: 4096 max_seq_length: 4096
# num_epochs: 4 # num_epochs: 4
num_epochs: 0 num_epochs: 0
@ -35,7 +36,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
@ -45,6 +45,7 @@ logging:
log_frequency: 10 log_frequency: 10
inference: inference:
trust_remote_code: false
temperature: 1.0 temperature: 1.0
top_k: 64 top_k: 64
top_p: 0.95 top_p: 0.95

View file

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

View file

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

View file

@ -4,6 +4,7 @@
# added inference parameters from unsloth guides # added inference parameters from unsloth guides
training: training:
trust_remote_code: false
max_seq_length: 2048 max_seq_length: 2048
# num_epochs: 4 # num_epochs: 4
num_epochs: 0 num_epochs: 0
@ -35,7 +36,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
@ -45,6 +45,7 @@ logging:
log_frequency: 10 log_frequency: 10
inference: inference:
trust_remote_code: false
temperature: 1.0 temperature: 1.0
top_k: 64 top_k: 64
top_p: 0.95 top_p: 0.95

View file

@ -4,6 +4,7 @@
# added inference parameters from unsloth guides # added inference parameters from unsloth guides
training: training:
trust_remote_code: false
max_seq_length: 2048 max_seq_length: 2048
# num_epochs: 4 # num_epochs: 4
num_epochs: 0 num_epochs: 0
@ -29,7 +30,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
@ -43,6 +43,7 @@ logging:
log_frequency: 10 log_frequency: 10
inference: inference:
trust_remote_code: false
temperature: 1.0 temperature: 1.0
top_k: 64 top_k: 64
top_p: 0.95 top_p: 0.95

View file

@ -4,6 +4,7 @@
# added inference parameters from unsloth guides # added inference parameters from unsloth guides
training: training:
trust_remote_code: false
max_seq_length: 2048 max_seq_length: 2048
# num_epochs: 4 # num_epochs: 4
num_epochs: 0 num_epochs: 0
@ -29,7 +30,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
@ -43,6 +43,7 @@ logging:
log_frequency: 10 log_frequency: 10
inference: inference:
trust_remote_code: false
temperature: 1.0 temperature: 1.0
top_k: 64 top_k: 64
top_p: 0.95 top_p: 0.95

View file

@ -4,6 +4,7 @@
# added inference parameters from unsloth guides # added inference parameters from unsloth guides
training: training:
trust_remote_code: false
max_seq_length: 2048 max_seq_length: 2048
# num_epochs: 2 # num_epochs: 2
num_epochs: 0 num_epochs: 0
@ -29,7 +30,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
@ -43,6 +43,7 @@ logging:
log_frequency: 10 log_frequency: 10
inference: inference:
trust_remote_code: false
temperature: 1.0 temperature: 1.0
top_k: 64 top_k: 64
top_p: 0.95 top_p: 0.95

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