From 3869fbe1cc6495cb6bc5c0d48e2b3159ee364d5b Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 15 Apr 2026 08:23:41 -0700 Subject: [PATCH 01/20] Bump installer minimum to 2026.4.5 (#5041) --- install.ps1 | 10 +++++----- install.sh | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/install.ps1 b/install.ps1 index 61f31d85b4..ee28a14e5f 100644 --- a/install.ps1 +++ b/install.ps1 @@ -819,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.4.4" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.4.5" unsloth-zoo } if ($baseInstallExit -eq 0) { $NoTorchReq = Find-NoTorchRuntimeFile if ($NoTorchReq) { @@ -827,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.4.4" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.4.5" unsloth-zoo } } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red @@ -857,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.4.4" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.4.5" unsloth-zoo } if ($baseInstallExit -eq 0) { $NoTorchReq = Find-NoTorchRuntimeFile if ($NoTorchReq) { @@ -865,7 +865,7 @@ shell.Run cmd, 0, False } } } elseif ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.4.4" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.4.5" unsloth-zoo } } else { $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "$PackageName" } } @@ -886,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.4.4" --torch-backend=auto } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.4.5" --torch-backend=auto } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red return diff --git a/install.sh b/install.sh index 0dbcdf380e..6915893ddf 100755 --- a/install.sh +++ b/install.sh @@ -1316,7 +1316,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.4.4" unsloth-zoo + "unsloth>=2026.4.5" 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" @@ -1324,7 +1324,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.4.4" unsloth-zoo + "unsloth>=2026.4.5" unsloth-zoo fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then substep "overlaying local repo (editable)..." @@ -1487,7 +1487,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.4.4" unsloth-zoo + "unsloth>=2026.4.5" 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" @@ -1498,7 +1498,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.4.4" unsloth-zoo + --upgrade-package unsloth "unsloth>=2026.4.5" 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 @@ -1525,7 +1525,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.4.4" --torch-backend=auto + run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.4.5" --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 From a4d4dfe4ac87bea8b2b0bfbc80a20c57b96e3b95 Mon Sep 17 00:00:00 2001 From: DoubleMathew Date: Wed, 15 Apr 2026 17:50:48 -0500 Subject: [PATCH 02/20] fix Gemma4 flash attn disable (#5045) * fix pass attn implementation * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- unsloth/models/_utils.py | 70 ++++++++++++++++++++++++++++++++++++++++ unsloth/models/vision.py | 26 ++++++++++++++- 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index 03f5b965d0..1e0b015c44 100644 --- a/unsloth/models/_utils.py +++ b/unsloth/models/_utils.py @@ -233,6 +233,8 @@ 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_DISABLED_WARNED = set() def _is_flex_excluded(model_type): @@ -243,6 +245,61 @@ 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 _is_flash_attention_requested(attn_implementation): + return isinstance(attn_implementation, str) and attn_implementation.startswith( + "flash_attention" + ) + + +def _disable_flash_attention_if_needed( + model_type, + config, + attn_implementation = None, + supports_sdpa = False, + would_use_flash_attention = False, +): + if not _is_flash_attention_disabled(model_type): + return attn_implementation + + requested_attn_implementation = attn_implementation + if requested_attn_implementation is None: + requested_attn_implementation = getattr(config, "_attn_implementation", None) + if requested_attn_implementation is None: + requested_attn_implementation = getattr(config, "attn_implementation", None) + + if requested_attn_implementation == "eager": + return _set_attn_impl(config, "eager") + + fallback_attn_implementation = "sdpa" if supports_sdpa else "eager" + if ( + _is_flash_attention_requested(requested_attn_implementation) + or would_use_flash_attention + ): + logged_attn_implementation = ( + requested_attn_implementation + if _is_flash_attention_requested(requested_attn_implementation) + else "flash_attention_2" + ) + warning_key = ( + model_type, + logged_attn_implementation, + fallback_attn_implementation, + ) + 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"defaulting to `{fallback_attn_implementation}`." + ) + + return _set_attn_impl(config, fallback_attn_implementation) + + def _set_attn_impl(config, impl): """Helper function to set attention implementation on config and return it.""" if config is not None: @@ -260,6 +317,19 @@ def determine_attention_implementation(model_class, config): _set_attn_impl(config, "eager") return "eager" + # 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): + supports_sdpa = model_class is not None and getattr( + model_class, "_supports_sdpa", False + ) + return _disable_flash_attention_if_needed( + model_type, + config, + supports_sdpa = supports_sdpa, + ) + # 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( diff --git a/unsloth/models/vision.py b/unsloth/models/vision.py index 2bdff55a56..e31617f89a 100644 --- a/unsloth/models/vision.py +++ b/unsloth/models/vision.py @@ -29,7 +29,13 @@ except: from ..kernels import ( post_patch_loss_function, ) -from ._utils import __version__, importlib_version, _prepare_model_for_qat +from ._utils import ( + __version__, + importlib_version, + _prepare_model_for_qat, + _is_flash_attention_disabled, + _disable_flash_attention_if_needed, +) from ._utils import * from .loader_utils import _get_fp8_mode_and_check_settings from ..save import patch_saving_functions @@ -607,6 +613,7 @@ 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: @@ -631,6 +638,23 @@ class FastBaseModel: 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." From 14ab6fbfae79b9b8ee8612793ecd3f2fac528d93 Mon Sep 17 00:00:00 2001 From: Imgyu Kim Date: Thu, 16 Apr 2026 16:21:29 +0900 Subject: [PATCH 03/20] BUG: fix _fix_chat_template for ChatML templates missing add_generation_prompt (#4426) Fixes #4150. Pre-PR, `_fix_chat_template` only patched templates where a trailing `{{ ... }}` expression followed the last `{% endfor %}`. ChatML templates (Hermes, Magnum, Phi-4, etc.) that end cleanly at `{% endfor %}` with no generation-prompt block were left unchanged, so the outer `fix_chat_template` raised: ``` RuntimeError: Unsloth: The tokenizer `...` does not have a {% if add_generation_prompt %} for generation purposes. ``` This commonly shows up when a downstream tool (LlamaFactory, Axolotl) re-serializes the tokenizer during LoRA save and strips the generation-prompt block. This PR adds a second branch to `_fix_chat_template` that fires when: - the content after the last `{% endfor %}` is empty modulo Jinja `{# ... #}` comments, - the scrubbed template contains `<|im_start|>` and `<|im_end|>`, - and the scrubbed template does not already mention `add_generation_prompt`. The assistant-turn separator is inferred from the template itself (preferring an explicit `'<|im_start|>assistant'` literal, then the unique `message['role'] + ''` from role concatenations, then `<|im_sep|>` for Phi-4-mini mixed-separator templates, then `\n`), so Phi-4-style templates are not silently corrupted with the wrong separator. Verified against the existing chat-template corpus: - Hermes-3, Magnum-v2, Phi-4-mini, Phi-4 multi-sep, ChatML with trailing whitespace, ChatML with trailing Jinja comment, dot-access `message.role`, split-literal `'<|im_start|>assistant'`: all repaired with the correct assistant prefix. - Already-fixed ChatML templates: idempotent NOP. - Trap templates with `<|im_start|>` only inside a Jinja comment: correctly not rewritten. - Llama-3, Gemma-3, Qwen2.5 (non-ChatML): byte-identical. - Mistral family (5 models including Mistral-Nemo, Mistral-Small-24B, Mixtral): byte-identical, protected both by the structural guard (no ChatML tokens) and the existing name-based exemption in `load_correct_tokenizer`. - Qwen family (14 models including Qwen2.5, Qwen3, Qwen3-Coder, QwQ, VL, Math, Qwen3-Guard): byte-identical. End-to-end reproduction: Hermes-3 LoRA SFT, save with stripped chat_template, reload. Pre-PR code path raises the RuntimeError above. Post-PR reload loads cleanly, patches the template at load time, and `apply_chat_template(add_generation_prompt=True)` produces the correct `<|im_start|>assistant\n` prefix. --- unsloth/tokenizer_utils.py | 48 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/unsloth/tokenizer_utils.py b/unsloth/tokenizer_utils.py index a3f8affccf..4fc09ed76b 100644 --- a/unsloth/tokenizer_utils.py +++ b/unsloth/tokenizer_utils.py @@ -677,6 +677,54 @@ def _fix_chat_template(chat_template): ) 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' literal; else the unique + # `message['role'] + ''` 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, + ) + 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 + return chat_template From ec32ce2e822289a192b92f877ef4f7b5d1850434 Mon Sep 17 00:00:00 2001 From: Etherll <61019402+Etherll@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:49:51 +0200 Subject: [PATCH 04/20] fix: use direct registry API for PATH writes instead of SetEnvironmentVariable (#4961) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: replacing SetEnvironmentVariable with direct registry API * apply reviews * Use CreateSubKey for HKCU\Environment * Store PATH backup under HKCU\Software\Unsloth * Fix $backupKey registry handle leak in PATH backup block Wrap $backupKey operations in try/finally so the handle is closed even if GetValue or SetValue throws. The Add-ToUserPath helper already uses this pattern for its registry key -- the backup block was the only place missing it. * Isolate WM_SETTINGCHANGE broadcast from PATH write error handling Wrap the broadcast dummy-variable calls in their own try/catch so a broadcast failure does not mask a successful registry PATH write. Previously, if SetEnvironmentVariable threw after SetValue already committed the new PATH, Add-ToUserPath would return $false and the caller would skip Refresh-SessionPath. * PATH helper polish: venv precedence, quoted entries, raw/expanded dedup Three small follow-ups surfaced by a 10-reviewer pass against the rebased PR head. None fix a regression vs main; each strictly improves the new helpers. Refresh-SessionPath / Refresh-Environment: - Move $env:Path to the front of the merge so an activated venv keeps precedence over machine/user PATH after a refresh. Pre-PR dropped process-only entries entirely; post-PR kept them but at the back. - Dedup on both raw and expanded forms so %USERPROFILE%\foo and the already-expanded C:\Users\me\foo do not both survive. Add-ToUserPath: - Trim whitespace and surrounding double-quotes from each compared entry so quoted PATH entries like "C:\Program Files\CMake\bin" deduplicate against an unquoted directory of the same path. * Back up User PATH inside Add-ToUserPath, before first mutation Previously only studio/setup.ps1 took a one-time PATH backup, at script top (line ~547). install.ps1 (the irm | iex entry point) had no backup, so users who installed via that path had no recovery surface if anything clobbered their PATH. The PR description's "one-time backup before any modifications" promise only held for the studio installer flow. Move the backup into Add-ToUserPath itself: just before the first actual SetValue mutation, write the pristine raw PATH to HKCU\Software\Unsloth\PathBackup if no backup already exists. This: - Covers both entry points (install.ps1 and studio/setup.ps1). - Captures the TRUE pristine PATH even when install.ps1 runs first and studio/setup.ps1 runs afterwards (the script-top backup in setup.ps1 would otherwise see an already-modified PATH). - Is idempotent: once a backup exists, subsequent calls preserve it. - Skips when nothing would mutate (dedup match) or PATH is empty. The script-top backup in studio/setup.ps1 is kept for defense in depth. * Refresh PATH: venv-aware merge order Reconcile two competing concerns about Refresh-SessionPath / Refresh-Environment surfaced by separate review rounds: - venv at the back -> activated venv loses precedence to system Python - process at the front -> stale shims (old node, old python, etc.) still on $env:Path can beat a freshly installed tool New merge order: 1. Activated venv Scripts dir, only if $env:VIRTUAL_ENV is set 2. Machine PATH freshly read from registry 3. User PATH freshly read from registry 4. Current $env:Path as fallback This way an explicitly-activated venv keeps priority while a tool the script just installed wins over any stale entry that was already on the inherited shell PATH. When no venv is active, fresh registry entries take precedence as expected. * Append to User PATH by default, close $envKey in finally Add-ToUserPath gains a -Position Append|Prepend parameter defaulting to Append so installing unsloth no longer prepends the bundled venv Scripts directory ahead of the user's existing python / pip on new shells. The four current call sites (install.ps1 launcher, studio/setup.ps1 CMake, nvcc, Python user Scripts) all take the Append default because each one that needs in-session precedence already does an inline $env:Path prepend independently. This matches rustup / cargo / nvm / pyenv / uv behavior. Also wrap the script-top $envKey.GetValue in a try/finally so the registry handle is released even if the read throws. Matches the pattern already used for $backupKey five lines below. * Prepend cmake, nvcc, Python Scripts; keep venv Scripts appended The previous commit switched Add-ToUserPath to append by default so that installing unsloth would not silently hijack the user's system python / pip. That was correct for the venv Scripts dir (which contains python.exe and pip.exe alongside unsloth.exe), but wrong for the three studio/setup call sites. Those persist cmake, the driver-compatible nvcc, and the Python user Scripts dir for future shells, and in all three cases an older tool already earlier in the user PATH would keep winning after the install finished. The nvcc case is especially load-bearing: setup selects a driver-compatible CUDA toolkit, then llama.cpp builds against whatever wins PATH resolution, so a stale older nvcc produces broken builds. Pass -Position 'Prepend' explicitly at the three setup.ps1 call sites (cmake at line 754, nvcc bin at line 1025, Python user Scripts at line 1191). None of those directories holds python.exe, so prepending them does not re-introduce the original hijack problem. Leave the install.ps1 venv Scripts call on the default Append with a comment explaining why. * Symmetric dedup, Prepend reorders duplicates, unsloth shim dir Address three separate findings surfaced by review: 1. Dedup asymmetry (Gemini high-priority): the existing dedup expanded registry entries via ExpandEnvironmentVariables but did NOT expand the new directory. Passing "%USERPROFILE%\foo" when "C:\Users\me\foo" was already in PATH produced a duplicate. Expand both sides so the check is symmetric. 2. -Position Prepend no-op on existing duplicates: the dedup loop returned $false as soon as it saw a match, regardless of position. That left a late-position duplicate in place instead of moving it to the front, so "prepend the newly selected cmake/nvcc" did not always beat an older copy earlier in PATH. Partition entries into kept and dropped lists, then reinsert a single copy at the requested position. Append still returns $false on any match so user-curated orderings are not reshuffled. Prepend also returns $false when the only copy is already at position 0 so we preserve the user's casing. 3. Stop adding the venv Scripts dir to User PATH entirely. That dir holds python.exe and pip.exe alongside unsloth.exe, so neither Prepend nor Append worked: prepend hijacked the user's system python and pip, append made the freshly-installed unsloth.exe lose to any older unsloth.exe earlier on PATH. Replace the Scripts-dir PATH add with a dedicated shim directory that contains only unsloth.cmd, and prepend that dir. The shim calls the venv's unsloth.exe by absolute path so future pip upgrades inside the venv propagate automatically. * Shim via hardlink, Append user Scripts, drop venv sysconfig fallback Three follow-ups to the c0ab1ab shim commit, targeting concerns raised in the second 20-reviewer pass: 1. Shim uses unsloth.exe (hardlink, copy fallback) instead of unsloth.cmd. The batch-file approach had three distinct regressions: - cmd.exe expanded %...% sequences inside user arguments, so prompts like "What does 50% mean?" got mangled before reaching the CLI - Git Bash / MSYS2 / POSIX-style shells on Windows do not resolve bare-name lookups to .cmd files, so `unsloth` stopped working there - Set-Content -Encoding ASCII replaced non-ASCII profile characters with '?', so installs under C:\Users\Jörg\... wrote a broken shim A hardlink (fallback: copy) of unsloth.exe is a native Windows executable with no shell indirection. PATHEXT picks .exe before .cmd in cmd.exe and PowerShell, Git Bash honors .exe natively, subprocess callers hit it directly, and a hardlink stays in sync with the venv on pip upgrades because both names point at the same inode. 2. studio/setup.ps1 Python user Scripts dir is added with default Append instead of -Position Prepend. That directory holds every pip-installed user console script (pip, pytest, huggingface-cli, and so on), not just unsloth, so reordering it silently changed resolution order for unrelated tools. The new install.ps1 shim at PATH position 0 already guarantees `unsloth` resolves to the freshly installed copy, so the Python user Scripts entry only needs to be present, not at the front. 3. The sysconfig lookup in studio/setup.ps1 no longer falls back to sysconfig.get_path('scripts') when the nt_user scheme dir does not exist. When setup.ps1 is invoked from an activated venv (a flow the linked issue actually hits) that fallback returns the venv's Scripts directory, which would then be added to the persisted User PATH and re-introduce the python / pip hijack the shim dir is meant to avoid. Stick strictly to the nt_user scheme; skip the block if it does not exist on disk. * Do not crash installer when unsloth.exe shim is locked The shim update sequence at install.ps1:1095 did a bare Remove-Item / New-Item HardLink / Copy-Item. Under the script's $ErrorActionPreference a locked target (most commonly 'unsloth studio' still running while the user re-invokes the installer) turns the Remove-Item failure into a terminating error that aborts the install with no actionable message. The existing shim is perfectly usable in that state, so there is no reason to abort. Wrap the whole remove/link/copy sequence in a try/catch that logs the probable cause (Studio still running), points at the fix (close Studio and re-run), and lets the installer finish with the old launcher still serving the command. Also only emit the "added unsloth launcher to PATH" step line when the launcher was actually (re)created AND the PATH entry was newly added -- previously the message fired even when the shim refresh silently failed, which was confusing. * Guard shim PATH entry on existence, use NullString for broadcast delete Two follow-ups surfaced by the latest review pass: 1. Do not add the shim directory to User PATH when the launcher was not actually created. Antivirus blocking unsloth.exe, a disk-full volume, or restrictive filesystem permissions can make both the hardlink and the copy fallback fail on a fresh install. In that case the existing sequence would report "added unsloth launcher to PATH" warnings but still prepend the empty $ShimDir to User PATH -- the user sees an install that claims success but then cannot resolve `unsloth` in a new shell. Gate Add-ToUserPath on Test-Path $ShimExe so the PATH entry is only persisted when the launcher is really there. 2. Pass [NullString]::Value instead of $null to the broadcast-delete call in Add-ToUserPath. On PowerShell 7.5 and later (running on .NET 9), a bare $null going into [Environment]::SetEnvironmentVariable can be coerced to an empty string rather than a true .NET null, which sets the dummy UnslothPathRefresh_XXXXXXXX variable to "" in HKCU\Environment instead of deleting it. The leaked variable is visible in System Properties and accumulates one entry per install run. [NullString]::Value is a PowerShell-specific sentinel that crosses the interop boundary as a real null and works on both PS 5.1 and PS 7.x. See PowerShell/PowerShell#24637 for the underlying issue. --------- Co-authored-by: Daniel Han Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> --- install.ps1 | 227 +++++++++++++++++++++++++++++++++++++++++++---- studio/setup.ps1 | 221 ++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 410 insertions(+), 38 deletions(-) diff --git a/install.ps1 b/install.ps1 index ee28a14e5f..26ba14c5e5 100644 --- a/install.ps1 +++ b/install.ps1 @@ -100,22 +100,155 @@ function Install-UnslothStudio { Write-Host "" # ── Helper: refresh PATH from registry (deduplicating entries) ── + # Merge order: + # 1. Activated venv Scripts dir (only if $env:VIRTUAL_ENV is set) so an + # explicitly-activated venv keeps precedence. + # 2. Machine, then User PATH freshly read from registry so a tool we + # just installed wins over any stale shim still in $env:Path. + # 3. Current $env:Path as fallback so process-only entries that nothing + # else covers are not lost. + # Dedup compares both raw and expanded forms so %VAR% references don't + # survive twice (once as %VAR%\foo and once as the expanded 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 ── + # Uses direct registry access to preserve REG_EXPAND_SZ type + # (avoids .NET SetEnvironmentVariable bug that converts to REG_SZ). + # + # Position: 'Append' (default) adds $Directory to the END of the persisted + # User PATH so existing user tools (e.g. system python, pip) keep taking + # precedence in new shells. This matches rustup/cargo/nvm/pyenv/uv behavior + # and avoids silently hijacking resolution of common executables. Pass + # 'Prepend' only when a caller truly needs the new entry to win over + # existing ones at registry scope. In-session precedence should be handled + # by an inline $env:Path = "$Dir;$env:Path" prepend instead. + 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) + # Explicit string[] cast: a single-entry split otherwise collapses + # to a scalar string, which then gets char-indexed and breaks the + # partition loop below. + [string[]]$entries = if ($rawPath) { $rawPath -split ';' } else { @() } + # Normalize both the raw and expanded forms of the new directory + # so dedup catches mirror-image cases: PATH holding %USERPROFILE%\foo + # vs Directory passed as C:\Users\me\foo, and vice versa. + $normalDir = $Directory.Trim().Trim('"').TrimEnd('\').ToLowerInvariant() + $expNormalDir = [Environment]::ExpandEnvironmentVariables($Directory).Trim().Trim('"').TrimEnd('\').ToLowerInvariant() + # Partition existing entries into "kept" (not our dir) and "dropped" + # (matches our dir). Track match indices so we can distinguish + # "already at position 0" from "present but at a late position". + $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 + # Append semantics: if the entry is already anywhere in PATH we + # leave it untouched (idempotent, never reorder user-curated order). + if ($alreadyPresent -and $Position -eq 'Append') { + return $false + } + # Prepend semantics: if the entry is already at position 0 with + # exactly one copy, preserve the user's existing casing/form and + # no-op. Only rebuild when a reorder or dedup is actually needed. + if ($alreadyPresent -and $Position -eq 'Prepend' -and + $matchIndices.Count -eq 1 -and $matchIndices[0] -eq 0) { + return $false + } + # One-time backup of the pristine User PATH before our first + # mutation. Stored under HKCU\Software\Unsloth so a wiped/clobbered + # PATH can be recovered. Idempotent: existing backup is preserved. + 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 + } + # Prepend idempotency: if the new directory was already at + # position 0 (and no duplicates existed elsewhere) the composed + # string matches rawPath byte-for-byte. Skip the registry write + # so we do not broadcast an unnecessary WM_SETTINGCHANGE. + if ($newPath -ceq $rawPath) { + return $false + } + $regKey.SetValue('Path', $newPath, [Microsoft.Win32.RegistryValueKind]::ExpandString) + # Broadcast WM_SETTINGCHANGE so other processes pick up the change. + # Use [NullString]::Value (not $null) for the delete call so the + # sentinel crosses into .NET as a real null reference -- on + # PowerShell 7.5+ / .NET 9, a bare $null here can be coerced to + # an empty string, which sets the dummy variable to "" instead + # of deleting it and leaves UnslothPathRefresh_XXXXXXXX in + # HKCU\Environment permanently. + 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 +1078,80 @@ 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 the `unsloth` command via a single-purpose shim directory ── + # The venv's Scripts dir holds python.exe and pip.exe alongside unsloth.exe, + # so adding that dir to PATH (at either position) has unwanted side effects: + # Prepend hijacks the user's system python / pip in every future shell; + # Append makes the installer's newly-built unsloth.exe lose to any older + # unsloth.exe the user already had earlier on PATH. Both are bad. + # + # Instead we create a small directory that contains only the unsloth + # launcher (hardlinked or copied from the venv's Scripts\unsloth.exe), + # and Prepend just that directory. Benefits over a .cmd wrapper: + # - no batch %...% expansion of user arguments (e.g. prompts with `%`) + # - works in Git Bash / MSYS2 / POSIX-style shells on Windows that do + # not resolve .cmd by bare name + # - no source encoding concerns on non-ASCII profile paths + # - programmatic callers (subprocess.run, child_process.execFile) hit + # the native executable directly instead of shelling into cmd.exe + # We try a hardlink first so pip upgrades inside the venv propagate + # automatically (same inode). If the filesystem or volume rejects the + # hardlink we fall back to a plain copy, which the next install run + # will refresh. + $ShimDir = Join-Path $StudioHome "bin" + New-Item -ItemType Directory -Force -Path $ShimDir | Out-Null + $ShimExe = Join-Path $ShimDir "unsloth.exe" + # Wrap the whole remove/link/copy sequence in a try/catch so a locked + # launcher does not crash the installer. The common case is a re-run + # while the user still has `unsloth studio` open: the existing shim is + # held open by the running process, Remove-Item refuses (and under the + # script's $ErrorActionPreference this would otherwise be fatal). When + # that happens the existing shim is perfectly usable, so we log and + # keep going instead of aborting the install. + $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 { + # Hardlink unavailable (cross-volume, non-NTFS, permissions). Copy + # is self-contained; future pip upgrades inside the venv will not + # update the copy until the user re-runs the installer. + Copy-Item -Path $UnslothExe -Destination $ShimExe -Force -ErrorAction Stop + } + $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 the shim directory to PATH when the launcher actually exists + # in it. Otherwise a total shim-creation failure on a fresh install (e.g. + # antivirus blocks unsloth.exe, disk full, restrictive FS permissions) + # would prepend an empty directory to User PATH and leave the user with + # an install that reports success but cannot resolve `unsloth` in a new + # shell. Also gate the "added to PATH" step message on both a successful + # shim (re)create AND a fresh PATH insertion, so idempotent re-runs stay + # quiet. + $pathAdded = $false + if (Test-Path $ShimExe) { + $pathAdded = Add-ToUserPath -Directory $ShimDir -Position 'Prepend' + } + if ($shimUpdated -and $pathAdded) { + step "path" "added unsloth launcher to PATH" + } + # Sync the current session unconditionally so re-runs in stale terminals + # see the shim, and so PATH entries that the studio/setup.ps1 subprocess + # persisted (cmake, nvcc, Python Scripts) are visible in this parent + # process before it returns control to the user's shell. + Refresh-SessionPath # Launch studio automatically in interactive terminals; # in non-interactive environments (CI, Docker) just print instructions. diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 1ba41360c7..53a559e6e0 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -73,7 +73,152 @@ function Refresh-Environment { } $machinePath = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') $userPath = [System.Environment]::GetEnvironmentVariable('Path', 'User') - $env:Path = "$machinePath;$userPath" + # Merge order: + # 1. Activated venv Scripts dir (only if $env:VIRTUAL_ENV is set) so an + # explicitly-activated venv keeps precedence. + # 2. Machine, then User PATH freshly read from registry so a tool we + # just installed wins over any stale shim still in $env:Path. + # 3. Current $env:Path as fallback so process-only entries that nothing + # else covers are not lost. + # Dedup compares both raw and expanded forms so %VAR% references don't + # survive twice (once as %VAR%\foo and once as the expanded literal). + $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 ── +# Uses direct registry access to preserve REG_EXPAND_SZ type +# (avoids .NET SetEnvironmentVariable bug that converts to REG_SZ). +# +# Position: 'Append' (default) adds $Directory to the END of the persisted +# User PATH so existing user tools (e.g. system python, pip) keep taking +# precedence in new shells. This matches rustup/cargo/nvm/pyenv/uv behavior +# and avoids silently hijacking resolution of common executables. Pass +# 'Prepend' only when a caller truly needs the new entry to win over +# existing ones at registry scope. In-session precedence should be handled +# by an inline $env:Path = "$Dir;$env:Path" prepend instead. +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) + # Explicit string[] cast: a single-entry split otherwise collapses + # to a scalar string, which then gets char-indexed and breaks the + # partition loop below. + [string[]]$entries = if ($rawPath) { $rawPath -split ';' } else { @() } + # Normalize both the raw and expanded forms of the new directory + # so dedup catches mirror-image cases: PATH holding %USERPROFILE%\foo + # vs Directory passed as C:\Users\me\foo, and vice versa. + $normalDir = $Directory.Trim().Trim('"').TrimEnd('\').ToLowerInvariant() + $expNormalDir = [Environment]::ExpandEnvironmentVariables($Directory).Trim().Trim('"').TrimEnd('\').ToLowerInvariant() + # Partition existing entries into "kept" (not our dir) and "dropped" + # (matches our dir). Track match indices so we can distinguish + # "already at position 0" from "present but at a late position". + $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 + # Append semantics: if the entry is already anywhere in PATH we + # leave it untouched (idempotent, never reorder user-curated order). + if ($alreadyPresent -and $Position -eq 'Append') { + return $false + } + # Prepend semantics: if the entry is already at position 0 with + # exactly one copy, preserve the user's existing casing/form and + # no-op. Only rebuild when a reorder or dedup is actually needed. + if ($alreadyPresent -and $Position -eq 'Prepend' -and + $matchIndices.Count -eq 1 -and $matchIndices[0] -eq 0) { + return $false + } + # One-time backup of the pristine User PATH before our first + # mutation. Stored under HKCU\Software\Unsloth so a wiped/clobbered + # PATH can be recovered. Idempotent: existing backup is preserved. + # The script-top backup at line ~547 covers the studio entry point; + # this in-helper backup also covers callers that bypass that block. + 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 + } + # Prepend idempotency: if the new directory was already at + # position 0 (and no duplicates existed elsewhere) the composed + # string matches rawPath byte-for-byte. Skip the registry write + # so we do not broadcast an unnecessary WM_SETTINGCHANGE. + if ($newPath -ceq $rawPath) { + return $false + } + $regKey.SetValue('Path', $newPath, [Microsoft.Win32.RegistryValueKind]::ExpandString) + # Broadcast WM_SETTINGCHANGE so other processes pick up the change. + # Use [NullString]::Value (not $null) for the delete call so the + # sentinel crosses into .NET as a real null reference -- on + # PowerShell 7.5+ / .NET 9, a bare $null here can be coerced to + # an empty string, which sets the dummy variable to "" instead + # of deleting it and leaves UnslothPathRefresh_XXXXXXXX in + # HKCU\Environment permanently. + 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 +638,33 @@ if ($script:StudioVtOk -and -not $env:NO_COLOR) { Write-Host " $Rule" -ForegroundColor DarkGray } +# Back up User PATH before any modifications for recovery. +# Stored under HKCU\Software\Unsloth (not HKCU\Environment) to avoid +# polluting the process environment block with a multi-KB variable. +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 +798,11 @@ 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 so Refresh-Environment does not drop it later. + # Prepend so the newly-selected cmake wins over any older cmake + # entry already in the user PATH (this dir has only cmake.exe, no + # python.exe, so prepending does not hijack the user's interpreter). + 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 +1068,12 @@ $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 to User PATH so it works in new terminals. +# Prepend so the toolkit we just selected (driver-compatible) wins over any +# older CUDA bin dir already on the user PATH. Critical for llama.cpp builds: +# a later Refresh-Environment could otherwise reorder the selected nvcc behind +# a stale one. No hijack risk since this dir has only CUDA tools, no python. +if (Add-ToUserPath -Directory $nvccBinDir -Position 'Prepend') { substep "Persisted CUDA bin dir to user PATH" } @@ -1061,15 +1231,22 @@ 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'))" +# Ensure the user-scheme Python Scripts dir is on PATH so any pip-installed +# console scripts (including `unsloth` if installed via `pip install --user`) +# are discoverable in new terminals. Stick strictly to the 'nt_user' scheme: +# we do NOT fall back to sysconfig.get_path('scripts') because that returns +# the venv's Scripts dir when this setup.ps1 is invoked inside an activated +# venv, which would re-introduce the python / pip hijack that the dedicated +# shim directory (install.ps1) was designed to avoid. +$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') - + # Use Append semantics here: this dir holds ALL user-installed pip + # console scripts (pip, pytest, huggingface-cli, etc.), and reordering + # it to the front of PATH would silently change resolution precedence + # for every one of those tools. Install.ps1 already guarantees the new + # `unsloth` wins via a dedicated shim dir at PATH position 0, so we + # only need to make sure this directory is present, not at the front. + 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 })) { From 6e87bade2504d009c9130b78d46708f0f5cf2166 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 16 Apr 2026 12:01:01 +0000 Subject: [PATCH 05/20] Trim verbose comments in PATH helpers Reduce inline comments from ~160 lines to ~25 across both files. Keep one-line summaries of the "why"; drop multi-paragraph rationale blocks that repeated information already captured in commit messages and PR discussion. --- install.ps1 | 114 ++++++++--------------------------------------- studio/setup.ps1 | 93 +++++++------------------------------- 2 files changed, 34 insertions(+), 173 deletions(-) diff --git a/install.ps1 b/install.ps1 index 26ba14c5e5..dfb54b1e87 100644 --- a/install.ps1 +++ b/install.ps1 @@ -100,15 +100,8 @@ function Install-UnslothStudio { Write-Host "" # ── Helper: refresh PATH from registry (deduplicating entries) ── - # Merge order: - # 1. Activated venv Scripts dir (only if $env:VIRTUAL_ENV is set) so an - # explicitly-activated venv keeps precedence. - # 2. Machine, then User PATH freshly read from registry so a tool we - # just installed wins over any stale shim still in $env:Path. - # 3. Current $env:Path as fallback so process-only entries that nothing - # else covers are not lost. - # Dedup compares both raw and expanded forms so %VAR% references don't - # survive twice (once as %VAR%\foo and once as the expanded literal). + # 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") @@ -132,16 +125,8 @@ function Install-UnslothStudio { } # ── Helper: safely add a directory to the persistent User PATH ── - # Uses direct registry access to preserve REG_EXPAND_SZ type - # (avoids .NET SetEnvironmentVariable bug that converts to REG_SZ). - # - # Position: 'Append' (default) adds $Directory to the END of the persisted - # User PATH so existing user tools (e.g. system python, pip) keep taking - # precedence in new shells. This matches rustup/cargo/nvm/pyenv/uv behavior - # and avoids silently hijacking resolution of common executables. Pass - # 'Prepend' only when a caller truly needs the new entry to win over - # existing ones at registry scope. In-session precedence should be handled - # by an inline $env:Path = "$Dir;$env:Path" prepend instead. + # 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, @@ -152,18 +137,9 @@ function Install-UnslothStudio { $regKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment') try { $rawPath = $regKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) - # Explicit string[] cast: a single-entry split otherwise collapses - # to a scalar string, which then gets char-indexed and breaks the - # partition loop below. - [string[]]$entries = if ($rawPath) { $rawPath -split ';' } else { @() } - # Normalize both the raw and expanded forms of the new directory - # so dedup catches mirror-image cases: PATH holding %USERPROFILE%\foo - # vs Directory passed as C:\Users\me\foo, and vice versa. + [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() - # Partition existing entries into "kept" (not our dir) and "dropped" - # (matches our dir). Track match indices so we can distinguish - # "already at position 0" from "present but at a late position". $kept = New-Object System.Collections.Generic.List[string] $matchIndices = New-Object System.Collections.Generic.List[int] for ($i = 0; $i -lt $entries.Count; $i++) { @@ -179,21 +155,14 @@ function Install-UnslothStudio { $kept.Add($entries[$i]) } $alreadyPresent = $matchIndices.Count -gt 0 - # Append semantics: if the entry is already anywhere in PATH we - # leave it untouched (idempotent, never reorder user-curated order). - if ($alreadyPresent -and $Position -eq 'Append') { + if ($alreadyPresent -and $Position -eq 'Append') { # Append: idempotent no-op return $false } - # Prepend semantics: if the entry is already at position 0 with - # exactly one copy, preserve the user's existing casing/form and - # no-op. Only rebuild when a reorder or dedup is actually needed. - if ($alreadyPresent -and $Position -eq 'Prepend' -and + 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 of the pristine User PATH before our first - # mutation. Stored under HKCU\Software\Unsloth so a wiped/clobbered - # PATH can be recovered. Idempotent: existing backup is preserved. + # One-time backup under HKCU\Software\Unsloth\PathBackup if ($rawPath) { try { $backupKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\Unsloth') @@ -219,21 +188,12 @@ function Install-UnslothStudio { } else { $Directory } - # Prepend idempotency: if the new directory was already at - # position 0 (and no duplicates existed elsewhere) the composed - # string matches rawPath byte-for-byte. Skip the registry write - # so we do not broadcast an unnecessary WM_SETTINGCHANGE. - if ($newPath -ceq $rawPath) { + if ($newPath -ceq $rawPath) { # no actual change return $false } $regKey.SetValue('Path', $newPath, [Microsoft.Win32.RegistryValueKind]::ExpandString) - # Broadcast WM_SETTINGCHANGE so other processes pick up the change. - # Use [NullString]::Value (not $null) for the delete call so the - # sentinel crosses into .NET as a real null reference -- on - # PowerShell 7.5+ / .NET 9, a bare $null here can be coerced to - # an empty string, which sets the dummy variable to "" instead - # of deleting it and leaves UnslothPathRefresh_XXXXXXXX in - # HKCU\Environment permanently. + # 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') @@ -1078,46 +1038,21 @@ shell.Run cmd, 0, False New-StudioShortcuts -UnslothExePath $UnslothExe - # ── Expose the `unsloth` command via a single-purpose shim directory ── - # The venv's Scripts dir holds python.exe and pip.exe alongside unsloth.exe, - # so adding that dir to PATH (at either position) has unwanted side effects: - # Prepend hijacks the user's system python / pip in every future shell; - # Append makes the installer's newly-built unsloth.exe lose to any older - # unsloth.exe the user already had earlier on PATH. Both are bad. - # - # Instead we create a small directory that contains only the unsloth - # launcher (hardlinked or copied from the venv's Scripts\unsloth.exe), - # and Prepend just that directory. Benefits over a .cmd wrapper: - # - no batch %...% expansion of user arguments (e.g. prompts with `%`) - # - works in Git Bash / MSYS2 / POSIX-style shells on Windows that do - # not resolve .cmd by bare name - # - no source encoding concerns on non-ASCII profile paths - # - programmatic callers (subprocess.run, child_process.execFile) hit - # the native executable directly instead of shelling into cmd.exe - # We try a hardlink first so pip upgrades inside the venv propagate - # automatically (same inode). If the filesystem or volume rejects the - # hardlink we fall back to a plain copy, which the next install run - # will refresh. + # ── 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. $ShimDir = Join-Path $StudioHome "bin" New-Item -ItemType Directory -Force -Path $ShimDir | Out-Null $ShimExe = Join-Path $ShimDir "unsloth.exe" - # Wrap the whole remove/link/copy sequence in a try/catch so a locked - # launcher does not crash the installer. The common case is a re-run - # while the user still has `unsloth studio` open: the existing shim is - # held open by the running process, Remove-Item refuses (and under the - # script's $ErrorActionPreference this would otherwise be fatal). When - # that happens the existing shim is perfectly usable, so we log and - # keep going instead of aborting the install. + # 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 { - # Hardlink unavailable (cross-volume, non-NTFS, permissions). Copy - # is self-contained; future pip upgrades inside the venv will not - # update the copy until the user re-runs the installer. - Copy-Item -Path $UnslothExe -Destination $ShimExe -Force -ErrorAction Stop + Copy-Item -Path $UnslothExe -Destination $ShimExe -Force -ErrorAction Stop # fallback: copy } $shimUpdated = $true } catch { @@ -1132,14 +1067,7 @@ shell.Run cmd, 0, False Write-Host " Launch unsloth studio directly via '$UnslothExe' until the next successful install." -ForegroundColor Yellow } } - # Only add the shim directory to PATH when the launcher actually exists - # in it. Otherwise a total shim-creation failure on a fresh install (e.g. - # antivirus blocks unsloth.exe, disk full, restrictive FS permissions) - # would prepend an empty directory to User PATH and leave the user with - # an install that reports success but cannot resolve `unsloth` in a new - # shell. Also gate the "added to PATH" step message on both a successful - # shim (re)create AND a fresh PATH insertion, so idempotent re-runs stay - # quiet. + # Only add to PATH when the launcher actually exists on disk. $pathAdded = $false if (Test-Path $ShimExe) { $pathAdded = Add-ToUserPath -Directory $ShimDir -Position 'Prepend' @@ -1147,11 +1075,7 @@ shell.Run cmd, 0, False if ($shimUpdated -and $pathAdded) { step "path" "added unsloth launcher to PATH" } - # Sync the current session unconditionally so re-runs in stale terminals - # see the shim, and so PATH entries that the studio/setup.ps1 subprocess - # persisted (cmake, nvcc, Python Scripts) are visible in this parent - # process before it returns control to the user's shell. - Refresh-SessionPath + Refresh-SessionPath # sync current session with registry # Launch studio automatically in interactive terminals; # in non-interactive environments (CI, Docker) just print instructions. diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 53a559e6e0..218be7e045 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -73,15 +73,7 @@ function Refresh-Environment { } $machinePath = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') $userPath = [System.Environment]::GetEnvironmentVariable('Path', 'User') - # Merge order: - # 1. Activated venv Scripts dir (only if $env:VIRTUAL_ENV is set) so an - # explicitly-activated venv keeps precedence. - # 2. Machine, then User PATH freshly read from registry so a tool we - # just installed wins over any stale shim still in $env:Path. - # 3. Current $env:Path as fallback so process-only entries that nothing - # else covers are not lost. - # Dedup compares both raw and expanded forms so %VAR% references don't - # survive twice (once as %VAR%\foo and once as the expanded literal). + # 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 } @@ -102,16 +94,8 @@ function Refresh-Environment { } # ── Helper: safely add a directory to the persistent User PATH ── -# Uses direct registry access to preserve REG_EXPAND_SZ type -# (avoids .NET SetEnvironmentVariable bug that converts to REG_SZ). -# -# Position: 'Append' (default) adds $Directory to the END of the persisted -# User PATH so existing user tools (e.g. system python, pip) keep taking -# precedence in new shells. This matches rustup/cargo/nvm/pyenv/uv behavior -# and avoids silently hijacking resolution of common executables. Pass -# 'Prepend' only when a caller truly needs the new entry to win over -# existing ones at registry scope. In-session precedence should be handled -# by an inline $env:Path = "$Dir;$env:Path" prepend instead. +# 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, @@ -122,18 +106,9 @@ function Add-ToUserPath { $regKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment') try { $rawPath = $regKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) - # Explicit string[] cast: a single-entry split otherwise collapses - # to a scalar string, which then gets char-indexed and breaks the - # partition loop below. - [string[]]$entries = if ($rawPath) { $rawPath -split ';' } else { @() } - # Normalize both the raw and expanded forms of the new directory - # so dedup catches mirror-image cases: PATH holding %USERPROFILE%\foo - # vs Directory passed as C:\Users\me\foo, and vice versa. + [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() - # Partition existing entries into "kept" (not our dir) and "dropped" - # (matches our dir). Track match indices so we can distinguish - # "already at position 0" from "present but at a late position". $kept = New-Object System.Collections.Generic.List[string] $matchIndices = New-Object System.Collections.Generic.List[int] for ($i = 0; $i -lt $entries.Count; $i++) { @@ -149,23 +124,14 @@ function Add-ToUserPath { $kept.Add($entries[$i]) } $alreadyPresent = $matchIndices.Count -gt 0 - # Append semantics: if the entry is already anywhere in PATH we - # leave it untouched (idempotent, never reorder user-curated order). - if ($alreadyPresent -and $Position -eq 'Append') { + if ($alreadyPresent -and $Position -eq 'Append') { # Append: idempotent no-op return $false } - # Prepend semantics: if the entry is already at position 0 with - # exactly one copy, preserve the user's existing casing/form and - # no-op. Only rebuild when a reorder or dedup is actually needed. - if ($alreadyPresent -and $Position -eq 'Prepend' -and + 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 of the pristine User PATH before our first - # mutation. Stored under HKCU\Software\Unsloth so a wiped/clobbered - # PATH can be recovered. Idempotent: existing backup is preserved. - # The script-top backup at line ~547 covers the studio entry point; - # this in-helper backup also covers callers that bypass that block. + # One-time backup under HKCU\Software\Unsloth\PathBackup if ($rawPath) { try { $backupKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\Unsloth') @@ -191,21 +157,12 @@ function Add-ToUserPath { } else { $Directory } - # Prepend idempotency: if the new directory was already at - # position 0 (and no duplicates existed elsewhere) the composed - # string matches rawPath byte-for-byte. Skip the registry write - # so we do not broadcast an unnecessary WM_SETTINGCHANGE. - if ($newPath -ceq $rawPath) { + if ($newPath -ceq $rawPath) { # no actual change return $false } $regKey.SetValue('Path', $newPath, [Microsoft.Win32.RegistryValueKind]::ExpandString) - # Broadcast WM_SETTINGCHANGE so other processes pick up the change. - # Use [NullString]::Value (not $null) for the delete call so the - # sentinel crosses into .NET as a real null reference -- on - # PowerShell 7.5+ / .NET 9, a bare $null here can be coerced to - # an empty string, which sets the dummy variable to "" instead - # of deleting it and leaves UnslothPathRefresh_XXXXXXXX in - # HKCU\Environment permanently. + # 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') @@ -638,9 +595,7 @@ if ($script:StudioVtOk -and -not $env:NO_COLOR) { Write-Host " $Rule" -ForegroundColor DarkGray } -# Back up User PATH before any modifications for recovery. -# Stored under HKCU\Software\Unsloth (not HKCU\Environment) to avoid -# polluting the process environment block with a multi-KB variable. +# Back up User PATH under HKCU\Software\Unsloth before any modifications. try { $envKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $false) if ($envKey) { @@ -798,10 +753,7 @@ 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. - # Prepend so the newly-selected cmake wins over any older cmake - # entry already in the user PATH (this dir has only cmake.exe, no - # python.exe, so prepending does not hijack the user's interpreter). + # 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) { @@ -1068,11 +1020,7 @@ $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. -# Prepend so the toolkit we just selected (driver-compatible) wins over any -# older CUDA bin dir already on the user PATH. Critical for llama.cpp builds: -# a later Refresh-Environment could otherwise reorder the selected nvcc behind -# a stale one. No hijack risk since this dir has only CUDA tools, no python. +# 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" } @@ -1231,21 +1179,10 @@ if ($HasPython) { $PythonOk = $true } -# Ensure the user-scheme Python Scripts dir is on PATH so any pip-installed -# console scripts (including `unsloth` if installed via `pip install --user`) -# are discoverable in new terminals. Stick strictly to the 'nt_user' scheme: -# we do NOT fall back to sysconfig.get_path('scripts') because that returns -# the venv's Scripts dir when this setup.ps1 is invoked inside an activated -# venv, which would re-introduce the python / pip hijack that the dedicated -# shim directory (install.ps1) was designed to avoid. +# 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)) { - # Use Append semantics here: this dir holds ALL user-installed pip - # console scripts (pip, pytest, huggingface-cli, etc.), and reordering - # it to the front of PATH would silently change resolution precedence - # for every one of those tools. Install.ps1 already guarantees the new - # `unsloth` wins via a dedicated shim dir at PATH position 0, so we - # only need to make sure this directory is present, not at the front. + # 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(';') From c5be8b1cd234ceeb15cda929d982a2d84029bf52 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 16 Apr 2026 05:52:33 -0700 Subject: [PATCH 06/20] Chat-template repair: warn-by-default, AST classification, dict support (#5049) * Chat-template repair: warn-by-default, AST classification, dict support Follow-up hardening on top of PR #4426 (which fixed the #4150 RuntimeError for ChatML LoRA reloads). Behavior changes: - Warn-by-default instead of RuntimeError. When fix_chat_template cannot repair a broken template, emit a warning and return the original. Set UNSLOTH_STRICT_CHAT_TEMPLATE=1 to restore the pre-warn hard fail. Fixes the UX where a missing `{% if add_generation_prompt %}` block on a saved LoRA (typical after LlamaFactory / Axolotl re-serialize) would block model loading entirely. - Local path vs HF hub distinguished in the warning message. For local paths the message points at the likely downstream tool; for HF IDs it points at the upstream model maintainers. Previously both said "file a bug report to the maintainers of " even when was the user's own saves/ directory. - Dict / list chat_template now handled. Hermes-3 ships with {default, tool_use} and the previous code crashed with AttributeError: 'dict' object has no attribute 'find' when entering _fix_chat_template with a dict. Each variant is now fixed independently; structure is preserved. Internals: - _find_end_position now matches all four Jinja whitespace-control variants ({% %}, {%- %}, {% -%}, {%- -%}) and returns the rightmost endfor/endif so multi-for templates aren't locked onto the first loop. Previously {%- endfor -%} (both-side dash, used by Qwen3-Guard) was silently bypassed. - _has_add_generation_prompt_block uses Jinja AST via jinja2.nodes.If/Name walks instead of substring matching, so templates that hide the block behind comments or dash-style variants are classified correctly. - _template_ends_with_toplevel_for gates the GH#4150 ChatML repair on the AST: only fires when the last structural top-level node is a For (standard ChatML shape), ignoring trailing pure-whitespace output nodes. Templates wrapped in an outer If (Qwen3-Guard) are now explicitly skipped at the _fix_chat_template level as well, not just at load_correct_tokenizer's name-based exemption. - _validate_patched_template renders the patched template with and without add_generation_prompt and confirms the patched output responds to the flag by appending (not replacing) content. If validation fails, the patch is discarded and we fall through to the warn path. Verified with an expanded regression suite in tests/: - test_fix_chat_template_pr4426.py: 42/42 template-matrix cells - test_load_correct_tokenizer_pr4426.py: 5/5 tokenizer loads - test_chat_template_followups.py: 10/10 new follow-up tests - test_mistral_pr4426.py: 5 Mistral variants byte-identical - test_qwen_pr4426.py: 14 Qwen variants byte-identical (Qwen1.5, Qwen2, Qwen2.5-Instruct/Coder/Math/VL, Qwen3, Qwen3-Coder, QwQ, Qwen3-Guard-Gen) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Guard _validate_patched_template against read-only chat_template If tokenizer.chat_template is a property or otherwise read-only, the validation helper would crash with AttributeError when trying to temporarily set the patched template. Catch the assignment failure and return False (skip validation), and best-effort restore in the finally block. * Replace regex separator inference with render-diff; broaden repair to non-ChatML templates The previous `_infer_assistant_separator` was a four-tier regex heuristic that only worked on ChatML-shaped templates and forced a hard `<|im_start|>` / `<|im_end|>` presence gate on Case 2 repair. This meant a Llama-3, Gemma, or Phi-3 template stripped of its generation-prompt block by a downstream tool (LlamaFactory, Axolotl, etc.) would still warn-and-return even though the structural shape is identical to the ChatML case the PR already handles. This replaces the regex with `_derive_assistant_prefix_by_render`: render the template with two dialogs that differ only in assistant content, then `os.path.commonprefix` on the tails captures the exact assistant-turn prefix the template emits. The template itself is ground truth, so non-ChatML shapes work as long as the assistant block is a literal the template emits once per message. Three guards keep the derivation safe: A. both assistant renders extend the base render (no reordering); B. the divergence point is exactly the content-insertion site (sentinel follows the common prefix); C. a user-role cross-check: if a render with a user sentinel also emits the same prefix, role has no effect on output and we reject. A render failure on [user, user] (e.g. Gemma's `raise_exception` alternation check) is evidence that role matters; we accept. Sentinels differ at character 0 so `commonprefix` cannot absorb them, and trailing whitespace/comments after the last `{% endfor %}` are stripped before probing (they would appear in base but not after the appended assistant turn and break Guard A). `_fix_chat_template` and `_repair_string_template` now thread an `is_sharegpt` kwarg; `_fix_chat_template` retries once with `is_sharegpt=True` if the first probe returns None (dual-probe fallback for dict/list callers). The ChatML `<|im_start|>` / `<|im_end|>` hard gate in Case 2 is dropped. `_infer_assistant_separator` is deleted. Verified via: - tests/test_fix_chat_template_pr4426.py: 51/51 cells (new Llama-3, Gemma, Phi-3 broken-template rows all repair FIX-OK) - tests/test_load_correct_tokenizer_pr4426.py: 5/5 - tests/test_chat_template_followups.py: 18/18 (T11-T18 cover non-ChatML repair + probe failure modes) - tests/test_mistral_pr4426.py: 5/5 byte-identical - tests/test_qwen_pr4426.py: 14/14 byte-identical (Qwen3-Guard AST gate still rejects) - tests/hermes3_lora_pr4426.py reload: patched template ends with `<|im_start|>assistant\n`, inference returns sensible output. - temp/sim/battery.py: 79/79 followup; vs baseline: 0 regressions, 9 improvements. - Spot-check probe on real stripped tokenizers (Hermes-3, Phi-4, Llama-3.2-1B, Gemma-3-1B): all derive the expected prefix. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address reviewer findings: variant routing, positive-gate detection, comment-safe end scan Resolves three reviewer findings on PR #5049 (`fix/chat-template-followups`): Finding #1 [10/10]: dict/list variants now route through `_fix_chat_template_for_tokenizer` via a new `_VariantTokenizerProxy` adapter. Previously the dict/list branches called `_fix_chat_template` directly, silently bypassing the warn/strict (`UNSLOTH_STRICT_CHAT_TEMPLATE`) contract, the `no == yes` diagnostic, broken-existing-block detection, and `_validate_patched_template` guard. The proxy swaps `base.chat_template` to the variant string before each `apply_chat_template` call so tokenizer globals (`bos_token`, custom filters, `raise_exception`) remain available; if the base is read-only it falls back to isolated Jinja rendering. Finding #2 [1/10]: `_has_add_generation_prompt_block` now requires the `If` body to contain at least one `Output` node (a new `_if_body_emits_content` helper walks descendants). This distinguishes a real generation-prompt block from a header guard like `{% if not add_generation_prompt is defined %}{% set ... %}{% endif %}` (body contains only `Assign`) which references the name but emits nothing. Also dropped a now-redundant `"add_generation_prompt" not in scrubbed` guard in `_fix_chat_template` Case 2 so header-guarded templates still get repaired. Finding #4 [1/10]: `_find_end_position` now replaces Jinja comments with equal-length whitespace before scanning for `{% endfor %}` / `{% endif %}` tokens. This prevents a trailing comment containing those tokens from being picked as the real end tag. Positions in the padded string map 1:1 to positions in the original template. Tests: - tests/test_chat_template_followups.py: 21/21 (T19 strict-mode dict variant, T20 header-guard repair, T21 comment-endfor trap added; T4/T5 stubs updated with a working apply_chat_template that routes through Jinja). - tests/test_fix_chat_template_pr4426.py: 51/51 cells unchanged. - tests/test_load_correct_tokenizer_pr4426.py: 5/5. - tests/test_mistral_pr4426.py: 5/5 byte-identical. - tests/test_qwen_pr4426.py: 14/14 byte-identical. - temp/sim/battery.py: 79/79 followup; 0 regressions vs baseline. - Phase 3 Hermes-3 broken-LoRA reload: inference still returns `'The answer to the equation 2+2 is 4.'`. - Spot-checks on Hermes-3 / Phi-4 / Llama-3.2-1B / Gemma-3-1B real stripped templates: probe still derives the expected prefix. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Tighten comments in chat-template helpers Pure comment minimization across `_find_end_position`, `_has_add_generation_prompt_block`, `_if_body_emits_content`, `_derive_assistant_prefix_by_render`, `_fix_chat_template` Case 2, and `_VariantTokenizerProxy`. No behavior change; same intent, fewer lines. All 21 follow-up tests and the 51-cell Phase 1 matrix still pass. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Sandbox probe, fix is_sharegpt validator mismatch, reject negated gates Three real bugs from the 10-agent Opus review: 1. Probe now uses `jinja2.sandbox.SandboxedEnvironment` instead of bare `jinja2.Environment`. The probe renders at model-load time (before the user calls `apply_chat_template`), so it was a new eager code-execution surface that the base HF tokenizer loading does not have. SandboxedEnvironment blocks attribute-chain exploits at negligible cost. 2. `_repair_string_template` now tries validation with both `is_sharegpt=False` and `is_sharegpt=True`. Previously, when `_fix_chat_template` internally fell back to the other schema via its dual-probe, the outer validation still used the caller's original `is_sharegpt` -- rendering with the wrong message keys and spuriously dropping a valid repair. 3. `_has_add_generation_prompt_block` now skips `If` nodes whose test is a `Not` expression. A negated gate like `{% if not add_generation_prompt %}{{ x }}{% endif %}` fires when agp=False, so its emitting body is not a generation block -- but the old code counted any Name reference regardless of polarity. Cleanup: removed unused `self._label`, added `\r` escape in generation-block literal, switched variant labels to `!r` formatting, removed redundant `import os as _os`. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix jinja2.sandbox import and sandbox proxy fallback Two critical findings from the 20-reviewer pass: 1. [20/20] The proxy read-only fallback used bare `jinja2.Environment`, not sandboxed. All 20 reviewers independently reproduced marker-file creation via `cycler.__init__.__globals__['os'].system(...)` during `fix_chat_template()`. Fixed: fallback now uses `from jinja2.sandbox import SandboxedEnvironment`. 2. [14/20] The render-diff probe did `import jinja2` then referenced `jinja2.sandbox.SandboxedEnvironment`. `jinja2.sandbox` is a submodule that is NOT auto-imported by `import jinja2` on Jinja 3.1.6. This caused `AttributeError` (swallowed by `except Exception`), making the entire Case 2 repair path silently return None in a clean process. The 6 reviewers who saw it work had `jinja2.sandbox` pre-imported by an earlier module in their process. Fixed: both the probe and the proxy fallback now use `from jinja2.sandbox import SandboxedEnvironment`. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- unsloth/tokenizer_utils.py | 682 +++++++++++++++++++++++++++++-------- 1 file changed, 544 insertions(+), 138 deletions(-) diff --git a/unsloth/tokenizer_utils.py b/unsloth/tokenizer_utils.py index 4fc09ed76b..2fed54dc01 100644 --- a/unsloth/tokenizer_utils.py +++ b/unsloth/tokenizer_utils.py @@ -636,173 +636,579 @@ 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. Used to gate the GH#4150 ChatML repair: if the outermost structure + is something else (e.g. an outer If that wraps the whole template, as in + Qwen3-Guard), we shouldn't inject an {% if add_generation_prompt %} + block at the end -- it would land inside or after an unrelated control + structure.""" + try: + import jinja2 + import jinja2.nodes + + ast = jinja2.Environment().parse(chat_template) + except Exception: + return False + for node in reversed(ast.body): + # Skip trailing output nodes that are only whitespace -- they come + # from trailing whitespace/newlines in the source, not from real + # message-rendering logic. + 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 isinstance(node, jinja2.nodes.For) + return False + + +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' literal; else the unique - # `message['role'] + ''` 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 ' 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): + """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(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) + 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) + return ( + "Unsloth: The tokenizer `{name}` does not have a " + "{{% if add_generation_prompt %}} block for generation purposes, and " + "automatic repair was not possible. The model will still load, but " + "`apply_chat_template(add_generation_prompt=True)` may not produce a " + "correct assistant-turn marker. {hint} Set " + "UNSLOTH_STRICT_CHAT_TEMPLATE=1 to raise instead of warn." + ).format(name = name_or_path, hint = source_hint) + + +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") + + # 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. + msg = _format_chat_template_message(name, repaired = False) + if _is_strict_chat_template_mode(): + 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)) + return repaired + + msg = _format_chat_template_message(name, repaired = False) + if _is_strict_chat_template_mode(): + 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.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( From 6764cb9b90c7d9e2e0e170ce70f841d0bcf3d4ea Mon Sep 17 00:00:00 2001 From: Datta Nimmaturi Date: Thu, 16 Apr 2026 19:30:17 +0530 Subject: [PATCH 07/20] Restrict flash attn to <=256 head dim. Consolidate attn impl checks (#5051) * Restrict flash attn to <=256 head dim. Consolidate attn impl checks * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Consolidate the changes into single function * safeguard for dict instead of object * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- unsloth/models/_utils.py | 266 ++++++++++++++++++++----- unsloth/models/llama.py | 2 +- unsloth/models/loader.py | 3 - unsloth/models/sentence_transformer.py | 41 ++-- unsloth/models/vision.py | 61 +----- 5 files changed, 239 insertions(+), 134 deletions(-) diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index 1e0b015c44..820a111a9b 100644 --- a/unsloth/models/_utils.py +++ b/unsloth/models/_utils.py @@ -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): diff --git a/unsloth/models/llama.py b/unsloth/models/llama.py index 425df1c084..999711efdb 100644 --- a/unsloth/models/llama.py +++ b/unsloth/models/llama.py @@ -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 ) diff --git a/unsloth/models/loader.py b/unsloth/models/loader.py index cd12544ae9..fc91178d88 100644 --- a/unsloth/models/loader.py +++ b/unsloth/models/loader.py @@ -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"): diff --git a/unsloth/models/sentence_transformer.py b/unsloth/models/sentence_transformer.py index ad59165a50..541875a3fc 100644 --- a/unsloth/models/sentence_transformer.py +++ b/unsloth/models/sentence_transformer.py @@ -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 "" diff --git a/unsloth/models/vision.py b/unsloth/models/vision.py index e31617f89a..90c93ea3f7 100644 --- a/unsloth/models/vision.py +++ b/unsloth/models/vision.py @@ -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 @@ -613,55 +613,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) @@ -804,9 +767,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) From cae4a742974b09b02a3d8b140b77bc330651ef23 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 16 Apr 2026 14:18:57 +0000 Subject: [PATCH 08/20] Remove legacy venv Scripts entry from User PATH on upgrade Older installers persisted the venv Scripts directory directly in the User PATH registry. The shim approach (added in this PR) no longer writes that entry, but it also did not remove the old one. On upgrade, the legacy entry survived and python.exe / pip.exe from the unsloth venv continued winning resolution in every new shell, which is exactly the hijack the shim was designed to prevent. Before creating the shim, read the current User PATH, filter out any entry matching $VenvDir\Scripts (using the same symmetric raw+expanded comparison as Add-ToUserPath), and write back if changed. This runs once per install and is a no-op on fresh installs where the legacy entry was never written. --- install.ps1 | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/install.ps1 b/install.ps1 index dfb54b1e87..e6cf8f993a 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1042,6 +1042,41 @@ shell.Run cmd, 0, False # 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. + # + # Clean up the legacy venv Scripts PATH entry that older installers wrote. + # Without this, upgrade users keep python/pip hijacked even after the shim + # approach is in place. + $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) + # Broadcast so other processes see the removal immediately. + 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" From 5b8643969ef753a7e58afc944a0e98fc572689a9 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 16 Apr 2026 14:20:43 +0000 Subject: [PATCH 09/20] Revert "Remove legacy venv Scripts entry from User PATH on upgrade" This reverts commit cae4a742974b09b02a3d8b140b77bc330651ef23. --- install.ps1 | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/install.ps1 b/install.ps1 index e6cf8f993a..dfb54b1e87 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1042,41 +1042,6 @@ shell.Run cmd, 0, False # 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. - # - # Clean up the legacy venv Scripts PATH entry that older installers wrote. - # Without this, upgrade users keep python/pip hijacked even after the shim - # approach is in place. - $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) - # Broadcast so other processes see the removal immediately. - 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" From b42e3a120d15eb5c9378b4ef89e2bbe1e26ce3b0 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 16 Apr 2026 07:36:59 -0700 Subject: [PATCH 10/20] Remove legacy venv Scripts entry from User PATH on upgrade (#5060) Older installers persisted the venv Scripts directory directly in the User PATH registry. The shim approach from #4961 no longer writes that entry, but on upgrade the old one survived and python.exe / pip.exe from the unsloth venv continued winning resolution in every new shell. Before creating the shim, read the current User PATH, filter out any entry matching $VenvDir\Scripts (using the same symmetric raw+expanded comparison as Add-ToUserPath), and write back if changed. No-op on fresh installs where the legacy entry was never written. Confirmed on a real Windows machine: `where.exe python` was returning the venv interpreter first even after the shim PR merged. --- install.ps1 | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/install.ps1 b/install.ps1 index dfb54b1e87..3fc9ac4690 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1042,6 +1042,38 @@ shell.Run cmd, 0, False # 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" From ff23ce40b4f511bca0e997be02c6121a6e8d72ea Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 16 Apr 2026 08:02:05 -0700 Subject: [PATCH 11/20] Fix review findings for chat-template repair (#5049) (#5056) * Fix review findings for PR #49 1. Sandbox fallback Jinja env in _VariantTokenizerProxy.apply_chat_template (use SandboxedEnvironment, matching _derive_assistant_prefix_by_render) 2. Unwrap benign outer-If guards in _template_ends_with_toplevel_for so templates like {% if messages %}{% for ... %}{% endfor %}{% endif %} are still repairable (preserves Qwen3-Guard rejection via else-branch and add_generation_prompt-name checks) 3. Preserve raw name_or_path in _VariantTokenizerProxy._source_path so local-path detection works for dict/list variant tokenizers 4. Context-aware strict-mode messages: omit "will still load" and "Set UNSLOTH_STRICT_CHAT_TEMPLATE=1" when already raising * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- unsloth/tokenizer_utils.py | 122 +++++++++++++++++++++++++++---------- 1 file changed, 91 insertions(+), 31 deletions(-) diff --git a/unsloth/tokenizer_utils.py b/unsloth/tokenizer_utils.py index 2fed54dc01..85b3c5308b 100644 --- a/unsloth/tokenizer_utils.py +++ b/unsloth/tokenizer_utils.py @@ -669,11 +669,11 @@ def _find_end_position(template, endfor = None, endif = None): 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. Used to gate the GH#4150 ChatML repair: if the outermost structure - is something else (e.g. an outer If that wraps the whole template, as in - Qwen3-Guard), we shouldn't inject an {% if add_generation_prompt %} - block at the end -- it would land inside or after an unrelated control - structure.""" + 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 @@ -681,20 +681,31 @@ def _template_ends_with_toplevel_for(chat_template): ast = jinja2.Environment().parse(chat_template) except Exception: return False - for node in reversed(ast.body): - # Skip trailing output nodes that are only whitespace -- they come - # from trailing whitespace/newlines in the source, not from real - # message-rendering logic. - 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 isinstance(node, jinja2.nodes.For) - 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): @@ -944,10 +955,18 @@ def _name_is_local_path(name_or_path): return False -def _format_chat_template_message(name_or_path, repaired): +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(name_or_path) + 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 " @@ -961,19 +980,39 @@ def _format_chat_template_message(name_or_path, repaired): "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. The model will still load, but " - "`apply_chat_template(add_generation_prompt=True)` may not produce a " - "correct assistant-turn marker. {hint} Set " - "UNSLOTH_STRICT_CHAT_TEMPLATE=1 to raise instead of warn." - ).format(name = name_or_path, hint = source_hint) + "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): @@ -1041,6 +1080,7 @@ def _fix_chat_template_for_tokenizer(tokenizer, chat_template): 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 @@ -1092,19 +1132,38 @@ def _fix_chat_template_for_tokenizer(tokenizer, chat_template): 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. - msg = _format_chat_template_message(name, repaired = False) - if _is_strict_chat_template_mode(): + 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)) + logger.warning_once( + _format_chat_template_message( + name, + repaired = True, + local_path_source = source_path, + ) + ) return repaired - msg = _format_chat_template_message(name, repaired = False) - if _is_strict_chat_template_mode(): + 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 @@ -1125,6 +1184,7 @@ class _VariantTokenizerProxy: 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 ) From 05ec0f110bb8fd7e1872fc77a6900ee5a2b449f6 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 16 Apr 2026 08:24:08 -0700 Subject: [PATCH 12/20] Studio: Ollama support, recommended folders, Custom Folders UX polish (#5050) * Studio: Ollama support, recommended folders, Custom Folders UX polish Backend: - Add _scan_ollama_dir that reads manifests/registry.ollama.ai/library/* and creates .gguf symlinks under /.studio_links/ pointing at the content-addressable blobs, so detect_gguf_model and llama-server -m work unchanged for Ollama models - Filter entries under .studio_links from the generic models/hf/lmstudio scanners to avoid duplicate rows and leaked internal paths in the UI - New GET /api/models/recommended-folders endpoint returning LM Studio and Ollama model directories that currently exist on the machine (OLLAMA_MODELS env var + standard paths, ~/.lmstudio/models, legacy LM Studio cache), used by the Custom Folders quick-add chips - detect_gguf_model now uses os.path.abspath instead of Path.resolve so the readable symlink name is preserved as display_name (e.g. qwen2.5-0.5b-Q4_K_M.gguf instead of sha256-abc...) - llama-server failure with a path under .studio_links or .cache/ollama surfaces a friendlier message ("Some Ollama models do not work with llama.cpp. Try a different model, or use this model directly through Ollama instead.") instead of the generic validation error Frontend: - ListLabel supports an optional leading icon and collapse toggle; used for Downloaded (download icon), Custom Folders (folder icon), and Recommended (star icon) - Custom Folders header gets folder icon on the left, and +, search, and chevron buttons on the right; chevron uses ml-auto so it aligns with the Downloaded and Recommended chevrons - New recommended folder chips render below the registered scan folders when there are unregistered well-known paths; one click adds them as a scan folder - Custom folder rows that are direct .gguf files (Ollama symlinks) load immediately via onSelect instead of opening the GGUF variant expander (which is for repos containing multiple quants, not single files) - When loading a direct .gguf file path, send max_seq_length = 0 so the backend uses the model's native context instead of the 4096 chat default (qwen2.5:0.5b now loads at 32768 instead of 4096) - New listRecommendedFolders() helper on the chat API * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address review: log silent exceptions and support read-only Ollama dirs Replace silent except blocks in _scan_ollama_dir and the recommended-folders endpoint with narrower exception types plus debug or warning logs, so failures are diagnosable without hiding signal. Add _ollama_links_dir helper that falls back to a per-ollama-dir hashed namespace under Studio's own cache (~/.unsloth/studio/cache/ollama_links) when the Ollama models directory is read-only. Common for system installs at /usr/share/ollama/.ollama/models and /var/lib/ollama/.ollama/models where the Studio process has read but not write access. Previously the scanner returned an empty list in that case and Ollama models would silently not appear. The fallback preserves the .gguf suffix on symlink names so detect_gguf_model keeps recognising them. The prior "raw sha256 blob path" fallback would have missed the suffix check and failed to load. * Address review: detect mmproj next to symlink target for vision GGUFs Codex P1 on model_config.py:1012: when detect_gguf_model returns the symlink path (to preserve readable display names), detect_mmproj_file searched the symlink's parent directory instead of the target's. For vision GGUFs surfaced via Ollama's .studio_links/ -- where the weight file is symlinked but any mmproj sidecar lives next to the real blob -- mmproj was no longer detected, so the model was misclassified as text-only and llama-server would start without --mmproj. detect_mmproj_file now adds the resolved target's parent to the scan order when path is a symlink. Direct (non-symlink) .gguf paths are unchanged, so LM Studio and HF cache layouts keep working exactly as before. Verified with a fake layout reproducing the bug plus a regression check on a non-symlink LM Studio model. * Address review: support all Ollama namespaces and vision projector layers - Iterate over all directories under registry.ollama.ai/ instead of hardcoding the "library" namespace. Custom namespaces like "mradermacher/llama3" now get scanned and include the namespace prefix in display names, model IDs, and symlink names to avoid collisions. - Create companion -mmproj.gguf symlinks for Ollama vision models that have an "application/vnd.ollama.image.projector" layer, so detect_mmproj_file can find the projector alongside the model. - Extract symlink creation into _make_symlink helper to reduce duplication between model and projector paths. * Address review: move imports to top level and add scan limit - Move hashlib and json imports to the top of the file (PEP 8). - Remove inline `import json as _json` and `import hashlib` from function bodies, use the top-level imports directly. - Add `limit` parameter to `_scan_ollama_dir()` with early exit when the threshold is reached. - Pass `_MAX_MODELS_PER_FOLDER` into the scanner so it stops traversing once enough models are found. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address review: Windows fallback, all registry hosts, collision safety _make_link (formerly _make_symlink): - Falls back to os.link() hardlink when symlink_to() fails (Windows without Developer Mode), then to shutil.copy2 as last resort - Uses atomic os.replace via tmp file to avoid race window where the .gguf path is missing during rescan Scanner now handles all Ollama registry layouts: - Uses rglob over manifests/ instead of hardcoding registry.ollama.ai - Discovers hf.co/org/repo:tag and any other host, not just library/ - Filenames include a stable sha1 hash of the manifest path to prevent collisions between models that normalize to the same stem Per-model subdirectories under .studio_links/: - Each model's links live in their own hash-keyed subdirectory - detect_mmproj_file only sees the projector for that specific model, not siblings from other Ollama models Friendly Ollama error detection: - Now also matches ollama_links/ (the read-only fallback cache path) and model_identifier starting with "ollama/" Recommended folders: - Added os.access(R_OK | X_OK) check so unreadable system directories like /var/lib/ollama/.ollama/models are not advertised as chips * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address review: filter ollama_links from generic scanners The generic scanners (models_dir, hf_cache, lmstudio) already filter out .studio_links to avoid duplicate Ollama entries, but missed the ollama_links fallback cache directory used for read-only Ollama installs. Add it to the filter. * Address review: idempotent link creation and path-component filter _make_link: - Skip recreation when a valid link/copy already exists (samefile or matching size check). Prevents blocking the model-list API with multi-GB copies on repeated scans. - Use uuid4 instead of os.getpid() for tmp file names to avoid race conditions from concurrent scans. - Log cleanup errors instead of silently swallowing them. Path filter: - Use os.sep-bounded checks instead of bare substring match to avoid false positives on paths like "my.studio_links.backup/model.gguf". * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address review: drop copy fallback, targeted glob, robust path filter _make_link: - Drop shutil.copy2 fallback -- copying multi-GB GGUFs inside a sync API request would block the backend. Log a warning and skip the model when both symlink and hardlink fail. Scanner: - Replace rglob("*") with targeted glob patterns (*/*/* and */*/*/*) to avoid traversing unrelated subdirectories in large custom folders. Path filter: - Use Path.parts membership check instead of os.sep substring matching for robustness across platforms. Scan limit: - Skip _scan_ollama_dir when _generic already fills the per-folder cap. * Address review: sha256, top-level uuid import, Path.absolute() - Switch hashlib.sha1 to hashlib.sha256 for path hashing consistency. - Move uuid import to the top of the file instead of inside _make_link. - Replace os.path.abspath with Path.absolute() in detect_gguf_model to match the pathlib style used throughout the codebase. * Address review: fix stale comments (sha1, rglob, copy fallback) Update three docstrings/comments that still referenced the old implementation after recent changes: - sha1 comment now says "not a security boundary" (no hash name) - "rglob" -> "targeted glob patterns" - "file copies as a last resort" -> removed (copy fallback was dropped) * Address review: fix stale links, support all manifest depths, scope error _make_link: - Drop size-based idempotency shortcut that kept stale links after ollama pull updates a tag to a same-sized blob. Only samefile() is used now -- if the link doesn't point at the exact same inode, it gets replaced. Scanner: - Revert targeted glob back to rglob so deeper OCI-style repo names (5+ path segments) are not silently skipped. Ollama error: - Only show "Some Ollama models do not work with llama.cpp" when the server output contains GGUF compatibility hints (key not found, unknown architecture, failed to load). Unrelated failures like OOM or missing binaries now show the generic error instead of being misdiagnosed. --------- Co-authored-by: Daniel Han Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen --- studio/backend/core/inference/llama_cpp.py | 22 ++ studio/backend/routes/models.py | 341 +++++++++++++++++- studio/backend/utils/models/model_config.py | 19 +- .../assistant-ui/model-selector/pickers.tsx | 144 ++++++-- .../src/features/chat/api/chat-api.ts | 6 + .../chat/hooks/use-chat-model-runtime.ts | 3 +- 6 files changed, 500 insertions(+), 35 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index b53fc513de..77b58e22fb 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -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." diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 9e7168eed6..db27ce1907 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -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 ``/.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:: + + /manifests//// + /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 + ``/.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. diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index 44754520e3..a2d48cf009 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -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(): diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index 2f661c2e72..8f318a8292 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -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 ( -
- {children} +
+ + {icon} + {children} + + {onToggle && ( + + )}
); } @@ -489,6 +515,9 @@ export function HubModelPicker({ // Delete confirmation dialog state const [deleteTarget, setDeleteTarget] = useState(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([]); 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)) ? ( <> - Downloaded - {cachedGguf.map((c) => ( + } + collapsed={downloadedCollapsed} + onToggle={() => setDownloadedCollapsed((v) => !v)} + >Downloaded + {!downloadedCollapsed && cachedGguf.map((c) => (
))} - {!chatOnly && + {!downloadedCollapsed && !chatOnly && cachedModels.map((c) => (
@@ -1001,20 +1038,12 @@ export function HubModelPicker({ {!showHfSection ? ( <> -
- +
+ + Custom Folders
- + +
+
+
{/* Folder paths */} - {scanFolders.map((f) => ( + {!customFoldersCollapsed && scanFolders.map((f) => (
))} + {/* 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 ( +
+ {unregistered.map((p) => ( + + ))} +
+ ); + })()} + {/* Add folder input */} - {showFolderInput && ( + {!customFoldersCollapsed && showFolderInput && (
@@ -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 (
{ - 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 ? ( <> - Recommended - {visibleRecommendedIds.length === 0 ? ( + } + collapsed={recommendedCollapsed} + onToggle={() => setRecommendedCollapsed((v) => !v)} + >Recommended + {recommendedCollapsed ? null : visibleRecommendedIds.length === 0 ? (
No default models.
@@ -1203,7 +1291,7 @@ export function HubModelPicker({ ); }) )} - {hasMoreRecommended && ( + {!recommendedCollapsed && hasMoreRecommended && ( <>
@@ -1216,7 +1304,7 @@ export function HubModelPicker({ {showHfSection && filteredRecommendedIds.length > 0 ? ( <> - Recommended + }>Recommended {filteredRecommendedIds.map((id) => { const vram = recommendedVramMap.get(id); return ( diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts index 9aacfc5af4..15ac8748f0 100644 --- a/studio/frontend/src/features/chat/api/chat-api.ts +++ b/studio/frontend/src/features/chat/api/chat-api.ts @@ -262,6 +262,12 @@ export interface BrowseFoldersResponse { model_files_here?: number; } +export async function listRecommendedFolders(): Promise { + 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, diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index 53f0d1d352..037d3182ac 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -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, From b01e9af1240df395bc3361a9c473826214f48d9f Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Thu, 16 Apr 2026 17:46:16 +0200 Subject: [PATCH 13/20] feat(studio): replace navbar with collapsible sidebar (#4936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(studio): replace navbar navigation with collapsible sidebar Add an app-wide sidebar with hover-expand and pin-to-dock behavior. Navigation items (Studio, Recipes, Export, Chat) move from the center pill navbar to the sidebar. Chat threads and recipes render as collapsible sub-lists. Navbar simplified to logo + update + close. - Extend SidebarProvider with pinned/hovered state model - New AppSidebar with animated active indicator, sloth profile menu, theme toggle, guided tour, back/forward navigation - Chat page refactored to URL-driven view state via search params - Extract reusable hooks for chat thread and recipe sidebar data - Guard startViewTransition for browser compatibility - Wrap chat deletions in Dexie transaction for data integrity * feat(studio): move logo to sidebar and make navbar overlay - Sidebar is now full-height with logo in SidebarHeader - Collapsed sidebar shows sticker.png, expanded shows full logo - Navbar is absolute-positioned overlay (no layout space) - Main content extends to top, aligning with navbar controls * feat(studio): full-height sidebar with recents, edge-to-edge nav buttons - Sidebar outside max-w-7xl, pinned to left edge - Remove sidebar rounding, menu buttons rounded-md - Nav buttons flush to sidebar edges with no left rounding - Replace collapsible recipes/chat with flat nav items - Add Recents section with chat history (1 item when not on chat, full on chat) - New Chat as first nav item with PencilEdit02Icon - Cursor pointer on all sidebar buttons - Navbar temporarily hidden for screenshots * fix(studio): fix chat scroll, action bar hover, collapsible recents - Fix sticky composer by removing `relative` override on viewport footer - Action bar buttons only show on hover (autohide=always) - Remove floating border/shadow from action bar - Add scroll space above composer for last message actions - Back/forward buttons use router history (stay in-app) - Recents section collapsible with chevron on chat route - Set html/body/#root height for proper h-full chain * fix(studio): address review feedback, clean up unused code - Unhide navbar (was left hidden from screenshot) - Remove unused imports: SidebarMenuSub*, BubbleChatIcon, ColumnInsertIcon - Remove unused vars: recipeItems, activeRecipeId, canCompare, recipesOpen - Include compare query id in active sidebar selection - Use store type for contextUsage instead of inline type - Simplify noop in sidebar.tsx - Remove empty className prop * feat(studio): add mobile sidebar, recent runs section, and misc UX fixes * feat(studio): scaffold settings feature module with dialog store * feat(studio): add tri-state theme store for settings * feat(chat): add clear-all-chats and export-chat-history utils * feat(studio): add settings dialog shell with tab rail * feat(studio): add appearance tab with theme and sidebar pin * feat(studio): add settings general tab with hf token, auto-title, reset prefs * feat(studio): add settings chat tab with export and clear * feat(studio): add api keys tab with list and revoke flow * feat(studio): add create-key form and reveal dialog * feat(studio): add usage examples panel to api keys tab * feat(studio): add settings about tab with update and shutdown * feat(studio): add settings dropdown item and cmd-comma shortcut * feat(studio): remove legacy api-keys route and chat-sheet preference rows * fix(studio): settings dialog a11y + polish pass * feat(studio): inline api key reveal card replacing nested dialog * fix(studio): hide revoked keys from settings list * refactor(studio): strip navbar and hoist training unload guard * feat(studio): explicit sidebar toggle, remove hover-open and pin icons * fix(studio): use SidebarRight01Icon for collapsed sidebar open toggle * fix(studio): address code review findings for settings dialog * feat(studio): collapsible navigate group with standalone new-chat and compare * fix(studio): chat-only standalone actions, use ColumnInsertIcon for compare * fix(studio): sidebar new-chat/compare state reset and icon-mode collapsible * feat(studio): add compact logo assets for sidebar header * Fixed sidebar design * fix(studio): sidebar delete icon hover contrast and sizing * feat(studio): route-gate sidebar recents (chats off /studio, runs on /studio) * feat(studio): add chat search store * feat(studio): add chat search index hook with snapshot-on-open * feat(studio): add chat search command dialog with global shortcut * feat(studio): wire chat search into sidebar * fix(studio): trim hf token on save, add show/hide toggle, commit on close * revert(studio): restore original sidebar/border colors, brighten sidebar * feat(studio): forward overlayClassName through CommandDialog * fix(studio): wrap search dialog in Command context, redesign as flat 635px card * fix(studio): reserve right padding on recent items so delete icon stops overlapping title * fix(studio): skip hf token unmount-commit during reset-prefs reload * chore(studio): drop unused icon import and unreachable runs navigate branch * fix(studio): chat search index filters archived before limit, batches message query, picks up reasoning text * fix(studio): keep CommandEmpty in tree so empty state renders correctly * fix(studio): cap system prompt and chat template textareas so they scroll instead of growing * fix(studio): attach chat-compare tour anchor to sidebar compare button * fix(studio): persist system theme explicitly so next-themes does not clobber on reload * fix(studio): auto-switch to history tab when selecting a recent run from sidebar * UI overhaul: chatbox, scrollbar, sidebar, and compare view UI Changes: - Redesigned the Compare UI with general cleanup - Redesigned the Chatbox UI - Reduced the width of the user chat bubble for improved readability - Narrowed the user chat box across the content page - Adjusted thinking-box text color to be slightly darker - Removed faded text effect from chat messages - Removed faded text effect from the thinking box - Added a small LLM chat safety note at the bottom of the chatbox - Restyled the scrollbar Layout & Behavior: - Reworked the scrollbar to span the full height of the page (no top/bottom padding) and remain persistently visible when content is scrollable, rather than only on hover - Reworked the Configuration sidebar to span full height — removed rounded corners and borders, with the scrollbar adjusted to match the full top-to-bottom layout - Adjusted the top menu and bottom chatbox content areas to work correctly with the new full-page scroll behavior - Made chat content match the chatbox width, with content sliding slightly behind the chatbox when scrolling - Aligned chat text width with the chatbox for visual consistency, including how far the text extends behind the chatbox Fixes: - Fixed the chatbox not auto-expanding when typing multi-line input while bottom-positioned during an active chat (previously only worked before a chat had started) - Fixed positioning and design of the user chat hover menu buttons to match the assistant chat box — now displayed below the chat bubble instead of on the left side * Fix user message layout in thread component * swap code icon * fix compare layout * fix compare pane flex * Sidebar improvements and fixes - Added scrolling support to the sidebar so menus and recent chats no longer get hidden - Recent chats are now always visible in the sidebar, not hidden when in Studio, Recipes, or Export - Recent chat is now deselected when selecting other navigations - Fixed sidebar glitch where browser resize could make the sidebar and expand button disappear completely - Fixed glitch where the open-sidebar hover tooltip appeared above the logo when clicking expand sidebar - Reduced sidebar width on mobile to around 2/3 of the screen (was too wide) - Made the close-sidebar hover tooltip consistent with the rest of the design - Removed sidebar collapse/expand animation - Small adjustment to chat width * Fix route scrolling, polling, and theme sync issues * Fix Studio page scrolling --------- Co-authored-by: sneakr --- studio/frontend/package.json | 1 + studio/frontend/public/blacklogo-c.png | Bin 0 -> 141545 bytes studio/frontend/public/sticker.png | Bin 0 -> 1013935 bytes studio/frontend/public/whitelogo-c.png | Bin 0 -> 139810 bytes studio/frontend/src/app/router.tsx | 2 - studio/frontend/src/app/routes/__root.tsx | 71 +- studio/frontend/src/app/routes/api-keys.tsx | 18 - studio/frontend/src/app/routes/chat.tsx | 15 +- .../frontend/src/components/app-sidebar.tsx | 607 +++++++++++++ .../src/components/assistant-ui/reasoning.tsx | 41 +- .../src/components/assistant-ui/thread.tsx | 120 ++- studio/frontend/src/components/navbar.tsx | 645 +------------- .../src/components/shutdown-dialog.tsx | 13 +- .../components/ui/animated-theme-toggler.tsx | 76 +- studio/frontend/src/components/ui/command.tsx | 11 +- studio/frontend/src/components/ui/sidebar.tsx | 133 ++- .../src/features/auth/api-keys-page.tsx | 426 --------- studio/frontend/src/features/auth/index.ts | 1 - .../frontend/src/features/chat/chat-page.tsx | 475 ++++------ .../src/features/chat/chat-settings-sheet.tsx | 63 +- .../chat/components/chat-search-dialog.tsx | 120 +++ .../chat/hooks/use-chat-search-index.ts | 156 ++++ .../chat/hooks/use-chat-sidebar-items.ts | 82 ++ .../src/features/chat/shared-composer.tsx | 2 +- .../chat/stores/chat-runtime-store.ts | 4 + .../features/chat/stores/chat-search-store.ts | 18 + .../src/features/chat/thread-sidebar.tsx | 74 +- .../features/chat/utils/clear-all-chats.ts | 15 + .../chat/utils/export-chat-history.ts | 41 + .../hooks/use-recipe-sidebar-items.ts | 22 + .../src/features/settings/api/api-keys.ts | 41 + .../settings/components/api-key-row.tsx | 101 +++ .../settings/components/create-key-form.tsx | 83 ++ .../settings/components/key-reveal-card.tsx | 71 ++ .../settings/components/settings-row.tsx | 39 + .../settings/components/settings-section.tsx | 30 + .../settings/components/theme-segmented.tsx | 58 ++ .../components/update-studio-instructions.tsx | 185 ++++ .../settings/components/usage-examples.tsx | 146 +++ .../frontend/src/features/settings/index.ts | 6 + .../src/features/settings/settings-dialog.tsx | 142 +++ .../settings/stores/settings-dialog-store.ts | 52 ++ .../features/settings/stores/theme-store.ts | 96 ++ .../src/features/settings/tabs/about-tab.tsx | 101 +++ .../features/settings/tabs/api-keys-tab.tsx | 159 ++++ .../features/settings/tabs/appearance-tab.tsx | 40 + .../src/features/settings/tabs/chat-tab.tsx | 129 +++ .../features/settings/tabs/general-tab.tsx | 197 +++++ .../src/features/studio/studio-page.tsx | 33 +- .../hooks/use-training-history-sidebar.ts | 56 ++ .../hooks/use-training-unload-guard.ts | 40 + .../frontend/src/features/training/index.ts | 1 + .../training/stores/training-runtime-store.ts | 4 + .../src/features/training/types/runtime.ts | 2 + studio/frontend/src/hooks/use-mobile.ts | 31 +- studio/frontend/src/hooks/use-sidebar-pin.ts | 59 ++ studio/frontend/src/index.css | 832 ++++++++++-------- 57 files changed, 3920 insertions(+), 2066 deletions(-) create mode 100644 studio/frontend/public/blacklogo-c.png create mode 100644 studio/frontend/public/sticker.png create mode 100644 studio/frontend/public/whitelogo-c.png delete mode 100644 studio/frontend/src/app/routes/api-keys.tsx create mode 100644 studio/frontend/src/components/app-sidebar.tsx delete mode 100644 studio/frontend/src/features/auth/api-keys-page.tsx create mode 100644 studio/frontend/src/features/chat/components/chat-search-dialog.tsx create mode 100644 studio/frontend/src/features/chat/hooks/use-chat-search-index.ts create mode 100644 studio/frontend/src/features/chat/hooks/use-chat-sidebar-items.ts create mode 100644 studio/frontend/src/features/chat/stores/chat-search-store.ts create mode 100644 studio/frontend/src/features/chat/utils/clear-all-chats.ts create mode 100644 studio/frontend/src/features/chat/utils/export-chat-history.ts create mode 100644 studio/frontend/src/features/data-recipes/hooks/use-recipe-sidebar-items.ts create mode 100644 studio/frontend/src/features/settings/api/api-keys.ts create mode 100644 studio/frontend/src/features/settings/components/api-key-row.tsx create mode 100644 studio/frontend/src/features/settings/components/create-key-form.tsx create mode 100644 studio/frontend/src/features/settings/components/key-reveal-card.tsx create mode 100644 studio/frontend/src/features/settings/components/settings-row.tsx create mode 100644 studio/frontend/src/features/settings/components/settings-section.tsx create mode 100644 studio/frontend/src/features/settings/components/theme-segmented.tsx create mode 100644 studio/frontend/src/features/settings/components/update-studio-instructions.tsx create mode 100644 studio/frontend/src/features/settings/components/usage-examples.tsx create mode 100644 studio/frontend/src/features/settings/index.ts create mode 100644 studio/frontend/src/features/settings/settings-dialog.tsx create mode 100644 studio/frontend/src/features/settings/stores/settings-dialog-store.ts create mode 100644 studio/frontend/src/features/settings/stores/theme-store.ts create mode 100644 studio/frontend/src/features/settings/tabs/about-tab.tsx create mode 100644 studio/frontend/src/features/settings/tabs/api-keys-tab.tsx create mode 100644 studio/frontend/src/features/settings/tabs/appearance-tab.tsx create mode 100644 studio/frontend/src/features/settings/tabs/chat-tab.tsx create mode 100644 studio/frontend/src/features/settings/tabs/general-tab.tsx create mode 100644 studio/frontend/src/features/training/hooks/use-training-history-sidebar.ts create mode 100644 studio/frontend/src/features/training/hooks/use-training-unload-guard.ts create mode 100644 studio/frontend/src/hooks/use-sidebar-pin.ts diff --git a/studio/frontend/package.json b/studio/frontend/package.json index ffb3c65719..a2eebd5cb5 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -87,6 +87,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "playwright": "^1.59.1", "typescript": "~5.9.3", "typescript-eslint": "^8.55.0", "vite": "^8.0.1" diff --git a/studio/frontend/public/blacklogo-c.png b/studio/frontend/public/blacklogo-c.png new file mode 100644 index 0000000000000000000000000000000000000000..7ab9959536d21dc48ed49f7782d579033014b9f9 GIT binary patch literal 141545 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