From 1a4ca5eca8d5b37f77e3d08f361d43e824cb7838 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 15 Apr 2026 06:59:36 -0700 Subject: [PATCH 01/24] Fix grad-accum accepts_loss_kwargs detection for vision wrappers (#5036) * Fix grad-accum model_accepts_loss_kwargs detection for vision wrappers Replace the source-string rewrite of Trainer.__init__ with an instance-level accepts_loss_kwargs shadow applied on the loaded model. Covers: 1. Unsloth-compiled forward -> True, so HF Trainer does not double-scale on top of unsloth_fixed_cross_entropy's num_items_in_batch division. 2. Stock forward on a conditional-generation wrapper (Gemma3n, Gemma3 pre-4.57, Qwen-VL family, etc.) where the outer class has no accepts_loss_kwargs but the inner .model declares False -> False. This is the case that reproduces issue #4982 under trust_remote_code or UNSLOTH_COMPILE_DISABLE, where the previous fix's outer-attr check walked past the inner model and fell through to signature inspection. 3. Text LMs without any explicit accepts_loss_kwargs -> leave HF default. The previous .replace()-based patch silently no-ops on transformers 4.48 through 4.52 (variable named model, not unwrapped_model) and is fragile against any upstream reformat. The new helper walks the PEFT / HF wrapper chain, finds the first class that declares accepts_loss_kwargs on its own class dict (type(m).__dict__, not hasattr, to avoid PEFT __getattr__ forwarding), and setattr-shadows that value at every wrapper level so HF Trainer's hasattr(unwrapped_model, ...) check picks it up at whichever level accelerate.unwrap_model returns. Also adds an unconditional post-init clamp of accelerator.gradient_accumulation_steps = 1 to work around the transformers 5.0 through 5.5 GradientAccumulationPlugin regression that makes accelerator.backward divide loss by GA on top of training_step's own /GA division. Fixed upstream in 5.6.0.dev0; no-op on 4.x and 5.6+. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Trim comments * Address review: cover PEFT-after-load and custom compile location Two review findings from 3/20 reviewers: 1. [3 of 20 reviewers] apply_accepts_loss_kwargs_fix was called from the loaders before get_peft_model wraps the base model, so on transformers 4.48-4.52 (which does hasattr on the outer model) the instance shadow on the base model was lost after PEFT wrapping. Fix: also call it from the wrapped Trainer.__init__ so it runs on whatever model the user actually hands to Trainer, which is always the final wrapped form. 2. [1 of 20 reviewers] _forward_is_unsloth_compiled hard-coded the substrings "unsloth_compiled" / "unsloth_cache" in the co_filename check, which misclassifies compiled forwards when UNSLOTH_COMPILE_LOCATION is set to a custom directory. Fix: new _unsloth_compile_cache_leaves helper that reads the env var and matches the basename against path components, honoring both the default and any user override. Verified locally: - PEFT-after-load simulation: HF's hasattr(peft, "accepts_loss_kwargs") now returns True after our init wrapper runs, and value resolves to False on Gemma3n-style inner wrappers. - Custom UNSLOTH_COMPILE_LOCATION simulation: compiled detection returns True for /tmp/my_custom_cache/compiled.py when the env var is set. - End-to-end Gemma-3 270m + LoRA SFT unchanged: loss 4.9626, grad-norm matches prior run, all 4 wrapper levels now carry the shadowed attr. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- unsloth/models/_utils.py | 174 +++++++++++++++++++++++++++++++-------- unsloth/models/llama.py | 3 +- unsloth/models/vision.py | 3 +- 3 files changed, 142 insertions(+), 38 deletions(-) diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index d0cd4ba028..c330dcc32c 100644 --- a/unsloth/models/_utils.py +++ b/unsloth/models/_utils.py @@ -45,6 +45,7 @@ __all__ = [ # "accelerate_old_send_to_device", # "accelerate_new_send_to_device", "patch_gradient_accumulation_fix", + "apply_accepts_loss_kwargs_fix", "patch_compiling_bitsandbytes", "patch_regional_compilation", "patch_layernorm", @@ -2083,47 +2084,148 @@ def patch_gradient_accumulation_fix(Trainer): exec(function, globals()) Trainer.training_step = _unsloth_training_step - # Prevent double scaling gradient accumulation - # https://github.com/huggingface/transformers/pull/37208 - # Patch model_accepts_loss_kwargs detection in Trainer.__init__ - if Trainer.__init__.__name__ != "_unsloth___init__": + # Wrap Trainer.__init__: (1) pre-init, shadow accepts_loss_kwargs on whatever + # model was passed in (covers PEFT wrapping done after FastModel.from_pretrained); + # (2) post-init, clamp accelerator GA to 1 for the transformers 5.0-5.5 + # GradientAccumulationPlugin regression. No-op on 4.x and 5.6+. See #4982. + if not getattr(Trainer, "_unsloth_init_wrapped_for_accelerate_gas", False): + _original_trainer_init = Trainer.__init__ + + def _unsloth_trainer_init(self, *args, **kwargs): + model = kwargs.get("model") + if model is None and len(args) > 0: + model = args[0] + if model is not None: + try: + apply_accepts_loss_kwargs_fix(model) + except Exception: + pass + _original_trainer_init(self, *args, **kwargs) + try: + accelerator = getattr(self, "accelerator", None) + if ( + accelerator is not None + and getattr(accelerator, "gradient_accumulation_steps", 1) > 1 + ): + accelerator.gradient_accumulation_steps = 1 + gs = getattr(accelerator, "gradient_state", None) + if gs is not None and hasattr(gs, "plugin_kwargs"): + try: + gs.plugin_kwargs["num_steps"] = 1 + except Exception: + pass + except Exception: + pass + + _unsloth_trainer_init.__wrapped__ = _original_trainer_init + Trainer.__init__ = _unsloth_trainer_init + Trainer._unsloth_init_wrapped_for_accelerate_gas = True + + +def _unsloth_compile_cache_leaves(): + # Accepts `UNSLOTH_COMPILE_LOCATION` overrides (the env var unsloth_zoo honors). + leaves = {"unsloth_compiled_cache", "unsloth_cache", "unsloth_compiled"} + loc = os.environ.get("UNSLOTH_COMPILE_LOCATION", "") or "" + loc = loc.rstrip("/\\") + if loc: + leaves.add(os.path.basename(loc) or loc) + return leaves + + +def _forward_is_unsloth_compiled(model): + # True iff forward was installed from the Unsloth compile cache directory. + # __module__ stays as the transformers module, so check co_filename. + leaves = _unsloth_compile_cache_leaves() + + def check(m): + if m is None: + return False + fwd = getattr(type(m), "forward", None) + if fwd is None: + return False + code = getattr(fwd, "__code__", None) + fn = getattr(code, "co_filename", "") if code is not None else "" + fn = fn.replace("\\", "/") + parts = set(fn.split("/")) + return any(leaf in parts for leaf in leaves) + + if check(model): + return True + seen = set() + m = model + for _ in range(4): + if m is None or id(m) in seen: + break + seen.add(id(m)) + nxt = getattr(m, "base_model", None) + if nxt is None or nxt is m: + nxt = getattr(m, "model", None) + if nxt is None or nxt is m: + break + if check(nxt): + return True + m = nxt + return False + + +def _find_concrete_accepts_loss_kwargs(model): + # Walk wrapper chain for first class that declares accepts_loss_kwargs in its + # own __mro__ dict. Avoids PEFT __getattr__ forwarding and our own shadow. + seen = set() + m = model + for _ in range(6): + if m is None or id(m) in seen: + break + seen.add(id(m)) + for klass in type(m).__mro__: + if "accepts_loss_kwargs" in klass.__dict__: + return klass.__dict__[ + "accepts_loss_kwargs" + ], f"{klass.__name__}.accepts_loss_kwargs" + nxt = getattr(m, "base_model", None) + if nxt is None or nxt is m: + nxt = getattr(m, "model", None) + if nxt is None or nxt is m: + break + m = nxt + return None, "no explicit accepts_loss_kwargs on any wrapper level" + + +def _shadow_accepts_loss_kwargs(model, value): + # Set the attribute at every wrapper level so HF's hasattr check resolves + # regardless of where accelerator / peft unwrap lands. + seen = set() + m = model + for _ in range(8): + if m is None or id(m) in seen: + break + seen.add(id(m)) try: - init_function = inspect.getsource(Trainer.__init__) + setattr(m, "accepts_loss_kwargs", value) except Exception: - init_function = "" - if init_function is not None: - init_function = textwrap.dedent(init_function) + pass + nxt = getattr(m, "base_model", None) + if nxt is None or nxt is m: + nxt = getattr(m, "model", None) + if nxt is None or nxt is m: + break + m = nxt - # Import all variables that need importing - import transformers.trainer - items_in_trainer = dir(transformers.trainer) - good_items = [] - for item in items_in_trainer: - if item in init_function: - good_items.append(item) - exec( - "from transformers.trainer import (" - + ", ".join(x for x in good_items) - + ")", - globals(), - ) +def apply_accepts_loss_kwargs_fix(model): + # Shadow the correct accepts_loss_kwargs on the model so HF Trainer picks it + # up via hasattr(unwrapped_model, ...). Replaces the old Trainer.__init__ + # source rewrite. Priority: compiled forward -> True; else first class attr + # in wrapper chain; else leave HF default. Issue #4982. + if _forward_is_unsloth_compiled(model): + _shadow_accepts_loss_kwargs(model, True) + return "True (Unsloth compiled forward)" - init_function = init_function.replace( - "def __init__", "def _unsloth___init__", 1 - ) - - # Respect an inner wrapped model's explicit accepts_loss_kwargs flag before inferring from forward(**kwargs). - # https://github.com/unslothai/unsloth/issues/4982 Gemma4ForConditionalGeneration had issues with grad_acc - init_function = init_function.replace( - "self.model_accepts_loss_kwargs = unwrapped_model.accepts_loss_kwargs\n else:", - "self.model_accepts_loss_kwargs = unwrapped_model.accepts_loss_kwargs\n" - ' elif hasattr(getattr(unwrapped_model, "model", None), "accepts_loss_kwargs"):\n' - " self.model_accepts_loss_kwargs = unwrapped_model.model.accepts_loss_kwargs\n" - " else:", - ) - exec(init_function, globals()) - Trainer.__init__ = _unsloth___init__ + value, reason = _find_concrete_accepts_loss_kwargs(model) + if value is None: + return f"default (signature inspection, {reason})" + _shadow_accepts_loss_kwargs(model, value) + return f"{value} ({reason})" def patch_tokenizer(model, tokenizer): diff --git a/unsloth/models/llama.py b/unsloth/models/llama.py index 63cd9b1c8a..425df1c084 100644 --- a/unsloth/models/llama.py +++ b/unsloth/models/llama.py @@ -2695,7 +2695,8 @@ class FastLlamaModel: patch_saving_functions(model) Trainer._inner_training_loop = _fast_inner_training_loop - # Fix gradient accumulation + # Fix gradient accumulation. See issue #4982. + apply_accepts_loss_kwargs_fix(model) patch_gradient_accumulation_fix(Trainer) # Save tokenizer for inference purposes diff --git a/unsloth/models/vision.py b/unsloth/models/vision.py index 5abeb3a81a..2bdff55a56 100644 --- a/unsloth/models/vision.py +++ b/unsloth/models/vision.py @@ -1123,9 +1123,10 @@ class FastBaseModel: ) patch_saving_functions(tokenizer, vision = True) - # Fix gradient accumulation + # Fix gradient accumulation. See issue #4982. from transformers.trainer import Trainer + apply_accepts_loss_kwargs_fix(model) patch_gradient_accumulation_fix(Trainer) # Save tokenizer for inference purposes From 777e1bd0ac34e85e139b3b0f9d03bca993d76170 Mon Sep 17 00:00:00 2001 From: jonahsamost <92005111+jonahsamost@users.noreply.github.com> Date: Wed, 15 Apr 2026 07:21:03 -0700 Subject: [PATCH 02/24] fix (#4887) --- unsloth/models/rl_replacements.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unsloth/models/rl_replacements.py b/unsloth/models/rl_replacements.py index 93a7f89bcb..4d36af62cc 100755 --- a/unsloth/models/rl_replacements.py +++ b/unsloth/models/rl_replacements.py @@ -1154,6 +1154,7 @@ def grpo_trainer_compute_loss(function_name, function): ref_logps, per_token_logps, old_logps, + sampling_per_token_logps, input_ids, completion_mask, self.beta, @@ -1174,7 +1175,6 @@ def grpo_trainer_compute_loss(function_name, function): num_items_in_batch = num_items_in_batch, current_gradient_accumulation_steps = current_gradient_accumulation_steps, num_processes = num_processes, - sampling_per_token_logps = sampling_per_token_logps, ) else: if hasattr(self.args, "loss_type"): From 156f3fc4b07c7a306822d6ef82208dc9d127463f Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 15 Apr 2026 07:33:48 -0700 Subject: [PATCH 03/24] Gate trl disable_gradient_checkpointing patch warning on UNSLOTH_ENABLE_LOGGING (#5038) The "Patched trl.models.utils.disable_gradient_checkpointing with a no-op" warning fires once on every Unsloth import, including from notebooks where the user did not opt into verbose logging. It is a routine integration patch, not an anomaly the user needs to know about. Gate it on UNSLOTH_ENABLE_LOGGING=1 like other diagnostic notices. --- unsloth/models/rl.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/unsloth/models/rl.py b/unsloth/models/rl.py index f444f6bd37..ac9b35a822 100755 --- a/unsloth/models/rl.py +++ b/unsloth/models/rl.py @@ -2018,11 +2018,12 @@ def patch_trl_disable_gradient_checkpointing(): except (AttributeError, TypeError): pass - logger.warning_once( - "Unsloth: Patched trl.models.utils.disable_gradient_checkpointing with " - "a no-op to preserve Unsloth gradient checkpointing across TRL " - "generation passes." - ) + if os.environ.get("UNSLOTH_ENABLE_LOGGING", "0") == "1": + logger.warning_once( + "Unsloth: Patched trl.models.utils.disable_gradient_checkpointing with " + "a no-op to preserve Unsloth gradient checkpointing across TRL " + "generation passes." + ) return From c3cd890357a15c64bb6e0d810559e264e1c8044c Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 15 Apr 2026 07:34:42 -0700 Subject: [PATCH 04/24] Studio: refresh Downloaded GGUF list and recurse into variant subdirs (#5032) * Studio: refresh Downloaded GGUF list and recurse into variant subdirs Two fixes for the model picker's "Downloaded" section. Frontend (`pickers.tsx`): * `HubModelPicker`'s mount effect short-circuited the cached-gguf and cached-models refetch whenever the module-level cache already had entries (`if (alreadyCached) return;`). After downloading a new repo in the same session, reopening the picker rendered the stale cache and the new repo never appeared in "Downloaded" until a full page reload. The early return is removed so the lists are always refreshed on mount; the module cache still drives the initial render so there is no spinner flash when we already had data. Backend (`utils/models/model_config.py`): * `list_local_gguf_variants` and `_find_local_gguf_by_variant` used a non-recursive `Path.glob("*.gguf")`. Some HF GGUF repos (e.g. `unsloth/gemma-4-26B-A4B-it-GGUF`) place the largest quants under a variant-named subdirectory such as `BF16/...gguf`, which the top-level glob missed. Both helpers now use `rglob` and the variant filename is stored as a path relative to the scan root so the locator can still find the file. The flat-layout case (variants directly in the snapshot root) is unchanged: verified against `unsloth/gemma-4-E2B-it-GGUF` which still returns its UD-Q4_K_XL variant correctly. * Studio: emit posix-style relative filenames for local GGUF subdirs `list_local_gguf_variants` was doing `str(f.relative_to(p))`, which on Windows produces backslash-separated paths like `BF16\foo.gguf`. The remote `list_gguf_variants` (HF API path) always returns forward-slash filenames such as `BF16/foo.gguf`, so the two would diverge on Windows. Switch to `.as_posix()` so the local and remote variant filenames stay identical across Linux, macOS, and Windows. Verified by simulating with `PureWindowsPath` in the test suite. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: detect mmproj at snapshot root for nested-variant layouts When _find_local_gguf_by_variant returns a weight file inside a quant-named subdir (e.g. snapshot/BF16/foo.gguf), detect_mmproj_file was scanning only the immediate parent and missing the mmproj file sitting at the snapshot root. The model was then loaded without --mmproj, silently breaking vision support for repos that ship nested variants. detect_mmproj_file now takes an optional search_root and walks up from the weight file to that root, in order, so the mmproj at the snapshot root is picked up. Sibling quant subdirs are not scanned, so an unrelated variant's mmproj does not leak in. Also apply the suggested micro-optimization on relative_to in list_local_gguf_variants -- only build the posix path when storing the first file for a quant. * [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> --- studio/backend/utils/models/model_config.py | 99 ++++++++++++++++--- .../assistant-ui/model-selector/pickers.tsx | 8 +- 2 files changed, 90 insertions(+), 17 deletions(-) diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index fae2337bbd..44754520e3 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -908,32 +908,81 @@ def _is_gguf_filename(filename: str) -> bool: return filename.lower().endswith(".gguf") -def _iter_gguf_files(directory: Path): +def _iter_gguf_files(directory: Path, recursive: bool = False): if not directory.is_dir(): return - for f in directory.iterdir(): + iterator = directory.rglob("*") if recursive else directory.iterdir() + for f in iterator: if f.is_file() and _is_gguf_filename(f.name): yield f -def detect_mmproj_file(path: str) -> Optional[str]: +def detect_mmproj_file(path: str, search_root: Optional[str] = None) -> Optional[str]: """ - Find the mmproj (vision projection) GGUF file in a directory. + Find the mmproj (vision projection) GGUF file for a given model. Args: - path: Directory to search — or a .gguf file (uses its parent dir). + path: Directory to search — or a .gguf file (uses its parent dir + as the starting point). + search_root: Optional outer directory that should also be scanned + (and any directory between it and ``path``). This handles + local layouts where the model weights live in a quant-named + subdir (``snapshot/BF16/foo.gguf``) but the mmproj sits at + the snapshot root (``snapshot/mmproj-BF16.gguf``). When + ``None``, only the immediate parent dir is scanned, matching + the historical behavior. Returns: Full path to the mmproj .gguf file, or None if not found. """ p = Path(path) - search_dir = p.parent if p.is_file() else p - if not search_dir.is_dir(): + start_dir = p.parent if p.is_file() else p + if not start_dir.is_dir(): return None - for f in _iter_gguf_files(search_dir): - if _is_mmproj(f.name): - return str(f.resolve()) + # Build the list of dirs to scan: immediate dir first, then walk up + # to (and including) ``search_root`` if it is an ancestor. We walk + # incrementally rather than recursing into ``search_root`` so we + # don't accidentally pick up an mmproj from a sibling subdir + # belonging to a different model variant. + seen: set[Path] = set() + scan_order: list[Path] = [] + + def _add(d: Path) -> None: + try: + resolved = d.resolve() + except OSError: + return + if resolved in seen or not resolved.is_dir(): + return + seen.add(resolved) + scan_order.append(resolved) + + _add(start_dir) + if search_root is not None: + try: + root_resolved = Path(search_root).resolve() + start_resolved = start_dir.resolve() + # Only walk if start_dir is inside (or equal to) search_root. + if root_resolved == start_resolved or ( + start_resolved.is_relative_to(root_resolved) + if hasattr(start_resolved, "is_relative_to") + else str(start_resolved).startswith(str(root_resolved) + "/") + ): + cur = start_resolved + # Walk up from start_dir to (and including) root_resolved. + while cur != root_resolved and cur.parent != cur: + cur = cur.parent + _add(cur) + if cur == root_resolved: + break + except OSError: + pass + + for d in scan_order: + for f in _iter_gguf_files(d): + if _is_mmproj(f.name): + return str(f.resolve()) return None @@ -1183,7 +1232,11 @@ def list_local_gguf_variants( quant_first_file: dict[str, str] = {} has_vision = False - for f in sorted(_iter_gguf_files(p)): + # Recurse so variant-specific subdirectories (e.g. ``BF16/...gguf`` + # used by some HF GGUF repos for the largest quants) are picked up. + # Filenames in the result preserve the relative subpath so that + # ``_find_local_gguf_by_variant`` can locate the file again. + for f in sorted(_iter_gguf_files(p, recursive = True)): if _is_mmproj(f.name): has_vision = True continue @@ -1193,8 +1246,14 @@ def list_local_gguf_variants( size = 0 quant = _extract_quant_label(f.name) quant_totals[quant] = quant_totals.get(quant, 0) + size + # Only compute the (potentially expensive) relative path when this + # is the first file we've seen for this quant -- after that we'd + # discard the result anyway. Use posix-style separators so the + # filename matches what ``list_gguf_variants`` (the remote HF + # API path) returns on every platform; otherwise Windows would + # emit ``BF16\foo.gguf`` here. if quant not in quant_first_file: - quant_first_file[quant] = f.name + quant_first_file[quant] = f.relative_to(p).as_posix() variants = [ GgufVariantInfo( @@ -1220,9 +1279,11 @@ def _find_local_gguf_by_variant(directory: str, variant: str) -> Optional[str]: if p is None: return None + # Recurse into subdirectories so variants stored under a quant-named + # subdir (e.g. ``BF16/foo-BF16-00001-of-00002.gguf``) are found. matches = sorted( f - for f in _iter_gguf_files(p) + for f in _iter_gguf_files(p, recursive = True) if not _is_mmproj(f.name) and _extract_quant_label(f.name) == variant ) if matches: @@ -1932,8 +1993,16 @@ class ModelConfig: except Exception as e: logger.debug(f"Could not read export metadata: {e}") - # If vision (or mmproj happens to exist), find the mmproj file - mmproj_file = detect_mmproj_file(gguf_file) + # If vision (or mmproj happens to exist), find the mmproj + # file. The recursive variant scan in + # ``_find_local_gguf_by_variant`` may have returned a + # weight file inside a quant-named subdir (e.g. + # ``.../BF16/foo.gguf``) while ``mmproj-*.gguf`` lives + # at the snapshot root. Pass ``search_root=path`` so + # ``detect_mmproj_file`` walks up to the snapshot root + # instead of seeing only the weight file's immediate + # parent. + mmproj_file = detect_mmproj_file(gguf_file, search_root = path) if mmproj_file: gguf_is_vision = True logger.info(f"Detected mmproj for vision: {mmproj_file}") 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 313a950cc1..dc4c210be4 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -600,7 +600,11 @@ export function HubModelPicker({ refreshLocalModelsList(); refreshScanFolders(); - if (alreadyCached) return; + // Always refetch cached GGUF/model lists. The module-level caches give + // an instant render with stale data (no spinner flash), but newly + // downloaded repos won't appear unless we re-hit the backend on every + // mount. Initial state already has cachedReady=alreadyCached, so the + // background refresh is invisible when we already had data. let done = 0; const check = () => { if (++done >= 2) setCachedReady(true); @@ -619,7 +623,7 @@ export function HubModelPicker({ }) .catch(() => {}) .finally(check); - }, [alreadyCached, refreshLocalModelsList, refreshScanFolders]); + }, [refreshLocalModelsList, refreshScanFolders]); const handleDeleteConfirm = useCallback(async () => { if (!deleteTarget) return; From f18e9dddf0cfaf1b6382dfe937430c3d480dd83c Mon Sep 17 00:00:00 2001 From: Avaya Aggarwal <119044997+OnePunchMonk@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:09:11 +0530 Subject: [PATCH 05/24] feat: Add support for OLMo-3 model (#4678) * feat: Add support for OLMo-3 model in mapping and tests * Update unsloth/models/mapper.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update tests/test_get_model_name.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Fix casing, add Think variants, and align version gate for OLMo-3 PR 4678 Mapper: switch slugs from OLMo-3 to canonical Olmo-3 mixed case, drop the non-existent unsloth/Olmo-3-7B-Instruct-bnb-4bit dead alias, and add the already-published Olmo-3-7B-Think and Olmo-3-32B-Think Unsloth mirrors. Loader: change the olmo3 transformers version gate from Version("4.57.0") to Version("4.57.0.dev0") so nightly/source builds that already contain olmo3 are not blocked, matching the OLMo-2, Gemma 3 and Cohere patterns. * Use canonical Olmo-3 casing and cover Think variants in OLMo-3 tests Mirrors the mapper.py fixes on pr-4678-code: HuggingFace canonical slugs for the OLMo-3 family use mixed-case Olmo-3 (not OLMo-3 like OLMo-2), and Unsloth already hosts Olmo-3-7B-Think and Olmo-3-32B-Think mirrors, so the resolution matrix now covers all three published Olmo-3 families. --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Daniel Han --- tests/test_get_model_name.py | 40 ++++++++++++++++++++++++++++++++++++ unsloth/models/loader.py | 9 +++++++- unsloth/models/mapper.py | 12 +++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/tests/test_get_model_name.py b/tests/test_get_model_name.py index ad89f595f0..15dd44f0ec 100644 --- a/tests/test_get_model_name.py +++ b/tests/test_get_model_name.py @@ -64,6 +64,42 @@ class TestGetModelName(unittest.TestCase): "unsloth/Ministral-3-3B-Instruct-2512", True, ), + ( + "allenai/Olmo-3-7B-Instruct", + True, + "unsloth/Olmo-3-7B-Instruct-unsloth-bnb-4bit", + True, + ), + ( + "allenai/Olmo-3-7B-Instruct", + False, + "unsloth/Olmo-3-7B-Instruct", + True, + ), + ( + "allenai/Olmo-3-7B-Think", + True, + "unsloth/Olmo-3-7B-Think-unsloth-bnb-4bit", + True, + ), + ( + "allenai/Olmo-3-7B-Think", + False, + "unsloth/Olmo-3-7B-Think", + True, + ), + ( + "allenai/Olmo-3-32B-Think", + True, + "unsloth/Olmo-3-32B-Think-unsloth-bnb-4bit", + True, + ), + ( + "allenai/Olmo-3-32B-Think", + False, + "unsloth/Olmo-3-32B-Think", + True, + ), ("unsloth/Kimi-K2-Instruct", True, "unsloth/Kimi-K2-Instruct-BF16", True), ("unsloth/Kimi-K2-Instruct", False, "unsloth/Kimi-K2-Instruct", False), # Fallback-to-original behavior @@ -113,6 +149,10 @@ class TestGetModelName(unittest.TestCase): "mistralai/ministral-3-3b-instruct-2512", "unsloth/ministral-3-3b-instruct-2512-unsloth-bnb-4bit", ), + ( + "allenai/olmo-3-7b-instruct", + "unsloth/olmo-3-7b-instruct-unsloth-bnb-4bit", + ), ("unsloth/kimi-k2-instruct", "unsloth/kimi-k2-instruct-bf16"), ] for src, expected in contracts: diff --git a/unsloth/models/loader.py b/unsloth/models/loader.py index a66a7c6abc..cd12544ae9 100644 --- a/unsloth/models/loader.py +++ b/unsloth/models/loader.py @@ -1210,13 +1210,20 @@ class FastModel(FastBaseModel): # Granite-4 rms norms are stored as 16 bit, but we upcast os.environ["UNSLOTH_HIGH_PRECISION_LAYERNORM"] = "1" os.environ["UNSLOTH_DISABLE_STATIC_GENERATION"] = "1" - # Olmo 2 + # OLMo 2 elif "olmo2" in model_types_all and transformers_version < Version( "4.50.0.dev0" ): raise RuntimeError( "Unsloth: OLMo-2 only works on transformers >= 4.50.0." + NIGHTLY ) + # OLMo 3 + elif "olmo3" in model_types_all and transformers_version < Version( + "4.57.0.dev0" + ): + raise RuntimeError( + "Unsloth: OLMo-3 only works on transformers >= 4.57.0." + LATEST + ) elif "falcon_h1" in model_types_all: # Falcon must use float32 Triton ie TRITON_F32_DEFAULT = 'ieee' # since Mamba kernels error out on using lower precision diff --git a/unsloth/models/mapper.py b/unsloth/models/mapper.py index f0f430eb7e..8f2861db68 100644 --- a/unsloth/models/mapper.py +++ b/unsloth/models/mapper.py @@ -762,6 +762,18 @@ __INT_TO_FLOAT_MAPPER = \ "allenai/OLMo-2-0325-32B-Instruct", "unsloth/OLMo-2-0325-32B-Instruct-bnb-4bit", ), + "unsloth/Olmo-3-7B-Instruct-unsloth-bnb-4bit" : ( + "unsloth/Olmo-3-7B-Instruct", + "allenai/Olmo-3-7B-Instruct", + ), + "unsloth/Olmo-3-7B-Think-unsloth-bnb-4bit" : ( + "unsloth/Olmo-3-7B-Think", + "allenai/Olmo-3-7B-Think", + ), + "unsloth/Olmo-3-32B-Think-unsloth-bnb-4bit" : ( + "unsloth/Olmo-3-32B-Think", + "allenai/Olmo-3-32B-Think", + ), "unsloth/Mistral-Small-3.1-24B-Instruct-2503-unsloth-bnb-4bit" : ( "unsloth/Mistral-Small-3.1-24B-Instruct-2503", "mistralai/Mistral-Small-3.1-24B-Instruct-2503", From 7c5464ad71f93365773f68bb9469c9d55a859ad9 Mon Sep 17 00:00:00 2001 From: Avaya Aggarwal <119044997+OnePunchMonk@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:10:03 +0530 Subject: [PATCH 06/24] feat: Add cactus QAT scheme support (#4679) * feat: Add cactus QAT scheme support * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * test(qat): add tests for cactus QAT scheme and fix missing import * Fix cactus QAT scheme: correct MappingType import, tighten PerGroup filter - Drop the broken `from torchao.dtypes import MappingType` import. `MappingType` lives in `torchao.quantization` (and `torchao.quantization.quant_primitives`); it is not exported from `torchao.dtypes` in any supported torchao release (verified on 0.14, 0.16, 0.17). The previous code raised `ImportError` on every cactus call and was masked as a misleading 'torchao not found' error. - Since `IntxWeightOnlyConfig` already defaults `mapping_type` to `MappingType.SYMMETRIC`, drop the explicit kwarg entirely and remove the import. Behavior is unchanged. - Introduce a named `group_size = 32` constant (matches the int4 / fp8-int4 pattern in the surrounding branches) and add a `% group_size == 0` divisibility guard to the filter. `PerGroup(32)` requires `in_features % 32 == 0` at `quantize_()` time, otherwise torchao raises `ValueError: in_features (N) % group_size (32) must be == 0`. The old `in_features >= 32` filter would admit non-aligned widths (e.g. 33, 48, 65, 127) and crash `_prepare_model_for_qat` for those shapes. * Warn when cactus QAT skips non-divisible Linear layers Multiple reviewers flagged that the divisibility guard added in the previous commit can silently leave Linear layers in full precision when their in_features is not a multiple of 32. For currently supported Unsloth models (Qwen, Llama, Gemma, Mistral, Phi) every Linear width is already a multiple of 32/64/128 so this never triggers, but surfacing the coverage gap is cheap and avoids users assuming 100% QAT coverage when they bring a custom model with unusual shapes. Emit a UserWarning listing up to the first 8 skipped layers whenever the cactus filter excludes any Linear due to the modulo guard. This keeps the lenient silent-skip behavior (consistent with int4 / fp8-int4), but stops making it silent. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han --- tests/utils/test_qat.py | 11 ++++++--- unsloth/models/_utils.py | 49 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/tests/utils/test_qat.py b/tests/utils/test_qat.py index 1083712d78..08b8cd3930 100644 --- a/tests/utils/test_qat.py +++ b/tests/utils/test_qat.py @@ -70,6 +70,11 @@ def _test_linear_is_fake_quantized(linear: torch.nn.Linear, qat_scheme: str): weight_fq_class = IntxFakeQuantizer min_in_features = 128 weight_only = True + elif qat_scheme == "cactus": + act_fq_class = None + weight_fq_class = IntxFakeQuantizer + min_in_features = 32 + weight_only = True else: raise ValueError(f"Unknown qat_scheme: {qat_scheme}") @@ -106,7 +111,7 @@ def _test_fake_quantizers_are_called( """ Verify that the fake quantizers are actually called when the model is called. """ - weight_only = qat_scheme == "int8" + weight_only = qat_scheme in ["int8", "cactus"] def _swap_fake_quantizers(model: torch.nn.Module): for name, child in model.named_children(): @@ -167,11 +172,11 @@ def _test_model_fake_quantize(qat_scheme: str, full_finetuning: bool): # TODO: there are bad interactions across tests right now, need to figure out # how to disable model caching before re-enabling this test -@pytest.mark.parametrize("qat_scheme", ["fp8-int4", "fp8-fp8", "int8"]) +@pytest.mark.parametrize("qat_scheme", ["fp8-int4", "fp8-fp8", "int8", "cactus"]) def _test_full_model_fake_quantize(qat_scheme: str): _test_model_fake_quantize(qat_scheme, full_finetuning = True) -@pytest.mark.parametrize("qat_scheme", ["fp8-int4", "fp8-fp8", "int8"]) +@pytest.mark.parametrize("qat_scheme", ["fp8-int4", "fp8-fp8", "int8", "cactus"]) def test_lora_model_fake_quantize(qat_scheme: str): _test_model_fake_quantize(qat_scheme, full_finetuning = False) diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index c330dcc32c..7d99ec3932 100644 --- a/unsloth/models/_utils.py +++ b/unsloth/models/_utils.py @@ -2727,6 +2727,55 @@ def _prepare_model_for_qat( qat_scheme = qat_scheme, base_config_and_filter_fns = [(base_config, filter_fn)], ) + elif qat_scheme == "cactus": + try: + from torchao.quantization import IntxWeightOnlyConfig + except ImportError: + raise ImportError(TORCHAO_MSG) + + # IntxWeightOnlyConfig already defaults to + # `mapping_type = MappingType.SYMMETRIC`, so we intentionally do not + # import `MappingType` here. Matches the upstream Cactus runtime + # int8 / per-group-32 / symmetric weight-only configuration. + group_size = 32 + base_config = IntxWeightOnlyConfig( + weight_dtype = torch.int8, + granularity = PerGroup(group_size), + ) + filter_fn = ( + lambda m, _: isinstance(m, torch.nn.Linear) + and m.in_features >= group_size + and m.in_features % group_size == 0 + ) + # Warn if any Linear layer is skipped by the cactus filter because + # its in_features is not divisible by `group_size`. torchao's + # PerGroup(32) quantizer rejects non-divisible widths at + # `quantize_()` time, so the filter excludes those layers to keep + # the QAT prepare step from crashing. Surface that silently-skipped + # coverage gap to the user so they know some Linears will stay in + # full precision during training. + skipped_cactus_layers = [ + name + for name, module in model.named_modules() + if isinstance(module, torch.nn.Linear) + and module.in_features >= group_size + and module.in_features % group_size != 0 + ] + if skipped_cactus_layers: + preview = ", ".join(skipped_cactus_layers[:8]) + if len(skipped_cactus_layers) > 8: + preview += f", ... ({len(skipped_cactus_layers) - 8} more)" + warnings.warn( + f"Unsloth: qat_scheme='cactus' uses PerGroup({group_size}) " + "which requires in_features to be divisible by " + f"{group_size}. The following Linear layers will be kept " + f"in full precision during QAT: {preview}", + stacklevel = 2, + ) + torchao_config = TorchAOConfig( + qat_scheme = qat_scheme, + base_config_and_filter_fns = [(base_config, filter_fn)], + ) else: raise ValueError(f"Unexpected QAT scheme {qat_scheme}") assert torchao_config is not None, f"TorchAOConfig was not set for {qat_scheme}" From 800ddc95f8457a57cc5642500b193e724902109f Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:52:12 +0400 Subject: [PATCH 07/24] Re-apply #4939: updated models template mappers (#4950) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Reapply "updated models template mappers. added lfm2.5vl450m to transformers 5…" (#4945) This reverts commit 33503ea2482819ad5aaf048f2edd0a69cb540500. * Add missing gemma-4-31B-it bnb-4bit mapper entry and LFM2.5 upstream namespace for PR #4950 - Add unsloth/gemma-4-31B-it-unsloth-bnb-4bit to __INT_TO_FLOAT_MAPPER so the int-to-float resolution works for this model (already listed in TEMPLATE_TO_MODEL_MAPPER but had no mapper entry). - Add LiquidAI/LFM2.5-1.2B-Instruct to lfm-2.5 TEMPLATE_TO_MODEL_MAPPER entry so the canonical upstream namespace is mapped consistently with lfm-2. * Add missing gemma-4-31B-it bnb-4bit Ollama mapping and lfm-2.5 chat template alias - Add unsloth/gemma-4-31B-it-unsloth-bnb-4bit to OLLAMA_TEMPLATE_TO_MODEL_MAPPER so Ollama export works for this model (E2B-it and E4B-it bnb-4bit variants were already present, 31B-it was inconsistently omitted) - Register CHAT_TEMPLATES["lfm-2.5"] as alias of the lfm-2 template to prevent KeyError when Studio resolves LFM2.5 models through MODEL_TO_TEMPLATE_MAPPER * Add missing LFM2 bnb-4bit INT_TO_FLOAT_MAPPER entry unsloth/LFM2-1.2B-unsloth-bnb-4bit is referenced in model_mappings.py but had no mapper.py entry, so model resolution would fail when users load that variant with load_in_4bit=False or when the float name is used with load_in_4bit=True. * Fix review findings for PR #16 1. ollama_template_mappers.py: Restore dropped Gemma-4 base model IDs (E2B, E4B, 31B, 26B-A4B) and add missing google/ upstream IDs to the gemma4 Ollama mapper for consistency with other gemma entries. 2. mapper.py: Remove self-mapping non-bnb-4bit entries from __INT_TO_FLOAT_MAPPER that were polluting FLOAT_TO_INT_MAPPER with lowercase 16-bit names, causing load_in_4bit=True to return bad model names. Add direct MAP_TO_UNSLOTH_16bit entries to preserve the google->unsloth 16-bit redirects. 3. mapper.py: Add LFM2.5 MAP_TO_UNSLOTH_16bit redirect so LiquidAI/LFM2.5-1.2B-Instruct resolves to its unsloth mirror. * Add review tests for PR #4950 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Remove top-level test files These test_*.py files were added at the repo root rather than under tests/. Removing them from this PR; the production mapper changes remain. * Add gemma-4-26B-A4B-it mapping Adds unsloth/gemma-4-26B-A4B-it to __INT_TO_FLOAT_MAPPER as a 2-tuple so google/gemma-4-26B-A4B-it routes to unsloth/gemma-4-26B-A4B-it across INT_TO_FLOAT_MAPPER, FLOAT_TO_INT_MAPPER, and MAP_TO_UNSLOTH_16bit. The 26B-A4B (MoE) model has no bnb-4bit variant, so the key uses the plain unsloth name rather than the -unsloth-bnb-4bit suffix. Removes the now-redundant standalone _add_with_lower call for the -it variant; the 16bit mapping is registered via the dict loop. * Add unsloth-bnb-4bit mappings for gemma-4 base (non-it) models Adds E2B, E4B, 31B base unsloth-bnb-4bit entries to __INT_TO_FLOAT_MAPPER. The 26B-A4B (MoE) base has no bnb-4bit variant on HF, so it stays on the standalone _add_with_lower line for the 16bit-only routing. Removes the redundant _add_with_lower lines for E2B, E4B, 31B base since the dict loop now registers the same google->unsloth route through the 2-tuple entries, plus full FLOAT_TO_INT and INT_TO_FLOAT coverage. --------- Co-authored-by: Daniel Han Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .../backend/utils/datasets/model_mappings.py | 36 +++++++++++++++++++ studio/backend/utils/transformers_version.py | 1 + unsloth/chat_templates.py | 2 ++ unsloth/models/mapper.py | 36 +++++++++++++++++++ unsloth/ollama_template_mappers.py | 7 ++++ 5 files changed, 82 insertions(+) diff --git a/studio/backend/utils/datasets/model_mappings.py b/studio/backend/utils/datasets/model_mappings.py index 95b4791574..7fcac637c6 100644 --- a/studio/backend/utils/datasets/model_mappings.py +++ b/studio/backend/utils/datasets/model_mappings.py @@ -215,6 +215,21 @@ TEMPLATE_TO_MODEL_MAPPER = { "google/gemma-3n-E2B-it", "unsloth/gemma-3n-E2B-it-unsloth-bnb-4bit", ), + "gemma-4": ( + "unsloth/gemma-4-E2B-it", + "google/gemma-4-E2B-it", + "unsloth/gemma-4-E4B-it", + "google/gemma-4-E4B-it", + "unsloth/gemma-4-E2B-it-unsloth-bnb-4bit", + "unsloth/gemma-4-E4B-it-unsloth-bnb-4bit", + ), + "gemma-4-thinking": ( + "unsloth/gemma-4-26B-A4B-it", + "google/gemma-4-26B-A4B-it", + "unsloth/gemma-4-31B-it", + "unsloth/gemma-4-31B-it-unsloth-bnb-4bit", + "google/gemma-4-31B-it", + ), "qwen2.5": ( "unsloth/Qwen2.5-0.5B-Instruct-unsloth-bnb-4bit", "unsloth/Qwen2.5-0.5B-Instruct", @@ -399,6 +414,15 @@ TEMPLATE_TO_MODEL_MAPPER = { "THUDM/GLM-4.7-Flash", "unsloth/GLM-4.7-Flash-bnb-4bit", ), + "lfm-2": ( + "unsloth/LFM2-1.2B", + "LiquidAI/LFM2-1.2B", + "unsloth/LFM2-1.2B-unsloth-bnb-4bit", + ), + "lfm-2.5": ( + "unsloth/LFM2.5-1.2B-Instruct", + "LiquidAI/LFM2.5-1.2B-Instruct", + ), } MODEL_TO_TEMPLATE_MAPPER = {} @@ -414,6 +438,14 @@ for key, values in TEMPLATE_TO_MODEL_MAPPER.items(): TEMPLATE_TO_RESPONSES_MAPPER = { + "gemma-4-thinking": { + "instruction": "<|turn>user\n", + "response": "<|turn>model\n", + }, + "gemma-4": { + "instruction": "<|turn>user\n", + "response": "<|turn>model\n", + }, "gemma-3": { "instruction": "user\n", "response": "model\n", @@ -514,6 +546,10 @@ TEMPLATE_TO_RESPONSES_MAPPER = { "instruction": "<|im_start|>user\n", "response": "<|im_start|>assistant\n", }, + "lfm-2.5": { + "instruction": "<|im_start|>user\n", + "response": "<|im_start|>assistant\n", + }, "starling": { "instruction": "GPT4 Correct User: ", "response": "GPT4 Correct Assistant: ", diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index 0c13b5455b..36c3a4c22d 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -52,6 +52,7 @@ TRANSFORMERS_5_MODEL_SUBSTRINGS: tuple[str, ...] = ( "qwen3.5", # Qwen3.5 family (35B-A3B, etc.) "qwen3-next", # Qwen3-Next and variants "tiny_qwen3_moe", # imdatta0/tiny_qwen3_moe_2.8B_0.7B + "lfm2.5-vl-450m", # LiquidAI/LFM2.5-VL-450M ) # Lowercase substrings for models that require transformers 5.5.0 (checked first). diff --git a/unsloth/chat_templates.py b/unsloth/chat_templates.py index 71f91cc828..326fd59289 100644 --- a/unsloth/chat_templates.py +++ b/unsloth/chat_templates.py @@ -1716,6 +1716,8 @@ liquid_lfm2_template = \ liquid_lfm2_template_eos_token = "<|im_end|>" CHAT_TEMPLATES["lfm-2"] = (liquid_lfm2_template, liquid_lfm2_template_eos_token, False, None) DEFAULT_SYSTEM_MESSAGE["lfm-2"] = None # No system message in Phi-3 +CHAT_TEMPLATES["lfm-2.5"] = (liquid_lfm2_template, liquid_lfm2_template_eos_token, False, None) +DEFAULT_SYSTEM_MESSAGE["lfm-2.5"] = None # =========================================== Starling-LM diff --git a/unsloth/models/mapper.py b/unsloth/models/mapper.py index 8f2861db68..57c1e292c3 100644 --- a/unsloth/models/mapper.py +++ b/unsloth/models/mapper.py @@ -22,6 +22,39 @@ __all__ = [ __INT_TO_FLOAT_MAPPER = \ { + "unsloth/gemma-4-E2B-it-unsloth-bnb-4bit" : ( + "unsloth/gemma-4-E2B-it", + "google/gemma-4-E2B-it", + ), + "unsloth/gemma-4-E4B-it-unsloth-bnb-4bit" : ( + "unsloth/gemma-4-E4B-it", + "google/gemma-4-E4B-it", + ), + "unsloth/gemma-4-31B-it-unsloth-bnb-4bit" : ( + "unsloth/gemma-4-31B-it", + "google/gemma-4-31B-it", + ), + "unsloth/gemma-4-26B-A4B-it" : ( + "unsloth/gemma-4-26B-A4B-it", + "google/gemma-4-26B-A4B-it", + ), + "unsloth/gemma-4-E2B-unsloth-bnb-4bit" : ( + "unsloth/gemma-4-E2B", + "google/gemma-4-E2B", + ), + "unsloth/gemma-4-E4B-unsloth-bnb-4bit" : ( + "unsloth/gemma-4-E4B", + "google/gemma-4-E4B", + ), + "unsloth/gemma-4-31B-unsloth-bnb-4bit" : ( + "unsloth/gemma-4-31B", + "google/gemma-4-31B", + ), + "unsloth/LFM2-1.2B-unsloth-bnb-4bit" : ( + "unsloth/LFM2-1.2B", + "LiquidAI/LFM2-1.2B", + ), + "unsloth/mistral-7b-bnb-4bit" : ( "unsloth/mistral-7b", "mistralai/Mistral-7B-v0.1", @@ -1428,3 +1461,6 @@ for key, values in __INT_TO_FLOAT_MAPPER.items(): for value in values: FLOAT_TO_INT_MAPPER[value.lower()] = lowered_key + +_add_with_lower(MAP_TO_UNSLOTH_16bit, "google/gemma-4-26B-A4B", "unsloth/gemma-4-26B-A4B") +_add_with_lower(MAP_TO_UNSLOTH_16bit, "LiquidAI/LFM2.5-1.2B-Instruct", "unsloth/LFM2.5-1.2B-Instruct") diff --git a/unsloth/ollama_template_mappers.py b/unsloth/ollama_template_mappers.py index 728b08813a..065165d5d7 100644 --- a/unsloth/ollama_template_mappers.py +++ b/unsloth/ollama_template_mappers.py @@ -1978,12 +1978,19 @@ OLLAMA_TEMPLATE_TO_MODEL_MAPPER = { ), "gemma4": ( "unsloth/gemma-4-E2B-it", + "unsloth/gemma-4-E2B-it-unsloth-bnb-4bit", + "google/gemma-4-E2B-it", "unsloth/gemma-4-E2B", "unsloth/gemma-4-E4B-it", + "unsloth/gemma-4-E4B-it-unsloth-bnb-4bit", + "google/gemma-4-E4B-it", "unsloth/gemma-4-E4B", "unsloth/gemma-4-31B-it", + "unsloth/gemma-4-31B-it-unsloth-bnb-4bit", + "google/gemma-4-31B-it", "unsloth/gemma-4-31B", "unsloth/gemma-4-26B-A4B-it", + "google/gemma-4-26B-A4B-it", "unsloth/gemma-4-26B-A4B", ), "gemma3n": ( From f0d03655e802bc4dd0a41c1ea7490fd94f6aaf3d Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 15 Apr 2026 08:04:33 -0700 Subject: [PATCH 08/24] Studio: add folder browser modal for Custom Folders (#5035) * Studio: add folder browser modal for Custom Folders The Custom Folders row in the model picker currently only accepts a typed path. On a remote-served Studio (Colab, shared workstation) that means the user has to guess or paste the exact server-side absolute path. A native browser folder picker can't solve this: HTML `` hides the absolute path for security, and the File System Access API (Chrome/Edge only) returns handles rather than strings, neither of which the server can act on. This PR adds a small in-app directory browser that lists paths on the server and hands the chosen string back to the existing `POST /api/models/scan-folders` flow. ## Backend * New endpoint `GET /api/models/browse-folders`: * `path` query param (expands `~`, accepts relative or absolute; empty defaults to the user's home directory). * `show_hidden` boolean to include dotfiles/dotdirs. * Returns `{current, parent, entries[], suggestions[]}`. `parent` is null at the filesystem root. * Immediate subdirectories only (no recursion); files are never returned. * `entries[].has_models` is a cheap hint: the directory looks like it holds models if it is named `models--*` (HF hub cache layout) or one of the first 64 children is a .gguf/.safetensors/config.json/ adapter_config.json or another `models--*` subfolder. * Sort order: model-bearing dirs, then plain, then hidden; case- insensitive alphabetical within each bucket. * Suggestions auto-populate from HOME, the HF cache root, and any already-registered scan folders, deduplicated. * Error surface: 404 for missing path, 400 for non-directory, 403 on permission errors. Auth-required like the other models routes. * New Pydantic schemas `BrowseEntry` and `BrowseFoldersResponse` in `studio/backend/models/models.py`. ## Frontend * New `FolderBrowser` component (`studio/frontend/src/components/assistant-ui/model-selector/folder-browser.tsx`) using the existing `Dialog` primitive. Features: * Clickable breadcrumb with a `..` row for parent navigation. * Quick-pick chips for the server-provided suggestions. * `Show hidden` checkbox. * In-flight fetch cancellation via AbortController so rapid navigation doesn't flash stale results. * Badges model-bearing directories inline. * `chat-api.ts` gains `browseFolders(path?, showHidden?)` and matching types. * `pickers.tsx` adds a folder-magnifier icon next to the existing `Add` button. Opening the browser seeds it with whatever the user has already typed; confirming fills the text input, leaving the existing validation and save flow unchanged. ## What it does NOT change * The existing text-input flow still works; the browser is additive. * No new permissions or escalation; the endpoint reads only directories the server process is already allowed to read. * No model scanning or filesystem mutation happens from the browser itself -- it just returns basenames for render. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: cap folder-browser entries and expose truncated flag Pointing the folder browser at a huge directory (``/usr/lib``, ``/proc``, or a synthetic tree with thousands of subfolders) previously walked the whole listing and stat-probed every child via ``_looks_like_model_dir``. That is both a DoS shape for the server process and a large-payload surprise for the client. Introduce a hard cap of 2000 subdirectory entries and a ``truncated: bool`` field on the response. The frontend renders a small hint below the list when it fires, prompting the user to narrow the path. Below-cap directories are unchanged. Verified end-to-end against the live backend with a synthetic tree of 2050 directories: response lands at 2000 entries, ``truncated=true``, listing finishes in sub-second time (versus tens of seconds if we were stat-storming). * Studio: suggest LM Studio / Ollama dirs + 2-level model probe Three improvements to the folder-browser, driven by actually dropping an LM Studio-style install (publisher/model/weights.gguf) into the sandbox and walking the UX: ## 1. Quick-pick chips for other local-LLM tools `well_known_model_dirs()` (new) returns paths commonly used by adjacent tools. Only paths that exist are returned so the UI never shows dead chips. * LM Studio current + legacy roots + user-configured `downloadsFolder` from its `settings.json` (reuses the existing `lmstudio_model_dirs()` helper). * Ollama: `$OLLAMA_MODELS` env override, then `~/.ollama/models`, `/usr/share/ollama/.ollama/models`, and `/var/lib/ollama/.ollama/models` (the systemd-service install path surfaced in the upstream "where is everything?" issue). * Generic user-choice locations: `~/models`, `~/Models`. Dedup is stable across all sources. ## 2. Two-level model-bearing probe LM Studio and Ollama both use `root/publisher/model/weights.gguf`. The previous `has_models` heuristic only probed one level, so the publisher dir (whose immediate children are model dirs, not weight files) was always marked as non-model-bearing. Pulled the direct- signal logic into `_has_direct_model_signal` and added a grandchild probe so the classic layout is now recognised. Still O(PROBE^2) worst-case, still returns immediately for `models--*` names (HF cache layout) and for any direct weight file. ## 3. model_files_here hint on response body A leaf model dir (just GGUFs, no subdirs) previously rendered as `(empty directory)` in the modal, confusing users into thinking the folder wasn't scannable. Added a `model_files_here` count on the response (capped at 200) and a small hint row in the modal: `N model files in this folder. Click "Use this folder" to scan it.` ## Verification Simulated an LM Studio install by downloading the real 84 MB `unsloth/SmolLM2-135M-Instruct-Q2_K.gguf` into `~/.lmstudio/models/unsloth/SmolLM2-135M-Instruct-GGUF/`. Confirmed end-to-end: * Home listing suggests `~/.lmstudio/models` as a chip. * Browsing `~/.lmstudio/models` flags `unsloth` (publisher) as `has_models=true` via the 2-level probe. * Browsing the publisher flags `SmolLM2-135M-Instruct-GGUF` (model dir) as `has_models=true`. * Browsing the model dir returns empty entries but `model_files_here=1`, and the frontend renders a hint telling the user it is a valid target. * Studio: one-click scan-folder add + prominent remove + plain search icon Three small Custom Folders UX fixes after real-use walkthrough: * **One-click add from the folder browser**. Confirming `Use this folder` now submits the path directly to `POST /api/models/scan-folders` instead of just populating the text input. `handleAddFolder` takes an optional explicit path so the submit lands in the same tick as `setFolderInput`, avoiding a state-flush race. The typed-path + `Add` button flow is unchanged. * **Prominent remove X on scan folders**. The per-folder delete button was `text-muted-foreground/40` and hidden entirely on desktop until hovered (`md:opacity-0 md:group-hover:opacity-100`). Dropped the hover-only cloak, bumped color to `text-foreground/70`, added a red hover/focus background, and sized the icon up from `size-2.5` to `size-3`. Always visible on every viewport. * **Plain search icon for the Browse button**. `FolderSearchIcon` replaced with `Search01Icon` so it reads as a simple "find a folder" action alongside the existing `Add01Icon`. * Studio: align Custom Folders + and X buttons on the same right edge The Custom Folders header used `px-2.5` with a `p-0.5` icon button, while each folder row used `px-3` with a `p-1` button. That put the X icon 4px further from the right edge than the +. Normalised both rows to `px-2.5` with `p-1` so the two icons share a column. * Studio: empty-state button opens the folder browser directly The first-run empty state for Custom Folders was a text link reading "+ Add a folder to scan for local models" whose click toggled the text input. That's the wrong default: a user hitting the empty state usually doesn't know what absolute path to type, which is exactly what the folder browser is for. * Reword to "Browse for a models folder" with a search-icon affordance so the label matches what the click does. * Click opens the folder browser modal directly. The typed-path + Add button flow is still available via the + icon in the section header, so users who know their path keep that option. * Slightly bump the muted foreground opacity (70 -> hover:foreground) so the button reads as a primary empty-state action rather than a throwaway hint. * Studio: Custom Folders header gets a dedicated search + add button pair The Custom Folders section header had a single toggle button that flipped between + and X. That put the folder-browser entry point behind the separate empty-state link. Cleaner layout: two buttons in the header, search first, then add. * Search icon (left) opens the folder browser modal directly. * Plus icon (right) toggles the text-path input (unchanged). * The first-run empty-state link is removed -- the two header icons cover both flows on every state. Both buttons share the same padding / icon size so they line up with each other and with the per-folder remove X. * Studio: sandbox folder browser + bound caps + UX recoveries PR review fixes for the Custom Folders folder browser. Closes the high-severity CodeQL path-traversal alert and addresses the codex / gemini P2 findings. Backend (studio/backend/routes/models.py): * New _build_browse_allowlist + _is_path_inside_allowlist sandbox. browse_folders now refuses any target that doesn't resolve under HOME, HF cache, Studio dirs, registered scan folders, or the well-known third-party model dirs. realpath() is used so symlink traversal cannot escape the sandbox. Also gates the parent crumb so the up-row hides instead of 403'ing. * _BROWSE_ENTRY_CAP now bounds *visited* iterdir entries, not *appended* entries. Dirs full of files (or hidden subdirs when show_hidden is False) used to defeat the cap. * _count_model_files gets the same visited-count fix. * PermissionError no longer swallowed silently inside the enumeration / counter loops -- now logged at debug. Frontend (folder-browser.tsx, pickers.tsx, chat-api.ts): * splitBreadcrumb stops mangling literal backslashes inside POSIX filenames; only Windows-style absolute paths trigger separator normalization. The Windows drive crumb value is now C:/ (drive root) instead of C: (drive-relative CWD-on-C). * browseFolders accepts and forwards an AbortSignal so cancelled navigations actually cancel the in-flight backend enumeration. * On initial-path fetch error, FolderBrowser now falls back to HOME instead of leaving the modal as an empty dead end. * When the auto-add path (one-click "Use this folder") fails, the failure now surfaces via toast in addition to the inline paragraph (which is hidden when the typed-input panel is closed). * Studio: rebuild browse target from trusted root for CodeQL clean dataflow CodeQL's py/path-injection rule kept flagging the post-validation filesystem operations because the sandbox check lived inside a helper function (_is_path_inside_allowlist) and CodeQL only does intra-procedural taint tracking by default. The user-derived ``target`` was still flowing into ``target.exists`` / ``target.is_dir`` / ``target.iterdir``. The fix: after resolving the user-supplied ``candidate_path``, locate the matching trusted root from the allowlist and rebuild ``target`` by appending each individually-validated segment to that trusted root. Each segment is rejected if it isn't a single safe path component (no separators, no ``..``, no empty/dot). The downstream filesystem ops now operate on a Path constructed entirely from ``allowed_roots`` (trusted) plus those validated segments, so CodeQL's dataflow no longer sees a tainted source. Behavior is unchanged for all valid inputs -- only the construction of ``target`` is restructured. Live + unit tests all pass (58 selected, 7 deselected for Playwright env). * Studio: walk browse paths from trusted roots for CodeQL --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Ubuntu --- studio/backend/models/models.py | 65 +++ studio/backend/routes/models.py | 525 ++++++++++++++++++ .../tests/test_browse_folders_route.py | 86 +++ studio/backend/utils/paths/__init__.py | 2 + studio/backend/utils/paths/storage_roots.py | 45 ++ .../model-selector/folder-browser.tsx | 328 +++++++++++ .../assistant-ui/model-selector/pickers.tsx | 105 ++-- .../src/features/chat/api/chat-api.ts | 36 ++ 8 files changed, 1161 insertions(+), 31 deletions(-) create mode 100644 studio/backend/tests/test_browse_folders_route.py create mode 100644 studio/frontend/src/components/assistant-ui/model-selector/folder-browser.tsx diff --git a/studio/backend/models/models.py b/studio/backend/models/models.py index f67014a17b..46ca4e3784 100644 --- a/studio/backend/models/models.py +++ b/studio/backend/models/models.py @@ -213,3 +213,68 @@ class ScanFolderInfo(BaseModel): id: int = Field(..., description = "Database row ID") path: str = Field(..., description = "Normalized absolute path") created_at: str = Field(..., description = "ISO 8601 creation timestamp") + + +class BrowseEntry(BaseModel): + """A directory entry surfaced by the folder browser.""" + + name: str = Field(..., description = "Entry name (basename, not full path)") + has_models: bool = Field( + False, + description = ( + "Hint that the directory likely contains models " + "(*.gguf, *.safetensors, config.json, or HF-style " + "`models--*` subfolders). Used by the UI to highlight " + "promising candidates; the scanner itself is authoritative." + ), + ) + hidden: bool = Field( + False, + description = "Name starts with a dot (e.g. `.cache`)", + ) + + +class BrowseFoldersResponse(BaseModel): + """Response schema for the folder browser endpoint.""" + + current: str = Field(..., description = "Absolute path of the directory just listed") + parent: Optional[str] = Field( + None, + description = ( + "Parent directory of `current`, or null if `current` is the " + "filesystem root. The frontend uses this to render an `Up` row." + ), + ) + entries: List[BrowseEntry] = Field( + default_factory = list, + description = ( + "Subdirectories of `current`. Sorted with model-bearing " + "directories first, then alphabetically case-insensitive; " + "hidden entries come last within each group." + ), + ) + suggestions: List[str] = Field( + default_factory = list, + description = ( + "Handy starting points (home, HF cache, already-registered " + "scan folders). Rendered as quick-pick chips above the list." + ), + ) + truncated: bool = Field( + False, + description = ( + "True when the listing was capped because the directory had " + "more subfolders than the server is willing to enumerate in " + "one request. The UI should show a hint telling the user to " + "narrow their path." + ), + ) + model_files_here: int = Field( + 0, + description = ( + "Count of GGUF/safetensors files immediately inside " + "``current``. Used by the UI to surface a hint on leaf " + "model directories (which otherwise look `empty` because " + "they contain only files, no subdirectories)." + ), + ) diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 4ce2d0787c..9e7168eed6 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -101,6 +101,8 @@ from models import ( ModelListResponse, ) from models.models import ( + BrowseEntry, + BrowseFoldersResponse, GgufVariantDetail, GgufVariantsResponse, ModelType, @@ -573,6 +575,529 @@ async def remove_scan_folder_endpoint( return {"ok": True} +# 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. +_BROWSE_MODEL_HINT_PROBE = 64 +# Hard cap on how many subdirectory entries we send back. Pointing the +# browser at something like ``/usr/lib`` or ``/proc`` must not stat-storm +# the process or send tens of thousands of rows to the client. +_BROWSE_ENTRY_CAP = 2000 + + +def _count_model_files(directory: Path, cap: int = 200) -> int: + """Count GGUF/safetensors files immediately inside *directory*. + Used to surface a count-hint on the response so the UI can tell + users that a leaf directory (no subdirs, only weights) is a valid + "Use this folder" target. + + Bounded by *visited entries*, not by *match count*: in directories + with many non-model files (or many subdirectories) the scan still + stops after ``cap`` entries so a UI hint never costs more than a + bounded directory walk. + """ + n = 0 + visited = 0 + try: + for f in directory.iterdir(): + visited += 1 + if visited > cap: + break + try: + if f.is_file(): + low = f.name.lower() + if low.endswith((".gguf", ".safetensors")): + n += 1 + except OSError: + continue + except PermissionError as e: + logger.debug("browse-folders: permission denied counting %s: %s", directory, e) + return 0 + except OSError as e: + logger.debug("browse-folders: OS error counting %s: %s", directory, e) + return 0 + return n + + +def _has_direct_model_signal(directory: Path) -> bool: + """Return True if *directory* has an immediate child that signals + it holds a model: a GGUF/safetensors/config.json file, or a + `models--*` subdir (HF hub cache). Bounded by + ``_BROWSE_MODEL_HINT_PROBE`` to stay fast.""" + try: + it = directory.iterdir() + except OSError: + return False + try: + for i, child in enumerate(it): + if i >= _BROWSE_MODEL_HINT_PROBE: + break + try: + name = child.name + if child.is_file(): + low = name.lower() + if low.endswith((".gguf", ".safetensors")): + return True + if low in ("config.json", "adapter_config.json"): + return True + elif child.is_dir() and name.startswith("models--"): + return True + except OSError: + continue + except OSError: + return False + return False + + +def _looks_like_model_dir(directory: Path) -> bool: + """Bounded heuristic used by the folder browser to flag directories + worth exploring. False negatives are fine; the real scanner is + authoritative. + + Three signals, cheapest first: + + 1. Directory name itself: ``models--*`` is the HuggingFace hub cache + layout (``blobs``/``refs``/``snapshots`` children wouldn't match + the file-level probes below). + 2. An immediate child is a weight file or config (handled by + :func:`_has_direct_model_signal`). + 3. A grandchild has a direct signal -- this catches the + ``publisher/model/weights.gguf`` layout used by LM Studio and + Ollama. We probe at most the first + ``_BROWSE_MODEL_HINT_PROBE`` child directories, each of which is + checked with a bounded :func:`_has_direct_model_signal` call, + so the total cost stays O(PROBE^2) worst-case. + """ + if directory.name.startswith("models--"): + return True + if _has_direct_model_signal(directory): + return True + # Grandchild probe: LM Studio / Ollama publisher/model layout. + try: + it = directory.iterdir() + except OSError: + return False + try: + for i, child in enumerate(it): + if i >= _BROWSE_MODEL_HINT_PROBE: + break + try: + if not child.is_dir(): + continue + except OSError: + continue + # Fast name check first + if child.name.startswith("models--"): + return True + if _has_direct_model_signal(child): + return True + except OSError: + return False + return False + + +def _build_browse_allowlist() -> list[Path]: + """Return the list of root directories the folder browser is allowed + to walk. The same list is used to seed the sidebar suggestion chips, + so chip targets are always reachable. + + Roots include the current user's HOME, the resolved HF cache dirs, + Studio's own outputs/exports/studio root, registered scan folders, + and well-known third-party local-LLM dirs (LM Studio, Ollama, + `~/models`). Each is added only if it currently resolves to a real + directory, so we never produce a "dead" sandbox boundary the user + can't navigate into. + """ + from utils.paths import ( + hf_default_cache_dir, + legacy_hf_cache_dir, + well_known_model_dirs, + ) + from storage.studio_db import list_scan_folders + + candidates: list[Path] = [] + + def _add(p: Optional[Path]) -> None: + if p is None: + return + try: + resolved = p.resolve() + except OSError: + return + if resolved.is_dir(): + candidates.append(resolved) + + _add(Path.home()) + _add(_resolve_hf_cache_dir()) + try: + _add(hf_default_cache_dir()) + except Exception: # noqa: BLE001 -- best-effort + pass + try: + _add(legacy_hf_cache_dir()) + except Exception: # noqa: BLE001 -- best-effort + pass + try: + from utils.paths import ( + exports_root, + outputs_root, + studio_root, + ) + + _add(studio_root()) + _add(outputs_root()) + _add(exports_root()) + except Exception as exc: # noqa: BLE001 -- best-effort + logger.debug("browse-folders: studio roots unavailable: %s", exc) + try: + for folder in list_scan_folders(): + p = folder.get("path") + if p: + _add(Path(p)) + except Exception as exc: # noqa: BLE001 -- best-effort + logger.debug("browse-folders: could not load scan folders: %s", exc) + try: + for p in well_known_model_dirs(): + _add(p) + except Exception as exc: # noqa: BLE001 -- best-effort + logger.debug("browse-folders: well-known dirs unavailable: %s", exc) + + # Dedupe while preserving order. + seen: set[str] = set() + deduped: list[Path] = [] + for p in candidates: + key = str(p) + if key in seen: + continue + seen.add(key) + deduped.append(p) + return deduped + + +def _is_path_inside_allowlist(target: Path, allowed_roots: list[Path]) -> bool: + """Return True if *target* equals or is a descendant of any allowed + root. The comparison uses ``os.path.realpath`` so symlinks cannot be + used to escape the sandbox. + """ + try: + target_real = os.path.realpath(str(target)) + except OSError: + return False + for root in allowed_roots: + try: + root_real = os.path.realpath(str(root)) + except OSError: + continue + if target_real == root_real or target_real.startswith(root_real + os.sep): + return True + return False + + +def _normalize_browse_request_path(path: Optional[str]) -> str: + """Normalize the browse request path lexically, without touching the FS.""" + if path is None or not path.strip(): + return os.path.normpath(str(Path.home())) + + expanded = os.path.expanduser(path.strip()) + if not os.path.isabs(expanded): + expanded = os.path.join(str(Path.cwd()), expanded) + return os.path.normpath(expanded) + + +def _browse_relative_parts(requested_path: str, root: Path) -> Optional[list[str]]: + """Return validated relative path components under ``root``.""" + root_text = os.path.normpath(str(root)) + try: + rel_text = os.path.relpath(requested_path, root_text) + except ValueError: + return None + + if rel_text == ".": + return [] + if rel_text == ".." or rel_text.startswith(f"..{os.sep}"): + return None + + parts = [part for part in rel_text.split(os.sep) if part not in ("", ".")] + altsep = os.altsep + for part in parts: + if part == ".." or os.sep in part or (altsep and altsep in part): + return None + return parts + + +def _match_browse_child(current: Path, name: str) -> Optional[Path]: + """Return the immediate child named ``name`` under ``current``.""" + try: + for child in current.iterdir(): + if child.name == name: + return child + except PermissionError: + raise HTTPException( + status_code = 403, + detail = f"Permission denied reading {current}", + ) from None + except OSError as exc: + raise HTTPException( + status_code = 500, + detail = f"Could not read {current}: {exc}", + ) from exc + return None + + +def _resolve_browse_target(path: Optional[str], allowed_roots: list[Path]) -> Path: + """Resolve a requested browse path by walking from trusted allowlist roots.""" + requested_path = _normalize_browse_request_path(path) + resolved_roots: list[Path] = [] + seen_roots: set[str] = set() + for root in sorted(allowed_roots, key = lambda p: len(str(p)), reverse = True): + try: + resolved = root.resolve() + except OSError: + continue + key = str(resolved) + if key in seen_roots: + continue + seen_roots.add(key) + resolved_roots.append(resolved) + + for root in resolved_roots: + parts = _browse_relative_parts(requested_path, root) + if parts is None: + continue + + current = root + for part in parts: + child = _match_browse_child(current, part) + if child is None: + raise HTTPException( + status_code = 404, + detail = f"Path does not exist: {requested_path}", + ) + try: + resolved_child = child.resolve() + except OSError as exc: + raise HTTPException( + status_code = 400, + detail = f"Invalid path: {exc}", + ) from exc + if not _is_path_inside_allowlist(resolved_child, resolved_roots): + raise HTTPException( + status_code = 403, + detail = ( + "Path is not in the browseable allowlist. Register it via " + "POST /api/models/scan-folders first, or pick a directory " + "under your home folder." + ), + ) + current = resolved_child + + if not current.is_dir(): + raise HTTPException( + status_code = 400, + detail = f"Not a directory: {current}", + ) + return current + + raise HTTPException( + status_code = 403, + detail = ( + "Path is not in the browseable allowlist. Register it via " + "POST /api/models/scan-folders first, or pick a directory " + "under your home folder." + ), + ) + + +@router.get("/browse-folders", response_model = BrowseFoldersResponse) +async def browse_folders( + path: Optional[str] = Query( + None, + description = ( + "Directory to list. If omitted, defaults to the current user's " + "home directory. Tilde (`~`) and relative paths are expanded. " + "Must resolve inside the allowlist of browseable roots (HOME, " + "HF cache, Studio dirs, registered scan folders, well-known " + "model dirs)." + ), + ), + show_hidden: bool = Query( + False, + description = "Include entries whose name starts with a dot", + ), + current_subject: str = Depends(get_current_subject), +): + """ + List immediate subdirectories of *path* for the Custom Folders picker. + + The frontend uses this to render a modal folder browser without needing + a native OS dialog (Studio is served over HTTP, so the browser can't + reveal absolute paths on the host). The endpoint is read-only and does + not create, move, or delete anything. It simply enumerates visible + subdirectories so the user can click their way to a folder and hand + the resulting string back to POST `/api/models/scan-folders`. + + Sandbox: requests are bounded to the allowlist returned by + :func:`_build_browse_allowlist` (HOME, HF cache, Studio dirs, + registered scan folders, well-known model dirs). Paths outside the + allowlist return 403 so users cannot probe ``/etc``, ``/proc``, + ``/root`` (when not HOME), or other sensitive system locations + even if the server process can read them. Symlinks are resolved + via ``os.path.realpath`` before the check, so symlink traversal + cannot escape the sandbox either. + + Sorting: directories that look like they hold models come first, then + plain directories, then hidden entries (if `show_hidden=true`). + """ + from utils.paths import hf_default_cache_dir, well_known_model_dirs + from storage.studio_db import list_scan_folders + + # Build the allowlist once -- both the sandbox check below and the + # suggestion chips use the same set, so chips are always navigable. + allowed_roots = _build_browse_allowlist() + + try: + target = _resolve_browse_target(path, allowed_roots) + except HTTPException: + requested_path = _normalize_browse_request_path(path) + if path is not None and path.strip(): + logger.warning( + "browse-folders: rejected path %r (normalized=%s)", + path, + requested_path, + ) + raise + + # Enumerate immediate subdirectories with a bounded cap so a stray + # query against ``/usr/lib`` or ``/proc`` can't stat-storm the process. + entries: list[BrowseEntry] = [] + truncated = False + visited = 0 + try: + it = target.iterdir() + except PermissionError: + raise HTTPException( + status_code = 403, + detail = f"Permission denied reading {target}", + ) + except OSError as exc: + raise HTTPException( + status_code = 500, + detail = f"Could not read {target}: {exc}", + ) + + try: + for child in it: + # Bound by *visited entries*, not by *appended entries*: in + # directories full of files (or hidden subdirs when + # ``show_hidden=False``) the cap on ``len(entries)`` would + # never trigger and we'd still stat every child. Counting + # visits keeps the worst-case work to ``_BROWSE_ENTRY_CAP`` + # iterdir/is_dir calls regardless of how many of them + # survive the filters below. + visited += 1 + if visited > _BROWSE_ENTRY_CAP: + truncated = True + break + try: + if not child.is_dir(): + continue + except OSError: + continue + name = child.name + is_hidden = name.startswith(".") + if is_hidden and not show_hidden: + continue + entries.append( + BrowseEntry( + name = name, + has_models = _looks_like_model_dir(child), + hidden = is_hidden, + ) + ) + except PermissionError as exc: + logger.debug( + "browse-folders: permission denied during enumeration of %s: %s", + target, + exc, + ) + except OSError as exc: + # Rare: iterdir succeeded but reading a specific entry failed. + logger.warning("browse-folders: partial enumeration of %s: %s", target, exc) + + # Model-bearing dirs first, then plain, then hidden; case-insensitive + # alphabetical within each bucket. + def _sort_key(e: BrowseEntry) -> tuple[int, str]: + bucket = 0 if e.has_models else (2 if e.hidden else 1) + return (bucket, e.name.lower()) + + entries.sort(key = _sort_key) + + # Parent is None at the filesystem root (`p.parent == p`) AND when + # the parent would step outside the sandbox -- otherwise the up-row + # would 403 on click. Users can still hop to other allowed roots + # via the suggestion chips below. + parent: Optional[str] + if target.parent == target or not _is_path_inside_allowlist( + target.parent, allowed_roots + ): + parent = None + else: + parent = str(target.parent) + + # Handy starting points for the quick-pick chips. + suggestions: list[str] = [] + seen_sug: set[str] = set() + + def _add_sug(p: Optional[Path]) -> None: + if p is None: + return + try: + resolved = str(p.resolve()) + except OSError: + return + if resolved in seen_sug: + return + if Path(resolved).is_dir(): + seen_sug.add(resolved) + suggestions.append(resolved) + + # Home always comes first -- it's the safe fallback when everything + # else is cold. + _add_sug(Path.home()) + # The HF cache root the process is actually using. + try: + _add_sug(hf_default_cache_dir()) + except Exception: + pass + # Already-registered scan folders (what the user has curated). + try: + for folder in list_scan_folders(): + _add_sug(Path(folder.get("path", ""))) + except Exception as exc: + logger.debug("browse-folders: could not load scan folders: %s", exc) + # Directories commonly used by other local-LLM tools: LM Studio + # (`~/.lmstudio/models` + legacy `~/.cache/lm-studio/models` + + # user-configured downloadsFolder from LM Studio's settings.json), + # Ollama (`~/.ollama/models` + common system paths + OLLAMA_MODELS + # env var), and generic user-choice spots (`~/models`, `~/Models`). + # Each helper only returns paths that currently exist so we never + # show dead chips. + try: + for p in well_known_model_dirs(): + _add_sug(p) + except Exception as exc: + logger.debug("browse-folders: could not load well-known dirs: %s", exc) + + return BrowseFoldersResponse( + current = str(target), + parent = parent, + entries = entries, + suggestions = suggestions, + truncated = truncated, + model_files_here = _count_model_files(target), + ) + + @router.get("/list") async def list_models( current_subject: str = Depends(get_current_subject), diff --git a/studio/backend/tests/test_browse_folders_route.py b/studio/backend/tests/test_browse_folders_route.py new file mode 100644 index 0000000000..19a83987d3 --- /dev/null +++ b/studio/backend/tests/test_browse_folders_route.py @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import os +import sys +import types +from pathlib import Path + +import pytest +from fastapi import HTTPException + +# Keep this test runnable in lightweight environments where optional logging +# deps are not installed. +if "structlog" not in sys.modules: + + class _DummyLogger: + def __getattr__(self, _name): + return lambda *args, **kwargs: None + + sys.modules["structlog"] = types.SimpleNamespace( + BoundLogger = _DummyLogger, + get_logger = lambda *args, **kwargs: _DummyLogger(), + ) + +import routes.models as models_route + + +def test_resolve_browse_target_returns_allowed_directory(tmp_path): + allowed = tmp_path / "allowed" + target = allowed / "models" / "nested" + target.mkdir(parents = True) + + resolved = models_route._resolve_browse_target(str(target), [allowed]) + + assert resolved == target.resolve() + + +def test_resolve_browse_target_rejects_outside_allowlist(tmp_path): + allowed = tmp_path / "allowed" + disallowed = tmp_path / "disallowed" + allowed.mkdir() + disallowed.mkdir() + + with pytest.raises(HTTPException) as exc_info: + models_route._resolve_browse_target(str(disallowed), [allowed]) + + assert exc_info.value.status_code == 403 + + +def test_resolve_browse_target_rejects_file_path(tmp_path): + allowed = tmp_path / "allowed" + allowed.mkdir() + model_file = allowed / "model.gguf" + model_file.write_text("gguf") + + with pytest.raises(HTTPException) as exc_info: + models_route._resolve_browse_target(str(model_file), [allowed]) + + assert exc_info.value.status_code == 400 + + +def test_resolve_browse_target_allows_symlink_into_other_allowed_root(tmp_path): + home_root = tmp_path / "home" + scan_root = tmp_path / "scan" + target = scan_root / "nested" + home_root.mkdir() + target.mkdir(parents = True) + (home_root / "scan-link").symlink_to(scan_root, target_is_directory = True) + + resolved = models_route._resolve_browse_target( + str(home_root / "scan-link" / "nested"), + [home_root, scan_root], + ) + + assert resolved == target.resolve() + + +@pytest.mark.skipif(os.altsep is not None, reason = "POSIX-only path semantics") +def test_resolve_browse_target_allows_backslash_in_posix_segment(tmp_path): + allowed = tmp_path / "allowed" + target = allowed / r"dir\name" + target.mkdir(parents = True) + + resolved = models_route._resolve_browse_target(str(target), [allowed]) + + assert resolved == target.resolve() diff --git a/studio/backend/utils/paths/__init__.py b/studio/backend/utils/paths/__init__.py index 11709ae56e..92191dccdd 100644 --- a/studio/backend/utils/paths/__init__.py +++ b/studio/backend/utils/paths/__init__.py @@ -34,6 +34,7 @@ from .storage_roots import ( legacy_hf_cache_dir, hf_default_cache_dir, lmstudio_model_dirs, + well_known_model_dirs, ensure_dir, ensure_studio_directories, resolve_under_root, @@ -70,6 +71,7 @@ __all__ = [ "legacy_hf_cache_dir", "hf_default_cache_dir", "lmstudio_model_dirs", + "well_known_model_dirs", "ensure_dir", "ensure_studio_directories", "resolve_under_root", diff --git a/studio/backend/utils/paths/storage_roots.py b/studio/backend/utils/paths/storage_roots.py index 4841c5d0a3..b52609b06b 100644 --- a/studio/backend/utils/paths/storage_roots.py +++ b/studio/backend/utils/paths/storage_roots.py @@ -130,6 +130,51 @@ def lmstudio_model_dirs() -> list[Path]: return dirs +def well_known_model_dirs() -> list[Path]: + """Return directories commonly used by other local LLM tools. + + Used by the folder browser to offer quick-pick chips. Returns only + paths that exist on disk, so the UI never shows dead chips. Order + reflects a rough "likelihood the user has models here" -- LM Studio + and Ollama first, then the generic fallbacks. + """ + candidates: list[Path] = [] + + # LM Studio (reuses the logic above, including settings.json override) + candidates.extend(lmstudio_model_dirs()) + + # Ollama -- both the user-level and common system-wide install paths + # (https://github.com/ollama/ollama/issues/733). + ollama_env = os.environ.get("OLLAMA_MODELS") + if ollama_env: + candidates.append(Path(ollama_env).expanduser()) + candidates.append(Path.home() / ".ollama" / "models") + candidates.append(Path("/usr/share/ollama/.ollama/models")) + candidates.append(Path("/var/lib/ollama/.ollama/models")) + + # HF hub cache root (separate from the explicit HF cache chip) + candidates.append(Path.home() / ".cache" / "huggingface" / "hub") + + # Generic "my models" spots users tend to drop things into + for name in ("models", "Models"): + candidates.append(Path.home() / name) + + # Deduplicate while preserving order; keep only extant dirs + out: list[Path] = [] + seen: set[str] = set() + for p in candidates: + try: + resolved = str(p.resolve()) + except OSError: + continue + if resolved in seen: + continue + if Path(resolved).is_dir(): + seen.add(resolved) + out.append(Path(resolved)) + return out + + def _setup_cache_env() -> None: """Set cache environment variables for HuggingFace, uv, and vLLM. diff --git a/studio/frontend/src/components/assistant-ui/model-selector/folder-browser.tsx b/studio/frontend/src/components/assistant-ui/model-selector/folder-browser.tsx new file mode 100644 index 0000000000..42bd1716a1 --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/model-selector/folder-browser.tsx @@ -0,0 +1,328 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"use client"; + +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Spinner } from "@/components/ui/spinner"; +import { + type BrowseFoldersResponse, + browseFolders, +} from "@/features/chat/api/chat-api"; +import { cn } from "@/lib/utils"; +import { ArrowUp02Icon, Folder02Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +export interface FolderBrowserProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Called with the absolute path the user confirmed. */ + onSelect: (path: string) => void; + /** Optional initial directory. Defaults to the user's home on the server. */ + initialPath?: string; +} + +function splitBreadcrumb(path: string): { label: string; value: string }[] { + if (!path) return []; + // Distinguish path styles BEFORE normalizing separators. On POSIX + // backslashes are valid filename characters, so we cannot blindly + // rewrite ``\`` -> ``/`` -- doing so would mangle directory names + // like ``my\backup`` into ``my/backup`` and produce breadcrumb + // values that 404 on the server. Only Windows-style absolute paths + // (drive letter, or UNC ``\\server\share``) get the conversion. + const isWindowsDrive = /^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path); + const isUnc = /^\\\\/.test(path); + const isWindows = isWindowsDrive || isUnc; + const normalized = isWindows ? path.replace(/\\/g, "/") : path; + const segments = normalized.split("/"); + const parts: { label: string; value: string }[] = []; + + // POSIX absolute path: leading empty segment from split("/") + if (segments[0] === "") { + parts.push({ label: "/", value: "/" }); + let cur = ""; + for (const seg of segments.slice(1)) { + if (!seg) continue; + cur = `${cur}/${seg}`; + parts.push({ label: seg, value: cur }); + } + return parts; + } + + // Windows-ish drive path (C:, D:): first segment is the drive. Use + // ``C:/`` (drive-absolute) as the crumb value so clicking the drive + // root navigates to the root of the drive rather than the + // drive-relative current working directory on that drive (``C:`` + // alone resolves to ``CWD-on-C``, not ``C:\``). + if (/^[A-Za-z]:$/.test(segments[0])) { + const driveRoot = `${segments[0]}/`; + let cur = driveRoot; + parts.push({ label: segments[0], value: driveRoot }); + for (const seg of segments.slice(1)) { + if (!seg) continue; + cur = cur.endsWith("/") ? `${cur}${seg}` : `${cur}/${seg}`; + parts.push({ label: seg, value: cur }); + } + return parts; + } + + // Fallback: relative / UNC-ish. Render as-is as a single crumb. + return [{ label: path, value: path }]; +} + +export function FolderBrowser({ + open, + onOpenChange, + onSelect, + initialPath, +}: FolderBrowserProps) { + const [data, setData] = useState(null); + const [path, setPath] = useState(initialPath); + const [showHidden, setShowHidden] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + const navigate = useCallback( + ( + target: string | undefined, + hidden: boolean, + opts?: { fallbackOnError?: boolean }, + ) => { + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + setLoading(true); + setError(null); + // Forward the signal so cancelled navigation actually cancels the + // backend enumeration instead of just discarding the response. + browseFolders(target, hidden, ctrl.signal) + .then((res) => { + if (ctrl.signal.aborted) return; + setData(res); + setPath(res.current); + }) + .catch((err) => { + if (ctrl.signal.aborted) return; + // Surface the error, but if the very first request (typically + // a typo'd or denylisted ``initialPath``) fails AND the + // browser is empty (no ``data`` to render against), fall + // back to the user's HOME so the modal is navigable instead + // of an irrecoverable dead end. + const message = err instanceof Error ? err.message : String(err); + setError(message); + if (opts?.fallbackOnError && target !== undefined) { + // Re-issue without a target -> backend defaults to HOME. + // Don't recurse if HOME itself fails (paranoia: shouldn't + // happen since the sandbox allowlist always includes HOME). + queueMicrotask(() => navigate(undefined, hidden)); + } + }) + .finally(() => { + if (!ctrl.signal.aborted) setLoading(false); + }); + }, + [], + ); + + // Fetch when the dialog opens. Only re-run when the dialog transitions + // closed -> open; subsequent navigation is driven by `navigate()` so we + // don't want `path` in the dependency list here. + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + if (!open) return; + // ``fallbackOnError``: if the user-supplied ``initialPath`` is bad + // (typo, denylisted, deleted) we recover into HOME instead of + // showing an empty modal with no breadcrumbs/entries. + navigate(initialPath, showHidden, { fallbackOnError: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const handleConfirm = useCallback(() => { + if (!path) return; + onSelect(path); + onOpenChange(false); + }, [onSelect, onOpenChange, path]); + + const crumbs = useMemo( + () => (data?.current ? splitBreadcrumb(data.current) : []), + [data?.current], + ); + + return ( + + + + + Browse for folder + + + + {/* Breadcrumb */} +
+ {crumbs.length === 0 ? ( + (loading…) + ) : ( + crumbs.map((c, i) => ( + + + {i < crumbs.length - 1 && ( + / + )} + + )) + )} +
+ + {/* Suggestions (quick-pick chips) */} + {data?.suggestions && data.suggestions.length > 0 && ( +
+ {data.suggestions.map((s) => ( + + ))} +
+ )} + + {/* Entry list */} +
+ {error && ( +
{error}
+ )} + {!error && loading && ( +
+ + Loading… +
+ )} + {!error && !loading && data && ( + <> + {/* Up row */} + {data.parent !== null && ( + + )} + {data.entries.length === 0 && !(data.model_files_here && data.model_files_here > 0) && ( +
+ (empty directory) +
+ )} + {data.model_files_here !== undefined && data.model_files_here > 0 && ( +
+ {data.model_files_here} model file{data.model_files_here === 1 ? "" : "s"} in this folder. Click "Use this folder" to scan it. +
+ )} + {data.truncated === true && ( +
+ Showing first {data.entries.length} entries. Narrow the path + to see more. +
+ )} + {data.entries.map((e) => ( + + ))} + + )} +
+ + {/* Footer */} + + +
+ + + + +
+
+
+
+ ); +} 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 dc4c210be4..2f661c2e72 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -48,6 +48,7 @@ import type { VramFitStatus } from "@/lib/vram"; 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 { type ReactNode, @@ -512,6 +513,7 @@ export function HubModelPicker({ const [folderError, setFolderError] = useState(null); const [showFolderInput, setShowFolderInput] = useState(false); const [folderLoading, setFolderLoading] = useState(false); + const [showFolderBrowser, setShowFolderBrowser] = useState(false); const refreshLocalModelsList = useCallback(() => { listLocalModels() @@ -537,11 +539,22 @@ export function HubModelPicker({ .catch(() => {}); }, []); - const handleAddFolder = useCallback(async () => { - const trimmed = folderInput.trim(); + const handleAddFolder = useCallback(async (overridePath?: string) => { + // Accept an explicit path so the folder browser can submit the + // chosen path in the same tick it calls `setFolderInput`; reading + // `folderInput` alone would race the state update. + const raw = overridePath !== undefined ? overridePath : folderInput; + const trimmed = raw.trim(); if (!trimmed || folderLoading) return; setFolderError(null); setFolderLoading(true); + // True when the request originated from the folder browser's + // ``onSelect`` (one-click "Use this folder"). In that flow the + // typed-input panel is closed, so the inline ``folderError`` + // paragraph is invisible. Surface failures via toast instead so + // the action doesn't appear to silently no-op when the backend + // rejects (denylisted path, sandbox 403, etc.). + const fromBrowser = overridePath !== undefined; try { const created = await addScanFolder(trimmed); // Backend returns existing row for duplicates, so deduplicate @@ -557,7 +570,11 @@ export function HubModelPicker({ // Background reconciliation with the server void refreshScanFolders(); } catch (e) { - setFolderError(e instanceof Error ? e.message : "Failed to add folder"); + const message = e instanceof Error ? e.message : "Failed to add folder"; + setFolderError(message); + if (fromBrowser) { + toast.error("Couldn't add folder", { description: message }); + } } finally { setFolderLoading(false); } @@ -984,30 +1001,42 @@ export function HubModelPicker({ {!showHfSection ? ( <> -
+
Custom Folders - +
+ + +
{/* Folder paths */} {scanFolders.map((f) => (
handleRemoveFolder(f.id)} aria-label={`Remove folder ${f.path}`} - className="shrink-0 rounded p-0.5 text-muted-foreground/40 opacity-100 md:opacity-0 md:group-hover:opacity-100 focus-visible:opacity-100 transition-opacity hover:text-destructive" + className="shrink-0 rounded p-1 text-foreground/70 transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive" > - +
))} @@ -1046,7 +1075,17 @@ export function HubModelPicker({ /> +
)} - {/* Empty state */} - {scanFolders.length === 0 && customFolderModels.length === 0 && !showFolderInput && ( - - )} + { + setFolderInput(picked); + setFolderError(null); + // One-click UX: the "Use this folder" button submits + // the scan folder directly. Pass the path explicitly + // because `folderInput` state hasn't flushed yet. + void handleAddFolder(picked); + }} + /> + {/* Models from custom folders */} {customFolderModels.map((m) => { diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts index ddc0e9d39e..9aacfc5af4 100644 --- a/studio/frontend/src/features/chat/api/chat-api.ts +++ b/studio/frontend/src/features/chat/api/chat-api.ts @@ -247,6 +247,42 @@ export async function removeScanFolder(id: number): Promise { await parseJsonOrThrow(response); } +export interface BrowseEntry { + name: string; + has_models: boolean; + hidden: boolean; +} + +export interface BrowseFoldersResponse { + current: string; + parent: string | null; + entries: BrowseEntry[]; + suggestions: string[]; + truncated?: boolean; + model_files_here?: number; +} + +export async function browseFolders( + path?: string, + showHidden = false, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams(); + if (path !== undefined && path !== null) params.set("path", path); + if (showHidden) params.set("show_hidden", "true"); + const qs = params.toString(); + // Forward the AbortSignal through authFetch -> fetch so that a + // navigation cancelled in the FolderBrowser (rapid breadcrumb / row / + // hidden-toggle clicks) actually cancels the in-flight HTTP request + // server-side, instead of merely dropping the response client-side + // while the backend keeps walking large directory trees. + const response = await authFetch( + `/api/models/browse-folders${qs ? `?${qs}` : ""}`, + signal ? { signal } : undefined, + ); + return parseJsonOrThrow(response); +} + export async function listGgufVariants( repoId: string, hfToken?: string, From ba387e2c8fac170780a66813b4828204744701fb Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 15 Apr 2026 08:06:30 -0700 Subject: [PATCH 09/24] Update pyproject.toml --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 50bdf58b95..b4c0122f4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,7 +88,7 @@ huggingfacenotorch = [ ] huggingface = [ "unsloth[huggingfacenotorch]", - "unsloth_zoo>=2026.4.3", + "unsloth_zoo>=2026.4.7", "torchvision", "unsloth[triton]", ] @@ -578,7 +578,7 @@ colab-ampere-torch220 = [ "flash-attn>=2.6.3 ; ('linux' in sys_platform)", ] colab-new = [ - "unsloth_zoo>=2026.4.3", + "unsloth_zoo>=2026.4.7", "packaging", "tyro", "transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.5.0", From cdb3e752ecc62899522c7f7cb768934c989af7e6 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 15 Apr 2026 08:06:43 -0700 Subject: [PATCH 10/24] Update _utils.py --- unsloth/models/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index 7d99ec3932..03f5b965d0 100644 --- a/unsloth/models/_utils.py +++ b/unsloth/models/_utils.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2026.4.4" +__version__ = "2026.4.5" __all__ = [ "SUPPORTS_BFLOAT16", From 3869fbe1cc6495cb6bc5c0d48e2b3159ee364d5b Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 15 Apr 2026 08:23:41 -0700 Subject: [PATCH 11/24] 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 12/24] 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 13/24] 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 14/24] 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 15/24] 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 16/24] 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 17/24] 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 18/24] 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 19/24] 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 20/24] 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 21/24] 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 22/24] 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 23/24] 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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