From ca4ea8b9fbe64554fc707b020e866c721b70d35d Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Thu, 2 Apr 2026 03:57:10 +0100 Subject: [PATCH 001/216] studio: align composer/code, unify fonts, and remove tool collapse jitter (#4763) - Add min-w-0 guards to thread/message/markdown containers to prevent content overflow past the composer width - Unify chat typography from Hellix/Space Grotesk to the sans stack, keeping monospace for code blocks and inline code - Restructure desktop navbar right-side controls with shrink-0 wrappers for consistent spacing across HoverCard roots - Soften tool-call label styling (font-medium + text-foreground/85 instead of bold) - Add responsive code block sizing via @container queries - Add horizontal scrolling for wide code blocks within the thread column - Scope list-item code block alignment CSS to .aui-thread-root - Preserve useScrollLock in tool-fallback and tool-group collapsibles - Fall back to bg-background on ViewportFooter when hideComposer is true - Widen inline code monospace selector to cover th, blockquote, and heading elements - Remove unused @fontsource-variable/space-grotesk import --- .../components/assistant-ui/markdown-text.tsx | 2 +- .../src/components/assistant-ui/thread.tsx | 40 +++-- .../components/assistant-ui/tool-fallback.tsx | 6 +- studio/frontend/src/components/navbar.tsx | 164 +++++++++--------- .../src/features/chat/shared-composer.tsx | 2 +- studio/frontend/src/index.css | 72 ++++++-- 6 files changed, 175 insertions(+), 111 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/markdown-text.tsx b/studio/frontend/src/components/assistant-ui/markdown-text.tsx index 5e84b9175e..17a6f8f054 100644 --- a/studio/frontend/src/components/assistant-ui/markdown-text.tsx +++ b/studio/frontend/src/components/assistant-ui/markdown-text.tsx @@ -403,7 +403,7 @@ const MarkdownTextImpl = () => { } return ( -
+
= ({ }} > {!hideWelcome && ( thread.isEmpty}> @@ -89,7 +89,13 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({ }} /> - + + {!hideComposer && ( +
+ )} !thread.isEmpty}> {!hideComposer && } @@ -220,14 +226,20 @@ const GeneratingSpinner: FC = () => { const ComposerAnimated: FC = () => { return ( - - - +
+
+ + + +
); }; @@ -262,7 +274,7 @@ const Composer: FC = () => { { const AssistantMessage: FC = () => { return ( -
+
{
-
+
diff --git a/studio/frontend/src/components/assistant-ui/tool-fallback.tsx b/studio/frontend/src/components/assistant-ui/tool-fallback.tsx index c965749f74..82a5b17e04 100644 --- a/studio/frontend/src/components/assistant-ui/tool-fallback.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-fallback.tsx @@ -156,12 +156,12 @@ function ToolFallbackTrigger({ - {label}: {toolName} + {label}: {toolName} {isRunning && ( - {label}: {toolName} + {label}: {toolName} )} diff --git a/studio/frontend/src/components/navbar.tsx b/studio/frontend/src/components/navbar.tsx index 63189a0d5d..121c559db8 100644 --- a/studio/frontend/src/components/navbar.tsx +++ b/studio/frontend/src/components/navbar.tsx @@ -372,90 +372,94 @@ export function Navbar() { })} - {/* Right: docs/tour desktop */} -
- - - - - - Learn more - - - - -

- Unsloth Documentation -

-

- Guides on fine-tuning LLMs 2x faster with 70% less memory. - Covers LoRA, QLoRA, data formatting, and deployment. -

- - Visit docs - - -
-
-
- - - - - + {/* Right: docs/tour desktop — one wrapper per control so flex gap is even (HoverCard roots can confuse flex spacing). */} +
+
+ +
+ + {tourId ? ( +
- - - - - - - +
+ ) : null} +
+ + + + + + + + +
+
+ +
{/* Right: mobile */} diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index 59b0880add..ac01f77381 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -473,7 +473,7 @@ export function SharedComposer({ onChange={(e) => setText(e.target.value)} onKeyDown={onKeyDown} placeholder="Send to both models..." - className="mb-1 max-h-32 min-h-14 w-full resize-none bg-transparent px-4 pt-2 pb-3 text-sm outline-none placeholder:text-muted-foreground" + className="mb-1 max-h-32 min-h-14 w-full resize-none bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm outline-none placeholder:text-muted-foreground" rows={1} />
diff --git a/studio/frontend/src/index.css b/studio/frontend/src/index.css index 8dc159a002..45696b2274 100644 --- a/studio/frontend/src/index.css +++ b/studio/frontend/src/index.css @@ -6,21 +6,11 @@ @import "shadcn/tailwind.css"; @import "streamdown/styles.css"; @import "@fontsource-variable/figtree"; -@import "@fontsource-variable/space-grotesk"; @import "@fontsource-variable/inter"; @import "tw-shimmer"; @plugin "@toolwind/corner-shape"; @source "../node_modules/streamdown/dist/*.js"; -@font-face { - font-family: "Hellix"; - src: url("/fonts/Hellix-SemiBold.woff2") format("woff2"), - url("/fonts/Hellix-SemiBold.woff") format("woff"); - font-weight: 600; - font-style: normal; - font-display: swap; -} - @custom-variant dark (&:is(.dark *)); :root { @@ -67,7 +57,7 @@ --sidebar-ring: oklch(0.6929 0.1396 166.5513); --destructive-foreground: oklch(1 0 0); --font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui; - --font-heading: "Hellix", "Space Grotesk Variable", ui-sans-serif, sans-serif; + --font-heading: var(--font-sans); --font-serif: Source Serif 4, serif; --font-mono: JetBrains Mono, monospace; --shadow-color: hsl(0 0% 0%); @@ -159,7 +149,7 @@ @theme inline { --font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui; - --font-heading: "Hellix", "Space Grotesk Variable", ui-sans-serif, sans-serif; + --font-heading: var(--font-sans); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -343,10 +333,68 @@ [data-streamdown="code-block"] { gap: 0; padding: 0.5rem; + /* Wide lines must scroll inside the thread column, not widen past the composer (flex min-width:auto). */ + max-width: 100%; + min-width: 0; + overflow-x: auto; } [data-streamdown="code-block-header"] { padding-left: 0.75rem; } + + /* Chat thread: code slightly smaller by default; step up when the thread column is wide. */ + .aui-thread-root [data-streamdown="code-block"] { + font-size: 0.8125rem; + line-height: 1.55; + } + + .aui-thread-root [data-streamdown="code-block-header"] { + font-size: 0.6875rem; + } + + @container (min-width: 36rem) { + .aui-thread-root [data-streamdown="code-block"] { + font-size: 0.875rem; + } + + .aui-thread-root [data-streamdown="code-block-header"] { + font-size: 0.75rem; + } + } + + /* Chat: use the app sans stack for UI + prose. */ + .aui-thread-root { + font-family: var(--font-sans); + } + + .aui-thread-root .font-medium, + .aui-thread-root .font-semibold, + .aui-thread-root .font-bold, + .aui-thread-root .font-heading { + font-family: var(--font-sans); + } + + .aui-thread-root :where(h1, h2, h3, h4, h5, h6) { + font-family: var(--font-sans); + } + + /* Keep monospace for code fences and inline code (not KaTeX). */ + .aui-thread-root [data-streamdown="code-block"] pre, + .aui-thread-root [data-streamdown="code-block"] code { + font-family: var(--font-mono), ui-monospace, monospace; + } + + .aui-thread-root :where(p, li, td, th, blockquote, h1, h2, h3, h4, h5, h6) code { + font-family: var(--font-mono), ui-monospace, monospace; + } + + /* Align fenced code blocks with the main chat column even when nested in lists. */ + .aui-thread-root [data-streamdown="list-item"] > [data-streamdown="code-block"], + .aui-thread-root [data-streamdown="list-item"] [data-streamdown="code-block"] { + margin-left: -1.25rem; + width: calc(100% + 1.25rem); + max-width: calc(100% + 1.25rem); + } } /* Minimal scrollbar — thumb only, no track */ From ba8081fc96f8253bbeae846961e1c5812491e25b Mon Sep 17 00:00:00 2001 From: AdamPlatin123 <154926636+AdamPlatin123@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:24:48 +0800 Subject: [PATCH 002/216] fix(chat): correct loading text for cached models during inference (#4764) Distinguish between actual network downloads and GPU memory loading for cached LoRA adapters in Studio chat. - Add isCachedLora detection for local LoRA adapter paths using comprehensive cross-platform regex (Unix, Windows, UNC, WSL, tilde) - Thread isCachedLora through loadInfo to chat-page inline status for proper 3-way distinction (cached / local LoRA / downloading) - Skip download progress polling for cached LoRA models (no useless /download-progress API calls) - Fix initial toast state to use isCachedLoad consistently instead of only checking isDownloaded - Fix cancelLoading toast to not mention background downloads for cached/local loads - Keep download-specific text ("Downloading model..." / "Download complete") inside the download-only polling block --- .../frontend/src/features/chat/chat-page.tsx | 6 +++-- .../chat/hooks/use-chat-model-runtime.ts | 23 +++++++++++++------ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 8d0a9649b4..08450c7ec7 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -866,14 +866,16 @@ export function ChatPage(): ReactElement { label={ loadProgress?.phase === "starting" ? "Starting model…" - : loadingModel.isDownloaded + : loadingModel.isDownloaded || loadingModel.isCachedLora ? "Loading model…" : "Downloading model…" } title={ loadingModel.isDownloaded ? `Loading ${loadingModel.displayName} from cache.` - : `Loading ${loadingModel.displayName}. This may include downloading.` + : loadingModel.isCachedLora + ? `Loading ${loadingModel.displayName} into memory.` + : `Loading ${loadingModel.displayName}. This may include downloading.` } progressPercent={loadProgress?.percent} progressLabel={loadProgress?.label} 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 fc0e392596..8fcd22d0f8 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 @@ -168,6 +168,7 @@ export function useChatModelRuntime() { id: string; displayName: string; isDownloaded?: boolean; + isCachedLora?: boolean; } | null>(null); const [loadToastDismissed, setLoadToastDismissed] = useState(false); const [loadProgress, setLoadProgress] = useState<{ @@ -294,8 +295,11 @@ export function useChatModelRuntime() { setLoadToastDismissedState(false); clearCheckpoint(); if (tid != null) toast.dismiss(tid); + const isCachedOrLocal = model.isDownloaded || model.isCachedLora; toast.info("Stopped loading model", { - description: "The current download may still finish in the background.", + description: isCachedOrLocal + ? undefined + : "The current download may still finish in the background.", }); // Fire-and-forget: tell backend to stop, don't block UI unloadModel({ model_path: model.id }).catch(() => {}); @@ -339,20 +343,24 @@ export function useChatModelRuntime() { : undefined; const previousIsLora = previousModel?.isLora ?? (previousLora ? true : false); + // Covers Unix absolute (/), relative (./ ../), tilde (~/), Windows drive (C:\), UNC (\\server) + const isLocal = /^(\/|\.{1,2}[\\\/]|~[\\\/]|[A-Za-z]:[\\\/]|\\\\)/.test(modelId); + const isCachedLora = isLora && isLocal; const loadingDescription = [ currentCheckpoint ? "Switching models." : null, extraLoadingDescription ?? null, isDownloaded ? "Loading cached model into memory." : null, + !isDownloaded && isCachedLora ? "Loading trained model into memory." : null, ] .filter(Boolean) .join(" "); setModelsError(null); setLoadToastDismissedState(false); - const loadInfo = { id: modelId, displayName, isDownloaded }; + const loadInfo = { id: modelId, displayName, isDownloaded, isCachedLora }; setLoadingModel(loadInfo); useChatRuntimeStore.getState().setModelLoading(true); setLoadProgress( - isDownloaded + isDownloaded || isCachedLora ? { percent: null, label: null, phase: "starting" } : { percent: 0, label: "Preparing download", phase: "downloading" }, ); @@ -485,15 +493,16 @@ export function useChatModelRuntime() { } } - const toastTitle = isDownloaded ? "Starting model…" : "Downloading model…"; + const isCachedLoad = isDownloaded || isCachedLora; + const toastTitle = isCachedLoad ? "Starting model…" : "Downloading model…"; const toastId = toast( null, { description: renderLoadDescription( toastTitle, loadingDescription, - isDownloaded ? null : 0, - isDownloaded ? null : "Preparing download", + isCachedLoad ? null : 0, + isCachedLoad ? null : "Preparing download", cancelLoading, ), duration: Infinity, @@ -511,7 +520,7 @@ export function useChatModelRuntime() { // Poll download progress for non-cached models (GGUF and non-GGUF) let progressInterval: ReturnType | null = null; - if (!isDownloaded) { + if (!isDownloaded && !isCachedLora) { const expectedBytes = typeof selection !== "string" ? selection.expectedBytes ?? 0 : 0; let hasShownProgress = false; From 752cef329906d860188cda3fdaf7becaa2413940 Mon Sep 17 00:00:00 2001 From: mateeaaaaaaa Date: Thu, 2 Apr 2026 10:10:43 +0300 Subject: [PATCH 003/216] fix(security): shell injection in GGML export conversion (#4768) * Fix shell injection in GGML conversion paths * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Remove test file from security fix PR Move test_save_shell_injection.py to a separate PR to keep this PR focused on the security fix itself. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han --- unsloth/save.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/unsloth/save.py b/unsloth/save.py index 178178980d..3c318fab02 100644 --- a/unsloth/save.py +++ b/unsloth/save.py @@ -2533,12 +2533,15 @@ def unsloth_convert_lora_to_ggml_and_push_to_hub( ) print(f"The output file will be {output_file}") - command = f"python3 llama.cpp/convert-lora-to-ggml.py {lora_directory_push} {output_file} llama" - try: with subprocess.Popen( - command, - shell = True, + [ + sys.executable, + "llama.cpp/convert-lora-to-ggml.py", + lora_directory_push, + output_file, + "llama", + ], stdout = subprocess.PIPE, stderr = subprocess.PIPE, bufsize = 1, @@ -2550,7 +2553,7 @@ def unsloth_convert_lora_to_ggml_and_push_to_hub( print(line, end = "", flush = True) sp.wait() if sp.returncode != 0: - raise subprocess.CalledProcessError(sp.returncode, command) + raise subprocess.CalledProcessError(sp.returncode, sp.args) except subprocess.CalledProcessError as e: print(f"Error: Conversion failed with return code {e.returncode}") return @@ -2612,12 +2615,15 @@ def unsloth_convert_lora_to_ggml_and_save_locally( ) print(f"The output file will be {output_file}") - command = f"python3 llama.cpp/convert-lora-to-ggml.py {save_directory} {output_file} llama" - try: with subprocess.Popen( - command, - shell = True, + [ + sys.executable, + "llama.cpp/convert-lora-to-ggml.py", + save_directory, + output_file, + "llama", + ], stdout = subprocess.PIPE, stderr = subprocess.PIPE, bufsize = 1, @@ -2629,7 +2635,7 @@ def unsloth_convert_lora_to_ggml_and_save_locally( print(line, end = "", flush = True) sp.wait() if sp.returncode != 0: - raise subprocess.CalledProcessError(sp.returncode, command) + raise subprocess.CalledProcessError(sp.returncode, sp.args) except subprocess.CalledProcessError as e: print(f"Error: Conversion failed with return code {e.returncode}") return From dc0729aadfb6aa891468d1432394db59a183eca1 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 2 Apr 2026 00:10:47 -0700 Subject: [PATCH 004/216] Add regression test for shell injection fix in GGML conversion (#4773) AST-based test ensures subprocess.Popen calls in GGML conversion functions use argv lists instead of shell=True. Companion to PR #4768. --- tests/saving/test_save_shell_injection.py | 74 +++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/saving/test_save_shell_injection.py diff --git a/tests/saving/test_save_shell_injection.py b/tests/saving/test_save_shell_injection.py new file mode 100644 index 0000000000..c6c2c8fe15 --- /dev/null +++ b/tests/saving/test_save_shell_injection.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import ast +from pathlib import Path + + +SAVE_PY = Path(__file__).resolve().parents[2] / "unsloth" / "save.py" + + +def _function_calls(source: str, function_name: str) -> list[ast.Call]: + tree = ast.parse(source, filename = str(SAVE_PY)) + for node in tree.body: + if isinstance(node, ast.FunctionDef) and node.name == function_name: + return [child for child in ast.walk(node) if isinstance(child, ast.Call)] + raise AssertionError(f"Function {function_name} not found in save.py") + + +def _assert_safe_ggml_calls(calls: list[ast.Call]) -> None: + popen_calls = [] + for call in calls: + if isinstance(call.func, ast.Attribute) and call.func.attr == "Popen": + if ( + isinstance(call.func.value, ast.Name) + and call.func.value.id == "subprocess" + ): + popen_calls.append(call) + + assert popen_calls, "Expected at least one subprocess.Popen call" + + ggml_calls = [] + for call in popen_calls: + if not call.args: + continue + argv = call.args[0] + if isinstance(argv, ast.List) and len(argv.elts) >= 2: + second_arg = argv.elts[1] + if ( + isinstance(second_arg, ast.Constant) + and second_arg.value == "llama.cpp/convert-lora-to-ggml.py" + ): + ggml_calls.append(call) + + assert ggml_calls, "Expected the GGML conversion subprocess call" + + for call in ggml_calls: + shell_kwargs = [ + keyword + for keyword in call.keywords + if keyword.arg == "shell" + and isinstance(keyword.value, ast.Constant) + and keyword.value.value is True + ] + assert not shell_kwargs, "subprocess.Popen must not use shell=True" + + assert call.args, "subprocess.Popen must receive argv as a positional argument" + argv = call.args[0] + assert isinstance( + argv, ast.List + ), "subprocess.Popen must be called with an argv list" + assert len(argv.elts) == 5, "GGML conversion argv should have five elements" + + second_arg = argv.elts[1] + assert isinstance(second_arg, ast.Constant) + assert second_arg.value == "llama.cpp/convert-lora-to-ggml.py" + + +def test_ggml_conversion_paths_do_not_use_shell() -> None: + source = SAVE_PY.read_text(encoding = "utf-8") + for function_name in ( + "unsloth_convert_lora_to_ggml_and_push_to_hub", + "unsloth_convert_lora_to_ggml_and_save_locally", + ): + calls = _function_calls(source, function_name) + _assert_safe_ggml_calls(calls) From e4d149923097f25d804177d53e2f2964e7bb1ca7 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 2 Apr 2026 02:11:07 -0700 Subject: [PATCH 005/216] fix(studio): prevent small models from stalling on tool-calling tasks (#4769) * fix(studio): prevent small models from stalling on tool-calling tasks Small GGUF models (< 9B params) in "Think, Search, Code" mode would often describe what they planned to do ("Let me create this dashboard") and then stop generating without ever calling a tool. Three changes: 1. Simplify web_tips for small models: remove the "fetch its full content by calling web_search with the url parameter" guidance for models < 9B. This multi-step instruction causes small models to plan elaborate search-then-fetch-then-code sequences they cannot reliably execute. 2. Add "always call tools directly" imperative to the system prompt nudge so models act immediately instead of narrating their intentions. 3. Add plan-without-action re-prompt in the agentic loop: when the model emits planning text (matching patterns like "let me", "I'll", etc.) without calling any tool, inject a nudge asking it to call the tool and continue the loop. Capped at 2 re-prompts per request. Benchmarked with Qwen3.5-4B-GGUF (N=5 trials per variant): - Baseline: 40% of requests had any tool call - Combined fix: 100% of requests had at least one tool call * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: Daniel Han Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/core/inference/llama_cpp.py | 172 ++++++++++++++------ studio/backend/routes/inference.py | 37 ++++- studio/backend/utils/models/__init__.py | 2 + studio/backend/utils/models/model_config.py | 31 ++++ 4 files changed, 189 insertions(+), 53 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 5f974b0af3..1b7fe548e4 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -27,6 +27,52 @@ import httpx logger = get_logger(__name__) +# ── Pre-compiled patterns for plan-without-action re-prompt ── +# Forward-looking intent signals that indicate the model is +# describing what it *will* do rather than giving a final answer. +_INTENT_SIGNAL = re.compile( + r"(?i)(" + # Direct intent: "I'll ...", "I will ...", "Let me ...", "I am going to ..." + # Handles both straight and curly apostrophes. + # Excludes "I can", "I should", "I want to", "let's" which + # appear frequently in direct answers / explanations. + r"\b(i['\u2019](ll|m going to|m gonna)|i am (going to|gonna)|i will|i shall|let me|allow me)\b" + r"|" + # Step/plan framing: "First ...", "Step 1:", "Here's my plan" + r"\b(?:first\b|step \d+:?|here['\u2019]?s (?:my |the |a )?(?:plan|approach))" + r"|" + # "Now I" / "Next I" patterns + r"\b(?:now i|next i)\b" + r")" +) +_MAX_REPROMPTS = 1 +_REPROMPT_MAX_CHARS = 500 + +# ── Pre-compiled patterns for GGUF shard detection ─────────── +_SHARD_FULL_RE = re.compile(r"^(.*)-(\d{5})-of-(\d{5})\.gguf$") +_SHARD_RE = re.compile(r"^(.*)-\d{5}-of-\d{5}\.gguf$") + +# Model size extraction (shared with routes/inference.py) +from utils.models import extract_model_size_b as _extract_model_size_b + +# ── Pre-compiled patterns for tool XML stripping ───────────── +_TOOL_CLOSED_PATS = [ + re.compile(r".*?", re.DOTALL), + re.compile(r".*?", re.DOTALL), +] +_TOOL_ALL_PATS = _TOOL_CLOSED_PATS + [ + re.compile(r".*$", re.DOTALL), + re.compile(r".*$", re.DOTALL), +] + +# ── Pre-compiled patterns for tool-call XML parsing ────────── +_TC_JSON_START_RE = re.compile(r"\s*\{") +_TC_FUNC_START_RE = re.compile(r"\s*") +_TC_END_TAG_RE = re.compile(r"") +_TC_FUNC_CLOSE_RE = re.compile(r"\s*\s*$") +_TC_PARAM_START_RE = re.compile(r"\s*") +_TC_PARAM_CLOSE_RE = re.compile(r"\s*\s*$") + class LlamaCppBackend: """ @@ -242,14 +288,11 @@ class LlamaCppBackend: @staticmethod def _get_gguf_size_bytes(model_path: str) -> int: """Get total GGUF size in bytes, including split shards.""" - import re - main = Path(model_path) total = main.stat().st_size # Check for split shards (e.g., model-00001-of-00003.gguf) - shard_pat = re.compile(r"^(.*)-(\d{5})-of-(\d{5})\.gguf$") - m = shard_pat.match(main.name) + m = _SHARD_FULL_RE.match(main.name) if m: prefix, _, num_total = m.group(1), m.group(2), m.group(3) sibling_pat = re.compile( @@ -539,8 +582,6 @@ class LlamaCppBackend: Returns (first_shard_filename, total_size_bytes) or None if nothing fits. """ - import re - try: from huggingface_hub import get_paths_info, list_repo_files @@ -556,10 +597,9 @@ class LlamaCppBackend: size_map = {p.path: (p.size or 0) for p in path_infos} # Group files by variant: shards share a prefix before -NNNNN-of-NNNNN - shard_pat = re.compile(r"^(.*)-\d{5}-of-\d{5}\.gguf$") variants: dict[str, list[str]] = {} for f in gguf_files: - m = shard_pat.match(f) + m = _SHARD_RE.match(f) key = m.group(1) if m else f variants.setdefault(key, []).append(f) @@ -810,7 +850,6 @@ class LlamaCppBackend: gguf_extra_shards: list[str] = [] if hf_variant: try: - import re from huggingface_hub import list_repo_files files = list_repo_files(hf_repo, token = hf_token) @@ -825,11 +864,10 @@ class LlamaCppBackend: ) if gguf_files: gguf_filename = gguf_files[0] - shard_pat = re.compile(r"^(.*)-\d{5}-of-(\d{5})\.gguf$") - m = shard_pat.match(gguf_filename) + m = _SHARD_FULL_RE.match(gguf_filename) if m: prefix = m.group(1) - total = m.group(2) + total = m.group(3) sibling_pat = re.compile( r"^" + re.escape(prefix) @@ -886,10 +924,7 @@ class LlamaCppBackend: f"falling back to {fallback_file} ({fallback_size / (1024**3):.1f} GB)" ) gguf_filename = fallback_file - import re as _re - - _shard_pat = _re.compile(r"^(.*)-\d{5}-of-\d{5}\.gguf$") - _m = _shard_pat.match(gguf_filename) + _m = _SHARD_RE.match(gguf_filename) _prefix = _m.group(1) if _m else None if _prefix: gguf_extra_shards = sorted( @@ -1292,17 +1327,12 @@ class LlamaCppBackend: # Qwen3.5 models below 9B (0.8B, 2B, 4B) disable thinking by default. # Only 9B and larger enable thinking. if self._supports_reasoning: - import re - thinking_default = True mid = (model_identifier or "").lower() if "qwen3.5" in mid: - # Extract size like "0.8b", "4b", "35b" etc. - size_match = re.search(r"(\d+\.?\d*)\s*b", mid) - if size_match: - size_val = float(size_match.group(1)) - if size_val < 9: - thinking_default = False + size_val = _extract_model_size_b(mid) + if size_val is not None and size_val < 9: + thinking_default = False self._reasoning_default = thinking_default cmd.extend( [ @@ -1775,13 +1805,11 @@ class LlamaCppBackend: Closing tags (, , ) are all optional since models frequently omit them. """ - import re - tool_calls = [] # Pattern 1: JSON inside tags. # Use balanced-brace extraction that skips braces inside JSON strings. - for m in re.finditer(r"\s*\{", content): + for m in _TC_JSON_START_RE.finditer(content): brace_start = m.end() - 1 # position of the opening { depth, i = 0, brace_start in_string = False @@ -1831,7 +1859,7 @@ class LlamaCppBackend: # boundaries. We avoid using as a boundary because # code parameter values can contain that literal string. # After extracting, we trim a trailing if present. - func_starts = list(re.finditer(r"\s*", content)) + func_starts = list(_TC_FUNC_START_RE.finditer(content)) for idx, fm in enumerate(func_starts): func_name = fm.group(1) body_start = fm.end() @@ -1841,7 +1869,7 @@ class LlamaCppBackend: if idx + 1 < len(func_starts) else len(content) ) - end_tag = re.search(r"", content[body_start:]) + end_tag = _TC_END_TAG_RE.search(content[body_start:]) if end_tag: body_end = body_start + end_tag.start() else: @@ -1849,20 +1877,20 @@ class LlamaCppBackend: body_end = min(body_end, next_func) body = content[body_start:body_end] # Trim trailing if present (it's the real closing tag) - body = re.sub(r"\s*\s*$", "", body) + body = _TC_FUNC_CLOSE_RE.sub("", body) # Step 2: Extract parameters from body. # For single-parameter functions (the common case: code, command, # query), use body end as the only boundary to avoid false matches # on inside code strings. arguments = {} - param_starts = list(re.finditer(r"\s*", body)) + param_starts = list(_TC_PARAM_START_RE.finditer(body)) if len(param_starts) == 1: # Single parameter: value is everything from after the tag # to end of body, trimming any trailing . pm = param_starts[0] val = body[pm.end() :] - val = re.sub(r"\s*\s*$", "", val) + val = _TC_PARAM_CLOSE_RE.sub("", val) arguments[pm.group(1)] = val.strip() else: for pidx, pm in enumerate(param_starts): @@ -1876,7 +1904,7 @@ class LlamaCppBackend: ) val = body[val_start:next_param] # Trim trailing if present - val = re.sub(r"\s*\s*$", "", val) + val = _TC_PARAM_CLOSE_RE.sub("", val) arguments[param_name] = val.strip() tc = { @@ -2249,22 +2277,10 @@ class LlamaCppBackend: _accumulated_predicted_ms = 0.0 _accumulated_predicted_n = 0 - # ── Shared patterns for stripping tool XML from streamed content ── - import re as _re_tool - - _TOOL_CLOSED_PATTERNS = [ - _re_tool.compile(r".*?", _re_tool.DOTALL), - _re_tool.compile(r".*?", _re_tool.DOTALL), - ] - _TOOL_ALL_PATTERNS = _TOOL_CLOSED_PATTERNS + [ - _re_tool.compile(r".*$", _re_tool.DOTALL), - _re_tool.compile(r".*$", _re_tool.DOTALL), - ] - def _strip_tool_markup(text: str, *, final: bool = False) -> str: if not auto_heal_tool_calls: return text - patterns = _TOOL_ALL_PATTERNS if final else _TOOL_CLOSED_PATTERNS + patterns = _TOOL_ALL_PATS if final else _TOOL_CLOSED_PATS for pat in patterns: text = pat.sub("", text) return text.strip() if final else text @@ -2284,7 +2300,19 @@ class LlamaCppBackend: # identical call succeeded). _tool_call_history: list[tuple[str, bool]] = [] # (key, failed) - for iteration in range(max_tool_iterations): + # ── Re-prompt on plan-without-action ───────────────── + # When the model describes what it intends to do (forward-looking + # language) without actually calling a tool, re-prompt once. + # Only triggers on responses that signal intent/planning -- a + # direct answer like "4" or "Hello!" will not match. + # Pattern is compiled once at module level (_INTENT_SIGNAL). + _reprompt_count = 0 + + # Reserve extra iterations for re-prompts so they don't + # consume the caller's tool-call budget. Only add the + # extra slot when tool iterations are actually allowed. + _extra = _MAX_REPROMPTS if max_tool_iterations > 0 else 0 + for iteration in range(max_tool_iterations + _extra): if cancel_event is not None and cancel_event.is_set(): return @@ -2595,6 +2623,56 @@ class LlamaCppBackend: content_accum, ) if not _safety_tc: + # ── Re-prompt on plan-without-action ── + # If the model described what it intends to do + # (forward-looking language) without calling any + # tool, nudge it to act. Only fires once per + # request and only on short responses that + # contain intent signals -- a direct answer + # like "4" or "Hello!" won't trigger this. + # Use content if available, otherwise fall back + # to reasoning text (reasoning-only stalls). + _stripped = content_accum.strip() + if not _stripped: + _stripped = reasoning_accum.strip() + if ( + tools + and _reprompt_count < _MAX_REPROMPTS + and 0 < len(_stripped) < _REPROMPT_MAX_CHARS + and _INTENT_SIGNAL.search(_stripped) + ): + _reprompt_count += 1 + logger.info( + f"Re-prompt {_reprompt_count}/{_MAX_REPROMPTS}: " + f"model responded without calling tools " + f"({len(_stripped)} chars)" + ) + conversation.append( + { + "role": "assistant", + "content": _stripped, + } + ) + conversation.append( + { + "role": "user", + "content": ( + "Please use the available tools to complete " + "the task instead of describing what to do." + ), + } + ) + # Accumulate tokens and timing from this iteration + _fu_r = _iter_usage or {} + _accumulated_completion_tokens += _fu_r.get( + "completion_tokens", 0 + ) + _it_r = _iter_timings or {} + _accumulated_predicted_ms += _it_r.get("predicted_ms", 0) + _accumulated_predicted_n += _it_r.get("predicted_n", 0) + yield {"type": "status", "text": ""} + continue + # Content was already streamed. Yield metadata. yield {"type": "status", "text": ""} _fu = _iter_usage or {} diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 99a52dfe2d..0c1d7e37f9 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -21,6 +21,9 @@ import threading import re as _re +# Model size extraction (shared with core/inference/llama_cpp.py) +from utils.models import extract_model_size_b as _extract_model_size_b + def _friendly_error(exc: Exception) -> str: """Extract a user-friendly message from known llama-server errors.""" @@ -90,6 +93,12 @@ from datetime import date as _date router = APIRouter() +# Appended to tool-use nudge to discourage plan-without-action +_TOOL_ACTION_NUDGE = ( + " Always call tools directly." + " Never describe what you plan to do -- just call the tool immediately." +) + # Regex for stripping leaked tool-call XML from assistant messages/stream _TOOL_XML_RE = _re.compile( r".*?|.*?", @@ -1095,12 +1104,20 @@ async def openai_chat_completions( _date_line = f"The current date is {_date.today().isoformat()}." - _web_tips = ( - "When you search and find a relevant URL in the results, " - "fetch its full content by calling web_search with the url parameter. " - "Do not repeat the same search query. If a search returns " - "no useful results, try rephrasing or fetching a result URL directly." - ) + # Small models (<9B) struggle with multi-step search plans, + # so simplify the web tips to avoid plan-then-stall behavior. + _model_size_b = _extract_model_size_b(model_name) + _is_small_model = _model_size_b is not None and _model_size_b < 9 + + if _is_small_model: + _web_tips = "Do not repeat the same search query." + else: + _web_tips = ( + "When you search and find a relevant URL in the results, " + "fetch its full content by calling web_search with the url parameter. " + "Do not repeat the same search query. If a search returns " + "no useful results, try rephrasing or fetching a result URL directly." + ) _code_tips = ( "Use code execution for math, calculations, data processing, " "or to parse and analyze information from tool results." @@ -1132,6 +1149,7 @@ async def openai_chat_completions( _nudge = "" if _nudge: + _nudge += _TOOL_ACTION_NUDGE # Append nudge to system prompt (preserve user's prompt) if system_prompt: system_prompt = system_prompt.rstrip() + "\n\n" + _nudge @@ -1208,7 +1226,14 @@ async def openai_chat_completions( break if event["type"] == "status": + # Empty status marks an iteration boundary + # in the GGUF tool loop (e.g. after a + # re-prompt). Reset the cumulative cursor + # so the next assistant turn streams cleanly. + if not event["text"]: + prev_text = "" # Emit tool status as a custom SSE event + # (including empty ones to clear UI badges) status_data = json.dumps( { "type": "tool_status", diff --git a/studio/backend/utils/models/__init__.py b/studio/backend/utils/models/__init__.py index 82236d8013..a81682d8b7 100644 --- a/studio/backend/utils/models/__init__.py +++ b/studio/backend/utils/models/__init__.py @@ -19,6 +19,7 @@ from .model_config import ( get_base_model_from_lora, load_model_config, list_gguf_variants, + extract_model_size_b, MODEL_NAME_MAPPING, UI_STATUS_INDICATORS, ) @@ -38,6 +39,7 @@ __all__ = [ "get_base_model_from_lora", "load_model_config", "list_gguf_variants", + "extract_model_size_b", "MODEL_NAME_MAPPING", "UI_STATUS_INDICATORS", "scan_checkpoints", diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index f7d9b33542..df1058abf6 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -31,6 +31,37 @@ import yaml logger = get_logger(__name__) +# ── Model size extraction ──────────────────────────────────── +import re as _re + +_MODEL_SIZE_RE = _re.compile( + r"(?:^|[-_/])(\d+\.?\d*)\s*([bm])(?:$|[-_/])", _re.IGNORECASE +) +# MoE active-parameter pattern: matches "A3B", "A3.5B", etc. +_ACTIVE_SIZE_RE = _re.compile( + r"(?:^|[-_/])a(\d+\.?\d*)\s*([bm])(?:$|[-_/])", _re.IGNORECASE +) + + +def extract_model_size_b(model_id: str) -> float | None: + """Extract model size in billions from a model identifier. + + Prefers MoE active-parameter notation (e.g. ``A3B`` in + ``Qwen3.5-35B-A3B``) over the total parameter count. + Handles both ``B`` (billions) and ``M`` (millions) suffixes. + """ + mid = (model_id or "").lower() + active = _ACTIVE_SIZE_RE.search(mid) + if active: + val = float(active.group(1)) + return val / 1000.0 if active.group(2).lower() == "m" else val + size = _MODEL_SIZE_RE.search(mid) + if not size: + return None + val = float(size.group(1)) + return val / 1000.0 if size.group(2).lower() == "m" else val + + # Model name mapping: maps all equivalent model names to their canonical YAML config file # Format: "canonical_model_name.yaml": [list of all equivalent model names] # Based on the model mapper provided - canonical filename is based on the first model name in the mapper From 31d6aeb1975c418267547e0baa4ed698564d9940 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Thu, 2 Apr 2026 02:58:21 -0700 Subject: [PATCH 006/216] Unsloth new logo --- images/unsloth logo only.png | Bin 57176 -> 60610 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/images/unsloth logo only.png b/images/unsloth logo only.png index 92340eef059b9b687cf92aea9fe109106906eff3..adaafee48dc80f38582cad3df0b56b0b3e7d7302 100644 GIT binary patch literal 60610 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 DzlO!b literal 57176 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

*kdw!3gU%!f{_eeByur%6A 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 V?zYKCmJ7e`8S0zpk#rtJ{0}|tFD3v0 From e2fd946fe12e7defab5f36973628d3bc3ff388c2 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Thu, 2 Apr 2026 03:00:10 -0700 Subject: [PATCH 007/216] Add files via upload --- images/unsloth new logo.png | Bin 60119 -> 60610 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/images/unsloth new logo.png b/images/unsloth new logo.png index 20dac04f388d8eec89066d81362359994e855786..adaafee48dc80f38582cad3df0b56b0b3e7d7302 100644 GIT binary patch literal 60610 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 DzlO!b literal 60119 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 k_raXWj`fv?Im>JM6Rn>$X}XY Date: Thu, 2 Apr 2026 04:45:09 -0700 Subject: [PATCH 008/216] Add regression tests for custom llama prebuilt installer (#4772) Expand test coverage for install_llama_prebuilt.py: - Add tests for source build plan resolution with custom repos - Add tests for branch/commit/PR ref matching and normalization - Add tests for manifest checksum validation - Add tests for Windows CUDA upstream asset name patterns - Update capsys checks to capture stderr after log() redirect --- .../test_install_llama_prebuilt_logic.py | 6 +- tests/studio/install/test_pr4562_bugfixes.py | 82 +++- tests/studio/install/test_selection_logic.py | 384 +++++++++++++++++- 3 files changed, 445 insertions(+), 27 deletions(-) diff --git a/tests/studio/install/test_install_llama_prebuilt_logic.py b/tests/studio/install/test_install_llama_prebuilt_logic.py index cfbbdeb66c..79dab30129 100644 --- a/tests/studio/install/test_install_llama_prebuilt_logic.py +++ b/tests/studio/install/test_install_llama_prebuilt_logic.py @@ -509,7 +509,8 @@ def test_activate_install_tree_restores_existing_install_after_activation_failur assert not staging_dir.exists() assert not (tmp_path / ".staging").exists() - output = capsys.readouterr().out + captured = capsys.readouterr() + output = captured.out + captured.err assert "moving existing install to rollback path" in output assert "restored previous install from rollback path" in output @@ -571,7 +572,8 @@ def test_activate_install_tree_cleans_all_paths_when_rollback_restore_fails( assert not staging_dir.exists() assert not (tmp_path / ".staging").exists() - output = capsys.readouterr().out + captured = capsys.readouterr() + output = captured.out + captured.err assert "rollback after failed activation also failed: restore failed" in output assert ( "cleaning staging, install, and rollback paths before source build fallback" diff --git a/tests/studio/install/test_pr4562_bugfixes.py b/tests/studio/install/test_pr4562_bugfixes.py index 32787aee34..f5a423d130 100644 --- a/tests/studio/install/test_pr4562_bugfixes.py +++ b/tests/studio/install/test_pr4562_bugfixes.py @@ -6,7 +6,7 @@ Tests cover: - Bug 2: Source-build fallback ignores pinned tag (both .sh and .ps1) - Bug 3: Unix fallback deletes install before checking prerequisites - Bug 4: Linux LD_LIBRARY_PATH missing build/bin - - "latest" tag resolution fallback chain (helper -> raw) + - "latest" tag resolution fallback chain (helper only) - Cross-platform binary_env (Linux, macOS, Windows) - Edge cases: malformed JSON, empty responses, env overrides @@ -296,6 +296,56 @@ class TestResolveRequestedLlamaTag: assert resolve_requested_llama_tag("latest", "unslothai/llama.cpp") == "b8999" + def test_latest_with_published_release_tag_passes_pin_through( + self, monkeypatch: pytest.MonkeyPatch + ): + captured = {} + + def fake_resolve(requested_tag, published_repo, published_release_tag = ""): + captured["requested_tag"] = requested_tag + captured["published_repo"] = published_repo + captured["published_release_tag"] = published_release_tag + return MOD.ResolvedPublishedRelease( + bundle = PublishedReleaseBundle( + repo = published_repo, + release_tag = published_release_tag, + upstream_tag = "b9001", + assets = {}, + manifest_asset_name = "llama-prebuilt-manifest.json", + artifacts = [], + selection_log = [], + ), + checksums = ApprovedReleaseChecksums( + repo = published_repo, + release_tag = published_release_tag, + upstream_tag = "b9001", + artifacts = { + source_archive_logical_name("b9001"): ApprovedArtifactHash( + asset_name = source_archive_logical_name("b9001"), + sha256 = "a" * 64, + repo = "ggml-org/llama.cpp", + kind = "upstream-source", + ) + }, + ), + ) + + monkeypatch.setattr(MOD, "resolve_published_release", fake_resolve) + + assert ( + resolve_requested_llama_tag( + "latest", + "unslothai/llama.cpp", + "llama-prebuilt-main", + ) + == "b9001" + ) + assert captured == { + "requested_tag": "latest", + "published_repo": "unslothai/llama.cpp", + "published_release_tag": "llama-prebuilt-main", + } + # ========================================================================= # TEST GROUP C: setup.sh logic (bash subprocess tests) @@ -586,18 +636,29 @@ class TestSourceCodePatterns: content = SETUP_SH.read_text() assert "_CLONE_ARGS=(git clone --depth 1)" in content assert ( - '_CLONE_ARGS+=(--branch "$_RESOLVED_LLAMA_TAG")' in content - ), "_CLONE_ARGS should be extended with --branch $_RESOLVED_LLAMA_TAG" + '_CLONE_ARGS+=(--branch "$_RESOLVED_SOURCE_REF")' in content + ), "_CLONE_ARGS should be extended with --branch $_RESOLVED_SOURCE_REF" # Verify the guard: --branch is only used when tag is not "latest" assert ( - '_RESOLVED_LLAMA_TAG" != "latest"' in content + '_RESOLVED_SOURCE_REF" != "latest"' in content ), "Should guard against literal 'latest' tag" + def test_setup_sh_source_build_uses_helper_resolution(self): + """Shell source fallback should consult the helper for repo/ref planning.""" + content = SETUP_SH.read_text() + assert "--resolve-source-build" in content + assert "--output-format json" in content + assert "_RESOLVED_SOURCE_URL" in content + assert "_RESOLVED_SOURCE_REF_KIND" in content + assert "_RESOLVED_SOURCE_REF" in content + def test_setup_sh_latest_resolution_uses_helper_only(self): """Shell fallback should rely on helper output, not raw GitHub API tag_name.""" content = SETUP_SH.read_text() assert "--resolve-install-tag" in content assert "--resolve-llama-tag" in content + assert 'tail -n 1 "$_RESOLVE_LLAMA_LOG"' not in content + assert "json.load" in content assert "_HELPER_RELEASE_REPO}/releases/latest" not in content assert "ggml-org/llama.cpp/releases/latest" not in content @@ -677,7 +738,7 @@ class TestSourceCodePatterns: def test_setup_ps1_clone_uses_branch_tag(self): """PS1 clone should use --branch with the resolved tag.""" content = SETUP_PS1.read_text() - assert "--branch" in content and "$ResolvedLlamaTag" in content + assert "--branch" in content and "$ResolvedSourceRef" in content # The old commented-out line should be gone assert "# git clone --depth 1 --branch" not in content @@ -703,9 +764,20 @@ class TestSourceCodePatterns: content = SETUP_PS1.read_text() assert "--resolve-install-tag" in content assert "--resolve-llama-tag" in content + assert '--output-format", "json"' in content + assert "ConvertFrom-Json" in content assert "$HelperReleaseRepo/releases/latest" not in content assert "ggml-org/llama.cpp/releases/latest" not in content + def test_setup_ps1_source_build_uses_helper_resolution(self): + """PS1 source fallback should consult the helper for repo/ref planning.""" + content = SETUP_PS1.read_text() + assert "--resolve-source-build" in content + assert '--output-format", "json"' in content + assert "$ResolvedSourceUrl" in content + assert "$ResolvedSourceRefKind" in content + assert "$ResolvedSourceRef" in content + def test_binary_env_linux_has_binary_parent(self): """The Linux branch of binary_env should include binary_path.parent.""" content = MODULE_PATH.read_text() diff --git a/tests/studio/install/test_selection_logic.py b/tests/studio/install/test_selection_logic.py index d7bea4bb5c..b8de0e221f 100644 --- a/tests/studio/install/test_selection_logic.py +++ b/tests/studio/install/test_selection_logic.py @@ -58,7 +58,21 @@ resolve_requested_install_tag = INSTALL_LLAMA_PREBUILT.resolve_requested_install resolve_install_attempts = INSTALL_LLAMA_PREBUILT.resolve_install_attempts resolve_install_release_plans = INSTALL_LLAMA_PREBUILT.resolve_install_release_plans resolve_published_release = INSTALL_LLAMA_PREBUILT.resolve_published_release +resolve_source_build_plan = INSTALL_LLAMA_PREBUILT.resolve_source_build_plan +validated_checksums_for_bundle = INSTALL_LLAMA_PREBUILT.validated_checksums_for_bundle +parse_approved_release_checksums = ( + INSTALL_LLAMA_PREBUILT.parse_approved_release_checksums +) +published_release_matches_request = ( + INSTALL_LLAMA_PREBUILT.published_release_matches_request +) +exact_source_archive_logical_name = ( + INSTALL_LLAMA_PREBUILT.exact_source_archive_logical_name +) source_archive_logical_name = INSTALL_LLAMA_PREBUILT.source_archive_logical_name +windows_cuda_upstream_asset_names = ( + INSTALL_LLAMA_PREBUILT.windows_cuda_upstream_asset_names +) env_int = INSTALL_LLAMA_PREBUILT.env_int @@ -110,6 +124,13 @@ def make_release(artifacts, **overrides): repo = "unslothai/llama.cpp", release_tag = "v1.0", upstream_tag = "b8508", + source_repo = None, + source_repo_url = None, + source_ref_kind = None, + requested_source_ref = None, + resolved_source_ref = None, + source_commit = None, + source_commit_short = None, assets = {a.asset_name: f"https://example.com/{a.asset_name}" for a in artifacts}, manifest_asset_name = "llama-prebuilt-manifest.json", artifacts = artifacts, @@ -124,7 +145,13 @@ def make_checksums(asset_names): repo = "unslothai/llama.cpp", release_tag = "v1.0", upstream_tag = "b8508", + source_repo = None, + source_repo_url = None, + source_ref_kind = None, + requested_source_ref = None, + resolved_source_ref = None, source_commit = None, + source_commit_short = None, artifacts = { name: ApprovedArtifactHash( asset_name = name, @@ -142,29 +169,56 @@ def make_checksums_with_source( *, release_tag = "v1.0", upstream_tag = "b8508", + source_repo = None, + source_repo_url = None, + source_ref_kind = None, + requested_source_ref = None, + resolved_source_ref = None, + source_commit = None, ): + artifacts = { + **{ + name: ApprovedArtifactHash( + asset_name = name, + sha256 = "a" * 64, + repo = "unslothai/llama.cpp", + kind = "prebuilt", + ) + for name in asset_names + }, + source_archive_logical_name(upstream_tag): ApprovedArtifactHash( + asset_name = source_archive_logical_name(upstream_tag), + sha256 = "b" * 64, + repo = "ggml-org/llama.cpp", + kind = "upstream-source", + ), + } + normalized_source_commit = ( + source_commit.lower() if isinstance(source_commit, str) else None + ) + if normalized_source_commit: + artifacts[exact_source_archive_logical_name(normalized_source_commit)] = ( + ApprovedArtifactHash( + asset_name = exact_source_archive_logical_name(normalized_source_commit), + sha256 = "c" * 64, + repo = source_repo or "example/custom-llama.cpp", + kind = "exact-source", + ) + ) return ApprovedReleaseChecksums( repo = "unslothai/llama.cpp", release_tag = release_tag, upstream_tag = upstream_tag, - source_commit = None, - artifacts = { - **{ - name: ApprovedArtifactHash( - asset_name = name, - sha256 = "a" * 64, - repo = "unslothai/llama.cpp", - kind = "prebuilt", - ) - for name in asset_names - }, - source_archive_logical_name(upstream_tag): ApprovedArtifactHash( - asset_name = source_archive_logical_name(upstream_tag), - sha256 = "b" * 64, - repo = "ggml-org/llama.cpp", - kind = "upstream-source", - ), - }, + source_repo = source_repo, + source_repo_url = source_repo_url, + source_ref_kind = source_ref_kind, + requested_source_ref = requested_source_ref, + resolved_source_ref = resolved_source_ref, + source_commit = normalized_source_commit, + source_commit_short = normalized_source_commit[:7] + if normalized_source_commit + else None, + artifacts = artifacts, ) @@ -432,6 +486,31 @@ class TestApplyApprovedHashes: assert len(result) == 1 assert result[0].name == "a.tar.gz" + def test_upstream_asset_can_match_compatibility_tag_name(self): + choice = AssetChoice( + repo = UPSTREAM_REPO, + tag = "main", + name = "llama-main-bin-macos-arm64.tar.gz", + url = "https://x/llama-main-bin-macos-arm64.tar.gz", + source_label = "upstream", + ) + checksums = ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "r1", + upstream_tag = "b9000", + artifacts = { + "llama-b9000-bin-macos-arm64.tar.gz": ApprovedArtifactHash( + asset_name = "llama-b9000-bin-macos-arm64.tar.gz", + sha256 = "a" * 64, + repo = UPSTREAM_REPO, + kind = "macos-arm64-upstream", + ) + }, + ) + + result = apply_approved_hashes([choice], checksums) + assert result[0].expected_sha256 == "a" * 64 + def test_none_approved(self): c1 = self._choice("missing.tar.gz") checksums = make_checksums(["other.tar.gz"]) @@ -536,6 +615,259 @@ class TestPublishedReleaseResolution: "unslothai/llama.cpp", ) + def test_request_matches_requested_source_ref(self, monkeypatch): + release = make_release( + [], + release_tag = "release-main", + upstream_tag = "b9000", + requested_source_ref = "main", + resolved_source_ref = "refs/heads/main", + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "iter_published_release_bundles", + lambda repo, published_release_tag = "": iter([release]), + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "load_approved_release_checksums", + lambda repo, release_tag: make_checksums_with_source( + [], + release_tag = release_tag, + upstream_tag = "b9000", + requested_source_ref = "main", + resolved_source_ref = "refs/heads/main", + ), + ) + + resolved = resolve_published_release("main", "unslothai/llama.cpp") + assert resolved.bundle.release_tag == "release-main" + + def test_request_matches_source_commit(self, monkeypatch): + commit = "a" * 40 + release = make_release( + [], + release_tag = "release-commit", + upstream_tag = "b9000", + source_commit = commit, + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "iter_published_release_bundles", + lambda repo, published_release_tag = "": iter([release]), + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "load_approved_release_checksums", + lambda repo, release_tag: make_checksums_with_source( + [], + release_tag = release_tag, + upstream_tag = "b9000", + source_commit = commit, + ), + ) + + resolved = resolve_published_release(commit, "unslothai/llama.cpp") + assert resolved.bundle.release_tag == "release-commit" + + +class TestSourceBuildPlanResolution: + def test_matches_request_by_non_tag_provenance(self): + bundle = make_release( + [], + requested_source_ref = "main", + resolved_source_ref = "refs/heads/main", + source_commit = "a" * 40, + ) + assert published_release_matches_request(bundle, "main") is True + assert published_release_matches_request(bundle, "refs/heads/main") is True + assert published_release_matches_request(bundle, "a" * 12) is True + assert published_release_matches_request(bundle, "a" * 40) is True + + def test_matches_pull_ref_aliases(self): + bundle = make_release( + [], + requested_source_ref = "refs/pull/123/head", + resolved_source_ref = "pull/123/head", + ) + assert published_release_matches_request(bundle, "refs/pull/123/head") is True + assert published_release_matches_request(bundle, "pull/123/head") is True + + def test_prefers_exact_source_commit_when_available(self, monkeypatch): + commit = "a" * 40 + resolved = INSTALL_LLAMA_PREBUILT.ResolvedPublishedRelease( + bundle = make_release( + [], + release_tag = "release-main", + upstream_tag = "b9000", + source_repo = "example/custom-llama.cpp", + source_repo_url = "https://github.com/example/custom-llama.cpp", + source_ref_kind = "branch", + requested_source_ref = "main", + resolved_source_ref = "refs/heads/main", + source_commit = commit, + ), + checksums = make_checksums_with_source( + [], + release_tag = "release-main", + upstream_tag = "b9000", + source_repo = "example/custom-llama.cpp", + source_repo_url = "https://github.com/example/custom-llama.cpp", + source_ref_kind = "branch", + requested_source_ref = "main", + resolved_source_ref = "refs/heads/main", + source_commit = commit, + ), + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "resolve_published_release", + lambda requested_tag, published_repo, published_release_tag = "": resolved, + ) + + plan = resolve_source_build_plan("main", "unslothai/llama.cpp") + assert plan.source_url == "https://github.com/example/custom-llama.cpp" + assert plan.source_ref_kind == "commit" + assert plan.source_ref == commit + assert plan.compatibility_upstream_tag == "b9000" + + def test_uses_branch_provenance_without_exact_source_hash(self, monkeypatch): + resolved = INSTALL_LLAMA_PREBUILT.ResolvedPublishedRelease( + bundle = make_release( + [], + release_tag = "release-main", + upstream_tag = "b9000", + source_repo = "example/custom-llama.cpp", + source_repo_url = "https://github.com/example/custom-llama.cpp", + source_ref_kind = "branch", + requested_source_ref = "main", + resolved_source_ref = "main", + ), + checksums = make_checksums_with_source( + [], + release_tag = "release-main", + upstream_tag = "b9000", + source_repo = "example/custom-llama.cpp", + source_repo_url = "https://github.com/example/custom-llama.cpp", + source_ref_kind = "branch", + requested_source_ref = "main", + resolved_source_ref = "main", + source_commit = None, + ), + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "resolve_published_release", + lambda requested_tag, published_repo, published_release_tag = "": resolved, + ) + + plan = resolve_source_build_plan("main", "unslothai/llama.cpp") + assert plan.source_url == "https://github.com/example/custom-llama.cpp" + assert plan.source_ref_kind == "branch" + assert plan.source_ref == "main" + assert plan.compatibility_upstream_tag == "b9000" + + def test_direct_main_request_without_published_release_uses_branch_kind( + self, monkeypatch + ): + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "resolve_published_release", + lambda requested_tag, published_repo, published_release_tag = "": ( + _ for _ in () + ).throw(PrebuiltFallback("missing")), + ) + + plan = resolve_source_build_plan("main", "unslothai/llama.cpp") + assert plan.source_url == "https://github.com/ggml-org/llama.cpp" + assert plan.source_ref_kind == "branch" + assert plan.source_ref == "main" + + +class TestParseApprovedReleaseChecksums: + def test_rejects_wrong_component(self): + with pytest.raises(RuntimeError, match = "did not describe llama.cpp"): + parse_approved_release_checksums( + "repo/test", + "r1", + { + "schema_version": 1, + "component": "other", + "release_tag": "r1", + "upstream_tag": "b8508", + "artifacts": {}, + }, + ) + + def test_rejects_mismatched_release_tag(self): + with pytest.raises(RuntimeError, match = "did not match pinned release tag"): + parse_approved_release_checksums( + "repo/test", + "r1", + { + "schema_version": 1, + "component": "llama.cpp", + "release_tag": "r2", + "upstream_tag": "b8508", + "artifacts": {}, + }, + ) + + def test_rejects_bad_sha256(self): + with pytest.raises(RuntimeError, match = "valid sha256"): + parse_approved_release_checksums( + "repo/test", + "r1", + { + "schema_version": 1, + "component": "llama.cpp", + "release_tag": "r1", + "upstream_tag": "b8508", + "artifacts": { + "asset.tar.gz": { + "sha256": "bad-digest", + } + }, + }, + ) + + def test_rejects_unsupported_schema_version(self): + with pytest.raises(RuntimeError, match = "schema_version=2 is unsupported"): + parse_approved_release_checksums( + "repo/test", + "r1", + { + "schema_version": 2, + "component": "llama.cpp", + "release_tag": "r1", + "upstream_tag": "b8508", + "artifacts": {}, + }, + ) + + +class TestValidatedChecksumsForBundle: + def test_rejects_manifest_checksum_mismatch(self, monkeypatch): + bundle = make_release([], release_tag = "r1", upstream_tag = "b8508") + bundle.manifest_sha256 = "a" * 64 + checksums = make_checksums_with_source( + [], release_tag = "r1", upstream_tag = "b8508" + ) + checksums.artifacts[bundle.manifest_asset_name] = ApprovedArtifactHash( + asset_name = bundle.manifest_asset_name, + sha256 = "b" * 64, + repo = "unslothai/llama.cpp", + kind = "published-manifest", + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "load_approved_release_checksums", + lambda repo, release_tag: checksums, + ) + + with pytest.raises(PrebuiltFallback, match = "manifest checksum"): + validated_checksums_for_bundle("unslothai/llama.cpp", bundle) + # =========================================================================== # K. linux_cuda_choice_from_release -- core selection @@ -1360,10 +1692,13 @@ class TestResolveInstallReleasePlans: class TestWindowsCudaAttempts: TAG = "b8508" - def _upstream(self, *runtime_versions): + def _upstream(self, *runtime_versions, current_names: bool = False): assets = {} for rv in runtime_versions: - name = f"llama-{self.TAG}-bin-win-cuda-{rv}-x64.zip" + if current_names: + name = f"cudart-llama-bin-win-cuda-{rv}-x64.zip" + else: + name = f"llama-{self.TAG}-bin-win-cuda-{rv}-x64.zip" assets[name] = f"https://example.com/{name}" return assets @@ -1428,6 +1763,15 @@ class TestWindowsCudaAttempts: result = windows_cuda_attempts(host, self.TAG, assets, None) assert len(result) == 2 + def test_current_upstream_names_are_supported(self, monkeypatch): + mock_windows_runtime(monkeypatch, ["cuda13", "cuda12"]) + host = make_host(system = "Windows", machine = "AMD64", driver_cuda_version = (13, 1)) + assets = self._upstream("13.1", "12.4", current_names = True) + result = windows_cuda_attempts(host, self.TAG, assets, None) + assert len(result) == 2 + assert result[0].name == "cudart-llama-bin-win-cuda-13.1-x64.zip" + assert result[1].name == "cudart-llama-bin-win-cuda-12.4-x64.zip" + # =========================================================================== # O. resolve_upstream_asset_choice -- platform routing From 1ce8a8e7cdb37f5ab613a8f1468dd0c3b92fb5aa Mon Sep 17 00:00:00 2001 From: DoubleMathew Date: Thu, 2 Apr 2026 06:52:26 -0500 Subject: [PATCH 009/216] Feat/custom llama prebuilt (#4771) * update logic to incorporate custom prebuilt installs * bug fixes * update for review comments * fix tags * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Separate test changes from main PR Move test file changes out of this PR to keep the diff focused on the install_llama_prebuilt.py and setup script changes. Test updates will be submitted in a follow-up PR. * Fix branch ref normalization and harden JSON parsing - Add checkout_friendly_ref() to strip refs/heads/ prefix from branch refs before emitting them in SourceBuildPlan. git clone --branch does not accept fully qualified refs like refs/heads/main. - Apply normalization in source_build_plan_for_release() and the direct-ref fallback in resolve_source_build_plan(). - Allow validated_checksums_for_bundle() to accept releases that carry only an exact-commit source archive without the legacy upstream-tag source tarball. - Add 2>/dev/null || true guards to all inline python -c JSON parsing in setup.sh so a malformed payload does not abort the script under set -e. * Fix Windows CUDA asset ordering and tag ref normalization - Reorder windows_cuda_upstream_asset_names to prefer the main binary archive (llama-{tag}-bin-win-cuda-*) over the cudart sidecar archive (cudart-llama-bin-win-cuda-*). The cudart ZIP only contains CUDA runtime DLLs, not llama-server or llama-quantize binaries. - Extend checkout_friendly_ref to also strip refs/tags/ prefix for tag refs, matching the refs/heads/ handling for branch refs. * Simplify JSON parsing consistency in setup.sh Use json.load(sys.stdin) consistently for all inline JSON parsing in setup.sh, instead of the more complex json.loads(raw) pattern on the install-tag resolution path. The 2>/dev/null || true guard already handles empty/malformed input gracefully. * Fix source build plan fallback for commit ref kind in PR #4771 * [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: Daniel Han Co-authored-by: Daniel Han --- studio/install_llama_prebuilt.py | 710 +++++++++++++++++++++++++++---- studio/setup.ps1 | 145 ++++++- studio/setup.sh | 102 ++++- 3 files changed, 857 insertions(+), 100 deletions(-) diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 0f96cf34eb..76d22984d7 100755 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -28,7 +28,7 @@ import urllib.parse import urllib.request import zipfile from contextlib import contextmanager -from dataclasses import dataclass +from dataclasses import dataclass, field try: from filelock import FileLock, Timeout as FileLockTimeout @@ -59,7 +59,6 @@ def env_int(name: str, default: int, *, minimum: int | None = None) -> int: return value -APPROVED_PREBUILT_LLAMA_TAG = "b8508" DEFAULT_LLAMA_TAG = os.environ.get("UNSLOTH_LLAMA_TAG", "latest") DEFAULT_PUBLISHED_REPO = os.environ.get( "UNSLOTH_LLAMA_RELEASE_REPO", "unslothai/llama.cpp" @@ -151,10 +150,18 @@ class PublishedReleaseBundle: repo: str release_tag: str upstream_tag: str - assets: dict[str, str] - manifest_asset_name: str - artifacts: list[PublishedLlamaArtifact] - selection_log: list[str] + manifest_sha256: str | None = None + source_repo: str | None = None + source_repo_url: str | None = None + source_ref_kind: str | None = None + requested_source_ref: str | None = None + resolved_source_ref: str | None = None + source_commit: str | None = None + source_commit_short: str | None = None + assets: dict[str, str] = field(default_factory = dict) + manifest_asset_name: str = DEFAULT_PUBLISHED_MANIFEST_ASSET + artifacts: list[PublishedLlamaArtifact] = field(default_factory = list) + selection_log: list[str] = field(default_factory = list) @dataclass @@ -188,8 +195,14 @@ class ApprovedReleaseChecksums: repo: str release_tag: str upstream_tag: str - source_commit: str | None - artifacts: dict[str, ApprovedArtifactHash] + source_repo: str | None = None + source_repo_url: str | None = None + source_ref_kind: str | None = None + requested_source_ref: str | None = None + resolved_source_ref: str | None = None + source_commit: str | None = None + source_commit_short: str | None = None + artifacts: dict[str, ApprovedArtifactHash] = field(default_factory = dict) @dataclass(frozen = True) @@ -198,6 +211,19 @@ class ResolvedPublishedRelease: checksums: ApprovedReleaseChecksums +@dataclass(frozen = True) +class SourceBuildPlan: + source_url: str + source_ref: str + source_ref_kind: str + compatibility_upstream_tag: str + source_repo: str | None = None + source_repo_url: str | None = None + requested_source_ref: str | None = None + resolved_source_ref: str | None = None + source_commit: str | None = None + + @dataclass(frozen = True) class InstallReleasePlan: requested_tag: str @@ -270,7 +296,7 @@ def is_busy_lock_error(exc: BaseException) -> bool: def log(message: str) -> None: - print(f"[llama-prebuilt] {message}") + print(f"[llama-prebuilt] {message}", file = sys.stderr) def log_lines(lines: Iterable[str]) -> None: @@ -358,6 +384,10 @@ def source_archive_logical_name(upstream_tag: str) -> str: return f"llama.cpp-source-{upstream_tag}.tar.gz" +def exact_source_archive_logical_name(source_commit: str) -> str: + return f"llama.cpp-source-commit-{source_commit}.tar.gz" + + def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: @@ -366,6 +396,10 @@ def sha256_file(path: Path) -> str: return digest.hexdigest() +def sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + def normalize_sha256_digest(value: str | None) -> str | None: if not isinstance(value, str) or not value: return None @@ -377,6 +411,156 @@ def normalize_sha256_digest(value: str | None) -> str | None: return lowered +def normalize_source_ref_kind(value: str | None) -> str | None: + if not isinstance(value, str): + return None + normalized = value.strip().lower() + if normalized in {"tag", "branch", "pull", "commit", "custom"}: + return normalized + return None + + +def normalize_source_commit(value: str | None) -> str | None: + if not isinstance(value, str): + return None + normalized = value.strip().lower() + if len(normalized) < 7 or len(normalized) > 40: + return None + if any(ch not in "0123456789abcdef" for ch in normalized): + return None + return normalized + + +def validate_schema_version(payload: dict[str, Any], *, label: str) -> None: + schema_version = payload.get("schema_version") + if schema_version is None: + return + try: + normalized = int(schema_version) + except (TypeError, ValueError) as exc: + raise RuntimeError(f"{label} schema_version was not an integer") from exc + if normalized != 1: + raise RuntimeError(f"{label} schema_version={normalized} is unsupported") + + +def repo_slug_from_source(value: str | None) -> str | None: + if not isinstance(value, str): + return None + normalized = value.strip() + if not normalized: + return None + normalized = normalized.removesuffix(".git") + if normalized.startswith("https://github.com/"): + slug = normalized[len("https://github.com/") :] + elif normalized.startswith("http://github.com/"): + slug = normalized[len("http://github.com/") :] + elif normalized.startswith("git@github.com:"): + slug = normalized[len("git@github.com:") :] + else: + slug = normalized + slug = slug.strip("/") + parts = slug.split("/") + if len(parts) != 2 or not all(parts): + return None + return f"{parts[0]}/{parts[1]}" + + +def source_url_from_repo_slug(repo_slug: str | None) -> str | None: + if not isinstance(repo_slug, str) or not repo_slug: + return None + return f"https://github.com/{repo_slug}" + + +def source_repo_clone_url(repo: str | None, repo_url: str | None) -> str | None: + if isinstance(repo_url, str) and repo_url.strip(): + return repo_url.strip().removesuffix(".git") + return source_url_from_repo_slug(repo_slug_from_source(repo)) + + +def infer_source_ref_kind(ref: str | None) -> str: + if not isinstance(ref, str): + return "tag" + normalized = ref.strip() + lowered = normalized.lower() + if not normalized: + return "tag" + if lowered.startswith("refs/pull/") or lowered.startswith("pull/"): + return "pull" + if ( + lowered.startswith("refs/heads/") + or lowered in {"main", "master", "head"} + or lowered.startswith("origin/") + ): + return "branch" + normalized_commit = normalize_source_commit(normalized) + if normalized_commit is not None: + return "commit" + return "tag" + + +def normalized_ref_aliases(ref: str | None) -> set[str]: + if not isinstance(ref, str): + return set() + normalized = ref.strip() + if not normalized: + return set() + aliases = {normalized} + lowered = normalized.lower() + commit = normalize_source_commit(normalized) + if commit is not None: + aliases.add(commit) + if lowered.startswith("refs/heads/"): + aliases.add(normalized.split("/", 2)[2]) + elif "/" not in normalized and infer_source_ref_kind(normalized) == "branch": + aliases.add(f"refs/heads/{normalized}") + if lowered.startswith("refs/pull/"): + aliases.add(normalized.removeprefix("refs/")) + elif lowered.startswith("pull/"): + aliases.add(f"refs/{normalized}") + return aliases + + +def refs_match(candidate_ref: str | None, requested_ref: str | None) -> bool: + candidate_aliases = normalized_ref_aliases(candidate_ref) + requested_aliases = normalized_ref_aliases(requested_ref) + if not candidate_aliases or not requested_aliases: + return False + if candidate_aliases & requested_aliases: + return True + candidate_commit = normalize_source_commit(candidate_ref) + requested_commit = normalize_source_commit(requested_ref) + if candidate_commit and requested_commit: + return candidate_commit.startswith( + requested_commit + ) or requested_commit.startswith(candidate_commit) + return False + + +def checkout_friendly_ref(ref_kind: str | None, ref: str | None) -> str | None: + """Normalize a source ref to a form that ``git clone --branch`` accepts. + + Fully qualified branch refs like ``refs/heads/main`` are stripped to + ``main``; tag refs like ``refs/tags/b8508`` are stripped to ``b8508``. + Pull refs like ``refs/pull/123/head`` are left as-is since they are + always fetched explicitly rather than cloned with ``--branch``. + """ + if not isinstance(ref, str) or not ref: + return ref + lowered = ref.lower() + if ref_kind == "branch" and lowered.startswith("refs/heads/"): + return ref.split("/", 2)[2] + if ref_kind == "tag" and lowered.startswith("refs/tags/"): + return ref.split("/", 2)[2] + return ref + + +def windows_cuda_upstream_asset_names(llama_tag: str, runtime: str) -> list[str]: + return [ + f"llama-{llama_tag}-bin-win-cuda-{runtime}-x64.zip", + f"cudart-llama-bin-win-cuda-{runtime}-x64.zip", + ] + + def format_byte_count(num_bytes: float) -> str: units = ["B", "KiB", "MiB", "GiB", "TiB"] value = float(num_bytes) @@ -537,13 +721,21 @@ def download_bytes( def fetch_json(url: str) -> Any: - data = download_bytes( - url, - timeout = 30, - headers = github_api_headers(url) - if is_github_api_url(url) - else auth_headers(url), - ) + try: + data = download_bytes( + url, + timeout = 30, + headers = github_api_headers(url) + if is_github_api_url(url) + else auth_headers(url), + ) + except urllib.error.HTTPError as exc: + if exc.code == 403 and is_github_api_url(url): + hint = "" + if not (os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")): + hint = "; set GH_TOKEN or GITHUB_TOKEN to avoid GitHub API rate limits" + raise RuntimeError(f"GitHub API returned 403 for {url}{hint}") from exc + raise if not data: raise RuntimeError(f"downloaded empty JSON payload from {url}") try: @@ -648,6 +840,14 @@ def upstream_source_archive_urls(tag: str) -> list[str]: ] +def commit_source_archive_urls(repo: str, source_commit: str) -> list[str]: + encoded_commit = urllib.parse.quote(source_commit, safe = "") + return [ + f"https://codeload.github.com/{repo}/tar.gz/{encoded_commit}", + f"https://github.com/{repo}/archive/{encoded_commit}.tar.gz", + ] + + def github_release_assets(repo: str, tag: str) -> dict[str, str]: payload = fetch_json( f"https://api.github.com/repos/{repo}/releases/tags/{urllib.parse.quote(tag, safe = '')}" @@ -984,13 +1184,35 @@ def parse_published_release_bundle( # Mixed repos are filtered by an explicit release-side manifest rather than # by release tag or asset filename conventions. - manifest_payload = fetch_json(manifest_url) + manifest_bytes = download_bytes( + manifest_url, + timeout = 30, + headers = auth_headers(manifest_url), + ) + manifest_sha256 = sha256_bytes(manifest_bytes) + try: + manifest_payload = json.loads(manifest_bytes.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RuntimeError( + f"published manifest {DEFAULT_PUBLISHED_MANIFEST_ASSET} was not valid JSON" + ) from exc if not isinstance(manifest_payload, dict): raise RuntimeError( f"published manifest {DEFAULT_PUBLISHED_MANIFEST_ASSET} was not a JSON object" ) + validate_schema_version( + manifest_payload, + label = f"published manifest {DEFAULT_PUBLISHED_MANIFEST_ASSET} in {repo}@{release_tag}", + ) component = manifest_payload.get("component") upstream_tag = manifest_payload.get("upstream_tag") + source_repo = manifest_payload.get("source_repo") + source_repo_url = manifest_payload.get("source_repo_url") + source_ref_kind = normalize_source_ref_kind(manifest_payload.get("source_ref_kind")) + requested_source_ref = manifest_payload.get("requested_source_ref") + resolved_source_ref = manifest_payload.get("resolved_source_ref") + source_commit = normalize_source_commit(manifest_payload.get("source_commit")) + source_commit_short = manifest_payload.get("source_commit_short") if component != "llama.cpp": return None if not isinstance(upstream_tag, str) or not upstream_tag: @@ -1021,10 +1243,32 @@ def parse_published_release_bundle( f"published_release: manifest={DEFAULT_PUBLISHED_MANIFEST_ASSET}", f"published_release: upstream_tag={upstream_tag}", ] + if isinstance(source_repo, str) and source_repo: + selection_log.append(f"published_release: source_repo={source_repo}") + if source_commit: + selection_log.append(f"published_release: source_commit={source_commit}") return PublishedReleaseBundle( repo = repo, release_tag = release_tag, upstream_tag = upstream_tag, + manifest_sha256 = manifest_sha256, + source_repo = source_repo + if isinstance(source_repo, str) and source_repo + else None, + source_repo_url = source_repo_url + if isinstance(source_repo_url, str) and source_repo_url + else None, + source_ref_kind = source_ref_kind, + requested_source_ref = requested_source_ref + if isinstance(requested_source_ref, str) and requested_source_ref + else None, + resolved_source_ref = resolved_source_ref + if isinstance(resolved_source_ref, str) and resolved_source_ref + else None, + source_commit = source_commit, + source_commit_short = source_commit_short + if isinstance(source_commit_short, str) and source_commit_short + else None, assets = assets, manifest_asset_name = DEFAULT_PUBLISHED_MANIFEST_ASSET, artifacts = artifacts, @@ -1041,6 +1285,10 @@ def parse_approved_release_checksums( raise RuntimeError( f"published checksum asset {DEFAULT_PUBLISHED_SHA256_ASSET} was not a JSON object" ) + validate_schema_version( + payload, + label = f"published checksum asset {DEFAULT_PUBLISHED_SHA256_ASSET}", + ) if payload.get("component") != "llama.cpp": raise RuntimeError( f"published checksum asset {DEFAULT_PUBLISHED_SHA256_ASSET} did not describe llama.cpp" @@ -1090,13 +1338,33 @@ def parse_approved_release_checksums( kind = kind_value if isinstance(kind_value, str) and kind_value else None, ) - source_commit = payload.get("source_commit") + source_commit = normalize_source_commit(payload.get("source_commit")) + source_commit_short = payload.get("source_commit_short") + source_repo = payload.get("source_repo") + source_repo_url = payload.get("source_repo_url") + source_ref_kind = normalize_source_ref_kind(payload.get("source_ref_kind")) + requested_source_ref = payload.get("requested_source_ref") + resolved_source_ref = payload.get("resolved_source_ref") return ApprovedReleaseChecksums( repo = repo, release_tag = release_tag, upstream_tag = upstream_tag, - source_commit = source_commit - if isinstance(source_commit, str) and source_commit + source_repo = source_repo + if isinstance(source_repo, str) and source_repo + else None, + source_repo_url = source_repo_url + if isinstance(source_repo_url, str) and source_repo_url + else None, + source_ref_kind = source_ref_kind, + requested_source_ref = requested_source_ref + if isinstance(requested_source_ref, str) and requested_source_ref + else None, + resolved_source_ref = resolved_source_ref + if isinstance(resolved_source_ref, str) and resolved_source_ref + else None, + source_commit = source_commit, + source_commit_short = source_commit_short + if isinstance(source_commit_short, str) and source_commit_short else None, artifacts = artifacts, ) @@ -1390,10 +1658,36 @@ def validated_checksums_for_bundle( repo: str, bundle: PublishedReleaseBundle ) -> ApprovedReleaseChecksums: checksums = load_approved_release_checksums(repo, bundle.release_tag) - require_approved_source_hash(checksums, bundle.upstream_tag) + manifest_hash = checksums.artifacts.get(bundle.manifest_asset_name) + if manifest_hash is not None and bundle.manifest_sha256 is not None: + if manifest_hash.sha256 != bundle.manifest_sha256: + raise PrebuiltFallback( + "published manifest checksum did not match the approved checksum asset" + ) + # Accept bundles that carry only an exact-commit source archive + # (e.g. llama.cpp-source-commit-.tar.gz) without requiring the + # legacy llama.cpp-source-.tar.gz entry. + if exact_source_archive_hash(checksums) is None: + require_approved_source_hash(checksums, bundle.upstream_tag) return checksums +def published_release_matches_request( + bundle: PublishedReleaseBundle, requested_ref: str +) -> bool: + if requested_ref == "latest": + return True + for candidate in ( + bundle.upstream_tag, + bundle.requested_source_ref, + bundle.resolved_source_ref, + bundle.source_commit, + ): + if refs_match(candidate, requested_ref): + return True + return False + + def resolve_published_release( requested_tag: str | None, published_repo: str, @@ -1404,10 +1698,7 @@ def resolve_published_release( if published_release_tag: bundle = pinned_published_release_bundle(repo, published_release_tag) - if ( - normalized_requested != "latest" - and bundle.upstream_tag != normalized_requested - ): + if not published_release_matches_request(bundle, normalized_requested): raise PrebuiltFallback( "published release " f"{repo}@{published_release_tag} targeted upstream tag {bundle.upstream_tag}, " @@ -1420,10 +1711,7 @@ def resolve_published_release( skipped_invalid = 0 for bundle in iter_published_release_bundles(repo): - if ( - normalized_requested != "latest" - and bundle.upstream_tag != normalized_requested - ): + if not published_release_matches_request(bundle, normalized_requested): continue try: checksums = validated_checksums_for_bundle(repo, bundle) @@ -1460,10 +1748,7 @@ def iter_resolved_published_releases( if published_release_tag: bundle = pinned_published_release_bundle(repo, published_release_tag) - if ( - normalized_requested != "latest" - and bundle.upstream_tag != normalized_requested - ): + if not published_release_matches_request(bundle, normalized_requested): raise PrebuiltFallback( "published release " f"{repo}@{published_release_tag} targeted upstream tag {bundle.upstream_tag}, " @@ -1479,10 +1764,7 @@ def iter_resolved_published_releases( skipped_invalid = 0 yielded_valid = False for bundle in iter_published_release_bundles(repo): - if ( - normalized_requested != "latest" - and bundle.upstream_tag != normalized_requested - ): + if not published_release_matches_request(bundle, normalized_requested): continue matched_any = True try: @@ -1520,6 +1802,7 @@ def iter_resolved_published_releases( def resolve_requested_llama_tag( requested_tag: str | None, published_repo: str = "", + published_release_tag: str = "", ) -> str: """Resolve a llama.cpp tag for source-build fallback. @@ -1547,6 +1830,7 @@ def resolve_requested_llama_tag( return resolve_published_release( "latest", published_repo, + published_release_tag, ).bundle.upstream_tag except Exception: pass @@ -1566,6 +1850,125 @@ def resolve_requested_install_tag( ).bundle.upstream_tag +def exact_source_archive_hash( + checksums: ApprovedReleaseChecksums, +) -> ApprovedArtifactHash | None: + if not checksums.source_commit: + return None + return checksums.artifacts.get( + exact_source_archive_logical_name(checksums.source_commit) + ) + + +def source_clone_url_from_checksums(checksums: ApprovedReleaseChecksums) -> str | None: + return source_repo_clone_url(checksums.source_repo, checksums.source_repo_url) + + +def source_build_plan_for_release( + release: ResolvedPublishedRelease, +) -> SourceBuildPlan: + checksums = release.checksums + exact_source = exact_source_archive_hash(checksums) + source_repo = checksums.source_repo or release.bundle.source_repo + source_repo_url = checksums.source_repo_url or release.bundle.source_repo_url + requested_source_ref = ( + checksums.requested_source_ref or release.bundle.requested_source_ref + ) + resolved_source_ref = ( + checksums.resolved_source_ref or release.bundle.resolved_source_ref + ) + source_commit = checksums.source_commit or release.bundle.source_commit + source_ref_kind = checksums.source_ref_kind or release.bundle.source_ref_kind + source_url = source_repo_clone_url(source_repo, source_repo_url) + if exact_source is not None and source_url and source_commit: + return SourceBuildPlan( + source_url = source_url, + source_ref = source_commit, + source_ref_kind = "commit", + compatibility_upstream_tag = release.bundle.upstream_tag, + source_repo = source_repo, + source_repo_url = source_repo_url, + requested_source_ref = requested_source_ref, + resolved_source_ref = resolved_source_ref, + source_commit = source_commit, + ) + source_ref = checkout_friendly_ref( + source_ref_kind, resolved_source_ref or requested_source_ref + ) + if ( + source_url + and source_ref + and source_ref_kind in {"tag", "branch", "pull", "commit"} + ): + return SourceBuildPlan( + source_url = source_url, + source_ref = source_ref, + source_ref_kind = source_ref_kind, + compatibility_upstream_tag = release.bundle.upstream_tag, + source_repo = source_repo, + source_repo_url = source_repo_url, + requested_source_ref = requested_source_ref, + resolved_source_ref = resolved_source_ref, + source_commit = source_commit, + ) + return SourceBuildPlan( + source_url = source_url_from_repo_slug(UPSTREAM_REPO) + or "https://github.com/ggml-org/llama.cpp", + source_ref = release.bundle.upstream_tag, + source_ref_kind = "tag", + compatibility_upstream_tag = release.bundle.upstream_tag, + source_repo = source_repo, + source_repo_url = source_repo_url, + requested_source_ref = requested_source_ref, + resolved_source_ref = resolved_source_ref, + source_commit = source_commit, + ) + + +def resolve_source_build_plan( + requested_tag: str | None, + published_repo: str, + published_release_tag: str = "", +) -> SourceBuildPlan: + normalized_requested = normalized_requested_llama_tag(requested_tag) + if normalized_requested != "latest": + try: + release = resolve_published_release( + normalized_requested, + published_repo, + published_release_tag, + ) + return source_build_plan_for_release(release) + except Exception: + pass + inferred_kind = infer_source_ref_kind(normalized_requested) + return SourceBuildPlan( + source_url = "https://github.com/ggml-org/llama.cpp", + source_ref = checkout_friendly_ref(inferred_kind, normalized_requested) + or normalized_requested, + source_ref_kind = inferred_kind, + compatibility_upstream_tag = normalized_requested, + ) + + if published_repo: + try: + release = resolve_published_release( + "latest", + published_repo, + published_release_tag, + ) + return source_build_plan_for_release(release) + except Exception: + pass + latest_tag = latest_upstream_release_tag() + return SourceBuildPlan( + source_url = "https://github.com/ggml-org/llama.cpp", + source_ref = latest_tag, + source_ref_kind = "tag", + compatibility_upstream_tag = latest_tag, + ) + + def run_capture( command: list[str], *, @@ -1883,25 +2286,31 @@ def windows_cuda_attempts( attempts: list[AssetChoice] = [] for runtime_line in runtime_order: runtime = runtime_by_line[runtime_line] - upstream_name = f"llama-{llama_tag}-bin-win-cuda-{runtime}-x64.zip" - asset_url = upstream_assets.get(upstream_name) - if not asset_url: + selected_name = None + asset_url = None + for candidate_name in windows_cuda_upstream_asset_names(llama_tag, runtime): + asset_url = upstream_assets.get(candidate_name) + if asset_url: + selected_name = candidate_name + break + if not asset_url or not selected_name: selection_log.append( - f"windows_cuda_selection: skip missing asset {upstream_name}" + "windows_cuda_selection: skip missing assets " + + ",".join(windows_cuda_upstream_asset_names(llama_tag, runtime)) ) continue attempts.append( AssetChoice( repo = UPSTREAM_REPO, tag = llama_tag, - name = upstream_name, + name = selected_name, url = asset_url, source_label = "upstream", install_kind = "windows-cuda", runtime_line = runtime_line, selection_log = list(selection_log) + [ - f"windows_cuda_selection: selected {upstream_name} runtime={runtime}" + f"windows_cuda_selection: selected {selected_name} runtime={runtime}" ], ) ) @@ -2353,18 +2762,26 @@ def copy_directory_contents(source_dir: Path, destination: Path) -> None: def hydrate_source_tree( - upstream_tag: str, + source_ref: str, install_dir: Path, work_dir: Path, *, + source_repo: str = UPSTREAM_REPO, expected_sha256: str, + source_label: str | None = None, + exact_source: bool = False, ) -> None: - archive_path = work_dir / f"llama.cpp-source-{upstream_tag}.tar.gz" - source_urls = upstream_source_archive_urls(upstream_tag) + archive_path = work_dir / f"llama.cpp-source-{source_ref}.tar.gz" + source_urls = ( + commit_source_archive_urls(source_repo, source_ref) + if exact_source + else upstream_source_archive_urls(source_ref) + ) + label = source_label or f"llama.cpp source tree for {source_ref}" extract_dir = Path(tempfile.mkdtemp(prefix = "source-extract-", dir = work_dir)) try: - log(f"downloading llama.cpp source tree for upstream tag {upstream_tag}") + log(f"downloading {label}") last_exc: Exception | None = None downloaded = False for index, source_url in enumerate(source_urls): @@ -2377,7 +2794,7 @@ def hydrate_source_tree( source_url, archive_path, expected_sha256 = expected_sha256, - label = f"llama.cpp source tree for {upstream_tag}", + label = label, ) downloaded = True break @@ -2410,9 +2827,7 @@ def hydrate_source_tree( except PrebuiltFallback: raise except Exception as exc: - raise PrebuiltFallback( - f"failed to hydrate upstream llama.cpp source tree for {upstream_tag}: {exc}" - ) from exc + raise PrebuiltFallback(f"failed to hydrate {label}: {exc}") from exc finally: remove_tree(extract_dir) @@ -3451,10 +3866,32 @@ def apply_approved_hashes( attempts: Iterable[AssetChoice], checksums: ApprovedReleaseChecksums, ) -> list[AssetChoice]: + def approved_hash_for_attempt(attempt: AssetChoice) -> ApprovedArtifactHash | None: + approved = checksums.artifacts.get(attempt.name) + if approved is not None: + return approved + if ( + isinstance(attempt.tag, str) + and attempt.tag + and attempt.tag != checksums.upstream_tag + and attempt.name.startswith("llama-") + ): + legacy_prefix = f"llama-{attempt.tag}-" + compatibility_prefix = f"llama-{checksums.upstream_tag}-" + compatibility_name = ( + attempt.name.replace(legacy_prefix, compatibility_prefix, 1) + if attempt.name.startswith(legacy_prefix) + else attempt.name + ) + approved = checksums.artifacts.get(compatibility_name) + if approved is not None: + return approved + return None + approved_attempts: list[AssetChoice] = [] missing_assets: list[str] = [] for attempt in attempts: - approved = checksums.artifacts.get(attempt.name) + approved = approved_hash_for_attempt(attempt) if approved is None: missing_assets.append(attempt.name) continue @@ -3481,6 +3918,39 @@ def require_approved_source_hash( return approved_source +def preferred_source_archive( + checksums: ApprovedReleaseChecksums, llama_tag: str +) -> tuple[str, str, ApprovedArtifactHash, bool]: + exact_source = exact_source_archive_hash(checksums) + exact_repo = repo_slug_from_source(checksums.source_repo) or repo_slug_from_source( + checksums.source_repo_url + ) + if exact_source is not None and exact_repo and checksums.source_commit: + return ( + exact_repo, + checksums.source_commit, + exact_source, + True, + ) + legacy = require_approved_source_hash(checksums, llama_tag) + return ( + UPSTREAM_REPO, + llama_tag, + legacy, + False, + ) + + +def selected_source_archive_metadata( + checksums: ApprovedReleaseChecksums, + llama_tag: str, +) -> tuple[str, str | None]: + _source_repo, _source_ref, source_archive, _exact_source = preferred_source_archive( + checksums, llama_tag + ) + return source_archive.asset_name, source_archive.sha256 + + def resolve_install_attempts( llama_tag: str, host: HostInfo, @@ -3583,10 +4053,10 @@ def write_prebuilt_metadata( approved_checksums: ApprovedReleaseChecksums, prebuilt_fallback_used: bool, ) -> None: - source_archive = approved_checksums.artifacts.get( - source_archive_logical_name(llama_tag) + source_asset_name, source_sha256 = selected_source_archive_metadata( + approved_checksums, + llama_tag, ) - source_sha256 = source_archive.sha256 if source_archive is not None else None fingerprint_payload = { "published_repo": approved_checksums.repo, "release_tag": release_tag, @@ -3594,6 +4064,7 @@ def write_prebuilt_metadata( "asset": choice.name, "asset_sha256": choice.expected_sha256, "source": choice.source_label, + "source_asset": source_asset_name, "source_sha256": source_sha256, "runtime_line": choice.runtime_line, "bundle_profile": choice.bundle_profile, @@ -3612,8 +4083,15 @@ def write_prebuilt_metadata( "asset": choice.name, "asset_sha256": choice.expected_sha256, "source": choice.source_label, + "source_asset": source_asset_name, "source_sha256": source_sha256, "source_commit": approved_checksums.source_commit, + "source_commit_short": approved_checksums.source_commit_short, + "source_repo": approved_checksums.source_repo, + "source_repo_url": approved_checksums.source_repo_url, + "source_ref_kind": approved_checksums.source_ref_kind, + "requested_source_ref": approved_checksums.requested_source_ref, + "resolved_source_ref": approved_checksums.resolved_source_ref, "bundle_profile": choice.bundle_profile, "runtime_line": choice.runtime_line, "coverage_class": choice.coverage_class, @@ -3635,10 +4113,10 @@ def expected_install_fingerprint( ) -> str | None: if not choice.expected_sha256: return None - source_archive = approved_checksums.artifacts.get( - source_archive_logical_name(llama_tag) + source_asset_name, source_sha256 = selected_source_archive_metadata( + approved_checksums, + llama_tag, ) - source_sha256 = source_archive.sha256 if source_archive is not None else None payload = { "published_repo": approved_checksums.repo, "release_tag": release_tag, @@ -3646,6 +4124,7 @@ def expected_install_fingerprint( "asset": choice.name, "asset_sha256": choice.expected_sha256, "source": choice.source_label, + "source_asset": source_asset_name, "source_sha256": source_sha256, "runtime_line": choice.runtime_line, "bundle_profile": choice.bundle_profile, @@ -3817,19 +4296,27 @@ def validate_prebuilt_choice( prebuilt_fallback_used: bool, quantized_path: Path, ) -> tuple[Path, Path]: - source_archive = approved_checksums.artifacts.get( - source_archive_logical_name(llama_tag) + source_repo, source_ref, source_archive, exact_source = preferred_source_archive( + approved_checksums, llama_tag ) - if source_archive is None: - raise PrebuiltFallback( - f"approved checksum asset did not contain source archive {source_archive_logical_name(llama_tag)}" + if exact_source: + log( + f"hydrating exact llama.cpp source for {source_repo}@{source_ref} into {install_dir}" ) - log(f"hydrating upstream llama.cpp source for {llama_tag} into {install_dir}") + else: + log(f"hydrating upstream llama.cpp source for {llama_tag} into {install_dir}") hydrate_source_tree( - llama_tag, + source_ref, install_dir, work_dir, + source_repo = source_repo, expected_sha256 = source_archive.sha256, + source_label = ( + f"llama.cpp source tree for {source_repo}@{source_ref}" + if exact_source + else f"llama.cpp source tree for {llama_tag}" + ), + exact_source = exact_source, ) log(f"overlaying prebuilt bundle {choice.name} into {install_dir}") server_path, quantize_path = install_from_archives( @@ -4087,30 +4574,103 @@ def parse_args() -> argparse.Namespace: "selected by the current published-release policy." ), ) + resolve_group.add_argument( + "--resolve-source-build", + nargs = "?", + const = "latest", + help = ("Resolve the source-build fallback plan."), + ) + parser.add_argument( + "--output-format", + choices = ("plain", "json"), + default = "plain", + help = "Resolver output format. Defaults to plain.", + ) return parser.parse_args() +def emit_resolver_output(payload: dict[str, Any], *, output_format: str) -> None: + if output_format == "json": + print(json.dumps(payload, sort_keys = True)) + return + if "llama_tag" in payload: + print(payload["llama_tag"]) + return + if { + "source_url", + "source_ref_kind", + "source_ref", + }.issubset(payload): + print( + "\t".join( + ( + str(payload["source_url"]), + str(payload["source_ref_kind"]), + str(payload["source_ref"]), + ) + ) + ) + return + print(json.dumps(payload, sort_keys = True)) + + def main() -> int: args = parse_args() if args.resolve_llama_tag is not None: - # Pass published_repo so the resolver prefers the Unsloth release tag - # (tested/approved) over the upstream ggml-org bleeding-edge tag. - print(resolve_requested_llama_tag(args.resolve_llama_tag, args.published_repo)) + resolved = resolve_requested_llama_tag( + args.resolve_llama_tag, + args.published_repo, + args.published_release_tag or "", + ) + emit_resolver_output( + { + "requested_tag": normalized_requested_llama_tag(args.resolve_llama_tag), + "llama_tag": resolved, + }, + output_format = args.output_format, + ) return EXIT_SUCCESS if args.resolve_install_tag is not None: - print( - resolve_requested_install_tag( - args.resolve_install_tag, - args.published_release_tag or "", - args.published_repo, - ) + resolved = resolve_requested_install_tag( + args.resolve_install_tag, + args.published_release_tag or "", + args.published_repo, + ) + emit_resolver_output( + { + "requested_tag": normalized_requested_llama_tag( + args.resolve_install_tag + ), + "llama_tag": resolved, + }, + output_format = args.output_format, + ) + return EXIT_SUCCESS + + if args.resolve_source_build is not None: + plan = resolve_source_build_plan( + args.resolve_source_build, + args.published_repo, + args.published_release_tag or "", + ) + emit_resolver_output( + { + "requested_tag": normalized_requested_llama_tag( + args.resolve_source_build + ), + "source_url": plan.source_url, + "source_ref_kind": plan.source_ref_kind, + "source_ref": plan.source_ref, + "compatibility_upstream_tag": plan.compatibility_upstream_tag, + }, + output_format = args.output_format, ) return EXIT_SUCCESS if not args.install_dir: raise SystemExit( - "install_llama_prebuilt.py: --install-dir is required unless --resolve-llama-tag or --resolve-install-tag is used" + "install_llama_prebuilt.py: --install-dir is required unless --resolve-llama-tag, --resolve-install-tag, or --resolve-source-build is used" ) install_prebuilt( install_dir = Path(args.install_dir).expanduser().resolve(), diff --git a/studio/setup.ps1 b/studio/setup.ps1 index f4ad42d615..1784b66ee9 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -1604,6 +1604,9 @@ $LlamaPr = if ($env:UNSLOTH_LLAMA_PR) { $env:UNSLOTH_LLAMA_PR.Trim() } else { "" $LlamaPrForce = if ($env:UNSLOTH_LLAMA_PR_FORCE) { $env:UNSLOTH_LLAMA_PR_FORCE.Trim() } else { $DefaultLlamaPrForce } $LlamaSource = if ($env:UNSLOTH_LLAMA_SOURCE) { $env:UNSLOTH_LLAMA_SOURCE.Trim() } else { $DefaultLlamaSource } if ($LlamaSource.EndsWith('.git')) { $LlamaSource = $LlamaSource.Substring(0, $LlamaSource.Length - 4) } +$ResolvedSourceUrl = $LlamaSource +$ResolvedSourceRef = $RequestedLlamaTag +$ResolvedSourceRefKind = "tag" if ($LlamaSource -ne "https://github.com/ggml-org/llama.cpp") { step "llama.cpp" "custom source: $LlamaSource -- forcing source build" "Yellow" @@ -1623,6 +1626,9 @@ if ($LlamaPr) { } step "llama.cpp" "UNSLOTH_LLAMA_PR=$LlamaPr -- will build from PR head" "Yellow" $ResolvedLlamaTag = "pr-$LlamaPr" + $ResolvedSourceUrl = $LlamaSource + $ResolvedSourceRef = "pr-$LlamaPr" + $ResolvedSourceRefKind = "pull" $NeedLlamaSourceBuild = $true $SkipPrebuiltInstall = $true } elseif ($SkipPrebuiltInstall) { @@ -1630,12 +1636,16 @@ if ($LlamaPr) { # prebuilt release resolution. When building from a custom fork, the fork # may not carry upstream bNNNN tags. if ($LlamaSource -eq "https://github.com/ggml-org/llama.cpp") { - $resolveTagArgs = @("--resolve-llama-tag", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo) + $resolveTagArgs = @("--resolve-llama-tag", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo, "--output-format", "json") if ($env:UNSLOTH_LLAMA_RELEASE_TAG) { $resolveTagArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG) } $fallbackOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" @resolveTagArgs 2>$null $fallbackExit = $LASTEXITCODE $ResolvedLlamaTag = if ($fallbackExit -eq 0 -and $fallbackOutput) { - ($fallbackOutput | Select-Object -Last 1).ToString().Trim() + try { + (($fallbackOutput | Out-String) | ConvertFrom-Json).llama_tag + } catch { + $RequestedLlamaTag + } } else { $RequestedLlamaTag } @@ -1643,30 +1653,42 @@ if ($LlamaPr) { $ResolvedLlamaTag = $RequestedLlamaTag } } else { - $resolveInstallArgs = @("--resolve-install-tag", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo) + $resolveInstallArgs = @("--resolve-install-tag", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo, "--output-format", "json") if ($env:UNSLOTH_LLAMA_RELEASE_TAG) { $resolveInstallArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG) } - $resolveOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" @resolveInstallArgs 2>&1 + $resolveErrorLog = New-TemporaryFile + $resolveOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" @resolveInstallArgs 2>$resolveErrorLog $resolveExit = $LASTEXITCODE - $ResolvedLlamaTag = if ($resolveOutput) { ($resolveOutput | Select-Object -Last 1).ToString().Trim() } else { "" } + $ResolvedLlamaTag = if ($resolveOutput) { + try { + (($resolveOutput | Out-String) | ConvertFrom-Json).llama_tag + } catch { + "" + } + } else { "" } if ($resolveExit -ne 0 -or [string]::IsNullOrWhiteSpace($ResolvedLlamaTag)) { Write-Host "" substep "Failed to resolve a published llama.cpp release via $HelperReleaseRepo" "Yellow" - Write-LlamaFailureLog -Output ($resolveOutput | Out-String) + Write-LlamaFailureLog -Output (Get-Content -Raw $resolveErrorLog) # Resolve the llama.cpp tag for source-build fallback. Pass --published-repo # so the resolver prefers the latest usable Unsloth-published upstream tag # before falling back to the bleeding-edge ggml-org/llama.cpp tag. - $resolveFallbackArgs = @("--resolve-llama-tag", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo) + $resolveFallbackArgs = @("--resolve-llama-tag", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo, "--output-format", "json") if ($env:UNSLOTH_LLAMA_RELEASE_TAG) { $resolveFallbackArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG) } $fallbackOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" @resolveFallbackArgs 2>$null $fallbackExit = $LASTEXITCODE $ResolvedLlamaTag = if ($fallbackExit -eq 0 -and $fallbackOutput) { - ($fallbackOutput | Select-Object -Last 1).ToString().Trim() + try { + (($fallbackOutput | Out-String) | ConvertFrom-Json).llama_tag + } catch { + $RequestedLlamaTag + } } else { $RequestedLlamaTag } $NeedLlamaSourceBuild = $true $SkipPrebuiltInstall = $true } + Remove-Item $resolveErrorLog -Force -ErrorAction SilentlyContinue } Write-Host "" @@ -1875,14 +1897,34 @@ if (-not $NeedLlamaSourceBuild) { [Environment]::SetEnvironmentVariable('CudaToolkitDir', "$CudaToolkitRoot\", 'Process') } + if (-not $LlamaPr) { + if ($LlamaSource -eq "https://github.com/ggml-org/llama.cpp") { + $resolveSourceArgs = @("--resolve-source-build", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo, "--output-format", "json") + if ($env:UNSLOTH_LLAMA_RELEASE_TAG) { $resolveSourceArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG) } + $sourcePlanOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" @resolveSourceArgs 2>$null + $sourcePlanExit = $LASTEXITCODE + if ($sourcePlanExit -eq 0 -and $sourcePlanOutput) { + try { + $sourcePlan = ($sourcePlanOutput | Out-String) | ConvertFrom-Json + $ResolvedSourceUrl = $sourcePlan.source_url + $ResolvedSourceRefKind = $sourcePlan.source_ref_kind + $ResolvedSourceRef = $sourcePlan.source_ref + } catch { + } + } + } + if ([string]::IsNullOrWhiteSpace($ResolvedSourceUrl)) { $ResolvedSourceUrl = $LlamaSource } + if ([string]::IsNullOrWhiteSpace($ResolvedSourceRef)) { $ResolvedSourceRef = $ResolvedLlamaTag } + } + # -- Step A: Clone or pull llama.cpp -- - $UseConcreteRef = ($ResolvedLlamaTag -ne "latest" -and -not [string]::IsNullOrWhiteSpace($ResolvedLlamaTag)) + $UseConcreteRef = ($ResolvedSourceRef -ne "latest" -and -not [string]::IsNullOrWhiteSpace($ResolvedSourceRef)) if (Test-Path (Join-Path $LlamaCppDir ".git")) { - Write-Host " Syncing llama.cpp to $ResolvedLlamaTag..." -ForegroundColor Gray + Write-Host " Syncing llama.cpp to $ResolvedSourceRef..." -ForegroundColor Gray # Always sync the remote URL so switching between default/fork sources works - Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir remote set-url origin "$LlamaSource.git" } | Out-Null + Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir remote set-url origin "$ResolvedSourceUrl.git" } | Out-Null if ($LlamaPr) { $gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin "pull/$LlamaPr/head" } if ($gitFetchExit -ne 0) { @@ -1897,8 +1939,34 @@ if (-not $NeedLlamaSourceBuild) { Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir clean -fdx } | Out-Null } } + } elseif ($ResolvedSourceRefKind -eq "pull") { + $gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin $ResolvedSourceRef } + if ($gitFetchExit -ne 0) { + substep "git fetch failed -- using existing source" "Yellow" + } else { + $gitCheckoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir checkout -B unsloth-llama-build FETCH_HEAD } + if ($gitCheckoutExit -ne 0) { + $BuildOk = $false + $FailedStep = "git checkout" + } else { + Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir clean -fdx } | Out-Null + } + } + } elseif ($ResolvedSourceRefKind -eq "commit") { + $gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin $ResolvedSourceRef } + if ($gitFetchExit -ne 0) { + substep "git fetch failed -- using existing source" "Yellow" + } else { + $gitCheckoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir checkout -B unsloth-llama-build FETCH_HEAD } + if ($gitCheckoutExit -ne 0) { + $BuildOk = $false + $FailedStep = "git checkout" + } else { + Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir clean -fdx } | Out-Null + } + } } elseif ($UseConcreteRef) { - $gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin $ResolvedLlamaTag } + $gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin $ResolvedSourceRef } if ($gitFetchExit -ne 0) { substep "git fetch failed -- using existing source" "Yellow" } else { @@ -1925,8 +1993,9 @@ if (-not $NeedLlamaSourceBuild) { } } } else { - Write-Host " Cloning llama.cpp @ $ResolvedLlamaTag..." -ForegroundColor Gray + Write-Host " Cloning llama.cpp @ $ResolvedSourceRef..." -ForegroundColor Gray $buildTmp = "$LlamaCppDir.build.$PID" + $null = New-Item -ItemType Directory -Force -Path (Split-Path $LlamaCppDir -Parent) if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp } if ($LlamaPr) { $cloneExit = Invoke-SetupCommand -AlwaysQuiet { git clone --depth 1 "$LlamaSource.git" $buildTmp } @@ -1951,12 +2020,58 @@ if (-not $NeedLlamaSourceBuild) { if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp } } } + } elseif ($ResolvedSourceRefKind -eq "pull") { + $cloneExit = Invoke-SetupCommand -AlwaysQuiet { git clone --depth 1 "$ResolvedSourceUrl.git" $buildTmp } + if ($cloneExit -ne 0) { + $BuildOk = $false + $FailedStep = "git clone" + if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp } + } + if ($BuildOk) { + $fetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp fetch --depth 1 origin $ResolvedSourceRef } + if ($fetchExit -ne 0) { + $BuildOk = $false + $FailedStep = "git fetch source PR ref" + if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp } + } + } + if ($BuildOk) { + $checkoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp checkout -B unsloth-llama-build FETCH_HEAD } + if ($checkoutExit -ne 0) { + $BuildOk = $false + $FailedStep = "git checkout source PR ref" + if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp } + } + } + } elseif ($ResolvedSourceRefKind -eq "commit") { + $cloneExit = Invoke-SetupCommand -AlwaysQuiet { git clone --depth 1 "$ResolvedSourceUrl.git" $buildTmp } + if ($cloneExit -ne 0) { + $BuildOk = $false + $FailedStep = "git clone" + if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp } + } + if ($BuildOk) { + $fetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp fetch --depth 1 origin $ResolvedSourceRef } + if ($fetchExit -ne 0) { + $BuildOk = $false + $FailedStep = "git fetch source commit" + if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp } + } + } + if ($BuildOk) { + $checkoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp checkout -B unsloth-llama-build FETCH_HEAD } + if ($checkoutExit -ne 0) { + $BuildOk = $false + $FailedStep = "git checkout source commit" + if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp } + } + } } else { $cloneArgs = @("clone", "--depth", "1") if ($UseConcreteRef) { - $cloneArgs += @("--branch", $ResolvedLlamaTag) + $cloneArgs += @("--branch", $ResolvedSourceRef) } - $cloneArgs += @("$LlamaSource.git", $buildTmp) + $cloneArgs += @("$ResolvedSourceUrl.git", $buildTmp) $cloneExit = Invoke-SetupCommand -AlwaysQuiet { git @cloneArgs } if ($cloneExit -ne 0) { $BuildOk = $false diff --git a/studio/setup.sh b/studio/setup.sh index 36926733e1..26903b120a 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -485,6 +485,9 @@ _LLAMA_PR="${UNSLOTH_LLAMA_PR:-}" _LLAMA_PR_FORCE="${UNSLOTH_LLAMA_PR_FORCE:-${_DEFAULT_LLAMA_PR_FORCE}}" _LLAMA_SOURCE="${UNSLOTH_LLAMA_SOURCE:-${_DEFAULT_LLAMA_SOURCE}}" _LLAMA_SOURCE="${_LLAMA_SOURCE%.git}" # normalize: strip trailing .git +_RESOLVED_SOURCE_URL="$_LLAMA_SOURCE" +_RESOLVED_SOURCE_REF="$_REQUESTED_LLAMA_TAG" +_RESOLVED_SOURCE_REF_KIND="tag" # Non-default source URL forces source build (fork has different code than prebuilt). if [ "$_LLAMA_SOURCE" != "https://github.com/ggml-org/llama.cpp" ]; then @@ -507,6 +510,9 @@ if [ -n "$_LLAMA_PR" ]; then fi step "llama.cpp" "UNSLOTH_LLAMA_PR=$_LLAMA_PR -- will build from PR head" "$C_WARN" _RESOLVED_LLAMA_TAG="pr-$_LLAMA_PR" + _RESOLVED_SOURCE_URL="$_LLAMA_SOURCE" + _RESOLVED_SOURCE_REF="pr-$_LLAMA_PR" + _RESOLVED_SOURCE_REF_KIND="pull" _NEED_LLAMA_SOURCE_BUILD=true _SKIP_PREBUILT_INSTALL=true elif [ "${_SKIP_PREBUILT_INSTALL:-false}" = true ]; then @@ -516,14 +522,22 @@ elif [ "${_SKIP_PREBUILT_INSTALL:-false}" = true ]; then # only when the source is the default ggml-org repo. if [ "$_LLAMA_SOURCE" = "https://github.com/ggml-org/llama.cpp" ]; then _RESOLVE_TAG_ARGS=(--resolve-llama-tag "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO") + _RESOLVE_TAG_ARGS+=(--output-format json) if [ -n "${UNSLOTH_LLAMA_RELEASE_TAG:-}" ]; then _RESOLVE_TAG_ARGS+=(--published-release-tag "$UNSLOTH_LLAMA_RELEASE_TAG") fi set +e - _RESOLVED_LLAMA_TAG="$(python "$SCRIPT_DIR/install_llama_prebuilt.py" "${_RESOLVE_TAG_ARGS[@]}" 2>/dev/null)" + _RESOLVE_TAG_JSON="$(python "$SCRIPT_DIR/install_llama_prebuilt.py" "${_RESOLVE_TAG_ARGS[@]}" 2>/dev/null)" _RESOLVE_UPSTREAM_STATUS=$? set -e - if [ "$_RESOLVE_UPSTREAM_STATUS" -ne 0 ] || [ -z "$_RESOLVED_LLAMA_TAG" ]; then + if [ "$_RESOLVE_UPSTREAM_STATUS" -eq 0 ] && [ -n "${_RESOLVE_TAG_JSON:-}" ]; then + _RESOLVED_LLAMA_TAG="$( + printf '%s' "$_RESOLVE_TAG_JSON" | python -c 'import json,sys; print(json.load(sys.stdin).get("llama_tag",""))' 2>/dev/null || true + )" + else + _RESOLVED_LLAMA_TAG="" + fi + if [ -z "$_RESOLVED_LLAMA_TAG" ]; then _RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG" fi else @@ -531,17 +545,22 @@ elif [ "${_SKIP_PREBUILT_INSTALL:-false}" = true ]; then fi else _RESOLVE_INSTALL_ARGS=(--resolve-install-tag "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO") + _RESOLVE_INSTALL_ARGS+=(--output-format json) if [ -n "${UNSLOTH_LLAMA_RELEASE_TAG:-}" ]; then _RESOLVE_INSTALL_ARGS+=(--published-release-tag "$UNSLOTH_LLAMA_RELEASE_TAG") fi _RESOLVE_LLAMA_LOG="$(mktemp)" set +e - python "$SCRIPT_DIR/install_llama_prebuilt.py" \ - "${_RESOLVE_INSTALL_ARGS[@]}" >"$_RESOLVE_LLAMA_LOG" 2>&1 + _RESOLVE_INSTALL_JSON="$( + python "$SCRIPT_DIR/install_llama_prebuilt.py" \ + "${_RESOLVE_INSTALL_ARGS[@]}" 2>"$_RESOLVE_LLAMA_LOG" + )" _RESOLVE_LLAMA_STATUS=$? set -e if [ "$_RESOLVE_LLAMA_STATUS" -eq 0 ]; then - _RESOLVED_LLAMA_TAG="$(tail -n 1 "$_RESOLVE_LLAMA_LOG" | tr -d '\r')" + _RESOLVED_LLAMA_TAG="$( + printf '%s' "${_RESOLVE_INSTALL_JSON:-}" | python -c 'import json,sys; print(json.load(sys.stdin).get("llama_tag",""))' 2>/dev/null || true + )" else _RESOLVED_LLAMA_TAG="" fi @@ -553,13 +572,21 @@ else # so the resolver prefers the latest usable Unsloth-published upstream tag # before falling back to the bleeding-edge ggml-org/llama.cpp tag. _RESOLVE_FALLBACK_ARGS=(--resolve-llama-tag "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO") + _RESOLVE_FALLBACK_ARGS+=(--output-format json) if [ -n "${UNSLOTH_LLAMA_RELEASE_TAG:-}" ]; then _RESOLVE_FALLBACK_ARGS+=(--published-release-tag "$UNSLOTH_LLAMA_RELEASE_TAG") fi - _RESOLVED_LLAMA_TAG="$(python "$SCRIPT_DIR/install_llama_prebuilt.py" "${_RESOLVE_FALLBACK_ARGS[@]}" 2>/dev/null)" + _RESOLVE_FALLBACK_JSON="$(python "$SCRIPT_DIR/install_llama_prebuilt.py" "${_RESOLVE_FALLBACK_ARGS[@]}" 2>/dev/null)" _RESOLVE_UPSTREAM_STATUS=$? set -e - if [ "$_RESOLVE_UPSTREAM_STATUS" -ne 0 ] || [ -z "$_RESOLVED_LLAMA_TAG" ]; then + if [ "$_RESOLVE_UPSTREAM_STATUS" -eq 0 ] && [ -n "${_RESOLVE_FALLBACK_JSON:-}" ]; then + _RESOLVED_LLAMA_TAG="$( + printf '%s' "$_RESOLVE_FALLBACK_JSON" | python -c 'import json,sys; print(json.load(sys.stdin).get("llama_tag",""))' 2>/dev/null || true + )" + else + _RESOLVED_LLAMA_TAG="" + fi + if [ -z "$_RESOLVED_LLAMA_TAG" ]; then _RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG" fi _NEED_LLAMA_SOURCE_BUILD=true @@ -701,7 +728,40 @@ else step "llama.cpp" "skipped (git not found)" "$C_WARN" [ -f "$LLAMA_SERVER_BIN" ] || _LLAMA_CPP_DEGRADED=true else + if [ -z "$_LLAMA_PR" ]; then + if [ "$_LLAMA_SOURCE" = "https://github.com/ggml-org/llama.cpp" ]; then + _RESOLVE_SOURCE_ARGS=(--resolve-source-build "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO") + _RESOLVE_SOURCE_ARGS+=(--output-format json) + if [ -n "${UNSLOTH_LLAMA_RELEASE_TAG:-}" ]; then + _RESOLVE_SOURCE_ARGS+=(--published-release-tag "$UNSLOTH_LLAMA_RELEASE_TAG") + fi + set +e + _SOURCE_BUILD_PLAN="$(python "$SCRIPT_DIR/install_llama_prebuilt.py" "${_RESOLVE_SOURCE_ARGS[@]}" 2>/dev/null)" + _RESOLVE_SOURCE_STATUS=$? + set -e + if [ "$_RESOLVE_SOURCE_STATUS" -eq 0 ] && [ -n "$_SOURCE_BUILD_PLAN" ]; then + _RESOLVED_SOURCE_URL="$( + printf '%s' "$_SOURCE_BUILD_PLAN" | python -c 'import json,sys; print(json.load(sys.stdin).get("source_url",""))' 2>/dev/null || true + )" + _RESOLVED_SOURCE_REF_KIND="$( + printf '%s' "$_SOURCE_BUILD_PLAN" | python -c 'import json,sys; print(json.load(sys.stdin).get("source_ref_kind",""))' 2>/dev/null || true + )" + _RESOLVED_SOURCE_REF="$( + printf '%s' "$_SOURCE_BUILD_PLAN" | python -c 'import json,sys; print(json.load(sys.stdin).get("source_ref",""))' 2>/dev/null || true + )" + fi + fi + if [ -z "$_RESOLVED_SOURCE_URL" ]; then + _RESOLVED_SOURCE_URL="$_LLAMA_SOURCE" + fi + if [ -z "$_RESOLVED_SOURCE_REF" ]; then + _RESOLVED_SOURCE_REF="$_RESOLVED_LLAMA_TAG" + fi + fi + verbose_substep "source build repo: $_RESOLVED_SOURCE_URL" + verbose_substep "source build ref: ${_RESOLVED_SOURCE_REF:-latest} (${_RESOLVED_SOURCE_REF_KIND})" BUILD_OK=true + mkdir -p "$(dirname "$LLAMA_CPP_DIR")" _BUILD_TMP="${LLAMA_CPP_DIR}.build.$$" rm -rf "$_BUILD_TMP" if [ -n "$_LLAMA_PR" ]; then @@ -715,12 +775,34 @@ else run_quiet_no_exit "checkout PR #$_LLAMA_PR" \ git -C "$_BUILD_TMP" checkout "pr-$_LLAMA_PR" || BUILD_OK=false fi + elif [ "$_RESOLVED_SOURCE_REF_KIND" = "pull" ] && [ -n "$_RESOLVED_SOURCE_REF" ]; then + run_quiet_no_exit "clone llama.cpp" \ + git clone --depth 1 "${_RESOLVED_SOURCE_URL}.git" "$_BUILD_TMP" || BUILD_OK=false + if [ "$BUILD_OK" = true ]; then + run_quiet_no_exit "fetch source PR ref" \ + git -C "$_BUILD_TMP" fetch --depth 1 origin "$_RESOLVED_SOURCE_REF" || BUILD_OK=false + fi + if [ "$BUILD_OK" = true ]; then + run_quiet_no_exit "checkout source PR ref" \ + git -C "$_BUILD_TMP" checkout -B unsloth-llama-build FETCH_HEAD || BUILD_OK=false + fi + elif [ "$_RESOLVED_SOURCE_REF_KIND" = "commit" ] && [ -n "$_RESOLVED_SOURCE_REF" ]; then + run_quiet_no_exit "clone llama.cpp" \ + git clone --depth 1 "${_RESOLVED_SOURCE_URL}.git" "$_BUILD_TMP" || BUILD_OK=false + if [ "$BUILD_OK" = true ]; then + run_quiet_no_exit "fetch source commit" \ + git -C "$_BUILD_TMP" fetch --depth 1 origin "$_RESOLVED_SOURCE_REF" || BUILD_OK=false + fi + if [ "$BUILD_OK" = true ]; then + run_quiet_no_exit "checkout source commit" \ + git -C "$_BUILD_TMP" checkout -B unsloth-llama-build FETCH_HEAD || BUILD_OK=false + fi else _CLONE_ARGS=(git clone --depth 1) - if [ "$_RESOLVED_LLAMA_TAG" != "latest" ] && [ -n "$_RESOLVED_LLAMA_TAG" ]; then - _CLONE_ARGS+=(--branch "$_RESOLVED_LLAMA_TAG") + if [ "$_RESOLVED_SOURCE_REF" != "latest" ] && [ -n "$_RESOLVED_SOURCE_REF" ]; then + _CLONE_ARGS+=(--branch "$_RESOLVED_SOURCE_REF") fi - _CLONE_ARGS+=("${_LLAMA_SOURCE}.git" "$_BUILD_TMP") + _CLONE_ARGS+=("${_RESOLVED_SOURCE_URL}.git" "$_BUILD_TMP") run_quiet_no_exit "clone llama.cpp" \ "${_CLONE_ARGS[@]}" || BUILD_OK=false fi From 5a5f1a4f342b058cf5d161513148be33a4d2f7aa Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:04:23 +0100 Subject: [PATCH 010/216] studio: fix chat font changes leaking outside chat page (#4775) * fix(frontend): scope sans font overrides to chat thread only * fix(frontend): use font-sans fallback for heading stack and simplify chat font rules --------- Co-authored-by: Daniel Han --- studio/frontend/src/index.css | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/studio/frontend/src/index.css b/studio/frontend/src/index.css index 45696b2274..a30b4ca287 100644 --- a/studio/frontend/src/index.css +++ b/studio/frontend/src/index.css @@ -6,6 +6,7 @@ @import "shadcn/tailwind.css"; @import "streamdown/styles.css"; @import "@fontsource-variable/figtree"; +@import "@fontsource-variable/space-grotesk"; @import "@fontsource-variable/inter"; @import "tw-shimmer"; @plugin "@toolwind/corner-shape"; @@ -13,6 +14,15 @@ @custom-variant dark (&:is(.dark *)); +@font-face { + font-family: "Hellix"; + src: url("/fonts/Hellix-SemiBold.woff2") format("woff2"), + url("/fonts/Hellix-SemiBold.woff") format("woff"); + font-weight: 600; + font-style: normal; + font-display: swap; +} + :root { /* Animation timing */ --duration-micro: 100ms; @@ -57,7 +67,7 @@ --sidebar-ring: oklch(0.6929 0.1396 166.5513); --destructive-foreground: oklch(1 0 0); --font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui; - --font-heading: var(--font-sans); + --font-heading: "Hellix", "Space Grotesk Variable", var(--font-sans); --font-serif: Source Serif 4, serif; --font-mono: JetBrains Mono, monospace; --shadow-color: hsl(0 0% 0%); @@ -149,7 +159,7 @@ @theme inline { --font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui; - --font-heading: var(--font-sans); + --font-heading: "Hellix", "Space Grotesk Variable", var(--font-sans); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -364,17 +374,7 @@ /* Chat: use the app sans stack for UI + prose. */ .aui-thread-root { - font-family: var(--font-sans); - } - - .aui-thread-root .font-medium, - .aui-thread-root .font-semibold, - .aui-thread-root .font-bold, - .aui-thread-root .font-heading { - font-family: var(--font-sans); - } - - .aui-thread-root :where(h1, h2, h3, h4, h5, h6) { + --font-heading: var(--font-sans); font-family: var(--font-sans); } From c8d311a053711348b6b5ca09e2ab810e9bf0ceb7 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 2 Apr 2026 05:08:16 -0700 Subject: [PATCH 011/216] feat(studio): display images from Python tool execution in chat UI (#4778) * feat(studio): display images from Python tool execution in chat UI When the model calls the Python tool to create a matplotlib plot or other image file, the image now displays inline in the chat output instead of being invisible to the user. Backend: - Detect new image files (png/jpg/gif/webp/bmp) after Python subprocess completes by diffing os.listdir before/after execution - Append __IMAGES__ sentinel to tool result for frontend consumption - Strip sentinel before injecting result into LLM context (role: tool) so the model never sees file paths - Add GET /sandbox/{session_id}/{filename} endpoint with JWT auth (header or query param), path traversal protection, extension allowlist, realpath containment check, and nosniff header Frontend: - Parse __IMAGES__ sentinel in tool_end SSE events, create structured result with text/images/sessionId - Render tags in Python tool UI pointing at the sandbox endpoint Also fixes a bug where SyntaxError in user code was misreported as "unsafe code detected" instead of showing the actual Python traceback. The _check_code_safety function now lets SyntaxError pass through to the subprocess for a proper error message. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix(studio): improve SVG detection and strip XML preamble Handle declarations before tags in code fences, strip XML declaration from SVGs before data URI rendering, and update the sloth suggestion prompt to request showing code. * fix(studio): persist parentId so retries survive reload The append() handler was destructuring only { message } from ExportedMessageRepositoryItem and discarding parentId. When loading a saved thread, load() used ExportedMessageRepository.fromArray() which chains all messages sequentially, flattening retry branches into a linear list. Now append() writes parentId to the MessageRecord, and load() reconstructs the tree when parentIds are present. Old threads without parentId fall back to the existing fromArray() behavior. * fix(studio): address review findings for image display and retry persistence Image detection: - Use mtime comparison instead of filename-only diff so overwritten files (e.g. plt.savefig("chart.png") called twice) are detected Sentinel parsing: - Use rsplit/lastIndexOf instead of split/indexOf so user code that prints __IMAGES__: does not collide with the backend sentinel Mixed legacy/new threads: - For old messages without a stored parentId, infer sequential parent from the previous message instead of null, preventing multiple roots Sandbox endpoint: - Change Cache-Control from "public, max-age=3600" to "private, no-store" since these are authenticated responses --------- Co-authored-by: Daniel Han Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/core/inference/llama_cpp.py | 7 +- studio/backend/core/inference/tools.py | 45 ++++++++- studio/backend/routes/inference.py | 91 ++++++++++++++++++- .../components/assistant-ui/markdown-text.tsx | 13 ++- .../src/components/assistant-ui/thread.tsx | 2 +- .../assistant-ui/tool-ui-python.tsx | 56 ++++++++++-- .../src/features/chat/api/chat-adapter.ts | 18 +++- .../src/features/chat/runtime-provider.tsx | 29 +++++- studio/frontend/src/features/chat/types.ts | 1 + 9 files changed, 245 insertions(+), 17 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 1b7fe548e4..80dda61b02 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -2905,10 +2905,15 @@ class LlamaCppBackend: _error_prefixes ) _tool_call_history.append((_tc_key, _is_error)) + # Strip image sentinel before feeding result to the LLM + # (the full result with sentinel is still yielded via + # tool_end so the frontend can extract image paths). _result_content = result + if "\n__IMAGES__:" in _result_content: + _result_content = _result_content.rsplit("\n__IMAGES__:", 1)[0] if _is_error: _result_content = ( - result + "\n\nThe tool call encountered an issue. " + _result_content + "\n\nThe tool call encountered an issue. " "Please try a different approach or rephrase your request." ) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index d425daa49d..b23372b766 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -26,6 +26,10 @@ from loggers import get_logger logger = get_logger(__name__) _EXEC_TIMEOUT = 300 # 5 minutes + +# Strict raster-image allowlist for sandbox file serving. +# No .svg (XSS risk via embedded scripts), no .html, no .pdf. +_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}) _MAX_OUTPUT_CHARS = 8000 # truncate long output _BASH_BLOCKED_WORDS = {"rm", "sudo", "dd", "chmod", "mkfs", "shutdown", "reboot"} @@ -591,6 +595,12 @@ def _check_code_safety(code: str) -> str | None: """ safe, info = _check_signal_escape_patterns(code) if not safe: + # SyntaxError from ast.parse -- let these through so the subprocess + # produces a normal Python traceback instead of a misleading + # "unsafe code detected" message. + if info.get("error"): + return None + reasons = [ item.get("description", "") for item in info.get("signal_tampering", []) ] @@ -634,6 +644,17 @@ def _python_exec( tmp_path = None workdir = _get_workdir(session_id) + # Snapshot image mtimes so we detect both new and overwritten files. + _before: dict[str, int] = {} + if os.path.isdir(workdir): + for _name in os.listdir(workdir): + if os.path.splitext(_name)[1].lower() in _IMAGE_EXTS: + _p = os.path.join(workdir, _name) + if os.path.isfile(_p): + try: + _before[_name] = os.stat(_p).st_mtime_ns + except OSError: + pass try: fd, tmp_path = tempfile.mkstemp( suffix = ".py", prefix = "studio_exec_", dir = workdir @@ -669,7 +690,29 @@ def _python_exec( result = output or "" if proc.returncode != 0: result = f"Exit code {proc.returncode}:\n{result}" - return _truncate(result) if result.strip() else "(no output)" + result = _truncate(result) if result.strip() else "(no output)" + + # Detect new or overwritten image files and append sentinel for frontend + if session_id and os.path.isdir(workdir): + new_images = [] + for _name in os.listdir(workdir): + if os.path.splitext(_name)[1].lower() not in _IMAGE_EXTS: + continue + _p = os.path.join(workdir, _name) + if not os.path.isfile(_p): + continue + try: + _mtime = os.stat(_p).st_mtime_ns + except OSError: + continue + if _name not in _before or _mtime != _before[_name]: + new_images.append(_name) + if new_images: + import json as _json + + result += f"\n__IMAGES__:{_json.dumps(sorted(new_images))}" + + return result except Exception as e: return f"Execution error: {e}" diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 0c1d7e37f9..8f058bd0f2 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -5,11 +5,12 @@ Inference API routes for model loading and text generation. """ +import os import sys import time import uuid from pathlib import Path -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import StreamingResponse, JSONResponse from typing import Optional import json @@ -1680,6 +1681,94 @@ async def openai_chat_completions( raise HTTPException(status_code = 500, detail = str(e)) +# ===================================================================== +# Sandbox file serving (/sandbox/{session_id}/{filename}) +# ===================================================================== + +_SANDBOX_MEDIA_TYPES = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", +} + + +@router.get("/sandbox/{session_id}/{filename}") +async def serve_sandbox_file( + session_id: str, + filename: str, + request: Request, + token: Optional[str] = None, +): + """ + Serve image files created by Python tool execution. + + Accepts auth via Authorization header OR ?token= query param + (needed because cannot send custom headers). + """ + from fastapi.responses import FileResponse + + # ── Authentication (header or query param) ────────────────── + auth_header = request.headers.get("authorization") + if auth_header and auth_header.lower().startswith("bearer "): + jwt_token = auth_header[7:] + elif token: + jwt_token = token + else: + raise HTTPException( + status_code = status.HTTP_401_UNAUTHORIZED, + detail = "Missing authentication token", + ) + from fastapi.security import HTTPAuthorizationCredentials + + creds = HTTPAuthorizationCredentials(scheme = "Bearer", credentials = jwt_token) + await get_current_subject(creds) + + # ── Filename sanitization ─────────────────────────────────── + safe_filename = os.path.basename(filename) + if not safe_filename or safe_filename in (".", ".."): + raise HTTPException(status_code = 404, detail = "Not found") + + # ── Extension allowlist ───────────────────────────────────── + ext = os.path.splitext(safe_filename)[1].lower() + media_type = _SANDBOX_MEDIA_TYPES.get(ext) + if not media_type: + raise HTTPException( + status_code = status.HTTP_403_FORBIDDEN, + detail = "File type not allowed", + ) + + # ── Path containment check ────────────────────────────────── + home = os.path.expanduser("~") + sandbox_root = os.path.realpath(os.path.join(home, "studio_sandbox")) + safe_session = os.path.basename(session_id.replace("..", "")) + if not safe_session: + raise HTTPException(status_code = 404, detail = "Not found") + + file_path = os.path.realpath( + os.path.join(sandbox_root, safe_session, safe_filename) + ) + if not file_path.startswith(sandbox_root + os.sep): + raise HTTPException( + status_code = status.HTTP_403_FORBIDDEN, + detail = "Access denied", + ) + + if not os.path.isfile(file_path): + raise HTTPException(status_code = 404, detail = "Not found") + + return FileResponse( + path = file_path, + media_type = media_type, + headers = { + "Cache-Control": "private, no-store", + "X-Content-Type-Options": "nosniff", + }, + ) + + # ===================================================================== # OpenAI-Compatible Models Listing (/models → /v1/models) # ===================================================================== diff --git a/studio/frontend/src/components/assistant-ui/markdown-text.tsx b/studio/frontend/src/components/assistant-ui/markdown-text.tsx index 17a6f8f054..91ef78fcf9 100644 --- a/studio/frontend/src/components/assistant-ui/markdown-text.tsx +++ b/studio/frontend/src/components/assistant-ui/markdown-text.tsx @@ -100,20 +100,27 @@ function getCodeFilename(language: string | null) { function isSvgFence(codeFence: CodeFence): boolean { const lang = codeFence.language?.toLowerCase() ?? ""; if (lang === "svg") return true; - if ((lang === "xml" || lang === "html") && codeFence.source.trimStart().startsWith(" followed by ]|on\w+\s*=|javascript:|]|]|]|]/i; function sanitizeSvg(source: string): string | null { if (UNSAFE_SVG_RE.test(source)) return null; - return source; + // Strip XML declaration () -- not needed for data URI + // rendering and can cause issues with some renderers. + return source.replace(/^\s*<\?xml[^?]*\?>\s*/i, ""); } function SvgPreview({ source }: { source: string }) { diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 5170f6ff69..4db0eda0ad 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -124,7 +124,7 @@ const SUGGESTION_TOOLS: Record> = "How do you fine-tune an audio model with Unsloth?": ["thinking", "search"], "Create a live weather dashboard in HTML using no API key. Show me the code": ["thinking", "code", "search"], "Solve the integral of x·sin(x), and verify it step by step": ["thinking", "code"], - "Draw an SVG of a cute sloth": ["thinking", "code", "search"], + "Draw an SVG of a cute sloth & show the code": ["thinking", "code", "search"], }; const toolIconMap = { diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx index a510ed0d9e..6aa590ae11 100644 --- a/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx @@ -4,6 +4,7 @@ "use client"; import { copyToClipboard } from "@/lib/copy-to-clipboard"; +import { getAuthToken } from "@/features/auth/session"; import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; import { code as codePlugin } from "@streamdown/code"; import { CheckIcon, CodeIcon, CopyIcon, LoaderIcon } from "lucide-react"; @@ -15,6 +16,12 @@ import { ToolFallbackTrigger, } from "./tool-fallback"; +interface StructuredResult { + text: string; + images: string[]; + sessionId: string; +} + const MAX_DISPLAY = 10_000; const COPY_RESET_MS = 2000; const SHIKI_THEME = ["github-light", "github-dark"] as ["github-light", "github-dark"]; @@ -84,6 +91,16 @@ function HighlightedCode({ code: source, language }: { code: string; language: s ); } +function isStructuredResult(val: unknown): val is StructuredResult { + return ( + typeof val === "object" && + val !== null && + "text" in val && + "images" in val && + "sessionId" in val + ); +} + const PythonToolUIImpl: ToolCallMessagePartComponent = ({ args, result, @@ -92,12 +109,24 @@ const PythonToolUIImpl: ToolCallMessagePartComponent = ({ const code = (args as { code?: string })?.code ?? ""; const firstLine = code.split("\n")[0]?.slice(0, 60) ?? ""; const isRunning = status?.type === "running"; - const output = - typeof result === "string" - ? result - : result - ? JSON.stringify(result, null, 2) - : ""; + + let output: string; + let images: string[] = []; + let sessionId = ""; + + if (isStructuredResult(result)) { + output = result.text; + images = result.images; + sessionId = result.sessionId; + } else if (typeof result === "string") { + output = result; + } else if (result) { + output = JSON.stringify(result, null, 2); + } else { + output = ""; + } + + const authToken = getAuthToken(); return ( @@ -133,6 +162,21 @@ const PythonToolUIImpl: ToolCallMessagePartComponent = ({

) : null} + + {/* Images from Python tool execution */} + {images.length > 0 && sessionId && ( +
+ {images.map((filename) => ( + {filename} + ))} +
+ )}
diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 2b8a259930..e287daf33a 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -635,7 +635,23 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { toolCallParts[toolCallParts.length - 1]?.toolCallId || ""; const idx = toolCallParts.findIndex((p) => p.toolCallId === id); if (idx !== -1) { - toolCallParts[idx] = { ...toolCallParts[idx], result: toolEvent.result as string }; + const rawResult = (toolEvent.result as string) ?? ""; + const imgMarker = "\n__IMAGES__:"; + const imgIdx = rawResult.lastIndexOf(imgMarker); + let parsedResult: string | { text: string; images: string[]; sessionId: string }; + if (imgIdx !== -1) { + const text = rawResult.slice(0, imgIdx); + const sessionId = unstable_threadId || ""; + try { + const images = JSON.parse(rawResult.slice(imgIdx + imgMarker.length)) as string[]; + parsedResult = { text, images, sessionId }; + } catch { + parsedResult = rawResult; + } + } else { + parsedResult = rawResult; + } + toolCallParts[idx] = { ...toolCallParts[idx], result: parsedResult }; } } // Yield cumulative state so tool UI updates (tools first, text after) diff --git a/studio/frontend/src/features/chat/runtime-provider.tsx b/studio/frontend/src/features/chat/runtime-provider.tsx index 404271f896..02f792b509 100644 --- a/studio/frontend/src/features/chat/runtime-provider.tsx +++ b/studio/frontend/src/features/chat/runtime-provider.tsx @@ -47,9 +47,9 @@ const DEFAULT_SUGGESTIONS = [ prompt: "Solve the integral of x·sin(x), and verify it step by step", }, { - title: "Draw an SVG of a cute sloth", + title: "Draw an SVG of a cute sloth & show the code", label: "SVG sloth", - prompt: "Draw an SVG of a cute sloth", + prompt: "Draw an SVG of a cute sloth & show the code", }, ]; @@ -569,10 +569,32 @@ function ThreadHistoryProvider({ store.setContextUsage(savedUsage); } + // If any message has a stored parentId, reconstruct the tree + // so retries/regenerations load as branches instead of being + // unrolled into a flat list. For mixed legacy/new threads + // (old messages without parentId + new messages with), infer + // sequential parents for old messages to preserve the chain. + // Fall back to fromArray for fully legacy threads. + const hasParentIds = msgs.some((m) => "parentId" in m); + if (hasParentIds) { + let previousId: string | null = null; + return { + messages: msgs.map((m) => { + const parentId = "parentId" in m + ? (m.parentId ?? null) + : previousId; + previousId = m.id; + return { + parentId, + message: toThreadMessage(m), + }; + }), + }; + } return ExportedMessageRepository.fromArray(msgs.map(toThreadMessage)); }, - async append({ message }: ExportedMessageRepositoryItem) { + async append({ parentId, message }: ExportedMessageRepositoryItem) { const { remoteId } = await aui.threadListItem().initialize(); const content = cloneContent(message.content); const attachments = @@ -586,6 +608,7 @@ function ThreadHistoryProvider({ await db.messages.put({ id: message.id, threadId: remoteId, + parentId: parentId ?? null, role: message.role, content, ...(attachments.length > 0 && { attachments }), diff --git a/studio/frontend/src/features/chat/types.ts b/studio/frontend/src/features/chat/types.ts index 11e53d76d3..1f370b6ac1 100644 --- a/studio/frontend/src/features/chat/types.ts +++ b/studio/frontend/src/features/chat/types.ts @@ -20,6 +20,7 @@ export interface ThreadRecord { export interface MessageRecord { id: string; threadId: string; + parentId?: string | null; role: import("@assistant-ui/react").ThreadMessage["role"]; content: import("@assistant-ui/react").ThreadMessage["content"]; attachments?: import("@assistant-ui/react").ThreadMessage["attachments"]; From 3b613eb1e8ff71a0fca02b83cdfa06a09c864c86 Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Thu, 2 Apr 2026 18:57:47 +0400 Subject: [PATCH 012/216] ui improvement (#4781) * ui * ui * ui --- install_gemma4_mlx.sh | 215 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100755 install_gemma4_mlx.sh diff --git a/install_gemma4_mlx.sh b/install_gemma4_mlx.sh new file mode 100755 index 0000000000..1f5e7c2d9e --- /dev/null +++ b/install_gemma4_mlx.sh @@ -0,0 +1,215 @@ +#!/bin/bash +set -e + +# ============================================================ +# Gemma 4 MLX — One-command setup + inference +# +# Usage: +# bash install_gemma4_mlx.sh [--venv-dir DIR] +# +# This script: +# 1. Creates a Python virtual environment +# 2. Installs uv, mlx, mlx-lm, transformers +# 3. Downloads gemma4.py and gemma4_text.py from unsloth repo +# 4. Installs them into mlx-lm's models directory +# ============================================================ + +# ── Output style (inspired by unsloth/install.sh) ───────────── +RULE="" +_rule_i=0 +while [ "$_rule_i" -lt 52 ]; do + RULE="${RULE}─" + _rule_i=$((_rule_i + 1)) +done + +if [ -n "${NO_COLOR:-}" ]; then + C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST= +elif [ -t 1 ] || [ -n "${FORCE_COLOR:-}" ]; then + _ESC="$(printf '\033')" + C_TITLE="${_ESC}[38;5;117m" + C_DIM="${_ESC}[38;5;245m" + C_OK="${_ESC}[38;5;108m" + C_WARN="${_ESC}[38;5;136m" + C_ERR="${_ESC}[91m" + C_RST="${_ESC}[0m" +else + C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST= +fi + +step() { printf " ${C_DIM}%-18.18s${C_RST}${3:-$C_OK}%s${C_RST}\n" "$1" "$2"; } +substep() { printf " ${C_DIM}%-18s${2:-$C_DIM}%s${C_RST}\n" "" "$1"; } +fail() { step "error" "$1" "$C_ERR"; exit 1; } + +# ── Parse flags ─────────────────────────────────────────────── +VENV_DIR="" +_next_is_venv=false + +for arg in "$@"; do + if [ "$_next_is_venv" = true ]; then + VENV_DIR="$arg" + _next_is_venv=false + continue + fi + case "$arg" in + --venv-dir) _next_is_venv=true ;; + esac +done + +# Default venv location +if [ -z "$VENV_DIR" ]; then + VENV_DIR="$HOME/.unsloth/unsloth_gemma4_mlx" +fi + +# ── Banner ──────────────────────────────────────────────────── +echo "" +printf " ${C_TITLE}%s${C_RST}\n" "💎 Gemma 4 MLX Installer" +printf " ${C_DIM}%s${C_RST}\n" "$RULE" +echo "" + +# ── Platform check ──────────────────────────────────────────── +if [ "$(uname)" != "Darwin" ]; then + fail "MLX requires macOS with Apple Silicon. Detected: $(uname)" +fi + +_ARCH=$(uname -m) +if [ "$_ARCH" != "arm64" ]; then + step "warning" "Apple Silicon recommended (detected: $_ARCH)" "$C_WARN" +fi + +step "platform" "macOS ($_ARCH)" + +# ── Detect Python ───────────────────────────────────────────── +PYTHON="" +for _candidate in python3.12 python3.11 python3.13 python3; do + if command -v "$_candidate" >/dev/null 2>&1; then + PYTHON="$_candidate" + break + fi +done + +if [ -z "$PYTHON" ]; then + fail "Python 3 not found. Install via: brew install python@3.12" +fi + +_PY_VERSION=$("$PYTHON" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')") +step "python" "$PYTHON ($_PY_VERSION)" + +# ── Create virtual environment ──────────────────────────────── +if [ -x "$VENV_DIR/bin/python" ]; then + step "venv" "using existing environment" + substep "$VENV_DIR" +else + step "venv" "creating virtual environment" + substep "$VENV_DIR" + mkdir -p "$(dirname "$VENV_DIR")" + "$PYTHON" -m venv "$VENV_DIR" +fi + +# ── Install uv ─────────────────────────────────────────────── +if ! command -v uv >/dev/null 2>&1; then + step "uv" "installing uv package manager..." + _uv_tmp=$(mktemp) + curl -LsSf "https://astral.sh/uv/install.sh" -o "$_uv_tmp" + sh "$_uv_tmp" /dev/null 2>&1 + rm -f "$_uv_tmp" + if [ -f "$HOME/.local/bin/env" ]; then + . "$HOME/.local/bin/env" + fi + export PATH="$HOME/.local/bin:$PATH" + substep "done" +else + step "uv" "found $(uv --version 2>/dev/null || echo 'uv')" +fi + +_VENV_PY="$VENV_DIR/bin/python" + +# ── Repo config ────────────────────────────────────────────── +BRANCH="fix/ui-fix" +REPO_URL="https://raw.githubusercontent.com/unslothai/unsloth/refs/heads/${BRANCH}" + +# ── Install dependencies ────────────────────────────────────── +step "install" "installing mlx, mlx-lm..." +uv pip install --python "$_VENV_PY" -q mlx mlx-lm 2>/dev/null +substep "done" + +TRANSFORMERS_WHL="transformers-5.5.0.dev0-py3-none-any.whl" +TRANSFORMERS_GH="git+https://github.com/huggingface/transformers.git@v5.5-release" + +step "install" "installing transformers>=5.5.0..." +if uv pip install --python "$_VENV_PY" -q "$TRANSFORMERS_GH" 2>/dev/null; then + substep "installed from huggingface/transformers v5.5-release" +elif uv pip install --python "$_VENV_PY" -q "transformers>=5.5.0" 2>/dev/null; then + substep "installed from PyPI" +else + substep "not on PyPI, trying unsloth branch..." + _whl_tmp=$(mktemp -d)/"${TRANSFORMERS_WHL}" + if curl -fsSL "${REPO_URL}/${TRANSFORMERS_WHL}" -o "$_whl_tmp" 2>/dev/null && \ + uv pip install --python "$_VENV_PY" -q "$_whl_tmp" 2>/dev/null; then + substep "installed from branch ${BRANCH}" + elif [ -f "./${TRANSFORMERS_WHL}" ]; then + substep "using local ./${TRANSFORMERS_WHL}" + uv pip install --python "$_VENV_PY" -q "./${TRANSFORMERS_WHL}" + else + rm -f "$_whl_tmp" 2>/dev/null + step "install" "skipping transformers — could not find >=5.5.0" "$C_WARN" + substep "tried: huggingface/transformers v5.5-release, PyPI, branch ${BRANCH}, local ./${TRANSFORMERS_WHL}" + fi + rm -f "$_whl_tmp" 2>/dev/null +fi + +# ── Find mlx-lm models directory ───────────────────────────── +MLX_MODELS=$("$_VENV_PY" -c "import mlx_lm; print(mlx_lm.__path__[0])")/models +step "models dir" "$MLX_MODELS" + +# ── Download and install Gemma 4 model files ────────────────── + +step "download" "installing Gemma 4 model files..." + +_install_model_file() { + _fname="$1" + if curl -fsSL "${REPO_URL}/unsloth/models/${_fname}" -o "${MLX_MODELS}/${_fname}" 2>/dev/null; then + substep "downloaded ${_fname} from branch ${BRANCH}" + elif [ -f "./${_fname}" ]; then + substep "using local ./${_fname}" + cp "./${_fname}" "${MLX_MODELS}/${_fname}" + else + fail "Could not install ${_fname}. Tried: + 1) ${REPO_URL}/unsloth/models/${_fname} + 2) Local file ./${_fname} + + To fix, download the file manually and place it in the current directory, + then re-run this script." + fi +} + +_install_model_file "gemma4.py" +_install_model_file "gemma4_text.py" + +# Verify files were installed correctly +if "$_VENV_PY" -c "from mlx_lm.models.gemma4_text import ProportionalRoPE" 2>/dev/null; then + substep "model files verified" +else + fail "Model files installed but verification failed (ProportionalRoPE import error). + Try manually from: https://github.com/unslothai/unsloth/tree/feature/${BRANCH}" +fi + +# ── Done ────────────────────────────────────────────────────── +echo "" +printf " ${C_TITLE}%s${C_RST}\n" "Gemma 4 MLX installed!" +printf " ${C_DIM}%s${C_RST}\n" "$RULE" +echo "" +step "available models" "unsloth/gemma-4-E2B-it-MLX-4bit (/BF16)" +substep "unsloth/gemma-4-E4B-it-MLX-4bit (/BF16)" +echo "" +step "venv activate" "source ${VENV_DIR}/bin/activate" +echo "" +step "quick start" "python -m mlx_lm chat --model unsloth/gemma-4-E2B-it-MLX-4bit --max-tokens 200" +echo "" +step "python API" "from mlx_lm import load, generate" +substep "model, tokenizer = load('unsloth/gemma-4-E2B-it-MLX-4bit')" +substep "messages = [{'role': 'user', 'content': 'Hello!'}]" +substep "prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)" +substep "print(generate(model, tokenizer, prompt=prompt, max_tokens=200))" +echo "" +printf " ${C_DIM}%s${C_RST}\n" "$RULE" +echo "" From f9c4b08726939fb415f23ae4ed356120dbf796b0 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 2 Apr 2026 08:05:55 -0700 Subject: [PATCH 013/216] UI Changes (#4782) * UI Changes * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Remove unrelated test file --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/core/inference/defaults.py | 20 ++++ studio/backend/core/inference/llama_cpp.py | 2 + .../backend/requirements/extras-no-deps.txt | 2 +- .../requirements/single-env/constraints.txt | 4 +- studio/backend/utils/transformers_version.py | 6 +- studio/frontend/src/config/training.ts | 4 + unsloth/chat_templates.py | 108 ++++++++++++++++++ unsloth/models/_utils.py | 21 ++++ unsloth/models/loader.py | 14 +++ unsloth/models/vision.py | 67 ++++++++++- unsloth/ollama_template_mappers.py | 25 ++++ 11 files changed, 267 insertions(+), 6 deletions(-) diff --git a/studio/backend/core/inference/defaults.py b/studio/backend/core/inference/defaults.py index d5e9ca2e97..f3026dddaf 100644 --- a/studio/backend/core/inference/defaults.py +++ b/studio/backend/core/inference/defaults.py @@ -6,6 +6,14 @@ import utils.hardware.hardware as hw DEFAULT_MODELS_GGUF = [ + "unsloth/gemma-4-E2B-it-GGUF", + "unsloth/gemma-4-E4B-it-GGUF", + "unsloth/gemma-4-31B-it-GGUF", + "unsloth/gemma-4-26B-A4B-it-GGUF", + "unsloth/Qwen3.5-4B-GGUF", + "unsloth/Qwen3.5-9B-GGUF", + "unsloth/Qwen3.5-35B-A3B-GGUF", + "unsloth/Qwen3.5-0.8B-GGUF", "unsloth/Llama-3.2-1B-Instruct-GGUF", "unsloth/Llama-3.2-3B-Instruct-GGUF", "unsloth/Llama-3.1-8B-Instruct-GGUF", @@ -15,6 +23,18 @@ DEFAULT_MODELS_GGUF = [ ] DEFAULT_MODELS_STANDARD = [ + "unsloth/gemma-4-E2B-it-GGUF", + "unsloth/gemma-4-E4B-it-GGUF", + "unsloth/gemma-4-31B-it-GGUF", + "unsloth/gemma-4-26B-A4B-it-GGUF", + "unsloth/Qwen3.5-4B-GGUF", + "unsloth/Qwen3.5-9B-GGUF", + "unsloth/Qwen3.5-35B-A3B-GGUF", + "unsloth/Qwen3.5-0.8B-GGUF", + "unsloth/gemma-4-E2B-it", + "unsloth/gemma-4-E4B-it", + "unsloth/gemma-4-31B-it", + "unsloth/gemma-4-26B-A4B-it", "unsloth/Qwen3-4B-Instruct-2507", "unsloth/Meta-Llama-3.1-8B-Instruct-bnb-4bit", "unsloth/Mistral-Nemo-Instruct-2407-bnb-4bit", diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 80dda61b02..b436f4dc6d 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -810,7 +810,9 @@ class LlamaCppBackend: # Detect tool calling support from chat template tool_markers = [ "{%- if tools %}", + "{%- if tools -%}", "{% if tools %}", + "{% if tools -%}", '"role" == "tool"', "'role' == 'tool'", 'message.role == "tool"', diff --git a/studio/backend/requirements/extras-no-deps.txt b/studio/backend/requirements/extras-no-deps.txt index 9934bacd24..325eb494fc 100644 --- a/studio/backend/requirements/extras-no-deps.txt +++ b/studio/backend/requirements/extras-no-deps.txt @@ -11,6 +11,6 @@ git+https://github.com/meta-pytorch/OpenEnv.git # executorch>=1.0.1 # 41.5 MB - no imports in unsloth/zoo/studio torch-c-dlpack-ext sentence_transformers==5.2.0 -transformers==4.57.6 +transformers>=4.57.6 pytorch_tokenizers kernels diff --git a/studio/backend/requirements/single-env/constraints.txt b/studio/backend/requirements/single-env/constraints.txt index 156f78567e..37391e739e 100644 --- a/studio/backend/requirements/single-env/constraints.txt +++ b/studio/backend/requirements/single-env/constraints.txt @@ -1,8 +1,8 @@ # Single-env pins for unsloth + studio + data-designer # Keep compatible with unsloth transformers bounds. -transformers==4.57.6 +transformers>=4.57.6 trl==0.23.1 -huggingface-hub==0.36.2 +huggingface-hub>=0.36.2 # Studio stack datasets==4.3.0 diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index d8724de723..9db4b3ce4b 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -47,6 +47,8 @@ TRANSFORMERS_5_MODEL_SUBSTRINGS: tuple[str, ...] = ( "qwen3.5", # Qwen3.5 family (35B-A3B, etc.) "qwen3-next", # Qwen3-Next and variants "tiny_qwen3_moe", # imdatta0/tiny_qwen3_moe_2.8B_0.7B + "gemma-4", # Gemma-4 (E2B-it, E4B-it, 31B-it, 26B-A4B-it) + "gemma4", # Gemma-4 alternate naming ) # Tokenizer classes that only exist in transformers>=5.x @@ -58,7 +60,7 @@ _TRANSFORMERS_5_TOKENIZER_CLASSES: set[str] = { _tokenizer_class_cache: dict[str, bool] = {} # Versions -TRANSFORMERS_5_VERSION = "5.3.0" +TRANSFORMERS_5_VERSION = "5.5.0.dev0" TRANSFORMERS_DEFAULT_VERSION = "4.57.6" # Pre-installed directory for transformers 5.x — created by setup.sh / setup.ps1 @@ -258,7 +260,7 @@ def _purge_modules() -> int: _VENV_T5_PACKAGES = ( f"transformers=={TRANSFORMERS_5_VERSION}", - "huggingface_hub==1.7.1", + "huggingface_hub==1.8.0", "hf_xet==1.4.2", "tiktoken", ) diff --git a/studio/frontend/src/config/training.ts b/studio/frontend/src/config/training.ts index 42fccde552..913d612838 100644 --- a/studio/frontend/src/config/training.ts +++ b/studio/frontend/src/config/training.ts @@ -141,6 +141,10 @@ export const MODEL_TYPE_TO_HF_TASK: Record = { export const PRIORITY_TRAINING_MODELS: readonly string[] = [ + "unsloth/gemma-4-E2B-it", + "unsloth/gemma-4-E4B-it", + "unsloth/gemma-4-31B-it", + "unsloth/gemma-4-26B-A4B-it", "unsloth/Qwen3.5-2B", "unsloth/Qwen3.5-9B", "unsloth/gpt-oss-20b", diff --git a/unsloth/chat_templates.py b/unsloth/chat_templates.py index 35eb871529..71f91cc828 100644 --- a/unsloth/chat_templates.py +++ b/unsloth/chat_templates.py @@ -863,6 +863,114 @@ DEFAULT_SYSTEM_MESSAGE["gemma-3n"] = None # No system message in Gemma-3n CHAT_TEMPLATES["gemma3n"] = (gemma3n_template, gemma3n_template_eos_token, False, gemma3n_ollama,) DEFAULT_SYSTEM_MESSAGE["gemma3n"] = None # No system message in Gemma-3n +# =========================================== Gemma-4 +# Gemma-4 uses <|turn>role\n...\n format +gemma4_template = \ +"""{%- if messages[0]['role'] == 'system' -%} + {%- set first_user_prefix = messages[0]['content'] + '\n\n' -%} + {%- set loop_messages = messages[1:] -%} +{%- else -%} + {%- set first_user_prefix = "" -%} + {%- set loop_messages = messages -%} +{%- endif -%} +{%- for message in loop_messages -%} + {%- if (message['role'] == 'user') != (loop.index0 % 2 == 0) -%} + {{ raise_exception("Conversation roles must alternate user/assistant/user/assistant/...") }} + {%- endif -%} + {%- if (message['role'] == 'assistant') -%} + {%- set role = "model" -%} + {%- else -%} + {%- set role = message['role'] -%} + {%- endif -%} + {{ '<|turn>' + role + '\n' + (first_user_prefix if loop.first else "") }} + {%- if message['content'] is string -%} + {{ message['content'] | trim }} + {%- elif message['content'] is iterable -%} + {%- for item in message['content'] -%} + {%- if item['type'] == 'audio' -%} + {{ '<|audio|>' }} + {%- elif item['type'] == 'image' -%} + {{ '<|image|>' }} + {%- elif item['type'] == 'video' -%} + {{ '<|video|>' }} + {%- elif item['type'] == 'text' -%} + {{ item['text'] | trim }} + {%- endif -%} + {%- endfor -%} + {%- else -%} + {{ raise_exception("Invalid content type") }} + {%- endif -%} + {{ '\n' }} +{%- endfor -%} +{%- if add_generation_prompt -%} + {{'<|turn>model\n'}} +{%- endif -%} +""" + +try: + gemma4_ollama = _ollama_template("gemma-4") +except KeyError: + gemma4_ollama = "" +gemma4_template_eos_token = "" +CHAT_TEMPLATES["gemma-4"] = (gemma4_template, gemma4_template_eos_token, False, gemma4_ollama,) +DEFAULT_SYSTEM_MESSAGE["gemma-4"] = None + +CHAT_TEMPLATES["gemma4"] = (gemma4_template, gemma4_template_eos_token, False, gemma4_ollama,) +DEFAULT_SYSTEM_MESSAGE["gemma4"] = None + +# Gemma-4 with empty thought channel (required for larger models like 31B, 26B-A4B) +# Injects <|channel>thought\n at the start of each model response during training +gemma4_thinking_template = \ +"""{%- if messages[0]['role'] == 'system' -%} + {%- set first_user_prefix = messages[0]['content'] + '\n\n' -%} + {%- set loop_messages = messages[1:] -%} +{%- else -%} + {%- set first_user_prefix = "" -%} + {%- set loop_messages = messages -%} +{%- endif -%} +{%- for message in loop_messages -%} + {%- if (message['role'] == 'user') != (loop.index0 % 2 == 0) -%} + {{ raise_exception("Conversation roles must alternate user/assistant/user/assistant/...") }} + {%- endif -%} + {%- if (message['role'] == 'assistant') -%} + {%- set role = "model" -%} + {%- else -%} + {%- set role = message['role'] -%} + {%- endif -%} + {{ '<|turn>' + role + '\n' + (first_user_prefix if loop.first else "") }} + {%- if role == "model" -%} + {{ '<|channel>thought\n' }} + {%- endif -%} + {%- if message['content'] is string -%} + {{ message['content'] | trim }} + {%- elif message['content'] is iterable -%} + {%- for item in message['content'] -%} + {%- if item['type'] == 'audio' -%} + {{ '<|audio|>' }} + {%- elif item['type'] == 'image' -%} + {{ '<|image|>' }} + {%- elif item['type'] == 'video' -%} + {{ '<|video|>' }} + {%- elif item['type'] == 'text' -%} + {{ item['text'] | trim }} + {%- endif -%} + {%- endfor -%} + {%- else -%} + {{ raise_exception("Invalid content type") }} + {%- endif -%} + {{ '\n' }} +{%- endfor -%} +{%- if add_generation_prompt -%} + {{'<|turn>model\n'}} +{%- endif -%} +""" + +CHAT_TEMPLATES["gemma-4-thinking"] = (gemma4_thinking_template, gemma4_template_eos_token, False, gemma4_ollama,) +DEFAULT_SYSTEM_MESSAGE["gemma-4-thinking"] = None + +CHAT_TEMPLATES["gemma4-thinking"] = (gemma4_thinking_template, gemma4_template_eos_token, False, gemma4_ollama,) +DEFAULT_SYSTEM_MESSAGE["gemma4-thinking"] = None + # =========================================== GPT-OSS # Obtained via # print(tokenizer.chat_template.replace("}\n", "####").replace("\n", "\\n").replace("####", "}\n")) diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index df6462eade..1c803a4972 100644 --- a/unsloth/models/_utils.py +++ b/unsloth/models/_utils.py @@ -537,6 +537,15 @@ try: except: pass +# Gemma4 It is strongly recommended to train Gemma4 models with the `eager` +try: + from transformers.models.gemma4.modeling_gemma4 import logger as gemma4_logger + + gemma4_logger.addFilter(HideLoggingMessage("strongly recommended")) + del gemma4_logger +except: + pass + # Xet Storage is enabled for this repo, but the 'hf_xet' package is not installed. try: from huggingface_hub.file_download import logger as hub_logger @@ -1930,6 +1939,18 @@ def _unsloth_pre_compute_loss(self, model, inputs, *args, **kwargs): _has_ccm = _mod is not None and hasattr(_mod, "create_causal_mask_mapping") if _has_ccm and _inner.training: inputs["token_type_ids"] = torch.zeros_like(inputs["input_ids"]) + # Gemma4 uses mm_token_type_ids (not token_type_ids) for VLM masking + if "mm_token_type_ids" not in inputs and "input_ids" in inputs: + _inner = model + for _attr in ("base_model", "model", "model"): + _inner = getattr(_inner, _attr, _inner) + if getattr(getattr(_inner, "config", None), "model_type", "") in ("gemma4",): + import sys as _sys + + _mod = _sys.modules.get(type(_inner).__module__) + _has_ccm = _mod is not None and hasattr(_mod, "create_causal_mask_mapping") + if _has_ccm and _inner.training: + inputs["mm_token_type_ids"] = torch.zeros_like(inputs["input_ids"]) outputs = self._old_compute_loss(model, inputs, *args, **kwargs) return outputs diff --git a/unsloth/models/loader.py b/unsloth/models/loader.py index 9b7b1b02c5..297da73183 100644 --- a/unsloth/models/loader.py +++ b/unsloth/models/loader.py @@ -78,6 +78,7 @@ SUPPORTS_QWEN3_MOE = transformers_version >= Version("4.50.3") SUPPORTS_FALCON_H1 = transformers_version >= Version("4.53.0") SUPPORTS_GEMMA3N = transformers_version >= Version("4.53.0") SUPPORTS_GPTOSS = transformers_version >= Version("4.55.0") +SUPPORTS_GEMMA4 = transformers_version >= Version("5.5.0.dev0") # Transformers v5 meta-device loading corrupts non-persistent buffers (inv_freq). # See _fix_rope_inv_freq() below for details. _NEEDS_ROPE_FIX = transformers_version >= Version("5.0.0") @@ -107,6 +108,8 @@ FORCE_FLOAT32 = [ "gemma3n", "gpt_oss", "qwen3_5", # Qwen3.5 GDN layers produce NaN grad norms in float16 training + "gemma4,", # Add comma bc gemma4 will match gemma4_text + "gemma4_text", ] global DISABLE_COMPILE_MODEL_NAMES @@ -1130,6 +1133,17 @@ class FastModel(FastBaseModel): raise RuntimeError( "Unsloth: Qwen 2.5 only works on transformers >= 4.49.0." + LATEST ) + # Gemma 4 must be before Gemma 3N and Gemma 3 + elif "gemma4" in model_types_all: + if not SUPPORTS_GEMMA4: + raise RuntimeError( + "Unsloth: Gemma 4 requires transformers >= 5.5.0" + LATEST + ) + os.environ["UNSLOTH_DISABLE_STATIC_GENERATION"] = "1" + os.environ["UNSLOTH_HIGH_PRECISION_LAYERNORM"] = "1" + # Disable flex_attention for Gemma-4: flex compile overhead is 2.7x slower + # than SDPA. Our attention patch ensures Q/K/V dtype alignment for SDPA. + os.environ["UNSLOTH_ENABLE_FLEX_ATTENTION"] = "0" # Gemma 3N must be before Gemma 3 elif "gemma3n" in model_types_all: if transformers_version < Version("4.53.0"): diff --git a/unsloth/models/vision.py b/unsloth/models/vision.py index ceff1c4393..5abeb3a81a 100644 --- a/unsloth/models/vision.py +++ b/unsloth/models/vision.py @@ -216,6 +216,10 @@ def unsloth_base_fast_generate( kwargs["pixel_values"] = kwargs["pixel_values"].to(dtype) except: pass + try: + kwargs["pixel_values_videos"] = kwargs["pixel_values_videos"].to(dtype) + except: + pass # Mixed precision autocast if os.environ.get("UNSLOTH_FORCE_FLOAT32", "0") == "1": @@ -1029,6 +1033,15 @@ class FastBaseModel: f"Unsloth: Warning - VLM processor fallback returned None for model_type={model_type_arch}", file = sys.stderr, ) + # Backwards compat: if processor has no chat_template (e.g. old saves without + # chat_template.jinja) but the inner tokenizer does, copy it to the processor. + if ( + hasattr(tokenizer, "tokenizer") + and getattr(tokenizer, "chat_template", None) is None + and getattr(tokenizer.tokenizer, "chat_template", None) is not None + ): + tokenizer.chat_template = tokenizer.tokenizer.chat_template + if hasattr(tokenizer, "tokenizer"): __tokenizer = tokenizer.tokenizer # Add padding side as well @@ -1285,7 +1298,59 @@ class FastBaseModel: model, use_gradient_checkpointing = use_gradient_checkpointing, ) + # Gemma4 ClippableLinear wraps nn.Linear -- PEFT can't inject LoRA on it directly. + # Monkey-patch PEFT to target the inner .linear child instead. + _clippable_linear_cls = None + try: + from transformers.models.gemma4.modeling_gemma4 import ( + Gemma4ClippableLinear as _clippable_linear_cls, + ) + except ImportError: + pass + if _clippable_linear_cls is not None: + from peft.tuners.lora.model import LoraModel as _LoraModel + + _original_car = _LoraModel._create_and_replace + + def _patched_car( + self, + peft_config, + adapter_name, + target, + target_name, + parent, + current_key = None, + **kwargs, + ): + if isinstance(target, _clippable_linear_cls): + return _original_car( + self, + peft_config, + adapter_name, + target.linear, + "linear", + target, + current_key = current_key, + **kwargs, + ) + return _original_car( + self, + peft_config, + adapter_name, + target, + target_name, + parent, + current_key = current_key, + **kwargs, + ) + + _LoraModel._create_and_replace = _patched_car + model = _get_peft_model(model, lora_config) + + # Restore original PEFT method + if _clippable_linear_cls is not None: + _LoraModel._create_and_replace = _original_car # Apply QAT + LoRA if specified if qat_scheme is not None: print("Unsloth: Applying QAT to mitigate quantization degradation") @@ -1383,7 +1448,7 @@ class FastBaseModel: # after this point, so we intercept gradient_checkpointing_enable # to always force use_reentrant=True for Gemma3N. _model_type = getattr(getattr(model, "config", None), "model_type", "") or "" - if "gemma3n" in _model_type.lower(): + if "gemma3n" in _model_type.lower() or "gemma4" in _model_type.lower(): _original_gc_enable = model.gradient_checkpointing_enable def _gc_enable_reentrant(**kwargs): diff --git a/unsloth/ollama_template_mappers.py b/unsloth/ollama_template_mappers.py index 1bf77461d9..728b08813a 100644 --- a/unsloth/ollama_template_mappers.py +++ b/unsloth/ollama_template_mappers.py @@ -1199,6 +1199,21 @@ TEMPLATE """{{- range $i, $_ := .Messages }} OLLAMA_TEMPLATES["gemma-3n"] = gemma3n_ollama OLLAMA_TEMPLATES["gemma3n"] = gemma3n_ollama +# =========================================== Gemma-4 +gemma4_ollama = ''' +FROM {__FILE_LOCATION__} +TEMPLATE """{{- range $i, $_ := .Messages }} +{{- $last := eq (len (slice $.Messages $i)) 1 }} +<|turn>{{ .Role }} +{{ .Content }}{{ if not $last }} +{{ end }} +{{- end }} +<|turn>model +""" +''' +OLLAMA_TEMPLATES["gemma-4"] = gemma4_ollama +OLLAMA_TEMPLATES["gemma4"] = gemma4_ollama + # =========================================== GPT-OSS # Ollama from https://ollama.com/library/gpt-oss:latest/blobs/fa6710a93d78 @@ -1961,6 +1976,16 @@ OLLAMA_TEMPLATE_TO_MODEL_MAPPER = { "google/medgemma-27b-text-it", "unsloth/medgemma-27b-text-it-bnb-4bit", ), + "gemma4": ( + "unsloth/gemma-4-E2B-it", + "unsloth/gemma-4-E2B", + "unsloth/gemma-4-E4B-it", + "unsloth/gemma-4-E4B", + "unsloth/gemma-4-31B-it", + "unsloth/gemma-4-31B", + "unsloth/gemma-4-26B-A4B-it", + "unsloth/gemma-4-26B-A4B", + ), "gemma3n": ( "unsloth/gemma-3n-E4B-it-unsloth-bnb-4bit", "unsloth/gemma-3n-E4B-it", From 4f9986ecb991c51b26c206ffcf33f9ff23291d7b Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 2 Apr 2026 08:59:02 -0700 Subject: [PATCH 014/216] fix(studio): improve tool-calling re-prompt for small models (#4783) Small GGUF models (<9B) frequently generate full code or lengthy explanations instead of calling tools, bypassing the existing plan-without-action re-prompt mechanism. Three issues: 1. _REPROMPT_MAX_CHARS=500 was too low -- models that output full HTML/code responses (often 1000+ chars) never triggered the re-prompt at all, since it only fires on short responses. 2. _MAX_REPROMPTS=1 gave the model only one chance to comply. Small models often need 2-3 nudges before switching from text generation to tool calling. 3. The re-prompt text ("Please use the available tools...") was too polite for small models to follow reliably. 4. Tool-calling detection missed chat templates using Jinja whitespace-trimming syntax ({%- if tools -%}) since only ({%- if tools %}) and ({% if tools %}) were checked. Changes: - Raise _REPROMPT_MAX_CHARS from 500 to 2000 so longer responses (code blocks, multi-paragraph plans) still trigger re-prompts - Raise _MAX_REPROMPTS from 1 to 3 for more retry budget - Use direct, imperative re-prompt language that small models follow more reliably ("STOP. You MUST call a tool NOW.") - Strengthen the system prompt tool nudge to explicitly forbid outputting code blocks (redirect to the python tool instead) - Add Jinja whitespace-trimmed variants to the tool_markers list so all template styles are detected correctly --- studio/backend/core/inference/llama_cpp.py | 9 +++++---- studio/backend/routes/inference.py | 4 +++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index b436f4dc6d..44c700bf3d 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -45,8 +45,8 @@ _INTENT_SIGNAL = re.compile( r"\b(?:now i|next i)\b" r")" ) -_MAX_REPROMPTS = 1 -_REPROMPT_MAX_CHARS = 500 +_MAX_REPROMPTS = 3 +_REPROMPT_MAX_CHARS = 2000 # ── Pre-compiled patterns for GGUF shard detection ─────────── _SHARD_FULL_RE = re.compile(r"^(.*)-(\d{5})-of-(\d{5})\.gguf$") @@ -2659,8 +2659,9 @@ class LlamaCppBackend: { "role": "user", "content": ( - "Please use the available tools to complete " - "the task instead of describing what to do." + "STOP. Do NOT write code or explain. " + "You MUST call a tool NOW. " + "Call web_search or python immediately." ), } ) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 8f058bd0f2..ced24c1d5f 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -96,8 +96,10 @@ router = APIRouter() # Appended to tool-use nudge to discourage plan-without-action _TOOL_ACTION_NUDGE = ( - " Always call tools directly." + " IMPORTANT: Always call tools directly -- never write code yourself." " Never describe what you plan to do -- just call the tool immediately." + " For any code request, call the python tool. For any factual question, call web_search." + " Do NOT output code blocks -- use the python tool instead." ) # Regex for stripping leaked tool-call XML from assistant messages/stream From f1c3b9caa94e4eebe0218ee4d80436dd6e9cb92a Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 2 Apr 2026 08:59:21 -0700 Subject: [PATCH 015/216] Pin Gemma-4 transformers requirement to 5.5.0 stable (#4784) Gemma-4 support landed in transformers main (huggingface/transformers#45192). Update the version pin from 5.5.0.dev0 to 5.5.0 across loader, Studio version switcher, and the MLX installer. Also fix install_gemma4_mlx.sh which referenced a non-existent v5.5-release branch -- pin it to the correct commit (91b1ab1) instead. --- install_gemma4_mlx.sh | 4 ++-- studio/backend/utils/transformers_version.py | 2 +- unsloth/models/loader.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/install_gemma4_mlx.sh b/install_gemma4_mlx.sh index 1f5e7c2d9e..71575846a5 100755 --- a/install_gemma4_mlx.sh +++ b/install_gemma4_mlx.sh @@ -132,8 +132,8 @@ step "install" "installing mlx, mlx-lm..." uv pip install --python "$_VENV_PY" -q mlx mlx-lm 2>/dev/null substep "done" -TRANSFORMERS_WHL="transformers-5.5.0.dev0-py3-none-any.whl" -TRANSFORMERS_GH="git+https://github.com/huggingface/transformers.git@v5.5-release" +TRANSFORMERS_WHL="transformers-5.5.0-py3-none-any.whl" +TRANSFORMERS_GH="git+https://github.com/huggingface/transformers.git@91b1ab1fdfa81a552644a92fbe3e8d88de40e167" step "install" "installing transformers>=5.5.0..." if uv pip install --python "$_VENV_PY" -q "$TRANSFORMERS_GH" 2>/dev/null; then diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index 9db4b3ce4b..07e4a5c000 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -60,7 +60,7 @@ _TRANSFORMERS_5_TOKENIZER_CLASSES: set[str] = { _tokenizer_class_cache: dict[str, bool] = {} # Versions -TRANSFORMERS_5_VERSION = "5.5.0.dev0" +TRANSFORMERS_5_VERSION = "5.5.0" TRANSFORMERS_DEFAULT_VERSION = "4.57.6" # Pre-installed directory for transformers 5.x — created by setup.sh / setup.ps1 diff --git a/unsloth/models/loader.py b/unsloth/models/loader.py index 297da73183..a811d3fb75 100644 --- a/unsloth/models/loader.py +++ b/unsloth/models/loader.py @@ -78,7 +78,7 @@ SUPPORTS_QWEN3_MOE = transformers_version >= Version("4.50.3") SUPPORTS_FALCON_H1 = transformers_version >= Version("4.53.0") SUPPORTS_GEMMA3N = transformers_version >= Version("4.53.0") SUPPORTS_GPTOSS = transformers_version >= Version("4.55.0") -SUPPORTS_GEMMA4 = transformers_version >= Version("5.5.0.dev0") +SUPPORTS_GEMMA4 = transformers_version >= Version("5.5.0") # Transformers v5 meta-device loading corrupts non-persistent buffers (inv_freq). # See _fix_rope_inv_freq() below for details. _NEEDS_ROPE_FIX = transformers_version >= Version("5.0.0") From a3535572499194909386318322ce18edabcdf0c1 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 2 Apr 2026 09:03:00 -0700 Subject: [PATCH 016/216] Force llama.cpp to always use mainline ggml-org (#4785) Hardcode the release repo to ggml-org/llama.cpp and remove the UNSLOTH_LLAMA_RELEASE_REPO and UNSLOTH_LLAMA_SOURCE env var overrides so that all users always build/download from mainline llama.cpp. --- studio/install_llama_prebuilt.py | 6 +++--- studio/setup.ps1 | 8 ++++++-- studio/setup.sh | 8 ++++++-- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 76d22984d7..898606514e 100755 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -60,9 +60,9 @@ def env_int(name: str, default: int, *, minimum: int | None = None) -> int: DEFAULT_LLAMA_TAG = os.environ.get("UNSLOTH_LLAMA_TAG", "latest") -DEFAULT_PUBLISHED_REPO = os.environ.get( - "UNSLOTH_LLAMA_RELEASE_REPO", "unslothai/llama.cpp" -) +# Force all installs to use mainline llama.cpp from ggml-org. +# Previously: DEFAULT_PUBLISHED_REPO = os.environ.get("UNSLOTH_LLAMA_RELEASE_REPO", "unslothai/llama.cpp") +DEFAULT_PUBLISHED_REPO = "ggml-org/llama.cpp" DEFAULT_PUBLISHED_TAG = os.environ.get("UNSLOTH_LLAMA_RELEASE_TAG") DEFAULT_PUBLISHED_MANIFEST_ASSET = os.environ.get( "UNSLOTH_LLAMA_RELEASE_MANIFEST_ASSET", "llama-prebuilt-manifest.json" diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 1784b66ee9..5e29d1876a 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -1598,11 +1598,15 @@ $LlamaCppDir = Join-Path $UnslothHome "llama.cpp" $NeedLlamaSourceBuild = $false $SkipPrebuiltInstall = $false $RequestedLlamaTag = if ($env:UNSLOTH_LLAMA_TAG) { $env:UNSLOTH_LLAMA_TAG } else { "latest" } -$HelperReleaseRepo = if ($env:UNSLOTH_LLAMA_RELEASE_REPO) { $env:UNSLOTH_LLAMA_RELEASE_REPO } else { "unslothai/llama.cpp" } +# Force all installs to use mainline llama.cpp from ggml-org. +# Previously: $HelperReleaseRepo = if ($env:UNSLOTH_LLAMA_RELEASE_REPO) { $env:UNSLOTH_LLAMA_RELEASE_REPO } else { "unslothai/llama.cpp" } +$HelperReleaseRepo = "ggml-org/llama.cpp" $LlamaPr = if ($env:UNSLOTH_LLAMA_PR) { $env:UNSLOTH_LLAMA_PR.Trim() } else { "" } $LlamaPrForce = if ($env:UNSLOTH_LLAMA_PR_FORCE) { $env:UNSLOTH_LLAMA_PR_FORCE.Trim() } else { $DefaultLlamaPrForce } -$LlamaSource = if ($env:UNSLOTH_LLAMA_SOURCE) { $env:UNSLOTH_LLAMA_SOURCE.Trim() } else { $DefaultLlamaSource } +# Force mainline source -- no env var override. +# Previously: $LlamaSource = if ($env:UNSLOTH_LLAMA_SOURCE) { $env:UNSLOTH_LLAMA_SOURCE.Trim() } else { $DefaultLlamaSource } +$LlamaSource = $DefaultLlamaSource if ($LlamaSource.EndsWith('.git')) { $LlamaSource = $LlamaSource.Substring(0, $LlamaSource.Length - 4) } $ResolvedSourceUrl = $LlamaSource $ResolvedSourceRef = $RequestedLlamaTag diff --git a/studio/setup.sh b/studio/setup.sh index 26903b120a..39b186ab09 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -479,11 +479,15 @@ _NEED_LLAMA_SOURCE_BUILD=false _LLAMA_CPP_DEGRADED=false _LLAMA_FORCE_COMPILE="${UNSLOTH_LLAMA_FORCE_COMPILE:-0}" _REQUESTED_LLAMA_TAG="${UNSLOTH_LLAMA_TAG:-latest}" -_HELPER_RELEASE_REPO="${UNSLOTH_LLAMA_RELEASE_REPO:-unslothai/llama.cpp}" +# Force all installs to use mainline llama.cpp from ggml-org. +# Previously: _HELPER_RELEASE_REPO="${UNSLOTH_LLAMA_RELEASE_REPO:-unslothai/llama.cpp}" +_HELPER_RELEASE_REPO="ggml-org/llama.cpp" _LLAMA_PR="${UNSLOTH_LLAMA_PR:-}" _LLAMA_PR_FORCE="${UNSLOTH_LLAMA_PR_FORCE:-${_DEFAULT_LLAMA_PR_FORCE}}" -_LLAMA_SOURCE="${UNSLOTH_LLAMA_SOURCE:-${_DEFAULT_LLAMA_SOURCE}}" +# Force mainline source -- no env var override. +# Previously: _LLAMA_SOURCE="${UNSLOTH_LLAMA_SOURCE:-${_DEFAULT_LLAMA_SOURCE}}" +_LLAMA_SOURCE="${_DEFAULT_LLAMA_SOURCE}" _LLAMA_SOURCE="${_LLAMA_SOURCE%.git}" # normalize: strip trailing .git _RESOLVED_SOURCE_URL="$_LLAMA_SOURCE" _RESOLVED_SOURCE_REF="$_REQUESTED_LLAMA_TAG" From a241c58d841fed0c697e0239fce863a87423c6d2 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 2 Apr 2026 09:10:02 -0700 Subject: [PATCH 017/216] Use transformers v5.5-release branch and pin to 5.5.0 (#4786) The v5.5-release branch now exists on huggingface/transformers. Use transformers==5.5.0 for all install paths and git+transformers.git@v5.5-release for the MLX installer. Also bumps huggingface_hub from 1.7.1 to 1.8.0 in setup.sh and setup.ps1 to stay consistent. --- install_gemma4_mlx.sh | 2 +- studio/setup.ps1 | 2 +- studio/setup.sh | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/install_gemma4_mlx.sh b/install_gemma4_mlx.sh index 71575846a5..128b0d5bca 100755 --- a/install_gemma4_mlx.sh +++ b/install_gemma4_mlx.sh @@ -133,7 +133,7 @@ uv pip install --python "$_VENV_PY" -q mlx mlx-lm 2>/dev/null substep "done" TRANSFORMERS_WHL="transformers-5.5.0-py3-none-any.whl" -TRANSFORMERS_GH="git+https://github.com/huggingface/transformers.git@91b1ab1fdfa81a552644a92fbe3e8d88de40e167" +TRANSFORMERS_GH="git+https://github.com/huggingface/transformers.git@v5.5-release" step "install" "installing transformers>=5.5.0..." if uv pip install --python "$_VENV_PY" -q "$TRANSFORMERS_GH" 2>/dev/null; then diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 5e29d1876a..130b66c7df 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -1551,7 +1551,7 @@ if (Test-Path $VenvT5Dir) { Remove-Item -Recurse -Force $VenvT5Dir } New-Item -ItemType Directory -Path $VenvT5Dir -Force | Out-Null $prevEAP_t5 = $ErrorActionPreference $ErrorActionPreference = "Continue" -foreach ($pkg in @("transformers==5.3.0", "huggingface_hub==1.7.1", "hf_xet==1.4.2")) { +foreach ($pkg in @("transformers==5.5.0", "huggingface_hub==1.8.0", "hf_xet==1.4.2")) { if ($script:UnslothVerbose) { Fast-Install --target $VenvT5Dir --no-deps $pkg $t5PkgExit = $LASTEXITCODE diff --git a/studio/setup.sh b/studio/setup.sh index 39b186ab09..9096c3bb19 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -459,8 +459,8 @@ if [ "$_SKIP_PYTHON_DEPS" = false ]; then # at runtime (slow, ~10-15s), we pre-install into a separate directory. # The training subprocess just prepends .venv_t5/ to sys.path -- instant switch. mkdir -p "$VENV_T5_DIR" - run_quiet "install transformers 5.x" fast_install --target "$VENV_T5_DIR" --no-deps "transformers==5.3.0" - run_quiet "install huggingface_hub for t5" fast_install --target "$VENV_T5_DIR" --no-deps "huggingface_hub==1.7.1" + run_quiet "install transformers 5.x" fast_install --target "$VENV_T5_DIR" --no-deps "transformers==5.5.0" + run_quiet "install huggingface_hub for t5" fast_install --target "$VENV_T5_DIR" --no-deps "huggingface_hub==1.8.0" run_quiet "install hf_xet for t5" fast_install --target "$VENV_T5_DIR" --no-deps "hf_xet==1.4.2" run_quiet "install tiktoken for t5" fast_install --target "$VENV_T5_DIR" "tiktoken" step "transformers" "5.x pre-installed" From 2af53bf9a65d1b96a8e10c8d67feaefd1a2c67d7 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 2 Apr 2026 09:21:30 -0700 Subject: [PATCH 018/216] Pin transformers and huggingface-hub in main Studio venv (#4788) Revert the >= loosening from f9c4b08 back to exact pins. Using transformers>=4.57.6 allows pip to install 5.x into the main Studio venv, which breaks huggingface_hub imports (is_offline_mode removed in newer hub versions). The main venv must stay on transformers==4.57.6 and huggingface-hub==0.36.2. The 5.x version lives only in .venv_t5/ and is dynamically switched via sys.path at runtime. --- studio/backend/requirements/extras-no-deps.txt | 2 +- studio/backend/requirements/single-env/constraints.txt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/studio/backend/requirements/extras-no-deps.txt b/studio/backend/requirements/extras-no-deps.txt index 325eb494fc..9934bacd24 100644 --- a/studio/backend/requirements/extras-no-deps.txt +++ b/studio/backend/requirements/extras-no-deps.txt @@ -11,6 +11,6 @@ git+https://github.com/meta-pytorch/OpenEnv.git # executorch>=1.0.1 # 41.5 MB - no imports in unsloth/zoo/studio torch-c-dlpack-ext sentence_transformers==5.2.0 -transformers>=4.57.6 +transformers==4.57.6 pytorch_tokenizers kernels diff --git a/studio/backend/requirements/single-env/constraints.txt b/studio/backend/requirements/single-env/constraints.txt index 37391e739e..156f78567e 100644 --- a/studio/backend/requirements/single-env/constraints.txt +++ b/studio/backend/requirements/single-env/constraints.txt @@ -1,8 +1,8 @@ # Single-env pins for unsloth + studio + data-designer # Keep compatible with unsloth transformers bounds. -transformers>=4.57.6 +transformers==4.57.6 trl==0.23.1 -huggingface-hub>=0.36.2 +huggingface-hub==0.36.2 # Studio stack datasets==4.3.0 From 1ce83c40aa6087445703d009d904d793b76b2c30 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 2 Apr 2026 09:45:56 -0700 Subject: [PATCH 019/216] fix(studio): build llama.cpp from master instead of latest release tag (#4790) The latest ggml-org/llama.cpp release (b8635) does not include Gemma 4 support (ggml-org/llama.cpp#21309 merged after the release was cut). This causes `llama-server` to fail with "unknown model architecture: gemma4" when loading Gemma 4 GGUFs. Temporarily default _DEFAULT_LLAMA_TAG to "master" so all new installs build from the llama.cpp master branch which includes Gemma 4 support. Once a new upstream release is cut with Gemma 4, this can be reverted back to "latest". Changes: - setup.sh: add _DEFAULT_LLAMA_TAG="master" maintainer default - setup.ps1: add $DefaultLlamaTag="master" maintainer default - install_llama_prebuilt.py: change DEFAULT_LLAMA_TAG fallback to "master" Users can still override via UNSLOTH_LLAMA_TAG env var. --- studio/install_llama_prebuilt.py | 2 +- studio/setup.ps1 | 3 ++- studio/setup.sh | 5 ++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 898606514e..61e57614a8 100755 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -59,7 +59,7 @@ def env_int(name: str, default: int, *, minimum: int | None = None) -> int: return value -DEFAULT_LLAMA_TAG = os.environ.get("UNSLOTH_LLAMA_TAG", "latest") +DEFAULT_LLAMA_TAG = os.environ.get("UNSLOTH_LLAMA_TAG", "master") # Force all installs to use mainline llama.cpp from ggml-org. # Previously: DEFAULT_PUBLISHED_REPO = os.environ.get("UNSLOTH_LLAMA_RELEASE_REPO", "unslothai/llama.cpp") DEFAULT_PUBLISHED_REPO = "ggml-org/llama.cpp" diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 130b66c7df..396a04486f 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -29,6 +29,7 @@ $PackageDir = Split-Path -Parent $ScriptDir # -------------------------------------------------------------------------- $DefaultLlamaPrForce = "" $DefaultLlamaSource = "https://github.com/ggml-org/llama.cpp" +$DefaultLlamaTag = "master" # Verbose can be enabled either by CLI flag or by UNSLOTH_VERBOSE=1. $script:UnslothVerbose = ($env:UNSLOTH_VERBOSE -eq '1') @@ -1597,7 +1598,7 @@ if (-not (Test-Path $UnslothHome)) { New-Item -ItemType Directory -Force $Unslot $LlamaCppDir = Join-Path $UnslothHome "llama.cpp" $NeedLlamaSourceBuild = $false $SkipPrebuiltInstall = $false -$RequestedLlamaTag = if ($env:UNSLOTH_LLAMA_TAG) { $env:UNSLOTH_LLAMA_TAG } else { "latest" } +$RequestedLlamaTag = if ($env:UNSLOTH_LLAMA_TAG) { $env:UNSLOTH_LLAMA_TAG } else { $DefaultLlamaTag } # Force all installs to use mainline llama.cpp from ggml-org. # Previously: $HelperReleaseRepo = if ($env:UNSLOTH_LLAMA_RELEASE_REPO) { $env:UNSLOTH_LLAMA_RELEASE_REPO } else { "unslothai/llama.cpp" } $HelperReleaseRepo = "ggml-org/llama.cpp" diff --git a/studio/setup.sh b/studio/setup.sh index 9096c3bb19..116dc12e08 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -14,9 +14,12 @@ RULE=$(printf '\342\224\200%.0s' {1..52}) # # _DEFAULT_LLAMA_PR_FORCE : PR number to build by default ("" = normal path) # _DEFAULT_LLAMA_SOURCE : git clone URL for source builds +# _DEFAULT_LLAMA_TAG : llama.cpp ref to build ("latest" = newest release, +# "master" = bleeding-edge, "bNNNN" = specific tag) # ────────────────────────────────────────────────────────────────────────── _DEFAULT_LLAMA_PR_FORCE="" _DEFAULT_LLAMA_SOURCE="https://github.com/ggml-org/llama.cpp" +_DEFAULT_LLAMA_TAG="master" # ── Colors (same palette as startup_banner / install_python_stack) ── if [ -n "${NO_COLOR:-}" ]; then @@ -478,7 +481,7 @@ LLAMA_SERVER_BIN="$LLAMA_CPP_DIR/build/bin/llama-server" _NEED_LLAMA_SOURCE_BUILD=false _LLAMA_CPP_DEGRADED=false _LLAMA_FORCE_COMPILE="${UNSLOTH_LLAMA_FORCE_COMPILE:-0}" -_REQUESTED_LLAMA_TAG="${UNSLOTH_LLAMA_TAG:-latest}" +_REQUESTED_LLAMA_TAG="${UNSLOTH_LLAMA_TAG:-${_DEFAULT_LLAMA_TAG}}" # Force all installs to use mainline llama.cpp from ggml-org. # Previously: _HELPER_RELEASE_REPO="${UNSLOTH_LLAMA_RELEASE_REPO:-unslothai/llama.cpp}" _HELPER_RELEASE_REPO="ggml-org/llama.cpp" From 0446d4668957827e7af1aa8f6719df720d9b6457 Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:04:42 +0400 Subject: [PATCH 020/216] fixed name (#4791) --- install_gemma4_mlx.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/install_gemma4_mlx.sh b/install_gemma4_mlx.sh index 128b0d5bca..e06339e204 100755 --- a/install_gemma4_mlx.sh +++ b/install_gemma4_mlx.sh @@ -198,15 +198,15 @@ echo "" printf " ${C_TITLE}%s${C_RST}\n" "Gemma 4 MLX installed!" printf " ${C_DIM}%s${C_RST}\n" "$RULE" echo "" -step "available models" "unsloth/gemma-4-E2B-it-MLX-4bit (/BF16)" -substep "unsloth/gemma-4-E4B-it-MLX-4bit (/BF16)" +step "available models" "unsloth/gemma-4-E2B-it-UD-MLX-4bit (/BF16)" +substep "unsloth/gemma-4-E4B-it-UD-MLX-4bit (/BF16)" echo "" step "venv activate" "source ${VENV_DIR}/bin/activate" echo "" -step "quick start" "python -m mlx_lm chat --model unsloth/gemma-4-E2B-it-MLX-4bit --max-tokens 200" +step "quick start" "python -m mlx_lm chat --model unsloth/gemma-4-E2B-it-UD-MLX-4bit --max-tokens 200" echo "" step "python API" "from mlx_lm import load, generate" -substep "model, tokenizer = load('unsloth/gemma-4-E2B-it-MLX-4bit')" +substep "model, tokenizer = load('unsloth/gemma-4-E2B-it-UD-MLX-4bit')" substep "messages = [{'role': 'user', 'content': 'Hello!'}]" substep "prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)" substep "print(generate(model, tokenizer, prompt=prompt, max_tokens=200))" From 7023e2a4ff0a8fa968aed2f55a0abd81a28feb55 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 2 Apr 2026 10:46:53 -0700 Subject: [PATCH 021/216] fix(studio): prioritize curated defaults over HF download ranking in Recommended (#4792) The model list merge order was `top_gguf + top_hub + static_models`, which meant the HF download-ranked models always came first. New models like Gemma 4 have low download counts and were not in the HF top-40, so they got buried after 80 other models despite being at the top of the curated static defaults in defaults.py. Flip the merge to `static_models + top_gguf + top_hub` so editorial picks (new model launches, promoted models) always appear first in the Recommended section, with HF popularity backfilling after. Co-authored-by: Daniel Han --- studio/backend/core/inference/orchestrator.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/studio/backend/core/inference/orchestrator.py b/studio/backend/core/inference/orchestrator.py index f293a0dcd8..cb5d9da34a 100644 --- a/studio/backend/core/inference/orchestrator.py +++ b/studio/backend/core/inference/orchestrator.py @@ -109,12 +109,13 @@ class InferenceOrchestrator: self._top_models_ready.wait(timeout = 5) top_gguf = self._top_gguf_cache or [] top_hub = self._top_hub_cache or [] - # GGUFs first, then hub models, then static fallbacks. + # Curated static defaults first (editorial picks like new models), + # then HF download-ranked models to backfill. # Send extras so the frontend still has 4 per category # after removing already-downloaded models. result: list[str] = [] seen: set[str] = set() - for m in top_gguf + top_hub + self._static_models: + for m in self._static_models + top_gguf + top_hub: if m not in seen: result.append(m) seen.add(m) From 7ae9b7f45f966abf5a8dde752509015e5ace3931 Mon Sep 17 00:00:00 2001 From: DoubleMathew Date: Thu, 2 Apr 2026 13:43:46 -0500 Subject: [PATCH 022/216] fix windows llama.cpp compile from source issue (#4793) * fix windows llama.cpp compile from source issue * undo local repo usage * fix llama.cpp install * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix windows * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: route resolve-source-build call through Invoke-LlamaHelper The --resolve-source-build call at the source-build resolution path was still calling install_llama_prebuilt.py directly instead of going through Invoke-LlamaHelper. On PS7+ with ErrorActionPreference=Stop, stderr from the 422 response (when tag is "master") would trigger a terminating NativeCommandError and crash setup. * fix: suppress stderr error records from Invoke-LlamaHelper ErrorActionPreference=Continue prevents termination but PowerShell still displays stderr lines as visible ErrorRecord objects. Capture all output via 2>&1 and split stdout from stderr manually so that stderr lines never appear on the console. When StderrPath is given the stderr content is written to that file for diagnostics. * fix: always rebuild llama.cpp on Windows when tag is master When the requested llama.cpp tag is "master" (a moving target), skip the "already built" early exit so the build path runs and syncs to the latest commit. Without this, existing llama-server binaries from an older build (e.g. b8635 which lacks Gemma 4 support) are reused and model loading fails. Pinned tags (e.g. b8635) still skip the rebuild when the binary already exists, since the tag is immutable. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han --- studio/install_llama_prebuilt.py | 46 ++++++- studio/setup.ps1 | 125 +++++++++++++---- studio/setup.sh | 13 +- .../install/test_llama_pr_force_and_source.py | 129 +++++++++--------- tests/studio/install/test_pr4562_bugfixes.py | 28 ++++ tests/studio/install/test_selection_logic.py | 50 +++++++ 6 files changed, 296 insertions(+), 95 deletions(-) diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 61e57614a8..13382d3c16 100755 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -14,6 +14,7 @@ import json import os import platform import random +import re import shutil import site import socket @@ -561,6 +562,33 @@ def windows_cuda_upstream_asset_names(llama_tag: str, runtime: str) -> list[str] ] +def windows_cuda_asset_aliases( + asset_name: str, + *, + compatibility_tag: str | None = None, +) -> list[str]: + aliases: list[str] = [] + legacy_match = re.fullmatch( + r"llama-(?P[^/]+)-bin-win-cuda-(?P\d+\.\d+)-x64\.zip", + asset_name, + ) + if legacy_match: + runtime = legacy_match.group("runtime") + aliases.append(f"cudart-llama-bin-win-cuda-{runtime}-x64.zip") + if compatibility_tag: + aliases.append(f"llama-{compatibility_tag}-bin-win-cuda-{runtime}-x64.zip") + return aliases + + current_match = re.fullmatch( + r"cudart-llama-bin-win-cuda-(?P\d+\.\d+)-x64\.zip", + asset_name, + ) + if current_match and compatibility_tag: + runtime = current_match.group("runtime") + aliases.append(f"llama-{compatibility_tag}-bin-win-cuda-{runtime}-x64.zip") + return aliases + + def format_byte_count(num_bytes: float) -> str: units = ["B", "KiB", "MiB", "GiB", "TiB"] value = float(num_bytes) @@ -3867,9 +3895,7 @@ def apply_approved_hashes( checksums: ApprovedReleaseChecksums, ) -> list[AssetChoice]: def approved_hash_for_attempt(attempt: AssetChoice) -> ApprovedArtifactHash | None: - approved = checksums.artifacts.get(attempt.name) - if approved is not None: - return approved + candidate_names = [attempt.name] if ( isinstance(attempt.tag, str) and attempt.tag @@ -3883,7 +3909,19 @@ def apply_approved_hashes( if attempt.name.startswith(legacy_prefix) else attempt.name ) - approved = checksums.artifacts.get(compatibility_name) + candidate_names.append(compatibility_name) + candidate_names.extend( + windows_cuda_asset_aliases( + attempt.name, + compatibility_tag = checksums.upstream_tag, + ) + ) + seen_names: set[str] = set() + for candidate_name in candidate_names: + if candidate_name in seen_names: + continue + seen_names.add(candidate_name) + approved = checksums.artifacts.get(candidate_name) if approved is not None: return approved return None diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 396a04486f..382de89b9f 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -71,6 +71,12 @@ function Refresh-Environment { $env:Path = "$machinePath;$userPath" } +# PowerShell 5.1 compatibility helper: avoid relying on New-TemporaryFile. +function New-UnslothTemporaryFile { + $tempPath = [System.IO.Path]::GetTempFileName() + return Get-Item -LiteralPath $tempPath +} + # Find nvcc on PATH, CUDA_PATH, or standard toolkit dirs. # Returns the path to nvcc.exe, or $null if not found. function Find-Nvcc { @@ -1599,20 +1605,67 @@ $LlamaCppDir = Join-Path $UnslothHome "llama.cpp" $NeedLlamaSourceBuild = $false $SkipPrebuiltInstall = $false $RequestedLlamaTag = if ($env:UNSLOTH_LLAMA_TAG) { $env:UNSLOTH_LLAMA_TAG } else { $DefaultLlamaTag } -# Force all installs to use mainline llama.cpp from ggml-org. -# Previously: $HelperReleaseRepo = if ($env:UNSLOTH_LLAMA_RELEASE_REPO) { $env:UNSLOTH_LLAMA_RELEASE_REPO } else { "unslothai/llama.cpp" } $HelperReleaseRepo = "ggml-org/llama.cpp" $LlamaPr = if ($env:UNSLOTH_LLAMA_PR) { $env:UNSLOTH_LLAMA_PR.Trim() } else { "" } $LlamaPrForce = if ($env:UNSLOTH_LLAMA_PR_FORCE) { $env:UNSLOTH_LLAMA_PR_FORCE.Trim() } else { $DefaultLlamaPrForce } -# Force mainline source -- no env var override. -# Previously: $LlamaSource = if ($env:UNSLOTH_LLAMA_SOURCE) { $env:UNSLOTH_LLAMA_SOURCE.Trim() } else { $DefaultLlamaSource } $LlamaSource = $DefaultLlamaSource if ($LlamaSource.EndsWith('.git')) { $LlamaSource = $LlamaSource.Substring(0, $LlamaSource.Length - 4) } $ResolvedSourceUrl = $LlamaSource $ResolvedSourceRef = $RequestedLlamaTag $ResolvedSourceRefKind = "tag" +if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") { + $NeedLlamaSourceBuild = $true + $SkipPrebuiltInstall = $true +} + +function Invoke-LlamaHelper { + param( + [string[]]$Arguments, + [string]$StderrPath = $null + ) + + $previousErrorActionPreference = $ErrorActionPreference + $previousNativeErrorPreference = $null + $restoreNativeErrorPreference = $false + $ErrorActionPreference = "Continue" + if ($PSVersionTable.PSVersion.Major -ge 7) { + $previousNativeErrorPreference = $PSNativeCommandUseErrorActionPreference + $PSNativeCommandUseErrorActionPreference = $false + $restoreNativeErrorPreference = $true + } + + try { + # Capture all output (stdout + stderr) so that PowerShell does not + # convert stderr lines into visible ErrorRecord objects. Separate + # stdout from stderr afterwards. + $allOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" @Arguments 2>&1 + $exitCode = $LASTEXITCODE + $stdoutLines = @() + $stderrLines = @() + foreach ($line in $allOutput) { + if ($line -is [System.Management.Automation.ErrorRecord]) { + $stderrLines += $line.ToString() + } else { + $stdoutLines += $line + } + } + if ($StderrPath -and $stderrLines.Count -gt 0) { + $stderrLines | Out-File -FilePath $StderrPath -Encoding utf8 + } + return [pscustomobject]@{ + Output = $stdoutLines + ExitCode = $exitCode + } + } finally { + $ErrorActionPreference = $previousErrorActionPreference + if ($restoreNativeErrorPreference) { + $PSNativeCommandUseErrorActionPreference = $previousNativeErrorPreference + } + } +} + if ($LlamaSource -ne "https://github.com/ggml-org/llama.cpp") { step "llama.cpp" "custom source: $LlamaSource -- forcing source build" "Yellow" $NeedLlamaSourceBuild = $true @@ -1640,11 +1693,14 @@ if ($LlamaPr) { # Custom source or other override already forced source build; skip the # prebuilt release resolution. When building from a custom fork, the fork # may not carry upstream bNNNN tags. - if ($LlamaSource -eq "https://github.com/ggml-org/llama.cpp") { + if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") { + $ResolvedLlamaTag = $RequestedLlamaTag + } elseif ($LlamaSource -eq "https://github.com/ggml-org/llama.cpp") { $resolveTagArgs = @("--resolve-llama-tag", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo, "--output-format", "json") if ($env:UNSLOTH_LLAMA_RELEASE_TAG) { $resolveTagArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG) } - $fallbackOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" @resolveTagArgs 2>$null - $fallbackExit = $LASTEXITCODE + $fallbackResult = Invoke-LlamaHelper -Arguments $resolveTagArgs + $fallbackOutput = $fallbackResult.Output + $fallbackExit = $fallbackResult.ExitCode $ResolvedLlamaTag = if ($fallbackExit -eq 0 -and $fallbackOutput) { try { (($fallbackOutput | Out-String) | ConvertFrom-Json).llama_tag @@ -1660,9 +1716,10 @@ if ($LlamaPr) { } else { $resolveInstallArgs = @("--resolve-install-tag", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo, "--output-format", "json") if ($env:UNSLOTH_LLAMA_RELEASE_TAG) { $resolveInstallArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG) } - $resolveErrorLog = New-TemporaryFile - $resolveOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" @resolveInstallArgs 2>$resolveErrorLog - $resolveExit = $LASTEXITCODE + $resolveErrorLog = New-UnslothTemporaryFile + $resolveResult = Invoke-LlamaHelper -Arguments $resolveInstallArgs -StderrPath $resolveErrorLog + $resolveOutput = $resolveResult.Output + $resolveExit = $resolveResult.ExitCode $ResolvedLlamaTag = if ($resolveOutput) { try { (($resolveOutput | Out-String) | ConvertFrom-Json).llama_tag @@ -1679,8 +1736,9 @@ if ($LlamaPr) { # before falling back to the bleeding-edge ggml-org/llama.cpp tag. $resolveFallbackArgs = @("--resolve-llama-tag", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo, "--output-format", "json") if ($env:UNSLOTH_LLAMA_RELEASE_TAG) { $resolveFallbackArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG) } - $fallbackOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" @resolveFallbackArgs 2>$null - $fallbackExit = $LASTEXITCODE + $fallbackResult = Invoke-LlamaHelper -Arguments $resolveFallbackArgs + $fallbackOutput = $fallbackResult.Output + $fallbackExit = $fallbackResult.ExitCode $ResolvedLlamaTag = if ($fallbackExit -eq 0 -and $fallbackOutput) { try { (($fallbackOutput | Out-String) | ConvertFrom-Json).llama_tag @@ -1723,16 +1781,29 @@ if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") { } $prevEAPPrebuilt = $ErrorActionPreference $ErrorActionPreference = "Continue" - if ($script:UnslothVerbose) { - # Show live output in verbose mode while still capturing for error log - $prebuiltLog = Join-Path $env:TEMP "unsloth-prebuilt-$PID.log" - & python @prebuiltArgs 2>&1 | Tee-Object -FilePath $prebuiltLog | Out-Host - $prebuiltExit = $LASTEXITCODE - $prebuiltOutput = if (Test-Path $prebuiltLog) { Get-Content $prebuiltLog -Raw } else { "" } - Remove-Item $prebuiltLog -ErrorAction SilentlyContinue - } else { - $prebuiltOutput = & python @prebuiltArgs 2>&1 | Out-String - $prebuiltExit = $LASTEXITCODE + $previousNativeErrorPreference = $null + $restoreNativeErrorPreference = $false + if ($PSVersionTable.PSVersion.Major -ge 7) { + $previousNativeErrorPreference = $PSNativeCommandUseErrorActionPreference + $PSNativeCommandUseErrorActionPreference = $false + $restoreNativeErrorPreference = $true + } + try { + if ($script:UnslothVerbose) { + # Show live output in verbose mode while still capturing for error log + $prebuiltLog = Join-Path $env:TEMP "unsloth-prebuilt-$PID.log" + & python @prebuiltArgs 2>&1 | Tee-Object -FilePath $prebuiltLog | Out-Host + $prebuiltExit = $LASTEXITCODE + $prebuiltOutput = if (Test-Path $prebuiltLog) { Get-Content $prebuiltLog -Raw } else { "" } + Remove-Item $prebuiltLog -ErrorAction SilentlyContinue + } else { + $prebuiltOutput = & python @prebuiltArgs 2>&1 | Out-String + $prebuiltExit = $LASTEXITCODE + } + } finally { + if ($restoreNativeErrorPreference) { + $PSNativeCommandUseErrorActionPreference = $previousNativeErrorPreference + } } $ErrorActionPreference = $prevEAPPrebuilt @@ -1847,7 +1918,10 @@ if (Test-Path $LlamaServerBin) { if (-not $NeedLlamaSourceBuild) { Write-Host "" step "llama.cpp" "prebuilt (validated)" -} elseif ((Test-Path $LlamaServerBin) -and -not $NeedRebuild) { +} elseif ((Test-Path $LlamaServerBin) -and -not $NeedRebuild -and $RequestedLlamaTag -ne "master") { + # Skip rebuild only for pinned tags (e.g. b8635). When the requested + # tag is "master" (a moving target), always rebuild so the binary picks + # up new model architecture support (e.g. Gemma 4). Write-Host "" step "llama.cpp" "already built" } elseif (-not $HasCmakeForBuild) { @@ -1906,8 +1980,9 @@ if (-not $NeedLlamaSourceBuild) { if ($LlamaSource -eq "https://github.com/ggml-org/llama.cpp") { $resolveSourceArgs = @("--resolve-source-build", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo, "--output-format", "json") if ($env:UNSLOTH_LLAMA_RELEASE_TAG) { $resolveSourceArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG) } - $sourcePlanOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" @resolveSourceArgs 2>$null - $sourcePlanExit = $LASTEXITCODE + $sourcePlanResult = Invoke-LlamaHelper -Arguments $resolveSourceArgs + $sourcePlanOutput = $sourcePlanResult.Output + $sourcePlanExit = $sourcePlanResult.ExitCode if ($sourcePlanExit -eq 0 -and $sourcePlanOutput) { try { $sourcePlan = ($sourcePlanOutput | Out-String) | ConvertFrom-Json diff --git a/studio/setup.sh b/studio/setup.sh index 116dc12e08..76fd79e7a2 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -483,19 +483,22 @@ _LLAMA_CPP_DEGRADED=false _LLAMA_FORCE_COMPILE="${UNSLOTH_LLAMA_FORCE_COMPILE:-0}" _REQUESTED_LLAMA_TAG="${UNSLOTH_LLAMA_TAG:-${_DEFAULT_LLAMA_TAG}}" # Force all installs to use mainline llama.cpp from ggml-org. -# Previously: _HELPER_RELEASE_REPO="${UNSLOTH_LLAMA_RELEASE_REPO:-unslothai/llama.cpp}" _HELPER_RELEASE_REPO="ggml-org/llama.cpp" _LLAMA_PR="${UNSLOTH_LLAMA_PR:-}" _LLAMA_PR_FORCE="${UNSLOTH_LLAMA_PR_FORCE:-${_DEFAULT_LLAMA_PR_FORCE}}" -# Force mainline source -- no env var override. -# Previously: _LLAMA_SOURCE="${UNSLOTH_LLAMA_SOURCE:-${_DEFAULT_LLAMA_SOURCE}}" +# Force mainline source -- no env var override for now. _LLAMA_SOURCE="${_DEFAULT_LLAMA_SOURCE}" _LLAMA_SOURCE="${_LLAMA_SOURCE%.git}" # normalize: strip trailing .git _RESOLVED_SOURCE_URL="$_LLAMA_SOURCE" _RESOLVED_SOURCE_REF="$_REQUESTED_LLAMA_TAG" _RESOLVED_SOURCE_REF_KIND="tag" +if [ "$_LLAMA_FORCE_COMPILE" = "1" ]; then + _NEED_LLAMA_SOURCE_BUILD=true + _SKIP_PREBUILT_INSTALL=true +fi + # Non-default source URL forces source build (fork has different code than prebuilt). if [ "$_LLAMA_SOURCE" != "https://github.com/ggml-org/llama.cpp" ]; then step "llama.cpp" "custom source: $_LLAMA_SOURCE -- forcing source build" "$C_WARN" @@ -527,7 +530,9 @@ elif [ "${_SKIP_PREBUILT_INSTALL:-false}" = true ]; then # the prebuilt release resolution entirely. When building from a custom # fork, the fork may not carry upstream bNNNN tags, so resolve the tag # only when the source is the default ggml-org repo. - if [ "$_LLAMA_SOURCE" = "https://github.com/ggml-org/llama.cpp" ]; then + if [ "$_LLAMA_FORCE_COMPILE" = "1" ]; then + _RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG" + elif [ "$_LLAMA_SOURCE" = "https://github.com/ggml-org/llama.cpp" ]; then _RESOLVE_TAG_ARGS=(--resolve-llama-tag "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO") _RESOLVE_TAG_ARGS+=(--output-format json) if [ -n "${UNSLOTH_LLAMA_RELEASE_TAG:-}" ]; then diff --git a/tests/studio/install/test_llama_pr_force_and_source.py b/tests/studio/install/test_llama_pr_force_and_source.py index 44cbda4c71..114680f458 100644 --- a/tests/studio/install/test_llama_pr_force_and_source.py +++ b/tests/studio/install/test_llama_pr_force_and_source.py @@ -1,11 +1,11 @@ """ -Tests for UNSLOTH_LLAMA_PR_FORCE and UNSLOTH_LLAMA_SOURCE in setup.sh / setup.ps1. +Tests for the current llama.cpp wrapper policy in setup.sh / setup.ps1. Tests cover: - Bash subprocess: PR_FORCE promotion, user-override, zero/empty/invalid ignored - - Bash subprocess: custom source URL forces build, clone URL uses variable - - Static source checks: defaults present, clone URLs parameterized - - PowerShell subprocess: PR_FORCE promotion, user-override parity + - Bash subprocess: source remains pinned to ggml-org even if env source is set + - Static source checks: mainline repo/source are hardcoded for now + - PowerShell subprocess: PR_FORCE promotion and fixed-source parity Run: pytest tests/studio/install/test_llama_pr_force_and_source.py -v """ @@ -109,7 +109,7 @@ def make_mock_git(tmp_path: Path, *, fail_on: str = "") -> tuple[Path, Path]: # ========================================================================= -# Bash fragment that exercises PR_FORCE and _LLAMA_SOURCE resolution +# Bash fragment that exercises PR_FORCE and fixed _LLAMA_SOURCE resolution # ========================================================================= def _bash_resolution_fragment( llama_pr: str = "", @@ -125,7 +125,8 @@ def _bash_resolution_fragment( _DEFAULT_LLAMA_SOURCE={shlex.quote(default_source)} _LLAMA_PR_FORCE={shlex.quote(llama_pr_force) if llama_pr_force else '"$_DEFAULT_LLAMA_PR_FORCE"'} - _LLAMA_SOURCE={shlex.quote(llama_source) if llama_source else '"$_DEFAULT_LLAMA_SOURCE"'} + export UNSLOTH_LLAMA_SOURCE={shlex.quote(llama_source) if llama_source else '""'} + _LLAMA_SOURCE="$_DEFAULT_LLAMA_SOURCE" _LLAMA_SOURCE="${{_LLAMA_SOURCE%.git}}" _NEED_LLAMA_SOURCE_BUILD=false @@ -225,20 +226,10 @@ class TestBashPrForcePromotion: # ========================================================================= -# TEST GROUP B: Bash custom source URL (subprocess) +# TEST GROUP B: Bash fixed mainline source (subprocess) # ========================================================================= -class TestBashCustomSource: - """Custom _LLAMA_SOURCE forces source build.""" - - def test_custom_source_forces_build(self): - script = _bash_resolution_fragment( - llama_source = "https://github.com/unslothai/llama.cpp", - ) - r = run_bash(script) - assert r.returncode == 0 - assert "NEED_SOURCE=true" in r.stdout - assert "SKIP_PREBUILT=true" in r.stdout - assert "custom source:" in r.stdout +class TestBashFixedMainlineSource: + """Source remains pinned to ggml-org while the temporary policy is active.""" def test_default_source_no_force(self): script = _bash_resolution_fragment() @@ -248,32 +239,23 @@ class TestBashCustomSource: assert "SKIP_PREBUILT=false" in r.stdout assert "custom source:" not in r.stdout - def test_trailing_git_stripped(self): + def test_env_source_override_is_ignored(self): script = _bash_resolution_fragment( llama_source = "https://github.com/unslothai/llama.cpp.git", ) r = run_bash(script) assert r.returncode == 0 - assert "LLAMA_SOURCE=https://github.com/unslothai/llama.cpp" in r.stdout - assert "NEED_SOURCE=true" in r.stdout + assert "LLAMA_SOURCE=https://github.com/ggml-org/llama.cpp" in r.stdout + assert "NEED_SOURCE=false" in r.stdout + assert "SKIP_PREBUILT=false" in r.stdout - def test_baked_in_source_forces_build(self): + def test_baked_in_source_stays_mainline(self): script = _bash_resolution_fragment( - default_source = "https://github.com/unslothai/llama.cpp", + default_source = "https://github.com/ggml-org/llama.cpp", ) r = run_bash(script) assert r.returncode == 0 - assert "NEED_SOURCE=true" in r.stdout - - def test_env_source_overrides_baked_in(self): - """User UNSLOTH_LLAMA_SOURCE overrides _DEFAULT_LLAMA_SOURCE.""" - script = _bash_resolution_fragment( - llama_source = "https://github.com/custom/llama.cpp", - default_source = "https://github.com/unslothai/llama.cpp", - ) - r = run_bash(script) - assert r.returncode == 0 - assert "LLAMA_SOURCE=https://github.com/custom/llama.cpp" in r.stdout + assert "LLAMA_SOURCE=https://github.com/ggml-org/llama.cpp" in r.stdout # ========================================================================= @@ -386,7 +368,7 @@ class TestBashCloneUrlParameterized: # TEST GROUP D: Static source patterns -- setup.sh # ========================================================================= class TestSourcePatternsSh: - """Verify setup.sh has the new defaults and parameterized clone URLs.""" + """Verify setup.sh keeps the temporary mainline-only llama.cpp policy.""" @pytest.fixture(autouse = True) def _load_source(self): @@ -404,8 +386,21 @@ class TestSourcePatternsSh: def test_has_pr_force_env_read(self): assert "UNSLOTH_LLAMA_PR_FORCE" in self.content - def test_has_source_env_read(self): - assert "UNSLOTH_LLAMA_SOURCE" in self.content + def test_source_env_override_removed(self): + assert "UNSLOTH_LLAMA_SOURCE:-${_DEFAULT_LLAMA_SOURCE}" not in self.content + assert '_LLAMA_SOURCE="${_DEFAULT_LLAMA_SOURCE}"' in self.content + + def test_release_repo_override_removed(self): + assert "UNSLOTH_LLAMA_RELEASE_REPO:-unslothai/llama.cpp" not in self.content + assert '_HELPER_RELEASE_REPO="ggml-org/llama.cpp"' in self.content + + def test_force_compile_skips_prebuilt_resolution_early(self): + assert 'if [ "$_LLAMA_FORCE_COMPILE" = "1" ]; then' in self.content + assert "_SKIP_PREBUILT_INSTALL=true" in self.content + + def test_force_compile_uses_requested_tag_without_helper(self): + assert 'if [ "$_LLAMA_FORCE_COMPILE" = "1" ]; then' in self.content + assert '_RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG"' in self.content def test_pr_force_resolution_block(self): assert '_LLAMA_PR="$_LLAMA_PR_FORCE"' in self.content @@ -425,16 +420,13 @@ class TestSourcePatternsSh: assert "ggml-org/llama.cpp.git" not in pr_block def test_clone_urls_parameterized_tag_path(self): - """Non-PR clone path uses ${_LLAMA_SOURCE}.git, not hardcoded URL.""" + """Non-PR clone path uses the resolved source URL, not a hardcoded URL.""" # Find the non-PR clone line (after _CLONE_ARGS) idx = self.content.index("_CLONE_ARGS=(git clone --depth 1)") block = self.content[idx : idx + 400] - assert '"${_LLAMA_SOURCE}.git"' in block + assert '"${_RESOLVED_SOURCE_URL}.git"' in block assert "ggml-org/llama.cpp.git" not in block - def test_custom_source_forces_build(self): - assert "custom source: $_LLAMA_SOURCE -- forcing source build" in self.content - def test_no_hardcoded_clone_urls(self): """No remaining hardcoded ggml-org clone URLs in clone commands.""" lines = self.content.splitlines() @@ -449,7 +441,7 @@ class TestSourcePatternsSh: # TEST GROUP E: Static source patterns -- setup.ps1 # ========================================================================= class TestSourcePatternsPs1: - """Verify setup.ps1 has the new defaults and parameterized clone URLs.""" + """Verify setup.ps1 keeps the temporary mainline-only llama.cpp policy.""" @pytest.fixture(autouse = True) def _load_source(self): @@ -467,8 +459,24 @@ class TestSourcePatternsPs1: def test_has_pr_force_env_read(self): assert "$env:UNSLOTH_LLAMA_PR_FORCE" in self.content - def test_has_source_env_read(self): - assert "$env:UNSLOTH_LLAMA_SOURCE" in self.content + def test_source_env_override_removed(self): + assert "$LlamaSource = if ($env:UNSLOTH_LLAMA_SOURCE)" not in self.content + assert "$LlamaSource = $DefaultLlamaSource" in self.content + + def test_release_repo_override_removed(self): + assert ( + "$HelperReleaseRepo = if ($env:UNSLOTH_LLAMA_RELEASE_REPO)" + not in self.content + ) + assert '$HelperReleaseRepo = "ggml-org/llama.cpp"' in self.content + + def test_force_compile_skips_prebuilt_resolution_early(self): + assert 'if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {' in self.content + assert "$SkipPrebuiltInstall = $true" in self.content + + def test_force_compile_uses_requested_tag_without_helper(self): + assert 'if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {' in self.content + assert "$ResolvedLlamaTag = $RequestedLlamaTag" in self.content def test_pr_force_promotion_block(self): assert "$LlamaPr = $LlamaPrForce" in self.content @@ -487,15 +495,12 @@ class TestSourcePatternsPs1: assert "ggml-org/llama.cpp.git" not in pr_block def test_clone_urls_parameterized_tag_path(self): - """Non-PR clone path uses $LlamaSource.git, not hardcoded URL.""" + """Non-PR clone path uses the resolved source URL, not a hardcoded URL.""" clone_args_idx = self.content.index('$cloneArgs = @("clone"') block = self.content[clone_args_idx : clone_args_idx + 400] - assert '"$LlamaSource.git"' in block + assert '"$ResolvedSourceUrl.git"' in block assert "ggml-org/llama.cpp.git" not in block - def test_custom_source_forces_build(self): - assert "custom source: $LlamaSource -- forcing source build" in self.content - def test_no_hardcoded_clone_urls(self): """No remaining hardcoded ggml-org clone URLs in clone commands.""" lines = self.content.splitlines() @@ -511,7 +516,7 @@ class TestSourcePatternsPs1: # ========================================================================= @requires_pwsh class TestPwshPrForcePromotion: - """PR_FORCE promotion and source URL logic via pwsh subprocess.""" + """PR_FORCE promotion and fixed-source logic via pwsh subprocess.""" FRAGMENT_TEMPLATE = textwrap.dedent("""\ function step($a, $b, $c) { Write-Output "step:$a`:$b" } @@ -521,7 +526,7 @@ class TestPwshPrForcePromotion: $LlamaPr = if ($env:UNSLOTH_LLAMA_PR) { $env:UNSLOTH_LLAMA_PR.Trim() } else { "" } $LlamaPrForce = if ($env:UNSLOTH_LLAMA_PR_FORCE) { $env:UNSLOTH_LLAMA_PR_FORCE.Trim() } else { $DefaultLlamaPrForce } - $LlamaSource = if ($env:UNSLOTH_LLAMA_SOURCE) { $env:UNSLOTH_LLAMA_SOURCE.Trim() } else { $DefaultLlamaSource } + $LlamaSource = $DefaultLlamaSource if ($LlamaSource.EndsWith('.git')) { $LlamaSource = $LlamaSource.Substring(0, $LlamaSource.Length - 4) } $NeedLlamaSourceBuild = $false @@ -561,7 +566,6 @@ class TestPwshPrForcePromotion: # Ensure env vars are unset by default run_env["UNSLOTH_LLAMA_PR"] = "" run_env["UNSLOTH_LLAMA_PR_FORCE"] = "" - run_env["UNSLOTH_LLAMA_SOURCE"] = "" if env: run_env.update(env) return run_pwsh(script, env = run_env) @@ -599,15 +603,16 @@ class TestPwshPrForcePromotion: assert r.returncode == 0 assert "baked-in PR_FORCE" not in r.stdout - def test_custom_source_forces_build(self): + def test_env_source_override_is_ignored(self): r = self._run( env = { "UNSLOTH_LLAMA_SOURCE": "https://github.com/unslothai/llama.cpp", } ) assert r.returncode == 0 - assert "NEED_SOURCE=True" in r.stdout - assert "SKIP_PREBUILT=True" in r.stdout + assert "LLAMA_SOURCE=https://github.com/ggml-org/llama.cpp" in r.stdout + assert "NEED_SOURCE=False" in r.stdout + assert "SKIP_PREBUILT=False" in r.stdout def test_default_source_no_force(self): r = self._run() @@ -615,16 +620,16 @@ class TestPwshPrForcePromotion: assert "NEED_SOURCE=False" in r.stdout assert "SKIP_PREBUILT=False" in r.stdout - def test_trailing_git_stripped(self): + def test_trailing_git_override_is_ignored(self): r = self._run( env = { "UNSLOTH_LLAMA_SOURCE": "https://github.com/unslothai/llama.cpp.git", } ) assert r.returncode == 0 - assert "LLAMA_SOURCE=https://github.com/unslothai/llama.cpp" in r.stdout + assert "LLAMA_SOURCE=https://github.com/ggml-org/llama.cpp" in r.stdout - def test_baked_in_source_forces_build(self): - r = self._run(default_source = "https://github.com/unslothai/llama.cpp") + def test_baked_in_source_stays_mainline(self): + r = self._run(default_source = "https://github.com/ggml-org/llama.cpp") assert r.returncode == 0 - assert "NEED_SOURCE=True" in r.stdout + assert "LLAMA_SOURCE=https://github.com/ggml-org/llama.cpp" in r.stdout diff --git a/tests/studio/install/test_pr4562_bugfixes.py b/tests/studio/install/test_pr4562_bugfixes.py index f5a423d130..7fa654d845 100644 --- a/tests/studio/install/test_pr4562_bugfixes.py +++ b/tests/studio/install/test_pr4562_bugfixes.py @@ -778,6 +778,34 @@ class TestSourceCodePatterns: assert "$ResolvedSourceRefKind" in content assert "$ResolvedSourceRef" in content + def test_setup_ps1_prebuilt_install_disables_native_error_abort(self): + """PS1 prebuilt install should not abort setup on helper stderr.""" + content = SETUP_PS1.read_text() + install_idx = content.index("& python @prebuiltArgs 2>&1") + block = content[max(0, install_idx - 800) : install_idx + 800] + assert "$PSNativeCommandUseErrorActionPreference = $false" in block + assert "$restoreNativeErrorPreference = $true" in block + assert ( + "$PSNativeCommandUseErrorActionPreference = $previousNativeErrorPreference" + in block + ) + + def test_setup_ps1_helper_disables_error_action_abort(self): + """Helper resolution should suppress terminating NativeCommandError on PS 5.1.""" + content = SETUP_PS1.read_text() + helper_idx = content.index("function Invoke-LlamaHelper") + block = content[helper_idx : helper_idx + 1200] + assert "$previousErrorActionPreference = $ErrorActionPreference" in block + assert '$ErrorActionPreference = "Continue"' in block + assert "$ErrorActionPreference = $previousErrorActionPreference" in block + + def test_setup_ps1_uses_local_tempfile_helper(self): + """PS1 should not depend on New-TemporaryFile being available.""" + content = SETUP_PS1.read_text() + assert "function New-UnslothTemporaryFile" in content + assert "$resolveErrorLog = New-UnslothTemporaryFile" in content + assert "$resolveErrorLog = New-TemporaryFile" not in content + def test_binary_env_linux_has_binary_parent(self): """The Linux branch of binary_env should include binary_path.parent.""" content = MODULE_PATH.read_text() diff --git a/tests/studio/install/test_selection_logic.py b/tests/studio/install/test_selection_logic.py index b8de0e221f..6a2e367dc8 100644 --- a/tests/studio/install/test_selection_logic.py +++ b/tests/studio/install/test_selection_logic.py @@ -511,6 +511,56 @@ class TestApplyApprovedHashes: result = apply_approved_hashes([choice], checksums) assert result[0].expected_sha256 == "a" * 64 + def test_windows_cuda_legacy_choice_can_match_current_upstream_name(self): + choice = AssetChoice( + repo = UPSTREAM_REPO, + tag = "b9000", + name = "llama-b9000-bin-win-cuda-13.1-x64.zip", + url = "https://x/llama-b9000-bin-win-cuda-13.1-x64.zip", + source_label = "upstream", + ) + checksums = ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "r1", + upstream_tag = "b9000", + artifacts = { + "cudart-llama-bin-win-cuda-13.1-x64.zip": ApprovedArtifactHash( + asset_name = "cudart-llama-bin-win-cuda-13.1-x64.zip", + sha256 = "b" * 64, + repo = UPSTREAM_REPO, + kind = "windows-cuda-upstream", + ) + }, + ) + + result = apply_approved_hashes([choice], checksums) + assert result[0].expected_sha256 == "b" * 64 + + def test_windows_cuda_current_choice_can_match_legacy_compatibility_name(self): + choice = AssetChoice( + repo = UPSTREAM_REPO, + tag = "main", + name = "cudart-llama-bin-win-cuda-13.1-x64.zip", + url = "https://x/cudart-llama-bin-win-cuda-13.1-x64.zip", + source_label = "upstream", + ) + checksums = ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "r1", + upstream_tag = "b9000", + artifacts = { + "llama-b9000-bin-win-cuda-13.1-x64.zip": ApprovedArtifactHash( + asset_name = "llama-b9000-bin-win-cuda-13.1-x64.zip", + sha256 = "c" * 64, + repo = UPSTREAM_REPO, + kind = "windows-cuda-upstream", + ) + }, + ) + + result = apply_approved_hashes([choice], checksums) + assert result[0].expected_sha256 == "c" * 64 + def test_none_approved(self): c1 = self._choice("missing.tar.gz") checksums = make_checksums(["other.tar.gz"]) From 8d1712b4ea50cdbf36c1db3f22c7e2c23eadfedf Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 2 Apr 2026 11:43:53 -0700 Subject: [PATCH 023/216] fix(studio): pin llama.cpp to b8637 release (Gemma 4 support) (#4796) ggml-org/llama.cpp b8637 includes Gemma 4 support (ggml-org/llama.cpp#21309). Revert the temporary "master" default back to a pinned release tag. This eliminates the HTTP 422 errors from the prebuilt resolver (which could not find a release matching "master"), avoids unnecessary source builds, and restores prebuilt binary downloads on all platforms. Co-authored-by: Daniel Han --- studio/install_llama_prebuilt.py | 2 +- studio/setup.ps1 | 2 +- studio/setup.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 13382d3c16..9fe5b55e9f 100755 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -60,7 +60,7 @@ def env_int(name: str, default: int, *, minimum: int | None = None) -> int: return value -DEFAULT_LLAMA_TAG = os.environ.get("UNSLOTH_LLAMA_TAG", "master") +DEFAULT_LLAMA_TAG = os.environ.get("UNSLOTH_LLAMA_TAG", "b8637") # Force all installs to use mainline llama.cpp from ggml-org. # Previously: DEFAULT_PUBLISHED_REPO = os.environ.get("UNSLOTH_LLAMA_RELEASE_REPO", "unslothai/llama.cpp") DEFAULT_PUBLISHED_REPO = "ggml-org/llama.cpp" diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 382de89b9f..47d7f0ee55 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -29,7 +29,7 @@ $PackageDir = Split-Path -Parent $ScriptDir # -------------------------------------------------------------------------- $DefaultLlamaPrForce = "" $DefaultLlamaSource = "https://github.com/ggml-org/llama.cpp" -$DefaultLlamaTag = "master" +$DefaultLlamaTag = "b8637" # Verbose can be enabled either by CLI flag or by UNSLOTH_VERBOSE=1. $script:UnslothVerbose = ($env:UNSLOTH_VERBOSE -eq '1') diff --git a/studio/setup.sh b/studio/setup.sh index 76fd79e7a2..4e7bd366ef 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -19,7 +19,7 @@ RULE=$(printf '\342\224\200%.0s' {1..52}) # ────────────────────────────────────────────────────────────────────────── _DEFAULT_LLAMA_PR_FORCE="" _DEFAULT_LLAMA_SOURCE="https://github.com/ggml-org/llama.cpp" -_DEFAULT_LLAMA_TAG="master" +_DEFAULT_LLAMA_TAG="b8637" # ── Colors (same palette as startup_banner / install_python_stack) ── if [ -n "${NO_COLOR:-}" ]; then From 401621618b7ff548fd04c2ea5b2aca05afd936ac Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 2 Apr 2026 11:44:26 -0700 Subject: [PATCH 024/216] fix(studio): don't set trust_remote_code for Gemma 4 training (#4795) Gemma 4 is a native transformers 5.5 model and does not need trust_remote_code=True. The auto-enable logic (added for NemotronH) was catching all transformers 5.x models, including Gemma 4. When trust_remote_code=True, unsloth_compile_transformers() returns early without running the compiler. This disables the fused cross entropy patch, causing logged training loss to be inflated by the gradient_accumulation_steps factor. Exclude models matching "gemma-4" or "gemma4" from the auto-enable so the compiler runs and applies fused cross entropy correctly. --- studio/backend/core/training/worker.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/studio/backend/core/training/worker.py b/studio/backend/core/training/worker.py index 699cfe74f7..0454eada89 100644 --- a/studio/backend/core/training/worker.py +++ b/studio/backend/core/training/worker.py @@ -390,11 +390,16 @@ def run_training_process( # Some newer architectures (e.g. NemotronH) have config parsing bugs in # transformers that require trust_remote_code=True as a workaround. # Only auto-enable for unsloth/* prefixed models (trusted source). + # Exclude Gemma 4 since it is a native transformers 5.5 model and + # trust_remote_code=True would bypass the compiler (disabling fused CE). from utils.transformers_version import needs_transformers_5 + _lowered = model_name.lower() + _is_native_t5 = any(x in _lowered for x in ("gemma-4", "gemma4")) if ( needs_transformers_5(model_name) - and model_name.lower().startswith("unsloth/") + and _lowered.startswith("unsloth/") + and not _is_native_t5 and not config.get("trust_remote_code", False) ): config["trust_remote_code"] = True From 934478ae317d337bc89887eb445df533de42b40b Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 2 Apr 2026 11:52:37 -0700 Subject: [PATCH 025/216] fix(studio): revert llama.cpp default tag to latest (#4797) * fix(studio): revert llama.cpp default tag to latest The latest ggml-org/llama.cpp release (b8637) now includes Gemma 4 support. Revert the temporary "b8637" pin from #4796 to "latest" so the prebuilt resolver always picks the newest release automatically without needing manual tag bumps. * docs: add comment explaining latest vs master for llama.cpp tag Document in all three files why "latest" is preferred over "master" and when "master" should be used as a temporary override. --------- Co-authored-by: Daniel Han --- studio/install_llama_prebuilt.py | 6 +++++- studio/setup.ps1 | 6 +++++- studio/setup.sh | 7 ++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 9fe5b55e9f..1b02729649 100755 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -60,7 +60,11 @@ def env_int(name: str, default: int, *, minimum: int | None = None) -> int: return value -DEFAULT_LLAMA_TAG = os.environ.get("UNSLOTH_LLAMA_TAG", "b8637") +# Prefer "latest" over "master" -- "master" bypasses the prebuilt resolver +# (no matching GitHub release), forces a source build, and causes HTTP 422 +# errors. Only use "master" temporarily when the latest release is missing +# support for a new model architecture. +DEFAULT_LLAMA_TAG = os.environ.get("UNSLOTH_LLAMA_TAG", "latest") # Force all installs to use mainline llama.cpp from ggml-org. # Previously: DEFAULT_PUBLISHED_REPO = os.environ.get("UNSLOTH_LLAMA_RELEASE_REPO", "unslothai/llama.cpp") DEFAULT_PUBLISHED_REPO = "ggml-org/llama.cpp" diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 47d7f0ee55..d478319098 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -27,9 +27,13 @@ $PackageDir = Split-Path -Parent $ScriptDir # Change these in the GitHub-hosted script so users get updated defaults. # User env vars always override these baked-in values. # -------------------------------------------------------------------------- +# Prefer "latest" over "master" -- "master" bypasses the prebuilt resolver +# (no matching GitHub release), forces a source build, and causes HTTP 422 +# errors. Only use "master" temporarily when the latest release is missing +# support for a new model architecture. $DefaultLlamaPrForce = "" $DefaultLlamaSource = "https://github.com/ggml-org/llama.cpp" -$DefaultLlamaTag = "b8637" +$DefaultLlamaTag = "latest" # Verbose can be enabled either by CLI flag or by UNSLOTH_VERBOSE=1. $script:UnslothVerbose = ($env:UNSLOTH_VERBOSE -eq '1') diff --git a/studio/setup.sh b/studio/setup.sh index 4e7bd366ef..d9ce73661f 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -16,10 +16,15 @@ RULE=$(printf '\342\224\200%.0s' {1..52}) # _DEFAULT_LLAMA_SOURCE : git clone URL for source builds # _DEFAULT_LLAMA_TAG : llama.cpp ref to build ("latest" = newest release, # "master" = bleeding-edge, "bNNNN" = specific tag) +# Prefer "latest" over "master" -- "master" bypasses +# the prebuilt resolver (no matching GitHub release), +# forces a source build, and causes HTTP 422 errors. +# Only use "master" temporarily when the latest release +# is missing support for a new model architecture. # ────────────────────────────────────────────────────────────────────────── _DEFAULT_LLAMA_PR_FORCE="" _DEFAULT_LLAMA_SOURCE="https://github.com/ggml-org/llama.cpp" -_DEFAULT_LLAMA_TAG="b8637" +_DEFAULT_LLAMA_TAG="latest" # ── Colors (same palette as startup_banner / install_python_stack) ── if [ -n "${NO_COLOR:-}" ]; then From 8ffd5826f23d487999bee55d7e6345378c03c225 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 2 Apr 2026 11:59:37 -0700 Subject: [PATCH 026/216] Gemma-4 --- pyproject.toml | 8 ++++---- unsloth/models/_utils.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b876c19467..e391b4df3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,13 +82,13 @@ huggingfacenotorch = [ "huggingface_hub>=0.34.0", "hf_transfer", "diffusers", - "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", + "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", "trl>=0.18.2,!=0.19.0,<=0.24.0", "sentence-transformers", ] huggingface = [ "unsloth[huggingfacenotorch]", - "unsloth_zoo>=2026.3.7", + "unsloth_zoo>=2026.4.2", "torchvision", "unsloth[triton]", ] @@ -578,10 +578,10 @@ colab-ampere-torch220 = [ "flash-attn>=2.6.3 ; ('linux' in sys_platform)", ] colab-new = [ - "unsloth_zoo>=2026.3.7", + "unsloth_zoo>=2026.4.2", "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", + "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", "datasets>=3.4.1,!=4.0.*,!=4.1.0,<4.4.0", "sentencepiece>=0.2.0", "tqdm", diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index 1c803a4972..28526056ba 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.18" +__version__ = "2026.4.1" __all__ = [ "SUPPORTS_BFLOAT16", From e553a8ad0bc9645fdad3159c8f49f4340ab144a1 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 2 Apr 2026 12:18:11 -0700 Subject: [PATCH 027/216] fix(studio): suppress fatal error when prebuilt manifest is missing (#4799) When DEFAULT_PUBLISHED_REPO is ggml-org/llama.cpp, the prebuilt resolver raises PrebuiltFallback because ggml-org releases do not include a llama-prebuilt-manifest.json asset. This was caught by the generic Exception handler and printed as "fatal helper error" to stderr, which triggers NativeCommandError on PowerShell. Catch PrebuiltFallback separately in the top-level __main__ handler and exit with EXIT_FALLBACK (code 2) instead of EXIT_ERROR (code 1). The message is still logged but without the "fatal helper error" prefix. The shell scripts already handle non-zero exits and fall back to source builds. Co-authored-by: Daniel Han --- studio/install_llama_prebuilt.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 1b02729649..5e8fe314b4 100755 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -4733,6 +4733,12 @@ if __name__ == "__main__": f"fatal helper busy conflict: {textwrap.shorten(str(exc), width = 400, placeholder = '...')}" ) raise SystemExit(EXIT_BUSY) + except PrebuiltFallback as exc: + # Expected when the published repo (e.g. ggml-org/llama.cpp) has no + # prebuilt manifest. Exit quietly with EXIT_FALLBACK so the caller + # falls back to source build without a noisy "fatal helper error". + log(textwrap.shorten(str(exc), width = 400, placeholder = "...")) + raise SystemExit(EXIT_FALLBACK) except Exception as exc: message = textwrap.shorten(str(exc), width = 400, placeholder = "...") log(f"fatal helper error: {message}") From f91ef8f9b048938dd22bb37e820855d7a97f7871 Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:56:01 +0400 Subject: [PATCH 028/216] fix(studio): lazy-import transformers in model_config to fix 5.x version switch (#4806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(studio): lazy-import AutoConfig in model_config.py to fix transformers 5.x version switch Move `from transformers import AutoConfig` from module level to inside load_model_config() where it is actually used. model_config.py is transitively imported at module load time via: core/inference/__init__ → llama_cpp → utils.models → model_config In inference subprocesses (mp.spawn), this chain runs before _activate_transformers_version() can prepend .venv_t5/ to sys.path. The eager import caches transformers 4.57.6 in sys.modules, and the subsequent sys.path change has no effect — Python always checks sys.modules before sys.path. Making the import lazy ensures transformers is not loaded until after version activation, so the subprocess picks up the correct version. * fix(studio): also lazy-import extract_model_size_b in llama_cpp.py Belt-and-suspenders: make the import that originally triggered the chain lazy as well, so future module-level AutoConfig additions in utils.models cannot reintroduce the problem. * [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/core/inference/llama_cpp.py | 10 ++++++++-- studio/backend/utils/models/model_config.py | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 44c700bf3d..d752500bf4 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -52,8 +52,14 @@ _REPROMPT_MAX_CHARS = 2000 _SHARD_FULL_RE = re.compile(r"^(.*)-(\d{5})-of-(\d{5})\.gguf$") _SHARD_RE = re.compile(r"^(.*)-\d{5}-of-\d{5}\.gguf$") -# Model size extraction (shared with routes/inference.py) -from utils.models import extract_model_size_b as _extract_model_size_b + +# Model size extraction — lazy import to avoid pulling in transformers +# at module level. See PR description for the full explanation. +def _extract_model_size_b(model_id: str): + from utils.models import extract_model_size_b + + return extract_model_size_b(model_id) + # ── Pre-compiled patterns for tool XML stripping ───────────── _TOOL_CLOSED_PATS = [ diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index df1058abf6..6cffc534aa 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -5,7 +5,6 @@ Model and LoRA configuration handling """ -from transformers import AutoConfig from dataclasses import dataclass from typing import Optional, Dict, Any from utils.paths import ( @@ -422,6 +421,7 @@ def load_model_config( """ Load model config with optional authentication control. """ + from transformers import AutoConfig if token: # Explicit token provided - use it From 6644a771b4c5c0b13f9e06c2e459c4d82a204c6b Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Fri, 3 Apr 2026 04:03:54 +0400 Subject: [PATCH 029/216] fix: patch PEFT for Gemma4ClippableLinear in loader checkpoint path (fixes export) (#4807) * fix: patch PEFT for Gemma4ClippableLinear in loader checkpoint path The same Gemma4ClippableLinear monkey-patch that exists in vision.py for training is needed in loader.py for loading existing checkpoints (used by export and inference). Gemma4ClippableLinear wraps nn.Linear but does not subclass it, so PEFT's LoRA injection fails with "Target module not supported". The patch redirects PEFT to target the inner .linear child instead. Applied only to the vision model PeftModel.from_pretrained path. Temporary fix until PEFT adds native support (peft#3129). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: wrap ClippableLinear patch in try/finally to always restore Ensures _create_and_replace is restored even if PeftModel.from_pretrained raises, preventing leaked global state across subsequent model loads. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- unsloth/models/loader.py | 74 +++++++++++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 8 deletions(-) diff --git a/unsloth/models/loader.py b/unsloth/models/loader.py index a811d3fb75..cfe3acb656 100644 --- a/unsloth/models/loader.py +++ b/unsloth/models/loader.py @@ -1533,14 +1533,72 @@ class FastModel(FastBaseModel): if is_peft: # From https://github.com/huggingface/peft/issues/184 # Now add PEFT adapters - model = PeftModel.from_pretrained( - model, - old_model_name, - token = token, - revision = revision, - is_trainable = True, - trust_remote_code = trust_remote_code, - ) + + # Gemma4 ClippableLinear wraps nn.Linear -- PEFT can't inject LoRA + # on it directly. Monkey-patch PEFT to target the inner .linear + # child instead (same patch as vision.py training path). + # See https://github.com/huggingface/peft/issues/3129 + _clippable_linear_cls = None + try: + from transformers.models.gemma4.modeling_gemma4 import ( + Gemma4ClippableLinear as _clippable_linear_cls, + ) + except ImportError: + pass + + if _clippable_linear_cls is not None: + from peft.tuners.lora.model import LoraModel as _LoraModel + + _original_car = _LoraModel._create_and_replace + + def _patched_car( + self, + peft_config, + adapter_name, + target, + target_name, + parent, + current_key = None, + **kwargs, + ): + if isinstance(target, _clippable_linear_cls): + return _original_car( + self, + peft_config, + adapter_name, + target.linear, + "linear", + target, + current_key = current_key, + **kwargs, + ) + return _original_car( + self, + peft_config, + adapter_name, + target, + target_name, + parent, + current_key = current_key, + **kwargs, + ) + + _LoraModel._create_and_replace = _patched_car + + try: + model = PeftModel.from_pretrained( + model, + old_model_name, + token = token, + revision = revision, + is_trainable = True, + trust_remote_code = trust_remote_code, + ) + finally: + # Always restore original PEFT method, even if loading fails + if _clippable_linear_cls is not None: + _LoraModel._create_and_replace = _original_car + # Patch it as well! model = FastBaseModel.post_patch_model( model, use_gradient_checkpointing, trust_remote_code = trust_remote_code From a7e69641171467f49487979b1ce29d03aaacf2ca Mon Sep 17 00:00:00 2001 From: Manan Shah <52329525+Manan17@users.noreply.github.com> Date: Fri, 3 Apr 2026 00:03:35 -0500 Subject: [PATCH 030/216] Fix/gemma4 install script (#4815) * transformer 5.5.0 has now been released * fallback for python < 3.10 : --- install_gemma4_mlx.sh | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/install_gemma4_mlx.sh b/install_gemma4_mlx.sh index e06339e204..b653af9154 100755 --- a/install_gemma4_mlx.sh +++ b/install_gemma4_mlx.sh @@ -132,29 +132,17 @@ step "install" "installing mlx, mlx-lm..." uv pip install --python "$_VENV_PY" -q mlx mlx-lm 2>/dev/null substep "done" -TRANSFORMERS_WHL="transformers-5.5.0-py3-none-any.whl" -TRANSFORMERS_GH="git+https://github.com/huggingface/transformers.git@v5.5-release" - step "install" "installing transformers>=5.5.0..." -if uv pip install --python "$_VENV_PY" -q "$TRANSFORMERS_GH" 2>/dev/null; then - substep "installed from huggingface/transformers v5.5-release" -elif uv pip install --python "$_VENV_PY" -q "transformers>=5.5.0" 2>/dev/null; then +if uv pip install --python "$_VENV_PY" -q "transformers>=5.5.0" 2>/dev/null; then substep "installed from PyPI" else - substep "not on PyPI, trying unsloth branch..." - _whl_tmp=$(mktemp -d)/"${TRANSFORMERS_WHL}" - if curl -fsSL "${REPO_URL}/${TRANSFORMERS_WHL}" -o "$_whl_tmp" 2>/dev/null && \ - uv pip install --python "$_VENV_PY" -q "$_whl_tmp" 2>/dev/null; then - substep "installed from branch ${BRANCH}" - elif [ -f "./${TRANSFORMERS_WHL}" ]; then - substep "using local ./${TRANSFORMERS_WHL}" - uv pip install --python "$_VENV_PY" -q "./${TRANSFORMERS_WHL}" + substep "PyPI install failed (Python <3.10?), trying GitHub..." + if uv pip install --python "$_VENV_PY" -q "git+https://github.com/huggingface/transformers.git@v5.5-release" 2>/dev/null; then + substep "installed from huggingface/transformers v5.5-release" else - rm -f "$_whl_tmp" 2>/dev/null - step "install" "skipping transformers — could not find >=5.5.0" "$C_WARN" - substep "tried: huggingface/transformers v5.5-release, PyPI, branch ${BRANCH}, local ./${TRANSFORMERS_WHL}" + step "warning" "could not install transformers>=5.5.0" "$C_WARN" + substep "tried: PyPI, huggingface/transformers v5.5-release" fi - rm -f "$_whl_tmp" 2>/dev/null fi # ── Find mlx-lm models directory ───────────────────────────── From c1685b945902393e863a1ccddc347aa5229769df Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:54:03 -0700 Subject: [PATCH 031/216] Gemma 4 update.md --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 26a578656c..7046a2af7c 100644 --- a/README.md +++ b/README.md @@ -109,18 +109,19 @@ You can use the same Docker image as Unsloth Studio. For RTX 50x, B200, 6000 GPUs: `uv pip install unsloth --torch-backend=auto`. Read our guides for: [Blackwell](https://unsloth.ai/docs/blog/fine-tuning-llms-with-blackwell-rtx-50-series-and-unsloth) and [DGX Spark](https://unsloth.ai/docs/blog/fine-tuning-llms-with-nvidia-dgx-spark-and-unsloth).
To install Unsloth on **AMD** and **Intel** GPUs, follow our [AMD Guide](https://unsloth.ai/docs/get-started/install/amd) and [Intel Guide](https://unsloth.ai/docs/get-started/install/intel). -## ✨ Free Notebooks +## 📒 Free Notebooks -Train for free with our notebooks. Read our [guide](https://unsloth.ai/docs/get-started/fine-tuning-llms-guide). Add dataset, run, then deploy your trained model. +Train for free with our notebooks. You can use our new [free Unsloth Studio notebook](https://colab.research.google.com/github/unslothai/unsloth/blob/main/studio/Unsloth_Studio_Colab.ipynb) to run and train models for free in a web UI. +Read our [guide](https://unsloth.ai/docs/get-started/fine-tuning-llms-guide). Add dataset, run, then deploy your trained model. | Model | Free Notebooks | Performance | Memory use | |-----------|---------|--------|----------| +| **Gemma 4 (E2B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Gemma4_(E2B)-Vision.ipynb) | 1.5x faster | 50% less | | **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 | 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 | | **Llama 3.1 (8B) Alpaca** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Llama3.1_(8B)-Alpaca.ipynb) | 2x faster | 70% less | @@ -132,6 +133,7 @@ Train for free with our notebooks. Read our [guide](https://unsloth.ai/docs/get- - See detailed documentation for Unsloth [here](https://unsloth.ai/docs) ## 🦥 Unsloth News +- **Gemma 4**: Run and train Google’s new models directly in Unsloth Studio! [Blog](https://unsloth.ai/docs/models/gemma-4) - **Introducing Unsloth Studio**: our new web UI for running and training LLMs. [Blog](https://unsloth.ai/docs/new/studio) - **Qwen3.5** - 0.8B, 2B, 4B, 9B, 27B, 35-A3B, 112B-A10B are now supported. [Guide + notebooks](https://unsloth.ai/docs/models/qwen3.5/fine-tune) - Train **MoE LLMs 12x faster** with 35% less VRAM - DeepSeek, GLM, Qwen and gpt-oss. [Blog](https://unsloth.ai/docs/new/faster-moe) From ac562bac668347b70017dce7540fad1dd96989bc Mon Sep 17 00:00:00 2001 From: DoubleMathew Date: Fri, 3 Apr 2026 02:34:20 -0500 Subject: [PATCH 032/216] Fix/llama.cppbuilding (#4804) * Simplify llama.cpp install logic * print release tag * Retry failed json decode * don't pull all ggml releases * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Remove test file changes from main PR Test changes for test_pr4562_bugfixes.py will be submitted in a separate PR to keep this PR focused on the install path simplification. * Fix setup.sh executable bit and direct tag lookup for pinned releases - Restore setup.sh file mode to 100755 (was accidentally changed to 100644) - Add direct GitHub API tag lookup in iter_release_payloads_by_time for non-latest requested tags (e.g. b7879) instead of relying on paginated release scans that may miss older releases beyond the 5-page limit - Update stale DEFAULT_PUBLISHED_REPO comment to match new value * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix force-compile default ref and remove dead code in setup.ps1 - Change FORCE_COMPILE_DEFAULT_REF from "main" to "master" in all three files (install_llama_prebuilt.py, setup.sh, setup.ps1) since ggml-org/llama.cpp uses "master" as its default branch, not "main". Using "main" would cause git clone --branch to fail when UNSLOTH_LLAMA_FORCE_COMPILE=1 with UNSLOTH_LLAMA_TAG=latest. - Remove dead if ($SkipPrebuiltInstall) block inside the else branch of setup.ps1 that could never be reached (the outer elseif already handles $SkipPrebuiltInstall=true). - Maintain setup.sh executable bit (100755). * Improve iter_release_payloads_by_time error handling for direct tag lookup When a pinned release tag is not found (HTTP 404), fall through to the paginated release scan instead of silently returning empty results. Non-404 errors (network failures, rate limits) are propagated to the caller so users get actionable error messages. * [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: Daniel Han --- studio/install_llama_prebuilt.py | 571 ++++++++++++++++++++++++++++--- studio/setup.ps1 | 160 ++++----- studio/setup.sh | 272 +++++++-------- 3 files changed, 711 insertions(+), 292 deletions(-) diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 5e8fe314b4..8d06c7d0e1 100755 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -65,9 +65,10 @@ def env_int(name: str, default: int, *, minimum: int | None = None) -> int: # errors. Only use "master" temporarily when the latest release is missing # support for a new model architecture. DEFAULT_LLAMA_TAG = os.environ.get("UNSLOTH_LLAMA_TAG", "latest") -# Force all installs to use mainline llama.cpp from ggml-org. -# Previously: DEFAULT_PUBLISHED_REPO = os.environ.get("UNSLOTH_LLAMA_RELEASE_REPO", "unslothai/llama.cpp") -DEFAULT_PUBLISHED_REPO = "ggml-org/llama.cpp" +# Default published repo for prebuilt release resolution. Linux uses +# Unsloth prebuilts; setup.sh/setup.ps1 pass --published-repo explicitly +# for macOS/Windows to override with ggml-org/llama.cpp when needed. +DEFAULT_PUBLISHED_REPO = "unslothai/llama.cpp" DEFAULT_PUBLISHED_TAG = os.environ.get("UNSLOTH_LLAMA_RELEASE_TAG") DEFAULT_PUBLISHED_MANIFEST_ASSET = os.environ.get( "UNSLOTH_LLAMA_RELEASE_MANIFEST_ASSET", "llama-prebuilt-manifest.json" @@ -89,6 +90,12 @@ GITHUB_AUTH_HOSTS = {"api.github.com", "github.com"} RETRYABLE_HTTP_STATUS = {408, 429, 500, 502, 503, 504} HTTP_FETCH_ATTEMPTS = 4 HTTP_FETCH_BASE_DELAY_SECONDS = 0.75 +JSON_FETCH_ATTEMPTS = 3 +DEFAULT_GITHUB_RELEASE_SCAN_MAX_PAGES = env_int( + "UNSLOTH_LLAMA_GITHUB_RELEASE_SCAN_MAX_PAGES", + 5, + minimum = 1, +) SERVER_PORT_BIND_ATTEMPTS = 3 SERVER_BIND_RETRY_WINDOW_SECONDS = 5.0 TTY_PROGRESS_START_DELAY_SECONDS = 0.5 @@ -97,6 +104,58 @@ DEFAULT_MAX_PREBUILT_RELEASE_FALLBACKS = env_int( 2, minimum = 1, ) +FORCE_COMPILE_DEFAULT_REF = os.environ.get("UNSLOTH_LLAMA_FORCE_COMPILE_REF", "master") + +DIRECT_LINUX_BUNDLE_PROFILES: dict[str, dict[str, Any]] = { + "cuda12-older": { + "runtime_line": "cuda12", + "coverage_class": "older", + "supported_sms": ["70", "75", "80", "86", "89"], + "min_sm": 70, + "max_sm": 89, + "rank": 10, + }, + "cuda12-newer": { + "runtime_line": "cuda12", + "coverage_class": "newer", + "supported_sms": ["86", "89", "90", "100", "120"], + "min_sm": 86, + "max_sm": 120, + "rank": 20, + }, + "cuda12-portable": { + "runtime_line": "cuda12", + "coverage_class": "portable", + "supported_sms": ["70", "75", "80", "86", "89", "90", "100", "120"], + "min_sm": 70, + "max_sm": 120, + "rank": 30, + }, + "cuda13-older": { + "runtime_line": "cuda13", + "coverage_class": "older", + "supported_sms": ["75", "80", "86", "89"], + "min_sm": 75, + "max_sm": 89, + "rank": 40, + }, + "cuda13-newer": { + "runtime_line": "cuda13", + "coverage_class": "newer", + "supported_sms": ["86", "89", "90", "100", "120"], + "min_sm": 86, + "max_sm": 120, + "rank": 50, + }, + "cuda13-portable": { + "runtime_line": "cuda13", + "coverage_class": "portable", + "supported_sms": ["75", "80", "86", "89", "90", "100", "120"], + "min_sm": 75, + "max_sm": 120, + "rank": 60, + }, +} @dataclass @@ -753,32 +812,48 @@ def download_bytes( def fetch_json(url: str) -> Any: - try: - data = download_bytes( - url, - timeout = 30, - headers = github_api_headers(url) - if is_github_api_url(url) - else auth_headers(url), - ) - except urllib.error.HTTPError as exc: - if exc.code == 403 and is_github_api_url(url): - hint = "" - if not (os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")): - hint = "; set GH_TOKEN or GITHUB_TOKEN to avoid GitHub API rate limits" - raise RuntimeError(f"GitHub API returned 403 for {url}{hint}") from exc - raise - if not data: - raise RuntimeError(f"downloaded empty JSON payload from {url}") - try: - payload = json.loads(data.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError) as exc: - raise RuntimeError(f"downloaded invalid JSON from {url}: {exc}") from exc - if not isinstance(payload, dict) and not isinstance(payload, list): - raise RuntimeError( - f"downloaded unexpected JSON type from {url}: {type(payload).__name__}" - ) - return payload + attempts = JSON_FETCH_ATTEMPTS if is_github_api_url(url) else 1 + last_decode_exc: Exception | None = None + for attempt in range(1, attempts + 1): + try: + data = download_bytes( + url, + timeout = 30, + headers = github_api_headers(url) + if is_github_api_url(url) + else auth_headers(url), + ) + except urllib.error.HTTPError as exc: + if exc.code == 403 and is_github_api_url(url): + hint = "" + if not (os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")): + hint = ( + "; set GH_TOKEN or GITHUB_TOKEN to avoid GitHub API rate limits" + ) + raise RuntimeError(f"GitHub API returned 403 for {url}{hint}") from exc + raise + if not data: + last_decode_exc = RuntimeError(f"downloaded empty JSON payload from {url}") + else: + try: + payload = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + last_decode_exc = RuntimeError( + f"downloaded invalid JSON from {url}: {exc}" + ) + else: + if not isinstance(payload, dict) and not isinstance(payload, list): + raise RuntimeError( + f"downloaded unexpected JSON type from {url}: {type(payload).__name__}" + ) + return payload + if attempt >= attempts: + assert last_decode_exc is not None + raise last_decode_exc + log(f"json fetch failed ({attempt}/{attempts}) for {url}; retrying") + sleep_backoff(attempt) + assert last_decode_exc is not None + raise last_decode_exc def download_file(url: str, destination: Path) -> None: @@ -838,12 +913,16 @@ def download_file_verified( url: str, destination: Path, *, - expected_sha256: str, + expected_sha256: str | None, label: str, ) -> None: normalized_expected = normalize_sha256_digest(expected_sha256) if not normalized_expected: - raise PrebuiltFallback(f"{label} did not have a valid approved sha256") + download_file(url, destination) + log( + f"downloaded {label} without a published sha256; relying on install validation" + ) + return for attempt in range(1, 3): download_file(url, destination) @@ -898,7 +977,12 @@ def github_release(repo: str, tag: str) -> dict[str, Any]: return payload -def github_releases(repo: str, *, per_page: int = 100) -> list[dict[str, Any]]: +def github_releases( + repo: str, + *, + per_page: int = 100, + max_pages: int = 0, +) -> list[dict[str, Any]]: releases: list[dict[str, Any]] = [] page = 1 while True: @@ -912,6 +996,8 @@ def github_releases(repo: str, *, per_page: int = 100) -> list[dict[str, Any]]: if len(payload) < per_page: break page += 1 + if max_pages > 0 and page > max_pages: + break return releases @@ -925,6 +1011,372 @@ def latest_upstream_release_tag() -> str: return tag +def is_release_tag_like(value: str | None) -> bool: + return isinstance(value, str) and bool(re.fullmatch(r"b\d+", value.strip())) + + +def release_time_sort_key(release: dict[str, Any]) -> tuple[str, int]: + published_at = release.get("published_at") + created_at = release.get("created_at") + release_id = release.get("id") + timestamp = ( + published_at + if isinstance(published_at, str) and published_at + else created_at + if isinstance(created_at, str) and created_at + else "" + ) + try: + normalized_id = int(release_id) + except (TypeError, ValueError): + normalized_id = 0 + return (timestamp, normalized_id) + + +def iter_release_payloads_by_time( + repo: str, + published_release_tag: str = "", + requested_tag: str = "", +) -> Iterable[dict[str, Any]]: + if published_release_tag: + yield github_release(repo, published_release_tag) + return + + if ( + requested_tag + and requested_tag != "latest" + and is_release_tag_like(requested_tag) + ): + try: + yield github_release(repo, requested_tag) + return + except urllib.error.HTTPError as exc: + if exc.code == 404: + log( + f"release tag {requested_tag} not found in {repo}; scanning recent releases" + ) + else: + raise + except Exception: + raise + + releases = [ + release + for release in github_releases( + repo, max_pages = DEFAULT_GITHUB_RELEASE_SCAN_MAX_PAGES + ) + if isinstance(release, dict) + and not release.get("draft") + and not release.get("prerelease") + ] + releases.sort(key = release_time_sort_key, reverse = True) + for release in releases: + yield release + + +def direct_release_matches_request( + *, release_tag: str, llama_tag: str, requested_tag: str +) -> bool: + if requested_tag == "latest": + return True + for candidate in (release_tag, llama_tag): + if refs_match(candidate, requested_tag): + return True + return False + + +def synthetic_checksums_for_release( + repo: str, release_tag: str, upstream_tag: str +) -> ApprovedReleaseChecksums: + return ApprovedReleaseChecksums( + repo = repo, + release_tag = release_tag, + upstream_tag = upstream_tag, + artifacts = {}, + ) + + +def parse_direct_linux_release_bundle( + repo: str, release: dict[str, Any] +) -> PublishedReleaseBundle | None: + release_tag = release.get("tag_name") + if not isinstance(release_tag, str) or not release_tag: + return None + + assets = release_asset_map(release) + artifacts: list[PublishedLlamaArtifact] = [] + inferred_labels: list[str] = [] + + linux_asset_re = re.compile( + r"^app-(?P
{execution.dataset.length === 0 ? ( -

No rows returned.

+ isExecutionInProgress(execution.status) ? ( +
+ +
+

+ Generating data… +

+

+ Check the Overview tab for live terminal logs. +

+
+
+ ) : ( +

No rows returned.

+ ) ) : tableColumns.length === 0 ? (

All columns hidden. Use Columns to show at least one. diff --git a/studio/frontend/src/features/recipe-studio/components/executions/execution-overview-tab.tsx b/studio/frontend/src/features/recipe-studio/components/executions/execution-overview-tab.tsx index 06b4b20629..8e2e710299 100644 --- a/studio/frontend/src/features/recipe-studio/components/executions/execution-overview-tab.tsx +++ b/studio/frontend/src/features/recipe-studio/components/executions/execution-overview-tab.tsx @@ -116,10 +116,12 @@ export function ExecutionOverviewTab({ />

-

- LLM columns - {formatMetricValue(llmColumnCount)} -

+ {llmColumnCount > 0 && ( +

+ LLM columns + {formatMetricValue(llmColumnCount)} +

+ )}

Null rate {nullRate?.toFixed(1) ?? "--"}% @@ -164,40 +166,42 @@ export function ExecutionOverviewTab({

-
-
-

Model usage

- -
- {modelUsageRows.length === 0 ? ( -

No model usage yet.

- ) : ( -
- - - - Model - Input - Output - - - - {modelUsageRows.map((usage) => ( - - {usage.model} - - {formatMetricValue(usage.input)} - - - {formatMetricValue(usage.output)} - - - ))} - -
+ {(llmColumnCount > 0 || modelUsageRows.length > 0) && ( +
+
+

Model usage

+
- )} -
+ {modelUsageRows.length === 0 ? ( +

No model usage yet.

+ ) : ( +
+ + + + Model + Input + Output + + + + {modelUsageRows.map((usage) => ( + + {usage.model} + + {formatMetricValue(usage.input)} + + + {formatMetricValue(usage.output)} + + + ))} + +
+
+ )} +
+ )}
)}
diff --git a/studio/frontend/src/features/recipe-studio/components/executions/executions-view.tsx b/studio/frontend/src/features/recipe-studio/components/executions/executions-view.tsx index f30cf61012..f442d8f98e 100644 --- a/studio/frontend/src/features/recipe-studio/components/executions/executions-view.tsx +++ b/studio/frontend/src/features/recipe-studio/components/executions/executions-view.tsx @@ -59,7 +59,7 @@ export function ExecutionsView({ typeof value === "number" && Number.isFinite(value) ? `${value.toLocaleString()} s` : "--"; - const [detailTab, setDetailTab] = useState("overview"); + const [detailTab, setDetailTab] = useState("data"); const [hiddenDatasetColumnsByExecution, setHiddenDatasetColumnsByExecution] = useState< Record >({}); @@ -341,12 +341,16 @@ export function ExecutionsView({ }, [selectedExecution]); useEffect(() => { - if (!terminalRef.current) { + setDetailTab("data"); + }, [selectedExecution?.id]); + + useEffect(() => { + if (detailTab !== "overview" || !terminalRef.current) { return; } shouldStickTerminalToBottomRef.current = true; terminalRef.current.scrollTop = terminalRef.current.scrollHeight; - }, [selectedExecution?.id]); + }, [detailTab, selectedExecution?.id]); useEffect(() => { if (!terminalRef.current) { @@ -440,9 +444,9 @@ export function ExecutionsView({
+ Data Overview Columns - Data Raw
From a29b4e23fd92c804ce2c46ddc4acf73d756ec823 Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:48:24 +0100 Subject: [PATCH 036/216] studio: reuse HF cached repo casing to prevent duplicate downloads (#4822) * fix(studio): reuse HF cached repo casing to prevent duplicate downloads * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Move cache case resolution tests to separate PR Tests for resolve_cached_repo_id_case and get_model_config case resolution belong in their own PR to keep this change focused on the runtime fix. * fix(studio): debug-log HF_HUB_CACHE fallback in path_utils * Fix stale memoization in resolve_cached_repo_id_case - Check exact-case path before memo to ensure a newly-appeared exact match always wins over a previously memoized variant - Validate memoized entries still exist on disk before returning them to prevent stale results when cache dirs are deleted/recreated * Minor cleanups for cache case resolution - Use .is_dir() instead of .exists() for exact-case cache check (cache entries are always directories) - Remove redundant fallback in _detect_audio_from_tokenizer since get_cache_path already handles case resolution and returns None when the model is not cached --------- 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/worker.py | 3 + studio/backend/routes/models.py | 15 ++- studio/backend/utils/models/model_config.py | 31 +++-- studio/backend/utils/paths/__init__.py | 13 ++- studio/backend/utils/paths/path_utils.py | 118 +++++++++++++++++++- unsloth/models/loader.py | 23 ++-- unsloth/models/loader_utils.py | 2 +- 7 files changed, 180 insertions(+), 25 deletions(-) diff --git a/studio/backend/core/inference/worker.py b/studio/backend/core/inference/worker.py index e2513f43de..7f7291a56d 100644 --- a/studio/backend/core/inference/worker.py +++ b/studio/backend/core/inference/worker.py @@ -145,6 +145,8 @@ def _get_hf_download_state( blobs_dirs: list[Path] = [] if model_names: + from utils.paths import resolve_cached_repo_id_case + for name in model_names: if not name: continue @@ -154,6 +156,7 @@ def _get_hf_download_state( # relative paths, and Windows paths. if name.startswith(("/", ".", "~")) or "\\" in name: continue + name = resolve_cached_repo_id_case(name) # HF cache dir format: models--org--name (slashes -> --) cache_dir_name = "models--" + name.replace("/", "--") blobs_dir = cache / cache_dir_name / "blobs" diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 445cf0e7f4..1e31a91e26 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -49,8 +49,10 @@ try: ) from core.inference import get_inference_backend from utils.paths import ( + is_local_path, outputs_root, exports_root, + resolve_cached_repo_id_case, resolve_output_dir, resolve_export_dir, ) @@ -77,8 +79,10 @@ except ImportError: ) from core.inference import get_inference_backend from utils.paths import ( + is_local_path, outputs_root, exports_root, + resolve_cached_repo_id_case, resolve_output_dir, resolve_export_dir, ) @@ -597,10 +601,15 @@ async def get_model_config( This endpoint wraps the backend load_model_defaults function. """ try: - from utils.models.model_config import is_local_path - if not is_local_path(model_name): - model_name = model_name.lower() + resolved = resolve_cached_repo_id_case(model_name) + if resolved != model_name: + logger.info( + "Using cached repo_id casing '%s' for requested '%s'", + resolved, + model_name, + ) + model_name = resolved logger.info(f"Getting model config for: {model_name}") from utils.models.model_config import detect_audio_type diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index 6cffc534aa..8c25da4f43 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -11,6 +11,8 @@ from utils.paths import ( normalize_path, is_local_path, is_model_cached, + get_cache_path, + resolve_cached_repo_id_case, outputs_root, exports_root, resolve_output_dir, @@ -711,12 +713,8 @@ def _detect_audio_from_tokenizer( # 1) Check local HF cache first (works for gated/offline models) try: - from huggingface_hub.constants import HF_HUB_CACHE - - cache_dir = Path(HF_HUB_CACHE) - repo_dir_name = f"models--{model_name.replace('/', '--')}" - repo_dir = cache_dir / repo_dir_name - if repo_dir.exists(): + repo_dir = get_cache_path(model_name) + if repo_dir is not None and repo_dir.exists(): snapshots_dir = repo_dir / "snapshots" if snapshots_dir.exists(): for snapshot in snapshots_dir.iterdir(): @@ -1627,11 +1625,18 @@ class ModelConfig: identifier = f"unsloth/{identifier}" path = identifier - # Enforce lowercase for remote Hugging Face identifiers to prevent cache duplication - # Hugging Face Hub APIs are case-insensitive remotely, but case-sensitive locally (repo_folder_name). + # Preserve requested casing, but if a case-variant already exists in local HF cache, + # reuse that exact repo_id spelling to avoid one-time re-downloads after #2592. if not is_local: - identifier = identifier.lower() - path = path.lower() + resolved_identifier = resolve_cached_repo_id_case(identifier) + if resolved_identifier != identifier: + logger.info( + "Using cached repo_id casing '%s' for requested '%s'", + resolved_identifier, + identifier, + ) + identifier = resolved_identifier + path = resolved_identifier # Auto-detect GGUF models (check before LoRA/vision detection) if is_local: @@ -1852,6 +1857,12 @@ class ModelConfig: identifier = f"unsloth/{identifier}" path = identifier + if not is_local: + resolved_identifier = resolve_cached_repo_id_case(identifier) + if resolved_identifier != identifier: + identifier = resolved_identifier + path = resolved_identifier + # --- Logic for Base Model and Vision Detection --- base_model = None is_vision = False diff --git a/studio/backend/utils/paths/__init__.py b/studio/backend/utils/paths/__init__.py index 44a7c8e287..11709ae56e 100644 --- a/studio/backend/utils/paths/__init__.py +++ b/studio/backend/utils/paths/__init__.py @@ -5,7 +5,15 @@ Path utilities for model and dataset handling """ -from .path_utils import normalize_path, is_local_path, is_model_cached, get_cache_path +from .path_utils import ( + normalize_path, + is_local_path, + is_model_cached, + get_cache_path, + resolve_cached_repo_id_case, + get_cache_case_resolution_stats, + reset_cache_case_resolution_state, +) from .storage_roots import ( studio_root, assets_root, @@ -40,6 +48,9 @@ __all__ = [ "is_local_path", "is_model_cached", "get_cache_path", + "resolve_cached_repo_id_case", + "get_cache_case_resolution_stats", + "reset_cache_case_resolution_state", "studio_root", "assets_root", "datasets_root", diff --git a/studio/backend/utils/paths/path_utils.py b/studio/backend/utils/paths/path_utils.py index b38db18286..9ef9a2dd92 100644 --- a/studio/backend/utils/paths/path_utils.py +++ b/studio/backend/utils/paths/path_utils.py @@ -14,6 +14,20 @@ from loggers import get_logger logger = get_logger(__name__) +# Per-process cache to avoid repeated cache-dir scans for the same identifier. +_CACHE_CASE_RESOLUTION_MEMO: dict[str, str] = {} + +# Lightweight instrumentation counters for operational visibility. +_CACHE_CASE_RESOLUTION_STATS: dict[str, int] = { + "calls": 0, + "memo_hits": 0, + "exact_hits": 0, + "variant_hits": 0, + "tie_breaks": 0, + "fallbacks": 0, + "errors": 0, +} + def _is_wsl() -> bool: """Detect if we are running inside WSL (Windows Subsystem for Linux).""" @@ -94,8 +108,9 @@ def is_local_path(path: str) -> bool: def get_cache_path(model_name: str) -> Optional[Path]: """Get HuggingFace cache path for a model if it exists.""" - cache_dir = Path.home() / ".cache" / "huggingface" / "hub" - model_cache_name = model_name.replace("/", "--") + cache_dir = _hf_hub_cache_dir() + resolved_name = resolve_cached_repo_id_case(model_name) + model_cache_name = resolved_name.replace("/", "--") model_cache_path = cache_dir / f"models--{model_cache_name}" return model_cache_path if model_cache_path.exists() else None @@ -113,3 +128,102 @@ def is_model_cached(model_name: str) -> bool: return True return False + + +def _hf_hub_cache_dir() -> Path: + """Return HF cache root honoring HF_HUB_CACHE when available.""" + try: + from huggingface_hub.constants import HF_HUB_CACHE + + return Path(HF_HUB_CACHE) + except Exception as exc: + logger.debug( + "Could not read huggingface_hub HF_HUB_CACHE, using default hub path: %s", + exc, + ) + return Path.home() / ".cache" / "huggingface" / "hub" + + +def resolve_cached_repo_id_case(model_name: str, use_memo: bool = True) -> str: + """Resolve repo_id to the exact casing already present in local HF cache. + + Policy: prefer the requested/canonical repo_id, but if a case-variant already + exists in local HF cache, reuse that exact cached spelling. This avoids + duplicate downloads while preserving user intent whenever possible. + """ + _CACHE_CASE_RESOLUTION_STATS["calls"] += 1 + + if not model_name or "/" not in model_name: + _CACHE_CASE_RESOLUTION_STATS["fallbacks"] += 1 + return model_name + + cache_dir = _hf_hub_cache_dir() + if not cache_dir.exists(): + _CACHE_CASE_RESOLUTION_STATS["fallbacks"] += 1 + return model_name + + expected_dir = f"models--{model_name.replace('/', '--')}" + + # Always check the exact-case path first so a newly-appeared exact match + # wins over any previously memoized variant. + exact_path = cache_dir / expected_dir + if exact_path.is_dir(): + if use_memo: + _CACHE_CASE_RESOLUTION_MEMO[model_name] = model_name + _CACHE_CASE_RESOLUTION_STATS["exact_hits"] += 1 + return model_name + + # Validate memoized entries still exist on disk before returning them. + # This prevents stale results when cache dirs are deleted/recreated. + if use_memo: + cached = _CACHE_CASE_RESOLUTION_MEMO.get(model_name) + if cached is not None: + cached_path = cache_dir / f"models--{cached.replace('/', '--')}" + if cached_path.is_dir(): + _CACHE_CASE_RESOLUTION_STATS["memo_hits"] += 1 + return cached + # Stale entry -- drop it and re-scan below. + _CACHE_CASE_RESOLUTION_MEMO.pop(model_name, None) + + expected_lower = expected_dir.lower() + try: + candidates: list[str] = [] + for entry in cache_dir.iterdir(): + if not entry.is_dir(): + continue + if entry.name.lower() != expected_lower: + continue + if not entry.name.startswith("models--"): + continue + repo_part = entry.name[len("models--") :] + if not repo_part: + continue + candidates.append(repo_part.replace("--", "/")) + + if candidates: + # Deterministic tie-break if multiple case variants coexist. + resolved = sorted(candidates)[0] + if len(candidates) > 1: + _CACHE_CASE_RESOLUTION_STATS["tie_breaks"] += 1 + _CACHE_CASE_RESOLUTION_STATS["variant_hits"] += 1 + if use_memo: + _CACHE_CASE_RESOLUTION_MEMO[model_name] = resolved + return resolved + except Exception as exc: + _CACHE_CASE_RESOLUTION_STATS["errors"] += 1 + logger.debug(f"Could not resolve cached repo_id case for '{model_name}': {exc}") + + _CACHE_CASE_RESOLUTION_STATS["fallbacks"] += 1 + return model_name + + +def get_cache_case_resolution_stats() -> dict[str, int]: + """Return a copy of case-resolution instrumentation counters.""" + return dict(_CACHE_CASE_RESOLUTION_STATS) + + +def reset_cache_case_resolution_state() -> None: + """Clear resolver memo and counters (primarily for tests).""" + _CACHE_CASE_RESOLUTION_MEMO.clear() + for key in _CACHE_CASE_RESOLUTION_STATS: + _CACHE_CASE_RESOLUTION_STATS[key] = 0 diff --git a/unsloth/models/loader.py b/unsloth/models/loader.py index cfe3acb656..a6cb3eb529 100644 --- a/unsloth/models/loader.py +++ b/unsloth/models/loader.py @@ -114,6 +114,17 @@ FORCE_FLOAT32 = [ global DISABLE_COMPILE_MODEL_NAMES # Must be alphabetically sorted for each entry + + +def _strip_unsloth_bnb_4bit_suffix(model_name: str) -> str: + """Remove Unsloth 4bit suffixes without lowercasing (HF cache dirs are case-sensitive).""" + s = model_name + for suffix in ("-unsloth-bnb-4bit", "-bnb-4bit"): + if len(s) >= len(suffix) and s.lower().endswith(suffix.lower()): + s = s[: -len(suffix)] + return s + + DISABLE_COMPILE_MODEL_NAMES = [ "aya_vision", "modernbert", @@ -404,8 +415,7 @@ class FastLanguageModel(FastLlamaModel): if not ALLOW_PREQUANTIZED_MODELS and model_name.lower().endswith( ("-unsloth-bnb-4bit", "-bnb-4bit") ): - model_name = model_name.lower().removesuffix("-unsloth-bnb-4bit") - model_name = model_name.lower().removesuffix("-bnb-4bit") + model_name = _strip_unsloth_bnb_4bit_suffix(model_name) # Change -BF16 to all False for 4bit, 8bit etc if model_name.lower().endswith("-bf16"): load_in_4bit = False @@ -551,8 +561,7 @@ class FastLanguageModel(FastLlamaModel): if not ALLOW_PREQUANTIZED_MODELS and model_name.lower().endswith( ("-unsloth-bnb-4bit", "-bnb-4bit") ): - model_name = model_name.lower().removesuffix("-unsloth-bnb-4bit") - model_name = model_name.lower().removesuffix("-bnb-4bit") + model_name = _strip_unsloth_bnb_4bit_suffix(model_name) # Change -BF16 to all False for 4bit, 8bit etc if model_name.lower().endswith("-bf16"): load_in_4bit = False @@ -1019,8 +1028,7 @@ class FastModel(FastBaseModel): if not ALLOW_PREQUANTIZED_MODELS and model_name.lower().endswith( ("-unsloth-bnb-4bit", "-bnb-4bit") ): - model_name = model_name.lower().removesuffix("-unsloth-bnb-4bit") - model_name = model_name.lower().removesuffix("-bnb-4bit") + model_name = _strip_unsloth_bnb_4bit_suffix(model_name) # Change -BF16 to all False for 4bit, 8bit etc if model_name.lower().endswith("-bf16"): load_in_4bit = False @@ -1320,8 +1328,7 @@ class FastModel(FastBaseModel): if not ALLOW_PREQUANTIZED_MODELS and model_name.lower().endswith( ("-unsloth-bnb-4bit", "-bnb-4bit") ): - model_name = model_name.lower().removesuffix("-unsloth-bnb-4bit") - model_name = model_name.lower().removesuffix("-bnb-4bit") + model_name = _strip_unsloth_bnb_4bit_suffix(model_name) # Change -BF16 to all False for 4bit, 8bit etc if model_name.lower().endswith("-bf16"): load_in_4bit = False diff --git a/unsloth/models/loader_utils.py b/unsloth/models/loader_utils.py index cf5af983a6..99da5f799e 100644 --- a/unsloth/models/loader_utils.py +++ b/unsloth/models/loader_utils.py @@ -162,7 +162,7 @@ def __get_model_name( # Support returning original full -bnb-4bit name if specified specifically # since we'll map it to the dynamic version instead if lower_model_name.endswith("-bnb-4bit"): - return lower_model_name + return model_name new_model_name = FLOAT_TO_INT_MAPPER[lower_model_name] # logger.warning_once( From c027ec192ef5e4e69f4c02973081f5b3da67aa20 Mon Sep 17 00:00:00 2001 From: Neodon <82944+neodon@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:44:22 -0500 Subject: [PATCH 037/216] fix(studio): ensure first chat tool call starts in session sandbox (#4810) Fixes #4809 On a new Studio chat, the first tool call could start before the frontend initializes the thread ID. That meant the first request could go out without a session_id, so the backend started the tool in the shared sandbox root instead of the chat's session sandbox. Frontend: - Eagerly initialize the thread when switching to a new chat - Resolve the thread ID once at request time and keep it stable through async model-load waits - Disable ActiveThreadSync during new-chat initialization to prevent stale thread IDs from being written back - Add error handling for thread initialization failures - Clear activeThreadId on all compare-mode entry paths to prevent cross-session leakage - Fix exitCompare to restore context usage from the saved view - Coerce falsy thread IDs to undefined for consistent backend/frontend fallback behavior - Use _default as the image sessionId fallback to match the backend Backend: - Use ~/studio_sandbox/_default when a request arrives without a session_id --- studio/backend/core/inference/tools.py | 5 ++-- .../src/features/chat/api/chat-adapter.ts | 16 ++++++---- .../frontend/src/features/chat/chat-page.tsx | 15 ++++++++-- .../src/features/chat/runtime-provider.tsx | 30 +++++++++++++++++-- 4 files changed, 54 insertions(+), 12 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index b23372b766..86c22ce25a 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -34,7 +34,8 @@ _MAX_OUTPUT_CHARS = 8000 # truncate long output _BASH_BLOCKED_WORDS = {"rm", "sudo", "dd", "chmod", "mkfs", "shutdown", "reboot"} # Per-session working directories so each chat thread gets its own sandbox. -# Falls back to a shared ~/studio_sandbox/ for API callers without a session_id. +# Falls back to a shared ~/studio_sandbox/_default for API callers without a +# session_id. _workdirs: dict[str, str] = {} @@ -55,7 +56,7 @@ def _get_workdir(session_id: str | None = None) -> str: if not os.path.realpath(workdir).startswith(os.path.realpath(sandbox_root)): workdir = os.path.join(sandbox_root, "_invalid") else: - workdir = sandbox_root + workdir = os.path.join(sandbox_root, "_default") os.makedirs(workdir, exist_ok = True) _workdirs[key] = workdir return _workdirs[key] diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index e287daf33a..3d1bce2905 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -421,6 +421,10 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { return { async *run({ messages, abortSignal, unstable_threadId }) { let runtime = useChatRuntimeStore.getState(); + // Capture the thread ID once at the start so it stays stable even if + // the user switches chats while waiting for model load / auto-load. + const resolvedThreadId = + (unstable_threadId ?? runtime.activeThreadId) || undefined; // Wait for in-progress model load to finish before inferring if (runtime.modelLoading) { @@ -473,14 +477,14 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { } runtime.clearPendingAudio(); } - const useAdapter = await resolveUseAdapter(unstable_threadId); + const useAdapter = await resolveUseAdapter(resolvedThreadId); // ── Audio model path (non-streaming) ───────────────────── const activeModel = runtime.models.find( (m) => m.id === params.checkpoint, ); if (activeModel?.isAudio && !activeModel?.hasAudioInput) { - const threadKey = unstable_threadId || "__default"; + const threadKey = resolvedThreadId || "__default"; runtime.setThreadRunning(threadKey, true); try { yield { @@ -527,7 +531,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { return; } - const threadKey = unstable_threadId || "__default"; + const threadKey = resolvedThreadId || "__default"; let waitingFirstChunk = true; let firstTokenSettled = false; const streamStartTime = Date.now(); @@ -600,7 +604,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { const mins = useChatRuntimeStore.getState().toolCallTimeout; return mins >= 9999 ? 9999 : mins * 60; })(), - session_id: unstable_threadId || undefined, + session_id: resolvedThreadId, } : {}), }, @@ -641,7 +645,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { let parsedResult: string | { text: string; images: string[]; sessionId: string }; if (imgIdx !== -1) { const text = rawResult.slice(0, imgIdx); - const sessionId = unstable_threadId || ""; + // Fall back to "_default" to match the backend sandbox directory + // used when no session_id is provided (see tools.py _get_workdir). + const sessionId = resolvedThreadId || "_default"; try { const images = JSON.parse(rawResult.slice(imgIdx + imgMarker.length)) as string[]; parsedResult = { text, images, sessionId }; diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 08450c7ec7..1dbff145ee 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -592,6 +592,9 @@ export function ChatPage(): ReactElement { }, []); const handleNewCompare = useCallback(() => { setView({ mode: "compare", pairId: crypto.randomUUID() }); + // Clear activeThreadId so compare panes do not inherit the single-chat + // thread ID as a fallback for session_id routing. + useChatRuntimeStore.getState().setActiveThreadId(null); useChatRuntimeStore.getState().setContextUsage(null); }, []); @@ -619,6 +622,9 @@ export function ChatPage(): ReactElement { const enterCompare = useCallback(() => { setViewBeforeCompare((prev) => prev ?? view); setView({ mode: "compare", pairId: crypto.randomUUID() }); + // Clear activeThreadId so compare panes do not inherit the single-chat + // thread ID as a fallback for session_id routing. + useChatRuntimeStore.getState().setActiveThreadId(null); useChatRuntimeStore.getState().setContextUsage(null); }, [view]); @@ -626,9 +632,13 @@ export function ChatPage(): ReactElement { if (!viewBeforeCompare) return; setView(viewBeforeCompare); setViewBeforeCompare(null); - // Restore context usage from the active thread's last assistant message + // Restore context usage from the active thread's last assistant message. + // Use the thread ID from the saved view rather than the store, because + // activeThreadId may have been cleared on compare entry. const store = useChatRuntimeStore.getState(); - const threadId = store.activeThreadId; + const threadId = + ("threadId" in viewBeforeCompare ? viewBeforeCompare.threadId : null) ?? + store.activeThreadId; if (threadId) { void db.messages .where("threadId") @@ -735,6 +745,7 @@ export function ChatPage(): ReactElement { await selectModelRef.current({ id: targetLora.id, isLora: true }); if (canceled) return; setView({ mode: "compare", pairId: crypto.randomUUID() }); + useChatRuntimeStore.getState().setActiveThreadId(null); useChatRuntimeStore.getState().setContextUsage(null); clearHandoff(); console.info("[chat-handoff] loaded lora + opened compare"); diff --git a/studio/frontend/src/features/chat/runtime-provider.tsx b/studio/frontend/src/features/chat/runtime-provider.tsx index 02f792b509..7e6cd8e1dd 100644 --- a/studio/frontend/src/features/chat/runtime-provider.tsx +++ b/studio/frontend/src/features/chat/runtime-provider.tsx @@ -679,9 +679,33 @@ function ThreadNewChatSwitch({ const isLoading = useAuiState(({ threads }) => threads.isLoading); useEffect(() => { - if (!isLoading) { - aui.threads().switchToNewThread(); + if (isLoading) { + return; } + + let cancelled = false; + // Clear immediately so the adapter never picks up a stale thread ID + // from a previous chat while we initialize the new one. + useChatRuntimeStore.getState().setActiveThreadId(null); + + void (async () => { + try { + aui.threads().switchToNewThread(); + const { remoteId } = await aui.threadListItem().initialize(); + if (!cancelled) { + useChatRuntimeStore.getState().setActiveThreadId(remoteId); + } + } catch (error) { + if (!cancelled) { + useChatRuntimeStore.getState().setActiveThreadId(null); + } + console.error("Failed to initialize new chat thread", error); + } + })(); + + return () => { + cancelled = true; + }; }, [aui, isLoading, nonce]); return null; @@ -730,7 +754,7 @@ export function ChatRuntimeProvider({ return ( - + {initialThreadId && } {!initialThreadId && newThreadNonce && ( From 2c73ab7871849d50f85399d0cf8a453ce55c1baa Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 3 Apr 2026 13:33:42 -0700 Subject: [PATCH 038/216] fix(studio): harden sandbox security for terminal and python tools (#4827) * fix(studio): harden sandbox security for terminal and python tools The existing command blocklist used naive str.split() which is trivially bypassable via quoting, full paths, nested shells, variable expansion, and cross-tool pivoting through Python os.system/subprocess. Fixes #4818. Changes: - Replace str.split() blocklist with shlex.split() + os.path.basename() tokenization and regex scanning at shell command boundaries - Add sanitized subprocess environment (_build_safe_env) that strips credentials (HF_TOKEN, WANDB_API_KEY, GH_TOKEN, AWS_*, etc.) and restricts PATH to /usr/local/bin:/usr/bin:/bin - Add PR_SET_NO_NEW_PRIVS via prctl on Linux so sudo/su/pkexec fail at the kernel level regardless of how they are invoked - Add RLIMIT_NPROC (256) and RLIMIT_FSIZE (100MB) to prevent fork bombs and disk filling attacks - Extend AST safety checker to detect os.system(), os.popen(), subprocess.run/Popen/call/check_output, os.exec*, os.spawn* calls containing blocked commands or dynamic (non-literal) arguments - Add cross-platform support: cmd.exe on Windows, bash on Unix; CREATE_NO_WINDOW flag on Windows, preexec_fn on Unix - Expand blocklist from 7 to 14 commands: add su, chown, passwd, mount, umount, fdisk, kill, killall, pkill - Apply all layers to both _bash_exec and _python_exec Zero measurable performance overhead -- shlex parsing and a single prctl syscall per subprocess fork. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix review findings: exception_catching dead code, false positives, process substitution - Include exception_catching reasons in _check_code_safety so bare except-in-loop timeout evasion is actually blocked (was computed in _check_signal_escape_patterns but never read by the caller) - Remove base.split() inner loop that caused false positives on quoted text arguments containing blocked words (e.g. echo "kill this process") - Add targeted nested shell detection for bash/sh/zsh -c arguments instead, which catches bash -c 'sudo whoami' without false positives - Add <() process substitution to the regex character class so diff <(rm -rf /path) is also caught - Fix error message to say "unsafe patterns" instead of specifically mentioning signal manipulation when other categories trigger * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address review feedback: regex paths, keyword args, list element scanning - Regex now matches blocked commands after optional path prefix at shell boundaries (catches ls; /usr/bin/sudo and similar) - Nested shell detection uses os.path.basename so bash -c "/bin/rm" is caught - AST checker now inspects keyword arguments (not just positional) so subprocess.run(args="sudo ...", shell=True) is detected - List elements in subprocess calls are now checked via _find_blocked_commands for consistency (catches subprocess.run(["bash", "-c", "rm -rf /"])) - Dynamic argument check uses _is_safe_literal that validates list contents are all string literals * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix nested shell scan to only check the script body, not positional args bash -c 'script' arg0 arg1 -- only tokens[i+1] is the script body; subsequent tokens are $0, $1 positional parameters passed to the script and are not executed as shell commands. Scanning all remaining tokens caused false positives. * Add subshell parentheses to regex command boundary detection (sudo whoami) was not caught because ( was not in the regex character class for shell command boundaries. Add ( to the set alongside ;, &, |, backtick, newline. * Address high-priority review findings from 7 parallel reviewers - Track from-imports of dangerous functions (from os import system, from subprocess import run as r, etc.) via shell_exec_aliases dict so bare-name calls are detected by the AST checker - Include the active Python interpreter and virtualenv directories in the sanitized PATH so pip, uv, and Studio packages remain accessible in the sandbox - Add Windows-specific blocked commands (rmdir, takeown, icacls, runas, powershell, pwsh) only on win32 platform - Add os.posix_spawn and os.posix_spawnp to _SHELL_EXEC_FUNCS - Handle tuple literals same as list literals in AST argument inspection (both _extract_strings_from_list and _is_safe_literal) * Fix false positive on check=True kwargs and recursive nested shell scanning - Only inspect command-carrying keyword arguments (args, command, executable, path, file) in the AST checker, not control flags like check=True, text=True, capture_output=True which are booleans and were incorrectly flagged as non-literal dynamic arguments - Replace split() in nested shell detection with recursive call to _find_blocked_commands so that quoted commands (bash -c '"sudo" whoami') and semicolons (bash -c "sudo;ls") within nested shells are properly detected through the full shlex + regex pipeline * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Move preexec_fn imports to module level and use find_library for libc Addresses two Gemini review findings: 1. preexec_fn thread safety: _sandbox_preexec previously imported ctypes and resource inside the function body, which runs between fork() and exec() in the child process. In a multi-threaded server, this could deadlock if the import machinery locks were held by another thread at fork time. Now all imports and the libc handle are resolved once at module load time, so _sandbox_preexec only calls C-level functions (prctl, setrlimit) with no Python import activity. 2. Hardcoded libc.so.6 path: replaced with ctypes.util.find_library("c") which works on glibc (libc.so.6), musl (libc.musl-*.so.1), and other Linux distributions where libc has a different soname. * Apply Gemini style suggestions: combined regex, dict.fromkeys, constant hoisting - Combine per-word regex loop into a single re.findall with alternation pattern, avoiding repeated regex compilation and searching - Replace manual dedup loop with dict.fromkeys for PATH entries - Hoist _CMD_KWARGS frozenset out of visit_Call to avoid recreating it on every AST node visit * Add cmd /c nested shell detection for Windows parity The nested shell scan only checked for Unix shells (bash -c, sh -c, etc). Add cmd /c and cmd.exe /c detection so that Windows nested shell invocations are also recursively scanned for blocked commands. The token scan already catches blocked commands at any position, so this is defense-in-depth for consistency across platforms. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Handle combined shell flags (-lc, -xc) and interleaved flags (--login -c) The nested shell scan only matched token == "-c" with the immediately preceding token being a shell name. This missed: - Combined flags: bash -lc 'rm ...' (-lc ends with c, is a valid combined flag meaning -l -c) - Interleaved flags: bash --login -c 'sudo ...' (--login sits between bash and -c) Now matches any short flag ending in 'c' (e.g. -lc, -xc, -ic) and walks backwards past intermediate flags to find the shell binary. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix /bin/bash bypass, remove RLIMIT_NPROC, reduce AST false positives Addresses three high-consensus findings from 20-reviewer pass: 1. /bin/bash -c 'sudo whoami' bypassed nested shell scan because the backwards flag-skip logic treated paths starting with / as flags. Now only skips tokens starting with - as Unix flags; on Windows only skips short /X flags (not /bin/bash style paths). [9/20] 2. RLIMIT_NPROC=256 caused subprocess.run to fail with EAGAIN because Linux enforces NPROC per real UID, not per process tree. Removed RLIMIT_NPROC entirely; RLIMIT_FSIZE and PR_SET_NO_NEW_PRIVS remain as the primary resource and privilege controls. [5/20] 3. AST checker rejected safe dynamic subprocess usage like cmd=["git","status"]; subprocess.run(cmd) as shell_escape_dynamic. Now only flags dynamic args for shell-string functions (os.system, os.popen, subprocess.getoutput, etc.) or when shell=True is explicitly set. List-based subprocess calls with shell=False (the default) do not pass through a shell and are not flagged. [12/20] * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Handle Windows drive letter paths and .exe extensions in command detection Gemini review found that Windows absolute paths (C:\Windows\System32\ shutdown.exe) and executable extensions (.exe, .com, .bat, .cmd) were not handled: - Token scan now strips .exe/.com/.bat/.cmd extensions before checking the blocklist, so sudo.exe matches sudo, shutdown.bat matches shutdown - Regex pattern now includes optional Windows drive letter prefix ([a-zA-Z]:[/\\]) and optional executable extension suffix, so commands after shell metacharacters with full Windows paths are also caught * Handle **kwargs dict expansion, non-literal shell=, and except Exception false positive Addresses three findings from second 20-reviewer pass: 1. **kwargs dict expansion (9/20): subprocess.run(**{"args": "rm ...", "shell": True}) bypassed the AST checker because **kwargs were treated as opaque. Now expands literal dict **kwargs to inspect their keys, and flags opaque **kwargs (variable dicts) as unsafe. 2. Non-literal shell= values (7/20): shell=variable was treated as shell=False (safe). Now any shell= value that is not literally False is treated as potentially True (conservative default). 3. except Exception false positive (1/20): except Exception in a loop was flagged as timeout evasion, but Exception does not catch SystemExit or KeyboardInterrupt which are used for timeout enforcement. Narrowed to only flag except BaseException and except TimeoutError in loops. * [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/core/inference/tools.py | 481 ++++++++++++++++++++++++- 1 file changed, 466 insertions(+), 15 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 86c22ce25a..87cc933d4b 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -14,6 +14,8 @@ import os os.environ["UNSLOTH_IS_PRESENT"] = "1" import random +import re +import shlex import ssl import subprocess import sys @@ -27,11 +29,235 @@ logger = get_logger(__name__) _EXEC_TIMEOUT = 300 # 5 minutes +# Pre-import modules used in _sandbox_preexec at module level so that +# the preexec_fn closure does not trigger the import machinery in the +# forked child (which can deadlock in multi-threaded servers). +_libc = None +if sys.platform == "linux": + try: + import ctypes + import ctypes.util + + _libc_name = ctypes.util.find_library("c") + if _libc_name: + _libc = ctypes.CDLL(_libc_name, use_errno = True) + except (OSError, AttributeError): + pass + +_resource = None +if sys.platform != "win32": + try: + import resource as _resource + except ImportError: + pass + # Strict raster-image allowlist for sandbox file serving. # No .svg (XSS risk via embedded scripts), no .html, no .pdf. _IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}) _MAX_OUTPUT_CHARS = 8000 # truncate long output -_BASH_BLOCKED_WORDS = {"rm", "sudo", "dd", "chmod", "mkfs", "shutdown", "reboot"} +_BLOCKED_COMMANDS_COMMON = frozenset( + { + "rm", + "sudo", + "su", + "dd", + "chmod", + "chown", + "mkfs", + "shutdown", + "reboot", + "passwd", + "mount", + "umount", + "fdisk", + "kill", + "killall", + "pkill", + } +) +_BLOCKED_COMMANDS_WIN = frozenset( + { + "rmdir", + "takeown", + "icacls", + "runas", + "powershell", + "pwsh", + } +) +_BLOCKED_COMMANDS = ( + _BLOCKED_COMMANDS_COMMON | _BLOCKED_COMMANDS_WIN + if sys.platform == "win32" + else _BLOCKED_COMMANDS_COMMON +) + + +def _find_blocked_commands(command: str) -> set[str]: + """Detect blocked commands using shlex tokenization and regex scanning. + + Catches: full paths (/usr/bin/sudo), quoted strings ("sudo"), + split-quotes (su""do), backslash escapes (\\rm), and command-position + words after ;, |, &&, $(). + """ + blocked = set() + + # 1. shlex tokenization (handles quotes, escapes, concatenation) + try: + tokens = ( + shlex.split(command) + if sys.platform != "win32" + else shlex.split(command, posix = False) + ) + except ValueError: + tokens = command.split() + + for token in tokens: + base = os.path.basename(token).lower() + # Strip common Windows executable extensions so that + # runas.exe, shutdown.bat, etc. match the blocklist. + stem, ext = os.path.splitext(base) + if ext in {".exe", ".com", ".bat", ".cmd"}: + base = stem + if base in _BLOCKED_COMMANDS: + blocked.add(base) + + # 2. Regex: catch blocked words at shell command boundaries + # (semicolons, pipes, &&, ||, backticks, $(), <(), subshells, newlines) + # Uses a single combined pattern for all blocked words. + # Handles optional Unix path prefix (/usr/bin/) and Windows drive + # letter prefix (C:\Windows\...\). + lowered = command.lower() + if _BLOCKED_COMMANDS: + words_alt = "|".join(re.escape(w) for w in sorted(_BLOCKED_COMMANDS)) + pattern = ( + rf"(?:^|[;&|`\n(]\s*|[$]\(\s*|<\(\s*)" + rf"(?:[\w./\\-]*/|[a-zA-Z]:[/\\][\w./\\-]*)?" + rf"({words_alt})(?:\.(?:exe|com|bat|cmd))?\b" + ) + blocked.update(re.findall(pattern, lowered)) + + # 3. Check for nested shell invocations (bash -c 'sudo whoami', + # bash -lc '...', bash --login -c '...', cmd /c '...'). + # When a -c or /c flag is found, look backwards for a shell name + # (skipping intermediate flags like --login, -l, -x) and recursively + # scan the nested command string. + _SHELLS = {"bash", "sh", "zsh", "dash", "ksh", "csh", "tcsh", "fish"} + _SHELLS_WIN = {"cmd", "cmd.exe"} + for i, token in enumerate(tokens): + tok_lower = token.lower() + # Match -c exactly, or combined flags ending in c (e.g. -lc, -xc) + is_unix_c = tok_lower == "-c" or ( + tok_lower.startswith("-") + and tok_lower.endswith("c") + and not tok_lower.startswith("--") + ) + is_win_c = tok_lower == "/c" + if not (is_unix_c or is_win_c) or i < 1 or i + 1 >= len(tokens): + continue + # Look backwards past any flags to find the shell binary. + # On Unix, flags start with - (skip those). On Windows, flags + # start with / but so do absolute paths, so only skip short + # single-char /X flags (not /bin/bash style paths). + for j in range(i - 1, -1, -1): + prev = tokens[j] + if prev.startswith("-"): + continue # skip Unix flags like --login, -l + if is_win_c and prev.startswith("/") and len(prev) <= 3: + continue # skip Windows flags like /s, /q (not /bin/bash) + prev_base = os.path.basename(prev).lower() + if is_unix_c and prev_base in _SHELLS: + blocked |= _find_blocked_commands(tokens[i + 1]) + elif is_win_c and prev_base in _SHELLS_WIN: + blocked |= _find_blocked_commands(tokens[i + 1]) + break # stop at first non-flag token + + return blocked + + +def _build_safe_env(workdir: str) -> dict[str, str]: + """Build a minimal, credential-free environment for sandboxed subprocesses. + + Strips HF_TOKEN, WANDB_API_KEY, AWS_*, GH_TOKEN, LD_PRELOAD, DYLD_*, etc. + Preserves the active Python interpreter and virtualenv directories in PATH + so that pip, uv, and packages installed in the Studio runtime remain + accessible. + """ + # Start with the directory containing the running Python interpreter + # so that subprocess calls to 'python', 'pip', etc. resolve to the + # same environment the Studio server is running in. + exe_dir = os.path.dirname(sys.executable) + path_entries = [exe_dir] if exe_dir else [] + + # If a virtualenv is active, include its bin/Scripts directory. + venv = os.environ.get("VIRTUAL_ENV") + if venv: + venv_bin = os.path.join(venv, "Scripts" if sys.platform == "win32" else "bin") + if venv_bin not in path_entries: + path_entries.append(venv_bin) + + if sys.platform == "win32": + sysroot = os.environ.get("SystemRoot", r"C:\Windows") + path_entries.extend([os.path.join(sysroot, "System32"), sysroot]) + else: + path_entries.extend(["/usr/local/bin", "/usr/bin", "/bin"]) + + # Deduplicate while preserving order + deduped = list(dict.fromkeys(p for p in path_entries if p)) + + env = { + "PATH": os.pathsep.join(deduped), + "HOME": workdir, + "TMPDIR": workdir, + "LANG": os.environ.get("LANG", "C.UTF-8"), + "TERM": "dumb", + "PYTHONIOENCODING": "utf-8", + } + if venv: + env["VIRTUAL_ENV"] = venv + # Windows needs SystemRoot for Python/subprocess to work + if sys.platform == "win32": + env["SystemRoot"] = os.environ.get("SystemRoot", r"C:\Windows") + return env + + +def _sandbox_preexec(): + """Pre-exec hook: drop privilege escalation ability and set resource limits. + + On Linux, applies PR_SET_NO_NEW_PRIVS so sudo/su/pkexec fail at the + kernel level. On Linux and macOS, sets RLIMIT_FSIZE. + No-op on Windows (use creationflags instead). + + Note: RLIMIT_NPROC is intentionally NOT set because Linux enforces it + per real UID, not per process tree, so it would starve the Studio + server and other sessions sharing the same user account. + + All modules and handles are resolved at import time (module level) so + this function does not trigger Python imports in the forked child, + avoiding potential deadlocks in multi-threaded servers. + """ + if _libc is not None: + try: + # PR_SET_NO_NEW_PRIVS = 38, arg2 = 1 (enable) + _libc.prctl(38, 1, 0, 0, 0) + except (OSError, AttributeError): + pass # Not available (container, old kernel, etc.) + + if _resource is not None: + try: + # Limit file size to 100MB (prevents disk filling) + _resource.setrlimit( + _resource.RLIMIT_FSIZE, (100 * 1024 * 1024, 100 * 1024 * 1024) + ) + except (ValueError, OSError): + pass + + +def _get_shell_cmd(command: str) -> list[str]: + """Return the platform-appropriate shell invocation for a command string.""" + if sys.platform == "win32": + return ["cmd", "/c", command] + return ["bash", "-c", command] + # Per-session working directories so each chat thread gets its own sandbox. # Falls back to a shared ~/studio_sandbox/_default for API callers without a @@ -429,6 +655,7 @@ def _check_signal_escape_patterns(code: str): signal_tampering = [] exception_catching = [] + shell_escapes = [] warnings = [] def _ast_name_matches(node, names): @@ -446,10 +673,84 @@ def _check_signal_escape_patterns(code: str): return full_name in names return False + # Dangerous os/subprocess functions that can execute shell commands + _SHELL_EXEC_FUNCS = frozenset( + { + "os.system", + "os.popen", + "os.popen2", + "os.popen3", + "os.popen4", + "os.execl", + "os.execle", + "os.execlp", + "os.execlpe", + "os.execv", + "os.execve", + "os.execvp", + "os.execvpe", + "os.spawnl", + "os.spawnle", + "os.spawnlp", + "os.spawnlpe", + "os.spawnv", + "os.spawnve", + "os.spawnvp", + "os.spawnvpe", + "os.posix_spawn", + "os.posix_spawnp", + "subprocess.run", + "subprocess.call", + "subprocess.check_call", + "subprocess.check_output", + "subprocess.Popen", + "subprocess.getoutput", + "subprocess.getstatusoutput", + } + ) + + def _extract_string_from_node(node): + """Extract a plain string value from an AST node, if it is a constant.""" + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + return None + + def _extract_strings_from_list(node): + """Extract string elements from an AST List or Tuple node.""" + if isinstance(node, (ast.List, ast.Tuple)): + parts = [] + for elt in node.elts: + s = _extract_string_from_node(elt) + if s is not None: + parts.append(s) + return parts + return [] + + # Keyword argument names that carry command content (as opposed to + # control flags like check=True, text=True, capture_output=True). + _CMD_KWARGS = frozenset({"args", "command", "executable", "path", "file"}) + + def _check_args_for_blocked(args_nodes): + """Check if any call arguments contain blocked commands.""" + found = set() + for arg in args_nodes: + s = _extract_string_from_node(arg) + if s is not None: + found |= _find_blocked_commands(s) + strs = _extract_strings_from_list(arg) + for s in strs: + found |= _find_blocked_commands(s) + return found + class SignalEscapeVisitor(ast.NodeVisitor): def __init__(self): self.imports_signal = False self.signal_aliases = {"signal"} + self.os_aliases = {"os"} + self.subprocess_aliases = {"subprocess"} + # Maps bare function names to their fully-qualified form + # for from-import tracking (e.g. "system" -> "os.system") + self.shell_exec_aliases: dict[str, str] = {} self.loop_depth = 0 def visit_Import(self, node): @@ -458,6 +759,10 @@ def _check_signal_escape_patterns(code: str): self.imports_signal = True if alias.asname: self.signal_aliases.add(alias.asname) + elif alias.name == "os": + self.os_aliases.add(alias.asname or "os") + elif alias.name == "subprocess": + self.subprocess_aliases.add(alias.asname or "subprocess") self.generic_visit(node) def visit_ImportFrom(self, node): @@ -475,6 +780,16 @@ def _check_signal_escape_patterns(code: str): "alarm", ): self.signal_aliases.add(alias.asname or alias.name) + elif node.module in ("os", "subprocess"): + if node.module == "os": + self.os_aliases.add("os") + else: + self.subprocess_aliases.add("subprocess") + # Track from-imports of dangerous functions + for alias in node.names: + fq = f"{node.module}.{alias.name}" + if fq in _SHELL_EXEC_FUNCS: + self.shell_exec_aliases[alias.asname or alias.name] = fq self.generic_visit(node) def visit_While(self, node): @@ -539,6 +854,111 @@ def _check_signal_escape_patterns(code: str): "description": "Modifies signal mask (may block SIGALRM)", } ) + + # --- Shell escape detection --- + # Resolve the fully qualified function name for os.*/subprocess.* + shell_func = None + if isinstance(func, ast.Attribute): + if isinstance(func.value, ast.Name): + if func.value.id in self.os_aliases: + shell_func = f"os.{func.attr}" + elif func.value.id in self.subprocess_aliases: + shell_func = f"subprocess.{func.attr}" + elif isinstance(func, ast.Name): + # Check from-import aliases: from os import system; system(...) + shell_func = self.shell_exec_aliases.get(func.id) + + if shell_func and shell_func in _SHELL_EXEC_FUNCS: + # Expand **kwargs dicts to inspect their keys + expanded_kwargs: dict[str, ast.AST] = {} + has_opaque_kwargs = False + for kw in node.keywords: + if kw.arg is not None: + expanded_kwargs[kw.arg] = kw.value + elif isinstance(kw.value, ast.Dict): + for k, v in zip(kw.value.keys, kw.value.values): + key = _extract_string_from_node(k) if k else None + if key is not None: + expanded_kwargs[key] = v + else: + has_opaque_kwargs = True + + cmd_kw_values = [ + v for k, v in expanded_kwargs.items() if k in _CMD_KWARGS + ] + all_call_args = list(node.args) + cmd_kw_values + blocked_in_args = _check_args_for_blocked(all_call_args) + + if has_opaque_kwargs: + # Can't inspect dynamic **kwargs -- flag as unsafe + shell_escapes.append( + { + "type": "shell_escape_dynamic", + "line": node.lineno, + "description": ( + f"{shell_func}() called with dynamic **kwargs" + ), + } + ) + elif blocked_in_args: + shell_escapes.append( + { + "type": "shell_escape", + "line": node.lineno, + "description": ( + f"{shell_func}() invokes blocked command(s): " + f"{', '.join(sorted(blocked_in_args))}" + ), + } + ) + else: + # Only flag dynamic args for functions that interpret + # strings as shell commands, or when shell= might be + # enabled. Treat any non-literal-False shell= value + # as potentially True (conservative). + _STRING_SHELL_FUNCS = frozenset( + { + "os.system", + "os.popen", + "os.popen2", + "os.popen3", + "os.popen4", + "subprocess.getoutput", + "subprocess.getstatusoutput", + } + ) + shell_node = expanded_kwargs.get("shell") + shell_safe = shell_node is None or ( + isinstance(shell_node, ast.Constant) + and shell_node.value is False + ) + if shell_func in _STRING_SHELL_FUNCS or not shell_safe: + + def _is_safe_literal(n): + if _extract_string_from_node(n) is not None: + return True + if isinstance(n, (ast.List, ast.Tuple)): + return all( + _extract_string_from_node(e) is not None + for e in n.elts + ) + return False + + has_non_literal = any( + not _is_safe_literal(a) for a in all_call_args + ) + if has_non_literal: + shell_escapes.append( + { + "type": "shell_escape_dynamic", + "line": node.lineno, + "description": ( + f"{shell_func}() called with non-literal " + f"shell command (potential shell escape)" + ), + } + ) + self.generic_visit(node) def visit_ExceptHandler(self, node): @@ -554,7 +974,12 @@ def _check_signal_escape_patterns(code: str): } ) elif isinstance(node.type, ast.Name): - if node.type.id in ("TimeoutError", "BaseException", "Exception"): + # Only flag BaseException and TimeoutError, NOT Exception. + # except Exception does not catch SystemExit or + # KeyboardInterrupt, so it cannot suppress timeout + # enforcement. Flagging Exception causes false positives + # on normal error-handling patterns. + if node.type.id in ("TimeoutError", "BaseException"): exception_catching.append( { "type": f"catches_{node.type.id}_in_loop", @@ -565,7 +990,7 @@ def _check_signal_escape_patterns(code: str): elif isinstance(node.type, ast.Tuple): for elt in node.type.elts: if isinstance(elt, ast.Name): - if elt.id in ("TimeoutError", "BaseException", "Exception"): + if elt.id in ("TimeoutError", "BaseException"): exception_catching.append( { "type": f"catches_{elt.id}_in_loop", @@ -581,10 +1006,15 @@ def _check_signal_escape_patterns(code: str): if visitor.imports_signal and not signal_tampering: warnings.append("Code imports 'signal' module - review manually for safety") - is_safe = len(signal_tampering) == 0 and len(exception_catching) == 0 + is_safe = ( + len(signal_tampering) == 0 + and len(exception_catching) == 0 + and len(shell_escapes) == 0 + ) return is_safe, { "signal_tampering": signal_tampering, "exception_catching": exception_catching, + "shell_escapes": shell_escapes, "warnings": warnings, } @@ -605,10 +1035,18 @@ def _check_code_safety(code: str) -> str | None: reasons = [ item.get("description", "") for item in info.get("signal_tampering", []) ] - return ( - f"Error: unsafe code detected ({'; '.join(reasons)}). " - f"Please remove signal manipulation from your code." - ) + shell_reasons = [ + item.get("description", "") for item in info.get("shell_escapes", []) + ] + exception_reasons = [ + item.get("description", "") for item in info.get("exception_catching", []) + ] + all_reasons = [r for r in reasons + shell_reasons + exception_reasons if r] + if all_reasons: + return ( + f"Error: unsafe code detected ({'; '.join(all_reasons)}). " + f"Please remove unsafe patterns from your code." + ) return None @@ -663,13 +1101,20 @@ def _python_exec( with os.fdopen(fd, "w") as f: f.write(code) - proc = subprocess.Popen( - [sys.executable, tmp_path], + safe_env = _build_safe_env(workdir) + popen_kwargs = dict( stdout = subprocess.PIPE, stderr = subprocess.STDOUT, text = True, cwd = workdir, + env = safe_env, ) + if sys.platform != "win32": + popen_kwargs["preexec_fn"] = _sandbox_preexec + else: + popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + + proc = subprocess.Popen([sys.executable, tmp_path], **popen_kwargs) # Spawn cancel watcher if we have a cancel event if cancel_event is not None: @@ -735,21 +1180,27 @@ def _bash_exec( if not command or not command.strip(): return "No command provided." - # Block dangerous commands - tokens = set(command.lower().split()) - blocked = tokens & _BASH_BLOCKED_WORDS + # Block dangerous commands (shlex + regex based) + blocked = _find_blocked_commands(command) if blocked: return f"Blocked command(s) for safety: {', '.join(sorted(blocked))}" try: workdir = _get_workdir(session_id) - proc = subprocess.Popen( - ["bash", "-c", command], + safe_env = _build_safe_env(workdir) + popen_kwargs = dict( stdout = subprocess.PIPE, stderr = subprocess.STDOUT, text = True, cwd = workdir, + env = safe_env, ) + if sys.platform != "win32": + popen_kwargs["preexec_fn"] = _sandbox_preexec + else: + popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + + proc = subprocess.Popen(_get_shell_cmd(command), **popen_kwargs) if cancel_event is not None: watcher = threading.Thread( From a32b871f0e2e2f94cb88fec81596eff0796f1254 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 3 Apr 2026 13:56:59 -0700 Subject: [PATCH 039/216] studio: add speculative decoding support (ngram-mod, on by default) (#4836) * studio: add speculative decoding support (ngram-mod, on by default) Enable n-gram speculative decoding for GGUF models in Unsloth Studio. Uses llama.cpp's ngram-mod mode which gives 10-40% faster generation with zero VRAM cost via a 4MB fixed hash table that auto-resets on low acceptance rates. Backend: - Add speculative_type field to LoadRequest, LoadResponse, and InferenceStatusResponse pydantic models - Add speculative_type parameter to LlamaCppBackend.load_model() with allowlist validation (ngram-simple, ngram-mod) - Pass --spec-type, --spec-ngram-size-n 16, --draft-max 24 flags to llama-server when ngram-mod is active - Default to ngram-mod for non-vision GGUF models server-side - Silently skip speculative decoding for vision models (unsupported in llama.cpp server-context.cpp) Frontend: - Add speculative_type to TS API types - Add speculativeType/loadedSpeculativeType to chat runtime store with default value of "ngram-mod" - Add On/Off toggle in Model settings section (GGUF only, hidden for vision models), included in dirty check for Apply/Reset - Wire speculative_type through model load request and response - Restore speculative type state on page refresh/reconnect * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: remove server-side speculative decoding override The backend was overriding speculative_type=None to "ngram-mod" for non-vision GGUF models, which prevented users from disabling spec decoding via the UI toggle. The frontend store already defaults to "ngram-mod", so the backend fallback was redundant and blocked the explicit "Off" setting. * fix: use recommended ngram-mod params from llama.cpp docs Update speculative decoding params to match the recommended values from llama.cpp docs (docs/speculative.md): --spec-ngram-size-n 24 (was 16, docs say small n not recommended) --draft-min 48 (was 0) --draft-max 64 (was 24, docs note MoEs need long drafts) Also fix comment: ngram-mod uses ~16 MB (4M entries * 4 bytes), not 4 MB. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * add benchmark table and references to speculative decoding comment Include speedup numbers from llama.cpp PRs #18471 and #19164 as an inline comment so future readers understand the expected gains. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/core/inference/llama_cpp.py | 47 +++++++++++++++++++ studio/backend/models/inference.py | 12 +++++ studio/backend/routes/inference.py | 5 ++ .../src/features/chat/chat-settings-sheet.tsx | 39 ++++++++++++++- .../chat/hooks/use-chat-model-runtime.ts | 9 +++- .../chat/stores/chat-runtime-store.ts | 8 ++++ .../frontend/src/features/chat/types/api.ts | 3 ++ 7 files changed, 121 insertions(+), 2 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 4da62e23c3..c84ac640df 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -109,6 +109,7 @@ class LlamaCppBackend: self._supports_tools: bool = False self._cache_type_kv: Optional[str] = None self._reasoning_default: bool = True + self._speculative_type: Optional[str] = None # KV-cache estimation fields (populated by _read_gguf_metadata) self._n_layers: Optional[int] = None self._n_kv_heads: Optional[int] = None @@ -198,6 +199,10 @@ class LlamaCppBackend: def cache_type_kv(self) -> Optional[str]: return self._cache_type_kv + @property + def speculative_type(self) -> Optional[str]: + return self._speculative_type + # ── Binary discovery ────────────────────────────────────────── @staticmethod @@ -1055,6 +1060,7 @@ class LlamaCppBackend: n_ctx: int = 4096, chat_template_override: Optional[str] = None, cache_type_kv: Optional[str] = None, + speculative_type: Optional[str] = None, n_threads: Optional[int] = None, n_gpu_layers: Optional[int] = None, # Accepted for caller compat, unused ) -> bool: @@ -1315,6 +1321,46 @@ class LlamaCppBackend: else: self._cache_type_kv = None + # Speculative decoding (n-gram self-speculation, zero VRAM cost) + # ngram-mod: ~16 MB shared hash pool, constant memory/complexity, + # variable draft lengths. Helps most when the model repeats + # existing text (code refactoring, summarization, reasoning). + # For general chat with low repetition, overhead is ~5 ms. + # + # Benchmarks from llama.cpp PRs #18471, #19164: + # Scenario | Without | With | Speedup + # gpt-oss-120b code refactor | 181 t/s | 446 t/s | 2.5x + # Qwen3-235B offloaded | 12 t/s | 21 t/s | 1.8x + # gpt-oss-120b repeat (92% accept)| 181 t/s | 814 t/s | 4.5x + # + # Params from llama.cpp docs (docs/speculative.md): + # --spec-ngram-size-n 24 (small n not recommended) + # --draft-min 48 --draft-max 64 (MoEs need long drafts; + # dense models can reduce these) + # ref: https://github.com/ggml-org/llama.cpp/blob/master/docs/speculative.md + # ref: https://github.com/ggml-org/llama.cpp/pull/19164 + # ref: https://github.com/ggml-org/llama.cpp/pull/18471 + _valid_spec_types = {"ngram-simple", "ngram-mod"} + if speculative_type and speculative_type in _valid_spec_types: + if not is_vision: # spec decoding disabled for vision models + cmd.extend(["--spec-type", speculative_type]) + if speculative_type == "ngram-mod": + cmd.extend( + [ + "--spec-ngram-size-n", + "24", + "--draft-min", + "48", + "--draft-max", + "64", + ] + ) + self._speculative_type = speculative_type + else: + self._speculative_type = None + else: + self._speculative_type = None + # Apply custom chat template override if provided if chat_template_override: import tempfile @@ -1553,6 +1599,7 @@ class LlamaCppBackend: self._reasoning_always_on = False self._supports_tools = False self._cache_type_kv = None + self._speculative_type = None self._n_layers = None self._n_kv_heads = None self._n_heads = None diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index 3094df4169..cf08ecbc12 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -48,6 +48,10 @@ class LoadRequest(BaseModel): None, description = "Physical GPU indices to use, for example [0, 1]. Omit or pass [] to use automatic selection. Explicit gpu_ids are unsupported when the parent CUDA_VISIBLE_DEVICES uses UUID/MIG entries. Not supported for GGUF models.", ) + speculative_type: Optional[str] = Field( + None, + description = "Speculative decoding mode for GGUF models (e.g. 'ngram-simple', 'ngram-mod'). Ignored for non-GGUF and vision models.", + ) class UnloadRequest(BaseModel): @@ -163,6 +167,10 @@ class LoadResponse(BaseModel): None, description = "Jinja2 chat template string (from GGUF metadata or tokenizer)", ) + speculative_type: Optional[str] = Field( + None, + description = "Active speculative decoding mode (e.g. 'ngram-simple', 'ngram-mod'), or None if disabled", + ) class UnloadResponse(BaseModel): @@ -225,6 +233,10 @@ class InferenceStatusResponse(BaseModel): None, description = "Model's native context length from GGUF metadata (not capped by VRAM)", ) + speculative_type: Optional[str] = Field( + None, + description = "Active speculative decoding mode (e.g. 'ngram-simple', 'ngram-mod'), or None if disabled", + ) # ===================================================================== diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index ced24c1d5f..30ff7da49c 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -179,6 +179,7 @@ async def load_model( supports_reasoning = llama_backend.supports_reasoning, reasoning_always_on = llama_backend.reasoning_always_on, chat_template = llama_backend.chat_template, + speculative_type = llama_backend.speculative_type, ) else: if ( @@ -263,6 +264,7 @@ async def load_model( n_ctx = request.max_seq_length, chat_template_override = request.chat_template_override, cache_type_kv = request.cache_type_kv, + speculative_type = request.speculative_type, ) else: # Local mode: llama-server loads via -m @@ -275,6 +277,7 @@ async def load_model( n_ctx = request.max_seq_length, chat_template_override = request.chat_template_override, cache_type_kv = request.cache_type_kv, + speculative_type = request.speculative_type, ) if not success: @@ -317,6 +320,7 @@ async def load_model( supports_tools = llama_backend.supports_tools, cache_type_kv = llama_backend.cache_type_kv, chat_template = llama_backend.chat_template, + speculative_type = llama_backend.speculative_type, ) # ── Standard path: load via Unsloth/transformers ────────── @@ -652,6 +656,7 @@ async def get_status( context_length = llama_backend.context_length, max_context_length = llama_backend.max_context_length, native_context_length = llama_backend.native_context_length, + speculative_type = llama_backend.speculative_type, ) # Otherwise, report Unsloth backend status diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index e5b0814343..550df2bf7c 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -280,6 +280,15 @@ export function ChatSettingsPanel({ }: ChatSettingsPanelProps) { const isMobile = useIsMobile(); const isGguf = useChatRuntimeStore((s) => s.activeGgufVariant) != null; + const speculativeType = useChatRuntimeStore((s) => s.speculativeType); + const setSpeculativeType = useChatRuntimeStore((s) => s.setSpeculativeType); + const loadedSpeculativeType = useChatRuntimeStore( + (s) => s.loadedSpeculativeType, + ); + const currentModels = useChatRuntimeStore((s) => s.models); + const currentCheckpoint = params.checkpoint; + const currentModelIsVision = + currentModels.find((m) => m.id === currentCheckpoint)?.isVision ?? false; const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength); const ggufMaxContextLength = useChatRuntimeStore( (s) => s.ggufMaxContextLength, @@ -299,7 +308,8 @@ export function ChatSettingsPanel({ const ctxMaxValue = ggufNativeContextLength ?? ggufContextLength ?? null; const kvDirty = kvCacheDtype !== loadedKvCacheDtype; const ctxDirty = customContextLength !== null; - const modelSettingsDirty = kvDirty || ctxDirty; + const specDirty = speculativeType !== loadedSpeculativeType; + const modelSettingsDirty = kvDirty || ctxDirty || specDirty; const [customPresets, setCustomPresets] = useState(() => loadSavedCustomPresets(), ); @@ -580,6 +590,32 @@ export function ChatSettingsPanel({
+ {!currentModelIsVision && ( +
+
+
+ Speculative Decoding +
+
+ Speed up generation with no VRAM cost. +
+
+ +
+ )} {modelSettingsDirty && (
From 278f4629962358268674de0a102f7537c2b0ab3a Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:41:20 +0400 Subject: [PATCH 045/216] [Studio][Optimization]Add vision detection cache to is_vision_model() (#4853) * Add vision detection cache to is_vision_model() to avoid redundant subprocess spawns is_vision_model() is called 4-5 times per training run for the same model with zero caching. For transformers 5.x models, each call spawns a full subprocess (~6s each). This adds a module-level _vision_detection_cache dict following the same pattern as the existing _audio_detection_cache used by detect_audio_type(). The function is refactored into a thin cache wrapper around _is_vision_model_uncached(), saving ~12s per training run. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Include hf_token in vision cache key for gated model correctness Cache key is now (model_name, hf_token) instead of just model_name. This prevents stale False results when an unauthenticated probe for a gated model is followed by an authenticated call. * Remove test file from main PR - will be submitted separately * Fix vision cache: normalize model names and skip caching transient failures - Normalize model names in cache key using resolve_cached_repo_id_case() to avoid duplicate entries for different casings of the same HF repo (aligns with case normalization from #4822) - Return None instead of False on transient failures (network errors, subprocess timeouts, HF API issues) so the cache layer can distinguish "definitely not a vision model" from "failed to check" - Only cache definitive True/False results; transient failures are retried on the next call instead of being permanently locked in as False * Refine failure handling: cache deterministic failures, guard normalization - Subprocess non-zero exit, JSON errors, and general exceptions return False (deterministic, cached) instead of None (retryable). Only subprocess.TimeoutExpired returns None since timeouts are transient. - Wrap cache key normalization in try/except so resolve_cached_repo_id_case or normalize_path failures fall back to raw model_name instead of crashing callers. * Harden vision detection cache: fix transient failure handling, thread safety, token security - All subprocess failure paths now return None (transient) instead of False, preventing permanent misclassification of VLMs after temporary HF/auth/network errors - Use SHA256 fingerprint for hf_token in cache key instead of raw bearer token - Add threading.Lock with double-checked locking to prevent thundering herd of concurrent subprocess spawns for the same uncached model - Distinguish permanent failures (RepositoryNotFoundError, GatedRepoError, ValueError) from transient ones in _is_vision_model_uncached - Pass resolved/normalized model name to detection (not just cache key) - Log normalization fallback at debug level instead of silent swallow - Thread hf_token through callers in routes/models.py and trainer.py that previously omitted it * Refine lock strategy and token fingerprint - Move detection computation outside the lock to avoid serializing long-running subprocess spawns (60s timeout) and HF API calls across all concurrent model checks. Lock is now only held for cache writes. - Use full SHA256 digest for token fingerprint instead of truncated 16-char prefix to eliminate collision risk. * Fix huggingface_hub import fallback and use atomic cache read - Add fallback import path for RepositoryNotFoundError/GatedRepoError from huggingface_hub.utils (older hub versions) when .errors is not available - Use sentinel-based dict.get() for single atomic cache read instead of two-step in/[] pattern (future-proof for no-GIL runtimes) * [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: Daniel Han --- studio/backend/core/training/trainer.py | 12 ++- studio/backend/routes/models.py | 2 +- studio/backend/utils/models/model_config.py | 112 ++++++++++++++++++-- 3 files changed, 115 insertions(+), 11 deletions(-) diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py index ab1825d94a..77cbda6b45 100644 --- a/studio/backend/core/training/trainer.py +++ b/studio/backend/core/training/trainer.py @@ -190,7 +190,11 @@ class UnslothTrainer: self._cuda_audio_used = False # --- Detect VLM --- - vision = is_vision_model(model_name) if not self.is_audio else False + vision = ( + is_vision_model(model_name, hf_token = hf_token) + if not self.is_audio + else False + ) self.is_vlm = not self.is_audio_vlm and vision and is_dataset_image logger.info( @@ -558,7 +562,11 @@ class UnslothTrainer: self._cuda_audio_used = False # VLM: vision model with image dataset (mutually exclusive with audio paths) - vision = is_vision_model(model_name) if not self.is_audio else False + vision = ( + is_vision_model(model_name, hf_token = hf_token) + if not self.is_audio + else False + ) self.is_vlm = not self.is_audio_vlm and vision and is_dataset_image self.model_name = model_name self.max_seq_length = max_seq_length diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 1e31a91e26..ea385436b8 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -618,7 +618,7 @@ async def get_model_config( config_dict = load_model_defaults(model_name) # Detect model capabilities (pass HF token for gated models) - is_vision = is_vision_model(model_name) + is_vision = is_vision_model(model_name, hf_token = hf_token) is_embedding = is_embedding_model(model_name, hf_token = hf_token) audio_type = detect_audio_type(model_name, hf_token = hf_token) diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index f97ea993eb..9d72846ca3 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -26,7 +26,9 @@ import subprocess import sys from pathlib import Path from typing import List, Tuple +import hashlib import json +import threading import yaml @@ -548,12 +550,17 @@ except Exception as exc: def _is_vision_model_subprocess( model_name: str, hf_token: Optional[str] = None -) -> bool: +) -> Optional[bool]: """Run is_vision_model check in a subprocess with transformers 5.x. Same pattern as training/inference workers: spawn a clean subprocess with .venv_t5/ prepended to sys.path so AutoConfig recognizes newer architectures (glm4_moe_lite, etc.). + + Returns True/False for definitive results, or None for transient failures + (timeouts, subprocess errors) so callers can decide whether to cache + the result. Subprocess failures are treated as transient because they + can be caused by temporary HF/auth/network issues. """ token_arg = hf_token or "" @@ -580,7 +587,7 @@ def _is_vision_model_subprocess( model_name, stderr or result.stdout.strip(), ) - return False + return None data = json.loads(result.stdout.strip()) if "error" in data: @@ -589,7 +596,7 @@ def _is_vision_model_subprocess( model_name, data["error"], ) - return False + return None is_vlm = data["is_vision"] logger.info( @@ -604,10 +611,28 @@ def _is_vision_model_subprocess( except subprocess.TimeoutExpired: logger.warning("Vision check subprocess timed out for '%s'", model_name) - return False + return None except Exception as exc: logger.warning("Vision check subprocess failed for '%s': %s", model_name, exc) - return False + return None + + +def _token_fingerprint(token: Optional[str]) -> Optional[str]: + """Return a SHA256 digest of the token for use as a cache key. + + Avoids storing the raw bearer token in process memory as a dict key. + """ + if token is None: + return None + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +# Cache vision detection results per session to avoid repeated subprocess spawns. +# Keyed by (normalized_model_name, token_fingerprint) to handle gated models correctly. +# Only definitive results (True/False from successful detection) are cached; +# transient failures (network errors, timeouts) are NOT cached so they can be retried. +_vision_detection_cache: Dict[Tuple[str, Optional[str]], bool] = {} +_vision_cache_lock = threading.Lock() def is_vision_model(model_name: str, hf_token: Optional[str] = None) -> bool: @@ -616,13 +641,66 @@ def is_vision_model(model_name: str, hf_token: Optional[str] = None) -> bool: Works for fine-tuned models since they inherit the base architecture. For models that require transformers 5.x (e.g. GLM-4.7-Flash), the check - runs in a subprocess with .venv_t5/ activated — same pattern as the + runs in a subprocess with .venv_t5/ activated -- same pattern as the training and inference workers. + Results are cached per (model_name, token_fingerprint) for the lifetime of + the process to avoid repeated subprocess spawns and HuggingFace API calls. + Transient failures are not cached so they can be retried on the next call. + Args: model_name: Model identifier (HF repo or local path) hf_token: Optional HF token for accessing gated/private models """ + # Normalize model name for cache key to avoid duplicate entries for + # different casings of the same HF repo (e.g. "Org/Model" vs "org/model"). + try: + if is_local_path(model_name): + resolved_name = normalize_path(model_name) + else: + resolved_name = resolve_cached_repo_id_case(model_name) + except Exception as exc: + logger.debug( + "Could not normalize model name '%s' for cache key: %s", + model_name, + exc, + ) + resolved_name = model_name + cache_key = (resolved_name, _token_fingerprint(hf_token)) + + # Lock-free fast path for cache hits. Uses a sentinel to distinguish + # "key not found" from "value is False" in a single atomic dict.get() call. + _MISS = object() + cached = _vision_detection_cache.get(cache_key, _MISS) + if cached is not _MISS: + return cached + + # Compute outside the lock to avoid serializing long-running detection + # (subprocess spawns with 60s timeout, HF API calls) across all models. + # The tradeoff: two concurrent calls for the same uncached model may + # both run detection, but they produce the same result and the second + # write is a benign no-op. + result = _is_vision_model_uncached(resolved_name, hf_token) + # Only cache definitive results; None means a transient failure occurred + # and we should retry on the next call instead of locking in a wrong answer. + if result is not None: + with _vision_cache_lock: + _vision_detection_cache[cache_key] = result + return result + return False + + +def _is_vision_model_uncached( + model_name: str, hf_token: Optional[str] = None +) -> Optional[bool]: + """Uncached vision model detection -- called by is_vision_model(). + + Returns True/False for definitive results, or None when detection failed + due to a transient error (network, timeout, subprocess failure) so the + caller knows not to cache the result. + + Do not call directly; use is_vision_model() instead. + """ # Models that need transformers 5.x must be checked in a subprocess # because AutoConfig in the main process (transformers 4.57.x) doesn't # recognize their architectures. @@ -630,7 +708,7 @@ def is_vision_model(model_name: str, hf_token: Optional[str] = None) -> bool: if needs_transformers_5(model_name): logger.info( - "Model '%s' needs transformers 5.x — checking vision via subprocess", + "Model '%s' needs transformers 5.x -- checking vision via subprocess", model_name, ) return _is_vision_model_subprocess(model_name, hf_token = hf_token) @@ -681,7 +759,25 @@ def is_vision_model(model_name: str, hf_token: Optional[str] = None) -> bool: except Exception as e: logger.warning(f"Could not determine if {model_name} is vision model: {e}") - return False + # Permanent failures (model not found, gated, bad config) should be + # cached as False. Transient failures (network, timeout) should not. + try: + from huggingface_hub.errors import RepositoryNotFoundError, GatedRepoError + except ImportError: + try: + from huggingface_hub.utils import ( + RepositoryNotFoundError, + GatedRepoError, + ) + except ImportError: + RepositoryNotFoundError = GatedRepoError = None + if RepositoryNotFoundError is not None and isinstance( + e, (RepositoryNotFoundError, GatedRepoError) + ): + return False + if isinstance(e, (ValueError, json.JSONDecodeError)): + return False + return None VALID_AUDIO_TYPES = ("snac", "csm", "bicodec", "dac", "whisper", "audio_vlm") From ab65b47c7312b0ac18e78a708180d08c75fed423 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 6 Apr 2026 06:41:40 -0700 Subject: [PATCH 046/216] Add tests for is_vision_model() caching behaviour (#4855) * Add tests for is_vision_model() caching behaviour * Fix review feedback: remove dead helper, fix exception test - Remove unused _make_config() helper function (dead code) - Fix test_exception_result_cached to actually exercise the exception path by mocking load_model_config to raise OSError instead of using side_effect=[False] which only tested normal False returns * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Use strict mock specs so tests exercise intended detection paths Use MagicMock(spec=[]) for all config mocks so hasattr() only returns True for explicitly set attributes. Without this, MagicMock defaults make all hasattr checks truthy, allowing tests to pass via unintended detection paths (e.g. img_processor instead of vision_config). --------- Co-authored-by: Roland Tannous Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/tests/test_vision_cache.py | 238 ++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 studio/backend/tests/test_vision_cache.py diff --git a/studio/backend/tests/test_vision_cache.py b/studio/backend/tests/test_vision_cache.py new file mode 100644 index 0000000000..fae1e95311 --- /dev/null +++ b/studio/backend/tests/test_vision_cache.py @@ -0,0 +1,238 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Tests for is_vision_model() caching behaviour. + +The vision detection cache (``_vision_detection_cache``) mirrors the existing +``_audio_detection_cache`` pattern used by ``detect_audio_type()``. These +tests verify that: + +* Repeated calls for the same model hit the cache (no redundant work). +* Different models each trigger their own detection. +* Both True and False results are cached. +* The subprocess path (transformers 5.x models) is also cached. +* Exceptions that fall back to False are cached. +""" + +import sys +import types as _types +from pathlib import Path +from unittest.mock import patch, MagicMock + +import pytest + +# --------------------------------------------------------------------------- +# sys.path + logger stub — same pattern as the rest of the test suite +# --------------------------------------------------------------------------- +_BACKEND_DIR = str(Path(__file__).resolve().parent.parent) +if _BACKEND_DIR not in sys.path: + sys.path.insert(0, _BACKEND_DIR) + +_loggers_stub = _types.ModuleType("loggers") +_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name) +sys.modules.setdefault("loggers", _loggers_stub) + +from utils.models.model_config import ( + is_vision_model, + _is_vision_model_uncached, + _vision_detection_cache, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse = True) +def _clear_vision_cache(): + """Ensure every test starts with a fresh cache.""" + _vision_detection_cache.clear() + yield + _vision_detection_cache.clear() + + +# --------------------------------------------------------------------------- +# Cache hit / miss tests +# --------------------------------------------------------------------------- + + +class TestVisionCacheHitMiss: + """Verify the cache prevents redundant detection calls.""" + + @patch("utils.models.model_config._is_vision_model_uncached", return_value = True) + def test_second_call_uses_cache(self, mock_uncached): + """Calling is_vision_model() twice for the same model should invoke + the uncached function only once.""" + assert is_vision_model("org/my-vlm") is True + assert is_vision_model("org/my-vlm") is True + mock_uncached.assert_called_once_with("org/my-vlm", None) + + @patch("utils.models.model_config._is_vision_model_uncached", return_value = False) + def test_different_models_each_detected(self, mock_uncached): + """Different model names should each trigger detection.""" + is_vision_model("model-a") + is_vision_model("model-b") + assert mock_uncached.call_count == 2 + + @patch("utils.models.model_config._is_vision_model_uncached", return_value = True) + def test_cache_returns_correct_value(self, mock_uncached): + """The cached value must match what _is_vision_model_uncached returned.""" + first = is_vision_model("org/vlm") + second = is_vision_model("org/vlm") + assert first is True + assert second is True + + +class TestVisionCacheStoresFalse: + """Non-VLM results (False) must also be cached to avoid re-detection.""" + + @patch("utils.models.model_config._is_vision_model_uncached", return_value = False) + def test_false_result_cached(self, mock_uncached): + assert is_vision_model("org/text-only") is False + assert is_vision_model("org/text-only") is False + mock_uncached.assert_called_once() + assert _vision_detection_cache[("org/text-only", None)] is False + + +# --------------------------------------------------------------------------- +# Subprocess path (transformers 5.x) caching +# --------------------------------------------------------------------------- + + +class TestVisionCacheSubprocessPath: + """Models needing transformers 5.x go through _is_vision_model_subprocess. + The cache should prevent the subprocess from being spawned more than once + per model per process.""" + + @patch("utils.models.model_config._is_vision_model_subprocess", return_value = True) + @patch("utils.transformers_version.needs_transformers_5", return_value = True) + def test_subprocess_called_once_with_cache(self, mock_needs_t5, mock_subprocess): + """Subprocess should only fire on the first call; second is cached.""" + # First call: goes through uncached → subprocess + assert is_vision_model("unsloth/Qwen3.5-2B") is True + # Second call: cache hit, no subprocess + assert is_vision_model("unsloth/Qwen3.5-2B") is True + + mock_subprocess.assert_called_once() + assert _vision_detection_cache[("unsloth/Qwen3.5-2B", None)] is True + + +# --------------------------------------------------------------------------- +# Exception handling — cache the False fallback +# --------------------------------------------------------------------------- + + +class TestVisionCacheOnException: + """When detection raises an exception, _is_vision_model_uncached catches + it and returns False. That False must be cached so subsequent calls don't + retry and fail again.""" + + @patch( + "utils.models.model_config.load_model_config", + side_effect = OSError("network down"), + ) + @patch("utils.transformers_version.needs_transformers_5", return_value = False) + def test_exception_result_cached(self, mock_needs_t5, mock_load_config): + """A real exception inside _is_vision_model_uncached should be caught, + return False, and that False should be cached for subsequent calls.""" + # First call: load_model_config raises → except branch → False + assert is_vision_model("broken/model") is False + # Second call: cache hit, load_model_config not called again + assert is_vision_model("broken/model") is False + mock_load_config.assert_called_once() + + +# --------------------------------------------------------------------------- +# Direct detection path (non-transformers-5 models) caching +# --------------------------------------------------------------------------- + + +class TestVisionCacheDirectPath: + """For models that do NOT need transformers 5.x, the detection goes through + load_model_config directly. The cache must work the same way.""" + + @patch("utils.transformers_version.needs_transformers_5", return_value = False) + @patch("utils.models.model_config.load_model_config") + def test_direct_vlm_detection_cached(self, mock_load_config, mock_needs_t5): + """A standard VLM detected via architecture suffix should be cached.""" + cfg = MagicMock(spec = []) # strict: only explicitly set attrs exist + cfg.model_type = "gemma3" + cfg.architectures = ["Gemma3ForConditionalGeneration"] + mock_load_config.return_value = cfg + + assert is_vision_model("google/gemma-3-4b-it") is True + assert is_vision_model("google/gemma-3-4b-it") is True + # load_model_config should only be called once + mock_load_config.assert_called_once() + + @patch("utils.transformers_version.needs_transformers_5", return_value = False) + @patch("utils.models.model_config.load_model_config") + def test_direct_non_vlm_detection_cached(self, mock_load_config, mock_needs_t5): + """A standard text model (no VLM indicators) should cache False.""" + cfg = MagicMock(spec = []) # spec=[] means no attributes at all + cfg.model_type = "llama" + cfg.architectures = ["LlamaForCausalLM"] + mock_load_config.return_value = cfg + + # LlamaForCausalLM doesn't end with VLM suffixes, no vision_config, etc. + assert is_vision_model("meta-llama/Llama-3-8B") is False + assert is_vision_model("meta-llama/Llama-3-8B") is False + mock_load_config.assert_called_once() + + @patch("utils.transformers_version.needs_transformers_5", return_value = False) + @patch("utils.models.model_config.load_model_config") + def test_vision_config_attr_detected_and_cached( + self, mock_load_config, mock_needs_t5 + ): + """Models with vision_config (LLaVA, Qwen2-VL, etc.) should be cached as True.""" + cfg = MagicMock(spec = []) # strict: only explicitly set attrs exist + cfg.model_type = "qwen2_vl" + cfg.architectures = ["Qwen2VLForCausalLM"] # Doesn't match VLM suffixes + cfg.vision_config = {"hidden_size": 1024} + mock_load_config.return_value = cfg + + assert is_vision_model("Qwen/Qwen2-VL-7B") is True + assert is_vision_model("Qwen/Qwen2-VL-7B") is True + mock_load_config.assert_called_once() + + @patch("utils.transformers_version.needs_transformers_5", return_value = False) + @patch("utils.models.model_config.load_model_config") + def test_audio_model_excluded_and_cached(self, mock_load_config, mock_needs_t5): + """Audio-only models (csm, whisper) with ForConditionalGeneration + should be excluded from VLM detection and cached as False.""" + cfg = MagicMock(spec = []) # strict: only explicitly set attrs exist + cfg.model_type = "whisper" + cfg.architectures = ["WhisperForConditionalGeneration"] + mock_load_config.return_value = cfg + + assert is_vision_model("openai/whisper-large-v3") is False + assert is_vision_model("openai/whisper-large-v3") is False + mock_load_config.assert_called_once() + + +# --------------------------------------------------------------------------- +# hf_token handling +# --------------------------------------------------------------------------- + + +class TestVisionCacheTokenHandling: + """The cache is keyed on (model_name, hf_token). + Different tokens for the same model should trigger separate detections + to handle gated models correctly.""" + + @patch("utils.models.model_config._is_vision_model_uncached", return_value = True) + def test_different_tokens_trigger_new_detection(self, mock_uncached): + """Calls with different tokens should trigger separate detections to + handle gated models correctly (e.g. unauthenticated probe → False, + then authenticated call should re-check).""" + assert is_vision_model("gated/model", hf_token = "token-a") is True + assert is_vision_model("gated/model", hf_token = "token-b") is True + assert mock_uncached.call_count == 2 + + @patch("utils.models.model_config._is_vision_model_uncached", return_value = True) + def test_same_token_uses_cache(self, mock_uncached): + """Repeated calls with identical model + token should hit cache.""" + assert is_vision_model("gated/model", hf_token = "token-a") is True + assert is_vision_model("gated/model", hf_token = "token-a") is True + mock_uncached.assert_called_once() From 07b6fcc344adff761f2130ac8434bac594a13c68 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 6 Apr 2026 07:33:28 -0700 Subject: [PATCH 047/216] Remove Gemma-4 from FORCE_FLOAT32 (#4875) Gemma-4 does not need FORCE_FLOAT32. Testing shows that both float16 and bfloat16 work correctly without the forced float32 override: - Inference: identical outputs for float16 and bfloat16 (greedy decoding) - Training (100 steps, 4-bit LoRA, SFT on FineTome-100k): - float16 final loss: 3.048 - bfloat16 final loss: 3.065 - Losses converge to within 0.02 by step 60 - Grad norms healthy and comparable for both dtypes The FORCE_FLOAT32 path was actually causing training divergence. With it enabled, the compiled float32 run diverged at step ~28 with grad norms collapsing to near zero and loss plateauing at ~12.4. Without it, both dtypes train normally. This enables float16 on Tesla T4 and other GPUs without bfloat16 support. --- unsloth/models/loader.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/unsloth/models/loader.py b/unsloth/models/loader.py index a6cb3eb529..5827711d04 100644 --- a/unsloth/models/loader.py +++ b/unsloth/models/loader.py @@ -108,8 +108,6 @@ FORCE_FLOAT32 = [ "gemma3n", "gpt_oss", "qwen3_5", # Qwen3.5 GDN layers produce NaN grad norms in float16 training - "gemma4,", # Add comma bc gemma4 will match gemma4_text - "gemma4_text", ] global DISABLE_COMPILE_MODEL_NAMES From 0835f0a61bad20f286507ffeaee3831944ec782c Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:46:39 +0400 Subject: [PATCH 048/216] fix: skip redundant HfFileSystem().glob() calls in loader.py (#4852) * fix: skip redundant HfFileSystem().glob() calls in loader.py Guard the SUPPORTS_LLAMA32 glob blocks with `is_model and is_peft` so the HfFileSystem HTTP call is only made when both configs could actually exist. This prevents indefinite hangs on slow/unreliable networks since the glob result is redundant when either AutoConfig or PeftConfig already failed to load. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Remove test file from main PR - moved to separate PR Tests for the glob skip guard belong in their own PR to keep the loader change minimal and reviewable. * Harden HfFileSystem glob: fix Windows path splitting, add try/except - Use str.rsplit("/", 1) instead of os.path.split to extract filenames from HfFileSystem paths. HfFileSystem always returns POSIX-style paths, but os.path.split uses the OS separator, so on Windows the entire path was returned as the "filename" and the config name comparison always failed. - Wrap the HfFileSystem().glob() call in try/except to gracefully handle network failures (offline mode, timeouts, unreachable Hub). On failure both_exist stays False, which is the safe default. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Remove redundant HfFileSystem().glob() call for remote repos When is_model and is_peft are both True, AutoConfig and PeftConfig have already loaded successfully, proving both config.json and adapter_config.json exist. The HfFileSystem network call to re-verify this was redundant and could cause hangs on slow networks. Replace the glob + try/except block with a direct both_exist = True assignment. * Remove unused HfFileSystem import HfFileSystem was only used for the glob() calls that were replaced with direct both_exist = True assignments in the previous commit. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han --- unsloth/models/loader.py | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/unsloth/models/loader.py b/unsloth/models/loader.py index 5827711d04..df97c5c7df 100644 --- a/unsloth/models/loader.py +++ b/unsloth/models/loader.py @@ -49,7 +49,6 @@ except: except: # For older versions of huggingface_hub from huggingface_hub.utils._token import get_token -from huggingface_hub import HfFileSystem import importlib.util from ..device_type import ( is_hip, @@ -506,7 +505,7 @@ class FastLanguageModel(FastLlamaModel): model_type = model_types # New transformers need to check manually. - if SUPPORTS_LLAMA32: + if SUPPORTS_LLAMA32 and is_model and is_peft: # Check if folder exists locally if os.path.isdir(model_name): exist_adapter_config = os.path.exists( @@ -515,14 +514,10 @@ class FastLanguageModel(FastLlamaModel): exist_config = os.path.exists(os.path.join(model_name, "config.json")) both_exist = exist_adapter_config and exist_config else: - # Because HfFileSystem assumes linux paths, we need to set the path with forward slashes, even on Windows. - files = HfFileSystem(token = token).glob(f"{model_name}/*.json") - files = list(os.path.split(x)[-1] for x in files) - if ( - sum(x == "adapter_config.json" or x == "config.json" for x in files) - >= 2 - ): - both_exist = True + # Both AutoConfig and PeftConfig loaded successfully from this + # remote repo, so both config.json and adapter_config.json + # definitely exist -- no need for an extra HfFileSystem network call. + both_exist = True if not is_model and not is_peft: error = autoconfig_error if autoconfig_error is not None else peft_error @@ -1280,7 +1275,7 @@ class FastModel(FastBaseModel): os.environ["UNSLOTH_DISABLE_STATIC_GENERATION"] = "1" # New transformers need to check manually. - if SUPPORTS_LLAMA32: + if SUPPORTS_LLAMA32 and is_model and is_peft: # Check if folder exists locally if os.path.isdir(model_name): exist_adapter_config = os.path.exists( @@ -1289,13 +1284,10 @@ class FastModel(FastBaseModel): exist_config = os.path.exists(os.path.join(model_name, "config.json")) both_exist = exist_adapter_config and exist_config else: - files = HfFileSystem(token = token).glob(f"{model_name}/*.json") - files = list(os.path.split(x)[-1] for x in files) - if ( - sum(x == "adapter_config.json" or x == "config.json" for x in files) - >= 2 - ): - both_exist = True + # Both AutoConfig and PeftConfig loaded successfully from this + # remote repo, so both config.json and adapter_config.json + # definitely exist -- no need for an extra HfFileSystem network call. + both_exist = True if not is_model and not is_peft: error = autoconfig_error if autoconfig_error is not None else peft_error From aa4c6010e189c3391bed238110cf46007b32a7d0 Mon Sep 17 00:00:00 2001 From: JYYYYYT <18164100443@163.com> Date: Mon, 6 Apr 2026 23:31:07 +0800 Subject: [PATCH 049/216] fix(studio): custom folder scan fails to find GGUF variants when pointing directly at a model directory (#4860) Fix custom folder scanning when pointing directly at a model directory. When a user adds a custom scan folder that points directly at a model directory (e.g. /path/to/gemma-4-e2b-it-gguf/ containing config.json and gemma-4-E2B-it-BF16.gguf), the model list previously showed individual .gguf files as separate entries instead of recognizing the directory as a single model. Clicking any entry showed "No GGUF variants found" because list_local_gguf_variants received a file path and immediately returned empty. Changes: - Add _is_model_directory() helper that detects directories with both config metadata and actual model weight files (excludes mmproj GGUFs and non-weight .bin files like tokenizer.bin) - _scan_models_dir: detect self-model and return single directory entry - _scan_lmstudio_dir: surface model directories directly instead of descending into them as publisher folders; handle both root and child model directories - Add _resolve_gguf_dir() helper for GGUF path resolution that only falls back to parent directory when parent has model metadata - list_local_gguf_variants / _find_local_gguf_by_variant: use resolver so .gguf file paths inside model directories work correctly --- studio/backend/routes/models.py | 96 +++++++++++++++++++++ studio/backend/utils/models/model_config.py | 28 +++++- 2 files changed, 120 insertions(+), 4 deletions(-) diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index ea385436b8..3f361ca5eb 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -138,6 +138,47 @@ def _resolve_hf_cache_dir() -> Path: return Path.home() / ".cache" / "huggingface" / "hub" +def _is_model_directory(d: Path) -> bool: + """Return ``True`` when *d* looks like a model directory. + + A model directory must have **both** a config file (``config.json`` or + ``adapter_config.json``) **and** actual model weight files. Both + conditions are required: a bare directory with only loose ``.gguf`` + files (no config) might be a mixed collection, and a ``config.json`` + alone (no weights) is not a model directory. + + Excludes ``mmproj`` GGUF files (vision projectors) and non-weight + ``.bin`` files (``tokenizer.bin``, ``vocab.bin``, etc.) from the + weight check to avoid false positives. + """ + + def _is_weight_file(f: Path) -> bool: + suffix = f.suffix.lower() + if suffix == ".safetensors": + return True + if suffix == ".gguf": + return "mmproj" not in f.name.lower() + if suffix == ".bin": + name = f.name.lower() + return ( + name.startswith("pytorch_model") + or name.startswith("model") + or name.startswith("adapter_model") + or name.startswith("consolidated") + ) + return False + + try: + has_config = (d / "config.json").exists() or ( + d / "adapter_config.json" + ).exists() + if not has_config: + return False + return any(_is_weight_file(f) for f in d.iterdir() if f.is_file()) + except OSError: + return False + + def _scan_models_dir( models_dir: Path, *, @@ -146,6 +187,23 @@ def _scan_models_dir( if not models_dir.exists() or not models_dir.is_dir(): return [] + _is_self_model = _is_model_directory(models_dir) + + if _is_self_model: + try: + updated_at = models_dir.stat().st_mtime + except OSError: + updated_at = None + return [ + LocalModelInfo( + id = str(models_dir), + display_name = models_dir.name, + path = str(models_dir), + source = "models_dir", + updated_at = updated_at, + ), + ] + found: List[LocalModelInfo] = [] for child in models_dir.iterdir(): if limit is not None and len(found) >= limit: @@ -243,6 +301,25 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]: if not lm_dir.exists() or not lm_dir.is_dir(): return [] + # If the directory itself is a model directory (has config AND weight + # files), it is not an LM Studio publisher structure -- return it as a + # single model entry. We cannot skip it silently because this function + # is the only scanner called for default LM Studio roots. + if _is_model_directory(lm_dir): + try: + updated_at = lm_dir.stat().st_mtime + except OSError: + updated_at = None + return [ + LocalModelInfo( + id = str(lm_dir), + display_name = lm_dir.name, + path = str(lm_dir), + source = "lmstudio", + updated_at = updated_at, + ), + ] + found: List[LocalModelInfo] = [] for child in lm_dir.iterdir(): try: @@ -263,6 +340,25 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]: ) continue + # If the child directory itself looks like a model directory + # (has config AND weight files), surface it directly instead + # of descending into it as a publisher. + if _is_model_directory(child): + try: + updated_at = child.stat().st_mtime + except OSError: + updated_at = None + found.append( + LocalModelInfo( + id = str(child), + display_name = child.name, + path = str(child), + source = "lmstudio", + updated_at = updated_at, + ), + ) + continue + # child is a publisher directory -- scan its sub-directories for model_dir in child.iterdir(): try: diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index 9d72846ca3..61226e52cb 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -1130,6 +1130,26 @@ def list_gguf_variants( return variants, has_vision +def _resolve_gguf_dir(p: Path) -> Optional[Path]: + """Resolve a path to the directory containing GGUF variants. + + If *p* is already a directory, returns it directly. If *p* is a ``.gguf`` + file whose parent directory has model metadata (``config.json`` or + ``adapter_config.json``), returns the parent -- all GGUFs in that + directory belong to the same model. Returns ``None`` for loose standalone + GGUFs (no config) to avoid cross-wiring unrelated models. + """ + if p.is_dir(): + return p + if p.is_file() and p.suffix.lower() == ".gguf": + parent = p.parent + if (parent / "config.json").exists() or ( + parent / "adapter_config.json" + ).exists(): + return parent + return None + + def list_local_gguf_variants( directory: str, ) -> tuple[list[GgufVariantInfo], bool]: @@ -1142,8 +1162,8 @@ def list_local_gguf_variants( Returns: (variants, has_vision): list of non-mmproj GGUF variants + vision flag. """ - p = Path(directory) - if not p.is_dir(): + p = _resolve_gguf_dir(Path(directory)) + if p is None: return [], False quant_totals: dict[str, int] = {} @@ -1183,8 +1203,8 @@ def _find_local_gguf_by_variant(directory: str, variant: str) -> Optional[str]: Returns the resolved absolute path, or ``None`` if no match. """ - p = Path(directory) - if not p.is_dir(): + p = _resolve_gguf_dir(Path(directory)) + if p is None: return None matches = sorted( From 723bfb236305eb4ae4d0fdbf724fbe801b71da66 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 6 Apr 2026 08:54:36 -0700 Subject: [PATCH 050/216] Add unit tests for HfFileSystem glob skip guard (#4854) Tests verifying that HfFileSystem().glob() is correctly skipped when is_model or is_peft is False, matching the guard added in PR #4852. --- tests/test_loader_glob_skip.py | 152 +++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 tests/test_loader_glob_skip.py diff --git a/tests/test_loader_glob_skip.py b/tests/test_loader_glob_skip.py new file mode 100644 index 0000000000..d461e23cf8 --- /dev/null +++ b/tests/test_loader_glob_skip.py @@ -0,0 +1,152 @@ +"""Tests that HfFileSystem().glob() is skipped when is_model or is_peft is False. + +The glob calls in FastLanguageModel.from_pretrained and FastModel.from_pretrained +exist solely to detect repos with both config.json and adapter_config.json. When +either AutoConfig or PeftConfig fails to load, the glob cannot find both files, +so calling it is redundant and risks hanging on slow networks. +""" + +import os +import unittest +from unittest.mock import MagicMock, patch + + +class TestGlobSkippedWhenNotBothConfigs(unittest.TestCase): + """Verify HfFileSystem.glob is not called when is_model or is_peft is False.""" + + def _run_both_exist_block( + self, is_model, is_peft, supports_llama32, model_name, is_local_dir = False + ): + """Simulate the both_exist detection block from loader.py. + + This mirrors the exact logic at lines 500-517 / 1276-1292 of loader.py. + Returns (both_exist, glob_called). + """ + from unittest.mock import MagicMock + + both_exist = (is_model and is_peft) and not supports_llama32 + glob_mock = MagicMock( + return_value = [ + f"{model_name}/config.json", + f"{model_name}/adapter_config.json", + ] + ) + + # This mirrors the guarded block in loader.py + if supports_llama32 and is_model and is_peft: + if is_local_dir: + # Local path branch — would use os.path.exists in real code + both_exist = True # simulate both files present locally + else: + files = glob_mock(f"{model_name}/*.json") + files = list(os.path.split(x)[-1] for x in files) + if ( + sum(x == "adapter_config.json" or x == "config.json" for x in files) + >= 2 + ): + both_exist = True + + return both_exist, glob_mock.called + + # --- Cases where glob should NOT be called --- + + def test_glob_skipped_when_is_model_false(self): + both_exist, glob_called = self._run_both_exist_block( + is_model = False, + is_peft = True, + supports_llama32 = True, + model_name = "org/some-adapter", + ) + self.assertFalse(glob_called, "glob should not be called when is_model=False") + self.assertFalse(both_exist) + + def test_glob_skipped_when_is_peft_false(self): + both_exist, glob_called = self._run_both_exist_block( + is_model = True, + is_peft = False, + supports_llama32 = True, + model_name = "org/some-model", + ) + self.assertFalse(glob_called, "glob should not be called when is_peft=False") + self.assertFalse(both_exist) + + def test_glob_skipped_when_both_false(self): + both_exist, glob_called = self._run_both_exist_block( + is_model = False, + is_peft = False, + supports_llama32 = True, + model_name = "org/bad-repo", + ) + self.assertFalse(glob_called, "glob should not be called when both are False") + self.assertFalse(both_exist) + + def test_glob_skipped_when_supports_llama32_false(self): + both_exist, glob_called = self._run_both_exist_block( + is_model = True, + is_peft = True, + supports_llama32 = False, + model_name = "org/some-model", + ) + self.assertFalse( + glob_called, "glob should not be called when SUPPORTS_LLAMA32=False" + ) + # both_exist is set by the old-style check: (is_model and is_peft) and not SUPPORTS_LLAMA32 + self.assertTrue(both_exist) + + # --- Cases where glob SHOULD be called --- + + def test_glob_called_when_both_true_and_supports_llama32(self): + both_exist, glob_called = self._run_both_exist_block( + is_model = True, + is_peft = True, + supports_llama32 = True, + model_name = "org/mixed-repo", + ) + self.assertTrue( + glob_called, "glob should be called when is_model and is_peft are both True" + ) + self.assertTrue(both_exist) + + def test_local_dir_skips_glob(self): + both_exist, glob_called = self._run_both_exist_block( + is_model = True, + is_peft = True, + supports_llama32 = True, + model_name = "/local/path/to/model", + is_local_dir = True, + ) + self.assertFalse(glob_called, "glob should not be called for local directories") + self.assertTrue(both_exist) + + +class TestLoaderSourceHasGuard(unittest.TestCase): + """Verify the actual loader.py source code has the is_model/is_peft guard.""" + + def test_loader_source_has_guard(self): + """Check that both SUPPORTS_LLAMA32 checks in loader.py include is_model and is_peft.""" + loader_path = os.path.join( + os.path.dirname(__file__), os.pardir, "unsloth", "models", "loader.py" + ) + with open(loader_path) as f: + source = f.read() + + # Find all lines with the SUPPORTS_LLAMA32 check near glob usage + lines = source.splitlines() + guard_lines = [ + line.strip() + for line in lines + if "SUPPORTS_LLAMA32" in line and "if " in line and "is_model" in line + ] + # There should be exactly 2 guarded checks (one per from_pretrained method) + self.assertEqual( + len(guard_lines), + 2, + f"Expected 2 guarded SUPPORTS_LLAMA32 checks with is_model/is_peft, found {len(guard_lines)}: {guard_lines}", + ) + for line in guard_lines: + self.assertIn("is_model", line) + self.assertIn("is_peft", line) + + +if __name__ == "__main__": + unittest.main() From 4c83e3540ec878db055d35583fa83a3415acc957 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 6 Apr 2026 09:20:17 -0700 Subject: [PATCH 051/216] Update --- pyproject.toml | 4 ++-- unsloth/models/_utils.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e391b4df3d..50bdf58b95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,7 +88,7 @@ huggingfacenotorch = [ ] huggingface = [ "unsloth[huggingfacenotorch]", - "unsloth_zoo>=2026.4.2", + "unsloth_zoo>=2026.4.3", "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.2", + "unsloth_zoo>=2026.4.3", "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", diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index dcb7334417..9ee2ade3db 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.2" +__version__ = "2026.4.3" __all__ = [ "SUPPORTS_BFLOAT16", From 8c89b84bb678659139e2530cc56ca291583828c7 Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:32:54 +0100 Subject: [PATCH 052/216] Studio: Fix empty chat threads on navigation and stabilize new chat flow (#4872) * fix(chat): prevent implicit empty thread creation and stabilize new-chat flow * fix(chat): harden compare thread sync and simplify sidebar thread query * fix(chat): harden new-thread state sync and isolate compare active thread updates * fix(chat): stabilize new-thread state sync and prevent compare/session bleed * Fix thread restoration, handleNewThread guard, sidebar filter, and delete flow - Remove __LOCALID_ filter from getInitialSingleChatView: in this Dexie-backed adapter, AUI's __LOCALID_ prefixed IDs ARE the real persistent thread IDs stored by initialize(). Filtering them out breaks thread restoration on navigation. - Simplify handleNewThread to synchronous: the async Dexie message check is redundant (persistence is already deferred to first append) and strands users on legacy empty threads. Use a simple guard that checks the store's activeThreadId to detect unsent drafts. - Add message-count filter to sidebar: filter threads to only show those with at least one message, hiding legacy empty threads. - Add store-based sidebar highlighting fallback: use activeThreadId from the store when view.threadId is not set (nonce-backed chats). - Fix handleDelete to call onNewThread() instead of onSelect(), and clear activeThreadId, so the runtime properly resets after deleting the active thread. * Fix handleDelete nonce path and restore __LOCALID_ filter handleDelete was calling onNewThread() after clearing activeThreadId, but the handleNewThread guard sees !view.threadId && !activeThreadId and returns early, leaving the UI stuck on the deleted thread. Fix by directly calling onSelect with a new nonce instead. Restore __LOCALID_ filter in getInitialSingleChatView to prevent restoring unpersisted AUI local thread IDs on navigation. Without this filter, navigating away from /chat before sending a message would restore a non-existent thread that Dexie cannot fetch. --------- Co-authored-by: Daniel Han --- .../frontend/src/features/chat/chat-page.tsx | 35 +++++++++-- .../src/features/chat/runtime-provider.tsx | 61 +++++++++++-------- .../src/features/chat/thread-sidebar.tsx | 22 +++++-- 3 files changed, 80 insertions(+), 38 deletions(-) diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 1dbff145ee..cf1ba11d7b 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -225,6 +225,7 @@ const LoraCompareContent = memo(function LoraCompareContent({ modelType="base" pairId={pairId} initialThreadId={baseThreadId} + syncActiveThreadId={false} > @@ -242,6 +243,7 @@ const LoraCompareContent = memo(function LoraCompareContent({ modelType="lora" pairId={pairId} initialThreadId={loraThreadId} + syncActiveThreadId={false} > @@ -343,6 +345,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({ modelType="model1" pairId={pairId} initialThreadId={model1ThreadId} + syncActiveThreadId={false} > @@ -376,6 +379,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({ modelType="model2" pairId={pairId} initialThreadId={model2ThreadId} + syncActiveThreadId={false} > @@ -479,11 +483,19 @@ function TopBarActions({ ); } +function getInitialSingleChatView(): ChatView { + const id = useChatRuntimeStore.getState().activeThreadId; + if (typeof id === "string" && id.length > 0 && !id.startsWith("__LOCALID_")) { + return { mode: "single", threadId: id }; + } + return { mode: "single" }; +} + export function ChatPage(): ReactElement { - const [view, setView] = useState({ - mode: "single", - newThreadNonce: crypto.randomUUID(), - }); + // Do not set newThreadNonce here: each /chat mount would run ThreadNewChatSwitch + // and create spurious threads when navigating (e.g. Recipes / Export). New Chat + // explicitly sets a nonce in handleNewThread. + const [view, setView] = useState(getInitialSingleChatView); const [settingsOpen, setSettingsOpen] = useState(false); const [modelSelectorOpen, setModelSelectorOpen] = useState(false); const [modelSelectorLocked, setModelSelectorLocked] = useState(false); @@ -587,9 +599,20 @@ export function ChatPage(): ReactElement { void ejectModel(); }, [ejectModel]); const handleNewThread = useCallback(() => { + // Skip if we are already on a fresh unsaved draft with no messages sent. + // Once the user sends a message, append() sets activeThreadId in the store, + // so we check the store to know whether the current draft has been sent. + if ( + view.mode === "single" && + !view.threadId && + !useChatRuntimeStore.getState().activeThreadId + ) { + return; + } + useChatRuntimeStore.getState().setActiveThreadId(null); setView({ mode: "single", newThreadNonce: crypto.randomUUID() }); - }, []); + }, [view]); const handleNewCompare = useCallback(() => { setView({ mode: "compare", pairId: crypto.randomUUID() }); // Clear activeThreadId so compare panes do not inherit the single-chat @@ -922,7 +945,7 @@ export function ChatPage(): ReactElement { {view.mode === "single" ? ( diff --git a/studio/frontend/src/features/chat/runtime-provider.tsx b/studio/frontend/src/features/chat/runtime-provider.tsx index 7e6cd8e1dd..024543edb9 100644 --- a/studio/frontend/src/features/chat/runtime-provider.tsx +++ b/studio/frontend/src/features/chat/runtime-provider.tsx @@ -596,6 +596,15 @@ function ThreadHistoryProvider({ async append({ parentId, message }: ExportedMessageRepositoryItem) { const { remoteId } = await aui.threadListItem().initialize(); + // Keep single-chat runtime state in sync once a new chat is first + // persisted. Compare panes intentionally do not write global activeThreadId. + const thread = await db.threads.get(remoteId); + if (thread?.modelType === "base" && !thread.pairId) { + const store = useChatRuntimeStore.getState(); + if (store.activeThreadId !== remoteId) { + store.setActiveThreadId(remoteId); + } + } const content = cloneContent(message.content); const attachments = message.role === "user" ? cloneAttachments(message.attachments) : []; @@ -658,7 +667,11 @@ function useRuntimeHook(): ReturnType { function ThreadAutoSwitch({ threadId, -}: { threadId: string }): ReactElement | null { + syncActiveThreadId = true, +}: { + threadId: string; + syncActiveThreadId?: boolean; +}): ReactElement | null { const aui = useAui(); const isLoading = useAuiState(({ threads }) => threads.isLoading); const mainThreadId = useAuiState(({ threads }) => threads.mainThreadId); @@ -669,6 +682,13 @@ function ThreadAutoSwitch({ } }, [aui, isLoading, mainThreadId, threadId]); + useEffect(() => { + if (!syncActiveThreadId || isLoading || mainThreadId !== threadId) { + return; + } + useChatRuntimeStore.getState().setActiveThreadId(threadId); + }, [isLoading, mainThreadId, syncActiveThreadId, threadId]); + return null; } @@ -682,30 +702,10 @@ function ThreadNewChatSwitch({ if (isLoading) { return; } - - let cancelled = false; - // Clear immediately so the adapter never picks up a stale thread ID - // from a previous chat while we initialize the new one. + // Switch to a fresh local thread without persisting it yet. + // Persistence still happens on first message append. + void aui.threads().switchToNewThread(); useChatRuntimeStore.getState().setActiveThreadId(null); - - void (async () => { - try { - aui.threads().switchToNewThread(); - const { remoteId } = await aui.threadListItem().initialize(); - if (!cancelled) { - useChatRuntimeStore.getState().setActiveThreadId(remoteId); - } - } catch (error) { - if (!cancelled) { - useChatRuntimeStore.getState().setActiveThreadId(null); - } - console.error("Failed to initialize new chat thread", error); - } - })(); - - return () => { - cancelled = true; - }; }, [aui, isLoading, nonce]); return null; @@ -733,12 +733,14 @@ export function ChatRuntimeProvider({ pairId, initialThreadId, newThreadNonce, + syncActiveThreadId = true, }: { children: ReactNode; modelType?: ModelType; pairId?: string; initialThreadId?: string; newThreadNonce?: string; + syncActiveThreadId?: boolean; }): ReactElement { const runtime = useRemoteThreadListRuntime({ runtimeHook: useRuntimeHook, @@ -754,8 +756,15 @@ export function ChatRuntimeProvider({ return ( - - {initialThreadId && } + + {initialThreadId && ( + + )} {!initialThreadId && newThreadNonce && ( )} diff --git a/studio/frontend/src/features/chat/thread-sidebar.tsx b/studio/frontend/src/features/chat/thread-sidebar.tsx index ba97d2ee6e..62246cdad5 100644 --- a/studio/frontend/src/features/chat/thread-sidebar.tsx +++ b/studio/frontend/src/features/chat/thread-sidebar.tsx @@ -22,6 +22,7 @@ import { } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { db, useLiveQuery } from "./db"; +import { useChatRuntimeStore } from "./stores/chat-runtime-store"; import type { ChatView, ThreadRecord } from "./types"; interface SidebarItem { @@ -76,12 +77,17 @@ export function ThreadSidebar({ onNewCompare: () => void; showCompare: boolean; }) { - const allThreads = useLiveQuery( - () => db.threads.orderBy("createdAt").reverse().toArray(), - [], - ); + const allThreads = useLiveQuery(async () => { + const threadIdsWithMessage = new Set( + (await db.messages.orderBy("threadId").uniqueKeys()) as string[], + ); + const rows = await db.threads.orderBy("createdAt").reverse().toArray(); + return rows.filter((t) => !t.archived && threadIdsWithMessage.has(t.id)); + }, []); const items = groupThreads(allThreads ?? []); - const activeId = view.mode === "single" ? view.threadId : view.pairId; + const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId); + const activeId = + view.mode === "single" ? (view.threadId ?? storeThreadId) : view.pairId; function viewForItem(item: SidebarItem): ChatView { return item.type === "single" @@ -101,7 +107,11 @@ export function ThreadSidebar({ } } if (activeId === item.id) { - onSelect({ mode: "single" }); + // Directly set a new view with a nonce rather than going through + // onNewThread(), which may return early if the guard sees no + // threadId and no activeThreadId (after we just cleared it). + useChatRuntimeStore.getState().setActiveThreadId(null); + onSelect({ mode: "single", newThreadNonce: crypto.randomUUID() }); } } From b295daf9323d2782fe7d1286b2e1bde89dc6cd29 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 6 Apr 2026 09:39:06 -0700 Subject: [PATCH 053/216] 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 9ee2ade3db..d8fe92739a 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.3" +__version__ = "2026.4.4" __all__ = [ "SUPPORTS_BFLOAT16", From 1d8160376e169d13c386b7ef4bc1fdc8f855de68 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 6 Apr 2026 09:46:35 -0700 Subject: [PATCH 054/216] Bump minimum unsloth version to 2026.4.4 in install scripts (#4876) --- install.ps1 | 10 +++++----- install.sh | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/install.ps1 b/install.ps1 index 5ddb42ea7e..a2acd6c4ea 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.2" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.4.4" 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.2" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.4.4" 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.2" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.4.4" 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.2" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.4.4" 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.2" --torch-backend=auto } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.4.4" --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 053f334d2b..ea53ecc6d6 100755 --- a/install.sh +++ b/install.sh @@ -1040,7 +1040,7 @@ if [ "$_MIGRATED" = true ]; then # to prevent transitive torch resolution. run_install_cmd "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.4.2" unsloth-zoo + "unsloth>=2026.4.4" 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" @@ -1048,7 +1048,7 @@ if [ "$_MIGRATED" = true ]; then else run_install_cmd "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.4.2" unsloth-zoo + "unsloth>=2026.4.4" unsloth-zoo fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then substep "overlaying local repo (editable)..." @@ -1070,7 +1070,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. run_install_cmd "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --upgrade-package unsloth --upgrade-package unsloth-zoo \ - "unsloth>=2026.4.2" unsloth-zoo + "unsloth>=2026.4.4" 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" @@ -1081,7 +1081,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then fi elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then run_install_cmd "install unsloth (local)" uv pip install --python "$_VENV_PY" \ - --upgrade-package unsloth "unsloth>=2026.4.2" unsloth-zoo + --upgrade-package unsloth "unsloth>=2026.4.4" 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 @@ -1092,7 +1092,7 @@ else # Fallback: GPU detection failed to produce a URL -- let uv resolve torch substep "installing unsloth (this may take a few minutes)..." if [ "$STUDIO_LOCAL_INSTALL" = true ]; then - run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.4.2" --torch-backend=auto + run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.4.4" --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 f801e59c29db7b4028297ef987af6bfdaa464500 Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:05:01 +0400 Subject: [PATCH 055/216] split venv_t5 into tiered 5.3.0/5.5.0 and fix trust_remote_code (#4878) * split venv_t5 into venv_t5_530 and venv_t5_550 for tiered transformers 5.x support * fix bfloat16 crash on T4 for FORCE_FLOAT32 models and disable trust_remote_code auto-enable for native t5 models * revert FORCE_FLOAT32 dtype change * restrict trust_remote_code auto-enable to Nemotron models only * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * use config.json model_type for tier detection, add unsloth/nvidia namespace guard * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Revert "[pre-commit.ci] auto fixes from pre-commit.com hooks" This reverts commit fb43d468e25379f28dd2477e6c24dd60cf55c099. * Revert "use config.json model_type for tier detection, add unsloth/nvidia namespace guard" This reverts commit fc49ae24531780a658049e6c238f146960f466b6. * add unsloth/nvidia namespace guard to Nemotron trust_remote_code auto-enable * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * reorder tier checks: all substring matches before config.json fetches * extract shared activate_transformers_for_subprocess into transformers_version.py * narrow Nemotron trust_remote_code to nemotron_h/nemotron-3-nano, add to export worker * clean venv_t5 dirs before re-install in setup.sh, clarify version alias comment * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * run venv_t5 migration outside deps fast-path gate in both setup scripts --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/core/export/worker.py | 41 +-- studio/backend/core/inference/worker.py | 44 +-- studio/backend/core/training/worker.py | 51 +-- .../tests/test_transformers_version.py | 148 ++++++++ studio/backend/utils/models/model_config.py | 5 +- studio/backend/utils/transformers_version.py | 340 ++++++++++++++---- studio/setup.ps1 | 135 ++++--- studio/setup.sh | 53 ++- 8 files changed, 601 insertions(+), 216 deletions(-) diff --git a/studio/backend/core/export/worker.py b/studio/backend/core/export/worker.py index 6af6ff1193..3f3dc955fa 100644 --- a/studio/backend/core/export/worker.py +++ b/studio/backend/core/export/worker.py @@ -30,37 +30,15 @@ logger = get_logger(__name__) def _activate_transformers_version(model_name: str) -> None: - """Activate the correct transformers version BEFORE any ML imports. - - If the model needs transformers 5.x, prepend the pre-installed .venv_t5/ - directory to sys.path. Otherwise do nothing (default 4.57.x in .venv/). - """ + """Activate the correct transformers version BEFORE any ML imports.""" # Ensure backend is on path for utils imports backend_path = str(Path(__file__).resolve().parent.parent.parent) if backend_path not in sys.path: sys.path.insert(0, backend_path) - from utils.transformers_version import ( - needs_transformers_5, - _resolve_base_model, - _ensure_venv_t5_exists, - _VENV_T5_DIR, - ) + from utils.transformers_version import activate_transformers_for_subprocess - resolved = _resolve_base_model(model_name) - if needs_transformers_5(resolved): - if not _ensure_venv_t5_exists(): - raise RuntimeError( - f"Cannot activate transformers 5.x: .venv_t5 missing at {_VENV_T5_DIR}" - ) - if _VENV_T5_DIR not in sys.path: - sys.path.insert(0, _VENV_T5_DIR) - logger.info("Activated transformers 5.x from %s", _VENV_T5_DIR) - # Propagate to child subprocesses (e.g. GGUF converter) - _pp = os.environ.get("PYTHONPATH", "") - os.environ["PYTHONPATH"] = _VENV_T5_DIR + (os.pathsep + _pp if _pp else "") - else: - logger.info("Using default transformers (4.57.x) for %s", model_name) + activate_transformers_for_subprocess(model_name) def _send_response(resp_queue: Any, response: dict) -> None: @@ -78,6 +56,19 @@ def _handle_load(backend, cmd: dict, resp_queue: Any) -> None: load_in_4bit = cmd.get("load_in_4bit", True) trust_remote_code = cmd.get("trust_remote_code", False) + # Auto-enable trust_remote_code for NemotronH/Nano models. + if not trust_remote_code: + _NEMOTRON_TRUST_SUBSTRINGS = ("nemotron_h", "nemotron-h", "nemotron-3-nano") + _cp_lower = checkpoint_path.lower() + if any(sub in _cp_lower for sub in _NEMOTRON_TRUST_SUBSTRINGS) and ( + _cp_lower.startswith("unsloth/") or _cp_lower.startswith("nvidia/") + ): + trust_remote_code = True + logger.info( + "Auto-enabled trust_remote_code for Nemotron model: %s", + checkpoint_path, + ) + try: _send_response( resp_queue, diff --git a/studio/backend/core/inference/worker.py b/studio/backend/core/inference/worker.py index 7f7291a56d..fbcce276ba 100644 --- a/studio/backend/core/inference/worker.py +++ b/studio/backend/core/inference/worker.py @@ -34,37 +34,15 @@ from utils.hardware import apply_gpu_ids def _activate_transformers_version(model_name: str) -> None: - """Activate the correct transformers version BEFORE any ML imports. - - If the model needs transformers 5.x, prepend the pre-installed .venv_t5/ - directory to sys.path. Otherwise do nothing (default 4.57.x in .venv/). - """ + """Activate the correct transformers version BEFORE any ML imports.""" # Ensure backend is on path for utils imports backend_path = str(Path(__file__).resolve().parent.parent.parent) if backend_path not in sys.path: sys.path.insert(0, backend_path) - from utils.transformers_version import ( - needs_transformers_5, - _resolve_base_model, - _ensure_venv_t5_exists, - _VENV_T5_DIR, - ) + from utils.transformers_version import activate_transformers_for_subprocess - resolved = _resolve_base_model(model_name) - if needs_transformers_5(resolved): - if not _ensure_venv_t5_exists(): - raise RuntimeError( - f"Cannot activate transformers 5.x: .venv_t5 missing at {_VENV_T5_DIR}" - ) - if _VENV_T5_DIR not in sys.path: - sys.path.insert(0, _VENV_T5_DIR) - logger.info("Activated transformers 5.x from %s", _VENV_T5_DIR) - # Propagate to child subprocesses (e.g. GGUF converter) - _pp = os.environ.get("PYTHONPATH", "") - os.environ["PYTHONPATH"] = _VENV_T5_DIR + (os.pathsep + _pp if _pp else "") - else: - logger.info("Using default transformers (4.57.x) for %s", model_name) + activate_transformers_for_subprocess(model_name) def _decode_image(image_base64: str): @@ -309,19 +287,21 @@ def _handle_load(backend, config: dict, resp_queue: Any) -> None: except Exception as e: logger.warning("Could not read adapter_config.json: %s", e) - # Auto-enable trust_remote_code for unsloth/* transformers 5.x models - # (matches the training worker logic in core/training/worker.py) + # Auto-enable trust_remote_code for NemotronH/Nano models only. + # NemotronH has config parsing bugs requiring trust_remote_code=True. + # Other transformers 5.x models are native and do NOT need it. + # NOTE: Must NOT match Llama-Nemotron (standard Llama architecture). + _NEMOTRON_TRUST_SUBSTRINGS = ("nemotron_h", "nemotron-h", "nemotron-3-nano") trust_remote_code = config.get("trust_remote_code", False) if not trust_remote_code: - from utils.transformers_version import needs_transformers_5 - model_name = config["model_name"] - if needs_transformers_5(model_name) and model_name.lower().startswith( - "unsloth/" + _mn_lower = model_name.lower() + if any(sub in _mn_lower for sub in _NEMOTRON_TRUST_SUBSTRINGS) and ( + _mn_lower.startswith("unsloth/") or _mn_lower.startswith("nvidia/") ): trust_remote_code = True logger.info( - "Auto-enabled trust_remote_code for unsloth/* transformers 5.x model: %s", + "Auto-enabled trust_remote_code for Nemotron model: %s", model_name, ) diff --git a/studio/backend/core/training/worker.py b/studio/backend/core/training/worker.py index 0454eada89..a461972eca 100644 --- a/studio/backend/core/training/worker.py +++ b/studio/backend/core/training/worker.py @@ -306,37 +306,15 @@ def _ensure_mamba_ssm(event_queue: Any, model_name: str) -> None: def _activate_transformers_version(model_name: str) -> None: - """Activate the correct transformers version BEFORE any ML imports. - - If the model needs transformers 5.x, prepend the pre-installed .venv_t5/ - directory to sys.path. Otherwise do nothing (default 4.57.x in .venv/). - """ + """Activate the correct transformers version BEFORE any ML imports.""" # Ensure backend is on path for utils imports backend_path = str(Path(__file__).resolve().parent.parent.parent) if backend_path not in sys.path: sys.path.insert(0, backend_path) - from utils.transformers_version import ( - needs_transformers_5, - _resolve_base_model, - _ensure_venv_t5_exists, - _VENV_T5_DIR, - ) + from utils.transformers_version import activate_transformers_for_subprocess - resolved = _resolve_base_model(model_name) - if needs_transformers_5(resolved): - if not _ensure_venv_t5_exists(): - raise RuntimeError( - f"Cannot activate transformers 5.x: .venv_t5 missing at {_VENV_T5_DIR}" - ) - if _VENV_T5_DIR not in sys.path: - sys.path.insert(0, _VENV_T5_DIR) - logger.info("Activated transformers 5.x from %s", _VENV_T5_DIR) - # Propagate to child subprocesses (e.g. GGUF converter) - _pp = os.environ.get("PYTHONPATH", "") - os.environ["PYTHONPATH"] = _VENV_T5_DIR + (os.pathsep + _pp if _pp else "") - else: - logger.info("Using default transformers (4.57.x) for %s", model_name) + activate_transformers_for_subprocess(model_name) def run_training_process( @@ -386,25 +364,22 @@ def run_training_process( ) return - # ── 1a. Auto-enable trust_remote_code for unsloth/* transformers 5.x models ── - # Some newer architectures (e.g. NemotronH) have config parsing bugs in - # transformers that require trust_remote_code=True as a workaround. - # Only auto-enable for unsloth/* prefixed models (trusted source). - # Exclude Gemma 4 since it is a native transformers 5.5 model and - # trust_remote_code=True would bypass the compiler (disabling fused CE). - from utils.transformers_version import needs_transformers_5 - + # ── 1a. Auto-enable trust_remote_code for NemotronH/Nano models ── + # NemotronH has config parsing bugs in transformers that require + # trust_remote_code=True as a workaround. Other transformers 5.x models + # (Qwen3.5, Gemma 4, etc.) are native and do NOT need it — enabling it + # bypasses the compiler (disabling fused CE). + # NOTE: Must NOT match Llama-Nemotron (standard Llama architecture). + _NEMOTRON_TRUST_SUBSTRINGS = ("nemotron_h", "nemotron-h", "nemotron-3-nano") _lowered = model_name.lower() - _is_native_t5 = any(x in _lowered for x in ("gemma-4", "gemma4")) if ( - needs_transformers_5(model_name) - and _lowered.startswith("unsloth/") - and not _is_native_t5 + any(sub in _lowered for sub in _NEMOTRON_TRUST_SUBSTRINGS) + and (_lowered.startswith("unsloth/") or _lowered.startswith("nvidia/")) and not config.get("trust_remote_code", False) ): config["trust_remote_code"] = True logger.info( - "Auto-enabled trust_remote_code for unsloth/* transformers 5.x model: %s", + "Auto-enabled trust_remote_code for Nemotron model: %s", model_name, ) diff --git a/studio/backend/tests/test_transformers_version.py b/studio/backend/tests/test_transformers_version.py index f3dae537c7..c031c2fea3 100644 --- a/studio/backend/tests/test_transformers_version.py +++ b/studio/backend/tests/test_transformers_version.py @@ -31,8 +31,11 @@ sys.modules.setdefault("loggers", _loggers_stub) from utils.transformers_version import ( _resolve_base_model, _check_tokenizer_config_needs_v5, + _check_config_needs_550, _tokenizer_class_cache, + _config_needs_550_cache, needs_transformers_5, + get_transformers_tier, ) @@ -188,3 +191,148 @@ class TestNeedsTransformers5: # We test the full resolution chain here: resolved = _resolve_base_model(str(tmp_path)) assert needs_transformers_5(resolved) is True + + +# --------------------------------------------------------------------------- +# _check_config_needs_550 — config.json architecture/model_type check +# --------------------------------------------------------------------------- + + +class TestCheckConfigNeeds550: + """Tests for _check_config_needs_550() local config.json checks.""" + + def setup_method(self): + _config_needs_550_cache.clear() + + def test_gemma4_architecture(self, tmp_path: Path): + """config.json with Gemma4ForConditionalGeneration should return True.""" + cfg = { + "architectures": ["Gemma4ForConditionalGeneration"], + "model_type": "gemma4", + } + (tmp_path / "config.json").write_text(json.dumps(cfg)) + + assert _check_config_needs_550(str(tmp_path)) is True + + def test_gemma4_model_type_only(self, tmp_path: Path): + """config.json with model_type=gemma4 (no architectures) should return True.""" + cfg = {"model_type": "gemma4"} + (tmp_path / "config.json").write_text(json.dumps(cfg)) + + assert _check_config_needs_550(str(tmp_path)) is True + + def test_llama_architecture(self, tmp_path: Path): + """config.json with LlamaForCausalLM should return False.""" + cfg = {"architectures": ["LlamaForCausalLM"], "model_type": "llama"} + (tmp_path / "config.json").write_text(json.dumps(cfg)) + + assert _check_config_needs_550(str(tmp_path)) is False + + def test_no_config_json(self, tmp_path: Path): + """Missing config.json should return False (fail-open).""" + # Patch network call to avoid real fetch + with patch("urllib.request.urlopen") as mock_urlopen: + mock_urlopen.side_effect = Exception("no network") + assert _check_config_needs_550(str(tmp_path)) is False + + def test_result_is_cached(self, tmp_path: Path): + """Subsequent calls should use the cache.""" + cfg = {"architectures": ["Gemma4ForConditionalGeneration"]} + (tmp_path / "config.json").write_text(json.dumps(cfg)) + + key = str(tmp_path) + _check_config_needs_550(key) + assert key in _config_needs_550_cache + assert _config_needs_550_cache[key] is True + + def test_local_file_skips_network(self, tmp_path: Path): + """When local config.json exists, no network request should be made.""" + cfg = {"architectures": ["LlamaForCausalLM"]} + (tmp_path / "config.json").write_text(json.dumps(cfg)) + + with patch("urllib.request.urlopen") as mock_urlopen: + _check_config_needs_550(str(tmp_path)) + mock_urlopen.assert_not_called() + + +# --------------------------------------------------------------------------- +# get_transformers_tier — tier detection +# --------------------------------------------------------------------------- + + +class TestGetTransformersTier: + """Tests for get_transformers_tier() tiered version detection.""" + + def setup_method(self): + _tokenizer_class_cache.clear() + _config_needs_550_cache.clear() + + def test_gemma4_substring_returns_550(self): + assert get_transformers_tier("google/gemma-4-E2B-it") == "550" + + def test_gemma4_alt_substring_returns_550(self): + assert get_transformers_tier("unsloth/gemma4-E4B-it") == "550" + + def test_gemma4_config_json_returns_550(self, tmp_path: Path): + """Local checkpoint with Gemma4 architecture → 550.""" + cfg = { + "architectures": ["Gemma4ForConditionalGeneration"], + "model_type": "gemma4", + } + (tmp_path / "config.json").write_text(json.dumps(cfg)) + + assert get_transformers_tier(str(tmp_path)) == "550" + + def test_qwen35_returns_530(self): + with patch( + "utils.transformers_version._check_config_needs_550", + return_value = False, + ): + assert get_transformers_tier("Qwen/Qwen3.5-9B") == "530" + + def test_ministral_returns_530(self): + with patch( + "utils.transformers_version._check_config_needs_550", + return_value = False, + ): + assert ( + get_transformers_tier("mistralai/Ministral-3-8B-Instruct-2512") == "530" + ) + + def test_llama_returns_default(self): + with ( + patch( + "utils.transformers_version._check_config_needs_550", + return_value = False, + ), + patch( + "utils.transformers_version._check_tokenizer_config_needs_v5", + return_value = False, + ), + ): + assert get_transformers_tier("meta-llama/Llama-3-8B") == "default" + + def test_550_checked_before_530(self): + """Ensure 5.5.0 is checked first — a model matching both should get 550.""" + # This shouldn't happen in practice, but verifies priority + assert get_transformers_tier("gemma-4-model") == "550" + + def test_needs_transformers_5_compat(self): + """needs_transformers_5 should return True for both 530 and 550 models.""" + assert needs_transformers_5("google/gemma-4-E2B-it") is True + with patch( + "utils.transformers_version._check_config_needs_550", + return_value = False, + ): + assert needs_transformers_5("Qwen/Qwen3.5-9B") is True + with ( + patch( + "utils.transformers_version._check_config_needs_550", + return_value = False, + ), + patch( + "utils.transformers_version._check_tokenizer_config_needs_v5", + return_value = False, + ), + ): + assert needs_transformers_5("meta-llama/Llama-3-8B") is False diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index 61226e52cb..5be0b183d5 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -493,8 +493,9 @@ _VLM_MODEL_TYPES = { "minicpmv", } -# Pre-computed .venv_t5 path and backend dir for subprocess version switching. -_VENV_T5_DIR = str(Path.home() / ".unsloth" / "studio" / ".venv_t5") +# Pre-computed .venv_t5 paths and backend dir for subprocess version switching. +# Vision check uses 5.5.0 (newest, recognizes all architectures). +_VENV_T5_DIR = str(Path.home() / ".unsloth" / "studio" / ".venv_t5_550") _BACKEND_DIR = str(Path(__file__).resolve().parent.parent.parent) # Inline script executed in a subprocess with transformers 5.x activated. diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index 07e4a5c000..0c13b5455b 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -5,20 +5,25 @@ Automatic transformers version switching. Some newer model architectures (Ministral-3, GLM-4.7-Flash, Qwen3-30B-A3B MoE, -tiny_qwen3_moe) require transformers>=5.3.0, while everything else needs the -default 4.57.x that ships with Unsloth. +tiny_qwen3_moe) require transformers>=5.3.0, while Gemma 4 models require +transformers>=5.5.0. Everything else needs the default 4.57.x that ships +with Unsloth. + +Two separate target directories are maintained: + - .venv_t5_530/ — transformers 5.3.0 (Ministral-3, GLM, Qwen3 MoE, etc.) + - .venv_t5_550/ — transformers 5.5.0 (Gemma 4) When loading a LoRA adapter with a custom name, we resolve the base model from ``adapter_config.json`` and check *that* against the model list. Strategy: Training and inference run in subprocesses that activate the correct version - via sys.path (prepending .venv_t5/ for 5.x models). See: + via sys.path (prepending the appropriate .venv_t5_*/ directory). See: - core/training/worker.py - core/inference/worker.py For export (still in-process), ensure_transformers_version() does a lightweight - sys.path swap using the same .venv_t5/ directory pre-installed by setup.sh. + sys.path swap using the same directories pre-installed by setup.sh. """ import importlib @@ -39,7 +44,7 @@ logger = get_logger(__name__) # --------------------------------------------------------------------------- # Lowercase substrings — if ANY appears anywhere in the lowered model name, -# we need transformers 5.x. +# we need transformers 5.3.0. TRANSFORMERS_5_MODEL_SUBSTRINGS: tuple[str, ...] = ( "ministral-3-", # Ministral-3-{3,8,14}B-{Instruct,Reasoning,Base}-2512 "glm-4.7-flash", # GLM-4.7-Flash @@ -47,10 +52,23 @@ 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 +) + +# Lowercase substrings for models that require transformers 5.5.0 (checked first). +TRANSFORMERS_550_MODEL_SUBSTRINGS: tuple[str, ...] = ( "gemma-4", # Gemma-4 (E2B-it, E4B-it, 31B-it, 26B-A4B-it) "gemma4", # Gemma-4 alternate naming ) +# Architecture classes / model_type values that require transformers 5.5.0. +# Checked via config.json (local or HuggingFace). +_TRANSFORMERS_550_ARCHITECTURES: set[str] = { + "Gemma4ForConditionalGeneration", +} +_TRANSFORMERS_550_MODEL_TYPES: set[str] = { + "gemma4", +} + # Tokenizer classes that only exist in transformers>=5.x _TRANSFORMERS_5_TOKENIZER_CLASSES: set[str] = { "TokenizersBackend", @@ -59,12 +77,61 @@ _TRANSFORMERS_5_TOKENIZER_CLASSES: set[str] = { # Cache for dynamic tokenizer_config.json lookups to avoid repeated fetches _tokenizer_class_cache: dict[str, bool] = {} -# Versions -TRANSFORMERS_5_VERSION = "5.5.0" -TRANSFORMERS_DEFAULT_VERSION = "4.57.6" +# Cache for dynamic config.json lookups (architecture/model_type checks) +_config_needs_550_cache: dict[str, bool] = {} -# Pre-installed directory for transformers 5.x — created by setup.sh / setup.ps1 -_VENV_T5_DIR = str(Path.home() / ".unsloth" / "studio" / ".venv_t5") +# Versions +TRANSFORMERS_550_VERSION = "5.5.0" +TRANSFORMERS_530_VERSION = "5.3.0" +TRANSFORMERS_DEFAULT_VERSION = "4.57.6" +# Backwards-compat alias — points to 5.5.0 (the highest 5.x tier). +# Consumers should prefer TRANSFORMERS_530_VERSION / TRANSFORMERS_550_VERSION. +TRANSFORMERS_5_VERSION = TRANSFORMERS_550_VERSION + +# Pre-installed directories — created by setup.sh / setup.ps1 +_VENV_T5_530_DIR = str(Path.home() / ".unsloth" / "studio" / ".venv_t5_530") +_VENV_T5_550_DIR = str(Path.home() / ".unsloth" / "studio" / ".venv_t5_550") +# Backwards-compat alias +_VENV_T5_DIR = _VENV_T5_550_DIR + + +def activate_transformers_for_subprocess(model_name: str) -> None: + """Activate the correct transformers version in a subprocess worker. + + Call this BEFORE any ML imports. Resolves LoRA adapters to their base + model, determines the required tier, and prepends the appropriate + ``.venv_t5_*`` directory to ``sys.path``. Also propagates the path + via ``PYTHONPATH`` for child processes (e.g. GGUF converter). + + Used by training, inference, and export workers. + """ + resolved = _resolve_base_model(model_name) + tier = get_transformers_tier(resolved) + + if tier == "550": + if not _ensure_venv_t5_550_exists(): + raise RuntimeError( + f"Cannot activate transformers 5.5.0: " + f".venv_t5_550 missing at {_VENV_T5_550_DIR}" + ) + if _VENV_T5_550_DIR not in sys.path: + sys.path.insert(0, _VENV_T5_550_DIR) + logger.info("Activated transformers 5.5.0 from %s", _VENV_T5_550_DIR) + _pp = os.environ.get("PYTHONPATH", "") + os.environ["PYTHONPATH"] = _VENV_T5_550_DIR + (os.pathsep + _pp if _pp else "") + elif tier == "530": + if not _ensure_venv_t5_530_exists(): + raise RuntimeError( + f"Cannot activate transformers 5.3.0: " + f".venv_t5_530 missing at {_VENV_T5_530_DIR}" + ) + if _VENV_T5_530_DIR not in sys.path: + sys.path.insert(0, _VENV_T5_530_DIR) + logger.info("Activated transformers 5.3.0 from %s", _VENV_T5_530_DIR) + _pp = os.environ.get("PYTHONPATH", "") + os.environ["PYTHONPATH"] = _VENV_T5_530_DIR + (os.pathsep + _pp if _pp else "") + else: + logger.info("Using default transformers (4.57.x) for %s", model_name) def _resolve_base_model(model_name: str) -> str: @@ -192,18 +259,103 @@ def _check_tokenizer_config_needs_v5(model_name: str) -> bool: return False -def needs_transformers_5(model_name: str) -> bool: - """Return True if *model_name* belongs to an architecture that requires - ``transformers>=5.3.0``. +def _check_config_needs_550(model_name: str) -> bool: + """Check ``config.json`` for architectures or model_type that require + transformers 5.5.0 (e.g. Gemma 4). - First checks the hardcoded substring list for known models, then - dynamically fetches ``tokenizer_config.json`` from HuggingFace to check - if the tokenizer_class (e.g. ``TokenizersBackend``) requires v5. + Checks locally first, then falls back to fetching from HuggingFace. + Results are cached in ``_config_needs_550_cache``. + Returns False on any error (fail-open to lower tier). + """ + if model_name in _config_needs_550_cache: + return _config_needs_550_cache[model_name] + + def _check_cfg(cfg: dict) -> bool: + archs = cfg.get("architectures", []) + if any(a in _TRANSFORMERS_550_ARCHITECTURES for a in archs): + return True + if cfg.get("model_type") in _TRANSFORMERS_550_MODEL_TYPES: + return True + return False + + # --- Check local config.json first ------------------------------------ + local_path = Path(model_name) + local_cfg = local_path / "config.json" + if local_cfg.is_file(): + try: + with open(local_cfg) as f: + cfg = json.load(f) + result = _check_cfg(cfg) + if result: + logger.info( + "Local config.json check: %s needs transformers 5.5.0 " + "(architectures=%s, model_type=%s)", + model_name, + cfg.get("architectures", []), + cfg.get("model_type"), + ) + _config_needs_550_cache[model_name] = result + return result + except Exception as exc: + logger.debug("Could not read %s: %s", local_cfg, exc) + + # --- Fall back to fetching from HuggingFace --------------------------- + import urllib.request + + url = f"https://huggingface.co/{model_name}/raw/main/config.json" + try: + req = urllib.request.Request(url, headers = {"User-Agent": "unsloth-studio"}) + with urllib.request.urlopen(req, timeout = 10) as resp: + cfg = json.loads(resp.read().decode()) + result = _check_cfg(cfg) + if result: + logger.info( + "Dynamic config.json check: %s needs transformers 5.5.0 " + "(architectures=%s, model_type=%s)", + model_name, + cfg.get("architectures", []), + cfg.get("model_type"), + ) + _config_needs_550_cache[model_name] = result + return result + except Exception as exc: + logger.debug("Could not fetch config.json for '%s': %s", model_name, exc) + _config_needs_550_cache[model_name] = False + return False + + +def get_transformers_tier(model_name: str) -> str: + """Return the transformers tier required for *model_name*. + + Returns ``"550"`` for models needing transformers 5.5.0 (e.g. Gemma 4), + ``"530"`` for models needing transformers 5.3.0 (e.g. Ministral-3, Qwen3 MoE), + or ``"default"`` for everything else (4.57.x). + + The 5.5.0 check runs first, then 5.3.0. """ lowered = model_name.lower() + + # --- Fast substring checks (no I/O) ------------------------------------ + if any(sub in lowered for sub in TRANSFORMERS_550_MODEL_SUBSTRINGS): + return "550" if any(sub in lowered for sub in TRANSFORMERS_5_MODEL_SUBSTRINGS): - return True - return _check_tokenizer_config_needs_v5(model_name) + return "530" + + # --- Slow config fallbacks (local file first, then network) ----------- + if _check_config_needs_550(model_name): + return "550" + if _check_tokenizer_config_needs_v5(model_name): + return "530" + + return "default" + + +def needs_transformers_5(model_name: str) -> bool: + """Return True if *model_name* requires any transformers 5.x version. + + Convenience wrapper around :func:`get_transformers_tier`. + """ + return get_transformers_tier(model_name) != "default" # --------------------------------------------------------------------------- @@ -258,27 +410,36 @@ def _purge_modules() -> int: return len(to_remove) -_VENV_T5_PACKAGES = ( - f"transformers=={TRANSFORMERS_5_VERSION}", +_VENV_T5_530_PACKAGES = ( + f"transformers=={TRANSFORMERS_530_VERSION}", "huggingface_hub==1.8.0", "hf_xet==1.4.2", "tiktoken", ) +_VENV_T5_550_PACKAGES = ( + f"transformers=={TRANSFORMERS_550_VERSION}", + "huggingface_hub==1.8.0", + "hf_xet==1.4.2", + "tiktoken", +) -def _venv_t5_is_valid() -> bool: - """Return True if .venv_t5/ has all required packages at the correct versions.""" - if not os.path.isdir(_VENV_T5_DIR) or not os.listdir(_VENV_T5_DIR): +# Backwards-compat alias +_VENV_T5_PACKAGES = _VENV_T5_550_PACKAGES + + +def _venv_dir_is_valid(venv_dir: str, packages: tuple[str, ...]) -> bool: + """Return True if *venv_dir* has all *packages* at the correct versions.""" + if not os.path.isdir(venv_dir) or not os.listdir(venv_dir): return False - # Check that the key package directories exist AND match the required version - for pkg_spec in _VENV_T5_PACKAGES: + for pkg_spec in packages: parts = pkg_spec.split("==") pkg_name = parts[0] pkg_version = parts[1] if len(parts) > 1 else None pkg_name_norm = pkg_name.replace("-", "_") # Check directory exists if not any( - (Path(_VENV_T5_DIR) / d).is_dir() + (Path(venv_dir) / d).is_dir() for d in (pkg_name_norm, pkg_name_norm.replace("_", "-")) ): return False @@ -287,7 +448,7 @@ def _venv_t5_is_valid() -> bool: continue # Check version via .dist-info metadata dist_info_found = False - for di in Path(_VENV_T5_DIR).glob(f"{pkg_name_norm}-*.dist-info"): + for di in Path(venv_dir).glob(f"{pkg_name_norm}-*.dist-info"): metadata = di / "METADATA" if not metadata.is_file(): continue @@ -296,7 +457,8 @@ def _venv_t5_is_valid() -> bool: installed_ver = line.split(":", 1)[1].strip() if installed_ver != pkg_version: logger.info( - ".venv_t5 has %s==%s but need %s", + "%s has %s==%s but need %s", + venv_dir, pkg_name, installed_ver, pkg_version, @@ -311,8 +473,13 @@ def _venv_t5_is_valid() -> bool: return True -def _install_to_venv_t5(pkg: str) -> bool: - """Install a single package into .venv_t5/, preferring uv then pip.""" +def _venv_t5_is_valid() -> bool: + """Backwards-compat: check the 5.5.0 venv.""" + return _venv_dir_is_valid(_VENV_T5_550_DIR, _VENV_T5_550_PACKAGES) + + +def _install_to_dir(pkg: str, target_dir: str) -> bool: + """Install a single package into *target_dir*, preferring uv then pip.""" # Try uv first (faster) if already on PATH -- do NOT install uv at runtime if shutil.which("uv"): result = subprocess.run( @@ -323,7 +490,7 @@ def _install_to_venv_t5(pkg: str) -> bool: "--python", sys.executable, "--target", - _VENV_T5_DIR, + target_dir, "--no-deps", "--upgrade", pkg, @@ -344,7 +511,7 @@ def _install_to_venv_t5(pkg: str) -> bool: "pip", "install", "--target", - _VENV_T5_DIR, + target_dir, "--no-deps", "--upgrade", pkg, @@ -359,47 +526,62 @@ def _install_to_venv_t5(pkg: str) -> bool: return True -def _ensure_venv_t5_exists() -> bool: - """Ensure .venv_t5/ exists with all required packages. Install if missing.""" - if _venv_t5_is_valid(): +def _ensure_venv_dir(venv_dir: str, packages: tuple[str, ...], label: str) -> bool: + """Ensure *venv_dir* exists with all *packages*. Install if missing.""" + if _venv_dir_is_valid(venv_dir, packages): return True logger.warning( - ".venv_t5 not found or incomplete at %s -- installing at runtime", _VENV_T5_DIR + "%s not found or incomplete at %s -- installing at runtime", label, venv_dir ) - shutil.rmtree(_VENV_T5_DIR, ignore_errors = True) - os.makedirs(_VENV_T5_DIR, exist_ok = True) - for pkg in _VENV_T5_PACKAGES: - if not _install_to_venv_t5(pkg): + shutil.rmtree(venv_dir, ignore_errors = True) + os.makedirs(venv_dir, exist_ok = True) + for pkg in packages: + if not _install_to_dir(pkg, venv_dir): return False - logger.info("Installed transformers 5.x to %s", _VENV_T5_DIR) + logger.info("Installed %s to %s", label, venv_dir) return True -def _activate_5x() -> None: - """Prepend .venv_t5/ to sys.path, purge stale modules, reimport.""" - if not _ensure_venv_t5_exists(): - raise RuntimeError( - f"Cannot activate transformers 5.x: .venv_t5 missing at {_VENV_T5_DIR}" - ) +def _ensure_venv_t5_530_exists() -> bool: + """Ensure .venv_t5_530/ exists with transformers 5.3.0.""" + return _ensure_venv_dir( + _VENV_T5_530_DIR, _VENV_T5_530_PACKAGES, "transformers 5.3.0" + ) - if _VENV_T5_DIR not in sys.path: - sys.path.insert(0, _VENV_T5_DIR) - logger.info("Prepended %s to sys.path", _VENV_T5_DIR) + +def _ensure_venv_t5_550_exists() -> bool: + """Ensure .venv_t5_550/ exists with transformers 5.5.0.""" + return _ensure_venv_dir( + _VENV_T5_550_DIR, _VENV_T5_550_PACKAGES, "transformers 5.5.0" + ) + + +def _ensure_venv_t5_exists() -> bool: + """Backwards-compat: ensure the 5.5.0 venv exists.""" + return _ensure_venv_t5_550_exists() + + +def _activate_venv(venv_dir: str, label: str) -> None: + """Prepend *venv_dir* to sys.path, purge stale modules, reimport.""" + if venv_dir not in sys.path: + sys.path.insert(0, venv_dir) + logger.info("Prepended %s to sys.path", venv_dir) count = _purge_modules() logger.info("Purged %d cached modules", count) import transformers - logger.info("Loaded transformers %s", transformers.__version__) + logger.info("Loaded transformers %s (%s)", transformers.__version__, label) def _deactivate_5x() -> None: - """Remove .venv_t5/ from sys.path, purge stale modules, reimport.""" - while _VENV_T5_DIR in sys.path: - sys.path.remove(_VENV_T5_DIR) - logger.info("Removed %s from sys.path", _VENV_T5_DIR) + """Remove all .venv_t5_*/ dirs from sys.path, purge stale modules, reimport.""" + for d in (_VENV_T5_530_DIR, _VENV_T5_550_DIR): + while d in sys.path: + sys.path.remove(d) + logger.info("Removed venv_t5 dirs from sys.path") count = _purge_modules() logger.info("Purged %d cached modules", count) @@ -412,9 +594,10 @@ def _deactivate_5x() -> None: def ensure_transformers_version(model_name: str) -> None: """Ensure the correct ``transformers`` version is active for *model_name*. - Uses sys.path with .venv_t5/ (pre-installed by setup.sh): - • Need 5.x → prepend .venv_t5/ to sys.path, purge modules. - • Need 4.x → remove .venv_t5/ from sys.path, purge modules. + Uses sys.path with .venv_t5_530/ or .venv_t5_550/ (pre-installed by setup.sh): + • Need 5.5.0 → prepend .venv_t5_550/ to sys.path, purge modules. + • Need 5.3.0 → prepend .venv_t5_530/ to sys.path, purge modules. + • Need 4.x → remove all .venv_t5_*/ from sys.path, purge modules. For LoRA adapters with custom names, the base model is resolved from ``adapter_config.json`` before checking. @@ -424,8 +607,21 @@ def ensure_transformers_version(model_name: str) -> None: """ # Resolve LoRA adapters to their base model for accurate detection resolved = _resolve_base_model(model_name) - want_5 = needs_transformers_5(resolved) - target_version = TRANSFORMERS_5_VERSION if want_5 else TRANSFORMERS_DEFAULT_VERSION + tier = get_transformers_tier(resolved) + + if tier == "550": + target_version = TRANSFORMERS_550_VERSION + venv_dir = _VENV_T5_550_DIR + ensure_fn = _ensure_venv_t5_550_exists + elif tier == "530": + target_version = TRANSFORMERS_530_VERSION + venv_dir = _VENV_T5_530_DIR + ensure_fn = _ensure_venv_t5_530_exists + else: + target_version = TRANSFORMERS_DEFAULT_VERSION + venv_dir = None + ensure_fn = None + target_major = int(target_version.split(".")[0]) # Check what's actually loaded in memory @@ -441,8 +637,17 @@ def ensure_transformers_version(model_name: str) -> None: # --- Already correct? --------------------------------------------------- if in_memory is not None: + if in_memory == target_version: + logger.info( + "transformers %s already loaded — correct for '%s'", + in_memory, + model_name, + ) + return + # Different 5.x → need to switch (e.g. 5.3.0 loaded but need 5.5.0) in_memory_major = int(in_memory.split(".")[0]) - if in_memory_major == target_major: + if in_memory_major == target_major and venv_dir is None: + # Both are default (4.x) — close enough logger.info( "transformers %s already loaded — correct for '%s'", in_memory, @@ -451,9 +656,16 @@ def ensure_transformers_version(model_name: str) -> None: return # --- Switch version ----------------------------------------------------- - if want_5: - logger.info("Activating transformers %s via .venv_t5…", TRANSFORMERS_5_VERSION) - _activate_5x() + if venv_dir is not None: + # First remove any other 5.x venv from sys.path + _deactivate_5x() + if not ensure_fn(): + raise RuntimeError( + f"Cannot activate transformers {target_version}: " + f"venv missing at {venv_dir}" + ) + logger.info("Activating transformers %s…", target_version) + _activate_venv(venv_dir, f"transformers {target_version}") else: logger.info( "Reverting to default transformers %s…", TRANSFORMERS_DEFAULT_VERSION diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 60dfce0661..c3a8cd71ca 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -1579,55 +1579,104 @@ if ($stackExit -ne 0) { exit 1 } -# ── Pre-install transformers 5.x into .venv_t5/ ── -# Models like GLM-4.7-Flash need transformers>=5.3.0. Instead of pip-installing -# at runtime (slow, ~10-15s), we pre-install into a separate directory. -# The training subprocess just prepends .venv_t5/ to sys.path -- instant switch. -Write-Host "" -substep "pre-installing transformers 5.x for newer model support..." -$VenvT5Dir = Join-Path $env:USERPROFILE ".unsloth\studio\.venv_t5" -if (Test-Path $VenvT5Dir) { Remove-Item -Recurse -Force $VenvT5Dir } -New-Item -ItemType Directory -Path $VenvT5Dir -Force | Out-Null -$prevEAP_t5 = $ErrorActionPreference -$ErrorActionPreference = "Continue" -foreach ($pkg in @("transformers==5.5.0", "huggingface_hub==1.8.0", "hf_xet==1.4.2")) { - if ($script:UnslothVerbose) { - Fast-Install --target $VenvT5Dir --no-deps $pkg - $t5PkgExit = $LASTEXITCODE - $output = "" - } else { - $output = Fast-Install --target $VenvT5Dir --no-deps $pkg | Out-String - $t5PkgExit = $LASTEXITCODE - } - if ($t5PkgExit -ne 0) { - Write-Host "[FAIL] Could not install $pkg into .venv_t5/" -ForegroundColor Red - Write-Host $output -ForegroundColor Red - $ErrorActionPreference = $prevEAP_t5 - exit 1 - } -} -# tiktoken is needed by Qwen-family tokenizers -- install with deps since -# regex/requests may be missing on Windows -if ($script:UnslothVerbose) { - Fast-Install --target $VenvT5Dir tiktoken - $tiktokenInstallExit = $LASTEXITCODE - $output = "" -} else { - $output = Fast-Install --target $VenvT5Dir tiktoken | Out-String - $tiktokenInstallExit = $LASTEXITCODE -} -if ($tiktokenInstallExit -ne 0) { - substep "Could not install tiktoken into .venv_t5/ -- Qwen tokenizers may fail" "Yellow" -} -$ErrorActionPreference = $prevEAP_t5 -step "transformers" "5.x pre-installed" - } else { step "python" "dependencies up to date" # Restore ErrorActionPreference (was lowered for pip/python section) $ErrorActionPreference = $prevEAP } +# ── Pre-install transformers 5.x into .venv_t5_530/ and .venv_t5_550/ ── +# Runs outside the deps fast-path gate so that upgrades from the legacy +# single .venv_t5 are always migrated to the tiered layout. +$VenvT5_530Dir = Join-Path $env:USERPROFILE ".unsloth\studio\.venv_t5_530" +$VenvT5_550Dir = Join-Path $env:USERPROFILE ".unsloth\studio\.venv_t5_550" +$VenvT5Legacy = Join-Path $env:USERPROFILE ".unsloth\studio\.venv_t5" + +$_NeedT5Install = $false +if (Test-Path $VenvT5Legacy) { + Remove-Item -Recurse -Force $VenvT5Legacy + $_NeedT5Install = $true +} +if (-not (Test-Path $VenvT5_530Dir)) { $_NeedT5Install = $true } +if (-not (Test-Path $VenvT5_550Dir)) { $_NeedT5Install = $true } +# Also reinstall when python deps were updated +if (-not $SkipPythonDeps) { $_NeedT5Install = $true } + +if ($_NeedT5Install) { +Write-Host "" + +$prevEAP_t5 = $ErrorActionPreference +$ErrorActionPreference = "Continue" + +# --- .venv_t5_530 (transformers 5.3.0) --- +substep "pre-installing transformers 5.3.0 for newer model support..." +if (Test-Path $VenvT5_530Dir) { Remove-Item -Recurse -Force $VenvT5_530Dir } +New-Item -ItemType Directory -Path $VenvT5_530Dir -Force | Out-Null +foreach ($pkg in @("transformers==5.3.0", "huggingface_hub==1.8.0", "hf_xet==1.4.2")) { + if ($script:UnslothVerbose) { + Fast-Install --target $VenvT5_530Dir --no-deps $pkg + $t5PkgExit = $LASTEXITCODE + $output = "" + } else { + $output = Fast-Install --target $VenvT5_530Dir --no-deps $pkg | Out-String + $t5PkgExit = $LASTEXITCODE + } + if ($t5PkgExit -ne 0) { + Write-Host "[FAIL] Could not install $pkg into .venv_t5_530/" -ForegroundColor Red + Write-Host $output -ForegroundColor Red + $ErrorActionPreference = $prevEAP_t5 + exit 1 + } +} +if ($script:UnslothVerbose) { + Fast-Install --target $VenvT5_530Dir tiktoken + $tiktokenInstallExit = $LASTEXITCODE + $output = "" +} else { + $output = Fast-Install --target $VenvT5_530Dir tiktoken | Out-String + $tiktokenInstallExit = $LASTEXITCODE +} +if ($tiktokenInstallExit -ne 0) { + substep "Could not install tiktoken into .venv_t5_530/ -- Qwen tokenizers may fail" "Yellow" +} +step "transformers" "5.3.0 pre-installed" + +# --- .venv_t5_550 (transformers 5.5.0) --- +substep "pre-installing transformers 5.5.0 for Gemma 4 support..." +if (Test-Path $VenvT5_550Dir) { Remove-Item -Recurse -Force $VenvT5_550Dir } +New-Item -ItemType Directory -Path $VenvT5_550Dir -Force | Out-Null +foreach ($pkg in @("transformers==5.5.0", "huggingface_hub==1.8.0", "hf_xet==1.4.2")) { + if ($script:UnslothVerbose) { + Fast-Install --target $VenvT5_550Dir --no-deps $pkg + $t5PkgExit = $LASTEXITCODE + $output = "" + } else { + $output = Fast-Install --target $VenvT5_550Dir --no-deps $pkg | Out-String + $t5PkgExit = $LASTEXITCODE + } + if ($t5PkgExit -ne 0) { + Write-Host "[FAIL] Could not install $pkg into .venv_t5_550/" -ForegroundColor Red + Write-Host $output -ForegroundColor Red + $ErrorActionPreference = $prevEAP_t5 + exit 1 + } +} +if ($script:UnslothVerbose) { + Fast-Install --target $VenvT5_550Dir tiktoken + $tiktokenInstallExit = $LASTEXITCODE + $output = "" +} else { + $output = Fast-Install --target $VenvT5_550Dir tiktoken | Out-String + $tiktokenInstallExit = $LASTEXITCODE +} +if ($tiktokenInstallExit -ne 0) { + substep "Could not install tiktoken into .venv_t5_550/ -- Qwen tokenizers may fail" "Yellow" +} +$ErrorActionPreference = $prevEAP_t5 +step "transformers" "5.5.0 pre-installed" + +} # end $_NeedT5Install + # ========================================================================== # PHASE 3.4: Prefer prebuilt llama.cpp bundles before source build # ========================================================================== diff --git a/studio/setup.sh b/studio/setup.sh index e3ff2da35c..eb89e583ca 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -394,11 +394,14 @@ fi # ── Python venv + deps ── STUDIO_HOME="$HOME/.unsloth/studio" VENV_DIR="$STUDIO_HOME/unsloth_studio" -VENV_T5_DIR="$STUDIO_HOME/.venv_t5" +VENV_T5_530_DIR="$STUDIO_HOME/.venv_t5_530" +VENV_T5_550_DIR="$STUDIO_HOME/.venv_t5_550" [ -d "$REPO_ROOT/.venv" ] && rm -rf "$REPO_ROOT/.venv" [ -d "$REPO_ROOT/.venv_overlay" ] && rm -rf "$REPO_ROOT/.venv_overlay" [ -d "$REPO_ROOT/.venv_t5" ] && rm -rf "$REPO_ROOT/.venv_t5" +[ -d "$REPO_ROOT/.venv_t5_530" ] && rm -rf "$REPO_ROOT/.venv_t5_530" +[ -d "$REPO_ROOT/.venv_t5_550" ] && rm -rf "$REPO_ROOT/.venv_t5_550" # Note: do NOT delete $STUDIO_HOME/.venv here — install.sh handles migration _COLAB_NO_VENV=false @@ -501,21 +504,47 @@ fi if [ "$_SKIP_PYTHON_DEPS" = false ]; then install_python_stack - - # ── 6b. Pre-install transformers 5.x into .venv_t5/ ── - # Models like GLM-4.7-Flash need transformers>=5.3.0. Instead of pip-installing - # at runtime (slow, ~10-15s), we pre-install into a separate directory. - # The training subprocess just prepends .venv_t5/ to sys.path -- instant switch. - mkdir -p "$VENV_T5_DIR" - run_quiet "install transformers 5.x" fast_install --target "$VENV_T5_DIR" --no-deps "transformers==5.5.0" - run_quiet "install huggingface_hub for t5" fast_install --target "$VENV_T5_DIR" --no-deps "huggingface_hub==1.8.0" - run_quiet "install hf_xet for t5" fast_install --target "$VENV_T5_DIR" --no-deps "hf_xet==1.4.2" - run_quiet "install tiktoken for t5" fast_install --target "$VENV_T5_DIR" "tiktoken" - step "transformers" "5.x pre-installed" else step "python" "dependencies up to date" verbose_substep "python deps check: installed=$_PKG_NAME@${INSTALLED_VER:-unknown} latest=${LATEST_VER:-unknown}" fi + +# ── 6b. Pre-install transformers 5.x into .venv_t5_530/ and .venv_t5_550/ ── +# Models like GLM-4.7-Flash, Qwen3 MoE need transformers>=5.3.0. +# Gemma 4 models need transformers>=5.5.0. +# Pre-install into separate directories to avoid runtime pip overhead. +# The training subprocess prepends the appropriate dir to sys.path. +# +# Runs outside the _SKIP_PYTHON_DEPS gate so that upgrades from legacy +# single .venv_t5 are always migrated to the tiered layout. +_NEED_T5_INSTALL=false +if [ -d "$STUDIO_HOME/.venv_t5" ]; then + # Legacy layout — migrate + rm -rf "$STUDIO_HOME/.venv_t5" + _NEED_T5_INSTALL=true +fi +[ ! -d "$VENV_T5_530_DIR" ] && _NEED_T5_INSTALL=true +[ ! -d "$VENV_T5_550_DIR" ] && _NEED_T5_INSTALL=true +# Also reinstall when python deps were updated (packages may need rebuild) +[ "$_SKIP_PYTHON_DEPS" = false ] && _NEED_T5_INSTALL=true + +if [ "$_NEED_T5_INSTALL" = true ]; then + [ -d "$VENV_T5_530_DIR" ] && rm -rf "$VENV_T5_530_DIR" + mkdir -p "$VENV_T5_530_DIR" + run_quiet "install transformers 5.3.0" fast_install --target "$VENV_T5_530_DIR" --no-deps "transformers==5.3.0" + run_quiet "install huggingface_hub for t5_530" fast_install --target "$VENV_T5_530_DIR" --no-deps "huggingface_hub==1.8.0" + run_quiet "install hf_xet for t5_530" fast_install --target "$VENV_T5_530_DIR" --no-deps "hf_xet==1.4.2" + run_quiet "install tiktoken for t5_530" fast_install --target "$VENV_T5_530_DIR" "tiktoken" + step "transformers" "5.3.0 pre-installed" + + [ -d "$VENV_T5_550_DIR" ] && rm -rf "$VENV_T5_550_DIR" + mkdir -p "$VENV_T5_550_DIR" + run_quiet "install transformers 5.5.0" fast_install --target "$VENV_T5_550_DIR" --no-deps "transformers==5.5.0" + run_quiet "install huggingface_hub for t5_550" fast_install --target "$VENV_T5_550_DIR" --no-deps "huggingface_hub==1.8.0" + run_quiet "install hf_xet for t5_550" fast_install --target "$VENV_T5_550_DIR" --no-deps "hf_xet==1.4.2" + run_quiet "install tiktoken for t5_550" fast_install --target "$VENV_T5_550_DIR" "tiktoken" + step "transformers" "5.5.0 pre-installed" +fi fi # ── 7. Prefer prebuilt llama.cpp bundles before any source build path ── From c2184af07985b30eecdd41480d52dfafefc1e2ec Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 22:50:48 -0700 Subject: [PATCH 056/216] [pre-commit.ci] pre-commit autoupdate (#4879) 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.8 → v0.15.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.8...v0.15.9) 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 f3b5987a9c..e41c37d209 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.8 + rev: v0.15.9 hooks: - id: ruff args: From 67e9db4921e16562497dfd5c873e8bd01970906b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 03:35:33 -0700 Subject: [PATCH 057/216] build(deps): bump oxc-parser (#4776) Bumps the npm-oxc-validator group in /studio/backend/core/data_recipe/oxc-validator with 1 update: [oxc-parser](https://github.com/oxc-project/oxc/tree/HEAD/napi/parser). Updates `oxc-parser` from 0.121.0 to 0.123.0 - [Release notes](https://github.com/oxc-project/oxc/releases) - [Changelog](https://github.com/oxc-project/oxc/blob/main/napi/parser/CHANGELOG.md) - [Commits](https://github.com/oxc-project/oxc/commits/crates_v0.123.0/napi/parser) --- updated-dependencies: - dependency-name: oxc-parser dependency-version: 0.123.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-oxc-validator ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- studio/backend/core/data_recipe/oxc-validator/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/studio/backend/core/data_recipe/oxc-validator/package.json b/studio/backend/core/data_recipe/oxc-validator/package.json index d1c765a2e1..111ae2b257 100644 --- a/studio/backend/core/data_recipe/oxc-validator/package.json +++ b/studio/backend/core/data_recipe/oxc-validator/package.json @@ -4,7 +4,7 @@ "version": "0.0.1", "type": "module", "dependencies": { - "oxc-parser": "^0.121.0", + "oxc-parser": "^0.123.0", "oxlint": "^1.51.0" } } From 0087515d5cd5ffd0eb469c15c01a7d0aac3a0040 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 03:35:40 -0700 Subject: [PATCH 058/216] build(deps): bump oxc-parser (#4776) Bumps the npm-oxc-validator group in /studio/backend/core/data_recipe/oxc-validator with 1 update: [oxc-parser](https://github.com/oxc-project/oxc/tree/HEAD/napi/parser). Updates `oxc-parser` from 0.121.0 to 0.123.0 - [Release notes](https://github.com/oxc-project/oxc/releases) - [Changelog](https://github.com/oxc-project/oxc/blob/main/napi/parser/CHANGELOG.md) - [Commits](https://github.com/oxc-project/oxc/commits/crates_v0.123.0/napi/parser) --- updated-dependencies: - dependency-name: oxc-parser dependency-version: 0.123.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-oxc-validator ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From c3d2d58046ebf5d706d038173ddf661436c4e861 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 8 Apr 2026 03:39:50 -0700 Subject: [PATCH 059/216] Update dependabot.yml (#4915) --- .github/dependabot.yml | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a06cb1d114..4a0bfa70f1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,19 +9,6 @@ updates: actions: patterns: ["*"] - - package-ecosystem: "pip" - directories: - - "/" - - "/studio/backend/plugins/data-designer-unstructured-seed" - - "/studio/backend/requirements" - - "/unsloth/kernels/moe" - schedule: - interval: "weekly" - open-pull-requests-limit: 10 - groups: - pip: - patterns: ["*"] - - package-ecosystem: "bun" directory: "/studio/frontend" schedule: From 8e977445d43350e3bc74b67544a5fbdbde29bb52 Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Wed, 8 Apr 2026 12:48:22 +0200 Subject: [PATCH 060/216] Let recipes use the model loaded in Chat (#4840) * feat: inject local model provider into recipe jobs via JWT * feat: auto-generate JWT for local model providers in recipes * feat: add is_local flag to model provider config types and utils * fix(studio): skip endpoint validation for local providers * feat(studio): add local/external model source toggle to provider dialog * feat(studio): thread localProviderNames through model config dialog chain * feat(studio): show 'Local model (Chat)' label for local model_provider configs * fix: hardcode loopback for local endpoint, clear stale creds on toggle * fix: document TOCTOU/JWT rotation, add deferred import comments, fix is_local serialization * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix(studio): clear stale local model state on provider toggle and validation * fix(studio): override empty local endpoint in validation and skip model gate for unused providers * fix(studio): resolve loopback port from app.state, clear stale local provider fields, sync model id on toggle Address review feedback on the local-model-provider flow: - Backend (jobs.py): _resolve_local_v1_endpoint now reads the actual bound port from app.state.server_port (set in run.py after binding) instead of parsing it out of request.base_url, which is wrong behind any reverse proxy or non-default port. The two duplicated urlparse blocks are gone. - Backend (jobs.py): defensively pop api_key_env, extra_headers, extra_body from local providers so a previously external provider that flipped to local cannot leak invalid JSON or rogue auth headers into the local /v1 call. Also dedupe the post-loop assignment and tighten the local-name intersection so empty names cannot match. - Backend (jobs.py): hoist datetime and urllib.parse imports to the top import block for consistency with the rest of the file. - Backend (run.py): expose the bound port on app.state.server_port after the uvicorn server is constructed. - Frontend (model-provider-dialog.tsx): clear extra_headers and extra_body when toggling to local mode. Hidden inputs would otherwise keep stale JSON blocking validate/run. - Frontend (model-config-dialog.tsx): factor the local-aware provider selection logic into applyProviderChange and call it from both onValueChange and onBlur, so manually typing a provider name and tabbing away keeps the model field consistent. - Frontend (recipe-studio.ts store): handle both directions of the is_local toggle in the cascade. external -> local now backfills model: "local" on already-linked model_configs so they pass validation immediately, mirroring the existing local -> external clear path. - Frontend (validate.ts + build-payload.ts): thread localProviderNames into validateModelConfigProviders and skip the "model is required" check for local-linked configs. Local providers do not need a real model id since the inference endpoint uses the loaded Chat model. * fix(studio): narrow store cascade types, sync model placeholder on graph relink and node removal, harden ephemeral port path Loop 2 review fixes: - recipe-studio.ts: type-narrow next.is_local by also checking next.kind === "model_provider". TS otherwise raised TS2339 because next was typed as the union NodeConfig after the spread. The behavior is unchanged but the code now compiles cleanly. - model-config-dialog.tsx: convert the lastProviderRef / providerInputRef ref-during-render pattern (pre-existing react-hooks/refs lint error) to a useEffect that syncs providerInputRef from config.provider. The combobox blur path still uses applyProviderChange and remains stable. - recipe-graph-connection.ts: when a graph drag links a model_provider to a model_config, mirror the dialog applyProviderChange behavior: fill model: "local" if the new provider is local and the model field is blank, clear model when relinking from a local placeholder to an external provider, otherwise leave the model alone. - reference-sync.ts: when a referenced provider node is removed, clear the synthetic model: "local" placeholder along with the provider field, so a future relink to an external provider does not pass validation with a stale value that fails at runtime. - run.py: only publish app.state.server_port when the bound port is a real positive integer; for ephemeral binds (port==0) leave it unset and let request handlers fall back to request.base_url. - jobs.py: _resolve_local_v1_endpoint also falls back when app.state.server_port is non-positive, and uses `is None` instead of the truthy fallback so a literal 0 is handled correctly. * fix(studio): strict is_local check, narrow loaded-model gate to LLM-reachable configs, add scope-server port fallback Loop 3 review fixes: - jobs.py, validate.py: require `is_local is True` instead of truthy check. Malformed payloads such as is_local: "false" or is_local: 1 would otherwise be treated as local and silently rewritten to the loopback endpoint. - jobs.py: _resolve_local_v1_endpoint now tries request.scope["server"] (the actual uvicorn-assigned (host, port) tuple) as a second resolution step before falling back to parsing request.base_url. This covers direct-uvicorn startup paths and ephemeral binds that never publish app.state.server_port. - jobs.py: new _used_llm_model_aliases helper collects the set of model_aliases that an LLM column actually references, and the "Chat model loaded" gate is now only triggered when a local provider is reachable from that set. Orphan model_config nodes on the canvas no longer block unrelated recipe runs. * fix(studio): force skip_health_check on local-linked configs, skip JSON parsing for local providers, local-aware inline editor Loop 4 review fixes: - jobs.py: after rewriting local providers, also force skip_health_check: true on any model_config linked to a local provider. The /v1/models endpoint only advertises the real loaded model id, so data_designer's default model-availability health check would otherwise fail against the placeholder "local" id before the first chat completion call. The inference route already ignores the model id in chat completions, so skipping the check is safe. - builders-model.ts: buildModelProvider now short-circuits for local providers and emits only { name, endpoint: "", provider_type, is_local } without running parseJsonObject on the hidden extra_headers/extra_body inputs. Imported or hydrated recipes with stale invalid JSON in those fields no longer block client-side validate/run. - inline-model.tsx: the model_config branch now accepts an optional localProviderNames prop and mirrors the dialog applyProviderChange behavior. Changing provider to/from a local one auto-fills or clears the "local" placeholder consistently with the other edit paths. - recipe-graph-node.tsx: derive localProviderNames from the store via useMemo (stable identity) and pass it through renderNodeBody to . Hooks order is preserved by declaring them above the early return for markdown_note nodes. - run.py: minor comment tweak - loop 3 already added the scope-server fallback path, note that in the comment. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen --- studio/backend/routes/data_recipe/jobs.py | 164 ++++++++++++- studio/backend/routes/data_recipe/validate.py | 16 ++ studio/backend/run.py | 8 + .../recipe-studio/blocks/render-dialog.tsx | 2 + .../components/inline/inline-model.tsx | 39 +++- .../components/recipe-graph-node.tsx | 28 ++- .../recipe-studio/dialogs/config-dialog.tsx | 3 + .../dialogs/models/model-config-dialog.tsx | 38 ++- .../dialogs/models/model-provider-dialog.tsx | 218 ++++++++++++------ .../recipe-studio/recipe-studio-page.tsx | 1 + .../stores/helpers/reference-sync.ts | 10 +- .../recipe-studio/stores/recipe-studio.ts | 28 +++ .../src/features/recipe-studio/types/index.ts | 2 + .../recipe-studio/utils/config-factories.ts | 2 + .../utils/graph/recipe-graph-connection.ts | 12 +- .../utils/import/parsers/model-parser.ts | 2 + .../utils/payload/build-payload.ts | 5 + .../utils/payload/builders-model.ts | 20 ++ .../recipe-studio/utils/payload/validate.ts | 9 +- .../recipe-studio/utils/recipe-studio-view.ts | 6 + 20 files changed, 518 insertions(+), 95 deletions(-) diff --git a/studio/backend/routes/data_recipe/jobs.py b/studio/backend/routes/data_recipe/jobs.py index 1d5eceee03..00546b47a4 100644 --- a/studio/backend/routes/data_recipe/jobs.py +++ b/studio/backend/routes/data_recipe/jobs.py @@ -5,7 +5,9 @@ from __future__ import annotations +from datetime import timedelta from typing import Any +from urllib.parse import urlparse from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import JSONResponse, StreamingResponse @@ -26,6 +28,161 @@ from models.data_recipe import ( router = APIRouter() +def _resolve_local_v1_endpoint(request: Request) -> str: + """Return the loopback /v1 URL for the actual backend listen port. + + Resolution order: + 1. ``app.state.server_port`` - explicitly published by run.py after + the uvicorn server has bound. This is the most reliable source + because it survives reverse proxies, TLS terminators and tunnels. + 2. ``request.scope["server"]`` - the real (host, port) tuple uvicorn + sets when the request is dispatched. Used when Studio is started + outside ``run_server`` (e.g. ``uvicorn studio.backend.main:app``). + 3. ``request.base_url`` parsed - last resort for test fixtures that + do not route through a live uvicorn server. + """ + port: Any = getattr(request.app.state, "server_port", None) + if not isinstance(port, int) or port <= 0: + server = request.scope.get("server") + if ( + isinstance(server, tuple) + and len(server) >= 2 + and isinstance(server[1], int) + and server[1] > 0 + ): + port = server[1] + else: + parsed = urlparse(str(request.base_url)) + port = parsed.port if parsed.port is not None else 8888 + return f"http://127.0.0.1:{int(port)}/v1" + + +def _used_llm_model_aliases(recipe: dict[str, Any]) -> set[str]: + """Return the set of model_aliases that are actually referenced by an + LLM column. Used to narrow the "Chat model loaded" gate so that orphan + model_config nodes on the canvas do not block unrelated recipe runs. + + The ``llm-`` prefix matches the existing convention in + ``core/data_recipe/service.py::_recipe_has_llm_columns`` and covers all + LLM column types emitted by the frontend (llm-text, llm-code, + llm-structured, llm-judge). + """ + aliases: set[str] = set() + for column in recipe.get("columns", []): + if not isinstance(column, dict): + continue + column_type = column.get("column_type") + if not isinstance(column_type, str) or not column_type.startswith("llm-"): + continue + alias = column.get("model_alias") + if isinstance(alias, str) and alias: + aliases.add(alias) + return aliases + + +def _inject_local_providers(recipe: dict[str, Any], request: Request) -> None: + """ + Mutate recipe dict in-place: for any provider with is_local=True, + generate a JWT and fill in the endpoint pointing at this server. + """ + providers = recipe.get("model_providers") + if not providers: + return + + # Collect local providers and pop is_local from ALL dicts unconditionally. + # Strict `is True` guard so malformed payloads (is_local: 1, + # is_local: "true") do not accidentally trigger the loopback rewrite. + local_indices: list[int] = [] + for i, provider in enumerate(providers): + if not isinstance(provider, dict): + continue + is_local = provider.pop("is_local", None) + if is_local is True: + local_indices.append(i) + + if not local_indices: + return + + endpoint = _resolve_local_v1_endpoint(request) + + # Only gate on model-loaded if a local provider is actually reachable + # from an LLM column through a model_config. Orphan model_config nodes + # that reference a local provider but that no LLM column uses should + # not block runs; the recipe would never call /v1 for them. + local_names = { + providers[i].get("name") for i in local_indices if providers[i].get("name") + } + used_aliases = _used_llm_model_aliases(recipe) + referenced_providers = { + mc.get("provider") + for mc in recipe.get("model_configs", []) + if ( + isinstance(mc, dict) + and mc.get("provider") + and mc.get("alias") in used_aliases + ) + } + + token = "" + if local_names & referenced_providers: + # Verify a model is loaded. + # NOTE: This is a point-in-time check (TOCTOU). The model could be unloaded + # or swapped after this check but before the recipe subprocess calls /v1. + # The inference endpoint returns a clear 400 in that case. + # + # Imports are deferred to avoid circular dependencies with inference modules. + from routes.inference import get_llama_cpp_backend + from core.inference import get_inference_backend + + llama = get_llama_cpp_backend() + model_loaded = llama.is_loaded + if not model_loaded: + backend = get_inference_backend() + model_loaded = bool(backend.active_model_name) + if not model_loaded: + raise ValueError( + "No model loaded in Chat. Load a model first, then run the recipe." + ) + + from auth.authentication import ( + create_access_token, + ) # deferred: avoids circular import + + # Uses the "unsloth" admin subject. If the user changes their password, + # the JWT secret rotates and this token becomes invalid mid-run. + # Acceptable for v1 - recipes typically finish well within one session. + token = create_access_token( + subject = "unsloth", + expires_delta = timedelta(hours = 24), + ) + + # Defensively strip any stale "external"-only fields the frontend may + # have left on the dict (extra_headers/extra_body/api_key_env). The UI + # hides these inputs in local mode but the payload builder still serializes + # them, so a previously external provider that flipped to local can carry + # invalid JSON or rogue auth headers into the local /v1 call. + for i in local_indices: + providers[i]["endpoint"] = endpoint + providers[i]["api_key"] = token + providers[i]["provider_type"] = "openai" + providers[i].pop("api_key_env", None) + providers[i].pop("extra_headers", None) + providers[i].pop("extra_body", None) + + # Force skip_health_check on any model_config that references a local + # provider. The local /v1/models endpoint only lists the real loaded + # model (e.g. "unsloth/llama-3.2-1b") and not the placeholder "local" + # that the recipe sends as the model id, so data_designer's pre-flight + # health check would otherwise fail before the first completion call. + # The backend route ignores the model id field in chat completions, so + # skipping the check is safe. + for mc in recipe.get("model_configs", []): + if not isinstance(mc, dict): + continue + if mc.get("provider") in local_names: + mc["skip_health_check"] = True + + def _normalize_run_name(value: Any) -> str | None: if value is None: return None @@ -40,7 +197,7 @@ def _normalize_run_name(value: Any) -> str | None: @router.post("/jobs", response_class = JSONResponse, response_model = JobCreateResponse) -def create_job(payload: RecipePayload): +def create_job(payload: RecipePayload, request: Request): recipe = payload.recipe if not recipe.get("columns"): raise HTTPException(status_code = 400, detail = "Recipe must include columns.") @@ -67,6 +224,11 @@ def create_job(payload: RecipePayload): status_code = 400, detail = f"invalid run_config: {exc}" ) from exc + try: + _inject_local_providers(recipe, request) + except ValueError as exc: + raise HTTPException(status_code = 400, detail = str(exc)) from exc + mgr = get_job_manager() try: job_id = mgr.start(recipe = recipe, run = run) diff --git a/studio/backend/routes/data_recipe/validate.py b/studio/backend/routes/data_recipe/validate.py index a793a3b172..555e3eaa06 100644 --- a/studio/backend/routes/data_recipe/validate.py +++ b/studio/backend/routes/data_recipe/validate.py @@ -68,6 +68,20 @@ def _collect_validation_errors(recipe: dict[str, Any]) -> list[ValidateError]: return errors +def _patch_local_providers(recipe: dict[str, Any]) -> None: + """Strip is_local and fill a dummy endpoint so validation doesn't choke. + + Uses a strict `is True` check to match _inject_local_providers in + jobs.py - malformed payloads with truthy but non-boolean is_local + values should not be treated as local. + """ + for provider in recipe.get("model_providers", []): + if not isinstance(provider, dict): + continue + if provider.pop("is_local", None) is True: + provider["endpoint"] = "http://127.0.0.1" + + @router.post("/validate", response_model = ValidateResponse) def validate(payload: RecipePayload) -> ValidateResponse: recipe = payload.recipe @@ -77,6 +91,8 @@ def validate(payload: RecipePayload) -> ValidateResponse: errors = [ValidateError(message = "Recipe must include columns.")], ) + _patch_local_providers(recipe) + try: validate_recipe(recipe) except RuntimeError as exc: diff --git a/studio/backend/run.py b/studio/backend/run.py index 9c3622988e..86c1194661 100644 --- a/studio/backend/run.py +++ b/studio/backend/run.py @@ -324,6 +324,14 @@ def run_server( _server = uvicorn.Server(config) _shutdown_event = Event() + # Expose the actual bound port so request-handling code can build + # loopback URLs that point at the real backend, not whatever port a + # reverse proxy or tunnel exposed in the request URL. Only publish + # an explicit value when we know the concrete port; for ephemeral + # binds (port==0) leave it unset and let request handlers fall back + # to the ASGI request scope or request.base_url. + app.state.server_port = port if port and port > 0 else None + # Run server in a daemon thread def _run(): asyncio.run(_server.serve()) diff --git a/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx b/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx index 10fcaa489d..92f72dfff1 100644 --- a/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx +++ b/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx @@ -28,6 +28,7 @@ export function renderBlockDialog( categoryOptions: SamplerConfig[], modelConfigAliases: string[], modelProviderOptions: string[], + localProviderNames: Set, toolProfileAliases: string[], datetimeOptions: string[], onUpdate: (id: string, patch: Partial) => void, @@ -109,6 +110,7 @@ export function renderBlockDialog( ) : null; diff --git a/studio/frontend/src/features/recipe-studio/components/inline/inline-model.tsx b/studio/frontend/src/features/recipe-studio/components/inline/inline-model.tsx index d3ca22b00b..16e99f4fae 100644 --- a/studio/frontend/src/features/recipe-studio/components/inline/inline-model.tsx +++ b/studio/frontend/src/features/recipe-studio/components/inline/inline-model.tsx @@ -10,11 +10,21 @@ type InlineModelPatch = Partial | Partial; type InlineModelProps = { config: ModelProviderConfig | ModelConfig; + localProviderNames?: Set; onUpdate: (patch: InlineModelPatch) => void; }; export function InlineModel(props: InlineModelProps): ReactElement { if (props.config.kind === "model_provider") { + if (props.config.is_local) { + return ( +
+ + Local model (Chat) + +
+ ); + } return (
@@ -42,21 +52,40 @@ export function InlineModel(props: InlineModelProps): ReactElement { ); } + // model_config branch - mirror the local-aware provider sync from the + // dialog path so inline edits do not leave stale "local" placeholders + // on external providers and fill the placeholder when switching to local. + const localNames = props.localProviderNames ?? new Set(); + const modelConfig = props.config; + const handleProviderChange = (nextProvider: string) => { + const isLocal = localNames.has(nextProvider); + if (isLocal && !modelConfig.model.trim()) { + props.onUpdate({ provider: nextProvider, model: "local" }); + return; + } + if (!isLocal && modelConfig.model === "local") { + props.onUpdate({ provider: nextProvider, model: "" }); + return; + } + props.onUpdate({ provider: nextProvider }); + }; + const isLinkedToLocal = localNames.has(modelConfig.provider); + return (
props.onUpdate({ provider: event.target.value })} + value={modelConfig.provider} + onChange={(event) => handleProviderChange(event.target.value)} /> props.onUpdate({ model: event.target.value })} /> @@ -65,7 +94,7 @@ export function InlineModel(props: InlineModelProps): ReactElement { className="nodrag h-8 w-full text-xs" type="number" placeholder="0.7" - value={props.config.inference_temperature ?? ""} + value={modelConfig.inference_temperature ?? ""} onChange={(event) => props.onUpdate({ // biome-ignore lint/style/useNamingConvention: api schema diff --git a/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx b/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx index 0fcf202190..8afc4e26bf 100644 --- a/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx +++ b/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx @@ -30,7 +30,7 @@ import { Position, useUpdateNodeInternals, } from "@xyflow/react"; -import { type ReactElement, memo, useEffect } from "react"; +import { type ReactElement, memo, useEffect, useMemo } from "react"; import { MAX_NODE_WIDTH, MAX_NOTE_NODE_WIDTH, @@ -287,6 +287,7 @@ function renderNodeBody( config: NodeConfig | undefined, summary: string, updateConfig: (id: string, patch: Partial) => void, + localProviderNames: Set, ): ReactElement { if (config?.kind === "markdown_note") { return ; @@ -300,7 +301,13 @@ function renderNodeBody( return ; } if (config.kind === "model_provider" || config.kind === "model_config") { - return ; + return ( + + ); } if (config.kind === "llm") { return ; @@ -355,6 +362,16 @@ function RecipeGraphNodeBase({ const config = useRecipeStudioStore((state) => state.configs[id]); const openConfig = useRecipeStudioStore((state) => state.openConfig); const updateConfig = useRecipeStudioStore((state) => state.updateConfig); + const allConfigs = useRecipeStudioStore((state) => state.configs); + const localProviderNames = useMemo(() => { + const names = new Set(); + for (const cfg of Object.values(allConfigs)) { + if (cfg.kind === "model_provider" && cfg.is_local === true) { + names.add(cfg.name); + } + } + return names; + }, [allConfigs]); const llmAuxVisible = useRecipeStudioStore( (state) => state.llmAuxVisibility[id] ?? false, ); @@ -418,7 +435,12 @@ function RecipeGraphNodeBase({ data.kind === "tool_config" || data.kind === "validator"; const summary = getConfigSummary(config); - const nodeBody = renderNodeBody(config, summary, updateConfig); + const nodeBody = renderNodeBody( + config, + summary, + updateConfig, + localProviderNames, + ); const canShowLlmAux = config?.kind === "llm" && (Boolean(config.prompt.trim()) || diff --git a/studio/frontend/src/features/recipe-studio/dialogs/config-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/config-dialog.tsx index 6457993c35..c62169956d 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/config-dialog.tsx +++ b/studio/frontend/src/features/recipe-studio/dialogs/config-dialog.tsx @@ -18,6 +18,7 @@ type ConfigDialogProps = { categoryOptions: SamplerConfig[]; modelConfigAliases: string[]; modelProviderOptions: string[]; + localProviderNames: Set; toolProfileAliases: string[]; datetimeOptions: string[]; onUpdate: (id: string, patch: Partial) => void; @@ -32,6 +33,7 @@ export function ConfigDialog({ categoryOptions, modelConfigAliases, modelProviderOptions, + localProviderNames, toolProfileAliases, datetimeOptions, onUpdate, @@ -101,6 +103,7 @@ export function ConfigDialog({ categoryOptions, modelConfigAliases, modelProviderOptions, + localProviderNames, toolProfileAliases, datetimeOptions, onUpdate, diff --git a/studio/frontend/src/features/recipe-studio/dialogs/models/model-config-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/models/model-config-dialog.tsx index 3192dc8fd9..368ae08acb 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/models/model-config-dialog.tsx +++ b/studio/frontend/src/features/recipe-studio/dialogs/models/model-config-dialog.tsx @@ -17,7 +17,7 @@ import { } from "@/components/ui/combobox"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { type ReactElement, useRef, useState } from "react"; +import { type ReactElement, useEffect, useRef, useState } from "react"; import type { ModelConfig } from "../../types"; import { CollapsibleSectionTriggerButton } from "../shared/collapsible-section-trigger"; import { FieldLabel } from "../shared/field-label"; @@ -26,14 +26,17 @@ import { NameField } from "../shared/name-field"; type ModelConfigDialogProps = { config: ModelConfig; providerOptions: string[]; + localProviderNames: Set; onUpdate: (patch: Partial) => void; }; export function ModelConfigDialog({ config, providerOptions, + localProviderNames, onUpdate, }: ModelConfigDialogProps): ReactElement { + const isLinkedToLocal = localProviderNames.has(config.provider); const [optionalOpen, setOptionalOpen] = useState(false); const modelId = `${config.id}-model`; const providerId = `${config.id}-provider`; @@ -44,11 +47,13 @@ export function ModelConfigDialog({ const extraBodyId = `${config.id}-inference-extra-body`; const providerAnchorRef = useRef(null); const providerInputRef = useRef(config.provider); - const lastProviderRef = useRef(config.provider); - if (lastProviderRef.current !== config.provider) { - lastProviderRef.current = config.provider; + // Sync providerInputRef with the current provider value. Updating a ref in + // an effect (vs reading/writing it during render) satisfies the + // react-hooks/refs rule and keeps the combobox blur path stable across + // re-renders. + useEffect(() => { providerInputRef.current = config.provider; - } + }, [config.provider]); const updateField = ( key: K, value: ModelConfig[K], @@ -56,6 +61,21 @@ export function ModelConfigDialog({ onUpdate({ [key]: value } as Partial); }; + // Apply provider selection while keeping the local-provider model autofill + // consistent across both dropdown selection and free-typed + blur input. + const applyProviderChange = (selectedProvider: string) => { + const isLocal = localProviderNames.has(selectedProvider); + if (isLocal && !config.model.trim()) { + onUpdate({ provider: selectedProvider, model: "local" }); + return; + } + if (!isLocal && config.model === "local") { + onUpdate({ provider: selectedProvider, model: "" }); + return; + } + updateField("provider", selectedProvider); + }; + return (
updateField("provider", value ?? "")} + onValueChange={(value) => applyProviderChange(value ?? "")} onInputValueChange={(value) => { providerInputRef.current = value; }} @@ -98,7 +118,7 @@ export function ModelConfigDialog({ onBlur={() => { const next = providerInputRef.current; if (next !== config.provider) { - updateField("provider", next); + applyProviderChange(next); } }} /> @@ -124,12 +144,12 @@ export function ModelConfigDialog({ updateField("model", event.target.value)} /> diff --git a/studio/frontend/src/features/recipe-studio/dialogs/models/model-provider-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/models/model-provider-dialog.tsx index 897fc7ee36..ef7366a225 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/models/model-provider-dialog.tsx +++ b/studio/frontend/src/features/recipe-studio/dialogs/models/model-provider-dialog.tsx @@ -24,6 +24,7 @@ export function ModelProviderDialog({ onUpdate, }: ModelProviderDialogProps): ReactElement { const [optionalOpen, setOptionalOpen] = useState(false); + const isLocal = config.is_local ?? false; const endpointId = `${config.id}-endpoint`; const apiKeyEnvId = `${config.id}-api-key-env`; const apiKeyId = `${config.id}-api-key`; @@ -43,94 +44,163 @@ export function ModelProviderDialog({ value={config.name} onChange={(value) => onUpdate({ name: value })} /> -
-

- Start with the endpoint you want this model to use -

-

- Most connections only need an endpoint. Add an API key if that - service requires one. -

-
+ + {/* Model source toggle */}
- - updateField("endpoint", event.target.value)} - /> +

Model source

+
+ + +
-
- - updateField("api_key", event.target.value)} - /> -
- - - - - + + {isLocal ? ( +
+

+ Ready to go +

+

+ Recipes will use whatever model is loaded in the Chat tab when you + hit run. No endpoint or API key needed. +

+
+ ) : ( + <> +
+

+ Start with the endpoint you want this model to use +

+

+ Most connections only need an endpoint. Add an API key if that + service requires one. +

+
updateField("api_key_env", event.target.value)} + placeholder="https://..." + value={config.endpoint} + onChange={(event) => updateField("endpoint", event.target.value)} />
-