Merge remote-tracking branch 'origin/main' into add-cu128-torch2110-extra

This commit is contained in:
Daniel Han 2026-07-12 07:46:11 +00:00
commit ea33734214
150 changed files with 20422 additions and 3535 deletions

View file

@ -376,7 +376,7 @@ case "$MODE" in
hermes) patch_hermes_tools none
invoke_via_connect "$OUT" -z "$PROMPT" ;;
openclaw) patch_openclaw_agent notools
invoke_via_connect "$OUT" agent --local --agent ci \
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
@ -449,7 +449,7 @@ case "$MODE" in
fi ;;
opencode) invoke_via_connect "$out" run "$prompt" ;;
hermes) invoke_via_connect "$out" -z "$prompt" ;;
openclaw) invoke_via_connect "$out" agent --local --agent ci \
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
@ -527,6 +527,154 @@ case "$MODE" in
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

View file

@ -364,7 +364,9 @@ jobs:
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

View file

@ -471,6 +471,176 @@ jobs:
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

View file

@ -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]

View file

@ -444,6 +444,8 @@ jobs:
python - <<'PY'
import json
import os
import time
import urllib.error
import urllib.request
BASE = os.environ["BASE_URL"]
@ -464,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
@ -938,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
@ -956,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

View file

@ -430,6 +430,8 @@ jobs:
python - <<'PY'
import json
import os
import time
import urllib.error
import urllib.request
BASE = os.environ["BASE_URL"]
@ -450,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
@ -825,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
@ -848,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

View file

@ -634,6 +634,8 @@ jobs:
python - <<'PY'
import json
import os
import time
import urllib.error
import urllib.request
BASE = os.environ["BASE_URL"]
@ -656,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}
@ -1063,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
@ -1082,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", {
@ -1334,42 +1370,75 @@ jobs:
try { Add-MpPreference -ExclusionPath $p -ErrorAction Stop } catch { }
}
- name: Hide Visual Studio + CMake (simulate a host with no build tools)
- name: Prepare no-build-tools simulation
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
# A Program Files dir can hold a transient handle (Defender / MSBuild node)
# so Rename-Item intermittently fails with "Access is denied"; retry to ride it out.
function Rename-WithRetry($Path, $NewName) {
for ($i = 1; $i -le 6; $i++) {
try { Rename-Item -LiteralPath $Path -NewName $NewName -ErrorAction Stop; return }
catch { if ($i -eq 6) { throw }; Start-Sleep -Seconds 3 }
$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('\'))
}
}
}
}
# Rename the Visual Studio install roots (incl. the Installer that holds
# vswhere.exe) so Find-VsBuildTools' vswhere + filesystem scan both miss.
foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) {
if (Test-Path -LiteralPath $d) {
Rename-WithRetry $d ((Split-Path $d -Leaf) + '.vsoff')
Write-Host "Hid VS: $d"
}
# 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)
}
# Surgically rename each cmake executable on PATH (not its parent dir --
# cmake can share a dir with other shims) so Get-Command cmake fails.
$hidden = @()
foreach ($c in (Get-Command cmake -All -ErrorAction SilentlyContinue)) {
if ($c.Source -and (Test-Path -LiteralPath $c.Source)) {
Rename-WithRetry $c.Source ((Split-Path $c.Source -Leaf) + '.off')
$hidden += $c.Source
Write-Host "Hid cmake: $($c.Source)"
}
$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."
}
("HIDDEN_CMAKE=" + ($hidden -join '|')) | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Assert Visual Studio + CMake are genuinely undetectable
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
# 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')) {
@ -1394,6 +1463,10 @@ jobs:
# 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
@ -1480,19 +1553,19 @@ jobs:
[ -n "$CONTENT" ] && [ "$CONTENT" != "null" ] || { echo "::error::empty completion"; exit 1; }
echo "Inference OK without Visual Studio: $CONTENT"
- name: Restore Visual Studio + CMake
- name: Clean no-build-tools simulation
if: always()
shell: pwsh
run: |
foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) {
$off = "$d.vsoff"
if (Test-Path -LiteralPath $off) { Rename-Item -LiteralPath $off -NewName (Split-Path $d -Leaf); Write-Host "Restored $d" }
}
if ($env:HIDDEN_CMAKE) {
foreach ($src in ($env:HIDDEN_CMAKE -split '\|')) {
if ($src -and (Test-Path -LiteralPath "$src.off")) { Rename-Item -LiteralPath "$src.off" -NewName (Split-Path $src -Leaf) }
$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()
@ -1540,21 +1613,34 @@ jobs:
with:
python-version: '3.12'
- name: Hide Visual Studio
- name: Prepare no-build-tools simulation
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
# Retry the rename: a Program Files dir can hold a transient handle that
# makes Rename-Item intermittently fail with "Access is denied".
function Rename-WithRetry($Path, $NewName) {
for ($i = 1; $i -le 6; $i++) {
try { Rename-Item -LiteralPath $Path -NewName $NewName -ErrorAction Stop; return }
catch { if ($i -eq 6) { throw }; Start-Sleep -Seconds 3 }
$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) }
}
}
}
foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) {
if (Test-Path -LiteralPath $d) { Rename-WithRetry $d ((Split-Path $d -Leaf) + '.vsoff'); Write-Host "Hid VS: $d" }
}
$pathParts = $env:Path -split [IO.Path]::PathSeparator |
Where-Object { $_ -and -not $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:
@ -1577,25 +1663,34 @@ jobs:
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
python studio/install_llama_prebuilt.py --resolve-prebuilt latest --output-format json > /tmp/resolve.json || {
echo "::error::resolver exited non-zero"; cat /tmp/resolve.json || true; exit 1; }
cat /tmp/resolve.json
echo "Prebuilt resolver ran with no Visual Studio present."
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: Restore Visual Studio
- name: Clean no-build-tools simulation
if: always()
shell: pwsh
run: |
foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) {
$off = "$d.vsoff"
if (Test-Path -LiteralPath $off) { Rename-Item -LiteralPath $off -NewName (Split-Path $d -Leaf); Write-Host "Restored $d" }
}
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:

View file

@ -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

View file

@ -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:

View file

@ -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
```

View file

@ -469,6 +469,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 {
@ -488,6 +499,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] } } }
}
}
@ -2155,7 +2167,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.7.1" "unsloth-zoo>=2026.7.1" }
$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
@ -2169,7 +2181,7 @@ exit 0
}
}
} else {
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated)" { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.7.1" "unsloth-zoo>=2026.7.1" }
$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
@ -2200,7 +2212,7 @@ exit 0
# ABI-incompatible torchvision/torchaudio on AMD's per-arch index.
$visionSpec = if ($ROCmGfxArch -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" }
$audioSpec = if ($ROCmGfxArch -and $torchaudioFloorMap.ContainsKey($ROCmGfxArch)) { $torchaudioFloorMap[$ROCmGfxArch] } else { "torchaudio" }
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (AMD ROCm)" { uv pip install --python $VenvPython --force-reinstall --index-url $ROCmIndexUrl $torchSpec $visionSpec $audioSpec }
$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) {
# Transient AMD-index failure: fall back to a CPU base so the install
# still completes; Studio setup retries ROCm afterwards.
@ -2209,7 +2221,7 @@ exit 0
# torch (e.g. 2.10.0+rocm on gfx110X/gfx90a) that still satisfies the CPU
# torch>= range, so without it uv would keep the ROCm build and only swap
# the companions -- a mismatched venv the flavor-repair block won't fix.
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (CPU fallback)" { uv pip install --python $VenvPython --force-reinstall "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $TorchIndexUrl }
$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)
@ -2223,7 +2235,7 @@ exit 0
} 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)
@ -2235,7 +2247,7 @@ exit 0
if ($SkipTorch) {
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (no-torch)" { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.7.1" "unsloth-zoo>=2026.7.1" }
$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 }
@ -2247,7 +2259,7 @@ exit 0
}
}
} elseif ($StudioLocalInstall) {
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.7.1" "unsloth-zoo>=2026.7.1" }
$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" }
}
@ -2275,7 +2287,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.7.1" "unsloth>=2026.7.1" --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)
@ -2306,7 +2318,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
@ -2322,7 +2334,7 @@ exit 0
$visionSpec = if ($ROCmGfxArch -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" }
$audioSpec = if ($ROCmGfxArch -and $torchaudioFloorMap.ContainsKey($ROCmGfxArch)) { $torchaudioFloorMap[$ROCmGfxArch] } else { "torchaudio" }
substep "PyTorch flavor mismatch (installed $installedTorchTag, need ROCm) -- reinstalling correct build..." "Yellow"
$torchFixExit = Invoke-InstallCommand { uv pip install --python $VenvPython --force-reinstall --index-url $ROCmIndexUrl $rocmSpec $visionSpec $audioSpec }
$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)
@ -2331,7 +2343,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)

View file

@ -159,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=$?
@ -2190,9 +2196,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() {
@ -2706,7 +2712,7 @@ if [ "$_MIGRATED" = true ]; then
# to prevent transitive torch resolution.
run_install_cmd_retry "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
--reinstall-package unsloth --reinstall-package unsloth-zoo \
"unsloth>=2026.7.1" "unsloth-zoo>=2026.7.1"
"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.
@ -2721,7 +2727,7 @@ if [ "$_MIGRATED" = true ]; then
# 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.7.1" "unsloth-zoo>=2026.7.1" ${_MLX_LM_EXCLUDE_ARG:-}
"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)..."
@ -2744,7 +2750,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
;;
@ -2870,7 +2876,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
@ -2893,18 +2899,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
@ -2925,7 +2931,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.7.1" "unsloth-zoo>=2026.7.1"
"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
@ -2943,7 +2949,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.7.1" "unsloth-zoo>=2026.7.1"
--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..."
@ -2964,7 +2970,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
;;
@ -2975,7 +2981,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.7.1" "unsloth>=2026.7.1" --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..."
@ -2999,14 +3005,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=""
@ -3017,7 +3023,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

View file

@ -73,7 +73,7 @@ triton = [
]
huggingfacenotorch = [
"unsloth_zoo>=2026.7.1",
"unsloth_zoo>=2026.7.2",
"wheel>=0.42.0",
"packaging",
"numpy",
@ -94,7 +94,7 @@ huggingfacenotorch = [
]
huggingface = [
"unsloth[huggingfacenotorch]",
"unsloth_zoo>=2026.7.1",
"unsloth_zoo>=2026.7.2",
"torchvision",
"unsloth[triton]",
]
@ -629,7 +629,7 @@ colab-ampere-torch220 = [
"flash-attn>=2.6.3 ; ('linux' in sys_platform)",
]
colab-new = [
"unsloth_zoo>=2026.7.1",
"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",

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,110 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Standalone free-VRAM probe for the bundled ggml Vulkan backend.
Run in a short-lived subprocess (``python _vulkan_probe.py <bindir>``) so the
Vulkan instance never lives in the long-running backend process. Loads the
bundled ggml Vulkan backend from ``<bindir>`` and prints one
``<idx>\\t<free_bytes>\\t<is_igpu>\\t<total_bytes>`` line per device to stdout.
Indices are ggml's own Vulkan device ordinals, which need not match nvidia-smi
order. ``is_igpu`` (from ggml's device type) is ``1`` for an integrated GPU
sharing system RAM. ``total_bytes`` is the device-local heap; the reader uses
it to reserve absolute headroom on a discrete card (parity with the CUDA/ROCm
fit) and ignores it for an iGPU, whose "VRAM" is shared system RAM.
Uses only the standard library so it stays runnable as a bare script.
"""
import ctypes
import os
import sys
# ggml_backend_dev_type enum (ggml-backend.h): CPU=0, GPU=1, IGPU=2, ...
_GGML_BACKEND_DEVICE_TYPE_IGPU = 2
def _igpu_flags(base, lib, count: int) -> list[bool]:
"""Per-device integrated-GPU flags via ggml's backend registry.
The Vulkan reg enumerates devices in the same order as
``ggml_backend_vk_get_device_memory`` (each context uses ``ctx->device =
i``), so reg index == device ordinal. Returns all-False on any failure so
the reader never over-caps a discrete card.
"""
flags = [False] * count
try:
lib.ggml_backend_vk_reg.restype = ctypes.c_void_p
lib.ggml_backend_vk_reg.argtypes = []
base.ggml_backend_reg_dev_count.restype = ctypes.c_size_t
base.ggml_backend_reg_dev_count.argtypes = [ctypes.c_void_p]
base.ggml_backend_reg_dev_get.restype = ctypes.c_void_p
base.ggml_backend_reg_dev_get.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
base.ggml_backend_dev_type.restype = ctypes.c_int
base.ggml_backend_dev_type.argtypes = [ctypes.c_void_p]
reg = lib.ggml_backend_vk_reg()
if not reg:
return flags
dev_count = base.ggml_backend_reg_dev_count(reg)
for i in range(min(count, dev_count)):
dev = base.ggml_backend_reg_dev_get(reg, i)
if dev:
flags[i] = base.ggml_backend_dev_type(dev) == _GGML_BACKEND_DEVICE_TYPE_IGPU
except Exception:
# Best-effort: any failure degrades to "discrete" so the memory
# readings still get through instead of crashing the probe.
pass
return flags
def main() -> int:
if len(sys.argv) < 2:
return 0
bindir = sys.argv[1]
# Hold add_dll_directory's handle for the rest of main() (the documented
# idiom) so bindir stays on the search path while the sibling ggml DLLs
# resolve below.
_dll_dir = None
if sys.platform == "win32":
base_name, vk_name = "ggml-base.dll", "ggml-vulkan.dll"
try:
_dll_dir = os.add_dll_directory(bindir)
except Exception:
pass
else:
base_name, vk_name = "libggml-base.so", "libggml-vulkan.so"
# RTLD_GLOBAL exposes ggml-base's symbols to ggml-vulkan on POSIX. getattr
# falls back to 0 where the flag doesn't exist (Windows CDLL ignores mode).
_rtld_global = getattr(ctypes, "RTLD_GLOBAL", 0)
try:
base = ctypes.CDLL(os.path.join(bindir, base_name), mode = _rtld_global)
lib = ctypes.CDLL(os.path.join(bindir, vk_name), mode = _rtld_global)
except OSError as e:
print(f"ggml-vulkan load failed: {e}", file = sys.stderr)
return 1
lib.ggml_backend_vk_get_device_count.restype = ctypes.c_int
lib.ggml_backend_vk_get_device_count.argtypes = []
lib.ggml_backend_vk_get_device_memory.restype = None
lib.ggml_backend_vk_get_device_memory.argtypes = [
ctypes.c_int,
ctypes.POINTER(ctypes.c_size_t),
ctypes.POINTER(ctypes.c_size_t),
]
count = lib.ggml_backend_vk_get_device_count()
igpu = _igpu_flags(base, lib, count)
rows = []
for i in range(count):
free, total = ctypes.c_size_t(0), ctypes.c_size_t(0)
lib.ggml_backend_vk_get_device_memory(i, ctypes.byref(free), ctypes.byref(total))
rows.append("%d\t%d\t%d\t%d" % (i, free.value, int(igpu[i]), total.value))
sys.stdout.write("\n".join(rows))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -12,6 +12,43 @@ import json
import logging
from typing import Optional
_THINK_OPEN = "<think>"
_THINK_CLOSE = "</think>"
def detect_think_prefill(prompt: Optional[str], special_tokens = None) -> str:
"""Return the trailing open ``<think>`` prefill of a rendered prompt.
Reasoning templates (Qwen3.6, DeepSeek-R1-style) end the generation
prompt with ``<think>\\n`` so the model starts reasoning immediately.
Because that opening tag is part of the *prompt*, skip_prompt streaming
never emits it, and the frontend's ``<think>``/``</think>`` parser shows
the reasoning as plain text instead of a thinking block. (The GGUF path
is unaffected: llama-server's reasoning parser returns
``reasoning_content``, which gets re-wrapped in think tags.)
Returns the exact prompt tail to re-emit at the start of the generated
stream (e.g. ``"<think>\\n"``), or ``""`` when the prompt does not end
with an open think block, including the ``enable_thinking=False`` case
where templates prefill an already-closed ``<think>\\n\\n</think>``.
``special_tokens`` is the tokenizer's special-token list. If ``</think>``
is one, the streamer's skip_special_tokens strips the model's closing tag,
so re-emitting the open would leave an unclosed block that swallows the
answer. In that case return ``""`` and fall back to plain text.
"""
if not prompt:
return ""
open_idx = prompt.rfind(_THINK_OPEN)
if open_idx == -1:
return ""
tail = prompt[open_idx:]
if _THINK_CLOSE in tail or tail.strip() != _THINK_OPEN:
return ""
if special_tokens and _THINK_CLOSE in set(special_tokens):
return ""
return tail
logger = logging.getLogger(__name__)

View file

@ -1178,13 +1178,22 @@ class InferenceBackend:
add_special_tokens = False,
return_tensors = "pt",
).to(model.device)
prompt_text = input_text
else:
# Text-only path for a vision model
formatted_prompt = self.format_chat_prompt(messages, system_prompt)
inputs = raw_tokenizer(formatted_prompt, return_tensors = "pt").to(model.device)
prompt_text = formatted_prompt
# Stream with TextIteratorStreamer + background thread
try:
from core.inference.chat_template_helpers import detect_think_prefill
# Re-emit an open <think> prefill swallowed by skip_prompt (see
# generate_stream).
think_prefix = detect_think_prefill(
prompt_text, getattr(raw_tokenizer, "all_special_tokens", None)
)
from transformers import TextIteratorStreamer
import threading
@ -1233,7 +1242,11 @@ class InferenceBackend:
thread = threading.Thread(target = generate_fn)
thread.start()
output = ""
output = think_prefix
# Emit the prefilled <think> before the first token so the block
# renders during prompt prefill (which can take seconds).
if think_prefix:
yield think_prefix
from queue import Empty
generation_complete = False
@ -1467,6 +1480,16 @@ class InferenceBackend:
from transformers import TextIteratorStreamer
import threading
from core.inference.chat_template_helpers import detect_think_prefill
# skip_prompt swallows an open <think> prefilled by the template;
# re-emit it so the frontend can render the thinking block.
# gpt-oss emits its own tags via HarmonyTextStreamer.
think_prefix = (
""
if self._is_gpt_oss_model()
else detect_think_prefill(prompt, getattr(tokenizer, "all_special_tokens", None))
)
# gpt-oss models: HarmonyTextStreamer parses the multi-channel
# harmony protocol into <think> tags
@ -1550,7 +1573,11 @@ class InferenceBackend:
thread = threading.Thread(target = generate_fn)
thread.start()
output = ""
output = think_prefix
# Emit the prefilled <think> before the first token so the block
# renders during prompt prefill (which can take seconds).
if think_prefix:
yield think_prefix
from queue import Empty
generation_complete = False

View file

@ -0,0 +1,368 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Admission control for local llama-server generation requests.
The helpers in this module deliberately know nothing about FastAPI, SSE, or the
OpenAI-compatible route shape. They only coordinate how many upstream generation
requests may be active for one llama-server backend and provide a cancellable
FIFO queue for excess requests.
"""
from __future__ import annotations
import asyncio
import os
import threading
from collections import deque
from dataclasses import dataclass
from typing import Deque, Optional
ADMISSION_CONTROL_ENV = "UNSLOTH_OPENAI_COMPAT_ADMISSION_CONTROL"
ADMISSION_QUEUE_TIMEOUT_ENV = "UNSLOTH_OPENAI_COMPAT_ADMISSION_QUEUE_TIMEOUT"
ADMISSION_KEEPALIVE_INTERVAL_ENV = "UNSLOTH_OPENAI_COMPAT_ADMISSION_KEEPALIVE_INTERVAL"
ADMISSION_MAX_QUEUE_ENV = "UNSLOTH_OPENAI_COMPAT_ADMISSION_MAX_QUEUE"
DEFAULT_ADMISSION_ENABLED = True
DEFAULT_ADMISSION_QUEUE_TIMEOUT_S = None
DEFAULT_ADMISSION_KEEPALIVE_INTERVAL_S = 5.0
DEFAULT_ADMISSION_MAX_QUEUE = 64
@dataclass(frozen = True)
class LlamaAdmissionConfig:
enabled: bool = DEFAULT_ADMISSION_ENABLED
queue_timeout_s: Optional[float] = DEFAULT_ADMISSION_QUEUE_TIMEOUT_S
keepalive_interval_s: float = DEFAULT_ADMISSION_KEEPALIVE_INTERVAL_S
max_queue: Optional[int] = DEFAULT_ADMISSION_MAX_QUEUE
@dataclass(frozen = True)
class LlamaAdmissionSnapshot:
key: str
capacity: int
active: int
queued: int
class LlamaAdmissionError(Exception):
def __init__(
self,
message: str,
*,
snapshot: Optional[LlamaAdmissionSnapshot] = None,
):
super().__init__(message)
self.snapshot = snapshot
class LlamaAdmissionQueueFull(LlamaAdmissionError):
pass
class LlamaAdmissionTimeout(LlamaAdmissionError):
pass
class LlamaAdmissionCancelled(LlamaAdmissionError):
pass
def _bool_env(name: str, default: bool) -> bool:
value = os.environ.get(name)
if value is None or not value.strip():
return default
value = value.strip().lower()
if value in {"1", "true", "yes", "on"}:
return True
if value in {"0", "false", "no", "off"}:
return False
return default
def _optional_positive_float_env(name: str, default: Optional[float]) -> Optional[float]:
value = os.environ.get(name)
if value is None or not value.strip():
return default
try:
parsed = float(value.strip())
except ValueError:
return default
return parsed if parsed > 0 else None
def _positive_float_env(name: str, default: float) -> float:
value = os.environ.get(name)
if value is None or not value.strip():
return default
try:
parsed = float(value.strip())
except ValueError:
return default
return parsed if parsed > 0 else default
def _optional_positive_int_env(name: str, default: Optional[int]) -> Optional[int]:
value = os.environ.get(name)
if value is None or not value.strip():
return default
try:
parsed = int(value.strip())
except ValueError:
return default
return parsed if parsed > 0 else None
def llama_admission_config_from_env() -> LlamaAdmissionConfig:
return LlamaAdmissionConfig(
enabled = _bool_env(ADMISSION_CONTROL_ENV, DEFAULT_ADMISSION_ENABLED),
queue_timeout_s = _optional_positive_float_env(
ADMISSION_QUEUE_TIMEOUT_ENV,
DEFAULT_ADMISSION_QUEUE_TIMEOUT_S,
),
keepalive_interval_s = _positive_float_env(
ADMISSION_KEEPALIVE_INTERVAL_ENV,
DEFAULT_ADMISSION_KEEPALIVE_INTERVAL_S,
),
max_queue = _optional_positive_int_env(
ADMISSION_MAX_QUEUE_ENV,
DEFAULT_ADMISSION_MAX_QUEUE,
),
)
@dataclass
class _Waiter:
loop: asyncio.AbstractEventLoop
future: asyncio.Future
cancelled: bool = False
granted_lease: Optional["LlamaAdmissionLease"] = None
class LlamaAdmissionLease:
def __init__(self, queue: Optional["LlamaAdmissionQueue"]):
self._queue = queue
self._released = False
self._release_lock = threading.Lock()
def release(self) -> None:
queue = None
with self._release_lock:
if self._released:
return
self._released = True
queue = self._queue
if queue is not None:
queue.release()
async def __aenter__(self) -> "LlamaAdmissionLease":
return self
async def __aexit__(self, *_args) -> None:
self.release()
class LlamaAdmissionReservation:
def __init__(
self,
*,
queue: Optional["LlamaAdmissionQueue"],
lease: Optional[LlamaAdmissionLease] = None,
waiter: Optional[_Waiter] = None,
snapshot: Optional[LlamaAdmissionSnapshot] = None,
):
self._queue = queue
self._lease = lease
self._waiter = waiter
self.snapshot = snapshot
@property
def is_cancelled(self) -> bool:
return self._lease is None and self._waiter is None
def lease_nowait(self) -> Optional[LlamaAdmissionLease]:
if self._lease is not None:
return self._lease
if self._waiter is None or not self._waiter.future.done():
return None
if self._waiter.future.cancelled():
self._waiter.cancelled = True
self._waiter = None
return None
self._lease = self._waiter.future.result()
self._waiter = None
return self._lease
async def wait(self, timeout_s: float) -> Optional[LlamaAdmissionLease]:
lease = self.lease_nowait()
if lease is not None:
return lease
if self._waiter is None:
return None
waiter = self._waiter
try:
await asyncio.wait_for(asyncio.shield(waiter.future), timeout = timeout_s)
except asyncio.CancelledError:
if waiter.future.cancelled():
waiter.cancelled = True
if self._waiter is waiter:
self._waiter = None
return None
raise
return self.lease_nowait()
def cancel(self) -> None:
lease = self.lease_nowait()
if lease is not None:
lease.release()
self._lease = None
return
if self._queue is not None and self._waiter is not None:
self._queue.cancel(self._waiter)
self._waiter = None
def snapshot_now(self) -> Optional[LlamaAdmissionSnapshot]:
if self._queue is None:
return self.snapshot
return self._queue.snapshot()
class LlamaAdmissionQueue:
def __init__(self, key: str):
self.key = key
self._lock = threading.Lock()
self._active = 0
self._capacity = 1
self._waiters: Deque[_Waiter] = deque()
def reserve(self, *, capacity: int, config: LlamaAdmissionConfig) -> LlamaAdmissionReservation:
capacity = max(1, int(capacity or 1))
if not config.enabled:
return LlamaAdmissionReservation(
queue = None,
lease = LlamaAdmissionLease(None),
snapshot = LlamaAdmissionSnapshot(self.key, capacity, 0, 0),
)
loop = asyncio.get_running_loop()
with self._lock:
self._capacity = capacity
self._prune_waiters_locked()
self._grant_waiters_locked()
if self._active < self._capacity and not self._waiters:
self._active += 1
return LlamaAdmissionReservation(
queue = self,
lease = LlamaAdmissionLease(self),
snapshot = self._snapshot_locked(),
)
if config.max_queue is not None and len(self._waiters) >= config.max_queue:
raise LlamaAdmissionQueueFull(
"llama-server generation queue is full",
snapshot = self._snapshot_locked(),
)
waiter = _Waiter(
loop = loop,
future = loop.create_future(),
)
self._waiters.append(waiter)
return LlamaAdmissionReservation(
queue = self,
waiter = waiter,
snapshot = self._snapshot_locked(),
)
def release(self) -> None:
with self._lock:
if self._active > 0:
self._active -= 1
self._grant_waiters_locked()
def cancel(self, waiter: _Waiter) -> None:
lease_to_release = None
with self._lock:
waiter.cancelled = True
try:
self._waiters.remove(waiter)
except ValueError:
pass
if waiter.granted_lease is not None:
lease_to_release = waiter.granted_lease
waiter.granted_lease = None
if not waiter.future.done():
waiter.loop.call_soon_threadsafe(waiter.future.cancel)
if lease_to_release is not None:
lease_to_release.release()
def snapshot(self) -> LlamaAdmissionSnapshot:
with self._lock:
self._prune_waiters_locked()
return self._snapshot_locked()
def is_idle(self) -> bool:
with self._lock:
self._prune_waiters_locked()
return self._active == 0 and not self._waiters
def _grant_waiters_locked(self) -> None:
self._prune_waiters_locked()
while self._waiters and self._active < self._capacity:
waiter = self._waiters.popleft()
if waiter.cancelled or waiter.future.done():
continue
self._active += 1
lease = LlamaAdmissionLease(self)
waiter.granted_lease = lease
waiter.loop.call_soon_threadsafe(self._deliver_lease, waiter, lease)
def _deliver_lease(self, waiter: _Waiter, lease: LlamaAdmissionLease) -> None:
if waiter.cancelled or waiter.future.done():
waiter.granted_lease = None
if not waiter.future.done():
waiter.future.cancel()
lease.release()
return
try:
waiter.future.set_result(lease)
waiter.granted_lease = None
except asyncio.InvalidStateError:
waiter.granted_lease = None
lease.release()
def _prune_waiters_locked(self) -> None:
self._waiters = deque(
waiter for waiter in self._waiters if not waiter.cancelled and not waiter.future.done()
)
def _snapshot_locked(self) -> LlamaAdmissionSnapshot:
return LlamaAdmissionSnapshot(
key = self.key,
capacity = self._capacity,
active = self._active,
queued = len(self._waiters),
)
_QUEUES_LOCK = threading.Lock()
_QUEUES: dict[str, LlamaAdmissionQueue] = {}
def get_llama_admission_queue(key: str) -> LlamaAdmissionQueue:
with _QUEUES_LOCK:
queue = _QUEUES.get(key)
if queue is None:
queue = LlamaAdmissionQueue(key)
_QUEUES[key] = queue
# base_url carries a fresh ephemeral port on every model load, so
# each load registers a new key. Drop the now-idle queues from prior
# loads so the registry can't grow without bound on a long-running
# server. Queues with in-flight requests are kept until they drain.
for stale_key in [k for k in _QUEUES if k != key and _QUEUES[k].is_idle()]:
del _QUEUES[stale_key]
return queue
def reset_llama_admission_queues() -> None:
with _QUEUES_LOCK:
_QUEUES.clear()

View file

@ -67,7 +67,6 @@ from core.tool_healing import (
_strip_bracket_tag_calls,
apply_tool_strip_patterns,
strip_outside_think,
strip_tool_call_markup,
)
from utils.native_path_leases import child_env_without_native_path_secret
from utils.hf_xet_fallback import hf_hub_download_with_xet_fallback
@ -101,6 +100,10 @@ class LlamaServerNotFoundError(RuntimeError):
Subclasses RuntimeError so existing handlers still catch it."""
class _LlamaStreamCancelled(Exception):
"""Internal signal for an expected client/request cancellation."""
# Shared so the from_identifier preflight and the load-time raise stay in sync.
LLAMA_SERVER_NOT_FOUND_DETAIL = (
"This is a GGUF model, but the llama.cpp runtime (llama-server) is not "
@ -378,6 +381,19 @@ def _probe_dns_dead(host: str = "huggingface.co", timeout: float = 2.0) -> bool:
return True if result[0] is None else result[0]
def _hf_env_offline() -> bool:
"""True when an HF offline env var is set to any truthy value (1/true/yes/on).
Mirrors utils.models.model_config._env_offline so a user-set HF_HUB_OFFLINE=true
(not just "1") still routes through the local-cache reuse path below.
"""
try:
from utils.models.model_config import _env_offline
return _env_offline()
except Exception:
return os.environ.get("HF_HUB_OFFLINE", "").strip().lower() in {"1", "true", "yes", "on"}
@contextlib.contextmanager
def _hf_offline_if_dns_dead():
"""Set HF_HUB_OFFLINE for this block only when DNS to huggingface.co fails;
@ -838,6 +854,112 @@ def _gguf_snapshot_files(snapshot: Path) -> list[str]:
]
def _cached_hf_snapshot_file(
repo_id: str,
filename: str,
*,
expected_size: Optional[int] = None,
) -> Optional[str]:
"""Return a cached snapshot file even when HF's current-ref probe misses it."""
if not filename:
return None
parts = [part for part in filename.replace("\\", "/").split("/") if part]
if not parts or any(part in (".", "..") for part in parts):
return None
try:
from utils.models.model_config import _iter_hf_cache_snapshots
for snap in _iter_hf_cache_snapshots(repo_id):
candidate = snap.joinpath(*parts)
if not candidate.is_file():
continue
if expected_size:
try:
if candidate.stat().st_size < expected_size:
continue
except OSError:
continue
return str(candidate)
except Exception as e:
logger.debug("Snapshot cache lookup failed for %s/%s: %s", repo_id, filename, e)
return None
def _snapshot_has_all_shards(
main_path: str, main_filename: str, shards: Iterable[str], expected_sizes: dict[str, int]
) -> bool:
"""True when every shard sits beside ``main_path`` in the same cache snapshot.
llama.cpp loads a split GGUF by resolving its siblings from the main shard's
directory, so a cached main shard is only safe to reuse when the rest of the
set is co-located; otherwise the caller must fetch the whole set together.
"""
root = Path(main_path)
for _ in [part for part in main_filename.replace("\\", "/").split("/") if part]:
root = root.parent
for shard in shards:
parts = [part for part in shard.replace("\\", "/").split("/") if part]
if not parts or any(part in (".", "..") for part in parts):
return False
sibling = root.joinpath(*parts)
try:
if not sibling.is_file():
return False
expected = expected_sizes.get(shard)
if expected and sibling.stat().st_size < expected:
return False
except OSError:
return False
return True
def _resolve_repo_id_casing(hf_repo: str) -> str:
"""Map a requested repo id to its cached canonical casing, or return it unchanged.
A case-variant request (for example a lowercased id) resolves to the
canonical-cased cache directory so the main GGUF and its companions
(mmproj / MTP drafter) all read the same cache entry. Returns ``hf_repo``
unchanged when resolution is unavailable or errors.
"""
try:
from utils.paths import resolve_cached_repo_id_case
return resolve_cached_repo_id_case(hf_repo)
except Exception:
return hf_repo
def _cached_colocated_split_main(
repo_id: str, main_filename: str, shards: Iterable[str], expected_sizes: dict[str, int]
) -> Optional[str]:
"""Main-shard path from a cache snapshot that also holds every sibling shard.
A newer snapshot may hold only the first shard while an older snapshot has the
complete split set. ``_cached_hf_snapshot_file`` would return that newer partial
main and the co-location check would then force a refetch, so scan snapshots for
one where the whole set is present and return that main path instead. None when
no snapshot holds the full set.
"""
main_parts = [part for part in main_filename.replace("\\", "/").split("/") if part]
if not main_parts or any(part in (".", "..") for part in main_parts):
return None
try:
from utils.models.model_config import _iter_hf_cache_snapshots
for snap in _iter_hf_cache_snapshots(repo_id):
main_path = snap.joinpath(*main_parts)
if not main_path.is_file():
continue
expected_main = expected_sizes.get(main_filename)
try:
if expected_main and main_path.stat().st_size < expected_main:
continue
except OSError:
continue
if _snapshot_has_all_shards(str(main_path), main_filename, shards, expected_sizes):
return str(main_path)
except Exception as e:
logger.debug("Co-located split snapshot lookup failed for %s: %s", repo_id, e)
return None
def _gguf_extra_shards(files: Iterable[str], first_shard: str) -> list[str]:
m = _SHARD_FULL_RE.match(first_shard)
if not m:
@ -1318,6 +1440,50 @@ def _backfill_usage_from_timings(usage, timings):
return out
def _vulkan_lib_filename() -> str:
return "ggml-vulkan.dll" if sys.platform == "win32" else "libggml-vulkan.so"
# Host RAM to leave free on an integrated GPU, matching llama.cpp's own --fit
# margin (default 1024 MiB per device). ggml reports an iGPU's "VRAM" as shared
# system RAM, so hold back the same margin rather than inventing a larger one.
_IGPU_HOST_RESERVE_MIB = 1024
def _apply_igpu_host_reserve_mib(free_mib: int, is_igpu: bool) -> int:
"""Reserve host headroom on an integrated (shared-memory) Vulkan GPU.
An iGPU's reported free "VRAM" is really free system RAM, so sizing
context/offload against all of it would push the host into swap or the OOM
killer. Leave the same margin llama.cpp's --fit uses. ``is_igpu`` comes from
ggml's device type, so a discrete card is never touched; only ever reduces.
"""
if not is_igpu:
return free_mib
return max(0, free_mib - _IGPU_HOST_RESERVE_MIB)
def _llama_lib_dir(binary: str) -> Path:
# The installer exposes llama-server as a top-level entrypoint into build/bin/,
# where the ggml backend libs live, so callers looking for sibling libs (Vulkan
# detection, LD_LIBRARY_PATH, probe bindir) need the real dir. It is normally a
# symlink (resolve() reaches build/bin), but create_exec_entrypoint falls back to
# a shell wrapper (exec "$(dirname "$0")/build/bin/llama-server" "$@") when it
# cannot symlink, and resolve() stops at the wrapper file. Follow the wrapper's
# exec target too, so a wrapper-based install still finds build/bin.
resolved = Path(binary).resolve()
try:
with open(resolved, "rb") as _f:
_head = _f.read(256)
if _head.startswith(b"#!"):
_m = re.search(r'exec "\$\(dirname "\$0"\)/([^"]+)"', _head.decode("utf-8", "ignore"))
if _m:
return (resolved.parent / _m.group(1)).resolve().parent
except OSError:
pass
return resolved.parent
def _is_external_link(path: Path) -> bool:
"""True when ``path`` is a --with-llama-cpp-dir local link: a POSIX symlink
or a Windows directory junction / reparse point. Such a link resolves into
@ -1375,6 +1541,7 @@ class LlamaCppBackend:
self._context_length: Optional[int] = None
self._effective_context_length: Optional[int] = None
self._max_context_length: Optional[int] = None
self._effective_parallel_slots: int = 1
self._chat_template: Optional[str] = None
self._chat_template_override: Optional[str] = None
self._supports_reasoning: bool = False
@ -1560,6 +1727,15 @@ class LlamaCppBackend:
"""Return the effective context length the server is running at."""
return self._effective_context_length or self._context_length
@property
def effective_parallel_slots(self) -> int:
"""Return the serving-slot count the active llama-server actually uses."""
try:
slots = int(getattr(self, "_effective_parallel_slots", 1))
except (TypeError, ValueError):
slots = 1
return max(1, slots)
@property
def max_context_length(self) -> Optional[int]:
"""Return the largest context that fits on this hardware at load time.
@ -1576,6 +1752,16 @@ class LlamaCppBackend:
"""Return the model's native context length from GGUF metadata."""
return self._context_length
def _commit_effective_parallel_slots(self, n_parallel: int) -> None:
try:
slots = int(n_parallel)
except (TypeError, ValueError):
slots = 1
self._effective_parallel_slots = max(1, slots)
def _reset_effective_parallel_slots(self) -> None:
self._effective_parallel_slots = 1
@staticmethod
def _read_rss_bytes(pid: int) -> Optional[int]:
"""Resident set size of ``pid`` in bytes, from /proc/<pid>/status (Linux).
@ -1781,6 +1967,13 @@ class LlamaCppBackend:
return False
return self._supports_tools
@property
def supports_tool_passthrough(self) -> bool:
# supports_tools is forced off for DiffusionGemma (its agentic loop drops the
# per-step canvas frames), but client passthrough skips that loop, so it uses
# the real _supports_tools.
return self._supports_tools
@property
def cache_type_kv(self) -> Optional[str]:
return self._cache_type_kv
@ -2153,6 +2346,30 @@ class LlamaCppBackend:
return total
@staticmethod
def _is_vulkan_backend(binary: Optional[str] = None) -> bool:
"""True if the installed llama.cpp build is Vulkan-only.
The official prebuilts are single-backend, so the Vulkan ggml lib next
to llama-server identifies a Vulkan build. Keeps the free-memory probe
and GPU pin in ggml's Vulkan device-index space. For a custom
multi-backend build with a CUDA or HIP ggml lib alongside Vulkan, defer
to that backend (torch-usable, better-understood probe/pin).
"""
binary = binary or LlamaCppBackend._find_llama_server_binary()
if not binary:
return False
lib_dir = _llama_lib_dir(binary)
if not (lib_dir / _vulkan_lib_filename()).is_file():
return False
for _backend in ("cuda", "hip"):
sibling = (
f"ggml-{_backend}.dll" if sys.platform == "win32" else f"libggml-{_backend}.so"
)
if (lib_dir / sibling).is_file():
return False
return True
@staticmethod
def _resolve_visible_physical_ids() -> Optional[list[int]]:
"""Physical GPU ids behind the active visibility mask (HIP/ROCR/CUDA on
@ -2315,11 +2532,42 @@ class LlamaCppBackend:
return True
@staticmethod
def _get_gpu_free_memory() -> list[tuple[int, int]]:
def _visible_devices_mask(env_name: str) -> Optional[set[int]]:
"""Physical indices a ``*_VISIBLE_DEVICES`` mask permits, or None if unset.
``if x.strip()`` filters trailing-comma masks ("0,1,"); an empty mask
("") yields an empty set (all devices hidden), distinct from an unset
var (None, no mask). Used by the nvidia-smi probe.
"""
raw = os.environ.get(env_name)
if raw is None:
return None
try:
return set(int(x.strip()) for x in raw.split(",") if x.strip())
except ValueError:
return None
@staticmethod
def _vulkan_pin_args(gpu_indices: Optional[Iterable[int]]) -> list[str]:
"""``--device Vulkan<i>,...`` to pin a Vulkan launch to selected GPUs.
The indices are ggml's compact Vulkan ordinals (as _get_gpu_free_memory
reports and the registry names ``Vulkan<i>``). Pin by that name, NOT via
GGML_VK_VISIBLE_DEVICES: ggml parses that env var in the raw
vkEnumeratePhysicalDevices space (before dropping CPU/llvmpipe devices
and deduplicating ICDs), so a compact ordinal there could select a
different physical device or the CPU rasterizer.
"""
if not gpu_indices:
return []
return ["--device", ",".join(f"Vulkan{i}" for i in gpu_indices)]
@staticmethod
def _get_gpu_free_memory(binary: Optional[str] = None) -> list[tuple[int, int]]:
"""Query free memory per GPU. Returns ``(gpu_index, free_mib)`` sorted by
index; empty if no supported GPU is reachable. Thin wrapper over
``_get_gpu_memory`` for callers that only need free VRAM."""
return [(idx, free) for idx, free, _total in LlamaCppBackend._get_gpu_memory()]
return [(idx, free) for idx, free, _total in LlamaCppBackend._get_gpu_memory(binary)]
@staticmethod
def _apple_metal_memory_budget_bytes() -> int:
@ -2350,7 +2598,7 @@ class LlamaCppBackend:
return int(rec_bytes * _APPLE_UNIFIED_MEMORY_FRACTION)
@staticmethod
def _get_gpu_memory() -> list[tuple[int, int, int]]:
def _get_gpu_memory(binary: Optional[str] = None) -> list[tuple[int, int, int]]:
"""Query free AND total memory per GPU.
Order:
@ -2362,9 +2610,18 @@ class LlamaCppBackend:
probe returned [] on AMD) and NVIDIA hosts missing
``nvidia-smi`` from PATH.
On a Vulkan build the ggml Vulkan probe is authoritative, so the indices
are ggml's compact Vulkan ordinals (the space the pin selects via
``--device Vulkan<i>``). It reports ``total`` for discrete cards and 0
for an iGPU (shared RAM) so the fit falls back to free*frac there.
Otherwise nvidia-smi / torch cover NVIDIA + AMD ROCm.
Returns (gpu_index, free_mib, total_mib) sorted by index; empty if no
supported GPU is reachable. ``total`` lets the fit reserve absolute headroom.
supported GPU is reachable.
"""
binary = binary or LlamaCppBackend._find_llama_server_binary()
if LlamaCppBackend._is_vulkan_backend(binary):
return LlamaCppBackend._get_gpu_free_memory_vulkan(binary)
# ── NVIDIA via nvidia-smi ────────────────────────────────────
try:
result = subprocess.run(
@ -2380,16 +2637,7 @@ class LlamaCppBackend:
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode == 0:
allowed: Optional[set[int]] = None
cvd = os.environ.get("CUDA_VISIBLE_DEVICES")
if cvd is not None:
try:
# `if x.strip()` filters trailing-comma masks ("0,1,").
# Empty mask (CVD="") yields an empty set -> all GPUs
# filtered out, per codebase convention.
allowed = set(int(x.strip()) for x in cvd.split(",") if x.strip())
except ValueError:
pass
allowed = LlamaCppBackend._visible_devices_mask("CUDA_VISIBLE_DEVICES")
gpus: list[tuple[int, int, int]] = []
for line in result.stdout.strip().splitlines():
parts = [p.strip() for p in line.split(",")]
@ -2454,6 +2702,91 @@ class LlamaCppBackend:
logger.debug(f"torch GPU probe failed: {e}")
return []
@staticmethod
def _get_gpu_free_memory_vulkan(binary: Optional[str] = None) -> list[tuple[int, int, int]]:
"""Query free (and total) VRAM per device via the bundled ggml Vulkan backend.
Loads ``libggml-vulkan`` in a short-lived subprocess (no Vulkan instance
in this process) and returns (device_index, free_mib, total_mib) sorted
by index. The index is ggml's compact Vulkan ordinal -- the one the
registry names ``Vulkan<index>`` and load_model pins with ``--device``,
NOT the raw ``GGML_VK_VISIBLE_DEVICES`` space. A user-set
``GGML_VK_VISIBLE_DEVICES`` is honored by ggml (passed through), so the
list already reflects it. iGPUs leave a host-RAM margin (see
``_apply_igpu_host_reserve_mib``) and report total 0; discrete cards pass
their real total through. [] when no Vulkan build or device is reachable.
"""
binary = binary or LlamaCppBackend._find_llama_server_binary()
if not binary:
return []
binary_dir = _llama_lib_dir(binary)
if not (binary_dir / _vulkan_lib_filename()).is_file():
return []
env = child_env_without_native_path_secret()
# Pass any inherited GGML_VK_VISIBLE_DEVICES through to ggml unchanged so
# the probe enumerates the same device list the launch will, named
# Vulkan0..N in the compact order reported here and pinned by that name
# via --device -- probe, mask, and pin stay in one index space. Do NOT
# filter the mask in Python: ggml parses the env var in raw
# vkEnumeratePhysicalDevices space while this probe reports the compact
# post-filter ordinal, so a Python filter would compare mismatched spaces.
if sys.platform != "win32":
# Let the loader resolve sibling ggml libs next to the binary.
existing_ld = env.get("LD_LIBRARY_PATH", "")
env["LD_LIBRARY_PATH"] = (
f"{binary_dir}:{existing_ld}" if existing_ld else str(binary_dir)
)
probe_script = Path(__file__).with_name("_vulkan_probe.py")
try:
result = subprocess.run(
[sys.executable, str(probe_script), str(binary_dir)],
capture_output = True,
text = True,
timeout = 15,
env = env,
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode != 0:
logger.debug(
f"vulkan GPU probe exited {result.returncode}: {result.stderr.strip()}"
)
return []
except Exception as e:
logger.debug(f"vulkan GPU probe failed: {e}")
return []
gpus: list[tuple[int, int, int]] = []
for line in result.stdout.strip().splitlines():
parts = line.split("\t")
if len(parts) != 4:
continue
try:
idx = int(parts[0])
free_mib = int(parts[1]) // (1024 * 1024)
is_igpu = parts[2] == "1"
# iGPU "total" is shared RAM, not a VRAM budget -> keep 0 so the
# fit stays on free*frac (the host reserve below is its
# headroom); a discrete card passes its real total through.
total_mib = 0 if is_igpu else int(parts[3]) // (1024 * 1024)
except ValueError:
continue
capped = _apply_igpu_host_reserve_mib(free_mib, is_igpu)
if capped < free_mib:
logger.info(
f"Vulkan device VK{idx} is an integrated GPU sharing system "
f"RAM; reserving {free_mib - capped}MiB host headroom "
f"({free_mib}->{capped}MiB usable)"
)
gpus.append((idx, capped, total_mib))
gpus.sort(key = lambda g: g[0])
if gpus:
logger.info(
"Vulkan GPU memory detected: "
+ ", ".join(f"VK{idx}={free}MiB" for idx, free, _total in gpus)
)
return gpus
@staticmethod
def _available_system_memory_mib() -> Optional[int]:
"""Available system RAM in MiB (psutil, then /proc/meminfo), or None if
@ -2682,7 +3015,8 @@ class LlamaCppBackend:
def _llama_server_env_for_binary(binary: str) -> dict[str, str]:
"""Build a subprocess env that lets llama-server resolve native libs."""
env = child_env_without_native_path_secret()
binary_dir = str(Path(binary).parent)
# _llama_lib_dir resolves the llama-server symlink to the real build/bin.
binary_dir = str(_llama_lib_dir(binary))
if sys.platform == "win32":
# Ordering: see _build_windows_path_dirs. #5106.
@ -3846,7 +4180,11 @@ class LlamaCppBackend:
# Auto-size (0): the visual server probes the largest context that fits this GPU's VRAM
# (capped at the training context). An explicit in-range n_ctx overrides it.
maxtok = n_ctx if (n_ctx and 0 < n_ctx <= 65536) else 0
gpu = os.environ.get("DG_GPU", "0")
# No visible CUDA GPU: a genuine CPU host, or a GPU host masked with
# CUDA_VISIBLE_DEVICES="" to force CPU serving. Keep the visual-server child
# CPU-masked (empty --gpu) so the shim does not re-expose GPU 0 via its default.
cpu_only = self._effective_gpu_count() == 0
gpu = "" if cpu_only else os.environ.get("DG_GPU", "0")
cmd = list(shim_cmd) + [
"--gguf",
@ -3866,6 +4204,12 @@ class LlamaCppBackend:
# refuses to load unless UNSLOTH_IS_PRESENT is set (normally by `import
# unsloth`). The shim never imports unsloth, so set it here as unsloth does.
env["UNSLOTH_IS_PRESENT"] = "1"
# The shim's `import unsloth_zoo` aborts in get_device_type() ("needs a GPU")
# when no accelerator is visible, even though it only drives the CPU
# visual-server binary and does no torch GPU work. Allow the CPU device so the
# runner starts; the visual server still runs on the CPU llama.cpp build.
if cpu_only:
env.setdefault("UNSLOTH_ALLOW_CPU", "1")
env["DG_VISUAL_BIN"] = visual_bin
env["DG_GPU"] = gpu
# The file-override shim imports its sibling visual_engine; put its dir on PYTHONPATH.
@ -3986,6 +4330,15 @@ class LlamaCppBackend:
"Install it with: pip install huggingface_hub"
)
resolved_hf_repo = _resolve_repo_id_casing(hf_repo)
if resolved_hf_repo != hf_repo:
logger.info(
"Using cached repo_id casing '%s' for requested '%s'",
resolved_hf_repo,
hf_repo,
)
hf_repo = resolved_hf_repo
# Resolve the filename from the variant
gguf_filename = None
gguf_extra_shards: list[str] = []
@ -4031,10 +4384,12 @@ class LlamaCppBackend:
# Check disk space; fall back to a smaller variant if needed
all_gguf_files = [gguf_filename] + gguf_extra_shards
expected_sizes: dict[str, int] = {}
try:
from huggingface_hub import get_paths_info, try_to_load_from_cache
path_infos = list(get_paths_info(hf_repo, all_gguf_files, token = hf_token))
expected_sizes = {p.path: p.size for p in path_infos if p.size}
total_bytes = sum((p.size or 0) for p in path_infos)
# Subtract bytes already in the HF cache so we only preflight
@ -4043,7 +4398,26 @@ class LlamaCppBackend:
# cold whenever free disk is below the full weight footprint,
# even though nothing needs downloading.
already_cached_bytes = 0
if not force:
# Cross-snapshot / case-variant cache reuse is offline-only (see the download
# path below); online, hf_hub_download fetches the current revision and
# resumes partials, so an old snapshot must not be counted as cached here or
# the preflight would under-count the download and skip the disk fallback.
offline = _hf_env_offline()
# A split GGUF whose shards are not co-located in a single snapshot is
# refetched as a whole set later, so it must not be counted as cached here.
split_needs_refetch = False
if offline and not force and gguf_extra_shards:
# Scan all snapshots for one that holds the whole set co-located, so a
# newer snapshot with only the first shard does not mask an older
# complete one and needlessly trip the disk fallback.
if (
_cached_colocated_split_main(
hf_repo, gguf_filename, gguf_extra_shards, expected_sizes
)
is None
):
split_needs_refetch = True
if not force and not split_needs_refetch:
for p in path_infos:
if not p.size:
continue
@ -4051,6 +4425,15 @@ class LlamaCppBackend:
cached_path = try_to_load_from_cache(hf_repo, p.path)
except Exception:
cached_path = None
if (
not (isinstance(cached_path, str) and os.path.exists(cached_path))
and offline
):
cached_path = _cached_hf_snapshot_file(
hf_repo,
p.path,
expected_size = p.size,
)
if isinstance(cached_path, str) and os.path.exists(cached_path):
try:
on_disk = os.path.getsize(cached_path)
@ -4113,6 +4496,13 @@ class LlamaCppBackend:
)
else:
gguf_extra_shards = []
# Record the fallback's size so the later cache-reuse probe can
# size-verify it; only for a single-file fallback, since
# _find_smallest_fitting_variant returns the whole-variant size
# and using that as the first shard's expected size would reject
# a valid cached first shard of a split fallback.
if not gguf_extra_shards:
expected_sizes[fallback_file] = fallback_size
else:
raise RuntimeError(
f"Not enough disk space to download any variant. "
@ -4132,25 +4522,45 @@ class LlamaCppBackend:
raise RuntimeError("Cancelled")
dl_start = time.monotonic()
# Xet primary, HTTP fallback on stall; per-file so finished shards stay cached.
local_path = hf_hub_download_with_xet_fallback(
hf_repo,
gguf_filename,
hf_token,
cancel_event = cancel_event,
on_status = lambda m: logger.info(m),
force_download = force,
)
for shard in gguf_extra_shards:
if cancel_event.is_set():
raise RuntimeError("Cancelled")
logger.info(f"Resolving GGUF shard: {shard}")
hf_hub_download_with_xet_fallback(
local_path = None
# Reuse a cached copy from another snapshot / case-variant repo dir only when
# offline. Online, fall through to hf_hub_download so its revision/etag check
# fetches the current file (and resumes a partial) instead of serving a stale
# same-name blob from an older revision.
if not force and _hf_env_offline():
if gguf_extra_shards:
# A split GGUF must load every shard from one snapshot; reuse only a
# snapshot that holds the whole set co-located, scanning past a newer
# snapshot that has just the first shard while an older one is complete.
local_path = _cached_colocated_split_main(
hf_repo, gguf_filename, gguf_extra_shards, expected_sizes
)
else:
local_path = _cached_hf_snapshot_file(
hf_repo,
gguf_filename,
expected_size = expected_sizes.get(gguf_filename),
)
if local_path is None:
local_path = hf_hub_download_with_xet_fallback(
hf_repo,
shard,
gguf_filename,
hf_token,
cancel_event = cancel_event,
on_status = lambda m: logger.info(m),
force_download = force,
)
for shard in gguf_extra_shards:
if cancel_event.is_set():
raise RuntimeError("Cancelled")
logger.info(f"Resolving GGUF shard: {shard}")
hf_hub_download_with_xet_fallback(
hf_repo,
shard,
hf_token,
cancel_event = cancel_event,
force_download = force,
)
except Exception as e:
if isinstance(e, RuntimeError) and "Cancelled" in str(e):
raise
@ -4228,6 +4638,17 @@ class LlamaCppBackend:
if target is None or cancel_event.is_set():
return None
# Offline, resolve the companion straight from the cache snapshot that
# holds it. resolve_cached_repo_id_case can return a partial lower-case
# spelling when any dir exists under the requested casing, so calling
# hf_hub_download with hf_repo would miss the canonical file and silently
# drop the companion. _cached_hf_snapshot_file scans every case variant.
if _hf_env_offline():
cached = _cached_hf_snapshot_file(hf_repo, target)
if cached:
logger.info("Resolved %s from local HF cache: %s", label, cached)
return cached
try:
logger.info(f"Downloading {label}: {hf_repo}/{target}")
# Same policy; companions are best-effort (caller below swallows failures to None).
@ -4276,6 +4697,29 @@ class LlamaCppBackend:
cancel_event = cancel_event,
)
def _cached_repo_mtp_drafter(self, hf_repo: str) -> Optional[str]:
"""A drafter already in this repo's local HF cache, reused offline when a
fresh copy can't be fetched. Prefers a repo-root ``mtp-*.gguf`` across all
cached snapshots; else an existing ``MTP/`` copy (any precision -- the
target verifies every drafted token). None if none is cached."""
try:
from utils.models.model_config import _iter_hf_cache_snapshots
roots: list[Path] = []
subdirs: list[Path] = []
for snap in _iter_hf_cache_snapshots(hf_repo): # newest first
for f in sorted(_gguf_snapshot_files(snap)):
if _is_companion_gguf_path(f) and "mmproj" not in f.lower():
(roots if "/" not in f else subdirs).append(snap / f)
# Keep snapshot order (newest first), root before any MTP/ copy, so a
# newer main GGUF pairs with the newest cached drafter, not a stale one.
for cand in roots + subdirs:
if cand.is_file():
return str(cand)
except Exception as e:
logger.debug("Cached MTP drafter lookup failed for %s: %s", hf_repo, e)
return None
def _download_mtp(
self,
*,
@ -4292,11 +4736,25 @@ class LlamaCppBackend:
are intentionally skipped. Returns the local path, or None.
"""
# Offline, reuse any drafter already on disk (a fresh copy can't be
# fetched). Online, _download_companion_gguf/hf_hub_download reuse the
# current cached file and refetch a changed one, so skip the probe here
# rather than pair new weights with a stale draft.
if _hf_env_offline():
cached = self._cached_repo_mtp_drafter(hf_repo)
if cached:
logger.info(f"Reusing cached MTP drafter (offline): {cached}")
return cached
def _pick_mtp(candidates: list[str]) -> Optional[str]:
# Root-level only: MTP/ subdir copies now share the mtp- prefix but
# are explicit-selection, not auto-fetch (they'd sort ahead of root).
mtp_files = sorted(
f
for f in candidates
if f.lower().endswith(".gguf") and Path(f).name.lower().startswith("mtp-")
if f.lower().endswith(".gguf")
and "/" not in f
and Path(f).name.lower().startswith("mtp-")
)
return mtp_files[0] if mtp_files else None
@ -4998,6 +5456,7 @@ class LlamaCppBackend:
# Resolve llama-server now but defer a not-found error: a block-diffusion
# GGUF uses the diffusion runner, and its arch is only known after the header.
binary = self._find_llama_server_binary()
is_vulkan_backend = self._is_vulkan_backend(binary)
# ── Phase 2: download (NO lock held, so cancel can proceed) ──
# mtp_draft_path arrives set for local Gemma loads (detected
@ -5006,6 +5465,19 @@ class LlamaCppBackend:
# dead; cleanup runs even on exception so a transient hiccup
# can't quarantine future loads.
if hf_repo:
# Resolve the requested repo id to its cached canonical casing once,
# up front, so the main GGUF and its companions (mmproj / MTP drafter)
# all resolve from the same cache entry. Otherwise a case-variant
# request resolves the main file from the canonical cache dir while the
# companions keep the requested casing and miss the cached files.
_resolved_repo = _resolve_repo_id_casing(hf_repo)
if _resolved_repo != hf_repo:
logger.info(
"Using cached repo_id casing '%s' for requested '%s'",
_resolved_repo,
hf_repo,
)
hf_repo = _resolved_repo
with _hf_offline_if_dns_dead():
model_path = self._download_gguf(
hf_repo = hf_repo,
@ -5224,7 +5696,8 @@ class LlamaCppBackend:
model_size = gguf_size + mmproj_size
# 2-tuple gpus for existing logic + a total map for the absolute
# per-GPU headroom (correct when the GPU is already partly used).
_gpu_mem = self._get_gpu_memory()
# Pass binary so a Vulkan build probes ggml's Vulkan ordinals.
_gpu_mem = self._get_gpu_memory(binary)
gpus = [(idx, free) for idx, free, _t in _gpu_mem]
total_by_idx = {idx: total for idx, _f, total in _gpu_mem}
@ -5997,7 +6470,12 @@ class LlamaCppBackend:
# cap, not the ROCm-reported VRAM, is the real ceiling); refuse an
# oversize load the OS would otherwise kill mid-flight. Base model
# only: an optional MTP drafter is dropped by the MTP-drop fallback.
if model_size is not None and self._amd_apu_wants_unified_memory(gpu_indices):
# CUDA/ROCm ids only; a Vulkan build's gpu_indices are ggml ordinals.
if (
model_size is not None
and not is_vulkan_backend
and self._amd_apu_wants_unified_memory(gpu_indices)
):
_ram_msg = self._apu_ram_shortfall_message(
model_size, self._available_system_memory_mib()
)
@ -6260,6 +6738,12 @@ class LlamaCppBackend:
", ".join(unsupported_cache_flags),
)
# Vulkan pins via --device (a cmd arg, unlike the env-based
# CUDA/ROCm pin below), emitted BEFORE user extras so llama.cpp's
# last-wins parsing lets a user --device override Studio's pick.
if is_vulkan_backend and gpu_indices is not None:
cmd += LlamaCppBackend._vulkan_pin_args(gpu_indices)
# User pass-through args go last so llama.cpp's last-wins parsing
# lets the user override Studio's auto-set flags. Already
# validated by the route via validate_extra_args().
@ -6311,23 +6795,25 @@ class LlamaCppBackend:
env.setdefault("OMP_NUM_THREADS", "2")
# AMD unified-memory APUs (gfx1150/gfx1151): let llama.cpp use
# shared system RAM. setdefault so a user value wins.
if self._amd_apu_wants_unified_memory(gpu_indices):
# shared system RAM. setdefault so a user value wins. Not on Vulkan
# (nor DC below): gpu_indices are ggml ordinals, not CUDA/ROCm ids.
if not is_vulkan_backend and self._amd_apu_wants_unified_memory(gpu_indices):
env.setdefault("GGML_CUDA_ENABLE_UNIFIED_MEMORY", "1")
logger.info("AMD unified-memory APU: set GGML_CUDA_ENABLE_UNIFIED_MEMORY=1")
# DC NVIDIA GPUs: FP32 accum (+ P2P / launch queues for multi-GPU).
# See _apply_datacenter_env; opt out with UNSLOTH_DISABLE_DC_TUNING=1.
if self._apply_datacenter_env(env, gpu_indices):
if not is_vulkan_backend and self._apply_datacenter_env(env, gpu_indices):
multi_gpu = self._effective_gpu_count(gpu_indices) > 1
logger.info(
f"Data-center GPU detected: applied DC llama.cpp env tuning (multi_gpu={multi_gpu})"
)
# Pin to selected GPU(s). On ROCm, narrowing only
# CUDA_VISIBLE_DEVICES leaves an AMD child seeing the full
# set, so set HIP_VISIBLE_DEVICES too.
if gpu_indices is not None:
# CUDA_VISIBLE_DEVICES leaves an AMD child seeing the full set, so
# set HIP_VISIBLE_DEVICES too. Vulkan is pinned via --device
# (above), not here.
if gpu_indices is not None and not is_vulkan_backend:
pinned = ",".join(str(i) for i in gpu_indices)
env["CUDA_VISIBLE_DEVICES"] = pinned
try:
@ -6721,6 +7207,7 @@ class LlamaCppBackend:
)
self._healthy = True
self._commit_effective_parallel_slots(n_parallel)
# Commit caller intent only after _healthy=True so a failed start
# can't poison the next inheritance check. None keeps prior, []
@ -7258,6 +7745,7 @@ class LlamaCppBackend:
self._context_length = None
self._effective_context_length = None
self._max_context_length = None
self._reset_effective_parallel_slots()
self._chat_template = None
self._chat_template_override = None
self._supports_reasoning = False
@ -7313,6 +7801,7 @@ class LlamaCppBackend:
# Stop the watchdog before a deliberate kill so a planned reload/unload
# isn't seen as a crash; a real crash never routes through here.
self._stop_mtp_crash_watchdog()
self._reset_effective_parallel_slots()
if self._process is None:
return
try:
@ -8154,7 +8643,7 @@ class LlamaCppBackend:
):
"""Open one streaming POST and let cancel interrupt prefill or reads."""
if cancel_event is not None and cancel_event.is_set():
raise GeneratorExit
raise _LlamaStreamCancelled
_cancel_closed = threading.Event()
_response_ref: list = [None]
@ -8199,13 +8688,13 @@ class LlamaCppBackend:
) as response:
_response_ref[0] = response
if cancel_event is not None and cancel_event.is_set():
raise GeneratorExit
raise _LlamaStreamCancelled
yield response
return
except (httpx.RequestError, RuntimeError):
# Response was closed by the cancel watcher
if cancel_event is not None and cancel_event.is_set():
raise GeneratorExit
raise _LlamaStreamCancelled
raise
finally:
_cancel_closed.set()
@ -8408,6 +8897,8 @@ class LlamaCppBackend:
"finish_reason": _metadata_finish_reason,
}
except _LlamaStreamCancelled:
return
except httpx.ConnectError as e:
# Server already down. If this was an MTP+tensor crash, recover by
# reloading without MTP (scheduled in the background) and fail this
@ -9532,6 +10023,8 @@ class LlamaCppBackend:
break
continue
except _LlamaStreamCancelled:
return
except httpx.ConnectError:
# Mark unresolved provisional cards as failed before raising.
for _pid, _pname in provisional_started_tool_calls.items():
@ -9714,6 +10207,8 @@ class LlamaCppBackend:
if _meta is not None:
yield _meta
except _LlamaStreamCancelled:
return
except httpx.ConnectError:
raise RuntimeError("Lost connection to llama-server")
except Exception as e:

View file

@ -5,6 +5,7 @@ Drop-in replacement for InferenceBackend — same interface, uses mlx-lm/mlx-vlm
instead of torch/transformers for model loading and generation.
"""
import os
import threading
from typing import Optional, Generator
from core.inference.runtime_context import runtime_context_length
@ -41,6 +42,48 @@ def _build_generation_stats(prompt_n, prompt_tps, gen_n, gen_tps):
}
def _mlx_distributed_rank_size(group = None):
"""Return ``(rank, world_size)`` for an optional MLX distributed group."""
if group is None:
return 0, 1
rank = int(group.rank())
world_size = int(group.size())
if world_size < 1:
raise ValueError(f"Invalid MLX distributed world_size={world_size}.")
if rank < 0 or rank >= world_size:
raise ValueError(f"Invalid MLX distributed rank={rank} for world_size={world_size}.")
return rank, world_size
def _mlx_distributed_backend_from_env():
if os.environ.get("MLX_JACCL_COORDINATOR") and os.environ.get("MLX_IBV_DEVICES"):
return "jaccl"
return None
def _init_mlx_distributed():
"""Initialize MLX distributed state, falling back to singleton metadata."""
import mlx.core as mx
group = None
rank = 0
world_size = 1
distributed = getattr(mx, "distributed", None)
init = getattr(distributed, "init", None) if distributed is not None else None
if callable(init):
backend = _mlx_distributed_backend_from_env()
if backend is None:
group = init()
else:
try:
group = init(backend = backend)
except TypeError:
group = init()
if group is not None:
rank, world_size = _mlx_distributed_rank_size(group)
return group, rank, world_size
def _make_mlx_presence_penalty_processor(penalty: float):
"""Presence penalty as an mlx_lm/mlx_vlm logits processor, matching the safetensors path.
@ -52,7 +95,7 @@ def _make_mlx_presence_penalty_processor(penalty: float):
def _processor(tokens, logits):
if state["prompt_len"] is None:
# First call = prompt only; latch its length.
# First call is prompt-only; latch its length.
state["prompt_len"] = int(tokens.shape[0])
return logits
generated = tokens[state["prompt_len"] :]
@ -61,22 +104,17 @@ def _make_mlx_presence_penalty_processor(penalty: float):
import mlx.core as mx
vocab = logits.shape[-1]
# Bound generated ids to the valid range [0, vocab) before they index
# logits. MLX does no bounds checking and out-of-bounds indexing is
# documented undefined behavior (crash / memory corruption), unlike the
# torch path's harmless negative wrap -- so this bound is load-bearing
# here and matches the torch filter seen[(seen >= 0) & (seen < vocab)].
# MLX has no boolean-mask filtering (data-dependent output shape is
# unsupported), so instead of compacting the id list we route every
# out-of-range or negative id to a scratch slot at index ``vocab`` that
# is dropped before the subtract. That scratch slot can never collide
# with a real token, so real ids (including id 0) are penalized exactly
# once and stray ids are ignored.
# Bound ids to [0, vocab) before indexing logits: MLX does no bounds
# checking and out-of-bounds indexing is undefined behavior (crash /
# corruption), unlike torch's harmless negative wrap. MLX also lacks
# boolean-mask filtering, so out-of-range/negative ids route to a
# scratch slot at index vocab (dropped before the subtract) that never
# collides with a real token: real ids (including 0) are penalized
# once, strays ignored.
valid = (generated >= 0) & (generated < vocab)
safe = mx.where(valid, generated, vocab).astype(mx.int32)
# Scatter-assign a scalar penalty into a (vocab + 1)-wide mask: duplicate
# ids are idempotent, so presence applies once per distinct token; the
# scratch column is discarded and the full-width subtract stays on-device.
# Scatter penalty into a (vocab + 1)-wide mask: duplicate ids are
# idempotent (presence applies once per token); scratch column dropped.
mask = mx.zeros((vocab + 1,), dtype = logits.dtype)
mask[safe] = penalty
logits = logits - mask[:vocab]
@ -93,7 +131,7 @@ class MLXInferenceBackend:
self.loaded_local_models = []
self.device = "mlx"
self._generation_lock = threading.Lock()
# usage/timings of the latest generation; shipped on gen_done.
# usage/timings of the latest generation, shipped on gen_done.
self.last_generation_stats = None
self._model = None
@ -101,6 +139,9 @@ class MLXInferenceBackend:
self._processor = None
self._is_vlm = False
self._config = {}
self._distributed_group = None
self._distributed_rank = 0
self._distributed_world_size = 1
# Recorded for unload to release pinned memory back to the OS.
self._memory_limits_applied = {}
@ -145,19 +186,26 @@ class MLXInferenceBackend:
trust_remote_code = False,
gpu_ids = None,
dtype = None,
parallel_mode = None,
distributed_group = None,
) -> bool:
import mlx.core as mx
# Keep the token so the native-template fallback can fetch a
# gated model's repo template later during generation.
# Keep the token so the native-template fallback can fetch a gated
# model's repo template during generation.
self._hf_token = hf_token
model_name = config.identifier if hasattr(config, "identifier") else str(config)
is_vision = getattr(config, "is_vision", False)
distributed_rank, distributed_size = _mlx_distributed_rank_size(distributed_group)
is_distributed = distributed_group is not None and distributed_size > 1
self._distributed_group = distributed_group
self._distributed_rank = distributed_rank
self._distributed_world_size = distributed_size
# GGUF guard. GGUF models are served by llama-server in the parent
# process, not mlx-lm here. Reaching this with is_gguf=True means the
# route's first detection flaked (transient HF Hub) but the subprocess
# re-detected GGUF; raise loudly instead of a cryptic mlx_lm error.
# GGUF guard: GGUF is served by llama-server in the parent process,
# not mlx-lm. Reaching here with is_gguf=True means the route's
# detection flaked but the subprocess re-detected GGUF; raise loudly
# instead of a cryptic mlx_lm error.
if getattr(config, "is_gguf", False):
raise RuntimeError(
f"MLXInferenceBackend cannot load GGUF model '{model_name}': "
@ -176,11 +224,26 @@ class MLXInferenceBackend:
is_lora = getattr(config, "is_lora", False)
logger.info(
"Loading %s via %s (is_lora=%s)",
"Loading %s via %s (is_lora=%s, distributed=%s, rank=%s/%s, mode=%s)",
model_name,
"mlx-vlm" if is_vision else "mlx-lm",
is_lora,
is_distributed,
distributed_rank,
distributed_size,
parallel_mode,
)
if is_distributed and parallel_mode not in ("pipeline", "tensor"):
raise ValueError(
"Unsloth: distributed MLX inference requires parallel_mode='pipeline' "
"or parallel_mode='tensor'."
)
if is_distributed and is_lora:
raise ValueError(
"Unsloth: distributed MLX inference for LoRA adapter repos "
"is not supported yet. Merge/export the adapter into an MLX model "
"before distributed inference."
)
try:
from unsloth_zoo.mlx.loader import FastMLXModel
@ -190,14 +253,23 @@ class MLXInferenceBackend:
"(unsloth_zoo.mlx.loader). Reinstall via install.sh on Apple Silicon."
) from e
load_kwargs = {
"max_seq_length": max_seq_length,
"dtype": dtype,
"load_in_4bit": load_in_4bit,
"token": hf_token,
"trust_remote_code": trust_remote_code,
"text_only": False if is_vision else True,
}
if is_distributed:
if parallel_mode == "pipeline":
load_kwargs["pipeline_group"] = distributed_group
else:
load_kwargs["tensor_group"] = distributed_group
model, tokenizer_or_processor = FastMLXModel.from_pretrained(
model_name,
max_seq_length = max_seq_length,
dtype = dtype,
load_in_4bit = load_in_4bit,
token = hf_token,
trust_remote_code = trust_remote_code,
text_only = False if is_vision else True,
**load_kwargs,
)
if is_vision:
@ -217,8 +289,7 @@ class MLXInferenceBackend:
self.models[model_name] = {
# Per-model token for the native-template fallback (matches transformers).
"hf_token": hf_token,
# Per-model consent for the native-template reload: re-use the exact
# trust_remote_code this model was loaded with (matches transformers).
# Per-model trust_remote_code reused by the native-template reload (matches transformers).
"trust_remote_code": trust_remote_code,
"model": self._model,
"tokenizer": self._tokenizer,
@ -234,8 +305,7 @@ class MLXInferenceBackend:
"has_audio_input": False,
"context_length": runtime_context_length(self._model, max_seq_length),
}
# Capture chat_template_info so the worker IPC reply ships it back and
# the route layer classifies capabilities like the other paths.
# Capture chat_template_info for the worker IPC reply and route capability classification.
self._populate_chat_template_info(model_name)
logger.info("Model %s loaded successfully", model_name)
@ -293,6 +363,9 @@ class MLXInferenceBackend:
self._model = None
self._tokenizer = None
self._processor = None
self._distributed_group = None
self._distributed_rank = 0
self._distributed_world_size = 1
if self.active_model_name == model_name:
self.active_model_name = None
gc.collect()
@ -320,8 +393,7 @@ class MLXInferenceBackend:
max_new_tokens = 256,
repetition_penalty = 1.0,
cancel_event = None,
# Reasoning / tool kwargs forwarded by the route + worker; rendered via
# apply_chat_template_for_generation like the transformers path.
# Reasoning / tool kwargs, rendered via apply_chat_template_for_generation (transformers parity).
tools = None,
enable_thinking = None,
reasoning_effort = None,
@ -334,7 +406,6 @@ class MLXInferenceBackend:
# Reset so a failed run cannot surface stale stats.
self.last_generation_stats = None
# Build messages with system prompt
full_messages = []
if system_prompt:
full_messages.append({"role": "system", "content": system_prompt})
@ -351,7 +422,6 @@ class MLXInferenceBackend:
{"type": "text", "text": content},
]
elif isinstance(content, list):
# Prepend image if not already present
has_image = any(
p.get("type") == "image" for p in content if isinstance(p, dict)
)
@ -415,6 +485,7 @@ class MLXInferenceBackend:
from core.inference.chat_template_helpers import (
apply_chat_template_for_generation,
detect_think_prefill,
render_with_native_template_fallback,
)
@ -429,11 +500,11 @@ class MLXInferenceBackend:
if prompt is None:
raise RuntimeError("apply_chat_template returned None — tokenizer may be incompatible")
# Same parity fix as the transformers backend: if the template dropped the
# requested tools, fall back to the native template so MLX text models keep
# advertising them. ``self._tokenizer`` is this entry's model_info tokenizer,
# so probe and native render share a renderer. (The VLM path renders via the
# processor for image tokens and is intentionally not wired here.)
# Parity with the transformers backend: if the template dropped the
# requested tools, fall back to the native template so MLX text models
# keep advertising them. self._tokenizer is this entry's tokenizer, so
# probe and native render share a renderer. (VLM renders via the
# processor for image tokens and is not wired here.)
model_info = self.models.get(self.active_model_name, {})
prompt = render_with_native_template_fallback(
formatted_prompt = prompt,
@ -448,6 +519,15 @@ class MLXInferenceBackend:
hf_token = model_info.get("hf_token"),
)
# An open <think> prefilled by the template lives in the prompt, not
# the generated tokens; re-emit it so the frontend renders the block.
think_prefix = detect_think_prefill(
prompt, getattr(self._tokenizer, "all_special_tokens", None)
)
# Emit it before the first token so the block renders during prefill.
if think_prefix:
yield think_prefix
sampler = make_sampler(
temp = temperature,
top_p = top_p,
@ -455,7 +535,7 @@ class MLXInferenceBackend:
min_p = float(min_p or 0.0),
min_tokens_to_keep = 1,
)
# Repetition and/or presence penalty processors (parity with the GGUF/safetensors paths).
# Repetition and/or presence penalty processors (GGUF/safetensors parity).
logits_processors = []
if repetition_penalty is not None and float(repetition_penalty) not in (
0.0,
@ -496,12 +576,11 @@ class MLXInferenceBackend:
):
final_response = response
token_ids.append(response.token)
# Decode full sequence with skip_special_tokens
cumulative = self._tokenizer.decode(
token_ids,
skip_special_tokens = True,
)
yield cumulative
yield think_prefix + cumulative
if cancel_event and cancel_event.is_set():
break
@ -544,8 +623,7 @@ class MLXInferenceBackend:
)
# Pick the chat-template-aware caller: processors with their own
# apply_chat_template + chat_template (e.g. Qwen2.5-VL) use it
# directly; else fall back to the nested tokenizer.
# apply_chat_template + chat_template (e.g. Qwen2.5-VL), else the nested tokenizer.
chat_target = self._processor
if (
getattr(self._processor, "apply_chat_template", None) is None
@ -566,16 +644,21 @@ class MLXInferenceBackend:
# mlx_vlm's stream_generate handles pixel_values (None for text-only)
images = [image] if image is not None else None
cumulative = ""
from core.inference.chat_template_helpers import detect_think_prefill
# Re-emit an open <think> prefill from the prompt (see _generate_text).
cumulative = detect_think_prefill(prompt, getattr(chat_target, "all_special_tokens", None))
# Emit it before the first token so the block renders during prefill.
if cumulative:
yield cumulative
logger.info(
"VLM generating: prompt_len=%d, has_image=%s",
len(prompt),
image is not None,
)
# mlx_vlm.stream_generate forwards **kwargs into generate_step, which
# builds the sampler + logits_processors internally.
# GOTCHA: generate_step expects ``temperature=`` (long form); ``temp=``
# silently falls into **kwargs and is ignored, stuck at greedy 0.0.
# stream_generate forwards **kwargs into generate_step (builds the
# sampler + logits_processors internally). GOTCHA: generate_step expects
# temperature= (long form); temp= is silently ignored, stuck at greedy 0.0.
vlm_kwargs = dict(
max_tokens = max_new_tokens,
temperature = temperature,
@ -589,7 +672,7 @@ class MLXInferenceBackend:
)
if presence_penalty:
# Presence needs a custom processor: pass the full list (repetition +
# presence) instead of the repetition_penalty shortcut so both apply once.
# presence) instead of the repetition_penalty shortcut so both apply.
from mlx_lm.sample_utils import make_logits_processors
_vlm_processors = []
@ -634,7 +717,7 @@ class MLXInferenceBackend:
cancel_event = None,
**gen_kwargs,
) -> Generator[str, None, None]:
# MLX LoRA adapter toggling not yet supported generate normally
# MLX LoRA adapter toggling not yet supported; generate normally
yield from self.generate_chat_response(cancel_event = cancel_event, **gen_kwargs)
def reset_generation_state(self):

View file

@ -50,6 +50,18 @@ _DISPATCH_DRAIN_TIMEOUT = 5.0
_UNLOAD_GEN_LOCK_TIMEOUT = 15.0
class GenStreamError(str):
"""A stream chunk carrying a real backend/generation error, not model text.
Subclasses str so existing display/logging consumers are unaffected, while
callers that must abort a distributed run on error (raise_on_streamed_error)
can distinguish a real error from model output whose visible text starts with
"Error:" by checking isinstance(chunk, GenStreamError).
"""
__slots__ = ()
class InferenceOrchestrator:
"""
Inference backend orchestrator subprocess-based.
@ -482,13 +494,13 @@ class InferenceOrchestrator:
initial_resp_queue = self._resp_queue
while True:
if self._proc is not initial_proc or self._resp_queue is not initial_resp_queue:
yield f"Error: {self._subprocess_crash_message(crash_context)}"
yield GenStreamError(f"Error: {self._subprocess_crash_message(crash_context)}")
return
resp = read_one(read_timeout)
if resp is None:
# Check subprocess health
if not self._ensure_subprocess_alive():
yield f"Error: {self._subprocess_crash_message(crash_context)}"
yield GenStreamError(f"Error: {self._subprocess_crash_message(crash_context)}")
return
continue
@ -498,7 +510,7 @@ class InferenceOrchestrator:
# Subprocess-level error (no request_id); request-scoped failures
# arrive as gen_error below.
if rtype == "error" and not resp.get("request_id"):
yield f"Error: {resp.get('error', 'Unknown error')}"
yield GenStreamError(f"Error: {resp.get('error', 'Unknown error')}")
return
if rtype == "token":
@ -513,7 +525,7 @@ class InferenceOrchestrator:
stats_holder["stats"] = resp.get("stats")
return
elif rtype == "gen_error":
yield f"Error: {resp.get('error', 'Unknown error')}"
yield GenStreamError(f"Error: {resp.get('error', 'Unknown error')}")
return
# ------------------------------------------------------------------
@ -640,11 +652,11 @@ class InferenceOrchestrator:
GPU work stays serialized; this only avoids orchestrator lock contention.
"""
if not self._ensure_subprocess_alive():
yield "Error: Inference subprocess is not running"
yield GenStreamError("Error: Inference subprocess is not running")
return
if not self.active_model_name:
yield "Error: No active model"
yield GenStreamError("Error: No active model")
return
# Latch the target model so the recheck below can detect a switch that completed
# between _start_dispatcher and mailbox registration (mirrors the locked path's
@ -655,7 +667,7 @@ class InferenceOrchestrator:
# so without this early-out a compare request would enqueue a generate on the
# outgoing model and delay the switch.
if self._unload_pending:
yield "Error: model is being unloaded"
yield GenStreamError("Error: model is being unloaded")
return
# Ensure the dispatcher runs. _start_dispatcher serializes concurrent starters under
@ -727,7 +739,7 @@ class InferenceOrchestrator:
# _stop_dispatcher joins the dispatcher, which itself takes that lock.
if orphaned_dispatcher:
self._stop_dispatcher()
yield "Error: model is being unloaded"
yield GenStreamError("Error: model is being unloaded")
return
try:
@ -735,7 +747,7 @@ class InferenceOrchestrator:
except RuntimeError as exc:
with self._mailbox_lock:
self._mailboxes.pop(request_id, None)
yield f"Error: {exc}"
yield GenStreamError(f"Error: {exc}")
return
def read_mailbox(timeout):
@ -813,6 +825,59 @@ class InferenceOrchestrator:
self._stop_dispatcher()
return True
def share_distributed_object(
self,
obj,
timeout: Optional[float] = 300.0,
):
"""Share a small object through the worker's MLX distributed group."""
if not self._ensure_subprocess_alive():
raise RuntimeError("Inference subprocess is not running")
self._wait_dispatcher_idle()
with self._mailbox_lock:
if self._mailboxes:
raise RuntimeError(
"Cannot share distributed objects while compare requests are active"
)
request_id = str(uuid.uuid4())
cmd = {
"type": "share_object",
"request_id": request_id,
"object": obj,
}
with self._gen_lock:
self._send_cmd(cmd)
deadline = None if timeout is None else time.monotonic() + timeout
while deadline is None or time.monotonic() < deadline:
remaining = 1.0 if deadline is None else max(0.1, deadline - time.monotonic())
resp = self._read_resp(timeout = min(remaining, 1.0))
if resp is None:
if not self._ensure_subprocess_alive():
raise RuntimeError(self._subprocess_crash_message("sharing chat turn"))
continue
rtype = resp.get("type", "")
rid = resp.get("request_id")
if rid and rid != request_id:
logger.debug(
"Skipping response for request_id=%s while sharing request_id=%s",
rid,
request_id,
)
continue
if rtype == "shared":
return resp.get("object")
if rtype == "share_error":
raise RuntimeError(resp.get("error", "Failed to share object"))
if rtype == "error":
raise RuntimeError(resp.get("error", "Subprocess error"))
if rtype == "status":
continue
raise RuntimeError("Timeout waiting for distributed object share")
# ------------------------------------------------------------------
# Public API — same interface as InferenceBackend
# ------------------------------------------------------------------
@ -828,6 +893,8 @@ class InferenceOrchestrator:
approved_remote_code_fingerprint: Optional[str] = None,
gpu_ids: Optional[list[int]] = None,
subject: Optional[str] = None,
tensor_parallel: bool = False,
mlx_distributed: bool = False,
) -> bool:
"""Load a model for inference.
@ -853,6 +920,11 @@ class InferenceOrchestrator:
"approved_remote_code_fingerprint": approved_remote_code_fingerprint,
"subject": subject,
"gpu_ids": gpu_ids,
"tensor_parallel": bool(tensor_parallel),
"mlx_distributed": bool(mlx_distributed),
"mlx_parallel_mode": ("tensor" if tensor_parallel else "pipeline")
if mlx_distributed
else None,
}
resolved_gpu_ids, gpu_selection = prepare_gpu_selection(
gpu_ids,
@ -1338,11 +1410,11 @@ class InferenceOrchestrator:
readers don't consume each other's tokens off the shared resp_queue.
"""
if not self._ensure_subprocess_alive():
yield "Error: Inference subprocess is not running"
yield GenStreamError("Error: Inference subprocess is not running")
return
if not self.active_model_name:
yield "Error: No active model"
yield GenStreamError("Error: No active model")
return
expected_model = self.active_model_name
@ -1359,7 +1431,7 @@ class InferenceOrchestrator:
# so we never generate on the wrong one.
if self._unload_pending or self.active_model_name != expected_model:
# Won the lock handoff during a switch; don't start on the outgoing model.
yield "Error: model is being unloaded"
yield GenStreamError("Error: model is being unloaded")
return
request_id = str(uuid.uuid4())
image_b64 = self._pil_to_base64(image) if image is not None else None
@ -1385,7 +1457,7 @@ class InferenceOrchestrator:
try:
self._send_cmd(cmd)
except RuntimeError as exc:
yield f"Error: {exc}"
yield GenStreamError(f"Error: {exc}")
return
yield from self._consume_token_stream(
@ -1544,10 +1616,10 @@ class InferenceOrchestrator:
) -> Generator[str, None, None]:
"""Shared inner logic for audio input generation (Whisper + ASR)."""
if not self._ensure_subprocess_alive():
yield "Error: Inference subprocess is not running"
yield GenStreamError("Error: Inference subprocess is not running")
return
if not self.active_model_name:
yield "Error: No active model"
yield GenStreamError("Error: No active model")
return
expected_model = self.active_model_name
@ -1556,7 +1628,7 @@ class InferenceOrchestrator:
# cleared or swapped the model while we waited.
if self._unload_pending or self.active_model_name != expected_model:
# Won the lock handoff during a switch; don't start on the outgoing model.
yield "Error: model is being unloaded"
yield GenStreamError("Error: model is being unloaded")
return
request_id = str(uuid.uuid4())
@ -1583,7 +1655,7 @@ class InferenceOrchestrator:
try:
self._send_cmd(cmd)
except RuntimeError as exc:
yield f"Error: {exc}"
yield GenStreamError(f"Error: {exc}")
return
yield from self._consume_token_stream(

View file

@ -13,6 +13,7 @@ mp.Queue, and exits on shutdown or unload. Pattern follows core/training/worker.
from __future__ import annotations
import base64
import json
from loggers import get_logger
import os
import queue as _queue
@ -26,6 +27,9 @@ from typing import Any
logger = get_logger(__name__)
from utils.hardware import apply_gpu_ids
_SHARE_OBJECT_MAX_BYTES = 1 << 20
_SHARE_OBJECT_ERROR_SIZE = -1
# studio/backend root, prepended to sys.path so the spawned subprocess can
# import the utils/core packages.
_BACKEND_PATH = str(Path(__file__).resolve().parent.parent.parent)
@ -75,6 +79,17 @@ def _send_response(resp_queue: Any, response: dict) -> None:
logger.error("Failed to send response: %s", exc)
def _encode_share_object(obj: Any) -> bytes:
data = json.dumps(obj, separators = (",", ":"), ensure_ascii = False).encode("utf-8")
if len(data) > _SHARE_OBJECT_MAX_BYTES:
raise ValueError("Distributed object share payload is too large")
return data
def _decode_share_object(data: Any) -> Any:
return json.loads(bytes(data.tolist()).decode("utf-8"))
def _clean_token(value: str | None) -> str | None:
"""Normalize an HF token: blank or whitespace-only becomes None."""
return value if value and value.strip() else None
@ -329,14 +344,18 @@ def _handle_load(backend, config: dict, resp_queue: Any) -> None:
xet_disabled = os.environ.get("HF_HUB_DISABLE_XET") == "1",
)
try:
success = backend.load_model(
config = mc,
max_seq_length = config.get("max_seq_length", 2048),
load_in_4bit = load_in_4bit,
hf_token = hf_token,
trust_remote_code = trust_remote_code,
gpu_ids = config.get("resolved_gpu_ids"),
)
load_kwargs = {
"config": mc,
"max_seq_length": config.get("max_seq_length", 2048),
"load_in_4bit": load_in_4bit,
"hf_token": hf_token,
"trust_remote_code": trust_remote_code,
"gpu_ids": config.get("resolved_gpu_ids"),
}
if getattr(backend, "device", None) == "mlx":
load_kwargs["parallel_mode"] = config.get("mlx_parallel_mode")
load_kwargs["distributed_group"] = config.get("_mlx_distributed_group")
success = backend.load_model(**load_kwargs)
finally:
heartbeat_stop.set()
@ -521,6 +540,67 @@ def _handle_generate(backend, cmd: dict, resp_queue: Any, cancel_event) -> None:
)
def _handle_share_object(backend, cmd: dict, resp_queue: Any) -> None:
"""Share a small Python object across MLX distributed ranks."""
request_id = cmd.get("request_id", "")
group = getattr(backend, "_distributed_group", None)
rank = int(getattr(backend, "_distributed_rank", 0) or 0)
world_size = int(getattr(backend, "_distributed_world_size", 1) or 1)
obj = cmd.get("object")
try:
if group is None or world_size <= 1:
shared = obj
else:
import mlx.core as mx
if rank == 0:
if obj is None:
mx.eval(mx.distributed.all_sum(mx.array(0), group = group))
shared = None
else:
try:
data = mx.array(_encode_share_object(obj), dtype = mx.uint8)
except Exception:
mx.eval(
mx.distributed.all_sum(
mx.array(_SHARE_OBJECT_ERROR_SIZE),
group = group,
)
)
raise
mx.eval(mx.distributed.all_sum(mx.array(data.size), group = group))
mx.eval(mx.distributed.all_sum(data, group = group))
shared = obj
else:
size = int(mx.distributed.all_sum(mx.array(0), group = group).item())
if size == _SHARE_OBJECT_ERROR_SIZE:
raise RuntimeError("Failed to share distributed object")
if size == 0:
shared = None
else:
data = mx.zeros(size, dtype = mx.uint8)
data = mx.distributed.all_sum(data, group = group)
shared = _decode_share_object(data)
_send_response(
resp_queue,
{
"type": "shared",
"request_id": request_id,
"object": shared,
},
)
except Exception as exc:
_send_response(
resp_queue,
{
"type": "share_error",
"request_id": request_id,
"error": str(exc),
"stack": traceback.format_exc(limit = 20),
},
)
def _handle_generate_audio(backend, cmd: dict, resp_queue: Any) -> None:
"""Handle TTS audio generation — returns WAV bytes + sample_rate."""
request_id = cmd.get("request_id", "")
@ -720,9 +800,29 @@ def run_inference_process(
exc,
)
try:
from core.inference.mlx_inference import MLXInferenceBackend
from core.inference.mlx_inference import MLXInferenceBackend, _init_mlx_distributed
backend = MLXInferenceBackend()
if config.get("mlx_distributed"):
group, rank, size = _init_mlx_distributed()
config["_mlx_distributed_group"] = group
if size <= 1:
# A singleton group (MLX built without distributed support,
# or an invalid launch env/hostfile) would leave nonzero ranks
# looping forever on share_distributed_object. Fail the load
# instead of silently continuing without sharding.
raise RuntimeError(
"MLX distributed launch requested but initialized a singleton "
"group (size 1). Ensure the installed MLX has distributed "
"support and the launch environment/hostfile is valid, or run "
"without distributed."
)
logger.info(
"MLX distributed initialized in worker: rank=%s size=%s mode=%s",
rank,
size,
config.get("mlx_parallel_mode"),
)
_send_response(
resp_queue,
{"type": "status", "message": "Loading model..."},
@ -764,6 +864,8 @@ def run_inference_process(
if _drain_skip_generate(cmd, resp_queue, drain_event):
continue
_handle_generate(backend, cmd, resp_queue, cancel_event)
elif cmd_type == "share_object":
_handle_share_object(backend, cmd, resp_queue)
elif cmd_type == "load":
if backend.active_model_name:
backend.unload_model(backend.active_model_name)
@ -977,6 +1079,9 @@ def run_inference_process(
continue
_handle_generate(backend, cmd, resp_queue, cancel_event)
elif cmd_type == "share_object":
_handle_share_object(backend, cmd, resp_queue)
elif cmd_type == "load":
if backend.active_model_name:
backend.unload_model(backend.active_model_name)

View file

@ -62,7 +62,6 @@ from loggers import get_logger
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Callable
from dataclasses import dataclass
import pandas as pd
from datasets import Dataset
from utils.datasets.cache_safe import load_dataset_cache_safe as load_dataset
@ -71,7 +70,7 @@ from core.inference.llama_cpp import _hf_offline_if_dns_dead
from utils.models import is_vision_model, detect_audio_type
from utils.models.model_config import _env_offline
from utils.datasets import format_and_template_dataset
from utils.datasets import MODEL_TO_TEMPLATE_MAPPER, TEMPLATE_TO_RESPONSES_MAPPER
from utils.datasets.completion_masking import apply_completion_masking
from utils.datasets.iterable import is_streaming_dataset as detect_streaming_dataset
from utils.datasets.raw_text import prepare_raw_text_dataset, resolve_column_names
from utils.paths import (
@ -86,6 +85,11 @@ from utils.native_path_leases import child_env_without_native_path_secret
from utils.subprocess_compat import (
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
)
from .training import (
TrainingProgress,
create_mlx_trainer_adapter,
should_use_mlx_training_backend,
)
logger = get_logger(__name__)
@ -104,31 +108,16 @@ def _build_report_targets(training_args) -> list[str] | str:
return report_to or "none"
@dataclass
class TrainingProgress:
"""Training progress tracking"""
epoch: float = 0
step: int = 0
total_steps: int = 0
loss: Optional[float] = None
learning_rate: Optional[float] = None
is_training: bool = False
is_completed: bool = False
error: Optional[str] = None
status_message: str = "Ready to train" # Current stage
elapsed_seconds: Optional[float] = None
eta_seconds: Optional[float] = None
grad_norm: Optional[float] = None
num_tokens: Optional[int] = None
eval_loss: Optional[float] = None
class UnslothTrainer:
"""
Unsloth Training Backend
"""
def __new__(cls, *args, **kwargs):
if cls is UnslothTrainer and should_use_mlx_training_backend():
return create_mlx_trainer_adapter(*args, **kwargs)
return super().__new__(cls)
def __init__(self):
self.model = None
self.tokenizer = None
@ -3466,8 +3455,6 @@ class UnslothTrainer:
# ========== TRAIN ON RESPONSES ONLY ==========
# Raw-text datasets always train on all tokens.
instruction_part = None
response_part = None
is_cpt = training_args.get("is_cpt", False)
train_on_responses_enabled = (
False
@ -3484,113 +3471,93 @@ class UnslothTrainer:
# DeepSeek OCR handles this internally in its collator, so skip
# Audio VLM handles label masking in its collator, so skip
# Markers auto-detected from the chat template first, manual table
# as fallback; gpt-oss stays on its manual markers. See
# apply_completion_masking.
if (
train_on_responses_enabled
and not self.is_audio_vlm
and not self.is_audio
and not (is_deepseek_ocr or dataset_final_format == "alpaca")
):
try:
logger.info("Configuring train on responses only...\n")
from unsloth.chat_templates import train_on_responses_only
# Template mapping for this model
model_name_lower = self.model_name.lower()
logger.info("Configuring train on responses only...\n")
if model_name_lower in MODEL_TO_TEMPLATE_MAPPER:
template_name = MODEL_TO_TEMPLATE_MAPPER[model_name_lower]
logger.info(f"Detected template: {template_name}\n")
def _notify(level, message):
if level == "warning":
logger.warning(message)
else:
logger.info(f"{message}\n")
if template_name in TEMPLATE_TO_RESPONSES_MAPPER:
instruction_part = TEMPLATE_TO_RESPONSES_MAPPER[template_name][
"instruction"
]
response_part = TEMPLATE_TO_RESPONSES_MAPPER[template_name]["response"]
# No try/except: the helper handles detection failures and
# double misses itself, so an exception here is a real masking
# failure that must fail the run, not silently train on full
# sequences.
self.trainer, masking_applied = apply_completion_masking(
self.trainer,
self.model_name,
train_on_responses_only,
num_proc = config_args["dataset_num_proc"],
notify = _notify,
)
logger.info(f"Instruction marker: {instruction_part[:50]}...\n")
logger.info(f"Response marker: {response_part[:50]}...\n")
if not masking_applied:
train_on_responses_enabled = False
if masking_applied:
try:
# ── Safety net: check if all samples were filtered out ──
# train_on_responses_only masks non-response tokens with -100; a
# row becomes all -100 (Unsloth drops it) when the response
# template is not found in the formatted text. Usually a
# dataset/template mismatch (already-formatted data, or 'Train on
# completions' on data that doesn't match the model's chat
# template); only sometimes max_seq_length truncating the response
# away. Skip this len()-based check for streaming.
if detect_streaming_dataset(self.trainer.train_dataset):
logger.info("Skipping post-filter length check for streaming dataset\n")
else:
logger.info(
f"No response mapping found for template: {template_name}\n"
filtered_len = len(self.trainer.train_dataset)
original_dataset_obj = (
dataset["dataset"] if isinstance(dataset, dict) else dataset
)
train_on_responses_enabled = False
else:
logger.info(f"No template mapping found for model: {self.model_name}\n")
train_on_responses_enabled = False
except Exception as e:
logger.warning(f"Could not configure train on responses: {e}")
train_on_responses_enabled = False
# Apply train on responses only if we have valid parts
if (
train_on_responses_enabled
and instruction_part
and response_part
and not self.is_audio_vlm
and not self.is_audio
and not (is_deepseek_ocr or dataset_final_format == "alpaca")
):
try:
from unsloth.chat_templates import train_on_responses_only
self.trainer = train_on_responses_only(
self.trainer,
instruction_part = instruction_part,
response_part = response_part,
num_proc = config_args["dataset_num_proc"],
)
logger.info("Train on responses only configured successfully\n")
# ── Safety net: check if all samples were filtered out ──
# train_on_responses_only masks non-response tokens with -100;
# a row becomes all -100 (and Unsloth drops it) when the response
# template is not found in the formatted text. That is usually a
# dataset/template mismatch (already-formatted data, or 'Train on
# completions' applied to data that doesn't match the model's chat
# template), and only sometimes max_seq_length truncating the
# response away. Skip this len()-based check for streaming.
if detect_streaming_dataset(self.trainer.train_dataset):
logger.info("Skipping post-filter length check for streaming dataset\n")
else:
filtered_len = len(self.trainer.train_dataset)
original_dataset_obj = (
dataset["dataset"] if isinstance(dataset, dict) else dataset
)
original_len = len(original_dataset_obj)
dropped = original_len - filtered_len
drop_pct = round(100 * dropped / original_len, 1) if original_len > 0 else 0
if filtered_len == 0 or drop_pct > 30:
max_seq = training_args.get("max_seq_length", 2048)
error_msg = (
f"{dropped}/{original_len} samples ({drop_pct}%) were "
f"dropped after applying 'Train on completions': after "
f"masking, those rows had no trainable response tokens "
f"left. The usual cause is that this model's response "
f"template was not found in the formatted samples, so "
f"every token was masked out. That typically means the "
f"dataset is already formatted, or its structure does "
f"not match the model's chat template, so 'Train on "
f"completions' should be turned off for this dataset. "
f"Less commonly, a max_seq_length ({max_seq}) shorter "
f"than the prompt can truncate the response away; only "
f"raise it if your samples are actually longer than that."
original_len = len(original_dataset_obj)
dropped = original_len - filtered_len
drop_pct = (
round(100 * dropped / original_len, 1) if original_len > 0 else 0
)
logger.error(error_msg)
self._update_progress(error = error_msg, is_training = False)
return
if dropped > 0:
logger.info(
f"⚠️ {dropped}/{original_len} samples "
f"({drop_pct}%) were dropped (all labels "
f"masked). {filtered_len} samples remain.\n"
)
logger.info(f"Post-filter dataset size: {filtered_len} samples\n")
if filtered_len == 0 or drop_pct > 30:
max_seq = training_args.get("max_seq_length", 2048)
error_msg = (
f"{dropped}/{original_len} samples ({drop_pct}%) were "
f"dropped after applying 'Train on completions': after "
f"masking, those rows had no trainable response tokens "
f"left. The usual cause is that this model's response "
f"template was not found in the formatted samples, so "
f"every token was masked out. That typically means the "
f"dataset is already formatted, or its structure does "
f"not match the model's chat template, so 'Train on "
f"completions' should be turned off for this dataset. "
f"Less commonly, a max_seq_length ({max_seq}) shorter "
f"than the prompt can truncate the response away; only "
f"raise it if your samples are actually longer than that."
)
logger.error(error_msg)
self._update_progress(error = error_msg, is_training = False)
return
except Exception as e:
logger.warning(f"Failed to apply train on responses only: {e}")
train_on_responses_enabled = False
if dropped > 0:
logger.info(
f"⚠️ {dropped}/{original_len} samples "
f"({drop_pct}%) were dropped (all labels "
f"masked). {filtered_len} samples remain.\n"
)
logger.info(f"Post-filter dataset size: {filtered_len} samples\n")
except Exception as e:
logger.warning(f"Post-masking dataset size check failed: {e}")
else:
if train_on_responses_enabled and is_deepseek_ocr:
logger.info("Train on responses handled by DeepSeek OCR collator\n")

View file

@ -14,17 +14,19 @@ import json as _json
import math
import multiprocessing as mp
import os
import platform
import queue
import re
import shutil
import threading
import time
import traceback
import structlog
from datetime import datetime, timezone
from loggers import get_logger
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Optional, Tuple, Any, TYPE_CHECKING
from typing import Optional, Tuple, Any, Callable, Union, TYPE_CHECKING
if TYPE_CHECKING:
import matplotlib.pyplot as plt
@ -98,6 +100,107 @@ def _coerce_optional_nonneg_float(name: str, value):
return coerced
def is_apple_silicon_training_platform() -> bool:
return platform.system() == "Darwin" and platform.machine() == "arm64"
def is_mlx_training_device(device: Any) -> bool:
return (
str(device).lower() == "mlx"
or str(device).lower().endswith(".mlx")
or getattr(device, "name", "").lower() == "mlx"
)
def should_use_mlx_training_backend(*, device: Optional[Any] = None) -> bool:
if device is not None:
return is_mlx_training_device(device)
return is_apple_silicon_training_platform()
def _build_training_worker_config(values: dict[str, Any]) -> dict[str, Any]:
"""Build the normalized worker config shared by Studio and the CLI adapter."""
config = {
"model_name": values["model_name"],
"project_name": values.get("project_name"),
"training_type": values.get("training_type", "LoRA/QLoRA"),
"hf_token": values.get("hf_token", ""),
"load_in_4bit": values.get("load_in_4bit", True),
"max_seq_length": values.get("max_seq_length", 2048),
"vision_image_size": values.get("vision_image_size"),
"hf_dataset": values.get("hf_dataset", ""),
"local_datasets": values.get("local_datasets"),
"local_eval_datasets": values.get("local_eval_datasets"),
"format_type": values.get("format_type", ""),
"subset": values.get("subset"),
"train_split": values.get("train_split", "train"),
"eval_split": values.get("eval_split"),
"eval_steps": values.get("eval_steps", 0.00),
"dataset_streaming": values.get("dataset_streaming", False),
"dataset_slice_start": values.get("dataset_slice_start"),
"dataset_slice_end": values.get("dataset_slice_end"),
"custom_format_mapping": values.get("custom_format_mapping"),
"is_dataset_image": values.get("is_dataset_image", False),
"is_dataset_audio": values.get("is_dataset_audio", False),
"is_embedding": values.get("is_embedding", False),
"num_epochs": values.get("num_epochs", 3),
"learning_rate": values.get("learning_rate", "2e-4"),
"embedding_learning_rate": values.get("embedding_learning_rate"),
"batch_size": values.get("batch_size", 2),
"gradient_accumulation_steps": values.get("gradient_accumulation_steps", 4),
"warmup_steps": values.get("warmup_steps"),
"warmup_ratio": values.get("warmup_ratio"),
"max_steps": values.get("max_steps", 0),
"save_steps": values.get("save_steps", 0),
"weight_decay": values.get("weight_decay", 0.001),
"max_grad_norm": values.get("max_grad_norm", 0.0),
"max_grad_value": _coerce_optional_nonneg_float(
"max_grad_value", values.get("max_grad_value")
),
"max_grad_leaf_norm": _coerce_optional_nonneg_float(
"max_grad_leaf_norm", values.get("max_grad_leaf_norm")
),
"cast_norm_output_to_input_dtype": _coerce_optional_bool(
values.get("cast_norm_output_to_input_dtype"), True
),
"random_seed": _coerce_seed(values.get("random_seed")),
"packing": values.get("packing", False),
"optim": values.get("optim", "adamw_8bit"),
"lr_scheduler_type": values.get("lr_scheduler_type", "linear"),
"use_lora": values.get("use_lora", True),
"lora_r": values.get("lora_r", 16),
"lora_alpha": values.get("lora_alpha", 16),
"lora_dropout": values.get("lora_dropout", 0.0),
"target_modules": values.get("target_modules"),
"gradient_checkpointing": values.get("gradient_checkpointing", "unsloth"),
"use_rslora": values.get("use_rslora", False),
"use_loftq": values.get("use_loftq", False),
"train_on_completions": values.get("train_on_completions", False),
"finetune_vision_layers": values.get("finetune_vision_layers", True),
"finetune_language_layers": values.get("finetune_language_layers", True),
"finetune_attention_modules": values.get("finetune_attention_modules", True),
"finetune_mlp_modules": values.get("finetune_mlp_modules", True),
"enable_wandb": values.get("enable_wandb", False),
"wandb_token": values.get("wandb_token"),
"wandb_project": values.get("wandb_project", "unsloth-training"),
"enable_tensorboard": values.get("enable_tensorboard", False),
"tensorboard_dir": values.get("tensorboard_dir", "runs"),
"resume_from_checkpoint": values.get("resume_from_checkpoint"),
"trust_remote_code": values.get("trust_remote_code", False),
"approved_remote_code_fingerprint": values.get("approved_remote_code_fingerprint"),
"subject": values.get("subject"),
"gpu_ids": values.get("gpu_ids"),
"s3_config": values.get("s3_config"),
"disable_xet": values.get("disable_xet", False),
}
for key in ("output_dir", "allow_external_output_dir"):
if key in values:
config[key] = values.get(key)
if config["training_type"] == "Full Finetuning":
config["load_in_4bit"] = False
return config
_HF_TMP_CHECKPOINT_RE = re.compile(r"^tmp-checkpoint-\d+$")
@ -133,7 +236,7 @@ def _s3_dataset_name(s3_dataset: Any) -> Optional[str]:
return f"s3://{bucket}/{prefix}" if prefix else f"s3://{bucket}"
def _cleanup_cancelled_checkpoints(output_dir: str | os.PathLike) -> None:
def _cleanup_cancelled_checkpoints(output_dir: Union[str, os.PathLike]) -> None:
"""Remove only HF Trainer ``tmp-checkpoint-<step>/`` partials after a cancel.
Completed ``checkpoint-<int>/`` dirs survive. Symlinked output_dir / children
@ -183,7 +286,7 @@ PLOT_HEIGHT = 3.5
@dataclass
class TrainingProgress:
"""Mirror of trainer.TrainingProgress so the parent never imports heavy ML modules."""
"""Shared training progress payload for Studio and backend-aware trainers."""
epoch: float = 0
step: int = 0
@ -200,6 +303,423 @@ class TrainingProgress:
num_tokens: Optional[int] = None
eval_loss: Optional[float] = None
peak_memory_gb: Optional[float] = None
output_dir: Optional[str] = None
class _MLXTrainerAdapter:
"""Adapts the legacy UnslothTrainer API to the shared Studio MLX worker path."""
def __init__(self):
self.model = None
self.tokenizer = None
self.trainer = None
self.training_thread = None
self.training_progress = TrainingProgress()
self.progress_callbacks: list[Callable[[TrainingProgress], None]] = []
self.is_training = False
self.should_stop = False
self.save_on_stop = True
self.load_in_4bit = True
self.output_dir = None
self.is_cpt = False
self.is_vlm = False
self.is_audio = False
self.is_audio_vlm = False
self.model_name = None
self.max_seq_length = None
self._model_config: dict[str, Any] = {}
self._peft_config: dict[str, Any] = {}
self._dataset_config: dict[str, Any] = {}
self._event_queue: Optional[queue.Queue] = None
self._stop_queue: Optional[queue.Queue] = None
self._pump_thread: Optional[threading.Thread] = None
self._lock = threading.Lock()
def _activate_transformers_for_model(self, model_name: str, hf_token: Optional[str]) -> None:
try:
from utils.transformers_version import activate_transformers_for_subprocess
activate_transformers_for_subprocess(model_name, hf_token)
except Exception as exc:
logger.warning("MLX trainer adapter Transformers activation failed", error = str(exc))
def add_progress_callback(self, callback: Callable[[TrainingProgress], None]):
self.progress_callbacks.append(callback)
def _update_progress(self, **kwargs):
with self._lock:
for key, value in kwargs.items():
if hasattr(self.training_progress, key):
setattr(self.training_progress, key, value)
progress = self.training_progress
for callback in self.progress_callbacks:
try:
callback(progress)
except Exception:
pass
def load_model(
self,
model_name: str,
max_seq_length: int = 2048,
load_in_4bit: bool = True,
hf_token: Optional[str] = None,
is_dataset_image: bool = False,
is_dataset_audio: bool = False,
trust_remote_code: bool = False,
full_finetuning: bool = False,
gpu_ids: Optional[list[int]] = None,
) -> bool:
self.model_name = model_name
self.max_seq_length = max_seq_length
self.load_in_4bit = load_in_4bit
self._audio_type = None
self._activate_transformers_for_model(model_name, hf_token)
try:
from utils.models import detect_audio_type, is_vision_model
self._audio_type = detect_audio_type(model_name, hf_token)
if self._audio_type == "audio_vlm":
self.is_audio = False
self.is_audio_vlm = bool(is_dataset_audio)
self._audio_type = None
else:
self.is_audio = self._audio_type is not None
self.is_audio_vlm = False
vision = is_vision_model(model_name, hf_token = hf_token) if not self.is_audio else False
self.is_vlm = not self.is_audio_vlm and vision and bool(is_dataset_image)
except Exception as exc:
logger.warning("MLX trainer adapter model type detection failed", error = str(exc))
self.is_vlm = False
self.is_audio = False
self.is_audio_vlm = False
self.model = object()
self.tokenizer = object()
self._model_config = {
"model_name": model_name,
"max_seq_length": max_seq_length,
"load_in_4bit": load_in_4bit,
"hf_token": hf_token or "",
"is_dataset_image": bool(is_dataset_image),
"is_dataset_audio": bool(is_dataset_audio),
"trust_remote_code": bool(trust_remote_code),
"gpu_ids": gpu_ids,
}
self._update_progress(
is_training = False,
is_completed = False,
error = None,
step = 0,
loss = 0.0,
epoch = 0,
status_message = f"Queued MLX model load: {model_name}",
)
return True
def prepare_model_for_training(
self,
use_lora: bool = True,
finetune_vision_layers: bool = True,
finetune_language_layers: bool = True,
finetune_attention_modules: bool = True,
finetune_mlp_modules: bool = True,
target_modules: Optional[Union[list, str]] = None,
lora_r: int = 16,
lora_alpha: int = 16,
lora_dropout: float = 0.0,
use_gradient_checkpointing: Union[str, bool] = "unsloth",
use_rslora: bool = False,
use_loftq: bool = False,
) -> bool:
self._peft_config = {
"use_lora": bool(use_lora),
"lora_r": lora_r,
"lora_alpha": lora_alpha,
"lora_dropout": lora_dropout,
"target_modules": target_modules,
"gradient_checkpointing": use_gradient_checkpointing,
"use_rslora": bool(use_rslora),
"use_loftq": bool(use_loftq),
"finetune_vision_layers": bool(finetune_vision_layers),
"finetune_language_layers": bool(finetune_language_layers),
"finetune_attention_modules": bool(finetune_attention_modules),
"finetune_mlp_modules": bool(finetune_mlp_modules),
}
self._update_progress(status_message = "Queued MLX training setup")
return True
def load_and_format_dataset(
self,
dataset_source: Optional[str],
format_type: str = "auto",
local_datasets: Optional[list[str]] = None,
local_eval_datasets: Optional[list[str]] = None,
custom_format_mapping: Optional[dict[str, Any]] = None,
subset: Optional[str] = None,
train_split: str = "train",
eval_split: Optional[str] = None,
dataset_streaming: bool = False,
eval_steps: float = 0.00,
dataset_slice_start: Optional[int] = None,
dataset_slice_end: Optional[int] = None,
is_cpt: bool = False,
s3_config: dict = None,
) -> Optional[tuple]:
self._dataset_config = {
"hf_dataset": dataset_source or "",
"local_datasets": local_datasets,
"local_eval_datasets": local_eval_datasets,
"format_type": format_type or "",
"custom_format_mapping": custom_format_mapping,
"subset": subset,
"train_split": train_split or "train",
"eval_split": eval_split,
"dataset_streaming": bool(dataset_streaming),
"eval_steps": eval_steps or 0.0,
"dataset_slice_start": dataset_slice_start,
"dataset_slice_end": dataset_slice_end,
"s3_config": s3_config,
}
self.is_cpt = bool(is_cpt)
self._update_progress(status_message = "Queued MLX dataset load")
return ({"dataset": [], "final_format": "deferred_mlx_cli", "success": True}, None)
def start_training(
self,
dataset = None,
eval_dataset = None,
**training_args,
) -> bool:
if self.is_training and self.training_thread and self.training_thread.is_alive():
return False
if self._pump_thread and self._pump_thread.is_alive():
self._pump_thread.join(timeout = 2.0)
if self._pump_thread.is_alive():
self._update_progress(error = "Previous training event pump is still finalizing")
return False
if not self._model_config:
self._update_progress(error = "Model not loaded")
return False
if not self._dataset_config:
self._update_progress(error = "Dataset not loaded")
return False
if self.is_cpt:
self._update_progress(
error = "Continued Pretraining is not supported for MLX training yet.",
is_training = False,
is_completed = False,
)
return False
config = self._build_worker_config(training_args)
event_queue = queue.Queue()
stop_queue = queue.Queue()
self._event_queue = event_queue
self._stop_queue = stop_queue
self.should_stop = False
self.is_training = True
self.training_progress = TrainingProgress(
is_training = True,
status_message = "Initializing MLX training...",
)
self.training_thread = threading.Thread(
target = self._run_training_thread,
args = (config, event_queue, stop_queue),
daemon = True,
)
self._pump_thread = threading.Thread(
target = self._pump_events,
args = (event_queue, self.training_thread),
daemon = True,
)
self.training_thread.start()
self._pump_thread.start()
return True
def _build_worker_config(self, training_args: dict[str, Any]) -> dict[str, Any]:
peft = {
"use_lora": True,
"lora_r": 16,
"lora_alpha": 16,
"lora_dropout": 0.0,
"target_modules": None,
"gradient_checkpointing": "unsloth",
"use_rslora": False,
"use_loftq": False,
"finetune_vision_layers": True,
"finetune_language_layers": True,
"finetune_attention_modules": True,
"finetune_mlp_modules": True,
**self._peft_config,
}
output_dir = training_args.get("output_dir")
if output_dir:
output_dir = os.path.abspath(os.path.expanduser(str(output_dir)))
values = {
**self._model_config,
**self._dataset_config,
**training_args,
"training_type": (
"Continued Pretraining"
if self.is_cpt
else "LoRA/QLoRA"
if peft["use_lora"]
else "Full Finetuning"
),
**peft,
"output_dir": output_dir,
"allow_external_output_dir": bool(output_dir),
}
config = _build_training_worker_config(values)
config["resolved_gpu_ids"] = None
config["gpu_selection"] = None
return config
def _run_training_thread(
self, config: dict[str, Any], event_queue: queue.Queue, stop_queue: queue.Queue
):
try:
self._run_mlx_worker(config, event_queue, stop_queue)
except Exception as exc:
if event_queue is not None:
event_queue.put(
{
"type": "error",
"error": str(exc),
"stack": traceback.format_exc(limit = 20),
"ts": time.time(),
}
)
def _run_mlx_worker(
self, config: dict[str, Any], event_queue: queue.Queue, stop_queue: queue.Queue
):
from .worker import run_mlx_training_process
run_mlx_training_process(
event_queue = event_queue,
stop_queue = stop_queue,
config = config,
)
def _pump_events(self, event_queue: queue.Queue, training_thread: threading.Thread):
while True:
event = None
try:
event = event_queue.get(timeout = 0.25)
except queue.Empty:
pass
if event is not None:
self._handle_event(event)
continue
if not training_thread.is_alive():
self._drain_events(event_queue)
with self._lock:
if self.training_progress.is_training:
self.training_progress.is_training = False
if self.should_stop:
self.training_progress.status_message = "Training stopped."
elif (
not self.training_progress.error
and not self.training_progress.is_completed
):
self.training_progress.error = "Training process exited unexpectedly"
self.is_training = False
self._event_queue = None
self._stop_queue = None
return
def _drain_events(self, event_queue: Optional[queue.Queue] = None):
event_queue = event_queue or self._event_queue
if event_queue is None:
return
while True:
try:
self._handle_event(event_queue.get_nowait())
except queue.Empty:
return
def _handle_event(self, event: dict[str, Any]):
etype = event.get("type")
if etype == "status":
self._update_progress(
status_message = event.get("status_message") or event.get("message") or ""
)
return
if etype == "progress":
self._update_progress(
step = event.get("step", self.training_progress.step),
epoch = event.get("epoch", self.training_progress.epoch),
loss = event.get("loss", self.training_progress.loss),
learning_rate = event.get("learning_rate", self.training_progress.learning_rate),
total_steps = event.get("total_steps", self.training_progress.total_steps),
elapsed_seconds = event.get(
"elapsed_seconds",
self.training_progress.elapsed_seconds,
),
eta_seconds = event.get("eta_seconds", self.training_progress.eta_seconds),
grad_norm = event.get("grad_norm", self.training_progress.grad_norm),
num_tokens = event.get("num_tokens", self.training_progress.num_tokens),
eval_loss = event.get("eval_loss", self.training_progress.eval_loss),
peak_memory_gb = event.get("peak_memory_gb", self.training_progress.peak_memory_gb),
)
return
if etype == "complete":
status_message = event.get("status_message") or "Training completed"
output_dir = event.get("output_dir")
was_cancelled = self.should_stop or status_message.strip().lower() in {
"training cancelled",
"training stopped",
}
self.output_dir = output_dir
self._update_progress(
is_training = False,
is_completed = not was_cancelled,
error = None,
status_message = status_message,
output_dir = output_dir,
)
self.is_training = False
return
if etype == "error":
self._update_progress(
is_training = False,
is_completed = False,
error = event.get("error") or event.get("message") or "Training failed",
)
self.is_training = False
return
def stop_training(self, save: bool = True):
self.should_stop = True
self.save_on_stop = bool(save)
if self._stop_queue is not None:
self._stop_queue.put({"type": "stop", "save": save})
status_message = (
"Stopping training and saving checkpoint..." if save else "Cancelling training..."
)
self._update_progress(status_message = status_message)
return True
def get_training_progress(self) -> TrainingProgress:
pump_thread = self._pump_thread
training_thread = self.training_thread
if (
pump_thread is not None
and pump_thread.is_alive()
and (training_thread is None or not training_thread.is_alive())
and threading.current_thread() is not pump_thread
):
pump_thread.join(timeout = 5.0)
if pump_thread is None or not pump_thread.is_alive():
self._drain_events()
with self._lock:
return replace(self.training_progress)
def create_mlx_trainer_adapter(*args, **kwargs):
return _MLXTrainerAdapter(*args, **kwargs)
class TrainingBackend:
@ -296,86 +816,7 @@ class TrainingBackend:
# treat this fresh setup as a recoverable death.
self._pump_running = False
# Build config dict for the subprocess
config = {
"model_name": kwargs["model_name"],
"project_name": kwargs.get("project_name"),
"training_type": kwargs.get("training_type", "LoRA/QLoRA"),
"hf_token": kwargs.get("hf_token", ""),
"load_in_4bit": kwargs.get("load_in_4bit", True),
"max_seq_length": kwargs.get("max_seq_length", 2048),
"vision_image_size": kwargs.get("vision_image_size"),
"hf_dataset": kwargs.get("hf_dataset", ""),
"local_datasets": kwargs.get("local_datasets"),
"local_eval_datasets": kwargs.get("local_eval_datasets"),
"format_type": kwargs.get("format_type", ""),
"subset": kwargs.get("subset"),
"train_split": kwargs.get("train_split", "train"),
"eval_split": kwargs.get("eval_split"),
"eval_steps": kwargs.get("eval_steps", 0.00),
"dataset_streaming": kwargs.get("dataset_streaming", False),
"dataset_slice_start": kwargs.get("dataset_slice_start"),
"dataset_slice_end": kwargs.get("dataset_slice_end"),
"custom_format_mapping": kwargs.get("custom_format_mapping"),
"is_dataset_image": kwargs.get("is_dataset_image", False),
"is_dataset_audio": kwargs.get("is_dataset_audio", False),
"is_embedding": kwargs.get("is_embedding", False),
"num_epochs": kwargs.get("num_epochs", 3),
"learning_rate": kwargs.get("learning_rate", "2e-4"),
"embedding_learning_rate": kwargs.get("embedding_learning_rate"),
"batch_size": kwargs.get("batch_size", 2),
"gradient_accumulation_steps": kwargs.get("gradient_accumulation_steps", 4),
"warmup_steps": kwargs.get("warmup_steps"),
"warmup_ratio": kwargs.get("warmup_ratio"),
"max_steps": kwargs.get("max_steps", 0),
"save_steps": kwargs.get("save_steps", 0),
"weight_decay": kwargs.get("weight_decay", 0.001),
"max_grad_norm": kwargs.get("max_grad_norm", 0.0),
"max_grad_value": _coerce_optional_nonneg_float(
"max_grad_value", kwargs.get("max_grad_value")
),
"max_grad_leaf_norm": _coerce_optional_nonneg_float(
"max_grad_leaf_norm", kwargs.get("max_grad_leaf_norm")
),
"cast_norm_output_to_input_dtype": _coerce_optional_bool(
kwargs.get("cast_norm_output_to_input_dtype"), True
),
# MLX/CUDA/embedding workers need an int (transformers.set_seed(None) raises).
"random_seed": _coerce_seed(kwargs.get("random_seed")),
"packing": kwargs.get("packing", False),
"optim": kwargs.get("optim", "adamw_8bit"),
"lr_scheduler_type": kwargs.get("lr_scheduler_type", "linear"),
"use_lora": kwargs.get("use_lora", True),
"lora_r": kwargs.get("lora_r", 16),
"lora_alpha": kwargs.get("lora_alpha", 16),
"lora_dropout": kwargs.get("lora_dropout", 0.0),
"target_modules": kwargs.get("target_modules"),
"gradient_checkpointing": kwargs.get("gradient_checkpointing", "unsloth"),
"use_rslora": kwargs.get("use_rslora", False),
"use_loftq": kwargs.get("use_loftq", False),
"train_on_completions": kwargs.get("train_on_completions", False),
"finetune_vision_layers": kwargs.get("finetune_vision_layers", True),
"finetune_language_layers": kwargs.get("finetune_language_layers", True),
"finetune_attention_modules": kwargs.get("finetune_attention_modules", True),
"finetune_mlp_modules": kwargs.get("finetune_mlp_modules", True),
"enable_wandb": kwargs.get("enable_wandb", False),
"wandb_token": kwargs.get("wandb_token"),
"wandb_project": kwargs.get("wandb_project", "unsloth-training"),
"enable_tensorboard": kwargs.get("enable_tensorboard", False),
"tensorboard_dir": kwargs.get("tensorboard_dir", "runs"),
"resume_from_checkpoint": kwargs.get("resume_from_checkpoint"),
"trust_remote_code": kwargs.get("trust_remote_code", False),
"approved_remote_code_fingerprint": kwargs.get("approved_remote_code_fingerprint"),
"subject": kwargs.get("subject"),
"gpu_ids": kwargs.get("gpu_ids"),
"s3_config": kwargs.get("s3_config"),
# Flipped to True only by the HTTP-fallback respawn after a stall.
"disable_xet": kwargs.get("disable_xet", False),
}
# Full finetuning always runs in 16-bit; LoRA/QLoRA/CPT keep the request.
if config["training_type"] == "Full Finetuning":
config["load_in_4bit"] = False
config = _build_training_worker_config(kwargs)
# Split GPU validation from placement around the VRAM hook:
# * Explicit gpu_ids are validated here (raises -> the route returns 400
@ -401,7 +842,7 @@ class TrainingBackend:
)
defer_auto_selection = False
if _hw.DEVICE == _hw.DeviceType.MLX:
if should_use_mlx_training_backend(device = _hw.DEVICE):
config["resolved_gpu_ids"] = None
config["gpu_selection"] = None
elif gpu_ids:
@ -1022,17 +1463,22 @@ class TrainingBackend:
self._progress.is_training = True
elif etype == "complete":
self._progress.is_training = False
self._progress.is_completed = True
self._output_dir = event.get("output_dir")
msg = event.get("status_message", "Training completed")
stopped = self._should_stop or msg.strip().lower() in {
"training cancelled",
"training stopped",
}
self._progress.is_training = False
self._progress.is_completed = not stopped
self._output_dir = event.get("output_dir")
self._progress.output_dir = self._output_dir
self._progress.status_message = msg
if not self._db_run_created and self.current_job_id and self._db_config:
db_action = "create_and_finalize"
else:
db_action = "finalize"
db_action_kwargs = {
"status": "stopped" if self._should_stop else "completed",
"status": "stopped" if stopped else "completed",
"output_dir": self._output_dir,
}

View file

@ -1309,14 +1309,18 @@ def _normalize_mlx_studio_scheduler(value):
def _resolve_mlx_local_dataset_files(file_paths: list) -> list[str]:
"""Resolve Studio local dataset uploads without importing the GPU trainer."""
"""Resolve CLI paths and Studio local dataset uploads without importing the GPU trainer."""
from utils.paths import resolve_dataset_path
all_files: list[str] = []
for dataset_file in file_paths or []:
file_path = (
dataset_file if os.path.isabs(dataset_file) else str(resolve_dataset_path(dataset_file))
)
dataset_path = Path(os.path.expanduser(str(dataset_file)))
if dataset_path.is_absolute():
file_path = str(dataset_path)
elif dataset_path.exists():
file_path = str(dataset_path.resolve())
else:
file_path = str(resolve_dataset_path(str(dataset_file)))
file_path_obj = Path(file_path)
if file_path_obj.is_dir():
@ -1355,6 +1359,58 @@ def _mlx_local_dataset_loader_for_files(files: list[str]) -> str:
raise ValueError(f"Unsupported dataset format: {files[0]}")
_MLX_WORKER_COMPLETE = "_mlx_worker_complete"
def _start_mlx_stop_poller(stop_queue):
import queue as _queue
import threading
stop_save = [True]
stop_requested = [False]
trainer_ref = [None]
def is_stop_requested():
return stop_requested[0]
def poll_stop():
while True:
try:
msg = stop_queue.get(timeout = 0.25)
if msg and msg.get("type") == _MLX_WORKER_COMPLETE:
return
if msg and msg.get("type") == "stop":
stop_save[0] = msg.get("save", True)
stop_requested[0] = True
trainer = trainer_ref[0]
if trainer is not None:
trainer.stop_requested = True
return
except _queue.Empty:
continue
except (EOFError, OSError):
return
stop_thread = threading.Thread(target = poll_stop, daemon = True)
stop_thread.start()
return stop_save, stop_requested, trainer_ref, is_stop_requested, stop_thread
def _resolve_mlx_output_dir(config, model_name):
from utils.paths import resolve_output_dir, default_run_dir_name
output_dir = config.get("output_dir", "")
if not output_dir:
output_dir = f"{default_run_dir_name(model_name)}_{int(time.time())}"
return str(resolve_output_dir(output_dir))
if config.get("allow_external_output_dir"):
output_path = Path(output_dir).expanduser()
if not output_path.is_absolute():
output_path = Path.cwd() / output_path
return str(output_path.resolve())
return str(resolve_output_dir(output_dir))
def _run_mlx_training(event_queue, stop_queue, config):
"""Self-contained MLX training path for Apple Silicon.
@ -1363,8 +1419,6 @@ def _run_mlx_training(event_queue, stop_queue, config):
"""
import time
import math
import threading
import queue as _queue
from pathlib import Path
def _send(event_type, **kwargs):
@ -1374,31 +1428,9 @@ def _run_mlx_training(event_queue, stop_queue, config):
kwargs["message"] = sm
event_queue.put({"type": event_type, "ts": time.time(), **kwargs})
_stop_save = [True]
_stop_requested = [False]
_trainer_ref = [None]
def _is_stop_requested():
return _stop_requested[0]
def _poll_stop():
while True:
try:
msg = stop_queue.get(timeout = 1.0)
if msg and msg.get("type") == "stop":
_stop_save[0] = msg.get("save", True)
_stop_requested[0] = True
trainer = _trainer_ref[0]
if trainer is not None:
trainer.stop_requested = True
return
except _queue.Empty:
continue
except (EOFError, OSError):
return
stop_thread = threading.Thread(target = _poll_stop, daemon = True)
stop_thread.start()
_stop_save, _stop_requested, _trainer_ref, _is_stop_requested, _stop_thread = (
_start_mlx_stop_poller(stop_queue)
)
_send("status", status_message = "Loading MLX libraries...")
@ -1699,6 +1731,7 @@ def _run_mlx_training(event_queue, stop_queue, config):
# sharegpt+images) and text (alpaca/sharegpt/chatml → "text" column).
format_type = config.get("format_type", "")
custom_format_mapping = config.get("custom_format_mapping")
dataset_final_format = ""
try:
from utils.datasets import format_and_template_dataset
def _fmt_progress(status_message = "", **_kw):
@ -1764,6 +1797,7 @@ def _run_mlx_training(event_queue, stop_queue, config):
)
if info.get("success", True):
dataset = info.get("dataset", dataset)
dataset_final_format = str(info.get("final_format", "") or "").lower()
if eval_dataset is not None:
ev = format_and_template_dataset(
eval_dataset,
@ -1804,21 +1838,14 @@ def _run_mlx_training(event_queue, stop_queue, config):
# ── 5. Build output dir ──
# Resolve to ~/.unsloth/studio/outputs/ so the export page finds it
from utils.paths import resolve_output_dir, ensure_dir
from utils.paths import ensure_dir
output_dir = config.get("output_dir", "")
if not output_dir:
output_dir = build_default_output_dir_name(
model_name,
config.get("project_name"),
)
output_dir = str(resolve_output_dir(output_dir))
output_dir = _resolve_mlx_output_dir(config, model_name)
ensure_dir(Path(output_dir))
# ── 6. Create trainer ──
eval_steps_val = config.get("eval_steps", 0) or 0
if isinstance(eval_steps_val, float) and 0 < eval_steps_val < 1:
# Studio sometimes sends fraction-of-total-steps
eval_steps_val = max(1, int(eval_steps_val * max_steps))
else:
eval_steps_val = int(eval_steps_val)
@ -1869,6 +1896,9 @@ def _run_mlx_training(event_queue, stop_queue, config):
eval_steps = eval_steps_val,
)
# Also gates the masking skip below, so defined outside the feature-detect block.
raw_text_mode = training_type == "Continued Pretraining" or format_type == "raw"
# Feature-detect optional fields so this PR works without the paired zoo bump.
_supported_fields = getattr(MLXTrainingConfig, "__dataclass_fields__", {})
if "cast_norm_output_to_input_dtype" in _supported_fields:
@ -1882,7 +1912,6 @@ def _run_mlx_training(event_queue, stop_queue, config):
if "max_grad_leaf_norm" in _supported_fields:
mlx_config_kwargs["max_grad_leaf_norm"] = max_grad_leaf_norm
if "append_eos" in _supported_fields:
raw_text_mode = training_type == "Continued Pretraining" or format_type == "raw"
# Studio SFT formatting owns rendered examples; raw/CPT text still
# needs MLX to append EOS like the CUDA raw-text path.
mlx_config_kwargs["append_eos"] = bool(raw_text_mode)
@ -1903,29 +1932,27 @@ def _run_mlx_training(event_queue, stop_queue, config):
_send("eval_configured")
# ── 7. Apply train_on_responses_only if requested ──
if config.get("train_on_completions", False):
# Auto-detect markers from the chat template first, manual table as
# fallback. Mirror the CUDA skips: raw/CPT text has no chat turns and
# Alpaca-rendered text lacks the chat markers. Also check the resolved
# format, since format_type="auto" can land on alpaca or raw text.
if (
config.get("train_on_completions", False)
and not raw_text_mode
and format_type != "alpaca"
and dataset_final_format not in ("alpaca", "raw_text")
):
_send("status", status_message = "Configuring response-only training...")
try:
from utils.datasets import (
MODEL_TO_TEMPLATE_MAPPER,
TEMPLATE_TO_RESPONSES_MAPPER,
)
template_name = MODEL_TO_TEMPLATE_MAPPER.get(model_name.lower())
markers = TEMPLATE_TO_RESPONSES_MAPPER.get(template_name) if template_name else None
if markers:
trainer = train_on_responses_only(
trainer,
instruction_part = markers["instruction"],
response_part = markers["response"],
)
else:
_send(
"status",
status_message = f"train_on_completions skipped (no template for {model_name})",
)
except Exception as e:
_send("status", status_message = f"train_on_completions failed: {e}")
# No catch: the helper handles detection failures and double misses, so
# an exception here is a real masking failure that must fail the run,
# not silently train on full sequences.
from utils.datasets.completion_masking import apply_completion_masking
trainer, _masking_applied = apply_completion_masking(
trainer,
model_name,
train_on_responses_only,
notify = lambda level, message: _send("status", status_message = message),
)
# ── 8. Setup wandb / tensorboard ──
wandb_run = None
@ -2043,12 +2070,27 @@ def _run_mlx_training(event_queue, stop_queue, config):
# ── 11. Run training ──
gc.collect()
mx.synchronize()
trainer.train(resume_from_checkpoint = resume_from_checkpoint)
_save_model = trainer.save_model
def _skip_internal_final_save(*args, **kwargs):
raise ValueError("worker owns final save")
trainer.save_model = _skip_internal_final_save
try:
trainer.train(resume_from_checkpoint = resume_from_checkpoint)
finally:
trainer.save_model = _save_model
# ── 12. Save and finalize ──
if trainer.stop_requested and not _stop_save[0]:
# User clicked "Cancel" (save=False) — skip saving
_send("complete", output_dir = None, status_message = "Training cancelled")
if trainer.stop_requested:
if not _stop_save[0]:
# Cancel (save=False): skip saving.
_send("complete", output_dir = None, status_message = "Training cancelled")
else:
_send("status", status_message = "Saving stopped model...")
mx.synchronize()
trainer.save_model(output_dir)
_send("complete", output_dir = output_dir, status_message = "Training stopped")
else:
_send("status", status_message = "Saving model...")
mx.synchronize()
@ -2067,6 +2109,79 @@ def _run_mlx_training(event_queue, stop_queue, config):
pass
def _is_current_process_apple_silicon() -> bool:
import platform
return platform.system() == "Darwin" and platform.machine() == "arm64"
def run_mlx_training_process(
*,
event_queue: Any,
stop_queue: Any,
config: dict,
transformers_activated: bool = False,
) -> None:
"""MLX worker entrypoint shared by Studio subprocesses and the CLI adapter."""
model_name = config["model_name"]
backend_path = str(Path(__file__).resolve().parent.parent.parent)
if backend_path not in sys.path:
sys.path.insert(0, backend_path)
from utils.hf_xet_fallback import child_should_disable_xet
if child_should_disable_xet(config):
os.environ["HF_HUB_DISABLE_XET"] = "1"
os.environ["HF_HUB_ENABLE_HF_TRANSFER"] = "0"
if not transformers_activated:
# Must precede detect_hardware(): its MLX stack check imports mlx_lm, hence transformers.
_activate_transformers_version_or_warn(model_name, config.get("hf_token") or None)
from utils.hardware import hardware as _hw
_hw.detect_hardware()
if _hw.DEVICE != _hw.DeviceType.MLX:
event_queue.put(
{
"type": "error",
"error": "MLX training requires Apple Silicon with the MLX backend available.",
"stack": "",
"ts": time.time(),
}
)
return
if config.get("is_dataset_audio"):
event_queue.put(
{
"type": "error",
"error": "Audio dataset training is not yet supported on Apple Silicon.",
"stack": "",
"ts": time.time(),
}
)
return
try:
try:
_run_mlx_training(event_queue, stop_queue, config)
finally:
try:
stop_queue.put({"type": _MLX_WORKER_COMPLETE})
except (EOFError, OSError, ValueError):
pass
except Exception as exc:
event_queue.put(
{
"type": "error",
"error": str(exc),
"stack": traceback.format_exc(limit = 20),
"ts": time.time(),
}
)
def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) -> None:
"""Subprocess entrypoint. Fresh Python — no stale module state.
@ -2141,36 +2256,26 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
if backend_path not in sys.path:
sys.path.insert(0, backend_path)
from .training import is_apple_silicon_training_platform, should_use_mlx_training_backend
mlx_backend_requested = is_apple_silicon_training_platform()
mlx_transformers_activated = False
if mlx_backend_requested and _is_current_process_apple_silicon():
# Must precede detect_hardware(): its MLX stack check imports mlx_lm, hence transformers.
_activate_transformers_version_or_warn(model_name, config.get("hf_token") or None)
mlx_transformers_activated = True
from utils.hardware import hardware as _hw
_hw.detect_hardware()
if _hw.DEVICE == _hw.DeviceType.MLX:
if config.get("is_dataset_audio"):
event_queue.put(
{
"type": "error",
"error": "Audio dataset training is not yet supported on Apple Silicon.",
"stack": "",
"ts": time.time(),
}
)
return
# Activate correct transformers version (Gemma-4 needs a 5.x sidecar, etc.)
# Must happen before any transformers/mlx-lm imports in _run_mlx_training.
# Non-fatal: fall through with whatever version is installed, but log
# the failure instead of swallowing it (issue #6103).
_activate_transformers_version_or_warn(model_name, config.get("hf_token") or None)
try:
_run_mlx_training(event_queue, stop_queue, config)
except Exception as exc:
event_queue.put(
{
"type": "error",
"error": str(exc),
"stack": traceback.format_exc(limit = 20),
"ts": time.time(),
}
)
if mlx_backend_requested or should_use_mlx_training_backend(device = _hw.DEVICE):
run_mlx_training_process(
event_queue = event_queue,
stop_queue = stop_queue,
config = config,
transformers_activated = mlx_transformers_activated,
)
return
# ── 1. Activate correct transformers version BEFORE any ML imports ──
@ -2693,7 +2798,8 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
if backend_path not in sys.path:
sys.path.insert(0, backend_path)
from core.training.trainer import UnslothTrainer, TrainingProgress
from core.training.training import TrainingProgress
from core.training.trainer import UnslothTrainer
from utils.paths import (
ensure_dir,
resolve_output_dir,

View file

@ -105,6 +105,10 @@ def test_repo_id_validation_accepts_hf_repo_id_contract(repo_id):
assert paths.is_valid_repo_id(repo_id)
def test_repo_id_validation_accepts_max_length_namespaced_repo():
assert paths.is_valid_repo_id(f"{'a' * 96}/{'b' * 96}")
@pytest.mark.parametrize(
"repo_id",
[
@ -121,6 +125,48 @@ def test_repo_id_validation_rejects_unsafe_or_invalid_ids(repo_id):
assert not paths.is_valid_repo_id(repo_id)
def test_download_state_preserves_readable_keys_when_safe(monkeypatch, tmp_path):
monkeypatch.setattr(state_dir, "cache_root", lambda: tmp_path)
path = state_dir.marker_path("model", "Owner/Repo", "Q4_K_M")
assert path is not None
assert path.name == "models--owner--repo--variant--q4_k_m.json"
@pytest.mark.parametrize("variant", ["bad variant with spaces", "q" * 64])
def test_download_state_bounds_long_repo_variant_filenames(monkeypatch, tmp_path, variant):
monkeypatch.setattr(state_dir, "cache_root", lambda: tmp_path)
repo_id = f"{'a' * 96}/{'b' * 96}"
assert paths.is_valid_repo_id(repo_id)
assert download_manifest.write_cancel_marker("model", repo_id, variant, "http")
assert download_manifest.write_manifest(
"model",
repo_id,
variant,
[download_manifest.ExpectedFile(path = "model.gguf", size = 1)],
"http",
)
marker_path = state_dir.marker_path("model", repo_id, variant)
manifest_path = state_dir.manifest_path("model", repo_id, variant)
assert marker_path is not None
assert manifest_path is not None
assert "--sha256-" in marker_path.name
assert len(marker_path.name.encode("utf-8")) <= 255
assert len(f".{marker_path.name}.tmp-00000000".encode("utf-8")) <= 255
assert download_manifest.has_cancel_marker("model", repo_id, variant)
assert download_manifest.read_manifest("model", repo_id, variant) is not None
assert list(download_manifest.iter_variant_markers("model", repo_id)) == [
(variant, marker_path)
]
assert list(download_manifest.iter_variant_manifests("model", repo_id)) == [
(variant, manifest_path)
]
class _RecordingLogger:
def __init__(self):
self.warnings = []

View file

@ -102,16 +102,17 @@ def preferred_mmproj_sibling(siblings: Sequence) -> Optional[object]:
def preferred_mtp_sibling(siblings: Sequence) -> Optional[object]:
"""The separate MTP drafter to fetch with every variant: the repo-root
``mtp-*.gguf`` copy unsloth ships for llama.cpp ``-hf`` auto-discovery
(Gemma 4). Same pick as the loader's drafter resolution (``mtp-`` basename
prefix, first in sort order) so download and load resolve the same file;
the higher-precision ``MTP/`` subdir copies are for explicit selection and
are not auto-fetched. None for repos with the head baked into the main
GGUF (Qwen)."""
(Gemma 4). Same pick as the loader's drafter resolution (root-level
``mtp-`` prefix, first in sort order) so download and load resolve the same
file; the higher-precision ``MTP/`` subdir copies are for explicit
selection and are not auto-fetched. None for repos with the head baked into
the main GGUF (Qwen)."""
# Root-level only: the MTP/ subdir copies now share the mtp- prefix too.
candidates = sorted(
(
s
for s in siblings
if (name := _gguf_rfilename(s)) and name.lower().rsplit("/", 1)[-1].startswith("mtp-")
if (name := _gguf_rfilename(s)) and "/" not in name and name.lower().startswith("mtp-")
),
key = lambda s: getattr(s, "rfilename"),
)

View file

@ -181,15 +181,20 @@ def is_valid_repo_id(repo_id: str) -> bool:
"""Validate Hugging Face ``repo_name`` or ``namespace/repo_name`` IDs."""
if not repo_id or repo_id != repo_id.strip():
return False
if len(repo_id) > _MAX_REPO_ID_LENGTH or repo_id.endswith(".git"):
if repo_id.endswith(".git"):
return False
if "--" in repo_id or ".." in repo_id:
return False
segments = repo_id.split("/")
if len(segments) not in (1, 2):
return False
# Match huggingface_hub.validate_repo_id: the 96-char limit applies per
# segment (repo name / namespace), not to the whole "namespace/repo_name"
# string, so long-but-valid repo names are not falsely rejected.
return all(
segment not in ("", ".", "..") and _VALID_REPO_ID_SEGMENT.fullmatch(segment) is not None
segment not in ("", ".", "..")
and len(segment) <= _MAX_REPO_ID_LENGTH
and _VALID_REPO_ID_SEGMENT.fullmatch(segment) is not None
for segment in segments
)

View file

@ -11,8 +11,9 @@ cache lifecycle. Two subdirectories:
manifests/ <key>.json per-download expected-files manifest
cancelled/ <key>.json per-download cancel marker
The ``<key>`` mirrors HF's cache dir naming so a state file can be
eyeballed next to the on-disk repo it describes:
The ``<key>`` mirrors HF's cache dir naming while the resulting manifest,
cancel-marker, and atomic-write temp filenames fit common filesystem basename
limits. Very long repo IDs use a stable hash in the state key:
models--<owner>--<name> full snapshot
models--<owner>--<name>--variant--<variant> GGUF variant
@ -49,6 +50,11 @@ _MANIFESTS_SUBDIR = "manifests"
_CANCELLED_SUBDIR = "cancelled"
_WORKERS_SUBDIR = "workers"
_SAFE_VARIANT_FRAGMENT = re.compile(r"^[a-z0-9._-]{1,64}$")
_MAX_STATE_BASENAME_BYTES = 255
_STATE_EXTENSION = ".json"
# _atomic_write_json writes ".<target>.tmp-<8hex>" beside the final file.
_ATOMIC_WRITE_TMP_OVERHEAD = len(".") + len(".tmp-") + 8
_MAX_VARIANT_FRAGMENT_LENGTH = 64
def state_root() -> Optional[Path]:
@ -84,16 +90,35 @@ def repo_cache_basename(repo_type: RepoType, repo_id: str) -> str:
return f"{repo_type}s--{repo_id.replace('/', '--')}".lower()
def _filename_bytes(name: str) -> int:
return len(name.encode("utf-8"))
def _state_filename_fits(entry_key: str) -> bool:
filename = f"{entry_key}{_STATE_EXTENSION}"
return _filename_bytes(filename) + _ATOMIC_WRITE_TMP_OVERHEAD <= _MAX_STATE_BASENAME_BYTES
def _state_repo_key(repo_type: RepoType, repo_id: str) -> str:
base = repo_cache_basename(repo_type, repo_id)
variant_prefix = f"{base}--variant--"
longest_variant_key = f"{variant_prefix}{'x' * _MAX_VARIANT_FRAGMENT_LENGTH}"
if _state_filename_fits(longest_variant_key):
return base
digest = hashlib.sha256(base.encode("utf-8")).hexdigest()[:32]
return f"{repo_type}s--sha256-{digest}"
def variant_filename_prefix(repo_type: RepoType, repo_id: str) -> str:
"""Lowercased prefix every variant-keyed state file for this repo shares.
The single source the download_manifest enumerators match against, so the
scheme in :func:`_entry_key` cannot drift from them silently."""
return f"{repo_cache_basename(repo_type, repo_id)}--variant--"
return f"{_state_repo_key(repo_type, repo_id)}--variant--"
def _entry_key(repo_type: RepoType, repo_id: str, variant: Optional[str]) -> str:
base = repo_cache_basename(repo_type, repo_id)
base = _state_repo_key(repo_type, repo_id)
if not variant:
return base
normalized_variant = variant.strip().lower()

View file

@ -1533,12 +1533,41 @@ class AnthropicToolResultBlock(BaseModel):
tool_use_id: str
content: Union[str, list] = ""
@field_validator("content", mode = "before")
@classmethod
def _coerce_null_content(cls, v):
# Some clients send null content for an empty tool result; the str|list
# union would 400 on it, so treat null as "".
return "" if v is None else v
# Block types the converter translates explicitly. Anything else (thinking /
# redacted_thinking, a provider block a resumed session replays, or a future type)
# is accepted as an unknown block and dropped by the converter, rather than 400-ing
# the whole request on strict validation.
_KNOWN_ANTHROPIC_BLOCK_TYPES = frozenset({"text", "image", "tool_use", "tool_result"})
class AnthropicUnknownBlock(BaseModel):
type: str
model_config = {"extra": "allow"}
@field_validator("type")
@classmethod
def _only_unknown_types(cls, v):
# Known types parse as their typed models above (so a malformed known block
# still fails cleanly); this fallback only catches the rest.
if v in _KNOWN_ANTHROPIC_BLOCK_TYPES:
raise ValueError("known block type handled by its typed model")
return v
AnthropicContentBlock = Union[
AnthropicTextBlock,
AnthropicImageBlock,
AnthropicToolUseBlock,
AnthropicToolResultBlock,
AnthropicUnknownBlock,
]
@ -1583,6 +1612,40 @@ class AnthropicMessage(BaseModel):
role: Literal["user", "assistant"]
content: Union[str, list[AnthropicContentBlock]]
@model_validator(mode = "before")
@classmethod
def _normalize_content(cls, data):
# Role-aware leniency that never silently drops real user input:
# - assistant: a resumed tool-only turn's null content -> "" (str|list would
# 400 on null; "" keeps the converter's `for block in content` safe).
# Unknown blocks (thinking / future types) validate via
# AnthropicUnknownBlock and are dropped by the converter.
# - user: keep strict. Null user content stays None so str|list rejects it
# (400) rather than forwarding an empty prompt; and reject block types the
# converter cannot translate, since it silently skips unknown user blocks
# -- a user turn made only of them would validate yet send no content
# (silent data loss).
if not isinstance(data, dict):
return data
content = data.get("content")
if data.get("role") == "assistant":
# Coerce only an explicit null (resumed tool-only turn). A missing
# content key stays malformed so the required-field check still 400s.
if "content" in data and content is None:
return {**data, "content": ""}
return data
if isinstance(content, list):
for block in content:
btype = (
block.get("type") if isinstance(block, dict) else getattr(block, "type", None)
)
# Guard the value: a non-string type is unsupported too, and a
# membership test on an unhashable value would raise TypeError
# (escaping as a 500 instead of a clean 400).
if not isinstance(btype, str) or btype not in _KNOWN_ANTHROPIC_BLOCK_TYPES:
raise ValueError(f"unsupported content block type {btype!r} in a user message")
return data
class AnthropicTool(BaseModel):
# Client tools have input_schema; server tools may only have type/name.

View file

@ -10,6 +10,7 @@ import binascii
import json
import os
import re
import shutil
from itertools import islice
from pathlib import Path
from typing import Any
@ -59,6 +60,9 @@ UNSTRUCTURED_ALLOWED_EXTS = {".pdf", ".docx", ".txt", ".md"}
SEED_UPLOAD_DIR = seed_uploads_root()
UNSTRUCTURED_UPLOAD_ROOT = unstructured_uploads_root()
_SAFE_ID_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
# Frontend-generated upload namespace (UUID4 hex). Legacy node ids (n1, ...)
# never match: those directories can be shared by several recipes.
_UPLOAD_UID_RE = re.compile(r"^[0-9a-f]{32}$")
def _validate_safe_id(value: str, label: str) -> str:
@ -580,6 +584,39 @@ async def remove_unstructured_file(block_id: str, file_id: str):
return {"status": "ok"}
@router.delete("/seed/unstructured-block/{block_id}")
async def remove_unstructured_block(block_id: str):
"""Delete a block's upload directory; files on disk still count toward its quota.
Only uid-namespaced directories may be bulk-deleted: they have exactly one
owning block. Legacy node-id directories (n1, ...) can be shared by other
recipes, so they are managed file-by-file instead.
"""
_validate_safe_id(block_id, "block_id")
if not _UPLOAD_UID_RE.match(block_id):
raise HTTPException(400, "Invalid block_id: only uid-namespaced blocks can be deleted")
block_dir = (UNSTRUCTURED_UPLOAD_ROOT / block_id).resolve()
if not block_dir.is_relative_to(UNSTRUCTURED_UPLOAD_ROOT.resolve()):
raise HTTPException(400, "Invalid block_id: outside upload root")
if not block_dir.exists():
return {"status": "ok", "deleted": False}
try:
shutil.rmtree(block_dir)
except OSError as exc:
raise log_and_http_error(
exc,
500,
"failed to delete uploaded files",
event = "data_recipe.seed.unstructured_block_delete_failed",
log = logger,
) from exc
if block_dir.exists():
raise HTTPException(500, "failed to delete uploaded files")
return {"status": "ok", "deleted": True}
@router.post("/seed/inspect-upload", response_model = SeedInspectResponse)
def inspect_seed_upload(payload: SeedInspectUploadRequest) -> SeedInspectResponse:
if payload.file_ids is not None:

File diff suppressed because it is too large Load diff

View file

@ -32,6 +32,7 @@ from utils.helper_precache_settings import (
helper_model_disabled_by_env,
set_helper_precache_enabled,
)
from utils.coding_agents import CODING_AGENTS, detect_installed_coding_agents
from utils.openai_auto_switch_settings import (
DEFAULT_AUTO_UNLOAD_IDLE_SECONDS,
DEFAULT_OPENAI_AUTO_SWITCH_ENABLED,
@ -174,6 +175,19 @@ def update_helper_precache(
return _helper_precache_response(enabled)
class CodingAgentsResponse(BaseModel):
# All agents `unsloth start` supports, in the CLI's declared order.
agents: tuple[str, ...] = CODING_AGENTS
# Subset of `agents` whose CLI binary was found on PATH; the frontend uses
# this to default the API-keys panel to a command the user can run as-is.
detected: list[str]
@router.get("/coding-agents", response_model = CodingAgentsResponse)
def get_coding_agents(current_subject: str = Depends(get_current_subject)) -> CodingAgentsResponse:
return CodingAgentsResponse(detected = detect_installed_coding_agents())
@router.get("/openai-auto-switch", response_model = OpenAIAutoSwitchResponse)
def get_openai_auto_switch(
current_subject: str = Depends(get_current_subject),

View file

@ -1770,3 +1770,187 @@ class TestAnthropicMessagesToolRouting:
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
assert backend.calls[0][0] == "plain"
def test_resumed_session_thinking_and_null_content_do_not_400():
# A resumed session replays assistant turns with `thinking` (and sometimes null)
# content. Those must be accepted (thinking dropped by the converter), not 400ed.
from pydantic import ValidationError
req = AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "secret reasoning", "signature": "s"},
{"type": "text", "text": "the answer"},
{"type": "tool_use", "id": "t1", "name": "f", "input": {}},
],
},
{"role": "assistant", "content": None}, # tool-only turn serialized as null
],
)
# Known blocks still parse as their typed models; only the unknown one is loose.
assert type(req.messages[1].content[0]).__name__ == "AnthropicUnknownBlock"
assert type(req.messages[1].content[1]).__name__ == "AnthropicTextBlock"
assert req.messages[2].content == "" # null coerced
openai = anthropic_messages_to_openai([m.model_dump() for m in req.messages])
assistant = next(m for m in openai if m["role"] == "assistant" and m.get("content"))
assert assistant["content"] == "the answer"
assert "secret reasoning" not in json.dumps(openai) # thinking never forwarded
# A malformed KNOWN block still fails cleanly instead of being swallowed.
with pytest.raises(ValidationError):
AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [{"role": "assistant", "content": [{"type": "tool_use", "name": "f"}]}],
)
def test_user_null_content_rejected():
# The null->"" leniency is assistant-only; a null user content must be rejected
# at the boundary, not coerced into an empty prompt and forwarded to the model.
from pydantic import ValidationError
with pytest.raises(ValidationError):
AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [{"role": "user", "content": None}],
)
def test_user_unknown_block_rejected_not_silently_dropped():
# The converter skips user blocks it cannot translate, so a user turn whose only
# block is unknown would validate yet forward no content. Reject at the boundary
# to avoid that silent data loss (the assistant fallback is unaffected).
from pydantic import ValidationError
with pytest.raises(ValidationError):
AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [
{"role": "user", "content": [{"type": "document", "source": {}}]},
],
)
def test_user_translatable_blocks_still_accepted():
# text / image / tool_result are translatable, so a real user message built from
# them must still pass; the unknown-block guard only trips on other types.
req = AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "What is this?"},
{
"type": "image",
"source": {"type": "base64", "media_type": "image/png", "data": "AA"},
},
{"type": "tool_result", "tool_use_id": "t1", "content": "ok"},
],
}
],
)
assert [type(b).__name__ for b in req.messages[0].content] == [
"AnthropicTextBlock",
"AnthropicImageBlock",
"AnthropicToolResultBlock",
]
openai = anthropic_messages_to_openai([m.model_dump() for m in req.messages])
assert any(m["role"] == "tool" and m["tool_call_id"] == "t1" for m in openai)
def test_user_malformed_known_block_still_rejected():
# The guard only allow-lists a user block's *type*; the union still validates its
# shape, so a known-but-malformed block (tool_result without tool_use_id) fails.
from pydantic import ValidationError
with pytest.raises(ValidationError):
AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [
{"role": "user", "content": [{"type": "tool_result", "content": "x"}]},
],
)
def test_user_content_block_non_string_type_rejected_cleanly():
# A user block whose `type` is a non-string (unhashable list / dict, or a stray
# int) must fail as a clean validation error, not raise TypeError from the
# frozenset membership test and escape as a 500.
from pydantic import ValidationError
for bad_type in ([], {}, 5):
with pytest.raises(ValidationError):
AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [{"role": "user", "content": [{"type": bad_type}]}],
)
def test_assistant_missing_content_key_still_rejected():
# The null -> "" leniency is only for an EXPLICIT null. An assistant message that
# omits content entirely stays malformed and must fail required-field validation.
from pydantic import ValidationError
with pytest.raises(ValidationError):
AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [{"role": "assistant"}],
)
# An explicit null is still accepted and coerced (regression guard).
req = AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": None},
],
)
assert req.messages[1].content == ""
def test_resumed_null_assistant_between_users_coalesced_on_messages_route(monkeypatch):
# user -> assistant(null) -> user is now accepted: the null assistant turn coerces
# to "" and is dropped. The route must then coalesce the two remaining user turns
# so a strict GGUF chat template does not 400 on non-alternating roles.
backend = _mock_backend(monkeypatch, context_length = 2048)
class _Req:
state = SimpleNamespace()
url = SimpleNamespace(path = "/v1/messages")
method = "POST"
async def is_disconnected(self):
return False
payload = AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [
{"role": "user", "content": "first question"},
{"role": "assistant", "content": None},
{"role": "user", "content": "please continue"},
],
)
response = _drive(anthropic_messages(payload, request = _Req(), current_subject = "t"))
assert response.status_code == 200
[(_path, kwargs)] = backend.calls
user_turns = [m for m in kwargs["messages"] if m.get("role") == "user"]
assert len(user_turns) == 1 # the two user turns were merged, not left adjacent
merged = user_turns[0]["content"]
if isinstance(merged, list):
merged = " ".join(p.get("text", "") for p in merged if isinstance(p, dict))
assert "first question" in merged and "please continue" in merged

View file

@ -0,0 +1,50 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Tests for coding-agent CLI detection used by the API-keys settings panel."""
from unittest.mock import patch
from utils.coding_agents import CODING_AGENTS, detect_installed_coding_agents
def test_matches_unsloth_start_subcommands():
# Each entry must be an actual `unsloth start <agent>` subcommand name
# (unsloth_cli/commands/start.py). Spelled out here rather than imported
# from that module, which pulls in the CLI's heavier dependencies.
assert CODING_AGENTS == ("claude", "codex", "openclaw", "opencode", "hermes", "pi")
def test_detects_only_agents_present_on_path():
installed = {"claude", "opencode"}
with patch(
"utils.coding_agents.shutil.which",
side_effect = lambda name: f"/usr/bin/{name}" if name in installed else None,
):
assert detect_installed_coding_agents() == ["claude", "opencode"]
def test_returns_empty_list_when_nothing_is_installed():
with patch("utils.coding_agents.shutil.which", return_value = None):
assert detect_installed_coding_agents() == []
def test_preserves_declared_order_regardless_of_path_lookup_order():
with patch(
"utils.coding_agents.shutil.which",
side_effect = lambda name: name if name in ("pi", "claude", "hermes") else None,
):
assert detect_installed_coding_agents() == ["claude", "hermes", "pi"]
def test_treats_a_path_lookup_error_as_not_installed():
# An advisory check: shutil.which raising for one entry (e.g. a permission
# error walking a PATH directory) should not take down the whole endpoint,
# and should not stop the remaining agents from being checked.
def flaky_which(name: str):
if name == "codex":
raise OSError("permission denied")
return name if name == "claude" else None
with patch("utils.coding_agents.shutil.which", side_effect = flaky_which):
assert detect_installed_coding_agents() == ["claude"]

View file

@ -0,0 +1,314 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Completion-only masking policy: auto-detect first, manual table fallback.
Covers utils.datasets.completion_masking.apply_completion_masking, shared by
the CUDA trainer (core/training/trainer.py) and the MLX worker
(core/training/worker.py):
- unmapped models use chat template auto-detection (previously masking was
silently disabled),
- gpt-oss goes auto-first too (its quantized checkpoints ship a template
the manual markers cannot match),
- an auto-detection failure falls back to the template table markers,
- a table miss after an auto failure warns and leaves the trainer unchanged.
"""
from __future__ import annotations
import pytest
from utils.datasets.completion_masking import apply_completion_masking, lookup_manual_markers
from utils.datasets.model_mappings import TEMPLATE_TO_RESPONSES_MAPPER
class _Trainer:
"""Sentinel trainer; train_fn wraps it in a new object when applied."""
class _Recorder:
"""Fake train_on_responses_only that records calls."""
def __init__(self):
self.calls = []
def __call__(self, trainer, **kwargs):
self.calls.append(kwargs)
wrapped = _Trainer()
wrapped.wrapped_from = trainer
return wrapped
def _detect_ok(processor):
return "<INS>", "<RES>"
def _detect_fail(processor):
raise ValueError(
"Unsloth: Could not reliably auto-detect response_part - "
"pass instruction_part and response_part."
)
_AUTO = {"instruction_part": "<INS>", "response_part": "<RES>"}
class _Notes:
def __init__(self):
self.messages = []
def __call__(self, level, message):
self.messages.append((level, message))
def warnings(self):
return [m for level, m in self.messages if level == "warning"]
def test_unmapped_model_uses_auto_detection():
# Unmapped model: the auto path applies masking (was silently disabled).
trainer = _Trainer()
train_fn = _Recorder()
notes = _Notes()
result, applied = apply_completion_masking(
trainer, "LiquidAI/LFM2-8B-A1B", train_fn, notify = notes, detect_fn = _detect_ok
)
assert applied is True
assert result.wrapped_from is trainer
assert train_fn.calls == [dict(_AUTO)] # applied with the detected markers
assert notes.warnings() == []
def test_mapped_model_prefers_auto_detection():
trainer = _Trainer()
train_fn = _Recorder()
_, applied = apply_completion_masking(
trainer, "unsloth/Qwen3-0.6B", train_fn, detect_fn = _detect_ok
)
assert applied is True
assert train_fn.calls == [dict(_AUTO)]
def test_gpt_oss_uses_auto_detection_first():
# The quantized gpt-oss checkpoints ship a template without the
# <|channel|>final header, where the manual markers match nothing; auto
# derives markers from the template the checkpoint actually ships.
trainer = _Trainer()
train_fn = _Recorder()
_, applied = apply_completion_masking(
trainer, "unsloth/gpt-oss-20b", train_fn, detect_fn = _detect_ok
)
assert applied is True
assert train_fn.calls == [dict(_AUTO)]
def test_gpt_oss_detection_failure_falls_back_to_manual_markers():
trainer = _Trainer()
train_fn = _Recorder()
_, applied = apply_completion_masking(
trainer, "unsloth/gpt-oss-20b", train_fn, detect_fn = _detect_fail
)
assert applied is True
expected = TEMPLATE_TO_RESPONSES_MAPPER["gpt-oss"]
assert train_fn.calls == [
{
"instruction_part": expected["instruction"],
"response_part": expected["response"],
}
]
def test_auto_failure_falls_back_to_template_table():
trainer = _Trainer()
train_fn = _Recorder()
notes = _Notes()
result, applied = apply_completion_masking(
trainer, "unsloth/Qwen3-0.6B", train_fn, notify = notes, detect_fn = _detect_fail
)
assert applied is True
assert result.wrapped_from is trainer
expected = TEMPLATE_TO_RESPONSES_MAPPER["qwen3"]
assert train_fn.calls == [
{
"instruction_part": expected["instruction"],
"response_part": expected["response"],
},
]
assert any("falling back to the template table" in m for m in notes.warnings())
def test_application_failure_propagates_not_fallback():
# Detection succeeds; a failure while APPLYING the masking must propagate,
# never silently fall back to full-sequence training.
def train_fn(trainer, **kwargs):
raise RuntimeError("dataset map worker crashed")
with pytest.raises(RuntimeError, match = "dataset map worker crashed"):
apply_completion_masking(_Trainer(), "LiquidAI/LFM2-8B-A1B", train_fn, detect_fn = _detect_ok)
def test_preset_tokenizer_markers_used_directly():
# Preset unsloth marker attrs skip detection; zoo reuses them on a bare call.
class _Tok:
_unsloth_input_part = "<I>"
_unsloth_output_part = "<O>"
trainer = _Trainer()
trainer.processing_class = _Tok()
train_fn = _Recorder()
_, applied = apply_completion_masking(
trainer, "LiquidAI/LFM2-8B-A1B", train_fn, detect_fn = _detect_fail
)
assert applied is True
assert train_fn.calls == [{}] # bare call, stored parts
def test_table_miss_warns_and_disables_without_crashing():
trainer = _Trainer()
train_fn = _Recorder()
notes = _Notes()
result, applied = apply_completion_masking(
trainer, "some-org/not-in-any-mapper", train_fn, notify = notes, detect_fn = _detect_fail
)
assert applied is False
assert result is trainer # unchanged: full sequence training
assert train_fn.calls == [] # detection failed; nothing applied
assert any("could not be applied" in m for m in notes.warnings())
assert any("full sequences" in m for m in notes.warnings())
def test_num_proc_forwarded_only_when_given():
# CUDA path passes num_proc; the MLX path omits it.
train_fn = _Recorder()
apply_completion_masking(
_Trainer(), "unsloth/Qwen3-0.6B", train_fn, num_proc = 4, detect_fn = _detect_ok
)
assert train_fn.calls == [dict(_AUTO, num_proc = 4)]
train_fn = _Recorder()
apply_completion_masking(
_Trainer(), "unsloth/Qwen3-0.6B", train_fn, num_proc = 4, detect_fn = _detect_fail
)
assert train_fn.calls[0]["num_proc"] == 4
train_fn = _Recorder()
apply_completion_masking(_Trainer(), "unsloth/Qwen3-0.6B", train_fn, detect_fn = _detect_ok)
assert train_fn.calls == [dict(_AUTO)]
def test_manual_fallback_failure_propagates_to_caller():
# Errors while applying the manual fallback must propagate to the caller.
def train_fn(trainer, **kwargs):
raise RuntimeError("boom")
with pytest.raises(RuntimeError, match = "boom"):
apply_completion_masking(_Trainer(), "unsloth/gpt-oss-20b", train_fn)
def test_notify_is_optional():
train_fn = _Recorder()
_, applied = apply_completion_masking(
_Trainer(), "some-org/not-in-any-mapper", train_fn, detect_fn = _detect_fail
)
assert applied is False
def test_lookup_manual_markers():
template, instruction, response = lookup_manual_markers("unsloth/Qwen3-0.6B")
assert template == "qwen3"
assert instruction == TEMPLATE_TO_RESPONSES_MAPPER["qwen3"]["instruction"]
assert response == TEMPLATE_TO_RESPONSES_MAPPER["qwen3"]["response"]
template, instruction, response = lookup_manual_markers("some-org/unknown")
assert (template, instruction, response) == (None, None, None)
template, instruction, response = lookup_manual_markers(None)
assert (template, instruction, response) == (None, None, None)
def test_renamed_gpt_oss_gets_template_markers():
# Name-detected as gpt-oss but not in the exact-name table: must use the
# gpt-oss markers, not fall through to full-sequence training.
trainer = _Trainer()
train_fn = _Recorder()
_, applied = apply_completion_masking(
trainer, "some-org/gpt-oss-20b-sft", train_fn, detect_fn = _detect_fail
)
assert applied is True
expected = TEMPLATE_TO_RESPONSES_MAPPER["gpt-oss"]
assert train_fn.calls == [
{
"instruction_part": expected["instruction"],
"response_part": expected["response"],
}
]
class _FakeTokenizerWrapper:
"""mlx-lm TokenizerWrapper semantics: plain reads delegate to the wrapped
tokenizer, underscore attrs do not (so preset markers are hidden)."""
def __init__(self, tokenizer):
object.__setattr__(self, "_tokenizer", tokenizer)
def __getattr__(self, attr):
if attr.startswith("_"):
return object.__getattribute__(self, attr)
return getattr(object.__getattribute__(self, "_tokenizer"), attr)
_FakeTokenizerWrapper.__name__ = "TokenizerWrapper"
def test_mlx_tokenizer_wrapper_unwrapped_for_preset_markers():
# Markers live on the inner HF tokenizer that the wrapper hides; the helper
# must unwrap so the preset bare-call path still fires on MLX.
class _Tok:
_unsloth_input_part = "<I>"
_unsloth_output_part = "<O>"
trainer = _Trainer()
trainer.tokenizer = _FakeTokenizerWrapper(_Tok())
train_fn = _Recorder()
_, applied = apply_completion_masking(
trainer, "LiquidAI/LFM2-8B-A1B", train_fn, detect_fn = _detect_fail
)
assert applied is True
assert train_fn.calls == [{}] # bare call, stored parts
def test_mlx_tokenizer_wrapper_unwrapped_for_detection():
# Detection must see the real tokenizer, not the wrapper, so it does not
# depend on the loader's __call__ patch.
class _Tok:
pass
inner = _Tok()
trainer = _Trainer()
trainer.tokenizer = _FakeTokenizerWrapper(inner)
train_fn = _Recorder()
seen = []
def detect(processor):
seen.append(processor)
return "<INS>", "<RES>"
_, applied = apply_completion_masking(
trainer, "LiquidAI/LFM2-8B-A1B", train_fn, detect_fn = detect
)
assert applied is True
assert seen == [inner]

View file

@ -124,3 +124,100 @@ def test_unstructured_upload_import_errors_stay_generic(monkeypatch, tmp_path, e
assert result.status == "error"
assert result.error == "Text extraction failed."
assert _block_files(seed_route) == []
_TEST_UPLOAD_UID = "0f" * 16
def test_remove_unstructured_block_deletes_directory(monkeypatch, tmp_path):
seed_route = _load_seed_route(monkeypatch, tmp_path)
_run_upload(seed_route, "notes.txt", b"hello", block_id = _TEST_UPLOAD_UID)
assert _block_files(seed_route, _TEST_UPLOAD_UID) != []
result = asyncio.run(seed_route.remove_unstructured_block(_TEST_UPLOAD_UID))
assert result == {"status": "ok", "deleted": True}
assert not (seed_route.UNSTRUCTURED_UPLOAD_ROOT / _TEST_UPLOAD_UID).exists()
def test_remove_unstructured_block_missing_directory_is_ok(monkeypatch, tmp_path):
seed_route = _load_seed_route(monkeypatch, tmp_path)
result = asyncio.run(seed_route.remove_unstructured_block(_TEST_UPLOAD_UID))
assert result == {"status": "ok", "deleted": False}
def test_remove_unstructured_block_rejects_unsafe_ids(monkeypatch, tmp_path):
seed_route = _load_seed_route(monkeypatch, tmp_path)
with pytest.raises(seed_route.HTTPException) as exc:
asyncio.run(seed_route.remove_unstructured_block("../escape"))
assert exc.value.status_code == 400
def test_remove_unstructured_block_rejects_legacy_node_ids(monkeypatch, tmp_path):
seed_route = _load_seed_route(monkeypatch, tmp_path)
_run_upload(seed_route, "notes.txt", b"hello", block_id = "n1")
assert _block_files(seed_route, "n1") != []
with pytest.raises(seed_route.HTTPException) as exc:
asyncio.run(seed_route.remove_unstructured_block("n1"))
assert exc.value.status_code == 400
assert _block_files(seed_route, "n1") != []
def test_remove_unstructured_block_rejects_symlink_escape(monkeypatch, tmp_path):
seed_route = _load_seed_route(monkeypatch, tmp_path)
outside = tmp_path / "outside"
outside.mkdir()
(outside / "victim.txt").write_text("keep me")
root = seed_route.UNSTRUCTURED_UPLOAD_ROOT
root.mkdir(parents = True)
(root / _TEST_UPLOAD_UID).symlink_to(outside)
with pytest.raises(seed_route.HTTPException) as exc:
asyncio.run(seed_route.remove_unstructured_block(_TEST_UPLOAD_UID))
assert exc.value.status_code == 400
assert (outside / "victim.txt").exists()
def test_remove_unstructured_block_fails_if_directory_remains(monkeypatch, tmp_path):
seed_route = _load_seed_route(monkeypatch, tmp_path)
root = seed_route.UNSTRUCTURED_UPLOAD_ROOT
block_dir = root / _TEST_UPLOAD_UID
block_dir.mkdir(parents = True)
(block_dir / "victim.txt").write_text("keep me")
calls = []
def noop_rmtree(path, *args, **kwargs):
calls.append((path, args, kwargs))
monkeypatch.setattr(seed_route.shutil, "rmtree", noop_rmtree)
with pytest.raises(seed_route.HTTPException) as exc:
asyncio.run(seed_route.remove_unstructured_block(_TEST_UPLOAD_UID))
assert calls
assert exc.value.status_code == 500
assert block_dir.exists()
def test_total_upload_quota_is_scoped_per_block(monkeypatch, tmp_path):
seed_route = _load_seed_route(monkeypatch, tmp_path)
monkeypatch.setattr(seed_route, "UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_BYTES", 10)
first = _run_upload(seed_route, "a.txt", b"123456789")
assert first.status == "ok"
with pytest.raises(seed_route.HTTPException) as exc:
_run_upload(seed_route, "b.txt", b"123")
assert exc.value.status_code == 413
# Another block starts with its own untouched budget.
other = _run_upload(seed_route, "c.txt", b"123", block_id = "other")
assert other.status == "ok"

View file

@ -287,7 +287,9 @@ def test_degrades_when_shared_helper_import_raises_importerror():
def test_retries_under_light_gpu_init_when_import_fails(monkeypatch):
"""GPU detection in unsloth_zoo's __init__ raises NotImplementedError on a GPU-less host. The shim
retries under UNSLOTH_ZOO_DISABLE_GPU_INIT=1, restores the env, and degrades if the retry fails."""
retries under UNSLOTH_ZOO_DISABLE_GPU_INIT=1, restores the env, and degrades if the retry fails.
The backend loads lazily (first use of a heavy helper), so this triggers the load explicitly
before asserting the retry/degrade behavior."""
import importlib
import os
@ -321,11 +323,15 @@ def test_retries_under_light_gpu_init_when_import_fails(monkeypatch):
sys.meta_path.insert(0, finder)
try:
degraded = importlib.import_module("utils.hf_xet_fallback")
# First attempt without the light env, then a retry with it set.
# Import is light (lazy backend); unsloth_zoo not loaded yet.
assert seen_env == [], seen_env
# First use of a heavy helper triggers the load (attempt without the light env, then a retry
# with it set); accessing DownloadStallError drives it via __getattr__.
stall_error = degraded.DownloadStallError
assert seen_env == [None, "1"], seen_env
# Both attempts raised -> Studio still boots in degraded mode.
assert issubclass(degraded.DownloadStallError, RuntimeError)
# The env override must not leak past the import.
assert issubclass(stall_error, RuntimeError)
# The env override must not leak past the load.
assert os.environ.get("UNSLOTH_ZOO_DISABLE_GPU_INIT") is None
finally:
sys.meta_path.remove(finder)
@ -333,3 +339,31 @@ def test_retries_under_light_gpu_init_when_import_fails(monkeypatch):
sys.modules.update(saved)
if saved_shim is not None:
sys.modules["utils.hf_xet_fallback"] = saved_shim
def test_importing_child_should_disable_xet_stays_light(monkeypatch):
"""Regression guard for the stale-transformers-sidecar bug: importing the shim (and
``child_should_disable_xet``) must NOT pull in ``transformers``/``unsloth_zoo``. The worker calls
this at startup to decide the Xet env flip BEFORE activating the sidecar; an eager import here
would cache the default transformers 4.57.x in sys.modules, defeating the sidecar sys.path prepend
and breaking 5.x models (Qwen3.5/GLM/gemma-4)."""
import importlib
for name in [
m
for m in list(sys.modules)
if m == "transformers"
or m.startswith("transformers.")
or m == "unsloth_zoo"
or m.startswith("unsloth_zoo.")
or m == "utils.hf_xet_fallback"
]:
monkeypatch.delitem(sys.modules, name, raising = False)
mod = importlib.import_module("utils.hf_xet_fallback")
# The lightweight decision works without the heavy backend.
assert mod.child_should_disable_xet({"disable_xet": True}) is True
assert mod.child_should_disable_xet({}) is False
# And nothing heavy was imported as a side effect.
assert "transformers" not in sys.modules, "importing the shim must not import transformers"
assert "unsloth_zoo" not in sys.modules, "importing the shim must not import unsloth_zoo"

View file

@ -112,7 +112,7 @@ def test_route_llama_streaming_async_clients_disable_proxy_env():
continue
calls.append(node)
assert len(calls) == 4
assert len(calls) == 5
for call in calls:
assert any(
kw.arg == "trust_env" and isinstance(kw.value, ast.Constant) and kw.value.value is False

View file

@ -1,7 +1,8 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""install_llama_prebuilt.py: host->repo mapping and the --resolve-prebuilt mode.
"""install_llama_prebuilt.py: the --resolve-prebuilt probe (plans against the fork
by default; --published-repo overrides).
These back the in-app update for source-build (markerless) installs: the backend
asks the installer whether an official prebuilt exists for this host without
@ -24,9 +25,7 @@ if str(_studio) not in sys.path:
ilp = importlib.import_module("install_llama_prebuilt")
if not hasattr(ilp, "published_repo_for_host") or not hasattr(
ilp, "resolve_simple_install_release_plans"
):
if not hasattr(ilp, "resolve_simple_install_release_plans"):
pytest.skip("PR symbols not present - check branch", allow_module_level = True)
FORK = ilp.DEFAULT_PUBLISHED_REPO # unslothai/llama.cpp
@ -56,71 +55,25 @@ def _host(**kw):
return ilp.HostInfo(**base)
def test_published_repo_for_host():
# CPU-only Linux (x64 and arm64) -> ggml-org upstream.
assert ilp.published_repo_for_host(_host(is_linux = True, is_x86_64 = True)) == UPSTREAM
assert (
ilp.published_repo_for_host(_host(is_linux = True, is_arm64 = True, machine = "aarch64"))
== UPSTREAM
)
# GPU Linux -> fork.
assert (
ilp.published_repo_for_host(_host(is_linux = True, is_x86_64 = True, has_usable_nvidia = True))
== FORK
)
assert ilp.published_repo_for_host(_host(is_linux = True, is_x86_64 = True, has_rocm = True)) == FORK
# CPU-only Windows -> ggml-org (setup.ps1: the fork ships no win-cpu bundle).
assert (
ilp.published_repo_for_host(_host(system = "Windows", is_windows = True, is_x86_64 = True))
== UPSTREAM
)
# GPU Windows -> fork.
assert (
ilp.published_repo_for_host(
_host(system = "Windows", is_windows = True, is_x86_64 = True, has_usable_nvidia = True)
)
== FORK
)
# macOS -> fork regardless of GPU (ggml-org macOS bundles need too-new macOS).
assert (
ilp.published_repo_for_host(
_host(system = "Darwin", is_macos = True, is_arm64 = True, machine = "arm64")
)
== FORK
)
# Linux with AMD tooling but no probed GPU -> fork (setup.sh routes on tooling).
assert (
ilp.published_repo_for_host(
_host(is_linux = True, is_x86_64 = True), linux_amd_tooling_present = True
)
== FORK
)
# The tooling hint is Linux-only: Windows CPU stays on ggml-org.
assert (
ilp.published_repo_for_host(
_host(system = "Windows", is_windows = True, is_x86_64 = True),
linux_amd_tooling_present = True,
)
== UPSTREAM
)
def test_macos_intel_and_arm_both_route_to_fork():
# macOS uses the unslothai fork's own Mac prebuilts for BOTH arm64 and Intel;
# there is no longer any upstream-on-macOS default path, so the obsolete
# pre-macOS-26 pin (b9415) is gone.
assert (
ilp.published_repo_for_host(
_host(system = "Darwin", is_macos = True, is_arm64 = True, machine = "arm64")
)
== FORK
)
assert (
ilp.published_repo_for_host(
_host(system = "Darwin", is_macos = True, is_x86_64 = True, machine = "x86_64")
)
== FORK
def test_force_cpu_clears_all_gpu_attributes_including_intel():
# --cpu-fallback is the "select the CPU prebuilt even when a GPU is present"
# escape hatch. It must drop EVERY GPU attribute, including has_intel_gpu, or
# the planner still prepends the Vulkan asset on an Intel-GPU host.
host = _host(
is_linux = True,
is_x86_64 = True,
has_usable_nvidia = True,
has_physical_nvidia = True,
has_rocm = True,
rocm_gfx_target = "gfx1100",
has_intel_gpu = True,
)
forced = ilp._apply_host_overrides(host, force_cpu = True)
assert forced.has_usable_nvidia is False
assert forced.has_physical_nvidia is False
assert forced.has_rocm is False
assert forced.rocm_gfx_target is None
assert forced.has_intel_gpu is False
def test_macos_upstream_pin_only_for_explicit_pre26_upstream():
@ -188,15 +141,13 @@ def test_resolve_prebuilt_unavailable(monkeypatch, capsys):
assert out["repo"] == FORK
def test_resolve_prebuilt_linux_amd_tooling_routes_to_fork(monkeypatch, capsys):
# CPU-probed Linux host but rocminfo on PATH: the dispatch must route to the
# fork so a HIP source build is not offered an upstream CPU prebuilt.
monkeypatch.setattr(ilp, "detect_host", lambda: _host(is_linux = True, is_x86_64 = True))
monkeypatch.setattr(ilp.shutil, "which", lambda tool: tool == "rocminfo")
def _run_resolve_capture_host(monkeypatch, capsys):
"""Drive --resolve-prebuilt and return the host the resolver was handed."""
seen = {}
def _resolver(tag, host, repo, published_release_tag):
seen["repo"] = repo
seen["host"] = host
raise ilp.PrebuiltFallback("no asset")
monkeypatch.setattr(ilp, "resolve_simple_install_release_plans", _resolver)
@ -207,10 +158,33 @@ def test_resolve_prebuilt_linux_amd_tooling_routes_to_fork(monkeypatch, capsys):
)
assert ilp.main() == ilp.EXIT_SUCCESS
out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
return seen, out
def test_resolve_prebuilt_cpu_linux_routes_to_fork(monkeypatch, capsys):
# CPU-only Linux host (no GPU): the dispatch routes to the fork, which now
# ships the CPU prebuilt -- it no longer falls back to ggml-org upstream.
monkeypatch.setattr(ilp, "detect_host", lambda: _host(is_linux = True, is_x86_64 = True))
seen, out = _run_resolve_capture_host(monkeypatch, capsys)
assert seen["repo"] == FORK
assert out["repo"] == FORK
def test_resolve_prebuilt_rocm_sdk_only_host_still_offered_cpu(monkeypatch, capsys):
# A CPU-only host that merely has ROCm/HIP SDK tools on PATH (no AMD GPU, so
# detect_host leaves has_rocm False) is a valid CPU-prebuilt target. The probe
# must NOT reclassify it as ROCm from tool presence alone and suppress the CPU
# bundle -- that would deny the fork CPU prebuilt to a legitimate CPU source
# build. The host is left CPU-only and resolves against the fork.
monkeypatch.setattr(ilp, "detect_host", lambda: _host(is_linux = True, is_x86_64 = True))
monkeypatch.setattr(
ilp.shutil, "which", lambda tool: "/opt/rocm/bin/hipconfig" if tool == "hipconfig" else None
)
seen, out = _run_resolve_capture_host(monkeypatch, capsys)
assert seen["repo"] == FORK
assert seen["host"].has_rocm is False
# Blackwell floor is sm_100 (data-center B100/B200, B300/GB300), below consumer
# sm_120 -- 120 wrongly excluded data-center hosts from the prebuilt selection.
@ -360,3 +334,386 @@ def test_sm103_host_drops_cuda128_windows_build():
)
kept_b200 = ilp._drop_blackwell_incapable_windows_cuda(b200, [cuda128, cuda129])
assert [a.name for a in kept_b200] == [cuda128.name, cuda129.name]
def _upstream_release(tag, asset_names):
return {
"tag_name": tag,
"assets": [
{"name": n, "browser_download_url": f"https://example/{n}"} for n in asset_names
],
}
def test_direct_upstream_arm64_intel_prefers_vulkan():
# Auto-detected Intel GPU on Linux arm64 -> Vulkan prebuilt first, CPU
# second (mirrors the x86_64 branch; ggml-org ships the arm64 Vulkan asset).
host = _host(is_linux = True, is_arm64 = True, machine = "aarch64", has_intel_gpu = True)
rel = _upstream_release(
"b9925",
["llama-b9925-bin-ubuntu-vulkan-arm64.tar.gz", "llama-b9925-bin-ubuntu-arm64.tar.gz"],
)
plan = ilp.direct_upstream_release_plan(rel, host, UPSTREAM, "latest")
kinds = [a.install_kind for a in plan.attempts]
assert kinds[0] == "linux-vulkan", kinds
assert "linux-arm64" in kinds
assert plan.attempts[0].name == "llama-b9925-bin-ubuntu-vulkan-arm64.tar.gz"
def test_direct_upstream_intel_with_hidden_nvidia_is_cpu_only():
# A host with a physical NVIDIA hidden via CUDA_VISIBLE_DEVICES (physical
# True, usable False) + an Intel iGPU must NOT get the Vulkan archive even
# when planning directly against upstream: Vulkan ignores CUDA_VISIBLE_DEVICES
# and could grab the reserved card. It falls through to the CPU asset.
host = _host(
is_linux = True,
is_x86_64 = True,
has_intel_gpu = True,
has_physical_nvidia = True,
has_usable_nvidia = False,
)
rel = _upstream_release(
"b9925",
["llama-b9925-bin-ubuntu-vulkan-x64.tar.gz", "llama-b9925-bin-ubuntu-x64.tar.gz"],
)
plan = ilp.direct_upstream_release_plan(rel, host, UPSTREAM, "latest")
assert [a.install_kind for a in plan.attempts] == ["linux-cpu"]
def test_direct_upstream_arm64_without_intel_is_cpu_only():
host = _host(is_linux = True, is_arm64 = True, machine = "aarch64")
rel = _upstream_release(
"b9925",
["llama-b9925-bin-ubuntu-vulkan-arm64.tar.gz", "llama-b9925-bin-ubuntu-arm64.tar.gz"],
)
plan = ilp.direct_upstream_release_plan(rel, host, UPSTREAM, "latest")
assert [a.install_kind for a in plan.attempts] == ["linux-arm64"]
def test_direct_upstream_x86_intel_prefers_vulkan():
host = _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True)
rel = _upstream_release(
"b9925",
["llama-b9925-bin-ubuntu-vulkan-x64.tar.gz", "llama-b9925-bin-ubuntu-x64.tar.gz"],
)
plan = ilp.direct_upstream_release_plan(rel, host, UPSTREAM, "latest")
kinds = [a.install_kind for a in plan.attempts]
assert kinds[0] == "linux-vulkan", kinds
assert "linux-cpu" in kinds
def test_linux_vulkan_health_glob_matches_bare_cpu_lib():
# The widened glob must cover both arch-suffixed (x64) and bare (arm64) CPU
# libs so a valid Vulkan install is not re-flagged unhealthy every check.
choice = ilp.AssetChoice(
repo = UPSTREAM,
tag = "b9925",
name = "llama-b9925-bin-ubuntu-vulkan-arm64.tar.gz",
url = "https://example/x",
source_label = "upstream",
install_kind = "linux-vulkan",
)
groups = ilp.runtime_payload_health_groups(choice)
assert ["libggml-cpu*.so*"] in groups
assert ["libggml-cpu-*.so*"] not in groups
def test_route_to_vulkan_prebuilt_auto_intel_goes_upstream_and_drops_fork_pin():
# Routing fork -> upstream also drops the fork release pin, which is in a
# different tag namespace and would make the upstream resolver miss.
host = _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True)
routed, repo, tag = ilp._route_to_vulkan_prebuilt(host, FORK, "b9596-mix-abc", force_cpu = False)
assert repo == UPSTREAM
assert tag == ""
assert routed.has_intel_gpu is True
def test_route_to_vulkan_prebuilt_preserves_explicit_upstream_pin():
# A pin set WITH an explicit upstream repo is already on upstream -> kept.
host = _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True)
_routed, repo, tag = ilp._route_to_vulkan_prebuilt(host, UPSTREAM, "b9596", force_cpu = False)
assert repo == UPSTREAM
assert tag == "b9596"
def test_route_to_vulkan_prebuilt_cpu_fallback_wins():
# --cpu-fallback suppresses Vulkan routing even for an Intel host.
host = _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True)
routed, repo, tag = ilp._route_to_vulkan_prebuilt(host, FORK, "b9596-mix-abc", force_cpu = True)
assert repo == FORK
assert tag == "b9596-mix-abc"
assert routed is host
def test_route_to_vulkan_prebuilt_hidden_nvidia_not_rerouted():
# A mixed NVIDIA+Intel host that hid NVIDIA (CUDA_VISIBLE_DEVICES=""/-1):
# physical NVIDIA present but not usable. Must NOT auto-route to Vulkan, or
# Vulkan (which ignores CUDA_VISIBLE_DEVICES) could grab the reserved GPU.
host = _host(
is_linux = True,
is_x86_64 = True,
has_intel_gpu = True,
has_physical_nvidia = True,
has_usable_nvidia = False,
)
_routed, repo, _tag = ilp._route_to_vulkan_prebuilt(host, FORK, "", force_cpu = False)
assert repo == FORK
def test_route_to_vulkan_prebuilt_rocm_host_not_rerouted():
# An Intel iGPU alongside a usable ROCm GPU stays on its ROCm/fork path.
host = _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True, has_rocm = True)
_routed, repo, _tag = ilp._route_to_vulkan_prebuilt(host, FORK, "", force_cpu = False)
assert repo == FORK
def test_route_to_vulkan_prebuilt_non_intel_unchanged():
host = _host(is_linux = True, is_x86_64 = True)
routed, repo, _tag = ilp._route_to_vulkan_prebuilt(host, FORK, "", force_cpu = False)
assert repo == FORK
assert routed is host
def test_resolve_prebuilt_intel_host_routes_to_upstream(monkeypatch, capsys):
# The --resolve-prebuilt probe must agree with the install path: an
# auto-detected Intel host resolves against upstream (Vulkan), not the fork.
monkeypatch.setattr(
ilp, "detect_host", lambda: _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True)
)
seen, out = _run_resolve_capture_host(monkeypatch, capsys)
assert seen["repo"] == UPSTREAM
assert out["repo"] == UPSTREAM
# ---------------------------------------------------------------------------
# windows_intel_gpu_in_registry: the in-process Windows Intel probe. A fake
# winreg module stands in for the real registry so the walk runs anywhere.
# ---------------------------------------------------------------------------
class _FakeRegKey:
def __init__(
self,
subkeys = None,
values = None,
denied = False,
):
self.subkeys = subkeys or {}
self.values = values or {}
self.denied = denied
def __enter__(self):
return self
def __exit__(self, *exc):
return False
class _FakeWinreg:
HKEY_LOCAL_MACHINE = object()
def __init__(self, root_key):
self._root_key = root_key
def OpenKey(self, parent, name):
if parent is self.HKEY_LOCAL_MACHINE:
# Pin the production constant: a typo'd class GUID must fail here,
# not silently return the fake tree.
if name != ilp._WINDOWS_DISPLAY_CLASS_KEY:
raise FileNotFoundError(name)
if self._root_key is None:
raise FileNotFoundError(name)
return self._root_key
key = parent.subkeys.get(name)
if key is None:
# Real winreg raises OSError, never KeyError, for a missing key.
raise FileNotFoundError(name)
if key.denied:
raise PermissionError(name)
return key
def QueryInfoKey(self, key):
return (len(key.subkeys), len(key.values), 0)
def EnumKey(self, key, index):
return list(key.subkeys)[index]
def QueryValueEx(self, key, value_name):
if value_name not in key.values:
raise FileNotFoundError(value_name)
return (key.values[value_name], 1)
def _probe_with_display_class(monkeypatch, adapters):
# The helper lazily does `import winreg`; plant the fake in sys.modules the
# same way unsloth_cli/tests/test_start.py fakes it for _refresh_windows_path.
monkeypatch.setitem(sys.modules, "winreg", _FakeWinreg(_FakeRegKey(subkeys = adapters)))
return ilp.windows_intel_gpu_in_registry()
def test_windows_intel_registry_matches_vendor_id(monkeypatch):
assert (
_probe_with_display_class(
monkeypatch,
{
"0000": _FakeRegKey(
values = {
"MatchingDeviceId": r"PCI\VEN_8086&DEV_56A0&SUBSYS_12345678",
"DriverDesc": "Intel(R) Arc(TM) A770 Graphics",
}
),
},
)
is True
)
def test_windows_intel_registry_matches_driver_desc_without_device_id(monkeypatch):
assert (
_probe_with_display_class(
monkeypatch,
{
"0000": _FakeRegKey(values = {"DriverDesc": "Intel(R) UHD Graphics 630"}),
},
)
is True
)
def test_windows_intel_registry_ignores_non_intel_adapters(monkeypatch):
assert (
_probe_with_display_class(
monkeypatch,
{
"0000": _FakeRegKey(
values = {
"MatchingDeviceId": r"PCI\VEN_10DE&DEV_2684",
"DriverDesc": "NVIDIA GeForce RTX 4090",
}
),
"0001": _FakeRegKey(
values = {
"MatchingDeviceId": r"PCI\VEN_1002&DEV_744C",
"DriverDesc": "AMD Radeon RX 7900 XTX",
}
),
},
)
is False
)
def test_windows_intel_registry_skips_restricted_properties_subkey(monkeypatch):
# The real class key carries an ACL-restricted "Properties" subkey and can
# deny access to individual adapter keys; neither may abort the walk.
assert (
_probe_with_display_class(
monkeypatch,
{
"Properties": _FakeRegKey(denied = True),
"0000": _FakeRegKey(denied = True),
"0001": _FakeRegKey(
values = {
"MatchingDeviceId": r"PCI\VEN_8086&DEV_56A0",
}
),
},
)
is True
)
def test_windows_intel_registry_missing_class_key_is_false(monkeypatch):
monkeypatch.setitem(sys.modules, "winreg", _FakeWinreg(None))
assert ilp.windows_intel_gpu_in_registry() is False
def _detect_windows_host(
monkeypatch,
winreg_fake,
powershell_stdout = "",
):
"""Drive the real detect_host() as a GPU-less Windows host with a fake
registry, recording every run_capture invocation. Pins the wiring the
unit tests above cannot see: registry-first, CIM only on a registry miss."""
monkeypatch.setitem(sys.modules, "winreg", winreg_fake)
monkeypatch.setattr(ilp.platform, "system", lambda: "Windows")
monkeypatch.setattr(ilp.platform, "machine", lambda: "AMD64")
for _env in (
"CUDA_VISIBLE_DEVICES",
"HIP_VISIBLE_DEVICES",
"ROCR_VISIBLE_DEVICES",
"HIP_PATH",
"ROCM_PATH",
):
monkeypatch.delenv(_env, raising = False)
monkeypatch.setattr(
ilp.shutil,
"which",
lambda name: "powershell" if name in ("powershell", "pwsh") else None,
)
captured = []
def _fake_run_capture(command, **kwargs):
captured.append(command[0])
if command[0] == "powershell":
return SimpleNamespace(returncode = 0, stdout = powershell_stdout, stderr = "")
return SimpleNamespace(returncode = 1, stdout = "", stderr = "")
monkeypatch.setattr(ilp, "run_capture", _fake_run_capture)
return ilp.detect_host(), captured
def test_detect_host_registry_intel_skips_cim_probe(monkeypatch):
winreg = _FakeWinreg(
_FakeRegKey(
subkeys = {
"0000": _FakeRegKey(values = {"MatchingDeviceId": r"PCI\VEN_8086&DEV_56A0"}),
}
)
)
host, captured = _detect_windows_host(monkeypatch, winreg)
assert host.has_intel_gpu is True
assert "powershell" not in captured
def test_detect_host_cim_fallback_fires_on_registry_miss(monkeypatch):
winreg = _FakeWinreg(
_FakeRegKey(
subkeys = {
"0000": _FakeRegKey(values = {"MatchingDeviceId": r"PCI\VEN_10DE&DEV_2684"}),
}
)
)
host, captured = _detect_windows_host(
monkeypatch, winreg, powershell_stdout = "Intel(R) Arc(TM) A770 Graphics"
)
assert host.has_intel_gpu is True
assert "powershell" in captured
def test_windows_intel_registry_unexpected_error_is_false(monkeypatch):
# The probe is advisory: even a non-OSError bug in the walk must return
# False (deferring to the CIM fallback), never crash detect_host.
class _ExplodingWinreg:
HKEY_LOCAL_MACHINE = object()
def OpenKey(self, parent, name):
raise TypeError(name)
monkeypatch.setitem(sys.modules, "winreg", _ExplodingWinreg())
assert ilp.windows_intel_gpu_in_registry() is False
def test_detect_host_cim_rescues_exploding_registry(monkeypatch):
class _ExplodingWinreg:
HKEY_LOCAL_MACHINE = object()
def OpenKey(self, parent, name):
raise TypeError(name)
host, captured = _detect_windows_host(
monkeypatch, _ExplodingWinreg(), powershell_stdout = "Intel(R) Arc(TM) A770 Graphics"
)
assert host.has_intel_gpu is True
assert "powershell" in captured

View file

@ -0,0 +1,320 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
import asyncio
import os
import sys
import threading
import pytest
_backend = os.path.join(os.path.dirname(__file__), "..")
sys.path.insert(0, _backend)
from core.inference import llama_admission
from core.inference.llama_admission import (
ADMISSION_CONTROL_ENV,
ADMISSION_KEEPALIVE_INTERVAL_ENV,
ADMISSION_MAX_QUEUE_ENV,
ADMISSION_QUEUE_TIMEOUT_ENV,
DEFAULT_ADMISSION_KEEPALIVE_INTERVAL_S,
DEFAULT_ADMISSION_MAX_QUEUE,
DEFAULT_ADMISSION_QUEUE_TIMEOUT_S,
LlamaAdmissionConfig,
LlamaAdmissionQueueFull,
get_llama_admission_queue,
llama_admission_config_from_env,
reset_llama_admission_queues,
)
@pytest.fixture(autouse = True)
def _reset_queues():
reset_llama_admission_queues()
yield
reset_llama_admission_queues()
def test_admission_config_defaults(monkeypatch):
for name in (
ADMISSION_CONTROL_ENV,
ADMISSION_QUEUE_TIMEOUT_ENV,
ADMISSION_KEEPALIVE_INTERVAL_ENV,
ADMISSION_MAX_QUEUE_ENV,
):
monkeypatch.delenv(name, raising = False)
config = llama_admission_config_from_env()
assert config.enabled is True
assert config.queue_timeout_s == DEFAULT_ADMISSION_QUEUE_TIMEOUT_S
assert config.keepalive_interval_s == DEFAULT_ADMISSION_KEEPALIVE_INTERVAL_S
assert config.max_queue == DEFAULT_ADMISSION_MAX_QUEUE
def test_admission_config_env_overrides(monkeypatch):
monkeypatch.setenv(ADMISSION_CONTROL_ENV, "off")
monkeypatch.setenv(ADMISSION_QUEUE_TIMEOUT_ENV, "0")
monkeypatch.setenv(ADMISSION_KEEPALIVE_INTERVAL_ENV, "0.25")
monkeypatch.setenv(ADMISSION_MAX_QUEUE_ENV, "0")
config = llama_admission_config_from_env()
assert config.enabled is False
assert config.queue_timeout_s is None
assert config.keepalive_interval_s == 0.25
assert config.max_queue is None
def test_admission_config_positive_queue_timeout_env(monkeypatch):
monkeypatch.setenv(ADMISSION_QUEUE_TIMEOUT_ENV, "600")
config = llama_admission_config_from_env()
assert config.queue_timeout_s == 600.0
def test_fifo_capacity_one_grants_next_waiter_on_release():
async def _run():
queue = get_llama_admission_queue("http://llama.test")
config = LlamaAdmissionConfig()
first = queue.reserve(capacity = 1, config = config)
second = queue.reserve(capacity = 1, config = config)
third = queue.reserve(capacity = 1, config = config)
first_lease = first.lease_nowait()
assert first_lease is not None
assert second.lease_nowait() is None
assert third.lease_nowait() is None
assert queue.snapshot().queued == 2
first_lease.release()
second_lease = await second.wait(0.1)
assert second_lease is not None
assert third.lease_nowait() is None
second_lease.release()
third_lease = await third.wait(0.1)
assert third_lease is not None
third_lease.release()
snapshot = queue.snapshot()
assert snapshot.active == 0
assert snapshot.queued == 0
asyncio.run(_run())
def test_queue_full_rejects_excess_waiter():
async def _run():
queue = get_llama_admission_queue("http://llama.test")
config = LlamaAdmissionConfig(max_queue = 1)
first = queue.reserve(capacity = 1, config = config)
queued = queue.reserve(capacity = 1, config = config)
assert first.lease_nowait() is not None
assert queued.lease_nowait() is None
with pytest.raises(LlamaAdmissionQueueFull):
queue.reserve(capacity = 1, config = config)
asyncio.run(_run())
def test_disabled_admission_bypasses_active_slot_limit():
async def _run():
queue = get_llama_admission_queue("http://llama.test")
config = LlamaAdmissionConfig(enabled = False)
first = queue.reserve(capacity = 1, config = config)
second = queue.reserve(capacity = 1, config = config)
assert first.lease_nowait() is not None
assert second.lease_nowait() is not None
assert queue.snapshot().active == 0
assert queue.snapshot().queued == 0
asyncio.run(_run())
def test_cancelling_promoted_waiter_releases_slot():
async def _run():
queue = get_llama_admission_queue("http://llama.test")
config = LlamaAdmissionConfig()
first = queue.reserve(capacity = 1, config = config)
second = queue.reserve(capacity = 1, config = config)
first_lease = first.lease_nowait()
first_lease.release()
await asyncio.sleep(0)
second.cancel()
snapshot = queue.snapshot()
assert snapshot.active == 0
assert snapshot.queued == 0
asyncio.run(_run())
def test_cancelling_promoted_waiter_before_delivery_releases_slot():
async def _run():
queue = get_llama_admission_queue("http://llama.test")
config = LlamaAdmissionConfig()
first = queue.reserve(capacity = 1, config = config)
second = queue.reserve(capacity = 1, config = config)
first_lease = first.lease_nowait()
first_lease.release()
second.cancel()
snapshot = queue.snapshot()
assert snapshot.active == 0
assert snapshot.queued == 0
asyncio.run(_run())
def test_external_waiter_future_cancel_invalidates_reservation():
async def _run():
queue = get_llama_admission_queue("http://llama.test")
config = LlamaAdmissionConfig()
first = queue.reserve(capacity = 1, config = config)
second = queue.reserve(capacity = 1, config = config)
first_lease = first.lease_nowait()
assert first_lease is not None
assert second._waiter is not None
second._waiter.future.cancel()
assert second.lease_nowait() is None
assert second.is_cancelled is True
assert await second.wait(0.01) is None
first_lease.release()
snapshot = queue.snapshot()
assert snapshot.active == 0
assert snapshot.queued == 0
asyncio.run(_run())
def test_wait_returns_none_when_waiter_future_cancelled_during_wait():
async def _run():
queue = get_llama_admission_queue("http://llama.test")
config = LlamaAdmissionConfig()
first = queue.reserve(capacity = 1, config = config)
second = queue.reserve(capacity = 1, config = config)
first_lease = first.lease_nowait()
assert first_lease is not None
assert second._waiter is not None
wait_task = asyncio.create_task(second.wait(1.0))
await asyncio.sleep(0)
second._waiter.future.cancel()
assert await asyncio.wait_for(wait_task, timeout = 0.1) is None
assert second.is_cancelled is True
first_lease.release()
snapshot = queue.snapshot()
assert snapshot.active == 0
assert snapshot.queued == 0
asyncio.run(_run())
def test_capacity_increase_promotes_existing_waiter_fifo():
async def _run():
queue = get_llama_admission_queue("http://llama.test")
config = LlamaAdmissionConfig()
first = queue.reserve(capacity = 1, config = config)
second = queue.reserve(capacity = 1, config = config)
first_lease = first.lease_nowait()
assert first_lease is not None
assert second.lease_nowait() is None
assert queue.snapshot().active == 1
assert queue.snapshot().queued == 1
third = queue.reserve(capacity = 2, config = config)
second_lease = await second.wait(0.1)
assert second_lease is not None
assert third.lease_nowait() is None
snapshot = queue.snapshot()
assert snapshot.capacity == 2
assert snapshot.active == 2
assert snapshot.queued == 1
first_lease.release()
third_lease = await third.wait(0.1)
assert third_lease is not None
second_lease.release()
third_lease.release()
snapshot = queue.snapshot()
assert snapshot.active == 0
assert snapshot.queued == 0
asyncio.run(_run())
def test_lease_release_is_idempotent_under_concurrent_calls():
async def _run():
queue = get_llama_admission_queue("http://llama.test")
config = LlamaAdmissionConfig()
reservation = queue.reserve(capacity = 1, config = config)
lease = reservation.lease_nowait()
assert lease is not None
threads = [threading.Thread(target = lease.release) for _ in range(16)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
snapshot = queue.snapshot()
assert snapshot.active == 0
assert snapshot.queued == 0
asyncio.run(_run())
def test_new_key_evicts_idle_prior_load_queues():
# Each model load carries a fresh ephemeral port, so a new base_url key must
# not leave the drained queues from earlier loads accumulating forever.
get_llama_admission_queue("http://127.0.0.1:1001")
get_llama_admission_queue("http://127.0.0.1:1002")
assert set(llama_admission._QUEUES) == {"http://127.0.0.1:1002"}
get_llama_admission_queue("http://127.0.0.1:1003")
assert set(llama_admission._QUEUES) == {"http://127.0.0.1:1003"}
def test_new_key_retains_in_flight_prior_load_queue():
config = LlamaAdmissionConfig()
busy = get_llama_admission_queue("http://127.0.0.1:2001")
async def _run():
reservation = busy.reserve(capacity = 1, config = config)
lease = reservation.lease_nowait()
assert lease is not None
# A new load must not drop a queue that still has an in-flight request.
get_llama_admission_queue("http://127.0.0.1:2002")
assert set(llama_admission._QUEUES) == {"http://127.0.0.1:2001", "http://127.0.0.1:2002"}
# Once it drains, the next load reclaims it.
lease.release()
get_llama_admission_queue("http://127.0.0.1:2003")
assert set(llama_admission._QUEUES) == {"http://127.0.0.1:2003"}
asyncio.run(_run())

View file

@ -0,0 +1,53 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
import os
import sys
import pytest
_backend = os.path.join(os.path.dirname(__file__), "..")
sys.path.insert(0, _backend)
from core.inference import llama_cpp as llama_cpp_module
from core.inference.llama_cpp import LlamaCppBackend
@pytest.fixture
def backend(monkeypatch):
monkeypatch.setattr(LlamaCppBackend, "_kill_orphaned_servers", lambda self: 0)
monkeypatch.setattr(llama_cpp_module.atexit, "register", lambda *_args, **_kwargs: None)
return LlamaCppBackend()
def test_effective_parallel_slots_initial_value_is_one(backend):
assert backend.effective_parallel_slots == 1
def test_effective_parallel_slots_commit_uses_final_positive_parallel(backend):
backend._commit_effective_parallel_slots(3)
assert backend.effective_parallel_slots == 3
@pytest.mark.parametrize("value", [None, 0, -2, "not-an-int"])
def test_effective_parallel_slots_commit_invalid_value_falls_back_to_one(backend, value):
backend._commit_effective_parallel_slots(value)
assert backend.effective_parallel_slots == 1
def test_effective_parallel_slots_reset_returns_to_one(backend):
backend._commit_effective_parallel_slots(4)
backend._reset_effective_parallel_slots()
assert backend.effective_parallel_slots == 1
def test_effective_parallel_slots_unload_resets_to_one(backend):
backend._commit_effective_parallel_slots(4)
backend.unload_model()
assert backend.effective_parallel_slots == 1

View file

@ -137,9 +137,9 @@ def test_read_install_marker_finds_windows_cmake_layout(tmp_path):
@pytest.mark.parametrize("repo", ["unslothai/llama.cpp", "ggml-org/llama.cpp"])
def test_read_install_marker_carries_published_repo_dynamically(tmp_path, repo):
# The freshness check queries whichever release repo the marker records,
# so CUDA (unslothai), CPU/macOS (ggml-org), and ROCm all get the right
# "latest" tag.
# The freshness check queries whichever release repo the marker records:
# new installs record the fork, legacy CPU/macOS markers still say ggml-org,
# and both must get the right "latest" tag.
install_dir = tmp_path / "llama.cpp"
_write_marker(install_dir, tag = "b9000", published_repo = repo)
bin_path = _fake_binary(install_dir, layout = "cmake")

View file

@ -0,0 +1,81 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
import contextlib
import os
import sys
import threading
import httpx
import pytest
_backend = os.path.join(os.path.dirname(__file__), "..")
sys.path.insert(0, _backend)
from core.inference.llama_cpp import LlamaCppBackend, _LlamaStreamCancelled
def _backend_stub() -> LlamaCppBackend:
backend = LlamaCppBackend.__new__(LlamaCppBackend)
backend._process = object()
backend._healthy = True
backend._port = 48848
backend._effective_context_length = 4096
backend._supports_reasoning = False
backend._reasoning_always_on = False
backend._reasoning_style = "enable_thinking"
backend._supports_preserve_thinking = False
return backend
def test_stream_cancel_uses_internal_exception_not_generator_exit():
class FakeResponse:
status_code = 200
def close(self):
pass
class FakeStream:
def __enter__(self):
return FakeResponse()
def __exit__(self, *_args):
return False
class FakeClient:
def stream(self, *_args, **_kwargs):
return FakeStream()
cancel_event = threading.Event()
with pytest.raises(Exception) as exc_info:
with LlamaCppBackend._stream_with_retry(
FakeClient(),
"http://llama.test/v1/chat/completions",
{},
cancel_event,
):
cancel_event.set()
raise httpx.ReadError("client closed")
assert exc_info.type is _LlamaStreamCancelled
assert not issubclass(exc_info.type, GeneratorExit)
def test_generate_chat_completion_swallows_internal_stream_cancel(monkeypatch):
backend = _backend_stub()
@contextlib.contextmanager
def fake_open_stream(*_args, **_kwargs):
raise _LlamaStreamCancelled
monkeypatch.setattr(backend, "_open_stream", fake_open_stream)
chunks = list(
backend.generate_chat_completion(
[{"role": "user", "content": "hi"}],
cancel_event = threading.Event(),
)
)
assert chunks == []

View file

@ -448,6 +448,48 @@ def test_start_update_happy_path(monkeypatch, tmp_path):
assert popen_kwargs["env"]["UNSLOTH_PROGRESS_PERCENT_STEP"] == "5"
def test_start_update_preserves_vulkan_via_env(monkeypatch, tmp_path):
# A Vulkan install (marker asset carries 'vulkan') must re-assert
# UNSLOTH_FORCE_VULKAN on update, or detect_host on a GPU box re-routes to
# CUDA/ROCm and silently replaces the Vulkan build.
install_dir = tmp_path / "llama.cpp"
binary = _write_install(
install_dir,
"b9493",
repo = "ggml-org/llama.cpp",
asset = "llama-b9493-bin-ubuntu-vulkan-x64.tar.gz",
)
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9518")
def _on_start(cmd):
_write_install(
install_dir,
"b9518",
repo = "ggml-org/llama.cpp",
asset = "llama-b9518-bin-ubuntu-vulkan-x64.tar.gz",
)
popen_kwargs: dict = {}
_patch_installer_popen(
monkeypatch,
lines = ["installed\n"],
on_start = _on_start,
captured_kwargs = popen_kwargs,
)
assert upd.start_update()["started"] is True
deadline = time.time() + 10
while time.time() < deadline:
job = upd.get_update_status()["job"]
if job["state"] in ("success", "error"):
break
time.sleep(0.05)
assert job["state"] == "success", job
assert popen_kwargs["env"]["UNSLOTH_FORCE_VULKAN"] == "1"
def test_start_update_reports_full_release_tag(monkeypatch, tmp_path):
install_dir = tmp_path / "llama.cpp"
binary = _write_install(install_dir, "b9595")
@ -594,9 +636,10 @@ def test_install_cmd_fork_rocm_marker_forwards_has_rocm(monkeypatch, tmp_path):
def test_install_cmd_ggml_cpu_marker_has_no_cpu_fallback(monkeypatch, tmp_path):
# CPU installs come from ggml-org. Re-running into the same install-dir/repo
# reproduces the same CPU bundle; --cpu-fallback (which force-drops GPU
# detection) is reserved for setup.sh's arm64 rescue and must not appear here.
# Legacy CPU installs recorded a ggml-org marker (new installs use the fork).
# Re-running into the same install-dir/repo reproduces the same CPU bundle;
# --cpu-fallback (which force-drops GPU detection) is reserved for setup.sh's
# arm64 rescue and must not appear here.
cmd = _capture_install_cmd(
monkeypatch,
tmp_path,

View file

@ -0,0 +1,193 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Vulkan free-VRAM reader regression tests on a synthetic probe output.
Covers the post-probe handling in
``LlamaCppBackend._get_gpu_free_memory_vulkan``:
* integrated GPUs (probe reports is_igpu=1) leave a flat per-device host
margin matching llama.cpp's --fit-target, so context auto-sizing can't
over-commit shared RAM, and report total 0 (shared RAM is not a budget),
* discrete GPUs (is_igpu=0) keep their free untouched and pass their real
total through so the fit can reserve absolute headroom,
* an inherited ``GGML_VK_VISIBLE_DEVICES`` is passed through to ggml unchanged
(ggml applies it), not stripped or filtered in Python -- the probe reports
ggml's compact ordinal, which load_model pins with ``--device Vulkan<i>``.
The ggml Vulkan library is never loaded: subprocess.run is mocked to emit
the tab-separated lines the real ``_vulkan_probe.py`` would print.
"""
from __future__ import annotations
import subprocess
import sys
import types as _types
from pathlib import Path
from unittest import mock
import pytest
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
import importlib as _importlib # noqa: E402
def _maybe_stub(name: str, builder):
try:
_importlib.import_module(name)
except ImportError:
sys.modules[name] = builder()
def _build_loggers_stub():
m = _types.ModuleType("loggers")
m.get_logger = lambda name: __import__("logging").getLogger(name)
return m
_maybe_stub("loggers", _build_loggers_stub)
_maybe_stub("structlog", lambda: _types.ModuleType("structlog"))
from core.inference import llama_cpp as _llama_mod # noqa: E402
from core.inference.llama_cpp import ( # noqa: E402
LlamaCppBackend,
_llama_lib_dir,
_vulkan_lib_filename,
)
MIB = 1024 * 1024
GIB = 1024 * MIB
def _make_vulkan_install(tmp_path: Path) -> str:
"""A binary whose sibling dir holds the Vulkan ggml lib, so the
reader's ``is_vulkan_backend`` sibling-file check passes."""
bindir = tmp_path / "build" / "bin"
bindir.mkdir(parents = True)
binary = bindir / ("llama-server.exe" if sys.platform == "win32" else "llama-server")
binary.write_bytes(b"stub")
(bindir / _vulkan_lib_filename()).write_bytes(b"stub")
return str(binary)
def _mock_probe(rows: list[str], captured_env: dict | None = None):
"""Patch subprocess.run so the _vulkan_probe.py call returns ``rows``
(already tab-formatted), recording the env it was launched with."""
real_run = subprocess.run
def fake_run(cmd, *args, **kwargs):
if isinstance(cmd, list) and any("_vulkan_probe" in str(c) for c in cmd):
if captured_env is not None:
captured_env.clear()
captured_env.update(kwargs.get("env") or {})
return subprocess.CompletedProcess(
args = cmd, returncode = 0, stdout = "\n".join(rows), stderr = ""
)
return real_run(cmd, *args, **kwargs)
return mock.patch("subprocess.run", side_effect = fake_run)
def _row(
idx: int,
free_bytes: int,
is_igpu: int,
total_bytes: int = 0,
) -> str:
return f"{idx}\t{free_bytes}\t{is_igpu}\t{total_bytes}"
def test_integrated_gpu_leaves_host_margin(tmp_path):
binary = _make_vulkan_install(tmp_path)
# iGPU with 30 GiB free; reserve a flat 1024 MiB (llama.cpp --fit-target).
# total stays 0: shared system RAM is not a VRAM budget for the fit.
rows = [_row(0, 30 * GIB, is_igpu = 1, total_bytes = 32 * GIB)]
with _mock_probe(rows):
gpus = LlamaCppBackend._get_gpu_free_memory_vulkan(binary)
assert gpus == [(0, 30 * 1024 - 1024, 0)], gpus
def test_discrete_gpu_free_is_untouched_and_total_passed_through(tmp_path):
binary = _make_vulkan_install(tmp_path)
# 6 GiB free on a partially occupied 24 GiB card: free is untouched and the
# real total flows through so the fit reserves absolute headroom (CUDA/ROCm
# parity) instead of the looser free*frac budget.
rows = [_row(0, 6 * GIB, is_igpu = 0, total_bytes = 24 * GIB)]
with _mock_probe(rows):
gpus = LlamaCppBackend._get_gpu_free_memory_vulkan(binary)
assert gpus == [(0, 6 * 1024, 24 * 1024)], gpus
def test_large_discrete_gpu_is_untouched(tmp_path):
binary = _make_vulkan_install(tmp_path)
# A 48 GiB discrete card stays untouched regardless of size; only the
# iGPU flag triggers the host margin, never a VRAM/RAM ratio.
rows = [_row(0, 47 * GIB, is_igpu = 0, total_bytes = 48 * GIB)]
with _mock_probe(rows):
gpus = LlamaCppBackend._get_gpu_free_memory_vulkan(binary)
assert gpus == [(0, 47 * 1024, 48 * 1024)], gpus
def test_inherited_visible_devices_mask_is_passed_through_to_probe(tmp_path, monkeypatch):
# The mask is NOT stripped or filtered in Python: ggml parses it in raw
# physical-device space while this probe reports the compact post-filter
# ordinal, so mixing spaces would be wrong. It is passed through unchanged
# so ggml applies it to the same device list the launch will enumerate.
binary = _make_vulkan_install(tmp_path)
monkeypatch.setenv("GGML_VK_VISIBLE_DEVICES", "1")
captured: dict = {}
rows = [_row(0, 23 * GIB, is_igpu = 0, total_bytes = 24 * GIB)]
with _mock_probe(rows, captured_env = captured):
LlamaCppBackend._get_gpu_free_memory_vulkan(binary)
assert captured.get("GGML_VK_VISIBLE_DEVICES") == "1", captured
def test_vulkan_pin_args_uses_device_names_not_env_mask():
# Pin by compact device name via --device (the space the probe reports and
# the registry names), never by writing a compact ordinal into the raw
# GGML_VK_VISIBLE_DEVICES index space.
assert LlamaCppBackend._vulkan_pin_args([0]) == ["--device", "Vulkan0"]
assert LlamaCppBackend._vulkan_pin_args([1, 2]) == ["--device", "Vulkan1,Vulkan2"]
assert LlamaCppBackend._vulkan_pin_args(None) == []
assert LlamaCppBackend._vulkan_pin_args([]) == []
def test_vulkan_only_build_is_detected(tmp_path):
binary = _make_vulkan_install(tmp_path)
assert LlamaCppBackend._is_vulkan_backend(binary) is True
def test_multi_backend_build_is_not_vulkan_only(tmp_path):
# A custom build that ships CUDA (or HIP) alongside Vulkan must NOT be
# treated as Vulkan-only, or its CUDA GPU would be probed/pinned as a Vulkan
# device; defer to the CUDA/HIP path instead.
binary = _make_vulkan_install(tmp_path)
cuda = "ggml-cuda.dll" if sys.platform == "win32" else "libggml-cuda.so"
(_llama_lib_dir(binary) / cuda).write_bytes(b"stub")
assert LlamaCppBackend._is_vulkan_backend(binary) is False
@pytest.mark.skipif(sys.platform == "win32", reason = "shell wrapper fallback is POSIX")
def test_shell_wrapper_entrypoint_resolves_to_real_lib_dir(tmp_path):
# create_exec_entrypoint falls back to a #!/bin/sh wrapper at the install root
# when it cannot symlink; _find_llama_server_binary returns that root entrypoint,
# so _llama_lib_dir must follow the wrapper's exec target to build/bin -- else
# _is_vulkan_backend misses libggml-vulkan.so and the Vulkan probe/pin silently
# never engage on a valid Vulkan install.
import os
binary = _make_vulkan_install(tmp_path) # tmp_path/build/bin/llama-server + vulkan lib
bindir = Path(binary).parent
wrapper = tmp_path / "llama-server"
wrapper.write_text('#!/bin/sh\nexec "$(dirname "$0")/build/bin/llama-server" "$@"\n')
os.chmod(wrapper, 0o755)
assert _llama_lib_dir(str(wrapper)) == bindir
assert LlamaCppBackend._is_vulkan_backend(str(wrapper)) is True
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-v"]))

View file

@ -203,3 +203,87 @@ def test_preheader_send_cleanup_on_disconnect_and_cancel():
asyncio.run(_run(False))
asyncio.run(_run(True))
def test_stream_stall_timeout_callable_re_resolved_each_read():
# The OpenAI passthrough passes a callable so the stall bound can switch to
# the short post-terminal grace mid-stream; it must be re-resolved per read,
# not captured once at generator start.
async def _run():
response = SimpleNamespace(request = SimpleNamespace(extensions = {"timeout": {}}))
values = iter([100.0, 2.0])
seen = []
class _Request:
async def is_disconnected(self):
return False
class _Items:
def __init__(self):
self.count = 0
async def __anext__(self):
self.count += 1
if self.count > 3:
raise StopAsyncIteration
return "data: {}"
async for _ in inf_mod._aiter_llama_stream_items(
_Items(),
cancel_event = threading.Event(),
request = _Request(),
response = response,
first_token_deadline = time.monotonic() + 1,
post_first_item_read_timeout_s = lambda: next(values, 5.0),
):
seen.append(response.request.extensions["timeout"].get("read"))
assert len(seen) == 3
# The callable is resolved right after the first item (arming the
# post-first window) and again before each later read, consuming
# successive values.
assert seen[0] == 100.0
assert 1.0 <= seen[1] <= 2.0
assert 4.0 <= seen[2] <= 5.0
asyncio.run(_run())
def test_stream_stall_timeout_disabled_clears_read_timeout():
# UNSLOTH_OPENAI_COMPAT_STREAM_STALL_TIMEOUT=0 disables the stall guard, so
# the callable returns None. Once a chunk has arrived the leftover
# first-token read timeout must be cleared, else a long post-first-chunk gap
# trips a stale deadline the operator asked to turn off.
async def _run():
response = SimpleNamespace(request = SimpleNamespace(extensions = {"timeout": {}}))
seen = []
class _Request:
async def is_disconnected(self):
return False
class _Items:
def __init__(self):
self.count = 0
async def __anext__(self):
self.count += 1
if self.count > 2:
raise StopAsyncIteration
return "data: {}"
async for _ in inf_mod._aiter_llama_stream_items(
_Items(),
cancel_event = threading.Event(),
request = _Request(),
response = response,
first_token_deadline = time.monotonic() + 5,
post_first_item_read_timeout_s = lambda: None,
):
seen.append(response.request.extensions["timeout"].get("read"))
# The first-token path armed a finite read timeout; after the first chunk
# with the guard disabled, it is cleared to None on every subsequent read.
assert seen == [None, None], seen
asyncio.run(_run())

View file

@ -4,6 +4,8 @@ import sys
import types
from types import SimpleNamespace
import pytest
class _DummyMetal:
@staticmethod
@ -185,6 +187,129 @@ def test_mlx_inference_vlm_lora_uses_unsloth_loader_without_native_adapter_rewri
assert isinstance(backend._tokenizer, _DummyTokenizer)
def test_mlx_inference_distributed_vlm_forwards_group_to_fast_mlx(monkeypatch):
_install_fake_mlx(monkeypatch)
calls = []
_install_fake_fast_mlx(monkeypatch, calls)
from core.inference.mlx_inference import MLXInferenceBackend
group = SimpleNamespace(size = lambda: 2, rank = lambda: 0)
config = SimpleNamespace(identifier = "fake/vlm", is_vision = True, is_lora = False)
for mode, group_key in (("tensor", "tensor_group"), ("pipeline", "pipeline_group")):
calls.clear()
assert MLXInferenceBackend().load_model(config, parallel_mode = mode, distributed_group = group)
_, kwargs = calls.pop()
assert kwargs["text_only"] is False and kwargs[group_key] is group
calls.clear()
singleton = SimpleNamespace(size = lambda: 1, rank = lambda: 0)
assert MLXInferenceBackend().load_model(
config, parallel_mode = "tensor", distributed_group = singleton
)
assert not {"tensor_group", "pipeline_group"} & set(calls.pop()[1])
config = SimpleNamespace(identifier = "fake/adapter", is_vision = False, is_lora = True)
with pytest.raises(ValueError, match = "LoRA adapter repos"):
MLXInferenceBackend().load_model(config, parallel_mode = "tensor", distributed_group = group)
@pytest.mark.parametrize("accepts_backend", (True, False))
def test_mlx_distributed_init_selects_jaccl_backend(monkeypatch, accepts_backend):
_install_fake_mlx(monkeypatch)
from core.inference.mlx_inference import _init_mlx_distributed
group = SimpleNamespace(rank = lambda: 1, size = lambda: 2)
calls = []
def _init(**kwargs):
calls.append(kwargs)
if kwargs and not accepts_backend:
raise TypeError("backend keyword unsupported")
return group
sys.modules["mlx.core"].distributed = SimpleNamespace(init = _init)
monkeypatch.setenv("MLX_JACCL_COORDINATOR", "127.0.0.1:12345")
monkeypatch.setenv("MLX_IBV_DEVICES", "/tmp/devices.json")
assert _init_mlx_distributed() == (group, 1, 2)
assert calls == ([{"backend": "jaccl"}] if accepts_backend else [{"backend": "jaccl"}, {}])
def test_worker_share_object_receives_distributed_payload(monkeypatch):
from core.inference import worker
shared_obj = {"type": "turn", "text": "hi"}
payload = worker._encode_share_object(shared_obj)
def _array(value):
val = value.item() if hasattr(value, "item") else value
return SimpleNamespace(
item = lambda: val,
tolist = lambda: list(val) if hasattr(val, "__iter__") else [val],
)
mlx_pkg = types.ModuleType("mlx")
mlx_core = types.ModuleType("mlx.core")
mlx_core.uint8 = "uint8"
mlx_core.array = _array
mlx_core.zeros = lambda *_a, **_k: _array([])
def _all_sum(value, group = None):
value = value.item() if hasattr(value, "item") else value
return _array(len(payload)) if value == 0 else _array(payload)
mlx_core.distributed = SimpleNamespace(all_sum = _all_sum)
mlx_pkg.core = mlx_core
monkeypatch.setitem(sys.modules, "mlx", mlx_pkg)
monkeypatch.setitem(sys.modules, "mlx.core", mlx_core)
responses = []
worker._handle_share_object(
SimpleNamespace(
_distributed_group = object(),
_distributed_rank = 1,
_distributed_world_size = 2,
),
{"type": "share_object", "request_id": "rid", "object": None},
SimpleNamespace(put = responses.append),
)
response = responses[0]
assert response["object"] == shared_obj
def test_worker_share_object_oversize_notifies_peers(monkeypatch):
from core.inference import worker
calls = []
mlx_pkg = types.ModuleType("mlx")
mlx_core = types.ModuleType("mlx.core")
mlx_core.array = lambda value, **_kwargs: SimpleNamespace(item = lambda: value)
mlx_core.eval = lambda value: value
mlx_core.distributed = SimpleNamespace(
all_sum = lambda value, group = None: calls.append(value.item()) or value
)
mlx_pkg.core = mlx_core
monkeypatch.setitem(sys.modules, "mlx", mlx_pkg)
monkeypatch.setitem(sys.modules, "mlx.core", mlx_core)
monkeypatch.setattr(worker, "_SHARE_OBJECT_MAX_BYTES", 8)
responses = []
worker._handle_share_object(
SimpleNamespace(
_distributed_group = object(),
_distributed_rank = 0,
_distributed_world_size = 2,
),
{"type": "share_object", "request_id": "rid", "object": {"text": "too long"}},
SimpleNamespace(put = responses.append),
)
assert calls == [worker._SHARE_OBJECT_ERROR_SIZE]
assert responses[0]["type"] == "share_error"
# Regression: generate_chat_response must accept the four template kwargs
# (tools / enable_thinking / reasoning_effort / preserve_thinking) so the route
# layer can forward UI toggles. The old signature raised

View file

@ -23,7 +23,11 @@ if _BACKEND_DIR not in sys.path:
from hub.utils.download_manifest import ExpectedFile
from hub.utils.gguf import is_mtp_drafter_path
from hub.utils.gguf_plan import build_gguf_variant_plans, plan_from_expected_files
from hub.utils.gguf_plan import (
build_gguf_variant_plans,
plan_from_expected_files,
preferred_mtp_sibling,
)
from utils.models.model_config import (
_is_mtp_drafter,
detect_gguf_model,
@ -37,6 +41,8 @@ from utils.models.model_config import (
DRAFTER_CASES = [
("mtp-gemma-4-12b-it.gguf", True),
("MTP/gemma-4-12b-it-Q8_0-MTP.gguf", True),
# New-scheme MTP/ copies carry the mtp- basename prefix too.
("MTP/mtp-gemma-4-E4B-it-BF16.gguf", True),
("foo/MTP/bar.gguf", True),
("gemma-4-12b-it-Q8_0.gguf", False),
# Baked-in Qwen MTP repos: the head is inside the main GGUF, the file
@ -274,3 +280,178 @@ def test_detect_gguf_model_rejects_mtp_subdir_copy(tmp_path):
assert detect_gguf_model(str(copy)) is None
# Selecting the MTP dir itself must not surface the copies as models.
assert detect_gguf_model(str(sub)) is None
# ── Root drafter wins over new-scheme MTP/ copies ────────────────────
# The MTP/ copies were renamed to share the mtp- basename prefix (e.g.
# MTP/mtp-gemma-4-E4B-it-BF16.gguf). Auto-fetch/load must still resolve the
# small repo-root drafter, not a sort-first MTP/ copy (uppercase precedes
# lowercase, so the subdir path would otherwise win).
NEW_SCHEME_SIBLINGS = [
_sib("gemma-4-12b-it-Q4_K_M.gguf", 4_000, "main-q4"),
_sib("gemma-4-12b-it-Q8_0.gguf", 8_000, "main-q8"),
_sib("mtp-gemma-4-12b-it.gguf", 100, "drafter"),
_sib("MTP/mtp-gemma-4-12b-it-Q8_0.gguf", 100, "mtp-sub-q8"),
_sib("MTP/mtp-gemma-4-12b-it-BF16.gguf", 200, "mtp-sub-bf16"),
_sib("mmproj-F16.gguf", 500, "mmproj"),
]
def test_preferred_mtp_sibling_prefers_root_over_new_scheme_copies():
picked = preferred_mtp_sibling(NEW_SCHEME_SIBLINGS)
assert picked is not None and picked.rfilename == "mtp-gemma-4-12b-it.gguf"
def test_variant_plans_new_scheme_uses_root_drafter():
plans = build_gguf_variant_plans(NEW_SCHEME_SIBLINGS)
assert set(plans) == {"q4_k_m", "q8_0"}
for plan in plans.values():
assert "mtp-gemma-4-12b-it.gguf" in plan.target_filenames
assert not any("MTP/" in name for name in plan.target_filenames)
assert "drafter" in plan.companion_hashes
# Download size = main + mmproj + root drafter (not the 200-byte BF16 copy).
assert plans["q4_k_m"].download_size_bytes == 4_600
def test_download_mtp_prefers_root_over_new_scheme_copies(monkeypatch):
# _pick_mtp is nested; capture it via the companion-download seam.
from core.inference.llama_cpp import LlamaCppBackend
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False) # online: skip reuse probe
captured = {}
def _fake_companion(
*,
hf_repo,
hf_token,
pick,
label,
cancel_event = None,
):
captured["pick"] = pick
return None
b = LlamaCppBackend()
b._download_companion_gguf = _fake_companion
b._download_mtp(hf_repo = "unsloth/gemma-4-E4B-it-qat-mobile-GGUF")
repo_files = [
"MTP/mtp-gemma-4-E4B-it-BF16.gguf",
"MTP/mtp-gemma-4-E4B-it-Q4_0.gguf",
"MTP/mtp-gemma-4-E4B-it-Q8_0.gguf",
"gemma-4-E4B-it-qat-UD-Q2_K_XL.gguf",
"mmproj-F16.gguf",
"mtp-gemma-4-E4B-it.gguf",
]
assert captured["pick"](repo_files) == "mtp-gemma-4-E4B-it.gguf"
# ── Reuse an on-disk drafter offline; fetch fresh online ─────────────
def _seed_snapshot(tmp_path, names):
snap = tmp_path / "snap"
for rel in names:
f = snap / rel
f.parent.mkdir(parents = True, exist_ok = True)
f.write_bytes(b"x")
return snap
def test_download_mtp_reuses_cached_root_drafter_offline(tmp_path, monkeypatch):
import utils.models.model_config as mc
from core.inference.llama_cpp import LlamaCppBackend
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
snap = _seed_snapshot(
tmp_path,
[
"gemma-4-E4B-it-qat-UD-Q2_K_XL.gguf",
"mtp-gemma-4-E4B-it.gguf",
"MTP/mtp-gemma-4-E4B-it-BF16.gguf",
"mmproj-F16.gguf",
],
)
monkeypatch.setattr(mc, "_iter_hf_cache_snapshots", lambda repo: [snap])
got = LlamaCppBackend()._download_mtp(hf_repo = "unsloth/gemma-4-E4B-it-qat-mobile-GGUF")
assert got is not None and Path(got).name == "mtp-gemma-4-E4B-it.gguf"
def test_download_mtp_reuses_cached_subdir_copy_when_no_root_offline(tmp_path, monkeypatch):
# Pre-fix build may have fetched only the MTP/ copy; reuse it offline.
import utils.models.model_config as mc
from core.inference.llama_cpp import LlamaCppBackend
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
snap = _seed_snapshot(
tmp_path,
[
"gemma-4-E4B-it-qat-UD-Q2_K_XL.gguf",
"MTP/mtp-gemma-4-E4B-it-BF16.gguf",
],
)
monkeypatch.setattr(mc, "_iter_hf_cache_snapshots", lambda repo: [snap])
got = LlamaCppBackend()._download_mtp(hf_repo = "unsloth/gemma-4-E4B-it-qat-mobile-GGUF")
assert got is not None and Path(got).name == "mtp-gemma-4-E4B-it-BF16.gguf"
def test_download_mtp_prefers_root_across_snapshots_offline(tmp_path, monkeypatch):
# A newer partial snapshot holds only the MTP/ copy; an older one has the
# root. Must still return the small root, not the large subdir copy.
import utils.models.model_config as mc
from core.inference.llama_cpp import LlamaCppBackend
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
snap_partial = _seed_snapshot(tmp_path / "new", ["MTP/mtp-gemma-4-E4B-it-BF16.gguf"])
snap_full = _seed_snapshot(tmp_path / "old", ["mtp-gemma-4-E4B-it.gguf"])
monkeypatch.setattr(mc, "_iter_hf_cache_snapshots", lambda repo: [snap_partial, snap_full])
got = LlamaCppBackend()._download_mtp(hf_repo = "unsloth/gemma-4-E4B-it-qat-mobile-GGUF")
assert got is not None and Path(got).name == "mtp-gemma-4-E4B-it.gguf"
def test_download_mtp_reuse_follows_snapshot_order_offline(tmp_path, monkeypatch):
# Two snapshots both hold a root drafter; newest-first order must win so a
# fresh main GGUF is not paired with a stale drafter revision.
import utils.models.model_config as mc
from core.inference.llama_cpp import LlamaCppBackend
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
newest = _seed_snapshot(tmp_path / "newest", ["mtp-gemma-4-E4B-it.gguf"])
oldest = _seed_snapshot(tmp_path / "oldest", ["mtp-gemma-4-E4B-it.gguf"])
monkeypatch.setattr(mc, "_iter_hf_cache_snapshots", lambda repo: [newest, oldest])
got = LlamaCppBackend()._download_mtp(hf_repo = "unsloth/gemma-4-E4B-it-qat-mobile-GGUF")
assert got is not None and Path(got).parent.parent.name == "newest"
def test_download_mtp_online_skips_cache_reuse(tmp_path, monkeypatch):
# Online, do not reuse a cached copy: go to the download path so a changed
# drafter is refetched (hf_hub_download checks the current revision).
import utils.models.model_config as mc
from core.inference.llama_cpp import LlamaCppBackend
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
snap = _seed_snapshot(tmp_path, ["mtp-gemma-4-E4B-it.gguf"])
monkeypatch.setattr(mc, "_iter_hf_cache_snapshots", lambda repo: [snap])
reached = {}
def _fake_companion(
*,
hf_repo,
hf_token,
pick,
label,
cancel_event = None,
):
reached["hit"] = True
return None
b = LlamaCppBackend()
b._download_companion_gguf = _fake_companion
assert b._download_mtp(hf_repo = "unsloth/gemma-4-E4B-it-qat-mobile-GGUF") is None
assert reached.get("hit") is True

View file

@ -79,9 +79,11 @@ from huggingface_hub import constants as hf_constants
from core.inference.llama_cpp import (
LlamaCppBackend,
_cached_colocated_split_main,
_gguf_files_for_variant,
_hf_offline_if_dns_dead,
_probe_dns_dead,
_resolve_repo_id_casing,
)
from utils.models.model_config import (
_detect_gguf_from_hf_cache,
@ -217,7 +219,7 @@ class TestGgufVariantFileResolution:
downloaded.append(filename)
return f"/fake/{repo_id}/{filename}"
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
with (
patch(
"huggingface_hub.list_repo_files",
@ -239,6 +241,214 @@ class TestGgufVariantFileResolution:
assert downloaded == ["tinyllamas/stories260K.gguf"]
assert out == "/fake/ggml-org/models/tinyllamas/stories260K.gguf"
def test_download_reuses_older_snapshot_when_current_ref_snapshot_is_partial(
self, monkeypatch, hf_cache
):
# Cross-snapshot reuse is an offline-resilience path: online, hf_hub_download
# resumes the partial current-ref download and revalidates the revision instead
# of serving an older snapshot's same-name blob.
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
backend = LlamaCppBackend()
repo = "unsloth/vision-GGUF"
old = _build_cache(
hf_cache,
repo,
{"model-UD-Q4_K_XL.gguf": 4},
snapshot_sha = "a" * 40,
)
_build_cache(
hf_cache,
repo,
{"mtp-model.gguf": 1},
snapshot_sha = "b" * 40,
)
def fake_get_paths_info(
_repo_id,
paths,
token = None,
):
return [_types.SimpleNamespace(path = path, size = 4) for path in paths if path]
def fail_download(*_args, **_kwargs):
raise AssertionError("should reuse the cached GGUF instead of downloading")
with (
patch(
"huggingface_hub.list_repo_files",
lambda *_a, **_k: ["model-UD-Q4_K_XL.gguf", "mtp-model.gguf"],
),
patch("huggingface_hub.get_paths_info", fake_get_paths_info),
patch("huggingface_hub.try_to_load_from_cache", lambda *_a, **_k: None),
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", fail_download),
):
out = backend._download_gguf(
hf_repo = repo,
hf_variant = "UD-Q4_K_XL",
)
assert out == str(old / "model-UD-Q4_K_XL.gguf")
def test_download_reuses_cached_gguf_when_lowercase_partial_cache_shadows_it(
self, monkeypatch, hf_cache
):
# Case-variant cross-dir reuse is offline-only; online the canonical repo id
# resolves up front and hf_hub_download fetches the current revision.
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
backend = LlamaCppBackend()
canonical_repo = "unsloth/gemma-4-E2B-it-GGUF"
requested_repo = "unsloth/gemma-4-e2b-it-gguf"
gguf_file = "gemma-4-E2B-it-UD-Q4_K_XL.gguf"
snap = _build_cache(
hf_cache,
canonical_repo,
{gguf_file: 4},
snapshot_sha = "a" * 40,
)
lower_snap = _build_cache(
hf_cache,
requested_repo,
{"mtp-gemma-4-E2B-it.gguf": 1},
snapshot_sha = "b" * 40,
)
os.utime(lower_snap, (2000, 2000))
os.utime(snap, (1000, 1000))
seen_repos: list[str] = []
def fake_list_repo_files(repo_id, token = None):
seen_repos.append(repo_id)
return [gguf_file]
def fake_get_paths_info(
repo_id,
paths,
token = None,
):
seen_repos.append(repo_id)
return [_types.SimpleNamespace(path = path, size = 4) for path in paths if path]
def fake_cache(repo_id, filename, *args, **kwargs):
seen_repos.append(repo_id)
return str(snap / filename) if repo_id == canonical_repo else None
def fail_download(*_args, **_kwargs):
raise AssertionError("should reuse the cached GGUF instead of downloading")
with (
patch("huggingface_hub.list_repo_files", fake_list_repo_files),
patch("huggingface_hub.get_paths_info", fake_get_paths_info),
patch("huggingface_hub.try_to_load_from_cache", fake_cache),
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", fail_download),
):
out = backend._download_gguf(
hf_repo = requested_repo,
hf_variant = "UD-Q4_K_XL",
)
assert out == str(snap / gguf_file)
assert seen_repos
def test_download_online_does_not_reuse_old_snapshot(self, monkeypatch, hf_cache):
# Online, an older same-name snapshot must not be served (it may be a stale
# revision); hf_hub_download is called so the current revision is fetched and
# its etag revalidated.
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
backend = LlamaCppBackend()
repo = "unsloth/vision-GGUF"
_build_cache(hf_cache, repo, {"model-UD-Q4_K_XL.gguf": 4}, snapshot_sha = "a" * 40)
downloaded: list[str] = []
def fake_get_paths_info(
_repo_id,
paths,
token = None,
):
return [_types.SimpleNamespace(path = p, size = 4) for p in paths if p]
def fake_download(
repo_id,
filename,
token = None,
**kwargs,
):
downloaded.append(filename)
return f"/fresh/{filename}"
with (
patch(
"huggingface_hub.list_repo_files",
lambda *_a, **_k: ["model-UD-Q4_K_XL.gguf"],
),
patch("huggingface_hub.get_paths_info", fake_get_paths_info),
patch("huggingface_hub.try_to_load_from_cache", lambda *_a, **_k: None),
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", fake_download),
):
out = backend._download_gguf(hf_repo = repo, hf_variant = "UD-Q4_K_XL")
assert downloaded == ["model-UD-Q4_K_XL.gguf"]
assert out == "/fresh/model-UD-Q4_K_XL.gguf"
def test_download_reuses_older_snapshot_when_offline_env_is_true(self, monkeypatch, hf_cache):
# HF_HUB_OFFLINE accepts truthy spellings beyond "1" (true/yes/on); the offline
# cache reuse must trigger for those too, otherwise the earlier Hub calls run
# offline while this branch still attempts hf_hub_download and the cached GGUF
# cannot load.
monkeypatch.setenv("HF_HUB_OFFLINE", "true")
backend = LlamaCppBackend()
repo = "unsloth/vision-GGUF"
old = _build_cache(hf_cache, repo, {"model-UD-Q4_K_XL.gguf": 4}, snapshot_sha = "a" * 40)
def fake_get_paths_info(
_repo_id,
paths,
token = None,
):
return [_types.SimpleNamespace(path = p, size = 4) for p in paths if p]
def fail_download(*_args, **_kwargs):
raise AssertionError("should reuse the cached GGUF instead of downloading")
with (
patch("huggingface_hub.list_repo_files", lambda *_a, **_k: ["model-UD-Q4_K_XL.gguf"]),
patch("huggingface_hub.get_paths_info", fake_get_paths_info),
patch("huggingface_hub.try_to_load_from_cache", lambda *_a, **_k: None),
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", fail_download),
):
out = backend._download_gguf(hf_repo = repo, hf_variant = "UD-Q4_K_XL")
assert out == str(old / "model-UD-Q4_K_XL.gguf")
def test_download_companion_resolves_from_case_variant_snapshot_offline(
self, monkeypatch, hf_cache
):
# Offline, resolve_cached_repo_id_case can keep a partial lower-case spelling,
# so the companion (mmproj) must resolve from whichever case-variant snapshot
# actually holds it rather than being dropped by an hf_hub_download on the
# wrong casing.
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
backend = LlamaCppBackend()
canonical_repo = "unsloth/gemma-4-E2B-it-GGUF"
requested_repo = "unsloth/gemma-4-e2b-it-gguf"
snap = _build_cache(hf_cache, canonical_repo, {"mmproj-F16.gguf": 4}, snapshot_sha = "a" * 40)
# A partial lower-case dir exists so casing resolution keeps the requested spelling.
_build_cache(hf_cache, requested_repo, {"config.json": 1}, snapshot_sha = "b" * 40)
_offline_exc = type("OfflineModeIsEnabled", (Exception,), {})
def fake_list_repo_files(repo_id, token = None):
raise _offline_exc("offline")
def fail_download(*_args, **_kwargs):
raise AssertionError("should resolve the companion from cache, not download")
with (
patch("huggingface_hub.list_repo_files", fake_list_repo_files),
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", fail_download),
):
out = backend._download_mmproj(hf_repo = requested_repo)
assert out == str(snap / "mmproj-F16.gguf")
def test_download_includes_uppercase_split_gguf_shards(self, monkeypatch, tmp_path):
backend = LlamaCppBackend()
downloaded: list[str] = []
@ -264,7 +474,7 @@ class TestGgufVariantFileResolution:
downloaded.append(filename)
return f"/fake/{repo_id}/{filename}"
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
with (
patch("huggingface_hub.list_repo_files", lambda *_a, **_k: files),
patch("huggingface_hub.get_paths_info", fake_get_paths_info),
@ -279,6 +489,48 @@ class TestGgufVariantFileResolution:
assert downloaded == files
assert out == "/fake/org/repo/model-Q4_K_M-00001-of-00002.GGUF"
def test_download_refetches_split_gguf_when_shards_span_snapshots(self, monkeypatch, hf_cache):
# The cached main shard lives in an older snapshot; its sibling shard is only
# in a newer, separate snapshot. Reusing the main shard alone would leave
# llama.cpp unable to resolve the sibling, so the whole set must be re-fetched
# together (co-located) rather than served split across snapshot dirs.
backend = LlamaCppBackend()
repo = "org/split"
files = [
"model-Q4_K_M-00001-of-00002.gguf",
"model-Q4_K_M-00002-of-00002.gguf",
]
_build_cache(hf_cache, repo, {files[0]: 4}, snapshot_sha = "a" * 40)
_build_cache(hf_cache, repo, {files[1]: 4}, snapshot_sha = "b" * 40)
downloaded: list[str] = []
def fake_get_paths_info(
_repo_id,
paths,
token = None,
):
return [_types.SimpleNamespace(path = p, size = 4) for p in paths if p]
def fake_download(
repo_id,
filename,
token = None,
**_kwargs,
):
downloaded.append(filename)
return f"/fake/{repo_id}/{filename}"
with (
patch("huggingface_hub.list_repo_files", lambda *_a, **_k: files),
patch("huggingface_hub.get_paths_info", fake_get_paths_info),
patch("huggingface_hub.try_to_load_from_cache", lambda *_a, **_k: None),
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", fake_download),
):
out = backend._download_gguf(hf_repo = repo, hf_variant = "Q4_K_M")
assert downloaded == files
assert out == f"/fake/{repo}/{files[0]}"
def _siblings(items: dict[str, int]):
"""Mock ``hf_model_info(...).siblings`` payload."""
@ -315,6 +567,21 @@ class TestIterHfCacheSnapshots:
out = list(_iter_hf_cache_snapshots("unsloth/multi"))
assert [p.name for p in out] == ["b" * 40, "a" * 40]
def test_skips_snapshot_when_mtime_is_unavailable(self, hf_cache, monkeypatch):
stale = _build_cache(hf_cache, "unsloth/multi", {"x.gguf": 1}, snapshot_sha = "a" * 40)
good = _build_cache(hf_cache, "unsloth/multi", {"y.gguf": 1}, snapshot_sha = "b" * 40)
original_stat = Path.stat
def flaky_stat(self, *args, **kwargs):
if self == stale:
raise FileNotFoundError(str(self))
return original_stat(self, *args, **kwargs)
monkeypatch.setattr(Path, "stat", flaky_stat)
out = list(_iter_hf_cache_snapshots("unsloth/multi"))
assert out == [good]
def test_repo_id_match_is_case_insensitive(self, hf_cache):
_build_cache(hf_cache, "unsloth/Foo-GGUF", {"Foo-Q4_K_M.gguf": 1})
# Lookup with different org/name casing still resolves
@ -347,6 +614,87 @@ class TestListGgufVariantsFromCache:
assert _list_gguf_variants_from_hf_cache("unsloth/absent") is None
class TestCachedColocatedSplitMain:
def test_prefers_older_complete_snapshot_over_newer_partial(self, hf_cache):
# Newer snapshot has only shard 1; older snapshot has the complete set. The
# complete older snapshot must win so the split GGUF can load co-located.
shard1 = "m-00001-of-00002.gguf"
shard2 = "m-00002-of-00002.gguf"
old = _build_cache(
hf_cache, "unsloth/split-GGUF", {shard1: 100, shard2: 100}, snapshot_sha = "a" * 40
)
new = _build_cache(hf_cache, "unsloth/split-GGUF", {shard1: 100}, snapshot_sha = "b" * 40)
os.utime(old, (1000, 1000))
os.utime(new, (2000, 2000))
main = _cached_colocated_split_main("unsloth/split-GGUF", shard1, [shard2], {})
assert main is not None
assert main.startswith(str(old))
def test_returns_none_when_shards_span_snapshots(self, hf_cache):
shard1 = "m-00001-of-00002.gguf"
shard2 = "m-00002-of-00002.gguf"
a = _build_cache(hf_cache, "unsloth/split-GGUF", {shard1: 100}, snapshot_sha = "a" * 40)
b = _build_cache(hf_cache, "unsloth/split-GGUF", {shard2: 100}, snapshot_sha = "b" * 40)
os.utime(a, (1000, 1000))
os.utime(b, (2000, 2000))
assert _cached_colocated_split_main("unsloth/split-GGUF", shard1, [shard2], {}) is None
class TestResolveRepoIdCasing:
def test_maps_to_canonical_casing(self, monkeypatch):
monkeypatch.setattr(
"utils.paths.resolve_cached_repo_id_case",
lambda repo: "unsloth/Gemma-4-GGUF" if repo.lower() == "unsloth/gemma-4-gguf" else repo,
)
# A companion download passed the resolved id reads the same cache entry
# as the main GGUF instead of missing it under the requested casing.
assert _resolve_repo_id_casing("unsloth/gemma-4-gguf") == "unsloth/Gemma-4-GGUF"
def test_passthrough_on_resolver_error(self, monkeypatch):
def boom(_repo):
raise RuntimeError("resolver unavailable")
monkeypatch.setattr("utils.paths.resolve_cached_repo_id_case", boom)
assert _resolve_repo_id_casing("unsloth/gemma-4-gguf") == "unsloth/gemma-4-gguf"
def test_companion_only_newer_snapshot_does_not_shadow_real_variants(self, hf_cache):
# A newer snapshot holds only a vision projector fetched on demand,
# while the quant files live in an older snapshot. The newer snapshot
# must not shadow the real variants; the vision flag carries over.
old = _build_cache(
hf_cache,
"unsloth/vision-GGUF",
{"vision-Q4_K_M.gguf": 100},
snapshot_sha = "a" * 40,
)
new = _build_cache(
hf_cache,
"unsloth/vision-GGUF",
{"mmproj-vision-F16.gguf": 10},
snapshot_sha = "b" * 40,
)
os.utime(old, (1000, 1000))
os.utime(new, (2000, 2000))
out = _list_gguf_variants_from_hf_cache("unsloth/vision-GGUF")
assert out is not None
variants, has_vision = out
assert [v.quant for v in variants] == ["Q4_K_M"]
assert has_vision is True
def test_companion_only_cache_returns_empty_variants_with_vision(self, hf_cache):
# Only a vision projector is cached anywhere: report the vision flag
# with an empty variant list rather than None.
_build_cache(hf_cache, "unsloth/vision-GGUF", {"mmproj-vision-F16.gguf": 10})
out = _list_gguf_variants_from_hf_cache("unsloth/vision-GGUF")
assert out is not None
variants, has_vision = out
assert variants == []
assert has_vision is True
class TestListGgufVariantsOffline:
def test_offline_env_short_circuits_api(self, hf_cache, clean_offline_env, monkeypatch):
_build_cache(hf_cache, "unsloth/a", {"a-UD-Q4_K_XL.gguf": 1})

View file

@ -3037,3 +3037,60 @@ def test_acquire_swap_gate_is_cancellation_safe():
inference_route._auto_switch_process_lock.release()
asyncio.run(asyncio.wait_for(main(), timeout = 5))
def test_no_model_loaded_detail_appends_hint_only_when_off(monkeypatch):
# The "no model loaded" errors point at the opt-in auto-switch toggle so a
# request naming a listed-but-unloaded model is self-explanatory -- but only
# when it's off. With it on the name simply didn't resolve, so no hint.
base = "No GGUF model loaded. Load a GGUF model first."
monkeypatch.setattr(settings, "get_openai_auto_switch_enabled", lambda: False)
off = inference_route._no_model_loaded_detail(base)
assert off.startswith(base)
assert "Model auto-switch" in off and "Settings > API" in off
monkeypatch.setattr(settings, "get_openai_auto_switch_enabled", lambda: True)
assert inference_route._no_model_loaded_detail(base) == base
def _run_responses_stream_no_model(monkeypatch, *, enabled, active_model_name):
# Drive _responses_stream's GGUF-not-loaded guard: llama backend unloaded,
# inference backend maybe holding a non-GGUF model. Returns the 400 detail.
from fastapi import HTTPException
from models.inference import ResponsesRequest, ChatMessage
monkeypatch.setattr(settings, "get_openai_auto_switch_enabled", lambda: enabled)
monkeypatch.setattr(
inference_route, "get_llama_cpp_backend", lambda: _FakeBackend(loaded_id = None)
)
monkeypatch.setattr(
inference_route,
"get_inference_backend",
lambda: type("_B", (), {"active_model_name": active_model_name})(),
)
payload = ResponsesRequest(model = "unsloth/Qwen3.5-4B-GGUF", stream = True)
messages = [ChatMessage(role = "user", content = "hi")]
with pytest.raises(HTTPException) as exc:
asyncio.run(inference_route._responses_stream(payload, messages, None))
assert exc.value.status_code == 400
return exc.value.detail
def test_responses_stream_hint_matches_toggle_regardless_of_active_model(monkeypatch):
# Streaming /v1/responses shares the GGUF-only 400 with the other "no model
# loaded" sites, so the auto-switch hint attaches whenever the toggle is
# off -- including while a non-GGUF model is active, since auto-switch
# evicts it to load a resolved GGUF (_maybe_auto_switch_model's resolver
# branch has no active-model guard, unlike its reload-stash branch). Only
# the toggle being on suppresses it.
hinted = _run_responses_stream_no_model(monkeypatch, enabled = False, active_model_name = None)
assert "Model auto-switch" in hinted
on = _run_responses_stream_no_model(monkeypatch, enabled = True, active_model_name = None)
assert "Model auto-switch" not in on
non_gguf_loaded = _run_responses_stream_no_model(
monkeypatch, enabled = False, active_model_name = "unsloth/Llama-3.2-1B-Instruct"
)
assert "Model auto-switch" in non_gguf_loaded

File diff suppressed because it is too large Load diff

View file

@ -515,6 +515,7 @@ class ScriptedClient:
_url,
json = None,
timeout = None,
headers = None,
):
self.posts.append(json)
return httpx.Response(200, json = self.bodies[min(len(self.posts) - 1, len(self.bodies) - 1)])

View file

@ -0,0 +1,216 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""TEMPLATE_TO_RESPONSES_MAPPER markers must match what the templates render.
The manual instruction/response markers are the fallback for
train_on_completions when auto-detection is unavailable, so a marker that
never matches the rendered chat template masks every assistant token and the
run dies on the all-labels-masked safety net. Six template families shipped
such markers:
mistral - "[INST] " / " [/INST]": the surrounding spaces fold into
the neighbouring tokens ("[INST]" is a single special
token in Mistral v0.3), so the padded strings never match.
llama - same space folding, plus llama-2 tokenizes [INST] after
<s> as bare "[" on transformers 5.x while the standalone
encoding gives "▁[", so the marker must anchor on <s>.
starling - trailing space after "GPT4 Correct Assistant:" folds
into the next content token ("▁Hello").
glm - "[gMASK]<sop>" renders once at text start, never before
later user turns; "<think>" is generation scaffolding
that non-final turns render as a lone "</think>".
qwen3-thinking - "<think>" is stripped from non-final assistant turns
(Qwen3-Thinking-2507) or never rendered (QwQ).
zephyr - role tags are plain text, and SentencePiece tokenizes
"<|assistant|>" differently at text start than after
"</s>\\n" mid-conversation; the markers need the leading
newline anchor to tokenize like a real turn boundary.
Literal assertions run everywhere; the token-level masking checks need the
representative tokenizers plus unsloth_zoo and skip when either is
unavailable (offline CI).
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
# model_mappings is dependency-free: load it directly so these tests run
# without the studio venv / package import side effects.
_MM_PATH = Path(_BACKEND_DIR) / "utils" / "datasets" / "model_mappings.py"
_mm_spec = importlib.util.spec_from_file_location("_marker_test_mm", _MM_PATH)
model_mappings = importlib.util.module_from_spec(_mm_spec)
_mm_spec.loader.exec_module(model_mappings)
T2R = model_mappings.TEMPLATE_TO_RESPONSES_MAPPER
# ── Fixed entries: markers derived from what each representative tokenizer
# actually renders (see PR for the token-level derivation). ──
EXPECTED_FIXED = {
"mistral": {"instruction": "[INST]", "response": "[/INST]"},
"llama": {"instruction": "<s>[INST]", "response": "[/INST]"},
"starling": {"instruction": "GPT4 Correct User:", "response": "GPT4 Correct Assistant:"},
"glm": {"instruction": "<|user|>", "response": "<|assistant|>"},
"qwen3-thinking": {"instruction": "<|im_start|>user\n", "response": "<|im_start|>assistant\n"},
"zephyr": {"instruction": "\n<|user|>\n", "response": "\n<|assistant|>\n"},
}
# Spot-pin some known-good entries so a refactor cannot silently change them.
EXPECTED_UNCHANGED = {
"qwen3": {"instruction": "<|im_start|>user\n", "response": "<|im_start|>assistant\n"},
"llama-3.1": {
"instruction": "<|start_header_id|>user<|end_header_id|>\n\n",
"response": "<|start_header_id|>assistant<|end_header_id|>\n\n",
},
"phi-4": {
"instruction": "<|im_start|>user<|im_sep|>",
"response": "<|im_start|>assistant<|im_sep|>",
},
"gemma-3": {"instruction": "<start_of_turn>user\n", "response": "<start_of_turn>model\n"},
"gpt-oss": {
"instruction": "<|start|>user<|message|>",
"response": "<|start|>assistant<|channel|>final<|message|>",
},
}
@pytest.mark.parametrize("template", sorted(EXPECTED_FIXED))
def test_fixed_marker_literals(template):
assert T2R[template] == EXPECTED_FIXED[template]
@pytest.mark.parametrize("template", sorted(EXPECTED_UNCHANGED))
def test_unchanged_marker_literals(template):
assert T2R[template] == EXPECTED_UNCHANGED[template]
def test_no_marker_is_empty_or_whitespace():
for template, parts in T2R.items():
assert parts["instruction"].strip(), template
assert parts["response"].strip(), template
# ── Token-level checks: markers must select exactly the assistant turns on a
# rendered two-turn fixture, and the final EOS label must never be -100. ──
REPRESENTATIVES = {
"mistral": ["unsloth/mistral-7b-instruct-v0.3"],
"llama": ["unsloth/llama-2-7b-chat"],
"starling": ["unsloth/Starling-LM-7B-beta"],
"glm": ["unsloth/GLM-4.7-Flash"],
"qwen3-thinking": ["unsloth/Qwen3-4B-Thinking-2507", "Qwen/QwQ-32B"],
"zephyr": ["unsloth/zephyr-sft"],
}
FIXTURE = [
{"role": "user", "content": "zebra alpha question one?"},
{"role": "assistant", "content": "grape reply number one."},
{"role": "user", "content": "zebra beta question two?"},
{"role": "assistant", "content": "grape reply number two."},
]
def _load_tokenizer(repo):
try:
from transformers import AutoTokenizer
except Exception as e: # pragma: no cover
pytest.skip(f"transformers unavailable: {e}")
try:
return AutoTokenizer.from_pretrained(repo)
except OSError as e:
pytest.skip(f"tokenizer {repo} unavailable (offline?): {e}")
except Exception:
# Tokenizer class newer than this transformers (e.g. GLM-4.7's
# TokenizersBackend): build directly from tokenizer.json.
try:
import json as _json
from huggingface_hub import hf_hub_download
from transformers import PreTrainedTokenizerFast
with open(hf_hub_download(repo, "tokenizer_config.json"), encoding = "utf-8") as f:
cfg = _json.load(f)
tok_file = hf_hub_download(repo, "tokenizer.json")
def _tokval(v):
return v["content"] if isinstance(v, dict) else v
return PreTrainedTokenizerFast(
tokenizer_file = tok_file,
chat_template = cfg.get("chat_template"),
**{
k: _tokval(cfg[k])
for k in ("bos_token", "eos_token", "pad_token", "unk_token")
if cfg.get(k) is not None
},
)
except Exception as e:
pytest.skip(f"tokenizer {repo} unavailable (offline?): {e}")
def _train_on_responses_only():
try:
from unsloth_zoo.dataset_utils import train_on_responses_only
except Exception as e:
pytest.skip(f"unsloth_zoo unavailable: {e}")
return train_on_responses_only
@pytest.mark.parametrize(
"template,repo",
[(t, r) for t, repos in sorted(REPRESENTATIVES.items()) for r in repos],
)
def test_fixed_markers_token_level(template, repo):
tor = _train_on_responses_only()
tok = _load_tokenizer(repo)
parts = T2R[template]
msgs = [{"role": "system", "content": "You are a terse assistant."}] + FIXTURE
try:
ids = tok.apply_chat_template(msgs, tokenize = True, add_generation_prompt = False)
if hasattr(ids, "keys"):
ids = ids["input_ids"] # transformers 5.x returns a BatchEncoding
except Exception:
ids = tok.apply_chat_template(FIXTURE, tokenize = True, add_generation_prompt = False)
if hasattr(ids, "keys"):
ids = ids["input_ids"]
fn = tor(
None,
instruction_part = parts["instruction"],
response_part = parts["response"],
tokenizer = tok,
return_function = True,
)
labels = fn({"input_ids": [list(ids)]})["labels"][0]
n = len(ids)
trained = tok.decode([ids[i] for i in range(n) if labels[i] != -100])
masked = tok.decode([ids[i] for i in range(n) if labels[i] == -100])
# User and system content fully masked
assert "question one" not in trained and "question one" in masked
assert "question two" not in trained and "question two" in masked
assert "terse assistant" not in trained
# EVERY assistant turn trained, not just the last
assert "reply number one" in trained
assert "reply number two" in trained
# The final EOS (last non-whitespace token) must never be -100, or the
# fine-tuned model never learns to stop generating.
i = n - 1
while i > 0 and tok.decode([ids[i]]).strip() == "":
i -= 1
assert labels[i] != -100, f"final token {tok.convert_ids_to_tokens(int(ids[i]))!r} is masked"
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-v"]))

View file

@ -0,0 +1,89 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""
Unit tests for detect_think_prefill.
Reasoning templates (Qwen3.6-style) end the generation prompt with an open
``<think>\\n`` so the model starts reasoning immediately. skip_prompt
streaming drops that opening tag, so the safetensors/MLX paths must re-emit
it for the frontend's <think> parser to render a thinking block.
"""
import os
import sys
_backend = os.path.join(os.path.dirname(__file__), "..")
sys.path.insert(0, _backend)
from core.inference.chat_template_helpers import detect_think_prefill
QWEN_PROMPT = "<|im_start|>user\nHi!<|im_end|>\n<|im_start|>assistant\n"
def test_open_think_prefill_reemitted():
"""Qwen3.6-style enable_thinking=True prompt tail: <think>\\n."""
assert detect_think_prefill(QWEN_PROMPT + "<think>\n") == "<think>\n"
def test_bare_open_think_prefill_reemitted():
"""Prefill without trailing newline still detected."""
assert detect_think_prefill(QWEN_PROMPT + "<think>") == "<think>"
def test_closed_think_prefill_not_reemitted():
"""enable_thinking=False prefills a closed, empty think block."""
assert detect_think_prefill(QWEN_PROMPT + "<think>\n\n</think>\n\n") == ""
def test_prompt_without_think_untouched():
"""Non-reasoning templates produce no prefix."""
assert detect_think_prefill(QWEN_PROMPT) == ""
def test_historical_think_blocks_ignored():
"""A closed think block in a prior assistant turn (preserve_thinking)
must not trigger re-emission when the generation tail is plain."""
prompt = (
"<|im_start|>user\nHi!<|im_end|>\n"
"<|im_start|>assistant\n<think>\nprior reasoning\n</think>\n\nHello!<|im_end|>\n"
"<|im_start|>user\nAgain?<|im_end|>\n<|im_start|>assistant\n"
)
assert detect_think_prefill(prompt) == ""
def test_historical_blocks_plus_open_prefill():
"""Prior closed blocks plus a fresh open prefill: only the tail matters."""
prompt = (
"<|im_start|>assistant\n<think>\nprior\n</think>\n\nHello!<|im_end|>\n"
"<|im_start|>assistant\n<think>\n"
)
assert detect_think_prefill(prompt) == "<think>\n"
def test_content_after_open_tag_not_reemitted():
"""If non-whitespace follows the tag it is not a plain prefill."""
assert detect_think_prefill(QWEN_PROMPT + "<think>\npartial reasoning") == ""
def test_empty_and_none_prompts():
assert detect_think_prefill("") == ""
assert detect_think_prefill(None) == ""
def test_guard_suppresses_when_close_tag_is_special():
"""If </think> is a special token, skip_special_tokens strips the model's
close tag, so re-emitting the open would leave an unclosed block. Guard off."""
specials = ["<|im_end|>", "<think>", "</think>"]
assert detect_think_prefill(QWEN_PROMPT + "<think>\n", specials) == ""
def test_guard_emits_when_think_not_special():
specials = ["<|im_end|>", "<|endoftext|>"]
assert detect_think_prefill(QWEN_PROMPT + "<think>\n", specials) == "<think>\n"
def test_guard_default_and_empty_keep_emitting():
assert detect_think_prefill(QWEN_PROMPT + "<think>\n", None) == "<think>\n"
assert detect_think_prefill(QWEN_PROMPT + "<think>\n", []) == "<think>\n"

View file

@ -32,16 +32,23 @@ def _load_module(monkeypatch):
@pytest.mark.parametrize(
"torch_version, expected",
[
# torch 2.10 (the reported bug: cu130 resolves 2.10.0) -> 0.16.0,
# independent of the local +cuXXX/+rocm/+cpu suffix or patch level.
("2.10.0+cu130", "torchao==0.16.0"),
# torch 2.10 on CUDA <= 12 -> 0.16.0 (its cpp is built for torch 2.10.0 and
# loads against the CUDA-12 PyPI wheel). Independent of patch level.
("2.10.0+cu128", "torchao==0.16.0"),
("2.10.0+cu126", "torchao==0.16.0"),
("2.10.0+rocm6.4", "torchao==0.16.0"),
("2.10.0+cpu", "torchao==0.16.0"),
("2.10.1", "torchao==0.16.0"),
("2.10.0", "torchao==0.16.0"),
# Pre-release / dev / rc builds: the minor is cleaned of non-digits.
# torch 2.10 on CUDA >= 13 (Blackwell / cu130): 0.16.0's CUDA-12 cpp can't
# load against a CUDA-13 torch (libcudart.so.12 error), so use 0.17.0.
("2.10.0+cu130", "torchao==0.17.0"),
("2.10.0+cu140", "torchao==0.17.0"),
# Pre-release / dev / rc builds: the minor is cleaned of non-digits; the
# CUDA tag still decides 0.16.0 vs 0.17.0.
("2.10.0rc1", "torchao==0.16.0"),
("2.10.0.dev20250804+cu130", "torchao==0.16.0"),
("2.10.0.dev20250804+cu130", "torchao==0.17.0"),
("2.10.0.dev20250804+cu128", "torchao==0.16.0"),
("2.10rc1", "torchao==0.16.0"),
# torch 2.11 (reachable via ROCm rocm7.2) and forward -> 0.17.0.
("2.11.0+cu130", "torchao==0.17.0"),

View file

@ -6,9 +6,15 @@ empty-chat-template crash) before train(). The real methods are bound onto a lig
fake self so the production logic runs against controlled batches."""
import importlib
import json
import os
import queue
import subprocess
import sys
import threading
import types
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
@ -184,5 +190,231 @@ class TestChatTemplateRendersEmpty(unittest.TestCase):
self.assertFalse(s._chat_template_renders_empty())
def _clear_trainer_module(package: str):
sys.modules.pop(f"{package}.trainer", None)
pkg = sys.modules.get(package)
if pkg is not None and hasattr(pkg, "trainer"):
delattr(pkg, "trainer")
def _set_training_platform(monkeypatch, package: str, backend: str):
training_mod = importlib.import_module(f"{package}.training")
from utils.hardware import hardware as hw
monkeypatch.setattr(hw, "DEVICE", None)
monkeypatch.setattr(
training_mod.platform,
"system",
lambda: "Darwin" if backend == "mlx" else "Linux",
)
monkeypatch.setattr(
training_mod.platform,
"machine",
lambda: "arm64" if backend == "mlx" else "x86_64",
)
def _load_trainer_module(
monkeypatch,
backend: str,
package: str = "core.training",
):
_set_training_platform(monkeypatch, package, backend)
_clear_trainer_module(package)
if package in sys.modules:
importlib.reload(sys.modules[package])
trainer_mod = importlib.import_module(f"{package}.trainer")
training_mod = importlib.import_module(f"{package}.training")
monkeypatch.setattr(
training_mod._MLXTrainerAdapter,
"_activate_transformers_for_model",
lambda self, model_name, hf_token: None,
)
return trainer_mod
class _ExitedProc:
def join(self, timeout = None):
return None
def is_alive(self):
return False
class _TerminableProc:
def __init__(self):
self.terminated = False
self._done = threading.Event()
def join(self, timeout = None):
self._done.wait(timeout = timeout or 5)
def is_alive(self):
return not self.terminated
def terminate(self):
self.terminated = True
self._done.set()
def test_unsloth_trainer_dispatches_for_mlx_and_torch(monkeypatch):
trainer_mod = _load_trainer_module(monkeypatch, "mlx")
mlx_trainer = trainer_mod.UnslothTrainer()
assert type(mlx_trainer).__module__ == "core.training.training"
assert mlx_trainer.get_training_progress().status_message == "Ready to train"
trainer_mod = _load_trainer_module(monkeypatch, "torch")
assert trainer_mod.UnslothTrainer().__class__ is trainer_mod.UnslothTrainer
def test_cli_mlx_trainer_activates_before_importing_trainer():
repo_root = Path(__file__).resolve().parents[3]
script = """
import json
import sys
import unsloth_cli.commands.train as train_cmd
from studio.backend.core.training import training as training_mod
from utils.hardware import hardware as hw
training_mod.platform.system = lambda: "Darwin"
training_mod.platform.machine = lambda: "arm64"
hw.DEVICE = None
events = []
def fake_activate(model_name, hf_token):
events.append({
"model_name": model_name,
"trainer_loaded": "studio.backend.core.training.trainer" in sys.modules,
})
train_cmd._activate_mlx_transformers = fake_activate
trainer = train_cmd._create_cli_trainer("mlx-community/Qwen3-0.6B-4bit", None)
print(json.dumps({
"trainer_module": type(trainer).__module__,
"events": events,
}))
"""
env = os.environ.copy()
env["PYTHONPATH"] = os.pathsep.join(
[str(repo_root), str(repo_root / "studio" / "backend"), env.get("PYTHONPATH", "")]
)
result = subprocess.run(
[sys.executable, "-c", script],
cwd = repo_root,
env = env,
text = True,
stdout = subprocess.PIPE,
stderr = subprocess.PIPE,
check = True,
)
payload = json.loads(result.stdout)
assert payload["trainer_module"] == "studio.backend.core.training.training"
assert payload["events"] == [
{"model_name": "mlx-community/Qwen3-0.6B-4bit", "trainer_loaded": False}
]
def test_mlx_adapter_builds_config_and_reports_completion(tmp_path, monkeypatch):
trainer_mod = _load_trainer_module(monkeypatch, "mlx")
captured = {}
def fake_run_worker(config, event_queue, stop_queue):
captured["config"] = config
event_queue.put({"type": "progress", "step": 1, "total_steps": 1, "loss": 0.25})
event_queue.put(
{"type": "complete", "status_message": "done", "output_dir": config["output_dir"]}
)
trainer = trainer_mod.UnslothTrainer()
monkeypatch.setattr(trainer, "_run_mlx_worker", fake_run_worker)
assert trainer.load_model("mlx-community/Qwen3-0.6B-4bit", max_seq_length = 1024)
assert trainer.prepare_model_for_training(use_lora = False)
dataset, eval_dataset = trainer.load_and_format_dataset("org/dataset")
output_dir = tmp_path / "mlx-out"
assert trainer.start_training(
dataset = dataset,
eval_dataset = eval_dataset,
output_dir = output_dir,
project_name = "Sales Assistant",
max_steps = 1,
learning_rate = 3e-4,
)
trainer.training_thread.join(timeout = 5)
progress = trainer.get_training_progress()
config = captured["config"]
assert progress.is_completed
assert progress.output_dir == str(output_dir.resolve())
progress.status_message = "mutated"
assert trainer.get_training_progress().status_message == "done"
assert config["model_name"] == "mlx-community/Qwen3-0.6B-4bit"
assert config["project_name"] == "Sales Assistant"
assert config["hf_dataset"] == "org/dataset"
assert config["training_type"] == "Full Finetuning"
assert config["load_in_4bit"] is False
assert config["max_seq_length"] == 1024
assert config["learning_rate"] == 3e-4
assert config["output_dir"] == str(output_dir.resolve())
assert config["allow_external_output_dir"] is True
def test_mlx_worker_helpers_cover_cli_paths(tmp_path, monkeypatch):
_load_trainer_module(monkeypatch, "mlx")
from core.training.worker import (
_resolve_mlx_local_dataset_files,
_resolve_mlx_output_dir,
)
dataset = tmp_path / "train.jsonl"
dataset.write_text('{"text":"hello"}\n', encoding = "utf-8")
monkeypatch.chdir(tmp_path)
assert _resolve_mlx_local_dataset_files(["train.jsonl"]) == [str(dataset)]
assert _resolve_mlx_output_dir(
{"output_dir": "cli-out", "allow_external_output_dir": True},
"mlx-community/Qwen3-0.6B-4bit",
) == str((tmp_path / "cli-out").resolve())
def test_run_mlx_training_process_applies_side_effects_before_hardware_detection(monkeypatch):
_load_trainer_module(monkeypatch, "mlx")
from core.training import worker
from utils.hardware import hardware as hw
order = []
def fake_activate(model_name, hf_token):
order.append(("activate", model_name, hf_token))
def fake_detect_hardware():
order.append("detect")
hw.DEVICE = hw.DeviceType.CPU
return hw.DEVICE
monkeypatch.delenv("HF_HUB_DISABLE_XET", raising = False)
monkeypatch.delenv("HF_HUB_ENABLE_HF_TRANSFER", raising = False)
monkeypatch.setattr(worker, "_activate_transformers_version_or_warn", fake_activate)
monkeypatch.setattr(hw, "detect_hardware", fake_detect_hardware)
event_queue = queue.Queue()
worker.run_mlx_training_process(
event_queue = event_queue,
stop_queue = queue.Queue(),
config = {"model_name": "mlx-community/Gemma-4-12B", "disable_xet": True},
)
event = event_queue.get_nowait()
assert order == [("activate", "mlx-community/Gemma-4-12B", None), "detect"]
assert os.environ["HF_HUB_DISABLE_XET"] == "1"
assert os.environ["HF_HUB_ENABLE_HF_TRANSFER"] == "0"
assert "MLX training requires Apple Silicon" in event["error"]
if __name__ == "__main__":
unittest.main()

View file

@ -59,7 +59,6 @@ def test_runtime_flash_attn_prefers_prebuilt_wheel(monkeypatch):
statuses: list[str] = []
monkeypatch.delenv(worker._FLASH_ATTN_SKIP_ENV, raising = False)
monkeypatch.setattr(worker, "has_blackwell_gpu", lambda: False)
monkeypatch.setattr(builtins, "__import__", _missing_flash_attn_import())
monkeypatch.setattr(
worker,
@ -88,7 +87,6 @@ def test_runtime_flash_attn_falls_back_to_pypi(monkeypatch):
statuses: list[str] = []
monkeypatch.delenv(worker._FLASH_ATTN_SKIP_ENV, raising = False)
monkeypatch.setattr(worker, "has_blackwell_gpu", lambda: False)
monkeypatch.setattr(builtins, "__import__", _missing_flash_attn_import())
monkeypatch.setattr(
worker,
@ -141,27 +139,6 @@ def test_runtime_flash_attn_skip_env_avoids_all_install_work(monkeypatch):
worker._sp.run.assert_not_called()
def test_runtime_flash_attn_skips_on_blackwell(monkeypatch):
statuses: list[str] = []
install_mock = mock.Mock()
monkeypatch.delenv(worker._FLASH_ATTN_SKIP_ENV, raising = False)
monkeypatch.setattr(worker, "_should_try_runtime_flash_attn_install", lambda max_seq: True)
monkeypatch.setattr(worker, "has_blackwell_gpu", lambda: True)
monkeypatch.setattr(worker, "_install_package_wheel_first", install_mock)
monkeypatch.setattr(
worker,
"_send_status",
lambda queue, message: statuses.append(message),
)
worker._ensure_flash_attn_for_long_context(event_queue = [], max_seq_length = 65536)
install_mock.assert_not_called()
assert len(statuses) == 1
assert "Blackwell" in statuses[0]
def test_causal_conv1d_fast_path_preserves_wheel_first_install_args(monkeypatch):
install_mock = mock.Mock(return_value = True)
monkeypatch.setattr(worker, "_install_package_wheel_first", install_mock)

View file

@ -0,0 +1,81 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Invariant: the training worker must not import ``transformers`` before it activates the
transformers sidecar.
``core/training/worker.py:run_training_process`` runs a preflight (Xet decision, logging, hardware
detection) and only THEN calls ``_activate_transformers_version`` -> ``activate_transformers_for_subprocess``,
which prepends the correct ``.venv_t5_*`` (5.x) sidecar to ``sys.path``. Because activation only edits
``sys.path``, it is a no-op for any module already cached in ``sys.modules``. So if the preflight imports
``transformers`` (directly or transitively via ``unsloth_zoo``), the default 4.57.x gets pinned before
the sidecar is on the path -- and 5.x models (Qwen3.5, GLM-4.7, gemma-4) then fail to load their
tokenizer/config ("Tokenizer class TokenizersBackend does not exist").
This regression shipped once when ``utils/hf_xet_fallback.py`` eagerly imported ``unsloth_zoo`` (which
imports ``transformers``) at module load; the worker imports that shim during preflight to decide the
Xet env flip (see issue #6951). This test locks the invariant in a fresh interpreter. It is CPU-only,
needs no network/GPU/weights/sidecars, so it runs in the standard ``studio-backend-ci`` matrix.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
_BACKEND_DIR = Path(__file__).resolve().parent.parent # studio/backend
# Mirrors run_training_process's imports that run BEFORE _activate_transformers_version (worker.py);
# keep in sync. torch-dependent imports are optional (a no-torch CI shard skips them) but must still
# not drag in transformers.
_PREFLIGHT_SNIPPET = r"""
import sys
# worker.py: from utils.hf_xet_fallback import child_should_disable_xet (+ call it)
from utils.hf_xet_fallback import child_should_disable_xet
child_should_disable_xet({})
# worker.py: from loggers.config import LogConfig
from loggers.config import LogConfig # noqa: F401
# worker.py: from utils.hardware import hardware (imports torch, not transformers)
try:
from utils.hardware import hardware as _hw # noqa: F401
except Exception:
pass # torch may be absent in a no-torch shard; the invariant below still applies
# worker.py: from .training import is_apple_silicon_training_platform, should_use_mlx_training_backend
# (the MLX-dispatch preflight; must also stay clear of transformers). Guarded because it may pull
# unsloth/trl, absent in a minimal shard -- but a partial import that leaked transformers would still
# be caught by the assertion below.
try:
from core.training.training import ( # noqa: F401
is_apple_silicon_training_platform as _is_apple,
should_use_mlx_training_backend as _use_mlx,
)
except Exception:
pass
leaked_tf = sorted(m for m in sys.modules if m == "transformers" or m.startswith("transformers."))
leaked_zoo = sorted(m for m in sys.modules if m == "unsloth_zoo" or m.startswith("unsloth_zoo."))
assert not leaked_tf, f"transformers imported during worker preflight (before sidecar activation): {leaked_tf}"
assert not leaked_zoo, f"unsloth_zoo imported during worker preflight (before sidecar activation): {leaked_zoo}"
print("PREFLIGHT_CLEAN")
"""
def test_worker_preflight_does_not_import_transformers():
"""A fresh interpreter running the worker's pre-activation imports must leave ``transformers``
(and ``unsloth_zoo``) unimported, so the 5.x sidecar prepend is not defeated by a stale module."""
result = subprocess.run(
[sys.executable, "-c", _PREFLIGHT_SNIPPET],
cwd = str(_BACKEND_DIR),
capture_output = True,
text = True,
)
assert result.returncode == 0, (
"Worker preflight imported transformers before sidecar activation.\n"
f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
)
assert "PREFLIGHT_CLEAN" in result.stdout, result.stdout

View file

@ -0,0 +1,155 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Invariant: after the training worker runs its preflight and then activates the transformers
sidecar, the in-process ``transformers`` must be the sidecar version the model requires -- not the
default 4.57.x that the base environment ships.
The CPU-only "does it choose the correct transformers version" guard, stronger than the pure
import-order check in ``test_training_worker_import_discipline.py``: it runs the REAL tier detection
(``get_transformers_tier``) and REAL activation (``activate_transformers_for_subprocess``) for a
transformers-5.x model (Qwen3.5, tier 530) and asserts the version actually switched. It catches the
whole failure family at once:
* a stale pre-activation ``transformers`` import (the #6951 / ``TokenizersBackend`` regression: an
already-cached 4.57.x defeats the sidecar's ``sys.path`` prepend),
* a wrong tier selected for a 5.x model, and
* activation not actually swapping the resident module.
Why the CUDA spoof matters (verified): ``unsloth_zoo``'s eager ``import transformers`` only happens on
its full, GPU-present init path. On a GPU-less runner it silently degrades and never preloads
transformers -- which would MASK the stale-import bug (the check would falsely pass). Spoofing
``torch.cuda`` so ``unsloth_zoo`` believes a GPU is present forces the real init path, exposing the
regression on CPU CI. The spoof mirrors ``tests/_zoo_aggressive_cuda_spoof.py`` but is inlined so the
test is self-contained in the ``studio-backend-ci`` matrix (whose conftest does not apply the shared
spoof). No GPU/network/weights/real sidecar needed: a one-line stub sidecar stands in for the 5.x venv,
so we only assert activation lands on it.
Proven: passes on the fixed tree (active == 5.3.0) and fails on the buggy tree (active == 4.57.x) on
a simulated GPU-less runner.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
_BACKEND_DIR = Path(__file__).resolve().parent.parent # studio/backend
# Canonical CUDA spoof at the repo root (studio/backend -> studio -> repo root). Loaded by the
# subprocess when present (matches the consolidated CI); absent in a standalone studio checkout, where
# the subprocess falls back to a minimal inline spoof.
_SPOOF_PATH = _BACKEND_DIR.parent.parent / "tests" / "_zoo_aggressive_cuda_spoof.py"
# Runs in a fresh interpreter with cwd == studio/backend so ``utils.*`` resolves like the worker.
# STUB_HOME (a pytest tmp dir) holds a throwaway ``.venv_t5_530`` sidecar exporting transformers 5.3.0.
_SNIPPET = r"""
import os, sys
sys.path.insert(0, os.getcwd())
# CUDA spoof so unsloth_zoo takes its full, transformers-importing init path on a GPU-less runner.
# Without it unsloth_zoo degrades and never preloads transformers, which would MASK the stale-import
# regression under test (verified). Prefer the repo's canonical spoof (single source of truth, and the
# one the consolidated CI already relies on); fall back to a minimal inline spoof so this also works in
# a standalone studio checkout. If torch is absent the fixed tree still passes below; the bug just
# would not be exposable in that shard.
try:
import torch # noqa: F401
_sp = os.environ.get("SPOOF_PATH")
if _sp and os.path.exists(_sp):
import importlib.util
_spec = importlib.util.spec_from_file_location("_zoo_aggressive_cuda_spoof", _sp)
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
_mod.apply()
else:
torch.cuda.is_available = lambda: True
torch.cuda.device_count = lambda: 1
torch.cuda.current_device = lambda: 0
torch.cuda.get_device_capability = lambda *a, **k: (8, 0)
torch.cuda.get_device_name = lambda *a, **k: "NVIDIA A100-SPOOFED"
torch.cuda.is_bf16_supported = lambda *a, **k: True
class _Props:
name = "NVIDIA A100-SPOOFED"
major = 8
minor = 0
total_memory = 80 * 1024**3
multi_processor_count = 108
torch.cuda.get_device_properties = lambda *a, **k: _Props()
torch.cuda.mem_get_info = lambda *a, **k: (0, 80 * 1024**3)
except Exception:
pass
os.environ["UNSLOTH_IS_PRESENT"] = "1"
# Stub 5.x sidecar: activation only edits sys.path, so a package that merely exports __version__ is
# enough to prove the resident transformers switched to it.
home = os.environ["STUB_HOME"]
pkg = os.path.join(home, ".venv_t5_530", "transformers")
os.makedirs(pkg, exist_ok = True)
with open(os.path.join(pkg, "__init__.py"), "w") as f:
f.write('__version__ = "5.3.0"\n')
os.environ["UNSLOTH_STUDIO_HOME"] = home
# Faithful worker preflight (worker.py: from utils.hf_xet_fallback import child_should_disable_xet).
# This is the exact stale-import trigger: on the buggy tree it pulls unsloth_zoo -> transformers 4.57.x
# into sys.modules BEFORE activation.
from utils.hf_xet_fallback import child_should_disable_xet
child_should_disable_xet({})
_tf = sys.modules.get("transformers")
preload = _tf.__version__ if _tf is not None else None
# Real tier detection + real activation, with the 530 sidecar pointed at the stub above.
import utils.transformers_version as tv
tv._VENV_T5_530_DIR = os.path.join(home, ".venv_t5_530")
tv._ensure_venv_t5_530_exists = lambda: True
tier = tv.get_transformers_tier("Qwen/Qwen3.5-9B", None)
tv.activate_transformers_for_subprocess("Qwen/Qwen3.5-9B", None)
import transformers
print(f"RESULT tier={tier} preload={preload} active={transformers.__version__}")
"""
def _parse(stdout: str) -> dict[str, str]:
for line in stdout.splitlines():
if line.startswith("RESULT "):
return dict(kv.split("=", 1) for kv in line.split()[1:])
return {}
def test_worker_activates_correct_transformers_version(tmp_path):
"""The worker's real preflight + activation for a transformers-5.x model (Qwen3.5, tier 530) must
leave the in-process ``transformers`` on the 5.x sidecar. A stale pre-activation import leaves the
default 4.57.x pinned and fails this assertion -- exactly the #6951 ``TokenizersBackend`` regression."""
result = subprocess.run(
[sys.executable, "-c", _SNIPPET],
cwd = str(_BACKEND_DIR),
env = {
**__import__("os").environ,
"STUB_HOME": str(tmp_path),
**({"SPOOF_PATH": str(_SPOOF_PATH)} if _SPOOF_PATH.exists() else {}),
},
capture_output = True,
text = True,
)
assert result.returncode == 0, (
"Worker preflight + activation harness crashed.\n"
f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
)
parsed = _parse(result.stdout)
assert parsed, f"No RESULT line.\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
# Correct tier chosen for a transformers-5.x model (pure, deterministic; no network/GPU).
assert parsed["tier"] == "530", (
f"Wrong transformers tier for Qwen3.5 (expected 530, got {parsed['tier']}). "
"Tier detection regressed."
)
# Activation must actually swap the resident transformers to the sidecar version. If a preflight
# import cached 4.57.x first, the sidecar prepend is a no-op and this stays 4.57.x -- the bug.
assert parsed["active"] == "5.3.0", (
"Sidecar activation did NOT switch the in-process transformers to the model's 5.x version "
f"(active={parsed['active']}, preloaded-before-activation={parsed['preload']}). A pre-activation "
"transformers import (directly or via unsloth_zoo) defeated the sidecar; 5.x models (Qwen3.5, "
"GLM-4.7, gemma-4) then fail with 'Tokenizer class TokenizersBackend does not exist'. See #6951."
)

View file

@ -0,0 +1,39 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Detect which `unsloth start <agent>` coding-agent CLIs are on PATH.
The web UI only ever shows the user the "claude" flavor of the `unsloth start`
command (see agent-command.ts), leaving anyone using Codex, OpenCode, and the
other supported agents to manually edit the copied command. This module gives
the frontend a way to ask which of those CLIs are actually installed so it can
default to one the user can run immediately.
"""
import shutil
# Keep in sync with the `unsloth start <agent>` subcommands defined in
# unsloth_cli/commands/start.py. Each entry is the exact executable name that
# subcommand launches, so a hit here means `unsloth start <agent>` can find the
# binary on PATH without the user installing anything first.
CODING_AGENTS: tuple[str, ...] = ("claude", "codex", "openclaw", "opencode", "hermes", "pi")
def _is_on_path(agent: str) -> bool:
# shutil.which is documented to return None on a miss, but PATH lookups can
# still raise (e.g. a permission error while probing a directory entry);
# this is an advisory check, so a lookup failure should read as "not
# installed" instead of breaking the settings endpoint.
try:
return shutil.which(agent) is not None
except OSError:
return False
def detect_installed_coding_agents() -> list[str]:
"""Return the subset of CODING_AGENTS whose CLI binary is on PATH.
Order follows CODING_AGENTS, not discovery order, so callers can treat the
first entry as the preferred default among the installed agents.
"""
return [agent for agent in CODING_AGENTS if _is_on_path(agent)]

View file

@ -0,0 +1,144 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Completion-only masking policy shared by the CUDA and MLX training paths.
Decides how train_on_responses_only is applied for a model: chat template
auto-detection first, manual TEMPLATE_TO_RESPONSES_MAPPER markers as the
fallback. gpt-oss included: its quantized checkpoints ship a different
chat template, so only detection from the actual template is reliable.
"""
from .model_mappings import (
MODEL_TO_TEMPLATE_MAPPER,
TEMPLATE_TO_RESPONSES_MAPPER,
is_gpt_oss_model_name,
)
def lookup_manual_markers(model_name):
"""Return (template_name, instruction_part, response_part) from the
manual template table, with None parts when the model or template is
not mapped."""
template = MODEL_TO_TEMPLATE_MAPPER.get((model_name or "").lower())
markers = TEMPLATE_TO_RESPONSES_MAPPER.get(template) if template else None
if markers:
return template, markers["instruction"], markers["response"]
return template, None, None
def apply_completion_masking(
trainer,
model_name,
train_fn,
num_proc = None,
notify = None,
detect_fn = None,
):
"""Apply completion-only masking with auto-detection first and the manual
template table as fallback.
Args:
trainer: The platform trainer (SFTTrainer or MLXTrainer).
model_name: Model repo id used for table lookup and the gpt-oss
renamed-checkpoint fallback.
train_fn: The platform train_on_responses_only callable.
num_proc: Forwarded to train_fn when not None (CUDA path only).
notify: Optional callback notify(level, message) with level "info" or
"warning" for user-visible progress and warnings.
detect_fn: Marker detector (tokenizer/processor) -> (instruction_part,
response_part). Defaults to unsloth_zoo's get_chat_template_parts,
which raises loudly when the template cannot be parsed. Test seam.
Returns:
(trainer, applied): the possibly wrapped trainer and whether masking
was applied. When applied is False the trainer is unchanged and
training runs on full sequences.
Only marker DETECTION failures trigger the table fallback. Exceptions
raised while applying the masking (dataset map, tokenization) propagate
to the caller in both the auto and manual paths, so a real failure stops
the run instead of silently changing the training objective.
"""
if notify is None:
notify = lambda level, message: None
kwargs = {}
if num_proc is not None:
kwargs["num_proc"] = num_proc
template, instruction_part, response_part = lookup_manual_markers(model_name)
# gpt-oss goes auto-first: quantized/BF16 checkpoints ship a channel-less
# template, so the manual markers match nothing (zero tokens trained). Auto
# derives markers from whichever template ships, and per the harmony format
# only the final terminator carries stop supervision. Renamed checkpoints
# miss the exact-name table, so give the fallback the gpt-oss markers.
if is_gpt_oss_model_name(model_name) and not (instruction_part and response_part):
markers = TEMPLATE_TO_RESPONSES_MAPPER.get("gpt-oss")
if markers:
template = "gpt-oss"
instruction_part = markers["instruction"]
response_part = markers["response"]
processor = getattr(trainer, "processing_class", None) or getattr(trainer, "tokenizer", None)
# mlx-lm TokenizerWrapper hides underscore attrs, so preset _unsloth_*
# markers are invisible through it. Unwrap to the real tokenizer (as
# zoo's MLX resolver does) before the preset check and detection.
if type(processor).__name__ == "TokenizerWrapper":
wrapped = getattr(processor, "_tokenizer", None)
if wrapped is not None:
processor = wrapped
inner = getattr(processor, "tokenizer", processor)
if hasattr(inner, "_unsloth_input_part") and hasattr(inner, "_unsloth_output_part"):
# Markers preset on the tokenizer; zoo reuses them on a bare call.
trainer = train_fn(trainer, **kwargs)
notify(
"info",
"Train on responses only configured via tokenizer preset markers",
)
return trainer, True
auto_instruction = auto_response = None
try:
if detect_fn is None:
# Torch-backed import is fine: the MLX train_fn itself requires
# unsloth_zoo.dataset_utils, so a torch-free host cannot mask either way.
from unsloth_zoo.dataset_utils import get_chat_template_parts as detect_fn
auto_instruction, auto_response = detect_fn(processor)
except Exception as e:
notify(
"warning",
f"Auto-detection of instruction/response markers failed ({e}); "
f"falling back to the template table",
)
if auto_instruction and auto_response:
trainer = train_fn(
trainer,
instruction_part = auto_instruction,
response_part = auto_response,
**kwargs,
)
notify(
"info",
"Train on responses only configured via chat template auto-detection",
)
return trainer, True
if instruction_part and response_part:
trainer = train_fn(
trainer,
instruction_part = instruction_part,
response_part = response_part,
**kwargs,
)
notify(
"info",
f"Train on responses only configured with template table markers ({template})",
)
return trainer, True
notify(
"warning",
f"'Train on completions' could not be applied for {model_name}: no "
f"auto-detected or mapped instruction/response markers. Training "
f"will run on full sequences (prompts included).",
)
return trainer, False

View file

@ -485,9 +485,11 @@ TEMPLATE_TO_RESPONSES_MAPPER = {
"instruction": "<|im_start|>user\n",
"response": "<|im_start|>assistant\n",
},
# No "<think>" suffix: Qwen3-Thinking-2507 strips it from non-final turns
# and QwQ renders none, so a marker holding it masks those responses.
"qwen3-thinking": {
"instruction": "<|im_start|>user\n",
"response": "<|im_start|>assistant\n<think>",
"response": "<|im_start|>assistant\n",
},
"qwen3": {
"instruction": "<|im_start|>user\n",
@ -525,29 +527,39 @@ TEMPLATE_TO_RESPONSES_MAPPER = {
"instruction": "<|im_start|>user<|im_sep|>",
"response": "<|im_start|>assistant<|im_sep|>",
},
# No surrounding spaces: in Mistral v0.3 they fold into neighbouring text
# tokens ("[INST]"/"[/INST]" are single special tokens), so padded strings
# never match and everything masks. Same for Llama-2's SentencePiece.
"mistral": {
"instruction": "[INST] ",
"response": " [/INST]",
"instruction": "[INST]",
"response": "[/INST]",
},
"llama": {
"instruction": "[INST] ",
"response": " [/INST]",
# <s>-anchored: llama-2 tokenizes [INST] after <s> as bare "[" on
# transformers 5.x (standalone gives space-prefixed "▁["), so an
# unanchored marker misses every turn boundary there.
"instruction": "<s>[INST]",
"response": "[/INST]",
},
"chatml": {
"instruction": "<|im_start|>user\n",
"response": "<|im_start|>assistant\n",
},
# Leading newline required: Zephyr's role tags are plain text, and
# SentencePiece tokenizes "<|assistant|>" differently at text start than
# after "</s>\n". Without the "\n" anchor the markers never match real
# turns, so every assistant token masks.
"zephyr": {
"instruction": "<|user|>\n",
"response": "<|assistant|>\n",
"instruction": "\n<|user|>\n",
"response": "\n<|assistant|>\n",
},
"unsloth": {
"instruction": ">>> User: ",
"response": ">>> Assistant: ",
"instruction": ">>> User:",
"response": ">>> Assistant:",
},
"vicuna": {
"instruction": "USER: ",
"response": "ASSISTANT: ",
"instruction": "USER:",
"response": "ASSISTANT:",
},
"alpaca": {
"instruction": "### Instruction:\n",
@ -573,16 +585,21 @@ TEMPLATE_TO_RESPONSES_MAPPER = {
"instruction": "<|im_start|>user\n",
"response": "<|im_start|>assistant\n",
},
# No trailing space: SentencePiece folds it into the next content token
# ("▁Hello"), so the padded marker never matches and masks everything.
"starling": {
"instruction": "GPT4 Correct User: ",
"response": "GPT4 Correct Assistant: ",
"instruction": "GPT4 Correct User:",
"response": "GPT4 Correct Assistant:",
},
"yi-chat": {
"instruction": "<|im_start|>user\n",
"response": "<|im_start|>assistant\n",
},
# "[gMASK]<sop>" appears once at text start, so a marker holding it matches
# no later user turn; "<think>" is scaffolding GLM-4.x renders as a lone
# "</think>" on non-final turns, so "<|assistant|><think>" never matches.
"glm": {
"instruction": "[gMASK]<sop><|user|>",
"response": "<|assistant|><think>",
"instruction": "<|user|>",
"response": "<|assistant|>",
},
}

View file

@ -6,6 +6,16 @@
Re-exports the shared API and injects Studio's marker-aware cache purge
(``prepare_cache_for_transport``) so the download manager keeps its ``.transport``
marker semantics on the HTTP retry.
Import discipline: ``unsloth_zoo``'s ``__init__`` eagerly imports ``transformers``. The workers
import this shim at startup (to decide the per-worker Xet env flip) *before* activating the model's
``transformers`` sidecar. Activation only prepends the sidecar to ``sys.path``, so a ``transformers``
already cached in ``sys.modules`` (via an eager ``unsloth_zoo`` import here) wins -- pinning the
default 4.57.x and regressing Qwen3.5 / GLM-4.7 / gemma-4 training with
``Tokenizer class TokenizersBackend does not exist``. So the shared backend is loaded **lazily**
(``_load_shared``), only on first use of a heavy download helper, i.e. after the sidecar is active.
``child_should_disable_xet`` and the ``DEFAULT_*`` constants are defined locally so importing them
never triggers the heavy load.
"""
from __future__ import annotations
@ -13,161 +23,230 @@ from __future__ import annotations
import threading
from typing import Any, Callable, Optional
_shared_import_error = None
try:
import unsloth_zoo.hf_xet_fallback as _shared
_shared_available = True
except Exception as _exc: # noqa: BLE001 - any import failure must degrade, not crash
# unsloth_zoo's __init__ runs torch/GPU detection, which raises on a torch-less/GPU-less Studio
# host. The download helper needs none of it, so retry via the light UNSLOTH_ZOO_DISABLE_GPU_INIT
# path before giving up.
_shared_import_error = _exc
import os as _os
# Defaults mirror unsloth_zoo.hf_xet_fallback; plain literals so they resolve (including as
# default args below) without importing unsloth_zoo/transformers.
DEFAULT_GRACE_PERIOD = 10.0
DEFAULT_HEARTBEAT_INTERVAL = 30.0
DEFAULT_STALL_TIMEOUT = 180.0
_prev_gpu_init = _os.environ.get("UNSLOTH_ZOO_DISABLE_GPU_INIT")
_os.environ["UNSLOTH_ZOO_DISABLE_GPU_INIT"] = "1"
try:
import unsloth_zoo.hf_xet_fallback as _shared
_shared_available = True
_shared_import_error = None
except Exception as _exc2: # noqa: BLE001 - degrade so Studio still boots with plain HF downloads
_shared_import_error = _exc2
_shared_available = False
finally:
if _prev_gpu_init is None:
_os.environ.pop("UNSLOTH_ZOO_DISABLE_GPU_INIT", None)
else:
_os.environ["UNSLOTH_ZOO_DISABLE_GPU_INIT"] = _prev_gpu_init
# --- lazy shared-backend loader ----------------------------------------------------------------
_shared: Any = None
_shared_available: Optional[bool] = None # None = not yet attempted
_shared_import_error: Optional[BaseException] = None
_load_lock = threading.Lock()
if _shared_available:
# Bind by assignment so each public name shares one module-level binding with the degraded branch.
DEFAULT_GRACE_PERIOD = _shared.DEFAULT_GRACE_PERIOD
DEFAULT_HEARTBEAT_INTERVAL = _shared.DEFAULT_HEARTBEAT_INTERVAL
DEFAULT_STALL_TIMEOUT = _shared.DEFAULT_STALL_TIMEOUT
DownloadStallError = _shared.DownloadStallError
child_should_disable_xet = _shared.child_should_disable_xet
get_hf_download_state = _shared.get_hf_download_state
start_watchdog = _shared.start_watchdog
_shared_hf_hub_download_with_xet_fallback = _shared.hf_hub_download_with_xet_fallback
_shared_snapshot_download_with_xet_fallback = _shared.snapshot_download_with_xet_fallback
else:
# Degrade instead of crashing Studio: plain HF downloads, stall watchdog disabled. Thin stubs,
# not a second copy of the orchestration; recovery returns once unsloth_zoo is upgraded.
import logging as _logging
_logging.getLogger(__name__).warning(
"unsloth_zoo.hf_xet_fallback unavailable (%s); the Xet stall watchdog is "
"disabled. Install/upgrade unsloth_zoo (and its torch dependency) to "
"re-enable automatic Xet -> HTTP download recovery.",
_shared_import_error,
)
def _load_shared() -> bool:
"""Import ``unsloth_zoo.hf_xet_fallback`` on demand; return True if available. Deferred so
importing this module at worker startup does not pull transformers in before the sidecar is
activated. Degrades (returns False) rather than crashing when unsloth_zoo is unavailable."""
global _shared, _shared_available, _shared_import_error
if _shared_available is not None:
return _shared_available
with _load_lock:
if _shared_available is not None:
return _shared_available
try:
import unsloth_zoo.hf_xet_fallback as shared
DEFAULT_HEARTBEAT_INTERVAL = 30.0
DEFAULT_STALL_TIMEOUT = 180.0
DEFAULT_GRACE_PERIOD = 10.0
_shared = shared
_shared_available = True
_shared_import_error = None
return True
except Exception as exc: # noqa: BLE001 - any import failure must degrade, not crash
# unsloth_zoo's __init__ runs torch/GPU detection, which raises on a torch-less/GPU-less
# host. The download helper needs none of it, so retry via UNSLOTH_ZOO_DISABLE_GPU_INIT.
_shared_import_error = exc
import os as _os
class DownloadStallError(RuntimeError):
"""Stub mirror so callers' ``except`` clauses resolve; never raised in degraded mode."""
_prev_gpu_init = _os.environ.get("UNSLOTH_ZOO_DISABLE_GPU_INIT")
_os.environ["UNSLOTH_ZOO_DISABLE_GPU_INIT"] = "1"
try:
import unsloth_zoo.hf_xet_fallback as shared
def child_should_disable_xet(config: dict) -> bool:
return bool(config.get("disable_xet"))
_shared = shared
_shared_available = True
_shared_import_error = None
return True
except Exception as exc2: # noqa: BLE001 - degrade so Studio still boots with plain HF
_shared_import_error = exc2
_shared_available = False
import logging as _logging
def get_hf_download_state(*args: Any, **kwargs: Any) -> None:
return None # unmeasurable -> the (absent) watchdog never fires
_logging.getLogger(__name__).warning(
"unsloth_zoo.hf_xet_fallback unavailable (%s); the Xet stall watchdog is "
"disabled. Install/upgrade unsloth_zoo (and its torch dependency) to "
"re-enable automatic Xet -> HTTP download recovery.",
_shared_import_error,
)
return False
finally:
if _prev_gpu_init is None:
_os.environ.pop("UNSLOTH_ZOO_DISABLE_GPU_INIT", None)
else:
_os.environ["UNSLOTH_ZOO_DISABLE_GPU_INIT"] = _prev_gpu_init
def start_watchdog(
*,
on_heartbeat: "Optional[Callable[[str], None]]" = None,
interval: float = DEFAULT_HEARTBEAT_INTERVAL,
xet_disabled: bool = False,
**kwargs: Any,
) -> "threading.Event":
# No stall detection, but keep emitting heartbeats so the orchestrator's inactivity deadline
# is not tripped during a long download.
stop = threading.Event()
if on_heartbeat is None:
return stop
transport = "https" if xet_disabled else "xet"
def _beat() -> None:
while not stop.wait(interval):
try:
on_heartbeat(f"Downloading ({transport} transport)...")
except Exception:
pass
def child_should_disable_xet(config: dict) -> bool:
"""Single source of truth for the per-worker Xet env flip (mirrors
``unsloth_zoo.hf_xet_fallback.child_should_disable_xet``). Deliberately lightweight: importing or
calling it must NOT pull in unsloth_zoo/transformers, so the worker can decide before activating
the transformers sidecar (see the module docstring)."""
return bool(config.get("disable_xet"))
threading.Thread(
target = _beat,
daemon = True,
name = "hf-xet-degraded-heartbeat",
).start()
# --- degraded stubs (used only when unsloth_zoo is unavailable) -------------------------------
class _DegradedDownloadStallError(RuntimeError):
"""Stub mirror so callers' ``except`` clauses resolve; never raised in degraded mode."""
def _degraded_get_hf_download_state(*args: Any, **kwargs: Any) -> None:
return None # unmeasurable -> the (absent) watchdog never fires
def _degraded_start_watchdog(
*,
on_heartbeat: "Optional[Callable[[str], None]]" = None,
interval: float = DEFAULT_HEARTBEAT_INTERVAL,
xet_disabled: bool = False,
**kwargs: Any,
) -> "threading.Event":
# No stall detection, but keep emitting heartbeats so the orchestrator's inactivity deadline
# is not tripped during a long download.
stop = threading.Event()
if on_heartbeat is None:
return stop
transport = "https" if xet_disabled else "xet"
def _degraded_cancelled(cancel_event: "Optional[threading.Event]") -> bool:
return cancel_event is not None and cancel_event.is_set()
def _beat() -> None:
while not stop.wait(interval):
try:
on_heartbeat(f"Downloading ({transport} transport)...")
except Exception:
pass
def _shared_hf_hub_download_with_xet_fallback(
repo_id: str,
filename: str,
token: Optional[str],
*,
repo_type: str = "model",
revision: Optional[str] = None,
cache_dir: Optional[str] = None,
force_download: bool = False,
cancel_event: "Optional[threading.Event]" = None,
**_ignored: Any,
) -> str:
# Keep the cancellation contract: do not start or return a download once cancelled.
if _degraded_cancelled(cancel_event):
raise RuntimeError("Cancelled")
threading.Thread(
target = _beat,
daemon = True,
name = "hf-xet-degraded-heartbeat",
).start()
return stop
from huggingface_hub import hf_hub_download
path = hf_hub_download(
repo_id = repo_id,
filename = filename,
token = token,
repo_type = repo_type,
revision = revision,
cache_dir = cache_dir,
force_download = force_download,
)
if _degraded_cancelled(cancel_event):
raise RuntimeError("Cancelled")
return path
def _degraded_cancelled(cancel_event: "Optional[threading.Event]") -> bool:
return cancel_event is not None and cancel_event.is_set()
def _shared_snapshot_download_with_xet_fallback(
repo_id: str,
*,
revision: Optional[str] = None,
token: Optional[str] = None,
repo_type: str = "model",
cache_dir: Optional[str] = None,
allow_patterns: Optional[Any] = None,
ignore_patterns: Optional[Any] = None,
force_download: bool = False,
cancel_event: "Optional[threading.Event]" = None,
**_ignored: Any,
) -> str:
if _degraded_cancelled(cancel_event):
raise RuntimeError("Cancelled")
from huggingface_hub import snapshot_download
def _degraded_hf_hub_download_with_xet_fallback(
repo_id: str,
filename: str,
token: Optional[str],
*,
repo_type: str = "model",
revision: Optional[str] = None,
cache_dir: Optional[str] = None,
force_download: bool = False,
cancel_event: "Optional[threading.Event]" = None,
**_ignored: Any,
) -> str:
# Keep the cancellation contract: do not start or return a download once cancelled.
if _degraded_cancelled(cancel_event):
raise RuntimeError("Cancelled")
path = snapshot_download(
repo_id = repo_id,
repo_type = repo_type,
revision = revision,
token = token,
cache_dir = cache_dir,
allow_patterns = allow_patterns,
ignore_patterns = ignore_patterns,
force_download = force_download,
)
if _degraded_cancelled(cancel_event):
raise RuntimeError("Cancelled")
return path
from huggingface_hub import hf_hub_download
path = hf_hub_download(
repo_id = repo_id,
filename = filename,
token = token,
repo_type = repo_type,
revision = revision,
cache_dir = cache_dir,
force_download = force_download,
)
if _degraded_cancelled(cancel_event):
raise RuntimeError("Cancelled")
return path
def _degraded_snapshot_download_with_xet_fallback(
repo_id: str,
*,
revision: Optional[str] = None,
token: Optional[str] = None,
repo_type: str = "model",
cache_dir: Optional[str] = None,
allow_patterns: Optional[Any] = None,
ignore_patterns: Optional[Any] = None,
force_download: bool = False,
cancel_event: "Optional[threading.Event]" = None,
**_ignored: Any,
) -> str:
if _degraded_cancelled(cancel_event):
raise RuntimeError("Cancelled")
from huggingface_hub import snapshot_download
path = snapshot_download(
repo_id = repo_id,
repo_type = repo_type,
revision = revision,
token = token,
cache_dir = cache_dir,
allow_patterns = allow_patterns,
ignore_patterns = ignore_patterns,
force_download = force_download,
)
if _degraded_cancelled(cancel_event):
raise RuntimeError("Cancelled")
return path
# --- lazy attribute access for the heavy shared API -------------------------------------------
# ``DownloadStallError`` (class identity matters for ``except``), ``start_watchdog`` and
# ``get_hf_download_state`` come from the shared backend when available, else the degraded stubs.
# Resolved via PEP 562 ``__getattr__`` so ``from utils.hf_xet_fallback import X`` triggers the load
# only for these heavy names, not for ``child_should_disable_xet`` / ``DEFAULT_*``.
_DEGRADED_ATTRS = {
"DownloadStallError": _DegradedDownloadStallError,
"start_watchdog": _degraded_start_watchdog,
"get_hf_download_state": _degraded_get_hf_download_state,
}
# Annotation-only declarations for the three names above: they bind NO value, so lookup still misses
# and PEP 562 ``__getattr__`` resolves them lazily -- but ruff/pyflakes see them as defined, so listing
# them in ``__all__`` does not trip F822 (while F822 still catches a real typo elsewhere in the list).
DownloadStallError: type
start_watchdog: Any
get_hf_download_state: Any
def __getattr__(name: str) -> Any:
if name in _DEGRADED_ATTRS:
if _load_shared():
return getattr(_shared, name)
return _DEGRADED_ATTRS[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
# Indirection seam the public wrappers call (and tests monkeypatch): lazy-load the shared backend,
# then dispatch to it or the degraded stub. The ``_shared_*`` names preserve the pre-refactor contract.
def _shared_hf_hub_download_with_xet_fallback(*args: Any, **kwargs: Any) -> str:
impl = (
_shared.hf_hub_download_with_xet_fallback
if _load_shared()
else _degraded_hf_hub_download_with_xet_fallback
)
return impl(*args, **kwargs)
def _shared_snapshot_download_with_xet_fallback(*args: Any, **kwargs: Any) -> str:
impl = (
_shared.snapshot_download_with_xet_fallback
if _load_shared()
else _degraded_snapshot_download_with_xet_fallback
)
return impl(*args, **kwargs)
__all__ = [

View file

@ -514,6 +514,12 @@ def _run_update(install_dir: Path, repo: str, asset: Optional[str], script: Path
logger.info("llama update: installing", cmd = " ".join(cmd))
# Stream progress lines into job["progress"].
env = dict(os.environ, UNSLOTH_PROGRESS_PERCENT_STEP = "5")
# Preserve a Vulkan install across updates: detect_host on a CUDA/ROCm
# box would otherwise re-route and silently replace the Vulkan build.
# Re-assert it via the same env flag setup uses (mirrors
# _rocm_install_args).
if asset and "vulkan" in asset.lower():
env["UNSLOTH_FORCE_VULKAN"] = "1"
proc = subprocess.Popen(
cmd,
stdout = subprocess.PIPE,

View file

@ -1617,36 +1617,60 @@ def _iter_hf_cache_snapshots(repo_id: str):
cache_dir = Path(hf_constants.HF_HUB_CACHE)
target = f"models--{repo_id.replace('/', '--')}".lower()
repo_dir: Optional[Path] = None
repo_dirs: list[Path] = []
try:
if not cache_dir.is_dir():
return
for entry in cache_dir.iterdir():
if entry.is_dir() and entry.name.lower() == target:
repo_dir = entry
break
repo_dirs.append(entry)
except OSError:
return
if repo_dir is None:
if not repo_dirs:
return
snapshots = repo_dir / "snapshots"
try:
if not snapshots.is_dir():
return
snap_dirs = [s for s in snapshots.iterdir() if s.is_dir()]
except OSError:
snap_dirs: list[Path] = []
for repo_dir in repo_dirs:
snapshots = repo_dir / "snapshots"
try:
if snapshots.is_dir():
for snap_dir in snapshots.iterdir():
try:
if snap_dir.is_dir():
snap_dirs.append(snap_dir)
except OSError:
continue
except OSError:
continue
if not snap_dirs:
return
snap_dirs.sort(key = lambda s: s.stat().st_mtime, reverse = True)
yield from snap_dirs
snap_dirs_with_mtime = []
for snap_dir in snap_dirs:
try:
snap_dirs_with_mtime.append((snap_dir.stat().st_mtime, snap_dir))
except OSError:
continue
snap_dirs_with_mtime.sort(key = lambda item: item[0], reverse = True)
yield from (snap_dir for _, snap_dir in snap_dirs_with_mtime)
def _list_gguf_variants_from_hf_cache(repo_id: str) -> Optional[tuple[list[GgufVariantInfo], bool]]:
"""Variants from the local HF cache snapshot, or None if not cached."""
"""Variants from the local HF cache snapshot, or None if not cached.
A newer snapshot can hold only a companion file (for example a vision
projector fetched on demand) while the quant files live in an older
snapshot. Returning the first snapshot that merely reports a vision flag
would shadow those real variants, so keep scanning older snapshots for
actual variants and carry the vision flag across snapshots.
"""
any_vision = False
for snap in _iter_hf_cache_snapshots(repo_id):
variants, has_vision = list_local_gguf_variants(str(snap))
if variants or has_vision:
return variants, has_vision
any_vision = any_vision or has_vision
if variants:
return variants, any_vision
if any_vision:
return [], True
return None

View file

@ -28,7 +28,9 @@ Strategy:
sys.path swap using the same directories pre-installed by setup.sh.
"""
import ast
import importlib
import importlib.util
import json
import structlog
from loggers import get_logger
@ -173,6 +175,7 @@ _TRANSFORMERS_530_ARCHITECTURES: set[str] = {
"Qwen3MoeForCausalLM",
"Qwen3NextForCausalLM",
"Glm4MoeLiteForCausalLM",
"Lfm2MoeForCausalLM",
"Lfm2VlForConditionalGeneration",
}
_TRANSFORMERS_530_MODEL_TYPES: set[str] = {
@ -183,6 +186,7 @@ _TRANSFORMERS_530_MODEL_TYPES: set[str] = {
"qwen3_moe",
"qwen3_next",
"glm4_moe_lite",
"lfm2_moe",
"lfm2_vl",
}
@ -870,6 +874,116 @@ def _cached_config_json(model_name: str, hf_token: str | None) -> dict | None:
return _config_json_cache.get(_token_cache_key(model_name, hf_token))
# --- Static tier from CONFIG_MAPPING_NAMES (AST only: no import/network/exec) ---
# A model_type absent from an overlay's mapping can't load there. Parse each sidecar's
# config map from source and pick the lowest tier that ships it, so a new arch routes
# correctly with no per-model table edit. Only ever upgrades default, never lowers.
_config_mapping_cache: dict[str, frozenset[str]] = {}
def _overlay_transformers_dir(tier: str) -> str | None:
"""transformers source dir for a tier, located without importing it."""
if tier != "default":
root = {"530": _VENV_T5_530_DIR, "550": _VENV_T5_550_DIR, "510": _VENV_T5_510_DIR}.get(tier)
src = os.path.join(root, "transformers") if root else None
return src if src and _safe_is_dir(Path(src)) else None
# default: the base 4.x transformers. find_spec resolves to a 5.x sidecar if one
# is already on sys.path, so skip any .venv_t5_* / llmcompressor overlay dir.
sidecars = tuple(
os.path.abspath(d) + os.sep
for d in (_VENV_T5_530_DIR, _VENV_T5_550_DIR, _VENV_T5_510_DIR, _VENV_LLMCOMPRESSOR_DIR)
)
candidates = []
try:
spec = importlib.util.find_spec("transformers")
if spec and spec.origin:
candidates.append(os.path.dirname(spec.origin))
except Exception:
pass
candidates += [os.path.join(e, "transformers") for e in sys.path if e]
for c in candidates:
if _safe_is_dir(Path(c)) and not os.path.abspath(c).startswith(sidecars):
return c
return None
def _mapping_first_keys(value: ast.AST) -> set[str]:
"""First keys of a dict literal, or of an OrderedDict(...)/dict(...)/.update(...)
built from 2-tuple lists and **{...} unpacking."""
def keys_of(node):
if isinstance(node, ast.Dict):
return list(node.keys)
if isinstance(node, (ast.List, ast.Tuple)):
return [
el.elts[0] for el in node.elts if isinstance(el, (ast.Tuple, ast.List)) and el.elts
]
return []
nodes = keys_of(value)
if isinstance(value, ast.Call):
for a in value.args:
nodes += keys_of(a)
for kw in value.keywords: # **{...} unpacking has kw.arg is None
if kw.arg is None:
nodes += keys_of(kw.value)
return {n.value for n in nodes if isinstance(n, ast.Constant) and isinstance(n.value, str)}
def _config_model_types(tier: str) -> frozenset[str]:
"""model_type keys in a tier's CONFIG_MAPPING_NAMES (5.10 moved it to auto_mappings.py)."""
cached = _config_mapping_cache.get(tier)
if cached is not None:
return cached
tdir = _overlay_transformers_dir(tier)
if tdir is None:
return frozenset() # overlay not provisioned yet; do not cache so a later call re-reads
keys: set[str] = set()
for rel in ("models/auto/configuration_auto.py", "models/auto/auto_mappings.py"):
path = Path(tdir) / rel
if not _safe_is_file(path):
continue
try:
tree = ast.parse(path.read_text(encoding = "utf-8"))
for node in ast.walk(tree):
# direct binding, or a CONFIG_MAPPING_NAMES.update({...}) mutation
if isinstance(node, ast.Assign) and any(
isinstance(t, ast.Name) and t.id == "CONFIG_MAPPING_NAMES" for t in node.targets
):
keys |= _mapping_first_keys(node.value)
elif isinstance(node, ast.Expr) and isinstance(node.value, ast.Call):
fn = node.value.func
if (
isinstance(fn, ast.Attribute)
and fn.attr == "update"
and isinstance(fn.value, ast.Name)
and fn.value.id == "CONFIG_MAPPING_NAMES"
):
keys |= _mapping_first_keys(node.value)
except Exception:
continue
result = frozenset(keys)
_config_mapping_cache[tier] = result
return result
def _tier_from_config_mapping(cfg: dict) -> str | None:
"""Lowest tier whose transformers ships cfg's model_type, or None if unknown."""
model_type = cfg.get("model_type")
if not isinstance(model_type, str):
for key in _NESTED_CONFIG_KEYS:
sub = cfg.get(key)
if isinstance(sub, dict) and isinstance(sub.get("model_type"), str):
model_type = sub["model_type"]
break
if not isinstance(model_type, str):
return None
for tier in sorted(_TIER_RANK, key = _TIER_RANK.get):
if model_type in _config_model_types(tier):
return tier
return None
# --- AutoConfig probe: general tier resolution for ambiguous models ----------
# When the cheap signals only say "needs some 5.x", parse config.json with the built-in
# parser in each candidate sidecar (lowest first) instead of guessing. Generalizes beyond
@ -1210,6 +1324,14 @@ def get_transformers_tier(
match,
)
return tier
static = _tier_from_config_mapping(cfg)
if static is not None and static != "default":
logger.info(
"Transformers tier %s selected for %s (config mapping: model_type absent below)",
static,
model_name,
)
return static
local_tc = Path(model_name) / "tokenizer_config.json"
if _safe_is_file(local_tc) and _check_tokenizer_config_needs_v5(model_name, hf_token):
if not probe:
@ -1269,6 +1391,18 @@ def get_transformers_tier(
return override
logger.info("Transformers tier 530 selected for %s (config.json check)", model_name)
return "530"
# _load_config_json (not the cache-only reader) so a config served from the hub
# cache during a transient outage still feeds the mapping resolver.
remote_cfg = _load_config_json(model_name, hf_token)
if remote_cfg is not None:
static = _tier_from_config_mapping(remote_cfg)
if static is not None and static != "default":
logger.info(
"Transformers tier %s selected for %s (config mapping: model_type absent below)",
static,
model_name,
)
return static
if _check_tokenizer_config_needs_v5(model_name, hf_token):
if not probe:
return "530"

View file

@ -26,11 +26,14 @@ FLASH_ATTN_RELEASE_BASE_URL = "https://github.com/Dao-AILab/flash-attention/rele
def has_blackwell_gpu() -> bool:
"""Return True if any visible NVIDIA GPU has compute capability >= 10.0 (Blackwell).
Dao-AILab ships no flash-attention wheels for these archs and older-arch wheels
fail to load, so callers use this to skip the flash-attn install path. Cached
for the process lifetime; tests mocking nvidia-smi must call
Cached for the process lifetime; tests mocking nvidia-smi must call
``has_blackwell_gpu.cache_clear()`` first.
"""
# Detection disabled for now: Dao-AILab ships Blackwell (sm_100+) flash-attn
# wheels and url_exists() already gates resolution, so we no longer skip
# flash-attn on Blackwell. The nvidia-smi probe below is kept for possible
# future arch-based gating; drop this early return to re-enable it.
return False
exe = shutil.which("nvidia-smi")
if not exe:
return False
@ -117,6 +120,19 @@ def probe_torch_wheel_env(*, timeout: int | None = None) -> dict[str, str] | Non
return env
# torch 2.11 has no native prebuilt wheels for flash-attn / causal-conv1d / mamba
# yet, but their torch 2.10 CUDA wheels load and pass the projects' own test suites
# on torch 2.11 (verified on B200: FA2 fwd/bwd, causal-conv1d, and mamba selective
# scan all match reference). Reuse the torch 2.10 wheels on torch 2.11 so a 2.11
# install still gets these prebuilt accelerators instead of building from source.
_PREBUILT_WHEEL_TORCH_MM = {"2.11": "2.10"}
def prebuilt_wheel_torch_mm(torch_mm: str) -> str:
"""Map a torch major.minor to the one whose prebuilt accelerator wheels to use."""
return _PREBUILT_WHEEL_TORCH_MM.get(torch_mm, torch_mm)
def direct_wheel_url(
*,
filename_prefix: str,
@ -130,7 +146,7 @@ def direct_wheel_url(
filename = (
f"{filename_prefix}-{package_version}"
f"+cu{env['cuda_major']}torch{env['torch_mm']}"
f"+cu{env['cuda_major']}torch{prebuilt_wheel_torch_mm(env['torch_mm'])}"
f"cxx11abi{env['cxx11abi']}-{env['python_tag']}-{env['python_tag']}"
f"-{env['platform_tag']}.whl"
)
@ -152,7 +168,7 @@ def flash_attn_package_version(torch_mm: str) -> str | None:
def flash_attn_wheel_url(env: dict[str, str] | None) -> str | None:
if env is None:
return None
package_version = flash_attn_package_version(env["torch_mm"])
package_version = flash_attn_package_version(prebuilt_wheel_torch_mm(env["torch_mm"]))
if package_version is None:
return None
return direct_wheel_url(

View file

@ -81,7 +81,6 @@ import {
TestTube01Icon,
ZapIcon,
} from "@hugeicons/core-free-icons";
import { listStoredChatThreads } from "@/features/chat/utils/chat-history-storage";
import {
Tooltip,
TooltipContent,
@ -97,6 +96,7 @@ import {
createChatProject,
deleteChatProject,
deleteChatItem,
listStoredChatThreads,
moveChatItemToProject,
renameChatItem,
renameChatProject,
@ -582,7 +582,14 @@ export function AppSidebar() {
useEffect(() => {
if (!pendingRename) return;
const match = allChatItems.find((i) => i.id === pendingRename.id);
if (match && match.title === pendingRename.title) setPendingRename(null);
if (!match || match.title !== pendingRename.title) return;
queueMicrotask(() => {
setPendingRename((current) =>
current?.id === pendingRename.id && current.title === pendingRename.title
? null
: current,
);
});
}, [allChatItems, pendingRename]);
const [creatingProject, setCreatingProject] = useState(false);
const [projectNameDraft, setProjectNameDraft] = useState("");
@ -680,12 +687,6 @@ export function AppSidebar() {
useState<DeleteTarget | null>(null);
const [deleteProjectFiles, setDeleteProjectFiles] = useState(false);
useEffect(() => {
if (confirmingDelete?.kind !== "project") {
setDeleteProjectFiles(false);
}
}, [confirmingDelete]);
async function commitDelete() {
const target = confirmingDelete;
if (!target) return;
@ -1572,9 +1573,6 @@ export function AppSidebar() {
>
<HugeiconsIcon icon={Globe02Icon} strokeWidth={1.75} className="size-[18px]" />
<span>{t("shell.navigation.api")}</span>
<span className="ml-auto rounded-full bg-emerald-500/10 px-2 py-1 text-[10px] leading-none font-semibold text-emerald-700 dark:text-emerald-300">
{t("common.new")}
</span>
</DropdownMenuItem>
<DropdownMenuItem
ref={anchorRef as React.Ref<HTMLDivElement>}

View file

@ -18,7 +18,7 @@ import {
useExternalProvidersStore,
} from "@/features/chat";
import { cn } from "@/lib/utils";
import { FileDatabaseIcon } from "@hugeicons/core-free-icons";
import { HelpCircleIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useMessage, useMessageTiming } from "@assistant-ui/react";
import type { FC, ReactNode } from "react";
@ -341,7 +341,7 @@ export const MessageResponseDetailsSheet: FC<{
<SheetHeader className="border-b p-4">
<SheetTitle className="flex items-center gap-2 pr-10 font-heading text-base">
<HugeiconsIcon
icon={FileDatabaseIcon}
icon={HelpCircleIcon}
strokeWidth={1.75}
className="size-icon text-chat-icon-fg"
/>

View file

@ -393,6 +393,7 @@ function ModelRow({
vramEst,
gpuGb,
tooltipText,
hubUrl,
optionProps,
onArrowDownIntoChildren,
capabilities,
@ -409,6 +410,10 @@ function ModelRow({
vramEst?: number;
gpuGb?: number;
tooltipText?: ReactNode;
/** Hugging Face address (e.g. "huggingface.co/owner/name") for online/Hub
* rows; surfaced on hover so their repo id / URL is discoverable the same
* way local rows show an on-disk path. Omit to show no address line. */
hubUrl?: string;
optionProps?: ModelRowOptionProps;
onArrowDownIntoChildren?: () => boolean;
/** Capability override (HF rows have tags); falls back to name detection. */
@ -546,30 +551,41 @@ function ModelRow({
</button>
);
if (vramTooltipText) {
return (
<Tooltip>
<TooltipTrigger asChild={true}>{content}</TooltipTrigger>
<TooltipContent
side="left"
className="tooltip-compact max-w-xs break-all"
>
{label}
<span className="block text-[10px] mt-1">{vramTooltipText}</span>
</TooltipContent>
</Tooltip>
);
}
// Optional Hugging Face address line for online/Hub rows, rendered under
// whichever tooltip shows so the repo id / URL is always visible on hover.
const hubUrlLine = hubUrl ? (
<span className="block mt-1 text-[10px] text-muted-foreground break-all">
{hubUrl}
</span>
) : null;
if (tooltipText) {
const tooltipBody = vramTooltipText ? (
<>
{label}
<span className="block text-[10px] mt-1">{vramTooltipText}</span>
{hubUrlLine}
</>
) : tooltipText ? (
<>
{tooltipText}
{hubUrlLine}
</>
) : hubUrl ? (
<>
<span className="block break-words">{label}</span>
{hubUrlLine}
</>
) : null;
if (tooltipBody) {
return (
<Tooltip>
<Tooltip delayDuration={700}>
<TooltipTrigger asChild={true}>{content}</TooltipTrigger>
<TooltipContent
side="left"
className="tooltip-compact max-w-xs break-all"
>
{tooltipText}
{tooltipBody}
</TooltipContent>
</Tooltip>
);
@ -1193,6 +1209,13 @@ function localPathTooltip(name: string, path: string): ReactNode {
);
}
/** Hugging Face address for an online/Hub row, or undefined when the repo id is
* missing so the row shows no (empty) address line on hover. */
function hubRepoUrl(id: string | null | undefined): string | undefined {
const trimmed = id?.trim();
return trimmed ? `huggingface.co/${trimmed}` : undefined;
}
/** Whether a local model is an MLX build (name hint). MLX runs on Mac only, so
* callers gate visibility on the host being a Mac. */
function localModelIsMlx(m: LocalModelInfo): boolean {
@ -2462,6 +2485,7 @@ export function HubModelPicker({
<div className="min-w-0 flex-1">
<ModelRow
label={c.repo_id}
tooltipText={localPathTooltip(c.repo_id, c.cache_path)}
meta="GGUF"
showVision={c.has_vision ?? visionByRepo[c.repo_id]}
selected={isSelected}
@ -2518,6 +2542,7 @@ export function HubModelPicker({
<div className="min-w-0 flex-1">
<ModelRow
label={c.repo_id}
hubUrl={hubRepoUrl(c.repo_id)}
meta={`${isMlxId(c.repo_id) ? "MLX" : "Safetensors"} · ${formatBytes(
c.size_bytes,
)}`}
@ -3378,6 +3403,7 @@ export function HubModelPicker({
<div key={id}>
<ModelRow
label={id}
hubUrl={hubRepoUrl(id)}
hideOwner={true}
downloaded={downloadedSet.has(id.toLowerCase())}
capabilities={capsById.get(id)}
@ -3463,6 +3489,7 @@ export function HubModelPicker({
<div key={id}>
<ModelRow
label={id}
hubUrl={hubRepoUrl(id)}
capabilities={capsById.get(id)}
meta={
isKnownGgufRepo(id)
@ -3545,6 +3572,7 @@ export function HubModelPicker({
<div key={id}>
<ModelRow
label={id}
hubUrl={hubRepoUrl(id)}
capabilities={capsById.get(id)}
meta={
isSearchGguf

View file

@ -127,6 +127,7 @@ import {
FileDatabaseIcon,
Folder01Icon,
FolderAddIcon,
HelpCircleIcon,
Image03Icon,
McpServerIcon,
PencilRulerIcon,
@ -3952,7 +3953,7 @@ const AssistantActionBar: FC = () => {
strokeWidth={1.75}
className="size-icon"
/>
Export as Markdown
Export as markdown
</ActionBarMorePrimitive.Item>
</ActionBarPrimitive.ExportMarkdown>
<ActionBarMorePrimitive.Item
@ -3960,7 +3961,7 @@ const AssistantActionBar: FC = () => {
className="aui-action-bar-more-item flex cursor-pointer select-none items-center gap-2 rounded-[12px] px-3 py-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
>
<HugeiconsIcon
icon={FileDatabaseIcon}
icon={HelpCircleIcon}
strokeWidth={1.75}
className="size-icon"
/>

View file

@ -3,27 +3,35 @@
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { useMonitorOverlayStore } from "@/features/settings/stores/monitor-overlay-store";
import { useMonitorOverlayStore } from "@/features/settings";
import { useSystemInfo } from "@/hooks/use-system";
import { useT } from "@/i18n";
import { cn } from "@/lib/utils";
import { CpuIcon, GripVerticalIcon, XIcon } from "lucide-react";
import { motion } from "motion/react";
import { useRef } from "react";
import { AnimatePresence, motion, useDragControls } from "motion/react";
import { type PointerEvent, useMemo, useState } from "react";
function clampPercent(value: number): number {
return Math.max(0, Math.min(100, value));
}
function usageIndicatorClass(percent: number): string {
if (percent >= 90) return "bg-destructive";
if (percent >= 70) return "bg-amber-500";
if (percent >= 90) {
return "bg-destructive";
}
if (percent >= 70) {
return "bg-amber-500";
}
return "bg-primary";
}
function usageTextClass(percent: number): string {
if (percent >= 90) return "text-destructive";
if (percent >= 70) return "text-amber-600 dark:text-amber-400";
if (percent >= 90) {
return "text-destructive";
}
if (percent >= 70) {
return "text-amber-600 dark:text-amber-400";
}
return "text-primary";
}
@ -39,9 +47,18 @@ export function FloatingMonitor() {
const { isOpen, setIsOpen } = useMonitorOverlayStore();
const systemInfo = useSystemInfo({ enabled: isOpen, pollMs: 5000 });
const constraintsRef = useRef<HTMLDivElement>(null);
const [constraintsElement, setConstraintsElement] =
useState<HTMLDivElement | null>(null);
const constraintsRef = useMemo(
() => ({ current: constraintsElement }),
[constraintsElement],
);
const dragControls = useDragControls();
if (!isOpen) return null;
function startDrag(event: PointerEvent<HTMLDivElement>) {
event.preventDefault();
dragControls.start(event);
}
const ramTotal = systemInfo.memory?.total_gb ?? 0;
const ramAvailable = systemInfo.memory?.available_gb ?? 0;
@ -64,99 +81,109 @@ export function FloatingMonitor() {
const hasGpu = (systemInfo.gpu?.available ?? false) && devices.length > 0;
return (
<div
ref={constraintsRef}
className="fixed inset-0 z-50 pointer-events-none"
>
<motion.div
layout={true}
drag={true}
dragConstraints={constraintsRef}
dragElastic={0.1}
dragMomentum={false}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="settings-surface fixed bottom-4 right-4 w-64 max-w-[calc(100vw-2rem)] resize overflow-hidden rounded-xl border border-border/70 p-3 shadow-border ring-0 backdrop-blur-sm pointer-events-auto cursor-default select-none"
>
<div className="mb-2 flex items-center justify-between gap-2 border-b border-border/60 pb-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-xs font-semibold text-foreground">
<CpuIcon className="size-3.5 shrink-0 text-primary" />
<span className="truncate">
{t("settings.resources.liveMonitor.title")}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<div className="cursor-grab rounded-md px-1 text-muted-foreground/60 transition-colors hover:bg-muted/60 hover:text-muted-foreground active:cursor-grabbing">
<GripVerticalIcon className="size-3.5" />
</div>
<Button
size="icon-xs"
variant="ghost"
className="text-muted-foreground hover:text-foreground"
onClick={() => setIsOpen(false)}
title={t("common.close")}
aria-label={t("common.close")}
>
<XIcon className="size-3" />
</Button>
</div>
</div>
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="space-y-3 overflow-hidden"
<AnimatePresence>
{isOpen && (
<div
ref={setConstraintsElement}
className="fixed inset-0 z-50 pointer-events-none"
>
<div className="space-y-1">
<div className="flex justify-between text-[11px] font-medium font-mono">
<span>{t("settings.resources.liveMonitor.ram")}</span>
<span className={cn("tabular-nums", usageTextClass(ramPercent))}>
{Math.round(ramPercent)}%
</span>
</div>
<div className="text-xs text-muted-foreground font-mono tabular-nums">
{formatGiB(ramUsed)} / {formatGiB(ramTotal)}
</div>
<Progress
value={ramPercent}
className="mt-1 h-1.5 rounded-full bg-muted"
indicatorClassName={usageIndicatorClass(ramPercent)}
/>
</div>
{hasGpu && (
<div className="space-y-1">
<div className="flex justify-between text-[11px] font-medium font-mono">
<span className="truncate flex-1 pr-2">
{t("settings.resources.liveMonitor.vram")}{" "}
{devices.length > 1
? `(${devices.length} GPUs)`
: `(${devices[0].name ?? "GPU"})`}
<motion.div
drag={true}
dragControls={dragControls}
dragListener={false}
dragConstraints={constraintsRef}
dragElastic={0}
dragMomentum={false}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="settings-surface fixed bottom-4 right-4 w-64 max-w-[calc(100vw-2rem)] resize overflow-hidden rounded-xl border border-border/70 p-3 shadow-border ring-0 backdrop-blur-sm pointer-events-auto cursor-default select-none"
>
<div className="mb-2 flex items-center justify-between gap-2 border-b border-border/60 pb-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-xs font-semibold text-foreground">
<CpuIcon className="size-3.5 shrink-0 text-primary" />
<span className="truncate">
{t("settings.resources.liveMonitor.title")}
</span>
<span
className={cn(
"shrink-0 tabular-nums",
usageTextClass(vramPercent),
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<div
onPointerDown={startDrag}
className="touch-none cursor-grab rounded-md px-1 text-muted-foreground/60 transition-colors hover:bg-muted/60 hover:text-muted-foreground active:cursor-grabbing"
>
{Math.round(vramPercent)}%
</span>
<GripVerticalIcon className="size-3.5" />
</div>
<Button
size="icon-xs"
variant="ghost"
className="text-muted-foreground hover:text-foreground"
onClick={() => setIsOpen(false)}
title={t("common.close")}
aria-label={t("common.close")}
>
<XIcon className="size-3" />
</Button>
</div>
<div className="text-xs text-muted-foreground font-mono tabular-nums">
{formatGiB(vramUsed)} / {formatGiB(vramTotal)}
</div>
<Progress
value={vramPercent}
className="mt-1 h-1.5 rounded-full bg-muted"
indicatorClassName={usageIndicatorClass(vramPercent)}
/>
</div>
)}
</motion.div>
</motion.div>
</div>
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="space-y-3 overflow-hidden"
>
<div className="space-y-1">
<div className="flex justify-between text-[11px] font-medium font-mono">
<span>{t("settings.resources.liveMonitor.ram")}</span>
<span
className={cn("tabular-nums", usageTextClass(ramPercent))}
>
{Math.round(ramPercent)}%
</span>
</div>
<div className="text-xs text-muted-foreground font-mono tabular-nums">
{formatGiB(ramUsed)} / {formatGiB(ramTotal)}
</div>
<Progress
value={ramPercent}
className="mt-1 h-1.5 rounded-full bg-muted"
indicatorClassName={usageIndicatorClass(ramPercent)}
/>
</div>
{hasGpu && (
<div className="space-y-1">
<div className="flex justify-between text-[11px] font-medium font-mono">
<span className="truncate flex-1 pr-2">
{t("settings.resources.liveMonitor.vram")}{" "}
{devices.length > 1
? `(${devices.length} GPUs)`
: `(${devices[0].name ?? "GPU"})`}
</span>
<span
className={cn(
"shrink-0 tabular-nums",
usageTextClass(vramPercent),
)}
>
{Math.round(vramPercent)}%
</span>
</div>
<div className="text-xs text-muted-foreground font-mono tabular-nums">
{formatGiB(vramUsed)} / {formatGiB(vramTotal)}
</div>
<Progress
value={vramPercent}
className="mt-1 h-1.5 rounded-full bg-muted"
indicatorClassName={usageIndicatorClass(vramPercent)}
/>
</div>
)}
</motion.div>
</motion.div>
</div>
)}
</AnimatePresence>
);
}

View file

@ -2,6 +2,10 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { getAuthToken } from "@/features/auth";
import {
loadRememberedLoadSettings,
rememberedLoadSettingsKey,
} from "@/components/assistant-ui/model-selector/remembered-load-settings";
import { projectHasSources } from "@/features/rag/api/rag-api";
import { apiUrl } from "@/lib/api-base";
import { parseParamCountB } from "@/lib/model-size";
@ -63,11 +67,17 @@ import {
listStoredChatThreads,
updateStoredChatThread,
} from "../utils/chat-history-storage";
import {
readLastLocalModelLoad,
recordLastLocalModelLoad,
type LastLocalModelKind,
} from "../utils/last-local-model-load";
import { getImageInputUnavailableReason } from "../utils/image-input-support";
import {
hasClosedThinkTag,
parseAssistantContent,
} from "../utils/parse-assistant-content";
import { resolveLoadMaxSeqLength } from "../presets/preset-policy";
import {
generateAudio,
listCachedGguf,
@ -1309,6 +1319,30 @@ const BIG_ENDIAN_GGUF_FILENAME_RE = /(^|[-_])be(?:[._-]|$)/gi;
const GGUF_KNOWN_QUANT_RE =
/(UD-)?(MXFP[0-9]+(?:_[A-Z0-9]+)*|IQ[0-9]+_[A-Z]+(?:_[A-Z0-9]+)?|TQ[0-9]+_[0-9]+|Q[0-9]+_K_[A-Z]+|Q[0-9]+_[0-9]+|Q[0-9]+_K|BF16|F16|F32)/i;
type AutoLoadCandidate = {
id: string;
kind: LastLocalModelKind;
ggufVariant: string | null;
maxSeqLength: number;
successLabel: string;
};
function autoLoadCandidateKey(
kind: LastLocalModelKind,
id: string,
ggufVariant?: string | null,
): string {
return `${kind}:${id.toLowerCase()}:${(ggufVariant ?? "").toLowerCase()}`;
}
function findCachedRepo<T extends { repo_id: string }>(
repos: T[],
id: string,
): T | undefined {
const normalized = id.toLowerCase();
return repos.find((repo) => repo.repo_id.toLowerCase() === normalized);
}
function hasBigEndianGgufMarker(filename: string, quant?: string | null): boolean {
const normalized = filename.replace(/\\/g, "/").toLowerCase();
const separatorIndex = normalized.lastIndexOf("/");
@ -1357,14 +1391,18 @@ async function autoLoadSmallestModel(): Promise<{
const hfToken = store.hfToken || null;
const trustRemoteCode = store.params.trustRemoteCode ?? false;
const specSettings = resolveSpeculativeSettingsForLoad();
const lastLoaded = readLastLocalModelLoad();
const toastId = toast("Loading a model…", {
description: "Auto-selecting the smallest downloaded model.",
description: lastLoaded
? "Loading last used model."
: "Auto-selecting the smallest downloaded model.",
duration: 5000,
closeButton: true,
});
let blockedByTrustRemoteCode = false;
let hadNonTrustFailure = false;
let loadAttempts = 0;
const skippedAutoLoadCandidates = new Set<string>();
async function canAutoLoad(payload: {
model_path: string;
@ -1389,12 +1427,224 @@ async function autoLoadSmallestModel(): Promise<{
}
return true;
}
async function loadAutoLoadCandidate(
candidate: AutoLoadCandidate,
): Promise<boolean> {
if (loadAttempts >= MAX_AUTO_LOAD_ATTEMPTS) {
return false;
}
const currentStore = useChatRuntimeStore.getState();
const remembered = loadRememberedLoadSettings(
rememberedLoadSettingsKey({
id: candidate.id,
ggufVariant: candidate.ggufVariant,
}),
);
const effectiveMaxSeqLength = resolveLoadMaxSeqLength({
modelId: candidate.id,
ggufVariant: candidate.ggufVariant,
isGguf: candidate.kind === "gguf",
customContextLength: remembered?.contextLength ?? null,
ggufContextLength: null,
currentCheckpoint: currentStore.params.checkpoint,
activeGgufVariant: currentStore.activeGgufVariant,
maxSeqLength: candidate.maxSeqLength,
presetSource: currentStore.activePresetSource,
});
const effectiveSpeculativeType =
remembered?.speculativeType ?? specSettings.speculativeType;
const effectiveSpecDraftNMax =
remembered?.specDraftNMax ?? specSettings.specDraftNMax;
if (
!(await canAutoLoad({
model_path: candidate.id,
max_seq_length: effectiveMaxSeqLength,
is_lora: false,
gguf_variant: candidate.ggufVariant,
}))
) {
skippedAutoLoadCandidates.add(
autoLoadCandidateKey(candidate.kind, candidate.id, candidate.ggufVariant),
);
return false;
}
loadAttempts += 1;
const loadResp = await loadModel({
model_path: candidate.id,
hf_token: hfToken,
max_seq_length: effectiveMaxSeqLength,
load_in_4bit: true,
is_lora: false,
gguf_variant: candidate.ggufVariant,
trust_remote_code: trustRemoteCode,
cache_type_kv: remembered?.kvCacheDtype ?? null,
speculative_type: effectiveSpeculativeType,
spec_draft_n_max: effectiveSpecDraftNMax,
tensor_parallel: remembered?.tensorParallel ?? false,
});
saveSpeculativeType(effectiveSpeculativeType);
useChatRuntimeStore
.getState()
.setCheckpoint(candidate.id, candidate.ggufVariant ?? undefined);
const store = useChatRuntimeStore.getState();
store.setModelRequiresTrustRemoteCode(
loadResp.requires_trust_remote_code ?? false,
);
store.setParams({
...store.params,
maxTokens:
candidate.kind === "gguf"
? loadResp.context_length ?? 131072
: effectiveMaxSeqLength,
});
const autoModel: ChatModelSummary = {
id: candidate.id,
name: loadResp.display_name ?? candidate.id,
isVision: loadResp.is_vision ?? false,
isLora: loadResp.is_lora ?? false,
isGguf: loadResp.is_gguf ?? candidate.kind === "gguf",
isAudio: loadResp.is_audio ?? false,
audioType: loadResp.audio_type ?? null,
hasAudioInput: loadResp.has_audio_input ?? false,
};
if (!store.models.some((m) => m.id === candidate.id)) {
store.setModels([...store.models, autoModel]);
}
if (candidate.kind === "gguf") {
useChatRuntimeStore.setState({
ggufContextLength: loadResp.context_length ?? 131072,
ggufMaxContextLength:
loadResp.max_context_length ?? loadResp.context_length ?? 131072,
ggufNativeContextLength: loadResp.native_context_length ?? null,
supportsReasoning: loadResp.supports_reasoning ?? false,
reasoningAlwaysOn: loadResp.reasoning_always_on ?? false,
reasoningEnabled: loadResp.supports_reasoning ?? false,
...reasoningCapsFromLoad(loadResp),
supportsPreserveThinking: loadResp.supports_preserve_thinking ?? false,
supportsTools: loadResp.supports_tools ?? false,
...resolveToolsEnabledOnLoad(loadResp.supports_tools ?? false),
kvCacheDtype: loadResp.cache_type_kv ?? null,
loadedKvCacheDtype: loadResp.cache_type_kv ?? null,
tensorParallel: loadResp.tensor_parallel ?? false,
loadedTensorParallel: loadResp.tensor_parallel ?? false,
defaultChatTemplate: loadResp.chat_template ?? null,
chatTemplateOverride: null,
loadedChatTemplateOverride: null,
loadedIsMultimodal: isMultimodalResponse(loadResp),
loadedIsDiffusion: loadResp.is_diffusion ?? false,
...resolveLoadedSpeculativeSettings(loadResp),
});
} else {
useChatRuntimeStore.setState({
supportsReasoning: loadResp.supports_reasoning ?? false,
reasoningAlwaysOn: loadResp.reasoning_always_on ?? false,
reasoningEnabled: loadResp.supports_reasoning ?? false,
...reasoningCapsFromLoad(loadResp),
supportsPreserveThinking: loadResp.supports_preserve_thinking ?? false,
supportsTools: loadResp.supports_tools ?? false,
...resolveToolsEnabledOnLoad(loadResp.supports_tools ?? false),
kvCacheDtype: loadResp.cache_type_kv ?? null,
loadedKvCacheDtype: loadResp.cache_type_kv ?? null,
tensorParallel: loadResp.tensor_parallel ?? false,
loadedTensorParallel: loadResp.tensor_parallel ?? false,
defaultChatTemplate: loadResp.chat_template ?? null,
chatTemplateOverride: null,
loadedChatTemplateOverride: null,
...resolveLoadedSpeculativeSettings(loadResp),
loadedIsMultimodal: isMultimodalResponse(loadResp),
loadedIsDiffusion: loadResp.is_diffusion ?? false,
});
}
if (!(loadResp.is_lora ?? false)) {
recordLastLocalModelLoad({
id: candidate.id,
kind: candidate.kind,
ggufVariant: candidate.ggufVariant,
});
}
toast.success(candidate.successLabel, { id: toastId });
return true;
}
try {
const [ggufRepos, modelRepos] = await Promise.all([
listCachedGguf().catch(() => []),
listCachedModels().catch(() => []),
]);
if (lastLoaded) {
if (lastLoaded.kind === "gguf") {
const repo = findCachedRepo(ggufRepos, lastLoaded.id);
if (repo && lastLoaded.ggufVariant) {
try {
const variants = await listGgufVariants(repo.repo_id);
const variant = variants.variants.find(
(entry) =>
entry.downloaded &&
entry.quant?.toLowerCase() ===
lastLoaded.ggufVariant?.toLowerCase() &&
isAutoLoadableGgufVariant(entry),
);
if (variant) {
toast("Loading last used model…", {
id: toastId,
description: `${repo.repo_id} (${variant.quant})`,
duration: 5000,
});
if (
await loadAutoLoadCandidate({
id: repo.repo_id,
kind: "gguf",
ggufVariant: variant.quant,
maxSeqLength: 0,
successLabel: `Loaded ${repo.repo_id} (${variant.quant})`,
})
) {
return { loaded: true, blockedByTrustRemoteCode: false };
}
}
} catch {
hadNonTrustFailure = true;
skippedAutoLoadCandidates.add(
autoLoadCandidateKey("gguf", repo.repo_id, lastLoaded.ggufVariant),
);
}
}
} else {
const repo = findCachedRepo(modelRepos, lastLoaded.id);
if (repo) {
try {
toast("Loading last used model…", {
id: toastId,
description: repo.repo_id,
duration: 5000,
});
if (
await loadAutoLoadCandidate({
id: repo.repo_id,
kind: "model",
ggufVariant: null,
maxSeqLength: store.params.maxSeqLength,
successLabel: `Loaded ${repo.repo_id}`,
})
) {
return { loaded: true, blockedByTrustRemoteCode: false };
}
} catch {
hadNonTrustFailure = true;
skippedAutoLoadCandidates.add(
autoLoadCandidateKey("model", repo.repo_id),
);
}
}
}
toast("Loading a model…", {
id: toastId,
description: "Auto-selecting the smallest downloaded model.",
duration: 5000,
});
}
// GGUF first: smallest-total-size repo, then its smallest variant.
if (ggufRepos.length > 0) {
const sorted = [...ggufRepos].sort((a, b) => a.size_bytes - b.size_bytes);
@ -1408,82 +1658,23 @@ async function autoLoadSmallestModel(): Promise<{
if (downloaded.length > 0) {
const variant = downloaded[0];
if (
!(await canAutoLoad({
model_path: repo.repo_id,
max_seq_length: 0,
is_lora: false,
gguf_variant: variant.quant,
}))
skippedAutoLoadCandidates.has(
autoLoadCandidateKey("gguf", repo.repo_id, variant.quant),
)
) {
continue;
}
loadAttempts += 1;
const loadResp = await loadModel({
model_path: repo.repo_id,
hf_token: hfToken,
max_seq_length: 0,
load_in_4bit: true,
is_lora: false,
gguf_variant: variant.quant,
trust_remote_code: trustRemoteCode,
speculative_type: specSettings.speculativeType,
spec_draft_n_max: specSettings.specDraftNMax,
});
saveSpeculativeType(specSettings.speculativeType);
useChatRuntimeStore
.getState()
.setCheckpoint(repo.repo_id, variant.quant);
const store = useChatRuntimeStore.getState();
store.setModelRequiresTrustRemoteCode(
loadResp.requires_trust_remote_code ?? false,
);
store.setParams({
...store.params,
maxTokens: loadResp.context_length ?? 131072,
});
// Add to store so the selector shows the name.
const autoModel: ChatModelSummary = {
id: repo.repo_id,
name: loadResp.display_name ?? repo.repo_id,
isVision: loadResp.is_vision ?? false,
isLora: loadResp.is_lora ?? false,
isGguf: loadResp.is_gguf ?? false,
isAudio: loadResp.is_audio ?? false,
audioType: loadResp.audio_type ?? null,
hasAudioInput: loadResp.has_audio_input ?? false,
};
const existingModels = store.models;
if (!existingModels.some((m) => m.id === repo.repo_id)) {
store.setModels([...existingModels, autoModel]);
if (
await loadAutoLoadCandidate({
id: repo.repo_id,
kind: "gguf",
ggufVariant: variant.quant,
maxSeqLength: 0,
successLabel: `Loaded ${repo.repo_id} (${variant.quant})`,
})
) {
return { loaded: true, blockedByTrustRemoteCode: false };
}
useChatRuntimeStore.setState({
ggufContextLength: loadResp.context_length ?? 131072,
ggufMaxContextLength:
loadResp.max_context_length ??
loadResp.context_length ??
131072,
supportsReasoning: loadResp.supports_reasoning ?? false,
reasoningAlwaysOn: loadResp.reasoning_always_on ?? false,
reasoningEnabled: loadResp.supports_reasoning ?? false,
...reasoningCapsFromLoad(loadResp),
supportsPreserveThinking:
loadResp.supports_preserve_thinking ?? false,
supportsTools: loadResp.supports_tools ?? false,
...resolveToolsEnabledOnLoad(loadResp.supports_tools ?? false),
kvCacheDtype: loadResp.cache_type_kv ?? null,
loadedKvCacheDtype: loadResp.cache_type_kv ?? null,
tensorParallel: loadResp.tensor_parallel ?? false,
loadedTensorParallel: loadResp.tensor_parallel ?? false,
defaultChatTemplate: loadResp.chat_template ?? null,
chatTemplateOverride: null,
loadedChatTemplateOverride: null,
loadedIsMultimodal: isMultimodalResponse(loadResp),
...resolveLoadedSpeculativeSettings(loadResp),
});
toast.success(`Loaded ${repo.repo_id} (${variant.quant})`, {
id: toastId,
});
return { loaded: true, blockedByTrustRemoteCode: false };
}
} catch {
hadNonTrustFailure = true;
@ -1501,64 +1692,23 @@ async function autoLoadSmallestModel(): Promise<{
if (loadAttempts >= MAX_AUTO_LOAD_ATTEMPTS) break;
try {
if (
!(await canAutoLoad({
model_path: repo.repo_id,
max_seq_length: 4096,
is_lora: false,
gguf_variant: null,
}))
skippedAutoLoadCandidates.has(
autoLoadCandidateKey("model", repo.repo_id),
)
) {
continue;
}
loadAttempts += 1;
const sfLoadResp = await loadModel({
model_path: repo.repo_id,
hf_token: hfToken,
max_seq_length: 4096,
load_in_4bit: true,
is_lora: false,
gguf_variant: null,
trust_remote_code: trustRemoteCode,
speculative_type: specSettings.speculativeType,
spec_draft_n_max: specSettings.specDraftNMax,
});
saveSpeculativeType(specSettings.speculativeType);
useChatRuntimeStore.getState().setCheckpoint(repo.repo_id);
const store = useChatRuntimeStore.getState();
store.setModelRequiresTrustRemoteCode(
sfLoadResp.requires_trust_remote_code ?? false,
);
store.setParams({ ...store.params, maxTokens: 4096 });
useChatRuntimeStore.setState({
supportsReasoning: sfLoadResp.supports_reasoning ?? false,
reasoningAlwaysOn: sfLoadResp.reasoning_always_on ?? false,
reasoningEnabled: sfLoadResp.supports_reasoning ?? false,
...reasoningCapsFromLoad(sfLoadResp),
supportsPreserveThinking:
sfLoadResp.supports_preserve_thinking ?? false,
supportsTools: sfLoadResp.supports_tools ?? false,
// Parity with the GGUF branch above.
...resolveToolsEnabledOnLoad(sfLoadResp.supports_tools ?? false),
defaultChatTemplate: sfLoadResp.chat_template ?? null,
chatTemplateOverride: null,
loadedChatTemplateOverride: null,
...resolveLoadedSpeculativeSettings(sfLoadResp),
});
const sfModel: ChatModelSummary = {
id: repo.repo_id,
name: sfLoadResp.display_name ?? repo.repo_id,
isVision: sfLoadResp.is_vision ?? false,
isLora: sfLoadResp.is_lora ?? false,
isGguf: sfLoadResp.is_gguf ?? false,
};
if (!store.models.some((m) => m.id === repo.repo_id)) {
store.setModels([...store.models, sfModel]);
if (
await loadAutoLoadCandidate({
id: repo.repo_id,
kind: "model",
ggufVariant: null,
maxSeqLength: 4096,
successLabel: `Loaded ${repo.repo_id}`,
})
) {
return { loaded: true, blockedByTrustRemoteCode: false };
}
useChatRuntimeStore.setState({
loadedIsMultimodal: isMultimodalResponse(sfLoadResp),
});
toast.success(`Loaded ${repo.repo_id}`, { id: toastId });
return { loaded: true, blockedByTrustRemoteCode: false };
} catch {
hadNonTrustFailure = true;
continue;
@ -1650,6 +1800,11 @@ async function autoLoadSmallestModel(): Promise<{
loadedIsMultimodal: isMultimodalResponse(loadResp),
...resolveLoadedSpeculativeSettings(loadResp),
});
recordLastLocalModelLoad({
id: "unsloth/Qwen3.5-4B-MTP-GGUF",
kind: "gguf",
ggufVariant: "UD-Q4_K_XL",
});
toast.success("Loaded Qwen3.5-4B-MTP (UD-Q4_K_XL)", { id: toastId });
return { loaded: true, blockedByTrustRemoteCode: false };
} catch {

View file

@ -44,6 +44,7 @@ import {
mergeBackendRecommendedInference,
resolveLoadMaxSeqLength,
} from "../presets/preset-policy";
import { recordLastLocalModelLoad } from "../utils/last-local-model-load";
import {
isMultimodalResponse,
} from "../types/api";
@ -818,6 +819,23 @@ export function useChatModelRuntime() {
}
}
await refresh({ signal: abortCtrl.signal });
if (
!isLora &&
!(loadResponse.is_lora ?? false) &&
!nativePathToken &&
!isLocalModelPath(modelId) &&
!isExternalModelId(modelId)
) {
if (loadResponse.is_gguf || isGguf || ggufVariant) {
recordLastLocalModelLoad({
id: modelId,
kind: "gguf",
ggufVariant: ggufVariant ?? null,
});
} else {
recordLastLocalModelLoad({ id: modelId, kind: "model" });
}
}
// A successful load owns the shared (pick-unscoped) settings fields,
// so any surviving stage is stale: the just-loaded pick itself, or a
// pick queued for a different model mid-load whose knobs this load

View file

@ -36,6 +36,7 @@ export { ChatSearchDialog } from "./components/chat-search-dialog";
export { setTrainingCompareHandoff } from "./lib/training-compare-handoff";
export type { ProjectRecord } from "./types";
export { clearAllChats, countAllChats } from "./utils/clear-all-chats";
export { listStoredChatThreads } from "./utils/chat-history-storage";
export { ArtifactCard } from "./artifacts/artifact-card";
export {
useChatArtifactsStore,

View file

@ -3,16 +3,19 @@
import { getInferenceStatus } from "../api/chat-api";
import { mergeBackendRecommendedInference } from "../presets/preset-policy";
import { clampReasoningEffortToLevels } from "../provider-capabilities";
import {
CHAT_REASONING_ENABLED_KEY,
loadOptionalBool,
type ReasoningEffort,
type ReasoningStyle,
loadOptionalBool,
resolveToolsEnabledOnLoad,
useChatRuntimeStore,
} from "../stores/chat-runtime-store";
import { isMultimodalResponse, type InferenceStatusResponse } from "../types/api";
import { clampReasoningEffortToLevels } from "../provider-capabilities";
import {
type InferenceStatusResponse,
isMultimodalResponse,
} from "../types/api";
import type { ChatModelSummary } from "../types/runtime";
type LocalReasoningEffort = Extract<ReasoningEffort, "low" | "medium" | "high">;
@ -31,7 +34,10 @@ export function normalizeSpeculativeType(
return "ngram";
}
if (s === "mtp+ngram") return "mtp+ngram";
const parts = s.split(",").map((p) => p.trim()).filter(Boolean);
const parts = s
.split(",")
.map((p) => p.trim())
.filter(Boolean);
const hasMtp = parts.some((p) => p === "mtp" || p === "draft-mtp");
const hasNgram = parts.some(
(p) => p === "ngram" || p === "ngram-mod" || p === "ngram-simple",
@ -197,6 +203,12 @@ export function applyActiveModelStatusToStore(
ggufContextLength: currentGgufContextLength,
ggufMaxContextLength,
ggufNativeContextLength,
// A non-GGUF status must also drop a stale native-path token: without this the
// isGguf OR (activeGgufVariant || activeNativePathToken || ggufContextLength)
// stays true after switching from a native GGUF to a transformers model, so a
// Codex-only detection would auto-select for a model its preflight rejects. A real
// GGUF load reports is_gguf: true, so its token is preserved (the load path owns it).
...(status.is_gguf ? {} : { activeNativePathToken: null }),
modelRequiresTrustRemoteCode: status.requires_trust_remote_code ?? false,
defaultChatTemplate: nextDefaultChatTemplate,
loadedIsMultimodal: isMultimodalResponse(status),
@ -245,7 +257,7 @@ export function applyActiveModelStatusToStore(
const mid = checkpointId.toLowerCase();
if (mid.includes("qwen3.5") || mid.includes("qwen3.6")) {
const sizeMatch = mid.match(/(\d+\.?\d*)\s*b/);
if (sizeMatch && parseFloat(sizeMatch[1]) < 9) {
if (sizeMatch && Number.parseFloat(sizeMatch[1]) < 9) {
reasoningDefault = false;
}
}
@ -281,8 +293,7 @@ export async function tryAdoptServerActiveModel(): Promise<boolean> {
}
// Re-check after the await: keep a checkpoint the user picked meanwhile.
const previousCheckpoint =
useChatRuntimeStore.getState().params.checkpoint;
const previousCheckpoint = useChatRuntimeStore.getState().params.checkpoint;
if (previousCheckpoint) {
return true;
}

View file

@ -0,0 +1,86 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export type LastLocalModelKind = "gguf" | "model";
export type LastLocalModelLoad = {
id: string;
kind: LastLocalModelKind;
ggufVariant: string | null;
loadedAt: number;
};
const STORAGE_KEY = "unsloth.last-local-model-load.v1";
function storage(): Storage | null {
try {
return typeof localStorage === "undefined" ? null : localStorage;
} catch {
return null;
}
}
function isLastLocalModelKind(value: unknown): value is LastLocalModelKind {
return value === "gguf" || value === "model";
}
export function readLastLocalModelLoad(): LastLocalModelLoad | null {
try {
const raw = storage()?.getItem(STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as Partial<LastLocalModelLoad>;
if (
typeof parsed.id !== "string" ||
!parsed.id.trim() ||
!isLastLocalModelKind(parsed.kind) ||
typeof parsed.loadedAt !== "number"
) {
return null;
}
if (
parsed.kind === "gguf" &&
(typeof parsed.ggufVariant !== "string" || !parsed.ggufVariant.trim())
) {
return null;
}
return {
id: parsed.id,
kind: parsed.kind,
ggufVariant:
typeof parsed.ggufVariant === "string" ? parsed.ggufVariant : null,
loadedAt: parsed.loadedAt,
};
} catch {
return null;
}
}
export function recordLastLocalModelLoad(input: {
id: string;
kind: LastLocalModelKind;
ggufVariant?: string | null;
}): void {
const id = input.id.trim();
if (!id) {
return;
}
const ggufVariant = input.ggufVariant?.trim() || null;
if (input.kind === "gguf" && !ggufVariant) {
return;
}
try {
storage()?.setItem(
STORAGE_KEY,
JSON.stringify({
id,
kind: input.kind,
ggufVariant: input.kind === "gguf" ? ggufVariant : null,
loadedAt: Date.now(),
} satisfies LastLocalModelLoad),
);
} catch {
// Ignore disabled storage / quota errors; auto-load falls back to size order.
}
}

View file

@ -494,3 +494,13 @@ export async function removeUnstructuredFile(
throw new Error("Failed to remove file");
}
}
export async function removeUnstructuredBlock(blockId: string): Promise<void> {
const res = await authFetch(
`${DATA_DESIGNER_API_BASE}/seed/unstructured-block/${encodeURIComponent(blockId)}`,
{ method: "DELETE" },
);
if (!res.ok && res.status !== 404) {
throw new Error("Failed to remove uploaded files");
}
}

View file

@ -53,6 +53,11 @@ import {
inspectSeedDataset,
inspectSeedUpload,
} from "../../api";
import { useRecipeStudioStore } from "../../stores/recipe-studio";
import {
makeUnstructuredUploadUid,
resolveUnstructuredUploadBlockId,
} from "../../utils/config-factories";
import { resolveImagePreview } from "../../utils/image-preview";
import type {
GithubItemType,
@ -597,6 +602,41 @@ export function SeedDialog({
const mode = config.seed_source_type ?? "hf";
const previewEmpty = getPreviewEmptyStateCopy(mode);
const queueUploadCleanup = useRecipeStudioStore(
(state) => state.queueUploadCleanup,
);
// config.id collides across recipes (ids reset to n1 on import); use a
// stable per-block uid instead. Generate one synchronously so the first
// rendered drop zone cannot upload under a legacy node id.
const uploadUid = config.unstructured_upload_uid?.trim() ?? "";
const unstructuredFileCount = config.unstructured_file_ids?.length ?? 0;
const generatedUploadUidRef = useRef<string | null>(null);
if (
mode === "unstructured" &&
!uploadUid &&
unstructuredFileCount === 0 &&
generatedUploadUidRef.current === null
) {
generatedUploadUidRef.current = makeUnstructuredUploadUid();
}
const uploadBlockId = resolveUnstructuredUploadBlockId({
configId: config.id,
uploadUid,
generatedUploadUid: generatedUploadUidRef.current,
unstructuredFileCount,
});
useEffect(() => {
if (mode !== "unstructured") return;
if (uploadUid) return;
if (unstructuredFileCount > 0) return;
const nextUid =
generatedUploadUidRef.current ?? makeUnstructuredUploadUid();
generatedUploadUidRef.current = nextUid;
onUpdate({ unstructured_upload_uid: nextUid });
}, [mode, uploadUid, unstructuredFileCount, onUpdate]);
const prevModeRef = useRef(mode);
useEffect(() => {
const prevMode = prevModeRef.current;
@ -720,6 +760,11 @@ export function SeedDialog({
subset: config.hf_subset?.trim() || undefined,
preview_size: 10,
});
// Queue the block's upload directory for deletion after the next
// save; only uid-namespaced directories qualify (single owner).
if (uploadUid && unstructuredFileCount > 0) {
queueUploadCleanup(uploadUid);
}
onUpdate({
hf_path: response.resolved_path,
seed_columns: response.columns,
@ -730,6 +775,7 @@ export function SeedDialog({
hf_split: response.split ?? "",
hf_subset: response.subset ?? "",
local_file_name: "",
unstructured_upload_uid: "",
unstructured_file_ids: [],
unstructured_file_names: [],
unstructured_file_sizes: [],
@ -754,6 +800,11 @@ export function SeedDialog({
content_base64: payload,
preview_size: 10,
});
// Queue the block's upload directory for deletion after the next
// save; only uid-namespaced directories qualify (single owner).
if (uploadUid && unstructuredFileCount > 0) {
queueUploadCleanup(uploadUid);
}
onUpdate({
hf_path: response.resolved_path,
seed_columns: response.columns,
@ -765,6 +816,7 @@ export function SeedDialog({
hf_subset: "",
hf_split: "",
local_file_name: localFile.name,
unstructured_upload_uid: "",
unstructured_file_ids: [],
unstructured_file_names: [],
unstructured_file_sizes: [],
@ -789,7 +841,7 @@ export function SeedDialog({
const { chunkSize, chunkOverlap } = resolveChunking(config);
const response = await inspectSeedUpload({
block_id: config.id,
block_id: uploadBlockId,
file_ids: fileIds,
file_names: fileNames,
preview_size: 10,
@ -827,7 +879,18 @@ export function SeedDialog({
setIsInspecting(false);
}
},
[config, getCurrentLoadKey, localFile, mode, onUpdate, unstructuredFiles],
[
config,
getCurrentLoadKey,
localFile,
mode,
onUpdate,
queueUploadCleanup,
unstructuredFiles,
unstructuredFileCount,
uploadBlockId,
uploadUid,
],
);
useEffect(() => {
@ -997,7 +1060,7 @@ export function SeedDialog({
{mode === "unstructured" && (
<UnstructuredDropZone
blockId={config.id}
blockId={uploadBlockId}
files={unstructuredFiles}
onFilesChange={handleUnstructuredFilesChange}
disabled={isInspecting}

View file

@ -54,11 +54,17 @@ export function UnstructuredDropZone({
}: UnstructuredDropZoneProps) {
const inputRef = useRef<HTMLInputElement>(null);
const filesRef = useRef(files);
const blockIdRef = useRef(blockId);
const mountedRef = useRef(true);
const [isDragOver, setIsDragOver] = useState(false);
useEffect(() => {
filesRef.current = files;
}, [files]);
blockIdRef.current = blockId;
}, [files, blockId]);
useEffect(() => () => {
mountedRef.current = false;
}, []);
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
@ -134,15 +140,32 @@ export function UnstructuredDropZone({
if (entry.status === "uploading" && entry.abortController) {
entry.abortController.abort();
}
if (
const needsServerRemove =
entry.id &&
entry.status === "ok" &&
!deletedIdsRef.current.has(entry.id)
) {
deletedIdsRef.current.add(entry.id);
void removeUnstructuredFile(blockId, entry.id).catch(() => {});
}
!deletedIdsRef.current.has(entry.id);
onFilesChange((prev) => prev.filter((_, i) => i !== index));
if (!needsServerRemove) return;
deletedIdsRef.current.add(entry.id);
removeUnstructuredFile(blockId, entry.id).catch(() => {
// Skip if the drop zone unmounted or its block changed: the id no
// longer belongs here and restoring would leak it into another block.
if (!mountedRef.current || blockIdRef.current !== blockId) return;
// Still exists server-side (counts toward quota); restore it at its
// original position.
deletedIdsRef.current.delete(entry.id);
onFilesChange((prev) => {
const next = [...prev];
next.splice(Math.min(index, next.length), 0, {
id: entry.id,
name: entry.name,
size: entry.size,
status: "ok",
error: "Remove failed — try again",
});
return next;
});
});
},
[blockId, onFilesChange],
);

View file

@ -4,11 +4,13 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { toastError, toastSuccess } from "@/shared/toast";
import { normalizeNonEmptyName } from "@/utils";
import { removeUnstructuredBlock } from "../api";
import {
buildSignature,
copyTextToClipboard,
formatSavedLabel,
} from "../executions/execution-helpers";
import { useRecipeStudioStore } from "../stores/recipe-studio";
import { importRecipePayload, type RecipeSnapshot } from "../utils/import";
import type { RecipePayloadResult } from "../utils/payload/types";
@ -72,7 +74,10 @@ function stripApiKeys(value: unknown): unknown {
!Array.isArray(output.env)
) {
output.env = Object.fromEntries(
Object.keys(output.env as Record<string, unknown>).map((envKey) => [envKey, ""]),
Object.keys(output.env as Record<string, unknown>).map((envKey) => [
envKey,
"",
]),
);
}
return output;
@ -82,10 +87,7 @@ function inferHfRepoIdFromPath(pathValue: unknown): string {
if (typeof pathValue !== "string") {
return "";
}
const parts = pathValue
.trim()
.split("/")
.filter(Boolean);
const parts = pathValue.trim().split("/").filter(Boolean);
if (parts.length >= 3 && parts[0] === "datasets") {
return `${parts[1]}/${parts[2]}`;
}
@ -126,8 +128,7 @@ function sanitizeSeedForShare(payload: unknown): unknown {
typeof ui?.seed_source_type === "string" ? ui.seed_source_type : null;
const sourceType =
typeof source?.seed_type === "string" ? source.seed_type : null;
const shouldResetHfState =
sourceType === "hf" || uiSourceType === "hf";
const shouldResetHfState = sourceType === "hf" || uiSourceType === "hf";
const shouldResetLocalState =
sourceType === "local" ||
sourceType === "unstructured" ||
@ -144,6 +145,7 @@ function sanitizeSeedForShare(payload: unknown): unknown {
ui.seed_drop_columns = [];
ui.seed_preview_rows = [];
ui.local_file_name = "";
ui.unstructured_upload_uid = "";
ui.unstructured_file_ids = [];
ui.unstructured_file_names = [];
ui.unstructured_file_sizes = [];
@ -165,6 +167,7 @@ function sanitizeSeedForShare(payload: unknown): unknown {
ui.seed_drop_columns = [];
ui.seed_preview_rows = [];
ui.local_file_name = "";
ui.unstructured_upload_uid = "";
ui.unstructured_file_ids = [];
ui.unstructured_file_names = [];
ui.unstructured_file_sizes = [];
@ -174,6 +177,43 @@ function sanitizeSeedForShare(payload: unknown): unknown {
return root;
}
// Delete queued upload directories once a save stops referencing them, so a
// reload before autosave can never leave the saved recipe pointing at
// already-deleted files. Skips any uid the just-saved payload still uses.
function drainQueuedUploadCleanups(
savedPayload: RecipePayloadResult["payload"],
): void {
const pending = useRecipeStudioStore.getState().pendingUploadCleanups;
if (pending.length === 0) {
return;
}
const ui =
savedPayload && typeof savedPayload === "object"
? (savedPayload as { ui?: Record<string, unknown> }).ui
: undefined;
const savedUid =
ui && typeof ui.unstructured_upload_uid === "string"
? ui.unstructured_upload_uid
: "";
const ready = pending.filter((uid) => uid !== savedUid);
if (ready.length === 0) {
return;
}
for (const uid of ready) {
void removeUnstructuredBlock(uid)
.then(() => {
useRecipeStudioStore.setState((state) => ({
pendingUploadCleanups: state.pendingUploadCleanups.filter(
(pendingUid) => pendingUid !== uid,
),
}));
})
.catch((error) => {
console.warn("Failed to clean up uploaded documents:", error);
});
}
}
export function useRecipePersistence({
recipeId,
initialRecipeName,
@ -202,8 +242,10 @@ export function useRecipePersistence({
() => buildSignature(normalizedWorkflowName, currentPayload),
[currentPayload, normalizedWorkflowName],
);
const isDirty = savedSignature.length > 0 && currentSignature !== savedSignature;
const saveTone: SaveTone = !isDirty && Boolean(lastSavedAt) ? "success" : "error";
const isDirty =
savedSignature.length > 0 && currentSignature !== savedSignature;
const saveTone: SaveTone =
!isDirty && Boolean(lastSavedAt) ? "success" : "error";
const savedAtLabel = formatSavedLabel(lastSavedAt);
useEffect(() => {
@ -214,7 +256,9 @@ export function useRecipePersistence({
setLastSavedAt(initialSavedAt);
setCopied(false);
const parsed = importRecipePayload(JSON.stringify(initialPayload));
const parsed = importRecipePayload(JSON.stringify(initialPayload), {
preserveUnstructuredUploads: true,
});
if (parsed.snapshot) {
loadRecipe(parsed.snapshot);
} else {
@ -252,6 +296,7 @@ export function useRecipePersistence({
});
setLastSavedAt(result.updatedAt);
setSavedSignature(buildSignature(nextName, currentPayload));
drainQueuedUploadCleanups(currentPayload);
} catch (error) {
console.error("Save recipe failed:", error);
toastError("Save failed", "Could not save recipe.");
@ -270,11 +315,28 @@ export function useRecipePersistence({
return () => window.clearTimeout(timeoutId);
}, [isDirty, persistRecipe, saveLoading]);
// Drain queued cleanups even when autosave is skipped: a net-zero edit (add
// then remove an unstructured seed before the 800ms debounce) keeps isDirty
// false, so the autosave effect never drains and the queued uid leaks its
// upload dir. Not-dirty means currentPayload equals the saved recipe, and
// drain skips the uid it still references, so only dirs no saved recipe
// points at are deleted (keeps the save-first invariant).
useEffect(() => {
if (!initialRecipeReady || isDirty || saveLoading) {
return;
}
drainQueuedUploadCleanups(currentPayload);
}, [currentPayload, initialRecipeReady, isDirty, saveLoading]);
const copyRecipe = useCallback(async (): Promise<void> => {
setCopied(false);
try {
const safePayload = sanitizeSeedForShare(stripApiKeys(payloadResult.payload));
const ok = await copyTextToClipboard(JSON.stringify(safePayload, null, 2));
const safePayload = sanitizeSeedForShare(
stripApiKeys(payloadResult.payload),
);
const ok = await copyTextToClipboard(
JSON.stringify(safePayload, null, 2),
);
if (!ok) {
throw new Error("Clipboard not available.");
}

View file

@ -36,6 +36,7 @@ import {
} from "../utils/handles";
import type { RecipeSnapshot } from "../utils/import";
import { getLayoutedElements } from "../utils/layout";
import { makeUnstructuredUploadUid } from "../utils/config-factories";
import {
centerModelInfraNodes,
optimizeModelInfraEdgeHandles,
@ -76,6 +77,12 @@ type RecipeStudioState = {
nextId: number;
nextY: number;
fitViewTick: number;
// Upload-uid directories whose owning block dropped them; server-side
// deletion is deferred until a save no longer references them, so a
// reload before autosave cannot leave a saved recipe pointing at
// deleted files.
pendingUploadCleanups: string[];
queueUploadCleanup: (uid: string) => void;
setSheetOpen: (open: boolean) => void;
setSheetView: (view: SheetView) => void;
setProcessors: (processors: RecipeProcessorConfig[]) => void;
@ -137,6 +144,7 @@ const INITIAL_STATE = {
nextId: 3,
nextY: 280,
fitViewTick: 0,
pendingUploadCleanups: [],
} satisfies Pick<
RecipeStudioState,
| "nodes"
@ -154,6 +162,7 @@ const INITIAL_STATE = {
| "nextId"
| "nextY"
| "fitViewTick"
| "pendingUploadCleanups"
>;
function buildAddedNodeState(
@ -269,6 +278,20 @@ function isModelSemanticEdge(
);
}
// Upload uid of a seed block whose server-side directory becomes orphaned
// when the block drops it. Only uid directories qualify (single owner);
// legacy node-id directories can be shared by other recipes.
function seedUploadCleanupUid(config: NodeConfig | undefined): string | null {
if (!config || config.kind !== "seed") {
return null;
}
const uid = config.unstructured_upload_uid?.trim();
if (!uid || !config.unstructured_file_ids?.length) {
return null;
}
return uid;
}
export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
...INITIAL_STATE,
setSheetOpen: (open) => set({ sheetOpen: open }),
@ -278,6 +301,12 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
setDialogOpen: (open) => set({ dialogOpen: open }),
setExecutionLocked: (locked) => set({ executionLocked: locked }),
resetRecipe: () => set(INITIAL_STATE),
queueUploadCleanup: (uid) =>
set((state) =>
state.pendingUploadCleanups.includes(uid)
? state
: { pendingUploadCleanups: [...state.pendingUploadCleanups, uid] },
),
selectConfig: (id) => set({ activeConfigId: id, dialogOpen: false }),
openConfig: (id) => set({ activeConfigId: id, dialogOpen: true }),
setLayoutDirection: (direction) =>
@ -383,7 +412,18 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
}
return buildAddedNodeState(state, "sampler", type, position, openDialog);
}),
addSeedNode: (type, position, openDialog = true) =>
addSeedNode: (type, position, openDialog = true) => {
const current = get();
if (!current.executionLocked) {
// The reset below clears the block's upload uid and file list; queue
// its server-side directory for deletion after the next save.
const uid = seedUploadCleanupUid(
Object.values(current.configs).find((config) => config.kind === "seed"),
);
if (uid) {
current.queueUploadCleanup(uid);
}
}
set((state) => {
if (state.executionLocked) {
return state;
@ -413,6 +453,8 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
hf_token: "",
hf_endpoint: "https://huggingface.co",
local_file_name: "",
unstructured_upload_uid:
nextSourceType === "unstructured" ? makeUnstructuredUploadUid() : "",
unstructured_file_ids: [],
unstructured_file_names: [],
unstructured_file_sizes: [],
@ -446,7 +488,8 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
activeConfigId: existing.id,
dialogOpen: openDialog,
};
}),
});
},
addLlmNode: (type, position, openDialog = true) =>
set((state) => {
if (state.executionLocked) {
@ -699,6 +742,9 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
dialogOpen: false,
sheetView: "root",
fitViewTick: state.fitViewTick + 1,
// Queued cleanups belong to the previous recipe; draining them after
// a save of this one could delete files its saved payload still uses.
pendingUploadCleanups: [],
})),
setAuxNodePosition: (id, position) =>
set((state) => {
@ -786,6 +832,17 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
set(applyUpdate);
},
onNodesChange: (changes) => {
const current = get();
if (!current.executionLocked) {
for (const change of changes) {
if (change.type === "remove") {
const uid = seedUploadCleanupUid(current.configs[change.id]);
if (uid) {
current.queueUploadCleanup(uid);
}
}
}
}
const applyNodesChange = (state: RecipeStudioState) => {
if (state.executionLocked) {
return state;

View file

@ -340,6 +340,8 @@ export type SeedConfig = {
hf_token?: string;
hf_endpoint?: string;
local_file_name?: string;
// ui-only: stable per-block id for uploads, since node ids collide across imports
unstructured_upload_uid?: string;
unstructured_file_ids?: string[];
unstructured_file_names?: string[];
unstructured_file_sizes?: number[];

View file

@ -20,6 +20,46 @@ import type {
} from "../types";
import { nextName } from "./naming";
export function makeUnstructuredUploadUid(): string {
if (typeof globalThis.crypto?.randomUUID === "function") {
return globalThis.crypto.randomUUID().replace(/-/g, "").toLowerCase();
}
if (typeof globalThis.crypto?.getRandomValues === "function") {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
"",
);
}
let uid = "";
while (uid.length < 32) {
uid += Math.floor(Math.random() * 0x100000000)
.toString(16)
.padStart(8, "0");
}
return uid.slice(0, 32);
}
export function resolveUnstructuredUploadBlockId({
configId,
uploadUid,
generatedUploadUid,
unstructuredFileCount,
}: {
configId: string;
uploadUid: string;
generatedUploadUid: string | null;
unstructuredFileCount: number;
}): string {
if (uploadUid) {
return uploadUid;
}
if (generatedUploadUid) {
return generatedUploadUid;
}
return unstructuredFileCount > 0 ? configId : "";
}
export function makeSamplerConfig(
id: string,
samplerType: SamplerType,
@ -368,6 +408,9 @@ export function makeSeedConfig(
hf_token: "",
hf_endpoint: "https://huggingface.co",
local_file_name: "",
...(seedSourceType === "unstructured"
? { unstructured_upload_uid: makeUnstructuredUploadUid() }
: {}),
unstructured_file_ids: [],
unstructured_file_names: [],
unstructured_file_sizes: [],

View file

@ -16,11 +16,7 @@ import type {
} from "../../types";
import { buildEdges } from "./edges";
import { isRecord, parseJson, readString } from "./helpers";
import {
parseColumn,
parseModelConfig,
parseModelProvider,
} from "./parsers";
import { parseColumn, parseModelConfig, parseModelProvider } from "./parsers";
import { parseSeedConfig } from "./parsers/seed-config-parser";
import { buildNodes, parseUi } from "./ui";
import type { ImportResult } from "./types";
@ -43,6 +39,7 @@ type UiInput = {
seed_drop_columns?: unknown;
seed_preview_rows?: unknown;
local_file_name?: unknown;
unstructured_upload_uid?: unknown;
unstructured_file_ids?: unknown;
unstructured_file_names?: unknown;
unstructured_file_sizes?: unknown;
@ -51,6 +48,10 @@ type UiInput = {
advanced_open_by_node?: unknown;
};
type ImportRecipePayloadOptions = {
preserveUnstructuredUploads?: boolean;
};
type UiMarkdownNoteNode = {
name: string;
markdown: string;
@ -90,7 +91,7 @@ function parseProcessors(input: unknown): RecipeProcessorConfig[] {
? templateRaw
: isRecord(templateRaw)
? JSON.stringify(templateRaw, null, 2)
: "{\n \"text\": \"{{ column_name }}\"\n}";
: '{\n "text": "{{ column_name }}"\n}';
processors.push({
id: `p${index + 1}`,
// biome-ignore lint/style/useNamingConvention: api schema
@ -135,9 +136,7 @@ function parseSeedDropColumns(input: unknown): string[] {
return Array.from(values);
}
function parseMcpProviders(
input: unknown,
): Map<string, LlmMcpProviderConfig> {
function parseMcpProviders(input: unknown): Map<string, LlmMcpProviderConfig> {
const providers = new Map<string, LlmMcpProviderConfig>();
if (!Array.isArray(input)) {
return providers;
@ -156,13 +155,12 @@ function parseMcpProviders(
const args = Array.isArray(item.args)
? item.args.map((value) => String(value))
: [];
const envPairs =
isRecord(item.env)
? Object.entries(item.env).map(([key, value]) => ({
key: String(key),
value: String(value),
}))
: [];
const envPairs = isRecord(item.env)
? Object.entries(item.env).map(([key, value]) => ({
key: String(key),
value: String(value),
}))
: [];
providers.set(name, {
id: `mcp-${index + 1}`,
name,
@ -209,7 +207,8 @@ function parseToolConfigs(input: unknown): Map<string, LlmToolConfig> {
allow_tools: allowTools,
// biome-ignore lint/style/useNamingConvention: api schema
max_tool_call_turns:
item.max_tool_call_turns === null || item.max_tool_call_turns === undefined
item.max_tool_call_turns === null ||
item.max_tool_call_turns === undefined
? "5"
: String(item.max_tool_call_turns),
// biome-ignore lint/style/useNamingConvention: api schema
@ -257,7 +256,9 @@ function parseUiMarkdownNoteNodes(input: unknown): UiMarkdownNoteNode[] {
return noteNodes;
}
function parseUiToolProfileNodes(input: unknown): Map<string, Record<string, string[]>> {
function parseUiToolProfileNodes(
input: unknown,
): Map<string, Record<string, string[]>> {
const toolProfiles = new Map<string, Record<string, string[]>>();
if (!Array.isArray(input)) {
return toolProfiles;
@ -312,9 +313,15 @@ function parseAdvancedOpenByNode(input: unknown): Record<string, boolean> {
return out;
}
type AdvancedOpenConfig = LlmConfig | SamplerConfig | SeedConfig | ValidatorConfig;
type AdvancedOpenConfig =
| LlmConfig
| SamplerConfig
| SeedConfig
| ValidatorConfig;
function isAdvancedOpenConfig(config: NodeConfig): config is AdvancedOpenConfig {
function isAdvancedOpenConfig(
config: NodeConfig,
): config is AdvancedOpenConfig {
return (
config.kind === "llm" ||
config.kind === "sampler" ||
@ -350,7 +357,8 @@ function buildToolProfileConfig(
.map((providerName) => mcpProvidersByName.get(providerName))
.flatMap((provider) => (provider ? [cloneMcpProvider(provider)] : [])),
// biome-ignore lint/style/useNamingConvention: ui schema
fetched_tools_by_provider: fetchedToolsByProfileName.get(canonical.tool_alias) ?? {},
fetched_tools_by_provider:
fetchedToolsByProfileName.get(canonical.tool_alias) ?? {},
// biome-ignore lint/style/useNamingConvention: api schema
allow_tools: [...(canonical.allow_tools ?? [])],
// biome-ignore lint/style/useNamingConvention: api schema
@ -360,7 +368,10 @@ function buildToolProfileConfig(
};
}
export function importRecipePayload(input: string): ImportResult {
export function importRecipePayload(
input: string,
options: ImportRecipePayloadOptions = {},
): ImportResult {
const parsed = parseJson(input);
if (!parsed.data || !isRecord(parsed.data)) {
return {
@ -369,9 +380,9 @@ export function importRecipePayload(input: string): ImportResult {
};
}
const recipe = (isRecord(parsed.data.recipe)
? parsed.data.recipe
: parsed.data) as RecipeInput;
const recipe = (
isRecord(parsed.data.recipe) ? parsed.data.recipe : parsed.data
) as RecipeInput;
const ui = isRecord(parsed.data.ui) ? (parsed.data.ui as UiInput) : null;
if (!Array.isArray(recipe.columns)) {
@ -410,21 +421,36 @@ export function importRecipePayload(input: string): ImportResult {
.map((row) => ({ ...row }))
: undefined;
const uiLocalFileName = readString(ui?.local_file_name) ?? undefined;
// Preserve file IDs/names from saved recipes (cleared at share time by sanitizeSeedForShare)
const uiUnstructuredFileIds: string[] = Array.isArray(ui?.unstructured_file_ids)
? (ui.unstructured_file_ids as string[]).filter((v): v is string => typeof v === "string")
: [];
const uiUnstructuredFileNames: string[] = Array.isArray(ui?.unstructured_file_names)
? (ui.unstructured_file_names as string[]).filter((v): v is string => typeof v === "string")
: [];
const uiUnstructuredFileSizes: number[] = Array.isArray(ui?.unstructured_file_sizes)
? (ui.unstructured_file_sizes as number[]).filter((v): v is number => typeof v === "number")
: [];
const preserveUnstructuredUploads =
options.preserveUnstructuredUploads === true;
const uiUnstructuredUploadUid = preserveUnstructuredUploads
? (readString(ui?.unstructured_upload_uid) ?? undefined)
: undefined;
const uiUnstructuredFileIds: string[] =
preserveUnstructuredUploads && Array.isArray(ui?.unstructured_file_ids)
? (ui.unstructured_file_ids as string[]).filter(
(v): v is string => typeof v === "string",
)
: [];
const uiUnstructuredFileNames: string[] =
preserveUnstructuredUploads && Array.isArray(ui?.unstructured_file_names)
? (ui.unstructured_file_names as string[]).filter(
(v): v is string => typeof v === "string",
)
: [];
const uiUnstructuredFileSizes: number[] =
preserveUnstructuredUploads && Array.isArray(ui?.unstructured_file_sizes)
? (ui.unstructured_file_sizes as number[]).filter(
(v): v is number => typeof v === "number",
)
: [];
const uiUnstructuredChunkSize = readStringNumber(ui?.unstructured_chunk_size);
const uiUnstructuredChunkOverlap = readStringNumber(
ui?.unstructured_chunk_overlap,
);
const uiAdvancedOpenByNode = parseAdvancedOpenByNode(ui?.advanced_open_by_node);
const uiAdvancedOpenByNode = parseAdvancedOpenByNode(
ui?.advanced_open_by_node,
);
const uiMarkdownNotes = parseUiMarkdownNoteNodes(ui?.nodes);
const uiToolProfilesByName = parseUiToolProfileNodes(ui?.nodes);
@ -459,11 +485,13 @@ export function importRecipePayload(input: string): ImportResult {
: payloadSeedDropColumns,
seed_preview_rows: uiSeedPreviewRows,
local_file_name: uiLocalFileName,
unstructuredUploadUid: uiUnstructuredUploadUid,
unstructuredFileIds: uiUnstructuredFileIds,
unstructuredFileNames: uiUnstructuredFileNames,
unstructuredFileSizes: uiUnstructuredFileSizes,
unstructured_chunk_size: uiUnstructuredChunkSize,
unstructured_chunk_overlap: uiUnstructuredChunkOverlap,
preserveUnstructuredUploads,
});
if (seedConfig) {
applyAdvancedOpen(seedConfig, uiAdvancedOpenByNode);
@ -567,12 +595,7 @@ export function importRecipePayload(input: string): ImportResult {
const { layouts, auxNodes, edges: uiEdges, layoutDirection } = parseUi(ui);
const resolvedLayoutDirection = layoutDirection ?? "LR";
const nodes = buildNodes(configs, layouts);
const edges = buildEdges(
configs,
nameToId,
uiEdges,
resolvedLayoutDirection,
);
const edges = buildEdges(configs, nameToId, uiEdges, resolvedLayoutDirection);
const auxNodePositions = Object.fromEntries(
auxNodes.flatMap((item) => {
const llmId = nameToId.get(item.llm);
@ -583,10 +606,7 @@ export function importRecipePayload(input: string): ImportResult {
}),
);
const maxY = nodes.reduce(
(acc, node) => Math.max(acc, node.position.y),
0,
);
const maxY = nodes.reduce((acc, node) => Math.max(acc, node.position.y), 0);
return {
errors: [],

View file

@ -197,17 +197,26 @@ export function parseSeedConfig(
seed_drop_columns?: string[];
seed_preview_rows?: Record<string, unknown>[];
local_file_name?: string;
unstructuredUploadUid?: string;
unstructuredFileIds?: string[];
unstructuredFileNames?: string[];
unstructuredFileSizes?: number[];
unstructured_chunk_size?: string;
unstructured_chunk_overlap?: string;
preserveUnstructuredUploads?: boolean;
},
): SeedConfig | null {
if (!seedConfigRaw) {
return null;
}
const parsed = parseSeedSettings(seedConfigRaw);
const parsed = { ...parseSeedSettings(seedConfigRaw) };
if (
parsed.seed_source_type === "unstructured" &&
options?.preserveUnstructuredUploads !== true
) {
parsed.hf_path = "";
parsed.resolved_paths = [];
}
let sourceType: SeedSourceType = "hf";
if (parsed.seed_source_type === "hf") {
sourceType = "hf";
@ -230,6 +239,9 @@ export function parseSeedConfig(
...(options?.local_file_name !== undefined
? { local_file_name: options.local_file_name }
: {}),
...(options?.unstructuredUploadUid
? { unstructured_upload_uid: options.unstructuredUploadUid }
: {}),
...(options?.unstructuredFileIds !== undefined
? { unstructured_file_ids: options.unstructuredFileIds }
: {}),

View file

@ -440,6 +440,9 @@ export function buildRecipePayload(
unstructured_file_names: firstSeed.unstructured_file_names,
unstructured_file_sizes: firstSeed.unstructured_file_sizes,
}),
...(firstSeed?.unstructured_upload_uid?.trim() && {
unstructured_upload_uid: firstSeed.unstructured_upload_uid,
}),
...(firstSeed &&
firstSeed.unstructured_chunk_size !== undefined && {
unstructured_chunk_size: firstSeed.unstructured_chunk_size,

View file

@ -71,6 +71,8 @@ export type RecipePayload = {
seed_preview_rows?: Record<string, unknown>[];
local_file_name?: string;
// biome-ignore lint/style/useNamingConvention: api schema
unstructured_upload_uid?: string;
// biome-ignore lint/style/useNamingConvention: api schema
unstructured_file_ids?: string[];
// biome-ignore lint/style/useNamingConvention: api schema
unstructured_file_names?: string[];

View file

@ -0,0 +1,45 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { authFetch } from "@/features/auth";
import { readFastApiError } from "@/lib/format-fastapi-error";
export type CodingAgentsInfo = {
// Every agent `unsloth start` supports, in the CLI's declared order.
agents: string[];
// Subset of `agents` whose CLI binary was found on PATH by the backend.
detected: string[];
};
type ApiCodingAgentsInfo = {
agents: string[];
detected: string[];
};
// Which CLIs are on PATH is environment state, not a persisted setting -- it
// can change any time the user installs something new, so this only
// de-duplicates concurrent in-flight calls (e.g. React strict-mode's double
// mount) rather than caching the result across the module's lifetime. Every
// fresh call (each time a settings panel mounts) re-checks PATH for real.
let inFlightInfo: Promise<CodingAgentsInfo> | null = null;
function fromApi(info: ApiCodingAgentsInfo): CodingAgentsInfo {
return { agents: info.agents, detected: info.detected };
}
async function fetchCodingAgents(): Promise<CodingAgentsInfo> {
const res = await authFetch("/api/settings/coding-agents");
if (!res.ok) {
throw new Error(
await readFastApiError(res, "Failed to load installed coding agents"),
);
}
return fromApi(await res.json());
}
export async function loadCodingAgents(): Promise<CodingAgentsInfo> {
inFlightInfo ??= fetchCodingAgents().finally(() => {
inFlightInfo = null;
});
return inFlightInfo;
}

View file

@ -12,7 +12,7 @@ const DEFAULT_AGENT = "claude";
// URL.hostname brackets IPv6 literals (`new URL("http://[::1]:8888").hostname` is
// "[::1]"), so strip the brackets before matching the bare "::1" loopback rules below.
function normalizeHost(host: string): string {
export function normalizeHost(host: string): string {
const lower = host.toLowerCase();
return lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
}
@ -26,7 +26,7 @@ function isDefaultLocalHost(host: string): boolean {
}
// Match the CLI auto-mint rule (is_loopback_url): localhost, ::1, and all of 127.0.0.0/8.
function isLoopbackHost(host: string): boolean {
export function isLoopbackHost(host: string): boolean {
if (host === "localhost" || host === "::1") return true;
const octets = host.split(".");
return (

View file

@ -16,6 +16,7 @@ import { fetchDeviceType, usePlatformStore } from "@/config/env";
import { useChatRuntimeStore } from "@/features/chat";
import { useT } from "@/i18n";
import type { TranslationKey } from "@/i18n";
import { isTauri } from "@/lib/api-base";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { Tick02Icon } from "@/lib/tick-icon";
import { cn } from "@/lib/utils";
@ -25,14 +26,15 @@ import {
InformationCircleIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Streamdown } from "streamdown";
import { loadCodingAgents } from "../api/coding-agents";
import {
type OpenAIAutoSwitchSettings,
loadOpenAIAutoSwitchSettings,
updateOpenAIAutoSwitchSettings,
} from "../api/openai-auto-switch";
import { buildAgentCommand } from "./agent-command";
import { buildAgentCommand, isLoopbackHost, normalizeHost } from "./agent-command";
type ExampleType =
| "curl"
@ -114,6 +116,30 @@ const DOC_LINKS = [
{ label: "Hermes Agent", href: "https://unsloth.ai/docs/integrations/hermes-agent" },
];
// Falls back to this list until the backend's installed-CLI check resolves;
// kept in sync with the `unsloth start <agent>` subcommands and with
// CODING_AGENTS in studio/backend/utils/coding_agents.py.
const DEFAULT_AGENTS = [
"claude",
"codex",
"openclaw",
"opencode",
"hermes",
"pi",
];
// The agent selection resets to this whenever an auto-pick is no longer
// trustworthy (leaving loopback, or the only compatible detected agent
// stops being compatible) rather than lingering on a stale choice.
const DEFAULT_AGENT = "claude";
const AGENT_LABELS: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
openclaw: "OpenClaw",
opencode: "OpenCode",
hermes: "Hermes",
pi: "Pi",
};
const j = (s: string): string => JSON.stringify(s);
const shSingle = (s: string): string => s.replace(/'/g, "'\\''");
const psSingle = (s: string): string => s.replace(/'/g, "''");
@ -399,6 +425,17 @@ function useLoadedModelName(): string {
}, [checkpoint, ggufVariant]);
}
// Backend PATH detection is only safe in the desktop app, where the UI owns
// the local backend. A browser loopback URL may be an SSH/local port forward.
function canUseLocalAgentDetection(base: string): boolean {
if (!isTauri) return false;
try {
return isLoopbackHost(normalizeHost(new URL(base).hostname));
} catch {
return false;
}
}
const SHIKI_THEMES = [unslothLightTheme, unslothDarkTheme] as [
typeof unslothLightTheme,
typeof unslothDarkTheme,
@ -443,7 +480,18 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
const [copied, setCopied] = useState(false);
const [copiedUrl, setCopiedUrl] = useState(false);
const [copiedAgent, setCopiedAgent] = useState(false);
const [agent, setAgent] = useState<string>(DEFAULT_AGENT);
const [availableAgents, setAvailableAgents] =
useState<string[]>(DEFAULT_AGENTS);
const [detectedAgents, setDetectedAgents] = useState<string[]>([]);
// True once the user has picked an agent themselves; guards the detection
// effect below from clobbering that choice if it resolves afterward.
const agentPickedByUserRef = useRef(false);
const [useTunnel, setUseTunnel] = useState<boolean>(readUseTunnelPref);
const origin = typeof window !== "undefined" ? window.location.origin : "";
const base =
useTunnel && cloudflareUrl ? cloudflareUrl : (serverUrl ?? origin);
const localAgentDetection = canUseLocalAgentDetection(base);
// null while loading; the same setting the General tab exposes (shared cache).
const [autoSwitch, setAutoSwitch] = useState<OpenAIAutoSwitchSettings | null>(
null,
@ -454,6 +502,78 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
void fetchDeviceType({ force: true });
}, []);
// Fetching is the only job of this effect: populate availableAgents/
// detectedAgents (or clear them). Which agent gets auto-picked from that
// list is derived separately below, so it can react to the loaded model
// changing too, not just a fresh fetch.
useEffect(() => {
// Browser loopback URLs can be SSH/local forwards, so only the desktop app
// may use backend PATH checks to mark or auto-pick local agents.
if (!localAgentDetection) {
setDetectedAgents([]);
// A previously auto-picked agent was only ever verified against the
// Studio backend's PATH, which is meaningless now that this panel no
// longer targets a loopback base -- don't leave it selected, but
// never touch a choice the user made by hand.
if (!agentPickedByUserRef.current) {
setAgent(DEFAULT_AGENT);
}
return;
}
let cancelled = false;
void loadCodingAgents()
.then((info) => {
if (cancelled) return;
setAvailableAgents(info.agents);
setDetectedAgents(info.detected);
})
.catch(() => {
// Best-effort: keep the default agent list and let the user pick manually.
});
return () => {
cancelled = true;
};
}, [localAgentDetection]);
// Single source of truth for the auto-picked agent, re-derived whenever
// the detected list or the loaded model's GGUF-ness changes -- in either
// direction. `codex` needs a GGUF model (unsloth_cli's
// _require_gguf_for_codex exits otherwise), so it's only preferred once
// the loaded model actually qualifies; loading a GGUF model *after* a
// non-GGUF-gated fallback picked something else re-steers back to codex
// just as loading a non-GGUF model steers away from it. Never overrides a
// choice the user made by hand.
// activeGgufVariant alone only covers an HF-repo GGUF pick (a specific
// quant variant string) -- a direct local .gguf file (custom folder /
// LM Studio / drag-drop) is just as much a GGUF the codex preflight would
// accept, but never has a "variant" to report, and would otherwise read as
// non-GGUF here. activeNativePathToken covers the drag-drop/picked-file
// case; ggufContextLength is only ever populated when the backend's
// /api/inference/status last reported is_gguf: true for the active model
// (see applyActiveModelStatusToStore), so together these three cover every
// path a model can be GGUF through, matching the same is_gguf-or-equivalent
// check hasGgufSource applies to a staged pick.
const activeGgufVariant = useChatRuntimeStore((s) => s.activeGgufVariant);
const activeNativePathToken = useChatRuntimeStore((s) => s.activeNativePathToken);
const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength);
useEffect(() => {
if (agentPickedByUserRef.current) return;
if (detectedAgents.length === 0) return;
const isGguf =
activeGgufVariant != null || activeNativePathToken != null || ggufContextLength != null;
const preferred = detectedAgents.find((a) => a !== "codex" || isGguf);
if (preferred) {
setAgent(preferred);
} else if (agent === "codex" && !isGguf) {
// codex was auto-picked while a GGUF model was active and it's the
// only detected agent; now that the model isn't GGUF anymore, nothing
// detected is actually runnable, so fall back to the default instead
// of leaving a codex command unsloth_cli will reject.
setAgent(DEFAULT_AGENT);
}
}, [agent, detectedAgents, activeGgufVariant, activeNativePathToken, ggufContextLength]);
useEffect(() => {
let cancelled = false;
void loadOpenAIAutoSwitchSettings()
@ -470,9 +590,6 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
const model = useLoadedModelName();
const key = apiKey || KEY_PLACEHOLDER;
const origin = typeof window !== "undefined" ? window.location.origin : "";
const base =
useTunnel && cloudflareUrl ? cloudflareUrl : (serverUrl ?? origin);
const autoSwitchOn = autoSwitch?.enabled ?? false;
const snippets = useMemo(
@ -481,8 +598,8 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
);
// Agent command must target the server the panel shows, not the :8888 default.
const agentCommand = useMemo(
() => buildAgentCommand(base, key, os),
[base, key, os],
() => buildAgentCommand(base, key, os, agent),
[base, key, os, agent],
);
const osAware = OS_AWARE[lang];
@ -710,6 +827,42 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
<span className="text-[11px] leading-snug text-muted-foreground">
{t("settings.apiKeys.codingAgentsHint")}
</span>
<div className="flex min-w-0 flex-wrap items-center gap-1">
{availableAgents.map((id) => {
const installed = detectedAgents.includes(id);
const active = agent === id;
return (
<button
key={id}
type="button"
onClick={() => {
agentPickedByUserRef.current = true;
setAgent(id);
}}
aria-pressed={active}
title={
installed
? t("settings.apiKeys.codingAgentDetected")
: undefined
}
className={cn(
"flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active
? "hub-tab-toggle-pill text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{AGENT_LABELS[id] ?? id}
{installed ? (
<span
aria-hidden="true"
className="size-1.5 rounded-full bg-emerald-500"
/>
) : null}
</button>
);
})}
</div>
<div className="relative mt-0.5 min-w-0">
<code className="block min-w-0 overflow-x-auto rounded border border-border bg-muted/30 px-2 py-1.5 pr-14 font-mono text-[11px] text-foreground">
{agentCommand}
@ -727,7 +880,13 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
</button>
</div>
<span className="text-[11px] leading-snug text-muted-foreground">
{t("settings.apiKeys.codingAgentsSwap")}
{detectedAgents.length > 0
? t("settings.apiKeys.codingAgentsDetectedHint", {
agents: detectedAgents
.map((id) => AGENT_LABELS[id] ?? id)
.join(", "),
})
: t("settings.apiKeys.codingAgentsSwap")}
</span>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border px-3 py-2 text-[11px] text-muted-foreground">

View file

@ -7,6 +7,7 @@ export {
savePersonalization,
} from "./api/personalization";
export { setTheme, useTheme } from "./stores/theme-store";
export { useMonitorOverlayStore } from "./stores/monitor-overlay-store";
export type {
Personalization,
PersonalizationAppearance,

View file

@ -56,6 +56,7 @@ const TABS: TabDef[] = [
id: "resources",
labelKey: "settings.tabs.resources",
icon: CpuIcon,
badgeKey: "common.new",
},
{
id: "chat",
@ -72,7 +73,6 @@ const TABS: TabDef[] = [
id: "connections",
labelKey: "settings.tabs.connections",
icon: CloudIcon,
badgeKey: "common.new",
},
{ id: "about", labelKey: "settings.tabs.about", icon: HelpCircleIcon },
];

View file

@ -409,7 +409,6 @@ export const en = {
description: "Access Unsloth via the OpenAI-compatible API.",
readDocs: "Read the API docs",
noAccess: "No API access yet.",
newBadge: "New",
accessTokens: "Access tokens",
loadError: "Couldn't load API access.",
createError: "Couldn't create access token.",
@ -445,6 +444,8 @@ export const en = {
codingAgentsHint:
"Launch a coding agent against this server. It uses the loaded model; a local server mints an API key automatically, a remote one includes it in the command.",
codingAgentsSwap: "Swap claude for codex, openclaw, opencode, hermes, or pi.",
codingAgentDetected: "Installed on this machine",
codingAgentsDetectedHint: "Detected on this machine: {agents}.",
relativeNever: "never",
relativeJustNow: "just now",
relativeHoursAgo: "{count}h ago",

View file

@ -296,7 +296,6 @@ export const ja = {
description: "OpenAI互換 API を介して Unsloth にアクセスします。",
readDocs: "API ドキュメントを読む",
noAccess: "まだ API アクセス権がありません。",
newBadge: "新規",
accessTokens: "アクセストークン",
loadError: "API アクセス権を読み込めませんでした。",
createError: "アクセストークンを作成できませんでした。",

View file

@ -363,7 +363,6 @@ export const ptBR = {
"Acesse o Unsloth por meio da API compatível com OpenAI.",
readDocs: "Leia a documentação da API",
noAccess: "Nenhum acesso à API ainda.",
newBadge: "Novo",
accessTokens: "Tokens de acesso",
loadError: "Não foi possível carregar o acesso à API.",
createError: "Não foi possível criar o token de acesso.",

View file

@ -267,7 +267,6 @@ export const zhCN = {
description: "通过兼容 OpenAI 的 API 以编程方式访问 Unsloth。",
readDocs: "阅读 API 文档",
noAccess: "还没有 API 访问权限。",
newBadge: "新",
accessTokens: "访问 token",
loadError: "无法加载 API 访问权限。",
createError: "无法创建访问 token。",

View file

@ -173,7 +173,11 @@ function looksLikeMathBody(body: string): boolean {
* (`**$X$**`, `__$X$__`) are always math: LLMs use that for "bold math"
* and the heuristic would otherwise reject prose-shaped bodies like "90 - x".
*/
function hasInlineMathCloser(content: string, offset: number): boolean {
function hasInlineMathCloser(
content: string,
offset: number,
mathRegions: Array<[number, number]>,
): boolean {
const MAX_SPAN = 200;
const limit = Math.min(content.length, offset + 1 + MAX_SPAN);
for (let i = offset + 1; i < limit; i++) {
@ -181,6 +185,9 @@ function hasInlineMathCloser(content: string, offset: number): boolean {
if (c === "\n") return false;
if (c !== "$") continue;
if (content[i - 1] === "\\") continue;
// A `$` opening a generated span (from `\(...\)`) is not a currency closer;
// pairing with it would swallow the price into math (`$5 + x \(y\)`).
if (isInRegion(i, mathRegions)) return false;
if (content[i + 1] === "$") {
i++;
continue;
@ -294,7 +301,22 @@ function convertLatexDelimiters(content: string): {
continue;
}
append(content.slice(last, match.index));
const wrapped = isDisplay ? `\n$$\n${body}\n$$\n` : `$${body}$`;
let wrapped: string;
if (isDisplay) {
// Keep the opener's leading indentation so a `$$` block inside a list item
// stays in the container instead of breaking out at column 0. Only when the
// opener is whitespace-prefixed, so inline `text \[x\]` keeps column 0.
const lineStart =
match.index > 0 ? content.lastIndexOf("\n", match.index - 1) + 1 : 0;
const prefix = content.slice(lineStart, match.index);
const indent = /^\s*$/.test(prefix) ? prefix : "";
// Indent every body line, not just the first, so multi-line display math
// (`\[a\nb\]`) stays wholly inside the container.
const inner = indent ? body.replace(/\n/g, `\n${indent}`) : body;
wrapped = `\n${indent}$$\n${indent}${inner}\n${indent}$$\n`;
} else {
wrapped = `$${body}$`;
}
const start = append(wrapped);
mathRegions.push([start, offset]);
last = matchEnd;
@ -334,7 +356,7 @@ export function preprocessLaTeX(content: string): string {
if (isInRegion(offset, mathRegions)) {
return match;
}
if (hasInlineMathCloser(text, offset)) {
if (hasInlineMathCloser(text, offset, mathRegions)) {
return match;
}
return "\\" + match;

View file

@ -10,6 +10,7 @@ import argparse
import atexit
import errno
import fnmatch
import glob
import hashlib
import json
import os
@ -165,9 +166,9 @@ def env_int(
# errors. Only use "master" temporarily when the latest release is missing
# support for a new model architecture.
DEFAULT_LLAMA_TAG = os.environ.get("UNSLOTH_LLAMA_TAG", "latest")
# Default published repo for prebuilt release resolution. Linux uses
# Unsloth prebuilts; setup.sh/setup.ps1 pass --published-repo explicitly
# for macOS/Windows to override with ggml-org/llama.cpp when needed.
# Default published repo for prebuilt release resolution. Every host plans
# its prebuilt against the Unsloth fork; setup.sh/setup.ps1 pass it via
# --published-repo. ggml-org is reachable only via an explicit override.
DEFAULT_PUBLISHED_REPO = "unslothai/llama.cpp"
DEFAULT_PUBLISHED_TAG = os.environ.get("UNSLOTH_LLAMA_RELEASE_TAG")
DEFAULT_PUBLISHED_MANIFEST_ASSET = os.environ.get(
@ -265,6 +266,7 @@ class HostInfo:
has_physical_nvidia: bool
has_usable_nvidia: bool
has_rocm: bool = False
has_intel_gpu: bool = False
rocm_gfx_target: str | None = None
# (major, minor) from platform.mac_ver(); None off macOS or if unparseable.
# Skips a macos prebuilt whose minimum-OS exceeds this host.
@ -1284,162 +1286,6 @@ def synthetic_checksums_for_release(
)
def parse_direct_linux_release_bundle(
repo: str, release: dict[str, Any]
) -> PublishedReleaseBundle | None:
release_tag = release.get("tag_name")
if not isinstance(release_tag, str) or not release_tag:
return None
assets = release_asset_map(release)
artifacts: list[PublishedLlamaArtifact] = []
inferred_labels: list[str] = []
linux_asset_re = re.compile(
r"^app-(?P<label>.+)-(?P<target>linux-x64(?:-cpu)?|linux-x64-cuda\d+-(?:older|newer|portable))\.tar\.gz$"
)
for asset_name in sorted(assets):
match = linux_asset_re.fullmatch(asset_name)
if not match:
continue
inferred_labels.append(match.group("label"))
target = match.group("target")
if target in {"linux-x64", "linux-x64-cpu"}:
artifacts.append(
PublishedLlamaArtifact(
asset_name = asset_name,
install_kind = "linux-cpu",
runtime_line = None,
coverage_class = None,
supported_sms = [],
min_sm = None,
max_sm = None,
bundle_profile = None,
rank = 1000,
)
)
continue
bundle_profile = target.removeprefix("linux-x64-")
profile = _resolve_linux_bundle_profile(bundle_profile)
if profile is None:
continue
artifacts.append(
PublishedLlamaArtifact(
asset_name = asset_name,
install_kind = "linux-cuda",
runtime_line = str(profile["runtime_line"]),
coverage_class = str(profile["coverage_class"]),
supported_sms = [str(value) for value in profile["supported_sms"]],
min_sm = int(profile["min_sm"]),
max_sm = int(profile["max_sm"]),
bundle_profile = bundle_profile,
rank = int(profile["rank"]),
)
)
if not artifacts:
return None
upstream_tag = (
release_tag
if is_release_tag_like(release_tag)
else inferred_labels[0]
if len(set(inferred_labels)) == 1 and inferred_labels
else release_tag
)
selection_log = [
f"published_release: repo={repo}",
f"published_release: tag={release_tag}",
f"published_release: upstream_tag={upstream_tag}",
"published_release: direct_asset_scan=linux",
]
return PublishedReleaseBundle(
repo = repo,
release_tag = release_tag,
upstream_tag = upstream_tag,
assets = assets,
manifest_asset_name = DEFAULT_PUBLISHED_MANIFEST_ASSET,
artifacts = artifacts,
selection_log = selection_log,
)
def direct_linux_release_plan(
release: dict[str, Any], host: HostInfo, repo: str, requested_tag: str
) -> InstallReleasePlan | None:
bundle = parse_direct_linux_release_bundle(repo, release)
if bundle is None:
return None
if not direct_release_matches_request(
release_tag = bundle.release_tag,
llama_tag = bundle.upstream_tag,
requested_tag = requested_tag,
):
return None
attempts: list[AssetChoice] = []
if host.has_usable_nvidia:
# Prefer the cudart major Studio loads at runtime (torch's bundled
# libcudart), not the newest on disk. Otherwise a stray cuda13
# runtime outranks the torch cuda12 the binary links against.
torch_preference = detect_torch_cuda_runtime_preference(host)
selection = linux_cuda_choice_from_release(
host,
bundle,
preferred_runtime_line = torch_preference.runtime_line,
selection_preamble = torch_preference.selection_log,
)
if selection is not None:
attempts.extend(selection.attempts)
elif not host.has_rocm:
# A ROCm-only host gets no CPU asset: leaving attempts empty lets the
# raise below trigger a HIP source build instead of shipping a CPU
# binary on a GPU host (this ggml-org path has no per-gfx ROCm asset).
cpu_choice = published_asset_choice_for_kind(bundle, "linux-cpu")
if cpu_choice is not None:
attempts.append(cpu_choice)
# NVIDIA hosts whose CUDA selection produced nothing fall through to the
# raise below (mirroring the ROCm policy above): the caller then walks
# back to an older release that still ships a usable CUDA line instead of
# silently installing a CPU binary on a GPU host. Today's walk-back only
# works because partial releases ship no CPU bundle; this keeps it working
# if a future partial release does.
if not attempts:
raise PrebuiltFallback("no compatible Linux prebuilt asset was found")
approved_checksums = synthetic_checksums_for_release(
repo,
bundle.release_tag,
bundle.upstream_tag,
)
resolved_upstream_tag = bundle.upstream_tag
if DEFAULT_PUBLISHED_SHA256_ASSET in bundle.assets and not is_release_tag_like(
bundle.upstream_tag
):
approved_checksums = load_approved_release_checksums(repo, bundle.release_tag)
# Require exact source provenance for branch/pull/commit releases.
# Mirrors validated_checksums_for_bundle so incomplete metadata fails
# closed instead of degrading to the legacy branch-as-tag source
# hydration path this PR eliminates.
if (
not approved_checksums.source_commit
or exact_source_archive_hash(approved_checksums) is None
or source_clone_url_from_checksums(approved_checksums) is None
):
raise PrebuiltFallback(
f"approved checksum asset {DEFAULT_PUBLISHED_SHA256_ASSET} for "
f"{repo}@{bundle.release_tag} did not contain exact source provenance"
)
attempts = apply_approved_hashes(attempts, approved_checksums)
return InstallReleasePlan(
requested_tag = requested_tag,
llama_tag = resolved_upstream_tag,
release_tag = bundle.release_tag,
attempts = attempts,
approved_checksums = approved_checksums,
)
def direct_upstream_release_plan(
release: dict[str, Any], host: HostInfo, repo: str, requested_tag: str
) -> InstallReleasePlan | None:
@ -1482,6 +1328,24 @@ def direct_upstream_release_plan(
install_kind = "windows-hip",
)
)
# Intel (or other non-NVIDIA/non-AMD) GPU: use the Vulkan prebuilt. Gate
# on no PHYSICAL NVIDIA (not just no usable one): a host that hid NVIDIA
# via CUDA_VISIBLE_DEVICES must not reach Vulkan, which ignores that mask
# and could enumerate the reserved card. Falls through to CPU below.
elif host.has_intel_gpu and not host.has_physical_nvidia:
vulkan_asset = f"llama-{release_tag}-bin-win-vulkan-x64.zip"
vulkan_url = assets.get(vulkan_asset)
if vulkan_url:
attempts.append(
AssetChoice(
repo = repo,
tag = release_tag,
name = vulkan_asset,
url = vulkan_url,
source_label = "upstream",
install_kind = "windows-vulkan",
)
)
cpu_asset = f"llama-{release_tag}-bin-win-cpu-x64.zip"
cpu_url = assets.get(cpu_asset)
if cpu_url:
@ -1545,6 +1409,23 @@ def direct_upstream_release_plan(
# ROCm hosts are excluded: this ggml-org path ships no per-gfx ROCm
# asset, so they fall through to the empty-attempts raise (HIP source
# build) rather than silently getting a CPU binary on a GPU host.
# Intel (or other non-NVIDIA/non-AMD) GPU: use the Vulkan prebuilt. The
# elif already excludes usable NVIDIA and ROCm; also require no PHYSICAL
# NVIDIA so a CUDA-hidden card isn't reached through Vulkan (CPU below).
if host.has_intel_gpu and not host.has_physical_nvidia:
vulkan_asset = f"llama-{release_tag}-bin-ubuntu-vulkan-x64.tar.gz"
vulkan_url = assets.get(vulkan_asset)
if vulkan_url:
attempts.append(
AssetChoice(
repo = repo,
tag = release_tag,
name = vulkan_asset,
url = vulkan_url,
source_label = "upstream",
install_kind = "linux-vulkan",
)
)
asset_name = f"llama-{release_tag}-bin-ubuntu-x64.tar.gz"
asset_url = assets.get(asset_name)
if asset_url:
@ -1564,6 +1445,23 @@ def direct_upstream_release_plan(
# selector returned 0 attempts and the installer fell back to a
# source build on every Linux ARM64 host (DGX Spark, Ampere
# Altra, GitHub-hosted ubuntu-24.04-arm runners, etc.).
# Intel (or other non-NVIDIA/non-AMD) GPU: prefer the Vulkan prebuilt,
# mirroring the x86_64 branch. Upstream ships bin-ubuntu-vulkan-arm64.
# No physical NVIDIA: don't reach a CUDA-hidden card through Vulkan.
if host.has_intel_gpu and not host.has_physical_nvidia and not host.has_rocm:
vulkan_asset = f"llama-{release_tag}-bin-ubuntu-vulkan-arm64.tar.gz"
vulkan_url = assets.get(vulkan_asset)
if vulkan_url:
attempts.append(
AssetChoice(
repo = repo,
tag = release_tag,
name = vulkan_asset,
url = vulkan_url,
source_label = "upstream",
install_kind = "linux-vulkan",
)
)
asset_name = f"llama-{release_tag}-bin-ubuntu-arm64.tar.gz"
asset_url = assets.get(asset_name)
if asset_url:
@ -2864,6 +2762,64 @@ def _pick_rocm_gfx_target(out: str) -> str | None:
return _tokens[0]
# Display-adapter device class: one NNNN subkey per installed display driver
# config, each carrying the driver's DriverDesc and PCI MatchingDeviceId.
_WINDOWS_DISPLAY_CLASS_KEY = (
r"SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}"
)
def windows_intel_gpu_in_registry() -> bool:
"""Whether the Windows registry lists an Intel display adapter.
In-process Windows counterpart of the Linux DRM vendor-id check (0x8086),
with weaker semantics: the class key lists installed display-driver
configs, which can outlive removed hardware, where sysfs lists present
devices. A stale Intel entry at worst routes to the upstream Vulkan
prebuilt instead of the fork CPU bundle: inference still works (the
Vulkan build runs on CPU when no Vulkan device exists), at the cost of
fork-only extras such as the DiffusionGemma visual server. detect_host's
PowerShell + WMI probe can silently miss a real Intel GPU: a cold
powershell.exe start plus the first CIM query routinely exceeds the 15s
budget on hosts with slow AV scanning or a degraded WMI repository, and
the probe swallows the timeout (#4452, Arc A770 routed to the CPU
prebuilt). Reading the display-adapter class key needs no subprocess and
answers in microseconds. Matches the PCI vendor id in MatchingDeviceId
(ven_8086) or an Intel DriverDesc.
"""
try:
import winreg
except ImportError:
return False
try:
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, _WINDOWS_DISPLAY_CLASS_KEY) as class_key:
for index in range(winreg.QueryInfoKey(class_key)[0]):
try:
name = winreg.EnumKey(class_key, index)
if not name.isdigit():
# "Properties" is ACL-restricted and not an adapter.
continue
with winreg.OpenKey(class_key, name) as adapter_key:
for value_name, needle in (
("MatchingDeviceId", "ven_8086"),
("DriverDesc", "intel"),
):
try:
value, _ = winreg.QueryValueEx(adapter_key, value_name)
except OSError:
continue
if needle in str(value).lower():
return True
except OSError:
continue
except Exception:
# Advisory probe: any unexpected failure must degrade to the CIM
# fallback, never crash the installer (mirrors detect_host's own
# swallow around the CIM probe).
return False
return False
def detect_host() -> HostInfo:
system = platform.system()
machine = platform.machine().lower()
@ -3075,6 +3031,46 @@ def detect_host() -> HostInfo:
# Note: amdhip64.dll presence alone is NOT treated as GPU evidence
# since the HIP SDK can be installed without an AMD GPU.
# Detect an Intel GPU; gates the Vulkan prebuilt. Linux reads the DRM sysfs
# vendor id (0x8086); Windows reads the display-adapter registry class,
# then falls back to the WMI video controller list. Only probed with no
# usable NVIDIA and no ROCm (matching the Vulkan branches), keeping the
# probe (notably the Windows powershell call) off that path.
has_intel_gpu = False
if not has_usable_nvidia and not has_rocm:
if is_linux:
for _vendor_file in glob.glob("/sys/class/drm/card*/device/vendor"):
try:
with open(_vendor_file) as _vf:
if _vf.read().strip().lower() == "0x8086":
has_intel_gpu = True
break
except OSError:
continue
elif is_windows:
# Registry first (in-process; see windows_intel_gpu_in_registry).
# The CIM query stays as the fallback when the registry shows no
# Intel adapter.
has_intel_gpu = windows_intel_gpu_in_registry()
if not has_intel_gpu:
_ps = shutil.which("powershell") or shutil.which("pwsh")
if _ps:
try:
_result = run_capture(
[
_ps,
"-NoProfile",
"-Command",
"Get-CimInstance Win32_VideoController | "
"Select-Object -ExpandProperty Name",
],
timeout = 15,
)
if _result.returncode == 0 and "intel" in _result.stdout.lower():
has_intel_gpu = True
except Exception:
pass
return HostInfo(
system = system,
machine = machine,
@ -3090,6 +3086,7 @@ def detect_host() -> HostInfo:
has_physical_nvidia = has_physical_nvidia,
has_usable_nvidia = has_usable_nvidia,
has_rocm = has_rocm,
has_intel_gpu = has_intel_gpu,
rocm_gfx_target = rocm_gfx_target,
macos_version = macos_version,
)
@ -3126,6 +3123,7 @@ def _apply_host_overrides(
has_physical_nvidia = False,
has_rocm = False,
rocm_gfx_target = None,
has_intel_gpu = False,
)
gfx = _normalize_forwarded_gfx(override_rocm_gfx)
if gfx:
@ -3135,21 +3133,6 @@ def _apply_host_overrides(
return host
def published_repo_for_host(host: HostInfo, *, linux_amd_tooling_present: bool = False) -> str:
"""The release repo setup.sh / setup.ps1 pick for this host: macOS always the
fork (ggml-org macOS bundles need too-new macOS); else CPU-only Linux/Windows
-> ggml-org upstream (the fork ships no CPU bundle) and any usable GPU (NVIDIA
or ROCm) -> the fork. linux_amd_tooling_present mirrors setup.sh routing Linux
hosts that expose AMD tooling (rocminfo/amd-smi/hipconfig/hipinfo) to the fork
even when the probe cannot confirm an active GPU. Mirrors the shell routing."""
if host.is_macos:
return DEFAULT_PUBLISHED_REPO
has_gpu = (
host.has_usable_nvidia or host.has_rocm or (host.is_linux and linux_amd_tooling_present)
)
return DEFAULT_PUBLISHED_REPO if has_gpu else UPSTREAM_REPO
def pick_windows_cuda_runtime(host: HostInfo) -> str | None:
if not host.driver_cuda_version:
return None
@ -3881,6 +3864,23 @@ def resolve_upstream_asset_choice(host: HostInfo, llama_tag: str) -> AssetChoice
"falling back to source build with HIP support"
)
# Intel (or other non-NVIDIA/non-AMD) GPU: use the Vulkan prebuilt. No
# physical NVIDIA (not just no usable one): a CUDA-hidden card must not
# be reached through Vulkan, which ignores CUDA_VISIBLE_DEVICES.
if host.has_intel_gpu and not host.has_physical_nvidia and not host.has_rocm:
vulkan_name = f"llama-{llama_tag}-bin-ubuntu-vulkan-x64.tar.gz"
if vulkan_name in upstream_assets:
log(f"Intel GPU detected -- using upstream Vulkan prebuilt {vulkan_name}")
return AssetChoice(
repo = UPSTREAM_REPO,
tag = llama_tag,
name = vulkan_name,
url = upstream_assets[vulkan_name],
source_label = "upstream",
install_kind = "linux-vulkan",
)
log("Intel GPU detected but no Vulkan prebuilt found -- falling back to CPU")
upstream_name = f"llama-{llama_tag}-bin-ubuntu-x64.tar.gz"
if upstream_name not in upstream_assets:
raise PrebuiltFallback("upstream Linux CPU asset was not found")
@ -3923,6 +3923,24 @@ def resolve_upstream_asset_choice(host: HostInfo, llama_tag: str) -> AssetChoice
)
log("AMD ROCm detected on Windows but no HIP prebuilt found -- falling back to CPU")
# Intel (or other non-NVIDIA/non-AMD) GPU on Windows: use Vulkan. No
# physical NVIDIA so a CUDA-hidden card isn't reached through Vulkan.
if host.has_intel_gpu and not host.has_physical_nvidia and not host.has_rocm:
vulkan_name = f"llama-{llama_tag}-bin-win-vulkan-x64.zip"
if vulkan_name in upstream_assets:
log(
f"Intel GPU detected on Windows -- using upstream Vulkan prebuilt {vulkan_name}"
)
return AssetChoice(
repo = UPSTREAM_REPO,
tag = llama_tag,
name = vulkan_name,
url = upstream_assets[vulkan_name],
source_label = "upstream",
install_kind = "windows-vulkan",
)
log("Intel GPU detected on Windows but no Vulkan prebuilt found -- falling back to CPU")
upstream_name = f"llama-{llama_tag}-bin-win-cpu-x64.zip"
if upstream_name not in upstream_assets:
raise PrebuiltFallback("upstream Windows CPU asset was not found")
@ -4015,6 +4033,9 @@ def resolve_release_asset_choice(
published_choice = published_rocm_choice_for_host(release, host, "windows-rocm")
else:
published_choice = published_asset_choice_for_kind(release, "windows-cpu")
elif host.is_windows and host.is_arm64:
# Windows arm64 has no GPU prebuilt, so it always takes the CPU bundle.
published_choice = published_asset_choice_for_kind(release, "windows-arm64")
elif host.is_macos and host.is_arm64:
published_choice = published_asset_choice_for_kind(release, "macos-arm64")
elif host.is_macos and host.is_x86_64:
@ -4515,6 +4536,7 @@ def runtime_patterns_for_choice(choice: AssetChoice) -> list[str]:
"linux-arm64-cuda",
"linux-rocm",
"linux-arm64",
"linux-vulkan",
}:
return ["llama-server", "llama-quantize", "llama-diffusion-gemma-visual-server", "lib*.so*"]
if choice.install_kind in {"macos-arm64", "macos-x64"}:
@ -4528,6 +4550,7 @@ def runtime_patterns_for_choice(choice: AssetChoice) -> list[str]:
"windows-cpu",
"windows-cuda",
"windows-hip",
"windows-vulkan",
"windows-rocm",
"windows-arm64",
}:
@ -5743,8 +5766,10 @@ def validate_server(
"linux-cuda",
"linux-arm64-cuda",
"linux-rocm",
"linux-vulkan",
"windows-cuda",
"windows-hip",
"windows-vulkan",
"windows-rocm",
"macos-arm64",
}
@ -6127,8 +6152,13 @@ def _linux_published_attempts(host: HostInfo, bundle: PublishedReleaseBundle) ->
# CPU-only host. A usable-NVIDIA host never reaches here -- if its CUDA
# selection produced nothing we want an empty attempt list so the caller
# source-builds with CUDA, not a CPU-only binary silently installed on a
# GPU host (mirrors the ROCm branch, and Windows NVIDIA).
cpu_choice = published_asset_choice_for_kind(bundle, "linux-cpu")
# GPU host (mirrors the ROCm branch, and Windows NVIDIA). Only x86_64 and
# arm64 have a CPU bundle; any other Linux arch (ppc64le, riscv64, s390x)
# has none, so leave attempts empty and source-build rather than hand it
# the x86_64 linux-cpu binary (the Linux preflight checks libraries, not
# ELF arch, so a wrong-arch binary would not be caught).
kind = "linux-cpu" if host.is_x86_64 else "linux-arm64" if host.is_arm64 else None
cpu_choice = published_asset_choice_for_kind(bundle, kind) if kind else None
if cpu_choice is not None:
attempts.append(cpu_choice)
return attempts
@ -6143,9 +6173,9 @@ def _fork_manifest_release_plans(
max_release_fallbacks: int = DEFAULT_MAX_PREBUILT_RELEASE_FALLBACKS,
) -> tuple[str, list[InstallReleasePlan]]:
"""Manifest-reading branch of resolve_simple_install_release_plans, used for
the fork's bundles whose GPU/arch coverage lives in
llama-prebuilt-manifest.json rather than in the filename: arm64 CUDA, Windows
CUDA, per-gfx ROCm, and macOS. Linux x64 takes the faster filename path."""
every fork host: all of the fork's bundles describe their GPU/arch coverage
in llama-prebuilt-manifest.json rather than in the asset filename (CPU,
x64/arm64 CUDA, Windows CUDA, per-gfx ROCm, and macOS)."""
requested_tag = normalized_requested_llama_tag(llama_tag)
allow_older_release_fallback = requested_tag == "latest" and not published_release_tag
release_limit = max(1, max_release_fallbacks)
@ -6361,6 +6391,20 @@ def runtime_payload_health_groups(choice: AssetChoice) -> list[list[str]]:
["libmtmd.so*"],
["libggml-hip.so*"],
]
if choice.install_kind == "linux-vulkan":
return [
["libllama-common.so*"],
["libllama.so*"],
["libggml.so*"],
["libggml-base.so*"],
# Match the sibling globs (linux-cuda/-rocm): x64 bundles ship
# arch-suffixed libggml-cpu-<variant>.so, arm64 may ship a bare
# libggml-cpu.so; the '-' form missed the latter and re-flagged
# the install unhealthy on every check.
["libggml-cpu*.so*"],
["libmtmd.so*"],
["libggml-vulkan.so*"],
]
if choice.install_kind in {"windows-cpu", "windows-arm64"}:
return [["llama.dll"]]
if choice.install_kind == "windows-cuda":
@ -6380,6 +6424,8 @@ def runtime_payload_health_groups(choice: AssetChoice) -> list[list[str]]:
return groups
if choice.install_kind in {"windows-hip", "windows-rocm"}:
return [["llama.dll"], ["*hip*.dll"]]
if choice.install_kind == "windows-vulkan":
return [["llama.dll"], ["ggml-vulkan.dll"]]
return []
@ -6661,6 +6707,89 @@ def validate_prebuilt_attempts(
raise PrebuiltFallback("no prebuilt bundle passed validation")
def force_vulkan_requested() -> bool:
"""Whether UNSLOTH_FORCE_VULKAN opts this host into the Vulkan llama.cpp
prebuilt instead of its detected CUDA/ROCm backend (e.g. so an AMD user can
run the Vulkan build for inference). Scoped to the llama.cpp backend; the
torch/training stack installs separately and still sees the real GPU.
"""
return os.environ.get("UNSLOTH_FORCE_VULKAN", "").strip().lower() in (
"1",
"true",
"yes",
)
def _vulkan_only_host(host: HostInfo) -> HostInfo:
"""Rewrite ``host`` so the asset selectors take their Vulkan branch.
That branch fires on ``has_intel_gpu and not nvidia and not rocm``, so clear
the CUDA/ROCm flags and raise the integrated-GPU flag. The synthetic flag
never leaves install planning -- it only routes the llama.cpp prebuilt
choice, not the torch/training stack.
"""
return dataclasses_replace(
host,
has_usable_nvidia = False,
has_physical_nvidia = False,
has_rocm = False,
has_intel_gpu = True,
)
def _route_to_vulkan_prebuilt(
host: HostInfo, published_repo: str, published_release_tag: str, *, force_cpu: bool
) -> tuple[HostInfo, str, str]:
"""Point a Vulkan-capable host at the upstream ggml-org Vulkan prebuilt.
The unsloth published repo ships only CUDA/ROCm/CPU assets, so Vulkan comes
from UPSTREAM_REPO. Two triggers route here, both suppressed under
--cpu-fallback (the explicit "give me CPU" last resort wins):
* UNSLOTH_FORCE_VULKAN forces Vulkan over the detected CUDA/ROCm backend;
* an auto-detected Intel GPU with NO physical NVIDIA/ROCm -- the purpose
of the has_intel_gpu probe, since the fork manifest ships no Vulkan asset.
Applied by BOTH the install path and the --resolve-prebuilt probe so the
"is a prebuilt available" answer matches what actually gets installed.
Returns the (possibly rewritten) host, repo, and release tag.
"""
forced = force_vulkan_requested()
# Gate auto-routing on no PHYSICAL NVIDIA, not merely no usable one: a mixed
# NVIDIA+Intel host that hides NVIDIA with CUDA_VISIBLE_DEVICES=""/-1 keeps
# has_physical_nvidia=True while has_usable_nvidia goes False. Vulkan ignores
# CUDA_VISIBLE_DEVICES, so auto-routing such a host would let it grab the
# reserved NVIDIA GPU. An explicit UNSLOTH_FORCE_VULKAN still overrides.
auto_intel = host.has_intel_gpu and not host.has_physical_nvidia and not host.has_rocm
if force_cpu or not (forced or auto_intel):
return host, published_repo, published_release_tag
if host.is_macos:
if forced:
log(
"UNSLOTH_FORCE_VULKAN is set but ignored on macOS "
"(Metal is used; there is no Vulkan prebuilt)"
)
return host, published_repo, published_release_tag
if forced:
log(
"UNSLOTH_FORCE_VULKAN is set; installing the upstream Vulkan "
"llama.cpp prebuilt instead of the detected GPU backend"
)
# Forcing may override a detected NVIDIA/ROCm host, so normalize it to
# Vulkan-only; an auto-detected Intel host already is.
host = _vulkan_only_host(host)
else:
log("Intel GPU detected; installing the upstream Vulkan llama.cpp prebuilt")
# Swapping the fork for upstream invalidates a fork release pin: the two use
# different tag namespaces (fork b9596-mix-<sha> vs upstream b9596), so a
# pinned fork tag would make the upstream resolver query a nonexistent
# release and fall back to source. Drop it and let the upstream resolver
# pick by the requested llama tag. A pin already on an explicit upstream repo
# (repo unchanged here) is preserved.
if published_repo != UPSTREAM_REPO:
published_release_tag = ""
return host, UPSTREAM_REPO, published_release_tag
def diffusion_visual_server_backfill_needed(
install_dir: Path, host: HostInfo, choice: AssetChoice
) -> bool:
@ -6703,6 +6832,9 @@ def install_prebuilt(
override_rocm_gfx = override_rocm_gfx,
force_cpu = force_cpu,
)
host, published_repo, published_release_tag = _route_to_vulkan_prebuilt(
host, published_repo, published_release_tag, force_cpu = force_cpu
)
choice: AssetChoice | None = None
try:
with install_lock(install_lock_path(install_dir)):
@ -6714,8 +6846,10 @@ def install_prebuilt(
log(
f"no existing llama.cpp install detected at {install_dir}; performing fresh prebuilt install"
)
# Single resolver: linux-x64 takes the fast filename path internally,
# every other fork host reads the manifest.
# Single resolver: every fork host selects from the release manifest;
# an explicit ggml-org override selects by asset filename instead. A
# forced-Vulkan host already has published_repo pointed at
# UPSTREAM_REPO above, so the resolver takes the Vulkan asset branch.
requested_tag, release_plans = resolve_simple_install_release_plans(
llama_tag,
host,
@ -6903,8 +7037,8 @@ def parse_args() -> argparse.Namespace:
const = "latest",
help = (
"Report whether an official prebuilt exists for this host without "
"downloading. Picks the host's published repo when --published-repo "
"is left at the default. Use --output-format json."
"downloading. Plans against --published-repo (defaults to the "
"fork). Use --output-format json."
),
)
parser.add_argument(
@ -6992,27 +7126,23 @@ def main() -> int:
return EXIT_SUCCESS
if args.resolve_prebuilt is not None:
# Host-aware "is a prebuilt available" probe, no download. A default repo
# means "pick the repo for this host"; PrebuiltFallback == source build.
# Host-aware "is a prebuilt available" probe, no download. Every host now
# plans against the fork (args.published_repo defaults to it); an explicit
# --published-repo overrides. PrebuiltFallback == source build.
host = _apply_host_overrides(
detect_host(),
override_has_rocm = args.has_rocm,
override_rocm_gfx = args.rocm_gfx,
force_cpu = args.cpu_fallback,
)
# setup.sh routes Linux hosts with AMD tooling to the fork even when no GPU
# is probed; mirror that so a HIP source build is not offered a CPU prebuilt.
amd_tooling = host.is_linux and any(
shutil.which(t) for t in ("rocminfo", "amd-smi", "hipconfig", "hipinfo")
)
repo = (
published_repo_for_host(host, linux_amd_tooling_present = amd_tooling)
if args.published_repo == DEFAULT_PUBLISHED_REPO
else args.published_repo
# Same Vulkan routing the install path applies, so the probe's answer
# matches what would install (an Intel/forced-Vulkan host -> upstream).
host, repo, release_tag = _route_to_vulkan_prebuilt(
host, args.published_repo, args.published_release_tag or "", force_cpu = args.cpu_fallback
)
try:
_requested, plans = resolve_simple_install_release_plans(
args.resolve_prebuilt, host, repo, args.published_release_tag or ""
args.resolve_prebuilt, host, repo, release_tag
)
choice = plans[0].attempts[0] if plans and plans[0].attempts else None
if choice is None:

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