Merge branch 'main' into pip
This commit is contained in:
commit
2297f73cad
68 changed files with 10223 additions and 932 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
19
install.ps1
19
install.ps1
|
|
@ -749,7 +749,6 @@ shell.Run cmd, 0, False
|
|||
} else {
|
||||
step "gpu" "none (chat-only / GGUF)" "Yellow"
|
||||
substep "Training and GPU inference require an NVIDIA GPU with drivers installed." "Yellow"
|
||||
substep "https://www.nvidia.com/Download/index.aspx" "Yellow"
|
||||
}
|
||||
|
||||
# ── Choose the correct PyTorch index URL based on driver CUDA version ──
|
||||
|
|
@ -777,10 +776,10 @@ shell.Run cmd, 0, False
|
|||
# ── Print CPU-only hint when no GPU detected ──
|
||||
if (-not $SkipTorch -and $TorchIndexUrl -like "*/cpu") {
|
||||
Write-Host ""
|
||||
Write-Host " NOTE: No NVIDIA GPU detected." -ForegroundColor Yellow
|
||||
Write-Host " Installing CPU-only PyTorch. If you only need GGUF chat/inference,"
|
||||
Write-Host " re-run with --no-torch for a faster, lighter install:"
|
||||
Write-Host " .\install.ps1 --no-torch"
|
||||
substep "No NVIDIA GPU detected." "Yellow"
|
||||
substep "Installing CPU-only PyTorch. If you only need GGUF chat/inference," "Yellow"
|
||||
substep "re-run with --no-torch for a faster, lighter install:" "Yellow"
|
||||
substep ".\install.ps1 --no-torch" "Yellow"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
|
|
@ -820,7 +819,7 @@ shell.Run cmd, 0, False
|
|||
if ($SkipTorch) {
|
||||
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
||||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.16" unsloth-zoo }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.18" unsloth-zoo }
|
||||
if ($baseInstallExit -eq 0) {
|
||||
$NoTorchReq = Find-NoTorchRuntimeFile
|
||||
if ($NoTorchReq) {
|
||||
|
|
@ -828,7 +827,7 @@ shell.Run cmd, 0, False
|
|||
}
|
||||
}
|
||||
} else {
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.16" unsloth-zoo }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.18" unsloth-zoo }
|
||||
}
|
||||
if ($baseInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
||||
|
|
@ -858,7 +857,7 @@ shell.Run cmd, 0, False
|
|||
if ($SkipTorch) {
|
||||
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
||||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.3.16" unsloth-zoo }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.3.18" unsloth-zoo }
|
||||
if ($baseInstallExit -eq 0) {
|
||||
$NoTorchReq = Find-NoTorchRuntimeFile
|
||||
if ($NoTorchReq) {
|
||||
|
|
@ -866,7 +865,7 @@ shell.Run cmd, 0, False
|
|||
}
|
||||
}
|
||||
} elseif ($StudioLocalInstall) {
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.3.16" unsloth-zoo }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.3.18" unsloth-zoo }
|
||||
} else {
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "$PackageName" }
|
||||
}
|
||||
|
|
@ -887,7 +886,7 @@ shell.Run cmd, 0, False
|
|||
# Fallback: GPU detection failed to produce a URL -- let uv resolve torch
|
||||
substep "installing unsloth (this may take a few minutes)..."
|
||||
if ($StudioLocalInstall) {
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.3.16" --torch-backend=auto }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.3.18" --torch-backend=auto }
|
||||
if ($baseInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
||||
return
|
||||
|
|
|
|||
23
install.sh
23
install.sh
|
|
@ -949,6 +949,17 @@ if [ -x "$VENV_DIR/bin/python" ]; then
|
|||
substep "${VENV_DIR}"
|
||||
fi
|
||||
|
||||
# Default torch constraint -- tightened for Python 3.13+ on arm64 macOS
|
||||
# (torch <2.6 has no cp313 macOS arm64 wheels)
|
||||
TORCH_CONSTRAINT="torch>=2.4,<2.11.0"
|
||||
if [ "$SKIP_TORCH" = false ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then
|
||||
_PY_MINOR=$("$VENV_DIR/bin/python" -c \
|
||||
"import sys; print(sys.version_info.minor)" 2>/dev/null || echo "0")
|
||||
if [ "$_PY_MINOR" -ge 13 ] 2>/dev/null; then
|
||||
TORCH_CONSTRAINT="torch>=2.6,<2.11.0"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Resolve repo root (for --local installs) ──
|
||||
_REPO_ROOT="$(cd "$(dirname "$0" 2>/dev/null || echo ".")" && pwd)"
|
||||
|
||||
|
|
@ -1029,7 +1040,7 @@ if [ "$_MIGRATED" = true ]; then
|
|||
# to prevent transitive torch resolution.
|
||||
run_install_cmd "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
|
||||
--reinstall-package unsloth --reinstall-package unsloth-zoo \
|
||||
"unsloth>=2026.3.16" unsloth-zoo
|
||||
"unsloth>=2026.3.18" unsloth-zoo
|
||||
_NO_TORCH_RT="$(_find_no_torch_runtime)"
|
||||
if [ -n "$_NO_TORCH_RT" ]; then
|
||||
run_install_cmd "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT"
|
||||
|
|
@ -1037,7 +1048,7 @@ if [ "$_MIGRATED" = true ]; then
|
|||
else
|
||||
run_install_cmd "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \
|
||||
--reinstall-package unsloth --reinstall-package unsloth-zoo \
|
||||
"unsloth>=2026.3.16" unsloth-zoo
|
||||
"unsloth>=2026.3.18" unsloth-zoo
|
||||
fi
|
||||
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
||||
substep "overlaying local repo (editable)..."
|
||||
|
|
@ -1049,7 +1060,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
|
|||
substep "skipping PyTorch (--no-torch or Intel Mac x86_64)." "$C_WARN"
|
||||
else
|
||||
substep "installing PyTorch ($TORCH_INDEX_URL)..."
|
||||
run_install_cmd "install PyTorch" uv pip install --python "$_VENV_PY" "torch>=2.4,<2.11.0" torchvision torchaudio \
|
||||
run_install_cmd "install PyTorch" uv pip install --python "$_VENV_PY" "$TORCH_CONSTRAINT" torchvision torchaudio \
|
||||
--index-url "$TORCH_INDEX_URL"
|
||||
fi
|
||||
# Fresh: Step 2 - install unsloth, preserving pre-installed torch
|
||||
|
|
@ -1059,7 +1070,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
|
|||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
run_install_cmd "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
|
||||
--upgrade-package unsloth --upgrade-package unsloth-zoo \
|
||||
"unsloth>=2026.3.16" unsloth-zoo
|
||||
"unsloth>=2026.3.18" unsloth-zoo
|
||||
_NO_TORCH_RT="$(_find_no_torch_runtime)"
|
||||
if [ -n "$_NO_TORCH_RT" ]; then
|
||||
run_install_cmd "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT"
|
||||
|
|
@ -1070,7 +1081,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
|
|||
fi
|
||||
elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
||||
run_install_cmd "install unsloth (local)" uv pip install --python "$_VENV_PY" \
|
||||
--upgrade-package unsloth "unsloth>=2026.3.16" unsloth-zoo
|
||||
--upgrade-package unsloth "unsloth>=2026.3.18" unsloth-zoo
|
||||
substep "overlaying local repo (editable)..."
|
||||
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
|
||||
else
|
||||
|
|
@ -1081,7 +1092,7 @@ else
|
|||
# Fallback: GPU detection failed to produce a URL -- let uv resolve torch
|
||||
substep "installing unsloth (this may take a few minutes)..."
|
||||
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
||||
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.3.16" --torch-backend=auto
|
||||
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.3.18" --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
|
||||
else
|
||||
|
|
|
|||
215
install_gemma4_mlx.sh
Executable file
215
install_gemma4_mlx.sh
Executable file
|
|
@ -0,0 +1,215 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# ============================================================
|
||||
# Gemma 4 MLX — One-command setup + inference
|
||||
#
|
||||
# Usage:
|
||||
# bash install_gemma4_mlx.sh [--venv-dir DIR]
|
||||
#
|
||||
# This script:
|
||||
# 1. Creates a Python virtual environment
|
||||
# 2. Installs uv, mlx, mlx-lm, transformers
|
||||
# 3. Downloads gemma4.py and gemma4_text.py from unsloth repo
|
||||
# 4. Installs them into mlx-lm's models directory
|
||||
# ============================================================
|
||||
|
||||
# ── Output style (inspired by unsloth/install.sh) ─────────────
|
||||
RULE=""
|
||||
_rule_i=0
|
||||
while [ "$_rule_i" -lt 52 ]; do
|
||||
RULE="${RULE}─"
|
||||
_rule_i=$((_rule_i + 1))
|
||||
done
|
||||
|
||||
if [ -n "${NO_COLOR:-}" ]; then
|
||||
C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST=
|
||||
elif [ -t 1 ] || [ -n "${FORCE_COLOR:-}" ]; then
|
||||
_ESC="$(printf '\033')"
|
||||
C_TITLE="${_ESC}[38;5;117m"
|
||||
C_DIM="${_ESC}[38;5;245m"
|
||||
C_OK="${_ESC}[38;5;108m"
|
||||
C_WARN="${_ESC}[38;5;136m"
|
||||
C_ERR="${_ESC}[91m"
|
||||
C_RST="${_ESC}[0m"
|
||||
else
|
||||
C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST=
|
||||
fi
|
||||
|
||||
step() { printf " ${C_DIM}%-18.18s${C_RST}${3:-$C_OK}%s${C_RST}\n" "$1" "$2"; }
|
||||
substep() { printf " ${C_DIM}%-18s${2:-$C_DIM}%s${C_RST}\n" "" "$1"; }
|
||||
fail() { step "error" "$1" "$C_ERR"; exit 1; }
|
||||
|
||||
# ── Parse flags ───────────────────────────────────────────────
|
||||
VENV_DIR=""
|
||||
_next_is_venv=false
|
||||
|
||||
for arg in "$@"; do
|
||||
if [ "$_next_is_venv" = true ]; then
|
||||
VENV_DIR="$arg"
|
||||
_next_is_venv=false
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--venv-dir) _next_is_venv=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Default venv location
|
||||
if [ -z "$VENV_DIR" ]; then
|
||||
VENV_DIR="$HOME/.unsloth/unsloth_gemma4_mlx"
|
||||
fi
|
||||
|
||||
# ── Banner ────────────────────────────────────────────────────
|
||||
echo ""
|
||||
printf " ${C_TITLE}%s${C_RST}\n" "💎 Gemma 4 MLX Installer"
|
||||
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
|
||||
echo ""
|
||||
|
||||
# ── Platform check ────────────────────────────────────────────
|
||||
if [ "$(uname)" != "Darwin" ]; then
|
||||
fail "MLX requires macOS with Apple Silicon. Detected: $(uname)"
|
||||
fi
|
||||
|
||||
_ARCH=$(uname -m)
|
||||
if [ "$_ARCH" != "arm64" ]; then
|
||||
step "warning" "Apple Silicon recommended (detected: $_ARCH)" "$C_WARN"
|
||||
fi
|
||||
|
||||
step "platform" "macOS ($_ARCH)"
|
||||
|
||||
# ── Detect Python ─────────────────────────────────────────────
|
||||
PYTHON=""
|
||||
for _candidate in python3.12 python3.11 python3.13 python3; do
|
||||
if command -v "$_candidate" >/dev/null 2>&1; then
|
||||
PYTHON="$_candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$PYTHON" ]; then
|
||||
fail "Python 3 not found. Install via: brew install python@3.12"
|
||||
fi
|
||||
|
||||
_PY_VERSION=$("$PYTHON" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')")
|
||||
step "python" "$PYTHON ($_PY_VERSION)"
|
||||
|
||||
# ── Create virtual environment ────────────────────────────────
|
||||
if [ -x "$VENV_DIR/bin/python" ]; then
|
||||
step "venv" "using existing environment"
|
||||
substep "$VENV_DIR"
|
||||
else
|
||||
step "venv" "creating virtual environment"
|
||||
substep "$VENV_DIR"
|
||||
mkdir -p "$(dirname "$VENV_DIR")"
|
||||
"$PYTHON" -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
# ── Install uv ───────────────────────────────────────────────
|
||||
if ! command -v uv >/dev/null 2>&1; then
|
||||
step "uv" "installing uv package manager..."
|
||||
_uv_tmp=$(mktemp)
|
||||
curl -LsSf "https://astral.sh/uv/install.sh" -o "$_uv_tmp"
|
||||
sh "$_uv_tmp" </dev/null >/dev/null 2>&1
|
||||
rm -f "$_uv_tmp"
|
||||
if [ -f "$HOME/.local/bin/env" ]; then
|
||||
. "$HOME/.local/bin/env"
|
||||
fi
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
substep "done"
|
||||
else
|
||||
step "uv" "found $(uv --version 2>/dev/null || echo 'uv')"
|
||||
fi
|
||||
|
||||
_VENV_PY="$VENV_DIR/bin/python"
|
||||
|
||||
# ── Repo config ──────────────────────────────────────────────
|
||||
BRANCH="fix/ui-fix"
|
||||
REPO_URL="https://raw.githubusercontent.com/unslothai/unsloth/refs/heads/${BRANCH}"
|
||||
|
||||
# ── Install dependencies ──────────────────────────────────────
|
||||
step "install" "installing mlx, mlx-lm..."
|
||||
uv pip install --python "$_VENV_PY" -q mlx mlx-lm 2>/dev/null
|
||||
substep "done"
|
||||
|
||||
TRANSFORMERS_WHL="transformers-5.5.0-py3-none-any.whl"
|
||||
TRANSFORMERS_GH="git+https://github.com/huggingface/transformers.git@v5.5-release"
|
||||
|
||||
step "install" "installing transformers>=5.5.0..."
|
||||
if uv pip install --python "$_VENV_PY" -q "$TRANSFORMERS_GH" 2>/dev/null; then
|
||||
substep "installed from huggingface/transformers v5.5-release"
|
||||
elif uv pip install --python "$_VENV_PY" -q "transformers>=5.5.0" 2>/dev/null; then
|
||||
substep "installed from PyPI"
|
||||
else
|
||||
substep "not on PyPI, trying unsloth branch..."
|
||||
_whl_tmp=$(mktemp -d)/"${TRANSFORMERS_WHL}"
|
||||
if curl -fsSL "${REPO_URL}/${TRANSFORMERS_WHL}" -o "$_whl_tmp" 2>/dev/null && \
|
||||
uv pip install --python "$_VENV_PY" -q "$_whl_tmp" 2>/dev/null; then
|
||||
substep "installed from branch ${BRANCH}"
|
||||
elif [ -f "./${TRANSFORMERS_WHL}" ]; then
|
||||
substep "using local ./${TRANSFORMERS_WHL}"
|
||||
uv pip install --python "$_VENV_PY" -q "./${TRANSFORMERS_WHL}"
|
||||
else
|
||||
rm -f "$_whl_tmp" 2>/dev/null
|
||||
step "install" "skipping transformers — could not find >=5.5.0" "$C_WARN"
|
||||
substep "tried: huggingface/transformers v5.5-release, PyPI, branch ${BRANCH}, local ./${TRANSFORMERS_WHL}"
|
||||
fi
|
||||
rm -f "$_whl_tmp" 2>/dev/null
|
||||
fi
|
||||
|
||||
# ── Find mlx-lm models directory ─────────────────────────────
|
||||
MLX_MODELS=$("$_VENV_PY" -c "import mlx_lm; print(mlx_lm.__path__[0])")/models
|
||||
step "models dir" "$MLX_MODELS"
|
||||
|
||||
# ── Download and install Gemma 4 model files ──────────────────
|
||||
|
||||
step "download" "installing Gemma 4 model files..."
|
||||
|
||||
_install_model_file() {
|
||||
_fname="$1"
|
||||
if curl -fsSL "${REPO_URL}/unsloth/models/${_fname}" -o "${MLX_MODELS}/${_fname}" 2>/dev/null; then
|
||||
substep "downloaded ${_fname} from branch ${BRANCH}"
|
||||
elif [ -f "./${_fname}" ]; then
|
||||
substep "using local ./${_fname}"
|
||||
cp "./${_fname}" "${MLX_MODELS}/${_fname}"
|
||||
else
|
||||
fail "Could not install ${_fname}. Tried:
|
||||
1) ${REPO_URL}/unsloth/models/${_fname}
|
||||
2) Local file ./${_fname}
|
||||
|
||||
To fix, download the file manually and place it in the current directory,
|
||||
then re-run this script."
|
||||
fi
|
||||
}
|
||||
|
||||
_install_model_file "gemma4.py"
|
||||
_install_model_file "gemma4_text.py"
|
||||
|
||||
# Verify files were installed correctly
|
||||
if "$_VENV_PY" -c "from mlx_lm.models.gemma4_text import ProportionalRoPE" 2>/dev/null; then
|
||||
substep "model files verified"
|
||||
else
|
||||
fail "Model files installed but verification failed (ProportionalRoPE import error).
|
||||
Try manually from: https://github.com/unslothai/unsloth/tree/feature/${BRANCH}"
|
||||
fi
|
||||
|
||||
# ── Done ──────────────────────────────────────────────────────
|
||||
echo ""
|
||||
printf " ${C_TITLE}%s${C_RST}\n" "Gemma 4 MLX installed!"
|
||||
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
|
||||
echo ""
|
||||
step "available models" "unsloth/gemma-4-E2B-it-UD-MLX-4bit (/BF16)"
|
||||
substep "unsloth/gemma-4-E4B-it-UD-MLX-4bit (/BF16)"
|
||||
echo ""
|
||||
step "venv activate" "source ${VENV_DIR}/bin/activate"
|
||||
echo ""
|
||||
step "quick start" "python -m mlx_lm chat --model unsloth/gemma-4-E2B-it-UD-MLX-4bit --max-tokens 200"
|
||||
echo ""
|
||||
step "python API" "from mlx_lm import load, generate"
|
||||
substep "model, tokenizer = load('unsloth/gemma-4-E2B-it-UD-MLX-4bit')"
|
||||
substep "messages = [{'role': 'user', 'content': 'Hello!'}]"
|
||||
substep "prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)"
|
||||
substep "print(generate(model, tokenizer, prompt=prompt, max_tokens=200))"
|
||||
echo ""
|
||||
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
|
||||
echo ""
|
||||
|
|
@ -17,18 +17,26 @@ from html.parser import HTMLParser
|
|||
|
||||
__all__ = ["html_to_markdown"]
|
||||
|
||||
_SKIP_TAGS = frozenset({"script", "style", "head", "noscript", "svg", "math"})
|
||||
_SKIP_TAGS = frozenset(
|
||||
{
|
||||
"script",
|
||||
"style",
|
||||
"head",
|
||||
"noscript",
|
||||
"svg",
|
||||
"math",
|
||||
"nav",
|
||||
"footer",
|
||||
}
|
||||
)
|
||||
_BLOCK_TAGS = frozenset(
|
||||
{
|
||||
"p",
|
||||
"div",
|
||||
"section",
|
||||
"article",
|
||||
"header",
|
||||
"footer",
|
||||
"main",
|
||||
"aside",
|
||||
"nav",
|
||||
"figure",
|
||||
"figcaption",
|
||||
"details",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,14 @@
|
|||
import utils.hardware.hardware as hw
|
||||
|
||||
DEFAULT_MODELS_GGUF = [
|
||||
"unsloth/gemma-4-E2B-it-GGUF",
|
||||
"unsloth/gemma-4-E4B-it-GGUF",
|
||||
"unsloth/gemma-4-31B-it-GGUF",
|
||||
"unsloth/gemma-4-26B-A4B-it-GGUF",
|
||||
"unsloth/Qwen3.5-4B-GGUF",
|
||||
"unsloth/Qwen3.5-9B-GGUF",
|
||||
"unsloth/Qwen3.5-35B-A3B-GGUF",
|
||||
"unsloth/Qwen3.5-0.8B-GGUF",
|
||||
"unsloth/Llama-3.2-1B-Instruct-GGUF",
|
||||
"unsloth/Llama-3.2-3B-Instruct-GGUF",
|
||||
"unsloth/Llama-3.1-8B-Instruct-GGUF",
|
||||
|
|
@ -15,6 +23,18 @@ DEFAULT_MODELS_GGUF = [
|
|||
]
|
||||
|
||||
DEFAULT_MODELS_STANDARD = [
|
||||
"unsloth/gemma-4-E2B-it-GGUF",
|
||||
"unsloth/gemma-4-E4B-it-GGUF",
|
||||
"unsloth/gemma-4-31B-it-GGUF",
|
||||
"unsloth/gemma-4-26B-A4B-it-GGUF",
|
||||
"unsloth/Qwen3.5-4B-GGUF",
|
||||
"unsloth/Qwen3.5-9B-GGUF",
|
||||
"unsloth/Qwen3.5-35B-A3B-GGUF",
|
||||
"unsloth/Qwen3.5-0.8B-GGUF",
|
||||
"unsloth/gemma-4-E2B-it",
|
||||
"unsloth/gemma-4-E4B-it",
|
||||
"unsloth/gemma-4-31B-it",
|
||||
"unsloth/gemma-4-26B-A4B-it",
|
||||
"unsloth/Qwen3-4B-Instruct-2507",
|
||||
"unsloth/Meta-Llama-3.1-8B-Instruct-bnb-4bit",
|
||||
"unsloth/Mistral-Nemo-Instruct-2407-bnb-4bit",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,52 @@ import httpx
|
|||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# ── Pre-compiled patterns for plan-without-action re-prompt ──
|
||||
# Forward-looking intent signals that indicate the model is
|
||||
# describing what it *will* do rather than giving a final answer.
|
||||
_INTENT_SIGNAL = re.compile(
|
||||
r"(?i)("
|
||||
# Direct intent: "I'll ...", "I will ...", "Let me ...", "I am going to ..."
|
||||
# Handles both straight and curly apostrophes.
|
||||
# Excludes "I can", "I should", "I want to", "let's" which
|
||||
# appear frequently in direct answers / explanations.
|
||||
r"\b(i['\u2019](ll|m going to|m gonna)|i am (going to|gonna)|i will|i shall|let me|allow me)\b"
|
||||
r"|"
|
||||
# Step/plan framing: "First ...", "Step 1:", "Here's my plan"
|
||||
r"\b(?:first\b|step \d+:?|here['\u2019]?s (?:my |the |a )?(?:plan|approach))"
|
||||
r"|"
|
||||
# "Now I" / "Next I" patterns
|
||||
r"\b(?:now i|next i)\b"
|
||||
r")"
|
||||
)
|
||||
_MAX_REPROMPTS = 3
|
||||
_REPROMPT_MAX_CHARS = 2000
|
||||
|
||||
# ── Pre-compiled patterns for GGUF shard detection ───────────
|
||||
_SHARD_FULL_RE = re.compile(r"^(.*)-(\d{5})-of-(\d{5})\.gguf$")
|
||||
_SHARD_RE = re.compile(r"^(.*)-\d{5}-of-\d{5}\.gguf$")
|
||||
|
||||
# Model size extraction (shared with routes/inference.py)
|
||||
from utils.models import extract_model_size_b as _extract_model_size_b
|
||||
|
||||
# ── Pre-compiled patterns for tool XML stripping ─────────────
|
||||
_TOOL_CLOSED_PATS = [
|
||||
re.compile(r"<tool_call>.*?</tool_call>", re.DOTALL),
|
||||
re.compile(r"<function=\w+>.*?</function>", re.DOTALL),
|
||||
]
|
||||
_TOOL_ALL_PATS = _TOOL_CLOSED_PATS + [
|
||||
re.compile(r"<tool_call>.*$", re.DOTALL),
|
||||
re.compile(r"<function=\w+>.*$", re.DOTALL),
|
||||
]
|
||||
|
||||
# ── Pre-compiled patterns for tool-call XML parsing ──────────
|
||||
_TC_JSON_START_RE = re.compile(r"<tool_call>\s*\{")
|
||||
_TC_FUNC_START_RE = re.compile(r"<function=(\w+)>\s*")
|
||||
_TC_END_TAG_RE = re.compile(r"</tool_call>")
|
||||
_TC_FUNC_CLOSE_RE = re.compile(r"\s*</function>\s*$")
|
||||
_TC_PARAM_START_RE = re.compile(r"<parameter=(\w+)>\s*")
|
||||
_TC_PARAM_CLOSE_RE = re.compile(r"\s*</parameter>\s*$")
|
||||
|
||||
|
||||
class LlamaCppBackend:
|
||||
"""
|
||||
|
|
@ -61,6 +107,15 @@ class LlamaCppBackend:
|
|||
self._n_kv_heads: Optional[int] = None
|
||||
self._n_heads: Optional[int] = None
|
||||
self._embedding_length: Optional[int] = None
|
||||
# Architecture-aware KV fields (8 new fields for 5-path estimation)
|
||||
self._kv_key_length: Optional[int] = None
|
||||
self._kv_value_length: Optional[int] = None
|
||||
self._sliding_window: Optional[int] = None
|
||||
self._full_attention_interval: Optional[int] = None
|
||||
self._kv_lora_rank: Optional[int] = None
|
||||
self._key_length_mla: Optional[int] = None
|
||||
self._ssm_inner_size: Optional[int] = None
|
||||
self._ssm_state_size: Optional[int] = None
|
||||
self._lock = threading.Lock()
|
||||
self._stdout_lines: list[str] = []
|
||||
self._stdout_thread: Optional[threading.Thread] = None
|
||||
|
|
@ -107,6 +162,11 @@ class LlamaCppBackend:
|
|||
"""Return the maximum context currently available on this hardware."""
|
||||
return self._max_context_length or self._context_length
|
||||
|
||||
@property
|
||||
def native_context_length(self) -> Optional[int]:
|
||||
"""Return the model's native context length from GGUF metadata."""
|
||||
return self._context_length
|
||||
|
||||
@property
|
||||
def chat_template(self) -> Optional[str]:
|
||||
return self._chat_template
|
||||
|
|
@ -228,14 +288,11 @@ class LlamaCppBackend:
|
|||
@staticmethod
|
||||
def _get_gguf_size_bytes(model_path: str) -> int:
|
||||
"""Get total GGUF size in bytes, including split shards."""
|
||||
import re
|
||||
|
||||
main = Path(model_path)
|
||||
total = main.stat().st_size
|
||||
|
||||
# Check for split shards (e.g., model-00001-of-00003.gguf)
|
||||
shard_pat = re.compile(r"^(.*)-(\d{5})-of-(\d{5})\.gguf$")
|
||||
m = shard_pat.match(main.name)
|
||||
m = _SHARD_FULL_RE.match(main.name)
|
||||
if m:
|
||||
prefix, _, num_total = m.group(1), m.group(2), m.group(3)
|
||||
sibling_pat = re.compile(
|
||||
|
|
@ -306,11 +363,11 @@ class LlamaCppBackend:
|
|||
"""Pick GPU(s) for a model based on estimated VRAM and free memory.
|
||||
|
||||
``model_size_bytes`` should include both model weights and estimated
|
||||
KV cache. The 70% threshold provides headroom for compute buffers,
|
||||
KV cache. The 90% threshold provides headroom for compute buffers,
|
||||
CUDA context, and other runtime overhead.
|
||||
|
||||
Returns (gpu_indices, use_fit):
|
||||
- ([1], False) model fits on 1 GPU at 70% of free
|
||||
- ([1], False) model fits on 1 GPU at 90% of free
|
||||
- ([1, 2], False) model needs 2 GPUs
|
||||
- (None, True) model too large, let --fit handle it
|
||||
"""
|
||||
|
|
@ -322,8 +379,8 @@ class LlamaCppBackend:
|
|||
# Sort GPUs by free memory descending
|
||||
ranked = sorted(gpus, key = lambda g: g[1], reverse = True)
|
||||
|
||||
# Try fitting on 1 GPU (70% of free memory threshold)
|
||||
if ranked[0][1] * 0.70 >= model_size_mib:
|
||||
# Try fitting on 1 GPU (90% of free memory threshold)
|
||||
if ranked[0][1] * 0.90 >= model_size_mib:
|
||||
return [ranked[0][0]], False
|
||||
|
||||
# Try fitting on N GPUs (accumulate free memory from most-free)
|
||||
|
|
@ -331,7 +388,7 @@ class LlamaCppBackend:
|
|||
selected = []
|
||||
for idx, free_mib in ranked:
|
||||
selected.append(idx)
|
||||
cumulative += free_mib * 0.70
|
||||
cumulative += free_mib * 0.90
|
||||
if cumulative >= model_size_mib:
|
||||
return sorted(selected), False
|
||||
|
||||
|
|
@ -347,10 +404,17 @@ class LlamaCppBackend:
|
|||
|
||||
def _can_estimate_kv(self) -> bool:
|
||||
"""True if we have enough GGUF metadata to estimate KV cache size."""
|
||||
return (
|
||||
self._n_layers is not None
|
||||
and self._embedding_length is not None
|
||||
and (self._n_kv_heads is not None or self._n_heads is not None)
|
||||
if self._n_layers is None:
|
||||
return False
|
||||
# MLA: kv_lora_rank is sufficient (K-only cache)
|
||||
if self._kv_lora_rank is not None:
|
||||
return True
|
||||
# New-style: need both explicit key AND value dimensions
|
||||
if self._kv_key_length is not None and self._kv_value_length is not None:
|
||||
return True
|
||||
# Legacy: need embedding_length + head count
|
||||
return self._embedding_length is not None and (
|
||||
self._n_kv_heads is not None or self._n_heads is not None
|
||||
)
|
||||
|
||||
def _estimate_kv_cache_bytes(
|
||||
|
|
@ -358,14 +422,20 @@ class LlamaCppBackend:
|
|||
) -> int:
|
||||
"""Estimate KV cache VRAM for a given context length.
|
||||
|
||||
Uses 5-path architecture-aware estimation:
|
||||
1. MLA -- compressed KV latent + RoPE, K-only (no separate V)
|
||||
2. Hybrid -- only attention layers need KV (Mamba layers don't)
|
||||
3. SWA -- sliding-window layers cache min(ctx, window) tokens
|
||||
4. GQA -- standard full KV with explicit key/value dimensions
|
||||
5. Legacy -- fallback using embed // n_heads
|
||||
|
||||
Returns 0 if metadata is insufficient for estimation.
|
||||
"""
|
||||
if not self._can_estimate_kv() or n_ctx <= 0:
|
||||
return 0
|
||||
|
||||
n_layers = self._n_layers # type: ignore[assignment]
|
||||
n_kv_heads = self._n_kv_heads or self._n_heads # type: ignore[assignment]
|
||||
head_dim = self._embedding_length // self._n_heads if self._n_heads else 128 # type: ignore[operator]
|
||||
n_kv = self._n_kv_heads or self._n_heads or 1 # type: ignore[assignment]
|
||||
|
||||
# Bytes per element depends on KV cache quantization
|
||||
bpe = {
|
||||
|
|
@ -380,8 +450,60 @@ class LlamaCppBackend:
|
|||
"iq4_nl": 0.5625,
|
||||
}.get(cache_type_kv or "f16", 2.0)
|
||||
|
||||
# K + V caches: 2 * n_kv_heads * head_dim * n_layers * n_ctx * bpe
|
||||
return int(2 * n_kv_heads * head_dim * n_layers * n_ctx * bpe)
|
||||
# Path 1: MLA (DeepSeek-V2/V3, GLM-4.7, GLM-5, Kimi-K2.5)
|
||||
# MLA stores one compressed KV latent per token/layer (shared across heads).
|
||||
# V is reconstructed from the latent on the fly -- no separate V cache.
|
||||
# key_length = kv_lora_rank + rope_dim (the full compressed representation).
|
||||
# MLA GGUFs set head_count_kv=1; default to 1 if absent to avoid
|
||||
# falling back to n_heads (e.g., 128 for DeepSeek-V3) which would 128x.
|
||||
if self._kv_lora_rank is not None:
|
||||
n_kv_mla = self._n_kv_heads or 1
|
||||
rope_dim = self._key_length_mla or 64
|
||||
key_len = self._kv_key_length or (self._kv_lora_rank + rope_dim)
|
||||
return int(n_layers * n_ctx * n_kv_mla * key_len * bpe)
|
||||
|
||||
key_len = self._kv_key_length
|
||||
val_len = self._kv_value_length
|
||||
|
||||
# Path 2: Hybrid Mamba/Attention (Qwen3.5-27B, Qwen3.5-35B-A3B)
|
||||
# Only 1 in N layers is attention; the rest are Mamba (no KV cache).
|
||||
if (
|
||||
self._ssm_inner_size is not None
|
||||
and self._full_attention_interval is not None
|
||||
):
|
||||
fai = self._full_attention_interval
|
||||
n_attn = -(-n_layers // fai) if fai > 0 else n_layers # ceiling division
|
||||
if key_len is not None and val_len is not None:
|
||||
return int(n_attn * n_ctx * n_kv * (key_len + val_len) * bpe)
|
||||
head_dim = self._embedding_length // self._n_heads if self._n_heads else 128 # type: ignore[operator]
|
||||
return int(n_attn * n_ctx * n_kv * 2 * head_dim * bpe)
|
||||
|
||||
# Path 3: Sliding Window (Gemma-3, gpt-oss)
|
||||
# SWA layers only cache min(ctx, window) tokens; global layers cache full ctx.
|
||||
# Most SWA architectures use few global layers (e.g., Gemma-3 uses 1 in 6).
|
||||
# Without an explicit field, we conservatively assume 1/4 of layers are global
|
||||
# which is still far more accurate than the legacy formula (which ignores SWA).
|
||||
if (
|
||||
self._sliding_window is not None
|
||||
and self._sliding_window > 0
|
||||
and key_len is not None
|
||||
and val_len is not None
|
||||
):
|
||||
swa = self._sliding_window
|
||||
n_global = max(1, n_layers // 4)
|
||||
n_swa = n_layers - n_global
|
||||
kv_per_token = n_kv * (key_len + val_len) * bpe
|
||||
return int(
|
||||
n_global * n_ctx * kv_per_token + n_swa * min(n_ctx, swa) * kv_per_token
|
||||
)
|
||||
|
||||
# Path 4: Standard GQA with explicit key/value dimensions
|
||||
if key_len is not None and val_len is not None:
|
||||
return int(n_layers * n_ctx * n_kv * (key_len + val_len) * bpe)
|
||||
|
||||
# Path 5: Legacy fallback (old GGUFs without explicit dimensions)
|
||||
head_dim = self._embedding_length // self._n_heads if self._n_heads else 128 # type: ignore[operator]
|
||||
return int(2 * n_kv * head_dim * n_layers * n_ctx * bpe)
|
||||
|
||||
def _fit_context_to_vram(
|
||||
self,
|
||||
|
|
@ -393,8 +515,8 @@ class LlamaCppBackend:
|
|||
) -> int:
|
||||
"""Return the largest context length that fits in GPU VRAM.
|
||||
|
||||
Uses 70% of available VRAM as the budget (matching _select_gpus
|
||||
threshold -- 30% reserved for compute buffers, CUDA context,
|
||||
Uses 90% of available VRAM as the budget (matching _select_gpus
|
||||
threshold -- 10% reserved for compute buffers, CUDA context,
|
||||
scratch space, flash-attn workspace, etc.).
|
||||
If the model weights alone don't fit, returns min_ctx unchanged.
|
||||
"""
|
||||
|
|
@ -406,7 +528,7 @@ class LlamaCppBackend:
|
|||
)
|
||||
return requested_ctx
|
||||
|
||||
budget_bytes = available_mib * 1024 * 1024 * 0.70
|
||||
budget_bytes = available_mib * 1024 * 1024 * 0.90
|
||||
model_footprint = model_size_bytes
|
||||
|
||||
# Check if requested context already fits
|
||||
|
|
@ -460,8 +582,6 @@ class LlamaCppBackend:
|
|||
|
||||
Returns (first_shard_filename, total_size_bytes) or None if nothing fits.
|
||||
"""
|
||||
import re
|
||||
|
||||
try:
|
||||
from huggingface_hub import get_paths_info, list_repo_files
|
||||
|
||||
|
|
@ -477,10 +597,9 @@ class LlamaCppBackend:
|
|||
size_map = {p.path: (p.size or 0) for p in path_infos}
|
||||
|
||||
# Group files by variant: shards share a prefix before -NNNNN-of-NNNNN
|
||||
shard_pat = re.compile(r"^(.*)-\d{5}-of-\d{5}\.gguf$")
|
||||
variants: dict[str, list[str]] = {}
|
||||
for f in gguf_files:
|
||||
m = shard_pat.match(f)
|
||||
m = _SHARD_RE.match(f)
|
||||
key = m.group(1) if m else f
|
||||
variants.setdefault(key, []).append(f)
|
||||
|
||||
|
|
@ -585,6 +704,14 @@ class LlamaCppBackend:
|
|||
self._n_kv_heads = None
|
||||
self._n_heads = None
|
||||
self._embedding_length = None
|
||||
self._kv_key_length = None
|
||||
self._kv_value_length = None
|
||||
self._sliding_window = None
|
||||
self._full_attention_interval = None
|
||||
self._kv_lora_rank = None
|
||||
self._key_length_mla = None
|
||||
self._ssm_inner_size = None
|
||||
self._ssm_state_size = None
|
||||
|
||||
try:
|
||||
WANTED = {"general.architecture", "tokenizer.chat_template"}
|
||||
|
|
@ -619,6 +746,15 @@ class LlamaCppBackend:
|
|||
f"{arch}.attention.head_count_kv": "n_kv_heads",
|
||||
f"{arch}.attention.head_count": "n_heads",
|
||||
f"{arch}.embedding_length": "embedding_length",
|
||||
# Architecture-aware KV cache fields
|
||||
f"{arch}.attention.key_length": "kv_key_length",
|
||||
f"{arch}.attention.value_length": "kv_value_length",
|
||||
f"{arch}.attention.sliding_window": "sliding_window",
|
||||
f"{arch}.full_attention_interval": "full_attention_interval",
|
||||
f"{arch}.attention.kv_lora_rank": "kv_lora_rank",
|
||||
f"{arch}.attention.key_length_mla": "key_length_mla",
|
||||
f"{arch}.ssm.inner_size": "ssm_inner_size",
|
||||
f"{arch}.ssm.state_size": "ssm_state_size",
|
||||
}
|
||||
elif key == "tokenizer.chat_template":
|
||||
self._chat_template = val_s
|
||||
|
|
@ -674,7 +810,9 @@ class LlamaCppBackend:
|
|||
# Detect tool calling support from chat template
|
||||
tool_markers = [
|
||||
"{%- if tools %}",
|
||||
"{%- if tools -%}",
|
||||
"{% if tools %}",
|
||||
"{% if tools -%}",
|
||||
'"role" == "tool"',
|
||||
"'role' == 'tool'",
|
||||
'message.role == "tool"',
|
||||
|
|
@ -714,7 +852,6 @@ class LlamaCppBackend:
|
|||
gguf_extra_shards: list[str] = []
|
||||
if hf_variant:
|
||||
try:
|
||||
import re
|
||||
from huggingface_hub import list_repo_files
|
||||
|
||||
files = list_repo_files(hf_repo, token = hf_token)
|
||||
|
|
@ -729,11 +866,10 @@ class LlamaCppBackend:
|
|||
)
|
||||
if gguf_files:
|
||||
gguf_filename = gguf_files[0]
|
||||
shard_pat = re.compile(r"^(.*)-\d{5}-of-(\d{5})\.gguf$")
|
||||
m = shard_pat.match(gguf_filename)
|
||||
m = _SHARD_FULL_RE.match(gguf_filename)
|
||||
if m:
|
||||
prefix = m.group(1)
|
||||
total = m.group(2)
|
||||
total = m.group(3)
|
||||
sibling_pat = re.compile(
|
||||
r"^"
|
||||
+ re.escape(prefix)
|
||||
|
|
@ -790,10 +926,7 @@ class LlamaCppBackend:
|
|||
f"falling back to {fallback_file} ({fallback_size / (1024**3):.1f} GB)"
|
||||
)
|
||||
gguf_filename = fallback_file
|
||||
import re as _re
|
||||
|
||||
_shard_pat = _re.compile(r"^(.*)-\d{5}-of-\d{5}\.gguf$")
|
||||
_m = _shard_pat.match(gguf_filename)
|
||||
_m = _SHARD_RE.match(gguf_filename)
|
||||
_prefix = _m.group(1) if _m else None
|
||||
if _prefix:
|
||||
gguf_extra_shards = sorted(
|
||||
|
|
@ -1042,7 +1175,7 @@ class LlamaCppBackend:
|
|||
)
|
||||
kv = self._estimate_kv_cache_bytes(capped, cache_type_kv)
|
||||
total_mib = (model_size + kv) / (1024 * 1024)
|
||||
if total_mib <= pool_mib * 0.70:
|
||||
if total_mib <= pool_mib * 0.90:
|
||||
best_cap = max(best_cap, capped)
|
||||
if best_cap > 0:
|
||||
max_available_ctx = best_cap
|
||||
|
|
@ -1071,7 +1204,7 @@ class LlamaCppBackend:
|
|||
capped, cache_type_kv
|
||||
)
|
||||
total_mib = (model_size + kv) / (1024 * 1024)
|
||||
if total_mib <= pool_mib * 0.70:
|
||||
if total_mib <= pool_mib * 0.90:
|
||||
effective_ctx = capped
|
||||
gpu_indices = sorted(idx for idx, _ in subset)
|
||||
use_fit = False
|
||||
|
|
@ -1090,7 +1223,7 @@ class LlamaCppBackend:
|
|||
)
|
||||
kv = self._estimate_kv_cache_bytes(capped, cache_type_kv)
|
||||
total_mib = (model_size + kv) / (1024 * 1024)
|
||||
if total_mib <= pool_mib * 0.70:
|
||||
if total_mib <= pool_mib * 0.90:
|
||||
effective_ctx = capped
|
||||
gpu_indices = sorted(idx for idx, _ in subset)
|
||||
use_fit = False
|
||||
|
|
@ -1196,17 +1329,12 @@ class LlamaCppBackend:
|
|||
# Qwen3.5 models below 9B (0.8B, 2B, 4B) disable thinking by default.
|
||||
# Only 9B and larger enable thinking.
|
||||
if self._supports_reasoning:
|
||||
import re
|
||||
|
||||
thinking_default = True
|
||||
mid = (model_identifier or "").lower()
|
||||
if "qwen3.5" in mid:
|
||||
# Extract size like "0.8b", "4b", "35b" etc.
|
||||
size_match = re.search(r"(\d+\.?\d*)\s*b", mid)
|
||||
if size_match:
|
||||
size_val = float(size_match.group(1))
|
||||
if size_val < 9:
|
||||
thinking_default = False
|
||||
size_val = _extract_model_size_b(mid)
|
||||
if size_val is not None and size_val < 9:
|
||||
thinking_default = False
|
||||
self._reasoning_default = thinking_default
|
||||
cmd.extend(
|
||||
[
|
||||
|
|
@ -1422,6 +1550,14 @@ class LlamaCppBackend:
|
|||
self._n_kv_heads = None
|
||||
self._n_heads = None
|
||||
self._embedding_length = None
|
||||
self._kv_key_length = None
|
||||
self._kv_value_length = None
|
||||
self._sliding_window = None
|
||||
self._full_attention_interval = None
|
||||
self._kv_lora_rank = None
|
||||
self._key_length_mla = None
|
||||
self._ssm_inner_size = None
|
||||
self._ssm_state_size = None
|
||||
# Clean up temp chat template file
|
||||
if hasattr(self, "_chat_template_file") and self._chat_template_file:
|
||||
try:
|
||||
|
|
@ -1671,13 +1807,11 @@ class LlamaCppBackend:
|
|||
Closing tags (</tool_call>, </function>, </parameter>) are all optional
|
||||
since models frequently omit them.
|
||||
"""
|
||||
import re
|
||||
|
||||
tool_calls = []
|
||||
|
||||
# Pattern 1: JSON inside <tool_call> tags.
|
||||
# Use balanced-brace extraction that skips braces inside JSON strings.
|
||||
for m in re.finditer(r"<tool_call>\s*\{", content):
|
||||
for m in _TC_JSON_START_RE.finditer(content):
|
||||
brace_start = m.end() - 1 # position of the opening {
|
||||
depth, i = 0, brace_start
|
||||
in_string = False
|
||||
|
|
@ -1727,7 +1861,7 @@ class LlamaCppBackend:
|
|||
# boundaries. We avoid using </function> as a boundary because
|
||||
# code parameter values can contain that literal string.
|
||||
# After extracting, we trim a trailing </function> if present.
|
||||
func_starts = list(re.finditer(r"<function=(\w+)>\s*", content))
|
||||
func_starts = list(_TC_FUNC_START_RE.finditer(content))
|
||||
for idx, fm in enumerate(func_starts):
|
||||
func_name = fm.group(1)
|
||||
body_start = fm.end()
|
||||
|
|
@ -1737,7 +1871,7 @@ class LlamaCppBackend:
|
|||
if idx + 1 < len(func_starts)
|
||||
else len(content)
|
||||
)
|
||||
end_tag = re.search(r"</tool_call>", content[body_start:])
|
||||
end_tag = _TC_END_TAG_RE.search(content[body_start:])
|
||||
if end_tag:
|
||||
body_end = body_start + end_tag.start()
|
||||
else:
|
||||
|
|
@ -1745,20 +1879,20 @@ class LlamaCppBackend:
|
|||
body_end = min(body_end, next_func)
|
||||
body = content[body_start:body_end]
|
||||
# Trim trailing </function> if present (it's the real closing tag)
|
||||
body = re.sub(r"\s*</function>\s*$", "", body)
|
||||
body = _TC_FUNC_CLOSE_RE.sub("", body)
|
||||
|
||||
# Step 2: Extract parameters from body.
|
||||
# For single-parameter functions (the common case: code, command,
|
||||
# query), use body end as the only boundary to avoid false matches
|
||||
# on </parameter> inside code strings.
|
||||
arguments = {}
|
||||
param_starts = list(re.finditer(r"<parameter=(\w+)>\s*", body))
|
||||
param_starts = list(_TC_PARAM_START_RE.finditer(body))
|
||||
if len(param_starts) == 1:
|
||||
# Single parameter: value is everything from after the tag
|
||||
# to end of body, trimming any trailing </parameter>.
|
||||
pm = param_starts[0]
|
||||
val = body[pm.end() :]
|
||||
val = re.sub(r"\s*</parameter>\s*$", "", val)
|
||||
val = _TC_PARAM_CLOSE_RE.sub("", val)
|
||||
arguments[pm.group(1)] = val.strip()
|
||||
else:
|
||||
for pidx, pm in enumerate(param_starts):
|
||||
|
|
@ -1772,7 +1906,7 @@ class LlamaCppBackend:
|
|||
)
|
||||
val = body[val_start:next_param]
|
||||
# Trim trailing </parameter> if present
|
||||
val = re.sub(r"\s*</parameter>\s*$", "", val)
|
||||
val = _TC_PARAM_CLOSE_RE.sub("", val)
|
||||
arguments[param_name] = val.strip()
|
||||
|
||||
tc = {
|
||||
|
|
@ -2145,22 +2279,10 @@ class LlamaCppBackend:
|
|||
_accumulated_predicted_ms = 0.0
|
||||
_accumulated_predicted_n = 0
|
||||
|
||||
# ── Shared patterns for stripping tool XML from streamed content ──
|
||||
import re as _re_tool
|
||||
|
||||
_TOOL_CLOSED_PATTERNS = [
|
||||
_re_tool.compile(r"<tool_call>.*?</tool_call>", _re_tool.DOTALL),
|
||||
_re_tool.compile(r"<function=\w+>.*?</function>", _re_tool.DOTALL),
|
||||
]
|
||||
_TOOL_ALL_PATTERNS = _TOOL_CLOSED_PATTERNS + [
|
||||
_re_tool.compile(r"<tool_call>.*$", _re_tool.DOTALL),
|
||||
_re_tool.compile(r"<function=\w+>.*$", _re_tool.DOTALL),
|
||||
]
|
||||
|
||||
def _strip_tool_markup(text: str, *, final: bool = False) -> str:
|
||||
if not auto_heal_tool_calls:
|
||||
return text
|
||||
patterns = _TOOL_ALL_PATTERNS if final else _TOOL_CLOSED_PATTERNS
|
||||
patterns = _TOOL_ALL_PATS if final else _TOOL_CLOSED_PATS
|
||||
for pat in patterns:
|
||||
text = pat.sub("", text)
|
||||
return text.strip() if final else text
|
||||
|
|
@ -2180,7 +2302,19 @@ class LlamaCppBackend:
|
|||
# identical call succeeded).
|
||||
_tool_call_history: list[tuple[str, bool]] = [] # (key, failed)
|
||||
|
||||
for iteration in range(max_tool_iterations):
|
||||
# ── Re-prompt on plan-without-action ─────────────────
|
||||
# When the model describes what it intends to do (forward-looking
|
||||
# language) without actually calling a tool, re-prompt once.
|
||||
# Only triggers on responses that signal intent/planning -- a
|
||||
# direct answer like "4" or "Hello!" will not match.
|
||||
# Pattern is compiled once at module level (_INTENT_SIGNAL).
|
||||
_reprompt_count = 0
|
||||
|
||||
# Reserve extra iterations for re-prompts so they don't
|
||||
# consume the caller's tool-call budget. Only add the
|
||||
# extra slot when tool iterations are actually allowed.
|
||||
_extra = _MAX_REPROMPTS if max_tool_iterations > 0 else 0
|
||||
for iteration in range(max_tool_iterations + _extra):
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
return
|
||||
|
||||
|
|
@ -2491,6 +2625,57 @@ class LlamaCppBackend:
|
|||
content_accum,
|
||||
)
|
||||
if not _safety_tc:
|
||||
# ── Re-prompt on plan-without-action ──
|
||||
# If the model described what it intends to do
|
||||
# (forward-looking language) without calling any
|
||||
# tool, nudge it to act. Only fires once per
|
||||
# request and only on short responses that
|
||||
# contain intent signals -- a direct answer
|
||||
# like "4" or "Hello!" won't trigger this.
|
||||
# Use content if available, otherwise fall back
|
||||
# to reasoning text (reasoning-only stalls).
|
||||
_stripped = content_accum.strip()
|
||||
if not _stripped:
|
||||
_stripped = reasoning_accum.strip()
|
||||
if (
|
||||
tools
|
||||
and _reprompt_count < _MAX_REPROMPTS
|
||||
and 0 < len(_stripped) < _REPROMPT_MAX_CHARS
|
||||
and _INTENT_SIGNAL.search(_stripped)
|
||||
):
|
||||
_reprompt_count += 1
|
||||
logger.info(
|
||||
f"Re-prompt {_reprompt_count}/{_MAX_REPROMPTS}: "
|
||||
f"model responded without calling tools "
|
||||
f"({len(_stripped)} chars)"
|
||||
)
|
||||
conversation.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": _stripped,
|
||||
}
|
||||
)
|
||||
conversation.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"STOP. Do NOT write code or explain. "
|
||||
"You MUST call a tool NOW. "
|
||||
"Call web_search or python immediately."
|
||||
),
|
||||
}
|
||||
)
|
||||
# Accumulate tokens and timing from this iteration
|
||||
_fu_r = _iter_usage or {}
|
||||
_accumulated_completion_tokens += _fu_r.get(
|
||||
"completion_tokens", 0
|
||||
)
|
||||
_it_r = _iter_timings or {}
|
||||
_accumulated_predicted_ms += _it_r.get("predicted_ms", 0)
|
||||
_accumulated_predicted_n += _it_r.get("predicted_n", 0)
|
||||
yield {"type": "status", "text": ""}
|
||||
continue
|
||||
|
||||
# Content was already streamed. Yield metadata.
|
||||
yield {"type": "status", "text": ""}
|
||||
_fu = _iter_usage or {}
|
||||
|
|
@ -2723,10 +2908,15 @@ class LlamaCppBackend:
|
|||
_error_prefixes
|
||||
)
|
||||
_tool_call_history.append((_tc_key, _is_error))
|
||||
# Strip image sentinel before feeding result to the LLM
|
||||
# (the full result with sentinel is still yielded via
|
||||
# tool_end so the frontend can extract image paths).
|
||||
_result_content = result
|
||||
if "\n__IMAGES__:" in _result_content:
|
||||
_result_content = _result_content.rsplit("\n__IMAGES__:", 1)[0]
|
||||
if _is_error:
|
||||
_result_content = (
|
||||
result + "\n\nThe tool call encountered an issue. "
|
||||
_result_content + "\n\nThe tool call encountered an issue. "
|
||||
"Please try a different approach or rephrase your request."
|
||||
)
|
||||
|
||||
|
|
@ -2740,6 +2930,8 @@ class LlamaCppBackend:
|
|||
tool_msg["tool_call_id"] = tool_call_id
|
||||
conversation.append(tool_msg)
|
||||
|
||||
# Clear tool status badge before next generation iteration
|
||||
yield {"type": "status", "text": ""}
|
||||
# Continue the loop to let model respond with context
|
||||
continue
|
||||
|
||||
|
|
|
|||
|
|
@ -109,12 +109,13 @@ class InferenceOrchestrator:
|
|||
self._top_models_ready.wait(timeout = 5)
|
||||
top_gguf = self._top_gguf_cache or []
|
||||
top_hub = self._top_hub_cache or []
|
||||
# GGUFs first, then hub models, then static fallbacks.
|
||||
# Curated static defaults first (editorial picks like new models),
|
||||
# then HF download-ranked models to backfill.
|
||||
# Send extras so the frontend still has 4 per category
|
||||
# after removing already-downloaded models.
|
||||
result: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for m in top_gguf + top_hub + self._static_models:
|
||||
for m in self._static_models + top_gguf + top_hub:
|
||||
if m not in seen:
|
||||
result.append(m)
|
||||
seen.add(m)
|
||||
|
|
|
|||
|
|
@ -8,20 +8,28 @@ Supports web search (DuckDuckGo), Python code execution, and terminal commands.
|
|||
"""
|
||||
|
||||
import ast
|
||||
import http.client
|
||||
import os
|
||||
|
||||
os.environ["UNSLOTH_IS_PRESENT"] = "1"
|
||||
|
||||
import random
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.request
|
||||
|
||||
from loggers import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_EXEC_TIMEOUT = 300 # 5 minutes
|
||||
|
||||
# Strict raster-image allowlist for sandbox file serving.
|
||||
# No .svg (XSS risk via embedded scripts), no .html, no .pdf.
|
||||
_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"})
|
||||
_MAX_OUTPUT_CHARS = 8000 # truncate long output
|
||||
_BASH_BLOCKED_WORDS = {"rm", "sudo", "dd", "chmod", "mkfs", "shutdown", "reboot"}
|
||||
|
||||
|
|
@ -154,8 +162,74 @@ def execute_tool(
|
|||
return f"Unknown tool: {name}"
|
||||
|
||||
|
||||
_MAX_PAGE_CHARS = 16000 # limit fetched page text
|
||||
_MAX_FETCH_BYTES = _MAX_PAGE_CHARS * 4 + 1 # cap raw download size
|
||||
_MAX_PAGE_CHARS = 16000 # limit fetched page text (after HTML-to-MD conversion)
|
||||
# Raw download cap. Must be larger than _MAX_PAGE_CHARS because SSR pages
|
||||
# embed large <head> sections (CSS, JS, SVGs) that are stripped during
|
||||
# HTML-to-Markdown conversion. 512 KB is enough to reach article content
|
||||
# on GitBook / Next.js / Docusaurus pages whose <head> alone can be 200 KB.
|
||||
_MAX_FETCH_BYTES = 512 * 1024
|
||||
|
||||
_USER_AGENTS = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15",
|
||||
)
|
||||
|
||||
_tls_ctx = ssl.create_default_context()
|
||||
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
class _PinnedHTTPSConnection(http.client.HTTPSConnection):
|
||||
"""HTTPS connection that connects to a pinned IP but uses a different
|
||||
hostname for SNI and certificate verification.
|
||||
|
||||
The SSRF IP-pinning rewrites URLs to raw IPs. A normal HTTPSConnection
|
||||
would then send no SNI and verify the cert against the IP, both of which
|
||||
fail. This subclass splits the two concerns: TCP connects to the pinned
|
||||
IP (``host`` parameter) while TLS uses ``sni_hostname`` for the
|
||||
ClientHello and cert check.
|
||||
"""
|
||||
|
||||
def __init__(self, host: str, *, sni_hostname: str, **kwargs):
|
||||
super().__init__(host, **kwargs)
|
||||
self._sni_hostname = sni_hostname
|
||||
|
||||
def connect(self):
|
||||
# TCP connect to the pinned IP stored in self.host (+ tunnel if
|
||||
# a proxy is configured via set_tunnel, though we do not use one).
|
||||
http.client.HTTPConnection.connect(self)
|
||||
# TLS handshake with the real hostname for SNI + cert verification.
|
||||
self.sock = self._context.wrap_socket(
|
||||
self.sock,
|
||||
server_hostname = self._sni_hostname,
|
||||
)
|
||||
|
||||
|
||||
class _SNIHTTPSHandler(urllib.request.HTTPSHandler):
|
||||
"""HTTPS handler that sends the correct SNI hostname during TLS handshake.
|
||||
|
||||
The SSRF IP-pinning rewrites URLs to raw IPs, which breaks SNI and cert
|
||||
verification. This handler returns a ``_PinnedHTTPSConnection`` that
|
||||
connects to the pinned IP but verifies TLS against the original hostname.
|
||||
"""
|
||||
|
||||
def __init__(self, hostname: str):
|
||||
super().__init__(context = _tls_ctx)
|
||||
self._sni_hostname = hostname
|
||||
|
||||
def https_open(self, req):
|
||||
return self.do_open(self._sni_connection, req)
|
||||
|
||||
def _sni_connection(self, host, **kwargs):
|
||||
kwargs["context"] = _tls_ctx
|
||||
return _PinnedHTTPSConnection(host, sni_hostname = self._sni_hostname, **kwargs)
|
||||
|
||||
|
||||
def _validate_and_resolve_host(hostname: str, port: int) -> tuple[bool, str, str]:
|
||||
|
|
@ -215,33 +289,32 @@ def _fetch_page_text(
|
|||
return reason
|
||||
|
||||
try:
|
||||
import urllib.request
|
||||
from urllib.error import HTTPError as _HTTPError
|
||||
from urllib.parse import urljoin, urlunparse
|
||||
|
||||
# Disable auto-redirect so we can validate each hop for SSRF.
|
||||
# urllib raises HTTPError for 3xx when the handler returns None,
|
||||
# so we catch that and extract the Location header manually.
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
opener = urllib.request.build_opener(_NoRedirect)
|
||||
max_bytes = max_chars * 4 + 1
|
||||
max_bytes = _MAX_FETCH_BYTES
|
||||
current_url = url
|
||||
current_host = parsed.hostname
|
||||
ua = random.choice(_USER_AGENTS)
|
||||
|
||||
for _hop in range(5):
|
||||
# Pin to the validated IP to prevent DNS rebinding.
|
||||
# Rewrite the URL to use the IP and set the Host header.
|
||||
cp = urlparse(current_url)
|
||||
ip_netloc = f"{pinned_ip}:{cp.port}" if cp.port else pinned_ip
|
||||
# Bracket IPv6 addresses so the netloc is valid in a URL.
|
||||
ip_str = f"[{pinned_ip}]" if ":" in pinned_ip else pinned_ip
|
||||
ip_netloc = f"{ip_str}:{cp.port}" if cp.port else ip_str
|
||||
pinned_url = urlunparse(cp._replace(netloc = ip_netloc))
|
||||
|
||||
opener = urllib.request.build_opener(
|
||||
_NoRedirect,
|
||||
_SNIHTTPSHandler(current_host),
|
||||
)
|
||||
|
||||
req = urllib.request.Request(
|
||||
pinned_url,
|
||||
headers = {
|
||||
"User-Agent": "UnslothStudio/1.0",
|
||||
"User-Agent": ua,
|
||||
"Host": current_host,
|
||||
},
|
||||
)
|
||||
|
|
@ -522,6 +595,12 @@ def _check_code_safety(code: str) -> str | None:
|
|||
"""
|
||||
safe, info = _check_signal_escape_patterns(code)
|
||||
if not safe:
|
||||
# SyntaxError from ast.parse -- let these through so the subprocess
|
||||
# produces a normal Python traceback instead of a misleading
|
||||
# "unsafe code detected" message.
|
||||
if info.get("error"):
|
||||
return None
|
||||
|
||||
reasons = [
|
||||
item.get("description", "") for item in info.get("signal_tampering", [])
|
||||
]
|
||||
|
|
@ -565,6 +644,17 @@ def _python_exec(
|
|||
|
||||
tmp_path = None
|
||||
workdir = _get_workdir(session_id)
|
||||
# Snapshot image mtimes so we detect both new and overwritten files.
|
||||
_before: dict[str, int] = {}
|
||||
if os.path.isdir(workdir):
|
||||
for _name in os.listdir(workdir):
|
||||
if os.path.splitext(_name)[1].lower() in _IMAGE_EXTS:
|
||||
_p = os.path.join(workdir, _name)
|
||||
if os.path.isfile(_p):
|
||||
try:
|
||||
_before[_name] = os.stat(_p).st_mtime_ns
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
suffix = ".py", prefix = "studio_exec_", dir = workdir
|
||||
|
|
@ -600,7 +690,29 @@ def _python_exec(
|
|||
result = output or ""
|
||||
if proc.returncode != 0:
|
||||
result = f"Exit code {proc.returncode}:\n{result}"
|
||||
return _truncate(result) if result.strip() else "(no output)"
|
||||
result = _truncate(result) if result.strip() else "(no output)"
|
||||
|
||||
# Detect new or overwritten image files and append sentinel for frontend
|
||||
if session_id and os.path.isdir(workdir):
|
||||
new_images = []
|
||||
for _name in os.listdir(workdir):
|
||||
if os.path.splitext(_name)[1].lower() not in _IMAGE_EXTS:
|
||||
continue
|
||||
_p = os.path.join(workdir, _name)
|
||||
if not os.path.isfile(_p):
|
||||
continue
|
||||
try:
|
||||
_mtime = os.stat(_p).st_mtime_ns
|
||||
except OSError:
|
||||
continue
|
||||
if _name not in _before or _mtime != _before[_name]:
|
||||
new_images.append(_name)
|
||||
if new_images:
|
||||
import json as _json
|
||||
|
||||
result += f"\n__IMAGES__:{_json.dumps(sorted(new_images))}"
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return f"Execution error: {e}"
|
||||
|
|
|
|||
|
|
@ -390,11 +390,16 @@ def run_training_process(
|
|||
# Some newer architectures (e.g. NemotronH) have config parsing bugs in
|
||||
# transformers that require trust_remote_code=True as a workaround.
|
||||
# Only auto-enable for unsloth/* prefixed models (trusted source).
|
||||
# Exclude Gemma 4 since it is a native transformers 5.5 model and
|
||||
# trust_remote_code=True would bypass the compiler (disabling fused CE).
|
||||
from utils.transformers_version import needs_transformers_5
|
||||
|
||||
_lowered = model_name.lower()
|
||||
_is_native_t5 = any(x in _lowered for x in ("gemma-4", "gemma4"))
|
||||
if (
|
||||
needs_transformers_5(model_name)
|
||||
and model_name.lower().startswith("unsloth/")
|
||||
and _lowered.startswith("unsloth/")
|
||||
and not _is_native_t5
|
||||
and not config.get("trust_remote_code", False)
|
||||
):
|
||||
config["trust_remote_code"] = True
|
||||
|
|
|
|||
|
|
@ -121,13 +121,13 @@ async def lifespan(app: FastAPI):
|
|||
if storage.ensure_default_admin():
|
||||
bootstrap_pw = storage.get_bootstrap_password()
|
||||
app.state.bootstrap_password = bootstrap_pw
|
||||
|
||||
bootstrap_path = storage.DB_PATH.parent / ".bootstrap_password"
|
||||
print("\n" + "=" * 60)
|
||||
print("DEFAULT ADMIN ACCOUNT CREATED")
|
||||
print(
|
||||
"Sign in with the seeded credentials and change the password immediately:\n"
|
||||
)
|
||||
print(f" username: {storage.DEFAULT_ADMIN_USERNAME}")
|
||||
print(f" password: {bootstrap_pw}\n")
|
||||
print(f" password saved to: {bootstrap_path}")
|
||||
print(" Open the Studio UI to sign in and change it.")
|
||||
print("=" * 60 + "\n")
|
||||
else:
|
||||
app.state.bootstrap_password = storage.get_bootstrap_password()
|
||||
|
|
|
|||
|
|
@ -139,6 +139,10 @@ class LoadResponse(BaseModel):
|
|||
max_context_length: Optional[int] = Field(
|
||||
None, description = "Maximum context length currently available on this hardware"
|
||||
)
|
||||
native_context_length: Optional[int] = Field(
|
||||
None,
|
||||
description = "Model's native context length from GGUF metadata (not capped by VRAM)",
|
||||
)
|
||||
supports_reasoning: bool = Field(
|
||||
False,
|
||||
description = "Whether model supports thinking/reasoning mode (enable_thinking)",
|
||||
|
|
@ -217,6 +221,10 @@ class InferenceStatusResponse(BaseModel):
|
|||
None,
|
||||
description = "Maximum context length currently available for the active model",
|
||||
)
|
||||
native_context_length: Optional[int] = Field(
|
||||
None,
|
||||
description = "Model's native context length from GGUF metadata (not capped by VRAM)",
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
|
|
|
|||
|
|
@ -28,6 +28,21 @@ peft>=0.18.0,!=0.11.0
|
|||
huggingface_hub>=0.34.0
|
||||
hf_transfer
|
||||
diffusers
|
||||
|
||||
# Transitive deps required because this file is installed with --no-deps.
|
||||
# Without these, `from transformers import AutoConfig` fails at import time.
|
||||
regex
|
||||
typing_extensions
|
||||
filelock
|
||||
httpx
|
||||
httpcore
|
||||
certifi
|
||||
idna
|
||||
anyio
|
||||
sniffio
|
||||
h11
|
||||
|
||||
tokenizers
|
||||
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.3.0
|
||||
trl>=0.18.2,!=0.19.0,<=0.24.0
|
||||
sentence-transformers
|
||||
|
|
|
|||
|
|
@ -5,11 +5,12 @@
|
|||
Inference API routes for model loading and text generation.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import StreamingResponse, JSONResponse
|
||||
from typing import Optional
|
||||
import json
|
||||
|
|
@ -21,6 +22,9 @@ import threading
|
|||
|
||||
import re as _re
|
||||
|
||||
# Model size extraction (shared with core/inference/llama_cpp.py)
|
||||
from utils.models import extract_model_size_b as _extract_model_size_b
|
||||
|
||||
|
||||
def _friendly_error(exc: Exception) -> str:
|
||||
"""Extract a user-friendly message from known llama-server errors."""
|
||||
|
|
@ -90,6 +94,14 @@ from datetime import date as _date
|
|||
|
||||
router = APIRouter()
|
||||
|
||||
# Appended to tool-use nudge to discourage plan-without-action
|
||||
_TOOL_ACTION_NUDGE = (
|
||||
" IMPORTANT: Always call tools directly -- never write code yourself."
|
||||
" Never describe what you plan to do -- just call the tool immediately."
|
||||
" For any code request, call the python tool. For any factual question, call web_search."
|
||||
" Do NOT output code blocks -- use the python tool instead."
|
||||
)
|
||||
|
||||
# Regex for stripping leaked tool-call XML from assistant messages/stream
|
||||
_TOOL_XML_RE = _re.compile(
|
||||
r"<tool_call>.*?</tool_call>|<function=\w+>.*?</function>",
|
||||
|
|
@ -163,6 +175,7 @@ async def load_model(
|
|||
inference = inference_config,
|
||||
context_length = llama_backend.context_length,
|
||||
max_context_length = llama_backend.max_context_length,
|
||||
native_context_length = llama_backend.native_context_length,
|
||||
supports_reasoning = llama_backend.supports_reasoning,
|
||||
reasoning_always_on = llama_backend.reasoning_always_on,
|
||||
chat_template = llama_backend.chat_template,
|
||||
|
|
@ -298,6 +311,7 @@ async def load_model(
|
|||
inference = inference_config,
|
||||
context_length = llama_backend.context_length,
|
||||
max_context_length = llama_backend.max_context_length,
|
||||
native_context_length = llama_backend.native_context_length,
|
||||
supports_reasoning = llama_backend.supports_reasoning,
|
||||
reasoning_always_on = llama_backend.reasoning_always_on,
|
||||
supports_tools = llama_backend.supports_tools,
|
||||
|
|
@ -637,6 +651,7 @@ async def get_status(
|
|||
supports_tools = llama_backend.supports_tools,
|
||||
context_length = llama_backend.context_length,
|
||||
max_context_length = llama_backend.max_context_length,
|
||||
native_context_length = llama_backend.native_context_length,
|
||||
)
|
||||
|
||||
# Otherwise, report Unsloth backend status
|
||||
|
|
@ -1092,12 +1107,20 @@ async def openai_chat_completions(
|
|||
|
||||
_date_line = f"The current date is {_date.today().isoformat()}."
|
||||
|
||||
_web_tips = (
|
||||
"When you search and find a relevant URL in the results, "
|
||||
"fetch its full content by calling web_search with the url parameter. "
|
||||
"Do not repeat the same search query. If a search returns "
|
||||
"no useful results, try rephrasing or fetching a result URL directly."
|
||||
)
|
||||
# Small models (<9B) struggle with multi-step search plans,
|
||||
# so simplify the web tips to avoid plan-then-stall behavior.
|
||||
_model_size_b = _extract_model_size_b(model_name)
|
||||
_is_small_model = _model_size_b is not None and _model_size_b < 9
|
||||
|
||||
if _is_small_model:
|
||||
_web_tips = "Do not repeat the same search query."
|
||||
else:
|
||||
_web_tips = (
|
||||
"When you search and find a relevant URL in the results, "
|
||||
"fetch its full content by calling web_search with the url parameter. "
|
||||
"Do not repeat the same search query. If a search returns "
|
||||
"no useful results, try rephrasing or fetching a result URL directly."
|
||||
)
|
||||
_code_tips = (
|
||||
"Use code execution for math, calculations, data processing, "
|
||||
"or to parse and analyze information from tool results."
|
||||
|
|
@ -1129,6 +1152,7 @@ async def openai_chat_completions(
|
|||
_nudge = ""
|
||||
|
||||
if _nudge:
|
||||
_nudge += _TOOL_ACTION_NUDGE
|
||||
# Append nudge to system prompt (preserve user's prompt)
|
||||
if system_prompt:
|
||||
system_prompt = system_prompt.rstrip() + "\n\n" + _nudge
|
||||
|
|
@ -1205,7 +1229,14 @@ async def openai_chat_completions(
|
|||
break
|
||||
|
||||
if event["type"] == "status":
|
||||
# Empty status marks an iteration boundary
|
||||
# in the GGUF tool loop (e.g. after a
|
||||
# re-prompt). Reset the cumulative cursor
|
||||
# so the next assistant turn streams cleanly.
|
||||
if not event["text"]:
|
||||
prev_text = ""
|
||||
# Emit tool status as a custom SSE event
|
||||
# (including empty ones to clear UI badges)
|
||||
status_data = json.dumps(
|
||||
{
|
||||
"type": "tool_status",
|
||||
|
|
@ -1652,6 +1683,94 @@ async def openai_chat_completions(
|
|||
raise HTTPException(status_code = 500, detail = str(e))
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Sandbox file serving (/sandbox/{session_id}/{filename})
|
||||
# =====================================================================
|
||||
|
||||
_SANDBOX_MEDIA_TYPES = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/sandbox/{session_id}/{filename}")
|
||||
async def serve_sandbox_file(
|
||||
session_id: str,
|
||||
filename: str,
|
||||
request: Request,
|
||||
token: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Serve image files created by Python tool execution.
|
||||
|
||||
Accepts auth via Authorization header OR ?token= query param
|
||||
(needed because <img src> cannot send custom headers).
|
||||
"""
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
# ── Authentication (header or query param) ──────────────────
|
||||
auth_header = request.headers.get("authorization")
|
||||
if auth_header and auth_header.lower().startswith("bearer "):
|
||||
jwt_token = auth_header[7:]
|
||||
elif token:
|
||||
jwt_token = token
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||
detail = "Missing authentication token",
|
||||
)
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
creds = HTTPAuthorizationCredentials(scheme = "Bearer", credentials = jwt_token)
|
||||
await get_current_subject(creds)
|
||||
|
||||
# ── Filename sanitization ───────────────────────────────────
|
||||
safe_filename = os.path.basename(filename)
|
||||
if not safe_filename or safe_filename in (".", ".."):
|
||||
raise HTTPException(status_code = 404, detail = "Not found")
|
||||
|
||||
# ── Extension allowlist ─────────────────────────────────────
|
||||
ext = os.path.splitext(safe_filename)[1].lower()
|
||||
media_type = _SANDBOX_MEDIA_TYPES.get(ext)
|
||||
if not media_type:
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_403_FORBIDDEN,
|
||||
detail = "File type not allowed",
|
||||
)
|
||||
|
||||
# ── Path containment check ──────────────────────────────────
|
||||
home = os.path.expanduser("~")
|
||||
sandbox_root = os.path.realpath(os.path.join(home, "studio_sandbox"))
|
||||
safe_session = os.path.basename(session_id.replace("..", ""))
|
||||
if not safe_session:
|
||||
raise HTTPException(status_code = 404, detail = "Not found")
|
||||
|
||||
file_path = os.path.realpath(
|
||||
os.path.join(sandbox_root, safe_session, safe_filename)
|
||||
)
|
||||
if not file_path.startswith(sandbox_root + os.sep):
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_403_FORBIDDEN,
|
||||
detail = "Access denied",
|
||||
)
|
||||
|
||||
if not os.path.isfile(file_path):
|
||||
raise HTTPException(status_code = 404, detail = "Not found")
|
||||
|
||||
return FileResponse(
|
||||
path = file_path,
|
||||
media_type = media_type,
|
||||
headers = {
|
||||
"Cache-Control": "private, no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# OpenAI-Compatible Models Listing (/models → /v1/models)
|
||||
# =====================================================================
|
||||
|
|
|
|||
929
studio/backend/tests/test_kv_cache_estimation.py
Normal file
929
studio/backend/tests/test_kv_cache_estimation.py
Normal file
|
|
@ -0,0 +1,929 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
"""Tests for 5-path architecture-aware KV cache VRAM estimation.
|
||||
|
||||
Covers the GGUF metadata parser, _can_estimate_kv gate, all 5 estimation
|
||||
paths (MLA, Hybrid Mamba, Sliding Window, Standard GQA, Legacy), KV cache
|
||||
quantization, edge cases, and lifecycle (init/unload/reparse).
|
||||
|
||||
Requires no GPU, network, or external libraries beyond pytest.
|
||||
Cross-platform: Linux, macOS, Windows, WSL.
|
||||
"""
|
||||
|
||||
import io
|
||||
import struct
|
||||
import sys
|
||||
import types as _types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub heavy / unavailable external dependencies before importing the
|
||||
# module under test. Same pattern as test_native_context_length.py.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
||||
if _BACKEND_DIR not in sys.path:
|
||||
sys.path.insert(0, _BACKEND_DIR)
|
||||
|
||||
# loggers
|
||||
_loggers_stub = _types.ModuleType("loggers")
|
||||
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
|
||||
sys.modules.setdefault("loggers", _loggers_stub)
|
||||
|
||||
# structlog
|
||||
_structlog_stub = _types.ModuleType("structlog")
|
||||
sys.modules.setdefault("structlog", _structlog_stub)
|
||||
|
||||
# httpx
|
||||
_httpx_stub = _types.ModuleType("httpx")
|
||||
for _exc_name in (
|
||||
"ConnectError",
|
||||
"TimeoutException",
|
||||
"ReadTimeout",
|
||||
"ReadError",
|
||||
"RemoteProtocolError",
|
||||
"CloseError",
|
||||
):
|
||||
setattr(_httpx_stub, _exc_name, type(_exc_name, (Exception,), {}))
|
||||
|
||||
|
||||
class _FakeTimeout:
|
||||
def __init__(self, *a, **kw):
|
||||
pass
|
||||
|
||||
|
||||
_httpx_stub.Timeout = _FakeTimeout
|
||||
_httpx_stub.Client = type(
|
||||
"Client",
|
||||
(),
|
||||
{
|
||||
"__init__": lambda self, **kw: None,
|
||||
"__enter__": lambda self: self,
|
||||
"__exit__": lambda self, *a: None,
|
||||
},
|
||||
)
|
||||
sys.modules.setdefault("httpx", _httpx_stub)
|
||||
|
||||
from core.inference.llama_cpp import LlamaCppBackend
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_gguf_bytes(arch: str, kv_pairs: dict) -> bytes:
|
||||
"""Build a minimal GGUF v3 binary blob with the given KV metadata.
|
||||
|
||||
Only supports UINT32 (type 4), UINT64 (type 10), and STRING (type 8)
|
||||
values, which is all the metadata parser reads.
|
||||
"""
|
||||
buf = io.BytesIO()
|
||||
# Header: magic, version, tensor_count, kv_count
|
||||
buf.write(struct.pack("<I", 0x46554747)) # GGUF magic
|
||||
buf.write(struct.pack("<I", 3)) # version 3
|
||||
buf.write(struct.pack("<Q", 0)) # tensor_count
|
||||
buf.write(struct.pack("<Q", len(kv_pairs)))
|
||||
|
||||
for key, val in kv_pairs.items():
|
||||
key_bytes = key.encode("utf-8")
|
||||
buf.write(struct.pack("<Q", len(key_bytes)))
|
||||
buf.write(key_bytes)
|
||||
if isinstance(val, str):
|
||||
buf.write(struct.pack("<I", 8)) # STRING
|
||||
val_bytes = val.encode("utf-8")
|
||||
buf.write(struct.pack("<Q", len(val_bytes)))
|
||||
buf.write(val_bytes)
|
||||
elif isinstance(val, int):
|
||||
if val <= 0xFFFFFFFF:
|
||||
buf.write(struct.pack("<I", 4)) # UINT32
|
||||
buf.write(struct.pack("<I", val))
|
||||
else:
|
||||
buf.write(struct.pack("<I", 10)) # UINT64
|
||||
buf.write(struct.pack("<Q", val))
|
||||
else:
|
||||
raise TypeError(f"Unsupported value type: {type(val)}")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _backend_from_gguf(arch: str, fields: dict) -> LlamaCppBackend:
|
||||
"""Create a LlamaCppBackend with parsed GGUF metadata from given fields."""
|
||||
kv = {"general.architecture": arch}
|
||||
for k, v in fields.items():
|
||||
kv[f"{arch}.{k}"] = v
|
||||
import tempfile, os
|
||||
|
||||
data = _make_gguf_bytes(arch, kv)
|
||||
fd, path = tempfile.mkstemp(suffix = ".gguf")
|
||||
try:
|
||||
os.write(fd, data)
|
||||
os.close(fd)
|
||||
b = LlamaCppBackend()
|
||||
b._read_gguf_metadata(path)
|
||||
return b
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A. GGUF Parser Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGGUFParserNewFields:
|
||||
"""Verify that the 8 new architecture-aware fields are correctly parsed."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,gguf_key,value",
|
||||
[
|
||||
("_kv_key_length", "attention.key_length", 128),
|
||||
("_kv_value_length", "attention.value_length", 128),
|
||||
("_sliding_window", "attention.sliding_window", 1024),
|
||||
("_full_attention_interval", "full_attention_interval", 4),
|
||||
("_kv_lora_rank", "attention.kv_lora_rank", 512),
|
||||
("_key_length_mla", "attention.key_length_mla", 256),
|
||||
("_ssm_inner_size", "ssm.inner_size", 6144),
|
||||
("_ssm_state_size", "ssm.state_size", 128),
|
||||
],
|
||||
)
|
||||
def test_field_parsed(self, field, gguf_key, value):
|
||||
b = _backend_from_gguf("testarch", {gguf_key: value})
|
||||
assert getattr(b, field) == value
|
||||
|
||||
def test_missing_fields_are_none(self):
|
||||
b = _backend_from_gguf("testarch", {"block_count": 10})
|
||||
for attr in [
|
||||
"_kv_key_length",
|
||||
"_kv_value_length",
|
||||
"_sliding_window",
|
||||
"_full_attention_interval",
|
||||
"_kv_lora_rank",
|
||||
"_key_length_mla",
|
||||
"_ssm_inner_size",
|
||||
"_ssm_state_size",
|
||||
]:
|
||||
assert getattr(b, attr) is None
|
||||
|
||||
def test_all_13_fields_parsed_together(self):
|
||||
fields = {
|
||||
"context_length": 131072,
|
||||
"block_count": 62,
|
||||
"attention.head_count_kv": 16,
|
||||
"attention.head_count": 32,
|
||||
"embedding_length": 5376,
|
||||
"attention.key_length": 128,
|
||||
"attention.value_length": 128,
|
||||
"attention.sliding_window": 1024,
|
||||
"full_attention_interval": 6,
|
||||
"attention.kv_lora_rank": 512,
|
||||
"attention.key_length_mla": 256,
|
||||
"ssm.inner_size": 4096,
|
||||
"ssm.state_size": 128,
|
||||
}
|
||||
b = _backend_from_gguf("testarch", fields)
|
||||
assert b._context_length == 131072
|
||||
assert b._n_layers == 62
|
||||
assert b._n_kv_heads == 16
|
||||
assert b._n_heads == 32
|
||||
assert b._embedding_length == 5376
|
||||
assert b._kv_key_length == 128
|
||||
assert b._kv_value_length == 128
|
||||
assert b._sliding_window == 1024
|
||||
assert b._full_attention_interval == 6
|
||||
assert b._kv_lora_rank == 512
|
||||
assert b._key_length_mla == 256
|
||||
assert b._ssm_inner_size == 4096
|
||||
assert b._ssm_state_size == 128
|
||||
|
||||
|
||||
class TestGGUFParserReset:
|
||||
"""Verify that fields are properly reset between parses."""
|
||||
|
||||
def test_reset_between_parses(self):
|
||||
# First parse with all fields
|
||||
b = _backend_from_gguf(
|
||||
"arch1",
|
||||
{
|
||||
"block_count": 32,
|
||||
"attention.key_length": 128,
|
||||
"attention.kv_lora_rank": 512,
|
||||
"ssm.inner_size": 4096,
|
||||
},
|
||||
)
|
||||
assert b._kv_key_length == 128
|
||||
assert b._kv_lora_rank == 512
|
||||
assert b._ssm_inner_size == 4096
|
||||
|
||||
# Second parse without those fields -- they should be None
|
||||
kv = {"general.architecture": "arch2", "arch2.block_count": 64}
|
||||
import tempfile, os
|
||||
|
||||
data = _make_gguf_bytes("arch2", kv)
|
||||
fd, path = tempfile.mkstemp(suffix = ".gguf")
|
||||
os.write(fd, data)
|
||||
os.close(fd)
|
||||
try:
|
||||
b._read_gguf_metadata(path)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
assert b._kv_key_length is None
|
||||
assert b._kv_lora_rank is None
|
||||
assert b._ssm_inner_size is None
|
||||
assert b._n_layers == 64
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B. _can_estimate_kv Gate Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCanEstimateKV:
|
||||
"""Verify gate logic for all field combinations."""
|
||||
|
||||
def test_no_layers_returns_false(self):
|
||||
b = LlamaCppBackend()
|
||||
b._n_layers = None
|
||||
b._kv_key_length = 128
|
||||
assert not b._can_estimate_kv()
|
||||
|
||||
def test_explicit_both_dims_sufficient(self):
|
||||
b = LlamaCppBackend()
|
||||
b._n_layers = 32
|
||||
b._kv_key_length = 128
|
||||
b._kv_value_length = 128
|
||||
assert b._can_estimate_kv()
|
||||
|
||||
def test_key_length_alone_insufficient(self):
|
||||
"""key_length without value_length should NOT be enough."""
|
||||
b = LlamaCppBackend()
|
||||
b._n_layers = 32
|
||||
b._kv_key_length = 128
|
||||
assert not b._can_estimate_kv()
|
||||
|
||||
def test_kv_lora_rank_sufficient(self):
|
||||
b = LlamaCppBackend()
|
||||
b._n_layers = 61
|
||||
b._kv_lora_rank = 512
|
||||
assert b._can_estimate_kv()
|
||||
|
||||
def test_legacy_embed_plus_heads(self):
|
||||
b = LlamaCppBackend()
|
||||
b._n_layers = 28
|
||||
b._embedding_length = 1024
|
||||
b._n_heads = 16
|
||||
assert b._can_estimate_kv()
|
||||
|
||||
def test_legacy_embed_plus_kv_heads(self):
|
||||
b = LlamaCppBackend()
|
||||
b._n_layers = 28
|
||||
b._embedding_length = 1024
|
||||
b._n_kv_heads = 8
|
||||
assert b._can_estimate_kv()
|
||||
|
||||
def test_legacy_no_embed_returns_false(self):
|
||||
b = LlamaCppBackend()
|
||||
b._n_layers = 28
|
||||
b._n_heads = 16
|
||||
# No embedding_length, no new-style fields
|
||||
assert not b._can_estimate_kv()
|
||||
|
||||
def test_fresh_backend_returns_false(self):
|
||||
b = LlamaCppBackend()
|
||||
assert not b._can_estimate_kv()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C. Path 1: MLA Estimation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMLAEstimation:
|
||||
"""MLA: K-only cache using compressed KV latent + RoPE."""
|
||||
|
||||
def _mla_backend(self, **overrides):
|
||||
defaults = {
|
||||
"_n_layers": 61,
|
||||
"_n_kv_heads": 1,
|
||||
"_n_heads": 128,
|
||||
"_embedding_length": 7168,
|
||||
"_kv_key_length": 576,
|
||||
"_kv_value_length": 512,
|
||||
"_kv_lora_rank": 512,
|
||||
"_key_length_mla": 192,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
b = LlamaCppBackend()
|
||||
for k, v in defaults.items():
|
||||
setattr(b, k, v)
|
||||
return b
|
||||
|
||||
def test_deepseek_v3_f16(self):
|
||||
b = self._mla_backend()
|
||||
# 61 layers * 163840 ctx * 1 head * 576 key_len * 2 bpe
|
||||
expected = 61 * 163840 * 1 * 576 * 2
|
||||
assert b._estimate_kv_cache_bytes(163840, "f16") == expected
|
||||
|
||||
def test_mla_ignores_value_length(self):
|
||||
"""MLA should NOT add value_length -- V is reconstructed from the latent."""
|
||||
b = self._mla_backend()
|
||||
result = b._estimate_kv_cache_bytes(1000, "f16")
|
||||
# Should be n_layers * ctx * 1 * key_len(576) * 2
|
||||
expected = 61 * 1000 * 1 * 576 * 2
|
||||
assert result == expected
|
||||
|
||||
def test_mla_fallback_when_no_key_length(self):
|
||||
"""If key_length is missing, fallback to kv_lora_rank + key_length_mla."""
|
||||
b = self._mla_backend(_kv_key_length = None)
|
||||
# _key_length_mla=192 in default, so rope_dim=192
|
||||
result = b._estimate_kv_cache_bytes(1000, "f16")
|
||||
expected = 61 * 1000 * 1 * (512 + 192) * 2 # 704
|
||||
assert result == expected
|
||||
|
||||
def test_mla_fallback_no_key_length_mla(self):
|
||||
"""If both key_length and key_length_mla are missing, fallback to +64."""
|
||||
b = self._mla_backend(_kv_key_length = None, _key_length_mla = None)
|
||||
result = b._estimate_kv_cache_bytes(1000, "f16")
|
||||
expected = 61 * 1000 * 1 * (512 + 64) * 2 # 576
|
||||
assert result == expected
|
||||
|
||||
def test_mla_defaults_n_kv_to_1_when_heads_absent(self):
|
||||
"""MLA should use n_kv=1 even if n_kv_heads is None (not n_heads)."""
|
||||
b = self._mla_backend(_n_kv_heads = None) # n_heads=128 still set
|
||||
result = b._estimate_kv_cache_bytes(1000, "f16")
|
||||
# Should use n_kv_mla=1, NOT n_heads=128
|
||||
expected = 61 * 1000 * 1 * 576 * 2
|
||||
assert result == expected
|
||||
|
||||
def test_mla_q4_quantization(self):
|
||||
b = self._mla_backend()
|
||||
result_f16 = b._estimate_kv_cache_bytes(1000, "f16")
|
||||
result_q4 = b._estimate_kv_cache_bytes(1000, "q4_0")
|
||||
assert result_q4 < result_f16
|
||||
# q4_0 bpe = 0.5625, f16 bpe = 2.0
|
||||
assert result_q4 == int(61 * 1000 * 1 * 576 * 0.5625)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D. Path 2: Hybrid Mamba Estimation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHybridMambaEstimation:
|
||||
"""Hybrid Mamba: only attention layers (1 in N) need KV cache."""
|
||||
|
||||
def _hybrid_backend(self, **overrides):
|
||||
defaults = {
|
||||
"_n_layers": 64,
|
||||
"_n_kv_heads": 4,
|
||||
"_n_heads": 24,
|
||||
"_embedding_length": 5120,
|
||||
"_kv_key_length": 256,
|
||||
"_kv_value_length": 256,
|
||||
"_full_attention_interval": 4,
|
||||
"_ssm_inner_size": 6144,
|
||||
"_ssm_state_size": 128,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
b = LlamaCppBackend()
|
||||
for k, v in defaults.items():
|
||||
setattr(b, k, v)
|
||||
return b
|
||||
|
||||
def test_qwen35_27b(self):
|
||||
b = self._hybrid_backend()
|
||||
# n_attn = 64 // 4 = 16
|
||||
expected = 16 * 262144 * 4 * (256 + 256) * 2
|
||||
assert b._estimate_kv_cache_bytes(262144, "f16") == expected
|
||||
|
||||
def test_qwen35_35b_a3b(self):
|
||||
b = self._hybrid_backend(
|
||||
_n_layers = 40,
|
||||
_n_kv_heads = 2,
|
||||
_n_heads = 16,
|
||||
_embedding_length = 2048,
|
||||
_ssm_inner_size = 4096,
|
||||
)
|
||||
# n_attn = 40 // 4 = 10
|
||||
expected = 10 * 262144 * 2 * (256 + 256) * 2
|
||||
assert b._estimate_kv_cache_bytes(262144, "f16") == expected
|
||||
|
||||
def test_hybrid_without_explicit_dims(self):
|
||||
"""Fallback to head_dim when key_length/value_length are missing."""
|
||||
b = self._hybrid_backend(_kv_key_length = None, _kv_value_length = None)
|
||||
head_dim = 5120 // 24 # 213
|
||||
expected = 16 * 4096 * 4 * 2 * head_dim * 2
|
||||
assert b._estimate_kv_cache_bytes(4096, "f16") == expected
|
||||
|
||||
def test_fai_zero_safety(self):
|
||||
"""full_attention_interval=0 should not cause ZeroDivisionError."""
|
||||
b = self._hybrid_backend(_full_attention_interval = 0)
|
||||
result = b._estimate_kv_cache_bytes(4096, "f16")
|
||||
# fai=0 -> n_attn = n_layers (all layers)
|
||||
expected = 64 * 4096 * 4 * (256 + 256) * 2
|
||||
assert result == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# E. Path 3: Sliding Window Estimation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSlidingWindowEstimation:
|
||||
"""SWA: half global (full ctx) + half sliding window."""
|
||||
|
||||
def _swa_backend(self, **overrides):
|
||||
defaults = {
|
||||
"_n_layers": 62,
|
||||
"_n_kv_heads": 16,
|
||||
"_n_heads": 32,
|
||||
"_embedding_length": 5376,
|
||||
"_kv_key_length": 128,
|
||||
"_kv_value_length": 128,
|
||||
"_sliding_window": 1024,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
b = LlamaCppBackend()
|
||||
for k, v in defaults.items():
|
||||
setattr(b, k, v)
|
||||
return b
|
||||
|
||||
def test_gemma3(self):
|
||||
b = self._swa_backend()
|
||||
# 1/4 heuristic: 62 // 4 = 15 global, 47 SWA
|
||||
n_global = max(1, 62 // 4) # 15
|
||||
n_swa = 62 - n_global # 47
|
||||
kv_per = 16 * (128 + 128) * 2
|
||||
expected = int(n_global * 131072 * kv_per + n_swa * min(131072, 1024) * kv_per)
|
||||
assert b._estimate_kv_cache_bytes(131072, "f16") == expected
|
||||
|
||||
def test_gpt_oss(self):
|
||||
b = self._swa_backend(
|
||||
_n_layers = 24,
|
||||
_n_kv_heads = 8,
|
||||
_n_heads = 64,
|
||||
_embedding_length = 2880,
|
||||
_kv_key_length = 64,
|
||||
_kv_value_length = 64,
|
||||
_sliding_window = 128,
|
||||
)
|
||||
# 1/4 heuristic: 24 // 4 = 6 global, 18 SWA
|
||||
n_global = max(1, 24 // 4) # 6
|
||||
n_swa = 24 - n_global # 18
|
||||
kv_per = 8 * (64 + 64) * 2
|
||||
expected = int(n_global * 131072 * kv_per + n_swa * min(131072, 128) * kv_per)
|
||||
assert b._estimate_kv_cache_bytes(131072, "f16") == expected
|
||||
|
||||
def test_ctx_smaller_than_window(self):
|
||||
"""When context < sliding_window, SWA layers use full context anyway."""
|
||||
b = self._swa_backend(_sliding_window = 8192)
|
||||
n_global = max(1, 62 // 4) # 15
|
||||
n_swa = 62 - n_global # 47
|
||||
kv_per = 16 * (128 + 128) * 2
|
||||
ctx = 4096
|
||||
expected = int(n_global * ctx * kv_per + n_swa * min(ctx, 8192) * kv_per)
|
||||
# min(4096, 8192) = 4096, so both pools use full ctx
|
||||
assert b._estimate_kv_cache_bytes(ctx, "f16") == expected
|
||||
|
||||
def test_odd_layer_count(self):
|
||||
"""Odd layer count: n_global = max(1, n//4), n_swa = n - n_global."""
|
||||
b = self._swa_backend(_n_layers = 63)
|
||||
n_global = max(1, 63 // 4) # 15
|
||||
n_swa = 63 - n_global # 48
|
||||
kv_per = 16 * (128 + 128) * 2
|
||||
expected = int(n_global * 1000 * kv_per + n_swa * min(1000, 1024) * kv_per)
|
||||
assert b._estimate_kv_cache_bytes(1000, "f16") == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F. Path 4: Standard GQA Estimation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStandardGQAEstimation:
|
||||
"""Standard GQA with explicit key_length/value_length."""
|
||||
|
||||
def _gqa_backend(self, **overrides):
|
||||
defaults = {
|
||||
"_n_layers": 28,
|
||||
"_n_kv_heads": 8,
|
||||
"_n_heads": 16,
|
||||
"_embedding_length": 1024,
|
||||
"_kv_key_length": 128,
|
||||
"_kv_value_length": 128,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
b = LlamaCppBackend()
|
||||
for k, v in defaults.items():
|
||||
setattr(b, k, v)
|
||||
return b
|
||||
|
||||
def test_qwen3_06b(self):
|
||||
b = self._gqa_backend()
|
||||
expected = 28 * 40960 * 8 * (128 + 128) * 2
|
||||
assert b._estimate_kv_cache_bytes(40960, "f16") == expected
|
||||
|
||||
def test_asymmetric_kv_dims(self):
|
||||
"""key_length != value_length (some architectures have this)."""
|
||||
b = self._gqa_backend(_kv_key_length = 192, _kv_value_length = 64)
|
||||
expected = 28 * 4096 * 8 * (192 + 64) * 2
|
||||
assert b._estimate_kv_cache_bytes(4096, "f16") == expected
|
||||
|
||||
def test_differs_from_legacy(self):
|
||||
"""GQA path should differ from legacy when key_length != embed//n_heads."""
|
||||
b = self._gqa_backend()
|
||||
head_dim = 1024 // 16 # 64
|
||||
gqa_result = b._estimate_kv_cache_bytes(4096, "f16")
|
||||
# Legacy would use: 2 * 8 * 64 * 28 * 4096 * 2
|
||||
legacy_result = int(2 * 8 * head_dim * 28 * 4096 * 2)
|
||||
# GQA: 28 * 4096 * 8 * (128+128) * 2 -- uses actual key_length=128
|
||||
assert gqa_result != legacy_result
|
||||
assert gqa_result > legacy_result # key_length (128) > head_dim (64)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# G. Path 5: Legacy Fallback Estimation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLegacyEstimation:
|
||||
"""Legacy: embed // n_heads, for old GGUFs without new fields."""
|
||||
|
||||
def _legacy_backend(self, **overrides):
|
||||
defaults = {
|
||||
"_n_layers": 32,
|
||||
"_n_kv_heads": 8,
|
||||
"_n_heads": 32,
|
||||
"_embedding_length": 4096,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
b = LlamaCppBackend()
|
||||
for k, v in defaults.items():
|
||||
setattr(b, k, v)
|
||||
return b
|
||||
|
||||
def test_basic_legacy(self):
|
||||
b = self._legacy_backend()
|
||||
head_dim = 4096 // 32 # 128
|
||||
expected = int(2 * 8 * 128 * 32 * 4096 * 2)
|
||||
assert b._estimate_kv_cache_bytes(4096, "f16") == expected
|
||||
|
||||
def test_legacy_with_only_n_heads(self):
|
||||
"""n_kv_heads is None, falls back to n_heads."""
|
||||
b = self._legacy_backend(_n_kv_heads = None)
|
||||
head_dim = 4096 // 32
|
||||
expected = int(2 * 32 * head_dim * 32 * 4096 * 2)
|
||||
assert b._estimate_kv_cache_bytes(4096, "f16") == expected
|
||||
|
||||
def test_legacy_identical_to_old_formula(self):
|
||||
"""Confirm legacy path produces the same result as the pre-PR formula."""
|
||||
b = self._legacy_backend()
|
||||
n_layers = 32
|
||||
n_kv_heads = 8
|
||||
head_dim = 4096 // 32
|
||||
n_ctx = 8192
|
||||
bpe = 2.0
|
||||
old_formula = int(2 * n_kv_heads * head_dim * n_layers * n_ctx * bpe)
|
||||
assert b._estimate_kv_cache_bytes(n_ctx, "f16") == old_formula
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# H. Path Priority (selection order)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPathPriority:
|
||||
"""Confirm: MLA > Hybrid Mamba > SWA > GQA > Legacy."""
|
||||
|
||||
def test_mla_takes_priority_over_all(self):
|
||||
"""If kv_lora_rank is set, MLA path is used even if other fields are present."""
|
||||
b = LlamaCppBackend()
|
||||
b._n_layers = 61
|
||||
b._n_kv_heads = 1
|
||||
b._n_heads = 128
|
||||
b._embedding_length = 7168
|
||||
b._kv_key_length = 576
|
||||
b._kv_value_length = 512
|
||||
b._kv_lora_rank = 512
|
||||
b._ssm_inner_size = 4096 # Would trigger Hybrid
|
||||
b._full_attention_interval = 4
|
||||
b._sliding_window = 1024 # Would trigger SWA
|
||||
|
||||
# MLA: 61 * 1000 * 1 * 576 * 2
|
||||
expected_mla = int(61 * 1000 * 1 * 576 * 2)
|
||||
assert b._estimate_kv_cache_bytes(1000, "f16") == expected_mla
|
||||
|
||||
def test_hybrid_over_swa(self):
|
||||
"""Hybrid takes priority over SWA when both fields present."""
|
||||
b = LlamaCppBackend()
|
||||
b._n_layers = 64
|
||||
b._n_kv_heads = 4
|
||||
b._n_heads = 24
|
||||
b._embedding_length = 5120
|
||||
b._kv_key_length = 256
|
||||
b._kv_value_length = 256
|
||||
b._ssm_inner_size = 6144
|
||||
b._full_attention_interval = 4
|
||||
b._sliding_window = 1024 # Would trigger SWA
|
||||
|
||||
n_attn = 64 // 4
|
||||
expected_hybrid = int(n_attn * 1000 * 4 * (256 + 256) * 2)
|
||||
assert b._estimate_kv_cache_bytes(1000, "f16") == expected_hybrid
|
||||
|
||||
def test_all_paths_produce_different_values(self):
|
||||
"""With carefully chosen params, each path should yield a distinct value."""
|
||||
# Use embedding_length=768 so legacy head_dim (768//16=48) differs from
|
||||
# key_length (256), and MLA key_len (256) != legacy K+V (2*48=96).
|
||||
params = {
|
||||
"_n_layers": 40,
|
||||
"_n_kv_heads": 4,
|
||||
"_n_heads": 16,
|
||||
"_embedding_length": 768,
|
||||
"_kv_key_length": 256,
|
||||
"_kv_value_length": 256,
|
||||
}
|
||||
ctx = 4096
|
||||
|
||||
# Path 4: Standard GQA
|
||||
b_gqa = LlamaCppBackend()
|
||||
for k, v in params.items():
|
||||
setattr(b_gqa, k, v)
|
||||
gqa_val = b_gqa._estimate_kv_cache_bytes(ctx, "f16")
|
||||
|
||||
# Path 1: MLA
|
||||
b_mla = LlamaCppBackend()
|
||||
for k, v in params.items():
|
||||
setattr(b_mla, k, v)
|
||||
b_mla._kv_lora_rank = 512
|
||||
mla_val = b_mla._estimate_kv_cache_bytes(ctx, "f16")
|
||||
|
||||
# Path 2: Hybrid Mamba
|
||||
b_hybrid = LlamaCppBackend()
|
||||
for k, v in params.items():
|
||||
setattr(b_hybrid, k, v)
|
||||
b_hybrid._ssm_inner_size = 4096
|
||||
b_hybrid._full_attention_interval = 4
|
||||
hybrid_val = b_hybrid._estimate_kv_cache_bytes(ctx, "f16")
|
||||
|
||||
# Path 3: SWA
|
||||
b_swa = LlamaCppBackend()
|
||||
for k, v in params.items():
|
||||
setattr(b_swa, k, v)
|
||||
b_swa._sliding_window = 512
|
||||
swa_val = b_swa._estimate_kv_cache_bytes(ctx, "f16")
|
||||
|
||||
# Path 5: Legacy (no key_length/value_length)
|
||||
b_legacy = LlamaCppBackend()
|
||||
b_legacy._n_layers = 40
|
||||
b_legacy._n_kv_heads = 4
|
||||
b_legacy._n_heads = 16
|
||||
b_legacy._embedding_length = 768
|
||||
legacy_val = b_legacy._estimate_kv_cache_bytes(ctx, "f16")
|
||||
|
||||
values = [mla_val, hybrid_val, swa_val, gqa_val, legacy_val]
|
||||
assert len(set(values)) == 5, f"Expected 5 distinct values, got {values}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# I. KV Cache Quantization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestQuantization:
|
||||
"""Verify all supported cache_type_kv values produce correct scaling."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cache_type,expected_bpe",
|
||||
[
|
||||
("f32", 4.0),
|
||||
("f16", 2.0),
|
||||
("bf16", 2.0),
|
||||
("q8_0", 34 / 32),
|
||||
("q5_1", 0.75),
|
||||
("q5_0", 0.6875),
|
||||
("q4_1", 0.625),
|
||||
("q4_0", 0.5625),
|
||||
("iq4_nl", 0.5625),
|
||||
(None, 2.0), # default is f16
|
||||
("unknown", 2.0), # unknown falls back to f16
|
||||
],
|
||||
)
|
||||
def test_quantization_scaling(self, cache_type, expected_bpe):
|
||||
b = LlamaCppBackend()
|
||||
b._n_layers = 10
|
||||
b._n_kv_heads = 1
|
||||
b._n_heads = 8
|
||||
b._embedding_length = 512
|
||||
b._kv_key_length = 64
|
||||
b._kv_value_length = 64
|
||||
result = b._estimate_kv_cache_bytes(1000, cache_type)
|
||||
expected = int(10 * 1000 * 1 * (64 + 64) * expected_bpe)
|
||||
assert result == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# J. Edge Cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Boundary conditions and degenerate inputs."""
|
||||
|
||||
def test_zero_context(self):
|
||||
b = LlamaCppBackend()
|
||||
b._n_layers = 32
|
||||
b._kv_key_length = 128
|
||||
assert b._estimate_kv_cache_bytes(0, "f16") == 0
|
||||
|
||||
def test_negative_context(self):
|
||||
b = LlamaCppBackend()
|
||||
b._n_layers = 32
|
||||
b._kv_key_length = 128
|
||||
assert b._estimate_kv_cache_bytes(-1, "f16") == 0
|
||||
|
||||
def test_context_of_one(self):
|
||||
b = LlamaCppBackend()
|
||||
b._n_layers = 10
|
||||
b._n_kv_heads = 1
|
||||
b._kv_key_length = 64
|
||||
b._kv_value_length = 64
|
||||
result = b._estimate_kv_cache_bytes(1, "f16")
|
||||
assert result == int(10 * 1 * 1 * (64 + 64) * 2)
|
||||
|
||||
def test_very_large_context(self):
|
||||
"""1M context should not overflow or crash."""
|
||||
b = LlamaCppBackend()
|
||||
b._n_layers = 10
|
||||
b._n_kv_heads = 1
|
||||
b._kv_key_length = 128
|
||||
b._kv_value_length = 128
|
||||
result = b._estimate_kv_cache_bytes(1_000_000, "f16")
|
||||
assert result > 0
|
||||
assert isinstance(result, int)
|
||||
|
||||
def test_n_kv_heads_none_falls_to_n_heads(self):
|
||||
b = LlamaCppBackend()
|
||||
b._n_layers = 10
|
||||
b._n_kv_heads = None
|
||||
b._n_heads = 8
|
||||
b._kv_key_length = 64
|
||||
b._kv_value_length = 64
|
||||
result = b._estimate_kv_cache_bytes(100, "f16")
|
||||
expected = int(10 * 100 * 8 * (64 + 64) * 2)
|
||||
assert result == expected
|
||||
|
||||
def test_both_heads_none_falls_to_one(self):
|
||||
b = LlamaCppBackend()
|
||||
b._n_layers = 10
|
||||
b._n_kv_heads = None
|
||||
b._n_heads = None
|
||||
b._kv_key_length = 64
|
||||
b._kv_value_length = 64
|
||||
result = b._estimate_kv_cache_bytes(100, "f16")
|
||||
expected = int(10 * 100 * 1 * (64 + 64) * 2)
|
||||
assert result == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# K. Lifecycle Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLifecycle:
|
||||
"""Init, unload, and reparse field management."""
|
||||
|
||||
def test_init_fields_none(self):
|
||||
b = LlamaCppBackend()
|
||||
for attr in [
|
||||
"_kv_key_length",
|
||||
"_kv_value_length",
|
||||
"_sliding_window",
|
||||
"_full_attention_interval",
|
||||
"_kv_lora_rank",
|
||||
"_key_length_mla",
|
||||
"_ssm_inner_size",
|
||||
"_ssm_state_size",
|
||||
]:
|
||||
assert getattr(b, attr) is None
|
||||
|
||||
def test_unload_resets_fields(self):
|
||||
b = LlamaCppBackend()
|
||||
b._n_layers = 32
|
||||
b._kv_key_length = 128
|
||||
b._kv_lora_rank = 512
|
||||
b._sliding_window = 1024
|
||||
b._ssm_inner_size = 4096
|
||||
b._full_attention_interval = 4
|
||||
b.unload_model()
|
||||
for attr in [
|
||||
"_kv_key_length",
|
||||
"_kv_value_length",
|
||||
"_sliding_window",
|
||||
"_full_attention_interval",
|
||||
"_kv_lora_rank",
|
||||
"_key_length_mla",
|
||||
"_ssm_inner_size",
|
||||
"_ssm_state_size",
|
||||
]:
|
||||
assert getattr(b, attr) is None
|
||||
|
||||
def test_end_to_end_synthetic_mla(self):
|
||||
"""Full round-trip: write GGUF -> parse -> estimate."""
|
||||
b = _backend_from_gguf(
|
||||
"deepseek2",
|
||||
{
|
||||
"context_length": 163840,
|
||||
"block_count": 61,
|
||||
"attention.head_count_kv": 1,
|
||||
"attention.head_count": 128,
|
||||
"embedding_length": 7168,
|
||||
"attention.key_length": 576,
|
||||
"attention.value_length": 512,
|
||||
"attention.kv_lora_rank": 512,
|
||||
"attention.key_length_mla": 192,
|
||||
},
|
||||
)
|
||||
assert b._can_estimate_kv()
|
||||
result = b._estimate_kv_cache_bytes(163840, "f16")
|
||||
expected = 61 * 163840 * 1 * 576 * 2
|
||||
assert result == expected
|
||||
|
||||
def test_end_to_end_synthetic_hybrid(self):
|
||||
b = _backend_from_gguf(
|
||||
"qwen35",
|
||||
{
|
||||
"context_length": 262144,
|
||||
"block_count": 64,
|
||||
"attention.head_count_kv": 4,
|
||||
"attention.head_count": 24,
|
||||
"embedding_length": 5120,
|
||||
"attention.key_length": 256,
|
||||
"attention.value_length": 256,
|
||||
"full_attention_interval": 4,
|
||||
"ssm.inner_size": 6144,
|
||||
"ssm.state_size": 128,
|
||||
},
|
||||
)
|
||||
assert b._can_estimate_kv()
|
||||
result = b._estimate_kv_cache_bytes(262144, "f16")
|
||||
n_attn = 64 // 4
|
||||
expected = n_attn * 262144 * 4 * (256 + 256) * 2
|
||||
assert result == expected
|
||||
|
||||
def test_end_to_end_synthetic_swa(self):
|
||||
b = _backend_from_gguf(
|
||||
"gemma3",
|
||||
{
|
||||
"context_length": 131072,
|
||||
"block_count": 62,
|
||||
"attention.head_count_kv": 16,
|
||||
"attention.head_count": 32,
|
||||
"embedding_length": 5376,
|
||||
"attention.key_length": 128,
|
||||
"attention.value_length": 128,
|
||||
"attention.sliding_window": 1024,
|
||||
},
|
||||
)
|
||||
assert b._can_estimate_kv()
|
||||
result = b._estimate_kv_cache_bytes(131072, "f16")
|
||||
n_global = max(1, 62 // 4) # 15
|
||||
n_swa = 62 - n_global # 47
|
||||
kv_per = 16 * 256 * 2
|
||||
expected = int(n_global * 131072 * kv_per + n_swa * 1024 * kv_per)
|
||||
assert result == expected
|
||||
|
||||
def test_end_to_end_synthetic_gqa(self):
|
||||
b = _backend_from_gguf(
|
||||
"qwen3",
|
||||
{
|
||||
"context_length": 40960,
|
||||
"block_count": 28,
|
||||
"attention.head_count_kv": 8,
|
||||
"attention.head_count": 16,
|
||||
"embedding_length": 1024,
|
||||
"attention.key_length": 128,
|
||||
"attention.value_length": 128,
|
||||
},
|
||||
)
|
||||
assert b._can_estimate_kv()
|
||||
result = b._estimate_kv_cache_bytes(40960, "f16")
|
||||
expected = 28 * 40960 * 8 * 256 * 2
|
||||
assert result == expected
|
||||
|
||||
def test_end_to_end_synthetic_legacy(self):
|
||||
b = _backend_from_gguf(
|
||||
"llama",
|
||||
{
|
||||
"context_length": 4096,
|
||||
"block_count": 32,
|
||||
"attention.head_count_kv": 8,
|
||||
"attention.head_count": 32,
|
||||
"embedding_length": 4096,
|
||||
},
|
||||
)
|
||||
assert b._can_estimate_kv()
|
||||
result = b._estimate_kv_cache_bytes(4096, "f16")
|
||||
head_dim = 4096 // 32
|
||||
expected = int(2 * 8 * head_dim * 32 * 4096 * 2)
|
||||
assert result == expected
|
||||
518
studio/backend/tests/test_native_context_length.py
Normal file
518
studio/backend/tests/test_native_context_length.py
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
# 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 the native_context_length feature (PR #4746).
|
||||
|
||||
Verifies that the new `native_context_length` property on LlamaCppBackend
|
||||
and the corresponding Pydantic model fields work correctly. The raw GGUF
|
||||
`_context_length` must never be overwritten by VRAM-capping logic.
|
||||
|
||||
Requires no GPU, network, or external libraries beyond pytest and pydantic.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
import types as _types
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub heavy / unavailable external dependencies before importing the
|
||||
# module under test. Same pattern as test_kv_cache_estimation.py.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
||||
if _BACKEND_DIR not in sys.path:
|
||||
sys.path.insert(0, _BACKEND_DIR)
|
||||
|
||||
# loggers
|
||||
_loggers_stub = _types.ModuleType("loggers")
|
||||
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
|
||||
sys.modules.setdefault("loggers", _loggers_stub)
|
||||
|
||||
# structlog
|
||||
_structlog_stub = _types.ModuleType("structlog")
|
||||
sys.modules.setdefault("structlog", _structlog_stub)
|
||||
|
||||
# httpx -- stub only the names referenced at import / class-definition time
|
||||
_httpx_stub = _types.ModuleType("httpx")
|
||||
for _exc_name in (
|
||||
"ConnectError",
|
||||
"TimeoutException",
|
||||
"ReadTimeout",
|
||||
"ReadError",
|
||||
"RemoteProtocolError",
|
||||
"CloseError",
|
||||
):
|
||||
setattr(_httpx_stub, _exc_name, type(_exc_name, (Exception,), {}))
|
||||
|
||||
|
||||
class _FakeTimeout:
|
||||
def __init__(self, *a, **kw):
|
||||
pass
|
||||
|
||||
|
||||
_httpx_stub.Timeout = _FakeTimeout
|
||||
_httpx_stub.Client = type(
|
||||
"Client",
|
||||
(),
|
||||
{
|
||||
"__init__": lambda self, **kw: None,
|
||||
"__enter__": lambda self: self,
|
||||
"__exit__": lambda self, *a: None,
|
||||
},
|
||||
)
|
||||
sys.modules.setdefault("httpx", _httpx_stub)
|
||||
|
||||
from core.inference.llama_cpp import LlamaCppBackend
|
||||
from models.inference import LoadResponse, InferenceStatusResponse
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _write_kv(buf: io.BytesIO, key: str, value, vtype: int) -> None:
|
||||
"""Append a single GGUF KV pair to *buf*."""
|
||||
key_bytes = key.encode("utf-8")
|
||||
buf.write(struct.pack("<Q", len(key_bytes)))
|
||||
buf.write(key_bytes)
|
||||
buf.write(struct.pack("<I", vtype))
|
||||
if vtype == 4: # UINT32
|
||||
buf.write(struct.pack("<I", value))
|
||||
elif vtype == 10: # UINT64
|
||||
buf.write(struct.pack("<Q", value))
|
||||
elif vtype == 8: # STRING
|
||||
val_bytes = value.encode("utf-8")
|
||||
buf.write(struct.pack("<Q", len(val_bytes)))
|
||||
buf.write(val_bytes)
|
||||
else:
|
||||
raise ValueError(f"Unsupported vtype in test helper: {vtype}")
|
||||
|
||||
|
||||
def make_gguf(
|
||||
tmp_path: Path,
|
||||
arch: str,
|
||||
kvs: list,
|
||||
*,
|
||||
arch_first: bool = True,
|
||||
filename: str = "test.gguf",
|
||||
) -> str:
|
||||
"""Create a minimal valid GGUF v3 binary in *tmp_path*."""
|
||||
buf = io.BytesIO()
|
||||
buf.write(struct.pack("<I", 0x46554747)) # GGUF magic
|
||||
buf.write(struct.pack("<I", 3)) # version 3
|
||||
buf.write(struct.pack("<Q", 0)) # tensor count = 0
|
||||
|
||||
ordered = []
|
||||
arch_entry = ("general.architecture", arch, 8)
|
||||
|
||||
if arch_first:
|
||||
ordered.append(arch_entry)
|
||||
for suffix, val, vt in kvs:
|
||||
ordered.append((f"{arch}.{suffix}", val, vt))
|
||||
if not arch_first:
|
||||
ordered.append(arch_entry)
|
||||
|
||||
buf.write(struct.pack("<Q", len(ordered)))
|
||||
for key, val, vt in ordered:
|
||||
_write_kv(buf, key, val, vt)
|
||||
|
||||
path = tmp_path / filename
|
||||
path.write_bytes(buf.getvalue())
|
||||
return str(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend():
|
||||
"""Create a fresh LlamaCppBackend with side effects disabled."""
|
||||
with patch.object(LlamaCppBackend, "_kill_orphaned_servers"):
|
||||
with patch("atexit.register"):
|
||||
return LlamaCppBackend()
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# A. TestNativeContextLengthProperty -- the new property
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class TestNativeContextLengthProperty:
|
||||
"""Tests the new `native_context_length` property on LlamaCppBackend."""
|
||||
|
||||
def test_none_on_fresh_backend(self, backend):
|
||||
"""Returns None when no model loaded."""
|
||||
assert backend.native_context_length is None
|
||||
|
||||
def test_returns_raw_gguf_value(self, backend):
|
||||
"""Directly returns _context_length when set."""
|
||||
backend._context_length = 131072
|
||||
assert backend.native_context_length == 131072
|
||||
|
||||
def test_not_capped_by_effective(self, backend):
|
||||
"""native_context_length ignores _effective_context_length."""
|
||||
backend._context_length = 131072
|
||||
backend._effective_context_length = 32768
|
||||
assert backend.native_context_length == 131072
|
||||
|
||||
def test_not_capped_by_max(self, backend):
|
||||
"""native_context_length ignores _max_context_length."""
|
||||
backend._context_length = 131072
|
||||
backend._max_context_length = 65536
|
||||
assert backend.native_context_length == 131072
|
||||
|
||||
def test_none_after_unload(self, backend):
|
||||
"""After unload_model(), returns None."""
|
||||
backend._context_length = 131072
|
||||
assert backend.native_context_length == 131072
|
||||
backend.unload_model()
|
||||
assert backend.native_context_length is None
|
||||
|
||||
def test_after_gguf_parse(self, tmp_path, backend):
|
||||
"""Synthetic GGUF with context_length=16384 populates the property."""
|
||||
path = make_gguf(
|
||||
tmp_path,
|
||||
"llama",
|
||||
[("context_length", 16384, 4)],
|
||||
)
|
||||
backend._read_gguf_metadata(path)
|
||||
assert backend.native_context_length == 16384
|
||||
|
||||
def test_resets_between_parses(self, tmp_path, backend):
|
||||
"""Second GGUF without context_length resets native to None."""
|
||||
path_a = make_gguf(
|
||||
tmp_path,
|
||||
"llama",
|
||||
[("context_length", 16384, 4)],
|
||||
filename = "a.gguf",
|
||||
)
|
||||
backend._read_gguf_metadata(path_a)
|
||||
assert backend.native_context_length == 16384
|
||||
|
||||
path_b = make_gguf(
|
||||
tmp_path,
|
||||
"gpt2",
|
||||
[("block_count", 12, 4)],
|
||||
filename = "b.gguf",
|
||||
)
|
||||
backend._read_gguf_metadata(path_b)
|
||||
assert backend.native_context_length is None
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# B. TestContextValueSeparation -- core invariant
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class TestContextValueSeparation:
|
||||
"""_context_length is never overwritten by VRAM logic."""
|
||||
|
||||
def test_preserved_after_effective_set(self, backend):
|
||||
"""Setting _effective_context_length does not change _context_length."""
|
||||
backend._context_length = 131072
|
||||
backend._effective_context_length = 32768
|
||||
assert backend._context_length == 131072
|
||||
assert backend.native_context_length == 131072
|
||||
|
||||
def test_ordering_when_capped(self, backend):
|
||||
"""native >= max >= effective holds when VRAM-capped."""
|
||||
backend._context_length = 131072
|
||||
backend._max_context_length = 65536
|
||||
backend._effective_context_length = 32768
|
||||
assert backend.native_context_length >= backend.max_context_length
|
||||
assert backend.max_context_length >= backend.context_length
|
||||
|
||||
def test_all_equal_when_uncapped(self, backend):
|
||||
"""All three equal when no VRAM constraint."""
|
||||
backend._context_length = 8192
|
||||
# No effective or max set -- properties fall back to _context_length
|
||||
assert backend.native_context_length == 8192
|
||||
assert backend.max_context_length == 8192
|
||||
assert backend.context_length == 8192
|
||||
|
||||
def test_fit_context_does_not_modify(self, backend):
|
||||
"""_fit_context_to_vram() does not touch _context_length."""
|
||||
backend._context_length = 131072
|
||||
backend._n_layers = 32
|
||||
backend._n_kv_heads = 8
|
||||
backend._n_heads = 32
|
||||
backend._embedding_length = 4096
|
||||
original = backend._context_length
|
||||
|
||||
# Simulate a very small VRAM budget that forces capping
|
||||
result = backend._fit_context_to_vram(
|
||||
requested_ctx = 131072,
|
||||
available_mib = 512, # very small
|
||||
model_size_bytes = 0,
|
||||
)
|
||||
# _fit_context_to_vram returns the capped value, not modifying _context_length
|
||||
assert backend._context_length == original
|
||||
assert backend.native_context_length == original
|
||||
# The returned capped value should be <= requested
|
||||
assert result <= 131072
|
||||
|
||||
def test_native_gt_context_when_capped(self, backend):
|
||||
"""native_context_length > context_length after VRAM capping."""
|
||||
backend._context_length = 131072
|
||||
backend._effective_context_length = 16384
|
||||
assert backend.native_context_length > backend.context_length
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# C. TestPydanticModels -- LoadResponse & InferenceStatusResponse
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class TestPydanticModels:
|
||||
"""Tests native_context_length field on Pydantic models."""
|
||||
|
||||
def test_load_response_has_field(self):
|
||||
"""Field exists in LoadResponse.model_fields."""
|
||||
assert "native_context_length" in LoadResponse.model_fields
|
||||
|
||||
def test_load_response_defaults_none(self):
|
||||
"""Omitting native_context_length defaults to None."""
|
||||
resp = LoadResponse(
|
||||
status = "loaded",
|
||||
model = "test",
|
||||
display_name = "Test",
|
||||
inference = {},
|
||||
)
|
||||
assert resp.native_context_length is None
|
||||
|
||||
def test_load_response_accepts_int(self):
|
||||
"""native_context_length=131072 stores correctly."""
|
||||
resp = LoadResponse(
|
||||
status = "loaded",
|
||||
model = "test",
|
||||
display_name = "Test",
|
||||
inference = {},
|
||||
native_context_length = 131072,
|
||||
)
|
||||
assert resp.native_context_length == 131072
|
||||
|
||||
def test_load_response_json_null(self):
|
||||
"""None serializes to JSON null."""
|
||||
resp = LoadResponse(
|
||||
status = "loaded",
|
||||
model = "test",
|
||||
display_name = "Test",
|
||||
inference = {},
|
||||
)
|
||||
data = json.loads(resp.model_dump_json())
|
||||
assert data["native_context_length"] is None
|
||||
|
||||
def test_load_response_json_int(self):
|
||||
"""131072 serializes to JSON number."""
|
||||
resp = LoadResponse(
|
||||
status = "loaded",
|
||||
model = "test",
|
||||
display_name = "Test",
|
||||
inference = {},
|
||||
native_context_length = 131072,
|
||||
)
|
||||
data = json.loads(resp.model_dump_json())
|
||||
assert data["native_context_length"] == 131072
|
||||
|
||||
def test_status_response_has_field(self):
|
||||
"""Field exists in InferenceStatusResponse.model_fields."""
|
||||
assert "native_context_length" in InferenceStatusResponse.model_fields
|
||||
|
||||
def test_status_response_defaults_none(self):
|
||||
"""Omitting native_context_length defaults to None."""
|
||||
resp = InferenceStatusResponse()
|
||||
assert resp.native_context_length is None
|
||||
|
||||
def test_roundtrip_preserves_value(self):
|
||||
"""model_validate_json(model_dump_json()) round-trips."""
|
||||
resp = LoadResponse(
|
||||
status = "loaded",
|
||||
model = "test",
|
||||
display_name = "Test",
|
||||
inference = {},
|
||||
native_context_length = 131072,
|
||||
)
|
||||
roundtripped = LoadResponse.model_validate_json(resp.model_dump_json())
|
||||
assert roundtripped.native_context_length == 131072
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# D. TestRouteCompleteness -- source-level verification
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class TestRouteCompleteness:
|
||||
"""All response construction sites in routes/inference.py include native_context_length."""
|
||||
|
||||
@pytest.fixture(autouse = True)
|
||||
def _load_source(self):
|
||||
"""Read routes/inference.py source once."""
|
||||
routes_path = Path(__file__).resolve().parent.parent / "routes" / "inference.py"
|
||||
self._source = routes_path.read_text()
|
||||
|
||||
def _find_construction_blocks(self, class_name: str) -> list[str]:
|
||||
"""Extract all code blocks that construct a given response class."""
|
||||
blocks = []
|
||||
idx = 0
|
||||
while True:
|
||||
start = self._source.find(f"{class_name}(", idx)
|
||||
if start == -1:
|
||||
break
|
||||
# Find matching closing paren (simple depth counter)
|
||||
depth = 0
|
||||
end = start
|
||||
for i, ch in enumerate(self._source[start:], start):
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = i + 1
|
||||
break
|
||||
blocks.append(self._source[start:end])
|
||||
idx = end
|
||||
return blocks
|
||||
|
||||
def test_gguf_load_responses_have_field(self):
|
||||
"""Every GGUF LoadResponse (is_gguf = True) includes native_context_length."""
|
||||
blocks = self._find_construction_blocks("LoadResponse")
|
||||
gguf_blocks = [
|
||||
b for b in blocks if "is_gguf = True" in b or "is_gguf=True" in b
|
||||
]
|
||||
assert (
|
||||
len(gguf_blocks) >= 2
|
||||
), f"Expected at least 2 GGUF LoadResponse blocks, found {len(gguf_blocks)}"
|
||||
for i, block in enumerate(gguf_blocks):
|
||||
assert (
|
||||
"native_context_length" in block
|
||||
), f"GGUF LoadResponse block #{i} missing native_context_length:\n{block[:200]}"
|
||||
|
||||
def test_non_gguf_load_responses_omit_field(self):
|
||||
"""Non-GGUF LoadResponse blocks do not set native_context_length (defaults to None)."""
|
||||
blocks = self._find_construction_blocks("LoadResponse")
|
||||
non_gguf = [
|
||||
b for b in blocks if "is_gguf = True" not in b and "is_gguf=True" not in b
|
||||
]
|
||||
# Non-GGUF paths should not reference native_context_length
|
||||
# (Pydantic defaults it to None, so not setting it is correct)
|
||||
for block in non_gguf:
|
||||
assert (
|
||||
"native_context_length" not in block
|
||||
), f"Non-GGUF LoadResponse should not set native_context_length:\n{block[:200]}"
|
||||
|
||||
def test_status_path(self):
|
||||
"""InferenceStatusResponse construction with llama_backend has the field."""
|
||||
blocks = self._find_construction_blocks("InferenceStatusResponse")
|
||||
found = False
|
||||
for block in blocks:
|
||||
if "llama_backend" in block and "native_context_length" in block:
|
||||
found = True
|
||||
break
|
||||
assert found, "No InferenceStatusResponse block with llama_backend has native_context_length"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# E. TestEdgeCases
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class TestNativeContextEdgeCases:
|
||||
"""Edge cases for native_context_length."""
|
||||
|
||||
def test_context_length_zero(self, tmp_path, backend):
|
||||
"""GGUF context_length=0 returns 0, not None."""
|
||||
path = make_gguf(tmp_path, "llama", [("context_length", 0, 4)])
|
||||
backend._read_gguf_metadata(path)
|
||||
assert backend.native_context_length == 0
|
||||
|
||||
def test_context_length_uint32_max(self, tmp_path, backend):
|
||||
"""2^32 - 1 survives without truncation."""
|
||||
val = 2**32 - 1
|
||||
path = make_gguf(tmp_path, "llama", [("context_length", val, 4)])
|
||||
backend._read_gguf_metadata(path)
|
||||
assert backend.native_context_length == val
|
||||
|
||||
def test_context_length_uint64(self, tmp_path, backend):
|
||||
"""UINT64 type context_length parsed correctly."""
|
||||
val = 2**33 # exceeds UINT32 range
|
||||
path = make_gguf(tmp_path, "llama", [("context_length", val, 10)])
|
||||
backend._read_gguf_metadata(path)
|
||||
assert backend.native_context_length == val
|
||||
|
||||
def test_no_context_length_in_gguf(self, tmp_path, backend):
|
||||
"""GGUF without context_length key yields None."""
|
||||
path = make_gguf(tmp_path, "llama", [("block_count", 32, 4)])
|
||||
backend._read_gguf_metadata(path)
|
||||
assert backend.native_context_length is None
|
||||
|
||||
def test_native_equals_context_when_uncapped(self, backend):
|
||||
"""Both equal when no VRAM cap applied."""
|
||||
backend._context_length = 8192
|
||||
assert backend.native_context_length == backend.context_length
|
||||
|
||||
def test_native_survives_parse_then_cap(self, tmp_path, backend):
|
||||
"""Parse then set effective cap: native unchanged."""
|
||||
path = make_gguf(
|
||||
tmp_path,
|
||||
"llama",
|
||||
[
|
||||
("context_length", 131072, 4),
|
||||
("block_count", 32, 4),
|
||||
("attention.head_count", 32, 4),
|
||||
("attention.head_count_kv", 8, 4),
|
||||
("embedding_length", 4096, 4),
|
||||
],
|
||||
)
|
||||
backend._read_gguf_metadata(path)
|
||||
assert backend.native_context_length == 131072
|
||||
|
||||
# Simulate VRAM capping by setting effective and max
|
||||
backend._effective_context_length = 16384
|
||||
backend._max_context_length = 32768
|
||||
assert backend.native_context_length == 131072
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# F. TestCrossPlatform -- binary I/O and serialization
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class TestCrossPlatform:
|
||||
"""Binary I/O and serialization correctness across platforms."""
|
||||
|
||||
def test_le_uint32_context_length(self, tmp_path, backend):
|
||||
"""Little-endian UINT32 parsed correctly."""
|
||||
path = make_gguf(tmp_path, "llama", [("context_length", 16384, 4)])
|
||||
backend._read_gguf_metadata(path)
|
||||
assert backend.native_context_length == 16384
|
||||
|
||||
def test_le_uint64_context_length(self, tmp_path, backend):
|
||||
"""Little-endian UINT64 parsed correctly."""
|
||||
path = make_gguf(tmp_path, "llama", [("context_length", 16384, 10)])
|
||||
backend._read_gguf_metadata(path)
|
||||
assert backend.native_context_length == 16384
|
||||
|
||||
def test_gguf_magic_le_byte_order(self, tmp_path):
|
||||
"""Magic 0x46554747 matches GGUF spec (little-endian 'GGUF')."""
|
||||
path = tmp_path / "magic_check.gguf"
|
||||
buf = io.BytesIO()
|
||||
buf.write(struct.pack("<I", 0x46554747))
|
||||
raw = buf.getvalue()
|
||||
# 'G' = 0x47, 'G' = 0x47, 'U' = 0x55, 'F' = 0x46
|
||||
assert raw == b"GGUF"
|
||||
|
||||
def test_json_serialization_deterministic(self):
|
||||
"""model_dump_json() is consistent across calls."""
|
||||
resp = LoadResponse(
|
||||
status = "loaded",
|
||||
model = "test",
|
||||
display_name = "Test",
|
||||
inference = {},
|
||||
native_context_length = 131072,
|
||||
)
|
||||
json1 = resp.model_dump_json()
|
||||
json2 = resp.model_dump_json()
|
||||
assert json1 == json2
|
||||
assert '"native_context_length":131072' in json1
|
||||
|
|
@ -17,6 +17,7 @@ import structlog
|
|||
from loggers import get_logger
|
||||
|
||||
from utils.models.model_config import load_model_defaults
|
||||
from utils.paths import is_local_path, normalize_path
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
|
@ -93,8 +94,28 @@ def _has_specific_yaml(model_identifier: str) -> bool:
|
|||
if model_identifier.lower() in _REVERSE_MODEL_MAPPING:
|
||||
return True
|
||||
|
||||
# Check for exact filename match
|
||||
model_filename = model_identifier.replace("/", "_") + ".yaml"
|
||||
# For local filesystem paths (e.g. C:\Users\...\model on Windows),
|
||||
# normalize backslashes so Path().parts splits correctly on POSIX/WSL,
|
||||
# then try matching the last 1-2 path components against the registry
|
||||
# (mirrors the logic in load_model_defaults).
|
||||
_is_local = is_local_path(model_identifier)
|
||||
_normalized = normalize_path(model_identifier) if _is_local else model_identifier
|
||||
|
||||
if _is_local:
|
||||
parts = Path(_normalized).parts
|
||||
for depth in (2, 1):
|
||||
if len(parts) >= depth:
|
||||
suffix = "/".join(parts[-depth:])
|
||||
if suffix.lower() in _REVERSE_MODEL_MAPPING:
|
||||
return True
|
||||
_lookup = Path(_normalized).name
|
||||
else:
|
||||
_lookup = model_identifier
|
||||
|
||||
# Check for exact filename match (basename for local paths to avoid
|
||||
# passing absolute paths into rglob which raises
|
||||
# "Non-relative patterns are unsupported" on Windows).
|
||||
model_filename = _lookup.replace("/", "_") + ".yaml"
|
||||
for config_path in defaults_dir.rglob(model_filename):
|
||||
if config_path.is_file():
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from .model_config import (
|
|||
get_base_model_from_lora,
|
||||
load_model_config,
|
||||
list_gguf_variants,
|
||||
extract_model_size_b,
|
||||
MODEL_NAME_MAPPING,
|
||||
UI_STATUS_INDICATORS,
|
||||
)
|
||||
|
|
@ -38,6 +39,7 @@ __all__ = [
|
|||
"get_base_model_from_lora",
|
||||
"load_model_config",
|
||||
"list_gguf_variants",
|
||||
"extract_model_size_b",
|
||||
"MODEL_NAME_MAPPING",
|
||||
"UI_STATUS_INDICATORS",
|
||||
"scan_checkpoints",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,37 @@ import yaml
|
|||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# ── Model size extraction ────────────────────────────────────
|
||||
import re as _re
|
||||
|
||||
_MODEL_SIZE_RE = _re.compile(
|
||||
r"(?:^|[-_/])(\d+\.?\d*)\s*([bm])(?:$|[-_/])", _re.IGNORECASE
|
||||
)
|
||||
# MoE active-parameter pattern: matches "A3B", "A3.5B", etc.
|
||||
_ACTIVE_SIZE_RE = _re.compile(
|
||||
r"(?:^|[-_/])a(\d+\.?\d*)\s*([bm])(?:$|[-_/])", _re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def extract_model_size_b(model_id: str) -> float | None:
|
||||
"""Extract model size in billions from a model identifier.
|
||||
|
||||
Prefers MoE active-parameter notation (e.g. ``A3B`` in
|
||||
``Qwen3.5-35B-A3B``) over the total parameter count.
|
||||
Handles both ``B`` (billions) and ``M`` (millions) suffixes.
|
||||
"""
|
||||
mid = (model_id or "").lower()
|
||||
active = _ACTIVE_SIZE_RE.search(mid)
|
||||
if active:
|
||||
val = float(active.group(1))
|
||||
return val / 1000.0 if active.group(2).lower() == "m" else val
|
||||
size = _MODEL_SIZE_RE.search(mid)
|
||||
if not size:
|
||||
return None
|
||||
val = float(size.group(1))
|
||||
return val / 1000.0 if size.group(2).lower() == "m" else val
|
||||
|
||||
|
||||
# Model name mapping: maps all equivalent model names to their canonical YAML config file
|
||||
# Format: "canonical_model_name.yaml": [list of all equivalent model names]
|
||||
# Based on the model mapper provided - canonical filename is based on the first model name in the mapper
|
||||
|
|
@ -1420,17 +1451,20 @@ def load_model_defaults(model_name: str) -> Dict[str, Any]:
|
|||
return config
|
||||
|
||||
# If model_name is a local path (e.g. /home/.../Spark-TTS-0.5B/LLM from
|
||||
# adapter_config.json), try matching the last 1-2 path components against
|
||||
# the registry (e.g. "Spark-TTS-0.5B/LLM").
|
||||
if model_name not in _REVERSE_MODEL_MAPPING and (
|
||||
model_name.startswith("/") or model_name.startswith(".")
|
||||
):
|
||||
parts = Path(model_name).parts
|
||||
# adapter_config.json, or C:\Users\...\model on Windows), try matching
|
||||
# the last 1-2 path components against the registry
|
||||
# (e.g. "Spark-TTS-0.5B/LLM").
|
||||
_is_local_path = is_local_path(model_name)
|
||||
# Normalize Windows backslash paths so Path().parts splits correctly
|
||||
# on POSIX/WSL hosts (pathlib treats backslashes as literals on Linux).
|
||||
_normalized = normalize_path(model_name) if _is_local_path else model_name
|
||||
if model_name.lower() not in _REVERSE_MODEL_MAPPING and _is_local_path:
|
||||
parts = Path(_normalized).parts
|
||||
for depth in [2, 1]:
|
||||
if len(parts) >= depth:
|
||||
suffix = "/".join(parts[-depth:])
|
||||
if suffix in _REVERSE_MODEL_MAPPING:
|
||||
canonical_file = _REVERSE_MODEL_MAPPING[suffix]
|
||||
if suffix.lower() in _REVERSE_MODEL_MAPPING:
|
||||
canonical_file = _REVERSE_MODEL_MAPPING[suffix.lower()]
|
||||
for config_path in defaults_dir.rglob(canonical_file):
|
||||
if config_path.is_file():
|
||||
with open(config_path, "r", encoding = "utf-8") as f:
|
||||
|
|
@ -1440,8 +1474,12 @@ def load_model_defaults(model_name: str) -> Dict[str, Any]:
|
|||
)
|
||||
return config
|
||||
|
||||
# Try exact model name match (for backward compatibility)
|
||||
model_filename = model_name.replace("/", "_") + ".yaml"
|
||||
# Try exact model name match (for backward compatibility).
|
||||
# For local filesystem paths, use only the directory basename to
|
||||
# avoid passing absolute paths (e.g. C:\...) into rglob which
|
||||
# raises "Non-relative patterns are unsupported" on Windows.
|
||||
_lookup_name = Path(_normalized).name if _is_local_path else model_name
|
||||
model_filename = _lookup_name.replace("/", "_") + ".yaml"
|
||||
# Search in subfolders and root
|
||||
for config_path in defaults_dir.rglob(model_filename):
|
||||
if config_path.is_file():
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ TRANSFORMERS_5_MODEL_SUBSTRINGS: tuple[str, ...] = (
|
|||
"qwen3.5", # Qwen3.5 family (35B-A3B, etc.)
|
||||
"qwen3-next", # Qwen3-Next and variants
|
||||
"tiny_qwen3_moe", # imdatta0/tiny_qwen3_moe_2.8B_0.7B
|
||||
"gemma-4", # Gemma-4 (E2B-it, E4B-it, 31B-it, 26B-A4B-it)
|
||||
"gemma4", # Gemma-4 alternate naming
|
||||
)
|
||||
|
||||
# Tokenizer classes that only exist in transformers>=5.x
|
||||
|
|
@ -58,7 +60,7 @@ _TRANSFORMERS_5_TOKENIZER_CLASSES: set[str] = {
|
|||
_tokenizer_class_cache: dict[str, bool] = {}
|
||||
|
||||
# Versions
|
||||
TRANSFORMERS_5_VERSION = "5.3.0"
|
||||
TRANSFORMERS_5_VERSION = "5.5.0"
|
||||
TRANSFORMERS_DEFAULT_VERSION = "4.57.6"
|
||||
|
||||
# Pre-installed directory for transformers 5.x — created by setup.sh / setup.ps1
|
||||
|
|
@ -258,7 +260,7 @@ def _purge_modules() -> int:
|
|||
|
||||
_VENV_T5_PACKAGES = (
|
||||
f"transformers=={TRANSFORMERS_5_VERSION}",
|
||||
"huggingface_hub==1.7.1",
|
||||
"huggingface_hub==1.8.0",
|
||||
"hf_xet==1.4.2",
|
||||
"tiktoken",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -100,20 +100,27 @@ function getCodeFilename(language: string | null) {
|
|||
function isSvgFence(codeFence: CodeFence): boolean {
|
||||
const lang = codeFence.language?.toLowerCase() ?? "";
|
||||
if (lang === "svg") return true;
|
||||
if ((lang === "xml" || lang === "html") && codeFence.source.trimStart().startsWith("<svg")) return true;
|
||||
if (lang === "xml" || lang === "html") {
|
||||
const trimmed = codeFence.source.trimStart();
|
||||
// Match <svg directly or <?xml ...?> followed by <svg
|
||||
if (trimmed.startsWith("<svg")) return true;
|
||||
if (trimmed.startsWith("<?xml") && trimmed.includes("<svg")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isHtmlFence(codeFence: CodeFence): boolean {
|
||||
const lang = codeFence.language?.toLowerCase() ?? "";
|
||||
return lang === "html" && !codeFence.source.trimStart().startsWith("<svg");
|
||||
return lang === "html" && !isSvgFence(codeFence);
|
||||
}
|
||||
|
||||
const UNSAFE_SVG_RE = /<script[\s>]|on\w+\s*=|javascript:|<foreignObject[\s>]|<iframe[\s>]|<embed[\s>]|<object[\s>]/i;
|
||||
|
||||
function sanitizeSvg(source: string): string | null {
|
||||
if (UNSAFE_SVG_RE.test(source)) return null;
|
||||
return source;
|
||||
// Strip XML declaration (<?xml ...?>) -- not needed for data URI
|
||||
// rendering and can cause issues with some renderers.
|
||||
return source.replace(/^\s*<\?xml[^?]*\?>\s*/i, "");
|
||||
}
|
||||
|
||||
function SvgPreview({ source }: { source: string }) {
|
||||
|
|
@ -403,7 +410,7 @@ const MarkdownTextImpl = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div data-status={status.type}>
|
||||
<div data-status={status.type} className="min-w-0 max-w-full">
|
||||
<Streamdown
|
||||
mode="streaming"
|
||||
isAnimating={status.type === "running"}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ interface ModelSelectorProps {
|
|||
activeGgufVariant?: string | null;
|
||||
onValueChange?: (value: string, meta: ModelSelectorChangeMeta) => void;
|
||||
onEject?: () => void;
|
||||
onFoldersChange?: () => void;
|
||||
variant?: "outline" | "ghost" | "muted";
|
||||
size?: "sm" | "default" | "lg";
|
||||
className?: string;
|
||||
|
|
@ -100,6 +101,7 @@ function ModelSelectorContent({
|
|||
value,
|
||||
onSelect,
|
||||
onEject,
|
||||
onFoldersChange,
|
||||
className,
|
||||
dataTour,
|
||||
}: {
|
||||
|
|
@ -108,6 +110,7 @@ function ModelSelectorContent({
|
|||
value?: string;
|
||||
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
|
||||
onEject?: () => void;
|
||||
onFoldersChange?: () => void;
|
||||
className?: string;
|
||||
dataTour?: string;
|
||||
}) {
|
||||
|
|
@ -124,7 +127,7 @@ function ModelSelectorContent({
|
|||
)}
|
||||
>
|
||||
{chatOnly ? (
|
||||
<HubModelPicker models={models} value={value} onSelect={onSelect} />
|
||||
<HubModelPicker models={models} value={value} onSelect={onSelect} onFoldersChange={onFoldersChange} />
|
||||
) : (
|
||||
<Tabs defaultValue="hub" className="w-full">
|
||||
<TabsList className="mb-2 w-full">
|
||||
|
|
@ -133,7 +136,7 @@ function ModelSelectorContent({
|
|||
</TabsList>
|
||||
|
||||
<TabsContent value="hub" className="m-0">
|
||||
<HubModelPicker models={models} value={value} onSelect={onSelect} />
|
||||
<HubModelPicker models={models} value={value} onSelect={onSelect} onFoldersChange={onFoldersChange} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="lora" className="m-0">
|
||||
|
|
@ -171,6 +174,7 @@ export function ModelSelector({
|
|||
activeGgufVariant,
|
||||
onValueChange,
|
||||
onEject,
|
||||
onFoldersChange,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
className,
|
||||
|
|
@ -253,6 +257,7 @@ export function ModelSelector({
|
|||
value={selected}
|
||||
onSelect={handleSelect}
|
||||
onEject={onEject ? handleEject : undefined}
|
||||
onFoldersChange={onFoldersChange}
|
||||
className={contentClassName}
|
||||
dataTour={contentDataTour}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -20,11 +20,15 @@ import {
|
|||
} from "@/components/ui/tooltip";
|
||||
import { usePlatformStore } from "@/config/env";
|
||||
import {
|
||||
type ScanFolderInfo,
|
||||
addScanFolder,
|
||||
deleteCachedModel,
|
||||
listCachedGguf,
|
||||
listCachedModels,
|
||||
listGgufVariants,
|
||||
listLocalModels,
|
||||
listScanFolders,
|
||||
removeScanFolder,
|
||||
} from "@/features/chat/api/chat-api";
|
||||
import type {
|
||||
CachedGgufRepo,
|
||||
|
|
@ -42,7 +46,7 @@ import {
|
|||
import { cn, formatCompact } from "@/lib/utils";
|
||||
import type { VramFitStatus } from "@/lib/vram";
|
||||
import { checkVramFit, estimateLoadingVram } from "@/lib/vram";
|
||||
import { Search01Icon } from "@hugeicons/core-free-icons";
|
||||
import { Add01Icon, Cancel01Icon, Folder02Icon, Search01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Trash2Icon } from "lucide-react";
|
||||
import {
|
||||
|
|
@ -123,23 +127,22 @@ function ModelRow({
|
|||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent",
|
||||
selected && "bg-accent/60",
|
||||
exceeds && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"block min-w-0 flex-1 truncate",
|
||||
exceeds && "line-through decoration-muted-foreground/50",
|
||||
exceeds && "!text-gray-500 dark:!text-gray-400",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="ml-auto flex items-center gap-1.5 shrink-0">
|
||||
{vramStatus === "exceeds" && (
|
||||
<span className="text-[9px] font-medium text-red-400">OOM</span>
|
||||
<span className="text-[9px] font-medium !text-red-700 !bg-red-50 dark:!text-red-400 dark:!bg-red-950 px-1.5 py-0.5 rounded">OOM</span>
|
||||
)}
|
||||
{vramStatus === "tight" && (
|
||||
<span className="text-[9px] font-medium text-amber-400">TIGHT</span>
|
||||
<span className="text-[9px] font-medium !text-amber-400">TIGHT</span>
|
||||
)}
|
||||
{meta ? (
|
||||
<span className="text-[10px] text-muted-foreground">{meta}</span>
|
||||
|
|
@ -350,7 +353,7 @@ function GgufVariantExpander({
|
|||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||
{v.quant}
|
||||
<span className={cn(oom && "!text-gray-500 dark:!text-gray-400")}>{v.quant}</span>
|
||||
{v.downloaded ? (
|
||||
<span className="ml-1.5 text-[9px] font-sans font-medium text-green-400">
|
||||
downloaded
|
||||
|
|
@ -363,12 +366,12 @@ function GgufVariantExpander({
|
|||
</span>
|
||||
<span className="flex items-center gap-1.5 shrink-0">
|
||||
{oom && (
|
||||
<span className="text-[9px] font-medium text-red-400">
|
||||
<span className="text-[9px] font-medium !text-red-700 !bg-red-50 dark:!text-red-400 dark:!bg-red-950 px-1.5 py-0.5 rounded">
|
||||
OOM
|
||||
</span>
|
||||
)}
|
||||
{tight && (
|
||||
<span className="text-[9px] font-medium text-amber-400">
|
||||
<span className="text-[9px] font-medium !text-amber-400">
|
||||
TIGHT
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -415,6 +418,7 @@ let _cachedGgufCache: CachedGgufRepo[] = [];
|
|||
let _cachedModelsCache: CachedModelRepo[] = [];
|
||||
let _lmStudioCache: LocalModelInfo[] = [];
|
||||
let _customFolderCache: LocalModelInfo[] = [];
|
||||
let _scanFoldersCache: ScanFolderInfo[] = [];
|
||||
|
||||
/** Sort LM Studio models with unsloth publisher first. */
|
||||
function sortLmStudio(models: LocalModelInfo[]): LocalModelInfo[] {
|
||||
|
|
@ -434,10 +438,12 @@ export function HubModelPicker({
|
|||
models,
|
||||
value,
|
||||
onSelect,
|
||||
onFoldersChange,
|
||||
}: {
|
||||
models: ModelOption[];
|
||||
value?: string;
|
||||
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
|
||||
onFoldersChange?: () => void;
|
||||
}) {
|
||||
const gpu = useGpuInfo();
|
||||
const [query, setQuery] = useState("");
|
||||
|
|
@ -469,6 +475,13 @@ export function HubModelPicker({
|
|||
const [customFolderModels, setCustomFolderModels] =
|
||||
useState<LocalModelInfo[]>(_customFolderCache);
|
||||
|
||||
// Custom scan folders management
|
||||
const [scanFolders, setScanFolders] = useState<ScanFolderInfo[]>(_scanFoldersCache);
|
||||
const [folderInput, setFolderInput] = useState("");
|
||||
const [folderError, setFolderError] = useState<string | null>(null);
|
||||
const [showFolderInput, setShowFolderInput] = useState(false);
|
||||
const [folderLoading, setFolderLoading] = useState(false);
|
||||
|
||||
const refreshLocalModelsList = useCallback(() => {
|
||||
listLocalModels()
|
||||
.then((res) => {
|
||||
|
|
@ -484,6 +497,57 @@ export function HubModelPicker({
|
|||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const refreshScanFolders = useCallback(() => {
|
||||
listScanFolders()
|
||||
.then((v) => {
|
||||
_scanFoldersCache = v;
|
||||
setScanFolders(v);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleAddFolder = useCallback(async () => {
|
||||
const trimmed = folderInput.trim();
|
||||
if (!trimmed || folderLoading) return;
|
||||
setFolderError(null);
|
||||
setFolderLoading(true);
|
||||
try {
|
||||
const created = await addScanFolder(trimmed);
|
||||
// Backend returns existing row for duplicates, so deduplicate
|
||||
const next = _scanFoldersCache.some((f) => f.id === created.id || f.path === created.path)
|
||||
? _scanFoldersCache
|
||||
: [..._scanFoldersCache, created];
|
||||
_scanFoldersCache = next;
|
||||
setScanFolders(next);
|
||||
setFolderInput("");
|
||||
setShowFolderInput(false);
|
||||
refreshLocalModelsList();
|
||||
onFoldersChange?.();
|
||||
// Background reconciliation with the server
|
||||
void refreshScanFolders();
|
||||
} catch (e) {
|
||||
setFolderError(e instanceof Error ? e.message : "Failed to add folder");
|
||||
} finally {
|
||||
setFolderLoading(false);
|
||||
}
|
||||
}, [folderInput, folderLoading, refreshScanFolders, refreshLocalModelsList, onFoldersChange]);
|
||||
|
||||
const handleRemoveFolder = useCallback(async (id: number) => {
|
||||
try {
|
||||
await removeScanFolder(id);
|
||||
// Optimistic update so the folder disappears immediately
|
||||
const next = _scanFoldersCache.filter((f) => f.id !== id);
|
||||
_scanFoldersCache = next;
|
||||
setScanFolders(next);
|
||||
refreshScanFolders();
|
||||
refreshLocalModelsList();
|
||||
onFoldersChange?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to remove folder");
|
||||
refreshScanFolders();
|
||||
}
|
||||
}, [refreshScanFolders, refreshLocalModelsList, onFoldersChange]);
|
||||
|
||||
const refreshCachedLists = useCallback(() => {
|
||||
listCachedGguf()
|
||||
.then((v) => {
|
||||
|
|
@ -503,6 +567,7 @@ export function HubModelPicker({
|
|||
useEffect(() => {
|
||||
// Always refresh LM Studio + custom folder models (not gated by alreadyCached)
|
||||
refreshLocalModelsList();
|
||||
refreshScanFolders();
|
||||
|
||||
if (alreadyCached) return;
|
||||
let done = 0;
|
||||
|
|
@ -523,7 +588,7 @@ export function HubModelPicker({
|
|||
})
|
||||
.catch(() => {})
|
||||
.finally(check);
|
||||
}, [alreadyCached]);
|
||||
}, [alreadyCached, refreshLocalModelsList, refreshScanFolders]);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
|
|
@ -878,9 +943,95 @@ export function HubModelPicker({
|
|||
</>
|
||||
) : null}
|
||||
|
||||
{!showHfSection && customFolderModels.length > 0 ? (
|
||||
{!showHfSection ? (
|
||||
<>
|
||||
<ListLabel>Custom Folders</ListLabel>
|
||||
<div className="flex items-center justify-between px-2.5 py-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Custom Folders
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={showFolderInput ? "Cancel adding folder" : "Add scan folder"}
|
||||
onClick={() => {
|
||||
setShowFolderInput((open) => {
|
||||
if (open) { setFolderInput(""); setFolderError(null); }
|
||||
return !open;
|
||||
});
|
||||
}}
|
||||
className="rounded p-0.5 text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon icon={showFolderInput ? Cancel01Icon : Add01Icon} className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Folder paths */}
|
||||
{scanFolders.map((f) => (
|
||||
<div
|
||||
key={f.id}
|
||||
className="group flex items-center gap-1.5 px-3 py-0.5"
|
||||
>
|
||||
<HugeiconsIcon icon={Folder02Icon} className="size-3 shrink-0 text-muted-foreground/40" />
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-[10px] text-muted-foreground/70"
|
||||
title={f.path}
|
||||
>
|
||||
{f.path}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveFolder(f.id)}
|
||||
aria-label={`Remove folder ${f.path}`}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground/40 opacity-100 md:opacity-0 md:group-hover:opacity-100 focus-visible:opacity-100 transition-opacity hover:text-destructive"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add folder input */}
|
||||
{showFolderInput && (
|
||||
<div className="px-2.5 pb-1 pt-0.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<HugeiconsIcon icon={Folder02Icon} className="size-3 shrink-0 text-muted-foreground/40" />
|
||||
<input
|
||||
value={folderInput}
|
||||
onChange={(e) => { setFolderInput(e.target.value); setFolderError(null); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); handleAddFolder(); }
|
||||
if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); setShowFolderInput(false); setFolderInput(""); setFolderError(null); }
|
||||
}}
|
||||
placeholder="/path/to/models"
|
||||
className="h-6 min-w-0 flex-1 rounded border border-border/50 bg-transparent px-1.5 font-mono text-[10px] text-foreground outline-none placeholder:text-muted-foreground/40 focus:border-foreground/20"
|
||||
disabled={folderLoading}
|
||||
autoFocus={true}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddFolder}
|
||||
disabled={folderLoading || !folderInput.trim()}
|
||||
className="h-6 shrink-0 rounded border border-border/50 px-1.5 text-[10px] text-muted-foreground transition-colors hover:bg-accent disabled:opacity-40"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{folderError && (
|
||||
<p className="px-0.5 pt-0.5 text-[10px] text-destructive">{folderError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{scanFolders.length === 0 && customFolderModels.length === 0 && !showFolderInput && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFolderInput(true)}
|
||||
className="px-2.5 pb-1.5 text-left text-[10px] text-muted-foreground/60 transition-colors hover:text-muted-foreground"
|
||||
>
|
||||
+ Add a folder to scan for local models
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Models from custom folders */}
|
||||
{customFolderModels.map((m) => {
|
||||
const isGguf =
|
||||
isGgufRepo(m.id) ||
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
|
|||
}}
|
||||
>
|
||||
<ThreadPrimitive.Viewport
|
||||
className="aui-thread-viewport relative flex flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-4 pt-4"
|
||||
className="aui-thread-viewport relative flex min-w-0 flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-4 pt-4"
|
||||
>
|
||||
{!hideWelcome && (
|
||||
<AuiIf condition={({ thread }) => thread.isEmpty}>
|
||||
|
|
@ -89,7 +89,13 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
|
|||
}}
|
||||
/>
|
||||
|
||||
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 z-20 mt-auto flex w-full flex-col gap-4 overflow-visible bg-background pb-4 md:pb-4">
|
||||
<ThreadPrimitive.ViewportFooter className={cn(
|
||||
"aui-thread-viewport-footer sticky bottom-0 z-20 mt-auto flex w-full flex-col gap-4 overflow-visible pb-4 md:pb-4",
|
||||
hideComposer ? "bg-background" : "relative bg-transparent",
|
||||
)}>
|
||||
{!hideComposer && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-4 bg-background" aria-hidden />
|
||||
)}
|
||||
<ThreadScrollToBottom />
|
||||
<AuiIf condition={({ thread }) => !thread.isEmpty}>
|
||||
{!hideComposer && <ComposerAnimated />}
|
||||
|
|
@ -118,7 +124,7 @@ const SUGGESTION_TOOLS: Record<string, Array<"thinking" | "search" | "code">> =
|
|||
"How do you fine-tune an audio model with Unsloth?": ["thinking", "search"],
|
||||
"Create a live weather dashboard in HTML using no API key. Show me the code": ["thinking", "code", "search"],
|
||||
"Solve the integral of x·sin(x), and verify it step by step": ["thinking", "code"],
|
||||
"Draw an SVG of a cute sloth": ["thinking", "code", "search"],
|
||||
"Draw an SVG of a cute sloth & show the code": ["thinking", "code", "search"],
|
||||
};
|
||||
|
||||
const toolIconMap = {
|
||||
|
|
@ -220,14 +226,20 @@ const GeneratingSpinner: FC = () => {
|
|||
|
||||
const ComposerAnimated: FC = () => {
|
||||
return (
|
||||
<motion.div
|
||||
layout={true}
|
||||
layoutId="composer"
|
||||
transition={{ type: "spring", bounce: 0.15, duration: 0.5 }}
|
||||
className="mx-auto w-full max-w-(--thread-max-width)"
|
||||
>
|
||||
<Composer />
|
||||
</motion.div>
|
||||
<div className="relative mx-auto min-w-0 w-full max-w-(--thread-max-width)">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-1/2 bottom-0 z-0 bg-background"
|
||||
aria-hidden
|
||||
/>
|
||||
<motion.div
|
||||
layout={true}
|
||||
layoutId="composer"
|
||||
transition={{ type: "spring", bounce: 0.15, duration: 0.5 }}
|
||||
className="relative z-10 w-full"
|
||||
>
|
||||
<Composer />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -262,7 +274,7 @@ const Composer: FC = () => {
|
|||
<ToolStatusDisplay />
|
||||
<ComposerPrimitive.Input
|
||||
placeholder="Send a message..."
|
||||
className="aui-composer-input mb-1 max-h-32 min-h-12 w-full resize-none bg-transparent px-4 pt-2 pb-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-0"
|
||||
className="aui-composer-input mb-1 max-h-32 min-h-12 w-full resize-none bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-0"
|
||||
rows={1}
|
||||
autoFocus={true}
|
||||
aria-label="Message input"
|
||||
|
|
@ -437,21 +449,40 @@ const CodeToolsToggle: FC = () => {
|
|||
|
||||
const ToolStatusDisplay: FC = () => {
|
||||
const toolStatus = useChatRuntimeStore((s) => s.toolStatus);
|
||||
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toolStatus) {
|
||||
setElapsed(0);
|
||||
if (!isThreadRunning) {
|
||||
setVisible(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setElapsed(0);
|
||||
|
||||
// Debounce badge visibility by 300ms when the badge is not
|
||||
// already on screen. Once visible from a prior tool, consecutive
|
||||
// tools show immediately so the badge does not flicker. Fast
|
||||
// tool calls that all complete under 300ms never show the badge.
|
||||
let showTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
if (!visible) {
|
||||
showTimer = setTimeout(() => setVisible(true), 300);
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setElapsed((prev) => prev + 1);
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [toolStatus]);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
if (showTimer) clearTimeout(showTimer);
|
||||
};
|
||||
}, [toolStatus, isThreadRunning]);
|
||||
|
||||
if (!toolStatus) return null;
|
||||
if (!toolStatus || !visible) return null;
|
||||
const isRunning = toolStatus.startsWith("Running");
|
||||
const StatusIcon = isRunning ? TerminalIcon : GlobeIcon;
|
||||
return (
|
||||
|
|
@ -555,10 +586,10 @@ const GeneratingIndicator: FC = () => {
|
|||
const AssistantMessage: FC = () => {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
|
||||
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto min-w-0 w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
|
||||
data-role="assistant"
|
||||
>
|
||||
<div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed">
|
||||
<div className="aui-assistant-message-content wrap-break-word min-w-0 text-foreground leading-relaxed">
|
||||
<GeneratingIndicator />
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
|
|
@ -581,7 +612,7 @@ const AssistantMessage: FC = () => {
|
|||
<MessageError />
|
||||
</div>
|
||||
|
||||
<div className="aui-assistant-message-footer mt-1 ml-2 flex">
|
||||
<div className="aui-assistant-message-footer mt-1 flex">
|
||||
<BranchPicker />
|
||||
<AssistantActionBar />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -156,12 +156,12 @@ function ToolFallbackTrigger({
|
|||
<span
|
||||
data-slot="tool-fallback-trigger-label"
|
||||
className={cn(
|
||||
"aui-tool-fallback-trigger-label-wrapper relative inline-block grow text-left leading-none",
|
||||
"aui-tool-fallback-trigger-label-wrapper relative inline-block grow text-left leading-none text-muted-foreground",
|
||||
isCancelled && "text-muted-foreground line-through",
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{label}: <b>{toolName}</b>
|
||||
{label}: <span className="font-medium text-foreground/85">{toolName}</span>
|
||||
</span>
|
||||
{isRunning && (
|
||||
<span
|
||||
|
|
@ -169,7 +169,7 @@ function ToolFallbackTrigger({
|
|||
data-slot="tool-fallback-trigger-shimmer"
|
||||
className="aui-tool-fallback-trigger-shimmer shimmer pointer-events-none absolute inset-0 motion-reduce:animate-none"
|
||||
>
|
||||
{label}: <b>{toolName}</b>
|
||||
{label}: <span className="font-medium text-foreground/85">{toolName}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"use client";
|
||||
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { getAuthToken } from "@/features/auth/session";
|
||||
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
|
||||
import { code as codePlugin } from "@streamdown/code";
|
||||
import { CheckIcon, CodeIcon, CopyIcon, LoaderIcon } from "lucide-react";
|
||||
|
|
@ -15,6 +16,12 @@ import {
|
|||
ToolFallbackTrigger,
|
||||
} from "./tool-fallback";
|
||||
|
||||
interface StructuredResult {
|
||||
text: string;
|
||||
images: string[];
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
const MAX_DISPLAY = 10_000;
|
||||
const COPY_RESET_MS = 2000;
|
||||
const SHIKI_THEME = ["github-light", "github-dark"] as ["github-light", "github-dark"];
|
||||
|
|
@ -84,6 +91,16 @@ function HighlightedCode({ code: source, language }: { code: string; language: s
|
|||
);
|
||||
}
|
||||
|
||||
function isStructuredResult(val: unknown): val is StructuredResult {
|
||||
return (
|
||||
typeof val === "object" &&
|
||||
val !== null &&
|
||||
"text" in val &&
|
||||
"images" in val &&
|
||||
"sessionId" in val
|
||||
);
|
||||
}
|
||||
|
||||
const PythonToolUIImpl: ToolCallMessagePartComponent = ({
|
||||
args,
|
||||
result,
|
||||
|
|
@ -92,12 +109,24 @@ const PythonToolUIImpl: ToolCallMessagePartComponent = ({
|
|||
const code = (args as { code?: string })?.code ?? "";
|
||||
const firstLine = code.split("\n")[0]?.slice(0, 60) ?? "";
|
||||
const isRunning = status?.type === "running";
|
||||
const output =
|
||||
typeof result === "string"
|
||||
? result
|
||||
: result
|
||||
? JSON.stringify(result, null, 2)
|
||||
: "";
|
||||
|
||||
let output: string;
|
||||
let images: string[] = [];
|
||||
let sessionId = "";
|
||||
|
||||
if (isStructuredResult(result)) {
|
||||
output = result.text;
|
||||
images = result.images;
|
||||
sessionId = result.sessionId;
|
||||
} else if (typeof result === "string") {
|
||||
output = result;
|
||||
} else if (result) {
|
||||
output = JSON.stringify(result, null, 2);
|
||||
} else {
|
||||
output = "";
|
||||
}
|
||||
|
||||
const authToken = getAuthToken();
|
||||
|
||||
return (
|
||||
<ToolFallbackRoot>
|
||||
|
|
@ -133,6 +162,21 @@ const PythonToolUIImpl: ToolCallMessagePartComponent = ({
|
|||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Images from Python tool execution */}
|
||||
{images.length > 0 && sessionId && (
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
{images.map((filename) => (
|
||||
<img
|
||||
key={filename}
|
||||
src={`/api/inference/sandbox/${encodeURIComponent(sessionId)}/${encodeURIComponent(filename)}${authToken ? `?token=${encodeURIComponent(authToken)}` : ""}`}
|
||||
alt={filename}
|
||||
loading="lazy"
|
||||
className="max-w-full rounded border border-border"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ToolFallbackContent>
|
||||
</ToolFallbackRoot>
|
||||
|
|
|
|||
|
|
@ -372,90 +372,94 @@ export function Navbar() {
|
|||
})}
|
||||
</nav>
|
||||
|
||||
{/* Right: docs/tour desktop */}
|
||||
<div className="hidden items-center justify-self-end gap-2 md:flex">
|
||||
<AnimatedThemeToggler
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-4"
|
||||
title="Toggle theme"
|
||||
aria-label="Toggle theme"
|
||||
/>
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild={true}>
|
||||
<a
|
||||
href="https://unsloth.ai/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-emerald-600 hover:text-emerald-700 transition-colors"
|
||||
>
|
||||
<HugeiconsIcon icon={Book03Icon} className="size-4" />
|
||||
Learn more
|
||||
</a>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="end" className="w-80 p-0">
|
||||
<a
|
||||
href="https://unsloth.ai/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group/card flex flex-col gap-1 p-4 no-underline"
|
||||
>
|
||||
<p className="text-sm font-semibold font-heading">
|
||||
Unsloth Documentation
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Guides on fine-tuning LLMs 2x faster with 70% less memory.
|
||||
Covers LoRA, QLoRA, data formatting, and deployment.
|
||||
</p>
|
||||
<span className="mt-1 flex items-center gap-1 text-xs font-medium text-emerald-600 group-hover/card:underline">
|
||||
Visit docs
|
||||
<HugeiconsIcon icon={ArrowRight01Icon} className="size-3" />
|
||||
</span>
|
||||
</a>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={tourId ? openTour : undefined}
|
||||
className={cn(
|
||||
"flex h-9 items-center gap-1.5 rounded-md px-3 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground",
|
||||
!tourId && "invisible pointer-events-none",
|
||||
)}
|
||||
title="Tour"
|
||||
aria-hidden={!tourId}
|
||||
tabIndex={tourId ? 0 : -1}
|
||||
>
|
||||
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
|
||||
<span className="text-sm font-medium">Tour</span>
|
||||
</button>
|
||||
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild={true}>
|
||||
{/* Right: docs/tour desktop — one wrapper per control so flex gap is even (HoverCard roots can confuse flex spacing). */}
|
||||
<div className="hidden items-center justify-self-end gap-0 md:flex">
|
||||
<div className="flex shrink-0 items-center">
|
||||
<AnimatedThemeToggler
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-4"
|
||||
title="Toggle theme"
|
||||
aria-label="Toggle theme"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild={true}>
|
||||
<a
|
||||
href="https://unsloth.ai/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-md px-3 text-sm font-medium text-emerald-600 transition-colors hover:bg-accent hover:text-emerald-700 dark:hover:text-emerald-400"
|
||||
>
|
||||
<HugeiconsIcon icon={Book03Icon} className="size-4" />
|
||||
Learn more
|
||||
</a>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="end" className="w-80 p-0">
|
||||
<a
|
||||
href="https://unsloth.ai/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group/card flex flex-col gap-1 p-4 no-underline"
|
||||
>
|
||||
<p className="text-sm font-semibold font-heading">
|
||||
Unsloth Documentation
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Guides on fine-tuning LLMs 2x faster with 70% less memory.
|
||||
Covers LoRA, QLoRA, data formatting, and deployment.
|
||||
</p>
|
||||
<span className="mt-1 flex items-center gap-1 text-xs font-medium text-emerald-600 group-hover/card:underline">
|
||||
Visit docs
|
||||
<HugeiconsIcon icon={ArrowRight01Icon} className="size-3" />
|
||||
</span>
|
||||
</a>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
{tourId ? (
|
||||
<div className="flex shrink-0 items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 items-center gap-1.5 rounded-md px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
aria-label="How to update Unsloth Studio"
|
||||
onClick={openTour}
|
||||
className="flex h-9 items-center gap-1.5 rounded-md px-3 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Tour"
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowReloadHorizontalIcon} className="size-4" />
|
||||
Update
|
||||
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
|
||||
<span className="text-sm font-medium">Tour</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="end" className="w-[22.5rem] p-0">
|
||||
<UpdateStudioInstructions
|
||||
className="p-4"
|
||||
defaultShell={defaultUpdateShell}
|
||||
/>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShutdownOpen(true)}
|
||||
className="-mr-1.5 flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Shut down Unsloth Studio server"
|
||||
aria-label="Shut down Unsloth Studio server"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex shrink-0 items-center">
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 items-center gap-1.5 rounded-md px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
aria-label="How to update Unsloth Studio"
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowReloadHorizontalIcon} className="size-4" />
|
||||
Update
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="end" className="w-[22.5rem] p-0">
|
||||
<UpdateStudioInstructions
|
||||
className="p-4"
|
||||
defaultShell={defaultUpdateShell}
|
||||
/>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShutdownOpen(true)}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Shut down Unsloth Studio server"
|
||||
aria-label="Shut down Unsloth Studio server"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: mobile */}
|
||||
|
|
|
|||
|
|
@ -141,6 +141,10 @@ export const MODEL_TYPE_TO_HF_TASK: Record<ModelType, PipelineType> = {
|
|||
|
||||
|
||||
export const PRIORITY_TRAINING_MODELS: readonly string[] = [
|
||||
"unsloth/gemma-4-E2B-it",
|
||||
"unsloth/gemma-4-E4B-it",
|
||||
"unsloth/gemma-4-31B-it",
|
||||
"unsloth/gemma-4-26B-A4B-it",
|
||||
"unsloth/Qwen3.5-2B",
|
||||
"unsloth/Qwen3.5-9B",
|
||||
"unsloth/gpt-oss-20b",
|
||||
|
|
|
|||
|
|
@ -635,7 +635,23 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
toolCallParts[toolCallParts.length - 1]?.toolCallId || "";
|
||||
const idx = toolCallParts.findIndex((p) => p.toolCallId === id);
|
||||
if (idx !== -1) {
|
||||
toolCallParts[idx] = { ...toolCallParts[idx], result: toolEvent.result as string };
|
||||
const rawResult = (toolEvent.result as string) ?? "";
|
||||
const imgMarker = "\n__IMAGES__:";
|
||||
const imgIdx = rawResult.lastIndexOf(imgMarker);
|
||||
let parsedResult: string | { text: string; images: string[]; sessionId: string };
|
||||
if (imgIdx !== -1) {
|
||||
const text = rawResult.slice(0, imgIdx);
|
||||
const sessionId = unstable_threadId || "";
|
||||
try {
|
||||
const images = JSON.parse(rawResult.slice(imgIdx + imgMarker.length)) as string[];
|
||||
parsedResult = { text, images, sessionId };
|
||||
} catch {
|
||||
parsedResult = rawResult;
|
||||
}
|
||||
} else {
|
||||
parsedResult = rawResult;
|
||||
}
|
||||
toolCallParts[idx] = { ...toolCallParts[idx], result: parsedResult };
|
||||
}
|
||||
}
|
||||
// Yield cumulative state so tool UI updates (tools first, text after)
|
||||
|
|
|
|||
|
|
@ -162,10 +162,12 @@ const CompareContent = memo(function CompareContent({
|
|||
pairId,
|
||||
models,
|
||||
loraModels,
|
||||
onFoldersChange,
|
||||
}: {
|
||||
pairId: string;
|
||||
models: ModelOption[];
|
||||
loraModels: LoraModelOption[];
|
||||
onFoldersChange?: () => void;
|
||||
}): ReactElement {
|
||||
const isLoraCompare = useIsLoraCompare();
|
||||
|
||||
|
|
@ -176,6 +178,7 @@ const CompareContent = memo(function CompareContent({
|
|||
pairId={pairId}
|
||||
models={models}
|
||||
loraModels={loraModels}
|
||||
onFoldersChange={onFoldersChange}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
@ -259,10 +262,12 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
|
|||
pairId,
|
||||
models,
|
||||
loraModels,
|
||||
onFoldersChange,
|
||||
}: {
|
||||
pairId: string;
|
||||
models: ModelOption[];
|
||||
loraModels: LoraModelOption[];
|
||||
onFoldersChange?: () => void;
|
||||
}): ReactElement {
|
||||
const handlesRef = useRef<Record<string, CompareHandle>>({});
|
||||
const [model1ThreadId, setModel1ThreadId] = useState<string>();
|
||||
|
|
@ -327,6 +332,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
|
|||
ggufVariant: meta.ggufVariant,
|
||||
})
|
||||
}
|
||||
onFoldersChange={onFoldersChange}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="max-w-[50%]"
|
||||
|
|
@ -359,6 +365,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
|
|||
ggufVariant: meta.ggufVariant,
|
||||
})
|
||||
}
|
||||
onFoldersChange={onFoldersChange}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="max-w-[50%]"
|
||||
|
|
@ -846,6 +853,7 @@ export function ChatPage(): ReactElement {
|
|||
activeGgufVariant={activeGgufVariant}
|
||||
onValueChange={handleCheckpointChange}
|
||||
onEject={handleEject}
|
||||
onFoldersChange={refreshLocalModels}
|
||||
variant="ghost"
|
||||
open={modelSelectorOpen}
|
||||
onOpenChange={handleModelSelectorOpenChange}
|
||||
|
|
@ -858,14 +866,16 @@ export function ChatPage(): ReactElement {
|
|||
label={
|
||||
loadProgress?.phase === "starting"
|
||||
? "Starting model…"
|
||||
: loadingModel.isDownloaded
|
||||
: loadingModel.isDownloaded || loadingModel.isCachedLora
|
||||
? "Loading model…"
|
||||
: "Downloading model…"
|
||||
}
|
||||
title={
|
||||
loadingModel.isDownloaded
|
||||
? `Loading ${loadingModel.displayName} from cache.`
|
||||
: `Loading ${loadingModel.displayName}. This may include downloading.`
|
||||
: loadingModel.isCachedLora
|
||||
? `Loading ${loadingModel.displayName} into memory.`
|
||||
: `Loading ${loadingModel.displayName}. This may include downloading.`
|
||||
}
|
||||
progressPercent={loadProgress?.percent}
|
||||
progressLabel={loadProgress?.label}
|
||||
|
|
@ -911,6 +921,7 @@ export function ChatPage(): ReactElement {
|
|||
pairId={view.pairId}
|
||||
models={models}
|
||||
loraModels={loraModels}
|
||||
onFoldersChange={refreshLocalModels}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -934,7 +945,6 @@ export function ChatPage(): ReactElement {
|
|||
});
|
||||
}
|
||||
}}
|
||||
onFoldersChange={refreshLocalModels}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ import {
|
|||
CodeIcon,
|
||||
Delete02Icon,
|
||||
FloppyDiskIcon,
|
||||
FolderSearchIcon,
|
||||
PencilEdit01Icon,
|
||||
Settings02Icon,
|
||||
SlidersHorizontalIcon,
|
||||
|
|
@ -44,13 +43,7 @@ import {
|
|||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
type ScanFolderInfo,
|
||||
addScanFolder,
|
||||
listScanFolders,
|
||||
removeScanFolder,
|
||||
} from "./api/chat-api";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
|
||||
import {
|
||||
DEFAULT_INFERENCE_PARAMS,
|
||||
|
|
@ -266,108 +259,6 @@ function CollapsibleSection({
|
|||
);
|
||||
}
|
||||
|
||||
function ModelFoldersSection({
|
||||
onFoldersChange,
|
||||
}: { onFoldersChange?: () => void }) {
|
||||
const [folders, setFolders] = useState<ScanFolderInfo[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
listScanFolders()
|
||||
.then(setFolders)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return;
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await addScanFolder(trimmed);
|
||||
setInput("");
|
||||
refresh();
|
||||
onFoldersChange?.();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to add folder");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (id: number) => {
|
||||
try {
|
||||
await removeScanFolder(id);
|
||||
onFoldersChange?.();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to remove folder");
|
||||
} finally {
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CollapsibleSection icon={FolderSearchIcon} label="Model Folders">
|
||||
<div className="flex flex-col gap-2 py-1">
|
||||
{folders.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{folders.map((f) => (
|
||||
<div
|
||||
key={f.id}
|
||||
className="group flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs transition-colors hover:bg-accent"
|
||||
>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-muted-foreground"
|
||||
title={f.path}
|
||||
>
|
||||
{f.path}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(f.id)}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground/50 opacity-0 transition-opacity group-hover:opacity-100 hover:text-destructive"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAdd();
|
||||
}}
|
||||
placeholder="/path/to/models"
|
||||
className="h-7 flex-1 text-xs font-mono"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
disabled={loading || !input.trim()}
|
||||
className="h-7 rounded-md border px-2 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-[11px] text-destructive">{error}</p>}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChatSettingsPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
|
|
@ -376,7 +267,6 @@ interface ChatSettingsPanelProps {
|
|||
autoTitle: boolean;
|
||||
onAutoTitleChange: (enabled: boolean) => void;
|
||||
onReloadModel?: () => void;
|
||||
onFoldersChange?: () => void;
|
||||
}
|
||||
|
||||
export function ChatSettingsPanel({
|
||||
|
|
@ -387,7 +277,6 @@ export function ChatSettingsPanel({
|
|||
autoTitle,
|
||||
onAutoTitleChange,
|
||||
onReloadModel,
|
||||
onFoldersChange,
|
||||
}: ChatSettingsPanelProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const isGguf = useChatRuntimeStore((s) => s.activeGgufVariant) != null;
|
||||
|
|
@ -395,6 +284,9 @@ export function ChatSettingsPanel({
|
|||
const ggufMaxContextLength = useChatRuntimeStore(
|
||||
(s) => s.ggufMaxContextLength,
|
||||
);
|
||||
const ggufNativeContextLength = useChatRuntimeStore(
|
||||
(s) => s.ggufNativeContextLength,
|
||||
);
|
||||
const kvCacheDtype = useChatRuntimeStore((s) => s.kvCacheDtype);
|
||||
const setKvCacheDtype = useChatRuntimeStore((s) => s.setKvCacheDtype);
|
||||
const loadedKvCacheDtype = useChatRuntimeStore((s) => s.loadedKvCacheDtype);
|
||||
|
|
@ -404,7 +296,7 @@ export function ChatSettingsPanel({
|
|||
);
|
||||
|
||||
const ctxDisplayValue = customContextLength ?? ggufContextLength ?? "";
|
||||
const ctxMaxValue = ggufMaxContextLength ?? ggufContextLength ?? null;
|
||||
const ctxMaxValue = ggufNativeContextLength ?? ggufContextLength ?? null;
|
||||
const kvDirty = kvCacheDtype !== loadedKvCacheDtype;
|
||||
const ctxDirty = customContextLength !== null;
|
||||
const modelSettingsDirty = kvDirty || ctxDirty;
|
||||
|
|
@ -655,6 +547,13 @@ export function ChatSettingsPanel({
|
|||
);
|
||||
}}
|
||||
/>
|
||||
{ggufMaxContextLength != null &&
|
||||
typeof ctxDisplayValue === "number" &&
|
||||
ctxDisplayValue > ggufMaxContextLength && (
|
||||
<p className="text-[11px] text-amber-500">
|
||||
Exceeds estimated VRAM capacity ({ggufMaxContextLength.toLocaleString()} tokens). The model may use system RAM.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
|
|
@ -835,8 +734,6 @@ export function ChatSettingsPanel({
|
|||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<ModelFoldersSection onFoldersChange={onFoldersChange} />
|
||||
|
||||
<ChatTemplateSection onReloadModel={onReloadModel} />
|
||||
</div>
|
||||
<Dialog
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ export function useChatModelRuntime() {
|
|||
id: string;
|
||||
displayName: string;
|
||||
isDownloaded?: boolean;
|
||||
isCachedLora?: boolean;
|
||||
} | null>(null);
|
||||
const [loadToastDismissed, setLoadToastDismissed] = useState(false);
|
||||
const [loadProgress, setLoadProgress] = useState<{
|
||||
|
|
@ -246,12 +247,16 @@ export function useChatModelRuntime() {
|
|||
const ggufMaxContextLength = statusRes.is_gguf
|
||||
? (statusRes.max_context_length ?? null)
|
||||
: null;
|
||||
const ggufNativeContextLength = statusRes.is_gguf
|
||||
? (statusRes.native_context_length ?? null)
|
||||
: null;
|
||||
useChatRuntimeStore.setState({
|
||||
supportsReasoning,
|
||||
reasoningAlwaysOn,
|
||||
supportsTools,
|
||||
ggufContextLength: currentGgufContextLength,
|
||||
ggufMaxContextLength,
|
||||
ggufNativeContextLength,
|
||||
});
|
||||
|
||||
// Set reasoning default for Qwen3.5 small models
|
||||
|
|
@ -290,8 +295,11 @@ export function useChatModelRuntime() {
|
|||
setLoadToastDismissedState(false);
|
||||
clearCheckpoint();
|
||||
if (tid != null) toast.dismiss(tid);
|
||||
const isCachedOrLocal = model.isDownloaded || model.isCachedLora;
|
||||
toast.info("Stopped loading model", {
|
||||
description: "The current download may still finish in the background.",
|
||||
description: isCachedOrLocal
|
||||
? undefined
|
||||
: "The current download may still finish in the background.",
|
||||
});
|
||||
// Fire-and-forget: tell backend to stop, don't block UI
|
||||
unloadModel({ model_path: model.id }).catch(() => {});
|
||||
|
|
@ -335,20 +343,24 @@ export function useChatModelRuntime() {
|
|||
: undefined;
|
||||
const previousIsLora =
|
||||
previousModel?.isLora ?? (previousLora ? true : false);
|
||||
// Covers Unix absolute (/), relative (./ ../), tilde (~/), Windows drive (C:\), UNC (\\server)
|
||||
const isLocal = /^(\/|\.{1,2}[\\\/]|~[\\\/]|[A-Za-z]:[\\\/]|\\\\)/.test(modelId);
|
||||
const isCachedLora = isLora && isLocal;
|
||||
const loadingDescription = [
|
||||
currentCheckpoint ? "Switching models." : null,
|
||||
extraLoadingDescription ?? null,
|
||||
isDownloaded ? "Loading cached model into memory." : null,
|
||||
!isDownloaded && isCachedLora ? "Loading trained model into memory." : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
setModelsError(null);
|
||||
setLoadToastDismissedState(false);
|
||||
const loadInfo = { id: modelId, displayName, isDownloaded };
|
||||
const loadInfo = { id: modelId, displayName, isDownloaded, isCachedLora };
|
||||
setLoadingModel(loadInfo);
|
||||
useChatRuntimeStore.getState().setModelLoading(true);
|
||||
setLoadProgress(
|
||||
isDownloaded
|
||||
isDownloaded || isCachedLora
|
||||
? { percent: null, label: null, phase: "starting" }
|
||||
: { percent: 0, label: "Preparing download", phase: "downloading" },
|
||||
);
|
||||
|
|
@ -425,6 +437,9 @@ export function useChatModelRuntime() {
|
|||
const reportedMaxCtx = loadResponse.is_gguf
|
||||
? (loadResponse.max_context_length ?? null)
|
||||
: null;
|
||||
const reportedNativeCtx = loadResponse.is_gguf
|
||||
? (loadResponse.native_context_length ?? null)
|
||||
: null;
|
||||
// A successful reload has applied settings, so clear pending custom
|
||||
// context state and display the backend-reported effective context.
|
||||
const keepCustomCtx = null;
|
||||
|
|
@ -433,6 +448,7 @@ export function useChatModelRuntime() {
|
|||
useChatRuntimeStore.setState({
|
||||
ggufContextLength: nativeCtx,
|
||||
ggufMaxContextLength,
|
||||
ggufNativeContextLength: reportedNativeCtx,
|
||||
supportsReasoning: loadResponse.supports_reasoning ?? false,
|
||||
reasoningAlwaysOn,
|
||||
reasoningEnabled: reasoningAlwaysOn ? true : reasoningDefault,
|
||||
|
|
@ -477,15 +493,16 @@ export function useChatModelRuntime() {
|
|||
}
|
||||
}
|
||||
|
||||
const toastTitle = isDownloaded ? "Starting model…" : "Downloading model…";
|
||||
const isCachedLoad = isDownloaded || isCachedLora;
|
||||
const toastTitle = isCachedLoad ? "Starting model…" : "Downloading model…";
|
||||
const toastId = toast(
|
||||
null,
|
||||
{
|
||||
description: renderLoadDescription(
|
||||
toastTitle,
|
||||
loadingDescription,
|
||||
isDownloaded ? null : 0,
|
||||
isDownloaded ? null : "Preparing download",
|
||||
isCachedLoad ? null : 0,
|
||||
isCachedLoad ? null : "Preparing download",
|
||||
cancelLoading,
|
||||
),
|
||||
duration: Infinity,
|
||||
|
|
@ -503,7 +520,7 @@ export function useChatModelRuntime() {
|
|||
|
||||
// Poll download progress for non-cached models (GGUF and non-GGUF)
|
||||
let progressInterval: ReturnType<typeof setInterval> | null = null;
|
||||
if (!isDownloaded) {
|
||||
if (!isDownloaded && !isCachedLora) {
|
||||
const expectedBytes =
|
||||
typeof selection !== "string" ? selection.expectedBytes ?? 0 : 0;
|
||||
let hasShownProgress = false;
|
||||
|
|
|
|||
|
|
@ -47,9 +47,9 @@ const DEFAULT_SUGGESTIONS = [
|
|||
prompt: "Solve the integral of x·sin(x), and verify it step by step",
|
||||
},
|
||||
{
|
||||
title: "Draw an SVG of a cute sloth",
|
||||
title: "Draw an SVG of a cute sloth & show the code",
|
||||
label: "SVG sloth",
|
||||
prompt: "Draw an SVG of a cute sloth",
|
||||
prompt: "Draw an SVG of a cute sloth & show the code",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -569,10 +569,32 @@ function ThreadHistoryProvider({
|
|||
store.setContextUsage(savedUsage);
|
||||
}
|
||||
|
||||
// If any message has a stored parentId, reconstruct the tree
|
||||
// so retries/regenerations load as branches instead of being
|
||||
// unrolled into a flat list. For mixed legacy/new threads
|
||||
// (old messages without parentId + new messages with), infer
|
||||
// sequential parents for old messages to preserve the chain.
|
||||
// Fall back to fromArray for fully legacy threads.
|
||||
const hasParentIds = msgs.some((m) => "parentId" in m);
|
||||
if (hasParentIds) {
|
||||
let previousId: string | null = null;
|
||||
return {
|
||||
messages: msgs.map((m) => {
|
||||
const parentId = "parentId" in m
|
||||
? (m.parentId ?? null)
|
||||
: previousId;
|
||||
previousId = m.id;
|
||||
return {
|
||||
parentId,
|
||||
message: toThreadMessage(m),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
return ExportedMessageRepository.fromArray(msgs.map(toThreadMessage));
|
||||
},
|
||||
|
||||
async append({ message }: ExportedMessageRepositoryItem) {
|
||||
async append({ parentId, message }: ExportedMessageRepositoryItem) {
|
||||
const { remoteId } = await aui.threadListItem().initialize();
|
||||
const content = cloneContent(message.content);
|
||||
const attachments =
|
||||
|
|
@ -586,6 +608,7 @@ function ThreadHistoryProvider({
|
|||
await db.messages.put({
|
||||
id: message.id,
|
||||
threadId: remoteId,
|
||||
parentId: parentId ?? null,
|
||||
role: message.role,
|
||||
content,
|
||||
...(attachments.length > 0 && { attachments }),
|
||||
|
|
|
|||
|
|
@ -473,7 +473,7 @@ export function SharedComposer({
|
|||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Send to both models..."
|
||||
className="mb-1 max-h-32 min-h-14 w-full resize-none bg-transparent px-4 pt-2 pb-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||
className="mb-1 max-h-32 min-h-14 w-full resize-none bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||
rows={1}
|
||||
/>
|
||||
<div className="relative mx-2 mb-2 flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ type ChatRuntimeStore = {
|
|||
activeGgufVariant: string | null;
|
||||
ggufContextLength: number | null;
|
||||
ggufMaxContextLength: number | null;
|
||||
ggufNativeContextLength: number | null;
|
||||
supportsReasoning: boolean;
|
||||
reasoningAlwaysOn: boolean;
|
||||
reasoningEnabled: boolean;
|
||||
|
|
@ -215,6 +216,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
|
|||
activeGgufVariant: null,
|
||||
ggufContextLength: null,
|
||||
ggufMaxContextLength: null,
|
||||
ggufNativeContextLength: null,
|
||||
supportsReasoning: false,
|
||||
reasoningAlwaysOn: false,
|
||||
reasoningEnabled: true,
|
||||
|
|
@ -290,6 +292,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
|
|||
activeGgufVariant: null,
|
||||
ggufContextLength: null,
|
||||
ggufMaxContextLength: null,
|
||||
ggufNativeContextLength: null,
|
||||
contextUsage: null,
|
||||
supportsReasoning: false,
|
||||
reasoningEnabled: true,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export interface ThreadRecord {
|
|||
export interface MessageRecord {
|
||||
id: string;
|
||||
threadId: string;
|
||||
parentId?: string | null;
|
||||
role: import("@assistant-ui/react").ThreadMessage["role"];
|
||||
content: import("@assistant-ui/react").ThreadMessage["content"];
|
||||
attachments?: import("@assistant-ui/react").ThreadMessage["attachments"];
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ export interface LoadModelResponse {
|
|||
};
|
||||
context_length?: number | null;
|
||||
max_context_length?: number | null;
|
||||
native_context_length?: number | null;
|
||||
supports_reasoning?: boolean;
|
||||
reasoning_always_on?: boolean;
|
||||
supports_tools?: boolean;
|
||||
|
|
@ -121,6 +122,7 @@ export interface InferenceStatusResponse {
|
|||
supports_tools?: boolean;
|
||||
context_length?: number | null;
|
||||
max_context_length?: number | null;
|
||||
native_context_length?: number | null;
|
||||
}
|
||||
|
||||
export interface AudioGenerationResponse {
|
||||
|
|
|
|||
|
|
@ -268,12 +268,12 @@ export function ModelSelectionStep() {
|
|||
<ComboboxItem
|
||||
key={id}
|
||||
value={id}
|
||||
className={`justify-between ${exceeds ? "opacity-50" : ""}`}
|
||||
className="justify-between"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate ${exceeds ? "line-through decoration-muted-foreground/50" : ""}`}
|
||||
className={`min-w-0 flex-1 truncate ${exceeds ? "!text-gray-500 dark:!text-gray-400" : ""}`}
|
||||
>
|
||||
{id}
|
||||
</span>
|
||||
|
|
@ -287,12 +287,12 @@ export function ModelSelectionStep() {
|
|||
</Tooltip>
|
||||
<span className="flex items-center gap-1.5 shrink-0">
|
||||
{fitStatus === "exceeds" && (
|
||||
<span className="text-[9px] font-medium text-red-400">
|
||||
<span className="text-[9px] font-medium !text-red-700 !bg-red-50 dark:!text-red-400 dark:!bg-red-950 px-1.5 py-0.5 rounded">
|
||||
OOM
|
||||
</span>
|
||||
)}
|
||||
{fitStatus === "tight" && (
|
||||
<span className="text-[9px] font-medium text-amber-400">
|
||||
<span className="text-[9px] font-medium !text-amber-400">
|
||||
TIGHT
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -489,12 +489,12 @@ export function ModelSection() {
|
|||
<ComboboxItem
|
||||
key={id}
|
||||
value={id}
|
||||
className={`gap-2 ${exceeds ? "opacity-50" : ""}`}
|
||||
className="gap-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<span
|
||||
className={`block min-w-0 flex-1 truncate ${exceeds ? "line-through decoration-muted-foreground/50" : ""}`}
|
||||
className={`block min-w-0 flex-1 truncate ${exceeds ? "!text-gray-500 dark:!text-gray-400" : ""}`}
|
||||
>
|
||||
{id}
|
||||
</span>
|
||||
|
|
@ -519,12 +519,12 @@ export function ModelSection() {
|
|||
</Tooltip>
|
||||
<span className="ml-auto flex items-center gap-1.5 shrink-0">
|
||||
{fitStatus === "exceeds" && (
|
||||
<span className="text-[9px] font-medium text-red-400">
|
||||
<span className="text-[9px] font-medium !text-red-700 !bg-red-50 dark:!text-red-400 dark:!bg-red-950 px-1.5 py-0.5 rounded">
|
||||
OOM
|
||||
</span>
|
||||
)}
|
||||
{fitStatus === "tight" && (
|
||||
<span className="text-[9px] font-medium text-amber-400">
|
||||
<span className="text-[9px] font-medium !text-amber-400">
|
||||
TIGHT
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,11 @@ function makeMapModel(excludeGguf: boolean) {
|
|||
|
||||
/** Number of unsloth results to pull up-front before yielding general results. */
|
||||
const UNSLOTH_PREFETCH = 20;
|
||||
/** When the user searched for a specific publisher, show fewer unsloth results
|
||||
* before the pinned (original publisher) model. */
|
||||
const UNSLOTH_PINNED_PREFETCH = 4;
|
||||
/** Matches a valid "owner/repo" identifier (exactly two non-empty segments). */
|
||||
const PUBLISHER_RE = /^([^/\s]+)\/([^/\s]+)$/;
|
||||
|
||||
/**
|
||||
* Prime the hf-cache from a listModels result. For public (non-gated,
|
||||
|
|
@ -131,6 +136,7 @@ async function* mergedModelIterator(
|
|||
query: string,
|
||||
task?: PipelineType,
|
||||
accessToken?: string,
|
||||
pinnedId?: string,
|
||||
): AsyncGenerator<unknown> {
|
||||
const common = {
|
||||
additionalFields: ["safetensors", "tags"] as ("safetensors" | "tags")[],
|
||||
|
|
@ -148,6 +154,18 @@ async function* mergedModelIterator(
|
|||
...common,
|
||||
});
|
||||
|
||||
// Start pinned model lookup immediately so it can run in parallel with
|
||||
// the Phase 1 unsloth iteration instead of blocking Phase 2.
|
||||
const pinnedPromise = pinnedId
|
||||
? cachedModelInfo({
|
||||
name: pinnedId,
|
||||
additionalFields: ["safetensors", "tags"],
|
||||
...(accessToken ? { credentials: { accessToken } } : {}),
|
||||
}).catch(() => null)
|
||||
: null;
|
||||
|
||||
const limit = pinnedId ? UNSLOTH_PINNED_PREFETCH : UNSLOTH_PREFETCH;
|
||||
|
||||
// Phase 1: pull & yield unsloth models first
|
||||
const seen = new Set<string>();
|
||||
let count = 0;
|
||||
|
|
@ -159,10 +177,26 @@ async function* mergedModelIterator(
|
|||
}
|
||||
yield model;
|
||||
count++;
|
||||
if (count >= UNSLOTH_PREFETCH) break;
|
||||
if (count >= limit) break;
|
||||
}
|
||||
|
||||
// Phase 2: yield general results, skipping already-seen unsloth models
|
||||
// Phase 1b: yield the pinned (original publisher) model before general results
|
||||
if (pinnedId && !seen.has(pinnedId) && pinnedPromise) {
|
||||
const pinned = await pinnedPromise;
|
||||
if (pinned) {
|
||||
// Record both the raw input and the canonical name returned by HF
|
||||
// so phase 2 deduplication works even when casing differs
|
||||
// (e.g. user typed "OpenAI/gpt-oss-20b", HF returns "openai/gpt-oss-20b").
|
||||
seen.add(pinnedId);
|
||||
const canonicalName = (pinned as { name?: string }).name;
|
||||
if (canonicalName && canonicalName !== pinnedId) {
|
||||
seen.add(canonicalName);
|
||||
}
|
||||
yield pinned;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: yield general results, skipping already-seen models
|
||||
for await (const model of generalIter) {
|
||||
const m = model as { name?: string };
|
||||
if (m.name && seen.has(m.name)) continue;
|
||||
|
|
@ -235,11 +269,24 @@ export function useHfModelSearch(
|
|||
) {
|
||||
const { task, accessToken, excludeGguf = false, priorityIds } = options ?? {};
|
||||
|
||||
// Parse publisher detection once and share between the iterator factory
|
||||
// and the secondary sort gate (avoids duplicating the regex + logic).
|
||||
const { isPublisherQuery, searchQuery, pinnedId, trimmed } = useMemo(() => {
|
||||
const t = query.trim();
|
||||
const m = PUBLISHER_RE.exec(t);
|
||||
const is = !!m && m[1].toLowerCase() !== "unsloth";
|
||||
return {
|
||||
isPublisherQuery: is,
|
||||
searchQuery: is ? m![2] : t,
|
||||
pinnedId: is ? t : undefined,
|
||||
trimmed: t,
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
const createIter = useCallback(
|
||||
() => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
// No query → show priority models first (with full metadata), then general unsloth listing
|
||||
// No query: show priority models first (with full metadata), then general unsloth listing
|
||||
if (priorityIds && priorityIds.length > 0) {
|
||||
return priorityThenListingIterator(priorityIds, task, accessToken) as AsyncGenerator<unknown>;
|
||||
}
|
||||
|
|
@ -250,24 +297,35 @@ export function useHfModelSearch(
|
|||
...(accessToken ? { credentials: { accessToken } } : {}),
|
||||
}) as AsyncGenerator<unknown>;
|
||||
}
|
||||
// Typed query: disable task filter so explicitly searched models still appear even if HF task metadata is wrong/missing.
|
||||
return mergedModelIterator(trimmed, undefined, accessToken) as AsyncGenerator<unknown>;
|
||||
// Typed query: disable task filter so explicitly searched models still
|
||||
// appear even if HF task metadata is wrong/missing.
|
||||
// If the query is a valid "owner/repo" identifier (exactly two non-empty,
|
||||
// slash-free, space-free segments), strip the org prefix so unsloth
|
||||
// variants surface, then pin the original publisher model after a small
|
||||
// batch of unsloth results. Queries for unsloth-owned models are left
|
||||
// as-is so they get the full 20-result prefetch and secondary sort.
|
||||
return mergedModelIterator(searchQuery, undefined, accessToken, pinnedId) as AsyncGenerator<unknown>;
|
||||
},
|
||||
[query, task, accessToken, priorityIds],
|
||||
[trimmed, searchQuery, pinnedId, task, accessToken, priorityIds],
|
||||
);
|
||||
|
||||
const mapModel = useMemo(() => makeMapModel(excludeGguf), [excludeGguf]);
|
||||
const search = useHfPaginatedSearch(createIter, mapModel);
|
||||
|
||||
// Secondary sort guarantee: unsloth models always float to the top
|
||||
// Secondary sort guarantee: unsloth models always float to the top.
|
||||
// Skip when the user searched for a specific non-unsloth publisher
|
||||
// (e.g. "openai/gpt-oss-20b") -- the iterator already handles the
|
||||
// pinned ordering in that case.
|
||||
const results = useMemo(
|
||||
() =>
|
||||
[...search.results].sort((a, b) => {
|
||||
const aFirst = a.id.startsWith("unsloth/") ? 0 : 1;
|
||||
const bFirst = b.id.startsWith("unsloth/") ? 0 : 1;
|
||||
return aFirst - bFirst;
|
||||
}),
|
||||
[search.results],
|
||||
isPublisherQuery
|
||||
? search.results
|
||||
: [...search.results].sort((a, b) => {
|
||||
const aFirst = a.id.startsWith("unsloth/") ? 0 : 1;
|
||||
const bFirst = b.id.startsWith("unsloth/") ? 0 : 1;
|
||||
return aFirst - bFirst;
|
||||
}),
|
||||
[search.results, isPublisherQuery],
|
||||
);
|
||||
|
||||
return { ...search, results };
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
@plugin "@toolwind/corner-shape";
|
||||
@source "../node_modules/streamdown/dist/*.js";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@font-face {
|
||||
font-family: "Hellix";
|
||||
src: url("/fonts/Hellix-SemiBold.woff2") format("woff2"),
|
||||
|
|
@ -21,8 +23,6 @@
|
|||
font-display: swap;
|
||||
}
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
/* Animation timing */
|
||||
--duration-micro: 100ms;
|
||||
|
|
@ -67,7 +67,7 @@
|
|||
--sidebar-ring: oklch(0.6929 0.1396 166.5513);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui;
|
||||
--font-heading: "Hellix", "Space Grotesk Variable", ui-sans-serif, sans-serif;
|
||||
--font-heading: "Hellix", "Space Grotesk Variable", var(--font-sans);
|
||||
--font-serif: Source Serif 4, serif;
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
--shadow-color: hsl(0 0% 0%);
|
||||
|
|
@ -159,7 +159,7 @@
|
|||
|
||||
@theme inline {
|
||||
--font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui;
|
||||
--font-heading: "Hellix", "Space Grotesk Variable", ui-sans-serif, sans-serif;
|
||||
--font-heading: "Hellix", "Space Grotesk Variable", var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
|
|
@ -343,10 +343,58 @@
|
|||
[data-streamdown="code-block"] {
|
||||
gap: 0;
|
||||
padding: 0.5rem;
|
||||
/* Wide lines must scroll inside the thread column, not widen past the composer (flex min-width:auto). */
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
[data-streamdown="code-block-header"] {
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
/* Chat thread: code slightly smaller by default; step up when the thread column is wide. */
|
||||
.aui-thread-root [data-streamdown="code-block"] {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.aui-thread-root [data-streamdown="code-block-header"] {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
@container (min-width: 36rem) {
|
||||
.aui-thread-root [data-streamdown="code-block"] {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.aui-thread-root [data-streamdown="code-block-header"] {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Chat: use the app sans stack for UI + prose. */
|
||||
.aui-thread-root {
|
||||
--font-heading: var(--font-sans);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Keep monospace for code fences and inline code (not KaTeX). */
|
||||
.aui-thread-root [data-streamdown="code-block"] pre,
|
||||
.aui-thread-root [data-streamdown="code-block"] code {
|
||||
font-family: var(--font-mono), ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.aui-thread-root :where(p, li, td, th, blockquote, h1, h2, h3, h4, h5, h6) code {
|
||||
font-family: var(--font-mono), ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* Align fenced code blocks with the main chat column even when nested in lists. */
|
||||
.aui-thread-root [data-streamdown="list-item"] > [data-streamdown="code-block"],
|
||||
.aui-thread-root [data-streamdown="list-item"] [data-streamdown="code-block"] {
|
||||
margin-left: -1.25rem;
|
||||
width: calc(100% + 1.25rem);
|
||||
max-width: calc(100% + 1.25rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* Minimal scrollbar — thumb only, no track */
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
451
studio/setup.ps1
451
studio/setup.ps1
|
|
@ -22,6 +22,19 @@ $ErrorActionPreference = "Stop"
|
|||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$PackageDir = Split-Path -Parent $ScriptDir
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Maintainer-editable defaults
|
||||
# Change these in the GitHub-hosted script so users get updated defaults.
|
||||
# User env vars always override these baked-in values.
|
||||
# --------------------------------------------------------------------------
|
||||
# Prefer "latest" over "master" -- "master" bypasses the prebuilt resolver
|
||||
# (no matching GitHub release), forces a source build, and causes HTTP 422
|
||||
# errors. Only use "master" temporarily when the latest release is missing
|
||||
# support for a new model architecture.
|
||||
$DefaultLlamaPrForce = ""
|
||||
$DefaultLlamaSource = "https://github.com/ggml-org/llama.cpp"
|
||||
$DefaultLlamaTag = "latest"
|
||||
|
||||
# Verbose can be enabled either by CLI flag or by UNSLOTH_VERBOSE=1.
|
||||
$script:UnslothVerbose = ($env:UNSLOTH_VERBOSE -eq '1')
|
||||
foreach ($a in $args) {
|
||||
|
|
@ -62,6 +75,12 @@ function Refresh-Environment {
|
|||
$env:Path = "$machinePath;$userPath"
|
||||
}
|
||||
|
||||
# PowerShell 5.1 compatibility helper: avoid relying on New-TemporaryFile.
|
||||
function New-UnslothTemporaryFile {
|
||||
$tempPath = [System.IO.Path]::GetTempFileName()
|
||||
return Get-Item -LiteralPath $tempPath
|
||||
}
|
||||
|
||||
# Find nvcc on PATH, CUDA_PATH, or standard toolkit dirs.
|
||||
# Returns the path to nvcc.exe, or $null if not found.
|
||||
function Find-Nvcc {
|
||||
|
|
@ -490,8 +509,7 @@ if (-not $HasNvidiaSmi) {
|
|||
if (-not $HasNvidiaSmi) {
|
||||
Write-Host ""
|
||||
step "gpu" "none (chat-only / GGUF)" "Yellow"
|
||||
Write-Host " Training and GPU inference require an NVIDIA GPU with drivers installed." -ForegroundColor Yellow
|
||||
Write-Host " https://www.nvidia.com/Download/index.aspx" -ForegroundColor Yellow
|
||||
substep "Training and GPU inference require an NVIDIA GPU with drivers installed." "Yellow"
|
||||
Write-Host ""
|
||||
} else {
|
||||
step "gpu" "NVIDIA GPU detected"
|
||||
|
|
@ -1544,7 +1562,7 @@ if (Test-Path $VenvT5Dir) { Remove-Item -Recurse -Force $VenvT5Dir }
|
|||
New-Item -ItemType Directory -Path $VenvT5Dir -Force | Out-Null
|
||||
$prevEAP_t5 = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
foreach ($pkg in @("transformers==5.3.0", "huggingface_hub==1.7.1", "hf_xet==1.4.2")) {
|
||||
foreach ($pkg in @("transformers==5.5.0", "huggingface_hub==1.8.0", "hf_xet==1.4.2")) {
|
||||
if ($script:UnslothVerbose) {
|
||||
Fast-Install --target $VenvT5Dir --no-deps $pkg
|
||||
$t5PkgExit = $LASTEXITCODE
|
||||
|
|
@ -1590,43 +1608,156 @@ if (-not (Test-Path $UnslothHome)) { New-Item -ItemType Directory -Force $Unslot
|
|||
$LlamaCppDir = Join-Path $UnslothHome "llama.cpp"
|
||||
$NeedLlamaSourceBuild = $false
|
||||
$SkipPrebuiltInstall = $false
|
||||
$RequestedLlamaTag = if ($env:UNSLOTH_LLAMA_TAG) { $env:UNSLOTH_LLAMA_TAG } else { "latest" }
|
||||
$HelperReleaseRepo = if ($env:UNSLOTH_LLAMA_RELEASE_REPO) { $env:UNSLOTH_LLAMA_RELEASE_REPO } else { "unslothai/llama.cpp" }
|
||||
$resolveOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" --resolve-install-tag $RequestedLlamaTag --published-repo $HelperReleaseRepo 2>&1
|
||||
$resolveExit = $LASTEXITCODE
|
||||
$ResolvedLlamaTag = if ($resolveOutput) { ($resolveOutput | Select-Object -Last 1).ToString().Trim() } else { "" }
|
||||
if ($resolveExit -ne 0 -or [string]::IsNullOrWhiteSpace($ResolvedLlamaTag)) {
|
||||
Write-Host ""
|
||||
substep "Failed to resolve an installable prebuilt llama.cpp tag via $HelperReleaseRepo" "Yellow"
|
||||
Write-LlamaFailureLog -Output ($resolveOutput | Out-String)
|
||||
# Resolve the llama.cpp tag for source-build fallback. Pass --published-repo
|
||||
# so the resolver prefers Unsloth's tested tag (e.g. b8508) over the upstream
|
||||
# bleeding-edge tag (e.g. b8514) from ggml-org/llama.cpp.
|
||||
$fallbackOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" --resolve-llama-tag $RequestedLlamaTag --published-repo $HelperReleaseRepo 2>$null
|
||||
$fallbackExit = $LASTEXITCODE
|
||||
$ResolvedLlamaTag = if ($fallbackExit -eq 0 -and $fallbackOutput) {
|
||||
($fallbackOutput | Select-Object -Last 1).ToString().Trim()
|
||||
} elseif ($RequestedLlamaTag -eq "latest") {
|
||||
# Try Unsloth release repo first, then fall back to ggml-org upstream
|
||||
$resolvedLatest = $null
|
||||
try {
|
||||
$latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/$HelperReleaseRepo/releases/latest" -ErrorAction Stop
|
||||
$resolvedLatest = $latestRelease.tag_name
|
||||
} catch {}
|
||||
if (-not $resolvedLatest) {
|
||||
try {
|
||||
$latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest" -ErrorAction Stop
|
||||
$resolvedLatest = $latestRelease.tag_name
|
||||
} catch {}
|
||||
}
|
||||
if ($resolvedLatest) { $resolvedLatest } else { $RequestedLlamaTag }
|
||||
} else {
|
||||
$RequestedLlamaTag
|
||||
}
|
||||
$RequestedLlamaTag = if ($env:UNSLOTH_LLAMA_TAG) { $env:UNSLOTH_LLAMA_TAG } else { $DefaultLlamaTag }
|
||||
$HelperReleaseRepo = "ggml-org/llama.cpp"
|
||||
$LlamaPr = if ($env:UNSLOTH_LLAMA_PR) { $env:UNSLOTH_LLAMA_PR.Trim() } else { "" }
|
||||
|
||||
$LlamaPrForce = if ($env:UNSLOTH_LLAMA_PR_FORCE) { $env:UNSLOTH_LLAMA_PR_FORCE.Trim() } else { $DefaultLlamaPrForce }
|
||||
$LlamaSource = $DefaultLlamaSource
|
||||
if ($LlamaSource.EndsWith('.git')) { $LlamaSource = $LlamaSource.Substring(0, $LlamaSource.Length - 4) }
|
||||
$ResolvedSourceUrl = $LlamaSource
|
||||
$ResolvedSourceRef = $RequestedLlamaTag
|
||||
$ResolvedSourceRefKind = "tag"
|
||||
|
||||
if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {
|
||||
$NeedLlamaSourceBuild = $true
|
||||
$SkipPrebuiltInstall = $true
|
||||
}
|
||||
|
||||
function Invoke-LlamaHelper {
|
||||
param(
|
||||
[string[]]$Arguments,
|
||||
[string]$StderrPath = $null
|
||||
)
|
||||
|
||||
$previousErrorActionPreference = $ErrorActionPreference
|
||||
$previousNativeErrorPreference = $null
|
||||
$restoreNativeErrorPreference = $false
|
||||
$ErrorActionPreference = "Continue"
|
||||
if ($PSVersionTable.PSVersion.Major -ge 7) {
|
||||
$previousNativeErrorPreference = $PSNativeCommandUseErrorActionPreference
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
$restoreNativeErrorPreference = $true
|
||||
}
|
||||
|
||||
try {
|
||||
# Capture all output (stdout + stderr) so that PowerShell does not
|
||||
# convert stderr lines into visible ErrorRecord objects. Separate
|
||||
# stdout from stderr afterwards.
|
||||
$allOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" @Arguments 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
$stdoutLines = @()
|
||||
$stderrLines = @()
|
||||
foreach ($line in $allOutput) {
|
||||
if ($line -is [System.Management.Automation.ErrorRecord]) {
|
||||
$stderrLines += $line.ToString()
|
||||
} else {
|
||||
$stdoutLines += $line
|
||||
}
|
||||
}
|
||||
if ($StderrPath -and $stderrLines.Count -gt 0) {
|
||||
$stderrLines | Out-File -FilePath $StderrPath -Encoding utf8
|
||||
}
|
||||
return [pscustomobject]@{
|
||||
Output = $stdoutLines
|
||||
ExitCode = $exitCode
|
||||
}
|
||||
} finally {
|
||||
$ErrorActionPreference = $previousErrorActionPreference
|
||||
if ($restoreNativeErrorPreference) {
|
||||
$PSNativeCommandUseErrorActionPreference = $previousNativeErrorPreference
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($LlamaSource -ne "https://github.com/ggml-org/llama.cpp") {
|
||||
step "llama.cpp" "custom source: $LlamaSource -- forcing source build" "Yellow"
|
||||
$NeedLlamaSourceBuild = $true
|
||||
$SkipPrebuiltInstall = $true
|
||||
}
|
||||
|
||||
if (-not $LlamaPr -and $LlamaPrForce -and $LlamaPrForce -match '^\d+$' -and [int]$LlamaPrForce -gt 0) {
|
||||
$LlamaPr = $LlamaPrForce
|
||||
step "llama.cpp" "baked-in PR_FORCE=$LlamaPrForce" "Yellow"
|
||||
}
|
||||
|
||||
if ($LlamaPr) {
|
||||
if ($LlamaPr -notmatch '^\d+$' -or [int]$LlamaPr -le 0) {
|
||||
Write-Host "[ERROR] UNSLOTH_LLAMA_PR=$LlamaPr is not a valid PR number" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
step "llama.cpp" "UNSLOTH_LLAMA_PR=$LlamaPr -- will build from PR head" "Yellow"
|
||||
$ResolvedLlamaTag = "pr-$LlamaPr"
|
||||
$ResolvedSourceUrl = $LlamaSource
|
||||
$ResolvedSourceRef = "pr-$LlamaPr"
|
||||
$ResolvedSourceRefKind = "pull"
|
||||
$NeedLlamaSourceBuild = $true
|
||||
$SkipPrebuiltInstall = $true
|
||||
} elseif ($SkipPrebuiltInstall) {
|
||||
# Custom source or other override already forced source build; skip the
|
||||
# prebuilt release resolution. When building from a custom fork, the fork
|
||||
# may not carry upstream bNNNN tags.
|
||||
if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {
|
||||
$ResolvedLlamaTag = $RequestedLlamaTag
|
||||
} elseif ($LlamaSource -eq "https://github.com/ggml-org/llama.cpp") {
|
||||
$resolveTagArgs = @("--resolve-llama-tag", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo, "--output-format", "json")
|
||||
if ($env:UNSLOTH_LLAMA_RELEASE_TAG) { $resolveTagArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG) }
|
||||
$fallbackResult = Invoke-LlamaHelper -Arguments $resolveTagArgs
|
||||
$fallbackOutput = $fallbackResult.Output
|
||||
$fallbackExit = $fallbackResult.ExitCode
|
||||
$ResolvedLlamaTag = if ($fallbackExit -eq 0 -and $fallbackOutput) {
|
||||
try {
|
||||
(($fallbackOutput | Out-String) | ConvertFrom-Json).llama_tag
|
||||
} catch {
|
||||
$RequestedLlamaTag
|
||||
}
|
||||
} else {
|
||||
$RequestedLlamaTag
|
||||
}
|
||||
} else {
|
||||
$ResolvedLlamaTag = $RequestedLlamaTag
|
||||
}
|
||||
} else {
|
||||
$resolveInstallArgs = @("--resolve-install-tag", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo, "--output-format", "json")
|
||||
if ($env:UNSLOTH_LLAMA_RELEASE_TAG) { $resolveInstallArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG) }
|
||||
$resolveErrorLog = New-UnslothTemporaryFile
|
||||
$resolveResult = Invoke-LlamaHelper -Arguments $resolveInstallArgs -StderrPath $resolveErrorLog
|
||||
$resolveOutput = $resolveResult.Output
|
||||
$resolveExit = $resolveResult.ExitCode
|
||||
$ResolvedLlamaTag = if ($resolveOutput) {
|
||||
try {
|
||||
(($resolveOutput | Out-String) | ConvertFrom-Json).llama_tag
|
||||
} catch {
|
||||
""
|
||||
}
|
||||
} else { "" }
|
||||
if ($resolveExit -ne 0 -or [string]::IsNullOrWhiteSpace($ResolvedLlamaTag)) {
|
||||
Write-Host ""
|
||||
substep "Failed to resolve a published llama.cpp release via $HelperReleaseRepo" "Yellow"
|
||||
Write-LlamaFailureLog -Output (Get-Content -Raw $resolveErrorLog)
|
||||
# Resolve the llama.cpp tag for source-build fallback. Pass --published-repo
|
||||
# so the resolver prefers the latest usable Unsloth-published upstream tag
|
||||
# before falling back to the bleeding-edge ggml-org/llama.cpp tag.
|
||||
$resolveFallbackArgs = @("--resolve-llama-tag", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo, "--output-format", "json")
|
||||
if ($env:UNSLOTH_LLAMA_RELEASE_TAG) { $resolveFallbackArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG) }
|
||||
$fallbackResult = Invoke-LlamaHelper -Arguments $resolveFallbackArgs
|
||||
$fallbackOutput = $fallbackResult.Output
|
||||
$fallbackExit = $fallbackResult.ExitCode
|
||||
$ResolvedLlamaTag = if ($fallbackExit -eq 0 -and $fallbackOutput) {
|
||||
try {
|
||||
(($fallbackOutput | Out-String) | ConvertFrom-Json).llama_tag
|
||||
} catch {
|
||||
$RequestedLlamaTag
|
||||
}
|
||||
} else {
|
||||
$RequestedLlamaTag
|
||||
}
|
||||
$NeedLlamaSourceBuild = $true
|
||||
$SkipPrebuiltInstall = $true
|
||||
}
|
||||
Remove-Item $resolveErrorLog -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
substep "Resolved llama.cpp release tag: $ResolvedLlamaTag"
|
||||
|
||||
|
|
@ -1646,7 +1777,7 @@ if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {
|
|||
$prebuiltArgs = @(
|
||||
"$PSScriptRoot\install_llama_prebuilt.py",
|
||||
"--install-dir", $LlamaCppDir,
|
||||
"--llama-tag", $ResolvedLlamaTag,
|
||||
"--llama-tag", $RequestedLlamaTag,
|
||||
"--published-repo", $HelperReleaseRepo
|
||||
)
|
||||
if ($env:UNSLOTH_LLAMA_RELEASE_TAG) {
|
||||
|
|
@ -1654,21 +1785,46 @@ if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {
|
|||
}
|
||||
$prevEAPPrebuilt = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
if ($script:UnslothVerbose) {
|
||||
# Show live output in verbose mode while still capturing for error log
|
||||
$prebuiltLog = Join-Path $env:TEMP "unsloth-prebuilt-$PID.log"
|
||||
& python @prebuiltArgs 2>&1 | Tee-Object -FilePath $prebuiltLog | Out-Host
|
||||
$prebuiltExit = $LASTEXITCODE
|
||||
$prebuiltOutput = if (Test-Path $prebuiltLog) { Get-Content $prebuiltLog -Raw } else { "" }
|
||||
Remove-Item $prebuiltLog -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
$prebuiltOutput = & python @prebuiltArgs 2>&1 | Out-String
|
||||
$prebuiltExit = $LASTEXITCODE
|
||||
$previousNativeErrorPreference = $null
|
||||
$restoreNativeErrorPreference = $false
|
||||
if ($PSVersionTable.PSVersion.Major -ge 7) {
|
||||
$previousNativeErrorPreference = $PSNativeCommandUseErrorActionPreference
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
$restoreNativeErrorPreference = $true
|
||||
}
|
||||
try {
|
||||
if ($script:UnslothVerbose) {
|
||||
# Show live output in verbose mode while still capturing for error log
|
||||
$prebuiltLog = Join-Path $env:TEMP "unsloth-prebuilt-$PID.log"
|
||||
& python @prebuiltArgs 2>&1 | Tee-Object -FilePath $prebuiltLog | Out-Host
|
||||
$prebuiltExit = $LASTEXITCODE
|
||||
$prebuiltOutput = if (Test-Path $prebuiltLog) { Get-Content $prebuiltLog -Raw } else { "" }
|
||||
Remove-Item $prebuiltLog -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
$prebuiltOutput = & python @prebuiltArgs 2>&1 | Out-String
|
||||
$prebuiltExit = $LASTEXITCODE
|
||||
}
|
||||
} finally {
|
||||
if ($restoreNativeErrorPreference) {
|
||||
$PSNativeCommandUseErrorActionPreference = $previousNativeErrorPreference
|
||||
}
|
||||
}
|
||||
$ErrorActionPreference = $prevEAPPrebuilt
|
||||
|
||||
if ($prebuiltExit -eq 0) {
|
||||
step "llama.cpp" "prebuilt installed and validated"
|
||||
if ($prebuiltOutput -match "already matches") {
|
||||
step "llama.cpp" "prebuilt up to date and validated"
|
||||
} else {
|
||||
step "llama.cpp" "prebuilt installed and validated"
|
||||
}
|
||||
} elseif ($prebuiltExit -eq 3) {
|
||||
step "llama.cpp" "install blocked by active llama.cpp process" "Yellow"
|
||||
Write-LlamaFailureLog -Output $prebuiltOutput
|
||||
if (Test-Path $LlamaCppDir) {
|
||||
substep "Existing install was restored" "Yellow"
|
||||
}
|
||||
substep "Close Studio or other llama.cpp users and retry" "Yellow"
|
||||
exit 3
|
||||
} else {
|
||||
step "llama.cpp" "prebuilt install failed (continuing)" "Yellow"
|
||||
Write-LlamaFailureLog -Output $prebuiltOutput
|
||||
|
|
@ -1766,7 +1922,10 @@ if (Test-Path $LlamaServerBin) {
|
|||
if (-not $NeedLlamaSourceBuild) {
|
||||
Write-Host ""
|
||||
step "llama.cpp" "prebuilt (validated)"
|
||||
} elseif ((Test-Path $LlamaServerBin) -and -not $NeedRebuild) {
|
||||
} elseif ((Test-Path $LlamaServerBin) -and -not $NeedRebuild -and $RequestedLlamaTag -ne "master") {
|
||||
# Skip rebuild only for pinned tags (e.g. b8635). When the requested
|
||||
# tag is "master" (a moving target), always rebuild so the binary picks
|
||||
# up new model architecture support (e.g. Gemma 4).
|
||||
Write-Host ""
|
||||
step "llama.cpp" "already built"
|
||||
} elseif (-not $HasCmakeForBuild) {
|
||||
|
|
@ -1821,42 +1980,188 @@ if (-not $NeedLlamaSourceBuild) {
|
|||
[Environment]::SetEnvironmentVariable('CudaToolkitDir', "$CudaToolkitRoot\", 'Process')
|
||||
}
|
||||
|
||||
if (-not $LlamaPr) {
|
||||
if ($LlamaSource -eq "https://github.com/ggml-org/llama.cpp") {
|
||||
$resolveSourceArgs = @("--resolve-source-build", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo, "--output-format", "json")
|
||||
if ($env:UNSLOTH_LLAMA_RELEASE_TAG) { $resolveSourceArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG) }
|
||||
$sourcePlanResult = Invoke-LlamaHelper -Arguments $resolveSourceArgs
|
||||
$sourcePlanOutput = $sourcePlanResult.Output
|
||||
$sourcePlanExit = $sourcePlanResult.ExitCode
|
||||
if ($sourcePlanExit -eq 0 -and $sourcePlanOutput) {
|
||||
try {
|
||||
$sourcePlan = ($sourcePlanOutput | Out-String) | ConvertFrom-Json
|
||||
$ResolvedSourceUrl = $sourcePlan.source_url
|
||||
$ResolvedSourceRefKind = $sourcePlan.source_ref_kind
|
||||
$ResolvedSourceRef = $sourcePlan.source_ref
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($ResolvedSourceUrl)) { $ResolvedSourceUrl = $LlamaSource }
|
||||
if ([string]::IsNullOrWhiteSpace($ResolvedSourceRef)) { $ResolvedSourceRef = $ResolvedLlamaTag }
|
||||
}
|
||||
|
||||
# -- Step A: Clone or pull llama.cpp --
|
||||
|
||||
$UseConcreteRef = ($ResolvedLlamaTag -ne "latest" -and -not [string]::IsNullOrWhiteSpace($ResolvedLlamaTag))
|
||||
$UseConcreteRef = ($ResolvedSourceRef -ne "latest" -and -not [string]::IsNullOrWhiteSpace($ResolvedSourceRef))
|
||||
|
||||
if (Test-Path (Join-Path $LlamaCppDir ".git")) {
|
||||
Write-Host " Syncing llama.cpp to $ResolvedLlamaTag..." -ForegroundColor Gray
|
||||
if ($UseConcreteRef) {
|
||||
$gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin $ResolvedLlamaTag }
|
||||
Write-Host " Syncing llama.cpp to $ResolvedSourceRef..." -ForegroundColor Gray
|
||||
# Always sync the remote URL so switching between default/fork sources works
|
||||
Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir remote set-url origin "$ResolvedSourceUrl.git" } | Out-Null
|
||||
if ($LlamaPr) {
|
||||
$gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin "pull/$LlamaPr/head" }
|
||||
if ($gitFetchExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git fetch PR #$LlamaPr"
|
||||
} else {
|
||||
$gitCheckoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir checkout -B "pr-$LlamaPr" FETCH_HEAD }
|
||||
if ($gitCheckoutExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git checkout PR #$LlamaPr"
|
||||
} else {
|
||||
Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir clean -fdx } | Out-Null
|
||||
}
|
||||
}
|
||||
} elseif ($ResolvedSourceRefKind -eq "pull") {
|
||||
$gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin $ResolvedSourceRef }
|
||||
if ($gitFetchExit -ne 0) {
|
||||
substep "git fetch failed -- using existing source" "Yellow"
|
||||
} else {
|
||||
$gitCheckoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir checkout -B unsloth-llama-build FETCH_HEAD }
|
||||
if ($gitCheckoutExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git checkout"
|
||||
} else {
|
||||
Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir clean -fdx } | Out-Null
|
||||
}
|
||||
}
|
||||
} elseif ($ResolvedSourceRefKind -eq "commit") {
|
||||
$gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin $ResolvedSourceRef }
|
||||
if ($gitFetchExit -ne 0) {
|
||||
substep "git fetch failed -- using existing source" "Yellow"
|
||||
} else {
|
||||
$gitCheckoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir checkout -B unsloth-llama-build FETCH_HEAD }
|
||||
if ($gitCheckoutExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git checkout"
|
||||
} else {
|
||||
Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir clean -fdx } | Out-Null
|
||||
}
|
||||
}
|
||||
} elseif ($UseConcreteRef) {
|
||||
$gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin $ResolvedSourceRef }
|
||||
if ($gitFetchExit -ne 0) {
|
||||
substep "git fetch failed -- using existing source" "Yellow"
|
||||
} else {
|
||||
$gitCheckoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir checkout -B unsloth-llama-build FETCH_HEAD }
|
||||
if ($gitCheckoutExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git checkout"
|
||||
} else {
|
||||
Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir clean -fdx } | Out-Null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin }
|
||||
}
|
||||
if ($gitFetchExit -ne 0) {
|
||||
substep "git fetch failed -- using existing source" "Yellow"
|
||||
} else {
|
||||
$gitCheckoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir checkout -B unsloth-llama-build FETCH_HEAD }
|
||||
if ($gitCheckoutExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git checkout"
|
||||
if ($gitFetchExit -ne 0) {
|
||||
substep "git fetch failed -- using existing source" "Yellow"
|
||||
} else {
|
||||
Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir clean -fdx } | Out-Null
|
||||
$gitCheckoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir checkout -B unsloth-llama-build FETCH_HEAD }
|
||||
if ($gitCheckoutExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git checkout"
|
||||
} else {
|
||||
Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir clean -fdx } | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Host " Cloning llama.cpp @ $ResolvedLlamaTag..." -ForegroundColor Gray
|
||||
Write-Host " Cloning llama.cpp @ $ResolvedSourceRef..." -ForegroundColor Gray
|
||||
$buildTmp = "$LlamaCppDir.build.$PID"
|
||||
$null = New-Item -ItemType Directory -Force -Path (Split-Path $LlamaCppDir -Parent)
|
||||
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
||||
$cloneArgs = @("clone", "--depth", "1")
|
||||
if ($UseConcreteRef) {
|
||||
$cloneArgs += @("--branch", $ResolvedLlamaTag)
|
||||
}
|
||||
$cloneArgs += @("https://github.com/ggml-org/llama.cpp.git", $buildTmp)
|
||||
$cloneExit = Invoke-SetupCommand -AlwaysQuiet { git @cloneArgs }
|
||||
if ($cloneExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git clone"
|
||||
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
||||
if ($LlamaPr) {
|
||||
$cloneExit = Invoke-SetupCommand -AlwaysQuiet { git clone --depth 1 "$LlamaSource.git" $buildTmp }
|
||||
if ($cloneExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git clone"
|
||||
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
||||
}
|
||||
if ($BuildOk) {
|
||||
$fetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp fetch --depth 1 origin "pull/$LlamaPr/head:pr-$LlamaPr" }
|
||||
if ($fetchExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git fetch PR #$LlamaPr"
|
||||
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
||||
}
|
||||
}
|
||||
if ($BuildOk) {
|
||||
$checkoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp checkout "pr-$LlamaPr" }
|
||||
if ($checkoutExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git checkout PR #$LlamaPr"
|
||||
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
||||
}
|
||||
}
|
||||
} elseif ($ResolvedSourceRefKind -eq "pull") {
|
||||
$cloneExit = Invoke-SetupCommand -AlwaysQuiet { git clone --depth 1 "$ResolvedSourceUrl.git" $buildTmp }
|
||||
if ($cloneExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git clone"
|
||||
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
||||
}
|
||||
if ($BuildOk) {
|
||||
$fetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp fetch --depth 1 origin $ResolvedSourceRef }
|
||||
if ($fetchExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git fetch source PR ref"
|
||||
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
||||
}
|
||||
}
|
||||
if ($BuildOk) {
|
||||
$checkoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp checkout -B unsloth-llama-build FETCH_HEAD }
|
||||
if ($checkoutExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git checkout source PR ref"
|
||||
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
||||
}
|
||||
}
|
||||
} elseif ($ResolvedSourceRefKind -eq "commit") {
|
||||
$cloneExit = Invoke-SetupCommand -AlwaysQuiet { git clone --depth 1 "$ResolvedSourceUrl.git" $buildTmp }
|
||||
if ($cloneExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git clone"
|
||||
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
||||
}
|
||||
if ($BuildOk) {
|
||||
$fetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp fetch --depth 1 origin $ResolvedSourceRef }
|
||||
if ($fetchExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git fetch source commit"
|
||||
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
||||
}
|
||||
}
|
||||
if ($BuildOk) {
|
||||
$checkoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp checkout -B unsloth-llama-build FETCH_HEAD }
|
||||
if ($checkoutExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git checkout source commit"
|
||||
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$cloneArgs = @("clone", "--depth", "1")
|
||||
if ($UseConcreteRef) {
|
||||
$cloneArgs += @("--branch", $ResolvedSourceRef)
|
||||
}
|
||||
$cloneArgs += @("$ResolvedSourceUrl.git", $buildTmp)
|
||||
$cloneExit = Invoke-SetupCommand -AlwaysQuiet { git @cloneArgs }
|
||||
if ($cloneExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git clone"
|
||||
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
||||
}
|
||||
}
|
||||
# Use temp dir for build; swap into $LlamaCppDir only after build succeeds
|
||||
if ($BuildOk) {
|
||||
|
|
|
|||
333
studio/setup.sh
333
studio/setup.sh
|
|
@ -8,6 +8,24 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
RULE=$(printf '\342\224\200%.0s' {1..52})
|
||||
|
||||
# ── Maintainer-editable defaults ──────────────────────────────────────────
|
||||
# Change these in the GitHub-hosted script so all users get updated defaults.
|
||||
# User environment variables always override these baked-in values.
|
||||
#
|
||||
# _DEFAULT_LLAMA_PR_FORCE : PR number to build by default ("" = normal path)
|
||||
# _DEFAULT_LLAMA_SOURCE : git clone URL for source builds
|
||||
# _DEFAULT_LLAMA_TAG : llama.cpp ref to build ("latest" = newest release,
|
||||
# "master" = bleeding-edge, "bNNNN" = specific tag)
|
||||
# Prefer "latest" over "master" -- "master" bypasses
|
||||
# the prebuilt resolver (no matching GitHub release),
|
||||
# forces a source build, and causes HTTP 422 errors.
|
||||
# Only use "master" temporarily when the latest release
|
||||
# is missing support for a new model architecture.
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
_DEFAULT_LLAMA_PR_FORCE=""
|
||||
_DEFAULT_LLAMA_SOURCE="https://github.com/ggml-org/llama.cpp"
|
||||
_DEFAULT_LLAMA_TAG="latest"
|
||||
|
||||
# ── Colors (same palette as startup_banner / install_python_stack) ──
|
||||
if [ -n "${NO_COLOR:-}" ]; then
|
||||
C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST=
|
||||
|
|
@ -108,6 +126,10 @@ echo ""
|
|||
printf " ${C_TITLE}%s${C_RST}\n" "🦥 Unsloth Studio Setup"
|
||||
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
|
||||
verbose_substep "verbose diagnostics enabled"
|
||||
_LLAMA_ONLY="${UNSLOTH_STUDIO_LLAMA_ONLY:-0}"
|
||||
if [ "$_LLAMA_ONLY" = "1" ]; then
|
||||
substep "llama.cpp only mode"
|
||||
fi
|
||||
# ── Clean up stale caches ──
|
||||
rm -rf "$REPO_ROOT/unsloth_compiled_cache"
|
||||
rm -rf "$SCRIPT_DIR/backend/unsloth_compiled_cache"
|
||||
|
|
@ -120,6 +142,7 @@ if [[ "$keynames" == *$'\nCOLAB_'* ]]; then
|
|||
IS_COLAB=true
|
||||
fi
|
||||
|
||||
if [ "$_LLAMA_ONLY" != "1" ]; then
|
||||
# ── Frontend ──
|
||||
_NEED_FRONTEND_BUILD=true
|
||||
if [ -d "$SCRIPT_DIR/frontend/dist" ]; then
|
||||
|
|
@ -444,8 +467,8 @@ if [ "$_SKIP_PYTHON_DEPS" = false ]; then
|
|||
# at runtime (slow, ~10-15s), we pre-install into a separate directory.
|
||||
# The training subprocess just prepends .venv_t5/ to sys.path -- instant switch.
|
||||
mkdir -p "$VENV_T5_DIR"
|
||||
run_quiet "install transformers 5.x" fast_install --target "$VENV_T5_DIR" --no-deps "transformers==5.3.0"
|
||||
run_quiet "install huggingface_hub for t5" fast_install --target "$VENV_T5_DIR" --no-deps "huggingface_hub==1.7.1"
|
||||
run_quiet "install transformers 5.x" fast_install --target "$VENV_T5_DIR" --no-deps "transformers==5.5.0"
|
||||
run_quiet "install huggingface_hub for t5" fast_install --target "$VENV_T5_DIR" --no-deps "huggingface_hub==1.8.0"
|
||||
run_quiet "install hf_xet for t5" fast_install --target "$VENV_T5_DIR" --no-deps "hf_xet==1.4.2"
|
||||
run_quiet "install tiktoken for t5" fast_install --target "$VENV_T5_DIR" "tiktoken"
|
||||
step "transformers" "5.x pre-installed"
|
||||
|
|
@ -453,6 +476,7 @@ else
|
|||
step "python" "dependencies up to date"
|
||||
verbose_substep "python deps check: installed=$_PKG_NAME@${INSTALLED_VER:-unknown} latest=${LATEST_VER:-unknown}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 7. Prefer prebuilt llama.cpp bundles before any source build path ──
|
||||
UNSLOTH_HOME="$HOME/.unsloth"
|
||||
|
|
@ -462,46 +486,131 @@ LLAMA_SERVER_BIN="$LLAMA_CPP_DIR/build/bin/llama-server"
|
|||
_NEED_LLAMA_SOURCE_BUILD=false
|
||||
_LLAMA_CPP_DEGRADED=false
|
||||
_LLAMA_FORCE_COMPILE="${UNSLOTH_LLAMA_FORCE_COMPILE:-0}"
|
||||
_REQUESTED_LLAMA_TAG="${UNSLOTH_LLAMA_TAG:-latest}"
|
||||
_HELPER_RELEASE_REPO="${UNSLOTH_LLAMA_RELEASE_REPO:-unslothai/llama.cpp}"
|
||||
_RESOLVE_LLAMA_LOG="$(mktemp)"
|
||||
set +e
|
||||
python "$SCRIPT_DIR/install_llama_prebuilt.py" \
|
||||
--resolve-install-tag "$_REQUESTED_LLAMA_TAG" \
|
||||
--published-repo "$_HELPER_RELEASE_REPO" >"$_RESOLVE_LLAMA_LOG" 2>&1
|
||||
_RESOLVE_LLAMA_STATUS=$?
|
||||
set -e
|
||||
if [ "$_RESOLVE_LLAMA_STATUS" -eq 0 ]; then
|
||||
_RESOLVED_LLAMA_TAG="$(tail -n 1 "$_RESOLVE_LLAMA_LOG" | tr -d '\r')"
|
||||
else
|
||||
_RESOLVED_LLAMA_TAG=""
|
||||
_REQUESTED_LLAMA_TAG="${UNSLOTH_LLAMA_TAG:-${_DEFAULT_LLAMA_TAG}}"
|
||||
# Force all installs to use mainline llama.cpp from ggml-org.
|
||||
_HELPER_RELEASE_REPO="ggml-org/llama.cpp"
|
||||
_LLAMA_PR="${UNSLOTH_LLAMA_PR:-}"
|
||||
|
||||
_LLAMA_PR_FORCE="${UNSLOTH_LLAMA_PR_FORCE:-${_DEFAULT_LLAMA_PR_FORCE}}"
|
||||
# Force mainline source -- no env var override for now.
|
||||
_LLAMA_SOURCE="${_DEFAULT_LLAMA_SOURCE}"
|
||||
_LLAMA_SOURCE="${_LLAMA_SOURCE%.git}" # normalize: strip trailing .git
|
||||
_RESOLVED_SOURCE_URL="$_LLAMA_SOURCE"
|
||||
_RESOLVED_SOURCE_REF="$_REQUESTED_LLAMA_TAG"
|
||||
_RESOLVED_SOURCE_REF_KIND="tag"
|
||||
|
||||
if [ "$_LLAMA_FORCE_COMPILE" = "1" ]; then
|
||||
_NEED_LLAMA_SOURCE_BUILD=true
|
||||
_SKIP_PREBUILT_INSTALL=true
|
||||
fi
|
||||
if [ -z "$_RESOLVED_LLAMA_TAG" ]; then
|
||||
step "llama.cpp" "failed to resolve prebuilt tag via $_HELPER_RELEASE_REPO" "$C_WARN"
|
||||
print_llama_error_log "$_RESOLVE_LLAMA_LOG"
|
||||
set +e
|
||||
# Resolve the llama.cpp tag for source-build fallback. Pass --published-repo
|
||||
# so the resolver prefers Unsloth's tested tag (e.g. b8508) over the upstream
|
||||
# bleeding-edge tag (e.g. b8514) from ggml-org/llama.cpp.
|
||||
_RESOLVED_LLAMA_TAG="$(python "$SCRIPT_DIR/install_llama_prebuilt.py" --resolve-llama-tag "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO" 2>/dev/null)"
|
||||
_RESOLVE_UPSTREAM_STATUS=$?
|
||||
set -e
|
||||
if [ "$_RESOLVE_UPSTREAM_STATUS" -ne 0 ] || [ -z "$_RESOLVED_LLAMA_TAG" ]; then
|
||||
if [ "$_REQUESTED_LLAMA_TAG" = "latest" ]; then
|
||||
# Try Unsloth release repo first, then fall back to ggml-org upstream
|
||||
_RESOLVED_LLAMA_TAG="$(curl -fsSL "https://api.github.com/repos/${_HELPER_RELEASE_REPO}/releases/latest" 2>/dev/null | python -c "import sys,json; print(json.load(sys.stdin)['tag_name'])" 2>/dev/null)" || _RESOLVED_LLAMA_TAG=""
|
||||
if [ -z "$_RESOLVED_LLAMA_TAG" ]; then
|
||||
_RESOLVED_LLAMA_TAG="$(curl -fsSL https://api.github.com/repos/ggml-org/llama.cpp/releases/latest 2>/dev/null | python -c "import sys,json; print(json.load(sys.stdin)['tag_name'])" 2>/dev/null)" || _RESOLVED_LLAMA_TAG=""
|
||||
fi
|
||||
|
||||
# Non-default source URL forces source build (fork has different code than prebuilt).
|
||||
if [ "$_LLAMA_SOURCE" != "https://github.com/ggml-org/llama.cpp" ]; then
|
||||
step "llama.cpp" "custom source: $_LLAMA_SOURCE -- forcing source build" "$C_WARN"
|
||||
_NEED_LLAMA_SOURCE_BUILD=true
|
||||
_SKIP_PREBUILT_INSTALL=true
|
||||
fi
|
||||
|
||||
# Baked-in PR_FORCE promotes to _LLAMA_PR when user hasn't set one.
|
||||
if [ -z "$_LLAMA_PR" ] && [ -n "$_LLAMA_PR_FORCE" ] && \
|
||||
[[ "$_LLAMA_PR_FORCE" =~ ^[0-9]+$ ]] && [ "$_LLAMA_PR_FORCE" -gt 0 ]; then
|
||||
_LLAMA_PR="$_LLAMA_PR_FORCE"
|
||||
step "llama.cpp" "baked-in PR_FORCE=$_LLAMA_PR_FORCE" "$C_WARN"
|
||||
fi
|
||||
|
||||
if [ -n "$_LLAMA_PR" ]; then
|
||||
if ! [[ "$_LLAMA_PR" =~ ^[0-9]+$ ]] || [ "$_LLAMA_PR" -le 0 ]; then
|
||||
step "llama.cpp" "UNSLOTH_LLAMA_PR=$_LLAMA_PR is not a valid PR number" "$C_ERR"
|
||||
exit 1
|
||||
fi
|
||||
step "llama.cpp" "UNSLOTH_LLAMA_PR=$_LLAMA_PR -- will build from PR head" "$C_WARN"
|
||||
_RESOLVED_LLAMA_TAG="pr-$_LLAMA_PR"
|
||||
_RESOLVED_SOURCE_URL="$_LLAMA_SOURCE"
|
||||
_RESOLVED_SOURCE_REF="pr-$_LLAMA_PR"
|
||||
_RESOLVED_SOURCE_REF_KIND="pull"
|
||||
_NEED_LLAMA_SOURCE_BUILD=true
|
||||
_SKIP_PREBUILT_INSTALL=true
|
||||
elif [ "${_SKIP_PREBUILT_INSTALL:-false}" = true ]; then
|
||||
# Custom source or other override already forced source build; skip
|
||||
# the prebuilt release resolution entirely. When building from a custom
|
||||
# fork, the fork may not carry upstream bNNNN tags, so resolve the tag
|
||||
# only when the source is the default ggml-org repo.
|
||||
if [ "$_LLAMA_FORCE_COMPILE" = "1" ]; then
|
||||
_RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG"
|
||||
elif [ "$_LLAMA_SOURCE" = "https://github.com/ggml-org/llama.cpp" ]; then
|
||||
_RESOLVE_TAG_ARGS=(--resolve-llama-tag "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO")
|
||||
_RESOLVE_TAG_ARGS+=(--output-format json)
|
||||
if [ -n "${UNSLOTH_LLAMA_RELEASE_TAG:-}" ]; then
|
||||
_RESOLVE_TAG_ARGS+=(--published-release-tag "$UNSLOTH_LLAMA_RELEASE_TAG")
|
||||
fi
|
||||
set +e
|
||||
_RESOLVE_TAG_JSON="$(python "$SCRIPT_DIR/install_llama_prebuilt.py" "${_RESOLVE_TAG_ARGS[@]}" 2>/dev/null)"
|
||||
_RESOLVE_UPSTREAM_STATUS=$?
|
||||
set -e
|
||||
if [ "$_RESOLVE_UPSTREAM_STATUS" -eq 0 ] && [ -n "${_RESOLVE_TAG_JSON:-}" ]; then
|
||||
_RESOLVED_LLAMA_TAG="$(
|
||||
printf '%s' "$_RESOLVE_TAG_JSON" | python -c 'import json,sys; print(json.load(sys.stdin).get("llama_tag",""))' 2>/dev/null || true
|
||||
)"
|
||||
else
|
||||
_RESOLVED_LLAMA_TAG=""
|
||||
fi
|
||||
if [ -z "$_RESOLVED_LLAMA_TAG" ]; then
|
||||
_RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG"
|
||||
fi
|
||||
else
|
||||
_RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG"
|
||||
fi
|
||||
_NEED_LLAMA_SOURCE_BUILD=true
|
||||
_SKIP_PREBUILT_INSTALL=true
|
||||
else
|
||||
_RESOLVE_INSTALL_ARGS=(--resolve-install-tag "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO")
|
||||
_RESOLVE_INSTALL_ARGS+=(--output-format json)
|
||||
if [ -n "${UNSLOTH_LLAMA_RELEASE_TAG:-}" ]; then
|
||||
_RESOLVE_INSTALL_ARGS+=(--published-release-tag "$UNSLOTH_LLAMA_RELEASE_TAG")
|
||||
fi
|
||||
_RESOLVE_LLAMA_LOG="$(mktemp)"
|
||||
set +e
|
||||
_RESOLVE_INSTALL_JSON="$(
|
||||
python "$SCRIPT_DIR/install_llama_prebuilt.py" \
|
||||
"${_RESOLVE_INSTALL_ARGS[@]}" 2>"$_RESOLVE_LLAMA_LOG"
|
||||
)"
|
||||
_RESOLVE_LLAMA_STATUS=$?
|
||||
set -e
|
||||
if [ "$_RESOLVE_LLAMA_STATUS" -eq 0 ]; then
|
||||
_RESOLVED_LLAMA_TAG="$(
|
||||
printf '%s' "${_RESOLVE_INSTALL_JSON:-}" | python -c 'import json,sys; print(json.load(sys.stdin).get("llama_tag",""))' 2>/dev/null || true
|
||||
)"
|
||||
else
|
||||
_RESOLVED_LLAMA_TAG=""
|
||||
fi
|
||||
if [ -z "$_RESOLVED_LLAMA_TAG" ]; then
|
||||
step "llama.cpp" "failed to resolve a published llama.cpp release via $_HELPER_RELEASE_REPO" "$C_WARN"
|
||||
print_llama_error_log "$_RESOLVE_LLAMA_LOG"
|
||||
set +e
|
||||
# Resolve the llama.cpp tag for source-build fallback. Pass --published-repo
|
||||
# so the resolver prefers the latest usable Unsloth-published upstream tag
|
||||
# before falling back to the bleeding-edge ggml-org/llama.cpp tag.
|
||||
_RESOLVE_FALLBACK_ARGS=(--resolve-llama-tag "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO")
|
||||
_RESOLVE_FALLBACK_ARGS+=(--output-format json)
|
||||
if [ -n "${UNSLOTH_LLAMA_RELEASE_TAG:-}" ]; then
|
||||
_RESOLVE_FALLBACK_ARGS+=(--published-release-tag "$UNSLOTH_LLAMA_RELEASE_TAG")
|
||||
fi
|
||||
_RESOLVE_FALLBACK_JSON="$(python "$SCRIPT_DIR/install_llama_prebuilt.py" "${_RESOLVE_FALLBACK_ARGS[@]}" 2>/dev/null)"
|
||||
_RESOLVE_UPSTREAM_STATUS=$?
|
||||
set -e
|
||||
if [ "$_RESOLVE_UPSTREAM_STATUS" -eq 0 ] && [ -n "${_RESOLVE_FALLBACK_JSON:-}" ]; then
|
||||
_RESOLVED_LLAMA_TAG="$(
|
||||
printf '%s' "$_RESOLVE_FALLBACK_JSON" | python -c 'import json,sys; print(json.load(sys.stdin).get("llama_tag",""))' 2>/dev/null || true
|
||||
)"
|
||||
else
|
||||
_RESOLVED_LLAMA_TAG=""
|
||||
fi
|
||||
if [ -z "$_RESOLVED_LLAMA_TAG" ]; then
|
||||
_RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG"
|
||||
fi
|
||||
_NEED_LLAMA_SOURCE_BUILD=true
|
||||
_SKIP_PREBUILT_INSTALL=true
|
||||
fi
|
||||
rm -f "$_RESOLVE_LLAMA_LOG"
|
||||
fi
|
||||
rm -f "$_RESOLVE_LLAMA_LOG"
|
||||
|
||||
substep "resolved llama.cpp tag: $_RESOLVED_LLAMA_TAG"
|
||||
verbose_substep "requested llama.cpp tag: $_REQUESTED_LLAMA_TAG (repo: $_HELPER_RELEASE_REPO)"
|
||||
|
|
@ -520,7 +629,7 @@ else
|
|||
_PREBUILT_CMD=(
|
||||
python "$SCRIPT_DIR/install_llama_prebuilt.py"
|
||||
--install-dir "$LLAMA_CPP_DIR"
|
||||
--llama-tag "$_RESOLVED_LLAMA_TAG"
|
||||
--llama-tag "$_REQUESTED_LLAMA_TAG"
|
||||
--published-repo "$_HELPER_RELEASE_REPO"
|
||||
)
|
||||
if [ -n "${UNSLOTH_LLAMA_RELEASE_TAG:-}" ]; then
|
||||
|
|
@ -538,9 +647,22 @@ else
|
|||
set -e
|
||||
|
||||
if [ "$_PREBUILT_STATUS" -eq 0 ]; then
|
||||
step "llama.cpp" "prebuilt installed and validated"
|
||||
if grep -Fq "already matches" "$_PREBUILT_LOG"; then
|
||||
step "llama.cpp" "prebuilt up to date and validated"
|
||||
else
|
||||
step "llama.cpp" "prebuilt installed and validated"
|
||||
fi
|
||||
verbose_substep "llama.cpp install dir: $LLAMA_CPP_DIR"
|
||||
rm -f "$_PREBUILT_LOG"
|
||||
elif [ "$_PREBUILT_STATUS" -eq 3 ]; then
|
||||
step "llama.cpp" "install blocked by active llama.cpp process" "$C_WARN"
|
||||
print_llama_error_log "$_PREBUILT_LOG"
|
||||
rm -f "$_PREBUILT_LOG"
|
||||
if [ -d "$LLAMA_CPP_DIR" ]; then
|
||||
substep "existing install was restored"
|
||||
fi
|
||||
substep "close Studio or other llama.cpp users and retry"
|
||||
exit 3
|
||||
else
|
||||
step "llama.cpp" "prebuilt install failed (continuing)" "$C_WARN"
|
||||
print_llama_error_log "$_PREBUILT_LOG"
|
||||
|
|
@ -623,21 +745,99 @@ else
|
|||
step "llama.cpp" "skipped (git not found)" "$C_WARN"
|
||||
[ -f "$LLAMA_SERVER_BIN" ] || _LLAMA_CPP_DEGRADED=true
|
||||
else
|
||||
BUILD_OK=true
|
||||
_CLONE_BRANCH_ARGS=()
|
||||
if [ "$_RESOLVED_LLAMA_TAG" != "latest" ] && [ -n "$_RESOLVED_LLAMA_TAG" ]; then
|
||||
_CLONE_BRANCH_ARGS=(--branch "$_RESOLVED_LLAMA_TAG")
|
||||
if [ -z "$_LLAMA_PR" ]; then
|
||||
if [ "$_LLAMA_SOURCE" = "https://github.com/ggml-org/llama.cpp" ]; then
|
||||
_RESOLVE_SOURCE_ARGS=(--resolve-source-build "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO")
|
||||
_RESOLVE_SOURCE_ARGS+=(--output-format json)
|
||||
if [ -n "${UNSLOTH_LLAMA_RELEASE_TAG:-}" ]; then
|
||||
_RESOLVE_SOURCE_ARGS+=(--published-release-tag "$UNSLOTH_LLAMA_RELEASE_TAG")
|
||||
fi
|
||||
set +e
|
||||
_SOURCE_BUILD_PLAN="$(python "$SCRIPT_DIR/install_llama_prebuilt.py" "${_RESOLVE_SOURCE_ARGS[@]}" 2>/dev/null)"
|
||||
_RESOLVE_SOURCE_STATUS=$?
|
||||
set -e
|
||||
if [ "$_RESOLVE_SOURCE_STATUS" -eq 0 ] && [ -n "$_SOURCE_BUILD_PLAN" ]; then
|
||||
_RESOLVED_SOURCE_URL="$(
|
||||
printf '%s' "$_SOURCE_BUILD_PLAN" | python -c 'import json,sys; print(json.load(sys.stdin).get("source_url",""))' 2>/dev/null || true
|
||||
)"
|
||||
_RESOLVED_SOURCE_REF_KIND="$(
|
||||
printf '%s' "$_SOURCE_BUILD_PLAN" | python -c 'import json,sys; print(json.load(sys.stdin).get("source_ref_kind",""))' 2>/dev/null || true
|
||||
)"
|
||||
_RESOLVED_SOURCE_REF="$(
|
||||
printf '%s' "$_SOURCE_BUILD_PLAN" | python -c 'import json,sys; print(json.load(sys.stdin).get("source_ref",""))' 2>/dev/null || true
|
||||
)"
|
||||
fi
|
||||
fi
|
||||
if [ -z "$_RESOLVED_SOURCE_URL" ]; then
|
||||
_RESOLVED_SOURCE_URL="$_LLAMA_SOURCE"
|
||||
fi
|
||||
if [ -z "$_RESOLVED_SOURCE_REF" ]; then
|
||||
_RESOLVED_SOURCE_REF="$_RESOLVED_LLAMA_TAG"
|
||||
fi
|
||||
fi
|
||||
verbose_substep "source build repo: $_RESOLVED_SOURCE_URL"
|
||||
verbose_substep "source build ref: ${_RESOLVED_SOURCE_REF:-latest} (${_RESOLVED_SOURCE_REF_KIND})"
|
||||
BUILD_OK=true
|
||||
mkdir -p "$(dirname "$LLAMA_CPP_DIR")"
|
||||
_BUILD_TMP="${LLAMA_CPP_DIR}.build.$$"
|
||||
rm -rf "$_BUILD_TMP"
|
||||
run_quiet_no_exit "clone llama.cpp" git clone --depth 1 "${_CLONE_BRANCH_ARGS[@]}" https://github.com/ggml-org/llama.cpp.git "$_BUILD_TMP" || BUILD_OK=false
|
||||
if [ -n "$_LLAMA_PR" ]; then
|
||||
run_quiet_no_exit "clone llama.cpp" \
|
||||
git clone --depth 1 "${_LLAMA_SOURCE}.git" "$_BUILD_TMP" || BUILD_OK=false
|
||||
if [ "$BUILD_OK" = true ]; then
|
||||
run_quiet_no_exit "fetch PR #$_LLAMA_PR" \
|
||||
git -C "$_BUILD_TMP" fetch --depth 1 origin "pull/$_LLAMA_PR/head:pr-$_LLAMA_PR" || BUILD_OK=false
|
||||
fi
|
||||
if [ "$BUILD_OK" = true ]; then
|
||||
run_quiet_no_exit "checkout PR #$_LLAMA_PR" \
|
||||
git -C "$_BUILD_TMP" checkout "pr-$_LLAMA_PR" || BUILD_OK=false
|
||||
fi
|
||||
elif [ "$_RESOLVED_SOURCE_REF_KIND" = "pull" ] && [ -n "$_RESOLVED_SOURCE_REF" ]; then
|
||||
run_quiet_no_exit "clone llama.cpp" \
|
||||
git clone --depth 1 "${_RESOLVED_SOURCE_URL}.git" "$_BUILD_TMP" || BUILD_OK=false
|
||||
if [ "$BUILD_OK" = true ]; then
|
||||
run_quiet_no_exit "fetch source PR ref" \
|
||||
git -C "$_BUILD_TMP" fetch --depth 1 origin "$_RESOLVED_SOURCE_REF" || BUILD_OK=false
|
||||
fi
|
||||
if [ "$BUILD_OK" = true ]; then
|
||||
run_quiet_no_exit "checkout source PR ref" \
|
||||
git -C "$_BUILD_TMP" checkout -B unsloth-llama-build FETCH_HEAD || BUILD_OK=false
|
||||
fi
|
||||
elif [ "$_RESOLVED_SOURCE_REF_KIND" = "commit" ] && [ -n "$_RESOLVED_SOURCE_REF" ]; then
|
||||
run_quiet_no_exit "clone llama.cpp" \
|
||||
git clone --depth 1 "${_RESOLVED_SOURCE_URL}.git" "$_BUILD_TMP" || BUILD_OK=false
|
||||
if [ "$BUILD_OK" = true ]; then
|
||||
run_quiet_no_exit "fetch source commit" \
|
||||
git -C "$_BUILD_TMP" fetch --depth 1 origin "$_RESOLVED_SOURCE_REF" || BUILD_OK=false
|
||||
fi
|
||||
if [ "$BUILD_OK" = true ]; then
|
||||
run_quiet_no_exit "checkout source commit" \
|
||||
git -C "$_BUILD_TMP" checkout -B unsloth-llama-build FETCH_HEAD || BUILD_OK=false
|
||||
fi
|
||||
else
|
||||
_CLONE_ARGS=(git clone --depth 1)
|
||||
if [ "$_RESOLVED_SOURCE_REF" != "latest" ] && [ -n "$_RESOLVED_SOURCE_REF" ]; then
|
||||
_CLONE_ARGS+=(--branch "$_RESOLVED_SOURCE_REF")
|
||||
fi
|
||||
_CLONE_ARGS+=("${_RESOLVED_SOURCE_URL}.git" "$_BUILD_TMP")
|
||||
run_quiet_no_exit "clone llama.cpp" \
|
||||
"${_CLONE_ARGS[@]}" || BUILD_OK=false
|
||||
fi
|
||||
|
||||
if [ "$BUILD_OK" = true ]; then
|
||||
CMAKE_ARGS="-DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_EXAMPLES=OFF -DLLAMA_BUILD_SERVER=ON -DGGML_NATIVE=ON"
|
||||
_TRY_METAL_CPU_FALLBACK=false
|
||||
_HOST_SYSTEM="$(uname -s 2>/dev/null || true)"
|
||||
_HOST_MACHINE="$(uname -m 2>/dev/null || true)"
|
||||
_IS_MACOS_ARM64=false
|
||||
if [ "$_HOST_SYSTEM" = "Darwin" ] && { [ "$_HOST_MACHINE" = "arm64" ] || [ "$_HOST_MACHINE" = "aarch64" ]; }; then
|
||||
_IS_MACOS_ARM64=true
|
||||
fi
|
||||
|
||||
if command -v ccache &>/dev/null; then
|
||||
CMAKE_ARGS="$CMAKE_ARGS -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_CUDA_COMPILER_LAUNCHER=ccache"
|
||||
fi
|
||||
CPU_FALLBACK_CMAKE_ARGS="$CMAKE_ARGS"
|
||||
|
||||
GPU_BACKEND=""
|
||||
NVCC_PATH=""
|
||||
|
|
@ -673,7 +873,13 @@ else
|
|||
fi
|
||||
|
||||
_BUILD_DESC="building"
|
||||
if [ -n "$NVCC_PATH" ]; then
|
||||
if [ "$_IS_MACOS_ARM64" = true ]; then
|
||||
# Metal takes precedence on Apple Silicon (CUDA/ROCm not functional on macOS)
|
||||
_BUILD_DESC="building (Metal)"
|
||||
CMAKE_ARGS="$CMAKE_ARGS -DGGML_METAL=ON -DGGML_METAL_EMBED_LIBRARY=ON -DGGML_METAL_USE_BF16=ON -DCMAKE_INSTALL_RPATH=@loader_path -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON"
|
||||
CPU_FALLBACK_CMAKE_ARGS="$CPU_FALLBACK_CMAKE_ARGS -DGGML_METAL=OFF"
|
||||
_TRY_METAL_CPU_FALLBACK=true
|
||||
elif [ -n "$NVCC_PATH" ]; then
|
||||
CMAKE_ARGS="$CMAKE_ARGS -DGGML_CUDA=ON"
|
||||
|
||||
CUDA_ARCHS=""
|
||||
|
|
@ -755,11 +961,37 @@ else
|
|||
CMAKE_GENERATOR_ARGS="-G Ninja"
|
||||
fi
|
||||
|
||||
run_quiet_no_exit "cmake llama.cpp" cmake $CMAKE_GENERATOR_ARGS -S "$_BUILD_TMP" -B "$_BUILD_TMP/build" $CMAKE_ARGS || BUILD_OK=false
|
||||
if ! run_quiet_no_exit "cmake llama.cpp" cmake $CMAKE_GENERATOR_ARGS -S "$_BUILD_TMP" -B "$_BUILD_TMP/build" $CMAKE_ARGS; then
|
||||
if [ "$_TRY_METAL_CPU_FALLBACK" = true ]; then
|
||||
_TRY_METAL_CPU_FALLBACK=false
|
||||
substep "Metal configure failed; retrying CPU build..." "$C_WARN"
|
||||
rm -rf "$_BUILD_TMP/build"
|
||||
run_quiet_no_exit "cmake llama.cpp (cpu fallback)" cmake $CMAKE_GENERATOR_ARGS -S "$_BUILD_TMP" -B "$_BUILD_TMP/build" $CPU_FALLBACK_CMAKE_ARGS || BUILD_OK=false
|
||||
if [ "$BUILD_OK" = true ]; then
|
||||
_BUILD_DESC="building (CPU fallback)"
|
||||
fi
|
||||
else
|
||||
BUILD_OK=false
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$BUILD_OK" = true ]; then
|
||||
run_quiet_no_exit "build llama-server" cmake --build "$_BUILD_TMP/build" --config Release --target llama-server -j"$NCPU" || BUILD_OK=false
|
||||
if ! run_quiet_no_exit "build llama-server" cmake --build "$_BUILD_TMP/build" --config Release --target llama-server -j"$NCPU"; then
|
||||
if [ "$_TRY_METAL_CPU_FALLBACK" = true ]; then
|
||||
_TRY_METAL_CPU_FALLBACK=false
|
||||
substep "Metal build failed; retrying CPU build..." "$C_WARN"
|
||||
rm -rf "$_BUILD_TMP/build"
|
||||
if run_quiet_no_exit "cmake llama.cpp (cpu fallback)" cmake $CMAKE_GENERATOR_ARGS -S "$_BUILD_TMP" -B "$_BUILD_TMP/build" $CPU_FALLBACK_CMAKE_ARGS; then
|
||||
_BUILD_DESC="building (CPU fallback)"
|
||||
run_quiet_no_exit "build llama-server (cpu fallback)" cmake --build "$_BUILD_TMP/build" --config Release --target llama-server -j"$NCPU" || BUILD_OK=false
|
||||
else
|
||||
BUILD_OK=false
|
||||
fi
|
||||
else
|
||||
BUILD_OK=false
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$BUILD_OK" = true ]; then
|
||||
|
|
@ -794,7 +1026,16 @@ else
|
|||
fi # end _SKIP_GGUF_BUILD check
|
||||
|
||||
# ── Footer ──
|
||||
if [ "$IS_COLAB" = true ]; then
|
||||
if [ "$_LLAMA_ONLY" = "1" ]; then
|
||||
echo ""
|
||||
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
|
||||
if [ "$_LLAMA_CPP_DEGRADED" = true ]; then
|
||||
printf " ${C_WARN}%s${C_RST}\n" "llama.cpp update finished (limited: llama.cpp unavailable)"
|
||||
else
|
||||
printf " ${C_TITLE}%s${C_RST}\n" "llama.cpp update finished"
|
||||
fi
|
||||
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
|
||||
elif [ "$IS_COLAB" = true ]; then
|
||||
echo ""
|
||||
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
|
||||
if [ "$_LLAMA_CPP_DEGRADED" = true ]; then
|
||||
|
|
|
|||
|
|
@ -5,3 +5,6 @@ def pytest_configure(config):
|
|||
config.addinivalue_line(
|
||||
"markers", "server: heavyweight tests requiring studio venv"
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers", "e2e: end-to-end tests requiring network and venv creation"
|
||||
)
|
||||
|
|
|
|||
572
tests/python/test_tokenizers_and_torch_constraint.py
Normal file
572
tests/python/test_tokenizers_and_torch_constraint.py
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
"""
|
||||
Tests for two install fixes:
|
||||
1. tokenizers added to no-torch-runtime.txt (prevents AutoConfig crash)
|
||||
2. TORCH_CONSTRAINT variable in install.sh (arm64 macOS + py313+ -> torch>=2.6)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
# ── Locate source files relative to this test ──────────────────────────
|
||||
_TESTS_DIR = pathlib.Path(__file__).resolve().parent.parent # tests/
|
||||
_REPO_ROOT = _TESTS_DIR.parent # unsloth/
|
||||
_INSTALL_SH = _REPO_ROOT / "install.sh"
|
||||
_INSTALL_PS1 = _REPO_ROOT / "install.ps1"
|
||||
_NO_TORCH_RT = (
|
||||
_REPO_ROOT / "studio" / "backend" / "requirements" / "no-torch-runtime.txt"
|
||||
)
|
||||
|
||||
|
||||
def _read(path: pathlib.Path) -> str:
|
||||
return path.read_text(encoding = "utf-8")
|
||||
|
||||
|
||||
def _lines(path: pathlib.Path) -> list[str]:
|
||||
"""Return non-comment, non-blank lines stripped."""
|
||||
return [
|
||||
ln.strip()
|
||||
for ln in _read(path).splitlines()
|
||||
if ln.strip() and not ln.strip().startswith("#")
|
||||
]
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Group 1 -- Structural checks (no network, instant)
|
||||
# ======================================================================
|
||||
class TestStructuralTokenizers:
|
||||
"""Verify tokenizers presence and ordering in no-torch-runtime.txt."""
|
||||
|
||||
def test_tokenizers_present(self):
|
||||
"""tokenizers must be a standalone package line."""
|
||||
pkgs = _lines(_NO_TORCH_RT)
|
||||
bare_names = [
|
||||
p.split(">")[0].split("<")[0].split("!")[0].split("=")[0] for p in pkgs
|
||||
]
|
||||
assert "tokenizers" in bare_names
|
||||
|
||||
def test_tokenizers_before_transformers(self):
|
||||
"""tokenizers should appear before transformers (install order intent)."""
|
||||
pkgs = _lines(_NO_TORCH_RT)
|
||||
bare_names = [
|
||||
p.split(">")[0].split("<")[0].split("!")[0].split("=")[0] for p in pkgs
|
||||
]
|
||||
idx_tok = bare_names.index("tokenizers")
|
||||
idx_tf = bare_names.index("transformers")
|
||||
assert idx_tok < idx_tf, (
|
||||
f"tokenizers at index {idx_tok} should appear before "
|
||||
f"transformers at index {idx_tf}"
|
||||
)
|
||||
|
||||
def test_torch_not_in_no_torch_file(self):
|
||||
"""torch itself must NOT be listed in the no-torch requirements."""
|
||||
pkgs = _lines(_NO_TORCH_RT)
|
||||
bare_names = [
|
||||
p.split(">")[0].split("<")[0].split("!")[0].split("=")[0] for p in pkgs
|
||||
]
|
||||
assert "torch" not in bare_names
|
||||
|
||||
|
||||
class TestStructuralTorchConstraint:
|
||||
"""Verify TORCH_CONSTRAINT wiring in install.sh."""
|
||||
|
||||
_sh = _read(_INSTALL_SH)
|
||||
|
||||
def test_default_assignment_exists(self):
|
||||
assert 'TORCH_CONSTRAINT="torch>=2.4,<2.11.0"' in self._sh
|
||||
|
||||
def test_tightened_assignment_exists(self):
|
||||
assert 'TORCH_CONSTRAINT="torch>=2.6,<2.11.0"' in self._sh
|
||||
|
||||
def test_variable_used_in_pip_install(self):
|
||||
"""$TORCH_CONSTRAINT must appear in a uv pip install line."""
|
||||
assert '"$TORCH_CONSTRAINT"' in self._sh
|
||||
|
||||
def test_hardcoded_torch_constraint_only_once(self):
|
||||
"""The hard-coded torch>=2.4,<2.11.0 string should appear exactly once
|
||||
in install.sh (the default assignment), not in pip install lines."""
|
||||
count = self._sh.count('"torch>=2.4,<2.11.0"')
|
||||
assert count == 1, f"Expected 1, found {count}"
|
||||
|
||||
def test_tightening_guarded_by_skip_torch(self):
|
||||
"""The block must check SKIP_TORCH=false."""
|
||||
# Find the tightening if-block
|
||||
m = re.search(
|
||||
r"if\s.*SKIP_TORCH.*=\s*false.*&&.*OS.*=.*macos.*&&.*_ARCH.*=.*arm64",
|
||||
self._sh,
|
||||
)
|
||||
assert m is not None, "Guard not found: SKIP_TORCH + macos + arm64"
|
||||
|
||||
def test_tightening_guarded_by_arch(self):
|
||||
m = re.search(r"_ARCH.*=.*arm64", self._sh)
|
||||
assert m is not None
|
||||
|
||||
def test_tightening_guarded_by_os(self):
|
||||
m = re.search(r"OS.*=.*macos", self._sh)
|
||||
assert m is not None
|
||||
|
||||
|
||||
class TestStructuralInstallPs1Unchanged:
|
||||
"""install.ps1 should NOT have TORCH_CONSTRAINT variable."""
|
||||
|
||||
_ps1 = _read(_INSTALL_PS1)
|
||||
|
||||
def test_no_torch_constraint_variable(self):
|
||||
assert "TORCH_CONSTRAINT" not in self._ps1
|
||||
assert "$TorchConstraint" not in self._ps1
|
||||
|
||||
def test_hardcoded_torch_constraint_present(self):
|
||||
assert '"torch>=2.4,<2.11.0"' in self._ps1
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Group 2 -- Shell snippet tests (bash subprocess, mocked python)
|
||||
# ======================================================================
|
||||
class TestTorchConstraintShell:
|
||||
"""Test the TORCH_CONSTRAINT block using bash subprocesses with
|
||||
mocked python binaries that return controlled minor versions."""
|
||||
|
||||
# The extracted snippet we test in isolation. We override OS, _ARCH,
|
||||
# SKIP_TORCH, and provide a mock python at $VENV_DIR/bin/python.
|
||||
_SNIPPET_TEMPLATE = textwrap.dedent(r"""
|
||||
#!/bin/bash
|
||||
set -e
|
||||
SKIP_TORCH={skip_torch}
|
||||
OS="{os}"
|
||||
_ARCH="{arch}"
|
||||
VENV_DIR="{venv_dir}"
|
||||
|
||||
TORCH_CONSTRAINT="torch>=2.4,<2.11.0"
|
||||
if [ "$SKIP_TORCH" = false ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then
|
||||
_PY_MINOR=$("$VENV_DIR/bin/python" -c \
|
||||
"import sys; print(sys.version_info.minor)" 2>/dev/null || echo "0")
|
||||
if [ "$_PY_MINOR" -ge 13 ] 2>/dev/null; then
|
||||
TORCH_CONSTRAINT="torch>=2.6,<2.11.0"
|
||||
fi
|
||||
fi
|
||||
echo "$TORCH_CONSTRAINT"
|
||||
""").strip()
|
||||
|
||||
@staticmethod
|
||||
def _make_mock_python(tmp_path: pathlib.Path, minor: int) -> pathlib.Path:
|
||||
"""Create a mock python that prints a controlled minor version."""
|
||||
venv = tmp_path / "venv"
|
||||
bin_dir = venv / "bin"
|
||||
bin_dir.mkdir(parents = True, exist_ok = True)
|
||||
mock_py = bin_dir / "python"
|
||||
mock_py.write_text(
|
||||
textwrap.dedent(f"""\
|
||||
#!/bin/bash
|
||||
# Mock python: always report minor={minor}
|
||||
if echo "$@" | grep -q "sys.version_info.minor"; then
|
||||
echo "{minor}"
|
||||
else
|
||||
echo "0"
|
||||
fi
|
||||
""")
|
||||
)
|
||||
mock_py.chmod(0o755)
|
||||
return venv
|
||||
|
||||
def _run(
|
||||
self,
|
||||
tmp_path: pathlib.Path,
|
||||
*,
|
||||
py_minor: int = 12,
|
||||
os_val: str = "macos",
|
||||
arch: str = "arm64",
|
||||
skip_torch: str = "false",
|
||||
) -> str:
|
||||
venv = self._make_mock_python(tmp_path, py_minor)
|
||||
script = self._SNIPPET_TEMPLATE.format(
|
||||
skip_torch = skip_torch,
|
||||
os = os_val,
|
||||
arch = arch,
|
||||
venv_dir = str(venv),
|
||||
)
|
||||
script_file = tmp_path / "test_snippet.sh"
|
||||
script_file.write_text(script)
|
||||
script_file.chmod(0o755)
|
||||
result = subprocess.run(
|
||||
["bash", str(script_file)],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 10,
|
||||
)
|
||||
assert result.returncode == 0, f"Script failed: {result.stderr}"
|
||||
return result.stdout.strip()
|
||||
|
||||
# -- arm64 macOS tightening cases --
|
||||
|
||||
def test_arm64_macos_py313_tightened(self, tmp_path):
|
||||
out = self._run(tmp_path, py_minor = 13, os_val = "macos", arch = "arm64")
|
||||
assert out == "torch>=2.6,<2.11.0"
|
||||
|
||||
def test_arm64_macos_py314_tightened(self, tmp_path):
|
||||
out = self._run(tmp_path, py_minor = 14, os_val = "macos", arch = "arm64")
|
||||
assert out == "torch>=2.6,<2.11.0"
|
||||
|
||||
# -- arm64 macOS default (older python) --
|
||||
|
||||
def test_arm64_macos_py312_default(self, tmp_path):
|
||||
out = self._run(tmp_path, py_minor = 12, os_val = "macos", arch = "arm64")
|
||||
assert out == "torch>=2.4,<2.11.0"
|
||||
|
||||
def test_arm64_macos_py311_default(self, tmp_path):
|
||||
out = self._run(tmp_path, py_minor = 11, os_val = "macos", arch = "arm64")
|
||||
assert out == "torch>=2.4,<2.11.0"
|
||||
|
||||
# -- Linux (unaffected) --
|
||||
|
||||
def test_linux_x86_py313_default(self, tmp_path):
|
||||
out = self._run(tmp_path, py_minor = 13, os_val = "linux", arch = "x86_64")
|
||||
assert out == "torch>=2.4,<2.11.0"
|
||||
|
||||
def test_linux_aarch64_py313_default(self, tmp_path):
|
||||
out = self._run(tmp_path, py_minor = 13, os_val = "linux", arch = "aarch64")
|
||||
assert out == "torch>=2.4,<2.11.0"
|
||||
|
||||
# -- Intel Mac (arch mismatch) --
|
||||
|
||||
def test_intel_mac_x86_py313_default(self, tmp_path):
|
||||
out = self._run(tmp_path, py_minor = 13, os_val = "macos", arch = "x86_64")
|
||||
assert out == "torch>=2.4,<2.11.0"
|
||||
|
||||
# -- SKIP_TORCH bypass --
|
||||
|
||||
def test_skip_torch_arm64_macos_py313_default(self, tmp_path):
|
||||
out = self._run(
|
||||
tmp_path,
|
||||
py_minor = 13,
|
||||
os_val = "macos",
|
||||
arch = "arm64",
|
||||
skip_torch = "true",
|
||||
)
|
||||
assert out == "torch>=2.4,<2.11.0"
|
||||
|
||||
# -- WSL --
|
||||
|
||||
def test_wsl_py313_default(self, tmp_path):
|
||||
out = self._run(tmp_path, py_minor = 13, os_val = "wsl", arch = "x86_64")
|
||||
assert out == "torch>=2.4,<2.11.0"
|
||||
|
||||
# -- Edge cases --
|
||||
|
||||
def test_py_minor_0_fallback_default(self, tmp_path):
|
||||
"""If python query fails (returns 0), should stay at default."""
|
||||
out = self._run(tmp_path, py_minor = 0, os_val = "macos", arch = "arm64")
|
||||
assert out == "torch>=2.4,<2.11.0"
|
||||
|
||||
def test_boundary_py_minor_12_not_tightened(self, tmp_path):
|
||||
out = self._run(tmp_path, py_minor = 12, os_val = "macos", arch = "arm64")
|
||||
assert out == "torch>=2.4,<2.11.0"
|
||||
|
||||
def test_boundary_py_minor_13_tightened(self, tmp_path):
|
||||
out = self._run(tmp_path, py_minor = 13, os_val = "macos", arch = "arm64")
|
||||
assert out == "torch>=2.6,<2.11.0"
|
||||
|
||||
def test_mock_uv_receives_correct_constraint(self, tmp_path):
|
||||
"""Verify a mock uv would receive the correct constraint string."""
|
||||
venv = self._make_mock_python(tmp_path, minor = 13)
|
||||
|
||||
# Create a mock uv that logs its arguments
|
||||
mock_uv = tmp_path / "mock_uv"
|
||||
log_file = tmp_path / "uv_log.txt"
|
||||
mock_uv.write_text(
|
||||
textwrap.dedent(f"""\
|
||||
#!/bin/bash
|
||||
echo "$@" >> {log_file}
|
||||
""")
|
||||
)
|
||||
mock_uv.chmod(0o755)
|
||||
|
||||
script = textwrap.dedent(f"""\
|
||||
#!/bin/bash
|
||||
set -e
|
||||
SKIP_TORCH=false
|
||||
OS="macos"
|
||||
_ARCH="arm64"
|
||||
VENV_DIR="{venv}"
|
||||
|
||||
TORCH_CONSTRAINT="torch>=2.4,<2.11.0"
|
||||
if [ "$SKIP_TORCH" = false ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then
|
||||
_PY_MINOR=$("$VENV_DIR/bin/python" -c \\
|
||||
"import sys; print(sys.version_info.minor)" 2>/dev/null || echo "0")
|
||||
if [ "$_PY_MINOR" -ge 13 ] 2>/dev/null; then
|
||||
TORCH_CONSTRAINT="torch>=2.6,<2.11.0"
|
||||
fi
|
||||
fi
|
||||
# Simulate the uv pip install line
|
||||
{mock_uv} pip install --python "$VENV_DIR/bin/python" "$TORCH_CONSTRAINT" torchvision torchaudio
|
||||
""")
|
||||
script_file = tmp_path / "test_uv.sh"
|
||||
script_file.write_text(script)
|
||||
script_file.chmod(0o755)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script_file)],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 10,
|
||||
)
|
||||
assert result.returncode == 0, f"Script failed: {result.stderr}"
|
||||
logged = log_file.read_text()
|
||||
assert "torch>=2.6,<2.11.0" in logged, f"uv log: {logged}"
|
||||
|
||||
def test_mock_uv_receives_default_constraint(self, tmp_path):
|
||||
"""On py3.12 arm64 macOS, uv should receive the default constraint."""
|
||||
venv = self._make_mock_python(tmp_path, minor = 12)
|
||||
mock_uv = tmp_path / "mock_uv"
|
||||
log_file = tmp_path / "uv_log.txt"
|
||||
mock_uv.write_text(
|
||||
textwrap.dedent(f"""\
|
||||
#!/bin/bash
|
||||
echo "$@" >> {log_file}
|
||||
""")
|
||||
)
|
||||
mock_uv.chmod(0o755)
|
||||
|
||||
script = textwrap.dedent(f"""\
|
||||
#!/bin/bash
|
||||
set -e
|
||||
SKIP_TORCH=false
|
||||
OS="macos"
|
||||
_ARCH="arm64"
|
||||
VENV_DIR="{venv}"
|
||||
|
||||
TORCH_CONSTRAINT="torch>=2.4,<2.11.0"
|
||||
if [ "$SKIP_TORCH" = false ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then
|
||||
_PY_MINOR=$("$VENV_DIR/bin/python" -c \\
|
||||
"import sys; print(sys.version_info.minor)" 2>/dev/null || echo "0")
|
||||
if [ "$_PY_MINOR" -ge 13 ] 2>/dev/null; then
|
||||
TORCH_CONSTRAINT="torch>=2.6,<2.11.0"
|
||||
fi
|
||||
fi
|
||||
{mock_uv} pip install --python "$VENV_DIR/bin/python" "$TORCH_CONSTRAINT" torchvision torchaudio
|
||||
""")
|
||||
script_file = tmp_path / "test_uv.sh"
|
||||
script_file.write_text(script)
|
||||
script_file.chmod(0o755)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script_file)],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 10,
|
||||
)
|
||||
assert result.returncode == 0, f"Script failed: {result.stderr}"
|
||||
logged = log_file.read_text()
|
||||
assert "torch>=2.4,<2.11.0" in logged, f"uv log: {logged}"
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Group 3 -- E2E tokenizers fix (requires network, ~2-5 min)
|
||||
# ======================================================================
|
||||
@pytest.mark.e2e
|
||||
class TestE2ETokenizersFix:
|
||||
"""Creates real uv venvs to verify tokenizers + transformers work
|
||||
without torch installed."""
|
||||
|
||||
@staticmethod
|
||||
def _create_venv(tmp_path: pathlib.Path, name: str, py: str) -> pathlib.Path:
|
||||
venv = tmp_path / name
|
||||
result = subprocess.run(
|
||||
["uv", "venv", str(venv), "--python", py],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
pytest.skip(f"uv venv creation failed for {py}: {result.stderr}")
|
||||
return venv
|
||||
|
||||
@staticmethod
|
||||
def _pip_install(venv: pathlib.Path, *args: str) -> subprocess.CompletedProcess:
|
||||
py = str(venv / "bin" / "python")
|
||||
cmd = ["uv", "pip", "install", "--python", py, *args]
|
||||
return subprocess.run(cmd, capture_output = True, text = True, timeout = 300)
|
||||
|
||||
@staticmethod
|
||||
def _run_python(venv: pathlib.Path, code: str) -> subprocess.CompletedProcess:
|
||||
py = str(venv / "bin" / "python")
|
||||
return subprocess.run(
|
||||
[py, "-c", code],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 60,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("py_version", ["3.12", "3.13"])
|
||||
def test_autoconfig_works_with_no_torch_runtime(self, tmp_path, py_version):
|
||||
"""Install from no-torch-runtime.txt with --no-deps (matching the
|
||||
real install.sh path), then verify AutoConfig imports successfully."""
|
||||
venv = self._create_venv(tmp_path, f"tok-{py_version}", py_version)
|
||||
r = self._pip_install(venv, "--no-deps", "-r", str(_NO_TORCH_RT))
|
||||
assert r.returncode == 0, f"Install failed: {r.stderr}"
|
||||
|
||||
result = self._run_python(
|
||||
venv, "from transformers import AutoConfig; print('OK')"
|
||||
)
|
||||
assert (
|
||||
result.returncode == 0
|
||||
), f"AutoConfig import failed:\nstdout: {result.stdout}\nstderr: {result.stderr}"
|
||||
assert "OK" in result.stdout
|
||||
|
||||
@pytest.mark.parametrize("py_version", ["3.12", "3.13"])
|
||||
def test_tokenizers_directly_importable(self, tmp_path, py_version):
|
||||
venv = self._create_venv(tmp_path, f"tok-imp-{py_version}", py_version)
|
||||
r = self._pip_install(venv, "--no-deps", "-r", str(_NO_TORCH_RT))
|
||||
assert r.returncode == 0, f"Install failed: {r.stderr}"
|
||||
result = self._run_python(venv, "import tokenizers; print('OK')")
|
||||
assert result.returncode == 0, f"Failed: {result.stderr}"
|
||||
|
||||
@pytest.mark.parametrize("py_version", ["3.12", "3.13"])
|
||||
def test_torch_not_importable(self, tmp_path, py_version):
|
||||
"""In the no-torch scenario, torch should not be available."""
|
||||
venv = self._create_venv(tmp_path, f"no-torch-{py_version}", py_version)
|
||||
r = self._pip_install(venv, "--no-deps", "-r", str(_NO_TORCH_RT))
|
||||
assert r.returncode == 0, f"Install failed: {r.stderr}"
|
||||
result = self._run_python(venv, "import torch")
|
||||
assert result.returncode != 0, "torch should NOT be importable"
|
||||
|
||||
def test_negative_control_no_tokenizers(self, tmp_path):
|
||||
"""Without tokenizers, AutoConfig should fail. We create a copy of
|
||||
no-torch-runtime.txt with the tokenizers line removed."""
|
||||
venv = self._create_venv(tmp_path, "neg-ctrl", "3.12")
|
||||
req_no_tokenizers = tmp_path / "no-tokenizers.txt"
|
||||
req_no_tokenizers.write_text(
|
||||
"\n".join(
|
||||
line
|
||||
for line in _read(_NO_TORCH_RT).splitlines()
|
||||
if line.strip() != "tokenizers"
|
||||
),
|
||||
encoding = "utf-8",
|
||||
)
|
||||
r = self._pip_install(venv, "--no-deps", "-r", str(req_no_tokenizers))
|
||||
assert r.returncode == 0, f"Install failed: {r.stderr}"
|
||||
result = self._run_python(venv, "from transformers import AutoConfig")
|
||||
assert (
|
||||
result.returncode != 0
|
||||
), "AutoConfig should fail without tokenizers installed"
|
||||
assert (
|
||||
"tokenizers" in result.stderr.lower()
|
||||
or "ModuleNotFoundError" in result.stderr
|
||||
)
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Group 4 -- Integration: install.sh reads no-torch-runtime.txt correctly
|
||||
# ======================================================================
|
||||
class TestInstallShNoTorchIntegration:
|
||||
"""Verify install.sh has the correct no-torch-runtime.txt wiring."""
|
||||
|
||||
_sh = _read(_INSTALL_SH)
|
||||
|
||||
def test_find_no_torch_runtime_exists(self):
|
||||
assert "_find_no_torch_runtime()" in self._sh
|
||||
|
||||
def test_no_deps_invocation_for_migrated(self):
|
||||
"""Migrated path should use --no-deps -r."""
|
||||
assert '--no-deps -r "$_NO_TORCH_RT"' in self._sh
|
||||
|
||||
def test_no_deps_invocation_for_fresh(self):
|
||||
"""Fresh install path should also use --no-deps -r."""
|
||||
# Count occurrences of the no-deps -r pattern
|
||||
count = self._sh.count('--no-deps -r "$_NO_TORCH_RT"')
|
||||
assert count >= 2, f"Expected >=2 no-deps -r invocations, found {count}"
|
||||
|
||||
def test_mock_uv_skip_torch_reads_requirements(self, tmp_path):
|
||||
"""When SKIP_TORCH=true, the _find_no_torch_runtime path should be used."""
|
||||
# We test this structurally: verify the SKIP_TORCH=true blocks contain
|
||||
# _find_no_torch_runtime calls
|
||||
skip_blocks = re.findall(
|
||||
r'if \[ "\$SKIP_TORCH" = true \].*?(?=\n (?:else|elif|fi))',
|
||||
self._sh,
|
||||
re.DOTALL,
|
||||
)
|
||||
found = any("_find_no_torch_runtime" in block for block in skip_blocks)
|
||||
assert found, "SKIP_TORCH=true block should call _find_no_torch_runtime"
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Group 5 -- Full no-torch sandbox (requires network, ~5 min)
|
||||
# ======================================================================
|
||||
@pytest.mark.e2e
|
||||
class TestE2EFullNoTorchSandbox:
|
||||
"""Creates venvs and installs the actual no-torch-runtime.txt."""
|
||||
|
||||
@staticmethod
|
||||
def _create_venv(tmp_path: pathlib.Path, name: str) -> pathlib.Path:
|
||||
venv = tmp_path / name
|
||||
result = subprocess.run(
|
||||
["uv", "venv", str(venv), "--python", "3.12"],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
pytest.skip(f"uv venv creation failed: {result.stderr}")
|
||||
return venv
|
||||
|
||||
@staticmethod
|
||||
def _pip_install(venv: pathlib.Path, *args: str) -> subprocess.CompletedProcess:
|
||||
py = str(venv / "bin" / "python")
|
||||
cmd = ["uv", "pip", "install", "--python", py, *args]
|
||||
return subprocess.run(cmd, capture_output = True, text = True, timeout = 600)
|
||||
|
||||
@staticmethod
|
||||
def _run_python(venv: pathlib.Path, code: str) -> subprocess.CompletedProcess:
|
||||
py = str(venv / "bin" / "python")
|
||||
return subprocess.run(
|
||||
[py, "-c", code],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 60,
|
||||
)
|
||||
|
||||
def test_autoconfig_succeeds(self, tmp_path):
|
||||
"""The real bug fix: install with --no-deps (matching install.sh)
|
||||
and verify from transformers import AutoConfig works."""
|
||||
venv = self._create_venv(tmp_path, "full-no-torch")
|
||||
r = self._pip_install(venv, "--no-deps", "-r", str(_NO_TORCH_RT))
|
||||
assert r.returncode == 0, f"Install failed: {r.stderr}"
|
||||
result = self._run_python(
|
||||
venv, "from transformers import AutoConfig; print('OK')"
|
||||
)
|
||||
assert (
|
||||
result.returncode == 0
|
||||
), f"AutoConfig failed:\nstdout: {result.stdout}\nstderr: {result.stderr}"
|
||||
|
||||
def test_torch_not_importable(self, tmp_path):
|
||||
"""With --no-deps (as install.sh uses), torch must not be pulled in."""
|
||||
venv = self._create_venv(tmp_path, "no-torch-check")
|
||||
r = self._pip_install(venv, "--no-deps", "-r", str(_NO_TORCH_RT))
|
||||
assert r.returncode == 0, f"Install failed: {r.stderr}"
|
||||
result = self._run_python(venv, "import torch")
|
||||
assert result.returncode != 0, "torch should NOT be importable"
|
||||
|
||||
def test_tokenizers_importable(self, tmp_path):
|
||||
venv = self._create_venv(tmp_path, "tok-check")
|
||||
r = self._pip_install(venv, "--no-deps", "-r", str(_NO_TORCH_RT))
|
||||
assert r.returncode == 0, f"Install failed: {r.stderr}"
|
||||
result = self._run_python(venv, "import tokenizers; print('OK')")
|
||||
assert result.returncode == 0, f"tokenizers import failed: {result.stderr}"
|
||||
|
||||
def test_safetensors_importable(self, tmp_path):
|
||||
venv = self._create_venv(tmp_path, "st-check")
|
||||
r = self._pip_install(venv, "--no-deps", "-r", str(_NO_TORCH_RT))
|
||||
assert r.returncode == 0, f"Install failed: {r.stderr}"
|
||||
result = self._run_python(venv, "import safetensors; print('OK')")
|
||||
assert result.returncode == 0, f"safetensors import failed: {result.stderr}"
|
||||
|
||||
def test_huggingface_hub_importable(self, tmp_path):
|
||||
venv = self._create_venv(tmp_path, "hfhub-check")
|
||||
r = self._pip_install(venv, "--no-deps", "-r", str(_NO_TORCH_RT))
|
||||
assert r.returncode == 0, f"Install failed: {r.stderr}"
|
||||
result = self._run_python(venv, "import huggingface_hub; print('OK')")
|
||||
assert result.returncode == 0, f"huggingface_hub import failed: {result.stderr}"
|
||||
|
|
@ -7,6 +7,7 @@ TESTS_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|||
echo "=== Bash tests ==="
|
||||
sh "$TESTS_DIR/sh/test_get_torch_index_url.sh"
|
||||
sh "$TESTS_DIR/sh/test_mac_intel_compat.sh"
|
||||
sh "$TESTS_DIR/sh/test_torch_constraint.sh"
|
||||
|
||||
echo ""
|
||||
echo "=== Python tests ==="
|
||||
|
|
@ -14,6 +15,7 @@ python -m pytest "$TESTS_DIR/python/test_install_python_stack.py" -v
|
|||
python -m pytest "$TESTS_DIR/python/test_cross_platform_parity.py" -v
|
||||
python -m pytest "$TESTS_DIR/python/test_no_torch_filtering.py" -v
|
||||
python -m pytest "$TESTS_DIR/python/test_studio_import_no_torch.py" -v
|
||||
python -m pytest "$TESTS_DIR/python/test_tokenizers_and_torch_constraint.py" -v -k "not e2e"
|
||||
|
||||
echo ""
|
||||
echo "All tests passed."
|
||||
|
|
|
|||
74
tests/saving/test_save_shell_injection.py
Normal file
74
tests/saving/test_save_shell_injection.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SAVE_PY = Path(__file__).resolve().parents[2] / "unsloth" / "save.py"
|
||||
|
||||
|
||||
def _function_calls(source: str, function_name: str) -> list[ast.Call]:
|
||||
tree = ast.parse(source, filename = str(SAVE_PY))
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.FunctionDef) and node.name == function_name:
|
||||
return [child for child in ast.walk(node) if isinstance(child, ast.Call)]
|
||||
raise AssertionError(f"Function {function_name} not found in save.py")
|
||||
|
||||
|
||||
def _assert_safe_ggml_calls(calls: list[ast.Call]) -> None:
|
||||
popen_calls = []
|
||||
for call in calls:
|
||||
if isinstance(call.func, ast.Attribute) and call.func.attr == "Popen":
|
||||
if (
|
||||
isinstance(call.func.value, ast.Name)
|
||||
and call.func.value.id == "subprocess"
|
||||
):
|
||||
popen_calls.append(call)
|
||||
|
||||
assert popen_calls, "Expected at least one subprocess.Popen call"
|
||||
|
||||
ggml_calls = []
|
||||
for call in popen_calls:
|
||||
if not call.args:
|
||||
continue
|
||||
argv = call.args[0]
|
||||
if isinstance(argv, ast.List) and len(argv.elts) >= 2:
|
||||
second_arg = argv.elts[1]
|
||||
if (
|
||||
isinstance(second_arg, ast.Constant)
|
||||
and second_arg.value == "llama.cpp/convert-lora-to-ggml.py"
|
||||
):
|
||||
ggml_calls.append(call)
|
||||
|
||||
assert ggml_calls, "Expected the GGML conversion subprocess call"
|
||||
|
||||
for call in ggml_calls:
|
||||
shell_kwargs = [
|
||||
keyword
|
||||
for keyword in call.keywords
|
||||
if keyword.arg == "shell"
|
||||
and isinstance(keyword.value, ast.Constant)
|
||||
and keyword.value.value is True
|
||||
]
|
||||
assert not shell_kwargs, "subprocess.Popen must not use shell=True"
|
||||
|
||||
assert call.args, "subprocess.Popen must receive argv as a positional argument"
|
||||
argv = call.args[0]
|
||||
assert isinstance(
|
||||
argv, ast.List
|
||||
), "subprocess.Popen must be called with an argv list"
|
||||
assert len(argv.elts) == 5, "GGML conversion argv should have five elements"
|
||||
|
||||
second_arg = argv.elts[1]
|
||||
assert isinstance(second_arg, ast.Constant)
|
||||
assert second_arg.value == "llama.cpp/convert-lora-to-ggml.py"
|
||||
|
||||
|
||||
def test_ggml_conversion_paths_do_not_use_shell() -> None:
|
||||
source = SAVE_PY.read_text(encoding = "utf-8")
|
||||
for function_name in (
|
||||
"unsloth_convert_lora_to_ggml_and_push_to_hub",
|
||||
"unsloth_convert_lora_to_ggml_and_save_locally",
|
||||
):
|
||||
calls = _function_calls(source, function_name)
|
||||
_assert_safe_ggml_calls(calls)
|
||||
266
tests/sh/test_torch_constraint.sh
Normal file
266
tests/sh/test_torch_constraint.sh
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
#!/bin/bash
|
||||
# Tests for TORCH_CONSTRAINT variable in install.sh and tokenizers in no-torch-runtime.txt.
|
||||
# Follows the same assertion pattern as test_mac_intel_compat.sh.
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
INSTALL_SH="$SCRIPT_DIR/../../install.sh"
|
||||
INSTALL_PS1="$SCRIPT_DIR/../../install.ps1"
|
||||
NO_TORCH_RT="$SCRIPT_DIR/../../studio/backend/requirements/no-torch-runtime.txt"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_eq() {
|
||||
_label="$1"; _expected="$2"; _actual="$3"
|
||||
if [ "$_actual" = "$_expected" ]; then
|
||||
echo " PASS: $_label"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL: $_label (expected '$_expected', got '$_actual')"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
_label="$1"; _haystack="$2"; _needle="$3"
|
||||
if echo "$_haystack" | grep -qF "$_needle"; then
|
||||
echo " PASS: $_label"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL: $_label (expected to find '$_needle')"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_not_contains() {
|
||||
_label="$1"; _haystack="$2"; _needle="$3"
|
||||
if echo "$_haystack" | grep -qF "$_needle"; then
|
||||
echo " FAIL: $_label (found '$_needle' but should not)"
|
||||
FAIL=$((FAIL + 1))
|
||||
else
|
||||
echo " PASS: $_label"
|
||||
PASS=$((PASS + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Helper: create a mock python that reports a given minor version ──
|
||||
make_mock_python() {
|
||||
_minor="$1"
|
||||
_venv_dir="$2"
|
||||
mkdir -p "$_venv_dir/bin"
|
||||
cat > "$_venv_dir/bin/python" <<MOCK_EOF
|
||||
#!/bin/bash
|
||||
if echo "\$@" | grep -q "sys.version_info.minor"; then
|
||||
echo "$_minor"
|
||||
else
|
||||
echo "0"
|
||||
fi
|
||||
MOCK_EOF
|
||||
chmod +x "$_venv_dir/bin/python"
|
||||
}
|
||||
|
||||
# ── Helper: run the TORCH_CONSTRAINT snippet with given params ──
|
||||
run_constraint_snippet() {
|
||||
_skip_torch="$1"
|
||||
_os="$2"
|
||||
_arch="$3"
|
||||
_py_minor="$4"
|
||||
_venv_dir="$5"
|
||||
|
||||
make_mock_python "$_py_minor" "$_venv_dir"
|
||||
|
||||
bash -c "
|
||||
SKIP_TORCH=\"$_skip_torch\"
|
||||
OS=\"$_os\"
|
||||
_ARCH=\"$_arch\"
|
||||
VENV_DIR=\"$_venv_dir\"
|
||||
TORCH_CONSTRAINT=\"torch>=2.4,<2.11.0\"
|
||||
if [ \"\$SKIP_TORCH\" = false ] && [ \"\$OS\" = \"macos\" ] && [ \"\$_ARCH\" = \"arm64\" ]; then
|
||||
_PY_MINOR=\$(\"\$VENV_DIR/bin/python\" -c \"import sys; print(sys.version_info.minor)\" 2>/dev/null || echo \"0\")
|
||||
if [ \"\$_PY_MINOR\" -ge 13 ] 2>/dev/null; then
|
||||
TORCH_CONSTRAINT=\"torch>=2.6,<2.11.0\"
|
||||
fi
|
||||
fi
|
||||
echo \"\$TORCH_CONSTRAINT\"
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# Structural checks
|
||||
# ======================================================================
|
||||
echo "=== Structural: TORCH_CONSTRAINT in install.sh ==="
|
||||
|
||||
_SH_CONTENT=$(cat "$INSTALL_SH")
|
||||
|
||||
_count=$(grep -c 'TORCH_CONSTRAINT="torch>=2.4,<2.11.0"' "$INSTALL_SH" || true)
|
||||
assert_eq "default TORCH_CONSTRAINT assignment exists" "1" "$_count"
|
||||
|
||||
_count=$(grep -c 'TORCH_CONSTRAINT="torch>=2.6,<2.11.0"' "$INSTALL_SH" || true)
|
||||
assert_eq "tightened TORCH_CONSTRAINT assignment exists" "1" "$_count"
|
||||
|
||||
_count=$(grep -c '"\$TORCH_CONSTRAINT"' "$INSTALL_SH" || true)
|
||||
_has_var=$([ "$_count" -ge 1 ] && echo "yes" || echo "no")
|
||||
assert_eq "\$TORCH_CONSTRAINT used in pip install" "yes" "$_has_var"
|
||||
|
||||
# Hardcoded torch>=2.4,<2.11.0 should only appear once (the default assignment)
|
||||
_hardcoded=$(grep -c '"torch>=2.4,<2.11.0"' "$INSTALL_SH" || true)
|
||||
assert_eq "hardcoded torch>=2.4 appears exactly once" "1" "$_hardcoded"
|
||||
|
||||
echo ""
|
||||
echo "=== Structural: tokenizers in no-torch-runtime.txt ==="
|
||||
|
||||
_has_tokenizers=$(grep -c '^tokenizers$' "$NO_TORCH_RT" || true)
|
||||
assert_eq "tokenizers present as standalone line" "1" "$_has_tokenizers"
|
||||
|
||||
# tokenizers before transformers
|
||||
_tok_line=$(grep -n '^tokenizers$' "$NO_TORCH_RT" | head -1 | cut -d: -f1)
|
||||
_tf_line=$(grep -n '^transformers' "$NO_TORCH_RT" | head -1 | cut -d: -f1)
|
||||
_tok_first=$([ "$_tok_line" -lt "$_tf_line" ] && echo "yes" || echo "no")
|
||||
assert_eq "tokenizers before transformers" "yes" "$_tok_first"
|
||||
|
||||
# torch itself NOT in no-torch file
|
||||
_has_torch=$(grep -c '^torch$' "$NO_TORCH_RT" || true)
|
||||
assert_eq "torch not in no-torch-runtime.txt" "0" "$_has_torch"
|
||||
|
||||
echo ""
|
||||
echo "=== Structural: install.ps1 unchanged ==="
|
||||
|
||||
_PS1_CONTENT=$(cat "$INSTALL_PS1")
|
||||
_ps1_has_var=$(echo "$_PS1_CONTENT" | grep -c 'TORCH_CONSTRAINT\|TorchConstraint' || true)
|
||||
assert_eq "install.ps1 has no TORCH_CONSTRAINT variable" "0" "$_ps1_has_var"
|
||||
|
||||
_ps1_hardcoded=$(echo "$_PS1_CONTENT" | grep -c '"torch>=2.4,<2.11.0"' || true)
|
||||
_ps1_has_hc=$([ "$_ps1_hardcoded" -ge 1 ] && echo "yes" || echo "no")
|
||||
assert_eq "install.ps1 has hardcoded torch constraint" "yes" "$_ps1_has_hc"
|
||||
|
||||
# ======================================================================
|
||||
# Runtime: mocked platform/version combos
|
||||
# ======================================================================
|
||||
echo ""
|
||||
echo "=== Runtime: TORCH_CONSTRAINT with mocked inputs ==="
|
||||
|
||||
TMPDIR_BASE=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPDIR_BASE"' EXIT
|
||||
|
||||
# 1. arm64 macOS py3.13 -> tightened
|
||||
_result=$(run_constraint_snippet false macos arm64 13 "$TMPDIR_BASE/v1")
|
||||
assert_eq "arm64+macos+py313 -> tightened" "torch>=2.6,<2.11.0" "$_result"
|
||||
|
||||
# 2. arm64 macOS py3.14 -> tightened (future-proofed)
|
||||
_result=$(run_constraint_snippet false macos arm64 14 "$TMPDIR_BASE/v2")
|
||||
assert_eq "arm64+macos+py314 -> tightened" "torch>=2.6,<2.11.0" "$_result"
|
||||
|
||||
# 3. arm64 macOS py3.12 -> default
|
||||
_result=$(run_constraint_snippet false macos arm64 12 "$TMPDIR_BASE/v3")
|
||||
assert_eq "arm64+macos+py312 -> default" "torch>=2.4,<2.11.0" "$_result"
|
||||
|
||||
# 4. arm64 macOS py3.11 -> default
|
||||
_result=$(run_constraint_snippet false macos arm64 11 "$TMPDIR_BASE/v4")
|
||||
assert_eq "arm64+macos+py311 -> default" "torch>=2.4,<2.11.0" "$_result"
|
||||
|
||||
# 5. Linux x86_64 py3.13 -> default (Linux unaffected)
|
||||
_result=$(run_constraint_snippet false linux x86_64 13 "$TMPDIR_BASE/v5")
|
||||
assert_eq "linux+x86_64+py313 -> default" "torch>=2.4,<2.11.0" "$_result"
|
||||
|
||||
# 6. Linux aarch64 py3.13 -> default (guard checks OS=macos)
|
||||
_result=$(run_constraint_snippet false linux aarch64 13 "$TMPDIR_BASE/v6")
|
||||
assert_eq "linux+aarch64+py313 -> default" "torch>=2.4,<2.11.0" "$_result"
|
||||
|
||||
# 7. Intel Mac x86_64 py3.12 -> default (arch mismatch)
|
||||
_result=$(run_constraint_snippet false macos x86_64 12 "$TMPDIR_BASE/v7")
|
||||
assert_eq "macos+x86_64+py312 -> default" "torch>=2.4,<2.11.0" "$_result"
|
||||
|
||||
# 8. SKIP_TORCH=true arm64 macOS py3.13 -> block skipped, default
|
||||
_result=$(run_constraint_snippet true macos arm64 13 "$TMPDIR_BASE/v8")
|
||||
assert_eq "SKIP_TORCH=true -> default" "torch>=2.4,<2.11.0" "$_result"
|
||||
|
||||
# 9. WSL py3.13 -> default
|
||||
_result=$(run_constraint_snippet false wsl x86_64 13 "$TMPDIR_BASE/v9")
|
||||
assert_eq "wsl+py313 -> default" "torch>=2.4,<2.11.0" "$_result"
|
||||
|
||||
# 10. py_minor=0 (failed query fallback) -> default
|
||||
_result=$(run_constraint_snippet false macos arm64 0 "$TMPDIR_BASE/v10")
|
||||
assert_eq "py_minor=0 fallback -> default" "torch>=2.4,<2.11.0" "$_result"
|
||||
|
||||
# 11. Boundary: py_minor=12 -> NOT tightened
|
||||
_result=$(run_constraint_snippet false macos arm64 12 "$TMPDIR_BASE/v11")
|
||||
assert_eq "boundary py_minor=12 -> default" "torch>=2.4,<2.11.0" "$_result"
|
||||
|
||||
# 12. Boundary: py_minor=13 -> tightened
|
||||
_result=$(run_constraint_snippet false macos arm64 13 "$TMPDIR_BASE/v12")
|
||||
assert_eq "boundary py_minor=13 -> tightened" "torch>=2.6,<2.11.0" "$_result"
|
||||
|
||||
# 13. Intel Mac py3.13 -> default (arch=x86_64, not arm64)
|
||||
_result=$(run_constraint_snippet false macos x86_64 13 "$TMPDIR_BASE/v13")
|
||||
assert_eq "macos+x86_64+py313 -> default" "torch>=2.4,<2.11.0" "$_result"
|
||||
|
||||
# ======================================================================
|
||||
# Mock uv integration
|
||||
# ======================================================================
|
||||
echo ""
|
||||
echo "=== Mock uv: verify constraint passed to uv ==="
|
||||
|
||||
# arm64 + py313 -> uv receives torch>=2.6
|
||||
_UV_LOG="$TMPDIR_BASE/uv_log_tight.txt"
|
||||
make_mock_python 13 "$TMPDIR_BASE/uv_venv1"
|
||||
cat > "$TMPDIR_BASE/mock_uv_tight" <<UVEOF
|
||||
#!/bin/bash
|
||||
echo "\$@" >> $_UV_LOG
|
||||
UVEOF
|
||||
chmod +x "$TMPDIR_BASE/mock_uv_tight"
|
||||
|
||||
bash -c "
|
||||
SKIP_TORCH=false
|
||||
OS=\"macos\"
|
||||
_ARCH=\"arm64\"
|
||||
VENV_DIR=\"$TMPDIR_BASE/uv_venv1\"
|
||||
TORCH_CONSTRAINT=\"torch>=2.4,<2.11.0\"
|
||||
if [ \"\$SKIP_TORCH\" = false ] && [ \"\$OS\" = \"macos\" ] && [ \"\$_ARCH\" = \"arm64\" ]; then
|
||||
_PY_MINOR=\$(\"\$VENV_DIR/bin/python\" -c \"import sys; print(sys.version_info.minor)\" 2>/dev/null || echo \"0\")
|
||||
if [ \"\$_PY_MINOR\" -ge 13 ] 2>/dev/null; then
|
||||
TORCH_CONSTRAINT=\"torch>=2.6,<2.11.0\"
|
||||
fi
|
||||
fi
|
||||
\"$TMPDIR_BASE/mock_uv_tight\" pip install --python \"\$VENV_DIR/bin/python\" \"\$TORCH_CONSTRAINT\" torchvision torchaudio
|
||||
" 2>/dev/null
|
||||
_uv_got=$(cat "$_UV_LOG" 2>/dev/null || echo "")
|
||||
assert_contains "mock uv arm64+py313 receives torch>=2.6" "$_uv_got" "torch>=2.6,<2.11.0"
|
||||
|
||||
# arm64 + py312 -> uv receives torch>=2.4
|
||||
_UV_LOG2="$TMPDIR_BASE/uv_log_default.txt"
|
||||
make_mock_python 12 "$TMPDIR_BASE/uv_venv2"
|
||||
cat > "$TMPDIR_BASE/mock_uv_default" <<UVEOF
|
||||
#!/bin/bash
|
||||
echo "\$@" >> $_UV_LOG2
|
||||
UVEOF
|
||||
chmod +x "$TMPDIR_BASE/mock_uv_default"
|
||||
|
||||
bash -c "
|
||||
SKIP_TORCH=false
|
||||
OS=\"macos\"
|
||||
_ARCH=\"arm64\"
|
||||
VENV_DIR=\"$TMPDIR_BASE/uv_venv2\"
|
||||
TORCH_CONSTRAINT=\"torch>=2.4,<2.11.0\"
|
||||
if [ \"\$SKIP_TORCH\" = false ] && [ \"\$OS\" = \"macos\" ] && [ \"\$_ARCH\" = \"arm64\" ]; then
|
||||
_PY_MINOR=\$(\"\$VENV_DIR/bin/python\" -c \"import sys; print(sys.version_info.minor)\" 2>/dev/null || echo \"0\")
|
||||
if [ \"\$_PY_MINOR\" -ge 13 ] 2>/dev/null; then
|
||||
TORCH_CONSTRAINT=\"torch>=2.6,<2.11.0\"
|
||||
fi
|
||||
fi
|
||||
\"$TMPDIR_BASE/mock_uv_default\" pip install --python \"\$VENV_DIR/bin/python\" \"\$TORCH_CONSTRAINT\" torchvision torchaudio
|
||||
" 2>/dev/null
|
||||
_uv_got2=$(cat "$_UV_LOG2" 2>/dev/null || echo "")
|
||||
assert_contains "mock uv arm64+py312 receives torch>=2.4" "$_uv_got2" "torch>=2.4,<2.11.0"
|
||||
|
||||
# ======================================================================
|
||||
# Summary
|
||||
# ======================================================================
|
||||
echo ""
|
||||
echo "=== Results ==="
|
||||
echo " PASS: $PASS"
|
||||
echo " FAIL: $FAIL"
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
echo "FAILED"
|
||||
exit 1
|
||||
fi
|
||||
echo "ALL PASSED"
|
||||
|
|
@ -39,7 +39,7 @@ def parse_args() -> argparse.Namespace:
|
|||
parser.add_argument(
|
||||
"--llama-tag",
|
||||
default = "latest",
|
||||
help = "llama.cpp tag to resolve. Defaults to the approved prebuilt tag for this host.",
|
||||
help = "llama.cpp tag to resolve. Defaults to the latest usable published Unsloth release.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--published-repo",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
635
tests/studio/install/test_llama_pr_force_and_source.py
Normal file
635
tests/studio/install/test_llama_pr_force_and_source.py
Normal file
|
|
@ -0,0 +1,635 @@
|
|||
"""
|
||||
Tests for the current llama.cpp wrapper policy in setup.sh / setup.ps1.
|
||||
|
||||
Tests cover:
|
||||
- Bash subprocess: PR_FORCE promotion, user-override, zero/empty/invalid ignored
|
||||
- Bash subprocess: source remains pinned to ggml-org even if env source is set
|
||||
- Static source checks: mainline repo/source are hardcoded for now
|
||||
- PowerShell subprocess: PR_FORCE promotion and fixed-source parity
|
||||
|
||||
Run: pytest tests/studio/install/test_llama_pr_force_and_source.py -v
|
||||
"""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths
|
||||
# ---------------------------------------------------------------------------
|
||||
PACKAGE_ROOT = Path(__file__).resolve().parents[3]
|
||||
SETUP_SH = PACKAGE_ROOT / "studio" / "setup.sh"
|
||||
SETUP_PS1 = PACKAGE_ROOT / "studio" / "setup.ps1"
|
||||
|
||||
BASH = "/bin/bash"
|
||||
PWSH = "/usr/bin/pwsh"
|
||||
PWSH_AVAILABLE = os.path.isfile(PWSH) and os.access(PWSH, os.X_OK)
|
||||
requires_pwsh = pytest.mark.skipif(not PWSH_AVAILABLE, reason = "pwsh not available")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def run_bash(
|
||||
script: str, *, timeout: int = 10, env: dict | None = None
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a bash script fragment and return the CompletedProcess."""
|
||||
run_env = os.environ.copy()
|
||||
if env:
|
||||
run_env.update(env)
|
||||
return subprocess.run(
|
||||
[BASH, "-c", script],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = timeout,
|
||||
env = run_env,
|
||||
)
|
||||
|
||||
|
||||
def run_pwsh(
|
||||
script: str, *, timeout: int = 10, env: dict | None = None
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a PowerShell script fragment and return the CompletedProcess."""
|
||||
run_env = os.environ.copy()
|
||||
run_env["NO_COLOR"] = "1"
|
||||
if env:
|
||||
run_env.update(env)
|
||||
return subprocess.run(
|
||||
[PWSH, "-NoProfile", "-Command", script],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = timeout,
|
||||
env = run_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared bash stubs
|
||||
# ---------------------------------------------------------------------------
|
||||
BASH_STUBS = textwrap.dedent("""\
|
||||
step() { echo "step:$1:$2"; }
|
||||
substep() { :; }
|
||||
verbose_substep() { :; }
|
||||
print_llama_error_log() { :; }
|
||||
C_ERR= C_WARN= C_OK= C_RST= C_TITLE= C_DIM=
|
||||
""")
|
||||
|
||||
RUN_QUIET_STUB = textwrap.dedent("""\
|
||||
run_quiet_no_exit() { local _label="$1"; shift; "$@"; return $?; }
|
||||
""")
|
||||
|
||||
|
||||
def make_mock_git(tmp_path: Path, *, fail_on: str = "") -> tuple[Path, Path]:
|
||||
"""Create a mock git binary that logs calls. Returns (mock_bin, log_file)."""
|
||||
mock_bin = tmp_path / "mock_bin"
|
||||
mock_bin.mkdir(exist_ok = True)
|
||||
log_file = tmp_path / "git_calls.log"
|
||||
|
||||
if fail_on:
|
||||
script = (
|
||||
f'#!/bin/bash\necho "$*" >> {log_file}\n'
|
||||
f'_args=("$@")\n'
|
||||
f"_i=0\n"
|
||||
f'while [ "${{_args[$_i]:-}}" = "-C" ]; do _i=$((_i+2)); done\n'
|
||||
f'_subcmd="${{_args[$_i]:-}}"\n'
|
||||
f'if [ "$_subcmd" = "{fail_on}" ]; then exit 1; fi\n'
|
||||
f"exit 0\n"
|
||||
)
|
||||
else:
|
||||
script = f'#!/bin/bash\necho "$*" >> {log_file}\nexit 0\n'
|
||||
|
||||
git_bin = mock_bin / "git"
|
||||
git_bin.write_text(script)
|
||||
git_bin.chmod(0o755)
|
||||
return mock_bin, log_file
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Bash fragment that exercises PR_FORCE and fixed _LLAMA_SOURCE resolution
|
||||
# =========================================================================
|
||||
def _bash_resolution_fragment(
|
||||
llama_pr: str = "",
|
||||
llama_pr_force: str = "",
|
||||
llama_source: str = "",
|
||||
default_pr_force: str = "",
|
||||
default_source: str = "https://github.com/ggml-org/llama.cpp",
|
||||
) -> str:
|
||||
"""Build the bash fragment that mirrors setup.sh resolution logic."""
|
||||
return BASH_STUBS + textwrap.dedent(f"""\
|
||||
_LLAMA_PR={shlex.quote(llama_pr) if llama_pr else '""'}
|
||||
_DEFAULT_LLAMA_PR_FORCE={shlex.quote(default_pr_force) if default_pr_force else '""'}
|
||||
_DEFAULT_LLAMA_SOURCE={shlex.quote(default_source)}
|
||||
|
||||
_LLAMA_PR_FORCE={shlex.quote(llama_pr_force) if llama_pr_force else '"$_DEFAULT_LLAMA_PR_FORCE"'}
|
||||
export UNSLOTH_LLAMA_SOURCE={shlex.quote(llama_source) if llama_source else '""'}
|
||||
_LLAMA_SOURCE="$_DEFAULT_LLAMA_SOURCE"
|
||||
_LLAMA_SOURCE="${{_LLAMA_SOURCE%.git}}"
|
||||
|
||||
_NEED_LLAMA_SOURCE_BUILD=false
|
||||
_SKIP_PREBUILT_INSTALL=false
|
||||
|
||||
if [ "$_LLAMA_SOURCE" != "https://github.com/ggml-org/llama.cpp" ]; then
|
||||
step "llama.cpp" "custom source: $_LLAMA_SOURCE -- forcing source build"
|
||||
_NEED_LLAMA_SOURCE_BUILD=true
|
||||
_SKIP_PREBUILT_INSTALL=true
|
||||
fi
|
||||
|
||||
if [ -z "$_LLAMA_PR" ] && [ -n "$_LLAMA_PR_FORCE" ] && \\
|
||||
[[ "$_LLAMA_PR_FORCE" =~ ^[0-9]+$ ]] && [ "$_LLAMA_PR_FORCE" -gt 0 ]; then
|
||||
_LLAMA_PR="$_LLAMA_PR_FORCE"
|
||||
step "llama.cpp" "baked-in PR_FORCE=$_LLAMA_PR_FORCE"
|
||||
fi
|
||||
|
||||
echo "LLAMA_PR=$_LLAMA_PR"
|
||||
echo "LLAMA_SOURCE=$_LLAMA_SOURCE"
|
||||
echo "NEED_SOURCE=$_NEED_LLAMA_SOURCE_BUILD"
|
||||
echo "SKIP_PREBUILT=$_SKIP_PREBUILT_INSTALL"
|
||||
""")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# TEST GROUP A: Bash PR_FORCE promotion (subprocess)
|
||||
# =========================================================================
|
||||
class TestBashPrForcePromotion:
|
||||
"""PR_FORCE promotes to _LLAMA_PR when user hasn't set one."""
|
||||
|
||||
def test_baked_in_pr_force_promotes(self):
|
||||
script = _bash_resolution_fragment(default_pr_force = "12345")
|
||||
r = run_bash(script)
|
||||
assert r.returncode == 0
|
||||
assert "LLAMA_PR=12345" in r.stdout
|
||||
assert "baked-in PR_FORCE=12345" in r.stdout
|
||||
|
||||
def test_env_pr_force_promotes(self):
|
||||
script = _bash_resolution_fragment(llama_pr_force = "999")
|
||||
r = run_bash(script)
|
||||
assert r.returncode == 0
|
||||
assert "LLAMA_PR=999" in r.stdout
|
||||
|
||||
def test_user_pr_overrides_pr_force(self):
|
||||
"""UNSLOTH_LLAMA_PR takes priority over PR_FORCE."""
|
||||
script = _bash_resolution_fragment(
|
||||
llama_pr = "100",
|
||||
llama_pr_force = "200",
|
||||
)
|
||||
r = run_bash(script)
|
||||
assert r.returncode == 0
|
||||
assert "LLAMA_PR=100" in r.stdout
|
||||
assert "baked-in PR_FORCE" not in r.stdout
|
||||
|
||||
def test_user_pr_overrides_baked_in(self):
|
||||
script = _bash_resolution_fragment(
|
||||
llama_pr = "100",
|
||||
default_pr_force = "200",
|
||||
)
|
||||
r = run_bash(script)
|
||||
assert r.returncode == 0
|
||||
assert "LLAMA_PR=100" in r.stdout
|
||||
assert "baked-in PR_FORCE" not in r.stdout
|
||||
|
||||
def test_pr_force_zero_ignored(self):
|
||||
script = _bash_resolution_fragment(llama_pr_force = "0")
|
||||
r = run_bash(script)
|
||||
assert r.returncode == 0
|
||||
assert "LLAMA_PR=" in r.stdout
|
||||
assert "baked-in PR_FORCE" not in r.stdout
|
||||
|
||||
def test_pr_force_empty_ignored(self):
|
||||
script = _bash_resolution_fragment(default_pr_force = "")
|
||||
r = run_bash(script)
|
||||
assert r.returncode == 0
|
||||
assert "LLAMA_PR=" in r.stdout
|
||||
assert "baked-in PR_FORCE" not in r.stdout
|
||||
|
||||
def test_pr_force_alpha_ignored(self):
|
||||
script = _bash_resolution_fragment(llama_pr_force = "abc")
|
||||
r = run_bash(script)
|
||||
assert r.returncode == 0
|
||||
assert "LLAMA_PR=" in r.stdout
|
||||
assert "baked-in PR_FORCE" not in r.stdout
|
||||
|
||||
def test_pr_force_negative_ignored(self):
|
||||
script = _bash_resolution_fragment(llama_pr_force = "-5")
|
||||
r = run_bash(script)
|
||||
assert r.returncode == 0
|
||||
assert "LLAMA_PR=" in r.stdout
|
||||
|
||||
def test_pr_force_decimal_ignored(self):
|
||||
script = _bash_resolution_fragment(llama_pr_force = "12.34")
|
||||
r = run_bash(script)
|
||||
assert r.returncode == 0
|
||||
assert "LLAMA_PR=" in r.stdout
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# TEST GROUP B: Bash fixed mainline source (subprocess)
|
||||
# =========================================================================
|
||||
class TestBashFixedMainlineSource:
|
||||
"""Source remains pinned to ggml-org while the temporary policy is active."""
|
||||
|
||||
def test_default_source_no_force(self):
|
||||
script = _bash_resolution_fragment()
|
||||
r = run_bash(script)
|
||||
assert r.returncode == 0
|
||||
assert "NEED_SOURCE=false" in r.stdout
|
||||
assert "SKIP_PREBUILT=false" in r.stdout
|
||||
assert "custom source:" not in r.stdout
|
||||
|
||||
def test_env_source_override_is_ignored(self):
|
||||
script = _bash_resolution_fragment(
|
||||
llama_source = "https://github.com/unslothai/llama.cpp.git",
|
||||
)
|
||||
r = run_bash(script)
|
||||
assert r.returncode == 0
|
||||
assert "LLAMA_SOURCE=https://github.com/ggml-org/llama.cpp" in r.stdout
|
||||
assert "NEED_SOURCE=false" in r.stdout
|
||||
assert "SKIP_PREBUILT=false" in r.stdout
|
||||
|
||||
def test_baked_in_source_stays_mainline(self):
|
||||
script = _bash_resolution_fragment(
|
||||
default_source = "https://github.com/ggml-org/llama.cpp",
|
||||
)
|
||||
r = run_bash(script)
|
||||
assert r.returncode == 0
|
||||
assert "LLAMA_SOURCE=https://github.com/ggml-org/llama.cpp" in r.stdout
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# TEST GROUP C: Bash clone URL parameterization (subprocess with mock git)
|
||||
# =========================================================================
|
||||
class TestBashCloneUrlParameterized:
|
||||
"""Verify git clone uses _LLAMA_SOURCE instead of hardcoded URL."""
|
||||
|
||||
@staticmethod
|
||||
def _clone_script(
|
||||
mock_bin: Path,
|
||||
build_tmp: str,
|
||||
llama_pr: str = "",
|
||||
llama_source: str = "https://github.com/ggml-org/llama.cpp",
|
||||
resolved_tag: str = "b8508",
|
||||
) -> str:
|
||||
return RUN_QUIET_STUB + textwrap.dedent(f"""\
|
||||
export PATH="{mock_bin}:$PATH"
|
||||
_LLAMA_PR={shlex.quote(llama_pr) if llama_pr else '""'}
|
||||
_LLAMA_SOURCE={shlex.quote(llama_source)}
|
||||
_RESOLVED_LLAMA_TAG={shlex.quote(resolved_tag)}
|
||||
_BUILD_TMP={shlex.quote(build_tmp)}
|
||||
BUILD_OK=true
|
||||
|
||||
if [ -n "$_LLAMA_PR" ]; then
|
||||
run_quiet_no_exit "clone llama.cpp" \\
|
||||
git clone --depth 1 "${{_LLAMA_SOURCE}}.git" "$_BUILD_TMP" || BUILD_OK=false
|
||||
else
|
||||
_CLONE_ARGS=(git clone --depth 1)
|
||||
if [ "$_RESOLVED_LLAMA_TAG" != "latest" ] && [ -n "$_RESOLVED_LLAMA_TAG" ]; then
|
||||
_CLONE_ARGS+=(--branch "$_RESOLVED_LLAMA_TAG")
|
||||
fi
|
||||
_CLONE_ARGS+=("${{_LLAMA_SOURCE}}.git" "$_BUILD_TMP")
|
||||
run_quiet_no_exit "clone llama.cpp" \\
|
||||
"${{_CLONE_ARGS[@]}}" || BUILD_OK=false
|
||||
fi
|
||||
echo "BUILD_OK=$BUILD_OK"
|
||||
""")
|
||||
|
||||
def test_pr_path_uses_custom_source(self, tmp_path: Path):
|
||||
mock_bin, log_file = make_mock_git(tmp_path)
|
||||
build_tmp = str(tmp_path / "build_tmp")
|
||||
script = self._clone_script(
|
||||
mock_bin,
|
||||
build_tmp,
|
||||
llama_pr = "123",
|
||||
llama_source = "https://github.com/unslothai/llama.cpp",
|
||||
)
|
||||
r = run_bash(script)
|
||||
assert r.returncode == 0
|
||||
log = log_file.read_text()
|
||||
assert "unslothai/llama.cpp.git" in log
|
||||
assert "ggml-org" not in log
|
||||
|
||||
def test_non_pr_path_uses_custom_source(self, tmp_path: Path):
|
||||
mock_bin, log_file = make_mock_git(tmp_path)
|
||||
build_tmp = str(tmp_path / "build_tmp")
|
||||
script = self._clone_script(
|
||||
mock_bin,
|
||||
build_tmp,
|
||||
llama_source = "https://github.com/unslothai/llama.cpp",
|
||||
)
|
||||
r = run_bash(script)
|
||||
assert r.returncode == 0
|
||||
log = log_file.read_text()
|
||||
assert "unslothai/llama.cpp.git" in log
|
||||
assert "ggml-org" not in log
|
||||
|
||||
def test_default_source_unchanged(self, tmp_path: Path):
|
||||
mock_bin, log_file = make_mock_git(tmp_path)
|
||||
build_tmp = str(tmp_path / "build_tmp")
|
||||
script = self._clone_script(mock_bin, build_tmp)
|
||||
r = run_bash(script)
|
||||
assert r.returncode == 0
|
||||
log = log_file.read_text()
|
||||
assert "ggml-org/llama.cpp.git" in log
|
||||
|
||||
def test_latest_tag_omits_branch_flag(self, tmp_path: Path):
|
||||
"""resolved_tag='latest' should not pass --branch to git clone."""
|
||||
mock_bin, log_file = make_mock_git(tmp_path)
|
||||
build_tmp = str(tmp_path / "build_tmp")
|
||||
script = self._clone_script(
|
||||
mock_bin,
|
||||
build_tmp,
|
||||
resolved_tag = "latest",
|
||||
)
|
||||
r = run_bash(script)
|
||||
assert r.returncode == 0
|
||||
log = log_file.read_text()
|
||||
assert "--branch" not in log
|
||||
assert "ggml-org/llama.cpp.git" in log
|
||||
|
||||
def test_empty_tag_omits_branch_flag(self, tmp_path: Path):
|
||||
"""resolved_tag='' (empty) should not pass --branch to git clone."""
|
||||
mock_bin, log_file = make_mock_git(tmp_path)
|
||||
build_tmp = str(tmp_path / "build_tmp")
|
||||
script = self._clone_script(
|
||||
mock_bin,
|
||||
build_tmp,
|
||||
resolved_tag = "",
|
||||
)
|
||||
r = run_bash(script)
|
||||
assert r.returncode == 0
|
||||
log = log_file.read_text()
|
||||
assert "--branch" not in log
|
||||
assert "ggml-org/llama.cpp.git" in log
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# TEST GROUP D: Static source patterns -- setup.sh
|
||||
# =========================================================================
|
||||
class TestSourcePatternsSh:
|
||||
"""Verify setup.sh keeps the temporary mainline-only llama.cpp policy."""
|
||||
|
||||
@pytest.fixture(autouse = True)
|
||||
def _load_source(self):
|
||||
self.content = SETUP_SH.read_text()
|
||||
|
||||
def test_has_default_pr_force(self):
|
||||
assert '_DEFAULT_LLAMA_PR_FORCE=""' in self.content
|
||||
|
||||
def test_has_default_source(self):
|
||||
assert (
|
||||
'_DEFAULT_LLAMA_SOURCE="https://github.com/ggml-org/llama.cpp"'
|
||||
in self.content
|
||||
)
|
||||
|
||||
def test_has_pr_force_env_read(self):
|
||||
assert "UNSLOTH_LLAMA_PR_FORCE" in self.content
|
||||
|
||||
def test_source_env_override_removed(self):
|
||||
assert "UNSLOTH_LLAMA_SOURCE:-${_DEFAULT_LLAMA_SOURCE}" not in self.content
|
||||
assert '_LLAMA_SOURCE="${_DEFAULT_LLAMA_SOURCE}"' in self.content
|
||||
|
||||
def test_release_repo_override_removed(self):
|
||||
assert "UNSLOTH_LLAMA_RELEASE_REPO:-unslothai/llama.cpp" not in self.content
|
||||
assert '_HELPER_RELEASE_REPO="ggml-org/llama.cpp"' in self.content
|
||||
|
||||
def test_force_compile_skips_prebuilt_resolution_early(self):
|
||||
assert 'if [ "$_LLAMA_FORCE_COMPILE" = "1" ]; then' in self.content
|
||||
assert "_SKIP_PREBUILT_INSTALL=true" in self.content
|
||||
|
||||
def test_force_compile_uses_requested_tag_without_helper(self):
|
||||
assert 'if [ "$_LLAMA_FORCE_COMPILE" = "1" ]; then' in self.content
|
||||
assert '_RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG"' in self.content
|
||||
|
||||
def test_pr_force_resolution_block(self):
|
||||
assert '_LLAMA_PR="$_LLAMA_PR_FORCE"' in self.content
|
||||
|
||||
def test_source_trailing_git_strip(self):
|
||||
assert "${_LLAMA_SOURCE%.git}" in self.content
|
||||
|
||||
def test_clone_urls_parameterized_pr_path(self):
|
||||
"""PR clone path uses ${_LLAMA_SOURCE}.git, not hardcoded URL."""
|
||||
pr_clone_idx = self.content.index(
|
||||
'if [ -n "$_LLAMA_PR" ]; then\n'
|
||||
' run_quiet_no_exit "clone llama.cpp"'
|
||||
)
|
||||
else_idx = self.content.index("else\n", pr_clone_idx)
|
||||
pr_block = self.content[pr_clone_idx:else_idx]
|
||||
assert '"${_LLAMA_SOURCE}.git"' in pr_block
|
||||
assert "ggml-org/llama.cpp.git" not in pr_block
|
||||
|
||||
def test_clone_urls_parameterized_tag_path(self):
|
||||
"""Non-PR clone path uses the resolved source URL, not a hardcoded URL."""
|
||||
# Find the non-PR clone line (after _CLONE_ARGS)
|
||||
idx = self.content.index("_CLONE_ARGS=(git clone --depth 1)")
|
||||
block = self.content[idx : idx + 400]
|
||||
assert '"${_RESOLVED_SOURCE_URL}.git"' in block
|
||||
assert "ggml-org/llama.cpp.git" not in block
|
||||
|
||||
def test_no_hardcoded_clone_urls(self):
|
||||
"""No remaining hardcoded ggml-org clone URLs in clone commands."""
|
||||
lines = self.content.splitlines()
|
||||
for i, line in enumerate(lines, 1):
|
||||
if "git clone" in line and "ggml-org/llama.cpp.git" in line:
|
||||
pytest.fail(
|
||||
f"Line {i} has hardcoded ggml-org clone URL: {line.strip()}"
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# TEST GROUP E: Static source patterns -- setup.ps1
|
||||
# =========================================================================
|
||||
class TestSourcePatternsPs1:
|
||||
"""Verify setup.ps1 keeps the temporary mainline-only llama.cpp policy."""
|
||||
|
||||
@pytest.fixture(autouse = True)
|
||||
def _load_source(self):
|
||||
self.content = SETUP_PS1.read_text()
|
||||
|
||||
def test_has_default_pr_force(self):
|
||||
assert '$DefaultLlamaPrForce = ""' in self.content
|
||||
|
||||
def test_has_default_source(self):
|
||||
assert (
|
||||
'$DefaultLlamaSource = "https://github.com/ggml-org/llama.cpp"'
|
||||
in self.content
|
||||
)
|
||||
|
||||
def test_has_pr_force_env_read(self):
|
||||
assert "$env:UNSLOTH_LLAMA_PR_FORCE" in self.content
|
||||
|
||||
def test_source_env_override_removed(self):
|
||||
assert "$LlamaSource = if ($env:UNSLOTH_LLAMA_SOURCE)" not in self.content
|
||||
assert "$LlamaSource = $DefaultLlamaSource" in self.content
|
||||
|
||||
def test_release_repo_override_removed(self):
|
||||
assert (
|
||||
"$HelperReleaseRepo = if ($env:UNSLOTH_LLAMA_RELEASE_REPO)"
|
||||
not in self.content
|
||||
)
|
||||
assert '$HelperReleaseRepo = "ggml-org/llama.cpp"' in self.content
|
||||
|
||||
def test_force_compile_skips_prebuilt_resolution_early(self):
|
||||
assert 'if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {' in self.content
|
||||
assert "$SkipPrebuiltInstall = $true" in self.content
|
||||
|
||||
def test_force_compile_uses_requested_tag_without_helper(self):
|
||||
assert 'if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {' in self.content
|
||||
assert "$ResolvedLlamaTag = $RequestedLlamaTag" in self.content
|
||||
|
||||
def test_pr_force_promotion_block(self):
|
||||
assert "$LlamaPr = $LlamaPrForce" in self.content
|
||||
|
||||
def test_source_trailing_git_strip(self):
|
||||
assert ".EndsWith('.git')" in self.content
|
||||
|
||||
def test_clone_urls_parameterized_pr_path(self):
|
||||
"""PR clone path uses $LlamaSource.git, not hardcoded URL."""
|
||||
pr_idx = self.content.index(
|
||||
"if ($LlamaPr) {\n", self.content.index("Cloning llama.cpp")
|
||||
)
|
||||
else_idx = self.content.index("} else {", pr_idx)
|
||||
pr_block = self.content[pr_idx:else_idx]
|
||||
assert '"$LlamaSource.git"' in pr_block
|
||||
assert "ggml-org/llama.cpp.git" not in pr_block
|
||||
|
||||
def test_clone_urls_parameterized_tag_path(self):
|
||||
"""Non-PR clone path uses the resolved source URL, not a hardcoded URL."""
|
||||
clone_args_idx = self.content.index('$cloneArgs = @("clone"')
|
||||
block = self.content[clone_args_idx : clone_args_idx + 400]
|
||||
assert '"$ResolvedSourceUrl.git"' in block
|
||||
assert "ggml-org/llama.cpp.git" not in block
|
||||
|
||||
def test_no_hardcoded_clone_urls(self):
|
||||
"""No remaining hardcoded ggml-org clone URLs in clone commands."""
|
||||
lines = self.content.splitlines()
|
||||
for i, line in enumerate(lines, 1):
|
||||
if "git clone" in line and "ggml-org/llama.cpp.git" in line:
|
||||
pytest.fail(
|
||||
f"Line {i} has hardcoded ggml-org clone URL: {line.strip()}"
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# TEST GROUP F: PowerShell PR_FORCE promotion (subprocess)
|
||||
# =========================================================================
|
||||
@requires_pwsh
|
||||
class TestPwshPrForcePromotion:
|
||||
"""PR_FORCE promotion and fixed-source logic via pwsh subprocess."""
|
||||
|
||||
FRAGMENT_TEMPLATE = textwrap.dedent("""\
|
||||
function step($a, $b, $c) { Write-Output "step:$a`:$b" }
|
||||
|
||||
$DefaultLlamaPrForce = "%%DEFAULT_PR_FORCE%%"
|
||||
$DefaultLlamaSource = "%%DEFAULT_SOURCE%%"
|
||||
|
||||
$LlamaPr = if ($env:UNSLOTH_LLAMA_PR) { $env:UNSLOTH_LLAMA_PR.Trim() } else { "" }
|
||||
$LlamaPrForce = if ($env:UNSLOTH_LLAMA_PR_FORCE) { $env:UNSLOTH_LLAMA_PR_FORCE.Trim() } else { $DefaultLlamaPrForce }
|
||||
$LlamaSource = $DefaultLlamaSource
|
||||
if ($LlamaSource.EndsWith('.git')) { $LlamaSource = $LlamaSource.Substring(0, $LlamaSource.Length - 4) }
|
||||
|
||||
$NeedLlamaSourceBuild = $false
|
||||
$SkipPrebuiltInstall = $false
|
||||
|
||||
if ($LlamaSource -ne "https://github.com/ggml-org/llama.cpp") {
|
||||
step "llama.cpp" "custom source: $LlamaSource -- forcing source build" "Yellow"
|
||||
$NeedLlamaSourceBuild = $true
|
||||
$SkipPrebuiltInstall = $true
|
||||
}
|
||||
|
||||
if (-not $LlamaPr -and $LlamaPrForce -and $LlamaPrForce -match '^\\d+$' -and [int]$LlamaPrForce -gt 0) {
|
||||
$LlamaPr = $LlamaPrForce
|
||||
step "llama.cpp" "baked-in PR_FORCE=$LlamaPrForce" "Yellow"
|
||||
}
|
||||
|
||||
Write-Output "LLAMA_PR=$LlamaPr"
|
||||
Write-Output "LLAMA_SOURCE=$LlamaSource"
|
||||
Write-Output "NEED_SOURCE=$NeedLlamaSourceBuild"
|
||||
Write-Output "SKIP_PREBUILT=$SkipPrebuiltInstall"
|
||||
""")
|
||||
|
||||
def _run(
|
||||
self,
|
||||
default_pr_force: str = "",
|
||||
default_source: str = "https://github.com/ggml-org/llama.cpp",
|
||||
env: dict | None = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
script = self.FRAGMENT_TEMPLATE.replace(
|
||||
"%%DEFAULT_PR_FORCE%%",
|
||||
default_pr_force,
|
||||
).replace(
|
||||
"%%DEFAULT_SOURCE%%",
|
||||
default_source,
|
||||
)
|
||||
run_env = {}
|
||||
# Ensure env vars are unset by default
|
||||
run_env["UNSLOTH_LLAMA_PR"] = ""
|
||||
run_env["UNSLOTH_LLAMA_PR_FORCE"] = ""
|
||||
if env:
|
||||
run_env.update(env)
|
||||
return run_pwsh(script, env = run_env)
|
||||
|
||||
def test_baked_in_pr_force_promotes(self):
|
||||
r = self._run(default_pr_force = "12345")
|
||||
assert r.returncode == 0
|
||||
assert "LLAMA_PR=12345" in r.stdout
|
||||
assert "baked-in PR_FORCE=12345" in r.stdout
|
||||
|
||||
def test_env_pr_force_promotes(self):
|
||||
r = self._run(env = {"UNSLOTH_LLAMA_PR_FORCE": "999"})
|
||||
assert r.returncode == 0
|
||||
assert "LLAMA_PR=999" in r.stdout
|
||||
|
||||
def test_user_pr_overrides_pr_force(self):
|
||||
r = self._run(
|
||||
env = {
|
||||
"UNSLOTH_LLAMA_PR": "100",
|
||||
"UNSLOTH_LLAMA_PR_FORCE": "200",
|
||||
}
|
||||
)
|
||||
assert r.returncode == 0
|
||||
assert "LLAMA_PR=100" in r.stdout
|
||||
assert "baked-in PR_FORCE" not in r.stdout
|
||||
|
||||
def test_pr_force_zero_ignored(self):
|
||||
r = self._run(env = {"UNSLOTH_LLAMA_PR_FORCE": "0"})
|
||||
assert r.returncode == 0
|
||||
assert "LLAMA_PR=" in r.stdout
|
||||
assert "baked-in PR_FORCE" not in r.stdout
|
||||
|
||||
def test_pr_force_alpha_ignored(self):
|
||||
r = self._run(env = {"UNSLOTH_LLAMA_PR_FORCE": "abc"})
|
||||
assert r.returncode == 0
|
||||
assert "baked-in PR_FORCE" not in r.stdout
|
||||
|
||||
def test_env_source_override_is_ignored(self):
|
||||
r = self._run(
|
||||
env = {
|
||||
"UNSLOTH_LLAMA_SOURCE": "https://github.com/unslothai/llama.cpp",
|
||||
}
|
||||
)
|
||||
assert r.returncode == 0
|
||||
assert "LLAMA_SOURCE=https://github.com/ggml-org/llama.cpp" in r.stdout
|
||||
assert "NEED_SOURCE=False" in r.stdout
|
||||
assert "SKIP_PREBUILT=False" in r.stdout
|
||||
|
||||
def test_default_source_no_force(self):
|
||||
r = self._run()
|
||||
assert r.returncode == 0
|
||||
assert "NEED_SOURCE=False" in r.stdout
|
||||
assert "SKIP_PREBUILT=False" in r.stdout
|
||||
|
||||
def test_trailing_git_override_is_ignored(self):
|
||||
r = self._run(
|
||||
env = {
|
||||
"UNSLOTH_LLAMA_SOURCE": "https://github.com/unslothai/llama.cpp.git",
|
||||
}
|
||||
)
|
||||
assert r.returncode == 0
|
||||
assert "LLAMA_SOURCE=https://github.com/ggml-org/llama.cpp" in r.stdout
|
||||
|
||||
def test_baked_in_source_stays_mainline(self):
|
||||
r = self._run(default_source = "https://github.com/ggml-org/llama.cpp")
|
||||
assert r.returncode == 0
|
||||
assert "LLAMA_SOURCE=https://github.com/ggml-org/llama.cpp" in r.stdout
|
||||
|
|
@ -6,7 +6,7 @@ Tests cover:
|
|||
- Bug 2: Source-build fallback ignores pinned tag (both .sh and .ps1)
|
||||
- Bug 3: Unix fallback deletes install before checking prerequisites
|
||||
- Bug 4: Linux LD_LIBRARY_PATH missing build/bin
|
||||
- "latest" tag resolution fallback chain (Unsloth -> ggml-org -> raw)
|
||||
- "latest" tag resolution fallback chain (helper only)
|
||||
- Cross-platform binary_env (Linux, macOS, Windows)
|
||||
- Edge cases: malformed JSON, empty responses, env overrides
|
||||
|
||||
|
|
@ -14,13 +14,11 @@ Run: pytest tests/studio/install/test_pr4562_bugfixes.py -v
|
|||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -40,6 +38,10 @@ SPEC.loader.exec_module(MOD)
|
|||
binary_env = MOD.binary_env
|
||||
HostInfo = MOD.HostInfo
|
||||
resolve_requested_llama_tag = MOD.resolve_requested_llama_tag
|
||||
PublishedReleaseBundle = MOD.PublishedReleaseBundle
|
||||
ApprovedArtifactHash = MOD.ApprovedArtifactHash
|
||||
ApprovedReleaseChecksums = MOD.ApprovedReleaseChecksums
|
||||
source_archive_logical_name = MOD.source_archive_logical_name
|
||||
|
||||
SETUP_SH = PACKAGE_ROOT / "studio" / "setup.sh"
|
||||
SETUP_PS1 = PACKAGE_ROOT / "studio" / "setup.ps1"
|
||||
|
|
@ -82,6 +84,9 @@ def run_bash(script: str, *, timeout: int = 10, env: dict | None = None) -> str:
|
|||
timeout = timeout,
|
||||
env = run_env,
|
||||
)
|
||||
assert (
|
||||
result.returncode == 0
|
||||
), f"bash script failed (exit {result.returncode}):\n{result.stderr}"
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
|
|
@ -240,6 +245,107 @@ class TestResolveRequestedLlamaTag:
|
|||
monkeypatch.setattr(MOD, "latest_upstream_release_tag", lambda: "b5555")
|
||||
assert resolve_requested_llama_tag("") == "b5555"
|
||||
|
||||
def test_latest_with_published_repo_uses_latest_valid_published_release(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
invalid = PublishedReleaseBundle(
|
||||
repo = "unslothai/llama.cpp",
|
||||
release_tag = "v2.0",
|
||||
upstream_tag = "b9000",
|
||||
assets = {},
|
||||
manifest_asset_name = "llama-prebuilt-manifest.json",
|
||||
artifacts = [],
|
||||
selection_log = [],
|
||||
)
|
||||
valid = PublishedReleaseBundle(
|
||||
repo = "unslothai/llama.cpp",
|
||||
release_tag = "v1.0",
|
||||
upstream_tag = "b8999",
|
||||
assets = {},
|
||||
manifest_asset_name = "llama-prebuilt-manifest.json",
|
||||
artifacts = [],
|
||||
selection_log = [],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
MOD,
|
||||
"iter_published_release_bundles",
|
||||
lambda repo, published_release_tag = "": iter([invalid, valid]),
|
||||
)
|
||||
|
||||
def fake_load(repo, release_tag):
|
||||
if release_tag == "v2.0":
|
||||
raise MOD.PrebuiltFallback("checksum asset missing")
|
||||
return ApprovedReleaseChecksums(
|
||||
repo = repo,
|
||||
release_tag = release_tag,
|
||||
upstream_tag = "b8999",
|
||||
source_commit = None,
|
||||
artifacts = {
|
||||
source_archive_logical_name("b8999"): ApprovedArtifactHash(
|
||||
asset_name = source_archive_logical_name("b8999"),
|
||||
sha256 = "a" * 64,
|
||||
repo = "ggml-org/llama.cpp",
|
||||
kind = "upstream-source",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(MOD, "load_approved_release_checksums", fake_load)
|
||||
monkeypatch.setattr(MOD, "latest_upstream_release_tag", lambda: "b7777")
|
||||
|
||||
assert resolve_requested_llama_tag("latest", "unslothai/llama.cpp") == "b8999"
|
||||
|
||||
def test_latest_with_published_release_tag_passes_pin_through(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
captured = {}
|
||||
|
||||
def fake_resolve(requested_tag, published_repo, published_release_tag = ""):
|
||||
captured["requested_tag"] = requested_tag
|
||||
captured["published_repo"] = published_repo
|
||||
captured["published_release_tag"] = published_release_tag
|
||||
return MOD.ResolvedPublishedRelease(
|
||||
bundle = PublishedReleaseBundle(
|
||||
repo = published_repo,
|
||||
release_tag = published_release_tag,
|
||||
upstream_tag = "b9001",
|
||||
assets = {},
|
||||
manifest_asset_name = "llama-prebuilt-manifest.json",
|
||||
artifacts = [],
|
||||
selection_log = [],
|
||||
),
|
||||
checksums = ApprovedReleaseChecksums(
|
||||
repo = published_repo,
|
||||
release_tag = published_release_tag,
|
||||
upstream_tag = "b9001",
|
||||
artifacts = {
|
||||
source_archive_logical_name("b9001"): ApprovedArtifactHash(
|
||||
asset_name = source_archive_logical_name("b9001"),
|
||||
sha256 = "a" * 64,
|
||||
repo = "ggml-org/llama.cpp",
|
||||
kind = "upstream-source",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(MOD, "resolve_published_release", fake_resolve)
|
||||
|
||||
assert (
|
||||
resolve_requested_llama_tag(
|
||||
"latest",
|
||||
"unslothai/llama.cpp",
|
||||
"llama-prebuilt-main",
|
||||
)
|
||||
== "b9001"
|
||||
)
|
||||
assert captured == {
|
||||
"requested_tag": "latest",
|
||||
"published_repo": "unslothai/llama.cpp",
|
||||
"published_release_tag": "llama-prebuilt-main",
|
||||
}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# TEST GROUP C: setup.sh logic (bash subprocess tests)
|
||||
|
|
@ -429,140 +535,61 @@ class TestSetupShLogic:
|
|||
# TEST GROUP D: "latest" tag resolution (bash subprocess)
|
||||
# =========================================================================
|
||||
class TestLatestTagResolution:
|
||||
"""Test the fallback chain: Unsloth API -> ggml-org API -> raw."""
|
||||
"""Test the fallback chain: helper resolver -> raw."""
|
||||
|
||||
RESOLVE_TEMPLATE = textwrap.dedent("""\
|
||||
export PATH="{mock_bin}:$PATH"
|
||||
_REQUESTED_LLAMA_TAG="{requested_tag}"
|
||||
_RESOLVED_LLAMA_TAG=""
|
||||
_RESOLVE_UPSTREAM_STATUS=1
|
||||
_HELPER_RELEASE_REPO="unslothai/llama.cpp"
|
||||
if [ "$_RESOLVE_UPSTREAM_STATUS" -ne 0 ] || [ -z "$_RESOLVED_LLAMA_TAG" ]; then
|
||||
if [ "$_REQUESTED_LLAMA_TAG" = "latest" ]; then
|
||||
_RESOLVED_LLAMA_TAG="$(curl -fsSL "https://api.github.com/repos/${{_HELPER_RELEASE_REPO}}/releases/latest" 2>/dev/null | python -c "import sys,json; print(json.load(sys.stdin)['tag_name'])" 2>/dev/null)" || _RESOLVED_LLAMA_TAG=""
|
||||
if [ -z "$_RESOLVED_LLAMA_TAG" ]; then
|
||||
_RESOLVED_LLAMA_TAG="$(curl -fsSL https://api.github.com/repos/ggml-org/llama.cpp/releases/latest 2>/dev/null | python -c "import sys,json; print(json.load(sys.stdin)['tag_name'])" 2>/dev/null)" || _RESOLVED_LLAMA_TAG=""
|
||||
fi
|
||||
fi
|
||||
if [ -z "$_RESOLVED_LLAMA_TAG" ]; then
|
||||
_RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG"
|
||||
fi
|
||||
_RESOLVE_UPSTREAM_STATUS={resolve_status}
|
||||
if [ "$_RESOLVE_UPSTREAM_STATUS" -eq 0 ] && [ -n "{resolved_tag}" ]; then
|
||||
_RESOLVED_LLAMA_TAG="{resolved_tag}"
|
||||
else
|
||||
_RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG"
|
||||
fi
|
||||
echo "$_RESOLVED_LLAMA_TAG"
|
||||
""")
|
||||
|
||||
@staticmethod
|
||||
def _make_curl_mock(
|
||||
mock_bin: Path, unsloth_response: str | None, ggml_response: str | None
|
||||
):
|
||||
"""Create a curl mock that returns different responses per repo."""
|
||||
lines = ["#!/bin/bash"]
|
||||
if unsloth_response is not None:
|
||||
lines.append(
|
||||
f'if echo "$*" | grep -q "unslothai/llama.cpp"; then echo \'{unsloth_response}\'; exit 0; fi'
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
'if echo "$*" | grep -q "unslothai/llama.cpp"; then exit 1; fi'
|
||||
)
|
||||
if ggml_response is not None:
|
||||
lines.append(
|
||||
f'if echo "$*" | grep -q "ggml-org/llama.cpp"; then echo \'{ggml_response}\'; exit 0; fi'
|
||||
)
|
||||
else:
|
||||
lines.append('if echo "$*" | grep -q "ggml-org/llama.cpp"; then exit 1; fi')
|
||||
lines.append("exit 1")
|
||||
curl_path = mock_bin / "curl"
|
||||
curl_path.write_text("\n".join(lines) + "\n")
|
||||
curl_path.chmod(0o755)
|
||||
|
||||
def _run_resolve(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
requested_tag: str,
|
||||
unsloth_resp: str | None,
|
||||
ggml_resp: str | None,
|
||||
resolved_tag: str,
|
||||
resolve_status: int,
|
||||
) -> str:
|
||||
mock_bin = tmp_path / "mock_bin"
|
||||
mock_bin.mkdir(exist_ok = True)
|
||||
self._make_curl_mock(mock_bin, unsloth_resp, ggml_resp)
|
||||
script = self.RESOLVE_TEMPLATE.format(
|
||||
mock_bin = mock_bin, requested_tag = requested_tag
|
||||
requested_tag = requested_tag,
|
||||
resolved_tag = resolved_tag,
|
||||
resolve_status = resolve_status,
|
||||
)
|
||||
return run_bash(script)
|
||||
|
||||
def test_unsloth_succeeds(self, tmp_path: Path):
|
||||
def test_helper_resolution_succeeds(self, tmp_path: Path):
|
||||
output = self._run_resolve(
|
||||
tmp_path,
|
||||
"latest",
|
||||
unsloth_resp = '{"tag_name":"b8508"}',
|
||||
ggml_resp = '{"tag_name":"b9000"}',
|
||||
resolved_tag = "b8508",
|
||||
resolve_status = 0,
|
||||
)
|
||||
assert output == "b8508"
|
||||
|
||||
def test_unsloth_fails_ggml_succeeds(self, tmp_path: Path):
|
||||
def test_helper_resolution_falls_back_to_raw_requested_tag(self, tmp_path: Path):
|
||||
output = self._run_resolve(
|
||||
tmp_path,
|
||||
"latest",
|
||||
unsloth_resp = None,
|
||||
ggml_resp = '{"tag_name":"b9000"}',
|
||||
)
|
||||
assert output == "b9000"
|
||||
|
||||
def test_both_fail_raw_fallback(self, tmp_path: Path):
|
||||
output = self._run_resolve(
|
||||
tmp_path,
|
||||
"latest",
|
||||
unsloth_resp = None,
|
||||
ggml_resp = None,
|
||||
resolved_tag = "",
|
||||
resolve_status = 1,
|
||||
)
|
||||
assert output == "latest"
|
||||
|
||||
def test_concrete_tag_passes_through(self, tmp_path: Path):
|
||||
def test_concrete_tag_passes_through_when_helper_fails(self, tmp_path: Path):
|
||||
output = self._run_resolve(
|
||||
tmp_path,
|
||||
"b7777",
|
||||
unsloth_resp = '{"tag_name":"b8508"}',
|
||||
ggml_resp = '{"tag_name":"b9000"}',
|
||||
resolved_tag = "",
|
||||
resolve_status = 1,
|
||||
)
|
||||
assert output == "b7777"
|
||||
|
||||
def test_unsloth_malformed_json_falls_through(self, tmp_path: Path):
|
||||
output = self._run_resolve(
|
||||
tmp_path,
|
||||
"latest",
|
||||
unsloth_resp = '{"bad_key":"no_tag"}',
|
||||
ggml_resp = '{"tag_name":"b9001"}',
|
||||
)
|
||||
assert output == "b9001"
|
||||
|
||||
def test_both_malformed_json_raw_fallback(self, tmp_path: Path):
|
||||
output = self._run_resolve(
|
||||
tmp_path,
|
||||
"latest",
|
||||
unsloth_resp = '{"bad":"data"}',
|
||||
ggml_resp = '{"also":"bad"}',
|
||||
)
|
||||
assert output == "latest"
|
||||
|
||||
def test_unsloth_empty_body_falls_through(self, tmp_path: Path):
|
||||
output = self._run_resolve(
|
||||
tmp_path,
|
||||
"latest",
|
||||
unsloth_resp = "",
|
||||
ggml_resp = '{"tag_name":"b7000"}',
|
||||
)
|
||||
assert output == "b7000"
|
||||
|
||||
def test_unsloth_empty_tag_name_falls_through(self, tmp_path: Path):
|
||||
output = self._run_resolve(
|
||||
tmp_path,
|
||||
"latest",
|
||||
unsloth_resp = '{"tag_name":""}',
|
||||
ggml_resp = '{"tag_name":"b6000"}',
|
||||
)
|
||||
assert output == "b6000"
|
||||
|
||||
def test_env_override_unsloth_llama_tag(self):
|
||||
output = run_bash(
|
||||
'echo "${UNSLOTH_LLAMA_TAG:-latest}"',
|
||||
|
|
@ -593,10 +620,10 @@ class TestSourceCodePatterns:
|
|||
def test_setup_sh_no_rm_before_prereq_check(self):
|
||||
"""rm -rf must appear AFTER cmake/git checks, not before."""
|
||||
content = SETUP_SH.read_text()
|
||||
# Find the source-build block
|
||||
idx_else = content.find("# Check prerequisites")
|
||||
assert idx_else != -1
|
||||
block = content[idx_else:]
|
||||
# Anchor on the source-build cmake check block.
|
||||
idx_block = content.find("command -v cmake")
|
||||
assert idx_block != -1
|
||||
block = content[idx_block:]
|
||||
# rm -rf should appear after the cmake/git checks
|
||||
idx_cmake = block.find("command -v cmake")
|
||||
idx_git = block.find("command -v git")
|
||||
|
|
@ -605,28 +632,102 @@ class TestSourceCodePatterns:
|
|||
assert idx_rm > idx_git, "rm -rf should come after git check"
|
||||
|
||||
def test_setup_sh_clone_uses_branch_tag(self):
|
||||
"""git clone in source-build should use --branch via _CLONE_BRANCH_ARGS."""
|
||||
"""git clone in source-build should use --branch via the clone args array."""
|
||||
content = SETUP_SH.read_text()
|
||||
# The clone line should use _CLONE_BRANCH_ARGS (which conditionally includes --branch)
|
||||
assert "_CLONE_ARGS=(git clone --depth 1)" in content
|
||||
assert (
|
||||
"_CLONE_BRANCH_ARGS" in content
|
||||
), "Clone should use _CLONE_BRANCH_ARGS array"
|
||||
assert (
|
||||
'--branch "$_RESOLVED_LLAMA_TAG"' in content
|
||||
), "_CLONE_BRANCH_ARGS should be set to --branch $_RESOLVED_LLAMA_TAG"
|
||||
'_CLONE_ARGS+=(--branch "$_RESOLVED_SOURCE_REF")' in content
|
||||
), "_CLONE_ARGS should be extended with --branch $_RESOLVED_SOURCE_REF"
|
||||
# Verify the guard: --branch is only used when tag is not "latest"
|
||||
assert (
|
||||
'_RESOLVED_LLAMA_TAG" != "latest"' in content
|
||||
'_RESOLVED_SOURCE_REF" != "latest"' in content
|
||||
), "Should guard against literal 'latest' tag"
|
||||
|
||||
def test_setup_sh_latest_resolution_queries_unsloth_first(self):
|
||||
"""The Unsloth repo should be queried before ggml-org."""
|
||||
def test_setup_sh_source_build_uses_helper_resolution(self):
|
||||
"""Shell source fallback should consult the helper for repo/ref planning."""
|
||||
content = SETUP_SH.read_text()
|
||||
idx_unsloth = content.find("_HELPER_RELEASE_REPO}/releases/latest")
|
||||
idx_ggml = content.find("ggml-org/llama.cpp/releases/latest")
|
||||
assert idx_unsloth != -1, "Unsloth API query not found"
|
||||
assert idx_ggml != -1, "ggml-org API query not found"
|
||||
assert idx_unsloth < idx_ggml, "Unsloth should be queried before ggml-org"
|
||||
assert "--resolve-source-build" in content
|
||||
assert "--output-format json" in content
|
||||
assert "_RESOLVED_SOURCE_URL" in content
|
||||
assert "_RESOLVED_SOURCE_REF_KIND" in content
|
||||
assert "_RESOLVED_SOURCE_REF" in content
|
||||
|
||||
def test_setup_sh_latest_resolution_uses_helper_only(self):
|
||||
"""Shell fallback should rely on helper output, not raw GitHub API tag_name."""
|
||||
content = SETUP_SH.read_text()
|
||||
assert "--resolve-install-tag" in content
|
||||
assert "--resolve-llama-tag" in content
|
||||
assert 'tail -n 1 "$_RESOLVE_LLAMA_LOG"' not in content
|
||||
assert "json.load" in content
|
||||
assert "_HELPER_RELEASE_REPO}/releases/latest" not in content
|
||||
assert "ggml-org/llama.cpp/releases/latest" not in content
|
||||
|
||||
def test_setup_sh_macos_arm64_uses_metal_flags(self):
|
||||
"""Apple Silicon source builds should explicitly enable Metal like upstream."""
|
||||
content = SETUP_SH.read_text()
|
||||
assert "_IS_MACOS_ARM64=true" in content
|
||||
assert 'if [ "$_IS_MACOS_ARM64" = true ]; then' in content
|
||||
assert "-DGGML_METAL=ON" in content
|
||||
assert "-DGGML_METAL_EMBED_LIBRARY=ON" in content
|
||||
assert "-DGGML_METAL_USE_BF16=ON" in content
|
||||
assert "-DCMAKE_INSTALL_RPATH=@loader_path" in content
|
||||
assert "-DCMAKE_BUILD_WITH_INSTALL_RPATH=ON" in content
|
||||
|
||||
def test_setup_sh_macos_metal_configure_has_cpu_fallback(self):
|
||||
"""If Metal configure or build fails, setup should retry with CPU fallback."""
|
||||
content = SETUP_SH.read_text()
|
||||
assert "_TRY_METAL_CPU_FALLBACK=true" in content
|
||||
assert (
|
||||
'substep "Metal configure failed; retrying CPU build..." "$C_WARN"'
|
||||
in content
|
||||
)
|
||||
assert (
|
||||
'substep "Metal build failed; retrying CPU build..." "$C_WARN"' in content
|
||||
)
|
||||
assert 'run_quiet_no_exit "cmake llama.cpp (cpu fallback)"' in content
|
||||
assert "-DGGML_METAL=OFF" in content
|
||||
# _TRY_METAL_CPU_FALLBACK must be reset to false in both fallback branches
|
||||
# (1 init + 2 resets = at least 3 occurrences of =false)
|
||||
assert content.count("_TRY_METAL_CPU_FALLBACK=false") >= 3, (
|
||||
"_TRY_METAL_CPU_FALLBACK=false should appear at least 3 times "
|
||||
"(init + configure fallback + build fallback)"
|
||||
)
|
||||
|
||||
def test_macos_arm64_cpu_fallback_args_exclude_rpath(self):
|
||||
"""CPU fallback args must NOT contain Metal-only RPATH flags at runtime."""
|
||||
script = (
|
||||
'_IS_MACOS_ARM64=true\nNVCC_PATH=""\nGPU_BACKEND=""\n'
|
||||
+ _GPU_BACKEND_FRAGMENT
|
||||
)
|
||||
output = run_bash(script)
|
||||
fallback_line = next(
|
||||
line
|
||||
for line in output.splitlines()
|
||||
if line.startswith("CPU_FALLBACK_CMAKE_ARGS=")
|
||||
)
|
||||
assert "-DGGML_METAL=OFF" in fallback_line
|
||||
assert (
|
||||
"@loader_path" not in fallback_line
|
||||
), "CPU fallback args should not contain RPATH flags"
|
||||
assert (
|
||||
"-DCMAKE_BUILD_WITH_INSTALL_RPATH=ON" not in fallback_line
|
||||
), "CPU fallback args should not contain RPATH build flag"
|
||||
|
||||
def test_setup_sh_does_not_enable_metal_for_intel_macos(self):
|
||||
"""Intel macOS should stay on the existing non-Metal path in this patch."""
|
||||
content = SETUP_SH.read_text()
|
||||
assert 'if [ "$_IS_MACOS_ARM64" = true ]; then' in content
|
||||
assert (
|
||||
'Darwin" ] && { [ "$_HOST_MACHINE" = "arm64" ] || [ "$_HOST_MACHINE" = "aarch64" ]; }'
|
||||
in content
|
||||
)
|
||||
assert (
|
||||
"x86_64"
|
||||
not in content[
|
||||
content.find("-DGGML_METAL=ON") - 200 : content.find("-DGGML_METAL=ON")
|
||||
+ 200
|
||||
]
|
||||
)
|
||||
|
||||
def test_setup_ps1_uses_checkout_b(self):
|
||||
"""PS1 should use checkout -B, not checkout --force FETCH_HEAD."""
|
||||
|
|
@ -637,7 +738,7 @@ class TestSourceCodePatterns:
|
|||
def test_setup_ps1_clone_uses_branch_tag(self):
|
||||
"""PS1 clone should use --branch with the resolved tag."""
|
||||
content = SETUP_PS1.read_text()
|
||||
assert "--branch" in content and "$ResolvedLlamaTag" in content
|
||||
assert "--branch" in content and "$ResolvedSourceRef" in content
|
||||
# The old commented-out line should be gone
|
||||
assert "# git clone --depth 1 --branch" not in content
|
||||
|
||||
|
|
@ -658,14 +759,52 @@ class TestSourceCodePatterns:
|
|||
f"Found 'git pull' in llama.cpp build section at line {i+1}"
|
||||
)
|
||||
|
||||
def test_setup_ps1_latest_resolution_queries_unsloth_first(self):
|
||||
"""PS1 should query Unsloth repo before ggml-org."""
|
||||
def test_setup_ps1_latest_resolution_uses_helper_only(self):
|
||||
"""PS1 fallback should rely on helper output, not raw GitHub API tag_name."""
|
||||
content = SETUP_PS1.read_text()
|
||||
idx_unsloth = content.find("$HelperReleaseRepo/releases/latest")
|
||||
idx_ggml = content.find("ggml-org/llama.cpp/releases/latest")
|
||||
assert idx_unsloth != -1, "Unsloth API query not found in PS1"
|
||||
assert idx_ggml != -1, "ggml-org API query not found in PS1"
|
||||
assert idx_unsloth < idx_ggml, "Unsloth should be queried before ggml-org"
|
||||
assert "--resolve-install-tag" in content
|
||||
assert "--resolve-llama-tag" in content
|
||||
assert '--output-format", "json"' in content
|
||||
assert "ConvertFrom-Json" in content
|
||||
assert "$HelperReleaseRepo/releases/latest" not in content
|
||||
assert "ggml-org/llama.cpp/releases/latest" not in content
|
||||
|
||||
def test_setup_ps1_source_build_uses_helper_resolution(self):
|
||||
"""PS1 source fallback should consult the helper for repo/ref planning."""
|
||||
content = SETUP_PS1.read_text()
|
||||
assert "--resolve-source-build" in content
|
||||
assert '--output-format", "json"' in content
|
||||
assert "$ResolvedSourceUrl" in content
|
||||
assert "$ResolvedSourceRefKind" in content
|
||||
assert "$ResolvedSourceRef" in content
|
||||
|
||||
def test_setup_ps1_prebuilt_install_disables_native_error_abort(self):
|
||||
"""PS1 prebuilt install should not abort setup on helper stderr."""
|
||||
content = SETUP_PS1.read_text()
|
||||
install_idx = content.index("& python @prebuiltArgs 2>&1")
|
||||
block = content[max(0, install_idx - 800) : install_idx + 800]
|
||||
assert "$PSNativeCommandUseErrorActionPreference = $false" in block
|
||||
assert "$restoreNativeErrorPreference = $true" in block
|
||||
assert (
|
||||
"$PSNativeCommandUseErrorActionPreference = $previousNativeErrorPreference"
|
||||
in block
|
||||
)
|
||||
|
||||
def test_setup_ps1_helper_disables_error_action_abort(self):
|
||||
"""Helper resolution should suppress terminating NativeCommandError on PS 5.1."""
|
||||
content = SETUP_PS1.read_text()
|
||||
helper_idx = content.index("function Invoke-LlamaHelper")
|
||||
block = content[helper_idx : helper_idx + 1200]
|
||||
assert "$previousErrorActionPreference = $ErrorActionPreference" in block
|
||||
assert '$ErrorActionPreference = "Continue"' in block
|
||||
assert "$ErrorActionPreference = $previousErrorActionPreference" in block
|
||||
|
||||
def test_setup_ps1_uses_local_tempfile_helper(self):
|
||||
"""PS1 should not depend on New-TemporaryFile being available."""
|
||||
content = SETUP_PS1.read_text()
|
||||
assert "function New-UnslothTemporaryFile" in content
|
||||
assert "$resolveErrorLog = New-UnslothTemporaryFile" in content
|
||||
assert "$resolveErrorLog = New-TemporaryFile" not in content
|
||||
|
||||
def test_binary_env_linux_has_binary_parent(self):
|
||||
"""The Linux branch of binary_env should include binary_path.parent."""
|
||||
|
|
@ -685,3 +824,274 @@ class TestSourceCodePatterns:
|
|||
found = True
|
||||
break
|
||||
assert found, "binary_path.parent not found in Linux branch of binary_env"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# TEST GROUP F: macOS Metal build logic (bash subprocess tests)
|
||||
# =========================================================================
|
||||
|
||||
# Minimal bash fragment that mirrors setup.sh's GPU backend decision chain.
|
||||
# Variables _IS_MACOS_ARM64, NVCC_PATH, GPU_BACKEND are injected by tests.
|
||||
_GPU_BACKEND_FRAGMENT = textwrap.dedent("""\
|
||||
CMAKE_ARGS="-DLLAMA_BUILD_TESTS=OFF"
|
||||
_TRY_METAL_CPU_FALLBACK=false
|
||||
CPU_FALLBACK_CMAKE_ARGS="$CMAKE_ARGS"
|
||||
|
||||
_BUILD_DESC="building"
|
||||
if [ "$_IS_MACOS_ARM64" = true ]; then
|
||||
_BUILD_DESC="building (Metal)"
|
||||
CMAKE_ARGS="$CMAKE_ARGS -DGGML_METAL=ON -DGGML_METAL_EMBED_LIBRARY=ON -DGGML_METAL_USE_BF16=ON -DCMAKE_INSTALL_RPATH=@loader_path -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON"
|
||||
CPU_FALLBACK_CMAKE_ARGS="$CPU_FALLBACK_CMAKE_ARGS -DGGML_METAL=OFF"
|
||||
_TRY_METAL_CPU_FALLBACK=true
|
||||
elif [ -n "$NVCC_PATH" ]; then
|
||||
CMAKE_ARGS="$CMAKE_ARGS -DGGML_CUDA=ON"
|
||||
_BUILD_DESC="building (CUDA)"
|
||||
elif [ "$GPU_BACKEND" = "rocm" ]; then
|
||||
CMAKE_ARGS="$CMAKE_ARGS -DGGML_HIP=ON"
|
||||
_BUILD_DESC="building (ROCm)"
|
||||
else
|
||||
_BUILD_DESC="building (CPU)"
|
||||
fi
|
||||
|
||||
echo "CMAKE_ARGS=$CMAKE_ARGS"
|
||||
echo "CPU_FALLBACK_CMAKE_ARGS=$CPU_FALLBACK_CMAKE_ARGS"
|
||||
echo "BUILD_DESC=$_BUILD_DESC"
|
||||
echo "TRY_METAL_CPU_FALLBACK=$_TRY_METAL_CPU_FALLBACK"
|
||||
""")
|
||||
|
||||
|
||||
class TestMacOSMetalBuildLogic:
|
||||
"""Behavioral bash subprocess tests for the Metal GPU backend logic."""
|
||||
|
||||
def test_macos_arm64_cmake_args_contain_metal_flags(self):
|
||||
"""macOS arm64 should enable Metal, not CUDA."""
|
||||
script = (
|
||||
'_IS_MACOS_ARM64=true\nNVCC_PATH=""\nGPU_BACKEND=""\n'
|
||||
+ _GPU_BACKEND_FRAGMENT
|
||||
)
|
||||
output = run_bash(script)
|
||||
assert "-DGGML_METAL=ON" in output
|
||||
assert "-DGGML_CUDA=ON" not in output
|
||||
assert "BUILD_DESC=building (Metal)" in output
|
||||
|
||||
def test_intel_macos_no_metal_flags(self):
|
||||
"""Intel macOS (not arm64) should not get Metal flags."""
|
||||
script = (
|
||||
'_IS_MACOS_ARM64=false\nNVCC_PATH=""\nGPU_BACKEND=""\n'
|
||||
+ _GPU_BACKEND_FRAGMENT
|
||||
)
|
||||
output = run_bash(script)
|
||||
assert "-DGGML_METAL=ON" not in output
|
||||
assert "BUILD_DESC=building (CPU)" in output
|
||||
|
||||
def test_macos_arm64_metal_precedes_nvcc(self):
|
||||
"""Even with nvcc in PATH, macOS arm64 should use Metal, not CUDA."""
|
||||
script = (
|
||||
'_IS_MACOS_ARM64=true\nNVCC_PATH="/usr/local/cuda/bin/nvcc"\n'
|
||||
'GPU_BACKEND="cuda"\n' + _GPU_BACKEND_FRAGMENT
|
||||
)
|
||||
output = run_bash(script)
|
||||
assert "-DGGML_METAL=ON" in output
|
||||
assert "-DGGML_CUDA=ON" not in output
|
||||
assert "BUILD_DESC=building (Metal)" in output
|
||||
|
||||
def test_metal_cpu_fallback_triggers_on_cmake_failure(self, tmp_path: Path):
|
||||
"""When cmake fails on Metal, the fallback should retry with -DGGML_METAL=OFF."""
|
||||
mock_bin = tmp_path / "mock_bin"
|
||||
mock_bin.mkdir()
|
||||
calls_file = tmp_path / "cmake_calls.log"
|
||||
# cmake that logs args and fails on first call (Metal), succeeds on second (CPU fallback)
|
||||
cmake_script = mock_bin / "cmake"
|
||||
cmake_script.write_text(
|
||||
textwrap.dedent(f"""\
|
||||
#!/bin/bash
|
||||
echo "$*" >> "{calls_file}"
|
||||
COUNTER_FILE="{tmp_path}/cmake_counter"
|
||||
if [ ! -f "$COUNTER_FILE" ]; then
|
||||
echo 1 > "$COUNTER_FILE"
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
""")
|
||||
)
|
||||
cmake_script.chmod(0o755)
|
||||
|
||||
script = textwrap.dedent(f"""\
|
||||
export PATH="{mock_bin}:$PATH"
|
||||
_IS_MACOS_ARM64=true
|
||||
NVCC_PATH=""
|
||||
GPU_BACKEND=""
|
||||
CMAKE_ARGS="-DLLAMA_BUILD_TESTS=OFF"
|
||||
_TRY_METAL_CPU_FALLBACK=false
|
||||
CPU_FALLBACK_CMAKE_ARGS="$CMAKE_ARGS"
|
||||
|
||||
_BUILD_DESC="building"
|
||||
if [ "$_IS_MACOS_ARM64" = true ]; then
|
||||
_BUILD_DESC="building (Metal)"
|
||||
CMAKE_ARGS="$CMAKE_ARGS -DGGML_METAL=ON -DGGML_METAL_EMBED_LIBRARY=ON -DGGML_METAL_USE_BF16=ON -DCMAKE_INSTALL_RPATH=@loader_path -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON"
|
||||
CPU_FALLBACK_CMAKE_ARGS="$CPU_FALLBACK_CMAKE_ARGS -DGGML_METAL=OFF"
|
||||
_TRY_METAL_CPU_FALLBACK=true
|
||||
fi
|
||||
|
||||
BUILD_OK=true
|
||||
_BUILD_TMP="{tmp_path}/build_tmp"
|
||||
mkdir -p "$_BUILD_TMP"
|
||||
if ! cmake -S "$_BUILD_TMP" -B "$_BUILD_TMP/build" $CMAKE_ARGS; then
|
||||
if [ "$_TRY_METAL_CPU_FALLBACK" = true ]; then
|
||||
_TRY_METAL_CPU_FALLBACK=false
|
||||
echo "FALLBACK_TRIGGERED"
|
||||
rm -rf "$_BUILD_TMP/build"
|
||||
cmake -S "$_BUILD_TMP" -B "$_BUILD_TMP/build" $CPU_FALLBACK_CMAKE_ARGS || BUILD_OK=false
|
||||
if [ "$BUILD_OK" = true ]; then
|
||||
_BUILD_DESC="building (CPU fallback)"
|
||||
fi
|
||||
else
|
||||
BUILD_OK=false
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "BUILD_OK=$BUILD_OK"
|
||||
echo "BUILD_DESC=$_BUILD_DESC"
|
||||
echo "TRY_METAL_CPU_FALLBACK=$_TRY_METAL_CPU_FALLBACK"
|
||||
""")
|
||||
output = run_bash(script)
|
||||
assert "FALLBACK_TRIGGERED" in output
|
||||
assert "BUILD_OK=true" in output
|
||||
assert "BUILD_DESC=building (CPU fallback)" in output
|
||||
assert (
|
||||
"TRY_METAL_CPU_FALLBACK=false" in output
|
||||
), "Fallback flag should be reset to false after configure fallback"
|
||||
|
||||
# Verify cmake args: first call has Metal ON, second has Metal OFF
|
||||
calls = calls_file.read_text().splitlines()
|
||||
assert len(calls) >= 2, f"Expected >= 2 cmake calls, got {len(calls)}"
|
||||
assert (
|
||||
"-DGGML_METAL=ON" in calls[0]
|
||||
), f"First cmake call should have Metal ON: {calls[0]}"
|
||||
assert (
|
||||
"-DGGML_METAL=OFF" in calls[1]
|
||||
), f"Second cmake call should have Metal OFF: {calls[1]}"
|
||||
assert (
|
||||
"-DGGML_METAL=ON" not in calls[1]
|
||||
), f"Second cmake call should NOT have Metal ON: {calls[1]}"
|
||||
assert (
|
||||
"@loader_path" not in calls[1]
|
||||
), f"CPU fallback should not have RPATH: {calls[1]}"
|
||||
assert (
|
||||
"-DCMAKE_BUILD_WITH_INSTALL_RPATH=ON" not in calls[1]
|
||||
), f"CPU fallback should not have RPATH build flag: {calls[1]}"
|
||||
|
||||
def test_metal_build_failure_retries_cpu_fallback(self, tmp_path: Path):
|
||||
"""When cmake --build fails on Metal, the fallback should re-configure and rebuild with CPU."""
|
||||
mock_bin = tmp_path / "mock_bin"
|
||||
mock_bin.mkdir()
|
||||
calls_file = tmp_path / "cmake_calls.log"
|
||||
# cmake mock: configure always succeeds; first --build fails, rest succeed
|
||||
cmake_script = mock_bin / "cmake"
|
||||
cmake_script.write_text(
|
||||
textwrap.dedent(f"""\
|
||||
#!/bin/bash
|
||||
echo "$*" >> "{calls_file}"
|
||||
if [ "$1" = "--build" ]; then
|
||||
BUILD_COUNTER_FILE="{tmp_path}/build_counter"
|
||||
if [ ! -f "$BUILD_COUNTER_FILE" ]; then
|
||||
echo 1 > "$BUILD_COUNTER_FILE"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
""")
|
||||
)
|
||||
cmake_script.chmod(0o755)
|
||||
|
||||
script = textwrap.dedent(f"""\
|
||||
export PATH="{mock_bin}:$PATH"
|
||||
_IS_MACOS_ARM64=true
|
||||
NVCC_PATH=""
|
||||
GPU_BACKEND=""
|
||||
CMAKE_ARGS="-DLLAMA_BUILD_TESTS=OFF"
|
||||
_TRY_METAL_CPU_FALLBACK=false
|
||||
CPU_FALLBACK_CMAKE_ARGS="$CMAKE_ARGS"
|
||||
CMAKE_GENERATOR_ARGS=""
|
||||
NCPU=2
|
||||
|
||||
_BUILD_DESC="building"
|
||||
if [ "$_IS_MACOS_ARM64" = true ]; then
|
||||
_BUILD_DESC="building (Metal)"
|
||||
CMAKE_ARGS="$CMAKE_ARGS -DGGML_METAL=ON -DGGML_METAL_EMBED_LIBRARY=ON -DGGML_METAL_USE_BF16=ON -DCMAKE_INSTALL_RPATH=@loader_path -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON"
|
||||
CPU_FALLBACK_CMAKE_ARGS="$CPU_FALLBACK_CMAKE_ARGS -DGGML_METAL=OFF"
|
||||
_TRY_METAL_CPU_FALLBACK=true
|
||||
fi
|
||||
|
||||
BUILD_OK=true
|
||||
_BUILD_TMP="{tmp_path}/build_tmp"
|
||||
mkdir -p "$_BUILD_TMP"
|
||||
|
||||
# Configure (succeeds)
|
||||
if ! cmake $CMAKE_GENERATOR_ARGS -S "$_BUILD_TMP" -B "$_BUILD_TMP/build" $CMAKE_ARGS; then
|
||||
if [ "$_TRY_METAL_CPU_FALLBACK" = true ]; then
|
||||
_TRY_METAL_CPU_FALLBACK=false
|
||||
echo "CONFIGURE_FALLBACK"
|
||||
rm -rf "$_BUILD_TMP/build"
|
||||
cmake $CMAKE_GENERATOR_ARGS -S "$_BUILD_TMP" -B "$_BUILD_TMP/build" $CPU_FALLBACK_CMAKE_ARGS || BUILD_OK=false
|
||||
if [ "$BUILD_OK" = true ]; then
|
||||
_BUILD_DESC="building (CPU fallback)"
|
||||
fi
|
||||
else
|
||||
BUILD_OK=false
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build (first --build fails, triggers fallback)
|
||||
if [ "$BUILD_OK" = true ]; then
|
||||
if ! cmake --build "$_BUILD_TMP/build" --config Release --target llama-server -j"$NCPU"; then
|
||||
if [ "$_TRY_METAL_CPU_FALLBACK" = true ]; then
|
||||
_TRY_METAL_CPU_FALLBACK=false
|
||||
echo "BUILD_FALLBACK_TRIGGERED"
|
||||
rm -rf "$_BUILD_TMP/build"
|
||||
if cmake $CMAKE_GENERATOR_ARGS -S "$_BUILD_TMP" -B "$_BUILD_TMP/build" $CPU_FALLBACK_CMAKE_ARGS; then
|
||||
_BUILD_DESC="building (CPU fallback)"
|
||||
cmake --build "$_BUILD_TMP/build" --config Release --target llama-server -j"$NCPU" || BUILD_OK=false
|
||||
else
|
||||
BUILD_OK=false
|
||||
fi
|
||||
else
|
||||
BUILD_OK=false
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "BUILD_OK=$BUILD_OK"
|
||||
echo "BUILD_DESC=$_BUILD_DESC"
|
||||
echo "TRY_METAL_CPU_FALLBACK=$_TRY_METAL_CPU_FALLBACK"
|
||||
""")
|
||||
output = run_bash(script)
|
||||
assert "CONFIGURE_FALLBACK" not in output, "Configure should have succeeded"
|
||||
assert "BUILD_FALLBACK_TRIGGERED" in output
|
||||
assert "BUILD_OK=true" in output
|
||||
assert "BUILD_DESC=building (CPU fallback)" in output
|
||||
assert (
|
||||
"TRY_METAL_CPU_FALLBACK=false" in output
|
||||
), "Fallback flag should be reset to false after build fallback"
|
||||
|
||||
# Verify: configure with Metal ON, build fails, re-configure with Metal OFF, rebuild
|
||||
calls = calls_file.read_text().splitlines()
|
||||
assert len(calls) >= 4, f"Expected >= 4 cmake calls, got {len(calls)}: {calls}"
|
||||
# First call: configure with Metal ON
|
||||
assert "-DGGML_METAL=ON" in calls[0]
|
||||
# Second call: build (fails)
|
||||
assert "--build" in calls[1]
|
||||
# Third call: re-configure with Metal OFF and no RPATH flags
|
||||
assert "-DGGML_METAL=OFF" in calls[2]
|
||||
assert "-DGGML_METAL=ON" not in calls[2]
|
||||
assert (
|
||||
"@loader_path" not in calls[2]
|
||||
), f"CPU fallback should not have RPATH: {calls[2]}"
|
||||
assert (
|
||||
"-DCMAKE_BUILD_WITH_INSTALL_RPATH=ON" not in calls[2]
|
||||
), f"CPU fallback should not have RPATH build flag: {calls[2]}"
|
||||
assert (
|
||||
"-DLLAMA_BUILD_TESTS=OFF" in calls[2]
|
||||
), f"CPU fallback should preserve baseline flags: {calls[2]}"
|
||||
# Fourth call: rebuild (succeeds)
|
||||
assert "--build" in calls[3]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -863,6 +863,114 @@ DEFAULT_SYSTEM_MESSAGE["gemma-3n"] = None # No system message in Gemma-3n
|
|||
CHAT_TEMPLATES["gemma3n"] = (gemma3n_template, gemma3n_template_eos_token, False, gemma3n_ollama,)
|
||||
DEFAULT_SYSTEM_MESSAGE["gemma3n"] = None # No system message in Gemma-3n
|
||||
|
||||
# =========================================== Gemma-4
|
||||
# Gemma-4 uses <|turn>role\n...<turn|>\n format
|
||||
gemma4_template = \
|
||||
"""{%- if messages[0]['role'] == 'system' -%}
|
||||
{%- set first_user_prefix = messages[0]['content'] + '\n\n' -%}
|
||||
{%- set loop_messages = messages[1:] -%}
|
||||
{%- else -%}
|
||||
{%- set first_user_prefix = "" -%}
|
||||
{%- set loop_messages = messages -%}
|
||||
{%- endif -%}
|
||||
{%- for message in loop_messages -%}
|
||||
{%- if (message['role'] == 'user') != (loop.index0 % 2 == 0) -%}
|
||||
{{ raise_exception("Conversation roles must alternate user/assistant/user/assistant/...") }}
|
||||
{%- endif -%}
|
||||
{%- if (message['role'] == 'assistant') -%}
|
||||
{%- set role = "model" -%}
|
||||
{%- else -%}
|
||||
{%- set role = message['role'] -%}
|
||||
{%- endif -%}
|
||||
{{ '<|turn>' + role + '\n' + (first_user_prefix if loop.first else "") }}
|
||||
{%- if message['content'] is string -%}
|
||||
{{ message['content'] | trim }}
|
||||
{%- elif message['content'] is iterable -%}
|
||||
{%- for item in message['content'] -%}
|
||||
{%- if item['type'] == 'audio' -%}
|
||||
{{ '<|audio|>' }}
|
||||
{%- elif item['type'] == 'image' -%}
|
||||
{{ '<|image|>' }}
|
||||
{%- elif item['type'] == 'video' -%}
|
||||
{{ '<|video|>' }}
|
||||
{%- elif item['type'] == 'text' -%}
|
||||
{{ item['text'] | trim }}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{%- else -%}
|
||||
{{ raise_exception("Invalid content type") }}
|
||||
{%- endif -%}
|
||||
{{ '<turn|>\n' }}
|
||||
{%- endfor -%}
|
||||
{%- if add_generation_prompt -%}
|
||||
{{'<|turn>model\n'}}
|
||||
{%- endif -%}
|
||||
"""
|
||||
|
||||
try:
|
||||
gemma4_ollama = _ollama_template("gemma-4")
|
||||
except KeyError:
|
||||
gemma4_ollama = ""
|
||||
gemma4_template_eos_token = "<turn|>"
|
||||
CHAT_TEMPLATES["gemma-4"] = (gemma4_template, gemma4_template_eos_token, False, gemma4_ollama,)
|
||||
DEFAULT_SYSTEM_MESSAGE["gemma-4"] = None
|
||||
|
||||
CHAT_TEMPLATES["gemma4"] = (gemma4_template, gemma4_template_eos_token, False, gemma4_ollama,)
|
||||
DEFAULT_SYSTEM_MESSAGE["gemma4"] = None
|
||||
|
||||
# Gemma-4 with empty thought channel (required for larger models like 31B, 26B-A4B)
|
||||
# Injects <|channel>thought\n<channel|> at the start of each model response during training
|
||||
gemma4_thinking_template = \
|
||||
"""{%- if messages[0]['role'] == 'system' -%}
|
||||
{%- set first_user_prefix = messages[0]['content'] + '\n\n' -%}
|
||||
{%- set loop_messages = messages[1:] -%}
|
||||
{%- else -%}
|
||||
{%- set first_user_prefix = "" -%}
|
||||
{%- set loop_messages = messages -%}
|
||||
{%- endif -%}
|
||||
{%- for message in loop_messages -%}
|
||||
{%- if (message['role'] == 'user') != (loop.index0 % 2 == 0) -%}
|
||||
{{ raise_exception("Conversation roles must alternate user/assistant/user/assistant/...") }}
|
||||
{%- endif -%}
|
||||
{%- if (message['role'] == 'assistant') -%}
|
||||
{%- set role = "model" -%}
|
||||
{%- else -%}
|
||||
{%- set role = message['role'] -%}
|
||||
{%- endif -%}
|
||||
{{ '<|turn>' + role + '\n' + (first_user_prefix if loop.first else "") }}
|
||||
{%- if role == "model" -%}
|
||||
{{ '<|channel>thought\n<channel|>' }}
|
||||
{%- endif -%}
|
||||
{%- if message['content'] is string -%}
|
||||
{{ message['content'] | trim }}
|
||||
{%- elif message['content'] is iterable -%}
|
||||
{%- for item in message['content'] -%}
|
||||
{%- if item['type'] == 'audio' -%}
|
||||
{{ '<|audio|>' }}
|
||||
{%- elif item['type'] == 'image' -%}
|
||||
{{ '<|image|>' }}
|
||||
{%- elif item['type'] == 'video' -%}
|
||||
{{ '<|video|>' }}
|
||||
{%- elif item['type'] == 'text' -%}
|
||||
{{ item['text'] | trim }}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{%- else -%}
|
||||
{{ raise_exception("Invalid content type") }}
|
||||
{%- endif -%}
|
||||
{{ '<turn|>\n' }}
|
||||
{%- endfor -%}
|
||||
{%- if add_generation_prompt -%}
|
||||
{{'<|turn>model\n'}}
|
||||
{%- endif -%}
|
||||
"""
|
||||
|
||||
CHAT_TEMPLATES["gemma-4-thinking"] = (gemma4_thinking_template, gemma4_template_eos_token, False, gemma4_ollama,)
|
||||
DEFAULT_SYSTEM_MESSAGE["gemma-4-thinking"] = None
|
||||
|
||||
CHAT_TEMPLATES["gemma4-thinking"] = (gemma4_thinking_template, gemma4_template_eos_token, False, gemma4_ollama,)
|
||||
DEFAULT_SYSTEM_MESSAGE["gemma4-thinking"] = None
|
||||
|
||||
# =========================================== GPT-OSS
|
||||
# Obtained via
|
||||
# print(tokenizer.chat_template.replace("}\n", "####").replace("\n", "\\n").replace("####", "}\n"))
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
__version__ = "2026.3.18"
|
||||
__version__ = "2026.4.1"
|
||||
|
||||
__all__ = [
|
||||
"SUPPORTS_BFLOAT16",
|
||||
|
|
@ -64,7 +64,8 @@ __all__ = [
|
|||
"patch_compiled_autograd",
|
||||
"process_vision_info",
|
||||
"unsloth_compile_transformers",
|
||||
"prefer_flex_attn_if_supported",
|
||||
"determine_attention_implementation",
|
||||
"_set_attn_impl",
|
||||
"patch_fast_lora",
|
||||
"validate_loftq_config",
|
||||
"RaiseUninitialized",
|
||||
|
|
@ -222,44 +223,76 @@ def apply_unsloth_gradient_checkpointing(
|
|||
return use_gradient_checkpointing
|
||||
|
||||
|
||||
def prefer_flex_attn_if_supported(model_class, config):
|
||||
if os.environ.get("UNSLOTH_ENABLE_FLEX_ATTENTION", "1") == "0":
|
||||
return None
|
||||
try:
|
||||
from transformers.utils.import_utils import is_torch_flex_attn_available
|
||||
# Models that don't work with flex_attention:
|
||||
# GPT-OSS: left padding issues cause incorrect outputs.
|
||||
# Mllama: BlockMask Q_LEN!=KV_LEN ValueError on decode.
|
||||
# NemotronH: hybrid Mamba-2 + Transformer, raises NotImplementedError.
|
||||
# Gemma3N: timm vision wrappers don't support flex_attention.
|
||||
# ModernBERT: create_block_mask with _compile=True hits CUDA illegal memory
|
||||
# access on some GPU architectures (B200). Falls back to eager safely.
|
||||
_FLEX_EXCLUDED_MODELS = ("gpt_oss", "mllama", "nemotron_h", "modernbert")
|
||||
_EAGER_ONLY_PREFIXES = ("gemma3n",)
|
||||
|
||||
if not is_torch_flex_attn_available():
|
||||
return None
|
||||
if model_class is None or not getattr(
|
||||
model_class, "_supports_flex_attn", False
|
||||
):
|
||||
return None
|
||||
|
||||
attention_dropout = getattr(config, "attention_dropout", 0) or 0
|
||||
if attention_dropout > 0:
|
||||
return None
|
||||
# GPT-OSS, Mllama and Gemma3N use eager/sdpa attention during
|
||||
# inference since flex attention returns incorrect results or errors out.
|
||||
# GPT-OSS: left padding issues cause incorrect outputs.
|
||||
# Mllama: _update_causal_mask uses make_flex_block_causal_mask which
|
||||
# creates BlockMask with Q_LEN=KV_LEN=total_seq_len, but during
|
||||
# decode q_len=1, causing ValueError. Needs transformers update.
|
||||
# Gemma3N: timm vision wrappers (eg Gemma3nVisionConfig) do not
|
||||
# support flex_attention.
|
||||
# NemotronH: hybrid Mamba-2 + Transformer model that does not
|
||||
# support flex_attention (raises NotImplementedError from transformers).
|
||||
model_type = getattr(config, "model_type", "") if config else ""
|
||||
if model_type in ("gpt_oss", "mllama", "nemotron_h") or str(
|
||||
model_type
|
||||
).startswith("gemma3n"):
|
||||
return None
|
||||
if config is not None:
|
||||
setattr(config, "_attn_implementation", "flex_attention")
|
||||
if hasattr(config, "attn_implementation"):
|
||||
setattr(config, "attn_implementation", "flex_attention")
|
||||
return "flex_attention"
|
||||
except Exception:
|
||||
return None
|
||||
def _is_flex_excluded(model_type):
|
||||
return model_type in _FLEX_EXCLUDED_MODELS
|
||||
|
||||
|
||||
def _is_eager_only(model_type):
|
||||
return any(model_type.startswith(p) for p in _EAGER_ONLY_PREFIXES)
|
||||
|
||||
|
||||
def _set_attn_impl(config, impl):
|
||||
"""Helper function to set attention implementation on config and return it."""
|
||||
if config is not None:
|
||||
setattr(config, "_attn_implementation", impl)
|
||||
if hasattr(config, "attn_implementation"):
|
||||
setattr(config, "attn_implementation", impl)
|
||||
return impl
|
||||
|
||||
|
||||
def determine_attention_implementation(model_class, config):
|
||||
model_type = getattr(config, "model_type", "").lower()
|
||||
|
||||
# Eager-only models (e.g. gemma3n timm vision towers)
|
||||
if _is_eager_only(model_type):
|
||||
_set_attn_impl(config, "eager")
|
||||
return "eager"
|
||||
|
||||
# Flash Attention 2
|
||||
if HAS_FLASH_ATTENTION and model_class is not None:
|
||||
supports_fa2 = getattr(model_class, "_supports_flash_attn_2", False) or getattr(
|
||||
model_class, "_supports_flash_attn", False
|
||||
)
|
||||
if supports_fa2:
|
||||
_set_attn_impl(config, "flash_attention_2")
|
||||
return "flash_attention_2"
|
||||
|
||||
# Flex Attention
|
||||
if os.environ.get("UNSLOTH_ENABLE_FLEX_ATTENTION", "1") != "0":
|
||||
try:
|
||||
from transformers.utils.import_utils import is_torch_flex_attn_available
|
||||
|
||||
if (
|
||||
is_torch_flex_attn_available()
|
||||
and model_class is not None
|
||||
and getattr(model_class, "_supports_flex_attn", False)
|
||||
and not _is_flex_excluded(model_type)
|
||||
):
|
||||
attention_dropout = getattr(config, "attention_dropout", 0) or 0
|
||||
if attention_dropout == 0:
|
||||
_set_attn_impl(config, "flex_attention")
|
||||
return "flex_attention"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# SDPA
|
||||
if model_class is not None and getattr(model_class, "_supports_sdpa", False):
|
||||
_set_attn_impl(config, "sdpa")
|
||||
return "sdpa"
|
||||
|
||||
_set_attn_impl(config, "eager")
|
||||
return "eager"
|
||||
|
||||
|
||||
def _run_temporary_patches(phase):
|
||||
|
|
@ -504,6 +537,15 @@ try:
|
|||
except:
|
||||
pass
|
||||
|
||||
# Gemma4 It is strongly recommended to train Gemma4 models with the `eager`
|
||||
try:
|
||||
from transformers.models.gemma4.modeling_gemma4 import logger as gemma4_logger
|
||||
|
||||
gemma4_logger.addFilter(HideLoggingMessage("strongly recommended"))
|
||||
del gemma4_logger
|
||||
except:
|
||||
pass
|
||||
|
||||
# Xet Storage is enabled for this repo, but the 'hf_xet' package is not installed.
|
||||
try:
|
||||
from huggingface_hub.file_download import logger as hub_logger
|
||||
|
|
@ -765,7 +807,16 @@ model_architectures = [
|
|||
"falcon_h1",
|
||||
]
|
||||
|
||||
# Transformers 5.x uses class-level annotations with @strict, @auto_docstring,
|
||||
# and interval() in config classes. exec(inspect.getsource(...)) fails because
|
||||
# those symbols are not in scope. Skip the exec-based config patching for 5.x
|
||||
# since those configs already use rope_parameters (the v5 replacement for
|
||||
# rope_scaling).
|
||||
_skip_config_exec_patch = Version(transformers_version) >= Version("5.0.0")
|
||||
|
||||
for model_name in model_architectures:
|
||||
if _skip_config_exec_patch:
|
||||
break
|
||||
config_filepath = f"transformers.models.{model_name}.configuration_{model_name}"
|
||||
model_filepath = f"transformers.models.{model_name}.modeling_{model_name}"
|
||||
config_filename = f"{model_name.title().replace('_','')}Config" # qwen3 arch folder is qwen3_moe but config is Qwen3Config. Need to remove underscore(_) for now
|
||||
|
|
@ -799,9 +850,12 @@ for model_name in model_architectures:
|
|||
if Version(transformers_version) <= Version("4.42.4"):
|
||||
config = patch_mistral_nemo_config(config)
|
||||
|
||||
exec(config, globals())
|
||||
exec(f"import {config_filepath}", globals())
|
||||
exec(f"{config_filepath}.{config_filename} = {config_filename}", globals())
|
||||
try:
|
||||
exec(config, globals())
|
||||
exec(f"import {config_filepath}", globals())
|
||||
exec(f"{config_filepath}.{config_filename} = {config_filename}", globals())
|
||||
except Exception:
|
||||
continue
|
||||
# =============================================
|
||||
|
||||
# =============================================
|
||||
|
|
@ -1885,6 +1939,18 @@ def _unsloth_pre_compute_loss(self, model, inputs, *args, **kwargs):
|
|||
_has_ccm = _mod is not None and hasattr(_mod, "create_causal_mask_mapping")
|
||||
if _has_ccm and _inner.training:
|
||||
inputs["token_type_ids"] = torch.zeros_like(inputs["input_ids"])
|
||||
# Gemma4 uses mm_token_type_ids (not token_type_ids) for VLM masking
|
||||
if "mm_token_type_ids" not in inputs and "input_ids" in inputs:
|
||||
_inner = model
|
||||
for _attr in ("base_model", "model", "model"):
|
||||
_inner = getattr(_inner, _attr, _inner)
|
||||
if getattr(getattr(_inner, "config", None), "model_type", "") in ("gemma4",):
|
||||
import sys as _sys
|
||||
|
||||
_mod = _sys.modules.get(type(_inner).__module__)
|
||||
_has_ccm = _mod is not None and hasattr(_mod, "create_causal_mask_mapping")
|
||||
if _has_ccm and _inner.training:
|
||||
inputs["mm_token_type_ids"] = torch.zeros_like(inputs["input_ids"])
|
||||
|
||||
outputs = self._old_compute_loss(model, inputs, *args, **kwargs)
|
||||
return outputs
|
||||
|
|
|
|||
|
|
@ -357,6 +357,9 @@ def CohereAttention_fast_forward_inference(
|
|||
# cos, sin = self.rotary_emb(Vn, seq_len = kv_seq_len)
|
||||
# Qn, Kn = inplace_rope_embedding(Qn, Kn, cos, sin, position_ids)
|
||||
cos, sin = self.rotary_emb.get_cached(kv_seq_len, Qn.device.index)
|
||||
# Transformers 5.x: position_ids may be [batch, full_seq_len]; slice to last
|
||||
if position_ids.dim() >= 2 and position_ids.shape[-1] > 1:
|
||||
position_ids = position_ids[:, -1:]
|
||||
cos = cos[position_ids].unsqueeze(1)
|
||||
sin = sin[position_ids].unsqueeze(1)
|
||||
h = self.half_head_dim
|
||||
|
|
|
|||
|
|
@ -313,6 +313,9 @@ def FalconH1Attention_fast_forward_inference(
|
|||
# or else error
|
||||
self.rotary_emb.extend_rope_embedding(Vn, seq_len + 2)
|
||||
cos, sin = self.rotary_emb.get_cached(kv_seq_len, Qn.device.index)
|
||||
# Transformers 5.x: position_ids may be [batch, full_seq_len]; slice to last
|
||||
if position_ids.dim() >= 2 and position_ids.shape[-1] > 1:
|
||||
position_ids = position_ids[:, -1:]
|
||||
cos = cos[position_ids].unsqueeze(1)
|
||||
sin = sin[position_ids].unsqueeze(1)
|
||||
h = self.half_head_dim
|
||||
|
|
|
|||
|
|
@ -394,6 +394,9 @@ def Gemma2Attention_fast_forward_inference(
|
|||
# cos, sin = self.rotary_emb(Vn, seq_len = kv_seq_len)
|
||||
# Qn, Kn = inplace_rope_embedding(Qn, Kn, cos, sin, position_ids)
|
||||
cos, sin = self.rotary_emb.get_cached(kv_seq_len, Qn.device.index)
|
||||
# Transformers 5.x: position_ids may be [batch, full_seq_len]; slice to last
|
||||
if position_ids.dim() >= 2 and position_ids.shape[-1] > 1:
|
||||
position_ids = position_ids[:, -1:]
|
||||
cos = cos[position_ids].unsqueeze(1)
|
||||
sin = sin[position_ids].unsqueeze(1)
|
||||
h = self.half_head_dim
|
||||
|
|
|
|||
|
|
@ -355,6 +355,9 @@ def GraniteAttention_fast_forward_inference(
|
|||
# cos, sin = self.rotary_emb(Vn, seq_len = kv_seq_len)
|
||||
# Qn, Kn = inplace_rope_embedding(Qn, Kn, cos, sin, position_ids)
|
||||
cos, sin = position_embeddings
|
||||
# Transformers 5.x: position_ids may be [batch, full_seq_len]; slice to last
|
||||
if position_ids.dim() >= 2 and position_ids.shape[-1] > 1:
|
||||
position_ids = position_ids[:, -1:]
|
||||
cos, sin = cos[position_ids], sin[position_ids]
|
||||
h = self.half_head_dim
|
||||
|
||||
|
|
|
|||
|
|
@ -496,6 +496,10 @@ def LlamaAttention_fast_forward_inference(
|
|||
# ensure correct shape
|
||||
if position_ids.dim() == 1:
|
||||
position_ids = position_ids[:, None]
|
||||
# Transformers 5.x generate() accumulates position_ids as [batch, full_seq_len]
|
||||
# across decode steps. In single-token inference we only need the last position.
|
||||
if position_ids.shape[-1] > 1:
|
||||
position_ids = position_ids[:, -1:]
|
||||
position_ids = position_ids.to(Qn.device)
|
||||
|
||||
if rotary_seq_len is None:
|
||||
|
|
@ -2341,8 +2345,8 @@ class FastLlamaModel:
|
|||
model_function = MODEL_FOR_CAUSAL_LM_MAPPING[model_config.__class__]
|
||||
IS_FALCON_H1 = model_config.model_type.startswith("falcon_h1")
|
||||
|
||||
preferred_attn_impl = (
|
||||
prefer_flex_attn_if_supported(model_function, model_config) or "eager"
|
||||
preferred_attn_impl = determine_attention_implementation(
|
||||
model_function, model_config
|
||||
)
|
||||
|
||||
has_rope_scaling = False
|
||||
|
|
@ -2414,14 +2418,24 @@ class FastLlamaModel:
|
|||
|
||||
raise_handler = RaiseUninitialized()
|
||||
if num_labels is not None:
|
||||
# Transformers 5.x @strict config classes reject unexpected kwargs
|
||||
# like num_labels and max_position_embeddings. Set on the config
|
||||
# object directly and pass config= instead.
|
||||
model_config.num_labels = num_labels
|
||||
if max_position_embeddings is not None:
|
||||
model_config.max_position_embeddings = max_position_embeddings
|
||||
# Pop config-level attrs that would be rejected by @strict model init
|
||||
for _cfg_key in ("id2label", "label2id", "rope_scaling"):
|
||||
_cfg_val = kwargs.pop(_cfg_key, None)
|
||||
if _cfg_val is not None:
|
||||
setattr(model_config, _cfg_key, _cfg_val)
|
||||
model = AutoModelForSequenceClassification.from_pretrained(
|
||||
model_name,
|
||||
config = model_config,
|
||||
device_map = device_map,
|
||||
# torch_dtype = dtype, # transformers changed torch_dtype to dtype
|
||||
num_labels = num_labels,
|
||||
# quantization_config = bnb_config,
|
||||
token = token,
|
||||
max_position_embeddings = max_position_embeddings,
|
||||
trust_remote_code = trust_remote_code,
|
||||
attn_implementation = preferred_attn_impl,
|
||||
**kwargs,
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ SUPPORTS_QWEN3_MOE = transformers_version >= Version("4.50.3")
|
|||
SUPPORTS_FALCON_H1 = transformers_version >= Version("4.53.0")
|
||||
SUPPORTS_GEMMA3N = transformers_version >= Version("4.53.0")
|
||||
SUPPORTS_GPTOSS = transformers_version >= Version("4.55.0")
|
||||
SUPPORTS_GEMMA4 = transformers_version >= Version("5.5.0")
|
||||
# Transformers v5 meta-device loading corrupts non-persistent buffers (inv_freq).
|
||||
# See _fix_rope_inv_freq() below for details.
|
||||
_NEEDS_ROPE_FIX = transformers_version >= Version("5.0.0")
|
||||
|
|
@ -107,6 +108,8 @@ FORCE_FLOAT32 = [
|
|||
"gemma3n",
|
||||
"gpt_oss",
|
||||
"qwen3_5", # Qwen3.5 GDN layers produce NaN grad norms in float16 training
|
||||
"gemma4,", # Add comma bc gemma4 will match gemma4_text
|
||||
"gemma4_text",
|
||||
]
|
||||
|
||||
global DISABLE_COMPILE_MODEL_NAMES
|
||||
|
|
@ -1130,6 +1133,17 @@ class FastModel(FastBaseModel):
|
|||
raise RuntimeError(
|
||||
"Unsloth: Qwen 2.5 only works on transformers >= 4.49.0." + LATEST
|
||||
)
|
||||
# Gemma 4 must be before Gemma 3N and Gemma 3
|
||||
elif "gemma4" in model_types_all:
|
||||
if not SUPPORTS_GEMMA4:
|
||||
raise RuntimeError(
|
||||
"Unsloth: Gemma 4 requires transformers >= 5.5.0" + LATEST
|
||||
)
|
||||
os.environ["UNSLOTH_DISABLE_STATIC_GENERATION"] = "1"
|
||||
os.environ["UNSLOTH_HIGH_PRECISION_LAYERNORM"] = "1"
|
||||
# Disable flex_attention for Gemma-4: flex compile overhead is 2.7x slower
|
||||
# than SDPA. Our attention patch ensures Q/K/V dtype alignment for SDPA.
|
||||
os.environ["UNSLOTH_ENABLE_FLEX_ATTENTION"] = "0"
|
||||
# Gemma 3N must be before Gemma 3
|
||||
elif "gemma3n" in model_types_all:
|
||||
if transformers_version < Version("4.53.0"):
|
||||
|
|
@ -1407,8 +1421,14 @@ class FastModel(FastBaseModel):
|
|||
architectures = []
|
||||
is_vlm = any(x.endswith("ForConditionalGeneration") for x in architectures)
|
||||
is_vlm = is_vlm or hasattr(model_config, "vision_config")
|
||||
# If num_labels is set, use AutoModelForSequenceClassification
|
||||
_num_labels = kwargs.get("num_labels", None)
|
||||
if auto_model is None:
|
||||
if is_vlm:
|
||||
if _num_labels is not None:
|
||||
from transformers import AutoModelForSequenceClassification
|
||||
|
||||
auto_model = AutoModelForSequenceClassification
|
||||
elif is_vlm:
|
||||
# Check if the model's auto_map supports the VLM auto class.
|
||||
# Some VL models (e.g. Nemotron-VL) only register AutoModelForCausalLM
|
||||
# in their auto_map, not AutoModelForImageTextToText/AutoModelForVision2Seq.
|
||||
|
|
|
|||
|
|
@ -302,6 +302,9 @@ def Qwen3Attention_fast_forward_inference(
|
|||
# or else error
|
||||
self.rotary_emb.extend_rope_embedding(Vn, seq_len + 2)
|
||||
cos, sin = self.rotary_emb.get_cached(kv_seq_len, Qn.device.index)
|
||||
# Transformers 5.x: position_ids may be [batch, full_seq_len]; slice to last
|
||||
if position_ids.dim() >= 2 and position_ids.shape[-1] > 1:
|
||||
position_ids = position_ids[:, -1:]
|
||||
cos = cos[position_ids].unsqueeze(1)
|
||||
sin = sin[position_ids].unsqueeze(1)
|
||||
h = self.half_head_dim
|
||||
|
|
|
|||
|
|
@ -542,6 +542,37 @@ def grpo_trainer__generate_and_score_completions(function_name, function):
|
|||
|
||||
function = patched
|
||||
|
||||
# Transformers 5.x: Extend mm_token_type_ids for completion tokens (Qwen3VL M-RoPE).
|
||||
# TRL handles token_type_ids but not mm_token_type_ids.
|
||||
_tt_search = (
|
||||
'if "token_type_ids" in forward_kwargs:\n'
|
||||
' token_type_ids = forward_kwargs["token_type_ids"]\n'
|
||||
' forward_kwargs["token_type_ids"] = torch.cat(\n'
|
||||
" [token_type_ids, token_type_ids.new_zeros(completion_ids.shape)], dim=1\n"
|
||||
" )"
|
||||
)
|
||||
_tt_replace = (
|
||||
_tt_search + "\n"
|
||||
' if "mm_token_type_ids" in forward_kwargs:\n'
|
||||
' mm_tti = forward_kwargs["mm_token_type_ids"]\n'
|
||||
' forward_kwargs["mm_token_type_ids"] = torch.cat(\n'
|
||||
" [mm_tti, mm_tti.new_zeros(completion_ids.shape)], dim=1\n"
|
||||
" )"
|
||||
)
|
||||
function = function.replace(_tt_search, _tt_replace)
|
||||
|
||||
# Save mm_token_type_ids to output dict alongside token_type_ids
|
||||
_save_search = (
|
||||
'if "token_type_ids" in forward_kwargs:\n'
|
||||
' output["token_type_ids"] = forward_kwargs["token_type_ids"]'
|
||||
)
|
||||
_save_replace = (
|
||||
_save_search + "\n"
|
||||
' if "mm_token_type_ids" in forward_kwargs:\n'
|
||||
' output["mm_token_type_ids"] = forward_kwargs["mm_token_type_ids"]'
|
||||
)
|
||||
function = function.replace(_save_search, _save_replace)
|
||||
|
||||
return function
|
||||
|
||||
|
||||
|
|
@ -714,6 +745,9 @@ def grpo_trainer__get_per_token_logps_and_entropies(function_name, function):
|
|||
kwargs.get("pixel_attention_mask", None),
|
||||
kwargs.get("image_sizes", None),
|
||||
)
|
||||
# Transformers 5.x needs token_type_ids/mm_token_type_ids for some vision models
|
||||
token_type_ids = kwargs.get("token_type_ids", None)
|
||||
mm_token_type_ids = kwargs.get("mm_token_type_ids", None)
|
||||
|
||||
unwrapped_model = self.accelerator.unwrap_model(
|
||||
model, keep_fp32_wrapper = False
|
||||
|
|
@ -831,6 +865,10 @@ def grpo_trainer__get_per_token_logps_and_entropies(function_name, function):
|
|||
if logit_scale_divide is None:
|
||||
logit_scale_divide = 0
|
||||
|
||||
# Transformers 5.x needs token_type_ids/mm_token_type_ids for some vision models
|
||||
token_type_ids_chunks = chunk_optional(token_type_ids, B)
|
||||
mm_token_type_ids_chunks = chunk_optional(mm_token_type_ids, B)
|
||||
|
||||
zipped_inputs = zip(
|
||||
input_ids_chunks,
|
||||
attention_mask_chunks,
|
||||
|
|
@ -838,6 +876,8 @@ def grpo_trainer__get_per_token_logps_and_entropies(function_name, function):
|
|||
image_grid_thw_chunks,
|
||||
pixel_attention_mask_chunks,
|
||||
image_sizes_chunks,
|
||||
token_type_ids_chunks,
|
||||
mm_token_type_ids_chunks,
|
||||
)
|
||||
os.environ["UNSLOTH_RETURN_HIDDEN_STATES"] = "1"
|
||||
|
||||
|
|
@ -849,7 +889,16 @@ def grpo_trainer__get_per_token_logps_and_entropies(function_name, function):
|
|||
image_grid_thw_chunk,
|
||||
pixel_attention_mask_chunk,
|
||||
image_sizes_chunk,
|
||||
token_type_ids_chunk,
|
||||
mm_token_type_ids_chunk,
|
||||
) in zipped_inputs:
|
||||
_extra_vision_kwargs = {}
|
||||
if token_type_ids_chunk is not None:
|
||||
_extra_vision_kwargs["token_type_ids"] = token_type_ids_chunk
|
||||
if mm_token_type_ids_chunk is not None:
|
||||
_extra_vision_kwargs["mm_token_type_ids"] = (
|
||||
mm_token_type_ids_chunk
|
||||
)
|
||||
with torch.amp.autocast(
|
||||
device_type = "cuda", dtype = self._autocast_dtype
|
||||
):
|
||||
|
|
@ -861,6 +910,7 @@ def grpo_trainer__get_per_token_logps_and_entropies(function_name, function):
|
|||
image_grid_thw = image_grid_thw_chunk,
|
||||
pixel_attention_mask = pixel_attention_mask_chunk,
|
||||
image_sizes = image_sizes_chunk,
|
||||
**_extra_vision_kwargs,
|
||||
).logits
|
||||
|
||||
completion_input_ids_chunk = input_ids_chunk[
|
||||
|
|
@ -893,6 +943,7 @@ def grpo_trainer__get_per_token_logps_and_entropies(function_name, function):
|
|||
pixel_attention_mask = pixel_attention_mask_chunk,
|
||||
image_sizes = image_sizes_chunk,
|
||||
logits_to_keep = logits_to_keep + 1,
|
||||
**_extra_vision_kwargs,
|
||||
).logits
|
||||
|
||||
logits_chunk = logits_chunk[:, :-1, :]
|
||||
|
|
@ -993,6 +1044,9 @@ def grpo_trainer_compute_loss(function_name, function):
|
|||
inputs.get("pixel_attention_mask", None),
|
||||
inputs.get("image_sizes", None),
|
||||
)
|
||||
# Transformers 5.x needs token_type_ids/mm_token_type_ids for some vision models
|
||||
token_type_ids = inputs.get("token_type_ids", None)
|
||||
mm_token_type_ids = inputs.get("mm_token_type_ids", None)
|
||||
num_items_in_batch = inputs.get("num_items_in_batch", None)
|
||||
sampling_per_token_logps = inputs.get("sampling_per_token_logps", None)
|
||||
current_gradient_accumulation_steps = self.current_gradient_accumulation_steps
|
||||
|
|
@ -1136,6 +1190,8 @@ def grpo_trainer_compute_loss(function_name, function):
|
|||
current_gradient_accumulation_steps = current_gradient_accumulation_steps,
|
||||
num_processes = num_processes,
|
||||
sampling_per_token_logps = sampling_per_token_logps,
|
||||
token_type_ids = token_type_ids,
|
||||
mm_token_type_ids = mm_token_type_ids,
|
||||
)
|
||||
else:
|
||||
# to ensure backwards compatibility with trl 0.15.2 and maybe even 0.17
|
||||
|
|
@ -1154,6 +1210,8 @@ def grpo_trainer_compute_loss(function_name, function):
|
|||
logit_scale_multiply = logit_scale_multiply,
|
||||
logit_scale_divide = logit_scale_divide,
|
||||
attention_mask = attention_mask,
|
||||
token_type_ids = token_type_ids,
|
||||
mm_token_type_ids = mm_token_type_ids,
|
||||
)
|
||||
)
|
||||
if "train" in self._metrics:
|
||||
|
|
|
|||
|
|
@ -216,6 +216,10 @@ def unsloth_base_fast_generate(
|
|||
kwargs["pixel_values"] = kwargs["pixel_values"].to(dtype)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
kwargs["pixel_values_videos"] = kwargs["pixel_values_videos"].to(dtype)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Mixed precision autocast
|
||||
if os.environ.get("UNSLOTH_FORCE_FLOAT32", "0") == "1":
|
||||
|
|
@ -597,8 +601,6 @@ class FastBaseModel:
|
|||
custom_datatype = None
|
||||
correct_dtype = None
|
||||
|
||||
# Stop SDPA for some archs like Pixtral / Mistral3
|
||||
flex_attn_impl = None
|
||||
if auto_config is None:
|
||||
auto_config = AutoConfig.from_pretrained(
|
||||
model_name,
|
||||
|
|
@ -609,7 +611,14 @@ class FastBaseModel:
|
|||
model_class = auto_model._model_mapping[auto_config.__class__]
|
||||
except Exception:
|
||||
model_class = None
|
||||
flex_attn_impl = prefer_flex_attn_if_supported(model_class, auto_config)
|
||||
if model_class is None:
|
||||
# When model_class cannot be resolved (remote-code or unmapped
|
||||
# configs), preserve the old fallback of sdpa when supported.
|
||||
attn_impl = _set_attn_impl(
|
||||
auto_config, "sdpa" if supports_sdpa else "eager"
|
||||
)
|
||||
else:
|
||||
attn_impl = determine_attention_implementation(model_class, auto_config)
|
||||
|
||||
# Handle FP8 models: get_model_name has already redirected this to BF16 sibling if the model ships with
|
||||
# FP8 weights. We just need to update it here for sanity.
|
||||
|
|
@ -620,21 +629,15 @@ class FastBaseModel:
|
|||
except Exception:
|
||||
model_class = None
|
||||
|
||||
model_type = str(getattr(auto_config, "model_type", "")).lower()
|
||||
if model_type.startswith("gemma3n"):
|
||||
# Gemma3N variants initialize timm-based vision towers which do
|
||||
# not support flex_attention, so default to eager unless overridden.
|
||||
default_attn_impl = "eager"
|
||||
else:
|
||||
default_attn_impl = "flex_attention" if flex_attn_impl else "sdpa"
|
||||
if not ("attn_implementation" in kwargs):
|
||||
kwargs["attn_implementation"] = default_attn_impl
|
||||
kwargs["attn_implementation"] = attn_impl
|
||||
if not supports_sdpa and kwargs.get("attn_implementation") == "sdpa":
|
||||
if os.environ.get("UNSLOTH_ENABLE_FLEX_ATTENTION", "0") == "0":
|
||||
print(
|
||||
f"Unsloth: {model_type_arch.title()} does not support SDPA - switching to fast eager."
|
||||
)
|
||||
print(
|
||||
f"Unsloth: {model_type_arch.title()} does not support SDPA - switching to fast eager."
|
||||
)
|
||||
del kwargs["attn_implementation"]
|
||||
# Re-stamp config so it stays consistent with the actual impl
|
||||
_set_attn_impl(auto_config, "eager")
|
||||
|
||||
bnb_config = None
|
||||
user_quantization_config = kwargs.get("quantization_config", None)
|
||||
|
|
@ -788,6 +791,15 @@ class FastBaseModel:
|
|||
if not fast_inference:
|
||||
# Prevent load_in_fp8 from being forwarded into HF internal model loading
|
||||
load_in_fp8 = kwargs.pop("load_in_fp8", None)
|
||||
# Transformers 5.x @strict config classes reject unexpected kwargs.
|
||||
# Move config-level attributes onto the config object directly.
|
||||
_num_labels = kwargs.pop("num_labels", None)
|
||||
if _num_labels is not None:
|
||||
model_config.num_labels = _num_labels
|
||||
for _cfg_key in ("id2label", "label2id", "max_position_embeddings"):
|
||||
_cfg_val = kwargs.pop(_cfg_key, None)
|
||||
if _cfg_val is not None:
|
||||
setattr(model_config, _cfg_key, _cfg_val)
|
||||
model = auto_model.from_pretrained(
|
||||
model_name,
|
||||
config = model_config,
|
||||
|
|
@ -1021,6 +1033,15 @@ class FastBaseModel:
|
|||
f"Unsloth: Warning - VLM processor fallback returned None for model_type={model_type_arch}",
|
||||
file = sys.stderr,
|
||||
)
|
||||
# Backwards compat: if processor has no chat_template (e.g. old saves without
|
||||
# chat_template.jinja) but the inner tokenizer does, copy it to the processor.
|
||||
if (
|
||||
hasattr(tokenizer, "tokenizer")
|
||||
and getattr(tokenizer, "chat_template", None) is None
|
||||
and getattr(tokenizer.tokenizer, "chat_template", None) is not None
|
||||
):
|
||||
tokenizer.chat_template = tokenizer.tokenizer.chat_template
|
||||
|
||||
if hasattr(tokenizer, "tokenizer"):
|
||||
__tokenizer = tokenizer.tokenizer
|
||||
# Add padding side as well
|
||||
|
|
@ -1277,7 +1298,59 @@ class FastBaseModel:
|
|||
model,
|
||||
use_gradient_checkpointing = use_gradient_checkpointing,
|
||||
)
|
||||
# Gemma4 ClippableLinear wraps nn.Linear -- PEFT can't inject LoRA on it directly.
|
||||
# Monkey-patch PEFT to target the inner .linear child instead.
|
||||
_clippable_linear_cls = None
|
||||
try:
|
||||
from transformers.models.gemma4.modeling_gemma4 import (
|
||||
Gemma4ClippableLinear as _clippable_linear_cls,
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
if _clippable_linear_cls is not None:
|
||||
from peft.tuners.lora.model import LoraModel as _LoraModel
|
||||
|
||||
_original_car = _LoraModel._create_and_replace
|
||||
|
||||
def _patched_car(
|
||||
self,
|
||||
peft_config,
|
||||
adapter_name,
|
||||
target,
|
||||
target_name,
|
||||
parent,
|
||||
current_key = None,
|
||||
**kwargs,
|
||||
):
|
||||
if isinstance(target, _clippable_linear_cls):
|
||||
return _original_car(
|
||||
self,
|
||||
peft_config,
|
||||
adapter_name,
|
||||
target.linear,
|
||||
"linear",
|
||||
target,
|
||||
current_key = current_key,
|
||||
**kwargs,
|
||||
)
|
||||
return _original_car(
|
||||
self,
|
||||
peft_config,
|
||||
adapter_name,
|
||||
target,
|
||||
target_name,
|
||||
parent,
|
||||
current_key = current_key,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
_LoraModel._create_and_replace = _patched_car
|
||||
|
||||
model = _get_peft_model(model, lora_config)
|
||||
|
||||
# Restore original PEFT method
|
||||
if _clippable_linear_cls is not None:
|
||||
_LoraModel._create_and_replace = _original_car
|
||||
# Apply QAT + LoRA if specified
|
||||
if qat_scheme is not None:
|
||||
print("Unsloth: Applying QAT to mitigate quantization degradation")
|
||||
|
|
@ -1375,7 +1448,7 @@ class FastBaseModel:
|
|||
# after this point, so we intercept gradient_checkpointing_enable
|
||||
# to always force use_reentrant=True for Gemma3N.
|
||||
_model_type = getattr(getattr(model, "config", None), "model_type", "") or ""
|
||||
if "gemma3n" in _model_type.lower():
|
||||
if "gemma3n" in _model_type.lower() or "gemma4" in _model_type.lower():
|
||||
_original_gc_enable = model.gradient_checkpointing_enable
|
||||
|
||||
def _gc_enable_reentrant(**kwargs):
|
||||
|
|
|
|||
|
|
@ -1199,6 +1199,21 @@ TEMPLATE """{{- range $i, $_ := .Messages }}
|
|||
OLLAMA_TEMPLATES["gemma-3n"] = gemma3n_ollama
|
||||
OLLAMA_TEMPLATES["gemma3n"] = gemma3n_ollama
|
||||
|
||||
# =========================================== Gemma-4
|
||||
gemma4_ollama = '''
|
||||
FROM {__FILE_LOCATION__}
|
||||
TEMPLATE """{{- range $i, $_ := .Messages }}
|
||||
{{- $last := eq (len (slice $.Messages $i)) 1 }}
|
||||
<|turn>{{ .Role }}
|
||||
{{ .Content }}{{ if not $last }}<turn|>
|
||||
{{ end }}
|
||||
{{- end }}<turn|>
|
||||
<|turn>model
|
||||
"""
|
||||
'''
|
||||
OLLAMA_TEMPLATES["gemma-4"] = gemma4_ollama
|
||||
OLLAMA_TEMPLATES["gemma4"] = gemma4_ollama
|
||||
|
||||
# =========================================== GPT-OSS
|
||||
|
||||
# Ollama from https://ollama.com/library/gpt-oss:latest/blobs/fa6710a93d78
|
||||
|
|
@ -1961,6 +1976,16 @@ OLLAMA_TEMPLATE_TO_MODEL_MAPPER = {
|
|||
"google/medgemma-27b-text-it",
|
||||
"unsloth/medgemma-27b-text-it-bnb-4bit",
|
||||
),
|
||||
"gemma4": (
|
||||
"unsloth/gemma-4-E2B-it",
|
||||
"unsloth/gemma-4-E2B",
|
||||
"unsloth/gemma-4-E4B-it",
|
||||
"unsloth/gemma-4-E4B",
|
||||
"unsloth/gemma-4-31B-it",
|
||||
"unsloth/gemma-4-31B",
|
||||
"unsloth/gemma-4-26B-A4B-it",
|
||||
"unsloth/gemma-4-26B-A4B",
|
||||
),
|
||||
"gemma3n": (
|
||||
"unsloth/gemma-3n-E4B-it-unsloth-bnb-4bit",
|
||||
"unsloth/gemma-3n-E4B-it",
|
||||
|
|
|
|||
112
unsloth/save.py
112
unsloth/save.py
|
|
@ -2533,12 +2533,15 @@ def unsloth_convert_lora_to_ggml_and_push_to_hub(
|
|||
)
|
||||
print(f"The output file will be {output_file}")
|
||||
|
||||
command = f"python3 llama.cpp/convert-lora-to-ggml.py {lora_directory_push} {output_file} llama"
|
||||
|
||||
try:
|
||||
with subprocess.Popen(
|
||||
command,
|
||||
shell = True,
|
||||
[
|
||||
sys.executable,
|
||||
"llama.cpp/convert-lora-to-ggml.py",
|
||||
lora_directory_push,
|
||||
output_file,
|
||||
"llama",
|
||||
],
|
||||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.PIPE,
|
||||
bufsize = 1,
|
||||
|
|
@ -2550,7 +2553,7 @@ def unsloth_convert_lora_to_ggml_and_push_to_hub(
|
|||
print(line, end = "", flush = True)
|
||||
sp.wait()
|
||||
if sp.returncode != 0:
|
||||
raise subprocess.CalledProcessError(sp.returncode, command)
|
||||
raise subprocess.CalledProcessError(sp.returncode, sp.args)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error: Conversion failed with return code {e.returncode}")
|
||||
return
|
||||
|
|
@ -2612,12 +2615,15 @@ def unsloth_convert_lora_to_ggml_and_save_locally(
|
|||
)
|
||||
print(f"The output file will be {output_file}")
|
||||
|
||||
command = f"python3 llama.cpp/convert-lora-to-ggml.py {save_directory} {output_file} llama"
|
||||
|
||||
try:
|
||||
with subprocess.Popen(
|
||||
command,
|
||||
shell = True,
|
||||
[
|
||||
sys.executable,
|
||||
"llama.cpp/convert-lora-to-ggml.py",
|
||||
save_directory,
|
||||
output_file,
|
||||
"llama",
|
||||
],
|
||||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.PIPE,
|
||||
bufsize = 1,
|
||||
|
|
@ -2629,7 +2635,7 @@ def unsloth_convert_lora_to_ggml_and_save_locally(
|
|||
print(line, end = "", flush = True)
|
||||
sp.wait()
|
||||
if sp.returncode != 0:
|
||||
raise subprocess.CalledProcessError(sp.returncode, command)
|
||||
raise subprocess.CalledProcessError(sp.returncode, sp.args)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error: Conversion failed with return code {e.returncode}")
|
||||
return
|
||||
|
|
@ -2777,19 +2783,79 @@ def unsloth_generic_save(
|
|||
elif save_method == "merged_4bit_forced":
|
||||
save_method = "merged_4bit"
|
||||
|
||||
merge_and_overwrite_lora(
|
||||
get_model_name,
|
||||
model = model,
|
||||
tokenizer = tokenizer,
|
||||
save_directory = save_directory,
|
||||
push_to_hub = push_to_hub,
|
||||
private = private,
|
||||
token = token,
|
||||
save_method = save_method,
|
||||
output_dtype = None,
|
||||
low_disk_space_usage = True,
|
||||
use_temp_file = False,
|
||||
)
|
||||
# Full-finetuned models (no LoRA) cannot use merge_and_overwrite_lora
|
||||
# since there are no adapters to merge. Fall back to save_pretrained.
|
||||
# This mirrors the non-PeftModel handling in save_pretrained_torchao
|
||||
# and the GGUF save path.
|
||||
_is_peft = isinstance(model, PeftModel)
|
||||
if not _is_peft:
|
||||
if not is_main_process:
|
||||
return
|
||||
|
||||
# Honor merged_16bit by casting to the target dtype if needed
|
||||
_save_kwargs = dict(
|
||||
safe_serialization = safe_serialization,
|
||||
max_shard_size = max_shard_size,
|
||||
variant = variant,
|
||||
)
|
||||
if "16bit" in save_method:
|
||||
_target_dtype = (
|
||||
torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
|
||||
)
|
||||
_save_kwargs["state_dict"] = {
|
||||
k: v.to(dtype = _target_dtype) if v.is_floating_point() else v
|
||||
for k, v in model.state_dict().items()
|
||||
}
|
||||
|
||||
if push_to_hub:
|
||||
print(f"Unsloth: Pushing full fine-tuned model to '{save_directory}' ...")
|
||||
model.push_to_hub(
|
||||
repo_id = save_directory,
|
||||
token = token,
|
||||
private = private,
|
||||
commit_message = commit_message,
|
||||
create_pr = create_pr,
|
||||
revision = revision,
|
||||
commit_description = commit_description,
|
||||
tags = tags,
|
||||
**_save_kwargs,
|
||||
)
|
||||
if tokenizer is not None:
|
||||
old_padding_side = tokenizer.padding_side
|
||||
tokenizer.padding_side = "left"
|
||||
tokenizer.push_to_hub(
|
||||
save_directory,
|
||||
token = token,
|
||||
private = private,
|
||||
commit_message = commit_message,
|
||||
create_pr = create_pr,
|
||||
revision = revision,
|
||||
)
|
||||
tokenizer.padding_side = old_padding_side
|
||||
else:
|
||||
print(f"Unsloth: Saving full fine-tuned model to '{save_directory}' ...")
|
||||
model.save_pretrained(save_directory, **_save_kwargs)
|
||||
if tokenizer is not None:
|
||||
old_padding_side = tokenizer.padding_side
|
||||
tokenizer.padding_side = "left"
|
||||
tokenizer.save_pretrained(save_directory)
|
||||
tokenizer.padding_side = old_padding_side
|
||||
|
||||
print(f"Unsloth: Model saved successfully to '{save_directory}'")
|
||||
else:
|
||||
merge_and_overwrite_lora(
|
||||
get_model_name,
|
||||
model = model,
|
||||
tokenizer = tokenizer,
|
||||
save_directory = save_directory,
|
||||
push_to_hub = push_to_hub,
|
||||
private = private,
|
||||
token = token,
|
||||
save_method = save_method,
|
||||
output_dtype = None,
|
||||
low_disk_space_usage = True,
|
||||
use_temp_file = False,
|
||||
)
|
||||
|
||||
if push_to_hub and datasets:
|
||||
try:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue