Merge branch 'main' into fix/bnb-multidevice-inference-hooks

This commit is contained in:
Daniel Han 2026-04-16 08:39:22 -07:00 committed by GitHub
commit 705ef146da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1637 additions and 336 deletions

View file

@ -100,22 +100,115 @@ function Install-UnslothStudio {
Write-Host ""
# ── Helper: refresh PATH from registry (deduplicating entries) ──
# Merge order: venv Scripts (if active) > Machine > User > current $env:Path.
# Dedup compares both raw and expanded forms (%VAR% vs literal).
function Refresh-SessionPath {
$machine = [System.Environment]::GetEnvironmentVariable("Path", "Machine")
$user = [System.Environment]::GetEnvironmentVariable("Path", "User")
$merged = "$machine;$user;$env:Path"
$venvScripts = if ($env:VIRTUAL_ENV) { Join-Path $env:VIRTUAL_ENV "Scripts" } else { $null }
$sources = @()
if ($venvScripts) { $sources += $venvScripts }
$sources += @($machine, $user, $env:Path)
$merged = ($sources | Where-Object { $_ }) -join ";"
$seen = @{}
$unique = @()
$unique = New-Object System.Collections.Generic.List[string]
foreach ($p in $merged -split ";") {
$key = $p.TrimEnd("\").ToLowerInvariant()
if ($key -and -not $seen.ContainsKey($key)) {
$seen[$key] = $true
$unique += $p
$rawKey = $p.Trim().Trim('"').TrimEnd("\").ToLowerInvariant()
$expKey = [Environment]::ExpandEnvironmentVariables($p).Trim().Trim('"').TrimEnd("\").ToLowerInvariant()
if ($rawKey -and -not $seen.ContainsKey($rawKey) -and -not $seen.ContainsKey($expKey)) {
$seen[$rawKey] = $true
if ($expKey -and $expKey -ne $rawKey) { $seen[$expKey] = $true }
$unique.Add($p)
}
}
$env:Path = $unique -join ";"
}
# ── Helper: safely add a directory to the persistent User PATH ──
# Direct registry access preserves REG_EXPAND_SZ (avoids dotnet/runtime#1442).
# Append (default) keeps existing tools first; Prepend for must-win entries.
function Add-ToUserPath {
param(
[Parameter(Mandatory = $true)][string]$Directory,
[ValidateSet('Append','Prepend')]
[string]$Position = 'Append'
)
try {
$regKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment')
try {
$rawPath = $regKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
[string[]]$entries = if ($rawPath) { $rawPath -split ';' } else { @() } # string[] prevents scalar collapse
$normalDir = $Directory.Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
$expNormalDir = [Environment]::ExpandEnvironmentVariables($Directory).Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
$kept = New-Object System.Collections.Generic.List[string]
$matchIndices = New-Object System.Collections.Generic.List[int]
for ($i = 0; $i -lt $entries.Count; $i++) {
$stripped = $entries[$i].Trim().Trim('"')
$rawNorm = $stripped.TrimEnd('\').ToLowerInvariant()
$expNorm = [Environment]::ExpandEnvironmentVariables($stripped).TrimEnd('\').ToLowerInvariant()
$isMatch = ($rawNorm -and ($rawNorm -eq $normalDir -or $rawNorm -eq $expNormalDir)) -or
($expNorm -and ($expNorm -eq $normalDir -or $expNorm -eq $expNormalDir))
if ($isMatch) {
$matchIndices.Add($i)
continue
}
$kept.Add($entries[$i])
}
$alreadyPresent = $matchIndices.Count -gt 0
if ($alreadyPresent -and $Position -eq 'Append') { # Append: idempotent no-op
return $false
}
if ($alreadyPresent -and $Position -eq 'Prepend' -and # Prepend: no-op if already at front
$matchIndices.Count -eq 1 -and $matchIndices[0] -eq 0) {
return $false
}
# One-time backup under HKCU\Software\Unsloth\PathBackup
if ($rawPath) {
try {
$backupKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\Unsloth')
try {
$existingBackup = $backupKey.GetValue('PathBackup', $null)
if (-not $existingBackup) {
$backupKey.SetValue('PathBackup', $rawPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
}
} finally {
$backupKey.Close()
}
} catch { }
}
if (-not $rawPath) {
Write-Host "[WARN] User PATH is empty - initializing with $Directory" -ForegroundColor Yellow
}
$newPath = if ($rawPath) {
if ($Position -eq 'Prepend') {
(@($Directory) + $kept) -join ';'
} else {
($kept + @($Directory)) -join ';'
}
} else {
$Directory
}
if ($newPath -ceq $rawPath) { # no actual change
return $false
}
$regKey.SetValue('Path', $newPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
# Broadcast WM_SETTINGCHANGE via dummy env-var roundtrip.
# [NullString]::Value avoids PS 7.5+/.NET 9 $null-to-"" coercion.
try {
$d = "UnslothPathRefresh_$([guid]::NewGuid().ToString('N').Substring(0,8))"
[Environment]::SetEnvironmentVariable($d, '1', 'User')
[Environment]::SetEnvironmentVariable($d, [NullString]::Value, 'User')
} catch { }
return $true
} finally {
$regKey.Close()
}
} catch {
Write-Host "[WARN] Could not update User PATH: $($_.Exception.Message)" -ForegroundColor Yellow
return $false
}
}
function step {
param(
[Parameter(Mandatory = $true)][string]$Label,
@ -945,18 +1038,76 @@ shell.Run cmd, 0, False
New-StudioShortcuts -UnslothExePath $UnslothExe
# ── Add venv Scripts dir to User PATH so `unsloth studio` works from any terminal ──
$ScriptsDir = Join-Path $VenvDir "Scripts"
$UserPath = [System.Environment]::GetEnvironmentVariable("Path", "User")
if (-not $UserPath -or $UserPath -notlike "*$ScriptsDir*") {
if ($UserPath) {
[System.Environment]::SetEnvironmentVariable("Path", "$ScriptsDir;$UserPath", "User")
} else {
[System.Environment]::SetEnvironmentVariable("Path", "$ScriptsDir", "User")
# ── Expose `unsloth` via a shim dir containing only unsloth.exe ──
# We do NOT add the venv Scripts dir to PATH (it also holds python.exe
# and pip.exe, which would hijack the user's system interpreter).
# Hardlink preferred; falls back to copy if cross-volume or non-NTFS.
#
# Remove the legacy venv Scripts PATH entry that older installers wrote.
$LegacyScriptsDir = Join-Path $VenvDir "Scripts"
try {
$legacyKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment')
try {
$rawPath = $legacyKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
if ($rawPath) {
[string[]]$pathEntries = $rawPath -split ';'
$normalLegacy = $LegacyScriptsDir.Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
$expNormalLegacy = [Environment]::ExpandEnvironmentVariables($LegacyScriptsDir).Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
$filtered = @($pathEntries | Where-Object {
$stripped = $_.Trim().Trim('"')
$rawNorm = $stripped.TrimEnd('\').ToLowerInvariant()
$expNorm = [Environment]::ExpandEnvironmentVariables($stripped).TrimEnd('\').ToLowerInvariant()
($rawNorm -ne $normalLegacy -and $rawNorm -ne $expNormalLegacy) -and
($expNorm -ne $normalLegacy -and $expNorm -ne $expNormalLegacy)
})
$cleanedPath = $filtered -join ';'
if ($cleanedPath -ne $rawPath) {
$legacyKey.SetValue('Path', $cleanedPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
try {
$d = "UnslothPathRefresh_$([guid]::NewGuid().ToString('N').Substring(0,8))"
[Environment]::SetEnvironmentVariable($d, '1', 'User')
[Environment]::SetEnvironmentVariable($d, [NullString]::Value, 'User')
} catch { }
}
}
} finally {
$legacyKey.Close()
}
} catch { }
$ShimDir = Join-Path $StudioHome "bin"
New-Item -ItemType Directory -Force -Path $ShimDir | Out-Null
$ShimExe = Join-Path $ShimDir "unsloth.exe"
# try/catch: if unsloth.exe is locked (Studio running), keep the old shim.
$shimUpdated = $false
try {
if (Test-Path $ShimExe) { Remove-Item $ShimExe -Force -ErrorAction Stop }
try {
New-Item -ItemType HardLink -Path $ShimExe -Target $UnslothExe -ErrorAction Stop | Out-Null
} catch {
Copy-Item -Path $UnslothExe -Destination $ShimExe -Force -ErrorAction Stop # fallback: copy
}
$shimUpdated = $true
} catch {
if (Test-Path $ShimExe) {
Write-Host "[WARN] Could not refresh unsloth launcher at $ShimExe." -ForegroundColor Yellow
Write-Host " This usually means a running 'unsloth studio' process still holds the file open." -ForegroundColor Yellow
Write-Host " Close Studio and re-run the installer to pick up the latest launcher." -ForegroundColor Yellow
Write-Host " Continuing with the existing launcher." -ForegroundColor Yellow
} else {
Write-Host "[WARN] Could not create unsloth launcher at $ShimExe" -ForegroundColor Yellow
Write-Host " $($_.Exception.Message)" -ForegroundColor Yellow
Write-Host " Launch unsloth studio directly via '$UnslothExe' until the next successful install." -ForegroundColor Yellow
}
Refresh-SessionPath
step "path" "added unsloth to PATH"
}
# Only add to PATH when the launcher actually exists on disk.
$pathAdded = $false
if (Test-Path $ShimExe) {
$pathAdded = Add-ToUserPath -Directory $ShimDir -Position 'Prepend'
}
if ($shimUpdated -and $pathAdded) {
step "path" "added unsloth launcher to PATH"
}
Refresh-SessionPath # sync current session with registry
# Launch studio automatically in interactive terminals;
# in non-interactive environments (CI, Docker) just print instructions.

View file

@ -1703,6 +1703,28 @@ class LlamaCppBackend:
# Wait for llama-server to become healthy
if not self._wait_for_health(timeout = 600.0):
self._kill_process()
_gguf = gguf_path or ""
_is_ollama = (
".studio_links" in _gguf
or os.sep + "ollama_links" + os.sep in _gguf
or os.sep + ".cache" + os.sep + "ollama" + os.sep in _gguf
or (self._model_identifier or "").startswith("ollama/")
)
# Only show the Ollama-specific message when the server
# output indicates a GGUF compatibility issue, not for
# unrelated failures like OOM or missing binaries.
if _is_ollama:
_output = "\n".join(self._stdout_lines[-50:]).lower()
_gguf_compat_hints = (
"key not found",
"unknown model architecture",
"failed to load model",
)
if any(h in _output for h in _gguf_compat_hints):
raise RuntimeError(
"Some Ollama models do not work with llama.cpp. "
"Try a different model, or use this model directly through Ollama instead."
)
raise RuntimeError(
"llama-server failed to start. "
"Check that the GGUF file is valid and you have enough memory."

View file

@ -5,8 +5,11 @@
Model Management API routes
"""
import hashlib
import json
import os
import sys
import uuid
from pathlib import Path
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from typing import List, Optional
@ -411,6 +414,267 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]:
return found
def _ollama_links_dir(ollama_dir: Path) -> Optional[Path]:
"""Return a writable directory for Ollama ``.gguf`` symlinks.
Prefers ``<ollama_dir>/.studio_links/`` so the links sit next to the
blobs they point at. Falls back to a per-ollama-dir namespace under
Studio's own cache when the models directory is read-only (common
for system installs under ``/usr/share/ollama`` or ``/var/lib/ollama``)
so we still surface Ollama models in those environments.
"""
from utils.paths.storage_roots import cache_root
primary = ollama_dir / ".studio_links"
try:
primary.mkdir(exist_ok = True)
return primary
except OSError as e:
logger.debug(
"Ollama dir %s not writable for .studio_links (%s); "
"falling back to Studio cache",
ollama_dir,
e,
)
# Fallback: namespace by a hash of the ollama_dir so two different
# Ollama roots don't collide. This is a cache path, not a security
# boundary.
try:
digest = hashlib.sha256(str(ollama_dir.resolve()).encode()).hexdigest()[:12]
except OSError:
digest = "default"
fallback = cache_root() / "ollama_links" / digest
try:
fallback.mkdir(parents = True, exist_ok = True)
return fallback
except OSError as e:
logger.warning(
"Could not create Ollama symlink cache at %s: %s",
fallback,
e,
)
return None
def _scan_ollama_dir(
ollama_dir: Path, limit: Optional[int] = None
) -> List[LocalModelInfo]:
"""Scan an Ollama models directory for downloaded models.
Ollama stores models in a content-addressable layout::
<ollama_dir>/manifests/<host>/<namespace>/<model>/<tag>
<ollama_dir>/blobs/sha256-...
The default host is ``registry.ollama.ai`` with namespace
``library`` (official models), but users can pull from custom
namespaces (``mradermacher/llama3``) or entirely different hosts
(``hf.co/org/repo:tag``). We iterate all manifest files via
``rglob`` so every layout depth is discovered.
Each manifest is JSON with a ``layers`` array. The layer with
``mediaType == "application/vnd.ollama.image.model"`` contains the
GGUF weights. Vision models also have a projector layer
(``application/vnd.ollama.image.projector``). We read the config
layer to extract family/size info.
Since Ollama blobs lack a ``.gguf`` extension (which the GGUF
loading pipeline requires), we create ``.gguf``-named links
pointing at the blobs so the existing ``detect_gguf_model`` and
``llama-server -m`` paths work unchanged. Each model gets its
own subdirectory under the links dir (keyed by a short hash of
the manifest path) so that ``detect_mmproj_file`` only sees the
projector for *that* model. Links are created as symlinks when
possible, falling back to hardlinks (Windows without Developer
Mode) as a last resort. The link dir lives under
``<ollama_dir>/.studio_links/`` when writable, otherwise under
Studio's own cache directory.
"""
manifests_root = ollama_dir / "manifests"
if not manifests_root.is_dir():
return []
found: List[LocalModelInfo] = []
blobs_dir = ollama_dir / "blobs"
links_root = _ollama_links_dir(ollama_dir)
if links_root is None:
logger.warning(
"Skipping Ollama scan for %s: no writable location for .gguf links",
ollama_dir,
)
return []
def _make_link(link_dir: Path, link_name: str, target: Path) -> Optional[str]:
"""Create a .gguf-named link to an Ollama blob.
Tries symlink first, then hardlink (works on Windows without
Developer Mode when target is on the same filesystem). Skips
the model if neither works -- a full file copy of a multi-GB
GGUF inside a synchronous API request would block the backend.
Idempotent: skips recreation when a valid link already exists.
"""
link_dir.mkdir(parents = True, exist_ok = True)
link_path = link_dir / link_name
resolved = target.resolve()
# Skip if the link already points at the exact same blob.
# Only use samefile -- size-based checks can reuse stale links
# after `ollama pull` updates a tag to a same-sized blob.
try:
if link_path.exists() and os.path.samefile(str(link_path), str(resolved)):
return str(link_path)
except OSError as e:
logger.debug("Error checking existing link %s: %s", link_path, e)
tmp_path = link_dir / f".{link_name}.tmp-{uuid.uuid4().hex[:8]}"
try:
if tmp_path.is_symlink() or tmp_path.exists():
tmp_path.unlink()
try:
tmp_path.symlink_to(resolved)
except OSError:
try:
os.link(str(resolved), str(tmp_path))
except OSError:
logger.warning(
"Could not create link for Ollama blob %s "
"(symlinks and hardlinks both failed). "
"Skipping model to avoid blocking the API.",
target,
)
return None
os.replace(str(tmp_path), str(link_path))
return str(link_path)
except OSError as e:
logger.debug("Could not create Ollama link %s: %s", link_path, e)
try:
if tmp_path.is_symlink() or tmp_path.exists():
tmp_path.unlink()
except OSError as cleanup_err:
logger.debug(
"Could not clean up tmp path %s: %s", tmp_path, cleanup_err
)
return None
try:
for tag_file in manifests_root.rglob("*"):
if not tag_file.is_file():
continue
rel = tag_file.relative_to(manifests_root)
parts = rel.parts
if len(parts) < 3:
continue
host = parts[0]
repo_parts = list(parts[1:-1])
tag = parts[-1]
if (
host == "registry.ollama.ai"
and repo_parts
and repo_parts[0] == "library"
):
repo_name = "/".join(repo_parts[1:])
elif host == "registry.ollama.ai":
repo_name = "/".join(repo_parts)
else:
repo_name = "/".join([host] + repo_parts)
if not repo_name:
continue
display = f"{repo_name}:{tag}"
manifest_key = rel.as_posix()
stem_hash = hashlib.sha256(manifest_key.encode()).hexdigest()[:10]
try:
manifest = json.loads(tag_file.read_text())
except (json.JSONDecodeError, OSError) as e:
logger.debug(
"Skipping unreadable/invalid Ollama manifest %s: %s",
tag_file,
e,
)
continue
config_digest = manifest.get("config", {}).get("digest", "")
model_type = ""
file_type = ""
if config_digest and blobs_dir.is_dir():
config_blob = blobs_dir / config_digest.replace(":", "-")
if config_blob.is_file():
try:
cfg = json.loads(config_blob.read_text())
model_type = cfg.get("model_type", "")
file_type = cfg.get("file_type", "")
except (json.JSONDecodeError, OSError) as e:
logger.debug(
"Could not parse Ollama config blob %s: %s",
config_blob,
e,
)
model_link_dir = links_root / stem_hash
gguf_link_path: Optional[str] = None
quant = f"-{file_type}" if file_type else ""
safe_name = repo_name.replace("/", "-")
for layer in manifest.get("layers", []):
media = layer.get("mediaType", "")
digest = layer.get("digest", "")
if not digest:
continue
if media == "application/vnd.ollama.image.model":
candidate = blobs_dir / digest.replace(":", "-")
if candidate.is_file():
link_name = f"{safe_name}-{tag}{quant}.gguf"
gguf_link_path = _make_link(
model_link_dir, link_name, candidate
)
elif media == "application/vnd.ollama.image.projector":
candidate = blobs_dir / digest.replace(":", "-")
if candidate.is_file():
mmproj_name = f"{safe_name}-{tag}-mmproj.gguf"
_make_link(model_link_dir, mmproj_name, candidate)
if not gguf_link_path:
continue
suffix = ""
if model_type:
suffix += f" ({model_type}"
if file_type:
suffix += f" {file_type}"
suffix += ")"
try:
updated_at = tag_file.stat().st_mtime
except OSError:
updated_at = None
found.append(
LocalModelInfo(
id = gguf_link_path,
model_id = f"ollama/{repo_name}:{tag}",
display_name = display + suffix,
path = gguf_link_path,
source = "custom",
updated_at = updated_at,
),
)
if limit is not None and len(found) >= limit:
return found
except OSError as e:
logger.warning("Error scanning Ollama directory %s: %s", ollama_dir, e)
return found
@router.get("/local", response_model = LocalModelListResponse)
async def list_local_models(
models_dir: str = Query(
@ -493,11 +757,27 @@ async def list_local_models(
for folder in custom_folders:
folder_path = Path(folder["path"])
try:
custom_models = (
_scan_models_dir(folder_path, limit = _MAX_MODELS_PER_FOLDER)
+ _scan_hf_cache(folder_path)
+ _scan_lmstudio_dir(folder_path)
)[:_MAX_MODELS_PER_FOLDER]
# Ollama scanner creates .studio_links/ with .gguf symlinks.
# Filter those from the generic scanners to avoid duplicates
# and leaking internal paths into the UI.
_generic = [
m
for m in (
_scan_models_dir(folder_path, limit = _MAX_MODELS_PER_FOLDER)
+ _scan_hf_cache(folder_path)
+ _scan_lmstudio_dir(folder_path)
)
if not any(
p in (".studio_links", "ollama_links")
for p in Path(m.path).parts
)
]
custom_models = _generic
if len(custom_models) < _MAX_MODELS_PER_FOLDER:
custom_models += _scan_ollama_dir(
folder_path,
limit = _MAX_MODELS_PER_FOLDER - len(custom_models),
)
except OSError as e:
logger.warning("Skipping unreadable scan folder %s: %s", folder_path, e)
continue
@ -575,6 +855,57 @@ async def remove_scan_folder_endpoint(
return {"ok": True}
@router.get("/recommended-folders")
async def get_recommended_folders(
current_subject: str = Depends(get_current_subject),
):
"""Return well-known model directories that exist on this machine.
Lightweight alternative to ``browse-folders`` for showing quick-pick
chips without the overhead of enumerating a directory tree. Returns
paths that actually exist on disk (HF cache, LM Studio, Ollama,
``~/models``, etc.) so the frontend can offer them as one-click
"Recommended" shortcuts in the Custom Folders section.
"""
from utils.paths.storage_roots import lmstudio_model_dirs
folders: list[str] = []
seen: set[str] = set()
def _add(p: Optional[Path]) -> None:
if p is None:
return
try:
resolved = str(p.resolve())
except OSError:
return
if resolved in seen:
return
if Path(resolved).is_dir() and os.access(resolved, os.R_OK | os.X_OK):
seen.add(resolved)
folders.append(resolved)
# LM Studio model directories
try:
for p in lmstudio_model_dirs():
_add(p)
except Exception as e:
logger.warning("Failed to scan for LM Studio model directories: %s", e)
# Ollama model directories
ollama_env = os.environ.get("OLLAMA_MODELS")
if ollama_env:
_add(Path(ollama_env).expanduser())
for candidate in (
Path.home() / ".ollama" / "models",
Path("/usr/share/ollama/.ollama/models"),
Path("/var/lib/ollama/.ollama/models"),
):
_add(candidate)
return {"folders": folders}
# Heuristic ceiling on how many children to stat when checking whether a
# directory "looks like" it contains models. Keeps the browser snappy
# even when a directory has thousands of unrelated entries.

View file

@ -959,6 +959,20 @@ def detect_mmproj_file(path: str, search_root: Optional[str] = None) -> Optional
scan_order.append(resolved)
_add(start_dir)
# When ``path`` is a symlink (e.g. Ollama's ``.studio_links/...gguf``
# -> ``blobs/sha256-...``), the symlink's parent directory rarely
# contains the mmproj sibling; the real mmproj file lives next to
# the symlink target. Add the target's parent to the scan so vision
# GGUFs that are surfaced via symlinks are still recognised as
# vision models.
try:
if p.is_symlink() and p.is_file():
target_parent = p.resolve().parent
if target_parent.is_dir():
_add(target_parent)
except OSError:
pass
if search_root is not None:
try:
root_resolved = Path(search_root).resolve()
@ -1006,7 +1020,10 @@ def detect_gguf_model(path: str) -> Optional[str]:
if p.suffix.lower() == ".gguf" and p.is_file():
if _is_mmproj(p.name):
return None
return str(p.resolve())
# Use absolute (not resolve) to preserve symlink names -- e.g.
# Ollama .studio_links/model.gguf -> blobs/sha256-... should
# keep the readable symlink name, not the opaque blob hash.
return str(p.absolute())
# Case 2: directory containing .gguf files (skip mmproj)
if p.is_dir():

View file

@ -27,6 +27,7 @@ import {
listCachedModels,
listGgufVariants,
listLocalModels,
listRecommendedFolders,
listScanFolders,
removeScanFolder,
} from "@/features/chat/api/chat-api";
@ -49,7 +50,7 @@ import { checkVramFit, estimateLoadingVram } from "@/lib/vram";
import { Add01Icon, Cancel01Icon, Folder02Icon, Search01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { FolderBrowser } from "./folder-browser";
import { Trash2Icon } from "lucide-react";
import { ChevronDownIcon, ChevronRightIcon, DownloadIcon, StarIcon, Trash2Icon } from "lucide-react";
import {
type ReactNode,
useCallback,
@ -73,10 +74,35 @@ function normalizeForSearch(s: string): string {
return s.toLowerCase().replace(/[\s\-_\.]/g, "");
}
function ListLabel({ children }: { children: ReactNode }) {
function ListLabel({
children,
icon,
collapsed,
onToggle,
}: {
children: ReactNode;
icon?: ReactNode;
collapsed?: boolean;
onToggle?: () => void;
}) {
return (
<div className="px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{children}
<div className="flex items-center justify-between gap-1 px-2.5 py-1.5">
<span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{icon}
{children}
</span>
{onToggle && (
<button
type="button"
onClick={onToggle}
aria-label={collapsed ? "Expand section" : "Collapse section"}
className="shrink-0 rounded p-1 text-muted-foreground/60 transition-colors hover:text-foreground"
>
{collapsed
? <ChevronRightIcon className="size-3" />
: <ChevronDownIcon className="size-3" />}
</button>
)}
</div>
);
}
@ -489,6 +515,9 @@ export function HubModelPicker({
// Delete confirmation dialog state
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [downloadedCollapsed, setDownloadedCollapsed] = useState(false);
const [customFoldersCollapsed, setCustomFoldersCollapsed] = useState(false);
const [recommendedCollapsed, setRecommendedCollapsed] = useState(false);
// Cached (already downloaded) repos -- use module-level cache so
// re-mounting the popover does not flash an empty "Downloaded" section.
@ -514,6 +543,7 @@ export function HubModelPicker({
const [showFolderInput, setShowFolderInput] = useState(false);
const [folderLoading, setFolderLoading] = useState(false);
const [showFolderBrowser, setShowFolderBrowser] = useState(false);
const [recommendedFolders, setRecommendedFolders] = useState<string[]>([]);
const refreshLocalModelsList = useCallback(() => {
listLocalModels()
@ -616,6 +646,9 @@ export function HubModelPicker({
// Always refresh LM Studio + custom folder models (not gated by alreadyCached)
refreshLocalModelsList();
refreshScanFolders();
listRecommendedFolders()
.then(setRecommendedFolders)
.catch(() => {});
// Always refetch cached GGUF/model lists. The module-level caches give
// an instant render with stale data (no spinner flash), but newly
@ -893,8 +926,12 @@ export function HubModelPicker({
(cachedGguf.length > 0 ||
(!chatOnly && cachedModels.length > 0)) ? (
<>
<ListLabel>Downloaded</ListLabel>
{cachedGguf.map((c) => (
<ListLabel
icon={<DownloadIcon className="size-3" />}
collapsed={downloadedCollapsed}
onToggle={() => setDownloadedCollapsed((v) => !v)}
>Downloaded</ListLabel>
{!downloadedCollapsed && cachedGguf.map((c) => (
<div key={c.repo_id}>
<ModelRow
label={c.repo_id}
@ -922,7 +959,7 @@ export function HubModelPicker({
)}
</div>
))}
{!chatOnly &&
{!downloadedCollapsed && !chatOnly &&
cachedModels.map((c) => (
<div key={c.repo_id} className="flex items-center gap-0.5">
<div className="min-w-0 flex-1">
@ -1001,20 +1038,12 @@ export function HubModelPicker({
{!showHfSection ? (
<>
<div className="flex items-center justify-between gap-1 px-2.5 py-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<div className="flex items-center gap-1 px-2.5 py-1.5">
<span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<HugeiconsIcon icon={Folder02Icon} className="size-3" />
Custom Folders
</span>
<div className="flex items-center gap-0.5">
<button
type="button"
aria-label="Browse for a folder on the server"
title="Browse folders on the server"
onClick={() => setShowFolderBrowser(true)}
className="shrink-0 rounded p-1 text-muted-foreground/60 transition-colors hover:text-foreground"
>
<HugeiconsIcon icon={Search01Icon} className="size-3" />
</button>
<button
type="button"
aria-label={showFolderInput ? "Cancel adding folder" : "Add scan folder by path"}
@ -1029,11 +1058,33 @@ export function HubModelPicker({
>
<HugeiconsIcon icon={showFolderInput ? Cancel01Icon : Add01Icon} className="size-3" />
</button>
<button
type="button"
aria-label="Browse for a folder on the server"
title="Browse folders on the server"
onClick={() => setShowFolderBrowser(true)}
className="shrink-0 rounded p-0.5 text-muted-foreground/60 transition-colors hover:text-foreground"
>
<HugeiconsIcon icon={Search01Icon} className="size-2.5" />
</button>
</div>
<div className="ml-auto">
<button
type="button"
aria-label={customFoldersCollapsed ? "Expand custom folders" : "Collapse custom folders"}
title={customFoldersCollapsed ? "Expand" : "Collapse"}
onClick={() => setCustomFoldersCollapsed((v) => !v)}
className="shrink-0 rounded p-1 text-muted-foreground/60 transition-colors hover:text-foreground"
>
{customFoldersCollapsed
? <ChevronRightIcon className="size-3" />
: <ChevronDownIcon className="size-3" />}
</button>
</div>
</div>
{/* Folder paths */}
{scanFolders.map((f) => (
{!customFoldersCollapsed && scanFolders.map((f) => (
<div
key={f.id}
className="group flex items-center gap-1.5 px-2.5 py-0.5"
@ -1056,8 +1107,31 @@ export function HubModelPicker({
</div>
))}
{/* Recommended folders */}
{!customFoldersCollapsed && (() => {
const registered = new Set(scanFolders.map((f) => f.path));
const unregistered = recommendedFolders.filter((p) => !registered.has(p));
if (unregistered.length === 0) return null;
return (
<div className="flex flex-wrap gap-1 px-2.5 pb-0.5">
{unregistered.map((p) => (
<button
key={p}
type="button"
onClick={() => void handleAddFolder(p)}
disabled={folderLoading}
title={`Add ${p}`}
className="rounded-full border border-dashed border-border/50 px-2 py-0.5 font-mono text-[10px] text-muted-foreground/70 transition-colors hover:border-foreground/30 hover:bg-accent hover:text-foreground disabled:opacity-40"
>
<span className="text-[11px] font-semibold">+</span> {p.length > 30 ? `...${p.slice(-27)}` : p}
</button>
))}
</div>
);
})()}
{/* Add folder input */}
{showFolderInput && (
{!customFoldersCollapsed && 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" />
@ -1114,11 +1188,15 @@ export function HubModelPicker({
{/* Models from custom folders */}
{customFolderModels.map((m) => {
{!customFoldersCollapsed && customFolderModels.map((m) => {
const isGgufFile = m.path.toLowerCase().endsWith(".gguf");
const isGguf =
isGgufFile ||
isGgufRepo(m.id) ||
isGgufRepo(m.display_name) ||
m.path.toLowerCase().endsWith(".gguf");
isGgufRepo(m.display_name);
// Single .gguf files (e.g. Ollama blobs) load directly;
// GGUF repos/directories expand to pick a variant.
const isDirectGguf = isGgufFile;
return (
<div key={m.id}>
<ModelRow
@ -1126,7 +1204,13 @@ export function HubModelPicker({
meta={isGguf ? "GGUF" : "Local"}
selected={value === m.id}
onClick={() => {
if (isGguf) {
if (isDirectGguf) {
onSelect(m.id, {
source: "local",
isLora: false,
isDownloaded: true,
});
} else if (isGguf) {
setExpandedGguf((prev) =>
prev === m.id ? null : m.id,
);
@ -1158,8 +1242,12 @@ export function HubModelPicker({
{!showHfSection && cachedReady ? (
<>
<ListLabel>Recommended</ListLabel>
{visibleRecommendedIds.length === 0 ? (
<ListLabel
icon={<StarIcon className="size-3" />}
collapsed={recommendedCollapsed}
onToggle={() => setRecommendedCollapsed((v) => !v)}
>Recommended</ListLabel>
{recommendedCollapsed ? null : visibleRecommendedIds.length === 0 ? (
<div className="px-2.5 py-2 text-xs text-muted-foreground">
No default models.
</div>
@ -1203,7 +1291,7 @@ export function HubModelPicker({
);
})
)}
{hasMoreRecommended && (
{!recommendedCollapsed && hasMoreRecommended && (
<>
<div ref={recommendedSentinelRef} className="h-px" />
<div className="flex items-center justify-center py-2">
@ -1216,7 +1304,7 @@ export function HubModelPicker({
{showHfSection && filteredRecommendedIds.length > 0 ? (
<>
<ListLabel>Recommended</ListLabel>
<ListLabel icon={<StarIcon className="size-3" />}>Recommended</ListLabel>
{filteredRecommendedIds.map((id) => {
const vram = recommendedVramMap.get(id);
return (

View file

@ -262,6 +262,12 @@ export interface BrowseFoldersResponse {
model_files_here?: number;
}
export async function listRecommendedFolders(): Promise<string[]> {
const response = await authFetch("/api/models/recommended-folders");
const data = await parseJsonOrThrow<{ folders: string[] }>(response);
return data.folders;
}
export async function browseFolders(
path?: string,
showHidden = false,

View file

@ -437,9 +437,10 @@ export function useChatModelRuntime() {
const { chatTemplateOverride, kvCacheDtype, customContextLength, ggufContextLength, speculativeType } = useChatRuntimeStore.getState();
// GGUF: use custom context length, or 0 = model's native context
// Non-GGUF: use the Max Seq Length slider value
const isDirectGgufFile = modelId.toLowerCase().endsWith(".gguf");
const effectiveMaxSeqLength = customContextLength != null
? customContextLength
: ggufVariant != null ? (ggufContextLength ?? 0) : maxSeqLength;
: (ggufVariant != null || isDirectGgufFile) ? (ggufContextLength ?? 0) : maxSeqLength;
const loadResponse = await loadModel({
model_path: modelId,
hf_token: hfToken,

View file

@ -73,7 +73,109 @@ function Refresh-Environment {
}
$machinePath = [System.Environment]::GetEnvironmentVariable('Path', 'Machine')
$userPath = [System.Environment]::GetEnvironmentVariable('Path', 'User')
$env:Path = "$machinePath;$userPath"
# Merge: venv Scripts (if active) > Machine > User > current $env:Path. Dedup raw+expanded.
$venvScripts = if ($env:VIRTUAL_ENV) { Join-Path $env:VIRTUAL_ENV 'Scripts' } else { $null }
$sources = @()
if ($venvScripts) { $sources += $venvScripts }
$sources += @($machinePath, $userPath, $env:Path)
$merged = ($sources | Where-Object { $_ }) -join ';'
$seen = @{}
$unique = New-Object System.Collections.Generic.List[string]
foreach ($p in $merged -split ";") {
$rawKey = $p.Trim().Trim('"').TrimEnd("\").ToLowerInvariant()
$expKey = [Environment]::ExpandEnvironmentVariables($p).Trim().Trim('"').TrimEnd("\").ToLowerInvariant()
if ($rawKey -and -not $seen.ContainsKey($rawKey) -and -not $seen.ContainsKey($expKey)) {
$seen[$rawKey] = $true
if ($expKey -and $expKey -ne $rawKey) { $seen[$expKey] = $true }
$unique.Add($p)
}
}
$env:Path = $unique -join ";"
}
# ── Helper: safely add a directory to the persistent User PATH ──
# Direct registry access preserves REG_EXPAND_SZ (avoids dotnet/runtime#1442).
# Append (default) keeps existing tools first; Prepend for must-win entries.
function Add-ToUserPath {
param(
[Parameter(Mandatory = $true)][string]$Directory,
[ValidateSet('Append','Prepend')]
[string]$Position = 'Append'
)
try {
$regKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment')
try {
$rawPath = $regKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
[string[]]$entries = if ($rawPath) { $rawPath -split ';' } else { @() } # string[] prevents scalar collapse
$normalDir = $Directory.Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
$expNormalDir = [Environment]::ExpandEnvironmentVariables($Directory).Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
$kept = New-Object System.Collections.Generic.List[string]
$matchIndices = New-Object System.Collections.Generic.List[int]
for ($i = 0; $i -lt $entries.Count; $i++) {
$stripped = $entries[$i].Trim().Trim('"')
$rawNorm = $stripped.TrimEnd('\').ToLowerInvariant()
$expNorm = [Environment]::ExpandEnvironmentVariables($stripped).TrimEnd('\').ToLowerInvariant()
$isMatch = ($rawNorm -and ($rawNorm -eq $normalDir -or $rawNorm -eq $expNormalDir)) -or
($expNorm -and ($expNorm -eq $normalDir -or $expNorm -eq $expNormalDir))
if ($isMatch) {
$matchIndices.Add($i)
continue
}
$kept.Add($entries[$i])
}
$alreadyPresent = $matchIndices.Count -gt 0
if ($alreadyPresent -and $Position -eq 'Append') { # Append: idempotent no-op
return $false
}
if ($alreadyPresent -and $Position -eq 'Prepend' -and # Prepend: no-op if already at front
$matchIndices.Count -eq 1 -and $matchIndices[0] -eq 0) {
return $false
}
# One-time backup under HKCU\Software\Unsloth\PathBackup
if ($rawPath) {
try {
$backupKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\Unsloth')
try {
$existingBackup = $backupKey.GetValue('PathBackup', $null)
if (-not $existingBackup) {
$backupKey.SetValue('PathBackup', $rawPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
}
} finally {
$backupKey.Close()
}
} catch { }
}
if (-not $rawPath) {
Write-Host "[WARN] User PATH is empty - initializing with $Directory" -ForegroundColor Yellow
}
$newPath = if ($rawPath) {
if ($Position -eq 'Prepend') {
(@($Directory) + $kept) -join ';'
} else {
($kept + @($Directory)) -join ';'
}
} else {
$Directory
}
if ($newPath -ceq $rawPath) { # no actual change
return $false
}
$regKey.SetValue('Path', $newPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
# Broadcast WM_SETTINGCHANGE via dummy env-var roundtrip.
# [NullString]::Value avoids PS 7.5+/.NET 9 $null-to-"" coercion.
try {
$d = "UnslothPathRefresh_$([guid]::NewGuid().ToString('N').Substring(0,8))"
[Environment]::SetEnvironmentVariable($d, '1', 'User')
[Environment]::SetEnvironmentVariable($d, [NullString]::Value, 'User')
} catch { }
return $true
} finally {
$regKey.Close()
}
} catch {
Write-Host "[WARN] Could not update User PATH: $($_.Exception.Message)" -ForegroundColor Yellow
return $false
}
}
# PowerShell 5.1 compatibility helper: avoid relying on New-TemporaryFile.
@ -493,6 +595,31 @@ if ($script:StudioVtOk -and -not $env:NO_COLOR) {
Write-Host " $Rule" -ForegroundColor DarkGray
}
# Back up User PATH under HKCU\Software\Unsloth before any modifications.
try {
$envKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $false)
if ($envKey) {
try {
$rawPath = $envKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
} finally {
$envKey.Close()
}
if ($rawPath) {
$backupKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\Unsloth')
try {
$existingBackup = $backupKey.GetValue('PathBackup', $null)
if (-not $existingBackup) {
$backupKey.SetValue('PathBackup', $rawPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
}
} finally {
$backupKey.Close()
}
}
}
} catch {
Write-Host "[DEBUG] Could not back up User PATH: $($_.Exception.Message)" -ForegroundColor DarkGray
}
# ==========================================================================
# PHASE 1: System-level prerequisites (winget installs, env vars)
# All heavy system tool installs happen here BEFORE touching Python.
@ -626,11 +753,8 @@ if (-not $HasCmake) {
foreach ($d in $cmakeDefaults) {
if (Test-Path (Join-Path $d "cmake.exe")) {
$env:Path = "$d;$env:Path"
# Persist to user PATH so Refresh-Environment does not drop it later
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if (-not $userPath -or $userPath -notlike "*$d*") {
[Environment]::SetEnvironmentVariable('Path', "$d;$userPath", 'User')
}
# Persist to user PATH (Prepend so this cmake wins over older ones).
Add-ToUserPath -Directory $d -Position 'Prepend' | Out-Null
$HasCmake = $null -ne (Get-Command cmake -ErrorAction SilentlyContinue)
if ($HasCmake) {
Write-Host " Found cmake at $d (added to PATH)" -ForegroundColor Gray
@ -896,14 +1020,8 @@ $nvccBinDir = Split-Path $NvccPath -Parent
if ($env:PATH -notlike "*$nvccBinDir*") {
[Environment]::SetEnvironmentVariable('PATH', "$nvccBinDir;$env:PATH", 'Process')
}
# Persist nvcc bin dir to User PATH so it works in new terminals
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if (-not $userPath -or $userPath -notlike "*$nvccBinDir*") {
if ($userPath) {
[Environment]::SetEnvironmentVariable('Path', "$nvccBinDir;$userPath", 'User')
} else {
[Environment]::SetEnvironmentVariable('Path', "$nvccBinDir", 'User')
}
# Persist nvcc bin dir (Prepend so the driver-compatible toolkit wins).
if (Add-ToUserPath -Directory $nvccBinDir -Position 'Prepend') {
substep "Persisted CUDA bin dir to user PATH"
}
@ -1061,15 +1179,11 @@ if ($HasPython) {
$PythonOk = $true
}
# Ensure Python Scripts dir is on PATH (so 'unsloth' command works in new terminals)
$ScriptsDir = python -c "import sysconfig; print(sysconfig.get_path('scripts', 'nt_user') if __import__('os').path.exists(sysconfig.get_path('scripts', 'nt_user')) else sysconfig.get_path('scripts'))"
# Add user-scheme Python Scripts dir to PATH (nt_user only, no venv fallback).
$ScriptsDir = python -c "import os, sysconfig; p = sysconfig.get_path('scripts', 'nt_user'); print(p if os.path.exists(p) else '')"
if ($LASTEXITCODE -eq 0 -and $ScriptsDir -and (Test-Path $ScriptsDir)) {
$UserPath = [Environment]::GetEnvironmentVariable('Path', 'User')
$UserPathEntries = if ($UserPath) { $UserPath.Split(';') } else { @() }
if (-not ($UserPathEntries | Where-Object { $_.TrimEnd('\') -eq $ScriptsDir })) {
$newUserPath = if ($UserPath) { "$ScriptsDir;$UserPath" } else { $ScriptsDir }
[Environment]::SetEnvironmentVariable('Path', $newUserPath, 'User')
# Append (not Prepend) -- this dir has other pip scripts; shim handles unsloth.
if (Add-ToUserPath -Directory $ScriptsDir) {
# Also add to current process so it's available immediately
$ProcessPathEntries = $env:PATH.Split(';')
if (-not ($ProcessPathEntries | Where-Object { $_.TrimEnd('\') -eq $ScriptsDir })) {

View file

@ -65,7 +65,9 @@ __all__ = [
"patch_compiled_autograd",
"process_vision_info",
"unsloth_compile_transformers",
"determine_attention_implementation",
"resolve_model_class",
"resolve_attention_implementation",
"resolve_encoder_attention_implementation",
"_set_attn_impl",
"patch_fast_lora",
"validate_loftq_config",
@ -233,7 +235,7 @@ def apply_unsloth_gradient_checkpointing(
# access on some GPU architectures (B200). Falls back to eager safely.
_FLEX_EXCLUDED_MODELS = ("gpt_oss", "mllama", "nemotron_h", "modernbert")
_EAGER_ONLY_PREFIXES = ("gemma3n",)
_FLASH_ATTENTION_DISABLED_MODELS = ("gemma4", "gemma4_text")
_FLASH_ATTENTION_MAX_HEAD_DIM = 256
_FLASH_ATTENTION_DISABLED_WARNED = set()
@ -245,8 +247,102 @@ def _is_eager_only(model_type):
return any(model_type.startswith(p) for p in _EAGER_ONLY_PREFIXES)
def _is_flash_attention_disabled(model_type):
return model_type in _FLASH_ATTENTION_DISABLED_MODELS
def _config_items(config):
if isinstance(config, dict):
return config.items()
if hasattr(config, "__dict__"):
return vars(config).items()
return ()
def _config_get(config, field_name, default = None):
if isinstance(config, dict):
return config.get(field_name, default)
return getattr(config, field_name, default)
def _config_set(config, field_name, value):
if isinstance(config, dict):
config[field_name] = value
elif config is not None:
setattr(config, field_name, value)
def _iter_attention_configs(config, seen = None):
if config is None or (
not isinstance(config, dict) and not hasattr(config, "__dict__")
):
return
if seen is None:
seen = set()
config_id = id(config)
if config_id in seen:
return
seen.add(config_id)
yield config
for field_name, child_config in _config_items(config):
if not isinstance(field_name, str) or not field_name.endswith("_config"):
continue
if isinstance(child_config, dict) or hasattr(child_config, "__dict__"):
yield from _iter_attention_configs(child_config, seen)
def _collect_attention_head_dims(config):
explicit_head_dims = []
for field_name in (
"head_dim",
"global_head_dim",
"local_head_dim",
"kv_head_dim",
):
value = _config_get(config, field_name, None)
if isinstance(value, int) and value > 0:
explicit_head_dims.append(value)
if len(explicit_head_dims) != 0:
return explicit_head_dims
head_dims = []
hidden_size_names = ("hidden_size", "d_model", "embed_dim", "dim")
num_heads_names = ("num_attention_heads", "num_heads", "n_heads")
for hidden_size_name in hidden_size_names:
hidden_size = _config_get(config, hidden_size_name, None)
if not isinstance(hidden_size, int) or hidden_size <= 0:
continue
for num_heads_name in num_heads_names:
num_heads = _config_get(config, num_heads_name, None)
if (
isinstance(num_heads, int)
and num_heads > 0
and (hidden_size % num_heads) == 0
):
head_dims.append(hidden_size // num_heads)
return head_dims
def _get_max_attention_head_dim(config):
head_dims = []
for attention_config in _iter_attention_configs(config):
head_dims.extend(_collect_attention_head_dims(attention_config))
return max(head_dims) if len(head_dims) != 0 else None
def _get_flash_attention_disable_reason(config):
max_head_dim = _get_max_attention_head_dim(config)
if max_head_dim is not None and max_head_dim > _FLASH_ATTENTION_MAX_HEAD_DIM:
return (
f"max attention head dim {max_head_dim} exceeds the Flash Attention 2 "
f"limit of {_FLASH_ATTENTION_MAX_HEAD_DIM}"
)
return None
def _is_flash_attention_disabled(config):
return _get_flash_attention_disable_reason(config) is not None
def _is_flash_attention_requested(attn_implementation):
@ -256,20 +352,24 @@ def _is_flash_attention_requested(attn_implementation):
def _disable_flash_attention_if_needed(
model_type,
config,
attn_implementation = None,
supports_sdpa = False,
would_use_flash_attention = False,
disable_reason = None,
):
if not _is_flash_attention_disabled(model_type):
if disable_reason is None:
disable_reason = _get_flash_attention_disable_reason(config)
if disable_reason is None:
return attn_implementation
requested_attn_implementation = attn_implementation
if requested_attn_implementation is None:
requested_attn_implementation = getattr(config, "_attn_implementation", None)
requested_attn_implementation = _config_get(
config, "_attn_implementation", None
)
if requested_attn_implementation is None:
requested_attn_implementation = getattr(config, "attn_implementation", None)
requested_attn_implementation = _config_get(config, "attn_implementation", None)
if requested_attn_implementation == "eager":
return _set_attn_impl(config, "eager")
@ -284,16 +384,18 @@ def _disable_flash_attention_if_needed(
if _is_flash_attention_requested(requested_attn_implementation)
else "flash_attention_2"
)
model_type = _config_get(config, "model_type", "")
warning_key = (
model_type,
logged_attn_implementation,
fallback_attn_implementation,
disable_reason,
)
if warning_key not in _FLASH_ATTENTION_DISABLED_WARNED:
_FLASH_ATTENTION_DISABLED_WARNED.add(warning_key)
print(
f"Unsloth: `{logged_attn_implementation}` is not supported "
"for Gemma 4 - "
f"for `{model_type}` because {disable_reason} - "
f"defaulting to `{fallback_attn_implementation}`."
)
@ -301,69 +403,125 @@ def _disable_flash_attention_if_needed(
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)
_config_set(config, "_attn_implementation", impl)
if isinstance(config, dict) or hasattr(config, "attn_implementation"):
_config_set(config, "attn_implementation", impl)
return impl
def determine_attention_implementation(model_class, config):
model_type = getattr(config, "model_type", "").lower()
def resolve_model_class(auto_model, config):
mapping = getattr(auto_model, "_model_mapping", {})
try:
result = mapping[config.__class__]
except Exception:
for config_class, model_class in mapping.items():
if isinstance(config, config_class):
result = model_class
break
else:
return None
# Eager-only models (e.g. gemma3n timm vision towers)
if _is_eager_only(model_type):
_set_attn_impl(config, "eager")
return "eager"
return result[0] if isinstance(result, (list, tuple)) else result
# Models with known Flash Attention incompatibilities. Gemma 4 full-attention
# layers use global_head_dim=512, which exceeds Flash Attention's dense
# head-dim support. Keep explicit eager requests, otherwise prefer SDPA.
if _is_flash_attention_disabled(model_type):
def resolve_attention_implementation(
model_class,
config,
requested_attn_implementation = None,
supports_sdpa = None,
):
model_type_name = _config_get(config, "model_type", "")
model_type = model_type_name.lower()
if supports_sdpa is None:
supports_sdpa = model_class is not None and getattr(
model_class, "_supports_sdpa", False
)
return _disable_flash_attention_if_needed(
model_type,
supports_flash_attention = model_class is not None and (
getattr(model_class, "_supports_flash_attn_2", False)
or getattr(model_class, "_supports_flash_attn", False)
)
disable_reason = _get_flash_attention_disable_reason(config)
flash_attention_disabled = disable_reason is not None
if model_class is None:
attn_impl = _set_attn_impl(config, "sdpa" if supports_sdpa else "eager")
else:
if _is_eager_only(model_type):
attn_impl = _set_attn_impl(config, "eager")
elif flash_attention_disabled:
attn_impl = _disable_flash_attention_if_needed(
config,
supports_sdpa = supports_sdpa,
would_use_flash_attention = (
HAS_FLASH_ATTENTION and supports_flash_attention
),
disable_reason = disable_reason,
)
elif HAS_FLASH_ATTENTION and supports_flash_attention:
attn_impl = _set_attn_impl(config, "flash_attention_2")
elif supports_sdpa:
attn_impl = _set_attn_impl(config, "sdpa")
else:
attn_impl = "eager"
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 getattr(model_class, "_supports_flex_attn", False)
and not _is_flex_excluded(model_type)
):
attention_dropout = (
_config_get(config, "attention_dropout", 0) or 0
)
if attention_dropout == 0:
attn_impl = _set_attn_impl(config, "flex_attention")
except Exception:
pass
if attn_impl == "eager":
attn_impl = _set_attn_impl(config, "eager")
if requested_attn_implementation is None:
final_attn_impl = attn_impl
elif flash_attention_disabled:
final_attn_impl = _disable_flash_attention_if_needed(
config,
requested_attn_implementation,
supports_sdpa = supports_sdpa,
disable_reason = disable_reason,
)
else:
final_attn_impl = requested_attn_implementation
_set_attn_impl(config, final_attn_impl)
# 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 not supports_sdpa and final_attn_impl == "sdpa":
print(
f"Unsloth: {(model_type_name or 'model').title()} does not support SDPA - switching to fast eager."
)
if supports_fa2:
_set_attn_impl(config, "flash_attention_2")
return "flash_attention_2"
final_attn_impl = _set_attn_impl(config, "eager")
# Flex Attention
if os.environ.get("UNSLOTH_ENABLE_FLEX_ATTENTION", "1") != "0":
try:
from transformers.utils.import_utils import is_torch_flex_attn_available
return final_attn_impl
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")
def resolve_encoder_attention_implementation(
auto_model,
config,
model_type = "",
disable_sdpa_model_names = (),
):
model_class = resolve_model_class(auto_model, config)
supports_sdpa = model_class is not None and getattr(
model_class, "_supports_sdpa", False
)
if any(name in model_type.lower() for name in disable_sdpa_model_names):
return "eager"
if supports_sdpa:
return "sdpa"
_set_attn_impl(config, "eager")
return "eager"
return None
def _run_temporary_patches(phase):

View file

@ -2346,7 +2346,7 @@ 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 = determine_attention_implementation(
preferred_attn_impl = resolve_attention_implementation(
model_function, model_config
)

View file

@ -1151,9 +1151,6 @@ class FastModel(FastBaseModel):
)
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"):

View file

@ -15,7 +15,11 @@
import logging
from .loader import FastModel, DISABLE_SDPA_MODEL_NAMES
from ._utils import SUPPORTS_BFLOAT16
from ._utils import (
SUPPORTS_BFLOAT16,
resolve_model_class,
resolve_encoder_attention_implementation,
)
import inspect
import json
import os
@ -31,7 +35,6 @@ import transformers
from packaging.version import Version
import re
from transformers import AutoModel, AutoConfig
from transformers.models.auto.auto_factory import _get_model_class
import tempfile
from huggingface_hub import HfApi, get_token
from ..save import unsloth_save_pretrained_torchao, unsloth_save_pretrained_gguf
@ -870,7 +873,7 @@ class FastSentenceTransformer(FastModel):
if auto_model_class is None:
auto_model_class = AutoModel
# try to resolve the class
model_class = _get_model_class(config, auto_model_class._model_mapping)
model_class = resolve_model_class(auto_model_class, config)
if model_class:
sig = inspect.signature(model_class.__init__)
@ -1446,32 +1449,18 @@ class FastSentenceTransformer(FastModel):
):
st_device = "cuda"
# Check if model supports SDPA (Scaled Dot Product Attention) for extra speedup
supports_sdpa = False
if config is not None:
try:
model_class = _get_model_class(
config, kwargs.get("auto_model", AutoModel)._model_mapping
)
supports_sdpa = getattr(model_class, "_supports_sdpa", False)
except:
pass
# Build model_kwargs for SentenceTransformer
model_kwargs = {"torch_dtype": dtype}
# Enable SDPA if supported (1.2x extra speedup on top of torch.compile)
# But disable for models with known SDPA + torch.compile backward issues
_force_eager = False
for _sdpa_model in DISABLE_SDPA_MODEL_NAMES:
if _sdpa_model in model_type.lower():
supports_sdpa = False
_force_eager = True
break
if supports_sdpa:
model_kwargs["attn_implementation"] = "sdpa"
elif _force_eager:
model_kwargs["attn_implementation"] = "eager"
encoder_attn_impl = resolve_encoder_attention_implementation(
kwargs.get("auto_model", AutoModel),
config,
model_type = model_type,
disable_sdpa_model_names = DISABLE_SDPA_MODEL_NAMES,
)
supports_sdpa = encoder_attn_impl == "sdpa"
if encoder_attn_impl is not None:
model_kwargs["attn_implementation"] = encoder_attn_impl
# Print optimization status
sdpa_str = " + SDPA" if supports_sdpa else ""

View file

@ -33,8 +33,8 @@ from ._utils import (
__version__,
importlib_version,
_prepare_model_for_qat,
_is_flash_attention_disabled,
_disable_flash_attention_if_needed,
resolve_model_class,
resolve_attention_implementation,
)
from ._utils import *
from .loader_utils import _get_fp8_mode_and_check_settings
@ -739,55 +739,18 @@ class FastBaseModel:
token = token,
trust_remote_code = trust_remote_code,
)
user_attn_implementation = kwargs.get("attn_implementation", None)
try:
model_class = auto_model._model_mapping[auto_config.__class__]
except Exception:
model_class = None
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)
model_class = resolve_model_class(auto_model, auto_config)
attn_impl = resolve_attention_implementation(
model_class,
auto_config,
requested_attn_implementation = kwargs.get("attn_implementation", None),
supports_sdpa = supports_sdpa,
)
# 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.
auto_config.model_name = model_name
# Re-resolve model_class after potential config change
try:
model_class = auto_model._model_mapping[auto_config.__class__]
except Exception:
model_class = None
if not ("attn_implementation" in kwargs):
kwargs["attn_implementation"] = attn_impl
model_type = getattr(auto_config, "model_type", "").lower()
if _is_flash_attention_disabled(model_type):
supports_fa2 = model_class is not None and (
getattr(model_class, "_supports_flash_attn_2", False)
or getattr(model_class, "_supports_flash_attn", False)
)
kwargs["attn_implementation"] = _disable_flash_attention_if_needed(
model_type,
auto_config,
kwargs.get("attn_implementation"),
supports_sdpa = supports_sdpa,
would_use_flash_attention = (
user_attn_implementation is None
and HAS_FLASH_ATTENTION
and supports_fa2
),
)
if not supports_sdpa and kwargs.get("attn_implementation") == "sdpa":
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")
kwargs["attn_implementation"] = attn_impl
bnb_config = None
user_quantization_config = kwargs.get("quantization_config", None)
@ -930,9 +893,7 @@ class FastBaseModel:
token = token,
trust_remote_code = trust_remote_code,
)
setattr(auto_config, "_attn_implementation", config_attn_impl)
if hasattr(auto_config, "attn_implementation"):
setattr(auto_config, "attn_implementation", config_attn_impl)
_set_attn_impl(auto_config, config_attn_impl)
model_config = auto_config
verify_fp8_support_if_applicable(model_config)

View file

@ -636,173 +636,639 @@ def load_correct_tokenizer(
return tokenizer
def _find_end_position(template, endfor, endif):
where_endfor = template.find(endfor)
where_endif = template.find(endif)
if where_endfor == where_endif == -1:
# All four Jinja whitespace-control variants of endfor/endif:
# {% endfor %} {%- endfor %} {% endfor -%} {%- endfor -%}
_RE_ENDFOR = re.compile(r"\{%(-?)\s*endfor\s*(-?)%\}")
_RE_ENDIF = re.compile(r"\{%(-?)\s*endif\s*(-?)%\}")
_RE_JINJA_COMMENT = re.compile(r"\{#.*?#\}", flags = re.DOTALL)
def _find_end_position(template, endfor = None, endif = None):
"""Rightmost {% endfor %}/{% endif %} (any dash variant), as a dict
with start/end/text/dash_left/dash_right. Tokens inside Jinja comments
are ignored. `endfor`/`endif` kwargs kept for back-compat, ignored."""
# Space-pad comments so positions still map 1:1 to the original.
scrubbed = _RE_JINJA_COMMENT.sub(lambda m: " " * len(m.group(0)), template)
endfor_matches = list(_RE_ENDFOR.finditer(scrubbed))
endif_matches = list(_RE_ENDIF.finditer(scrubbed))
last_endfor = endfor_matches[-1] if endfor_matches else None
last_endif = endif_matches[-1] if endif_matches else None
candidates = [m for m in (last_endfor, last_endif) if m is not None]
if not candidates:
return None
elif where_endfor > where_endif:
return endfor
m = max(candidates, key = lambda x: x.end())
return {
"start": m.start(),
"end": m.end(),
"text": m.group(0),
"dash_left": bool(m.group(1)),
"dash_right": bool(m.group(2)),
}
def _template_ends_with_toplevel_for(chat_template):
"""Return True if the last structural node at the template's top level is
a For (message-iteration) loop, ignoring trailing pure-whitespace Output
nodes. Unwraps benign outer-If guards (no else branch, not testing
add_generation_prompt) so that templates like
``{% if messages %}{% for ... %}{% endfor %}{% endif %}`` are still
repairable. Rejects real structural wrappers (e.g. Qwen3-Guard with
else branches)."""
try:
import jinja2
import jinja2.nodes
ast = jinja2.Environment().parse(chat_template)
except Exception:
return False
def _last_structural(nodes):
for node in reversed(nodes):
if isinstance(node, jinja2.nodes.Output):
only_ws = all(
isinstance(child, jinja2.nodes.TemplateData)
and child.data.strip() == ""
for child in node.nodes
)
if only_ws:
continue
return node
return None
node = _last_structural(ast.body)
while isinstance(node, jinja2.nodes.If) and not node.else_:
names = []
if isinstance(node.test, jinja2.nodes.Name):
names.append(node.test)
names.extend(node.test.find_all(jinja2.nodes.Name))
if any(n.name == "add_generation_prompt" for n in names):
break
node = _last_structural(node.body)
return isinstance(node, jinja2.nodes.For)
def _if_body_emits_content(if_node):
"""True if the If's body contains any Output node (directly or nested).
Distinguishes a real generation block from a header guard that only
does `{% set ... %}`."""
import jinja2.nodes
for node in if_node.body:
if isinstance(node, jinja2.nodes.Output):
return True
if any(
isinstance(d, jinja2.nodes.Output)
for d in node.find_all(jinja2.nodes.Output)
):
return True
return False
def _has_add_generation_prompt_block(chat_template):
"""True if the template has a *positive* `{% if add_generation_prompt %}`
gate whose body emits output. Rejects header guards like
`{% if not add_generation_prompt is defined %}{% set ... %}{% endif %}`
that reference the name but emit nothing. AST-based; string-scan
fallback if Jinja fails to parse."""
try:
import jinja2
import jinja2.nodes
ast = jinja2.Environment().parse(chat_template)
except Exception:
return "if add_generation_prompt" in chat_template and "%}" in chat_template
for if_node in ast.find_all(jinja2.nodes.If):
test = if_node.test
# Reject negated gates: `{% if not add_generation_prompt %}` fires
# when agp=False, so it's not a generation block even if it emits.
if isinstance(test, jinja2.nodes.Not):
continue
# find_all skips the test root, so check bare Name tests explicitly.
references_agp = False
if isinstance(test, jinja2.nodes.Name) and test.name == "add_generation_prompt":
references_agp = True
else:
for name_node in test.find_all(jinja2.nodes.Name):
if name_node.name == "add_generation_prompt":
references_agp = True
break
if references_agp and _if_body_emits_content(if_node):
return True
return False
# Sentinels for _derive_assistant_prefix_by_render. Diverge at char 0 so
# commonprefix can't absorb them; long random tail makes collision with real
# template literals negligible (see T18).
_RENDER_DIFF_SENTINEL_A = "AAAA_0123456789_UNSLOTH_RENDER_DIFF_SENTINEL"
_RENDER_DIFF_SENTINEL_B = "BBBB_0123456789_UNSLOTH_RENDER_DIFF_SENTINEL"
_RENDER_DIFF_SENTINEL_C = "CCCC_0123456789_UNSLOTH_RENDER_DIFF_SENTINEL"
def _derive_assistant_prefix_by_render(chat_template, is_sharegpt = False):
"""Return the assistant-turn prefix the template emits, derived by
rendering two dialogs that differ only in assistant content: the common
prefix of their tails (after the base [user]-only render) is what the
template emits for an assistant turn. None if any guard fails.
Works for Llama-3 / Gemma / Phi-3 and other non-ChatML shapes; the
template is its own ground truth.
Known limitation: an `eos-on-non-last` pattern (turn-end sentinel only
emitted for non-last messages) would produce a consistent but wrong
prefix that `_validate_patched_template` can't catch. No real-world
template is known to use this.
"""
try:
from jinja2.sandbox import SandboxedEnvironment
except Exception:
return None
if is_sharegpt:
base_msgs = [{"from": "human", "value": "Hi"}]
sent_a_msgs = base_msgs + [{"from": "gpt", "value": _RENDER_DIFF_SENTINEL_A}]
sent_b_msgs = base_msgs + [{"from": "gpt", "value": _RENDER_DIFF_SENTINEL_B}]
# User-role cross-check (Guard C below).
sent_c_msgs = base_msgs + [{"from": "human", "value": _RENDER_DIFF_SENTINEL_C}]
else:
return endif
base_msgs = [{"role": "user", "content": "Hi"}]
sent_a_msgs = base_msgs + [
{"role": "assistant", "content": _RENDER_DIFF_SENTINEL_A}
]
sent_b_msgs = base_msgs + [
{"role": "assistant", "content": _RENDER_DIFF_SENTINEL_B}
]
sent_c_msgs = base_msgs + [{"role": "user", "content": _RENDER_DIFF_SENTINEL_C}]
# Strip trailing whitespace/comments after the last endfor/endif: they
# appear after the message loop and would break Guard A. The splice in
# `_fix_chat_template` drops them too.
probe_template = chat_template
end = _find_end_position(chat_template)
if end is not None:
after = chat_template[end["end"] :]
if _RE_JINJA_COMMENT.sub("", after).strip() == "":
probe_template = chat_template[: end["end"]]
# Sandboxed: probe renders at load time, before user calls
# apply_chat_template. SandboxedEnvironment blocks attribute-chain exploits.
try:
env = SandboxedEnvironment(
autoescape = False,
keep_trailing_newline = True,
)
tmpl = env.from_string(probe_template)
out_base = tmpl.render(messages = base_msgs, add_generation_prompt = False)
out_a = tmpl.render(messages = sent_a_msgs, add_generation_prompt = False)
out_b = tmpl.render(messages = sent_b_msgs, add_generation_prompt = False)
except Exception:
return None
# Best-effort: alternation-enforcing templates (e.g. Gemma's
# raise_exception) fail on [user, user]; that's a positive signal
# for Guard C, not a probe failure.
out_user_c = None
try:
out_user_c = tmpl.render(messages = sent_c_msgs, add_generation_prompt = False)
except Exception:
pass
# Guard A: assistant renders extend base (no reordering).
if not (out_a.startswith(out_base) and out_b.startswith(out_base)):
return None
tail_a = out_a[len(out_base) :]
tail_b = out_b[len(out_base) :]
if not tail_a or not tail_b:
return None
prefix = os.path.commonprefix([tail_a, tail_b])
# Guard B: divergence is exactly at the content-insertion site.
if not (
tail_a[len(prefix) :].startswith(_RENDER_DIFF_SENTINEL_A)
and tail_b[len(prefix) :].startswith(_RENDER_DIFF_SENTINEL_B)
):
return None
# Guard C: reject if a [user, user] render also emits the same prefix
# (role-insensitive template, e.g. `{% set greeting='Hi' %}...`).
if out_user_c is not None and out_user_c.startswith(out_base):
tail_c = out_user_c[len(out_base) :]
if tail_c.startswith(prefix) and prefix != "":
return None
if not prefix:
return None
return prefix
def _fix_chat_template(chat_template):
endfor = "{% endfor %}"
endif = "{% endif %}"
chosen_end = _find_end_position(chat_template, endfor, endif)
if chosen_end is None:
endfor = "{%- endfor %}"
endif = "{%- endif %}"
chosen_end = _find_end_position(chat_template, endfor, endif)
if chosen_end is None:
def _fix_chat_template(chat_template, is_sharegpt = False):
# Fast path: already has an {% if add_generation_prompt %} block, nothing
# to do. This catches cases the old string-based check would miss (e.g.
# templates that use {%- if add_generation_prompt -%} with both-side dash,
# or that sneak the block into a nested If/For).
if _has_add_generation_prompt_block(chat_template):
return chat_template
where = chat_template.find(chosen_end)
end = _find_end_position(chat_template)
if end is None:
return chat_template
after_endfor = chat_template[where + len(chosen_end) :]
dash = "-" if chosen_end.startswith("{%-") else ""
after_endfor = chat_template[end["end"] :]
dash_l = "-" if end["dash_left"] else ""
dash_r = "-" if end["dash_right"] else ""
open_tag = lambda body: "{%" + dash_l + " " + body + " " + dash_r + "%}"
# Case 1 (pre-existing base case): template ends with a single trailing
# {{ expr }} that is the generation prefix. Wrap it in an
# {% if add_generation_prompt %} ... {% endif %}.
if (
"{%" + dash + " if" not in after_endfor
and "{%" + dash + " set " not in after_endfor
"{%" + dash_l + " if" not in after_endfor
and "{%" + dash_l + " set " not in after_endfor
and after_endfor.startswith("{{")
and after_endfor.endswith("}}")
and after_endfor.count("{{") == 1
and after_endfor.count("}}") == 1
):
after_endfor = (
"{%" + dash + " if add_generation_prompt %}" + after_endfor + endif
wrapped = (
open_tag("if add_generation_prompt") + after_endfor + open_tag("endif")
)
return chat_template[: end["end"]] + wrapped
chat_template = chat_template[: where + len(chosen_end)] + after_endfor
elif re.sub(r"\{#.*?#\}", "", after_endfor, flags = re.DOTALL).strip() == "":
# GH#4150: ChatML templates ending at {% endfor %} without an
# add_generation_prompt block. Scrub Jinja `{# ... #}` comments so
# tokens inside comments cannot fool the guard below.
scrubbed = re.sub(r"\{#.*?#\}", "", chat_template, flags = re.DOTALL)
if (
"<|im_start|>" in scrubbed
and "<|im_end|>" in scrubbed
and "add_generation_prompt" not in scrubbed
):
# Infer the assistant-turn separator. Prefer an explicit
# '<|im_start|>assistant<sep>' literal; else the unique
# `message['role'] + '<sep>'` from role concatenations; else
# '<|im_sep|>' if present (Phi-4-mini uses '\n' for system and
# '<|im_sep|>' for user/assistant); else '\n'.
assistant_match = re.search(
r"""(['"])<\|im_start\|>assistant([^'"]*)\1""",
scrubbed,
# Case 2 (GH#4150): template ends at {% endfor %} with only whitespace
# or comments left. Inject an {% if add_generation_prompt %} block with
# the assistant prefix derived by render-diff. The top-level-For gate
# keeps us out of outer-If wrappers (e.g. Qwen3-Guard).
if _RE_JINJA_COMMENT.sub(
"", after_endfor
).strip() == "" and _template_ends_with_toplevel_for(chat_template):
# No redundant "agp not in scrubbed" check: the fast path already
# confirmed no *positive* block, and a mere reference (header
# guard) should still get repaired.
assistant_prefix = _derive_assistant_prefix_by_render(
chat_template, is_sharegpt
)
# Dual-probe: dict/list callers don't know the shape up front.
if assistant_prefix is None and not is_sharegpt:
assistant_prefix = _derive_assistant_prefix_by_render(
chat_template, is_sharegpt = True
)
role_seps = [
m.group(2)
for m in re.finditer(
r"""message(?:\[['"]role['"]\]|\.role)\s*\+\s*(['"])([^'"]*)\1""",
scrubbed,
)
]
unique_role_seps = list(dict.fromkeys(role_seps))
if assistant_match is not None and assistant_match.group(2):
separator = assistant_match.group(2)
elif len(unique_role_seps) == 1:
separator = unique_role_seps[0]
elif "<|im_sep|>" in scrubbed:
separator = "<|im_sep|>"
else:
separator = "\\n"
# Emit a double-quoted Jinja literal so a single quote in the
# separator cannot break the block. Drop trailing whitespace/
# comments after endfor: they would render as stray output
# after the generation prefix.
assistant_prefix = "<|im_start|>assistant" + separator
generation_block = (
"{%" + dash + " if add_generation_prompt %}"
'{{ "' + assistant_prefix.replace('"', '\\"') + '" }}'
"{%" + dash + " endif %}"
)
chat_template = chat_template[: where + len(chosen_end)] + generation_block
if assistant_prefix is None:
return chat_template
# Escape for a double-quoted Jinja string literal.
escaped = (
assistant_prefix.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
)
generation_block = (
open_tag("if add_generation_prompt")
+ '{{ "'
+ escaped
+ '" }}'
+ open_tag("endif")
)
return chat_template[: end["end"]] + generation_block
return chat_template
def _is_strict_chat_template_mode():
"""Opt-in strict mode restores the pre-warn RuntimeError behavior."""
val = os.environ.get("UNSLOTH_STRICT_CHAT_TEMPLATE", "0")
return str(val).strip().lower() in ("1", "true", "yes", "on")
def _name_is_local_path(name_or_path):
"""True if name_or_path refers to an existing local directory. Used to
tailor the warning message: for local paths the user cannot 'file a bug
report to the maintainers of <path>' since that path is their own."""
if not name_or_path:
return False
try:
return os.path.isdir(str(name_or_path))
except Exception:
return False
def _format_chat_template_message(
name_or_path,
repaired,
has_generation_block = False,
local_path_source = None,
strict = False,
):
"""Build a user-facing warning/error message that points at the right
responsible party (user's downstream tool vs. upstream model maintainer)."""
local = _name_is_local_path(
local_path_source if local_path_source is not None else name_or_path
)
if local:
source_hint = (
"This tokenizer was loaded from a local path. The likely cause is a "
"downstream tool (LlamaFactory, Axolotl, etc.) that re-serialized "
"the tokenizer during save and stripped the generation-prompt "
"block. Either re-save with the original template, or set "
"`tokenizer.chat_template` manually before loading."
)
else:
source_hint = (
"The chat_template shipped with `{name}` appears incomplete. "
"Consider filing a bug report with the model maintainers."
).format(name = name_or_path)
strict_suffix = (
""
if strict
else (" Set UNSLOTH_STRICT_CHAT_TEMPLATE=1 to raise instead of warn.")
)
if repaired:
return (
"Unsloth: Patched the chat_template on `{name}` to add a "
"{{% if add_generation_prompt %}} block. {hint}"
).format(name = name_or_path, hint = source_hint)
if has_generation_block:
return (
"Unsloth: The tokenizer `{name}` has a "
"{{% if add_generation_prompt %}} block, but it does not change "
"the rendered output. {hint}{suffix}"
).format(name = name_or_path, hint = source_hint, suffix = strict_suffix)
load_clause = (
"Loading is blocked in strict mode."
if strict
else "The model will still load, but "
"`apply_chat_template(add_generation_prompt=True)` may not produce a "
"correct assistant-turn marker."
)
return (
"Unsloth: The tokenizer `{name}` does not have a "
"{{% if add_generation_prompt %}} block for generation purposes, and "
"automatic repair was not possible. {load_clause} {hint}{suffix}"
).format(
name = name_or_path,
load_clause = load_clause,
hint = source_hint,
suffix = strict_suffix,
)
def _validate_patched_template(tokenizer, patched_template, is_sharegpt):
"""Render the just-patched template with and without
add_generation_prompt, and confirm the patched output responds to the
flag by appending (not replacing) content. Returns True if validation
passes."""
msgs = (
[{"from": "human", "value": "Hi"}]
if is_sharegpt
else [{"role": "user", "content": "Hi"}]
)
original = getattr(tokenizer, "chat_template", None)
try:
try:
tokenizer.chat_template = patched_template
except Exception:
return False # read-only tokenizer, skip validation
try:
yes = tokenizer.apply_chat_template(
msgs,
add_generation_prompt = True,
tokenize = False,
)
no = tokenizer.apply_chat_template(
msgs,
add_generation_prompt = False,
tokenize = False,
)
except Exception:
return False
finally:
try:
tokenizer.chat_template = original
except Exception:
pass # best-effort restore
# Contract after a successful repair: the two renders differ, and the
# "yes" render is a strict extension of the "no" render (we only
# appended content inside the new add_generation_prompt block).
return yes != no and yes.startswith(no)
def _repair_string_template(tokenizer, chat_template, is_sharegpt):
"""Core string-template repair. Returns the repaired template on success,
or None if repair was not possible / failed validation."""
candidate = _fix_chat_template(chat_template, is_sharegpt = is_sharegpt)
if not _has_add_generation_prompt_block(candidate):
return None
# Validate with the caller's is_sharegpt first. If that fails, the
# dual-probe in _fix_chat_template may have fallen back to the other
# schema internally -- try validating with the opposite schema before
# giving up.
if _validate_patched_template(tokenizer, candidate, is_sharegpt):
return candidate
if _validate_patched_template(tokenizer, candidate, not is_sharegpt):
return candidate
return None
def _fix_chat_template_for_tokenizer(tokenizer, chat_template):
"""Entry point for a string chat_template. Runs the no==yes diagnostic,
attempts repair if needed, and returns the (possibly patched) template.
On repair failure, the behavior is controlled by
UNSLOTH_STRICT_CHAT_TEMPLATE: warn + return original (default) or raise
RuntimeError (strict)."""
name = getattr(tokenizer, "name_or_path", "unknown")
source_path = getattr(tokenizer, "_source_path", name)
# Detect ShareGPT vs HF style by probing apply_chat_template.
is_sharegpt = None
try:
tokenizer.apply_chat_template(
[{"role": "user", "content": "Who are you?"}],
add_generation_prompt = False,
tokenize = False,
)
is_sharegpt = False
except Exception:
try:
tokenizer.apply_chat_template(
[{"from": "human", "value": "Who are you?"}],
add_generation_prompt = False,
tokenize = False,
)
is_sharegpt = True
except Exception:
is_sharegpt = None
if is_sharegpt is None:
return chat_template
messages = (
[{"from": "human", "value": "Who are you?"}]
if is_sharegpt
else [{"role": "user", "content": "Who are you?"}]
)
try:
no = tokenizer.apply_chat_template(
messages,
add_generation_prompt = False,
tokenize = False,
)
yes = tokenizer.apply_chat_template(
messages,
add_generation_prompt = True,
tokenize = False,
)
except Exception:
return chat_template
if no != yes:
# Template already responds to the flag; leave as is.
return chat_template
# no == yes: template ignores add_generation_prompt. Try to repair.
if _has_add_generation_prompt_block(chat_template):
# Template has the block but it does not change output. This is the
# "wasn't provided correctly" case from the pre-warn code path.
strict = _is_strict_chat_template_mode()
msg = _format_chat_template_message(
name,
repaired = False,
has_generation_block = True,
local_path_source = source_path,
strict = strict,
)
if strict:
raise RuntimeError(msg)
logger.warning_once(msg)
return chat_template
repaired = _repair_string_template(tokenizer, chat_template, is_sharegpt)
if repaired is not None:
logger.warning_once(
_format_chat_template_message(
name,
repaired = True,
local_path_source = source_path,
)
)
return repaired
strict = _is_strict_chat_template_mode()
msg = _format_chat_template_message(
name,
repaired = False,
local_path_source = source_path,
strict = strict,
)
if strict:
raise RuntimeError(msg)
logger.warning_once(msg)
return chat_template
class _VariantTokenizerProxy:
"""Single-variant view of a multi-variant tokenizer. Routes each variant
through `_fix_chat_template_for_tokenizer` so the full contract
(is_sharegpt probe, no==yes, warn/strict, `_validate_patched_template`)
applies instead of jumping straight to structural repair.
`apply_chat_template` swaps `base.chat_template` to the variant before
calling so tokenizer globals (bos_token, filters, raise_exception) are
preserved; falls back to bare Jinja for read-only stubs.
"""
def __init__(self, base_tokenizer, variant_template, variant_label = ""):
self._base = base_tokenizer
self._template = variant_template
base_name = getattr(base_tokenizer, "name_or_path", "unknown")
self._source_path = base_name
self.name_or_path = (
f"{base_name} ({variant_label})" if variant_label else base_name
)
@property
def chat_template(self):
return self._template
@chat_template.setter
def chat_template(self, value):
self._template = value
def apply_chat_template(self, *args, **kwargs):
base_original = getattr(self._base, "chat_template", None)
swapped = False
try:
try:
self._base.chat_template = self._template
swapped = True
except Exception:
swapped = False
if swapped:
return self._base.apply_chat_template(*args, **kwargs)
# Read-only base: fall back to sandboxed Jinja.
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment(
autoescape = False,
keep_trailing_newline = True,
)
messages = args[0] if args else kwargs.get("messages", [])
add_generation_prompt = kwargs.get("add_generation_prompt", False)
return env.from_string(self._template).render(
messages = messages,
add_generation_prompt = add_generation_prompt,
)
finally:
if swapped:
try:
self._base.chat_template = base_original
except Exception:
pass # best-effort restore
def fix_chat_template(tokenizer):
chat_template = getattr(tokenizer, "chat_template", None)
if chat_template is None:
return None
### 1. Check if add_generation_prompt works
# Check for ShareGPT style first
is_sharegpt = None
try:
messages = [
{"role": "user", "content": "Who are you?"},
]
tokenizer.apply_chat_template(
messages, add_generation_prompt = False, tokenize = False
)
is_sharegpt = False
except:
try:
messages = [
{"from": "human", "value": "Who are you?"},
]
tokenizer.apply_chat_template(
messages, add_generation_prompt = False, tokenize = False
# Multi-variant dict (e.g. Hermes-3 {default, tool_use}): route each
# variant through the full repair contract via _VariantTokenizerProxy.
if isinstance(chat_template, dict):
fixed = {}
for key, tmpl in chat_template.items():
if not isinstance(tmpl, str):
fixed[key] = tmpl
continue
proxy = _VariantTokenizerProxy(
tokenizer, tmpl, variant_label = f"variant={key!r}"
)
is_sharegpt = True
except:
is_sharegpt = None
fixed[key] = _fix_chat_template_for_tokenizer(proxy, tmpl)
return fixed
# Not ShareGPT or HF style - just return
if is_sharegpt is None:
return chat_template
# Tokenize
messages = [
{"role": "user", "content": "Who are you?"}
if not is_sharegpt
else {"from": "human", "value": "Who are you?"}
]
no = tokenizer.apply_chat_template(
messages, add_generation_prompt = False, tokenize = False
)
yes = tokenizer.apply_chat_template(
messages, add_generation_prompt = True, tokenize = False
)
if no == yes:
# SAME?! That's not good! We check for add_generation_prompt
if (
"{% if add_generation_prompt %}" not in chat_template
and "{%- if add_generation_prompt %}" not in chat_template
):
# Try fixing it by adding it
new_chat_template = _fix_chat_template(chat_template)
if (
"{% if add_generation_prompt %}" not in new_chat_template
and "{%- if add_generation_prompt %}" not in new_chat_template
):
raise RuntimeError(
f"Unsloth: The tokenizer `{tokenizer.name_or_path}`\n"
"does not have a {% if add_generation_prompt %} for generation purposes.\n"
f"Please file a bug report to the maintainers of `{tokenizer.name_or_path}` - thanks!"
)
# List-of-dicts form (older HF multi-template style).
if isinstance(chat_template, list):
fixed = []
for item in chat_template:
if not isinstance(item, dict) or "template" not in item:
fixed.append(item)
continue
tmpl = item["template"]
if not isinstance(tmpl, str):
fixed.append(item)
continue
label = f"variant={item.get('name', '?')!r}"
proxy = _VariantTokenizerProxy(tokenizer, tmpl, variant_label = label)
new_tmpl = _fix_chat_template_for_tokenizer(proxy, tmpl)
if new_tmpl is tmpl or new_tmpl == tmpl:
fixed.append(item)
else:
logger.warning_once(
"Unsloth: We successfully patched the tokenizer to add a {% if add_generation_prompt %} to the chat_template.\n"
f"This is not a bug, but please notify the maintainers of `{tokenizer.name_or_path}` - thanks!"
)
chat_template = new_chat_template
else:
raise RuntimeError(
f"Unsloth: The tokenizer `{tokenizer.name_or_path}`\n"
"has a {% if add_generation_prompt %} for generation purposes, but wasn't provided correctly.\n"
"Please file a bug report immediately - thanks!"
)
return chat_template
fixed.append({**item, "template": new_tmpl})
return fixed
return _fix_chat_template_for_tokenizer(tokenizer, chat_template)
def check_tokenizer(