From cd65584f1972e3e5a021665b21c8eb391a677acd Mon Sep 17 00:00:00 2001 From: Datta Nimmaturi Date: Mon, 23 Mar 2026 10:10:15 +0530 Subject: [PATCH 01/15] Update issue template --- .github/ISSUE_TEMPLATE/bug---issue.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug---issue.md b/.github/ISSUE_TEMPLATE/bug---issue.md index 83e0fd73a9..ffa3d3c885 100644 --- a/.github/ISSUE_TEMPLATE/bug---issue.md +++ b/.github/ISSUE_TEMPLATE/bug---issue.md @@ -6,7 +6,7 @@ labels: bug assignees: '' --- - +Note: Please do not remove the questions. Answer beside them. 1. Did you update? `pip install --upgrade unsloth unsloth_zoo` 2. `Colab` or `Kaggle` or local / cloud 3. Number GPUs used, use `nvidia-smi` @@ -16,6 +16,7 @@ assignees: '' ```python Put Minimal code to reproduce error here ###Remove Hugging Face token### +###Please make sure to check formatting properly, edit if needed.### ``` 🦥 You can also ask via our Reddit page: https://reddit.com/r/unsloth/ From a5be6904a685e73cd3547987f501232e9f6d183f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:55:27 -0700 Subject: [PATCH 02/15] [pre-commit.ci] pre-commit autoupdate (#4542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.15.6 → v0.15.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.6...v0.15.7) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1879186a73..25eeaedd3c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.6 + rev: v0.15.7 hooks: - id: ruff args: From 2b330e2f24ecb89f1347af57a4f64b43c32dcbb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=87=91=E9=BB=84=E8=89=B2=E8=91=A1=E8=90=84=E7=90=83?= =?UTF-8?q?=E5=90=9B=E5=90=9B?= Date: Tue, 24 Mar 2026 12:08:29 +0800 Subject: [PATCH 03/15] fix: store embedding_learning_rate on self in UnslothTrainingArguments (#4531) Fixes #4492 The embedding_learning_rate parameter was assigned to a local variable instead of self.embedding_learning_rate, causing UnslothTrainer.create_optimizer() to always get None via getattr and silently fall back to a single param group. Bug: embedding_learning_rate = embedding_learning_rate (no-op) Fix: self.embedding_learning_rate = embedding_learning_rate --- unsloth/trainer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unsloth/trainer.py b/unsloth/trainer.py index 65abe6801f..8bb4440021 100644 --- a/unsloth/trainer.py +++ b/unsloth/trainer.py @@ -132,8 +132,8 @@ except: class UnslothTrainingArguments(TrainingArguments): def __init__(self, embedding_learning_rate: float = None, *args, **kwargs): - embedding_learning_rate = embedding_learning_rate super().__init__(*args, **kwargs) + self.embedding_learning_rate = embedding_learning_rate def _create_unsloth_optimizer( From 01d7dce3f40ea5d9c3f52075eb672d1cededc6e8 Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Tue, 24 Mar 2026 05:21:04 +0000 Subject: [PATCH 04/15] studio: persist system prompt and preset settings across navigation (#4538) * fix(studio): harden system prompt persistence and storage fallback * Exclude checkpoint from localStorage persistence for PR #4538 checkpoint is backend-owned state -- refresh() already syncs it from getInferenceStatus() on every page load. Persisting it to localStorage causes a stale model ID to survive across backend restarts, which prevents auto-load from triggering when no model is actually loaded. --------- Co-authored-by: Daniel Han --- .../src/features/chat/api/chat-adapter.ts | 6 +- .../src/features/chat/chat-settings-sheet.tsx | 196 ++++++++++++++++-- .../chat/stores/chat-runtime-store.ts | 77 ++++++- 3 files changed, 262 insertions(+), 17 deletions(-) diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index d99a7a0de6..15ac416b1f 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -442,10 +442,12 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { Boolean(message), ); - if (params.systemPrompt.trim()) { + const safeSystemPrompt = + typeof params.systemPrompt === "string" ? params.systemPrompt : ""; + if (safeSystemPrompt.trim()) { outboundMessages.unshift({ role: "system", - content: params.systemPrompt.trim(), + content: safeSystemPrompt.trim(), }); } const imageBase64 = findLatestUserImageBase64(messages); diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index 5a25a1536e..45c9d17888 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -10,6 +10,16 @@ import { } from "@/components/ui/select"; import { Slider } from "@/components/ui/slider"; import { Textarea } from "@/components/ui/textarea"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { ArrowDown01Icon, CodeIcon, @@ -30,7 +40,7 @@ import { } from "@/components/ui/sheet"; import { useIsMobile } from "@/hooks/use-mobile"; import type { ReactNode } from "react"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { DEFAULT_INFERENCE_PARAMS, type InferenceParams, @@ -72,6 +82,52 @@ const BUILTIN_PRESETS: Preset[] = [ }, ]; +const CHAT_PRESETS_KEY = "unsloth_chat_custom_presets"; +const CHAT_ACTIVE_PRESET_KEY = "unsloth_chat_active_preset"; + +function canUseStorage(): boolean { + return typeof window !== "undefined"; +} + +function loadSavedCustomPresets(): Preset[] { + if (!canUseStorage()) return []; + try { + const raw = localStorage.getItem(CHAT_PRESETS_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed + .filter((item): item is Preset => { + if (!item || typeof item !== "object") return false; + const maybe = item as Partial; + return typeof maybe.name === "string" && !!maybe.params; + }) + .map((preset) => ({ + name: preset.name.trim(), + params: { + ...defaultInferenceParams, + ...preset.params, + }, + })) + .filter( + (preset) => + preset.name.length > 0 && + !BUILTIN_PRESETS.some((builtin) => builtin.name === preset.name), + ); + } catch { + return []; + } +} + +function loadSavedActivePreset(): string { + if (!canUseStorage()) return "Default"; + try { + return localStorage.getItem(CHAT_ACTIVE_PRESET_KEY) ?? "Default"; + } catch { + return "Default"; + } +} + function ParamSlider({ label, value, @@ -181,8 +237,16 @@ export function ChatSettingsPanel({ const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength); const kvCacheDtype = useChatRuntimeStore((s) => s.kvCacheDtype); const setKvCacheDtype = useChatRuntimeStore((s) => s.setKvCacheDtype); - const [presets, setPresets] = useState(BUILTIN_PRESETS); - const [activePreset, setActivePreset] = useState("Default"); + const [customPresets, setCustomPresets] = useState(() => + loadSavedCustomPresets(), + ); + const [activePreset, setActivePreset] = useState(() => loadSavedActivePreset()); + const [savePresetOpen, setSavePresetOpen] = useState(false); + const [presetNameDraft, setPresetNameDraft] = useState(""); + const presets = useMemo( + () => [...BUILTIN_PRESETS, ...customPresets], + [customPresets], + ); const isBuiltinPreset = BUILTIN_PRESETS.some((p) => p.name === activePreset); function set(key: K) { @@ -199,32 +263,93 @@ export function ChatSettingsPanel({ trustRemoteCode: params.trustRemoteCode, }); setActivePreset(name); + if (canUseStorage()) { + try { + localStorage.setItem(CHAT_ACTIVE_PRESET_KEY, name); + } catch { + // ignore + } + } } } - function savePreset() { - const name = prompt("Preset name:"); - if (!name?.trim()) { + function openSavePresetDialog() { + setPresetNameDraft(activePreset === "Default" ? "" : activePreset); + setSavePresetOpen(true); + } + + function savePresetWithName(rawName: string) { + const trimmed = rawName.trim(); + if (!trimmed) { return; } - const trimmed = name.trim(); - setPresets((prev) => [ - ...prev.filter((p) => p.name !== trimmed), - { name: trimmed, params: { ...params } }, - ]); + if (BUILTIN_PRESETS.some((preset) => preset.name === trimmed)) { + return; + } + setCustomPresets((prev) => { + const next = [ + ...prev.filter((preset) => preset.name !== trimmed), + { name: trimmed, params: { ...params } }, + ]; + if (canUseStorage()) { + try { + localStorage.setItem(CHAT_PRESETS_KEY, JSON.stringify(next)); + } catch { + // ignore + } + } + return next; + }); + if (canUseStorage()) { + try { + localStorage.setItem(CHAT_ACTIVE_PRESET_KEY, trimmed); + } catch { + // ignore + } + } setActivePreset(trimmed); + setSavePresetOpen(false); } function deletePreset(name: string) { if (BUILTIN_PRESETS.some((p) => p.name === name)) { return; } - setPresets((prev) => prev.filter((p) => p.name !== name)); + setCustomPresets((prev) => { + const next = prev.filter((preset) => preset.name !== name); + if (canUseStorage()) { + try { + localStorage.setItem(CHAT_PRESETS_KEY, JSON.stringify(next)); + } catch { + // ignore + } + } + return next; + }); if (activePreset === name) { setActivePreset("Default"); + if (canUseStorage()) { + try { + localStorage.setItem(CHAT_ACTIVE_PRESET_KEY, "Default"); + } catch { + // ignore + } + } } } + useEffect(() => { + if (presets.some((preset) => preset.name === activePreset)) return; + setActivePreset("Default"); + if (canUseStorage()) { + try { + localStorage.setItem(CHAT_ACTIVE_PRESET_KEY, "Default"); + } catch { + // ignore + } + } + }, [activePreset, presets]); + const settingsContent = ( <>
@@ -255,7 +380,7 @@ export function ChatSettingsPanel({
+ { + setSavePresetOpen(nextOpen); + if (!nextOpen) { + setPresetNameDraft(""); + } + }} + > + + + Save Preset + + Enter a name for this inference preset. + + +
{ + event.preventDefault(); + savePresetWithName(presetNameDraft); + }} + className="space-y-4" + > + setPresetNameDraft(event.target.value)} + placeholder="Preset name" + maxLength={80} + /> + + + + +
+
+
); diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index fea5442187..920737a279 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -2,6 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { create } from "zustand"; +import { toast } from "sonner"; import { DEFAULT_INFERENCE_PARAMS, type ChatLoraSummary, @@ -13,6 +14,8 @@ const AUTO_TITLE_KEY = "unsloth_chat_auto_title"; const AUTO_HEAL_TOOL_CALLS_KEY = "unsloth_auto_heal_tool_calls"; const MAX_TOOL_CALLS_KEY = "unsloth_max_tool_calls_per_message"; const TOOL_CALL_TIMEOUT_KEY = "unsloth_tool_call_timeout"; +const INFERENCE_PARAMS_KEY = "unsloth_chat_inference_params"; +let hasShownInferencePersistenceWarning = false; function canUseStorage(): boolean { return typeof window !== "undefined"; @@ -59,6 +62,65 @@ function saveInt(key: string, value: number): void { } } +function asFiniteNumber(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function asString(value: unknown, fallback: string): string { + return typeof value === "string" ? value : fallback; +} + +function asBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function loadInferenceParams(): InferenceParams { + if (!canUseStorage()) return DEFAULT_INFERENCE_PARAMS; + try { + const raw = localStorage.getItem(INFERENCE_PARAMS_KEY); + if (!raw) return DEFAULT_INFERENCE_PARAMS; + const parsed = JSON.parse(raw) as Partial; + return { + temperature: asFiniteNumber(parsed.temperature, DEFAULT_INFERENCE_PARAMS.temperature), + topP: asFiniteNumber(parsed.topP, DEFAULT_INFERENCE_PARAMS.topP), + topK: asFiniteNumber(parsed.topK, DEFAULT_INFERENCE_PARAMS.topK), + minP: asFiniteNumber(parsed.minP, DEFAULT_INFERENCE_PARAMS.minP), + repetitionPenalty: asFiniteNumber( + parsed.repetitionPenalty, + DEFAULT_INFERENCE_PARAMS.repetitionPenalty, + ), + presencePenalty: asFiniteNumber( + parsed.presencePenalty, + DEFAULT_INFERENCE_PARAMS.presencePenalty, + ), + maxSeqLength: asFiniteNumber( + parsed.maxSeqLength, + DEFAULT_INFERENCE_PARAMS.maxSeqLength, + ), + maxTokens: asFiniteNumber(parsed.maxTokens, DEFAULT_INFERENCE_PARAMS.maxTokens), + systemPrompt: asString(parsed.systemPrompt, DEFAULT_INFERENCE_PARAMS.systemPrompt), + checkpoint: DEFAULT_INFERENCE_PARAMS.checkpoint, + trustRemoteCode: asBoolean( + parsed.trustRemoteCode, + DEFAULT_INFERENCE_PARAMS.trustRemoteCode ?? false, + ), + }; + } catch { + return DEFAULT_INFERENCE_PARAMS; + } +} + +function saveInferenceParams(params: InferenceParams): boolean { + if (!canUseStorage()) return false; + try { + const { checkpoint: _, ...rest } = params; + localStorage.setItem(INFERENCE_PARAMS_KEY, JSON.stringify(rest)); + return true; + } catch { + return false; + } +} + type ChatRuntimeStore = { params: InferenceParams; models: ChatModelSummary[]; @@ -117,7 +179,7 @@ type ChatRuntimeStore = { }; export const useChatRuntimeStore = create((set) => ({ - params: DEFAULT_INFERENCE_PARAMS, + params: loadInferenceParams(), models: [], loras: [], runningByThreadId: {}, @@ -144,7 +206,18 @@ export const useChatRuntimeStore = create((set) => ({ contextUsage: null, modelLoading: false, setModelLoading: (loading) => set({ modelLoading: loading }), - setParams: (params) => set({ params }), + setParams: (params) => + set(() => { + const persisted = saveInferenceParams(params); + if (!persisted && !hasShownInferencePersistenceWarning) { + hasShownInferencePersistenceWarning = true; + toast.warning("Chat settings could not be persisted", { + description: + "Your changes apply now, but may reset after refresh.", + }); + } + return { params }; + }), setModels: (models) => set({ models }), setLoras: (loras) => set({ loras }), setThreadRunning: (threadId, running) => From 45e4a0473a1490609daaa4594ab9e1bd8969feac Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Tue, 24 Mar 2026 05:33:46 +0000 Subject: [PATCH 05/15] studio: stop scroll hijack during generation and fix thinking panel layout shift (#4543) * fix(chat): stabilize thinking panel and thread scroll during generation * fix: match ChatGPT scroll and thinking panel behavior - Remove autoScroll={false} from thread viewport to restore default follow-scroll during streaming (pauses when user scrolls up, resumes at bottom) - Rewrite reasoning panel state: auto-opens on stream start, user can close during streaming, auto-collapses when reasoning ends, user can re-expand after collapse --------- Co-authored-by: Daniel Han --- .../src/components/assistant-ui/reasoning.tsx | 59 ++++++++++++------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/reasoning.tsx b/studio/frontend/src/components/assistant-ui/reasoning.tsx index 4f3f8075a4..157a08297a 100644 --- a/studio/frontend/src/components/assistant-ui/reasoning.tsx +++ b/studio/frontend/src/components/assistant-ui/reasoning.tsx @@ -17,7 +17,6 @@ import { type ReasoningGroupComponent, type ReasoningMessagePartComponent, useAuiState, - useScrollLock, } from "@assistant-ui/react"; import { copyToClipboard } from "@/lib/copy-to-clipboard"; import { Idea01Icon } from "@hugeicons/core-free-icons"; @@ -67,29 +66,23 @@ function ReasoningRoot({ children, ...props }: ReasoningRootProps) { - const collapsibleRef = useRef(null); const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); - const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); const isControlled = controlledOpen !== undefined; const isOpen = isControlled ? controlledOpen : uncontrolledOpen; const handleOpenChange = useCallback( (open: boolean) => { - if (!open) { - lockScroll(); - } if (!isControlled) { setUncontrolledOpen(open); } controlledOnOpenChange?.(open); }, - [lockScroll, isControlled, controlledOnOpenChange], + [isControlled, controlledOnOpenChange], ); return ( & { streaming?: boolean }) { const scrollRef = useRef(null); + const shouldAutoScrollRef = useRef(true); useEffect(() => { if (!(streaming && scrollRef.current)) { return; } const el = scrollRef.current; + const updateAutoScroll = () => { + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + shouldAutoScrollRef.current = distanceFromBottom <= 24; + }; const observer = new MutationObserver(() => { - el.scrollTop = el.scrollHeight; + if (shouldAutoScrollRef.current) { + el.scrollTop = el.scrollHeight; + } }); + el.addEventListener("scroll", updateAutoScroll); observer.observe(el, { childList: true, subtree: true, characterData: true, }); + shouldAutoScrollRef.current = true; el.scrollTop = el.scrollHeight; - return () => observer.disconnect(); + return () => { + observer.disconnect(); + el.removeEventListener("scroll", updateAutoScroll); + }; }, [streaming]); return ( @@ -330,6 +335,7 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({ }); const [manualOpen, setManualOpen] = useState(false); + const [dismissedWhileStreaming, setDismissedWhileStreaming] = useState(false); const [duration, setDuration] = useState(0); const startTimeRef = useRef(null); @@ -345,17 +351,23 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({ } }, [isReasoningStreaming]); - const isOpen = isReasoningStreaming || manualOpen; + // Reset dismissed flag when a new stream starts + useEffect(() => { + if (isReasoningStreaming) { + setDismissedWhileStreaming(false); + } + }, [isReasoningStreaming]); - const variant = isReasoningStreaming - ? "outline" - : manualOpen - ? "outline" - : "ghost"; + // Derived: open during streaming (unless dismissed), or if user manually opened after + const isOpen = (isReasoningStreaming && !dismissedWhileStreaming) || manualOpen; + const variant = isOpen ? "outline" : "ghost"; + // Allow closing during streaming (matches ChatGPT) const handleOpenChange = useCallback( (open: boolean) => { - if (!isReasoningStreaming) { + if (isReasoningStreaming) { + setDismissedWhileStreaming(!open); + } else { setManualOpen(open); } }, @@ -368,14 +380,17 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({ onOpenChange={handleOpenChange} variant={variant} > -
+
- {isOpen && !isReasoningStreaming && ( - - )} +
+ {isOpen && !isReasoningStreaming && ( + + )} +
Date: Mon, 23 Mar 2026 22:34:47 -0700 Subject: [PATCH 06/15] Fix Studio port conflict detection for loopback addresses (#4532) * Fix port conflict detection when loopback address is held by another process * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Use getaddrinfo for IPv6 host support, restore emojis in terminal output * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Guard against conn.pid being None in _get_pid_on_port psutil.net_connections() can return entries with pid=None when the current user lacks privileges to see the owning process (common on macOS without root, Windows without admin, and some Linux configs). psutil.Process(None) does not raise -- it silently returns the current process, which would make the warning incorrectly blame Unsloth Studio itself for blocking the port. Skip entries with pid=None so the caller falls back to the generic "port is already in use" message instead. * Update studio/backend/run.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * [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> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- studio/backend/run.py | 92 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 83 insertions(+), 9 deletions(-) diff --git a/studio/backend/run.py b/studio/backend/run.py index 5c24c550c7..e32b912c37 100644 --- a/studio/backend/run.py +++ b/studio/backend/run.py @@ -73,18 +73,79 @@ def _resolve_external_ip() -> str: return "0.0.0.0" +def _get_pid_on_port(port: int) -> "tuple[int, str] | None": + """Return (pid, process_name) of the process listening on *port*, or None. + + Uses psutil when available. Falls back gracefully to None so callers + can still report the port conflict without process details. + + Works on Windows, macOS, and Linux wherever psutil is installed. + """ + try: + import psutil + except ImportError: + return None + try: + for conn in psutil.net_connections(kind = "tcp"): + if conn.status == "LISTEN" and conn.laddr.port == port: + if conn.pid is None: + return None + try: + proc = psutil.Process(conn.pid) + return (conn.pid, proc.name()) + except (psutil.NoSuchProcess, psutil.AccessDenied): + return (conn.pid, "") + except (psutil.AccessDenied, OSError) as e: + # psutil.net_connections() needs elevated privileges on some platforms + logger.debug("Failed to scan network connections for port %s: %s", port, e) + return None + + def _is_port_free(host: str, port: int) -> bool: - """Check if a port is available for binding.""" + """Check if a port is available for binding. + + When *host* is ``0.0.0.0`` (wildcard), we also check whether anything + is already listening on ``127.0.0.1`` (and ``::1`` when IPv6 is + available). An SSH tunnel or similar process may hold the loopback + address while our wildcard bind still succeeds, making Unsloth Studio + unreachable via ``localhost``. + + Works on Windows, macOS, and Linux. + """ import socket + # 1. Can we bind to the requested address? + # Use getaddrinfo so both IPv4 ("0.0.0.0") and IPv6 ("::") hosts + # resolve to the correct address family automatically. try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + addr_info = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM) + family, socktype, proto, _, sockaddr = addr_info[0] + with socket.socket(family, socktype, proto) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - s.bind((host, port)) - return True + s.bind(sockaddr) except OSError: return False + # 2. When binding to all interfaces, verify that localhost is not + # already claimed by another process (e.g. an SSH -L tunnel). + # We attempt a TCP connect -- if it succeeds something is listening. + if host in ("0.0.0.0", "::"): + for loopback, family in [ + ("127.0.0.1", socket.AF_INET), + ("::1", socket.AF_INET6), + ]: + try: + with socket.socket(family, socket.SOCK_STREAM) as s: + s.settimeout(1) + if s.connect_ex((loopback, port)) == 0: + # Connection succeeded -- port is taken on loopback + return False + except OSError: + # IPv6 disabled or other OS-level restriction -- skip + continue + + return True + def _find_free_port(host: str, start: int, max_attempts: int = 20) -> int: """Find a free port starting from `start`, trying up to max_attempts ports.""" @@ -149,11 +210,11 @@ def _graceful_shutdown(server = None): logger.info("All subprocesses cleaned up") -# The uvicorn server instance — set by run_server(), used by callers +# The uvicorn server instance -- set by run_server(), used by callers # that need to tell the server to exit (e.g. signal handlers). _server = None -# Shutdown event — used to wake the main loop on signal +# Shutdown event -- used to wake the main loop on signal _shutdown_event = None @@ -205,9 +266,22 @@ def run_server( # Auto-find free port if requested port is in use if not _is_port_free(host, port): original_port = port - port = _find_free_port(host, port) + blocker = _get_pid_on_port(port) + port = _find_free_port(host, port + 1) if not silent: - print(f"Port {original_port} is in use, using port {port} instead") + print("") + print("=" * 50) + if blocker: + pid, name = blocker + print( + f"Port {original_port} is already in use by " f"{name} (PID {pid})." + ) + else: + print(f"Port {original_port} is already in use.") + print(f"Unsloth Studio will use port {port} instead.") + print(f"Open http://localhost:{port} in your browser.") + print("=" * 50) + print("") # Setup frontend if path provided if frontend_path: @@ -297,7 +371,7 @@ if __name__ == "__main__": sys.stderr.flush() sys.exit(1) - # ── Signal handler — ensures subprocess cleanup on Ctrl+C ──── + # Signal handler -- ensures subprocess cleanup on Ctrl+C def _signal_handler(signum, frame): _graceful_shutdown(_server) _shutdown_event.set() From 1129ea44bcd9e571e4c42409e4b772600c5ac3f8 Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Tue, 24 Mar 2026 07:04:00 +0100 Subject: [PATCH 07/15] fix(studio): show Windows-specific reset-password command on login error (#4529) --- .../src/features/auth/components/auth-form.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/studio/frontend/src/features/auth/components/auth-form.tsx b/studio/frontend/src/features/auth/components/auth-form.tsx index 7d4363c1bf..d9190429bd 100644 --- a/studio/frontend/src/features/auth/components/auth-form.tsx +++ b/studio/frontend/src/features/auth/components/auth-form.tsx @@ -9,6 +9,7 @@ import { Eye, EyeOff } from "lucide-react"; import { useEffect, useState } from "react"; import type { ReactElement } from "react"; import type { SyntheticEvent } from "react"; +import { usePlatformStore } from "@/config/env"; import { refreshSession } from "../api"; // Bootstrap credentials injected into index.html by the backend @@ -278,7 +279,14 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null { ); navigate({ to: getPostAuthRoute() }); } catch (err: unknown) { - setError(err instanceof Error ? err.message : "Auth failed."); + let msg = err instanceof Error ? err.message : "Auth failed."; + if (msg.includes("unsloth studio reset-password") && usePlatformStore.getState().deviceType === "windows") { + msg = msg.replace( + "unsloth studio reset-password", + ".\\unsloth_studio\\Scripts\\unsloth.exe studio reset-password", + ); + } + setError(msg); } finally { setLoading(false); } From 77b21333fb5a6300505a2eca230b66d7a1521606 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 24 Mar 2026 02:27:06 -0700 Subject: [PATCH 08/15] fix(studio): restore scroll lock on reasoning panel collapse (#4545) PR #4543 removed useScrollLock from ReasoningRoot, causing the thread viewport to jump when a user collapses a reasoning panel. Restore the hook to freeze scrollTop during the 200ms collapse animation, matching the pattern used by tool-fallback.tsx and tool-group.tsx. --- .../frontend/src/components/assistant-ui/reasoning.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/studio/frontend/src/components/assistant-ui/reasoning.tsx b/studio/frontend/src/components/assistant-ui/reasoning.tsx index 157a08297a..0e37f6d433 100644 --- a/studio/frontend/src/components/assistant-ui/reasoning.tsx +++ b/studio/frontend/src/components/assistant-ui/reasoning.tsx @@ -17,6 +17,7 @@ import { type ReasoningGroupComponent, type ReasoningMessagePartComponent, useAuiState, + useScrollLock, } from "@assistant-ui/react"; import { copyToClipboard } from "@/lib/copy-to-clipboard"; import { Idea01Icon } from "@hugeicons/core-free-icons"; @@ -66,23 +67,29 @@ function ReasoningRoot({ children, ...props }: ReasoningRootProps) { + const collapsibleRef = useRef(null); const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); const isControlled = controlledOpen !== undefined; const isOpen = isControlled ? controlledOpen : uncontrolledOpen; const handleOpenChange = useCallback( (open: boolean) => { + if (!open) { + lockScroll(); + } if (!isControlled) { setUncontrolledOpen(open); } controlledOnOpenChange?.(open); }, - [isControlled, controlledOnOpenChange], + [lockScroll, isControlled, controlledOnOpenChange], ); return ( Date: Tue, 24 Mar 2026 11:26:56 +0100 Subject: [PATCH 09/15] fix: always show chat tool icons (#4525) * fix: always show chat tool icons, gray out when model doesn't support them Tool icons (Think, Search, Code) were hidden unless a model was loaded and supported those features. Now they're always visible so users can see and pre-select them. If a loaded model doesn't support a feature, the button gets grayed out and disabled instead of being removed. * refactor: centralize Qwen thinking params in store * fix: disable tool buttons when no model is loaded Change disabled condition from `modelLoaded && !supportsX` to `!modelLoaded || !supportsX` so buttons are grayed out both when no model is loaded and when the loaded model lacks the capability. * Fix Qwen3 param clobbering and restore SuggestionItem capability guards - Revert setReasoningEnabled() in the store to a pure boolean setter. Moving the Qwen3 param logic into it caused reconnect/load/refresh paths (which also call setReasoningEnabled) to silently overwrite user-customized or server-provided temperature/topP/topK/minP. - Restore applyQwenThinkingParams() as a standalone function called only from explicit user toggle click handlers in thread.tsx and shared-composer.tsx, matching the pre-PR behavior. - Re-add supportsReasoning/supportsTools guards in the SuggestionItem click handler so that clicking a suggestion card only activates tool toggles the loaded model actually supports. --------- Co-authored-by: Daniel Han --- .../src/components/assistant-ui/thread.tsx | 58 ++++---- .../src/features/chat/shared-composer.tsx | 124 ++++++++++-------- 2 files changed, 98 insertions(+), 84 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 8f85ecabbd..91cba5b02b 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -132,13 +132,7 @@ const SuggestionItem: FC = () => { const prompt = useAuiState(({ suggestion }) => suggestion.prompt); const isDisabled = useAuiState(({ thread }) => thread.isDisabled); const isRunning = useAuiState(({ thread }) => thread.isRunning); - const supportsTools = useChatRuntimeStore((s) => s.supportsTools); - const supportsReasoning = useChatRuntimeStore((s) => s.supportsReasoning); - const allTools = SUGGESTION_TOOLS[prompt] ?? []; - const tools = allTools.filter((tool) => { - if (tool === "thinking") return supportsReasoning; - return supportsTools; - }); + const tools = SUGGESTION_TOOLS[prompt] ?? []; return ( - )} - {supportsTools && ( - + - )} - {supportsTools && ( - + - )} + )} + aria-label={codeToolsEnabled ? "Disable code execution" : "Enable code execution"} + > + + Code +
{dictationSupported && ( From c8057d911b34c197d071ae335ba898a5174986d4 Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Tue, 24 Mar 2026 12:01:33 +0100 Subject: [PATCH 10/15] fix: system prompt ignored in unsloth inference (#4528) * fix: system prompt was dropped in unsloth text and vision inference * refactor: simplify system prompt message construction * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: use multimodal typed content parts for vision system message and add fallback The system message content must use typed content parts ([{"type": "text", "text": ...}]) instead of a plain string to match the multimodal processor contract (consistent with the audio path). Plain strings cause some processors (e.g. LLaVA) to silently drop the system prompt. Also wraps processor.apply_chat_template in try/except so models that reject the system role gracefully fall back to no system message with a warning log. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: capture and log original exception in vision system prompt fallback --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han --- studio/backend/core/inference/inference.py | 53 ++++++++++++++++------ 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/studio/backend/core/inference/inference.py b/studio/backend/core/inference/inference.py index 6cb077f4a9..1a265690ff 100644 --- a/studio/backend/core/inference/inference.py +++ b/studio/backend/core/inference/inference.py @@ -927,6 +927,12 @@ class InferenceBackend: logger.warning(f"Could not apply get_chat_template: {e}") # Step 2: Format with tokenizer.apply_chat_template() + if system_prompt: + template_messages = [ + {"role": "system", "content": system_prompt} + ] + messages + else: + template_messages = messages try: if not (hasattr(tokenizer, "chat_template") and tokenizer.chat_template): raise ValueError( @@ -937,7 +943,7 @@ class InferenceBackend: f"one via tokenizer.chat_template before inference." ) formatted_prompt = tokenizer.apply_chat_template( - messages, tokenize = False, add_generation_prompt = True + template_messages, tokenize = False, add_generation_prompt = True ) logger.debug(f"Formatted prompt: {formatted_prompt[:200]}...") except Exception as e: @@ -992,19 +998,40 @@ class InferenceBackend: # Prepare vision messages if image: - vision_messages = [ - { - "role": "user", - "content": [ - {"type": "image"}, - {"type": "text", "text": user_message}, - ], - } - ] + user_msg = { + "role": "user", + "content": [ + {"type": "image"}, + {"type": "text", "text": user_message}, + ], + } + if system_prompt: + vision_messages = [ + { + "role": "system", + "content": [{"type": "text", "text": system_prompt}], + }, + user_msg, + ] + else: + vision_messages = [user_msg] - input_text = processor.apply_chat_template( - vision_messages, add_generation_prompt = True, tokenize = False - ) + try: + input_text = processor.apply_chat_template( + vision_messages, add_generation_prompt = True, tokenize = False + ) + except Exception as e: + if system_prompt: + logger.warning( + f"Vision processor for '{self.active_model_name}' may not support " + f"system messages; retrying without. Original error: {e}" + ) + vision_messages = [user_msg] + input_text = processor.apply_chat_template( + vision_messages, add_generation_prompt = True, tokenize = False + ) + else: + raise inputs = processor( image, input_text, From 381f509695c5df47a5b2bcf1111ec089ed98e8ec Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Tue, 24 Mar 2026 04:06:23 -0700 Subject: [PATCH 11/15] Adding Qwen3.5 RL.md --- README.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7cc40c7644..a25d7ed419 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,9 @@ Run and train AI models with a unified local interface. FeaturesQuickstartNotebooks • - Documentation • - Discord + Docs • + Discord • + Reddit

unsloth studio ui homepage @@ -32,12 +33,12 @@ Unsloth provides several key features for both inference and training: * We work directly with teams behind [gpt-oss](https://docs.unsloth.ai/new/gpt-oss-how-to-run-and-fine-tune#unsloth-fixes-for-gpt-oss), [Qwen3](https://www.reddit.com/r/LocalLLaMA/comments/1kaodxu/qwen3_unsloth_dynamic_ggufs_128k_context_bug_fixes/), [Llama 4](https://github.com/ggml-org/llama.cpp/pull/12889), [Mistral](models/tutorials/devstral-how-to-run-and-fine-tune.md), [Gemma 1-3](https://news.ycombinator.com/item?id=39671146), and [Phi-4](https://unsloth.ai/blog/phi4), where we’ve fixed bugs that improve model accuracy. * Upload images, audio, PDFs, code, DOCX and more file types to chat with. ### Training -* Train **500+ models** up to **2x faster** with up to **70% less VRAM**, with no accuracy loss. +* Train and RL **500+ models** up to **2x faster** with up to **70% less VRAM**, with no accuracy loss. * Custom Triton and mathematical **kernels**. See some collabs we did with [PyTorch](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/fp8-reinforcement-learning) and [Hugging Face](https://unsloth.ai/docs/new/faster-moe). * **Data Recipes**: [Auto-create datasets](https://unsloth.ai/docs/new/studio/data-recipe) from **PDF, CSV, DOCX** etc. Edit data in a visual-node workflow. -* Supports full fine-tuning, pretraining, 4-bit, 16-bit and, FP8 training. +* **Reinforcement Learning** (RL): The most efficient [RL](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide) library, using **80% less VRAM** for GRPO, [FP8](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/fp8-reinforcement-learning) etc. +* Supports full fine-tuning, RL, pretraining, 4-bit, 16-bit and, FP8 training. * **Observability**: Monitor training live, track loss and GPU usage and customize graphs. -* **Reinforcement Learning**: The most efficient [RL](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide) library, using **80% less VRAM** for GRPO, [FP8](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/fp8-reinforcement-learning) etc. * [Multi-GPU](https://unsloth.ai/docs/basics/multi-gpu-training-with-unsloth) training is supported, with major improvements coming soon. ## ⚡ Quickstart @@ -49,7 +50,7 @@ Unsloth Studio (Beta) works on **Windows, Linux, WSL** and **macOS**. * **CPU:** Supported for Chat and Data Recipes currently * **NVIDIA:** Training works on RTX 30/40/50, Blackwell, DGX Spark, Station and more * **macOS:** Currently supports chat and Data Recipes. **MLX training** is coming very soon -* **AMD:** Chat works. Train with [Unsloth Core](#unsloth-core-code-based). Studio support is coming soon. +* **AMD:** Chat + Data works. Train with [Unsloth Core](#unsloth-core-code-based). Studio support is out soon. * **Coming soon:** Training support for Apple MLX, AMD, and Intel. * **Multi-GPU:** Available now, with a major upgrade on the way @@ -172,8 +173,9 @@ Train for free with our notebooks. Read our [guide](https://unsloth.ai/docs/get- |-----------|---------|--------|----------| | **Qwen3.5 (4B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Qwen3_5_(4B)_Vision.ipynb) | 1.5x faster | 60% less | | **gpt-oss (20B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/gpt-oss-(20B)-Fine-tuning.ipynb) | 2x faster | 70% less | +| **Qwen3.5 GSPO** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Qwen3_5_(4B)_Vision_GRPO.ipynb) | 2x faster | 70% less | | **gpt-oss (20B): GRPO** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/gpt-oss-(20B)-GRPO.ipynb) | 2x faster | 80% less | -| **Qwen3: Advanced GRPO** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Qwen3_(4B)-GRPO.ipynb) | 2x faster | 50% less | +| **Qwen3: Advanced GRPO** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Qwen3_(4B)-GRPO.ipynb) | 2x faster | 70% less | | **Gemma 3 (4B) Vision** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Gemma3_(4B)-Vision.ipynb) | 1.7x faster | 60% less | | **embeddinggemma (300M)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/EmbeddingGemma_(300M).ipynb) | 2x faster | 20% less | | **Mistral Ministral 3 (3B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Ministral_3_VL_(3B)_Vision.ipynb) | 1.5x faster | 60% less | From a41dbb6ab2fcb65b4909469eb394f49f56907155 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Tue, 24 Mar 2026 04:13:38 -0700 Subject: [PATCH 12/15] Add r/unsloth Reddit.md --- README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a25d7ed419..8f783bf661 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,7 @@ Run and train AI models with a unified local interface. FeaturesQuickstartNotebooks • - Docs • - Discord • + DocumentationReddit

@@ -36,7 +35,7 @@ Unsloth provides several key features for both inference and training: * Train and RL **500+ models** up to **2x faster** with up to **70% less VRAM**, with no accuracy loss. * Custom Triton and mathematical **kernels**. See some collabs we did with [PyTorch](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/fp8-reinforcement-learning) and [Hugging Face](https://unsloth.ai/docs/new/faster-moe). * **Data Recipes**: [Auto-create datasets](https://unsloth.ai/docs/new/studio/data-recipe) from **PDF, CSV, DOCX** etc. Edit data in a visual-node workflow. -* **Reinforcement Learning** (RL): The most efficient [RL](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide) library, using **80% less VRAM** for GRPO, [FP8](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/fp8-reinforcement-learning) etc. +* **[Reinforcement Learning](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide)** (RL): The most efficient [RL](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide) library, using **80% less VRAM** for GRPO, [FP8](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/fp8-reinforcement-learning) etc. * Supports full fine-tuning, RL, pretraining, 4-bit, 16-bit and, FP8 training. * **Observability**: Monitor training live, track loss and GPU usage and customize graphs. * [Multi-GPU](https://unsloth.ai/docs/basics/multi-gpu-training-with-unsloth) training is supported, with major improvements coming soon. @@ -198,13 +197,13 @@ Train for free with our notebooks. Read our [guide](https://unsloth.ai/docs/get- - **FP8 & Vision RL**: You can now do FP8 & VLM GRPO on consumer GPUs. [FP8 Blog](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/fp8-reinforcement-learning) • [Vision RL](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/vision-reinforcement-learning-vlm-rl) - **gpt-oss** by OpenAI: Read our [RL blog](https://unsloth.ai/docs/models/gpt-oss-how-to-run-and-fine-tune/gpt-oss-reinforcement-learning), [Flex Attention](https://unsloth.ai/docs/models/gpt-oss-how-to-run-and-fine-tune/long-context-gpt-oss-training) blog and [Guide](https://unsloth.ai/docs/models/gpt-oss-how-to-run-and-fine-tune). -## 🔗 Links and Resources +## 💚 Community and Links | Type | Links | | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +|   **Discord** | [Join Discord server](https://discord.com/invite/unsloth) | |   **r/unsloth Reddit** | [Join Reddit community](https://reddit.com/r/unsloth) | | 📚 **Documentation & Wiki** | [Read Our Docs](https://unsloth.ai/docs) | |   **Twitter (aka X)** | [Follow us on X](https://twitter.com/unslothai) | -| 💾 **Installation** | [Pip & Docker Install](https://unsloth.ai/docs/get-started/install) | | 🔮 **Our Models** | [Unsloth Catalog](https://unsloth.ai/docs/get-started/unsloth-model-catalog) | | ✍️ **Blog** | [Read our Blogs](https://unsloth.ai/blog) | From fca83182afa44ab69bf79e65078f2dc4456cc8de Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 24 Mar 2026 05:27:59 -0700 Subject: [PATCH 13/15] fix: handle prompt/completion datasets in slow-path BOS detection (#4548) * fix: handle prompt/completion datasets in slow-path BOS detection The slow-path check_text blocks in rl_replacements.py and tokenizer_utils.py crash when a prompt/completion dataset is used because they unconditionally access dataset[0][dataset_text_field] even when the dataset does not have a text field. This fixes both files to: - Default dataset_text_field to None instead of raising when undefined - Detect prompt/completion columns and concatenate them for BOS check - Guard with isinstance(str) on both prompt and completion to handle conversational format (list of dicts) by setting test_text to None - Add test_text is not None guard on has_bos_token_already to prevent AttributeError on NoneType.startswith() This is the slow-path complement to unslothai/unsloth-zoo#560 which fixes the fast-path in sft_prepare_dataset. Closes #4486 * fix: preserve chat_template BOS check when test_text is None The has_bos_token_already guard wrapped both test_text.startswith() and bos_token in chat_template with test_text is not None, which disabled the chat_template BOS detection for conversational datasets where test_text is set to None. Split the guard so test_text is not None only applies to the startswith() call, while bos_token in chat_template is always checked. --- unsloth/models/rl_replacements.py | 13 ++++++++++--- unsloth/tokenizer_utils.py | 13 ++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/unsloth/models/rl_replacements.py b/unsloth/models/rl_replacements.py index 805086324e..9f555416d4 100755 --- a/unsloth/models/rl_replacements.py +++ b/unsloth/models/rl_replacements.py @@ -153,11 +153,18 @@ def sft_trainer_prepare_dataset(function_name, function): "if 'tokenizer' not in locals(): tokenizer = processing_class\n" "if 'formatting_func' not in locals(): raise RuntimeError('Unsloth: Please file a bug report - `formatting_func` does not exist!')\n" "if 'dataset_text_field' not in locals() and 'args' in locals(): dataset_text_field = args.dataset_text_field\n" - "if 'dataset_text_field' not in locals(): raise RuntimeError('Unsloth: Please file a bug report - `dataset_text_field` does not exist!')\n" - "test_text = dataset[0][dataset_text_field] if (formatting_func is None and dataset_text_field is not None) else formatting_func(dataset[0])[0]\n" + "if 'dataset_text_field' not in locals(): dataset_text_field = None\n" + "if formatting_func is None and dataset_text_field is None and 'prompt' in dataset[0] and 'completion' in dataset[0]:\n" + " test_text = (dataset[0]['prompt'] + dataset[0]['completion']) if (isinstance(dataset[0]['prompt'], str) and isinstance(dataset[0]['completion'], str)) else None\n" + "elif formatting_func is None and dataset_text_field is not None:\n" + " test_text = dataset[0][dataset_text_field]\n" + "elif formatting_func is not None:\n" + " test_text = formatting_func(dataset[0])[0]\n" + "else:\n" + " test_text = None\n" "chat_template = getattr(tokenizer, 'chat_template', None)\n" "chat_template = '' if chat_template is None else chat_template\n" - "has_bos_token_already = (test_text.startswith(tokenizer.bos_token) or tokenizer.bos_token in chat_template) " + "has_bos_token_already = ((test_text is not None and test_text.startswith(tokenizer.bos_token)) or tokenizer.bos_token in chat_template) " "if getattr(tokenizer, 'bos_token', None) is not None else False\n" "if 'add_special_tokens' not in locals() and has_bos_token_already:\n" " from functools import partial\n" diff --git a/unsloth/tokenizer_utils.py b/unsloth/tokenizer_utils.py index c445879df7..96c22f62ff 100644 --- a/unsloth/tokenizer_utils.py +++ b/unsloth/tokenizer_utils.py @@ -974,11 +974,18 @@ def patch_sft_trainer_tokenizer(): "if 'tokenizer' not in locals(): tokenizer = processing_class\n" "if 'formatting_func' not in locals(): raise RuntimeError('Unsloth: Please file a bug report - `formatting_func` does not exist!')\n" "if 'dataset_text_field' not in locals() and 'args' in locals(): dataset_text_field = args.dataset_text_field\n" - "if 'dataset_text_field' not in locals(): raise RuntimeError('Unsloth: Please file a bug report - `dataset_text_field` does not exist!')\n" - "test_text = dataset[0][dataset_text_field] if (formatting_func is None and dataset_text_field is not None) else formatting_func(dataset[0])[0]\n" + "if 'dataset_text_field' not in locals(): dataset_text_field = None\n" + "if formatting_func is None and dataset_text_field is None and 'prompt' in dataset[0] and 'completion' in dataset[0]:\n" + " test_text = (dataset[0]['prompt'] + dataset[0]['completion']) if (isinstance(dataset[0]['prompt'], str) and isinstance(dataset[0]['completion'], str)) else None\n" + "elif formatting_func is None and dataset_text_field is not None:\n" + " test_text = dataset[0][dataset_text_field]\n" + "elif formatting_func is not None:\n" + " test_text = formatting_func(dataset[0])[0]\n" + "else:\n" + " test_text = None\n" "chat_template = getattr(tokenizer, 'chat_template', None)\n" "chat_template = '' if chat_template is None else chat_template\n" - "has_bos_token_already = (test_text.startswith(tokenizer.bos_token) or tokenizer.bos_token in chat_template) " + "has_bos_token_already = ((test_text is not None and test_text.startswith(tokenizer.bos_token)) or tokenizer.bos_token in chat_template) " "if getattr(tokenizer, 'bos_token', None) is not None else False\n" "if 'add_special_tokens' not in locals() and has_bos_token_already:\n" " from functools import partial\n" From 95d2748278c22444f31648287b087b024571e7c1 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 24 Mar 2026 06:38:57 -0700 Subject: [PATCH 14/15] fix: give @0xKushwaha git history credit for completion_only_loss fix (#4552) * Revert "fix: handle prompt/completion datasets in slow-path BOS detection (#4548)" This reverts commit fca83182afa44ab69bf79e65078f2dc4456cc8de. * fix: support completion_only_loss=True with prompt/completion dataset columns When completion_only_loss=True, TRL rejects formatting_func but Unsloth's patched _prepare_dataset/_prepare_non_packed_dataloader assumed either formatting_func or dataset_text_field was always set, causing a catch-22. Now handles prompt/completion columns as a third case for BOS token detection, with a safe None fallback for all other cases. (cherry picked from commit 978f78c6f1d3d5d92f97cbb19c869886ccf49169) * fix: handle prompt/completion datasets in slow-path BOS detection The slow-path check_text blocks in rl_replacements.py and tokenizer_utils.py crash when a prompt/completion dataset is used because they unconditionally access dataset[0][dataset_text_field] even when the dataset does not have a text field. This fixes both files to: - Default dataset_text_field to None instead of raising when undefined - Detect prompt/completion columns and concatenate them for BOS check - Guard with isinstance(str) on both prompt and completion to handle conversational format (list of dicts) by setting test_text to None - Add test_text is not None guard on has_bos_token_already to prevent AttributeError on NoneType.startswith() This is the slow-path complement to unslothai/unsloth-zoo#560 which fixes the fast-path in sft_prepare_dataset. Closes #4486 (cherry picked from commit b6ce5786d0d4301dd5483649ca8a5dff844a830c) * fix: preserve chat_template BOS check when test_text is None The has_bos_token_already guard wrapped both test_text.startswith() and bos_token in chat_template with test_text is not None, which disabled the chat_template BOS detection for conversational datasets where test_text is set to None. Split the guard so test_text is not None only applies to the startswith() call, while bos_token in chat_template is always checked. (cherry picked from commit 40bd8b89174c0878b016e2c67b92e7551f4dac3d) --------- Co-authored-by: Ayush Kushwaha <148432773+ayushkushwaha240@users.noreply.github.com> From fac6f7887e566fa7aae0cad8b6ee1500d91a02f5 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 24 Mar 2026 06:50:36 -0700 Subject: [PATCH 15/15] Versioning --- pyproject.toml | 4 ++-- unsloth/models/_utils.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3e48da7c1e..ba75cec594 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,7 +90,7 @@ huggingfacenotorch = [ ] huggingface = [ "unsloth[huggingfacenotorch]", - "unsloth_zoo>=2026.3.4", + "unsloth_zoo>=2026.3.5", "torchvision", "unsloth[triton]", ] @@ -580,7 +580,7 @@ colab-ampere-torch220 = [ "flash-attn>=2.6.3 ; ('linux' in sys_platform)", ] colab-new = [ - "unsloth_zoo>=2026.3.4", + "unsloth_zoo>=2026.3.5", "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.3.0", diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index ce8da7910f..13acc98ea6 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.3.10" +__version__ = "2026.3.11" __all__ = [ "SUPPORTS_BFLOAT16",