From 6984e118eb5b2e885136ce6621ab9ee0eb1eac40 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 31 Mar 2026 07:00:51 -0700 Subject: [PATCH 01/44] Bump installer minimum version pin to 2026.3.18 (#4729) Matches the latest PyPI release. --- install.ps1 | 10 +++++----- install.sh | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/install.ps1 b/install.ps1 index 0c36046195..e6c9f9efb6 100644 --- a/install.ps1 +++ b/install.ps1 @@ -820,7 +820,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.3.16" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.18" unsloth-zoo } if ($baseInstallExit -eq 0) { $NoTorchReq = Find-NoTorchRuntimeFile if ($NoTorchReq) { @@ -828,7 +828,7 @@ shell.Run cmd, 0, False } } } else { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.16" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.18" unsloth-zoo } } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red @@ -858,7 +858,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.3.16" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.3.18" unsloth-zoo } if ($baseInstallExit -eq 0) { $NoTorchReq = Find-NoTorchRuntimeFile if ($NoTorchReq) { @@ -866,7 +866,7 @@ shell.Run cmd, 0, False } } } elseif ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.3.16" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.3.18" unsloth-zoo } } else { $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "$PackageName" } } @@ -887,7 +887,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.3.16" --torch-backend=auto } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.3.18" --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 9ea80bc161..5d8f8c68cf 100755 --- a/install.sh +++ b/install.sh @@ -1029,7 +1029,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.3.16" unsloth-zoo + "unsloth>=2026.3.18" 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" @@ -1037,7 +1037,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.3.16" unsloth-zoo + "unsloth>=2026.3.18" unsloth-zoo fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then substep "overlaying local repo (editable)..." @@ -1059,7 +1059,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.3.16" unsloth-zoo + "unsloth>=2026.3.18" 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" @@ -1070,7 +1070,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.3.16" unsloth-zoo + --upgrade-package unsloth "unsloth>=2026.3.18" 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 @@ -1081,7 +1081,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.3.16" --torch-backend=auto + run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.3.18" --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 2cac3e8e4d4802036ce530caeaadd77a19e0bb2a Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Wed, 1 Apr 2026 07:12:42 +0100 Subject: [PATCH 02/44] studio: Polish Windows installer/setup logs (#4736) * style(windows): clean installer/setup log output and remove seeded credential banner * Keep startup credential hint without exposing plaintext password Print the username and .bootstrap_password file path on first-run admin creation instead of the raw password. Headless / Docker / SSH operators still get a startup-time hint for initial sign-in, and the plaintext credential no longer appears in terminal output or logs. --------- Co-authored-by: Daniel Han --- install.ps1 | 9 ++++----- studio/backend/main.py | 8 ++++---- studio/setup.ps1 | 3 +-- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/install.ps1 b/install.ps1 index e6c9f9efb6..ead4e7368d 100644 --- a/install.ps1 +++ b/install.ps1 @@ -749,7 +749,6 @@ shell.Run cmd, 0, False } else { step "gpu" "none (chat-only / GGUF)" "Yellow" substep "Training and GPU inference require an NVIDIA GPU with drivers installed." "Yellow" - substep "https://www.nvidia.com/Download/index.aspx" "Yellow" } # ── Choose the correct PyTorch index URL based on driver CUDA version ── @@ -777,10 +776,10 @@ shell.Run cmd, 0, False # ── Print CPU-only hint when no GPU detected ── if (-not $SkipTorch -and $TorchIndexUrl -like "*/cpu") { Write-Host "" - Write-Host " NOTE: No NVIDIA GPU detected." -ForegroundColor Yellow - Write-Host " Installing CPU-only PyTorch. If you only need GGUF chat/inference," - Write-Host " re-run with --no-torch for a faster, lighter install:" - Write-Host " .\install.ps1 --no-torch" + substep "No NVIDIA GPU detected." "Yellow" + substep "Installing CPU-only PyTorch. If you only need GGUF chat/inference," "Yellow" + substep "re-run with --no-torch for a faster, lighter install:" "Yellow" + substep ".\install.ps1 --no-torch" "Yellow" Write-Host "" } diff --git a/studio/backend/main.py b/studio/backend/main.py index c18f18a743..ad19ee9679 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -121,13 +121,13 @@ async def lifespan(app: FastAPI): if storage.ensure_default_admin(): bootstrap_pw = storage.get_bootstrap_password() app.state.bootstrap_password = bootstrap_pw + + bootstrap_path = storage.DB_PATH.parent / ".bootstrap_password" print("\n" + "=" * 60) print("DEFAULT ADMIN ACCOUNT CREATED") - print( - "Sign in with the seeded credentials and change the password immediately:\n" - ) print(f" username: {storage.DEFAULT_ADMIN_USERNAME}") - print(f" password: {bootstrap_pw}\n") + print(f" password saved to: {bootstrap_path}") + print(" Open the Studio UI to sign in and change it.") print("=" * 60 + "\n") else: app.state.bootstrap_password = storage.get_bootstrap_password() diff --git a/studio/setup.ps1 b/studio/setup.ps1 index cdba0e6690..aa3c11a594 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -490,8 +490,7 @@ if (-not $HasNvidiaSmi) { if (-not $HasNvidiaSmi) { Write-Host "" step "gpu" "none (chat-only / GGUF)" "Yellow" - Write-Host " Training and GPU inference require an NVIDIA GPU with drivers installed." -ForegroundColor Yellow - Write-Host " https://www.nvidia.com/Download/index.aspx" -ForegroundColor Yellow + substep "Training and GPU inference require an NVIDIA GPU with drivers installed." "Yellow" Write-Host "" } else { step "gpu" "NVIDIA GPU detected" From 4fb977898801d9c00395af098387587b655e094f Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Wed, 1 Apr 2026 08:15:50 +0200 Subject: [PATCH 03/44] feat: move folder management into model selector dropdown (#4731) * refactor: move folder management from sidebar into model selector * Fix folder management: restore LoRA picker sync, error handling, caching - Restore onFoldersChange callback to keep LoRA adapter picker in sync when scan folders are added/removed (fixes regression from sidebar move) - Thread onFoldersChange through ModelSelector -> HubModelPicker prop chain - Add module-level _scanFoldersCache to prevent folder list flash on re-open - Surface error toast on folder removal failure instead of silently ignoring - Guard handleAddFolder against concurrent double-submit via folderLoading - Clear folderInput on Escape key dismiss to prevent stale input on re-open - Add refreshLocalModelsList and refreshScanFolders to useEffect dep array * Fix compare-mode folder sync, Escape key propagation, cancel toggle state - Wire onFoldersChange through CompareContent/GeneralCompareContent so compare-mode selectors also refresh local models after folder changes - Add e.stopPropagation() on Escape key in folder input to prevent Radix Popover from closing the entire model selector dropdown - Add e.preventDefault() on Enter key to prevent form submission - Clear folderInput and folderError when cancel toggle hides the input, matching the Escape key behavior for consistency * Fix folder mutation state ordering and touch accessibility - Use optimistic updates for add/remove so the folder list reflects changes immediately instead of waiting on a second listScanFolders round-trip that could silently fail. - Move refreshScanFolders out of the finally block in handleRemoveFolder so it runs after the cache update, not after onFoldersChange. - Make the remove button visible on touch/mobile devices and reachable via keyboard focus (opacity-100 on small screens, focus-visible). - Add aria-label to the remove button for screen readers. * Deduplicate optimistic folder add to match backend behavior The backend returns the existing ScanFolderInfo row when adding a path that is already registered. The optimistic update was blindly appending the returned row, producing duplicate entries and React key warnings. Now checks by id before appending. * Add aria-label to folder toggle button and strengthen dedup check - Add aria-label to the +/cancel icon button for screen readers. - Extend optimistic dedup check to also compare by path, not just id, to handle edge cases where the cache is stale. --------- Co-authored-by: Daniel Han Co-authored-by: Daniel Han --- .../assistant-ui/model-selector.tsx | 9 +- .../assistant-ui/model-selector/pickers.tsx | 160 +++++++++++++++++- .../frontend/src/features/chat/chat-page.tsx | 10 +- .../src/features/chat/chat-settings-sheet.tsx | 115 +------------ 4 files changed, 173 insertions(+), 121 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/model-selector.tsx b/studio/frontend/src/components/assistant-ui/model-selector.tsx index 0332b3ca8a..441c2b48e4 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector.tsx @@ -34,6 +34,7 @@ interface ModelSelectorProps { activeGgufVariant?: string | null; onValueChange?: (value: string, meta: ModelSelectorChangeMeta) => void; onEject?: () => void; + onFoldersChange?: () => void; variant?: "outline" | "ghost" | "muted"; size?: "sm" | "default" | "lg"; className?: string; @@ -100,6 +101,7 @@ function ModelSelectorContent({ value, onSelect, onEject, + onFoldersChange, className, dataTour, }: { @@ -108,6 +110,7 @@ function ModelSelectorContent({ value?: string; onSelect: (id: string, meta: ModelSelectorChangeMeta) => void; onEject?: () => void; + onFoldersChange?: () => void; className?: string; dataTour?: string; }) { @@ -124,7 +127,7 @@ function ModelSelectorContent({ )} > {chatOnly ? ( - + ) : ( @@ -133,7 +136,7 @@ function ModelSelectorContent({ - + @@ -171,6 +174,7 @@ export function ModelSelector({ activeGgufVariant, onValueChange, onEject, + onFoldersChange, variant = "outline", size = "default", className, @@ -253,6 +257,7 @@ export function ModelSelector({ value={selected} onSelect={handleSelect} onEject={onEject ? handleEject : undefined} + onFoldersChange={onFoldersChange} className={contentClassName} dataTour={contentDataTour} /> diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index cf8b4cd54e..c072eb3096 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -20,11 +20,15 @@ import { } from "@/components/ui/tooltip"; import { usePlatformStore } from "@/config/env"; import { + type ScanFolderInfo, + addScanFolder, deleteCachedModel, listCachedGguf, listCachedModels, listGgufVariants, listLocalModels, + listScanFolders, + removeScanFolder, } from "@/features/chat/api/chat-api"; import type { CachedGgufRepo, @@ -42,7 +46,7 @@ import { import { cn, formatCompact } from "@/lib/utils"; import type { VramFitStatus } from "@/lib/vram"; import { checkVramFit, estimateLoadingVram } from "@/lib/vram"; -import { Search01Icon } from "@hugeicons/core-free-icons"; +import { Add01Icon, Cancel01Icon, Folder02Icon, Search01Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { Trash2Icon } from "lucide-react"; import { @@ -415,6 +419,7 @@ let _cachedGgufCache: CachedGgufRepo[] = []; let _cachedModelsCache: CachedModelRepo[] = []; let _lmStudioCache: LocalModelInfo[] = []; let _customFolderCache: LocalModelInfo[] = []; +let _scanFoldersCache: ScanFolderInfo[] = []; /** Sort LM Studio models with unsloth publisher first. */ function sortLmStudio(models: LocalModelInfo[]): LocalModelInfo[] { @@ -434,10 +439,12 @@ export function HubModelPicker({ models, value, onSelect, + onFoldersChange, }: { models: ModelOption[]; value?: string; onSelect: (id: string, meta: ModelSelectorChangeMeta) => void; + onFoldersChange?: () => void; }) { const gpu = useGpuInfo(); const [query, setQuery] = useState(""); @@ -469,6 +476,13 @@ export function HubModelPicker({ const [customFolderModels, setCustomFolderModels] = useState(_customFolderCache); + // Custom scan folders management + const [scanFolders, setScanFolders] = useState(_scanFoldersCache); + const [folderInput, setFolderInput] = useState(""); + const [folderError, setFolderError] = useState(null); + const [showFolderInput, setShowFolderInput] = useState(false); + const [folderLoading, setFolderLoading] = useState(false); + const refreshLocalModelsList = useCallback(() => { listLocalModels() .then((res) => { @@ -484,6 +498,57 @@ export function HubModelPicker({ .catch(() => {}); }, []); + const refreshScanFolders = useCallback(() => { + listScanFolders() + .then((v) => { + _scanFoldersCache = v; + setScanFolders(v); + }) + .catch(() => {}); + }, []); + + const handleAddFolder = useCallback(async () => { + const trimmed = folderInput.trim(); + if (!trimmed || folderLoading) return; + setFolderError(null); + setFolderLoading(true); + try { + const created = await addScanFolder(trimmed); + // Backend returns existing row for duplicates, so deduplicate + const next = _scanFoldersCache.some((f) => f.id === created.id || f.path === created.path) + ? _scanFoldersCache + : [..._scanFoldersCache, created]; + _scanFoldersCache = next; + setScanFolders(next); + setFolderInput(""); + setShowFolderInput(false); + refreshLocalModelsList(); + onFoldersChange?.(); + // Background reconciliation with the server + void refreshScanFolders(); + } catch (e) { + setFolderError(e instanceof Error ? e.message : "Failed to add folder"); + } finally { + setFolderLoading(false); + } + }, [folderInput, folderLoading, refreshScanFolders, refreshLocalModelsList, onFoldersChange]); + + const handleRemoveFolder = useCallback(async (id: number) => { + try { + await removeScanFolder(id); + // Optimistic update so the folder disappears immediately + const next = _scanFoldersCache.filter((f) => f.id !== id); + _scanFoldersCache = next; + setScanFolders(next); + refreshScanFolders(); + refreshLocalModelsList(); + onFoldersChange?.(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to remove folder"); + refreshScanFolders(); + } + }, [refreshScanFolders, refreshLocalModelsList, onFoldersChange]); + const refreshCachedLists = useCallback(() => { listCachedGguf() .then((v) => { @@ -503,6 +568,7 @@ export function HubModelPicker({ useEffect(() => { // Always refresh LM Studio + custom folder models (not gated by alreadyCached) refreshLocalModelsList(); + refreshScanFolders(); if (alreadyCached) return; let done = 0; @@ -523,7 +589,7 @@ export function HubModelPicker({ }) .catch(() => {}) .finally(check); - }, [alreadyCached]); + }, [alreadyCached, refreshLocalModelsList, refreshScanFolders]); const handleDeleteConfirm = useCallback(async () => { if (!deleteTarget) return; @@ -878,9 +944,95 @@ export function HubModelPicker({ ) : null} - {!showHfSection && customFolderModels.length > 0 ? ( + {!showHfSection ? ( <> - Custom Folders +
+ + Custom Folders + + +
+ + {/* Folder paths */} + {scanFolders.map((f) => ( +
+ + + {f.path} + + +
+ ))} + + {/* Add folder input */} + {showFolderInput && ( +
+
+ + { setFolderInput(e.target.value); setFolderError(null); }} + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); handleAddFolder(); } + if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); setShowFolderInput(false); setFolderInput(""); setFolderError(null); } + }} + placeholder="/path/to/models" + className="h-6 min-w-0 flex-1 rounded border border-border/50 bg-transparent px-1.5 font-mono text-[10px] text-foreground outline-none placeholder:text-muted-foreground/40 focus:border-foreground/20" + disabled={folderLoading} + autoFocus={true} + /> + +
+ {folderError && ( +

{folderError}

+ )} +
+ )} + + {/* Empty state */} + {scanFolders.length === 0 && customFolderModels.length === 0 && !showFolderInput && ( + + )} + + {/* Models from custom folders */} {customFolderModels.map((m) => { const isGguf = isGgufRepo(m.id) || diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index a47a2c6d92..8d0a9649b4 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -162,10 +162,12 @@ const CompareContent = memo(function CompareContent({ pairId, models, loraModels, + onFoldersChange, }: { pairId: string; models: ModelOption[]; loraModels: LoraModelOption[]; + onFoldersChange?: () => void; }): ReactElement { const isLoraCompare = useIsLoraCompare(); @@ -176,6 +178,7 @@ const CompareContent = memo(function CompareContent({ pairId={pairId} models={models} loraModels={loraModels} + onFoldersChange={onFoldersChange} /> ); }); @@ -259,10 +262,12 @@ const GeneralCompareContent = memo(function GeneralCompareContent({ pairId, models, loraModels, + onFoldersChange, }: { pairId: string; models: ModelOption[]; loraModels: LoraModelOption[]; + onFoldersChange?: () => void; }): ReactElement { const handlesRef = useRef>({}); const [model1ThreadId, setModel1ThreadId] = useState(); @@ -327,6 +332,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({ ggufVariant: meta.ggufVariant, }) } + onFoldersChange={onFoldersChange} variant="ghost" size="sm" className="max-w-[50%]" @@ -359,6 +365,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({ ggufVariant: meta.ggufVariant, }) } + onFoldersChange={onFoldersChange} variant="ghost" size="sm" className="max-w-[50%]" @@ -846,6 +853,7 @@ export function ChatPage(): ReactElement { activeGgufVariant={activeGgufVariant} onValueChange={handleCheckpointChange} onEject={handleEject} + onFoldersChange={refreshLocalModels} variant="ghost" open={modelSelectorOpen} onOpenChange={handleModelSelectorOpenChange} @@ -911,6 +919,7 @@ export function ChatPage(): ReactElement { pairId={view.pairId} models={models} loraModels={loraModels} + onFoldersChange={refreshLocalModels} /> )} @@ -934,7 +943,6 @@ export function ChatPage(): ReactElement { }); } }} - onFoldersChange={refreshLocalModels} /> diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index 6e62c7f9c5..6276e5c2e8 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -34,7 +34,6 @@ import { CodeIcon, Delete02Icon, FloppyDiskIcon, - FolderSearchIcon, PencilEdit01Icon, Settings02Icon, SlidersHorizontalIcon, @@ -44,13 +43,7 @@ import { import { HugeiconsIcon } from "@hugeicons/react"; import { AnimatePresence, motion } from "motion/react"; import type { ReactNode } from "react"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { - type ScanFolderInfo, - addScanFolder, - listScanFolders, - removeScanFolder, -} from "./api/chat-api"; +import { useEffect, useMemo, useState } from "react"; import { useChatRuntimeStore } from "./stores/chat-runtime-store"; import { DEFAULT_INFERENCE_PARAMS, @@ -266,108 +259,6 @@ function CollapsibleSection({ ); } -function ModelFoldersSection({ - onFoldersChange, -}: { onFoldersChange?: () => void }) { - const [folders, setFolders] = useState([]); - const [input, setInput] = useState(""); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - - const refresh = useCallback(() => { - listScanFolders() - .then(setFolders) - .catch(() => {}); - }, []); - - useEffect(() => { - refresh(); - }, [refresh]); - - const handleAdd = async () => { - const trimmed = input.trim(); - if (!trimmed) return; - setError(null); - setLoading(true); - try { - await addScanFolder(trimmed); - setInput(""); - refresh(); - onFoldersChange?.(); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to add folder"); - } finally { - setLoading(false); - } - }; - - const handleRemove = async (id: number) => { - try { - await removeScanFolder(id); - onFoldersChange?.(); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to remove folder"); - } finally { - refresh(); - } - }; - - return ( - -
- {folders.length > 0 && ( -
- {folders.map((f) => ( -
- - {f.path} - - -
- ))} -
- )} -
- { - setInput(e.target.value); - setError(null); - }} - onKeyDown={(e) => { - if (e.key === "Enter") handleAdd(); - }} - placeholder="/path/to/models" - className="h-7 flex-1 text-xs font-mono" - disabled={loading} - /> - -
- {error &&

{error}

} -
-
- ); -} - interface ChatSettingsPanelProps { open: boolean; onOpenChange?: (open: boolean) => void; @@ -376,7 +267,6 @@ interface ChatSettingsPanelProps { autoTitle: boolean; onAutoTitleChange: (enabled: boolean) => void; onReloadModel?: () => void; - onFoldersChange?: () => void; } export function ChatSettingsPanel({ @@ -387,7 +277,6 @@ export function ChatSettingsPanel({ autoTitle, onAutoTitleChange, onReloadModel, - onFoldersChange, }: ChatSettingsPanelProps) { const isMobile = useIsMobile(); const isGguf = useChatRuntimeStore((s) => s.activeGgufVariant) != null; @@ -835,8 +724,6 @@ export function ChatSettingsPanel({ - - Date: Wed, 1 Apr 2026 09:28:38 +0200 Subject: [PATCH 04/44] fix: clear tool status badge immediately after tool execution (#4733) * fix: clear tool status badge immediately after tool execution The tool status timer badge (Searching 1s, 2s...) persisted after tool calls finished because the status clear event was only sent at the start of the next generation iteration, not after tool execution completed. Backend: yield status clear after all tools finish in the agentic loop iteration, before continue starts the next generation pass. Frontend: debounce badge visibility by 300ms so sub-second tool calls dont flash the badge. * Fix debounce regression for consecutive tool calls Only apply the 300ms show-delay when transitioning from idle to tool-active. When switching between consecutive tools in the same turn (e.g. web_search -> python), keep the badge visible immediately so it does not flicker or disappear during multi-tool runs. * Delay wasActiveRef reset to bridge inter-iteration tool gaps The backend emits a status-clear event between tool iterations, which was resetting wasActiveRef immediately and causing the next tool to be re-debounced (300ms hidden gap between consecutive tools in the same turn). Now the ref reset is delayed by 500ms so a follow-up tool within the same agentic turn shows the badge immediately, while a genuinely new turn still gets the debounce. * Use thread lifecycle to track tool-run boundaries Replace the 500ms wall-clock timeout with the actual thread.isRunning state to determine when wasActiveRef should reset. This properly handles all cases: - Consecutive tools within the same run stay visible without flicker - The badge hides only when the thread run actually ends - New turns always get a fresh 300ms debounce on the first tool - No heuristic timeout that can misfire on slow or fast inference * Consolidate wasActiveRef reset into single effect Removes the separate isThreadRunning effect to avoid a race where the ref resets before the tool-status effect reads it (when isThreadRunning flips to false before setToolStatus(null) from the adapter's finally block). Now wasActiveRef resets only when both toolStatus is null AND the thread run has ended, eliminating any flicker on the last tool of a run. * Simplify debounce: use visible state instead of ref tracking Drop wasActiveRef entirely and use the visible state as the debounce gate. When the badge is not yet on screen, debounce for 300ms before showing. When already visible from a prior tool, keep showing immediately. This correctly handles all cases: - All fast tools (<300ms) are suppressed, not just the first - Consecutive tools after the badge is shown stay visible - Badge persists across inter-iteration clears while thread runs - New turns get a fresh debounce after visible resets --------- Co-authored-by: Daniel Han --- studio/backend/core/inference/llama_cpp.py | 2 ++ .../src/components/assistant-ui/thread.tsx | 25 ++++++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 894dd25cf7..eb3776e603 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -2740,6 +2740,8 @@ class LlamaCppBackend: tool_msg["tool_call_id"] = tool_call_id conversation.append(tool_msg) + # Clear tool status badge before next generation iteration + yield {"type": "status", "text": ""} # Continue the loop to let model respond with context continue diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index d688822815..aae027c74b 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -437,21 +437,40 @@ const CodeToolsToggle: FC = () => { const ToolStatusDisplay: FC = () => { const toolStatus = useChatRuntimeStore((s) => s.toolStatus); + const isThreadRunning = useAuiState(({ thread }) => thread.isRunning); const [elapsed, setElapsed] = useState(0); + const [visible, setVisible] = useState(false); useEffect(() => { if (!toolStatus) { setElapsed(0); + if (!isThreadRunning) { + setVisible(false); + } return; } + setElapsed(0); + + // Debounce badge visibility by 300ms when the badge is not + // already on screen. Once visible from a prior tool, consecutive + // tools show immediately so the badge does not flicker. Fast + // tool calls that all complete under 300ms never show the badge. + let showTimer: ReturnType | undefined; + if (!visible) { + showTimer = setTimeout(() => setVisible(true), 300); + } + const interval = setInterval(() => { setElapsed((prev) => prev + 1); }, 1000); - return () => clearInterval(interval); - }, [toolStatus]); + return () => { + clearInterval(interval); + if (showTimer) clearTimeout(showTimer); + }; + }, [toolStatus, isThreadRunning]); - if (!toolStatus) return null; + if (!toolStatus || !visible) return null; const isRunning = toolStatus.startsWith("Running"); const StatusIcon = isRunning ? TerminalIcon : GlobeIcon; return ( From 256c6e4884ae7385702560771efae3232fde17d9 Mon Sep 17 00:00:00 2001 From: Datta Nimmaturi Date: Wed, 1 Apr 2026 13:00:21 +0530 Subject: [PATCH 05/44] Refactor flex attn to prefer flash if possible (#4734) Replaces prefer_flex_attn_if_supported (which only returned flex_attention or None) with determine_attention_implementation, a centralized hierarchy: FA2 > Flex > SDPA > Eager. Changes: - New determine_attention_implementation function in _utils.py with clear priority chain - _set_attn_impl helper to stamp config consistently - _FLEX_EXCLUDED_MODELS / _FLEX_EXCLUDED_PREFIXES for model-specific exclusions - Gemma3N explicit eager override in vision.py (timm vision towers) - Preserved sdpa fallback for unmapped/remote-code vision configs - Config re-stamped to eager when supports_sdpa guard fires Co-authored-by: Datta Nimmaturi --- unsloth/models/_utils.py | 105 +++++++++++++++++++++++++-------------- unsloth/models/llama.py | 4 +- unsloth/models/vision.py | 29 ++++++----- 3 files changed, 84 insertions(+), 54 deletions(-) diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index d296ac7e74..49168eb8b6 100644 --- a/unsloth/models/_utils.py +++ b/unsloth/models/_utils.py @@ -64,7 +64,8 @@ __all__ = [ "patch_compiled_autograd", "process_vision_info", "unsloth_compile_transformers", - "prefer_flex_attn_if_supported", + "determine_attention_implementation", + "_set_attn_impl", "patch_fast_lora", "validate_loftq_config", "RaiseUninitialized", @@ -222,44 +223,74 @@ def apply_unsloth_gradient_checkpointing( return use_gradient_checkpointing -def prefer_flex_attn_if_supported(model_class, config): - if os.environ.get("UNSLOTH_ENABLE_FLEX_ATTENTION", "1") == "0": - return None - try: - from transformers.utils.import_utils import is_torch_flex_attn_available +# Models that don't work with flex_attention: +# GPT-OSS: left padding issues cause incorrect outputs. +# Mllama: BlockMask Q_LEN!=KV_LEN ValueError on decode. +# NemotronH: hybrid Mamba-2 + Transformer, raises NotImplementedError. +# Gemma3N: timm vision wrappers don't support flex_attention. +_FLEX_EXCLUDED_MODELS = ("gpt_oss", "mllama", "nemotron_h") +_EAGER_ONLY_PREFIXES = ("gemma3n",) - if not is_torch_flex_attn_available(): - return None - if model_class is None or not getattr( - model_class, "_supports_flex_attn", False - ): - return None - attention_dropout = getattr(config, "attention_dropout", 0) or 0 - if attention_dropout > 0: - return None - # GPT-OSS, Mllama and Gemma3N use eager/sdpa attention during - # inference since flex attention returns incorrect results or errors out. - # GPT-OSS: left padding issues cause incorrect outputs. - # Mllama: _update_causal_mask uses make_flex_block_causal_mask which - # creates BlockMask with Q_LEN=KV_LEN=total_seq_len, but during - # decode q_len=1, causing ValueError. Needs transformers update. - # Gemma3N: timm vision wrappers (eg Gemma3nVisionConfig) do not - # support flex_attention. - # NemotronH: hybrid Mamba-2 + Transformer model that does not - # support flex_attention (raises NotImplementedError from transformers). - model_type = getattr(config, "model_type", "") if config else "" - if model_type in ("gpt_oss", "mllama", "nemotron_h") or str( - model_type - ).startswith("gemma3n"): - return None - if config is not None: - setattr(config, "_attn_implementation", "flex_attention") - if hasattr(config, "attn_implementation"): - setattr(config, "attn_implementation", "flex_attention") - return "flex_attention" - except Exception: - return None +def _is_flex_excluded(model_type): + return model_type in _FLEX_EXCLUDED_MODELS + + +def _is_eager_only(model_type): + return any(model_type.startswith(p) for p in _EAGER_ONLY_PREFIXES) + + +def _set_attn_impl(config, impl): + """Helper function to set attention implementation on config and return it.""" + if config is not None: + setattr(config, "_attn_implementation", impl) + if hasattr(config, "attn_implementation"): + setattr(config, "attn_implementation", impl) + return impl + + +def determine_attention_implementation(model_class, config): + model_type = getattr(config, "model_type", "").lower() + + # Eager-only models (e.g. gemma3n timm vision towers) + if _is_eager_only(model_type): + _set_attn_impl(config, "eager") + return "eager" + + # Flash Attention 2 + if HAS_FLASH_ATTENTION and model_class is not None: + supports_fa2 = getattr(model_class, "_supports_flash_attn_2", False) or getattr( + model_class, "_supports_flash_attn", False + ) + if supports_fa2: + _set_attn_impl(config, "flash_attention_2") + return "flash_attention_2" + + # Flex Attention + if os.environ.get("UNSLOTH_ENABLE_FLEX_ATTENTION", "1") != "0": + try: + from transformers.utils.import_utils import is_torch_flex_attn_available + + if ( + is_torch_flex_attn_available() + and model_class is not None + and getattr(model_class, "_supports_flex_attn", False) + and not _is_flex_excluded(model_type) + ): + attention_dropout = getattr(config, "attention_dropout", 0) or 0 + if attention_dropout == 0: + _set_attn_impl(config, "flex_attention") + return "flex_attention" + except Exception: + pass + + # SDPA + if model_class is not None and getattr(model_class, "_supports_sdpa", False): + _set_attn_impl(config, "sdpa") + return "sdpa" + + _set_attn_impl(config, "eager") + return "eager" def _run_temporary_patches(phase): diff --git a/unsloth/models/llama.py b/unsloth/models/llama.py index 93d93e26d6..8e3892c712 100644 --- a/unsloth/models/llama.py +++ b/unsloth/models/llama.py @@ -2341,8 +2341,8 @@ class FastLlamaModel: model_function = MODEL_FOR_CAUSAL_LM_MAPPING[model_config.__class__] IS_FALCON_H1 = model_config.model_type.startswith("falcon_h1") - preferred_attn_impl = ( - prefer_flex_attn_if_supported(model_function, model_config) or "eager" + preferred_attn_impl = determine_attention_implementation( + model_function, model_config ) has_rope_scaling = False diff --git a/unsloth/models/vision.py b/unsloth/models/vision.py index f558aa3f00..a961225827 100644 --- a/unsloth/models/vision.py +++ b/unsloth/models/vision.py @@ -597,8 +597,6 @@ class FastBaseModel: custom_datatype = None correct_dtype = None - # Stop SDPA for some archs like Pixtral / Mistral3 - flex_attn_impl = None if auto_config is None: auto_config = AutoConfig.from_pretrained( model_name, @@ -609,7 +607,14 @@ class FastBaseModel: model_class = auto_model._model_mapping[auto_config.__class__] except Exception: model_class = None - flex_attn_impl = prefer_flex_attn_if_supported(model_class, auto_config) + if model_class is None: + # When model_class cannot be resolved (remote-code or unmapped + # configs), preserve the old fallback of sdpa when supported. + attn_impl = _set_attn_impl( + auto_config, "sdpa" if supports_sdpa else "eager" + ) + else: + attn_impl = determine_attention_implementation(model_class, auto_config) # Handle FP8 models: get_model_name has already redirected this to BF16 sibling if the model ships with # FP8 weights. We just need to update it here for sanity. @@ -620,21 +625,15 @@ class FastBaseModel: except Exception: model_class = None - model_type = str(getattr(auto_config, "model_type", "")).lower() - if model_type.startswith("gemma3n"): - # Gemma3N variants initialize timm-based vision towers which do - # not support flex_attention, so default to eager unless overridden. - default_attn_impl = "eager" - else: - default_attn_impl = "flex_attention" if flex_attn_impl else "sdpa" if not ("attn_implementation" in kwargs): - kwargs["attn_implementation"] = default_attn_impl + kwargs["attn_implementation"] = attn_impl if not supports_sdpa and kwargs.get("attn_implementation") == "sdpa": - if os.environ.get("UNSLOTH_ENABLE_FLEX_ATTENTION", "0") == "0": - print( - f"Unsloth: {model_type_arch.title()} does not support SDPA - switching to fast eager." - ) + print( + f"Unsloth: {model_type_arch.title()} does not support SDPA - switching to fast eager." + ) del kwargs["attn_implementation"] + # Re-stamp config so it stays consistent with the actual impl + _set_attn_impl(auto_config, "eager") bnb_config = None user_quantization_config = kwargs.get("quantization_config", None) From 6c0826a9e42663adfd16abd8ee4df6b8a55d56a5 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 1 Apr 2026 01:38:09 -0700 Subject: [PATCH 06/44] Fix Windows local GGUF model loading crash (#4730) * Fix Windows "Non-relative patterns are unsupported" when loading local GGUF models When a user loads a GGUF model from a local Windows path (e.g. C:\Users\danie\.lmstudio\models\unsloth\functiongemma-270m-it-GGUF), the model identifier contains backslashes and a drive letter. Both load_model_defaults() and _has_specific_yaml() constructed a YAML filename from the full absolute path and passed it to Path.rglob(), which rejects non-relative patterns on Windows. Fixed by detecting Windows-style paths (drive letters, UNC paths, backslashes) in addition to Unix-style paths, and using only the directory basename for the YAML filename lookup when the identifier is a local filesystem path. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Refactor: reuse is_local_path helper, fix case-sensitive suffix lookup - Replace inline local-path detection in model_config.py and inference_config.py with the existing is_local_path() from utils.paths, which already handles Unix, Windows drive-letter, UNC, and backslash paths - Fix case-sensitive suffix lookup in load_model_defaults(): the _REVERSE_MODEL_MAPPING is lowercase-keyed, so suffix comparisons must use .lower() to match paths like /path/to/Spark-TTS-0.5B/LLM * Fix WSL path parsing and _has_specific_yaml suffix lookup - Use normalize_path() before Path() operations so backslash Windows paths (e.g. C:\Users\...\model) are correctly split on POSIX/WSL hosts where pathlib treats backslashes as literal characters - Add suffix-based (2-component and 1-component) lookup to _has_specific_yaml() so it matches the same resolution rules as load_model_defaults(), fixing wrong inference params for local suffix-mapped models like Spark-TTS-0.5B/LLM --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .../utils/inference/inference_config.py | 25 +++++++++++++++-- studio/backend/utils/models/model_config.py | 27 ++++++++++++------- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/studio/backend/utils/inference/inference_config.py b/studio/backend/utils/inference/inference_config.py index a0377f9869..9efc281b0b 100644 --- a/studio/backend/utils/inference/inference_config.py +++ b/studio/backend/utils/inference/inference_config.py @@ -17,6 +17,7 @@ import structlog from loggers import get_logger from utils.models.model_config import load_model_defaults +from utils.paths import is_local_path, normalize_path logger = get_logger(__name__) @@ -93,8 +94,28 @@ def _has_specific_yaml(model_identifier: str) -> bool: if model_identifier.lower() in _REVERSE_MODEL_MAPPING: return True - # Check for exact filename match - model_filename = model_identifier.replace("/", "_") + ".yaml" + # For local filesystem paths (e.g. C:\Users\...\model on Windows), + # normalize backslashes so Path().parts splits correctly on POSIX/WSL, + # then try matching the last 1-2 path components against the registry + # (mirrors the logic in load_model_defaults). + _is_local = is_local_path(model_identifier) + _normalized = normalize_path(model_identifier) if _is_local else model_identifier + + if _is_local: + parts = Path(_normalized).parts + for depth in (2, 1): + if len(parts) >= depth: + suffix = "/".join(parts[-depth:]) + if suffix.lower() in _REVERSE_MODEL_MAPPING: + return True + _lookup = Path(_normalized).name + else: + _lookup = model_identifier + + # Check for exact filename match (basename for local paths to avoid + # passing absolute paths into rglob which raises + # "Non-relative patterns are unsupported" on Windows). + model_filename = _lookup.replace("/", "_") + ".yaml" for config_path in defaults_dir.rglob(model_filename): if config_path.is_file(): return True diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index 5de3fd2cf9..f7d9b33542 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -1420,17 +1420,20 @@ def load_model_defaults(model_name: str) -> Dict[str, Any]: return config # If model_name is a local path (e.g. /home/.../Spark-TTS-0.5B/LLM from - # adapter_config.json), try matching the last 1-2 path components against - # the registry (e.g. "Spark-TTS-0.5B/LLM"). - if model_name not in _REVERSE_MODEL_MAPPING and ( - model_name.startswith("/") or model_name.startswith(".") - ): - parts = Path(model_name).parts + # adapter_config.json, or C:\Users\...\model on Windows), try matching + # the last 1-2 path components against the registry + # (e.g. "Spark-TTS-0.5B/LLM"). + _is_local_path = is_local_path(model_name) + # Normalize Windows backslash paths so Path().parts splits correctly + # on POSIX/WSL hosts (pathlib treats backslashes as literals on Linux). + _normalized = normalize_path(model_name) if _is_local_path else model_name + if model_name.lower() not in _REVERSE_MODEL_MAPPING and _is_local_path: + parts = Path(_normalized).parts for depth in [2, 1]: if len(parts) >= depth: suffix = "/".join(parts[-depth:]) - if suffix in _REVERSE_MODEL_MAPPING: - canonical_file = _REVERSE_MODEL_MAPPING[suffix] + if suffix.lower() in _REVERSE_MODEL_MAPPING: + canonical_file = _REVERSE_MODEL_MAPPING[suffix.lower()] for config_path in defaults_dir.rglob(canonical_file): if config_path.is_file(): with open(config_path, "r", encoding = "utf-8") as f: @@ -1440,8 +1443,12 @@ def load_model_defaults(model_name: str) -> Dict[str, Any]: ) return config - # Try exact model name match (for backward compatibility) - model_filename = model_name.replace("/", "_") + ".yaml" + # Try exact model name match (for backward compatibility). + # For local filesystem paths, use only the directory basename to + # avoid passing absolute paths (e.g. C:\...) into rglob which + # raises "Non-relative patterns are unsupported" on Windows. + _lookup_name = Path(_normalized).name if _is_local_path else model_name + model_filename = _lookup_name.replace("/", "_") + ".yaml" # Search in subfolders and root for config_path in defaults_dir.rglob(model_filename): if config_path.is_file(): From 63ad6dbd6de89fcb4212afe666fcb5689404c66b Mon Sep 17 00:00:00 2001 From: Leo Borcherding Date: Wed, 1 Apr 2026 04:06:49 -0500 Subject: [PATCH 07/44] Fix OOM model styling in Studio model selectors (#4738) Replace strikethrough + opacity-50 OOM styling with gray text and red pill badge across all Studio model selectors (chat, training, onboarding). - Use gray-500/gray-400 for OOM model names (better contrast than strikethrough) - Red pill badge for OOM indicator with light/dark mode support - Scope GGUF gray override to quant name only so downloaded/recommended labels keep colors - Add !important on TIGHT/OOM badges to resist ComboboxItem hover overrides --- .../assistant-ui/model-selector/pickers.tsx | 13 ++++++------- .../components/steps/model-selection-step.tsx | 8 ++++---- .../src/features/studio/sections/model-section.tsx | 8 ++++---- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index c072eb3096..74ca2542d4 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -127,23 +127,22 @@ function ModelRow({ className={cn( "flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent", selected && "bg-accent/60", - exceeds && "opacity-50", )} > {label} {vramStatus === "exceeds" && ( - OOM + OOM )} {vramStatus === "tight" && ( - TIGHT + TIGHT )} {meta ? ( {meta} @@ -354,7 +353,7 @@ function GgufVariantExpander({ )} > - {v.quant} + {v.quant} {v.downloaded ? ( downloaded @@ -367,12 +366,12 @@ function GgufVariantExpander({ {oom && ( - + OOM )} {tight && ( - + TIGHT )} diff --git a/studio/frontend/src/features/onboarding/components/steps/model-selection-step.tsx b/studio/frontend/src/features/onboarding/components/steps/model-selection-step.tsx index f2a4796c54..1ff23cb0fc 100644 --- a/studio/frontend/src/features/onboarding/components/steps/model-selection-step.tsx +++ b/studio/frontend/src/features/onboarding/components/steps/model-selection-step.tsx @@ -268,12 +268,12 @@ export function ModelSelectionStep() { {id} @@ -287,12 +287,12 @@ export function ModelSelectionStep() { {fitStatus === "exceeds" && ( - + OOM )} {fitStatus === "tight" && ( - + TIGHT )} diff --git a/studio/frontend/src/features/studio/sections/model-section.tsx b/studio/frontend/src/features/studio/sections/model-section.tsx index 755c93c5f0..775073eb64 100644 --- a/studio/frontend/src/features/studio/sections/model-section.tsx +++ b/studio/frontend/src/features/studio/sections/model-section.tsx @@ -489,12 +489,12 @@ export function ModelSection() { {id} @@ -519,12 +519,12 @@ export function ModelSection() { {fitStatus === "exceeds" && ( - + OOM )} {fitStatus === "tight" && ( - + TIGHT )} From 41df4ec437886b3ef2e7b9ced9ba8a5393f5c6cf Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:37:28 +0400 Subject: [PATCH 08/44] feat(studio): strip org prefix in model search to surface unsloth variants (#4749) When searching for a specific publisher model (e.g. `openai/gpt-oss-20b`), the unsloth search used the full `openai/gpt-oss-20b` string with `author=unsloth`, which returned zero results because no unsloth model contains the publisher prefix in its name. Users never discovered unsloth variants. This PR strips the org prefix for publisher-qualified queries so unsloth variants surface, then pins the original publisher model after a small batch of unsloth results. Plain queries (no slash) and unsloth-prefixed queries are unchanged. - Strict regex (`/^([^/\s]+)\/([^/\s]+)$/`) only triggers on valid `owner/repo` identifiers; incomplete typeahead, multi-slash, and URL-like inputs are rejected - Queries for `unsloth/...` models (case-insensitive) keep the full 20-result prefetch and secondary sort - Pinned model lookup fires in parallel with the unsloth prefetch - Canonical-name dedup prevents duplicates when HF normalizes casing - Publisher detection extracted into a single `useMemo` block --- .../frontend/src/hooks/use-hf-model-search.ts | 86 ++++++++++++++++--- 1 file changed, 72 insertions(+), 14 deletions(-) diff --git a/studio/frontend/src/hooks/use-hf-model-search.ts b/studio/frontend/src/hooks/use-hf-model-search.ts index 32b261956b..e697544d5c 100644 --- a/studio/frontend/src/hooks/use-hf-model-search.ts +++ b/studio/frontend/src/hooks/use-hf-model-search.ts @@ -104,6 +104,11 @@ function makeMapModel(excludeGguf: boolean) { /** Number of unsloth results to pull up-front before yielding general results. */ const UNSLOTH_PREFETCH = 20; +/** When the user searched for a specific publisher, show fewer unsloth results + * before the pinned (original publisher) model. */ +const UNSLOTH_PINNED_PREFETCH = 4; +/** Matches a valid "owner/repo" identifier (exactly two non-empty segments). */ +const PUBLISHER_RE = /^([^/\s]+)\/([^/\s]+)$/; /** * Prime the hf-cache from a listModels result. For public (non-gated, @@ -131,6 +136,7 @@ async function* mergedModelIterator( query: string, task?: PipelineType, accessToken?: string, + pinnedId?: string, ): AsyncGenerator { const common = { additionalFields: ["safetensors", "tags"] as ("safetensors" | "tags")[], @@ -148,6 +154,18 @@ async function* mergedModelIterator( ...common, }); + // Start pinned model lookup immediately so it can run in parallel with + // the Phase 1 unsloth iteration instead of blocking Phase 2. + const pinnedPromise = pinnedId + ? cachedModelInfo({ + name: pinnedId, + additionalFields: ["safetensors", "tags"], + ...(accessToken ? { credentials: { accessToken } } : {}), + }).catch(() => null) + : null; + + const limit = pinnedId ? UNSLOTH_PINNED_PREFETCH : UNSLOTH_PREFETCH; + // Phase 1: pull & yield unsloth models first const seen = new Set(); let count = 0; @@ -159,10 +177,26 @@ async function* mergedModelIterator( } yield model; count++; - if (count >= UNSLOTH_PREFETCH) break; + if (count >= limit) break; } - // Phase 2: yield general results, skipping already-seen unsloth models + // Phase 1b: yield the pinned (original publisher) model before general results + if (pinnedId && !seen.has(pinnedId) && pinnedPromise) { + const pinned = await pinnedPromise; + if (pinned) { + // Record both the raw input and the canonical name returned by HF + // so phase 2 deduplication works even when casing differs + // (e.g. user typed "OpenAI/gpt-oss-20b", HF returns "openai/gpt-oss-20b"). + seen.add(pinnedId); + const canonicalName = (pinned as { name?: string }).name; + if (canonicalName && canonicalName !== pinnedId) { + seen.add(canonicalName); + } + yield pinned; + } + } + + // Phase 2: yield general results, skipping already-seen models for await (const model of generalIter) { const m = model as { name?: string }; if (m.name && seen.has(m.name)) continue; @@ -235,11 +269,24 @@ export function useHfModelSearch( ) { const { task, accessToken, excludeGguf = false, priorityIds } = options ?? {}; + // Parse publisher detection once and share between the iterator factory + // and the secondary sort gate (avoids duplicating the regex + logic). + const { isPublisherQuery, searchQuery, pinnedId, trimmed } = useMemo(() => { + const t = query.trim(); + const m = PUBLISHER_RE.exec(t); + const is = !!m && m[1].toLowerCase() !== "unsloth"; + return { + isPublisherQuery: is, + searchQuery: is ? m![2] : t, + pinnedId: is ? t : undefined, + trimmed: t, + }; + }, [query]); + const createIter = useCallback( () => { - const trimmed = query.trim(); if (!trimmed) { - // No query → show priority models first (with full metadata), then general unsloth listing + // No query: show priority models first (with full metadata), then general unsloth listing if (priorityIds && priorityIds.length > 0) { return priorityThenListingIterator(priorityIds, task, accessToken) as AsyncGenerator; } @@ -250,24 +297,35 @@ export function useHfModelSearch( ...(accessToken ? { credentials: { accessToken } } : {}), }) as AsyncGenerator; } - // Typed query: disable task filter so explicitly searched models still appear even if HF task metadata is wrong/missing. - return mergedModelIterator(trimmed, undefined, accessToken) as AsyncGenerator; + // Typed query: disable task filter so explicitly searched models still + // appear even if HF task metadata is wrong/missing. + // If the query is a valid "owner/repo" identifier (exactly two non-empty, + // slash-free, space-free segments), strip the org prefix so unsloth + // variants surface, then pin the original publisher model after a small + // batch of unsloth results. Queries for unsloth-owned models are left + // as-is so they get the full 20-result prefetch and secondary sort. + return mergedModelIterator(searchQuery, undefined, accessToken, pinnedId) as AsyncGenerator; }, - [query, task, accessToken, priorityIds], + [trimmed, searchQuery, pinnedId, task, accessToken, priorityIds], ); const mapModel = useMemo(() => makeMapModel(excludeGguf), [excludeGguf]); const search = useHfPaginatedSearch(createIter, mapModel); - // Secondary sort guarantee: unsloth models always float to the top + // Secondary sort guarantee: unsloth models always float to the top. + // Skip when the user searched for a specific non-unsloth publisher + // (e.g. "openai/gpt-oss-20b") -- the iterator already handles the + // pinned ordering in that case. const results = useMemo( () => - [...search.results].sort((a, b) => { - const aFirst = a.id.startsWith("unsloth/") ? 0 : 1; - const bFirst = b.id.startsWith("unsloth/") ? 0 : 1; - return aFirst - bFirst; - }), - [search.results], + isPublisherQuery + ? search.results + : [...search.results].sort((a, b) => { + const aFirst = a.id.startsWith("unsloth/") ? 0 : 1; + const bFirst = b.id.startsWith("unsloth/") ? 0 : 1; + return aFirst - bFirst; + }), + [search.results, isPublisherQuery], ); return { ...search, results }; From 3f3757b1437b37d547e9958e7dc73b98191144d2 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 1 Apr 2026 06:04:03 -0700 Subject: [PATCH 09/44] Fix forward compatibility with transformers 5.x (#4752) * Fix forward compatibility with transformers 5.x Tested on transformers 4.57.6, 5.3.0, and 5.4.0. All changes are no-ops on transformers 4.x. 1. Skip exec-based config patching for transformers >= 5.0 Config classes in v5 use @strict, @auto_docstring, and interval() which break exec(inspect.getsource(...)). Those configs already use rope_parameters (the v5 replacement for rope_scaling). 2. Slice position_ids to last token in fast_forward_inference Transformers 5.x generate() accumulates position_ids as [batch, full_seq_len] across decode steps instead of [batch, 1]. cos[position_ids] then produces the wrong shape for rotary embeddings. Fixed in llama, qwen3, falcon_h1, gemma2, cohere, granite. No-op on 4.x since position_ids is already [batch, 1]. 3. Handle @strict config kwargs for sequence classification num_labels, max_position_embeddings, id2label etc. are set on the config object and passed via config= instead of as kwargs. AutoModelForSequenceClassification routing added to FastModel loader. 4. Exclude modernbert from flex_attention ModernBERT with flex_attention hits CUDA illegal memory access in create_block_mask. Falls back to eager attention safely. 5. Propagate token_type_ids and mm_token_type_ids through GRPO VLM path Gemma3 Vision requires token_type_ids during training. Qwen3VL requires mm_token_type_ids for M-RoPE. Extract from inputs in compute_loss, pass to grpo_accumulated_loss, and extend mm_token_type_ids for completion tokens in _generate_and_score_completions. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add try/except safety net around config exec for pre-release transformers versions * Pop config-level kwargs in seqclass path and use except Exception --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- unsloth/models/_utils.py | 22 +++++++++--- unsloth/models/cohere.py | 3 ++ unsloth/models/falcon_h1.py | 3 ++ unsloth/models/gemma2.py | 3 ++ unsloth/models/granite.py | 3 ++ unsloth/models/llama.py | 18 ++++++++-- unsloth/models/loader.py | 8 ++++- unsloth/models/qwen3.py | 3 ++ unsloth/models/rl_replacements.py | 58 +++++++++++++++++++++++++++++++ unsloth/models/vision.py | 9 +++++ 10 files changed, 123 insertions(+), 7 deletions(-) diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index 49168eb8b6..df6462eade 100644 --- a/unsloth/models/_utils.py +++ b/unsloth/models/_utils.py @@ -228,7 +228,9 @@ def apply_unsloth_gradient_checkpointing( # Mllama: BlockMask Q_LEN!=KV_LEN ValueError on decode. # NemotronH: hybrid Mamba-2 + Transformer, raises NotImplementedError. # Gemma3N: timm vision wrappers don't support flex_attention. -_FLEX_EXCLUDED_MODELS = ("gpt_oss", "mllama", "nemotron_h") +# ModernBERT: create_block_mask with _compile=True hits CUDA illegal memory +# access on some GPU architectures (B200). Falls back to eager safely. +_FLEX_EXCLUDED_MODELS = ("gpt_oss", "mllama", "nemotron_h", "modernbert") _EAGER_ONLY_PREFIXES = ("gemma3n",) @@ -796,7 +798,16 @@ model_architectures = [ "falcon_h1", ] +# Transformers 5.x uses class-level annotations with @strict, @auto_docstring, +# and interval() in config classes. exec(inspect.getsource(...)) fails because +# those symbols are not in scope. Skip the exec-based config patching for 5.x +# since those configs already use rope_parameters (the v5 replacement for +# rope_scaling). +_skip_config_exec_patch = Version(transformers_version) >= Version("5.0.0") + for model_name in model_architectures: + if _skip_config_exec_patch: + break config_filepath = f"transformers.models.{model_name}.configuration_{model_name}" model_filepath = f"transformers.models.{model_name}.modeling_{model_name}" config_filename = f"{model_name.title().replace('_','')}Config" # qwen3 arch folder is qwen3_moe but config is Qwen3Config. Need to remove underscore(_) for now @@ -830,9 +841,12 @@ for model_name in model_architectures: if Version(transformers_version) <= Version("4.42.4"): config = patch_mistral_nemo_config(config) - exec(config, globals()) - exec(f"import {config_filepath}", globals()) - exec(f"{config_filepath}.{config_filename} = {config_filename}", globals()) + try: + exec(config, globals()) + exec(f"import {config_filepath}", globals()) + exec(f"{config_filepath}.{config_filename} = {config_filename}", globals()) + except Exception: + continue # ============================================= # ============================================= diff --git a/unsloth/models/cohere.py b/unsloth/models/cohere.py index 4251f3acd9..294e8d0c7e 100644 --- a/unsloth/models/cohere.py +++ b/unsloth/models/cohere.py @@ -357,6 +357,9 @@ def CohereAttention_fast_forward_inference( # cos, sin = self.rotary_emb(Vn, seq_len = kv_seq_len) # Qn, Kn = inplace_rope_embedding(Qn, Kn, cos, sin, position_ids) cos, sin = self.rotary_emb.get_cached(kv_seq_len, Qn.device.index) + # Transformers 5.x: position_ids may be [batch, full_seq_len]; slice to last + if position_ids.dim() >= 2 and position_ids.shape[-1] > 1: + position_ids = position_ids[:, -1:] cos = cos[position_ids].unsqueeze(1) sin = sin[position_ids].unsqueeze(1) h = self.half_head_dim diff --git a/unsloth/models/falcon_h1.py b/unsloth/models/falcon_h1.py index 6e3b16b21b..659d27de54 100644 --- a/unsloth/models/falcon_h1.py +++ b/unsloth/models/falcon_h1.py @@ -313,6 +313,9 @@ def FalconH1Attention_fast_forward_inference( # or else error self.rotary_emb.extend_rope_embedding(Vn, seq_len + 2) cos, sin = self.rotary_emb.get_cached(kv_seq_len, Qn.device.index) + # Transformers 5.x: position_ids may be [batch, full_seq_len]; slice to last + if position_ids.dim() >= 2 and position_ids.shape[-1] > 1: + position_ids = position_ids[:, -1:] cos = cos[position_ids].unsqueeze(1) sin = sin[position_ids].unsqueeze(1) h = self.half_head_dim diff --git a/unsloth/models/gemma2.py b/unsloth/models/gemma2.py index e59b8d5ebd..720c9a7414 100644 --- a/unsloth/models/gemma2.py +++ b/unsloth/models/gemma2.py @@ -394,6 +394,9 @@ def Gemma2Attention_fast_forward_inference( # cos, sin = self.rotary_emb(Vn, seq_len = kv_seq_len) # Qn, Kn = inplace_rope_embedding(Qn, Kn, cos, sin, position_ids) cos, sin = self.rotary_emb.get_cached(kv_seq_len, Qn.device.index) + # Transformers 5.x: position_ids may be [batch, full_seq_len]; slice to last + if position_ids.dim() >= 2 and position_ids.shape[-1] > 1: + position_ids = position_ids[:, -1:] cos = cos[position_ids].unsqueeze(1) sin = sin[position_ids].unsqueeze(1) h = self.half_head_dim diff --git a/unsloth/models/granite.py b/unsloth/models/granite.py index 79ac41c43f..fea3dc1b36 100644 --- a/unsloth/models/granite.py +++ b/unsloth/models/granite.py @@ -355,6 +355,9 @@ def GraniteAttention_fast_forward_inference( # cos, sin = self.rotary_emb(Vn, seq_len = kv_seq_len) # Qn, Kn = inplace_rope_embedding(Qn, Kn, cos, sin, position_ids) cos, sin = position_embeddings + # Transformers 5.x: position_ids may be [batch, full_seq_len]; slice to last + if position_ids.dim() >= 2 and position_ids.shape[-1] > 1: + position_ids = position_ids[:, -1:] cos, sin = cos[position_ids], sin[position_ids] h = self.half_head_dim diff --git a/unsloth/models/llama.py b/unsloth/models/llama.py index 8e3892c712..2f61913550 100644 --- a/unsloth/models/llama.py +++ b/unsloth/models/llama.py @@ -496,6 +496,10 @@ def LlamaAttention_fast_forward_inference( # ensure correct shape if position_ids.dim() == 1: position_ids = position_ids[:, None] + # Transformers 5.x generate() accumulates position_ids as [batch, full_seq_len] + # across decode steps. In single-token inference we only need the last position. + if position_ids.shape[-1] > 1: + position_ids = position_ids[:, -1:] position_ids = position_ids.to(Qn.device) if rotary_seq_len is None: @@ -2414,14 +2418,24 @@ class FastLlamaModel: raise_handler = RaiseUninitialized() if num_labels is not None: + # Transformers 5.x @strict config classes reject unexpected kwargs + # like num_labels and max_position_embeddings. Set on the config + # object directly and pass config= instead. + model_config.num_labels = num_labels + if max_position_embeddings is not None: + model_config.max_position_embeddings = max_position_embeddings + # Pop config-level attrs that would be rejected by @strict model init + for _cfg_key in ("id2label", "label2id", "rope_scaling"): + _cfg_val = kwargs.pop(_cfg_key, None) + if _cfg_val is not None: + setattr(model_config, _cfg_key, _cfg_val) model = AutoModelForSequenceClassification.from_pretrained( model_name, + config = model_config, device_map = device_map, # torch_dtype = dtype, # transformers changed torch_dtype to dtype - num_labels = num_labels, # quantization_config = bnb_config, token = token, - max_position_embeddings = max_position_embeddings, trust_remote_code = trust_remote_code, attn_implementation = preferred_attn_impl, **kwargs, diff --git a/unsloth/models/loader.py b/unsloth/models/loader.py index b54ceaf842..9b7b1b02c5 100644 --- a/unsloth/models/loader.py +++ b/unsloth/models/loader.py @@ -1407,8 +1407,14 @@ class FastModel(FastBaseModel): architectures = [] is_vlm = any(x.endswith("ForConditionalGeneration") for x in architectures) is_vlm = is_vlm or hasattr(model_config, "vision_config") + # If num_labels is set, use AutoModelForSequenceClassification + _num_labels = kwargs.get("num_labels", None) if auto_model is None: - if is_vlm: + if _num_labels is not None: + from transformers import AutoModelForSequenceClassification + + auto_model = AutoModelForSequenceClassification + elif is_vlm: # Check if the model's auto_map supports the VLM auto class. # Some VL models (e.g. Nemotron-VL) only register AutoModelForCausalLM # in their auto_map, not AutoModelForImageTextToText/AutoModelForVision2Seq. diff --git a/unsloth/models/qwen3.py b/unsloth/models/qwen3.py index b93dddb186..3129483be8 100644 --- a/unsloth/models/qwen3.py +++ b/unsloth/models/qwen3.py @@ -302,6 +302,9 @@ def Qwen3Attention_fast_forward_inference( # or else error self.rotary_emb.extend_rope_embedding(Vn, seq_len + 2) cos, sin = self.rotary_emb.get_cached(kv_seq_len, Qn.device.index) + # Transformers 5.x: position_ids may be [batch, full_seq_len]; slice to last + if position_ids.dim() >= 2 and position_ids.shape[-1] > 1: + position_ids = position_ids[:, -1:] cos = cos[position_ids].unsqueeze(1) sin = sin[position_ids].unsqueeze(1) h = self.half_head_dim diff --git a/unsloth/models/rl_replacements.py b/unsloth/models/rl_replacements.py index 9f555416d4..2544afe82e 100755 --- a/unsloth/models/rl_replacements.py +++ b/unsloth/models/rl_replacements.py @@ -542,6 +542,37 @@ def grpo_trainer__generate_and_score_completions(function_name, function): function = patched + # Transformers 5.x: Extend mm_token_type_ids for completion tokens (Qwen3VL M-RoPE). + # TRL handles token_type_ids but not mm_token_type_ids. + _tt_search = ( + 'if "token_type_ids" in forward_kwargs:\n' + ' token_type_ids = forward_kwargs["token_type_ids"]\n' + ' forward_kwargs["token_type_ids"] = torch.cat(\n' + " [token_type_ids, token_type_ids.new_zeros(completion_ids.shape)], dim=1\n" + " )" + ) + _tt_replace = ( + _tt_search + "\n" + ' if "mm_token_type_ids" in forward_kwargs:\n' + ' mm_tti = forward_kwargs["mm_token_type_ids"]\n' + ' forward_kwargs["mm_token_type_ids"] = torch.cat(\n' + " [mm_tti, mm_tti.new_zeros(completion_ids.shape)], dim=1\n" + " )" + ) + function = function.replace(_tt_search, _tt_replace) + + # Save mm_token_type_ids to output dict alongside token_type_ids + _save_search = ( + 'if "token_type_ids" in forward_kwargs:\n' + ' output["token_type_ids"] = forward_kwargs["token_type_ids"]' + ) + _save_replace = ( + _save_search + "\n" + ' if "mm_token_type_ids" in forward_kwargs:\n' + ' output["mm_token_type_ids"] = forward_kwargs["mm_token_type_ids"]' + ) + function = function.replace(_save_search, _save_replace) + return function @@ -714,6 +745,9 @@ def grpo_trainer__get_per_token_logps_and_entropies(function_name, function): kwargs.get("pixel_attention_mask", None), kwargs.get("image_sizes", None), ) + # Transformers 5.x needs token_type_ids/mm_token_type_ids for some vision models + token_type_ids = kwargs.get("token_type_ids", None) + mm_token_type_ids = kwargs.get("mm_token_type_ids", None) unwrapped_model = self.accelerator.unwrap_model( model, keep_fp32_wrapper = False @@ -831,6 +865,10 @@ def grpo_trainer__get_per_token_logps_and_entropies(function_name, function): if logit_scale_divide is None: logit_scale_divide = 0 + # Transformers 5.x needs token_type_ids/mm_token_type_ids for some vision models + token_type_ids_chunks = chunk_optional(token_type_ids, B) + mm_token_type_ids_chunks = chunk_optional(mm_token_type_ids, B) + zipped_inputs = zip( input_ids_chunks, attention_mask_chunks, @@ -838,6 +876,8 @@ def grpo_trainer__get_per_token_logps_and_entropies(function_name, function): image_grid_thw_chunks, pixel_attention_mask_chunks, image_sizes_chunks, + token_type_ids_chunks, + mm_token_type_ids_chunks, ) os.environ["UNSLOTH_RETURN_HIDDEN_STATES"] = "1" @@ -849,7 +889,16 @@ def grpo_trainer__get_per_token_logps_and_entropies(function_name, function): image_grid_thw_chunk, pixel_attention_mask_chunk, image_sizes_chunk, + token_type_ids_chunk, + mm_token_type_ids_chunk, ) in zipped_inputs: + _extra_vision_kwargs = {} + if token_type_ids_chunk is not None: + _extra_vision_kwargs["token_type_ids"] = token_type_ids_chunk + if mm_token_type_ids_chunk is not None: + _extra_vision_kwargs["mm_token_type_ids"] = ( + mm_token_type_ids_chunk + ) with torch.amp.autocast( device_type = "cuda", dtype = self._autocast_dtype ): @@ -861,6 +910,7 @@ def grpo_trainer__get_per_token_logps_and_entropies(function_name, function): image_grid_thw = image_grid_thw_chunk, pixel_attention_mask = pixel_attention_mask_chunk, image_sizes = image_sizes_chunk, + **_extra_vision_kwargs, ).logits completion_input_ids_chunk = input_ids_chunk[ @@ -893,6 +943,7 @@ def grpo_trainer__get_per_token_logps_and_entropies(function_name, function): pixel_attention_mask = pixel_attention_mask_chunk, image_sizes = image_sizes_chunk, logits_to_keep = logits_to_keep + 1, + **_extra_vision_kwargs, ).logits logits_chunk = logits_chunk[:, :-1, :] @@ -993,6 +1044,9 @@ def grpo_trainer_compute_loss(function_name, function): inputs.get("pixel_attention_mask", None), inputs.get("image_sizes", None), ) + # Transformers 5.x needs token_type_ids/mm_token_type_ids for some vision models + token_type_ids = inputs.get("token_type_ids", None) + mm_token_type_ids = inputs.get("mm_token_type_ids", None) num_items_in_batch = inputs.get("num_items_in_batch", None) sampling_per_token_logps = inputs.get("sampling_per_token_logps", None) current_gradient_accumulation_steps = self.current_gradient_accumulation_steps @@ -1136,6 +1190,8 @@ def grpo_trainer_compute_loss(function_name, function): current_gradient_accumulation_steps = current_gradient_accumulation_steps, num_processes = num_processes, sampling_per_token_logps = sampling_per_token_logps, + token_type_ids = token_type_ids, + mm_token_type_ids = mm_token_type_ids, ) else: # to ensure backwards compatibility with trl 0.15.2 and maybe even 0.17 @@ -1154,6 +1210,8 @@ def grpo_trainer_compute_loss(function_name, function): logit_scale_multiply = logit_scale_multiply, logit_scale_divide = logit_scale_divide, attention_mask = attention_mask, + token_type_ids = token_type_ids, + mm_token_type_ids = mm_token_type_ids, ) ) if "train" in self._metrics: diff --git a/unsloth/models/vision.py b/unsloth/models/vision.py index a961225827..ceff1c4393 100644 --- a/unsloth/models/vision.py +++ b/unsloth/models/vision.py @@ -787,6 +787,15 @@ class FastBaseModel: if not fast_inference: # Prevent load_in_fp8 from being forwarded into HF internal model loading load_in_fp8 = kwargs.pop("load_in_fp8", None) + # Transformers 5.x @strict config classes reject unexpected kwargs. + # Move config-level attributes onto the config object directly. + _num_labels = kwargs.pop("num_labels", None) + if _num_labels is not None: + model_config.num_labels = _num_labels + for _cfg_key in ("id2label", "label2id", "max_position_embeddings"): + _cfg_val = kwargs.pop(_cfg_key, None) + if _cfg_val is not None: + setattr(model_config, _cfg_key, _cfg_val) model = auto_model.from_pretrained( model_name, config = model_config, From 77e1a9edc9f7733f2b9bbed1401d0894c00aad25 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 1 Apr 2026 06:04:12 -0700 Subject: [PATCH 10/44] feat(studio): architecture-aware KV cache VRAM estimation (#4757) * feat(studio): architecture-aware KV cache VRAM estimation Replace the single legacy formula (2 * n_kv_heads * head_dim * n_layers * n_ctx * bpe) with 5-path estimation that reads 8 additional GGUF metadata fields: 1. MLA (DeepSeek-V2/V3, GLM-4.7, GLM-5, Kimi-K2.5) -- K-only cache using compressed KV latent + RoPE; no separate V allocation 2. Hybrid Mamba (Qwen3.5-27B, Qwen3.5-35B-A3B) -- only attention layers (1 in N) carry KV; Mamba layers have none 3. Sliding Window (Gemma-3, gpt-oss) -- SWA layers cache min(ctx, window) tokens instead of the full context 4. Standard GQA -- uses explicit key_length/value_length from GGUF instead of embed // n_heads (which is wrong for many models) 5. Legacy fallback -- identical to old formula for old GGUFs New GGUF fields parsed: attention.key_length, attention.value_length, attention.sliding_window, full_attention_interval, attention.kv_lora_rank, attention.key_length_mla, ssm.inner_size, ssm.state_size. Validated against 9 real GGUF files (72/72 field checks pass). The legacy formula was off by +682% for Gemma-3 and -81% for DeepSeek-V3.1. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix MLA fallback and SWA global/local ratio heuristic Two fixes based on review findings: 1. MLA fallback now uses key_length_mla from GGUF metadata instead of hardcoded rope_dim=64. Falls back to 64 only when key_length_mla is absent. This ensures correct estimates for MLA variants that use rope dimensions other than 64. 2. SWA global/local layer ratio changed from 50/50 to 1/4 (25% global, 75% SWA). Most sliding window architectures have predominantly local layers (Gemma-3 uses ~17% global, gpt-oss uses ~50%). The 1/4 heuristic is closer to the common case and still a large improvement over the legacy formula which ignores SWA entirely. * Tighten _can_estimate_kv gate and treat sliding_window=0 as disabled Two additional fixes from review round 1 (5/8 and 4/8 reviewer consensus): 1. _can_estimate_kv now requires BOTH key_length AND value_length for the explicit-dims path. Previously key_length alone was enough, which could cause silent fallthrough to the legacy formula with fabricated defaults (n_kv=1, head_dim=128) when value_length was absent from the GGUF. 2. SWA path now requires sliding_window > 0. Some GGUFs use 0 as a disabled sentinel. Without this guard, min(ctx, 0) would zero out all SWA layer contributions, severely underestimating KV cache. * Fix MLA n_kv safety and use ceiling division for hybrid path Addresses Gemini Code Assist review findings: 1. MLA path now uses n_kv_mla = n_kv_heads or 1 (not n_heads). This prevents a 128x overestimate for DeepSeek-V3 if head_count_kv is absent from the GGUF (n_heads=128 would have been used instead). 2. Hybrid path now uses ceiling division for attention layer count. This prevents undercounting by 1 when n_layers is not perfectly divisible by full_attention_interval. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/core/inference/llama_cpp.py | 115 +++++++++++++++++++-- 1 file changed, 107 insertions(+), 8 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index eb3776e603..873b72bba1 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -61,6 +61,15 @@ class LlamaCppBackend: self._n_kv_heads: Optional[int] = None self._n_heads: Optional[int] = None self._embedding_length: Optional[int] = None + # Architecture-aware KV fields (8 new fields for 5-path estimation) + self._kv_key_length: Optional[int] = None + self._kv_value_length: Optional[int] = None + self._sliding_window: Optional[int] = None + self._full_attention_interval: Optional[int] = None + self._kv_lora_rank: Optional[int] = None + self._key_length_mla: Optional[int] = None + self._ssm_inner_size: Optional[int] = None + self._ssm_state_size: Optional[int] = None self._lock = threading.Lock() self._stdout_lines: list[str] = [] self._stdout_thread: Optional[threading.Thread] = None @@ -347,10 +356,17 @@ class LlamaCppBackend: def _can_estimate_kv(self) -> bool: """True if we have enough GGUF metadata to estimate KV cache size.""" - return ( - self._n_layers is not None - and self._embedding_length is not None - and (self._n_kv_heads is not None or self._n_heads is not None) + if self._n_layers is None: + return False + # MLA: kv_lora_rank is sufficient (K-only cache) + if self._kv_lora_rank is not None: + return True + # New-style: need both explicit key AND value dimensions + if self._kv_key_length is not None and self._kv_value_length is not None: + return True + # Legacy: need embedding_length + head count + return self._embedding_length is not None and ( + self._n_kv_heads is not None or self._n_heads is not None ) def _estimate_kv_cache_bytes( @@ -358,14 +374,20 @@ class LlamaCppBackend: ) -> int: """Estimate KV cache VRAM for a given context length. + Uses 5-path architecture-aware estimation: + 1. MLA -- compressed KV latent + RoPE, K-only (no separate V) + 2. Hybrid -- only attention layers need KV (Mamba layers don't) + 3. SWA -- sliding-window layers cache min(ctx, window) tokens + 4. GQA -- standard full KV with explicit key/value dimensions + 5. Legacy -- fallback using embed // n_heads + Returns 0 if metadata is insufficient for estimation. """ if not self._can_estimate_kv() or n_ctx <= 0: return 0 n_layers = self._n_layers # type: ignore[assignment] - n_kv_heads = self._n_kv_heads or self._n_heads # type: ignore[assignment] - head_dim = self._embedding_length // self._n_heads if self._n_heads else 128 # type: ignore[operator] + n_kv = self._n_kv_heads or self._n_heads or 1 # type: ignore[assignment] # Bytes per element depends on KV cache quantization bpe = { @@ -380,8 +402,60 @@ class LlamaCppBackend: "iq4_nl": 0.5625, }.get(cache_type_kv or "f16", 2.0) - # K + V caches: 2 * n_kv_heads * head_dim * n_layers * n_ctx * bpe - return int(2 * n_kv_heads * head_dim * n_layers * n_ctx * bpe) + # Path 1: MLA (DeepSeek-V2/V3, GLM-4.7, GLM-5, Kimi-K2.5) + # MLA stores one compressed KV latent per token/layer (shared across heads). + # V is reconstructed from the latent on the fly -- no separate V cache. + # key_length = kv_lora_rank + rope_dim (the full compressed representation). + # MLA GGUFs set head_count_kv=1; default to 1 if absent to avoid + # falling back to n_heads (e.g., 128 for DeepSeek-V3) which would 128x. + if self._kv_lora_rank is not None: + n_kv_mla = self._n_kv_heads or 1 + rope_dim = self._key_length_mla or 64 + key_len = self._kv_key_length or (self._kv_lora_rank + rope_dim) + return int(n_layers * n_ctx * n_kv_mla * key_len * bpe) + + key_len = self._kv_key_length + val_len = self._kv_value_length + + # Path 2: Hybrid Mamba/Attention (Qwen3.5-27B, Qwen3.5-35B-A3B) + # Only 1 in N layers is attention; the rest are Mamba (no KV cache). + if ( + self._ssm_inner_size is not None + and self._full_attention_interval is not None + ): + fai = self._full_attention_interval + n_attn = -(-n_layers // fai) if fai > 0 else n_layers # ceiling division + if key_len is not None and val_len is not None: + return int(n_attn * n_ctx * n_kv * (key_len + val_len) * bpe) + head_dim = self._embedding_length // self._n_heads if self._n_heads else 128 # type: ignore[operator] + return int(n_attn * n_ctx * n_kv * 2 * head_dim * bpe) + + # Path 3: Sliding Window (Gemma-3, gpt-oss) + # SWA layers only cache min(ctx, window) tokens; global layers cache full ctx. + # Most SWA architectures use few global layers (e.g., Gemma-3 uses 1 in 6). + # Without an explicit field, we conservatively assume 1/4 of layers are global + # which is still far more accurate than the legacy formula (which ignores SWA). + if ( + self._sliding_window is not None + and self._sliding_window > 0 + and key_len is not None + and val_len is not None + ): + swa = self._sliding_window + n_global = max(1, n_layers // 4) + n_swa = n_layers - n_global + kv_per_token = n_kv * (key_len + val_len) * bpe + return int( + n_global * n_ctx * kv_per_token + n_swa * min(n_ctx, swa) * kv_per_token + ) + + # Path 4: Standard GQA with explicit key/value dimensions + if key_len is not None and val_len is not None: + return int(n_layers * n_ctx * n_kv * (key_len + val_len) * bpe) + + # Path 5: Legacy fallback (old GGUFs without explicit dimensions) + head_dim = self._embedding_length // self._n_heads if self._n_heads else 128 # type: ignore[operator] + return int(2 * n_kv * head_dim * n_layers * n_ctx * bpe) def _fit_context_to_vram( self, @@ -585,6 +659,14 @@ class LlamaCppBackend: self._n_kv_heads = None self._n_heads = None self._embedding_length = None + self._kv_key_length = None + self._kv_value_length = None + self._sliding_window = None + self._full_attention_interval = None + self._kv_lora_rank = None + self._key_length_mla = None + self._ssm_inner_size = None + self._ssm_state_size = None try: WANTED = {"general.architecture", "tokenizer.chat_template"} @@ -619,6 +701,15 @@ class LlamaCppBackend: f"{arch}.attention.head_count_kv": "n_kv_heads", f"{arch}.attention.head_count": "n_heads", f"{arch}.embedding_length": "embedding_length", + # Architecture-aware KV cache fields + f"{arch}.attention.key_length": "kv_key_length", + f"{arch}.attention.value_length": "kv_value_length", + f"{arch}.attention.sliding_window": "sliding_window", + f"{arch}.full_attention_interval": "full_attention_interval", + f"{arch}.attention.kv_lora_rank": "kv_lora_rank", + f"{arch}.attention.key_length_mla": "key_length_mla", + f"{arch}.ssm.inner_size": "ssm_inner_size", + f"{arch}.ssm.state_size": "ssm_state_size", } elif key == "tokenizer.chat_template": self._chat_template = val_s @@ -1422,6 +1513,14 @@ class LlamaCppBackend: self._n_kv_heads = None self._n_heads = None self._embedding_length = None + self._kv_key_length = None + self._kv_value_length = None + self._sliding_window = None + self._full_attention_interval = None + self._kv_lora_rank = None + self._key_length_mla = None + self._ssm_inner_size = None + self._ssm_state_size = None # Clean up temp chat template file if hasattr(self, "_chat_template_file") and self._chat_template_file: try: From 5d7d882ce6184c990246d41eb0e512a8fb7805a7 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 1 Apr 2026 06:05:37 -0700 Subject: [PATCH 11/44] Fix save_pretrained_merged for full-finetuned models (#4755) * Fix save_pretrained_merged for full-finetuned models save_pretrained_merged and push_to_hub_merged silently do nothing when the model is not a PeftModel (i.e. full finetuning without LoRA). merge_and_overwrite_lora returns None immediately for non-PeftModel, and unsloth_generic_save does not check the return value. Add a non-PeftModel branch in unsloth_generic_save that falls back to model.save_pretrained / model.push_to_hub. When save_method contains "16bit", cast weights to bfloat16 (or float16) via a state_dict copy to honor the user's intent without mutating the live model. The existing PeftModel (LoRA) code path is unchanged. * Forward create_pr and revision to tokenizer.push_to_hub The tokenizer push_to_hub call was missing create_pr and revision, which could cause the tokenizer to push to the wrong branch or bypass PR creation when the model push uses them. * Honor merged_16bit dtype contract for full-finetuned models Cast state_dict to bfloat16/float16 when save_method contains "16bit" to match the documented behavior of save_pretrained_merged. Also pass state_dict and save kwargs consistently to both save_pretrained and push_to_hub paths. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address review feedback for PR #4755 - Simplify PeftModel isinstance check (PeftModelForCausalLM inherits from PeftModel) - Add is_main_process guard for distributed training - Forward variant to save_pretrained - Set tokenizer padding_side to "left" before saving (matches other save paths) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- unsloth/save.py | 86 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 73 insertions(+), 13 deletions(-) diff --git a/unsloth/save.py b/unsloth/save.py index 1759d86fb1..178178980d 100644 --- a/unsloth/save.py +++ b/unsloth/save.py @@ -2777,19 +2777,79 @@ def unsloth_generic_save( elif save_method == "merged_4bit_forced": save_method = "merged_4bit" - merge_and_overwrite_lora( - get_model_name, - model = model, - tokenizer = tokenizer, - save_directory = save_directory, - push_to_hub = push_to_hub, - private = private, - token = token, - save_method = save_method, - output_dtype = None, - low_disk_space_usage = True, - use_temp_file = False, - ) + # Full-finetuned models (no LoRA) cannot use merge_and_overwrite_lora + # since there are no adapters to merge. Fall back to save_pretrained. + # This mirrors the non-PeftModel handling in save_pretrained_torchao + # and the GGUF save path. + _is_peft = isinstance(model, PeftModel) + if not _is_peft: + if not is_main_process: + return + + # Honor merged_16bit by casting to the target dtype if needed + _save_kwargs = dict( + safe_serialization = safe_serialization, + max_shard_size = max_shard_size, + variant = variant, + ) + if "16bit" in save_method: + _target_dtype = ( + torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16 + ) + _save_kwargs["state_dict"] = { + k: v.to(dtype = _target_dtype) if v.is_floating_point() else v + for k, v in model.state_dict().items() + } + + if push_to_hub: + print(f"Unsloth: Pushing full fine-tuned model to '{save_directory}' ...") + model.push_to_hub( + repo_id = save_directory, + token = token, + private = private, + commit_message = commit_message, + create_pr = create_pr, + revision = revision, + commit_description = commit_description, + tags = tags, + **_save_kwargs, + ) + if tokenizer is not None: + old_padding_side = tokenizer.padding_side + tokenizer.padding_side = "left" + tokenizer.push_to_hub( + save_directory, + token = token, + private = private, + commit_message = commit_message, + create_pr = create_pr, + revision = revision, + ) + tokenizer.padding_side = old_padding_side + else: + print(f"Unsloth: Saving full fine-tuned model to '{save_directory}' ...") + model.save_pretrained(save_directory, **_save_kwargs) + if tokenizer is not None: + old_padding_side = tokenizer.padding_side + tokenizer.padding_side = "left" + tokenizer.save_pretrained(save_directory) + tokenizer.padding_side = old_padding_side + + print(f"Unsloth: Model saved successfully to '{save_directory}'") + else: + merge_and_overwrite_lora( + get_model_name, + model = model, + tokenizer = tokenizer, + save_directory = save_directory, + push_to_hub = push_to_hub, + private = private, + token = token, + save_method = save_method, + output_dtype = None, + low_disk_space_usage = True, + use_temp_file = False, + ) if push_to_hub and datasets: try: From 428efc7d95cfb4dc3f479af63951ee2820162490 Mon Sep 17 00:00:00 2001 From: DoubleMathew Date: Wed, 1 Apr 2026 08:06:17 -0500 Subject: [PATCH 12/44] Resolve latest usable published llama.cpp release instead of fixed pinned tag (#4741) Replaces the fixed prebuilt llama.cpp tag with dynamic published-release resolution, adds bounded fallback across older published releases, and introduces maintainer-editable defaults for PR/source overrides. Changes: - Resolve latest from the latest usable published release in unslothai/llama.cpp - Use the selected release upstream_tag as the authoritative llama.cpp version - Prefer Unsloth-published platform assets when available - Fall back to same-tag upstream ggml-org/llama.cpp assets where allowed - Keep Linux CUDA anchored to Unsloth-published CUDA bundles only - Add bounded fallback across older Unsloth published releases - Add separate busy/in-use install handling (exit code 3) - Skip reinstall when the installed bundle already matches the selected candidate - Add maintainer-editable _DEFAULT_LLAMA_PR_FORCE and _DEFAULT_LLAMA_SOURCE - Harden env parsing so malformed installer env vars do not crash import-time fallback logic - Honor UNSLOTH_LLAMA_RELEASE_TAG in all resolve steps - Always sync git remote URL in existing-checkout path --- studio/install_llama_prebuilt.py | 945 +++++++++++++++++++++++++++---- studio/setup.ps1 | 213 +++++-- studio/setup.sh | 187 ++++-- 3 files changed, 1129 insertions(+), 216 deletions(-) diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 516dc4b6a4..f09955c9e3 100755 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -7,6 +7,7 @@ from __future__ import annotations import argparse +import errno import fnmatch import hashlib import json @@ -41,9 +42,25 @@ from typing import Any, Iterable, Iterator EXIT_SUCCESS = 0 EXIT_FALLBACK = 2 EXIT_ERROR = 1 +EXIT_BUSY = 3 + + +def env_int(name: str, default: int, *, minimum: int | None = None) -> int: + raw = os.environ.get(name) + if raw is None: + value = default + else: + try: + value = int(str(raw).strip()) + except (TypeError, ValueError): + value = default + if minimum is not None: + value = max(minimum, value) + return value + APPROVED_PREBUILT_LLAMA_TAG = "b8508" -DEFAULT_LLAMA_TAG = os.environ.get("UNSLOTH_LLAMA_TAG", APPROVED_PREBUILT_LLAMA_TAG) +DEFAULT_LLAMA_TAG = os.environ.get("UNSLOTH_LLAMA_TAG", "latest") DEFAULT_PUBLISHED_REPO = os.environ.get( "UNSLOTH_LLAMA_RELEASE_REPO", "unslothai/llama.cpp" ) @@ -71,6 +88,11 @@ HTTP_FETCH_BASE_DELAY_SECONDS = 0.75 SERVER_PORT_BIND_ATTEMPTS = 3 SERVER_BIND_RETRY_WINDOW_SECONDS = 5.0 TTY_PROGRESS_START_DELAY_SECONDS = 0.5 +DEFAULT_MAX_PREBUILT_RELEASE_FALLBACKS = env_int( + "UNSLOTH_LLAMA_MAX_PREBUILT_RELEASE_FALLBACKS", + 2, + minimum = 1, +) @dataclass @@ -170,10 +192,83 @@ class ApprovedReleaseChecksums: artifacts: dict[str, ApprovedArtifactHash] +@dataclass(frozen = True) +class ResolvedPublishedRelease: + bundle: PublishedReleaseBundle + checksums: ApprovedReleaseChecksums + + +@dataclass(frozen = True) +class InstallReleasePlan: + requested_tag: str + llama_tag: str + release_tag: str + attempts: list[AssetChoice] + approved_checksums: ApprovedReleaseChecksums + + class PrebuiltFallback(RuntimeError): pass +class BusyInstallConflict(RuntimeError): + pass + + +class ExistingInstallSatisfied(RuntimeError): + def __init__(self, choice: AssetChoice, used_fallback: bool): + super().__init__(f"existing install already matches candidate {choice.name}") + self.choice = choice + self.used_fallback = used_fallback + + +def _os_error_messages(exc: BaseException) -> list[str]: + messages: list[str] = [] + if isinstance(exc, OSError): + for value in ( + getattr(exc, "strerror", None), + getattr(exc, "filename", None), + getattr(exc, "filename2", None), + ): + if isinstance(value, str) and value: + messages.append(value) + text = str(exc) + if text: + messages.append(text) + return [message.lower() for message in messages if message] + + +def is_busy_lock_error(exc: BaseException) -> bool: + if isinstance(exc, BusyInstallConflict): + return True + if isinstance(exc, OSError): + if exc.errno in { + errno.EACCES, + errno.EBUSY, + errno.EPERM, + errno.ETXTBSY, + }: + return True + if getattr(exc, "winerror", None) in {5, 32, 145}: + return True + for message in _os_error_messages(exc): + if any( + needle in message + for needle in ( + "access is denied", + "being used by another process", + "device or resource busy", + "permission denied", + "text file busy", + "file is in use", + "process cannot access the file", + "cannot create a file when that file already exists", + ) + ): + return True + return False + + def log(message: str) -> None: print(f"[llama-prebuilt] {message}") @@ -598,6 +693,14 @@ def latest_upstream_release_tag() -> str: return tag +def normalized_requested_llama_tag(requested_tag: str | None) -> str: + if isinstance(requested_tag, str): + normalized = requested_tag.strip() + if normalized: + return normalized + return "latest" + + def normalize_compute_cap(value: Any) -> str | None: raw = str(value).strip() if not raw: @@ -1283,6 +1386,137 @@ def pinned_published_release_bundle( return bundle +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) + return checksums + + +def resolve_published_release( + requested_tag: str | None, + published_repo: str, + published_release_tag: str = "", +) -> ResolvedPublishedRelease: + repo = published_repo or DEFAULT_PUBLISHED_REPO + normalized_requested = normalized_requested_llama_tag(requested_tag) + + if published_release_tag: + bundle = pinned_published_release_bundle(repo, published_release_tag) + if ( + normalized_requested != "latest" + and bundle.upstream_tag != normalized_requested + ): + raise PrebuiltFallback( + "published release " + f"{repo}@{published_release_tag} targeted upstream tag {bundle.upstream_tag}, " + f"but requested {normalized_requested}" + ) + return ResolvedPublishedRelease( + bundle = bundle, + checksums = validated_checksums_for_bundle(repo, bundle), + ) + + skipped_invalid = 0 + for bundle in iter_published_release_bundles(repo): + if ( + normalized_requested != "latest" + and bundle.upstream_tag != normalized_requested + ): + continue + try: + checksums = validated_checksums_for_bundle(repo, bundle) + except PrebuiltFallback as exc: + skipped_invalid += 1 + log( + "published release ignored for install resolution: " + f"{repo}@{bundle.release_tag} ({exc})" + ) + continue + return ResolvedPublishedRelease(bundle = bundle, checksums = checksums) + + if normalized_requested == "latest": + if skipped_invalid: + raise PrebuiltFallback( + f"no usable published llama.cpp releases were available in {repo}" + ) + raise PrebuiltFallback( + f"no published llama.cpp releases were available in {repo}" + ) + + raise PrebuiltFallback( + f"no published prebuilt release in {repo} matched upstream tag {normalized_requested}" + ) + + +def iter_resolved_published_releases( + requested_tag: str | None, + published_repo: str, + published_release_tag: str = "", +) -> Iterable[ResolvedPublishedRelease]: + repo = published_repo or DEFAULT_PUBLISHED_REPO + normalized_requested = normalized_requested_llama_tag(requested_tag) + + if published_release_tag: + bundle = pinned_published_release_bundle(repo, published_release_tag) + if ( + normalized_requested != "latest" + and bundle.upstream_tag != normalized_requested + ): + raise PrebuiltFallback( + "published release " + f"{repo}@{published_release_tag} targeted upstream tag {bundle.upstream_tag}, " + f"but requested {normalized_requested}" + ) + yield ResolvedPublishedRelease( + bundle = bundle, + checksums = validated_checksums_for_bundle(repo, bundle), + ) + return + + matched_any = False + skipped_invalid = 0 + yielded_valid = False + for bundle in iter_published_release_bundles(repo): + if ( + normalized_requested != "latest" + and bundle.upstream_tag != normalized_requested + ): + continue + matched_any = True + try: + checksums = validated_checksums_for_bundle(repo, bundle) + except PrebuiltFallback as exc: + skipped_invalid += 1 + log( + "published release ignored for install resolution: " + f"{repo}@{bundle.release_tag} ({exc})" + ) + continue + yielded_valid = True + yield ResolvedPublishedRelease(bundle = bundle, checksums = checksums) + + if yielded_valid: + return + + if matched_any: + if skipped_invalid: + raise PrebuiltFallback( + f"no usable published llama.cpp releases were available in {repo}" + ) + return + + if normalized_requested == "latest": + raise PrebuiltFallback( + f"no published llama.cpp releases were available in {repo}" + ) + + raise PrebuiltFallback( + f"no published prebuilt release in {repo} matched upstream tag {normalized_requested}" + ) + + def resolve_requested_llama_tag( requested_tag: str | None, published_repo: str = "", @@ -1291,9 +1525,9 @@ def resolve_requested_llama_tag( Resolution order: 1. Concrete tag (e.g. "b8508") -- returned as-is. - 2. "latest" with published_repo -- query the Unsloth release repo - (e.g. unslothai/llama.cpp) for its latest release tag. This is the - tested/approved version that matches the prebuilt binaries. + 2. "latest" with published_repo -- resolve the latest usable Unsloth + published release bundle and return its upstream_tag. This is the + preferred version that matches the published prebuilt metadata. 3. "latest" without published_repo or if (2) fails -- query the upstream ggml-org/llama.cpp repo. This may return a newer, untested tag. @@ -1301,20 +1535,19 @@ def resolve_requested_llama_tag( upstream tags that have been validated with Unsloth Studio. Using the upstream bleeding-edge tag risks API/ABI incompatibilities. """ - if requested_tag and requested_tag != "latest": - return requested_tag + normalized_requested = normalized_requested_llama_tag(requested_tag) + if normalized_requested != "latest": + return normalized_requested # Prefer the Unsloth release repo tag (tested/approved) over bleeding-edge # upstream. For example, unslothai/llama.cpp may publish b8508 while # ggml-org/llama.cpp latest is b8514. The source-build fallback should # compile the same version the prebuilt path would have installed. if published_repo: try: - payload = fetch_json( - f"https://api.github.com/repos/{published_repo}/releases/latest" - ) - tag = payload.get("tag_name") - if isinstance(tag, str) and tag: - return tag + return resolve_published_release( + "latest", + published_repo, + ).bundle.upstream_tag except Exception: pass # Fall back to upstream ggml-org latest release tag @@ -1324,18 +1557,13 @@ def resolve_requested_llama_tag( def resolve_requested_install_tag( requested_tag: str | None, published_release_tag: str = "", + published_repo: str = DEFAULT_PUBLISHED_REPO, ) -> str: - approved_tag = APPROVED_PREBUILT_LLAMA_TAG - normalized_requested = requested_tag or "latest" - if normalized_requested not in {"latest", approved_tag}: - raise PrebuiltFallback( - f"prebuilt installs are pinned to approved release {approved_tag}; requested {normalized_requested}" - ) - if published_release_tag and published_release_tag != approved_tag: - raise PrebuiltFallback( - f"prebuilt installs require published release tag {approved_tag}; requested {published_release_tag}" - ) - return approved_tag + return resolve_published_release( + requested_tag, + published_repo, + published_release_tag, + ).bundle.upstream_tag def run_capture( @@ -1680,6 +1908,68 @@ def windows_cuda_attempts( return attempts +def published_windows_cuda_attempts( + host: HostInfo, + release: PublishedReleaseBundle, + preferred_runtime_line: str | None, + selection_preamble: Iterable[str] = (), +) -> list[AssetChoice]: + selection_log = list(release.selection_log) + list(selection_preamble) + runtime_by_line = {"cuda12": "12.4", "cuda13": "13.1"} + runtime_order = windows_cuda_attempts( + host, + release.upstream_tag, + { + f"llama-{release.upstream_tag}-bin-win-cuda-{runtime}-x64.zip": "published" + for runtime in runtime_by_line.values() + }, + preferred_runtime_line, + selection_log, + ) + published_artifacts = [ + artifact + for artifact in release.artifacts + if artifact.install_kind == "windows-cuda" + ] + artifacts_by_runtime: dict[str, list[PublishedLlamaArtifact]] = {} + for artifact in published_artifacts: + if not artifact.runtime_line: + continue + artifacts_by_runtime.setdefault(artifact.runtime_line, []).append(artifact) + + attempts: list[AssetChoice] = [] + for ordered_attempt in runtime_order: + runtime_line = ordered_attempt.runtime_line + if not runtime_line: + continue + candidates = sorted( + artifacts_by_runtime.get(runtime_line, []), + key = lambda artifact: (artifact.rank, artifact.asset_name), + ) + for artifact in candidates: + asset_url = release.assets.get(artifact.asset_name) + if not asset_url: + continue + attempts.append( + AssetChoice( + repo = release.repo, + tag = release.release_tag, + name = artifact.asset_name, + url = asset_url, + source_label = "published", + install_kind = "windows-cuda", + runtime_line = runtime_line, + selection_log = list(ordered_attempt.selection_log or []) + + [ + "windows_cuda_selection: selected published asset " + f"{artifact.asset_name} for runtime_line={runtime_line}" + ], + ) + ) + break + return attempts + + def resolve_windows_cuda_choices( host: HostInfo, llama_tag: str, upstream_assets: dict[str, str] ) -> list[AssetChoice]: @@ -1695,32 +1985,52 @@ def resolve_windows_cuda_choices( def resolve_linux_cuda_choice( - host: HostInfo, llama_tag: str, published_repo: str, published_release_tag: str + host: HostInfo, release: PublishedReleaseBundle ) -> LinuxCudaSelection: torch_preference = detect_torch_cuda_runtime_preference(host) - skipped_tag_mismatches = 0 - for release in iter_published_release_bundles( - published_repo, published_release_tag - ): - if release.upstream_tag != llama_tag: - skipped_tag_mismatches += 1 - continue - selection = linux_cuda_choice_from_release( - host, - release, - preferred_runtime_line = torch_preference.runtime_line, - selection_preamble = torch_preference.selection_log, - ) - if selection is not None: - return selection - if skipped_tag_mismatches: - log( - "published Linux CUDA selection skipped " - f"{skipped_tag_mismatches} release(s) with upstream_tag != {llama_tag}" - ) + selection = linux_cuda_choice_from_release( + host, + release, + preferred_runtime_line = torch_preference.runtime_line, + selection_preamble = torch_preference.selection_log, + ) + if selection is not None: + return selection raise PrebuiltFallback("no compatible published Linux CUDA bundle was found") +def published_asset_choice_for_kind( + release: PublishedReleaseBundle, + install_kind: str, +) -> AssetChoice | None: + candidates = sorted( + ( + artifact + for artifact in release.artifacts + if artifact.install_kind == install_kind + ), + key = lambda artifact: (artifact.rank, artifact.asset_name), + ) + for artifact in candidates: + asset_url = release.assets.get(artifact.asset_name) + if not asset_url: + continue + return AssetChoice( + repo = release.repo, + tag = release.release_tag, + name = artifact.asset_name, + url = asset_url, + source_label = "published", + install_kind = install_kind, + runtime_line = artifact.runtime_line, + selection_log = list(release.selection_log) + + [ + f"published_selection: selected {artifact.asset_name} install_kind={install_kind}" + ], + ) + return None + + def resolve_upstream_asset_choice(host: HostInfo, llama_tag: str) -> AssetChoice: upstream_assets = github_release_assets(UPSTREAM_REPO, llama_tag) if host.is_linux and host.is_x86_64: @@ -1786,16 +2096,62 @@ def resolve_upstream_asset_choice(host: HostInfo, llama_tag: str) -> AssetChoice ) -def resolve_asset_choice( - host: HostInfo, llama_tag: str, published_repo: str, published_release_tag: str -) -> AssetChoice: +def resolve_asset_choice(host: HostInfo, llama_tag: str) -> AssetChoice: if host.is_linux and host.is_x86_64 and host.has_usable_nvidia: - return resolve_linux_cuda_choice( - host, llama_tag, published_repo, published_release_tag - ).primary + raise PrebuiltFallback( + "Linux CUDA installs require a compatible published bundle; upstream fallback is not available" + ) return resolve_upstream_asset_choice(host, llama_tag) +def resolve_release_asset_choice( + host: HostInfo, + llama_tag: str, + release: PublishedReleaseBundle, + checksums: ApprovedReleaseChecksums, +) -> list[AssetChoice]: + if host.is_windows and host.is_x86_64 and host.has_usable_nvidia: + torch_preference = detect_torch_cuda_runtime_preference(host) + published_attempts = published_windows_cuda_attempts( + host, + release, + torch_preference.runtime_line, + torch_preference.selection_log, + ) + if published_attempts: + try: + return apply_approved_hashes(published_attempts, checksums) + except PrebuiltFallback as exc: + log( + "published Windows CUDA assets ignored for install planning: " + f"{release.repo}@{release.release_tag} ({exc})" + ) + upstream_assets = github_release_assets(UPSTREAM_REPO, llama_tag) + return apply_approved_hashes( + resolve_windows_cuda_choices(host, llama_tag, upstream_assets), + checksums, + ) + + published_choice: AssetChoice | None = None + if host.is_windows and host.is_x86_64: + published_choice = published_asset_choice_for_kind(release, "windows-cpu") + elif host.is_macos and host.is_arm64: + published_choice = published_asset_choice_for_kind(release, "macos-arm64") + elif host.is_macos and host.is_x86_64: + published_choice = published_asset_choice_for_kind(release, "macos-x64") + + if published_choice is not None: + try: + return apply_approved_hashes([published_choice], checksums) + except PrebuiltFallback as exc: + log( + "published platform asset ignored for install planning: " + f"{release.repo}@{release.release_tag} {published_choice.name} ({exc})" + ) + + return apply_approved_hashes([resolve_asset_choice(host, llama_tag)], checksums) + + def extract_archive(archive_path: Path, destination: Path) -> None: def safe_extract_path(base: Path, member_name: str) -> Path: normalized = member_name.replace("\\", "/") @@ -2163,8 +2519,14 @@ def install_lock(lock_path: Path) -> Iterator[None]: while True: try: fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_RDWR) - os.write(fd, f"{os.getpid()}\n".encode()) - os.fsync(fd) + try: + os.write(fd, f"{os.getpid()}\n".encode()) + os.fsync(fd) + except Exception: + os.close(fd) + fd = None + lock_path.unlink(missing_ok = True) + raise break except FileExistsError: # Check if the holder process is still alive @@ -2177,6 +2539,10 @@ def install_lock(lock_path: Path) -> Iterator[None]: if not raw: # File exists but PID not yet written -- another process # just created it. Wait briefly for the write to land. + if time.monotonic() >= deadline: + raise BusyInstallConflict( + f"timed out after {INSTALL_LOCK_TIMEOUT_SECONDS}s waiting for concurrent install lock: {lock_path}" + ) time.sleep(0.1) continue try: @@ -2195,7 +2561,7 @@ def install_lock(lock_path: Path) -> Iterator[None]: lock_path.unlink(missing_ok = True) continue if time.monotonic() >= deadline: - raise RuntimeError( + raise BusyInstallConflict( f"timed out after {INSTALL_LOCK_TIMEOUT_SECONDS}s waiting for concurrent install lock: {lock_path}" ) time.sleep(0.5) @@ -2211,7 +2577,7 @@ def install_lock(lock_path: Path) -> Iterator[None]: with FileLock(lock_path, timeout = INSTALL_LOCK_TIMEOUT_SECONDS): yield except FileLockTimeout as exc: - raise RuntimeError( + raise BusyInstallConflict( f"timed out after {INSTALL_LOCK_TIMEOUT_SECONDS}s waiting for concurrent install lock: {lock_path}" ) from exc @@ -2359,11 +2725,17 @@ def activate_install_tree(staging_dir: Path, install_dir: Path, host: HostInfo) log(f"restoring rollback path {rollback_dir} -> {install_dir}") os.replace(rollback_dir, install_dir) log(f"restored previous install from rollback path {rollback_dir.name}") + if is_busy_lock_error(exc): + raise BusyInstallConflict( + "staged prebuilt validation passed but the existing install could not be replaced " + "because llama.cpp appears to still be in use; restored previous install " + f"({textwrap.shorten(str(exc), width = 200, placeholder = '...')})" + ) from exc raise PrebuiltFallback( "staged prebuilt validation passed but activation failed; restored previous install " f"({textwrap.shorten(str(exc), width = 200, placeholder = '...')})" ) from exc - except PrebuiltFallback: + except (BusyInstallConflict, PrebuiltFallback): raise except Exception as rollback_exc: log(f"rollback after failed activation also failed: {rollback_exc}") @@ -2395,7 +2767,12 @@ def activate_install_tree(staging_dir: Path, install_dir: Path, host: HostInfo) ) from exc else: if rollback_dir: - remove_tree_logged(rollback_dir, "rollback path") + try: + remove_tree_logged(rollback_dir, "rollback path") + except Exception as cleanup_exc: + log( + f"non-fatal: rollback cleanup failed after successful activation: {cleanup_exc}" + ) finally: remove_tree(failed_dir) remove_tree(staging_dir) @@ -3110,39 +3487,90 @@ def resolve_install_attempts( published_repo: str, published_release_tag: str, ) -> tuple[str, str, list[AssetChoice], ApprovedReleaseChecksums]: - requested_tag = llama_tag - resolved_tag = resolve_requested_install_tag(llama_tag, published_release_tag) - checksums = load_approved_release_checksums(published_repo, resolved_tag) - require_approved_source_hash(checksums, resolved_tag) - - if host.is_linux and host.is_x86_64 and host.has_usable_nvidia: - linux_cuda_selection = resolve_linux_cuda_choice( - host, resolved_tag, published_repo, published_release_tag - ) - attempts = apply_approved_hashes(linux_cuda_selection.attempts, checksums) - if not attempts: - raise PrebuiltFallback("no compatible Linux CUDA asset was found") - log_lines(linux_cuda_selection.selection_log) - return requested_tag, resolved_tag, attempts, checksums - - if host.is_windows and host.is_x86_64 and host.has_usable_nvidia: - upstream_assets = github_release_assets(UPSTREAM_REPO, resolved_tag) - attempts = apply_approved_hashes( - resolve_windows_cuda_choices(host, resolved_tag, upstream_assets), checksums - ) - if not attempts: - raise PrebuiltFallback("no compatible Windows CUDA asset was found") - if attempts[0].selection_log: - log_lines(attempts[0].selection_log) - return requested_tag, resolved_tag, attempts, checksums - - choice = resolve_asset_choice( - host, resolved_tag, published_repo, published_release_tag + requested_tag, plans = resolve_install_release_plans( + llama_tag, + host, + published_repo, + published_release_tag, ) - approved_attempts = apply_approved_hashes([choice], checksums) - if choice.selection_log: - log_lines(choice.selection_log) - return requested_tag, resolved_tag, approved_attempts, checksums + if not plans: + raise PrebuiltFallback("no prebuilt release plans were available") + plan = plans[0] + return requested_tag, plan.llama_tag, plan.attempts, plan.approved_checksums + + +def resolve_install_release_plans( + llama_tag: str, + host: HostInfo, + published_repo: str, + published_release_tag: str, + *, + max_release_fallbacks: int = DEFAULT_MAX_PREBUILT_RELEASE_FALLBACKS, +) -> tuple[str, list[InstallReleasePlan]]: + requested_tag = normalized_requested_llama_tag(llama_tag) + allow_older_release_fallback = ( + requested_tag == "latest" and not published_release_tag + ) + release_limit = max(1, max_release_fallbacks) + plans: list[InstallReleasePlan] = [] + last_error: PrebuiltFallback | None = None + + for resolved_release in iter_resolved_published_releases( + llama_tag, + published_repo, + published_release_tag, + ): + bundle = resolved_release.bundle + checksums = resolved_release.checksums + resolved_tag = bundle.upstream_tag + try: + if host.is_linux and host.is_x86_64 and host.has_usable_nvidia: + linux_cuda_selection = resolve_linux_cuda_choice(host, bundle) + attempts = apply_approved_hashes( + linux_cuda_selection.attempts, checksums + ) + if not attempts: + raise PrebuiltFallback("no compatible Linux CUDA asset was found") + log_lines(linux_cuda_selection.selection_log) + else: + attempts = resolve_release_asset_choice( + host, + resolved_tag, + bundle, + checksums, + ) + if not attempts: + raise PrebuiltFallback("no compatible prebuilt asset was found") + if attempts[0].selection_log: + log_lines(attempts[0].selection_log) + except PrebuiltFallback as exc: + last_error = exc + if not allow_older_release_fallback: + raise + log( + "published release skipped for install planning: " + f"{bundle.repo}@{bundle.release_tag} upstream_tag={resolved_tag} ({exc})" + ) + continue + + plans.append( + InstallReleasePlan( + requested_tag = requested_tag, + llama_tag = resolved_tag, + release_tag = bundle.release_tag, + attempts = attempts, + approved_checksums = checksums, + ) + ) + + if not allow_older_release_fallback or len(plans) >= release_limit: + break + + if plans: + return requested_tag, plans + if last_error is not None: + raise last_error + raise PrebuiltFallback("no installable published llama.cpp releases were found") def write_prebuilt_metadata( @@ -3150,17 +3578,46 @@ def write_prebuilt_metadata( *, requested_tag: str, llama_tag: str, + release_tag: str, choice: AssetChoice, + approved_checksums: ApprovedReleaseChecksums, prebuilt_fallback_used: bool, ) -> None: + source_archive = approved_checksums.artifacts.get( + source_archive_logical_name(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, + "upstream_tag": llama_tag, + "asset": choice.name, + "asset_sha256": choice.expected_sha256, + "source": choice.source_label, + "source_sha256": source_sha256, + "runtime_line": choice.runtime_line, + "bundle_profile": choice.bundle_profile, + "coverage_class": choice.coverage_class, + } + fingerprint = hashlib.sha256( + json.dumps(fingerprint_payload, sort_keys = True, separators = (",", ":")).encode( + "utf-8" + ) + ).hexdigest() metadata = { "requested_tag": requested_tag, "tag": llama_tag, + "release_tag": release_tag, + "published_repo": approved_checksums.repo, "asset": choice.name, + "asset_sha256": choice.expected_sha256, "source": choice.source_label, + "source_sha256": source_sha256, + "source_commit": approved_checksums.source_commit, "bundle_profile": choice.bundle_profile, "runtime_line": choice.runtime_line, "coverage_class": choice.coverage_class, + "install_fingerprint": fingerprint, "prebuilt_fallback_used": prebuilt_fallback_used, "installed_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), } @@ -3169,6 +3626,178 @@ def write_prebuilt_metadata( ) +def expected_install_fingerprint( + *, + llama_tag: str, + release_tag: str, + choice: AssetChoice, + approved_checksums: ApprovedReleaseChecksums, +) -> str | None: + if not choice.expected_sha256: + return None + source_archive = approved_checksums.artifacts.get( + source_archive_logical_name(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, + "upstream_tag": llama_tag, + "asset": choice.name, + "asset_sha256": choice.expected_sha256, + "source": choice.source_label, + "source_sha256": source_sha256, + "runtime_line": choice.runtime_line, + "bundle_profile": choice.bundle_profile, + "coverage_class": choice.coverage_class, + } + return hashlib.sha256( + json.dumps(payload, sort_keys = True, separators = (",", ":")).encode("utf-8") + ).hexdigest() + + +def load_prebuilt_metadata(install_dir: Path) -> dict[str, Any] | None: + metadata_path = install_dir / "UNSLOTH_PREBUILT_INFO.json" + if not metadata_path.is_file(): + return None + try: + payload = json.loads(metadata_path.read_text(encoding = "utf-8")) + except Exception: + return None + if not isinstance(payload, dict): + return None + return payload + + +def runtime_payload_health_groups(choice: AssetChoice) -> list[list[str]]: + if choice.install_kind == "linux-cpu": + return [ + ["libllama.so*"], + ["libggml.so*"], + ["libggml-base.so*"], + ["libggml-cpu-*.so*"], + ["libmtmd.so*"], + ] + if choice.install_kind == "linux-cuda": + return [ + ["libllama.so*"], + ["libggml.so*"], + ["libggml-base.so*"], + ["libggml-cpu-*.so*"], + ["libmtmd.so*"], + ["libggml-cuda.so*"], + ] + if choice.install_kind in {"macos-arm64", "macos-x64"}: + return [ + ["libllama*.dylib"], + ["libggml*.dylib"], + ["libmtmd*.dylib"], + ] + if choice.install_kind == "windows-cpu": + return [["llama.dll"]] + if choice.install_kind == "windows-cuda": + return [["llama.dll"], ["ggml-cuda.dll"]] + return [] + + +def install_runtime_dir(install_dir: Path, host: HostInfo) -> Path: + if host.is_windows: + return install_dir / "build" / "bin" / "Release" + return install_dir / "build" / "bin" + + +def runtime_payload_is_healthy( + install_dir: Path, host: HostInfo, choice: AssetChoice +) -> bool: + runtime_dir = install_runtime_dir(install_dir, host) + if not runtime_dir.exists(): + return False + for pattern_group in runtime_payload_health_groups(choice): + matched = False + for pattern in pattern_group: + if any(runtime_dir.glob(pattern)): + matched = True + break + if not matched: + return False + return True + + +def existing_install_matches_choice( + install_dir: Path, + host: HostInfo, + *, + llama_tag: str, + release_tag: str, + choice: AssetChoice, + approved_checksums: ApprovedReleaseChecksums, +) -> bool: + if not install_dir.exists(): + return False + + metadata = load_prebuilt_metadata(install_dir) + if metadata is None: + return False + + if not runtime_payload_is_healthy(install_dir, host, choice): + return False + + # Verify primary executables still exist (catches partial deletion) + runtime_dir = install_runtime_dir(install_dir, host) + ext = ".exe" if host.is_windows else "" + for binary in ("llama-server", "llama-quantize"): + if not (runtime_dir / f"{binary}{ext}").exists(): + return False + expected_fingerprint = expected_install_fingerprint( + llama_tag = llama_tag, + release_tag = release_tag, + choice = choice, + approved_checksums = approved_checksums, + ) + if not expected_fingerprint: + return False + + recorded_fingerprint = metadata.get("install_fingerprint") + if not isinstance(recorded_fingerprint, str) or not recorded_fingerprint: + return False + + if recorded_fingerprint != expected_fingerprint: + return False + + expected_pairs = { + "release_tag": release_tag, + "published_repo": approved_checksums.repo, + "tag": llama_tag, + "asset": choice.name, + "asset_sha256": choice.expected_sha256, + "source": choice.source_label, + "runtime_line": choice.runtime_line, + "bundle_profile": choice.bundle_profile, + "coverage_class": choice.coverage_class, + } + for key, expected in expected_pairs.items(): + if metadata.get(key) != expected: + return False + return True + + +def existing_install_matches_plan( + install_dir: Path, + host: HostInfo, + plan: InstallReleasePlan, +) -> bool: + if not plan.attempts: + return False + return existing_install_matches_choice( + install_dir, + host, + llama_tag = plan.llama_tag, + release_tag = plan.release_tag, + choice = plan.attempts[0], + approved_checksums = plan.approved_checksums, + ) + + def validate_prebuilt_choice( choice: AssetChoice, host: HostInfo, @@ -3178,6 +3807,7 @@ def validate_prebuilt_choice( *, requested_tag: str, llama_tag: str, + release_tag: str, approved_checksums: ApprovedReleaseChecksums, prebuilt_fallback_used: bool, quantized_path: Path, @@ -3206,7 +3836,9 @@ def validate_prebuilt_choice( install_dir, requested_tag = requested_tag, llama_tag = llama_tag, + release_tag = release_tag, choice = choice, + approved_checksums = approved_checksums, prebuilt_fallback_used = prebuilt_fallback_used, ) validate_quantize( @@ -3237,13 +3869,16 @@ def validate_prebuilt_attempts( *, requested_tag: str, llama_tag: str, + release_tag: str, approved_checksums: ApprovedReleaseChecksums, + initial_fallback_used: bool = False, + existing_install_dir: Path | None = None, ) -> tuple[AssetChoice, Path, bool]: attempt_list = list(attempts) if not attempt_list: raise PrebuiltFallback("no prebuilt bundle attempts were available") - tried_fallback = False + tried_fallback = initial_fallback_used for index, attempt in enumerate(attempt_list): if index > 0: tried_fallback = True @@ -3253,6 +3888,20 @@ def validate_prebuilt_attempts( f"runtime_line={attempt.runtime_line} coverage_class={attempt.coverage_class}" ) + if existing_install_dir is not None and existing_install_matches_choice( + existing_install_dir, + host, + llama_tag = llama_tag, + release_tag = release_tag, + choice = attempt, + approved_checksums = approved_checksums, + ): + log( + "existing llama.cpp install already matches fallback candidate " + f"{attempt.name}; skipping reinstall" + ) + raise ExistingInstallSatisfied(attempt, tried_fallback) + staging_dir = create_install_staging_dir(install_dir) quantized_path = work_dir / f"stories260K-q4-{index}.gguf" if quantized_path.exists(): @@ -3266,6 +3915,7 @@ def validate_prebuilt_attempts( probe_path, requested_tag = requested_tag, llama_tag = llama_tag, + release_tag = release_tag, approved_checksums = approved_checksums, prebuilt_fallback_used = tried_fallback, quantized_path = quantized_path, @@ -3307,42 +3957,81 @@ def install_prebuilt( log( f"no existing llama.cpp install detected at {install_dir}; performing fresh prebuilt install" ) - requested_tag, llama_tag, attempts, approved_checksums = ( - resolve_install_attempts( - llama_tag, - host, - published_repo, - published_release_tag, + requested_tag, release_plans = resolve_install_release_plans( + llama_tag, + host, + published_repo, + published_release_tag, + ) + if release_plans and existing_install_matches_plan( + install_dir, host, release_plans[0] + ): + current = release_plans[0] + log( + "existing llama.cpp install already matches selected release " + f"{current.release_tag} upstream_tag={current.llama_tag}; skipping download and install" ) - ) - choice = attempts[0] - log( - f"selected {choice.name} ({choice.source_label}) for {host.system} {host.machine}" - ) + return with tempfile.TemporaryDirectory(prefix = "unsloth-llama-prebuilt-") as tmp: work_dir = Path(tmp) probe_path = work_dir / "stories260K.gguf" download_validation_model( probe_path, validation_model_cache_path(install_dir) ) - choice, selected_staging_dir, _ = validate_prebuilt_attempts( - attempts, - host, - install_dir, - work_dir, - probe_path, - requested_tag = requested_tag, - llama_tag = llama_tag, - approved_checksums = approved_checksums, - ) - activate_install_tree(selected_staging_dir, install_dir, host) - try: - ensure_converter_scripts(install_dir, llama_tag) - except Exception as exc: + release_count = len(release_plans) + for release_index, plan in enumerate(release_plans): + choice = plan.attempts[0] + if existing_install_matches_plan(install_dir, host, plan): + log( + "existing llama.cpp install already matches fallback release " + f"{plan.release_tag} upstream_tag={plan.llama_tag}; skipping reinstall" + ) + return log( - "converter script fetch failed after activation; install remains valid " - f"({textwrap.shorten(str(exc), width = 200, placeholder = '...')})" + "selected " + f"{choice.name} ({choice.source_label}) from published release " + f"{plan.release_tag} for {host.system} {host.machine}" ) + try: + choice, selected_staging_dir, _ = validate_prebuilt_attempts( + plan.attempts, + host, + install_dir, + work_dir, + probe_path, + requested_tag = requested_tag, + llama_tag = plan.llama_tag, + release_tag = plan.release_tag, + approved_checksums = plan.approved_checksums, + initial_fallback_used = release_index > 0, + existing_install_dir = install_dir, + ) + except ExistingInstallSatisfied: + return + except PrebuiltFallback as exc: + if release_index == release_count - 1: + raise + log( + "published release " + f"{plan.release_tag} upstream_tag={plan.llama_tag} failed; " + "trying the next older published prebuilt " + f"({textwrap.shorten(str(exc), width = 200, placeholder = '...')})" + ) + continue + + activate_install_tree(selected_staging_dir, install_dir, host) + try: + ensure_converter_scripts(install_dir, plan.llama_tag) + except Exception as exc: + log( + "converter script fetch failed after activation; install remains valid " + f"({textwrap.shorten(str(exc), width = 200, placeholder = '...')})" + ) + return + except BusyInstallConflict as exc: + log("prebuilt install path is blocked by an in-use llama.cpp install") + log(f"prebuilt busy reason: {exc}") + raise SystemExit(EXIT_BUSY) from exc except PrebuiltFallback as exc: log("prebuilt install path failed; falling back to source build") log(f"prebuilt fallback reason: {exc}") @@ -3359,7 +4048,10 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--llama-tag", default = DEFAULT_LLAMA_TAG, - help = f"llama.cpp release tag. Prebuilt installs are pinned to the approved tag {APPROVED_PREBUILT_LLAMA_TAG}.", + help = ( + "llama.cpp release tag. Defaults to the latest usable published Unsloth " + "release unless UNSLOTH_LLAMA_TAG overrides it." + ), ) parser.add_argument( "--published-repo", @@ -3369,7 +4061,10 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--published-release-tag", default = DEFAULT_PUBLISHED_TAG, - help = "Published GitHub release tag to pin. By default, scan releases until a compatible llama.cpp bundle is found.", + help = ( + "Published GitHub release tag to pin. By default, scan releases " + "until a usable published llama.cpp release bundle is found." + ), ) resolve_group = parser.add_mutually_exclusive_group() resolve_group.add_argument( @@ -3382,7 +4077,10 @@ def parse_args() -> argparse.Namespace: "--resolve-install-tag", nargs = "?", const = "latest", - help = "Resolve a llama.cpp tag such as 'latest' to the concrete tag installable on the current host.", + help = ( + "Resolve a llama.cpp tag such as 'latest' to the concrete upstream tag " + "selected by the current published-release policy." + ), ) return parser.parse_args() @@ -3398,7 +4096,9 @@ def main() -> int: if args.resolve_install_tag is not None: print( resolve_requested_install_tag( - args.resolve_install_tag, args.published_release_tag or "" + args.resolve_install_tag, + args.published_release_tag or "", + args.published_repo, ) ) return EXIT_SUCCESS @@ -3421,6 +4121,11 @@ if __name__ == "__main__": raise SystemExit(main()) except SystemExit: raise + except BusyInstallConflict as exc: + log( + f"fatal helper busy conflict: {textwrap.shorten(str(exc), width = 400, placeholder = '...')}" + ) + raise SystemExit(EXIT_BUSY) except Exception as exc: message = textwrap.shorten(str(exc), width = 400, placeholder = "...") log(f"fatal helper error: {message}") diff --git a/studio/setup.ps1 b/studio/setup.ps1 index aa3c11a594..f4ad42d615 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -22,6 +22,14 @@ $ErrorActionPreference = "Stop" $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $PackageDir = Split-Path -Parent $ScriptDir +# -------------------------------------------------------------------------- +# Maintainer-editable defaults +# Change these in the GitHub-hosted script so users get updated defaults. +# User env vars always override these baked-in values. +# -------------------------------------------------------------------------- +$DefaultLlamaPrForce = "" +$DefaultLlamaSource = "https://github.com/ggml-org/llama.cpp" + # Verbose can be enabled either by CLI flag or by UNSLOTH_VERBOSE=1. $script:UnslothVerbose = ($env:UNSLOTH_VERBOSE -eq '1') foreach ($a in $args) { @@ -1591,41 +1599,76 @@ $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" } -$resolveOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" --resolve-install-tag $RequestedLlamaTag --published-repo $HelperReleaseRepo 2>&1 -$resolveExit = $LASTEXITCODE -$ResolvedLlamaTag = if ($resolveOutput) { ($resolveOutput | Select-Object -Last 1).ToString().Trim() } else { "" } -if ($resolveExit -ne 0 -or [string]::IsNullOrWhiteSpace($ResolvedLlamaTag)) { - Write-Host "" - substep "Failed to resolve an installable prebuilt llama.cpp tag via $HelperReleaseRepo" "Yellow" - Write-LlamaFailureLog -Output ($resolveOutput | Out-String) - # Resolve the llama.cpp tag for source-build fallback. Pass --published-repo - # so the resolver prefers Unsloth's tested tag (e.g. b8508) over the upstream - # bleeding-edge tag (e.g. b8514) from ggml-org/llama.cpp. - $fallbackOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" --resolve-llama-tag $RequestedLlamaTag --published-repo $HelperReleaseRepo 2>$null - $fallbackExit = $LASTEXITCODE - $ResolvedLlamaTag = if ($fallbackExit -eq 0 -and $fallbackOutput) { - ($fallbackOutput | Select-Object -Last 1).ToString().Trim() - } elseif ($RequestedLlamaTag -eq "latest") { - # Try Unsloth release repo first, then fall back to ggml-org upstream - $resolvedLatest = $null - try { - $latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/$HelperReleaseRepo/releases/latest" -ErrorAction Stop - $resolvedLatest = $latestRelease.tag_name - } catch {} - if (-not $resolvedLatest) { - try { - $latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest" -ErrorAction Stop - $resolvedLatest = $latestRelease.tag_name - } catch {} - } - if ($resolvedLatest) { $resolvedLatest } else { $RequestedLlamaTag } - } else { - $RequestedLlamaTag - } +$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) } + +if ($LlamaSource -ne "https://github.com/ggml-org/llama.cpp") { + step "llama.cpp" "custom source: $LlamaSource -- forcing source build" "Yellow" $NeedLlamaSourceBuild = $true $SkipPrebuiltInstall = $true } +if (-not $LlamaPr -and $LlamaPrForce -and $LlamaPrForce -match '^\d+$' -and [int]$LlamaPrForce -gt 0) { + $LlamaPr = $LlamaPrForce + step "llama.cpp" "baked-in PR_FORCE=$LlamaPrForce" "Yellow" +} + +if ($LlamaPr) { + if ($LlamaPr -notmatch '^\d+$' -or [int]$LlamaPr -le 0) { + Write-Host "[ERROR] UNSLOTH_LLAMA_PR=$LlamaPr is not a valid PR number" -ForegroundColor Red + exit 1 + } + step "llama.cpp" "UNSLOTH_LLAMA_PR=$LlamaPr -- will build from PR head" "Yellow" + $ResolvedLlamaTag = "pr-$LlamaPr" + $NeedLlamaSourceBuild = $true + $SkipPrebuiltInstall = $true +} elseif ($SkipPrebuiltInstall) { + # 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") { + $resolveTagArgs = @("--resolve-llama-tag", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo) + 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() + } else { + $RequestedLlamaTag + } + } else { + $ResolvedLlamaTag = $RequestedLlamaTag + } +} else { + $resolveInstallArgs = @("--resolve-install-tag", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo) + 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 + $resolveExit = $LASTEXITCODE + $ResolvedLlamaTag = if ($resolveOutput) { ($resolveOutput | Select-Object -Last 1).ToString().Trim() } 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) + # 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) + 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() + } else { + $RequestedLlamaTag + } + $NeedLlamaSourceBuild = $true + $SkipPrebuiltInstall = $true + } +} + Write-Host "" substep "Resolved llama.cpp release tag: $ResolvedLlamaTag" @@ -1645,7 +1688,7 @@ if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") { $prebuiltArgs = @( "$PSScriptRoot\install_llama_prebuilt.py", "--install-dir", $LlamaCppDir, - "--llama-tag", $ResolvedLlamaTag, + "--llama-tag", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo ) if ($env:UNSLOTH_LLAMA_RELEASE_TAG) { @@ -1667,7 +1710,19 @@ if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") { $ErrorActionPreference = $prevEAPPrebuilt if ($prebuiltExit -eq 0) { - step "llama.cpp" "prebuilt installed and validated" + if ($prebuiltOutput -match "already matches") { + step "llama.cpp" "prebuilt up to date and validated" + } else { + step "llama.cpp" "prebuilt installed and validated" + } + } elseif ($prebuiltExit -eq 3) { + step "llama.cpp" "install blocked by active llama.cpp process" "Yellow" + Write-LlamaFailureLog -Output $prebuiltOutput + if (Test-Path $LlamaCppDir) { + substep "Existing install was restored" "Yellow" + } + substep "Close Studio or other llama.cpp users and retry" "Yellow" + exit 3 } else { step "llama.cpp" "prebuilt install failed (continuing)" "Yellow" Write-LlamaFailureLog -Output $prebuiltOutput @@ -1826,36 +1881,88 @@ if (-not $NeedLlamaSourceBuild) { if (Test-Path (Join-Path $LlamaCppDir ".git")) { Write-Host " Syncing llama.cpp to $ResolvedLlamaTag..." -ForegroundColor Gray - if ($UseConcreteRef) { + # 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 + if ($LlamaPr) { + $gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin "pull/$LlamaPr/head" } + if ($gitFetchExit -ne 0) { + $BuildOk = $false + $FailedStep = "git fetch PR #$LlamaPr" + } else { + $gitCheckoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir checkout -B "pr-$LlamaPr" FETCH_HEAD } + if ($gitCheckoutExit -ne 0) { + $BuildOk = $false + $FailedStep = "git checkout PR #$LlamaPr" + } 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 } + 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 + } + } } else { $gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin } - } - 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" + if ($gitFetchExit -ne 0) { + substep "git fetch failed -- using existing source" "Yellow" } else { - Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir clean -fdx } | Out-Null + $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 + } } } } else { Write-Host " Cloning llama.cpp @ $ResolvedLlamaTag..." -ForegroundColor Gray $buildTmp = "$LlamaCppDir.build.$PID" if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp } - $cloneArgs = @("clone", "--depth", "1") - if ($UseConcreteRef) { - $cloneArgs += @("--branch", $ResolvedLlamaTag) - } - $cloneArgs += @("https://github.com/ggml-org/llama.cpp.git", $buildTmp) - $cloneExit = Invoke-SetupCommand -AlwaysQuiet { git @cloneArgs } - if ($cloneExit -ne 0) { - $BuildOk = $false - $FailedStep = "git clone" - if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp } + if ($LlamaPr) { + $cloneExit = Invoke-SetupCommand -AlwaysQuiet { git clone --depth 1 "$LlamaSource.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 "pull/$LlamaPr/head:pr-$LlamaPr" } + if ($fetchExit -ne 0) { + $BuildOk = $false + $FailedStep = "git fetch PR #$LlamaPr" + if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp } + } + } + if ($BuildOk) { + $checkoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp checkout "pr-$LlamaPr" } + if ($checkoutExit -ne 0) { + $BuildOk = $false + $FailedStep = "git checkout PR #$LlamaPr" + if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp } + } + } + } else { + $cloneArgs = @("clone", "--depth", "1") + if ($UseConcreteRef) { + $cloneArgs += @("--branch", $ResolvedLlamaTag) + } + $cloneArgs += @("$LlamaSource.git", $buildTmp) + $cloneExit = Invoke-SetupCommand -AlwaysQuiet { git @cloneArgs } + if ($cloneExit -ne 0) { + $BuildOk = $false + $FailedStep = "git clone" + if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp } + } } # Use temp dir for build; swap into $LlamaCppDir only after build succeeds if ($BuildOk) { diff --git a/studio/setup.sh b/studio/setup.sh index 3715f536f6..4a3e23b1a4 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -8,6 +8,16 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" RULE=$(printf '\342\224\200%.0s' {1..52}) +# ── Maintainer-editable defaults ────────────────────────────────────────── +# Change these in the GitHub-hosted script so all users get updated defaults. +# User environment variables always override these baked-in values. +# +# _DEFAULT_LLAMA_PR_FORCE : PR number to build by default ("" = normal path) +# _DEFAULT_LLAMA_SOURCE : git clone URL for source builds +# ────────────────────────────────────────────────────────────────────────── +_DEFAULT_LLAMA_PR_FORCE="" +_DEFAULT_LLAMA_SOURCE="https://github.com/ggml-org/llama.cpp" + # ── Colors (same palette as startup_banner / install_python_stack) ── if [ -n "${NO_COLOR:-}" ]; then C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST= @@ -108,6 +118,10 @@ echo "" printf " ${C_TITLE}%s${C_RST}\n" "🦥 Unsloth Studio Setup" printf " ${C_DIM}%s${C_RST}\n" "$RULE" verbose_substep "verbose diagnostics enabled" +_LLAMA_ONLY="${UNSLOTH_STUDIO_LLAMA_ONLY:-0}" +if [ "$_LLAMA_ONLY" = "1" ]; then + substep "llama.cpp only mode" +fi # ── Clean up stale caches ── rm -rf "$REPO_ROOT/unsloth_compiled_cache" rm -rf "$SCRIPT_DIR/backend/unsloth_compiled_cache" @@ -120,6 +134,7 @@ if [[ "$keynames" == *$'\nCOLAB_'* ]]; then IS_COLAB=true fi +if [ "$_LLAMA_ONLY" != "1" ]; then # ── Frontend ── _NEED_FRONTEND_BUILD=true if [ -d "$SCRIPT_DIR/frontend/dist" ]; then @@ -453,6 +468,7 @@ else step "python" "dependencies up to date" verbose_substep "python deps check: installed=$_PKG_NAME@${INSTALLED_VER:-unknown} latest=${LATEST_VER:-unknown}" fi +fi # ── 7. Prefer prebuilt llama.cpp bundles before any source build path ── UNSLOTH_HOME="$HOME/.unsloth" @@ -464,44 +480,93 @@ _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}" -_RESOLVE_LLAMA_LOG="$(mktemp)" -set +e -python "$SCRIPT_DIR/install_llama_prebuilt.py" \ - --resolve-install-tag "$_REQUESTED_LLAMA_TAG" \ - --published-repo "$_HELPER_RELEASE_REPO" >"$_RESOLVE_LLAMA_LOG" 2>&1 -_RESOLVE_LLAMA_STATUS=$? -set -e -if [ "$_RESOLVE_LLAMA_STATUS" -eq 0 ]; then - _RESOLVED_LLAMA_TAG="$(tail -n 1 "$_RESOLVE_LLAMA_LOG" | tr -d '\r')" -else - _RESOLVED_LLAMA_TAG="" -fi -if [ -z "$_RESOLVED_LLAMA_TAG" ]; then - step "llama.cpp" "failed to resolve prebuilt tag via $_HELPER_RELEASE_REPO" "$C_WARN" - print_llama_error_log "$_RESOLVE_LLAMA_LOG" - set +e - # Resolve the llama.cpp tag for source-build fallback. Pass --published-repo - # so the resolver prefers Unsloth's tested tag (e.g. b8508) over the upstream - # bleeding-edge tag (e.g. b8514) from ggml-org/llama.cpp. - _RESOLVED_LLAMA_TAG="$(python "$SCRIPT_DIR/install_llama_prebuilt.py" --resolve-llama-tag "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO" 2>/dev/null)" - _RESOLVE_UPSTREAM_STATUS=$? - set -e - if [ "$_RESOLVE_UPSTREAM_STATUS" -ne 0 ] || [ -z "$_RESOLVED_LLAMA_TAG" ]; then - if [ "$_REQUESTED_LLAMA_TAG" = "latest" ]; then - # Try Unsloth release repo first, then fall back to ggml-org upstream - _RESOLVED_LLAMA_TAG="$(curl -fsSL "https://api.github.com/repos/${_HELPER_RELEASE_REPO}/releases/latest" 2>/dev/null | python -c "import sys,json; print(json.load(sys.stdin)['tag_name'])" 2>/dev/null)" || _RESOLVED_LLAMA_TAG="" - if [ -z "$_RESOLVED_LLAMA_TAG" ]; then - _RESOLVED_LLAMA_TAG="$(curl -fsSL https://api.github.com/repos/ggml-org/llama.cpp/releases/latest 2>/dev/null | python -c "import sys,json; print(json.load(sys.stdin)['tag_name'])" 2>/dev/null)" || _RESOLVED_LLAMA_TAG="" - fi - fi - if [ -z "$_RESOLVED_LLAMA_TAG" ]; then - _RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG" - fi - fi +_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 + +# 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" _NEED_LLAMA_SOURCE_BUILD=true _SKIP_PREBUILT_INSTALL=true fi -rm -f "$_RESOLVE_LLAMA_LOG" + +# Baked-in PR_FORCE promotes to _LLAMA_PR when user hasn't set one. +if [ -z "$_LLAMA_PR" ] && [ -n "$_LLAMA_PR_FORCE" ] && \ + [[ "$_LLAMA_PR_FORCE" =~ ^[0-9]+$ ]] && [ "$_LLAMA_PR_FORCE" -gt 0 ]; then + _LLAMA_PR="$_LLAMA_PR_FORCE" + step "llama.cpp" "baked-in PR_FORCE=$_LLAMA_PR_FORCE" "$C_WARN" +fi + +if [ -n "$_LLAMA_PR" ]; then + if ! [[ "$_LLAMA_PR" =~ ^[0-9]+$ ]] || [ "$_LLAMA_PR" -le 0 ]; then + step "llama.cpp" "UNSLOTH_LLAMA_PR=$_LLAMA_PR is not a valid PR number" "$C_ERR" + exit 1 + fi + step "llama.cpp" "UNSLOTH_LLAMA_PR=$_LLAMA_PR -- will build from PR head" "$C_WARN" + _RESOLVED_LLAMA_TAG="pr-$_LLAMA_PR" + _NEED_LLAMA_SOURCE_BUILD=true + _SKIP_PREBUILT_INSTALL=true +elif [ "${_SKIP_PREBUILT_INSTALL:-false}" = true ]; then + # Custom source or other override already forced source build; skip + # 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 + _RESOLVE_TAG_ARGS=(--resolve-llama-tag "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO") + 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_UPSTREAM_STATUS=$? + set -e + if [ "$_RESOLVE_UPSTREAM_STATUS" -ne 0 ] || [ -z "$_RESOLVED_LLAMA_TAG" ]; then + _RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG" + fi + else + _RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG" + fi +else + _RESOLVE_INSTALL_ARGS=(--resolve-install-tag "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO") + 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_LLAMA_STATUS=$? + set -e + if [ "$_RESOLVE_LLAMA_STATUS" -eq 0 ]; then + _RESOLVED_LLAMA_TAG="$(tail -n 1 "$_RESOLVE_LLAMA_LOG" | tr -d '\r')" + else + _RESOLVED_LLAMA_TAG="" + fi + if [ -z "$_RESOLVED_LLAMA_TAG" ]; then + step "llama.cpp" "failed to resolve a published llama.cpp release via $_HELPER_RELEASE_REPO" "$C_WARN" + print_llama_error_log "$_RESOLVE_LLAMA_LOG" + set +e + # 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. + _RESOLVE_FALLBACK_ARGS=(--resolve-llama-tag "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO") + 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_UPSTREAM_STATUS=$? + set -e + if [ "$_RESOLVE_UPSTREAM_STATUS" -ne 0 ] || [ -z "$_RESOLVED_LLAMA_TAG" ]; then + _RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG" + fi + _NEED_LLAMA_SOURCE_BUILD=true + _SKIP_PREBUILT_INSTALL=true + fi + rm -f "$_RESOLVE_LLAMA_LOG" +fi substep "resolved llama.cpp tag: $_RESOLVED_LLAMA_TAG" verbose_substep "requested llama.cpp tag: $_REQUESTED_LLAMA_TAG (repo: $_HELPER_RELEASE_REPO)" @@ -520,7 +585,7 @@ else _PREBUILT_CMD=( python "$SCRIPT_DIR/install_llama_prebuilt.py" --install-dir "$LLAMA_CPP_DIR" - --llama-tag "$_RESOLVED_LLAMA_TAG" + --llama-tag "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO" ) if [ -n "${UNSLOTH_LLAMA_RELEASE_TAG:-}" ]; then @@ -538,9 +603,22 @@ else set -e if [ "$_PREBUILT_STATUS" -eq 0 ]; then - step "llama.cpp" "prebuilt installed and validated" + if grep -Fq "already matches" "$_PREBUILT_LOG"; then + step "llama.cpp" "prebuilt up to date and validated" + else + step "llama.cpp" "prebuilt installed and validated" + fi verbose_substep "llama.cpp install dir: $LLAMA_CPP_DIR" rm -f "$_PREBUILT_LOG" + elif [ "$_PREBUILT_STATUS" -eq 3 ]; then + step "llama.cpp" "install blocked by active llama.cpp process" "$C_WARN" + print_llama_error_log "$_PREBUILT_LOG" + rm -f "$_PREBUILT_LOG" + if [ -d "$LLAMA_CPP_DIR" ]; then + substep "existing install was restored" + fi + substep "close Studio or other llama.cpp users and retry" + exit 3 else step "llama.cpp" "prebuilt install failed (continuing)" "$C_WARN" print_llama_error_log "$_PREBUILT_LOG" @@ -624,13 +702,27 @@ else [ -f "$LLAMA_SERVER_BIN" ] || _LLAMA_CPP_DEGRADED=true else BUILD_OK=true - _CLONE_BRANCH_ARGS=() - if [ "$_RESOLVED_LLAMA_TAG" != "latest" ] && [ -n "$_RESOLVED_LLAMA_TAG" ]; then - _CLONE_BRANCH_ARGS=(--branch "$_RESOLVED_LLAMA_TAG") - fi _BUILD_TMP="${LLAMA_CPP_DIR}.build.$$" rm -rf "$_BUILD_TMP" - run_quiet_no_exit "clone llama.cpp" git clone --depth 1 "${_CLONE_BRANCH_ARGS[@]}" https://github.com/ggml-org/llama.cpp.git "$_BUILD_TMP" || BUILD_OK=false + if [ -n "$_LLAMA_PR" ]; then + run_quiet_no_exit "clone llama.cpp" \ + git clone --depth 1 "${_LLAMA_SOURCE}.git" "$_BUILD_TMP" || BUILD_OK=false + if [ "$BUILD_OK" = true ]; then + run_quiet_no_exit "fetch PR #$_LLAMA_PR" \ + git -C "$_BUILD_TMP" fetch --depth 1 origin "pull/$_LLAMA_PR/head:pr-$_LLAMA_PR" || BUILD_OK=false + fi + if [ "$BUILD_OK" = true ]; then + run_quiet_no_exit "checkout PR #$_LLAMA_PR" \ + git -C "$_BUILD_TMP" checkout "pr-$_LLAMA_PR" || BUILD_OK=false + fi + else + _CLONE_BRANCH_ARGS=() + if [ "$_RESOLVED_LLAMA_TAG" != "latest" ] && [ -n "$_RESOLVED_LLAMA_TAG" ]; then + _CLONE_BRANCH_ARGS=(--branch "$_RESOLVED_LLAMA_TAG") + fi + run_quiet_no_exit "clone llama.cpp" \ + git clone --depth 1 "${_CLONE_BRANCH_ARGS[@]}" "${_LLAMA_SOURCE}.git" "$_BUILD_TMP" || BUILD_OK=false + fi if [ "$BUILD_OK" = true ]; then CMAKE_ARGS="-DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_EXAMPLES=OFF -DLLAMA_BUILD_SERVER=ON -DGGML_NATIVE=ON" @@ -794,7 +886,16 @@ else fi # end _SKIP_GGUF_BUILD check # ── Footer ── -if [ "$IS_COLAB" = true ]; then +if [ "$_LLAMA_ONLY" = "1" ]; then + echo "" + printf " ${C_DIM}%s${C_RST}\n" "$RULE" + if [ "$_LLAMA_CPP_DEGRADED" = true ]; then + printf " ${C_WARN}%s${C_RST}\n" "llama.cpp update finished (limited: llama.cpp unavailable)" + else + printf " ${C_TITLE}%s${C_RST}\n" "llama.cpp update finished" + fi + printf " ${C_DIM}%s${C_RST}\n" "$RULE" +elif [ "$IS_COLAB" = true ]; then echo "" printf " ${C_DIM}%s${C_RST}\n" "$RULE" if [ "$_LLAMA_CPP_DEGRADED" = true ]; then From f84c2d03d3dd01bedbb3b84a0ef210498f318f25 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 1 Apr 2026 06:06:29 -0700 Subject: [PATCH 13/44] Add installer test coverage for prebuilt llama.cpp changes (#4756) Split out from #4741 to keep the main PR focused on installer logic. - New test_install_llama_prebuilt_logic.py: tests for resolve logic, fallback behavior, env_int, busy/lock handling - New test_validate_llama_prebuilt.py: validator tests for staged release_tag/upstream_tag handling - New test_llama_pr_force_and_source.py: tests for PR_FORCE and LLAMA_SOURCE maintainer defaults - Updated test_selection_logic.py: expanded selection/fallback coverage - Updated test_pr4562_bugfixes.py: updated bugfix tests for new logic - Updated smoke_test_llama_prebuilt.py: minor update --- .../install/smoke_test_llama_prebuilt.py | 2 +- .../test_install_llama_prebuilt_logic.py | 1236 +++++++++++++++++ .../install/test_llama_pr_force_and_source.py | 599 ++++++++ tests/studio/install/test_pr4562_bugfixes.py | 208 ++- tests/studio/install/test_selection_logic.py | 683 ++++++++- .../install/test_validate_llama_prebuilt.py | 175 +++ 6 files changed, 2781 insertions(+), 122 deletions(-) create mode 100644 tests/studio/install/test_llama_pr_force_and_source.py create mode 100644 tests/studio/install/test_validate_llama_prebuilt.py diff --git a/tests/studio/install/smoke_test_llama_prebuilt.py b/tests/studio/install/smoke_test_llama_prebuilt.py index 994757d2e2..d87537dc94 100644 --- a/tests/studio/install/smoke_test_llama_prebuilt.py +++ b/tests/studio/install/smoke_test_llama_prebuilt.py @@ -39,7 +39,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--llama-tag", default = "latest", - help = "llama.cpp tag to resolve. Defaults to the approved prebuilt tag for this host.", + help = "llama.cpp tag to resolve. Defaults to the latest usable published Unsloth release.", ) parser.add_argument( "--published-repo", diff --git a/tests/studio/install/test_install_llama_prebuilt_logic.py b/tests/studio/install/test_install_llama_prebuilt_logic.py index eb30ac2745..622f01f946 100644 --- a/tests/studio/install/test_install_llama_prebuilt_logic.py +++ b/tests/studio/install/test_install_llama_prebuilt_logic.py @@ -33,6 +33,10 @@ activate_install_tree = INSTALL_LLAMA_PREBUILT.activate_install_tree create_install_staging_dir = INSTALL_LLAMA_PREBUILT.create_install_staging_dir sha256_file = INSTALL_LLAMA_PREBUILT.sha256_file source_archive_logical_name = INSTALL_LLAMA_PREBUILT.source_archive_logical_name +install_prebuilt = INSTALL_LLAMA_PREBUILT.install_prebuilt +write_prebuilt_metadata = INSTALL_LLAMA_PREBUILT.write_prebuilt_metadata +existing_install_matches_plan = INSTALL_LLAMA_PREBUILT.existing_install_matches_plan +existing_install_matches_choice = INSTALL_LLAMA_PREBUILT.existing_install_matches_choice def approved_checksums_for( @@ -318,6 +322,7 @@ def test_validate_prebuilt_choice_creates_repo_shaped_linux_install( probe_path, requested_tag = upstream_tag, llama_tag = upstream_tag, + release_tag = upstream_tag, approved_checksums = approved_checksums_for( upstream_tag, source_archive = source_archive, @@ -436,6 +441,7 @@ def test_validate_prebuilt_choice_creates_repo_shaped_windows_install( probe_path, requested_tag = upstream_tag, llama_tag = upstream_tag, + release_tag = upstream_tag, approved_checksums = approved_checksums_for( upstream_tag, source_archive = source_archive, @@ -610,6 +616,1236 @@ def test_binary_env_linux_includes_binary_parent_in_ld_library_path( assert str(install_dir) in ld_dirs +def test_install_prebuilt_falls_back_to_older_release_plan( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + install_dir = tmp_path / "llama.cpp" + host = HostInfo( + system = "Linux", + machine = "x86_64", + is_windows = False, + is_linux = True, + is_macos = False, + is_x86_64 = True, + is_arm64 = False, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + + first_choice = AssetChoice( + repo = "unslothai/llama.cpp", + tag = "old-release", + name = "app-b9002-linux-x64.tar.gz", + url = "https://example.com/app-b9002-linux-x64.tar.gz", + source_label = "published", + install_kind = "linux-cpu", + ) + second_choice = AssetChoice( + repo = "unslothai/llama.cpp", + tag = "older-release", + name = "app-b9001-linux-x64.tar.gz", + url = "https://example.com/app-b9001-linux-x64.tar.gz", + source_label = "published", + install_kind = "linux-cpu", + ) + first_plan = INSTALL_LLAMA_PREBUILT.InstallReleasePlan( + requested_tag = "latest", + llama_tag = "b9002", + release_tag = "release-2", + attempts = [first_choice], + approved_checksums = ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "release-2", + upstream_tag = "b9002", + source_commit = None, + artifacts = {}, + ), + ) + second_plan = INSTALL_LLAMA_PREBUILT.InstallReleasePlan( + requested_tag = "latest", + llama_tag = "b9001", + release_tag = "release-1", + attempts = [second_choice], + approved_checksums = ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "release-1", + upstream_tag = "b9001", + source_commit = None, + artifacts = {}, + ), + ) + + monkeypatch.setattr(INSTALL_LLAMA_PREBUILT, "detect_host", lambda: host) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "resolve_install_release_plans", + lambda llama_tag, host, published_repo, published_release_tag: ( + "latest", + [first_plan, second_plan], + ), + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "download_validation_model", + lambda probe_path, cache_path: probe_path.write_bytes(b"probe"), + ) + + call_log: list[tuple[str, bool]] = [] + + def fake_validate( + attempts, + host, + install_dir, + work_dir, + probe_path, + *, + requested_tag, + llama_tag, + release_tag, + approved_checksums, + initial_fallback_used = False, + existing_install_dir = None, + ): + call_log.append((llama_tag, initial_fallback_used)) + if llama_tag == "b9002": + raise PrebuiltFallback("validation failed for latest release") + staging_dir = create_install_staging_dir(install_dir) + (staging_dir / "marker.txt").write_text("ready\n") + return attempts[0], staging_dir, initial_fallback_used + + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "validate_prebuilt_attempts", + fake_validate, + ) + + activated = {} + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "activate_install_tree", + lambda staging_dir, install_dir, host: activated.update( + {"staging_dir": staging_dir, "install_dir": install_dir} + ), + ) + ensured_tags: list[str] = [] + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "ensure_converter_scripts", + lambda install_dir, llama_tag: ensured_tags.append(llama_tag), + ) + + install_prebuilt(install_dir, "latest", "unslothai/llama.cpp", "") + + assert call_log == [("b9002", False), ("b9001", True)] + assert activated["install_dir"] == install_dir + assert ensured_tags == ["b9001"] + + +def write_linux_install_shape(install_dir: Path) -> None: + runtime_dir = install_dir / "build" / "bin" + runtime_dir.mkdir(parents = True, exist_ok = True) + (install_dir / "llama-server").write_text("#!/bin/sh\n", encoding = "utf-8") + (install_dir / "llama-quantize").write_text("#!/bin/sh\n", encoding = "utf-8") + (runtime_dir / "llama-server").write_text("#!/bin/sh\n", encoding = "utf-8") + (runtime_dir / "llama-quantize").write_text("#!/bin/sh\n", encoding = "utf-8") + (runtime_dir / "libllama.so.0").write_bytes(b"DLL") + (runtime_dir / "libggml.so.0").write_bytes(b"DLL") + (runtime_dir / "libggml-base.so.0").write_bytes(b"DLL") + (runtime_dir / "libggml-cpu-x64.so.0").write_bytes(b"DLL") + (runtime_dir / "libmtmd.so.0").write_bytes(b"DLL") + (install_dir / "convert_hf_to_gguf.py").write_text( + "#!/usr/bin/env python3\n", encoding = "utf-8" + ) + (install_dir / "gguf-py" / "gguf").mkdir(parents = True, exist_ok = True) + + +def write_windows_install_shape( + install_dir: Path, *, include_llama_dll: bool = True, include_cuda_dll: bool = False +) -> None: + runtime_dir = install_dir / "build" / "bin" / "Release" + runtime_dir.mkdir(parents = True, exist_ok = True) + (runtime_dir / "llama-server.exe").write_bytes(b"MZ") + (runtime_dir / "llama-quantize.exe").write_bytes(b"MZ") + if include_llama_dll: + (runtime_dir / "llama.dll").write_bytes(b"DLL") + if include_cuda_dll: + (runtime_dir / "ggml-cuda.dll").write_bytes(b"DLL") + (install_dir / "convert_hf_to_gguf.py").write_text( + "#!/usr/bin/env python3\n", encoding = "utf-8" + ) + (install_dir / "gguf-py" / "gguf").mkdir(parents = True, exist_ok = True) + + +def write_macos_install_shape( + install_dir: Path, + *, + include_libllama: bool = True, + include_libggml: bool = True, + include_libmtmd: bool = True, +) -> None: + runtime_dir = install_dir / "build" / "bin" + runtime_dir.mkdir(parents = True, exist_ok = True) + (install_dir / "llama-server").write_text("#!/bin/sh\n", encoding = "utf-8") + (install_dir / "llama-quantize").write_text("#!/bin/sh\n", encoding = "utf-8") + (runtime_dir / "llama-server").write_text("#!/bin/sh\n", encoding = "utf-8") + (runtime_dir / "llama-quantize").write_text("#!/bin/sh\n", encoding = "utf-8") + if include_libllama: + (runtime_dir / "libllama.0.dylib").write_bytes(b"DLL") + if include_libggml: + (runtime_dir / "libggml.0.dylib").write_bytes(b"DLL") + if include_libmtmd: + (runtime_dir / "libmtmd.0.dylib").write_bytes(b"DLL") + (install_dir / "convert_hf_to_gguf.py").write_text( + "#!/usr/bin/env python3\n", encoding = "utf-8" + ) + (install_dir / "gguf-py" / "gguf").mkdir(parents = True, exist_ok = True) + + +def test_existing_install_matches_plan_with_fingerprint_linux(tmp_path: Path): + install_dir = tmp_path / "llama.cpp" + install_dir.mkdir() + write_linux_install_shape(install_dir) + + host = HostInfo( + system = "Linux", + machine = "x86_64", + is_windows = False, + is_linux = True, + is_macos = False, + is_x86_64 = True, + is_arm64 = False, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + choice = AssetChoice( + repo = "unslothai/llama.cpp", + tag = "release-1", + name = "llama-b9001-bin-ubuntu-x64.tar.gz", + url = "https://example.com/llama-b9001-bin-ubuntu-x64.tar.gz", + source_label = "upstream", + install_kind = "linux-cpu", + expected_sha256 = "a" * 64, + ) + checksums = ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "release-1", + upstream_tag = "b9001", + source_commit = "deadbeef", + artifacts = { + source_archive_logical_name("b9001"): ApprovedArtifactHash( + asset_name = source_archive_logical_name("b9001"), + sha256 = "b" * 64, + repo = "ggml-org/llama.cpp", + kind = "upstream-source", + ), + choice.name: ApprovedArtifactHash( + asset_name = choice.name, + sha256 = choice.expected_sha256, + repo = "ggml-org/llama.cpp", + kind = "upstream-prebuilt", + ), + }, + ) + plan = INSTALL_LLAMA_PREBUILT.InstallReleasePlan( + requested_tag = "latest", + llama_tag = "b9001", + release_tag = "release-1", + attempts = [choice], + approved_checksums = checksums, + ) + + write_prebuilt_metadata( + install_dir, + requested_tag = "latest", + llama_tag = "b9001", + release_tag = "release-1", + choice = choice, + approved_checksums = checksums, + prebuilt_fallback_used = False, + ) + + assert existing_install_matches_plan(install_dir, host, plan) is True + + +def test_existing_install_matches_plan_false_without_fingerprint(tmp_path: Path): + install_dir = tmp_path / "llama.cpp" + install_dir.mkdir() + write_linux_install_shape(install_dir) + (install_dir / "UNSLOTH_PREBUILT_INFO.json").write_text( + json.dumps({"tag": "b9001", "asset": "llama-b9001-bin-ubuntu-x64.tar.gz"}) + + "\n", + encoding = "utf-8", + ) + + host = HostInfo( + system = "Linux", + machine = "x86_64", + is_windows = False, + is_linux = True, + is_macos = False, + is_x86_64 = True, + is_arm64 = False, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + choice = AssetChoice( + repo = "unslothai/llama.cpp", + tag = "release-1", + name = "llama-b9001-bin-ubuntu-x64.tar.gz", + url = "https://example.com/x.tar.gz", + source_label = "upstream", + install_kind = "linux-cpu", + expected_sha256 = "a" * 64, + ) + checksums = ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "release-1", + upstream_tag = "b9001", + source_commit = "deadbeef", + artifacts = { + source_archive_logical_name("b9001"): ApprovedArtifactHash( + asset_name = source_archive_logical_name("b9001"), + sha256 = "b" * 64, + repo = "ggml-org/llama.cpp", + kind = "upstream-source", + ), + choice.name: ApprovedArtifactHash( + asset_name = choice.name, + sha256 = choice.expected_sha256, + repo = "ggml-org/llama.cpp", + kind = "upstream-prebuilt", + ), + }, + ) + plan = INSTALL_LLAMA_PREBUILT.InstallReleasePlan( + requested_tag = "latest", + llama_tag = "b9001", + release_tag = "release-1", + attempts = [choice], + approved_checksums = checksums, + ) + + assert existing_install_matches_plan(install_dir, host, plan) is False + + +def test_existing_install_matches_plan_false_with_malformed_metadata(tmp_path: Path): + install_dir = tmp_path / "llama.cpp" + install_dir.mkdir() + write_linux_install_shape(install_dir) + (install_dir / "UNSLOTH_PREBUILT_INFO.json").write_text( + "{not-json\n", encoding = "utf-8" + ) + + host = HostInfo( + system = "Linux", + machine = "x86_64", + is_windows = False, + is_linux = True, + is_macos = False, + is_x86_64 = True, + is_arm64 = False, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + choice = AssetChoice( + repo = "unslothai/llama.cpp", + tag = "release-1", + name = "llama-b9001-bin-ubuntu-x64.tar.gz", + url = "https://example.com/x.tar.gz", + source_label = "upstream", + install_kind = "linux-cpu", + expected_sha256 = "a" * 64, + ) + checksums = ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "release-1", + upstream_tag = "b9001", + source_commit = "deadbeef", + artifacts = { + source_archive_logical_name("b9001"): ApprovedArtifactHash( + asset_name = source_archive_logical_name("b9001"), + sha256 = "b" * 64, + repo = "ggml-org/llama.cpp", + kind = "upstream-source", + ), + choice.name: ApprovedArtifactHash( + asset_name = choice.name, + sha256 = choice.expected_sha256, + repo = "ggml-org/llama.cpp", + kind = "upstream-prebuilt", + ), + }, + ) + plan = INSTALL_LLAMA_PREBUILT.InstallReleasePlan( + requested_tag = "latest", + llama_tag = "b9001", + release_tag = "release-1", + attempts = [choice], + approved_checksums = checksums, + ) + + assert existing_install_matches_plan(install_dir, host, plan) is False + + +def test_existing_install_matches_plan_windows_cpu_requires_llama_dll(tmp_path: Path): + install_dir = tmp_path / "llama.cpp" + install_dir.mkdir() + write_windows_install_shape(install_dir, include_llama_dll = True) + + host = HostInfo( + system = "Windows", + machine = "AMD64", + is_windows = True, + is_linux = False, + is_macos = False, + is_x86_64 = True, + is_arm64 = False, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + choice = AssetChoice( + repo = "unslothai/llama.cpp", + tag = "release-1", + name = "llama-b9001-bin-win-cpu-x64.zip", + url = "https://example.com/x.zip", + source_label = "published", + install_kind = "windows-cpu", + expected_sha256 = "a" * 64, + ) + checksums = ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "release-1", + upstream_tag = "b9001", + source_commit = "deadbeef", + artifacts = { + source_archive_logical_name("b9001"): ApprovedArtifactHash( + asset_name = source_archive_logical_name("b9001"), + sha256 = "b" * 64, + repo = "ggml-org/llama.cpp", + kind = "upstream-source", + ), + choice.name: ApprovedArtifactHash( + asset_name = choice.name, + sha256 = choice.expected_sha256, + repo = "unslothai/llama.cpp", + kind = "prebuilt", + ), + }, + ) + plan = INSTALL_LLAMA_PREBUILT.InstallReleasePlan( + requested_tag = "latest", + llama_tag = "b9001", + release_tag = "release-1", + attempts = [choice], + approved_checksums = checksums, + ) + write_prebuilt_metadata( + install_dir, + requested_tag = "latest", + llama_tag = "b9001", + release_tag = "release-1", + choice = choice, + approved_checksums = checksums, + prebuilt_fallback_used = False, + ) + + assert existing_install_matches_plan(install_dir, host, plan) is True + (install_dir / "build" / "bin" / "Release" / "llama.dll").unlink() + assert existing_install_matches_plan(install_dir, host, plan) is False + + +def test_existing_install_matches_plan_windows_cuda_requires_cuda_dll(tmp_path: Path): + install_dir = tmp_path / "llama.cpp" + install_dir.mkdir() + write_windows_install_shape( + install_dir, include_llama_dll = True, include_cuda_dll = True + ) + + host = HostInfo( + system = "Windows", + machine = "AMD64", + is_windows = True, + is_linux = False, + is_macos = False, + is_x86_64 = True, + is_arm64 = False, + nvidia_smi = None, + driver_cuda_version = (12, 4), + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = True, + ) + choice = AssetChoice( + repo = "unslothai/llama.cpp", + tag = "release-1", + name = "llama-b9001-bin-win-cuda-12.4-x64.zip", + url = "https://example.com/x.zip", + source_label = "published", + install_kind = "windows-cuda", + runtime_line = "cuda12", + expected_sha256 = "a" * 64, + ) + checksums = ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "release-1", + upstream_tag = "b9001", + source_commit = "deadbeef", + artifacts = { + source_archive_logical_name("b9001"): ApprovedArtifactHash( + asset_name = source_archive_logical_name("b9001"), + sha256 = "b" * 64, + repo = "ggml-org/llama.cpp", + kind = "upstream-source", + ), + choice.name: ApprovedArtifactHash( + asset_name = choice.name, + sha256 = choice.expected_sha256, + repo = "unslothai/llama.cpp", + kind = "prebuilt", + ), + }, + ) + plan = INSTALL_LLAMA_PREBUILT.InstallReleasePlan( + requested_tag = "latest", + llama_tag = "b9001", + release_tag = "release-1", + attempts = [choice], + approved_checksums = checksums, + ) + write_prebuilt_metadata( + install_dir, + requested_tag = "latest", + llama_tag = "b9001", + release_tag = "release-1", + choice = choice, + approved_checksums = checksums, + prebuilt_fallback_used = False, + ) + + assert existing_install_matches_plan(install_dir, host, plan) is True + (install_dir / "build" / "bin" / "Release" / "ggml-cuda.dll").unlink() + assert existing_install_matches_plan(install_dir, host, plan) is False + + +def test_existing_install_matches_plan_macos_requires_dylibs(tmp_path: Path): + install_dir = tmp_path / "llama.cpp" + install_dir.mkdir() + write_macos_install_shape(install_dir) + + host = HostInfo( + system = "Darwin", + machine = "arm64", + is_windows = False, + is_linux = False, + is_macos = True, + is_x86_64 = False, + is_arm64 = True, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + choice = AssetChoice( + repo = "unslothai/llama.cpp", + tag = "release-1", + name = "llama-b9001-bin-macos-arm64.tar.gz", + url = "https://example.com/x.tar.gz", + source_label = "published", + install_kind = "macos-arm64", + expected_sha256 = "a" * 64, + ) + checksums = ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "release-1", + upstream_tag = "b9001", + source_commit = "deadbeef", + artifacts = { + source_archive_logical_name("b9001"): ApprovedArtifactHash( + asset_name = source_archive_logical_name("b9001"), + sha256 = "b" * 64, + repo = "ggml-org/llama.cpp", + kind = "upstream-source", + ), + choice.name: ApprovedArtifactHash( + asset_name = choice.name, + sha256 = choice.expected_sha256, + repo = "unslothai/llama.cpp", + kind = "prebuilt", + ), + }, + ) + plan = INSTALL_LLAMA_PREBUILT.InstallReleasePlan( + requested_tag = "latest", + llama_tag = "b9001", + release_tag = "release-1", + attempts = [choice], + approved_checksums = checksums, + ) + write_prebuilt_metadata( + install_dir, + requested_tag = "latest", + llama_tag = "b9001", + release_tag = "release-1", + choice = choice, + approved_checksums = checksums, + prebuilt_fallback_used = False, + ) + + assert existing_install_matches_plan(install_dir, host, plan) is True + (install_dir / "build" / "bin" / "libggml.0.dylib").unlink() + assert existing_install_matches_plan(install_dir, host, plan) is False + + +def test_install_prebuilt_skips_download_when_existing_install_matches( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + install_dir = tmp_path / "llama.cpp" + install_dir.mkdir() + write_linux_install_shape(install_dir) + + host = HostInfo( + system = "Linux", + machine = "x86_64", + is_windows = False, + is_linux = True, + is_macos = False, + is_x86_64 = True, + is_arm64 = False, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + choice = AssetChoice( + repo = "unslothai/llama.cpp", + tag = "release-1", + name = "llama-b9001-bin-ubuntu-x64.tar.gz", + url = "https://example.com/llama-b9001-bin-ubuntu-x64.tar.gz", + source_label = "upstream", + install_kind = "linux-cpu", + expected_sha256 = "a" * 64, + ) + checksums = ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "release-1", + upstream_tag = "b9001", + source_commit = "deadbeef", + artifacts = { + source_archive_logical_name("b9001"): ApprovedArtifactHash( + asset_name = source_archive_logical_name("b9001"), + sha256 = "b" * 64, + repo = "ggml-org/llama.cpp", + kind = "upstream-source", + ), + choice.name: ApprovedArtifactHash( + asset_name = choice.name, + sha256 = choice.expected_sha256, + repo = "ggml-org/llama.cpp", + kind = "upstream-prebuilt", + ), + }, + ) + plan = INSTALL_LLAMA_PREBUILT.InstallReleasePlan( + requested_tag = "latest", + llama_tag = "b9001", + release_tag = "release-1", + attempts = [choice], + approved_checksums = checksums, + ) + + write_prebuilt_metadata( + install_dir, + requested_tag = "latest", + llama_tag = "b9001", + release_tag = "release-1", + choice = choice, + approved_checksums = checksums, + prebuilt_fallback_used = False, + ) + + monkeypatch.setattr(INSTALL_LLAMA_PREBUILT, "detect_host", lambda: host) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "resolve_install_release_plans", + lambda llama_tag, host, published_repo, published_release_tag: ( + "latest", + [plan], + ), + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "download_validation_model", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError( + "matching install should skip before validation model download" + ) + ), + ) + + install_prebuilt(install_dir, "latest", "unslothai/llama.cpp", "") + + +def test_install_prebuilt_does_not_skip_unhealthy_existing_install( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + install_dir = tmp_path / "llama.cpp" + install_dir.mkdir() + write_linux_install_shape(install_dir) + (install_dir / "llama-quantize").unlink() + + host = HostInfo( + system = "Linux", + machine = "x86_64", + is_windows = False, + is_linux = True, + is_macos = False, + is_x86_64 = True, + is_arm64 = False, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + choice = AssetChoice( + repo = "unslothai/llama.cpp", + tag = "release-1", + name = "llama-b9001-bin-ubuntu-x64.tar.gz", + url = "https://example.com/llama-b9001-bin-ubuntu-x64.tar.gz", + source_label = "upstream", + install_kind = "linux-cpu", + expected_sha256 = "a" * 64, + ) + checksums = ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "release-1", + upstream_tag = "b9001", + source_commit = "deadbeef", + artifacts = { + source_archive_logical_name("b9001"): ApprovedArtifactHash( + asset_name = source_archive_logical_name("b9001"), + sha256 = "b" * 64, + repo = "ggml-org/llama.cpp", + kind = "upstream-source", + ), + choice.name: ApprovedArtifactHash( + asset_name = choice.name, + sha256 = choice.expected_sha256, + repo = "ggml-org/llama.cpp", + kind = "upstream-prebuilt", + ), + }, + ) + plan = INSTALL_LLAMA_PREBUILT.InstallReleasePlan( + requested_tag = "latest", + llama_tag = "b9001", + release_tag = "release-1", + attempts = [choice], + approved_checksums = checksums, + ) + + write_prebuilt_metadata( + install_dir, + requested_tag = "latest", + llama_tag = "b9001", + release_tag = "release-1", + choice = choice, + approved_checksums = checksums, + prebuilt_fallback_used = False, + ) + + monkeypatch.setattr(INSTALL_LLAMA_PREBUILT, "detect_host", lambda: host) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "resolve_install_release_plans", + lambda llama_tag, host, published_repo, published_release_tag: ( + "latest", + [plan], + ), + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "download_validation_model", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("unhealthy install must continue into normal install flow") + ), + ) + + with pytest.raises( + AssertionError, match = "unhealthy install must continue into normal install flow" + ): + install_prebuilt(install_dir, "latest", "unslothai/llama.cpp", "") + + +def test_install_prebuilt_skips_when_older_release_fallback_matches_existing_install( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + install_dir = tmp_path / "llama.cpp" + install_dir.mkdir() + write_linux_install_shape(install_dir) + + host = HostInfo( + system = "Linux", + machine = "x86_64", + is_windows = False, + is_linux = True, + is_macos = False, + is_x86_64 = True, + is_arm64 = False, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + latest_choice = AssetChoice( + repo = "unslothai/llama.cpp", + tag = "release-2", + name = "llama-b9002-bin-ubuntu-x64.tar.gz", + url = "https://example.com/llama-b9002-bin-ubuntu-x64.tar.gz", + source_label = "upstream", + install_kind = "linux-cpu", + expected_sha256 = "c" * 64, + ) + fallback_choice = AssetChoice( + repo = "unslothai/llama.cpp", + tag = "release-1", + name = "llama-b9001-bin-ubuntu-x64.tar.gz", + url = "https://example.com/llama-b9001-bin-ubuntu-x64.tar.gz", + source_label = "upstream", + install_kind = "linux-cpu", + expected_sha256 = "a" * 64, + ) + latest_checksums = ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "release-2", + upstream_tag = "b9002", + source_commit = "beadfeed", + artifacts = { + source_archive_logical_name("b9002"): ApprovedArtifactHash( + asset_name = source_archive_logical_name("b9002"), + sha256 = "d" * 64, + repo = "ggml-org/llama.cpp", + kind = "upstream-source", + ), + latest_choice.name: ApprovedArtifactHash( + asset_name = latest_choice.name, + sha256 = latest_choice.expected_sha256, + repo = "ggml-org/llama.cpp", + kind = "upstream-prebuilt", + ), + }, + ) + fallback_checksums = ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "release-1", + upstream_tag = "b9001", + source_commit = "deadbeef", + artifacts = { + source_archive_logical_name("b9001"): ApprovedArtifactHash( + asset_name = source_archive_logical_name("b9001"), + sha256 = "b" * 64, + repo = "ggml-org/llama.cpp", + kind = "upstream-source", + ), + fallback_choice.name: ApprovedArtifactHash( + asset_name = fallback_choice.name, + sha256 = fallback_choice.expected_sha256, + repo = "ggml-org/llama.cpp", + kind = "upstream-prebuilt", + ), + }, + ) + latest_plan = INSTALL_LLAMA_PREBUILT.InstallReleasePlan( + requested_tag = "latest", + llama_tag = "b9002", + release_tag = "release-2", + attempts = [latest_choice], + approved_checksums = latest_checksums, + ) + fallback_plan = INSTALL_LLAMA_PREBUILT.InstallReleasePlan( + requested_tag = "latest", + llama_tag = "b9001", + release_tag = "release-1", + attempts = [fallback_choice], + approved_checksums = fallback_checksums, + ) + + write_prebuilt_metadata( + install_dir, + requested_tag = "latest", + llama_tag = "b9001", + release_tag = "release-1", + choice = fallback_choice, + approved_checksums = fallback_checksums, + prebuilt_fallback_used = True, + ) + + monkeypatch.setattr(INSTALL_LLAMA_PREBUILT, "detect_host", lambda: host) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "resolve_install_release_plans", + lambda llama_tag, host, published_repo, published_release_tag: ( + "latest", + [latest_plan, fallback_plan], + ), + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "download_validation_model", + lambda probe_path, cache_path: probe_path.write_bytes(b"probe"), + ) + + call_log: list[str] = [] + + def fake_validate( + attempts, + host, + install_dir, + work_dir, + probe_path, + *, + requested_tag, + llama_tag, + release_tag, + approved_checksums, + initial_fallback_used = False, + existing_install_dir = None, + ): + call_log.append(llama_tag) + raise PrebuiltFallback("validation failed for latest release") + + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "validate_prebuilt_attempts", + fake_validate, + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "activate_install_tree", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("matching fallback install should not reactivate") + ), + ) + + install_prebuilt(install_dir, "latest", "unslothai/llama.cpp", "") + + assert call_log == ["b9002"] + + +def test_install_prebuilt_skips_same_release_fallback_attempt_when_installed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + install_dir = tmp_path / "llama.cpp" + install_dir.mkdir() + write_linux_install_shape(install_dir) + + host = HostInfo( + system = "Linux", + machine = "x86_64", + is_windows = False, + is_linux = True, + is_macos = False, + is_x86_64 = True, + is_arm64 = False, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + first_choice = AssetChoice( + repo = "unslothai/llama.cpp", + tag = "release-1", + name = "llama-b9001-bin-ubuntu-x64-bad.tar.gz", + url = "https://example.com/llama-b9001-bin-ubuntu-x64-bad.tar.gz", + source_label = "published", + install_kind = "linux-cpu", + expected_sha256 = "c" * 64, + ) + fallback_choice = AssetChoice( + repo = "unslothai/llama.cpp", + tag = "release-1", + name = "llama-b9001-bin-ubuntu-x64-good.tar.gz", + url = "https://example.com/llama-b9001-bin-ubuntu-x64-good.tar.gz", + source_label = "upstream", + install_kind = "linux-cpu", + expected_sha256 = "a" * 64, + ) + checksums = ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "release-1", + upstream_tag = "b9001", + source_commit = "deadbeef", + artifacts = { + source_archive_logical_name("b9001"): ApprovedArtifactHash( + asset_name = source_archive_logical_name("b9001"), + sha256 = "b" * 64, + repo = "ggml-org/llama.cpp", + kind = "upstream-source", + ), + first_choice.name: ApprovedArtifactHash( + asset_name = first_choice.name, + sha256 = first_choice.expected_sha256, + repo = "unslothai/llama.cpp", + kind = "prebuilt", + ), + fallback_choice.name: ApprovedArtifactHash( + asset_name = fallback_choice.name, + sha256 = fallback_choice.expected_sha256, + repo = "ggml-org/llama.cpp", + kind = "upstream-prebuilt", + ), + }, + ) + plan = INSTALL_LLAMA_PREBUILT.InstallReleasePlan( + requested_tag = "latest", + llama_tag = "b9001", + release_tag = "release-1", + attempts = [first_choice, fallback_choice], + approved_checksums = checksums, + ) + + write_prebuilt_metadata( + install_dir, + requested_tag = "latest", + llama_tag = "b9001", + release_tag = "release-1", + choice = fallback_choice, + approved_checksums = checksums, + prebuilt_fallback_used = True, + ) + assert ( + existing_install_matches_choice( + install_dir, + host, + llama_tag = "b9001", + release_tag = "release-1", + choice = fallback_choice, + approved_checksums = checksums, + ) + is True + ) + + monkeypatch.setattr(INSTALL_LLAMA_PREBUILT, "detect_host", lambda: host) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "resolve_install_release_plans", + lambda llama_tag, host, published_repo, published_release_tag: ( + "latest", + [plan], + ), + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "download_validation_model", + lambda probe_path, cache_path: probe_path.write_bytes(b"probe"), + ) + + attempted_names: list[str] = [] + + def fake_validate_choice( + choice, + host, + staging_dir, + work_dir, + probe_path, + *, + requested_tag, + llama_tag, + release_tag, + approved_checksums, + prebuilt_fallback_used, + quantized_path, + ): + attempted_names.append(choice.name) + if choice.name == first_choice.name: + raise PrebuiltFallback("newest candidate failed") + raise AssertionError("installed fallback candidate should have been skipped") + + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "validate_prebuilt_choice", + fake_validate_choice, + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "activate_install_tree", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("installed fallback candidate should not be activated") + ), + ) + + install_prebuilt(install_dir, "latest", "unslothai/llama.cpp", "") + + assert attempted_names == [first_choice.name] + + +def test_install_prebuilt_same_tag_upstream_failure_uses_older_unsloth_release_plan( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + install_dir = tmp_path / "llama.cpp" + host = HostInfo( + system = "Linux", + machine = "x86_64", + is_windows = False, + is_linux = True, + is_macos = False, + is_x86_64 = True, + is_arm64 = False, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + + same_tag_upstream_choice = AssetChoice( + repo = "ggml-org/llama.cpp", + tag = "b9002", + name = "llama-b9002-bin-ubuntu-x64.tar.gz", + url = "https://example.com/llama-b9002-bin-ubuntu-x64.tar.gz", + source_label = "upstream", + install_kind = "linux-cpu", + expected_sha256 = "a" * 64, + ) + older_release_choice = AssetChoice( + repo = "unslothai/llama.cpp", + tag = "release-1", + name = "llama-b9001-bin-ubuntu-x64.tar.gz", + url = "https://example.com/llama-b9001-bin-ubuntu-x64.tar.gz", + source_label = "upstream", + install_kind = "linux-cpu", + expected_sha256 = "b" * 64, + ) + latest_plan = INSTALL_LLAMA_PREBUILT.InstallReleasePlan( + requested_tag = "latest", + llama_tag = "b9002", + release_tag = "release-2", + attempts = [same_tag_upstream_choice], + approved_checksums = ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "release-2", + upstream_tag = "b9002", + source_commit = None, + artifacts = {}, + ), + ) + older_plan = INSTALL_LLAMA_PREBUILT.InstallReleasePlan( + requested_tag = "latest", + llama_tag = "b9001", + release_tag = "release-1", + attempts = [older_release_choice], + approved_checksums = ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "release-1", + upstream_tag = "b9001", + source_commit = None, + artifacts = {}, + ), + ) + + monkeypatch.setattr(INSTALL_LLAMA_PREBUILT, "detect_host", lambda: host) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "resolve_install_release_plans", + lambda llama_tag, host, published_repo, published_release_tag: ( + "latest", + [latest_plan, older_plan], + ), + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "download_validation_model", + lambda probe_path, cache_path: probe_path.write_bytes(b"probe"), + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "latest_upstream_release_tag", + lambda: (_ for _ in ()).throw( + AssertionError("install fallback should not walk upstream releases") + ), + ) + + attempted = [] + + def fake_validate( + attempts, + host, + install_dir, + work_dir, + probe_path, + *, + requested_tag, + llama_tag, + release_tag, + approved_checksums, + initial_fallback_used = False, + existing_install_dir = None, + ): + attempted.append((llama_tag, release_tag, attempts[0].source_label)) + if llama_tag == "b9002": + raise PrebuiltFallback("same-tag upstream asset failed validation") + staging_dir = create_install_staging_dir(install_dir) + (staging_dir / "marker.txt").write_text("ready\n") + return attempts[0], staging_dir, initial_fallback_used + + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, "validate_prebuilt_attempts", fake_validate + ) + + activated = {} + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "activate_install_tree", + lambda staging_dir, install_dir, host: activated.update( + {"staging_dir": staging_dir, "install_dir": install_dir} + ), + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "ensure_converter_scripts", + lambda install_dir, llama_tag: None, + ) + + install_prebuilt(install_dir, "latest", "unslothai/llama.cpp", "") + + assert attempted == [ + ("b9002", "release-2", "upstream"), + ("b9001", "release-1", "upstream"), + ] + assert activated["install_dir"] == install_dir + + def io_bytes(data: bytes): return io.BytesIO(data) diff --git a/tests/studio/install/test_llama_pr_force_and_source.py b/tests/studio/install/test_llama_pr_force_and_source.py new file mode 100644 index 0000000000..63a620b2a9 --- /dev/null +++ b/tests/studio/install/test_llama_pr_force_and_source.py @@ -0,0 +1,599 @@ +""" +Tests for UNSLOTH_LLAMA_PR_FORCE and UNSLOTH_LLAMA_SOURCE 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 + +Run: pytest tests/studio/install/test_llama_pr_force_and_source.py -v +""" + +import os +import shlex +import subprocess +import textwrap +from pathlib import Path + +import pytest + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- +PACKAGE_ROOT = Path(__file__).resolve().parents[3] +SETUP_SH = PACKAGE_ROOT / "studio" / "setup.sh" +SETUP_PS1 = PACKAGE_ROOT / "studio" / "setup.ps1" + +BASH = "/bin/bash" +PWSH = "/usr/bin/pwsh" +PWSH_AVAILABLE = os.path.isfile(PWSH) and os.access(PWSH, os.X_OK) +requires_pwsh = pytest.mark.skipif(not PWSH_AVAILABLE, reason = "pwsh not available") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def run_bash( + script: str, *, timeout: int = 10, env: dict | None = None +) -> subprocess.CompletedProcess: + """Run a bash script fragment and return the CompletedProcess.""" + run_env = os.environ.copy() + if env: + run_env.update(env) + return subprocess.run( + [BASH, "-c", script], + capture_output = True, + text = True, + timeout = timeout, + env = run_env, + ) + + +def run_pwsh( + script: str, *, timeout: int = 10, env: dict | None = None +) -> subprocess.CompletedProcess: + """Run a PowerShell script fragment and return the CompletedProcess.""" + run_env = os.environ.copy() + run_env["NO_COLOR"] = "1" + if env: + run_env.update(env) + return subprocess.run( + [PWSH, "-NoProfile", "-Command", script], + capture_output = True, + text = True, + timeout = timeout, + env = run_env, + ) + + +# --------------------------------------------------------------------------- +# Shared bash stubs +# --------------------------------------------------------------------------- +BASH_STUBS = textwrap.dedent("""\ + step() { echo "step:$1:$2"; } + substep() { :; } + verbose_substep() { :; } + print_llama_error_log() { :; } + C_ERR= C_WARN= C_OK= C_RST= C_TITLE= C_DIM= +""") + +RUN_QUIET_STUB = textwrap.dedent("""\ + run_quiet_no_exit() { local _label="$1"; shift; "$@"; return $?; } +""") + + +def make_mock_git(tmp_path: Path, *, fail_on: str = "") -> tuple[Path, Path]: + """Create a mock git binary that logs calls. Returns (mock_bin, log_file).""" + mock_bin = tmp_path / "mock_bin" + mock_bin.mkdir(exist_ok = True) + log_file = tmp_path / "git_calls.log" + + if fail_on: + script = ( + f'#!/bin/bash\necho "$*" >> {log_file}\n' + f'_args=("$@")\n' + f"_i=0\n" + f'while [ "${{_args[$_i]:-}}" = "-C" ]; do _i=$((_i+2)); done\n' + f'_subcmd="${{_args[$_i]:-}}"\n' + f'if [ "$_subcmd" = "{fail_on}" ]; then exit 1; fi\n' + f"exit 0\n" + ) + else: + script = f'#!/bin/bash\necho "$*" >> {log_file}\nexit 0\n' + + git_bin = mock_bin / "git" + git_bin.write_text(script) + git_bin.chmod(0o755) + return mock_bin, log_file + + +# ========================================================================= +# Bash fragment that exercises PR_FORCE and _LLAMA_SOURCE resolution +# ========================================================================= +def _bash_resolution_fragment( + llama_pr: str = "", + llama_pr_force: str = "", + llama_source: str = "", + default_pr_force: str = "", + default_source: str = "https://github.com/ggml-org/llama.cpp", +) -> str: + """Build the bash fragment that mirrors setup.sh resolution logic.""" + return BASH_STUBS + textwrap.dedent(f"""\ + _LLAMA_PR={shlex.quote(llama_pr) if llama_pr else '""'} + _DEFAULT_LLAMA_PR_FORCE={shlex.quote(default_pr_force) if default_pr_force else '""'} + _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"'} + _LLAMA_SOURCE="${{_LLAMA_SOURCE%.git}}" + + _NEED_LLAMA_SOURCE_BUILD=false + _SKIP_PREBUILT_INSTALL=false + + if [ "$_LLAMA_SOURCE" != "https://github.com/ggml-org/llama.cpp" ]; then + step "llama.cpp" "custom source: $_LLAMA_SOURCE -- forcing source build" + _NEED_LLAMA_SOURCE_BUILD=true + _SKIP_PREBUILT_INSTALL=true + fi + + if [ -z "$_LLAMA_PR" ] && [ -n "$_LLAMA_PR_FORCE" ] && \\ + [[ "$_LLAMA_PR_FORCE" =~ ^[0-9]+$ ]] && [ "$_LLAMA_PR_FORCE" -gt 0 ]; then + _LLAMA_PR="$_LLAMA_PR_FORCE" + step "llama.cpp" "baked-in PR_FORCE=$_LLAMA_PR_FORCE" + fi + + echo "LLAMA_PR=$_LLAMA_PR" + echo "LLAMA_SOURCE=$_LLAMA_SOURCE" + echo "NEED_SOURCE=$_NEED_LLAMA_SOURCE_BUILD" + echo "SKIP_PREBUILT=$_SKIP_PREBUILT_INSTALL" + """) + + +# ========================================================================= +# TEST GROUP A: Bash PR_FORCE promotion (subprocess) +# ========================================================================= +class TestBashPrForcePromotion: + """PR_FORCE promotes to _LLAMA_PR when user hasn't set one.""" + + def test_baked_in_pr_force_promotes(self): + script = _bash_resolution_fragment(default_pr_force = "12345") + r = run_bash(script) + assert r.returncode == 0 + assert "LLAMA_PR=12345" in r.stdout + assert "baked-in PR_FORCE=12345" in r.stdout + + def test_env_pr_force_promotes(self): + script = _bash_resolution_fragment(llama_pr_force = "999") + r = run_bash(script) + assert r.returncode == 0 + assert "LLAMA_PR=999" in r.stdout + + def test_user_pr_overrides_pr_force(self): + """UNSLOTH_LLAMA_PR takes priority over PR_FORCE.""" + script = _bash_resolution_fragment( + llama_pr = "100", + llama_pr_force = "200", + ) + r = run_bash(script) + assert r.returncode == 0 + assert "LLAMA_PR=100" in r.stdout + assert "baked-in PR_FORCE" not in r.stdout + + def test_user_pr_overrides_baked_in(self): + script = _bash_resolution_fragment( + llama_pr = "100", + default_pr_force = "200", + ) + r = run_bash(script) + assert r.returncode == 0 + assert "LLAMA_PR=100" in r.stdout + assert "baked-in PR_FORCE" not in r.stdout + + def test_pr_force_zero_ignored(self): + script = _bash_resolution_fragment(llama_pr_force = "0") + r = run_bash(script) + assert r.returncode == 0 + assert "LLAMA_PR=" in r.stdout + assert "baked-in PR_FORCE" not in r.stdout + + def test_pr_force_empty_ignored(self): + script = _bash_resolution_fragment(default_pr_force = "") + r = run_bash(script) + assert r.returncode == 0 + assert "LLAMA_PR=" in r.stdout + assert "baked-in PR_FORCE" not in r.stdout + + def test_pr_force_alpha_ignored(self): + script = _bash_resolution_fragment(llama_pr_force = "abc") + r = run_bash(script) + assert r.returncode == 0 + assert "LLAMA_PR=" in r.stdout + assert "baked-in PR_FORCE" not in r.stdout + + def test_pr_force_negative_ignored(self): + script = _bash_resolution_fragment(llama_pr_force = "-5") + r = run_bash(script) + assert r.returncode == 0 + assert "LLAMA_PR=" in r.stdout + + def test_pr_force_decimal_ignored(self): + script = _bash_resolution_fragment(llama_pr_force = "12.34") + r = run_bash(script) + assert r.returncode == 0 + assert "LLAMA_PR=" in r.stdout + + +# ========================================================================= +# TEST GROUP B: Bash custom source URL (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 + + def test_default_source_no_force(self): + script = _bash_resolution_fragment() + r = run_bash(script) + assert r.returncode == 0 + assert "NEED_SOURCE=false" in r.stdout + assert "SKIP_PREBUILT=false" in r.stdout + assert "custom source:" not in r.stdout + + def test_trailing_git_stripped(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 + + def test_baked_in_source_forces_build(self): + script = _bash_resolution_fragment( + default_source = "https://github.com/unslothai/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 + + +# ========================================================================= +# TEST GROUP C: Bash clone URL parameterization (subprocess with mock git) +# ========================================================================= +class TestBashCloneUrlParameterized: + """Verify git clone uses _LLAMA_SOURCE instead of hardcoded URL.""" + + @staticmethod + def _clone_script( + mock_bin: Path, + build_tmp: str, + llama_pr: str = "", + llama_source: str = "https://github.com/ggml-org/llama.cpp", + resolved_tag: str = "b8508", + ) -> str: + return RUN_QUIET_STUB + textwrap.dedent(f"""\ + export PATH="{mock_bin}:$PATH" + _LLAMA_PR={shlex.quote(llama_pr) if llama_pr else '""'} + _LLAMA_SOURCE={shlex.quote(llama_source)} + _RESOLVED_LLAMA_TAG={shlex.quote(resolved_tag)} + _BUILD_TMP={shlex.quote(build_tmp)} + BUILD_OK=true + + if [ -n "$_LLAMA_PR" ]; then + run_quiet_no_exit "clone llama.cpp" \\ + git clone --depth 1 "${{_LLAMA_SOURCE}}.git" "$_BUILD_TMP" || BUILD_OK=false + else + _CLONE_BRANCH_ARGS=() + if [ "$_RESOLVED_LLAMA_TAG" != "latest" ] && [ -n "$_RESOLVED_LLAMA_TAG" ]; then + _CLONE_BRANCH_ARGS=(--branch "$_RESOLVED_LLAMA_TAG") + fi + run_quiet_no_exit "clone llama.cpp" \\ + git clone --depth 1 "${{_CLONE_BRANCH_ARGS[@]}}" "${{_LLAMA_SOURCE}}.git" "$_BUILD_TMP" || BUILD_OK=false + fi + echo "BUILD_OK=$BUILD_OK" + """) + + def test_pr_path_uses_custom_source(self, tmp_path: Path): + mock_bin, log_file = make_mock_git(tmp_path) + build_tmp = str(tmp_path / "build_tmp") + script = self._clone_script( + mock_bin, + build_tmp, + llama_pr = "123", + llama_source = "https://github.com/unslothai/llama.cpp", + ) + r = run_bash(script) + assert r.returncode == 0 + log = log_file.read_text() + assert "unslothai/llama.cpp.git" in log + assert "ggml-org" not in log + + def test_non_pr_path_uses_custom_source(self, tmp_path: Path): + mock_bin, log_file = make_mock_git(tmp_path) + build_tmp = str(tmp_path / "build_tmp") + script = self._clone_script( + mock_bin, + build_tmp, + llama_source = "https://github.com/unslothai/llama.cpp", + ) + r = run_bash(script) + assert r.returncode == 0 + log = log_file.read_text() + assert "unslothai/llama.cpp.git" in log + assert "ggml-org" not in log + + def test_default_source_unchanged(self, tmp_path: Path): + mock_bin, log_file = make_mock_git(tmp_path) + build_tmp = str(tmp_path / "build_tmp") + script = self._clone_script(mock_bin, build_tmp) + r = run_bash(script) + assert r.returncode == 0 + log = log_file.read_text() + assert "ggml-org/llama.cpp.git" in log + + +# ========================================================================= +# TEST GROUP D: Static source patterns -- setup.sh +# ========================================================================= +class TestSourcePatternsSh: + """Verify setup.sh has the new defaults and parameterized clone URLs.""" + + @pytest.fixture(autouse = True) + def _load_source(self): + self.content = SETUP_SH.read_text() + + def test_has_default_pr_force(self): + assert '_DEFAULT_LLAMA_PR_FORCE=""' in self.content + + def test_has_default_source(self): + assert ( + '_DEFAULT_LLAMA_SOURCE="https://github.com/ggml-org/llama.cpp"' + in self.content + ) + + 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_pr_force_resolution_block(self): + assert '_LLAMA_PR="$_LLAMA_PR_FORCE"' in self.content + + def test_source_trailing_git_strip(self): + assert "${_LLAMA_SOURCE%.git}" in self.content + + def test_clone_urls_parameterized_pr_path(self): + """PR clone path uses ${_LLAMA_SOURCE}.git, not hardcoded URL.""" + pr_clone_idx = self.content.index( + 'if [ -n "$_LLAMA_PR" ]; then\n' + ' run_quiet_no_exit "clone llama.cpp"' + ) + else_idx = self.content.index("else\n", pr_clone_idx) + pr_block = self.content[pr_clone_idx:else_idx] + assert '"${_LLAMA_SOURCE}.git"' in pr_block + 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.""" + # Find the non-PR clone line (after _CLONE_BRANCH_ARGS) + idx = self.content.index("_CLONE_BRANCH_ARGS=()") + block = self.content[idx : idx + 400] + assert '"${_LLAMA_SOURCE}.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() + for i, line in enumerate(lines, 1): + if "git clone" in line and "ggml-org/llama.cpp.git" in line: + pytest.fail( + f"Line {i} has hardcoded ggml-org clone URL: {line.strip()}" + ) + + +# ========================================================================= +# TEST GROUP E: Static source patterns -- setup.ps1 +# ========================================================================= +class TestSourcePatternsPs1: + """Verify setup.ps1 has the new defaults and parameterized clone URLs.""" + + @pytest.fixture(autouse = True) + def _load_source(self): + self.content = SETUP_PS1.read_text() + + def test_has_default_pr_force(self): + assert '$DefaultLlamaPrForce = ""' in self.content + + def test_has_default_source(self): + assert ( + '$DefaultLlamaSource = "https://github.com/ggml-org/llama.cpp"' + in self.content + ) + + 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_pr_force_promotion_block(self): + assert "$LlamaPr = $LlamaPrForce" in self.content + + def test_source_trailing_git_strip(self): + assert ".EndsWith('.git')" in self.content + + def test_clone_urls_parameterized_pr_path(self): + """PR clone path uses $LlamaSource.git, not hardcoded URL.""" + pr_idx = self.content.index( + "if ($LlamaPr) {\n", self.content.index("Cloning llama.cpp") + ) + else_idx = self.content.index("} else {", pr_idx) + pr_block = self.content[pr_idx:else_idx] + assert '"$LlamaSource.git"' in pr_block + 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.""" + clone_args_idx = self.content.index('$cloneArgs = @("clone"') + block = self.content[clone_args_idx : clone_args_idx + 400] + assert '"$LlamaSource.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() + for i, line in enumerate(lines, 1): + if "git clone" in line and "ggml-org/llama.cpp.git" in line: + pytest.fail( + f"Line {i} has hardcoded ggml-org clone URL: {line.strip()}" + ) + + +# ========================================================================= +# TEST GROUP F: PowerShell PR_FORCE promotion (subprocess) +# ========================================================================= +@requires_pwsh +class TestPwshPrForcePromotion: + """PR_FORCE promotion and source URL logic via pwsh subprocess.""" + + FRAGMENT_TEMPLATE = textwrap.dedent("""\ + function step($a, $b, $c) { Write-Output "step:$a`:$b" } + + $DefaultLlamaPrForce = "%%DEFAULT_PR_FORCE%%" + $DefaultLlamaSource = "%%DEFAULT_SOURCE%%" + + $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) } + + $NeedLlamaSourceBuild = $false + $SkipPrebuiltInstall = $false + + if ($LlamaSource -ne "https://github.com/ggml-org/llama.cpp") { + step "llama.cpp" "custom source: $LlamaSource -- forcing source build" "Yellow" + $NeedLlamaSourceBuild = $true + $SkipPrebuiltInstall = $true + } + + if (-not $LlamaPr -and $LlamaPrForce -and $LlamaPrForce -match '^\\d+$' -and [int]$LlamaPrForce -gt 0) { + $LlamaPr = $LlamaPrForce + step "llama.cpp" "baked-in PR_FORCE=$LlamaPrForce" "Yellow" + } + + Write-Output "LLAMA_PR=$LlamaPr" + Write-Output "LLAMA_SOURCE=$LlamaSource" + Write-Output "NEED_SOURCE=$NeedLlamaSourceBuild" + Write-Output "SKIP_PREBUILT=$SkipPrebuiltInstall" + """) + + def _run( + self, + default_pr_force: str = "", + default_source: str = "https://github.com/ggml-org/llama.cpp", + env: dict | None = None, + ) -> subprocess.CompletedProcess: + script = self.FRAGMENT_TEMPLATE.replace( + "%%DEFAULT_PR_FORCE%%", + default_pr_force, + ).replace( + "%%DEFAULT_SOURCE%%", + default_source, + ) + run_env = {} + # 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) + + def test_baked_in_pr_force_promotes(self): + r = self._run(default_pr_force = "12345") + assert r.returncode == 0 + assert "LLAMA_PR=12345" in r.stdout + assert "baked-in PR_FORCE=12345" in r.stdout + + def test_env_pr_force_promotes(self): + r = self._run(env = {"UNSLOTH_LLAMA_PR_FORCE": "999"}) + assert r.returncode == 0 + assert "LLAMA_PR=999" in r.stdout + + def test_user_pr_overrides_pr_force(self): + r = self._run( + env = { + "UNSLOTH_LLAMA_PR": "100", + "UNSLOTH_LLAMA_PR_FORCE": "200", + } + ) + assert r.returncode == 0 + assert "LLAMA_PR=100" in r.stdout + assert "baked-in PR_FORCE" not in r.stdout + + def test_pr_force_zero_ignored(self): + r = self._run(env = {"UNSLOTH_LLAMA_PR_FORCE": "0"}) + assert r.returncode == 0 + assert "LLAMA_PR=" in r.stdout + assert "baked-in PR_FORCE" not in r.stdout + + def test_pr_force_alpha_ignored(self): + r = self._run(env = {"UNSLOTH_LLAMA_PR_FORCE": "abc"}) + assert r.returncode == 0 + assert "baked-in PR_FORCE" not in r.stdout + + def test_custom_source_forces_build(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 + + def test_default_source_no_force(self): + r = self._run() + assert r.returncode == 0 + assert "NEED_SOURCE=False" in r.stdout + assert "SKIP_PREBUILT=False" in r.stdout + + def test_trailing_git_stripped(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 + + def test_baked_in_source_forces_build(self): + r = self._run(default_source = "https://github.com/unslothai/llama.cpp") + assert r.returncode == 0 + assert "NEED_SOURCE=True" in r.stdout diff --git a/tests/studio/install/test_pr4562_bugfixes.py b/tests/studio/install/test_pr4562_bugfixes.py index 9b8c6219de..b1fe00f3b3 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 (Unsloth -> ggml-org -> raw) + - "latest" tag resolution fallback chain (helper -> raw) - Cross-platform binary_env (Linux, macOS, Windows) - Edge cases: malformed JSON, empty responses, env overrides @@ -40,6 +40,10 @@ SPEC.loader.exec_module(MOD) binary_env = MOD.binary_env HostInfo = MOD.HostInfo resolve_requested_llama_tag = MOD.resolve_requested_llama_tag +PublishedReleaseBundle = MOD.PublishedReleaseBundle +ApprovedArtifactHash = MOD.ApprovedArtifactHash +ApprovedReleaseChecksums = MOD.ApprovedReleaseChecksums +source_archive_logical_name = MOD.source_archive_logical_name SETUP_SH = PACKAGE_ROOT / "studio" / "setup.sh" SETUP_PS1 = PACKAGE_ROOT / "studio" / "setup.ps1" @@ -240,6 +244,57 @@ class TestResolveRequestedLlamaTag: monkeypatch.setattr(MOD, "latest_upstream_release_tag", lambda: "b5555") assert resolve_requested_llama_tag("") == "b5555" + def test_latest_with_published_repo_uses_latest_valid_published_release( + self, monkeypatch: pytest.MonkeyPatch + ): + invalid = PublishedReleaseBundle( + repo = "unslothai/llama.cpp", + release_tag = "v2.0", + upstream_tag = "b9000", + assets = {}, + manifest_asset_name = "llama-prebuilt-manifest.json", + artifacts = [], + selection_log = [], + ) + valid = PublishedReleaseBundle( + repo = "unslothai/llama.cpp", + release_tag = "v1.0", + upstream_tag = "b8999", + assets = {}, + manifest_asset_name = "llama-prebuilt-manifest.json", + artifacts = [], + selection_log = [], + ) + + monkeypatch.setattr( + MOD, + "iter_published_release_bundles", + lambda repo, published_release_tag = "": iter([invalid, valid]), + ) + + def fake_load(repo, release_tag): + if release_tag == "v2.0": + raise MOD.PrebuiltFallback("checksum asset missing") + return ApprovedReleaseChecksums( + repo = repo, + release_tag = release_tag, + upstream_tag = "b8999", + source_commit = None, + artifacts = { + source_archive_logical_name("b8999"): ApprovedArtifactHash( + asset_name = source_archive_logical_name("b8999"), + sha256 = "a" * 64, + repo = "ggml-org/llama.cpp", + kind = "upstream-source", + ) + }, + ) + + monkeypatch.setattr(MOD, "load_approved_release_checksums", fake_load) + monkeypatch.setattr(MOD, "latest_upstream_release_tag", lambda: "b7777") + + assert resolve_requested_llama_tag("latest", "unslothai/llama.cpp") == "b8999" + # ========================================================================= # TEST GROUP C: setup.sh logic (bash subprocess tests) @@ -429,140 +484,61 @@ class TestSetupShLogic: # TEST GROUP D: "latest" tag resolution (bash subprocess) # ========================================================================= class TestLatestTagResolution: - """Test the fallback chain: Unsloth API -> ggml-org API -> raw.""" + """Test the fallback chain: helper resolver -> raw.""" RESOLVE_TEMPLATE = textwrap.dedent("""\ - export PATH="{mock_bin}:$PATH" _REQUESTED_LLAMA_TAG="{requested_tag}" _RESOLVED_LLAMA_TAG="" - _RESOLVE_UPSTREAM_STATUS=1 - _HELPER_RELEASE_REPO="unslothai/llama.cpp" - if [ "$_RESOLVE_UPSTREAM_STATUS" -ne 0 ] || [ -z "$_RESOLVED_LLAMA_TAG" ]; then - if [ "$_REQUESTED_LLAMA_TAG" = "latest" ]; then - _RESOLVED_LLAMA_TAG="$(curl -fsSL "https://api.github.com/repos/${{_HELPER_RELEASE_REPO}}/releases/latest" 2>/dev/null | python -c "import sys,json; print(json.load(sys.stdin)['tag_name'])" 2>/dev/null)" || _RESOLVED_LLAMA_TAG="" - if [ -z "$_RESOLVED_LLAMA_TAG" ]; then - _RESOLVED_LLAMA_TAG="$(curl -fsSL https://api.github.com/repos/ggml-org/llama.cpp/releases/latest 2>/dev/null | python -c "import sys,json; print(json.load(sys.stdin)['tag_name'])" 2>/dev/null)" || _RESOLVED_LLAMA_TAG="" - fi - fi - if [ -z "$_RESOLVED_LLAMA_TAG" ]; then - _RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG" - fi + _RESOLVE_UPSTREAM_STATUS={resolve_status} + if [ "$_RESOLVE_UPSTREAM_STATUS" -eq 0 ] && [ -n "{resolved_tag}" ]; then + _RESOLVED_LLAMA_TAG="{resolved_tag}" + else + _RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG" fi echo "$_RESOLVED_LLAMA_TAG" """) - @staticmethod - def _make_curl_mock( - mock_bin: Path, unsloth_response: str | None, ggml_response: str | None - ): - """Create a curl mock that returns different responses per repo.""" - lines = ["#!/bin/bash"] - if unsloth_response is not None: - lines.append( - f'if echo "$*" | grep -q "unslothai/llama.cpp"; then echo \'{unsloth_response}\'; exit 0; fi' - ) - else: - lines.append( - 'if echo "$*" | grep -q "unslothai/llama.cpp"; then exit 1; fi' - ) - if ggml_response is not None: - lines.append( - f'if echo "$*" | grep -q "ggml-org/llama.cpp"; then echo \'{ggml_response}\'; exit 0; fi' - ) - else: - lines.append('if echo "$*" | grep -q "ggml-org/llama.cpp"; then exit 1; fi') - lines.append("exit 1") - curl_path = mock_bin / "curl" - curl_path.write_text("\n".join(lines) + "\n") - curl_path.chmod(0o755) - def _run_resolve( self, tmp_path: Path, requested_tag: str, - unsloth_resp: str | None, - ggml_resp: str | None, + resolved_tag: str, + resolve_status: int, ) -> str: - mock_bin = tmp_path / "mock_bin" - mock_bin.mkdir(exist_ok = True) - self._make_curl_mock(mock_bin, unsloth_resp, ggml_resp) script = self.RESOLVE_TEMPLATE.format( - mock_bin = mock_bin, requested_tag = requested_tag + requested_tag = requested_tag, + resolved_tag = resolved_tag, + resolve_status = resolve_status, ) return run_bash(script) - def test_unsloth_succeeds(self, tmp_path: Path): + def test_helper_resolution_succeeds(self, tmp_path: Path): output = self._run_resolve( tmp_path, "latest", - unsloth_resp = '{"tag_name":"b8508"}', - ggml_resp = '{"tag_name":"b9000"}', + resolved_tag = "b8508", + resolve_status = 0, ) assert output == "b8508" - def test_unsloth_fails_ggml_succeeds(self, tmp_path: Path): + def test_helper_resolution_falls_back_to_raw_requested_tag(self, tmp_path: Path): output = self._run_resolve( tmp_path, "latest", - unsloth_resp = None, - ggml_resp = '{"tag_name":"b9000"}', - ) - assert output == "b9000" - - def test_both_fail_raw_fallback(self, tmp_path: Path): - output = self._run_resolve( - tmp_path, - "latest", - unsloth_resp = None, - ggml_resp = None, + resolved_tag = "", + resolve_status = 1, ) assert output == "latest" - def test_concrete_tag_passes_through(self, tmp_path: Path): + def test_concrete_tag_passes_through_when_helper_fails(self, tmp_path: Path): output = self._run_resolve( tmp_path, "b7777", - unsloth_resp = '{"tag_name":"b8508"}', - ggml_resp = '{"tag_name":"b9000"}', + resolved_tag = "", + resolve_status = 1, ) assert output == "b7777" - def test_unsloth_malformed_json_falls_through(self, tmp_path: Path): - output = self._run_resolve( - tmp_path, - "latest", - unsloth_resp = '{"bad_key":"no_tag"}', - ggml_resp = '{"tag_name":"b9001"}', - ) - assert output == "b9001" - - def test_both_malformed_json_raw_fallback(self, tmp_path: Path): - output = self._run_resolve( - tmp_path, - "latest", - unsloth_resp = '{"bad":"data"}', - ggml_resp = '{"also":"bad"}', - ) - assert output == "latest" - - def test_unsloth_empty_body_falls_through(self, tmp_path: Path): - output = self._run_resolve( - tmp_path, - "latest", - unsloth_resp = "", - ggml_resp = '{"tag_name":"b7000"}', - ) - assert output == "b7000" - - def test_unsloth_empty_tag_name_falls_through(self, tmp_path: Path): - output = self._run_resolve( - tmp_path, - "latest", - unsloth_resp = '{"tag_name":""}', - ggml_resp = '{"tag_name":"b6000"}', - ) - assert output == "b6000" - def test_env_override_unsloth_llama_tag(self): output = run_bash( 'echo "${UNSLOTH_LLAMA_TAG:-latest}"', @@ -593,10 +569,10 @@ class TestSourceCodePatterns: def test_setup_sh_no_rm_before_prereq_check(self): """rm -rf must appear AFTER cmake/git checks, not before.""" content = SETUP_SH.read_text() - # Find the source-build block - idx_else = content.find("# Check prerequisites") - assert idx_else != -1 - block = content[idx_else:] + # Anchor on the source-build cmake check block. + idx_block = content.find("command -v cmake") + assert idx_block != -1 + block = content[idx_block:] # rm -rf should appear after the cmake/git checks idx_cmake = block.find("command -v cmake") idx_git = block.find("command -v git") @@ -619,14 +595,12 @@ class TestSourceCodePatterns: '_RESOLVED_LLAMA_TAG" != "latest"' in content ), "Should guard against literal 'latest' tag" - def test_setup_sh_latest_resolution_queries_unsloth_first(self): - """The Unsloth repo should be queried before ggml-org.""" + 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() - idx_unsloth = content.find("_HELPER_RELEASE_REPO}/releases/latest") - idx_ggml = content.find("ggml-org/llama.cpp/releases/latest") - assert idx_unsloth != -1, "Unsloth API query not found" - assert idx_ggml != -1, "ggml-org API query not found" - assert idx_unsloth < idx_ggml, "Unsloth should be queried before ggml-org" + assert "--resolve-llama-tag" in content + assert "_HELPER_RELEASE_REPO}/releases/latest" not in content + assert "ggml-org/llama.cpp/releases/latest" not in content def test_setup_ps1_uses_checkout_b(self): """PS1 should use checkout -B, not checkout --force FETCH_HEAD.""" @@ -658,14 +632,12 @@ class TestSourceCodePatterns: f"Found 'git pull' in llama.cpp build section at line {i+1}" ) - def test_setup_ps1_latest_resolution_queries_unsloth_first(self): - """PS1 should query Unsloth repo before ggml-org.""" + def test_setup_ps1_latest_resolution_uses_helper_only(self): + """PS1 fallback should rely on helper output, not raw GitHub API tag_name.""" content = SETUP_PS1.read_text() - idx_unsloth = content.find("$HelperReleaseRepo/releases/latest") - idx_ggml = content.find("ggml-org/llama.cpp/releases/latest") - assert idx_unsloth != -1, "Unsloth API query not found in PS1" - assert idx_ggml != -1, "ggml-org API query not found in PS1" - assert idx_unsloth < idx_ggml, "Unsloth should be queried before ggml-org" + assert "--resolve-llama-tag" in content + assert "$HelperReleaseRepo/releases/latest" not in content + assert "ggml-org/llama.cpp/releases/latest" not in content def test_binary_env_linux_has_binary_parent(self): """The Linux branch of binary_env should include binary_path.parent.""" diff --git a/tests/studio/install/test_selection_logic.py b/tests/studio/install/test_selection_logic.py index 906c978b0d..d7bea4bb5c 100644 --- a/tests/studio/install/test_selection_logic.py +++ b/tests/studio/install/test_selection_logic.py @@ -54,6 +54,12 @@ apply_approved_hashes = INSTALL_LLAMA_PREBUILT.apply_approved_hashes linux_cuda_choice_from_release = INSTALL_LLAMA_PREBUILT.linux_cuda_choice_from_release windows_cuda_attempts = INSTALL_LLAMA_PREBUILT.windows_cuda_attempts resolve_upstream_asset_choice = INSTALL_LLAMA_PREBUILT.resolve_upstream_asset_choice +resolve_requested_install_tag = INSTALL_LLAMA_PREBUILT.resolve_requested_install_tag +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 +source_archive_logical_name = INSTALL_LLAMA_PREBUILT.source_archive_logical_name +env_int = INSTALL_LLAMA_PREBUILT.env_int # --------------------------------------------------------------------------- @@ -131,6 +137,37 @@ def make_checksums(asset_names): ) +def make_checksums_with_source( + asset_names, + *, + release_tag = "v1.0", + upstream_tag = "b8508", +): + 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", + ), + }, + ) + + def mock_linux_runtime(monkeypatch, lines): dirs = {line: ["/usr/lib/stub"] for line in lines} monkeypatch.setattr( @@ -408,7 +445,100 @@ class TestApplyApprovedHashes: # =========================================================================== -# J. linux_cuda_choice_from_release -- core selection +# J. published release resolution +# =========================================================================== + + +class TestPublishedReleaseResolution: + def test_latest_skips_invalid_release_and_uses_next_valid(self, monkeypatch): + invalid = make_release([], release_tag = "v2.0", upstream_tag = "b9000") + valid = make_release([], release_tag = "v1.0", upstream_tag = "b8999") + + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "iter_published_release_bundles", + lambda repo, published_release_tag = "": iter([invalid, valid]), + ) + + def fake_load(repo, release_tag): + if release_tag == "v2.0": + raise PrebuiltFallback("checksum asset missing") + return make_checksums_with_source( + [], release_tag = "v1.0", upstream_tag = "b8999" + ) + + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "load_approved_release_checksums", + fake_load, + ) + + resolved = resolve_published_release("latest", "unslothai/llama.cpp") + assert resolved.bundle.release_tag == "v1.0" + assert resolved.bundle.upstream_tag == "b8999" + assert resolved.checksums.release_tag == "v1.0" + + def test_concrete_tag_matches_manifest_upstream_tag(self, monkeypatch): + release = make_release([], release_tag = "release-b8508", upstream_tag = "b8508") + 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 = "b8508", + ), + ) + + assert ( + resolve_requested_install_tag("b8508", "", "unslothai/llama.cpp") == "b8508" + ) + + def test_concrete_tag_without_matching_release_raises(self, monkeypatch): + release = make_release([], release_tag = "release-b9000", upstream_tag = "b9000") + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "iter_published_release_bundles", + lambda repo, published_release_tag = "": iter([release]), + ) + + with pytest.raises(PrebuiltFallback, match = "matched upstream tag b8508"): + resolve_requested_install_tag("b8508", "", "unslothai/llama.cpp") + + def test_pinned_release_must_match_requested_upstream_tag(self, monkeypatch): + bundle = make_release( + [], release_tag = "llama-prebuilt-latest", upstream_tag = "b9000" + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "pinned_published_release_bundle", + lambda repo, release_tag: bundle, + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "load_approved_release_checksums", + lambda repo, release_tag: make_checksums_with_source( + [], + release_tag = release_tag, + upstream_tag = "b9000", + ), + ) + + with pytest.raises(PrebuiltFallback, match = "but requested b8508"): + resolve_requested_install_tag( + "b8508", + "llama-prebuilt-latest", + "unslothai/llama.cpp", + ) + + +# =========================================================================== +# K. linux_cuda_choice_from_release -- core selection # =========================================================================== @@ -676,7 +806,554 @@ class TestLinuxCudaChoiceFromRelease: # =========================================================================== -# K. windows_cuda_attempts +# L. resolve_install_attempts +# =========================================================================== + + +class TestResolveInstallAttempts: + def test_windows_cuda_prefers_published_asset_from_selected_release( + self, monkeypatch + ): + host = make_host(system = "Windows", machine = "AMD64") + host.driver_cuda_version = (12, 4) + mock_windows_runtime(monkeypatch, ["cuda12"]) + asset_name = "llama-b9000-bin-win-cuda-12.4-x64.zip" + release = make_release( + [ + make_artifact( + asset_name, + install_kind = "windows-cuda", + runtime_line = "cuda12", + coverage_class = None, + supported_sms = [], + min_sm = None, + max_sm = None, + bundle_profile = None, + ) + ], + release_tag = "llama-prebuilt-latest", + upstream_tag = "b9000", + assets = {asset_name: f"https://published.example/{asset_name}"}, + ) + checksums = make_checksums_with_source( + [asset_name], + release_tag = release.release_tag, + upstream_tag = "b9000", + ) + + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "iter_resolved_published_releases", + lambda requested_tag, published_repo, published_release_tag = "": iter( + [ + INSTALL_LLAMA_PREBUILT.ResolvedPublishedRelease( + bundle = release, + checksums = checksums, + ) + ] + ), + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "github_release_assets", + lambda repo, tag: (_ for _ in ()).throw( + AssertionError( + "published Windows CUDA choice should not query upstream" + ) + ), + ) + + requested_tag, resolved_tag, attempts, approved = resolve_install_attempts( + "latest", + host, + "unslothai/llama.cpp", + "", + ) + + assert requested_tag == "latest" + assert resolved_tag == "b9000" + assert attempts[0].name == asset_name + assert attempts[0].source_label == "published" + assert attempts[0].expected_sha256 == "a" * 64 + assert approved.release_tag == "llama-prebuilt-latest" + + def test_windows_cuda_uses_selected_release_upstream_tag(self, monkeypatch): + host = make_host(system = "Windows", machine = "AMD64") + host.driver_cuda_version = (12, 4) + mock_windows_runtime(monkeypatch, ["cuda12"]) + release = make_release( + [], release_tag = "llama-prebuilt-latest", upstream_tag = "b9000" + ) + checksums = make_checksums_with_source( + ["llama-b9000-bin-win-cuda-12.4-x64.zip"], + release_tag = release.release_tag, + upstream_tag = "b9000", + ) + + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "iter_resolved_published_releases", + lambda requested_tag, published_repo, published_release_tag = "": iter( + [ + INSTALL_LLAMA_PREBUILT.ResolvedPublishedRelease( + bundle = release, + checksums = checksums, + ) + ] + ), + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "github_release_assets", + lambda repo, tag: { + f"llama-{tag}-bin-win-cuda-12.4-x64.zip": f"https://example.com/llama-{tag}-bin-win-cuda-12.4-x64.zip" + }, + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "resolve_windows_cuda_choices", + lambda host, tag, assets: [ + AssetChoice( + repo = UPSTREAM_REPO, + tag = tag, + name = f"llama-{tag}-bin-win-cuda-12.4-x64.zip", + url = assets[f"llama-{tag}-bin-win-cuda-12.4-x64.zip"], + source_label = "upstream", + install_kind = "windows-cuda", + runtime_line = "cuda12", + ) + ], + ) + + requested_tag, resolved_tag, attempts, approved = resolve_install_attempts( + "latest", + host, + "unslothai/llama.cpp", + "", + ) + + assert requested_tag == "latest" + assert resolved_tag == "b9000" + assert attempts[0].name == "llama-b9000-bin-win-cuda-12.4-x64.zip" + assert attempts[0].expected_sha256 == "a" * 64 + assert approved.release_tag == "llama-prebuilt-latest" + + def test_linux_cpu_uses_same_tag_upstream_asset(self, monkeypatch): + host = make_host( + has_usable_nvidia = False, + has_physical_nvidia = False, + nvidia_smi = None, + ) + release = make_release( + [], release_tag = "llama-prebuilt-latest", upstream_tag = "b9000" + ) + checksums = make_checksums_with_source( + ["llama-b9000-bin-ubuntu-x64.tar.gz"], + release_tag = release.release_tag, + upstream_tag = "b9000", + ) + + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "iter_resolved_published_releases", + lambda requested_tag, published_repo, published_release_tag = "": iter( + [ + INSTALL_LLAMA_PREBUILT.ResolvedPublishedRelease( + bundle = release, + checksums = checksums, + ) + ] + ), + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "github_release_assets", + lambda repo, tag: { + f"llama-{tag}-bin-ubuntu-x64.tar.gz": f"https://example.com/llama-{tag}-bin-ubuntu-x64.tar.gz" + }, + ) + + _requested_tag, resolved_tag, attempts, _approved = resolve_install_attempts( + "latest", + host, + "unslothai/llama.cpp", + "", + ) + + assert resolved_tag == "b9000" + assert attempts[0].name == "llama-b9000-bin-ubuntu-x64.tar.gz" + assert attempts[0].source_label == "upstream" + assert attempts[0].expected_sha256 == "a" * 64 + + def test_linux_cuda_does_not_fall_back_to_upstream_cpu(self, monkeypatch): + host = make_host(system = "Linux", machine = "x86_64", compute_caps = ["86"]) + release = make_release( + [], release_tag = "llama-prebuilt-latest", upstream_tag = "b9000" + ) + checksums = make_checksums_with_source( + [], + release_tag = release.release_tag, + upstream_tag = "b9000", + ) + + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "iter_resolved_published_releases", + lambda requested_tag, published_repo, published_release_tag = "": iter( + [ + INSTALL_LLAMA_PREBUILT.ResolvedPublishedRelease( + bundle = release, + checksums = checksums, + ) + ] + ), + ) + mock_linux_runtime(monkeypatch, ["cuda12"]) + + with pytest.raises( + PrebuiltFallback, match = "no compatible published Linux CUDA bundle" + ): + resolve_install_attempts("latest", host, "unslothai/llama.cpp", "") + + def test_windows_cpu_prefers_published_asset(self, monkeypatch): + host = make_host( + system = "Windows", + machine = "AMD64", + has_usable_nvidia = False, + has_physical_nvidia = False, + nvidia_smi = None, + ) + asset_name = "llama-b9000-bin-win-cpu-x64.zip" + release = make_release( + [ + make_artifact( + asset_name, + install_kind = "windows-cpu", + runtime_line = None, + coverage_class = None, + supported_sms = [], + min_sm = None, + max_sm = None, + bundle_profile = None, + ) + ], + release_tag = "llama-prebuilt-latest", + upstream_tag = "b9000", + assets = {asset_name: f"https://published.example/{asset_name}"}, + ) + checksums = make_checksums_with_source( + [asset_name], + release_tag = release.release_tag, + upstream_tag = "b9000", + ) + + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "iter_resolved_published_releases", + lambda requested_tag, published_repo, published_release_tag = "": iter( + [ + INSTALL_LLAMA_PREBUILT.ResolvedPublishedRelease( + bundle = release, + checksums = checksums, + ) + ] + ), + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "github_release_assets", + lambda repo, tag: (_ for _ in ()).throw( + AssertionError("published Windows CPU choice should not query upstream") + ), + ) + + _requested_tag, resolved_tag, attempts, _approved = resolve_install_attempts( + "latest", + host, + "unslothai/llama.cpp", + "", + ) + + assert resolved_tag == "b9000" + assert attempts[0].name == asset_name + assert attempts[0].source_label == "published" + + def test_macos_prefers_published_asset(self, monkeypatch): + host = make_host( + system = "Darwin", + machine = "arm64", + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + asset_name = "llama-b9000-bin-macos-arm64.tar.gz" + release = make_release( + [ + make_artifact( + asset_name, + install_kind = "macos-arm64", + runtime_line = None, + coverage_class = None, + supported_sms = [], + min_sm = None, + max_sm = None, + bundle_profile = None, + ) + ], + release_tag = "llama-prebuilt-latest", + upstream_tag = "b9000", + assets = {asset_name: f"https://published.example/{asset_name}"}, + ) + checksums = make_checksums_with_source( + [asset_name], + release_tag = release.release_tag, + upstream_tag = "b9000", + ) + + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "iter_resolved_published_releases", + lambda requested_tag, published_repo, published_release_tag = "": iter( + [ + INSTALL_LLAMA_PREBUILT.ResolvedPublishedRelease( + bundle = release, + checksums = checksums, + ) + ] + ), + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "github_release_assets", + lambda repo, tag: (_ for _ in ()).throw( + AssertionError("published macOS choice should not query upstream") + ), + ) + + _requested_tag, resolved_tag, attempts, _approved = resolve_install_attempts( + "latest", + host, + "unslothai/llama.cpp", + "", + ) + + assert resolved_tag == "b9000" + assert attempts[0].name == asset_name + assert attempts[0].source_label == "published" + + def test_windows_cpu_missing_checksum_rejects_install(self, monkeypatch): + host = make_host( + system = "Windows", + machine = "AMD64", + has_usable_nvidia = False, + has_physical_nvidia = False, + nvidia_smi = None, + ) + published_name = "llama-b9000-bin-win-cpu-x64.zip" + release = make_release( + [ + make_artifact( + published_name, + install_kind = "windows-cpu", + runtime_line = None, + coverage_class = None, + supported_sms = [], + min_sm = None, + max_sm = None, + bundle_profile = None, + ) + ], + release_tag = "llama-prebuilt-latest", + upstream_tag = "b9000", + assets = {published_name: f"https://published.example/{published_name}"}, + ) + checksums = make_checksums_with_source( + [], + release_tag = release.release_tag, + upstream_tag = "b9000", + ) + + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "iter_resolved_published_releases", + lambda requested_tag, published_repo, published_release_tag = "": iter( + [ + INSTALL_LLAMA_PREBUILT.ResolvedPublishedRelease( + bundle = release, + checksums = checksums, + ) + ] + ), + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "github_release_assets", + lambda repo, tag: { + f"llama-{tag}-bin-win-cpu-x64.zip": f"https://upstream.example/llama-{tag}-bin-win-cpu-x64.zip" + }, + ) + + with pytest.raises( + PrebuiltFallback, + match = "approved checksum asset did not contain the selected prebuilt archive", + ): + resolve_install_attempts( + "latest", + host, + "unslothai/llama.cpp", + "", + ) + + +class TestResolveInstallReleasePlans: + def test_latest_collects_multiple_older_release_plans_up_to_limit( + self, monkeypatch + ): + host = make_host( + has_usable_nvidia = False, + has_physical_nvidia = False, + nvidia_smi = None, + ) + releases = [ + INSTALL_LLAMA_PREBUILT.ResolvedPublishedRelease( + bundle = make_release([], release_tag = "r3", upstream_tag = "b9003"), + checksums = make_checksums_with_source( + ["llama-b9003-bin-ubuntu-x64.tar.gz"], + release_tag = "r3", + upstream_tag = "b9003", + ), + ), + INSTALL_LLAMA_PREBUILT.ResolvedPublishedRelease( + bundle = make_release([], release_tag = "r2", upstream_tag = "b9002"), + checksums = make_checksums_with_source( + ["llama-b9002-bin-ubuntu-x64.tar.gz"], + release_tag = "r2", + upstream_tag = "b9002", + ), + ), + INSTALL_LLAMA_PREBUILT.ResolvedPublishedRelease( + bundle = make_release([], release_tag = "r1", upstream_tag = "b9001"), + checksums = make_checksums_with_source( + ["llama-b9001-bin-ubuntu-x64.tar.gz"], + release_tag = "r1", + upstream_tag = "b9001", + ), + ), + ] + + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "iter_resolved_published_releases", + lambda requested_tag, published_repo, published_release_tag = "": iter( + releases + ), + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "github_release_assets", + lambda repo, tag: { + f"llama-{tag}-bin-ubuntu-x64.tar.gz": f"https://example.com/llama-{tag}-bin-ubuntu-x64.tar.gz" + }, + ) + + requested_tag, plans = resolve_install_release_plans( + "latest", + host, + "unslothai/llama.cpp", + "", + max_release_fallbacks = 2, + ) + + assert requested_tag == "latest" + assert [plan.release_tag for plan in plans] == ["r3", "r2"] + assert [plan.llama_tag for plan in plans] == ["b9003", "b9002"] + + def test_latest_skips_non_installable_release_and_keeps_searching( + self, monkeypatch + ): + host = make_host( + has_usable_nvidia = False, + has_physical_nvidia = False, + nvidia_smi = None, + ) + releases = [ + INSTALL_LLAMA_PREBUILT.ResolvedPublishedRelease( + bundle = make_release([], release_tag = "r2", upstream_tag = "b9002"), + checksums = make_checksums_with_source( + [], + release_tag = "r2", + upstream_tag = "b9002", + ), + ), + INSTALL_LLAMA_PREBUILT.ResolvedPublishedRelease( + bundle = make_release([], release_tag = "r1", upstream_tag = "b9001"), + checksums = make_checksums_with_source( + ["llama-b9001-bin-ubuntu-x64.tar.gz"], + release_tag = "r1", + upstream_tag = "b9001", + ), + ), + ] + + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "iter_resolved_published_releases", + lambda requested_tag, published_repo, published_release_tag = "": iter( + releases + ), + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "github_release_assets", + lambda repo, tag: ( + {} + if tag == "b9002" + else { + f"llama-{tag}-bin-ubuntu-x64.tar.gz": f"https://example.com/llama-{tag}-bin-ubuntu-x64.tar.gz" + } + ), + ) + + _requested_tag, plans = resolve_install_release_plans( + "latest", + host, + "unslothai/llama.cpp", + "", + max_release_fallbacks = 2, + ) + + assert len(plans) == 1 + assert plans[0].release_tag == "r1" + assert plans[0].llama_tag == "b9001" + + def test_malformed_release_fallback_env_uses_default(self, monkeypatch): + monkeypatch.setenv("UNSLOTH_LLAMA_MAX_PREBUILT_RELEASE_FALLBACKS", "not-an-int") + assert ( + env_int("UNSLOTH_LLAMA_MAX_PREBUILT_RELEASE_FALLBACKS", 3, minimum = 1) == 3 + ) + + def test_import_with_malformed_release_fallback_env_does_not_crash( + self, monkeypatch + ): + monkeypatch.setenv("UNSLOTH_LLAMA_MAX_PREBUILT_RELEASE_FALLBACKS", "bad-value") + spec = importlib.util.spec_from_file_location( + "studio_install_llama_prebuilt_env_reload", + MODULE_PATH, + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + try: + spec.loader.exec_module(module) + assert module.DEFAULT_MAX_PREBUILT_RELEASE_FALLBACKS == 2 + finally: + sys.modules.pop(spec.name, None) + + +# =========================================================================== +# N. windows_cuda_attempts # =========================================================================== @@ -753,7 +1430,7 @@ class TestWindowsCudaAttempts: # =========================================================================== -# L. resolve_upstream_asset_choice -- platform routing +# O. resolve_upstream_asset_choice -- platform routing # =========================================================================== diff --git a/tests/studio/install/test_validate_llama_prebuilt.py b/tests/studio/install/test_validate_llama_prebuilt.py new file mode 100644 index 0000000000..0f384b715e --- /dev/null +++ b/tests/studio/install/test_validate_llama_prebuilt.py @@ -0,0 +1,175 @@ +import importlib.util +import sys +from pathlib import Path + + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[3] +MODULE_PATH = REPO_ROOT / "validate-llama-prebuilt.py" + +if not MODULE_PATH.is_file(): + pytest.skip( + f"validate-llama-prebuilt.py not present at {MODULE_PATH}", + allow_module_level = True, + ) + +SPEC = importlib.util.spec_from_file_location("validate_llama_prebuilt", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +VALIDATE = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = VALIDATE +SPEC.loader.exec_module(VALIDATE) + + +def test_build_local_approved_checksums_uses_staged_upstream_tag( + tmp_path: Path, monkeypatch +): + stage_dir = tmp_path / "release-1" + stage_dir.mkdir() + asset_path = stage_dir / "app-test-linux-x64-cuda12-newer.tar.gz" + asset_path.write_bytes(b"bundle") + sibling_checksums = stage_dir / VALIDATE.installer.DEFAULT_PUBLISHED_SHA256_ASSET + sibling_checksums.write_text( + """ +{ + "schema_version": 1, + "component": "llama.cpp", + "release_tag": "release-1", + "upstream_tag": "b9001", + "source_commit": "deadbeef", + "artifacts": { + "llama.cpp-source-b9001.tar.gz": { + "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "repo": "ggml-org/llama.cpp", + "kind": "upstream-source" + } + } +} + """.strip() + + "\n", + encoding = "utf-8", + ) + asset = VALIDATE.LocalAsset( + path = asset_path, + tag = "test", + name = asset_path.name, + install_kind = "linux-cuda", + source_kind = "app-bundle", + native_runnable = True, + bundle_profile = "cuda12-newer", + runtime_line = "cuda12", + ) + + checksums = VALIDATE.build_local_approved_checksums( + asset, + allow_network_source_hash = False, + ) + + assert checksums.release_tag == "release-1" + assert checksums.upstream_tag == "b9001" + assert "llama.cpp-source-b9001.tar.gz" in checksums.artifacts + assert "llama.cpp-source-test.tar.gz" not in checksums.artifacts + + +def test_validate_native_asset_passes_release_tag_and_upstream_tag( + tmp_path: Path, monkeypatch +): + stage_dir = tmp_path / "release-7" + stage_dir.mkdir() + asset_path = stage_dir / "app-test-linux-x64-cuda12-newer.tar.gz" + asset_path.write_bytes(b"bundle") + sibling_checksums = stage_dir / VALIDATE.installer.DEFAULT_PUBLISHED_SHA256_ASSET + sibling_checksums.write_text( + """ +{ + "schema_version": 1, + "component": "llama.cpp", + "release_tag": "release-7", + "upstream_tag": "b9007", + "source_commit": "deadbeef", + "artifacts": { + "llama.cpp-source-b9007.tar.gz": { + "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "repo": "ggml-org/llama.cpp", + "kind": "upstream-source" + } + } +} + """.strip() + + "\n", + encoding = "utf-8", + ) + asset = VALIDATE.LocalAsset( + path = asset_path, + tag = "test", + name = asset_path.name, + install_kind = "linux-cuda", + source_kind = "app-bundle", + native_runnable = True, + bundle_profile = "cuda12-newer", + runtime_line = "cuda12", + ) + + host = VALIDATE.installer.HostInfo( + system = "Linux", + machine = "x86_64", + is_windows = False, + is_linux = True, + is_macos = False, + is_x86_64 = True, + is_arm64 = False, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + monkeypatch.setattr(VALIDATE.installer, "detect_host", lambda: host) + monkeypatch.setattr( + VALIDATE.installer, + "download_validation_model", + lambda probe_path, cache_path: probe_path.write_bytes(b"probe"), + ) + + captured = {} + + def fake_validate_prebuilt_attempts( + attempts, + host, + install_dir, + work_dir, + probe_path, + *, + requested_tag, + llama_tag, + release_tag, + approved_checksums, + initial_fallback_used = False, + existing_install_dir = None, + ): + captured["requested_tag"] = requested_tag + captured["llama_tag"] = llama_tag + captured["release_tag"] = release_tag + staging_dir = VALIDATE.installer.create_install_staging_dir(install_dir) + return attempts[0], staging_dir, False + + monkeypatch.setattr( + VALIDATE.installer, + "validate_prebuilt_attempts", + fake_validate_prebuilt_attempts, + ) + + record = VALIDATE.validate_native_asset( + asset, + keep_temp = False, + allow_network_source_hash = False, + ) + + assert record.status == "PASS" + assert captured == { + "requested_tag": "test", + "llama_tag": "b9007", + "release_tag": "release-7", + } From 76cb48be0ba73b98ffc277d5d9d142833e17529d Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 1 Apr 2026 06:12:02 -0700 Subject: [PATCH 14/44] fix: studio web search SSL failures and empty page content (#4754) - Fix SSL handshake failures (SSLV3_ALERT_HANDSHAKE_FAILURE, CERTIFICATE_VERIFY_FAILED) when fetching HTTPS pages by introducing _PinnedHTTPSConnection that separates TCP connect (to pinned IP) from TLS handshake (with real hostname for SNI/cert verification) - Fix SSRF DNS-rebinding vulnerability: previous impl swapped conn.host before connect(), causing fresh DNS resolution; new subclass keeps TCP pinned to validated IP - Fix SPA/JS-rendered doc sites returning empty content by rotating real browser User-Agents (Chrome/Firefox/Safari) - Strip nav/footer from HTML-to-Markdown output so article content is not buried under navigation chrome - Increase raw fetch cap from 64KB to 512KB so SSR article content is reached on GitBook/Docusaurus/Next.js pages - Fix IPv6 address bracketing in URL netloc construction - Hoist SSL context, handler classes, and stdlib imports to module level (created once, not per-call) - Use consistent UA across redirect hops to avoid breaking session-aware bot detection --- studio/backend/core/inference/_html_to_md.py | 16 +++- studio/backend/core/inference/tools.py | 97 +++++++++++++++++--- 2 files changed, 95 insertions(+), 18 deletions(-) diff --git a/studio/backend/core/inference/_html_to_md.py b/studio/backend/core/inference/_html_to_md.py index d96b8168e2..f999120ffb 100644 --- a/studio/backend/core/inference/_html_to_md.py +++ b/studio/backend/core/inference/_html_to_md.py @@ -17,18 +17,26 @@ from html.parser import HTMLParser __all__ = ["html_to_markdown"] -_SKIP_TAGS = frozenset({"script", "style", "head", "noscript", "svg", "math"}) +_SKIP_TAGS = frozenset( + { + "script", + "style", + "head", + "noscript", + "svg", + "math", + "nav", + "footer", + } +) _BLOCK_TAGS = frozenset( { "p", "div", "section", "article", - "header", - "footer", "main", "aside", - "nav", "figure", "figcaption", "details", diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 2ac8f76322..d425daa49d 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -8,14 +8,18 @@ Supports web search (DuckDuckGo), Python code execution, and terminal commands. """ import ast +import http.client import os os.environ["UNSLOTH_IS_PRESENT"] = "1" +import random +import ssl import subprocess import sys import tempfile import threading +import urllib.request from loggers import get_logger @@ -154,8 +158,74 @@ def execute_tool( return f"Unknown tool: {name}" -_MAX_PAGE_CHARS = 16000 # limit fetched page text -_MAX_FETCH_BYTES = _MAX_PAGE_CHARS * 4 + 1 # cap raw download size +_MAX_PAGE_CHARS = 16000 # limit fetched page text (after HTML-to-MD conversion) +# Raw download cap. Must be larger than _MAX_PAGE_CHARS because SSR pages +# embed large sections (CSS, JS, SVGs) that are stripped during +# HTML-to-Markdown conversion. 512 KB is enough to reach article content +# on GitBook / Next.js / Docusaurus pages whose alone can be 200 KB. +_MAX_FETCH_BYTES = 512 * 1024 + +_USER_AGENTS = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15", +) + +_tls_ctx = ssl.create_default_context() + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +class _PinnedHTTPSConnection(http.client.HTTPSConnection): + """HTTPS connection that connects to a pinned IP but uses a different + hostname for SNI and certificate verification. + + The SSRF IP-pinning rewrites URLs to raw IPs. A normal HTTPSConnection + would then send no SNI and verify the cert against the IP, both of which + fail. This subclass splits the two concerns: TCP connects to the pinned + IP (``host`` parameter) while TLS uses ``sni_hostname`` for the + ClientHello and cert check. + """ + + def __init__(self, host: str, *, sni_hostname: str, **kwargs): + super().__init__(host, **kwargs) + self._sni_hostname = sni_hostname + + def connect(self): + # TCP connect to the pinned IP stored in self.host (+ tunnel if + # a proxy is configured via set_tunnel, though we do not use one). + http.client.HTTPConnection.connect(self) + # TLS handshake with the real hostname for SNI + cert verification. + self.sock = self._context.wrap_socket( + self.sock, + server_hostname = self._sni_hostname, + ) + + +class _SNIHTTPSHandler(urllib.request.HTTPSHandler): + """HTTPS handler that sends the correct SNI hostname during TLS handshake. + + The SSRF IP-pinning rewrites URLs to raw IPs, which breaks SNI and cert + verification. This handler returns a ``_PinnedHTTPSConnection`` that + connects to the pinned IP but verifies TLS against the original hostname. + """ + + def __init__(self, hostname: str): + super().__init__(context = _tls_ctx) + self._sni_hostname = hostname + + def https_open(self, req): + return self.do_open(self._sni_connection, req) + + def _sni_connection(self, host, **kwargs): + kwargs["context"] = _tls_ctx + return _PinnedHTTPSConnection(host, sni_hostname = self._sni_hostname, **kwargs) def _validate_and_resolve_host(hostname: str, port: int) -> tuple[bool, str, str]: @@ -215,33 +285,32 @@ def _fetch_page_text( return reason try: - import urllib.request from urllib.error import HTTPError as _HTTPError from urllib.parse import urljoin, urlunparse - # Disable auto-redirect so we can validate each hop for SSRF. - # urllib raises HTTPError for 3xx when the handler returns None, - # so we catch that and extract the Location header manually. - class _NoRedirect(urllib.request.HTTPRedirectHandler): - def redirect_request(self, req, fp, code, msg, headers, newurl): - return None - - opener = urllib.request.build_opener(_NoRedirect) - max_bytes = max_chars * 4 + 1 + max_bytes = _MAX_FETCH_BYTES current_url = url current_host = parsed.hostname + ua = random.choice(_USER_AGENTS) for _hop in range(5): # Pin to the validated IP to prevent DNS rebinding. # Rewrite the URL to use the IP and set the Host header. cp = urlparse(current_url) - ip_netloc = f"{pinned_ip}:{cp.port}" if cp.port else pinned_ip + # Bracket IPv6 addresses so the netloc is valid in a URL. + ip_str = f"[{pinned_ip}]" if ":" in pinned_ip else pinned_ip + ip_netloc = f"{ip_str}:{cp.port}" if cp.port else ip_str pinned_url = urlunparse(cp._replace(netloc = ip_netloc)) + opener = urllib.request.build_opener( + _NoRedirect, + _SNIHTTPSHandler(current_host), + ) + req = urllib.request.Request( pinned_url, headers = { - "User-Agent": "UnslothStudio/1.0", + "User-Agent": ua, "Host": current_host, }, ) From d22b2a18f976814e5a751459e045435047334972 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 1 Apr 2026 06:12:17 -0700 Subject: [PATCH 15/44] fix: add tokenizers to no-torch deps and TORCH_CONSTRAINT for arm64 macOS py313+ (#4748) * fix: add tokenizers to no-torch runtime deps and add TORCH_CONSTRAINT for arm64 macOS py313+ Two installer fixes: 1. Add `tokenizers` to `no-torch-runtime.txt` before `transformers`. Without it, `from transformers import AutoConfig` crashes on startup because `--no-deps` skips transitive dependencies. 2. Add `TORCH_CONSTRAINT` variable to `install.sh`. On arm64 macOS with Python 3.13+, tighten the torch requirement to `>=2.6` since torch <2.6 has no cp313 arm64 wheels. The variable replaces the previously hard-coded constraint in the uv pip install line. Includes 66 tests (42 pytest + 24 bash) covering: - Structural checks on install.sh, install.ps1, no-torch-runtime.txt - Shell snippet tests with mocked python for 13 platform/version combos - Mock uv integration verifying correct constraint string - E2E venv tests on Python 3.12 and 3.13 confirming AutoConfig works - Negative control proving AutoConfig fails without tokenizers - Full no-torch sandbox regression guards (safetensors, huggingface_hub) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix incomplete no-torch manifest and align E2E tests with real --no-deps path - Add missing transitive deps to no-torch-runtime.txt that are required under --no-deps: regex, typing_extensions, filelock, httpx, httpcore, certifi, idna, anyio, sniffio, h11. Without these, `from transformers import AutoConfig` still fails after install.sh --no-torch. - Change all E2E tests to use --no-deps (matching what install.sh does) instead of normal dep resolution. Previous tests passed even with an incomplete manifest because uv backfilled transitive deps. - Rewrite negative control to derive from the real no-torch-runtime.txt with tokenizers stripped, proving the specific fix matters. - Replace GNU-only sed -i with heredoc in shell test for macOS compat. - Remove unused os/sys imports from Python test file. - Quote SKIP_TORCH and mock uv paths in bash -c strings. * Assert install succeeds before checking import results in E2E tests Address review feedback: test_torch_not_importable and test_tokenizers_directly_importable in Group 3 now assert that uv pip install returns 0 before checking import behavior. This prevents false positives when the install itself fails silently. * Assert install succeeds in negative control and tighten error check - Add missing install-success assertion in test_negative_control_no_tokenizers to prevent false positives from network/install failures. - Tighten error message check to look for "tokenizers" in stderr or ModuleNotFoundError, rather than the generic "No module" substring which could match unrelated import failures. * [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> --- install.sh | 13 +- .../backend/requirements/no-torch-runtime.txt | 15 + tests/python/conftest.py | 3 + .../test_tokenizers_and_torch_constraint.py | 572 ++++++++++++++++++ tests/run_all.sh | 2 + tests/sh/test_torch_constraint.sh | 266 ++++++++ 6 files changed, 870 insertions(+), 1 deletion(-) create mode 100644 tests/python/test_tokenizers_and_torch_constraint.py create mode 100644 tests/sh/test_torch_constraint.sh diff --git a/install.sh b/install.sh index 5d8f8c68cf..2d9a368555 100755 --- a/install.sh +++ b/install.sh @@ -949,6 +949,17 @@ if [ -x "$VENV_DIR/bin/python" ]; then substep "${VENV_DIR}" fi +# Default torch constraint -- tightened for Python 3.13+ on arm64 macOS +# (torch <2.6 has no cp313 macOS arm64 wheels) +TORCH_CONSTRAINT="torch>=2.4,<2.11.0" +if [ "$SKIP_TORCH" = false ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then + _PY_MINOR=$("$VENV_DIR/bin/python" -c \ + "import sys; print(sys.version_info.minor)" 2>/dev/null || echo "0") + if [ "$_PY_MINOR" -ge 13 ] 2>/dev/null; then + TORCH_CONSTRAINT="torch>=2.6,<2.11.0" + fi +fi + # ── Resolve repo root (for --local installs) ── _REPO_ROOT="$(cd "$(dirname "$0" 2>/dev/null || echo ".")" && pwd)" @@ -1049,7 +1060,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then substep "skipping PyTorch (--no-torch or Intel Mac x86_64)." "$C_WARN" else substep "installing PyTorch ($TORCH_INDEX_URL)..." - run_install_cmd "install PyTorch" uv pip install --python "$_VENV_PY" "torch>=2.4,<2.11.0" torchvision torchaudio \ + run_install_cmd "install PyTorch" uv pip install --python "$_VENV_PY" "$TORCH_CONSTRAINT" torchvision torchaudio \ --index-url "$TORCH_INDEX_URL" fi # Fresh: Step 2 - install unsloth, preserving pre-installed torch diff --git a/studio/backend/requirements/no-torch-runtime.txt b/studio/backend/requirements/no-torch-runtime.txt index 7133fe8922..3b822ac2a4 100644 --- a/studio/backend/requirements/no-torch-runtime.txt +++ b/studio/backend/requirements/no-torch-runtime.txt @@ -28,6 +28,21 @@ peft>=0.18.0,!=0.11.0 huggingface_hub>=0.34.0 hf_transfer diffusers + +# Transitive deps required because this file is installed with --no-deps. +# Without these, `from transformers import AutoConfig` fails at import time. +regex +typing_extensions +filelock +httpx +httpcore +certifi +idna +anyio +sniffio +h11 + +tokenizers 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 trl>=0.18.2,!=0.19.0,<=0.24.0 sentence-transformers diff --git a/tests/python/conftest.py b/tests/python/conftest.py index 66542d2451..9129e384e5 100644 --- a/tests/python/conftest.py +++ b/tests/python/conftest.py @@ -5,3 +5,6 @@ def pytest_configure(config): config.addinivalue_line( "markers", "server: heavyweight tests requiring studio venv" ) + config.addinivalue_line( + "markers", "e2e: end-to-end tests requiring network and venv creation" + ) diff --git a/tests/python/test_tokenizers_and_torch_constraint.py b/tests/python/test_tokenizers_and_torch_constraint.py new file mode 100644 index 0000000000..4be53d2d03 --- /dev/null +++ b/tests/python/test_tokenizers_and_torch_constraint.py @@ -0,0 +1,572 @@ +""" +Tests for two install fixes: + 1. tokenizers added to no-torch-runtime.txt (prevents AutoConfig crash) + 2. TORCH_CONSTRAINT variable in install.sh (arm64 macOS + py313+ -> torch>=2.6) +""" + +from __future__ import annotations + +import pathlib +import re +import subprocess +import textwrap + +import pytest + +# ── Locate source files relative to this test ────────────────────────── +_TESTS_DIR = pathlib.Path(__file__).resolve().parent.parent # tests/ +_REPO_ROOT = _TESTS_DIR.parent # unsloth/ +_INSTALL_SH = _REPO_ROOT / "install.sh" +_INSTALL_PS1 = _REPO_ROOT / "install.ps1" +_NO_TORCH_RT = ( + _REPO_ROOT / "studio" / "backend" / "requirements" / "no-torch-runtime.txt" +) + + +def _read(path: pathlib.Path) -> str: + return path.read_text(encoding = "utf-8") + + +def _lines(path: pathlib.Path) -> list[str]: + """Return non-comment, non-blank lines stripped.""" + return [ + ln.strip() + for ln in _read(path).splitlines() + if ln.strip() and not ln.strip().startswith("#") + ] + + +# ====================================================================== +# Group 1 -- Structural checks (no network, instant) +# ====================================================================== +class TestStructuralTokenizers: + """Verify tokenizers presence and ordering in no-torch-runtime.txt.""" + + def test_tokenizers_present(self): + """tokenizers must be a standalone package line.""" + pkgs = _lines(_NO_TORCH_RT) + bare_names = [ + p.split(">")[0].split("<")[0].split("!")[0].split("=")[0] for p in pkgs + ] + assert "tokenizers" in bare_names + + def test_tokenizers_before_transformers(self): + """tokenizers should appear before transformers (install order intent).""" + pkgs = _lines(_NO_TORCH_RT) + bare_names = [ + p.split(">")[0].split("<")[0].split("!")[0].split("=")[0] for p in pkgs + ] + idx_tok = bare_names.index("tokenizers") + idx_tf = bare_names.index("transformers") + assert idx_tok < idx_tf, ( + f"tokenizers at index {idx_tok} should appear before " + f"transformers at index {idx_tf}" + ) + + def test_torch_not_in_no_torch_file(self): + """torch itself must NOT be listed in the no-torch requirements.""" + pkgs = _lines(_NO_TORCH_RT) + bare_names = [ + p.split(">")[0].split("<")[0].split("!")[0].split("=")[0] for p in pkgs + ] + assert "torch" not in bare_names + + +class TestStructuralTorchConstraint: + """Verify TORCH_CONSTRAINT wiring in install.sh.""" + + _sh = _read(_INSTALL_SH) + + def test_default_assignment_exists(self): + assert 'TORCH_CONSTRAINT="torch>=2.4,<2.11.0"' in self._sh + + def test_tightened_assignment_exists(self): + assert 'TORCH_CONSTRAINT="torch>=2.6,<2.11.0"' in self._sh + + def test_variable_used_in_pip_install(self): + """$TORCH_CONSTRAINT must appear in a uv pip install line.""" + assert '"$TORCH_CONSTRAINT"' in self._sh + + def test_hardcoded_torch_constraint_only_once(self): + """The hard-coded torch>=2.4,<2.11.0 string should appear exactly once + in install.sh (the default assignment), not in pip install lines.""" + count = self._sh.count('"torch>=2.4,<2.11.0"') + assert count == 1, f"Expected 1, found {count}" + + def test_tightening_guarded_by_skip_torch(self): + """The block must check SKIP_TORCH=false.""" + # Find the tightening if-block + m = re.search( + r"if\s.*SKIP_TORCH.*=\s*false.*&&.*OS.*=.*macos.*&&.*_ARCH.*=.*arm64", + self._sh, + ) + assert m is not None, "Guard not found: SKIP_TORCH + macos + arm64" + + def test_tightening_guarded_by_arch(self): + m = re.search(r"_ARCH.*=.*arm64", self._sh) + assert m is not None + + def test_tightening_guarded_by_os(self): + m = re.search(r"OS.*=.*macos", self._sh) + assert m is not None + + +class TestStructuralInstallPs1Unchanged: + """install.ps1 should NOT have TORCH_CONSTRAINT variable.""" + + _ps1 = _read(_INSTALL_PS1) + + def test_no_torch_constraint_variable(self): + assert "TORCH_CONSTRAINT" not in self._ps1 + assert "$TorchConstraint" not in self._ps1 + + def test_hardcoded_torch_constraint_present(self): + assert '"torch>=2.4,<2.11.0"' in self._ps1 + + +# ====================================================================== +# Group 2 -- Shell snippet tests (bash subprocess, mocked python) +# ====================================================================== +class TestTorchConstraintShell: + """Test the TORCH_CONSTRAINT block using bash subprocesses with + mocked python binaries that return controlled minor versions.""" + + # The extracted snippet we test in isolation. We override OS, _ARCH, + # SKIP_TORCH, and provide a mock python at $VENV_DIR/bin/python. + _SNIPPET_TEMPLATE = textwrap.dedent(r""" + #!/bin/bash + set -e + SKIP_TORCH={skip_torch} + OS="{os}" + _ARCH="{arch}" + VENV_DIR="{venv_dir}" + + TORCH_CONSTRAINT="torch>=2.4,<2.11.0" + if [ "$SKIP_TORCH" = false ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then + _PY_MINOR=$("$VENV_DIR/bin/python" -c \ + "import sys; print(sys.version_info.minor)" 2>/dev/null || echo "0") + if [ "$_PY_MINOR" -ge 13 ] 2>/dev/null; then + TORCH_CONSTRAINT="torch>=2.6,<2.11.0" + fi + fi + echo "$TORCH_CONSTRAINT" + """).strip() + + @staticmethod + def _make_mock_python(tmp_path: pathlib.Path, minor: int) -> pathlib.Path: + """Create a mock python that prints a controlled minor version.""" + venv = tmp_path / "venv" + bin_dir = venv / "bin" + bin_dir.mkdir(parents = True, exist_ok = True) + mock_py = bin_dir / "python" + mock_py.write_text( + textwrap.dedent(f"""\ + #!/bin/bash + # Mock python: always report minor={minor} + if echo "$@" | grep -q "sys.version_info.minor"; then + echo "{minor}" + else + echo "0" + fi + """) + ) + mock_py.chmod(0o755) + return venv + + def _run( + self, + tmp_path: pathlib.Path, + *, + py_minor: int = 12, + os_val: str = "macos", + arch: str = "arm64", + skip_torch: str = "false", + ) -> str: + venv = self._make_mock_python(tmp_path, py_minor) + script = self._SNIPPET_TEMPLATE.format( + skip_torch = skip_torch, + os = os_val, + arch = arch, + venv_dir = str(venv), + ) + script_file = tmp_path / "test_snippet.sh" + script_file.write_text(script) + script_file.chmod(0o755) + result = subprocess.run( + ["bash", str(script_file)], + capture_output = True, + text = True, + timeout = 10, + ) + assert result.returncode == 0, f"Script failed: {result.stderr}" + return result.stdout.strip() + + # -- arm64 macOS tightening cases -- + + def test_arm64_macos_py313_tightened(self, tmp_path): + out = self._run(tmp_path, py_minor = 13, os_val = "macos", arch = "arm64") + assert out == "torch>=2.6,<2.11.0" + + def test_arm64_macos_py314_tightened(self, tmp_path): + out = self._run(tmp_path, py_minor = 14, os_val = "macos", arch = "arm64") + assert out == "torch>=2.6,<2.11.0" + + # -- arm64 macOS default (older python) -- + + def test_arm64_macos_py312_default(self, tmp_path): + out = self._run(tmp_path, py_minor = 12, os_val = "macos", arch = "arm64") + assert out == "torch>=2.4,<2.11.0" + + def test_arm64_macos_py311_default(self, tmp_path): + out = self._run(tmp_path, py_minor = 11, os_val = "macos", arch = "arm64") + assert out == "torch>=2.4,<2.11.0" + + # -- Linux (unaffected) -- + + def test_linux_x86_py313_default(self, tmp_path): + out = self._run(tmp_path, py_minor = 13, os_val = "linux", arch = "x86_64") + assert out == "torch>=2.4,<2.11.0" + + def test_linux_aarch64_py313_default(self, tmp_path): + out = self._run(tmp_path, py_minor = 13, os_val = "linux", arch = "aarch64") + assert out == "torch>=2.4,<2.11.0" + + # -- Intel Mac (arch mismatch) -- + + def test_intel_mac_x86_py313_default(self, tmp_path): + out = self._run(tmp_path, py_minor = 13, os_val = "macos", arch = "x86_64") + assert out == "torch>=2.4,<2.11.0" + + # -- SKIP_TORCH bypass -- + + def test_skip_torch_arm64_macos_py313_default(self, tmp_path): + out = self._run( + tmp_path, + py_minor = 13, + os_val = "macos", + arch = "arm64", + skip_torch = "true", + ) + assert out == "torch>=2.4,<2.11.0" + + # -- WSL -- + + def test_wsl_py313_default(self, tmp_path): + out = self._run(tmp_path, py_minor = 13, os_val = "wsl", arch = "x86_64") + assert out == "torch>=2.4,<2.11.0" + + # -- Edge cases -- + + def test_py_minor_0_fallback_default(self, tmp_path): + """If python query fails (returns 0), should stay at default.""" + out = self._run(tmp_path, py_minor = 0, os_val = "macos", arch = "arm64") + assert out == "torch>=2.4,<2.11.0" + + def test_boundary_py_minor_12_not_tightened(self, tmp_path): + out = self._run(tmp_path, py_minor = 12, os_val = "macos", arch = "arm64") + assert out == "torch>=2.4,<2.11.0" + + def test_boundary_py_minor_13_tightened(self, tmp_path): + out = self._run(tmp_path, py_minor = 13, os_val = "macos", arch = "arm64") + assert out == "torch>=2.6,<2.11.0" + + def test_mock_uv_receives_correct_constraint(self, tmp_path): + """Verify a mock uv would receive the correct constraint string.""" + venv = self._make_mock_python(tmp_path, minor = 13) + + # Create a mock uv that logs its arguments + mock_uv = tmp_path / "mock_uv" + log_file = tmp_path / "uv_log.txt" + mock_uv.write_text( + textwrap.dedent(f"""\ + #!/bin/bash + echo "$@" >> {log_file} + """) + ) + mock_uv.chmod(0o755) + + script = textwrap.dedent(f"""\ + #!/bin/bash + set -e + SKIP_TORCH=false + OS="macos" + _ARCH="arm64" + VENV_DIR="{venv}" + + TORCH_CONSTRAINT="torch>=2.4,<2.11.0" + if [ "$SKIP_TORCH" = false ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then + _PY_MINOR=$("$VENV_DIR/bin/python" -c \\ + "import sys; print(sys.version_info.minor)" 2>/dev/null || echo "0") + if [ "$_PY_MINOR" -ge 13 ] 2>/dev/null; then + TORCH_CONSTRAINT="torch>=2.6,<2.11.0" + fi + fi + # Simulate the uv pip install line + {mock_uv} pip install --python "$VENV_DIR/bin/python" "$TORCH_CONSTRAINT" torchvision torchaudio + """) + script_file = tmp_path / "test_uv.sh" + script_file.write_text(script) + script_file.chmod(0o755) + + result = subprocess.run( + ["bash", str(script_file)], + capture_output = True, + text = True, + timeout = 10, + ) + assert result.returncode == 0, f"Script failed: {result.stderr}" + logged = log_file.read_text() + assert "torch>=2.6,<2.11.0" in logged, f"uv log: {logged}" + + def test_mock_uv_receives_default_constraint(self, tmp_path): + """On py3.12 arm64 macOS, uv should receive the default constraint.""" + venv = self._make_mock_python(tmp_path, minor = 12) + mock_uv = tmp_path / "mock_uv" + log_file = tmp_path / "uv_log.txt" + mock_uv.write_text( + textwrap.dedent(f"""\ + #!/bin/bash + echo "$@" >> {log_file} + """) + ) + mock_uv.chmod(0o755) + + script = textwrap.dedent(f"""\ + #!/bin/bash + set -e + SKIP_TORCH=false + OS="macos" + _ARCH="arm64" + VENV_DIR="{venv}" + + TORCH_CONSTRAINT="torch>=2.4,<2.11.0" + if [ "$SKIP_TORCH" = false ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then + _PY_MINOR=$("$VENV_DIR/bin/python" -c \\ + "import sys; print(sys.version_info.minor)" 2>/dev/null || echo "0") + if [ "$_PY_MINOR" -ge 13 ] 2>/dev/null; then + TORCH_CONSTRAINT="torch>=2.6,<2.11.0" + fi + fi + {mock_uv} pip install --python "$VENV_DIR/bin/python" "$TORCH_CONSTRAINT" torchvision torchaudio + """) + script_file = tmp_path / "test_uv.sh" + script_file.write_text(script) + script_file.chmod(0o755) + + result = subprocess.run( + ["bash", str(script_file)], + capture_output = True, + text = True, + timeout = 10, + ) + assert result.returncode == 0, f"Script failed: {result.stderr}" + logged = log_file.read_text() + assert "torch>=2.4,<2.11.0" in logged, f"uv log: {logged}" + + +# ====================================================================== +# Group 3 -- E2E tokenizers fix (requires network, ~2-5 min) +# ====================================================================== +@pytest.mark.e2e +class TestE2ETokenizersFix: + """Creates real uv venvs to verify tokenizers + transformers work + without torch installed.""" + + @staticmethod + def _create_venv(tmp_path: pathlib.Path, name: str, py: str) -> pathlib.Path: + venv = tmp_path / name + result = subprocess.run( + ["uv", "venv", str(venv), "--python", py], + capture_output = True, + text = True, + timeout = 120, + ) + if result.returncode != 0: + pytest.skip(f"uv venv creation failed for {py}: {result.stderr}") + return venv + + @staticmethod + def _pip_install(venv: pathlib.Path, *args: str) -> subprocess.CompletedProcess: + py = str(venv / "bin" / "python") + cmd = ["uv", "pip", "install", "--python", py, *args] + return subprocess.run(cmd, capture_output = True, text = True, timeout = 300) + + @staticmethod + def _run_python(venv: pathlib.Path, code: str) -> subprocess.CompletedProcess: + py = str(venv / "bin" / "python") + return subprocess.run( + [py, "-c", code], + capture_output = True, + text = True, + timeout = 60, + ) + + @pytest.mark.parametrize("py_version", ["3.12", "3.13"]) + def test_autoconfig_works_with_no_torch_runtime(self, tmp_path, py_version): + """Install from no-torch-runtime.txt with --no-deps (matching the + real install.sh path), then verify AutoConfig imports successfully.""" + venv = self._create_venv(tmp_path, f"tok-{py_version}", py_version) + r = self._pip_install(venv, "--no-deps", "-r", str(_NO_TORCH_RT)) + assert r.returncode == 0, f"Install failed: {r.stderr}" + + result = self._run_python( + venv, "from transformers import AutoConfig; print('OK')" + ) + assert ( + result.returncode == 0 + ), f"AutoConfig import failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" + assert "OK" in result.stdout + + @pytest.mark.parametrize("py_version", ["3.12", "3.13"]) + def test_tokenizers_directly_importable(self, tmp_path, py_version): + venv = self._create_venv(tmp_path, f"tok-imp-{py_version}", py_version) + r = self._pip_install(venv, "--no-deps", "-r", str(_NO_TORCH_RT)) + assert r.returncode == 0, f"Install failed: {r.stderr}" + result = self._run_python(venv, "import tokenizers; print('OK')") + assert result.returncode == 0, f"Failed: {result.stderr}" + + @pytest.mark.parametrize("py_version", ["3.12", "3.13"]) + def test_torch_not_importable(self, tmp_path, py_version): + """In the no-torch scenario, torch should not be available.""" + venv = self._create_venv(tmp_path, f"no-torch-{py_version}", py_version) + r = self._pip_install(venv, "--no-deps", "-r", str(_NO_TORCH_RT)) + assert r.returncode == 0, f"Install failed: {r.stderr}" + result = self._run_python(venv, "import torch") + assert result.returncode != 0, "torch should NOT be importable" + + def test_negative_control_no_tokenizers(self, tmp_path): + """Without tokenizers, AutoConfig should fail. We create a copy of + no-torch-runtime.txt with the tokenizers line removed.""" + venv = self._create_venv(tmp_path, "neg-ctrl", "3.12") + req_no_tokenizers = tmp_path / "no-tokenizers.txt" + req_no_tokenizers.write_text( + "\n".join( + line + for line in _read(_NO_TORCH_RT).splitlines() + if line.strip() != "tokenizers" + ), + encoding = "utf-8", + ) + r = self._pip_install(venv, "--no-deps", "-r", str(req_no_tokenizers)) + assert r.returncode == 0, f"Install failed: {r.stderr}" + result = self._run_python(venv, "from transformers import AutoConfig") + assert ( + result.returncode != 0 + ), "AutoConfig should fail without tokenizers installed" + assert ( + "tokenizers" in result.stderr.lower() + or "ModuleNotFoundError" in result.stderr + ) + + +# ====================================================================== +# Group 4 -- Integration: install.sh reads no-torch-runtime.txt correctly +# ====================================================================== +class TestInstallShNoTorchIntegration: + """Verify install.sh has the correct no-torch-runtime.txt wiring.""" + + _sh = _read(_INSTALL_SH) + + def test_find_no_torch_runtime_exists(self): + assert "_find_no_torch_runtime()" in self._sh + + def test_no_deps_invocation_for_migrated(self): + """Migrated path should use --no-deps -r.""" + assert '--no-deps -r "$_NO_TORCH_RT"' in self._sh + + def test_no_deps_invocation_for_fresh(self): + """Fresh install path should also use --no-deps -r.""" + # Count occurrences of the no-deps -r pattern + count = self._sh.count('--no-deps -r "$_NO_TORCH_RT"') + assert count >= 2, f"Expected >=2 no-deps -r invocations, found {count}" + + def test_mock_uv_skip_torch_reads_requirements(self, tmp_path): + """When SKIP_TORCH=true, the _find_no_torch_runtime path should be used.""" + # We test this structurally: verify the SKIP_TORCH=true blocks contain + # _find_no_torch_runtime calls + skip_blocks = re.findall( + r'if \[ "\$SKIP_TORCH" = true \].*?(?=\n (?:else|elif|fi))', + self._sh, + re.DOTALL, + ) + found = any("_find_no_torch_runtime" in block for block in skip_blocks) + assert found, "SKIP_TORCH=true block should call _find_no_torch_runtime" + + +# ====================================================================== +# Group 5 -- Full no-torch sandbox (requires network, ~5 min) +# ====================================================================== +@pytest.mark.e2e +class TestE2EFullNoTorchSandbox: + """Creates venvs and installs the actual no-torch-runtime.txt.""" + + @staticmethod + def _create_venv(tmp_path: pathlib.Path, name: str) -> pathlib.Path: + venv = tmp_path / name + result = subprocess.run( + ["uv", "venv", str(venv), "--python", "3.12"], + capture_output = True, + text = True, + timeout = 120, + ) + if result.returncode != 0: + pytest.skip(f"uv venv creation failed: {result.stderr}") + return venv + + @staticmethod + def _pip_install(venv: pathlib.Path, *args: str) -> subprocess.CompletedProcess: + py = str(venv / "bin" / "python") + cmd = ["uv", "pip", "install", "--python", py, *args] + return subprocess.run(cmd, capture_output = True, text = True, timeout = 600) + + @staticmethod + def _run_python(venv: pathlib.Path, code: str) -> subprocess.CompletedProcess: + py = str(venv / "bin" / "python") + return subprocess.run( + [py, "-c", code], + capture_output = True, + text = True, + timeout = 60, + ) + + def test_autoconfig_succeeds(self, tmp_path): + """The real bug fix: install with --no-deps (matching install.sh) + and verify from transformers import AutoConfig works.""" + venv = self._create_venv(tmp_path, "full-no-torch") + r = self._pip_install(venv, "--no-deps", "-r", str(_NO_TORCH_RT)) + assert r.returncode == 0, f"Install failed: {r.stderr}" + result = self._run_python( + venv, "from transformers import AutoConfig; print('OK')" + ) + assert ( + result.returncode == 0 + ), f"AutoConfig failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" + + def test_torch_not_importable(self, tmp_path): + """With --no-deps (as install.sh uses), torch must not be pulled in.""" + venv = self._create_venv(tmp_path, "no-torch-check") + r = self._pip_install(venv, "--no-deps", "-r", str(_NO_TORCH_RT)) + assert r.returncode == 0, f"Install failed: {r.stderr}" + result = self._run_python(venv, "import torch") + assert result.returncode != 0, "torch should NOT be importable" + + def test_tokenizers_importable(self, tmp_path): + venv = self._create_venv(tmp_path, "tok-check") + r = self._pip_install(venv, "--no-deps", "-r", str(_NO_TORCH_RT)) + assert r.returncode == 0, f"Install failed: {r.stderr}" + result = self._run_python(venv, "import tokenizers; print('OK')") + assert result.returncode == 0, f"tokenizers import failed: {result.stderr}" + + def test_safetensors_importable(self, tmp_path): + venv = self._create_venv(tmp_path, "st-check") + r = self._pip_install(venv, "--no-deps", "-r", str(_NO_TORCH_RT)) + assert r.returncode == 0, f"Install failed: {r.stderr}" + result = self._run_python(venv, "import safetensors; print('OK')") + assert result.returncode == 0, f"safetensors import failed: {result.stderr}" + + def test_huggingface_hub_importable(self, tmp_path): + venv = self._create_venv(tmp_path, "hfhub-check") + r = self._pip_install(venv, "--no-deps", "-r", str(_NO_TORCH_RT)) + assert r.returncode == 0, f"Install failed: {r.stderr}" + result = self._run_python(venv, "import huggingface_hub; print('OK')") + assert result.returncode == 0, f"huggingface_hub import failed: {result.stderr}" diff --git a/tests/run_all.sh b/tests/run_all.sh index a1516aa6c8..6525263d8f 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -7,6 +7,7 @@ TESTS_DIR="$(cd "$(dirname "$0")" && pwd)" echo "=== Bash tests ===" sh "$TESTS_DIR/sh/test_get_torch_index_url.sh" sh "$TESTS_DIR/sh/test_mac_intel_compat.sh" +sh "$TESTS_DIR/sh/test_torch_constraint.sh" echo "" echo "=== Python tests ===" @@ -14,6 +15,7 @@ python -m pytest "$TESTS_DIR/python/test_install_python_stack.py" -v python -m pytest "$TESTS_DIR/python/test_cross_platform_parity.py" -v python -m pytest "$TESTS_DIR/python/test_no_torch_filtering.py" -v python -m pytest "$TESTS_DIR/python/test_studio_import_no_torch.py" -v +python -m pytest "$TESTS_DIR/python/test_tokenizers_and_torch_constraint.py" -v -k "not e2e" echo "" echo "All tests passed." diff --git a/tests/sh/test_torch_constraint.sh b/tests/sh/test_torch_constraint.sh new file mode 100644 index 0000000000..8766635209 --- /dev/null +++ b/tests/sh/test_torch_constraint.sh @@ -0,0 +1,266 @@ +#!/bin/bash +# Tests for TORCH_CONSTRAINT variable in install.sh and tokenizers in no-torch-runtime.txt. +# Follows the same assertion pattern as test_mac_intel_compat.sh. +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALL_SH="$SCRIPT_DIR/../../install.sh" +INSTALL_PS1="$SCRIPT_DIR/../../install.ps1" +NO_TORCH_RT="$SCRIPT_DIR/../../studio/backend/requirements/no-torch-runtime.txt" +PASS=0 +FAIL=0 + +assert_eq() { + _label="$1"; _expected="$2"; _actual="$3" + if [ "$_actual" = "$_expected" ]; then + echo " PASS: $_label" + PASS=$((PASS + 1)) + else + echo " FAIL: $_label (expected '$_expected', got '$_actual')" + FAIL=$((FAIL + 1)) + fi +} + +assert_contains() { + _label="$1"; _haystack="$2"; _needle="$3" + if echo "$_haystack" | grep -qF "$_needle"; then + echo " PASS: $_label" + PASS=$((PASS + 1)) + else + echo " FAIL: $_label (expected to find '$_needle')" + FAIL=$((FAIL + 1)) + fi +} + +assert_not_contains() { + _label="$1"; _haystack="$2"; _needle="$3" + if echo "$_haystack" | grep -qF "$_needle"; then + echo " FAIL: $_label (found '$_needle' but should not)" + FAIL=$((FAIL + 1)) + else + echo " PASS: $_label" + PASS=$((PASS + 1)) + fi +} + +# ── Helper: create a mock python that reports a given minor version ── +make_mock_python() { + _minor="$1" + _venv_dir="$2" + mkdir -p "$_venv_dir/bin" + cat > "$_venv_dir/bin/python" <=2.4,<2.11.0\" + if [ \"\$SKIP_TORCH\" = false ] && [ \"\$OS\" = \"macos\" ] && [ \"\$_ARCH\" = \"arm64\" ]; then + _PY_MINOR=\$(\"\$VENV_DIR/bin/python\" -c \"import sys; print(sys.version_info.minor)\" 2>/dev/null || echo \"0\") + if [ \"\$_PY_MINOR\" -ge 13 ] 2>/dev/null; then + TORCH_CONSTRAINT=\"torch>=2.6,<2.11.0\" + fi + fi + echo \"\$TORCH_CONSTRAINT\" + " 2>/dev/null +} + +# ====================================================================== +# Structural checks +# ====================================================================== +echo "=== Structural: TORCH_CONSTRAINT in install.sh ===" + +_SH_CONTENT=$(cat "$INSTALL_SH") + +_count=$(grep -c 'TORCH_CONSTRAINT="torch>=2.4,<2.11.0"' "$INSTALL_SH" || true) +assert_eq "default TORCH_CONSTRAINT assignment exists" "1" "$_count" + +_count=$(grep -c 'TORCH_CONSTRAINT="torch>=2.6,<2.11.0"' "$INSTALL_SH" || true) +assert_eq "tightened TORCH_CONSTRAINT assignment exists" "1" "$_count" + +_count=$(grep -c '"\$TORCH_CONSTRAINT"' "$INSTALL_SH" || true) +_has_var=$([ "$_count" -ge 1 ] && echo "yes" || echo "no") +assert_eq "\$TORCH_CONSTRAINT used in pip install" "yes" "$_has_var" + +# Hardcoded torch>=2.4,<2.11.0 should only appear once (the default assignment) +_hardcoded=$(grep -c '"torch>=2.4,<2.11.0"' "$INSTALL_SH" || true) +assert_eq "hardcoded torch>=2.4 appears exactly once" "1" "$_hardcoded" + +echo "" +echo "=== Structural: tokenizers in no-torch-runtime.txt ===" + +_has_tokenizers=$(grep -c '^tokenizers$' "$NO_TORCH_RT" || true) +assert_eq "tokenizers present as standalone line" "1" "$_has_tokenizers" + +# tokenizers before transformers +_tok_line=$(grep -n '^tokenizers$' "$NO_TORCH_RT" | head -1 | cut -d: -f1) +_tf_line=$(grep -n '^transformers' "$NO_TORCH_RT" | head -1 | cut -d: -f1) +_tok_first=$([ "$_tok_line" -lt "$_tf_line" ] && echo "yes" || echo "no") +assert_eq "tokenizers before transformers" "yes" "$_tok_first" + +# torch itself NOT in no-torch file +_has_torch=$(grep -c '^torch$' "$NO_TORCH_RT" || true) +assert_eq "torch not in no-torch-runtime.txt" "0" "$_has_torch" + +echo "" +echo "=== Structural: install.ps1 unchanged ===" + +_PS1_CONTENT=$(cat "$INSTALL_PS1") +_ps1_has_var=$(echo "$_PS1_CONTENT" | grep -c 'TORCH_CONSTRAINT\|TorchConstraint' || true) +assert_eq "install.ps1 has no TORCH_CONSTRAINT variable" "0" "$_ps1_has_var" + +_ps1_hardcoded=$(echo "$_PS1_CONTENT" | grep -c '"torch>=2.4,<2.11.0"' || true) +_ps1_has_hc=$([ "$_ps1_hardcoded" -ge 1 ] && echo "yes" || echo "no") +assert_eq "install.ps1 has hardcoded torch constraint" "yes" "$_ps1_has_hc" + +# ====================================================================== +# Runtime: mocked platform/version combos +# ====================================================================== +echo "" +echo "=== Runtime: TORCH_CONSTRAINT with mocked inputs ===" + +TMPDIR_BASE=$(mktemp -d) +trap 'rm -rf "$TMPDIR_BASE"' EXIT + +# 1. arm64 macOS py3.13 -> tightened +_result=$(run_constraint_snippet false macos arm64 13 "$TMPDIR_BASE/v1") +assert_eq "arm64+macos+py313 -> tightened" "torch>=2.6,<2.11.0" "$_result" + +# 2. arm64 macOS py3.14 -> tightened (future-proofed) +_result=$(run_constraint_snippet false macos arm64 14 "$TMPDIR_BASE/v2") +assert_eq "arm64+macos+py314 -> tightened" "torch>=2.6,<2.11.0" "$_result" + +# 3. arm64 macOS py3.12 -> default +_result=$(run_constraint_snippet false macos arm64 12 "$TMPDIR_BASE/v3") +assert_eq "arm64+macos+py312 -> default" "torch>=2.4,<2.11.0" "$_result" + +# 4. arm64 macOS py3.11 -> default +_result=$(run_constraint_snippet false macos arm64 11 "$TMPDIR_BASE/v4") +assert_eq "arm64+macos+py311 -> default" "torch>=2.4,<2.11.0" "$_result" + +# 5. Linux x86_64 py3.13 -> default (Linux unaffected) +_result=$(run_constraint_snippet false linux x86_64 13 "$TMPDIR_BASE/v5") +assert_eq "linux+x86_64+py313 -> default" "torch>=2.4,<2.11.0" "$_result" + +# 6. Linux aarch64 py3.13 -> default (guard checks OS=macos) +_result=$(run_constraint_snippet false linux aarch64 13 "$TMPDIR_BASE/v6") +assert_eq "linux+aarch64+py313 -> default" "torch>=2.4,<2.11.0" "$_result" + +# 7. Intel Mac x86_64 py3.12 -> default (arch mismatch) +_result=$(run_constraint_snippet false macos x86_64 12 "$TMPDIR_BASE/v7") +assert_eq "macos+x86_64+py312 -> default" "torch>=2.4,<2.11.0" "$_result" + +# 8. SKIP_TORCH=true arm64 macOS py3.13 -> block skipped, default +_result=$(run_constraint_snippet true macos arm64 13 "$TMPDIR_BASE/v8") +assert_eq "SKIP_TORCH=true -> default" "torch>=2.4,<2.11.0" "$_result" + +# 9. WSL py3.13 -> default +_result=$(run_constraint_snippet false wsl x86_64 13 "$TMPDIR_BASE/v9") +assert_eq "wsl+py313 -> default" "torch>=2.4,<2.11.0" "$_result" + +# 10. py_minor=0 (failed query fallback) -> default +_result=$(run_constraint_snippet false macos arm64 0 "$TMPDIR_BASE/v10") +assert_eq "py_minor=0 fallback -> default" "torch>=2.4,<2.11.0" "$_result" + +# 11. Boundary: py_minor=12 -> NOT tightened +_result=$(run_constraint_snippet false macos arm64 12 "$TMPDIR_BASE/v11") +assert_eq "boundary py_minor=12 -> default" "torch>=2.4,<2.11.0" "$_result" + +# 12. Boundary: py_minor=13 -> tightened +_result=$(run_constraint_snippet false macos arm64 13 "$TMPDIR_BASE/v12") +assert_eq "boundary py_minor=13 -> tightened" "torch>=2.6,<2.11.0" "$_result" + +# 13. Intel Mac py3.13 -> default (arch=x86_64, not arm64) +_result=$(run_constraint_snippet false macos x86_64 13 "$TMPDIR_BASE/v13") +assert_eq "macos+x86_64+py313 -> default" "torch>=2.4,<2.11.0" "$_result" + +# ====================================================================== +# Mock uv integration +# ====================================================================== +echo "" +echo "=== Mock uv: verify constraint passed to uv ===" + +# arm64 + py313 -> uv receives torch>=2.6 +_UV_LOG="$TMPDIR_BASE/uv_log_tight.txt" +make_mock_python 13 "$TMPDIR_BASE/uv_venv1" +cat > "$TMPDIR_BASE/mock_uv_tight" <> $_UV_LOG +UVEOF +chmod +x "$TMPDIR_BASE/mock_uv_tight" + +bash -c " + SKIP_TORCH=false + OS=\"macos\" + _ARCH=\"arm64\" + VENV_DIR=\"$TMPDIR_BASE/uv_venv1\" + TORCH_CONSTRAINT=\"torch>=2.4,<2.11.0\" + if [ \"\$SKIP_TORCH\" = false ] && [ \"\$OS\" = \"macos\" ] && [ \"\$_ARCH\" = \"arm64\" ]; then + _PY_MINOR=\$(\"\$VENV_DIR/bin/python\" -c \"import sys; print(sys.version_info.minor)\" 2>/dev/null || echo \"0\") + if [ \"\$_PY_MINOR\" -ge 13 ] 2>/dev/null; then + TORCH_CONSTRAINT=\"torch>=2.6,<2.11.0\" + fi + fi + \"$TMPDIR_BASE/mock_uv_tight\" pip install --python \"\$VENV_DIR/bin/python\" \"\$TORCH_CONSTRAINT\" torchvision torchaudio +" 2>/dev/null +_uv_got=$(cat "$_UV_LOG" 2>/dev/null || echo "") +assert_contains "mock uv arm64+py313 receives torch>=2.6" "$_uv_got" "torch>=2.6,<2.11.0" + +# arm64 + py312 -> uv receives torch>=2.4 +_UV_LOG2="$TMPDIR_BASE/uv_log_default.txt" +make_mock_python 12 "$TMPDIR_BASE/uv_venv2" +cat > "$TMPDIR_BASE/mock_uv_default" <> $_UV_LOG2 +UVEOF +chmod +x "$TMPDIR_BASE/mock_uv_default" + +bash -c " + SKIP_TORCH=false + OS=\"macos\" + _ARCH=\"arm64\" + VENV_DIR=\"$TMPDIR_BASE/uv_venv2\" + TORCH_CONSTRAINT=\"torch>=2.4,<2.11.0\" + if [ \"\$SKIP_TORCH\" = false ] && [ \"\$OS\" = \"macos\" ] && [ \"\$_ARCH\" = \"arm64\" ]; then + _PY_MINOR=\$(\"\$VENV_DIR/bin/python\" -c \"import sys; print(sys.version_info.minor)\" 2>/dev/null || echo \"0\") + if [ \"\$_PY_MINOR\" -ge 13 ] 2>/dev/null; then + TORCH_CONSTRAINT=\"torch>=2.6,<2.11.0\" + fi + fi + \"$TMPDIR_BASE/mock_uv_default\" pip install --python \"\$VENV_DIR/bin/python\" \"\$TORCH_CONSTRAINT\" torchvision torchaudio +" 2>/dev/null +_uv_got2=$(cat "$_UV_LOG2" 2>/dev/null || echo "") +assert_contains "mock uv arm64+py312 receives torch>=2.4" "$_uv_got2" "torch>=2.4,<2.11.0" + +# ====================================================================== +# Summary +# ====================================================================== +echo "" +echo "=== Results ===" +echo " PASS: $PASS" +echo " FAIL: $FAIL" +if [ "$FAIL" -gt 0 ]; then + echo "FAILED" + exit 1 +fi +echo "ALL PASSED" From 653eb3819af7830e07e255223a9c4610523cab09 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 1 Apr 2026 06:12:52 -0700 Subject: [PATCH 16/44] fix(studio): allow context length slider to reach model's native limit (#4746) * fix(studio): allow context length slider to reach model's native limit The context length slider was hard-capped to the VRAM-estimated maximum, preventing users from requesting higher context even though the backend already handles it safely (multi-GPU selection, --fit fallback). Expose the model's native context length from GGUF metadata as a separate API field and use it as the slider ceiling instead. Add an amber warning when the selected context exceeds the estimated VRAM capacity. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Raise VRAM budget to 90% and add native_context_length tests Increase the GPU memory utilization threshold from 70% to 90% across _select_gpus and _fit_context_to_vram, allowing longer context lengths before VRAM capping kicks in. Add 33 tests for the native_context_length feature covering the backend property, context value separation invariants, Pydantic models, route completeness, edge cases, and cross-platform binary I/O. * [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 | 27 +- studio/backend/models/inference.py | 8 + studio/backend/routes/inference.py | 3 + .../tests/test_native_context_length.py | 518 ++++++++++++++++++ .../src/features/chat/chat-settings-sheet.tsx | 12 +- .../chat/hooks/use-chat-model-runtime.ts | 8 + .../chat/stores/chat-runtime-store.ts | 3 + .../frontend/src/features/chat/types/api.ts | 2 + 8 files changed, 569 insertions(+), 12 deletions(-) create mode 100644 studio/backend/tests/test_native_context_length.py diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 873b72bba1..5f974b0af3 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -116,6 +116,11 @@ class LlamaCppBackend: """Return the maximum context currently available on this hardware.""" return self._max_context_length or self._context_length + @property + def native_context_length(self) -> Optional[int]: + """Return the model's native context length from GGUF metadata.""" + return self._context_length + @property def chat_template(self) -> Optional[str]: return self._chat_template @@ -315,11 +320,11 @@ class LlamaCppBackend: """Pick GPU(s) for a model based on estimated VRAM and free memory. ``model_size_bytes`` should include both model weights and estimated - KV cache. The 70% threshold provides headroom for compute buffers, + KV cache. The 90% threshold provides headroom for compute buffers, CUDA context, and other runtime overhead. Returns (gpu_indices, use_fit): - - ([1], False) model fits on 1 GPU at 70% of free + - ([1], False) model fits on 1 GPU at 90% of free - ([1, 2], False) model needs 2 GPUs - (None, True) model too large, let --fit handle it """ @@ -331,8 +336,8 @@ class LlamaCppBackend: # Sort GPUs by free memory descending ranked = sorted(gpus, key = lambda g: g[1], reverse = True) - # Try fitting on 1 GPU (70% of free memory threshold) - if ranked[0][1] * 0.70 >= model_size_mib: + # Try fitting on 1 GPU (90% of free memory threshold) + if ranked[0][1] * 0.90 >= model_size_mib: return [ranked[0][0]], False # Try fitting on N GPUs (accumulate free memory from most-free) @@ -340,7 +345,7 @@ class LlamaCppBackend: selected = [] for idx, free_mib in ranked: selected.append(idx) - cumulative += free_mib * 0.70 + cumulative += free_mib * 0.90 if cumulative >= model_size_mib: return sorted(selected), False @@ -467,8 +472,8 @@ class LlamaCppBackend: ) -> int: """Return the largest context length that fits in GPU VRAM. - Uses 70% of available VRAM as the budget (matching _select_gpus - threshold -- 30% reserved for compute buffers, CUDA context, + Uses 90% of available VRAM as the budget (matching _select_gpus + threshold -- 10% reserved for compute buffers, CUDA context, scratch space, flash-attn workspace, etc.). If the model weights alone don't fit, returns min_ctx unchanged. """ @@ -480,7 +485,7 @@ class LlamaCppBackend: ) return requested_ctx - budget_bytes = available_mib * 1024 * 1024 * 0.70 + budget_bytes = available_mib * 1024 * 1024 * 0.90 model_footprint = model_size_bytes # Check if requested context already fits @@ -1133,7 +1138,7 @@ class LlamaCppBackend: ) kv = self._estimate_kv_cache_bytes(capped, cache_type_kv) total_mib = (model_size + kv) / (1024 * 1024) - if total_mib <= pool_mib * 0.70: + if total_mib <= pool_mib * 0.90: best_cap = max(best_cap, capped) if best_cap > 0: max_available_ctx = best_cap @@ -1162,7 +1167,7 @@ class LlamaCppBackend: capped, cache_type_kv ) total_mib = (model_size + kv) / (1024 * 1024) - if total_mib <= pool_mib * 0.70: + if total_mib <= pool_mib * 0.90: effective_ctx = capped gpu_indices = sorted(idx for idx, _ in subset) use_fit = False @@ -1181,7 +1186,7 @@ class LlamaCppBackend: ) kv = self._estimate_kv_cache_bytes(capped, cache_type_kv) total_mib = (model_size + kv) / (1024 * 1024) - if total_mib <= pool_mib * 0.70: + if total_mib <= pool_mib * 0.90: effective_ctx = capped gpu_indices = sorted(idx for idx, _ in subset) use_fit = False diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index 77f70b9bd6..3094df4169 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -139,6 +139,10 @@ class LoadResponse(BaseModel): max_context_length: Optional[int] = Field( None, description = "Maximum context length currently available on this hardware" ) + native_context_length: Optional[int] = Field( + None, + description = "Model's native context length from GGUF metadata (not capped by VRAM)", + ) supports_reasoning: bool = Field( False, description = "Whether model supports thinking/reasoning mode (enable_thinking)", @@ -217,6 +221,10 @@ class InferenceStatusResponse(BaseModel): None, description = "Maximum context length currently available for the active model", ) + native_context_length: Optional[int] = Field( + None, + description = "Model's native context length from GGUF metadata (not capped by VRAM)", + ) # ===================================================================== diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 9bce371775..99a52dfe2d 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -163,6 +163,7 @@ async def load_model( inference = inference_config, context_length = llama_backend.context_length, max_context_length = llama_backend.max_context_length, + native_context_length = llama_backend.native_context_length, supports_reasoning = llama_backend.supports_reasoning, reasoning_always_on = llama_backend.reasoning_always_on, chat_template = llama_backend.chat_template, @@ -298,6 +299,7 @@ async def load_model( inference = inference_config, context_length = llama_backend.context_length, max_context_length = llama_backend.max_context_length, + native_context_length = llama_backend.native_context_length, supports_reasoning = llama_backend.supports_reasoning, reasoning_always_on = llama_backend.reasoning_always_on, supports_tools = llama_backend.supports_tools, @@ -637,6 +639,7 @@ async def get_status( supports_tools = llama_backend.supports_tools, context_length = llama_backend.context_length, max_context_length = llama_backend.max_context_length, + native_context_length = llama_backend.native_context_length, ) # Otherwise, report Unsloth backend status diff --git a/studio/backend/tests/test_native_context_length.py b/studio/backend/tests/test_native_context_length.py new file mode 100644 index 0000000000..7c69e56f89 --- /dev/null +++ b/studio/backend/tests/test_native_context_length.py @@ -0,0 +1,518 @@ +# 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 the native_context_length feature (PR #4746). + +Verifies that the new `native_context_length` property on LlamaCppBackend +and the corresponding Pydantic model fields work correctly. The raw GGUF +`_context_length` must never be overwritten by VRAM-capping logic. + +Requires no GPU, network, or external libraries beyond pytest and pydantic. +""" + +import io +import json +import struct +import sys +import types as _types +from pathlib import Path +from unittest.mock import patch + +import pytest + +# --------------------------------------------------------------------------- +# Stub heavy / unavailable external dependencies before importing the +# module under test. Same pattern as test_kv_cache_estimation.py. +# --------------------------------------------------------------------------- + +_BACKEND_DIR = str(Path(__file__).resolve().parent.parent) +if _BACKEND_DIR not in sys.path: + sys.path.insert(0, _BACKEND_DIR) + +# loggers +_loggers_stub = _types.ModuleType("loggers") +_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name) +sys.modules.setdefault("loggers", _loggers_stub) + +# structlog +_structlog_stub = _types.ModuleType("structlog") +sys.modules.setdefault("structlog", _structlog_stub) + +# httpx -- stub only the names referenced at import / class-definition time +_httpx_stub = _types.ModuleType("httpx") +for _exc_name in ( + "ConnectError", + "TimeoutException", + "ReadTimeout", + "ReadError", + "RemoteProtocolError", + "CloseError", +): + setattr(_httpx_stub, _exc_name, type(_exc_name, (Exception,), {})) + + +class _FakeTimeout: + def __init__(self, *a, **kw): + pass + + +_httpx_stub.Timeout = _FakeTimeout +_httpx_stub.Client = type( + "Client", + (), + { + "__init__": lambda self, **kw: None, + "__enter__": lambda self: self, + "__exit__": lambda self, *a: None, + }, +) +sys.modules.setdefault("httpx", _httpx_stub) + +from core.inference.llama_cpp import LlamaCppBackend +from models.inference import LoadResponse, InferenceStatusResponse + + +# ── Helpers ────────────────────────────────────────────────────────── + + +def _write_kv(buf: io.BytesIO, key: str, value, vtype: int) -> None: + """Append a single GGUF KV pair to *buf*.""" + key_bytes = key.encode("utf-8") + buf.write(struct.pack(" str: + """Create a minimal valid GGUF v3 binary in *tmp_path*.""" + buf = io.BytesIO() + buf.write(struct.pack("= max >= effective holds when VRAM-capped.""" + backend._context_length = 131072 + backend._max_context_length = 65536 + backend._effective_context_length = 32768 + assert backend.native_context_length >= backend.max_context_length + assert backend.max_context_length >= backend.context_length + + def test_all_equal_when_uncapped(self, backend): + """All three equal when no VRAM constraint.""" + backend._context_length = 8192 + # No effective or max set -- properties fall back to _context_length + assert backend.native_context_length == 8192 + assert backend.max_context_length == 8192 + assert backend.context_length == 8192 + + def test_fit_context_does_not_modify(self, backend): + """_fit_context_to_vram() does not touch _context_length.""" + backend._context_length = 131072 + backend._n_layers = 32 + backend._n_kv_heads = 8 + backend._n_heads = 32 + backend._embedding_length = 4096 + original = backend._context_length + + # Simulate a very small VRAM budget that forces capping + result = backend._fit_context_to_vram( + requested_ctx = 131072, + available_mib = 512, # very small + model_size_bytes = 0, + ) + # _fit_context_to_vram returns the capped value, not modifying _context_length + assert backend._context_length == original + assert backend.native_context_length == original + # The returned capped value should be <= requested + assert result <= 131072 + + def test_native_gt_context_when_capped(self, backend): + """native_context_length > context_length after VRAM capping.""" + backend._context_length = 131072 + backend._effective_context_length = 16384 + assert backend.native_context_length > backend.context_length + + +# ===================================================================== +# C. TestPydanticModels -- LoadResponse & InferenceStatusResponse +# ===================================================================== + + +class TestPydanticModels: + """Tests native_context_length field on Pydantic models.""" + + def test_load_response_has_field(self): + """Field exists in LoadResponse.model_fields.""" + assert "native_context_length" in LoadResponse.model_fields + + def test_load_response_defaults_none(self): + """Omitting native_context_length defaults to None.""" + resp = LoadResponse( + status = "loaded", + model = "test", + display_name = "Test", + inference = {}, + ) + assert resp.native_context_length is None + + def test_load_response_accepts_int(self): + """native_context_length=131072 stores correctly.""" + resp = LoadResponse( + status = "loaded", + model = "test", + display_name = "Test", + inference = {}, + native_context_length = 131072, + ) + assert resp.native_context_length == 131072 + + def test_load_response_json_null(self): + """None serializes to JSON null.""" + resp = LoadResponse( + status = "loaded", + model = "test", + display_name = "Test", + inference = {}, + ) + data = json.loads(resp.model_dump_json()) + assert data["native_context_length"] is None + + def test_load_response_json_int(self): + """131072 serializes to JSON number.""" + resp = LoadResponse( + status = "loaded", + model = "test", + display_name = "Test", + inference = {}, + native_context_length = 131072, + ) + data = json.loads(resp.model_dump_json()) + assert data["native_context_length"] == 131072 + + def test_status_response_has_field(self): + """Field exists in InferenceStatusResponse.model_fields.""" + assert "native_context_length" in InferenceStatusResponse.model_fields + + def test_status_response_defaults_none(self): + """Omitting native_context_length defaults to None.""" + resp = InferenceStatusResponse() + assert resp.native_context_length is None + + def test_roundtrip_preserves_value(self): + """model_validate_json(model_dump_json()) round-trips.""" + resp = LoadResponse( + status = "loaded", + model = "test", + display_name = "Test", + inference = {}, + native_context_length = 131072, + ) + roundtripped = LoadResponse.model_validate_json(resp.model_dump_json()) + assert roundtripped.native_context_length == 131072 + + +# ===================================================================== +# D. TestRouteCompleteness -- source-level verification +# ===================================================================== + + +class TestRouteCompleteness: + """All response construction sites in routes/inference.py include native_context_length.""" + + @pytest.fixture(autouse = True) + def _load_source(self): + """Read routes/inference.py source once.""" + routes_path = Path(__file__).resolve().parent.parent / "routes" / "inference.py" + self._source = routes_path.read_text() + + def _find_construction_blocks(self, class_name: str) -> list[str]: + """Extract all code blocks that construct a given response class.""" + blocks = [] + idx = 0 + while True: + start = self._source.find(f"{class_name}(", idx) + if start == -1: + break + # Find matching closing paren (simple depth counter) + depth = 0 + end = start + for i, ch in enumerate(self._source[start:], start): + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + end = i + 1 + break + blocks.append(self._source[start:end]) + idx = end + return blocks + + def test_gguf_load_responses_have_field(self): + """Every GGUF LoadResponse (is_gguf = True) includes native_context_length.""" + blocks = self._find_construction_blocks("LoadResponse") + gguf_blocks = [ + b for b in blocks if "is_gguf = True" in b or "is_gguf=True" in b + ] + assert ( + len(gguf_blocks) >= 2 + ), f"Expected at least 2 GGUF LoadResponse blocks, found {len(gguf_blocks)}" + for i, block in enumerate(gguf_blocks): + assert ( + "native_context_length" in block + ), f"GGUF LoadResponse block #{i} missing native_context_length:\n{block[:200]}" + + def test_non_gguf_load_responses_omit_field(self): + """Non-GGUF LoadResponse blocks do not set native_context_length (defaults to None).""" + blocks = self._find_construction_blocks("LoadResponse") + non_gguf = [ + b for b in blocks if "is_gguf = True" not in b and "is_gguf=True" not in b + ] + # Non-GGUF paths should not reference native_context_length + # (Pydantic defaults it to None, so not setting it is correct) + for block in non_gguf: + assert ( + "native_context_length" not in block + ), f"Non-GGUF LoadResponse should not set native_context_length:\n{block[:200]}" + + def test_status_path(self): + """InferenceStatusResponse construction with llama_backend has the field.""" + blocks = self._find_construction_blocks("InferenceStatusResponse") + found = False + for block in blocks: + if "llama_backend" in block and "native_context_length" in block: + found = True + break + assert found, "No InferenceStatusResponse block with llama_backend has native_context_length" + + +# ===================================================================== +# E. TestEdgeCases +# ===================================================================== + + +class TestNativeContextEdgeCases: + """Edge cases for native_context_length.""" + + def test_context_length_zero(self, tmp_path, backend): + """GGUF context_length=0 returns 0, not None.""" + path = make_gguf(tmp_path, "llama", [("context_length", 0, 4)]) + backend._read_gguf_metadata(path) + assert backend.native_context_length == 0 + + def test_context_length_uint32_max(self, tmp_path, backend): + """2^32 - 1 survives without truncation.""" + val = 2**32 - 1 + path = make_gguf(tmp_path, "llama", [("context_length", val, 4)]) + backend._read_gguf_metadata(path) + assert backend.native_context_length == val + + def test_context_length_uint64(self, tmp_path, backend): + """UINT64 type context_length parsed correctly.""" + val = 2**33 # exceeds UINT32 range + path = make_gguf(tmp_path, "llama", [("context_length", val, 10)]) + backend._read_gguf_metadata(path) + assert backend.native_context_length == val + + def test_no_context_length_in_gguf(self, tmp_path, backend): + """GGUF without context_length key yields None.""" + path = make_gguf(tmp_path, "llama", [("block_count", 32, 4)]) + backend._read_gguf_metadata(path) + assert backend.native_context_length is None + + def test_native_equals_context_when_uncapped(self, backend): + """Both equal when no VRAM cap applied.""" + backend._context_length = 8192 + assert backend.native_context_length == backend.context_length + + def test_native_survives_parse_then_cap(self, tmp_path, backend): + """Parse then set effective cap: native unchanged.""" + path = make_gguf( + tmp_path, + "llama", + [ + ("context_length", 131072, 4), + ("block_count", 32, 4), + ("attention.head_count", 32, 4), + ("attention.head_count_kv", 8, 4), + ("embedding_length", 4096, 4), + ], + ) + backend._read_gguf_metadata(path) + assert backend.native_context_length == 131072 + + # Simulate VRAM capping by setting effective and max + backend._effective_context_length = 16384 + backend._max_context_length = 32768 + assert backend.native_context_length == 131072 + + +# ===================================================================== +# F. TestCrossPlatform -- binary I/O and serialization +# ===================================================================== + + +class TestCrossPlatform: + """Binary I/O and serialization correctness across platforms.""" + + def test_le_uint32_context_length(self, tmp_path, backend): + """Little-endian UINT32 parsed correctly.""" + path = make_gguf(tmp_path, "llama", [("context_length", 16384, 4)]) + backend._read_gguf_metadata(path) + assert backend.native_context_length == 16384 + + def test_le_uint64_context_length(self, tmp_path, backend): + """Little-endian UINT64 parsed correctly.""" + path = make_gguf(tmp_path, "llama", [("context_length", 16384, 10)]) + backend._read_gguf_metadata(path) + assert backend.native_context_length == 16384 + + def test_gguf_magic_le_byte_order(self, tmp_path): + """Magic 0x46554747 matches GGUF spec (little-endian 'GGUF').""" + path = tmp_path / "magic_check.gguf" + buf = io.BytesIO() + buf.write(struct.pack(" s.ggufMaxContextLength, ); + const ggufNativeContextLength = useChatRuntimeStore( + (s) => s.ggufNativeContextLength, + ); const kvCacheDtype = useChatRuntimeStore((s) => s.kvCacheDtype); const setKvCacheDtype = useChatRuntimeStore((s) => s.setKvCacheDtype); const loadedKvCacheDtype = useChatRuntimeStore((s) => s.loadedKvCacheDtype); @@ -293,7 +296,7 @@ export function ChatSettingsPanel({ ); const ctxDisplayValue = customContextLength ?? ggufContextLength ?? ""; - const ctxMaxValue = ggufMaxContextLength ?? ggufContextLength ?? null; + const ctxMaxValue = ggufNativeContextLength ?? ggufContextLength ?? null; const kvDirty = kvCacheDtype !== loadedKvCacheDtype; const ctxDirty = customContextLength !== null; const modelSettingsDirty = kvDirty || ctxDirty; @@ -544,6 +547,13 @@ export function ChatSettingsPanel({ ); }} /> + {ggufMaxContextLength != null && + typeof ctxDisplayValue === "number" && + ctxDisplayValue > ggufMaxContextLength && ( +

+ Exceeds estimated VRAM capacity ({ggufMaxContextLength.toLocaleString()} tokens). The model may use system RAM. +

+ )}
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 2c585a18b8..fc0e392596 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 @@ -246,12 +246,16 @@ export function useChatModelRuntime() { const ggufMaxContextLength = statusRes.is_gguf ? (statusRes.max_context_length ?? null) : null; + const ggufNativeContextLength = statusRes.is_gguf + ? (statusRes.native_context_length ?? null) + : null; useChatRuntimeStore.setState({ supportsReasoning, reasoningAlwaysOn, supportsTools, ggufContextLength: currentGgufContextLength, ggufMaxContextLength, + ggufNativeContextLength, }); // Set reasoning default for Qwen3.5 small models @@ -425,6 +429,9 @@ export function useChatModelRuntime() { const reportedMaxCtx = loadResponse.is_gguf ? (loadResponse.max_context_length ?? null) : null; + const reportedNativeCtx = loadResponse.is_gguf + ? (loadResponse.native_context_length ?? null) + : null; // A successful reload has applied settings, so clear pending custom // context state and display the backend-reported effective context. const keepCustomCtx = null; @@ -433,6 +440,7 @@ export function useChatModelRuntime() { useChatRuntimeStore.setState({ ggufContextLength: nativeCtx, ggufMaxContextLength, + ggufNativeContextLength: reportedNativeCtx, supportsReasoning: loadResponse.supports_reasoning ?? false, reasoningAlwaysOn, reasoningEnabled: reasoningAlwaysOn ? true : reasoningDefault, diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index 8cea234f21..48abaf7580 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -151,6 +151,7 @@ type ChatRuntimeStore = { activeGgufVariant: string | null; ggufContextLength: number | null; ggufMaxContextLength: number | null; + ggufNativeContextLength: number | null; supportsReasoning: boolean; reasoningAlwaysOn: boolean; reasoningEnabled: boolean; @@ -215,6 +216,7 @@ export const useChatRuntimeStore = create((set) => ({ activeGgufVariant: null, ggufContextLength: null, ggufMaxContextLength: null, + ggufNativeContextLength: null, supportsReasoning: false, reasoningAlwaysOn: false, reasoningEnabled: true, @@ -290,6 +292,7 @@ export const useChatRuntimeStore = create((set) => ({ activeGgufVariant: null, ggufContextLength: null, ggufMaxContextLength: null, + ggufNativeContextLength: null, contextUsage: null, supportsReasoning: false, reasoningEnabled: true, diff --git a/studio/frontend/src/features/chat/types/api.ts b/studio/frontend/src/features/chat/types/api.ts index dcc0a980c8..8f0839615f 100644 --- a/studio/frontend/src/features/chat/types/api.ts +++ b/studio/frontend/src/features/chat/types/api.ts @@ -87,6 +87,7 @@ export interface LoadModelResponse { }; context_length?: number | null; max_context_length?: number | null; + native_context_length?: number | null; supports_reasoning?: boolean; reasoning_always_on?: boolean; supports_tools?: boolean; @@ -121,6 +122,7 @@ export interface InferenceStatusResponse { supports_tools?: boolean; context_length?: number | null; max_context_length?: number | null; + native_context_length?: number | null; } export interface AudioGenerationResponse { From 39fe23ded85c2caa3f8407d2b61920af44175baa Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 1 Apr 2026 06:13:37 -0700 Subject: [PATCH 17/44] Tests for architecture-aware KV cache estimation (#4760) * test: add 66 tests for architecture-aware KV cache estimation Covers all 5 estimation paths (MLA, Hybrid Mamba, Sliding Window, Standard GQA, Legacy), GGUF parser for 8 new metadata fields, _can_estimate_kv gate conditions, quantization scaling, edge cases, path priority ordering, and lifecycle (init/unload/reparse). Zero external dependencies beyond pytest. No GPU or network required. Cross-platform (Linux, macOS, Windows, WSL). * [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> --- .../backend/tests/test_kv_cache_estimation.py | 929 ++++++++++++++++++ 1 file changed, 929 insertions(+) create mode 100644 studio/backend/tests/test_kv_cache_estimation.py diff --git a/studio/backend/tests/test_kv_cache_estimation.py b/studio/backend/tests/test_kv_cache_estimation.py new file mode 100644 index 0000000000..2640ded90d --- /dev/null +++ b/studio/backend/tests/test_kv_cache_estimation.py @@ -0,0 +1,929 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +"""Tests for 5-path architecture-aware KV cache VRAM estimation. + +Covers the GGUF metadata parser, _can_estimate_kv gate, all 5 estimation +paths (MLA, Hybrid Mamba, Sliding Window, Standard GQA, Legacy), KV cache +quantization, edge cases, and lifecycle (init/unload/reparse). + +Requires no GPU, network, or external libraries beyond pytest. +Cross-platform: Linux, macOS, Windows, WSL. +""" + +import io +import struct +import sys +import types as _types +from pathlib import Path + +import pytest + +# --------------------------------------------------------------------------- +# Stub heavy / unavailable external dependencies before importing the +# module under test. Same pattern as test_native_context_length.py. +# --------------------------------------------------------------------------- + +_BACKEND_DIR = str(Path(__file__).resolve().parent.parent) +if _BACKEND_DIR not in sys.path: + sys.path.insert(0, _BACKEND_DIR) + +# loggers +_loggers_stub = _types.ModuleType("loggers") +_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name) +sys.modules.setdefault("loggers", _loggers_stub) + +# structlog +_structlog_stub = _types.ModuleType("structlog") +sys.modules.setdefault("structlog", _structlog_stub) + +# httpx +_httpx_stub = _types.ModuleType("httpx") +for _exc_name in ( + "ConnectError", + "TimeoutException", + "ReadTimeout", + "ReadError", + "RemoteProtocolError", + "CloseError", +): + setattr(_httpx_stub, _exc_name, type(_exc_name, (Exception,), {})) + + +class _FakeTimeout: + def __init__(self, *a, **kw): + pass + + +_httpx_stub.Timeout = _FakeTimeout +_httpx_stub.Client = type( + "Client", + (), + { + "__init__": lambda self, **kw: None, + "__enter__": lambda self: self, + "__exit__": lambda self, *a: None, + }, +) +sys.modules.setdefault("httpx", _httpx_stub) + +from core.inference.llama_cpp import LlamaCppBackend + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_gguf_bytes(arch: str, kv_pairs: dict) -> bytes: + """Build a minimal GGUF v3 binary blob with the given KV metadata. + + Only supports UINT32 (type 4), UINT64 (type 10), and STRING (type 8) + values, which is all the metadata parser reads. + """ + buf = io.BytesIO() + # Header: magic, version, tensor_count, kv_count + buf.write(struct.pack(" LlamaCppBackend: + """Create a LlamaCppBackend with parsed GGUF metadata from given fields.""" + kv = {"general.architecture": arch} + for k, v in fields.items(): + kv[f"{arch}.{k}"] = v + import tempfile, os + + data = _make_gguf_bytes(arch, kv) + fd, path = tempfile.mkstemp(suffix = ".gguf") + try: + os.write(fd, data) + os.close(fd) + b = LlamaCppBackend() + b._read_gguf_metadata(path) + return b + finally: + os.unlink(path) + + +# --------------------------------------------------------------------------- +# A. GGUF Parser Tests +# --------------------------------------------------------------------------- + + +class TestGGUFParserNewFields: + """Verify that the 8 new architecture-aware fields are correctly parsed.""" + + @pytest.mark.parametrize( + "field,gguf_key,value", + [ + ("_kv_key_length", "attention.key_length", 128), + ("_kv_value_length", "attention.value_length", 128), + ("_sliding_window", "attention.sliding_window", 1024), + ("_full_attention_interval", "full_attention_interval", 4), + ("_kv_lora_rank", "attention.kv_lora_rank", 512), + ("_key_length_mla", "attention.key_length_mla", 256), + ("_ssm_inner_size", "ssm.inner_size", 6144), + ("_ssm_state_size", "ssm.state_size", 128), + ], + ) + def test_field_parsed(self, field, gguf_key, value): + b = _backend_from_gguf("testarch", {gguf_key: value}) + assert getattr(b, field) == value + + def test_missing_fields_are_none(self): + b = _backend_from_gguf("testarch", {"block_count": 10}) + for attr in [ + "_kv_key_length", + "_kv_value_length", + "_sliding_window", + "_full_attention_interval", + "_kv_lora_rank", + "_key_length_mla", + "_ssm_inner_size", + "_ssm_state_size", + ]: + assert getattr(b, attr) is None + + def test_all_13_fields_parsed_together(self): + fields = { + "context_length": 131072, + "block_count": 62, + "attention.head_count_kv": 16, + "attention.head_count": 32, + "embedding_length": 5376, + "attention.key_length": 128, + "attention.value_length": 128, + "attention.sliding_window": 1024, + "full_attention_interval": 6, + "attention.kv_lora_rank": 512, + "attention.key_length_mla": 256, + "ssm.inner_size": 4096, + "ssm.state_size": 128, + } + b = _backend_from_gguf("testarch", fields) + assert b._context_length == 131072 + assert b._n_layers == 62 + assert b._n_kv_heads == 16 + assert b._n_heads == 32 + assert b._embedding_length == 5376 + assert b._kv_key_length == 128 + assert b._kv_value_length == 128 + assert b._sliding_window == 1024 + assert b._full_attention_interval == 6 + assert b._kv_lora_rank == 512 + assert b._key_length_mla == 256 + assert b._ssm_inner_size == 4096 + assert b._ssm_state_size == 128 + + +class TestGGUFParserReset: + """Verify that fields are properly reset between parses.""" + + def test_reset_between_parses(self): + # First parse with all fields + b = _backend_from_gguf( + "arch1", + { + "block_count": 32, + "attention.key_length": 128, + "attention.kv_lora_rank": 512, + "ssm.inner_size": 4096, + }, + ) + assert b._kv_key_length == 128 + assert b._kv_lora_rank == 512 + assert b._ssm_inner_size == 4096 + + # Second parse without those fields -- they should be None + kv = {"general.architecture": "arch2", "arch2.block_count": 64} + import tempfile, os + + data = _make_gguf_bytes("arch2", kv) + fd, path = tempfile.mkstemp(suffix = ".gguf") + os.write(fd, data) + os.close(fd) + try: + b._read_gguf_metadata(path) + finally: + os.unlink(path) + assert b._kv_key_length is None + assert b._kv_lora_rank is None + assert b._ssm_inner_size is None + assert b._n_layers == 64 + + +# --------------------------------------------------------------------------- +# B. _can_estimate_kv Gate Tests +# --------------------------------------------------------------------------- + + +class TestCanEstimateKV: + """Verify gate logic for all field combinations.""" + + def test_no_layers_returns_false(self): + b = LlamaCppBackend() + b._n_layers = None + b._kv_key_length = 128 + assert not b._can_estimate_kv() + + def test_explicit_both_dims_sufficient(self): + b = LlamaCppBackend() + b._n_layers = 32 + b._kv_key_length = 128 + b._kv_value_length = 128 + assert b._can_estimate_kv() + + def test_key_length_alone_insufficient(self): + """key_length without value_length should NOT be enough.""" + b = LlamaCppBackend() + b._n_layers = 32 + b._kv_key_length = 128 + assert not b._can_estimate_kv() + + def test_kv_lora_rank_sufficient(self): + b = LlamaCppBackend() + b._n_layers = 61 + b._kv_lora_rank = 512 + assert b._can_estimate_kv() + + def test_legacy_embed_plus_heads(self): + b = LlamaCppBackend() + b._n_layers = 28 + b._embedding_length = 1024 + b._n_heads = 16 + assert b._can_estimate_kv() + + def test_legacy_embed_plus_kv_heads(self): + b = LlamaCppBackend() + b._n_layers = 28 + b._embedding_length = 1024 + b._n_kv_heads = 8 + assert b._can_estimate_kv() + + def test_legacy_no_embed_returns_false(self): + b = LlamaCppBackend() + b._n_layers = 28 + b._n_heads = 16 + # No embedding_length, no new-style fields + assert not b._can_estimate_kv() + + def test_fresh_backend_returns_false(self): + b = LlamaCppBackend() + assert not b._can_estimate_kv() + + +# --------------------------------------------------------------------------- +# C. Path 1: MLA Estimation +# --------------------------------------------------------------------------- + + +class TestMLAEstimation: + """MLA: K-only cache using compressed KV latent + RoPE.""" + + def _mla_backend(self, **overrides): + defaults = { + "_n_layers": 61, + "_n_kv_heads": 1, + "_n_heads": 128, + "_embedding_length": 7168, + "_kv_key_length": 576, + "_kv_value_length": 512, + "_kv_lora_rank": 512, + "_key_length_mla": 192, + } + defaults.update(overrides) + b = LlamaCppBackend() + for k, v in defaults.items(): + setattr(b, k, v) + return b + + def test_deepseek_v3_f16(self): + b = self._mla_backend() + # 61 layers * 163840 ctx * 1 head * 576 key_len * 2 bpe + expected = 61 * 163840 * 1 * 576 * 2 + assert b._estimate_kv_cache_bytes(163840, "f16") == expected + + def test_mla_ignores_value_length(self): + """MLA should NOT add value_length -- V is reconstructed from the latent.""" + b = self._mla_backend() + result = b._estimate_kv_cache_bytes(1000, "f16") + # Should be n_layers * ctx * 1 * key_len(576) * 2 + expected = 61 * 1000 * 1 * 576 * 2 + assert result == expected + + def test_mla_fallback_when_no_key_length(self): + """If key_length is missing, fallback to kv_lora_rank + key_length_mla.""" + b = self._mla_backend(_kv_key_length = None) + # _key_length_mla=192 in default, so rope_dim=192 + result = b._estimate_kv_cache_bytes(1000, "f16") + expected = 61 * 1000 * 1 * (512 + 192) * 2 # 704 + assert result == expected + + def test_mla_fallback_no_key_length_mla(self): + """If both key_length and key_length_mla are missing, fallback to +64.""" + b = self._mla_backend(_kv_key_length = None, _key_length_mla = None) + result = b._estimate_kv_cache_bytes(1000, "f16") + expected = 61 * 1000 * 1 * (512 + 64) * 2 # 576 + assert result == expected + + def test_mla_defaults_n_kv_to_1_when_heads_absent(self): + """MLA should use n_kv=1 even if n_kv_heads is None (not n_heads).""" + b = self._mla_backend(_n_kv_heads = None) # n_heads=128 still set + result = b._estimate_kv_cache_bytes(1000, "f16") + # Should use n_kv_mla=1, NOT n_heads=128 + expected = 61 * 1000 * 1 * 576 * 2 + assert result == expected + + def test_mla_q4_quantization(self): + b = self._mla_backend() + result_f16 = b._estimate_kv_cache_bytes(1000, "f16") + result_q4 = b._estimate_kv_cache_bytes(1000, "q4_0") + assert result_q4 < result_f16 + # q4_0 bpe = 0.5625, f16 bpe = 2.0 + assert result_q4 == int(61 * 1000 * 1 * 576 * 0.5625) + + +# --------------------------------------------------------------------------- +# D. Path 2: Hybrid Mamba Estimation +# --------------------------------------------------------------------------- + + +class TestHybridMambaEstimation: + """Hybrid Mamba: only attention layers (1 in N) need KV cache.""" + + def _hybrid_backend(self, **overrides): + defaults = { + "_n_layers": 64, + "_n_kv_heads": 4, + "_n_heads": 24, + "_embedding_length": 5120, + "_kv_key_length": 256, + "_kv_value_length": 256, + "_full_attention_interval": 4, + "_ssm_inner_size": 6144, + "_ssm_state_size": 128, + } + defaults.update(overrides) + b = LlamaCppBackend() + for k, v in defaults.items(): + setattr(b, k, v) + return b + + def test_qwen35_27b(self): + b = self._hybrid_backend() + # n_attn = 64 // 4 = 16 + expected = 16 * 262144 * 4 * (256 + 256) * 2 + assert b._estimate_kv_cache_bytes(262144, "f16") == expected + + def test_qwen35_35b_a3b(self): + b = self._hybrid_backend( + _n_layers = 40, + _n_kv_heads = 2, + _n_heads = 16, + _embedding_length = 2048, + _ssm_inner_size = 4096, + ) + # n_attn = 40 // 4 = 10 + expected = 10 * 262144 * 2 * (256 + 256) * 2 + assert b._estimate_kv_cache_bytes(262144, "f16") == expected + + def test_hybrid_without_explicit_dims(self): + """Fallback to head_dim when key_length/value_length are missing.""" + b = self._hybrid_backend(_kv_key_length = None, _kv_value_length = None) + head_dim = 5120 // 24 # 213 + expected = 16 * 4096 * 4 * 2 * head_dim * 2 + assert b._estimate_kv_cache_bytes(4096, "f16") == expected + + def test_fai_zero_safety(self): + """full_attention_interval=0 should not cause ZeroDivisionError.""" + b = self._hybrid_backend(_full_attention_interval = 0) + result = b._estimate_kv_cache_bytes(4096, "f16") + # fai=0 -> n_attn = n_layers (all layers) + expected = 64 * 4096 * 4 * (256 + 256) * 2 + assert result == expected + + +# --------------------------------------------------------------------------- +# E. Path 3: Sliding Window Estimation +# --------------------------------------------------------------------------- + + +class TestSlidingWindowEstimation: + """SWA: half global (full ctx) + half sliding window.""" + + def _swa_backend(self, **overrides): + defaults = { + "_n_layers": 62, + "_n_kv_heads": 16, + "_n_heads": 32, + "_embedding_length": 5376, + "_kv_key_length": 128, + "_kv_value_length": 128, + "_sliding_window": 1024, + } + defaults.update(overrides) + b = LlamaCppBackend() + for k, v in defaults.items(): + setattr(b, k, v) + return b + + def test_gemma3(self): + b = self._swa_backend() + # 1/4 heuristic: 62 // 4 = 15 global, 47 SWA + n_global = max(1, 62 // 4) # 15 + n_swa = 62 - n_global # 47 + kv_per = 16 * (128 + 128) * 2 + expected = int(n_global * 131072 * kv_per + n_swa * min(131072, 1024) * kv_per) + assert b._estimate_kv_cache_bytes(131072, "f16") == expected + + def test_gpt_oss(self): + b = self._swa_backend( + _n_layers = 24, + _n_kv_heads = 8, + _n_heads = 64, + _embedding_length = 2880, + _kv_key_length = 64, + _kv_value_length = 64, + _sliding_window = 128, + ) + # 1/4 heuristic: 24 // 4 = 6 global, 18 SWA + n_global = max(1, 24 // 4) # 6 + n_swa = 24 - n_global # 18 + kv_per = 8 * (64 + 64) * 2 + expected = int(n_global * 131072 * kv_per + n_swa * min(131072, 128) * kv_per) + assert b._estimate_kv_cache_bytes(131072, "f16") == expected + + def test_ctx_smaller_than_window(self): + """When context < sliding_window, SWA layers use full context anyway.""" + b = self._swa_backend(_sliding_window = 8192) + n_global = max(1, 62 // 4) # 15 + n_swa = 62 - n_global # 47 + kv_per = 16 * (128 + 128) * 2 + ctx = 4096 + expected = int(n_global * ctx * kv_per + n_swa * min(ctx, 8192) * kv_per) + # min(4096, 8192) = 4096, so both pools use full ctx + assert b._estimate_kv_cache_bytes(ctx, "f16") == expected + + def test_odd_layer_count(self): + """Odd layer count: n_global = max(1, n//4), n_swa = n - n_global.""" + b = self._swa_backend(_n_layers = 63) + n_global = max(1, 63 // 4) # 15 + n_swa = 63 - n_global # 48 + kv_per = 16 * (128 + 128) * 2 + expected = int(n_global * 1000 * kv_per + n_swa * min(1000, 1024) * kv_per) + assert b._estimate_kv_cache_bytes(1000, "f16") == expected + + +# --------------------------------------------------------------------------- +# F. Path 4: Standard GQA Estimation +# --------------------------------------------------------------------------- + + +class TestStandardGQAEstimation: + """Standard GQA with explicit key_length/value_length.""" + + def _gqa_backend(self, **overrides): + defaults = { + "_n_layers": 28, + "_n_kv_heads": 8, + "_n_heads": 16, + "_embedding_length": 1024, + "_kv_key_length": 128, + "_kv_value_length": 128, + } + defaults.update(overrides) + b = LlamaCppBackend() + for k, v in defaults.items(): + setattr(b, k, v) + return b + + def test_qwen3_06b(self): + b = self._gqa_backend() + expected = 28 * 40960 * 8 * (128 + 128) * 2 + assert b._estimate_kv_cache_bytes(40960, "f16") == expected + + def test_asymmetric_kv_dims(self): + """key_length != value_length (some architectures have this).""" + b = self._gqa_backend(_kv_key_length = 192, _kv_value_length = 64) + expected = 28 * 4096 * 8 * (192 + 64) * 2 + assert b._estimate_kv_cache_bytes(4096, "f16") == expected + + def test_differs_from_legacy(self): + """GQA path should differ from legacy when key_length != embed//n_heads.""" + b = self._gqa_backend() + head_dim = 1024 // 16 # 64 + gqa_result = b._estimate_kv_cache_bytes(4096, "f16") + # Legacy would use: 2 * 8 * 64 * 28 * 4096 * 2 + legacy_result = int(2 * 8 * head_dim * 28 * 4096 * 2) + # GQA: 28 * 4096 * 8 * (128+128) * 2 -- uses actual key_length=128 + assert gqa_result != legacy_result + assert gqa_result > legacy_result # key_length (128) > head_dim (64) + + +# --------------------------------------------------------------------------- +# G. Path 5: Legacy Fallback Estimation +# --------------------------------------------------------------------------- + + +class TestLegacyEstimation: + """Legacy: embed // n_heads, for old GGUFs without new fields.""" + + def _legacy_backend(self, **overrides): + defaults = { + "_n_layers": 32, + "_n_kv_heads": 8, + "_n_heads": 32, + "_embedding_length": 4096, + } + defaults.update(overrides) + b = LlamaCppBackend() + for k, v in defaults.items(): + setattr(b, k, v) + return b + + def test_basic_legacy(self): + b = self._legacy_backend() + head_dim = 4096 // 32 # 128 + expected = int(2 * 8 * 128 * 32 * 4096 * 2) + assert b._estimate_kv_cache_bytes(4096, "f16") == expected + + def test_legacy_with_only_n_heads(self): + """n_kv_heads is None, falls back to n_heads.""" + b = self._legacy_backend(_n_kv_heads = None) + head_dim = 4096 // 32 + expected = int(2 * 32 * head_dim * 32 * 4096 * 2) + assert b._estimate_kv_cache_bytes(4096, "f16") == expected + + def test_legacy_identical_to_old_formula(self): + """Confirm legacy path produces the same result as the pre-PR formula.""" + b = self._legacy_backend() + n_layers = 32 + n_kv_heads = 8 + head_dim = 4096 // 32 + n_ctx = 8192 + bpe = 2.0 + old_formula = int(2 * n_kv_heads * head_dim * n_layers * n_ctx * bpe) + assert b._estimate_kv_cache_bytes(n_ctx, "f16") == old_formula + + +# --------------------------------------------------------------------------- +# H. Path Priority (selection order) +# --------------------------------------------------------------------------- + + +class TestPathPriority: + """Confirm: MLA > Hybrid Mamba > SWA > GQA > Legacy.""" + + def test_mla_takes_priority_over_all(self): + """If kv_lora_rank is set, MLA path is used even if other fields are present.""" + b = LlamaCppBackend() + b._n_layers = 61 + b._n_kv_heads = 1 + b._n_heads = 128 + b._embedding_length = 7168 + b._kv_key_length = 576 + b._kv_value_length = 512 + b._kv_lora_rank = 512 + b._ssm_inner_size = 4096 # Would trigger Hybrid + b._full_attention_interval = 4 + b._sliding_window = 1024 # Would trigger SWA + + # MLA: 61 * 1000 * 1 * 576 * 2 + expected_mla = int(61 * 1000 * 1 * 576 * 2) + assert b._estimate_kv_cache_bytes(1000, "f16") == expected_mla + + def test_hybrid_over_swa(self): + """Hybrid takes priority over SWA when both fields present.""" + b = LlamaCppBackend() + b._n_layers = 64 + b._n_kv_heads = 4 + b._n_heads = 24 + b._embedding_length = 5120 + b._kv_key_length = 256 + b._kv_value_length = 256 + b._ssm_inner_size = 6144 + b._full_attention_interval = 4 + b._sliding_window = 1024 # Would trigger SWA + + n_attn = 64 // 4 + expected_hybrid = int(n_attn * 1000 * 4 * (256 + 256) * 2) + assert b._estimate_kv_cache_bytes(1000, "f16") == expected_hybrid + + def test_all_paths_produce_different_values(self): + """With carefully chosen params, each path should yield a distinct value.""" + # Use embedding_length=768 so legacy head_dim (768//16=48) differs from + # key_length (256), and MLA key_len (256) != legacy K+V (2*48=96). + params = { + "_n_layers": 40, + "_n_kv_heads": 4, + "_n_heads": 16, + "_embedding_length": 768, + "_kv_key_length": 256, + "_kv_value_length": 256, + } + ctx = 4096 + + # Path 4: Standard GQA + b_gqa = LlamaCppBackend() + for k, v in params.items(): + setattr(b_gqa, k, v) + gqa_val = b_gqa._estimate_kv_cache_bytes(ctx, "f16") + + # Path 1: MLA + b_mla = LlamaCppBackend() + for k, v in params.items(): + setattr(b_mla, k, v) + b_mla._kv_lora_rank = 512 + mla_val = b_mla._estimate_kv_cache_bytes(ctx, "f16") + + # Path 2: Hybrid Mamba + b_hybrid = LlamaCppBackend() + for k, v in params.items(): + setattr(b_hybrid, k, v) + b_hybrid._ssm_inner_size = 4096 + b_hybrid._full_attention_interval = 4 + hybrid_val = b_hybrid._estimate_kv_cache_bytes(ctx, "f16") + + # Path 3: SWA + b_swa = LlamaCppBackend() + for k, v in params.items(): + setattr(b_swa, k, v) + b_swa._sliding_window = 512 + swa_val = b_swa._estimate_kv_cache_bytes(ctx, "f16") + + # Path 5: Legacy (no key_length/value_length) + b_legacy = LlamaCppBackend() + b_legacy._n_layers = 40 + b_legacy._n_kv_heads = 4 + b_legacy._n_heads = 16 + b_legacy._embedding_length = 768 + legacy_val = b_legacy._estimate_kv_cache_bytes(ctx, "f16") + + values = [mla_val, hybrid_val, swa_val, gqa_val, legacy_val] + assert len(set(values)) == 5, f"Expected 5 distinct values, got {values}" + + +# --------------------------------------------------------------------------- +# I. KV Cache Quantization +# --------------------------------------------------------------------------- + + +class TestQuantization: + """Verify all supported cache_type_kv values produce correct scaling.""" + + @pytest.mark.parametrize( + "cache_type,expected_bpe", + [ + ("f32", 4.0), + ("f16", 2.0), + ("bf16", 2.0), + ("q8_0", 34 / 32), + ("q5_1", 0.75), + ("q5_0", 0.6875), + ("q4_1", 0.625), + ("q4_0", 0.5625), + ("iq4_nl", 0.5625), + (None, 2.0), # default is f16 + ("unknown", 2.0), # unknown falls back to f16 + ], + ) + def test_quantization_scaling(self, cache_type, expected_bpe): + b = LlamaCppBackend() + b._n_layers = 10 + b._n_kv_heads = 1 + b._n_heads = 8 + b._embedding_length = 512 + b._kv_key_length = 64 + b._kv_value_length = 64 + result = b._estimate_kv_cache_bytes(1000, cache_type) + expected = int(10 * 1000 * 1 * (64 + 64) * expected_bpe) + assert result == expected + + +# --------------------------------------------------------------------------- +# J. Edge Cases +# --------------------------------------------------------------------------- + + +class TestEdgeCases: + """Boundary conditions and degenerate inputs.""" + + def test_zero_context(self): + b = LlamaCppBackend() + b._n_layers = 32 + b._kv_key_length = 128 + assert b._estimate_kv_cache_bytes(0, "f16") == 0 + + def test_negative_context(self): + b = LlamaCppBackend() + b._n_layers = 32 + b._kv_key_length = 128 + assert b._estimate_kv_cache_bytes(-1, "f16") == 0 + + def test_context_of_one(self): + b = LlamaCppBackend() + b._n_layers = 10 + b._n_kv_heads = 1 + b._kv_key_length = 64 + b._kv_value_length = 64 + result = b._estimate_kv_cache_bytes(1, "f16") + assert result == int(10 * 1 * 1 * (64 + 64) * 2) + + def test_very_large_context(self): + """1M context should not overflow or crash.""" + b = LlamaCppBackend() + b._n_layers = 10 + b._n_kv_heads = 1 + b._kv_key_length = 128 + b._kv_value_length = 128 + result = b._estimate_kv_cache_bytes(1_000_000, "f16") + assert result > 0 + assert isinstance(result, int) + + def test_n_kv_heads_none_falls_to_n_heads(self): + b = LlamaCppBackend() + b._n_layers = 10 + b._n_kv_heads = None + b._n_heads = 8 + b._kv_key_length = 64 + b._kv_value_length = 64 + result = b._estimate_kv_cache_bytes(100, "f16") + expected = int(10 * 100 * 8 * (64 + 64) * 2) + assert result == expected + + def test_both_heads_none_falls_to_one(self): + b = LlamaCppBackend() + b._n_layers = 10 + b._n_kv_heads = None + b._n_heads = None + b._kv_key_length = 64 + b._kv_value_length = 64 + result = b._estimate_kv_cache_bytes(100, "f16") + expected = int(10 * 100 * 1 * (64 + 64) * 2) + assert result == expected + + +# --------------------------------------------------------------------------- +# K. Lifecycle Tests +# --------------------------------------------------------------------------- + + +class TestLifecycle: + """Init, unload, and reparse field management.""" + + def test_init_fields_none(self): + b = LlamaCppBackend() + for attr in [ + "_kv_key_length", + "_kv_value_length", + "_sliding_window", + "_full_attention_interval", + "_kv_lora_rank", + "_key_length_mla", + "_ssm_inner_size", + "_ssm_state_size", + ]: + assert getattr(b, attr) is None + + def test_unload_resets_fields(self): + b = LlamaCppBackend() + b._n_layers = 32 + b._kv_key_length = 128 + b._kv_lora_rank = 512 + b._sliding_window = 1024 + b._ssm_inner_size = 4096 + b._full_attention_interval = 4 + b.unload_model() + for attr in [ + "_kv_key_length", + "_kv_value_length", + "_sliding_window", + "_full_attention_interval", + "_kv_lora_rank", + "_key_length_mla", + "_ssm_inner_size", + "_ssm_state_size", + ]: + assert getattr(b, attr) is None + + def test_end_to_end_synthetic_mla(self): + """Full round-trip: write GGUF -> parse -> estimate.""" + b = _backend_from_gguf( + "deepseek2", + { + "context_length": 163840, + "block_count": 61, + "attention.head_count_kv": 1, + "attention.head_count": 128, + "embedding_length": 7168, + "attention.key_length": 576, + "attention.value_length": 512, + "attention.kv_lora_rank": 512, + "attention.key_length_mla": 192, + }, + ) + assert b._can_estimate_kv() + result = b._estimate_kv_cache_bytes(163840, "f16") + expected = 61 * 163840 * 1 * 576 * 2 + assert result == expected + + def test_end_to_end_synthetic_hybrid(self): + b = _backend_from_gguf( + "qwen35", + { + "context_length": 262144, + "block_count": 64, + "attention.head_count_kv": 4, + "attention.head_count": 24, + "embedding_length": 5120, + "attention.key_length": 256, + "attention.value_length": 256, + "full_attention_interval": 4, + "ssm.inner_size": 6144, + "ssm.state_size": 128, + }, + ) + assert b._can_estimate_kv() + result = b._estimate_kv_cache_bytes(262144, "f16") + n_attn = 64 // 4 + expected = n_attn * 262144 * 4 * (256 + 256) * 2 + assert result == expected + + def test_end_to_end_synthetic_swa(self): + b = _backend_from_gguf( + "gemma3", + { + "context_length": 131072, + "block_count": 62, + "attention.head_count_kv": 16, + "attention.head_count": 32, + "embedding_length": 5376, + "attention.key_length": 128, + "attention.value_length": 128, + "attention.sliding_window": 1024, + }, + ) + assert b._can_estimate_kv() + result = b._estimate_kv_cache_bytes(131072, "f16") + n_global = max(1, 62 // 4) # 15 + n_swa = 62 - n_global # 47 + kv_per = 16 * 256 * 2 + expected = int(n_global * 131072 * kv_per + n_swa * 1024 * kv_per) + assert result == expected + + def test_end_to_end_synthetic_gqa(self): + b = _backend_from_gguf( + "qwen3", + { + "context_length": 40960, + "block_count": 28, + "attention.head_count_kv": 8, + "attention.head_count": 16, + "embedding_length": 1024, + "attention.key_length": 128, + "attention.value_length": 128, + }, + ) + assert b._can_estimate_kv() + result = b._estimate_kv_cache_bytes(40960, "f16") + expected = 28 * 40960 * 8 * 256 * 2 + assert result == expected + + def test_end_to_end_synthetic_legacy(self): + b = _backend_from_gguf( + "llama", + { + "context_length": 4096, + "block_count": 32, + "attention.head_count_kv": 8, + "attention.head_count": 32, + "embedding_length": 4096, + }, + ) + assert b._can_estimate_kv() + result = b._estimate_kv_cache_bytes(4096, "f16") + head_dim = 4096 // 32 + expected = int(2 * 8 * head_dim * 32 * 4096 * 2) + assert result == expected From 71b934ef9d599c27041141e9b8ca461e8bf53e05 Mon Sep 17 00:00:00 2001 From: DoubleMathew Date: Wed, 1 Apr 2026 14:06:39 -0500 Subject: [PATCH 18/44] Fix custom llama.cpp source builds and macos metal source builds (#4762) * Fix script unbound variable error * remove stale test script, add llama.cpp metal source builds, update tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix Metal precedence, test sync, and add behavioral tests - Move macOS arm64 Metal check before CUDA/ROCm in GPU backend decision chain so Metal is not bypassed when nvcc is in PATH - Remove RPATH flags from CPU fallback CMAKE_ARGS (only needed for Metal library linking) - Update test_llama_pr_force_and_source.py to match _CLONE_ARGS rename from _CLONE_BRANCH_ARGS in setup.sh - Add confirm_install_tree guard test for existing_install_matches_choice - Add TestMacOSMetalBuildLogic bash subprocess tests verifying Metal flag selection, nvcc precedence, and CPU fallback behavior * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix Metal CPU fallback to also cover cmake build failures and update tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * 1. _GPU_BACKEND_FRAGMENT synced -- removed dead CPU_FALLBACK_CMAKE_ARGS= init (6/8) 2. RPATH assertion replaced -- new test_macos_arm64_cpu_fallback_args_exclude_rpath checks the actual runtime CPU_FALLBACK_CMAKE_ARGS output for @loader_path and -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON (6/8) 3. _TRY_METAL_CPU_FALLBACK=false reset after both configure-failure and build-failure fallback branches in setup.sh (4/8) 4. macOS test now removes libmtmd.0.dylib instead of the platform-agnostic convert_hf_to_gguf.py (3/8) 5. Empty-string tag test added -- test_empty_tag_omits_branch_flag for resolved_tag= (2/8) 6. RPATH checks on cmake call logs -- both fallback tests now assert @loader_path and -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON are absent from CPU fallback cmake calls, plus baseline flag preservation (multiple) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * tests clean up * [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/install_llama_prebuilt.py | 5 + studio/setup.sh | 53 ++- .../test_install_llama_prebuilt_logic.py | 181 +++++++++ .../install/test_llama_pr_force_and_source.py | 41 +- tests/studio/install/test_pr4562_bugfixes.py | 356 +++++++++++++++++- .../install/test_validate_llama_prebuilt.py | 175 --------- 6 files changed, 616 insertions(+), 195 deletions(-) delete mode 100644 tests/studio/install/test_validate_llama_prebuilt.py diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index f09955c9e3..0f96cf34eb 100755 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -3739,6 +3739,11 @@ def existing_install_matches_choice( if metadata is None: return False + try: + confirm_install_tree(install_dir, host) + except Exception: + return False + if not runtime_payload_is_healthy(install_dir, host, choice): return False diff --git a/studio/setup.sh b/studio/setup.sh index 4a3e23b1a4..36926733e1 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -716,20 +716,29 @@ else git -C "$_BUILD_TMP" checkout "pr-$_LLAMA_PR" || BUILD_OK=false fi else - _CLONE_BRANCH_ARGS=() + _CLONE_ARGS=(git clone --depth 1) if [ "$_RESOLVED_LLAMA_TAG" != "latest" ] && [ -n "$_RESOLVED_LLAMA_TAG" ]; then - _CLONE_BRANCH_ARGS=(--branch "$_RESOLVED_LLAMA_TAG") + _CLONE_ARGS+=(--branch "$_RESOLVED_LLAMA_TAG") fi + _CLONE_ARGS+=("${_LLAMA_SOURCE}.git" "$_BUILD_TMP") run_quiet_no_exit "clone llama.cpp" \ - git clone --depth 1 "${_CLONE_BRANCH_ARGS[@]}" "${_LLAMA_SOURCE}.git" "$_BUILD_TMP" || BUILD_OK=false + "${_CLONE_ARGS[@]}" || BUILD_OK=false fi if [ "$BUILD_OK" = true ]; then CMAKE_ARGS="-DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_EXAMPLES=OFF -DLLAMA_BUILD_SERVER=ON -DGGML_NATIVE=ON" + _TRY_METAL_CPU_FALLBACK=false + _HOST_SYSTEM="$(uname -s 2>/dev/null || true)" + _HOST_MACHINE="$(uname -m 2>/dev/null || true)" + _IS_MACOS_ARM64=false + if [ "$_HOST_SYSTEM" = "Darwin" ] && { [ "$_HOST_MACHINE" = "arm64" ] || [ "$_HOST_MACHINE" = "aarch64" ]; }; then + _IS_MACOS_ARM64=true + fi if command -v ccache &>/dev/null; then CMAKE_ARGS="$CMAKE_ARGS -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_CUDA_COMPILER_LAUNCHER=ccache" fi + CPU_FALLBACK_CMAKE_ARGS="$CMAKE_ARGS" GPU_BACKEND="" NVCC_PATH="" @@ -765,7 +774,13 @@ else fi _BUILD_DESC="building" - if [ -n "$NVCC_PATH" ]; then + if [ "$_IS_MACOS_ARM64" = true ]; then + # Metal takes precedence on Apple Silicon (CUDA/ROCm not functional on macOS) + _BUILD_DESC="building (Metal)" + CMAKE_ARGS="$CMAKE_ARGS -DGGML_METAL=ON -DGGML_METAL_EMBED_LIBRARY=ON -DGGML_METAL_USE_BF16=ON -DCMAKE_INSTALL_RPATH=@loader_path -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON" + CPU_FALLBACK_CMAKE_ARGS="$CPU_FALLBACK_CMAKE_ARGS -DGGML_METAL=OFF" + _TRY_METAL_CPU_FALLBACK=true + elif [ -n "$NVCC_PATH" ]; then CMAKE_ARGS="$CMAKE_ARGS -DGGML_CUDA=ON" CUDA_ARCHS="" @@ -847,11 +862,37 @@ else CMAKE_GENERATOR_ARGS="-G Ninja" fi - run_quiet_no_exit "cmake llama.cpp" cmake $CMAKE_GENERATOR_ARGS -S "$_BUILD_TMP" -B "$_BUILD_TMP/build" $CMAKE_ARGS || BUILD_OK=false + if ! run_quiet_no_exit "cmake llama.cpp" cmake $CMAKE_GENERATOR_ARGS -S "$_BUILD_TMP" -B "$_BUILD_TMP/build" $CMAKE_ARGS; then + if [ "$_TRY_METAL_CPU_FALLBACK" = true ]; then + _TRY_METAL_CPU_FALLBACK=false + substep "Metal configure failed; retrying CPU build..." "$C_WARN" + rm -rf "$_BUILD_TMP/build" + run_quiet_no_exit "cmake llama.cpp (cpu fallback)" cmake $CMAKE_GENERATOR_ARGS -S "$_BUILD_TMP" -B "$_BUILD_TMP/build" $CPU_FALLBACK_CMAKE_ARGS || BUILD_OK=false + if [ "$BUILD_OK" = true ]; then + _BUILD_DESC="building (CPU fallback)" + fi + else + BUILD_OK=false + fi + fi fi if [ "$BUILD_OK" = true ]; then - run_quiet_no_exit "build llama-server" cmake --build "$_BUILD_TMP/build" --config Release --target llama-server -j"$NCPU" || BUILD_OK=false + if ! run_quiet_no_exit "build llama-server" cmake --build "$_BUILD_TMP/build" --config Release --target llama-server -j"$NCPU"; then + if [ "$_TRY_METAL_CPU_FALLBACK" = true ]; then + _TRY_METAL_CPU_FALLBACK=false + substep "Metal build failed; retrying CPU build..." "$C_WARN" + rm -rf "$_BUILD_TMP/build" + if run_quiet_no_exit "cmake llama.cpp (cpu fallback)" cmake $CMAKE_GENERATOR_ARGS -S "$_BUILD_TMP" -B "$_BUILD_TMP/build" $CPU_FALLBACK_CMAKE_ARGS; then + _BUILD_DESC="building (CPU fallback)" + run_quiet_no_exit "build llama-server (cpu fallback)" cmake --build "$_BUILD_TMP/build" --config Release --target llama-server -j"$NCPU" || BUILD_OK=false + else + BUILD_OK=false + fi + else + BUILD_OK=false + fi + fi fi if [ "$BUILD_OK" = true ]; then diff --git a/tests/studio/install/test_install_llama_prebuilt_logic.py b/tests/studio/install/test_install_llama_prebuilt_logic.py index 622f01f946..cfbbdeb66c 100644 --- a/tests/studio/install/test_install_llama_prebuilt_logic.py +++ b/tests/studio/install/test_install_llama_prebuilt_logic.py @@ -1864,3 +1864,184 @@ def add_symlink_to_tar(archive: tarfile.TarFile, name: str, target: str) -> None info.type = tarfile.SYMTYPE info.linkname = target archive.addfile(info) + + +def test_existing_install_matches_choice_fails_when_install_tree_incomplete( + tmp_path: Path, +): + """confirm_install_tree guard rejects installs missing critical files.""" + install_dir = tmp_path / "llama.cpp" + install_dir.mkdir() + write_linux_install_shape(install_dir) + + host = HostInfo( + system = "Linux", + machine = "x86_64", + is_windows = False, + is_linux = True, + is_macos = False, + is_x86_64 = True, + is_arm64 = False, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + choice = AssetChoice( + repo = "unslothai/llama.cpp", + tag = "release-1", + name = "llama-b9001-bin-ubuntu-x64.tar.gz", + url = "https://example.com/llama-b9001-bin-ubuntu-x64.tar.gz", + source_label = "upstream", + install_kind = "linux-cpu", + expected_sha256 = "a" * 64, + ) + checksums = ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "release-1", + upstream_tag = "b9001", + source_commit = "deadbeef", + artifacts = { + source_archive_logical_name("b9001"): ApprovedArtifactHash( + asset_name = source_archive_logical_name("b9001"), + sha256 = "b" * 64, + repo = "ggml-org/llama.cpp", + kind = "upstream-source", + ), + choice.name: ApprovedArtifactHash( + asset_name = choice.name, + sha256 = choice.expected_sha256, + repo = "ggml-org/llama.cpp", + kind = "upstream-prebuilt", + ), + }, + ) + write_prebuilt_metadata( + install_dir, + requested_tag = "latest", + llama_tag = "b9001", + release_tag = "release-1", + choice = choice, + approved_checksums = checksums, + prebuilt_fallback_used = False, + ) + + # Full install should match + assert ( + existing_install_matches_choice( + install_dir, + host, + llama_tag = "b9001", + release_tag = "release-1", + choice = choice, + approved_checksums = checksums, + ) + is True + ) + + # Remove convert_hf_to_gguf.py (checked by confirm_install_tree but not + # runtime_payload_is_healthy) and verify the guard catches it + (install_dir / "convert_hf_to_gguf.py").unlink() + assert ( + existing_install_matches_choice( + install_dir, + host, + llama_tag = "b9001", + release_tag = "release-1", + choice = choice, + approved_checksums = checksums, + ) + is False + ) + + +def test_existing_install_matches_choice_fails_when_install_tree_incomplete_macos( + tmp_path: Path, +): + """confirm_install_tree guard rejects macOS arm64 installs missing critical files.""" + install_dir = tmp_path / "llama.cpp" + install_dir.mkdir() + write_macos_install_shape(install_dir) + + host = HostInfo( + system = "Darwin", + machine = "arm64", + is_windows = False, + is_linux = False, + is_macos = True, + is_x86_64 = False, + is_arm64 = True, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + choice = AssetChoice( + repo = "unslothai/llama.cpp", + tag = "release-1", + name = "llama-b9001-bin-macos-arm64.tar.gz", + url = "https://example.com/llama-b9001-bin-macos-arm64.tar.gz", + source_label = "upstream", + install_kind = "macos-arm64", + expected_sha256 = "a" * 64, + ) + checksums = ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "release-1", + upstream_tag = "b9001", + source_commit = "deadbeef", + artifacts = { + source_archive_logical_name("b9001"): ApprovedArtifactHash( + asset_name = source_archive_logical_name("b9001"), + sha256 = "b" * 64, + repo = "ggml-org/llama.cpp", + kind = "upstream-source", + ), + choice.name: ApprovedArtifactHash( + asset_name = choice.name, + sha256 = choice.expected_sha256, + repo = "ggml-org/llama.cpp", + kind = "upstream-prebuilt", + ), + }, + ) + write_prebuilt_metadata( + install_dir, + requested_tag = "latest", + llama_tag = "b9001", + release_tag = "release-1", + choice = choice, + approved_checksums = checksums, + prebuilt_fallback_used = False, + ) + + # Full install should match + assert ( + existing_install_matches_choice( + install_dir, + host, + llama_tag = "b9001", + release_tag = "release-1", + choice = choice, + approved_checksums = checksums, + ) + is True + ) + + # Remove a macOS-specific runtime artifact and verify the guard catches it + (install_dir / "build" / "bin" / "libmtmd.0.dylib").unlink() + assert ( + existing_install_matches_choice( + install_dir, + host, + llama_tag = "b9001", + release_tag = "release-1", + choice = choice, + approved_checksums = checksums, + ) + is False + ) 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 63a620b2a9..44cbda4c71 100644 --- a/tests/studio/install/test_llama_pr_force_and_source.py +++ b/tests/studio/install/test_llama_pr_force_and_source.py @@ -302,12 +302,13 @@ class TestBashCloneUrlParameterized: run_quiet_no_exit "clone llama.cpp" \\ git clone --depth 1 "${{_LLAMA_SOURCE}}.git" "$_BUILD_TMP" || BUILD_OK=false else - _CLONE_BRANCH_ARGS=() + _CLONE_ARGS=(git clone --depth 1) if [ "$_RESOLVED_LLAMA_TAG" != "latest" ] && [ -n "$_RESOLVED_LLAMA_TAG" ]; then - _CLONE_BRANCH_ARGS=(--branch "$_RESOLVED_LLAMA_TAG") + _CLONE_ARGS+=(--branch "$_RESOLVED_LLAMA_TAG") fi + _CLONE_ARGS+=("${{_LLAMA_SOURCE}}.git" "$_BUILD_TMP") run_quiet_no_exit "clone llama.cpp" \\ - git clone --depth 1 "${{_CLONE_BRANCH_ARGS[@]}}" "${{_LLAMA_SOURCE}}.git" "$_BUILD_TMP" || BUILD_OK=false + "${{_CLONE_ARGS[@]}}" || BUILD_OK=false fi echo "BUILD_OK=$BUILD_OK" """) @@ -350,6 +351,36 @@ class TestBashCloneUrlParameterized: log = log_file.read_text() assert "ggml-org/llama.cpp.git" in log + def test_latest_tag_omits_branch_flag(self, tmp_path: Path): + """resolved_tag='latest' should not pass --branch to git clone.""" + mock_bin, log_file = make_mock_git(tmp_path) + build_tmp = str(tmp_path / "build_tmp") + script = self._clone_script( + mock_bin, + build_tmp, + resolved_tag = "latest", + ) + r = run_bash(script) + assert r.returncode == 0 + log = log_file.read_text() + assert "--branch" not in log + assert "ggml-org/llama.cpp.git" in log + + def test_empty_tag_omits_branch_flag(self, tmp_path: Path): + """resolved_tag='' (empty) should not pass --branch to git clone.""" + mock_bin, log_file = make_mock_git(tmp_path) + build_tmp = str(tmp_path / "build_tmp") + script = self._clone_script( + mock_bin, + build_tmp, + resolved_tag = "", + ) + r = run_bash(script) + assert r.returncode == 0 + log = log_file.read_text() + assert "--branch" not in log + assert "ggml-org/llama.cpp.git" in log + # ========================================================================= # TEST GROUP D: Static source patterns -- setup.sh @@ -395,8 +426,8 @@ class TestSourcePatternsSh: def test_clone_urls_parameterized_tag_path(self): """Non-PR clone path uses ${_LLAMA_SOURCE}.git, not hardcoded URL.""" - # Find the non-PR clone line (after _CLONE_BRANCH_ARGS) - idx = self.content.index("_CLONE_BRANCH_ARGS=()") + # 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 "ggml-org/llama.cpp.git" not in block diff --git a/tests/studio/install/test_pr4562_bugfixes.py b/tests/studio/install/test_pr4562_bugfixes.py index b1fe00f3b3..32787aee34 100644 --- a/tests/studio/install/test_pr4562_bugfixes.py +++ b/tests/studio/install/test_pr4562_bugfixes.py @@ -14,13 +14,11 @@ Run: pytest tests/studio/install/test_pr4562_bugfixes.py -v """ import importlib.util -import json import os import subprocess import sys import textwrap from pathlib import Path -from unittest.mock import patch import pytest @@ -86,6 +84,9 @@ def run_bash(script: str, *, timeout: int = 10, env: dict | None = None) -> str: timeout = timeout, env = run_env, ) + assert ( + result.returncode == 0 + ), f"bash script failed (exit {result.returncode}):\n{result.stderr}" return result.stdout.strip() @@ -581,15 +582,12 @@ class TestSourceCodePatterns: assert idx_rm > idx_git, "rm -rf should come after git check" def test_setup_sh_clone_uses_branch_tag(self): - """git clone in source-build should use --branch via _CLONE_BRANCH_ARGS.""" + """git clone in source-build should use --branch via the clone args array.""" content = SETUP_SH.read_text() - # The clone line should use _CLONE_BRANCH_ARGS (which conditionally includes --branch) + assert "_CLONE_ARGS=(git clone --depth 1)" in content assert ( - "_CLONE_BRANCH_ARGS" in content - ), "Clone should use _CLONE_BRANCH_ARGS array" - assert ( - '--branch "$_RESOLVED_LLAMA_TAG"' in content - ), "_CLONE_BRANCH_ARGS should be set to --branch $_RESOLVED_LLAMA_TAG" + '_CLONE_ARGS+=(--branch "$_RESOLVED_LLAMA_TAG")' in content + ), "_CLONE_ARGS should be extended with --branch $_RESOLVED_LLAMA_TAG" # Verify the guard: --branch is only used when tag is not "latest" assert ( '_RESOLVED_LLAMA_TAG" != "latest"' in content @@ -598,10 +596,78 @@ class TestSourceCodePatterns: 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 "_HELPER_RELEASE_REPO}/releases/latest" not in content assert "ggml-org/llama.cpp/releases/latest" not in content + def test_setup_sh_macos_arm64_uses_metal_flags(self): + """Apple Silicon source builds should explicitly enable Metal like upstream.""" + content = SETUP_SH.read_text() + assert "_IS_MACOS_ARM64=true" in content + assert 'if [ "$_IS_MACOS_ARM64" = true ]; then' in content + assert "-DGGML_METAL=ON" in content + assert "-DGGML_METAL_EMBED_LIBRARY=ON" in content + assert "-DGGML_METAL_USE_BF16=ON" in content + assert "-DCMAKE_INSTALL_RPATH=@loader_path" in content + assert "-DCMAKE_BUILD_WITH_INSTALL_RPATH=ON" in content + + def test_setup_sh_macos_metal_configure_has_cpu_fallback(self): + """If Metal configure or build fails, setup should retry with CPU fallback.""" + content = SETUP_SH.read_text() + assert "_TRY_METAL_CPU_FALLBACK=true" in content + assert ( + 'substep "Metal configure failed; retrying CPU build..." "$C_WARN"' + in content + ) + assert ( + 'substep "Metal build failed; retrying CPU build..." "$C_WARN"' in content + ) + assert 'run_quiet_no_exit "cmake llama.cpp (cpu fallback)"' in content + assert "-DGGML_METAL=OFF" in content + # _TRY_METAL_CPU_FALLBACK must be reset to false in both fallback branches + # (1 init + 2 resets = at least 3 occurrences of =false) + assert content.count("_TRY_METAL_CPU_FALLBACK=false") >= 3, ( + "_TRY_METAL_CPU_FALLBACK=false should appear at least 3 times " + "(init + configure fallback + build fallback)" + ) + + def test_macos_arm64_cpu_fallback_args_exclude_rpath(self): + """CPU fallback args must NOT contain Metal-only RPATH flags at runtime.""" + script = ( + '_IS_MACOS_ARM64=true\nNVCC_PATH=""\nGPU_BACKEND=""\n' + + _GPU_BACKEND_FRAGMENT + ) + output = run_bash(script) + fallback_line = next( + line + for line in output.splitlines() + if line.startswith("CPU_FALLBACK_CMAKE_ARGS=") + ) + assert "-DGGML_METAL=OFF" in fallback_line + assert ( + "@loader_path" not in fallback_line + ), "CPU fallback args should not contain RPATH flags" + assert ( + "-DCMAKE_BUILD_WITH_INSTALL_RPATH=ON" not in fallback_line + ), "CPU fallback args should not contain RPATH build flag" + + def test_setup_sh_does_not_enable_metal_for_intel_macos(self): + """Intel macOS should stay on the existing non-Metal path in this patch.""" + content = SETUP_SH.read_text() + assert 'if [ "$_IS_MACOS_ARM64" = true ]; then' in content + assert ( + 'Darwin" ] && { [ "$_HOST_MACHINE" = "arm64" ] || [ "$_HOST_MACHINE" = "aarch64" ]; }' + in content + ) + assert ( + "x86_64" + not in content[ + content.find("-DGGML_METAL=ON") - 200 : content.find("-DGGML_METAL=ON") + + 200 + ] + ) + def test_setup_ps1_uses_checkout_b(self): """PS1 should use checkout -B, not checkout --force FETCH_HEAD.""" content = SETUP_PS1.read_text() @@ -635,6 +701,7 @@ class TestSourceCodePatterns: def test_setup_ps1_latest_resolution_uses_helper_only(self): """PS1 fallback should rely on helper output, not raw GitHub API tag_name.""" content = SETUP_PS1.read_text() + assert "--resolve-install-tag" in content assert "--resolve-llama-tag" in content assert "$HelperReleaseRepo/releases/latest" not in content assert "ggml-org/llama.cpp/releases/latest" not in content @@ -657,3 +724,274 @@ class TestSourceCodePatterns: found = True break assert found, "binary_path.parent not found in Linux branch of binary_env" + + +# ========================================================================= +# TEST GROUP F: macOS Metal build logic (bash subprocess tests) +# ========================================================================= + +# Minimal bash fragment that mirrors setup.sh's GPU backend decision chain. +# Variables _IS_MACOS_ARM64, NVCC_PATH, GPU_BACKEND are injected by tests. +_GPU_BACKEND_FRAGMENT = textwrap.dedent("""\ + CMAKE_ARGS="-DLLAMA_BUILD_TESTS=OFF" + _TRY_METAL_CPU_FALLBACK=false + CPU_FALLBACK_CMAKE_ARGS="$CMAKE_ARGS" + + _BUILD_DESC="building" + if [ "$_IS_MACOS_ARM64" = true ]; then + _BUILD_DESC="building (Metal)" + CMAKE_ARGS="$CMAKE_ARGS -DGGML_METAL=ON -DGGML_METAL_EMBED_LIBRARY=ON -DGGML_METAL_USE_BF16=ON -DCMAKE_INSTALL_RPATH=@loader_path -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON" + CPU_FALLBACK_CMAKE_ARGS="$CPU_FALLBACK_CMAKE_ARGS -DGGML_METAL=OFF" + _TRY_METAL_CPU_FALLBACK=true + elif [ -n "$NVCC_PATH" ]; then + CMAKE_ARGS="$CMAKE_ARGS -DGGML_CUDA=ON" + _BUILD_DESC="building (CUDA)" + elif [ "$GPU_BACKEND" = "rocm" ]; then + CMAKE_ARGS="$CMAKE_ARGS -DGGML_HIP=ON" + _BUILD_DESC="building (ROCm)" + else + _BUILD_DESC="building (CPU)" + fi + + echo "CMAKE_ARGS=$CMAKE_ARGS" + echo "CPU_FALLBACK_CMAKE_ARGS=$CPU_FALLBACK_CMAKE_ARGS" + echo "BUILD_DESC=$_BUILD_DESC" + echo "TRY_METAL_CPU_FALLBACK=$_TRY_METAL_CPU_FALLBACK" +""") + + +class TestMacOSMetalBuildLogic: + """Behavioral bash subprocess tests for the Metal GPU backend logic.""" + + def test_macos_arm64_cmake_args_contain_metal_flags(self): + """macOS arm64 should enable Metal, not CUDA.""" + script = ( + '_IS_MACOS_ARM64=true\nNVCC_PATH=""\nGPU_BACKEND=""\n' + + _GPU_BACKEND_FRAGMENT + ) + output = run_bash(script) + assert "-DGGML_METAL=ON" in output + assert "-DGGML_CUDA=ON" not in output + assert "BUILD_DESC=building (Metal)" in output + + def test_intel_macos_no_metal_flags(self): + """Intel macOS (not arm64) should not get Metal flags.""" + script = ( + '_IS_MACOS_ARM64=false\nNVCC_PATH=""\nGPU_BACKEND=""\n' + + _GPU_BACKEND_FRAGMENT + ) + output = run_bash(script) + assert "-DGGML_METAL=ON" not in output + assert "BUILD_DESC=building (CPU)" in output + + def test_macos_arm64_metal_precedes_nvcc(self): + """Even with nvcc in PATH, macOS arm64 should use Metal, not CUDA.""" + script = ( + '_IS_MACOS_ARM64=true\nNVCC_PATH="/usr/local/cuda/bin/nvcc"\n' + 'GPU_BACKEND="cuda"\n' + _GPU_BACKEND_FRAGMENT + ) + output = run_bash(script) + assert "-DGGML_METAL=ON" in output + assert "-DGGML_CUDA=ON" not in output + assert "BUILD_DESC=building (Metal)" in output + + def test_metal_cpu_fallback_triggers_on_cmake_failure(self, tmp_path: Path): + """When cmake fails on Metal, the fallback should retry with -DGGML_METAL=OFF.""" + mock_bin = tmp_path / "mock_bin" + mock_bin.mkdir() + calls_file = tmp_path / "cmake_calls.log" + # cmake that logs args and fails on first call (Metal), succeeds on second (CPU fallback) + cmake_script = mock_bin / "cmake" + cmake_script.write_text( + textwrap.dedent(f"""\ + #!/bin/bash + echo "$*" >> "{calls_file}" + COUNTER_FILE="{tmp_path}/cmake_counter" + if [ ! -f "$COUNTER_FILE" ]; then + echo 1 > "$COUNTER_FILE" + exit 1 + fi + exit 0 + """) + ) + cmake_script.chmod(0o755) + + script = textwrap.dedent(f"""\ + export PATH="{mock_bin}:$PATH" + _IS_MACOS_ARM64=true + NVCC_PATH="" + GPU_BACKEND="" + CMAKE_ARGS="-DLLAMA_BUILD_TESTS=OFF" + _TRY_METAL_CPU_FALLBACK=false + CPU_FALLBACK_CMAKE_ARGS="$CMAKE_ARGS" + + _BUILD_DESC="building" + if [ "$_IS_MACOS_ARM64" = true ]; then + _BUILD_DESC="building (Metal)" + CMAKE_ARGS="$CMAKE_ARGS -DGGML_METAL=ON -DGGML_METAL_EMBED_LIBRARY=ON -DGGML_METAL_USE_BF16=ON -DCMAKE_INSTALL_RPATH=@loader_path -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON" + CPU_FALLBACK_CMAKE_ARGS="$CPU_FALLBACK_CMAKE_ARGS -DGGML_METAL=OFF" + _TRY_METAL_CPU_FALLBACK=true + fi + + BUILD_OK=true + _BUILD_TMP="{tmp_path}/build_tmp" + mkdir -p "$_BUILD_TMP" + if ! cmake -S "$_BUILD_TMP" -B "$_BUILD_TMP/build" $CMAKE_ARGS; then + if [ "$_TRY_METAL_CPU_FALLBACK" = true ]; then + _TRY_METAL_CPU_FALLBACK=false + echo "FALLBACK_TRIGGERED" + rm -rf "$_BUILD_TMP/build" + cmake -S "$_BUILD_TMP" -B "$_BUILD_TMP/build" $CPU_FALLBACK_CMAKE_ARGS || BUILD_OK=false + if [ "$BUILD_OK" = true ]; then + _BUILD_DESC="building (CPU fallback)" + fi + else + BUILD_OK=false + fi + fi + + echo "BUILD_OK=$BUILD_OK" + echo "BUILD_DESC=$_BUILD_DESC" + echo "TRY_METAL_CPU_FALLBACK=$_TRY_METAL_CPU_FALLBACK" + """) + output = run_bash(script) + assert "FALLBACK_TRIGGERED" in output + assert "BUILD_OK=true" in output + assert "BUILD_DESC=building (CPU fallback)" in output + assert ( + "TRY_METAL_CPU_FALLBACK=false" in output + ), "Fallback flag should be reset to false after configure fallback" + + # Verify cmake args: first call has Metal ON, second has Metal OFF + calls = calls_file.read_text().splitlines() + assert len(calls) >= 2, f"Expected >= 2 cmake calls, got {len(calls)}" + assert ( + "-DGGML_METAL=ON" in calls[0] + ), f"First cmake call should have Metal ON: {calls[0]}" + assert ( + "-DGGML_METAL=OFF" in calls[1] + ), f"Second cmake call should have Metal OFF: {calls[1]}" + assert ( + "-DGGML_METAL=ON" not in calls[1] + ), f"Second cmake call should NOT have Metal ON: {calls[1]}" + assert ( + "@loader_path" not in calls[1] + ), f"CPU fallback should not have RPATH: {calls[1]}" + assert ( + "-DCMAKE_BUILD_WITH_INSTALL_RPATH=ON" not in calls[1] + ), f"CPU fallback should not have RPATH build flag: {calls[1]}" + + def test_metal_build_failure_retries_cpu_fallback(self, tmp_path: Path): + """When cmake --build fails on Metal, the fallback should re-configure and rebuild with CPU.""" + mock_bin = tmp_path / "mock_bin" + mock_bin.mkdir() + calls_file = tmp_path / "cmake_calls.log" + # cmake mock: configure always succeeds; first --build fails, rest succeed + cmake_script = mock_bin / "cmake" + cmake_script.write_text( + textwrap.dedent(f"""\ + #!/bin/bash + echo "$*" >> "{calls_file}" + if [ "$1" = "--build" ]; then + BUILD_COUNTER_FILE="{tmp_path}/build_counter" + if [ ! -f "$BUILD_COUNTER_FILE" ]; then + echo 1 > "$BUILD_COUNTER_FILE" + exit 1 + fi + fi + exit 0 + """) + ) + cmake_script.chmod(0o755) + + script = textwrap.dedent(f"""\ + export PATH="{mock_bin}:$PATH" + _IS_MACOS_ARM64=true + NVCC_PATH="" + GPU_BACKEND="" + CMAKE_ARGS="-DLLAMA_BUILD_TESTS=OFF" + _TRY_METAL_CPU_FALLBACK=false + CPU_FALLBACK_CMAKE_ARGS="$CMAKE_ARGS" + CMAKE_GENERATOR_ARGS="" + NCPU=2 + + _BUILD_DESC="building" + if [ "$_IS_MACOS_ARM64" = true ]; then + _BUILD_DESC="building (Metal)" + CMAKE_ARGS="$CMAKE_ARGS -DGGML_METAL=ON -DGGML_METAL_EMBED_LIBRARY=ON -DGGML_METAL_USE_BF16=ON -DCMAKE_INSTALL_RPATH=@loader_path -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON" + CPU_FALLBACK_CMAKE_ARGS="$CPU_FALLBACK_CMAKE_ARGS -DGGML_METAL=OFF" + _TRY_METAL_CPU_FALLBACK=true + fi + + BUILD_OK=true + _BUILD_TMP="{tmp_path}/build_tmp" + mkdir -p "$_BUILD_TMP" + + # Configure (succeeds) + if ! cmake $CMAKE_GENERATOR_ARGS -S "$_BUILD_TMP" -B "$_BUILD_TMP/build" $CMAKE_ARGS; then + if [ "$_TRY_METAL_CPU_FALLBACK" = true ]; then + _TRY_METAL_CPU_FALLBACK=false + echo "CONFIGURE_FALLBACK" + rm -rf "$_BUILD_TMP/build" + cmake $CMAKE_GENERATOR_ARGS -S "$_BUILD_TMP" -B "$_BUILD_TMP/build" $CPU_FALLBACK_CMAKE_ARGS || BUILD_OK=false + if [ "$BUILD_OK" = true ]; then + _BUILD_DESC="building (CPU fallback)" + fi + else + BUILD_OK=false + fi + fi + + # Build (first --build fails, triggers fallback) + if [ "$BUILD_OK" = true ]; then + if ! cmake --build "$_BUILD_TMP/build" --config Release --target llama-server -j"$NCPU"; then + if [ "$_TRY_METAL_CPU_FALLBACK" = true ]; then + _TRY_METAL_CPU_FALLBACK=false + echo "BUILD_FALLBACK_TRIGGERED" + rm -rf "$_BUILD_TMP/build" + if cmake $CMAKE_GENERATOR_ARGS -S "$_BUILD_TMP" -B "$_BUILD_TMP/build" $CPU_FALLBACK_CMAKE_ARGS; then + _BUILD_DESC="building (CPU fallback)" + cmake --build "$_BUILD_TMP/build" --config Release --target llama-server -j"$NCPU" || BUILD_OK=false + else + BUILD_OK=false + fi + else + BUILD_OK=false + fi + fi + fi + + echo "BUILD_OK=$BUILD_OK" + echo "BUILD_DESC=$_BUILD_DESC" + echo "TRY_METAL_CPU_FALLBACK=$_TRY_METAL_CPU_FALLBACK" + """) + output = run_bash(script) + assert "CONFIGURE_FALLBACK" not in output, "Configure should have succeeded" + assert "BUILD_FALLBACK_TRIGGERED" in output + assert "BUILD_OK=true" in output + assert "BUILD_DESC=building (CPU fallback)" in output + assert ( + "TRY_METAL_CPU_FALLBACK=false" in output + ), "Fallback flag should be reset to false after build fallback" + + # Verify: configure with Metal ON, build fails, re-configure with Metal OFF, rebuild + calls = calls_file.read_text().splitlines() + assert len(calls) >= 4, f"Expected >= 4 cmake calls, got {len(calls)}: {calls}" + # First call: configure with Metal ON + assert "-DGGML_METAL=ON" in calls[0] + # Second call: build (fails) + assert "--build" in calls[1] + # Third call: re-configure with Metal OFF and no RPATH flags + assert "-DGGML_METAL=OFF" in calls[2] + assert "-DGGML_METAL=ON" not in calls[2] + assert ( + "@loader_path" not in calls[2] + ), f"CPU fallback should not have RPATH: {calls[2]}" + assert ( + "-DCMAKE_BUILD_WITH_INSTALL_RPATH=ON" not in calls[2] + ), f"CPU fallback should not have RPATH build flag: {calls[2]}" + assert ( + "-DLLAMA_BUILD_TESTS=OFF" in calls[2] + ), f"CPU fallback should preserve baseline flags: {calls[2]}" + # Fourth call: rebuild (succeeds) + assert "--build" in calls[3] diff --git a/tests/studio/install/test_validate_llama_prebuilt.py b/tests/studio/install/test_validate_llama_prebuilt.py deleted file mode 100644 index 0f384b715e..0000000000 --- a/tests/studio/install/test_validate_llama_prebuilt.py +++ /dev/null @@ -1,175 +0,0 @@ -import importlib.util -import sys -from pathlib import Path - - -import pytest - - -REPO_ROOT = Path(__file__).resolve().parents[3] -MODULE_PATH = REPO_ROOT / "validate-llama-prebuilt.py" - -if not MODULE_PATH.is_file(): - pytest.skip( - f"validate-llama-prebuilt.py not present at {MODULE_PATH}", - allow_module_level = True, - ) - -SPEC = importlib.util.spec_from_file_location("validate_llama_prebuilt", MODULE_PATH) -assert SPEC is not None and SPEC.loader is not None -VALIDATE = importlib.util.module_from_spec(SPEC) -sys.modules[SPEC.name] = VALIDATE -SPEC.loader.exec_module(VALIDATE) - - -def test_build_local_approved_checksums_uses_staged_upstream_tag( - tmp_path: Path, monkeypatch -): - stage_dir = tmp_path / "release-1" - stage_dir.mkdir() - asset_path = stage_dir / "app-test-linux-x64-cuda12-newer.tar.gz" - asset_path.write_bytes(b"bundle") - sibling_checksums = stage_dir / VALIDATE.installer.DEFAULT_PUBLISHED_SHA256_ASSET - sibling_checksums.write_text( - """ -{ - "schema_version": 1, - "component": "llama.cpp", - "release_tag": "release-1", - "upstream_tag": "b9001", - "source_commit": "deadbeef", - "artifacts": { - "llama.cpp-source-b9001.tar.gz": { - "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "repo": "ggml-org/llama.cpp", - "kind": "upstream-source" - } - } -} - """.strip() - + "\n", - encoding = "utf-8", - ) - asset = VALIDATE.LocalAsset( - path = asset_path, - tag = "test", - name = asset_path.name, - install_kind = "linux-cuda", - source_kind = "app-bundle", - native_runnable = True, - bundle_profile = "cuda12-newer", - runtime_line = "cuda12", - ) - - checksums = VALIDATE.build_local_approved_checksums( - asset, - allow_network_source_hash = False, - ) - - assert checksums.release_tag == "release-1" - assert checksums.upstream_tag == "b9001" - assert "llama.cpp-source-b9001.tar.gz" in checksums.artifacts - assert "llama.cpp-source-test.tar.gz" not in checksums.artifacts - - -def test_validate_native_asset_passes_release_tag_and_upstream_tag( - tmp_path: Path, monkeypatch -): - stage_dir = tmp_path / "release-7" - stage_dir.mkdir() - asset_path = stage_dir / "app-test-linux-x64-cuda12-newer.tar.gz" - asset_path.write_bytes(b"bundle") - sibling_checksums = stage_dir / VALIDATE.installer.DEFAULT_PUBLISHED_SHA256_ASSET - sibling_checksums.write_text( - """ -{ - "schema_version": 1, - "component": "llama.cpp", - "release_tag": "release-7", - "upstream_tag": "b9007", - "source_commit": "deadbeef", - "artifacts": { - "llama.cpp-source-b9007.tar.gz": { - "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "repo": "ggml-org/llama.cpp", - "kind": "upstream-source" - } - } -} - """.strip() - + "\n", - encoding = "utf-8", - ) - asset = VALIDATE.LocalAsset( - path = asset_path, - tag = "test", - name = asset_path.name, - install_kind = "linux-cuda", - source_kind = "app-bundle", - native_runnable = True, - bundle_profile = "cuda12-newer", - runtime_line = "cuda12", - ) - - host = VALIDATE.installer.HostInfo( - system = "Linux", - machine = "x86_64", - is_windows = False, - is_linux = True, - is_macos = False, - is_x86_64 = True, - is_arm64 = False, - nvidia_smi = None, - driver_cuda_version = None, - compute_caps = [], - visible_cuda_devices = None, - has_physical_nvidia = False, - has_usable_nvidia = False, - ) - monkeypatch.setattr(VALIDATE.installer, "detect_host", lambda: host) - monkeypatch.setattr( - VALIDATE.installer, - "download_validation_model", - lambda probe_path, cache_path: probe_path.write_bytes(b"probe"), - ) - - captured = {} - - def fake_validate_prebuilt_attempts( - attempts, - host, - install_dir, - work_dir, - probe_path, - *, - requested_tag, - llama_tag, - release_tag, - approved_checksums, - initial_fallback_used = False, - existing_install_dir = None, - ): - captured["requested_tag"] = requested_tag - captured["llama_tag"] = llama_tag - captured["release_tag"] = release_tag - staging_dir = VALIDATE.installer.create_install_staging_dir(install_dir) - return attempts[0], staging_dir, False - - monkeypatch.setattr( - VALIDATE.installer, - "validate_prebuilt_attempts", - fake_validate_prebuilt_attempts, - ) - - record = VALIDATE.validate_native_asset( - asset, - keep_temp = False, - allow_network_source_hash = False, - ) - - assert record.status == "PASS" - assert captured == { - "requested_tag": "test", - "llama_tag": "b9007", - "release_tag": "release-7", - } 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 19/44] 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 20/44] 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 21/44] 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 22/44] 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 23/44] 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 24/44] 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 25/44] 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 zcmYIPcRW@9|G!p3*&|tHkL)5@DSL0Sg(92m8Oh#-%7}<;+2gv1l)bm>y7tAr)^%}> z-;qAQ?;joy?mffJ9yqvyo=2jMACN=u_LUuCIan8Ms39N#OvBGH&qin9?EE=|t`9*scdt2k4@cHw5 z!ro(30nuO|d>wpzg6|_(8d7A!65`x)Fnu08J^gWddVc!cZ_^{7xmvHax?VB|wI7(x z)xYBLCJFxw=&?ko2*`X6BZ{_*A=OHcc~z}s44ci)mfQSQ0(yin1z z5)JwU+QJ=zpFjKBxQ(xU(~2RK7$gaDBGB%xzY*=jjZ|sTzW${GJE65d>Iq>Rq`v&; z>(LafNO90q(ebfp=s0KsRJ0s@x9S}{TR!!Ey)g^1)veG)WX>7R`#p8w;=6B?@D=jT zOF*aiR^PE}A|S{4X}q`cj4=U}JRFzGcLq6$<-h~Qi5U*&}Ps@TEnVS9-=aiyt1LUC_ zB=mRI*Hzqa&np};dt>)iQh;ZeQR6(EuGaPX-F8;~1L(CK7Eq4o z0VNf1#Z&oH6;L^j(B1zgLJsHN2hEYfZXWh)-Qz}5Mc%8=3BDG3_q)uktBE8<{a|ck z0%hY2_2njna3hy;oIq>1Mz*Q^&7cFV zTD<=@65kPYDhsOEf2~9Yd@T%WI|F?y=)w872fClgKxVkGHGP&S`^SSp%YUx9Nr$rV zY!O}EoTu}CJ!uoPELIucdRgX&I{uDO=;EKiq^q%!Q-Hm$KJj~i9=rzPtkRsB08S0mSyK_A>_>Z^MzyF1U9C1#W+qf9j*Q}PI5_@wzM=Uy1 z|ArOCb2iuH@Xb_-f)MzUo)omh7~1~FP3XVpau|X#NUd-PrKD~(l9ACv96@~rmH%d( z^o4$FLT^$x=N%y%CU=yiO%Ft>HHPtt%j+(LfhDKx1dp6w%FVYsfT3FGqW4MLpA!raw79vnO6Oz@C(p z+zc==9dmj8`?tKJ$)jbawDjZx=3dpo$KL+${L8u72o;`5aQF4e$tj8vi&l@HX+CRQ z|704P*T1U2A4mL}|Cj+>j!e?Aj;25Pxx zr7!QDjW3_KwRt#Z1)jU@Z5{OVrkL24qNiy|Sv!fsm}A$Y8eHh(v49J&+KTR#{Zgkv zABW?Qso8I!Ofkb+E`D9>>6T$Qepy4pj+Ql+*jCQJ`^Fw|MA7ygWVoA8_zn)eMlY|u-hbxuzM$`s)Kld{)XyPv^du*T=t{RKUT)jU<1r|tM2%|WhiN+b+G&@;w zit-=yJXvq`_8UAKDV2)j-3nV&u)Ae3MT1>Q=XL~fAN`G8WrX(9;Uc8p)8%Ftm;iWI zR7w*?o8^Zzc8aH`NHs?vSm6vY$EwkoQEpXj7avSI9D<^r{eWIn+KiW%ex2;I;MFvT z3qB(%gH7IBttQQ}GrNX`&8%Trbcg8KNSdGPcZ`NTNd-=3hQj5-i} zYf_7R{VVo754t0f9-_ZcE!((3e`~agkud$B<1L)I2ZDO@Jm+s_WxtEUd&uzkfmXRJ zHRhVW`e)&5Z5N<}*xw1?xcSS)?#3k?4olmd&Q*>+eX15JV%DD z{$HsBXY(xf&9Enj^Qne`RBHZ9Q37XfZYo*^Wea#+GJn&Iira0_DQ#Pw(Jed_7a7$J^7EdqMl{{b8426EYG2MA<8J~(P`mpSFUdkxMgtdPm44m84sLsdX!MHs(1Tiq{?-ZBY%3{jS8 zH|0<*&ru6E6#flgI~&jKZYcPBFs`1(Tr~rjv-(Ft zmTxWO!LBvTE|${jx;t4;&Myzj6W&%Z-cqBDPP=G*F@?j4{g28va|Im7QdN|4^^ zb$KdO+Y4G5EF8MZZ$v$eAg*uzx#0J-7$B?<2zHo5b19&;`8Gj61zMVeIAmsaMG>7! zIq!lf{56V$L<(T_7l@V$s5rsGoi0mMiMT&_F+cQ=zZcDs*|? zD(cC<5WC(xpEWR1fFM$P=8h_l66|O-rS|4`I0~LLY-zFMS>2x7VuOu>ab;u_q+dUo zLJ32>6d|?dU+e?yQ*mdAaLzmgAS%&+3WD5%E2Bcqxya2;4G%r|C;M z@bDn{zV&C^fI0K*zVuxNozrxxI2EuS=H~UE*D81Z|8Ps z3pdi_$;i-5oKIavXNhTAZr*nbqP^E;Zj;>De=qeiap zd0_M;kZ9smuh)Y-ZGPZi@IK^EourQ%$y$GNC?yV0IT!!Hf_g#q2VHn8aNzakz~t3m zTO5fZ4!(uhY>Kp09xH9Va(Jz@GO7zTa^>jBdgV-4TmkhlRZP9n%5!v5wf8x6hxupg zf@-xStiuAkBo0>m8s7X;CgLi%qHRn-W+2#xzJv-7+u!9Ph4Fysg{NxYKIUD3GzJl5 zFC5@`i=n&%Hnxut(=!9SCd|MC_U&ORINLVYQmO7n`(G@52q^f-|nBYz`qeJ)H0#MSfi=iNqWBXTbr| z)s2Zf8I!W@xV*FW6GZ&P<}?eNC3rzt;tm46G+Fm`m{ZS%!^I?)JC79?A z$*~!Mq@qY2!#5kox9lDtC8m1^c2lP-1sAd;(+)Jn<~CzEyeex@?eS!+2~@AX8YO6mnS z%{d_il!wVX<4^gb%H(UKLEbY)t`B%%^-v+x*^NXM2y@QvNPLdP)Mom=_WR`UUTl1K z1Q>%k{QbY-fB3WBuJy+A}Auv-MW*$-iU$=pWTD5GIWql=?=s9i)_fqgjN+X?R~8nh7kkC|?%i$@zJ^;G;mcWWlR54i{q zy1#wS6rw$L02Y+p}0)U&wIN& zj9g#pwi&H6XsP?w({osYWe6-;%EYnvH%4>WkheUjIX@ik#=`Xe9h6(c#ISS_RP`NI z(mnY3br1^YcXW7AG!KA&4X)c#fekjb;$@eCYQ_oS&~-TwBQMsZ$kwA#0>SWHp(YZ>Ilw|jDAJVqHj1Cq{ zk(*1z-=PUjaIX{;Ucs81?hYDQS}Tn*pCBIa2~n-+I5^YgS3q4Q`s>g(EAo!bZVKSw zXOaiMc{=Bx78Y~2D*bN?KSIL+kl4n*h z16ywnHM$oGFTqIN_SJoi&G@q{PYz7$Q^-M{^bF6DcM|Ch$hPq9Nt8HI;{p6CjZ9S& zwY4L2h*Vf(u>zo0e4+cKha4}Q?5843jRK>`1H69~)n=iv-7`igzFYlYdp>ah*hlxS z0&3rACEv)_>L2b^!&2eNbCgVt_J2q7`d9eRkT-G!S@d03QC0QgLFiMEK0ExQvc7xH zO&AHLPqk;SJz>1TXS?3s$yr#V-Dk##j4%(qs$gO+DKUj{QF zmXa#JYhK!M75&v#cu!vd+-l05r!I9i= zfSV;IfA+(`u58Bt>!)LQTyUi9?R2CLU^tAdfEIf&xQ0g~wWer{GHX^IIB~A(dvtB}$ z))D74C%$z{eOn~d;pB>-8#c^M!v)!U=#|;sWog@-s@8wCx60EFws8WNVph~hrqs8Mt0*?U z!#`DsYoD+-3_K!)EVOsYN;a=&72NE8_Cu@6%asYpuL|I3P~CP(q#SYZTa(!cUIhB@ zaUL+O* zvm;dy`jL(!cjzSnNvg0IP%0U0!Z?@Q6K9!r_=qs5?T@$juFbOyCVFN1X*@*&_^c}C zm;aWFgZ^eERareB0^Q3=-1Sky*XhXpVUeC-MO!)p_rC>uorH@L?AkQ$u5Mt#waTU* zOaL<0ZA<;~pXRb5@w7$^fCk`~ObT=5L`WxI%cdTZc_Ae+%vMjHRlPqvH}MYHc+5R8 z@bub9QhIVkdqlz1{K#9R1i^a7$Jk&$j`ft(LJOnGk)rGpSLY|5rhEv~&%2&yn)gbl z!(6>xI^MlAM@U<y!%LE-=#T@Y%?kzGaKPgi0 zmY9I=Q5Q_1)~74|#}gxE_nx;n1~41{CGm8yUvod>m-5Y=Js%MU$evsqZ3 z!5}*N7}d8&Sb`o=+bCPVjN{&l8QD7gMjBxHc^K}Idq;HE>NQ^Vx7OQ<&PU7@u2_|E z>m4S+3(sXg){pXFTEF^*hgOsV>knw@>FKHe4eHbBGVKw1R}8Vsi_F2_`TTGyv1oVe z8_UkzHx{P?Sa)m%^A1gGXq7TXYLTm`OkO%pdR?=wj-<|?Ugp2J zq0BQHOssEDP;!?wuW@1AQqoTv4zWE8Y-#jT&^<UUp)v@{a3F7Q4dIC7;OC^&6YqE_nNgk^M z|83^_M#~VzL<)f7b0utzusn0vc(=r)k>3VIoV;ShZdVsky~*hWVB=x)?DbQdp~e4S z;Vpcj+ikym>CwKFn32I?(ono5D@)ddqgs?HmS3j)Xxh-R85V?Yg(y_lnh&-5i?*Y* z9VH!DG7?^+P#Vts#qZ>T6~%fyh=;f;zVe20+3U;;*PSM3G?^herk7Yx$%;&P zCZYI~NpDr(we91+r9rn%pr&4=XwN(NVY?Emq-ZMcRT7nt9mHW=i6;5=2^dX1Zi5Wz zAshU6)I#$rL!epJJ0R)lE=fD=9_p=Qhk<3`%m9Bm@x9LPwK}{vb&42Q4gUxRp z-mnV(8p66Bw!fz#dFc`8A}(?_p3Xf)D6|4(+X>{~_d(x}78b*qN~UHsFETBgCDQGP zgESR6AH7`3)zc(=Z@U7I8VSxAX=J%QYx4dJD&u2#>A_L3T5VljUO|CE^k8Xe=@$ii zJySl-Yz%8_$uwhCkUIOfbTZ7hb-gPyHi`RAL?tOs<(-LeCNg*{s0{-;j72BNhbv7A zw$08O$*Ye&<=ToLC?H1V;a)CVj(BL-wIS$%?({C)hhld|rtXV?Jw!?PEBWGs@5{d?U`*iu&It69(iueXLA6z^BIQ@I@_YVox z;lipaii*|~-a6;mceRd_ymuis-K0*?mS;r$EiFOR~M$fa1vNAp5UX zBmb@)gv58#xz{Z|k&I=-3CsjdtX5L!Tuhx1s7%ZVP`3KRzGzAdmwqAX@z@hCZ;)(S z`4l^AtZQTxSyv~9-v8{{+Rj(ek|&QHDNxhV(_3OzN?#m&oJiEz*q9*`^vI=gh4y`V zx+(L9248C%p5t2A%EPW02s$T<#6Q6r|6g?DH74Z{!$ytcz3;JMQW7rgd9PyyOC}cG z>npYVEWvU$7K(Dr3?@_b5R@m#-)H?+eV9dt=*tAviiIC`Br>NvPfIPpCehvR-d*E3 zr1(~ZIThFp*sAItYW8uRbNhKS4LgQ-rHA79wpzcBc~GsB@-fyjjcixu*Y$rGNbcYg zB&iAyt-7^r3}kukDZzKMiNw8|yzUF1{9K3_K2CV&ViDGNM*4%$*=w1q?WknaD1qtY z->O>O!{*nXtFFoXj@^GBKru@uds?a5XtaH({1T<5y>;__Baax7nVG z7M*ch2j`bYV*xp%H1kwsXT#=%u#9p%cPn=vQ=7&6KeQC#E|cZvQ{VvcAJx?c_brI$ zoEKV|5@pXHwqlWz_lzqaM+Z)k?|SEu95f%Hjmk{yc#s9+vIWJmBO0*06&CnPGM9~S zw+L$gzdZOPY9NTBZOfj_?OrGN?F$x_!`G+t{P9QF9zpHdky}1INA1Um2ZNGBGssdT zn^2QYBQGeODp$-af+|(`}ygCW2Muy2nV#J zq{LwH(RUX?W`BwxKEu+s6ZGbX-*oI6%(SdBnSiUZ_EvOIifXAlv0iG`E8vy>U@%+@ z;3WeW8|E#-C;G1kH`jx*|4Wmg#)eEHtI}o-i?JvLIWPotq$C1~1=7xL{Ys9tL6AWv z7SXS_)6a<;=IosQA?;1y!TmU1-tLY0NNahWXY$4KY6Si~&$)DbzH43#NTOi9f)M=< zv(A$yugw;00nOZqsRbwAr_Y~*5*O~Q$WZIJ9=43{nQ&@l#}TpwYS;d%ey)kW2Dn@w zicyt6%6r~WXvqAce%-atru9V5yS)B)c46Cp#LGQn6K9ULUoO6`fdyn0O^utls`xcJ zZuOGPmlUHD07OfF+D09i9^A-{MFgzUeiw6I!_dc)ZeUa7N0=yeD3dP^XI*!9yIJ_& z2WQ16Ragsed2;6oEezvblh$w3PT;@w%n(&?BB z7vm^DNpTxB8~&N+vp3n0M+y!hQbMn9{}2%c(;U#ZUuhc(E5XoYkfD;J+gE<1EXYm$ zzW6O?=v*D6ma|#r4&bT)WVLKt5%3{m&Izpnf)jveji-`B2f=dX1cpU2`(M}4(BM^d z<(S%zLO*7e)dviRpfgVsVq)T*VI`KuP&`s?=!G^wwV6dH`@1s{@kOrjuf(+HM86K= zn~iOPY<2%(!0)}gp$@~hm+WJ(sW>gnW$|5MEZ(l&3r4{Xd?vTgs84@m5u&3b!rwhv zZuD)w{gZ_)PV-)59rmL@>#if?#i4Dd$NL|Olt8)2w?*~)qxSS?XO9Q{$g3pmhwd1( zxOTiHvQEsZ1pHjH-6gECq0z?glax1cHTR{24<0fa&M3t%SJiFwTRELT$*(uAm`|_}WHMC;Mli;(T^ivp6NUGKQ zPH`C$lvyDM!%D&dhfYrOO$Ns+T~sWpG2r>N{t1e8_p>u?d2rHaNo7sjU7 z8+lvTGThnz047&pEC z?tgFWcOZKZ`b~EAeltb(!OqBtZXC1p?MOzC>!gxfLh7IHt0-jrl&iBCuOVbX8pm^q ziFva4{nra{uLC|m+hJlUA_SbjSIOz=SxfP-|G@nEjyNbJ@t{rd=^O^X68Ew1;@rd4 zT(5=2)R+g~BySPvES&q=n#@k`jwkZ+KD{{AY^Yhlw=I16c5GIArnmpo12uFKuquNF zSc#0NBjpps_UV0-Ux?Q8Mk|Up&-C@*GE4gx<@vKIC3FIZBeBY+n^jP-MwaVil!+ch zFB)r9cdu*h!{!qx#1>W~5WZU>r{sHvDgAO~&9SyIgPww%;+A2+_<$1hoU$vsq4H)` zz+|9f1KLeNy5{-Zl$m0#}c;*@-K;uDL-1uS{FwOudC06*u-ZVSjJ z5hGYoAM3r~@?r?<#cDm)^mqFXAJMXqPo?f^UP_Rxd^OH%unnzd;>tVFJVKx=>izqi zKH3Yp!{z%jgz>tWd@_yV-gP*dhIGgp4s?8bH~$XJoRW2VaIZ!BE`c9 z1RCxvm$?95%|0XqPZkNyPQeYSIYKFAh0T>Z(N6s%rf@Zo9&7EMatzi6r-kABhwe{w z;(|Kac4Uh&{`L{C$@8Gi>i8zJew?63`7ako&Z*qARkU9J86au0ho*3-?ZCpx;M?^J z-Yiddl8=4&#tHoeXqT6lVZi6Vye7>fzb48&%9oxRorz;e4@oE#=dT+d5T0(x?>=w% zfxzv`GVaMX#(L?r-Hwmim(d4GsA^H#1Oe zrejE|$;}ccdq_{G%W(=YKH^Es5b23>a|T0OnW4>63!Ak zvd7D*2D@O*tVaZ3AC!w_1cOmv0y^Lo9C5@Ne*g(_nIg*29?>HT2qL)hIG!-V2thXU z4krneJnn+=Cl~2)FI#hK>5T-=$Y&X934T9`op9v3HWSg{(s;eDu9_0<5hAZ+1o$o> zu5>a*!z70z(U$}IA5f6<6Gwm>|7p%rK1P(Kxv~gghfm}w(TeZizc0GpN)1i*_CLd7 z$W*_?R9-YZzj^LDZUkuZTm9yVJkp*dy8}g|I+>&GN-$CXtCPlP`zUlRi+M;nD&YH8 zt@4brBpB`sJFnf}dA4GL$$rR%m}McGH*)N3wr;; zr&1cRXED>@{#ErRon?@-a~Z~cP>ZV*JZ^Kc)|V|FFmRa^X2b?r@q=TNfD#_>PW(qJ0+W^H+qgWm5>}h0nyEJA-Z?Bkph-`%)FlueaU(+oy$!$&rWMa z)xylcy(X4~3XA5sjD(fT)Omw%>pAv7rM;yY)oOaY2^7c)f{I%GB?mwA+tg(ai=1@# zrK!+fBV~+Q=}jk`e}n>Wm$9BjIW^=Tx zo3E~RbC@5~-MX|>FP9old073TzXKI=5fBK#p}oDmHh6301^Y;CBEx*mU;xt5vcj@3 zIOxRAn)N(>qR^67g?o#@LFu7Y8_4eS^>;SC*WlR{qe2BWfX+s46C`d zwFdKX(8<5L_3c|ZOAy4}1C*uSn{KvXG{!YD=1?ZXrY-!hvmbZHMGXh0N~0Q&FU7d8}-IWOTxF+_IA^N*s2#!aaICjSRJ z_uqFTrxM`lE7Vr6CUNZT9ov7*5Z`ZR6x^XVx;L*E{WRA9i>W5g=>ES%k@U!m?2&=Q zf{J@cHa1Parpc_qdF9@g6LeGh9-j_b*)4H$^bW;*YX#67!XQO=hrrSWu)W;;F2ddP z*Uo(canBCk_Xh=fPjrQa&~{J?i@`PFDSLP|?C7 zTXnLYZBNqH79KTlrl0n^^ARX=>;+uM9Q3M-FL%+t{mk(+=6Uzmslq^u^yELf4&h1sKl(vj5y_pZaA zl!>78<29S})8nGj(i_iOMvYhk0Mfj>KPb!h2I9SKVqgHNtQ?vMW*YfU2h?igyi}kh zkYYPQVv!$0bzkC!{|$raUBg$5bu49}6o!lA>tyRUKS2v+0P(Ie;GNjg$`VstQZx);4Tee-32i-r}kXD3ag1V<;rs`{IV8EgK zDUF#6Z}eOnG0Mq5Js8z0s;Ds9-UApgF0-wsKWFf$K2tiU5kv7O1~+{b z7RCQiWt|XKc48~p#kX0!v+lo87k*y0{p4VLQ+F*$9`cMh7^o+tzfgMg zVg(KW+|I0wO|r|=_gmA|go2RMA2CyEB(-k3TUoT27Z@JI)3wX|1gI@x>kfQsIyyo# zEf+=3fpuXkOkKtIR_fl$~Y+bI>{NeJUnH$wR=$v z#duI93yNO`sXOx@MyFRErDxSAa{~hds~=h2aTXRjL&bk;43bTHLRdnnQnvd7&B5*b zTYzS7?SQscK-qphMUVf)Wgg3UNB57VlSj>PZ#5dDY!lsN%D^nM9z zP2p|7Trbw-O5=c`IQ>KvCMLU!YG%B@>Eh`c;A?F(Cw6c>u9CvwgICMJh??)|Zuf@; zXt^($Qa*z;>5x zS+KgPd#sWE6zkzUvt}l6(98FW!~n+DuE&sb8o z^@H_yMFGss_y+rY42ZnTJ|ab+inaFTBTxrLJIhev32C$&B{pMh9tRT%Yz2W11(CEn zQw5O5d`ru~-L5Hst>jLTvL8tI&O~qhl;0x!#5mMJeY##{`-d{qAMw4|+~Urj$PYK& zWAZuSz*o8nU3K65TN;8sCN@mAuKk6N>qOzPPl6nZ;K8h+6n|d-x->X?(m3AbK^Kf% zv%^qyc4V*;arO2|r*XT*lF34tW0!)9KP_M~(kl5dS#2KW_^s0SWlHE7Y~PRhWvZ~1 z)$|6a55;DpP>>5|K(~toA1xA^HXoNTv+Xv^5VEiMDj&4AHVz9t$U!MxLBut-Ie$s} zCo~upy4ny|tg62FwgCnYsKx88<%SIO8Ih6bxB`RtbZaN-L`~(rWP?lq>sBxGxHf@dseSWHJ@>z zec%*F9#>?)6X!_`G?Eo5iSzKm#{uJ~j-nQ`O@i}NM&AfsoC)KR1J+ygH;A~=%v&#Y1QKm2JUrJ%^QEci%sK5`ux>THESOhIaFh?_BIe%ED5$ zWU82|l&AyIFP>!Q@dW$s_lvvt^4EXK&29I{ksnh#IB$BBnj#QpIsb!B5NWKabH$xA zIlIf6!&#}_bM&`S`4wlIvuV%rVT3#5rUkIsopC|{mR<4eM>UAe#(Wqr8&I}DA9@Nz z&TA(vVk;o$Gg=uBlfztJ`n5fL#XrW#ZNvu>kbAk#hlzkg7dWv(hmH!7>UVi`n_6IH&-;_gwUixz)B(@11Rq&bQ&YL;}O3k7<6(7|%e6vxsuf6!IpD8_$uvibGqNLjs_H?!u3Sl@)O?0%o9FA9ulIcva+ zc0SV*MYSBh{Ia%n%1o5-2*K?ox$<652(aR6Bf4Vg2ulw&x7f1iUJPc(Bj?N3KwXbK ztWjsX72}?i!LOxh!z+ATe-%qh3O9TiRl#)ziu_(wwRhc(p zOvSvJJOva*deJxFGDCxr_%ktke#TUy|9;MB?5p`JhL~5DI>`OZ-Vod`=G7;P*mw(; z)BLtpB_*2lrKA)KoJ7oJkt3W;W<7r9r{a6*j2y*Y1d7mqfX5$=h=9YNUphNVEJ62w zC9)*u<*^=Y&rZH18L5y?(#o7tI>qRHQ*WPZ^wcpid3Dm1d7%EmVGq8_@hXL1XSm19 zk4w$81+vNnr@?G&Y#^jHtuNR6?I6H}cGDrJT~*1M_Z=v^q*bWpBQI1C&j@EMo8jl& zpsqh2C!c|=@6cuPRPJVpJ@_NchrnWB{QS#6H!4Ti%18Ih%ShHtT^V*O3TZk26xX?i zVV@8ta)A1=@gNlL`~4u3vbD8^4Lv36$`H13JDh&G)P_faBQ)4wY=jVDn>U2FsyQ=KTTVXWF`#jkiP6ldUrQ_k$#pgO8O?-pxkOScaUTc|BZ} zDgncqh5MCjojNyQy%fE6jc)VVo-v^5lw8fQNy^G9^FLI3^VrV`au(=Fc8e&WfrP{b z+9I)h=1$Cm5C>?i!LraZV@R;HMewm*ru#4YG^N)%3wXPJD<(jPuE5;{sigZ0^FV`T z$pozLN3O*JSVsD)0+mF5RGqq= zCT9)qOYfCbcjOhHF2)MBY7b+LMP7|@U(%)Dy}eH(Ntt%wv4X!|hh*jz7Za1v3EbdD zMx=LVy-CiIjRN}X-2ReaKagqS^eA*GP&~=@SdwzU~2N%oQu;!I|Zy&@NNDQ2+O*J zG-bT(W@+{NA?dS&(GNo(fRVw{b6B_f^H90O0UNjP1Y?cwcJFUmUfjMonS|(@%E<8^ ziHnObU82c!u*4sLIhRjxdXxVtuY9g8=P(x&;K+l#*h)mx7|&Nyh3~0t{2Q<4@E`LB z7d9p6X$lNP{hJ<~C!dbU57eYU0P5#C;%D0PmY6+inAf;<@vi%V}Io%4&vWHVP zui%TLTCxUfg2NPVLZ+-Et!HW+(x`+(nC{c-t;sMMDKV(mSX|6*8k7jJRitV;wrF$Z zT@`WNJyq|xyROCTH`vnmi=~sWxLiyZF0}NHMp{f(STHzaj+Xeb<4@}u&15Q^L%^<( z6EXX`?+dNHOZ`56^~vwef^h+d99*-63|WMIQ(@Yti$$nlB)x-P;N1?f^@E`zeJx z5{$91tEzm$idj3j%|wU&{DRLU=S6Ga?HzCQ?fdn0C#P9em@D#1lp5M3m1IIs^u^~# zW<*zW{nsYH#0p;wu?x5;QZd7^OZ@R@a5?Fz+oZWqZhk)dvUJ*Epc13|+l_r)t;Z&`768Gb=(mWXO_63zePp<7XS=u; zOy%ZQ*w%HIErJRV(2or~@mfpf5j5z3@9K2o_&1{)dERsejmU3D2=Y#zD9t<|Km_KY zWjbcWXUgN@jwGwKe)3@P2MbJDJ`f8&#B4RFX7m13-3whg`Lj_Qyk_>ad(XlmS%Rko zpSZ4F1IX!zZ?2qXotDAC;x3(G7P`lcEX$ZczfR&053Wys10qvEj> z?Iin_n&{1mvd=3eb|UhpN075^S1OsT=Gt1Ckn{Zpy0i@| zIe9`9@U@ig$~g#w;g=}j@kHi-Pgy{CSR11Ke{%bOHXNH(X`ci*GP*(YdH{=P4)svX znb^80K_C~6RH7;lI1bcd%-g0mj`rJMosRj9cdipy-5r-!Maiqgzjkcc{l&m< z#DzkX*1XPFa));(+k=U6kTap*>I_*Hq!s>)pQSPj7Y<~>v-2Zc`ZJV|vx3IHMJjb* znYfHLF?hg;%u%NtGyW6kmE`V+*cb`~>xc=#+*ND694cNIjS;GjVyq^OP;w(ZEPl%r z>Nzq@5fKsAS(1gxb)Me3&@LGv&sFvDU@|240b;ylcxZ@xgqg_~_U4xGb9wEGWVnCG z$!Ly#&V{@}6Q~6kG4Iz-VjgOVI~97Ji6eig14Un%+}_R(eGWD!1v{&0p2^RBuAQV(RZ}w{ zJ_jlTF-wVZplpW>pn$jmQFn%RSoZnlZyKb?h*z26<=-T(a8Qx3I z7ew58rh=nE+{&qo(pbdsO-#0y2`j7s0^e2Q@t_R0lv3wS3p(mCaqCd^ORd12cEaMB zfYO+iX*t$C5sEw|PR{(H1)y6)7^wT)V3wMCNwhl?U_x>&_Fz|b{Y&^0=*#Gn3&4)m zOXW1AqS{r})6;8nA{y=oiUD#9q)g$%Lv@vOW#Y&D$~q{yg4Oyu?Xkc8iR4@oJx=C4 zcad}xPVMDs-@cg6Is~D&lfA&c7+2?)O%^|?1MzD>)Gt?_zt;JhT35|Q~ zJl~h(zA;nl_#1mq;ra?%cSy^L4!)puUjTCI@xcALg17%Qz><8JnbMYgFix0PS4+Pz zCi374?jOc{*A&;Y);+r?R#3v$1{4EsS4ak$4Afq%3L=7-C-YHHsT?L15#WlQ>9i2} zg?<0W^_f0?3uh9wv)bdhgTyM`n3~q`n>rOC>LCm3KBDsWF%XNHE)HC{WqQvUw5*1;RE1TkW~OZ*qi8Kf9K2Q`qF!n zwfi8z$*Gf4RPib=F8*;a1iS_@$!f4upphw`qDMe6LMdGzYfHokbo$)I{HlMxK9t)4 zlsR`gskDmjz17TOwRDg%TR!8EJ}vv*|GPtuLpmI)%iZcV3b(pCxa9#KNBA?Mbq_{H~YXLrZ3Cwu0L)iKBP`dDhEW}rgQ!AqGQbqWw9!w;x!)(;rAtnoWKr-1Mz1E54eRaF%ptWPIs z;q)v+TYhh+qx_bMeMR--`zEs=E2h;a>O})EFI<Hl8mqljxp*$ z{`n@apEO2h6dm*qO7fa{I%Z~!`qUN(@~ESPYa+vYKgnc^dQNuZs6*cOTo@ko^ZmkM zD%(`=Y%anGO`7W`{%6OL<0+(U7}>ES$u!d_T%8=dfa?CK?s_=TjU>hMMWYv!I5z9x zT-Z+=TDYG5!sD)NOp0&ff4u;rBy?zLZ3D9U=YLW-j2k@)oGpWjc#cfo-DG$tz7RNC zGoLjEB6n?~rFhLMwbuVRa!~fdOhdUZTbhAE+~vSjEc-j)mm8qBMPBsXqNbry*pA9) z$~~GX@C>uP*~vEYZU?Z~tLLiwYF=lKI@UN~yDN!TCALb)YUmawox7oRv?s_bY)CyS z&}J~`)%qm02~cEf*IAjxqKe0m!ABM7)&S=HiZIlO{t)K7KWeY6$W>o~g?2NvJf+oJ zdft=RU7v0fvCq%Xe+2M)Foj!l@`)Qc*)dSf4F~?Ap1BO{(^owL*$l9~N3Hca(m6waq*o|xFy{ELd%%t_Uk@n{dZrtTjcBeriF#E= zJ{fbSTvsPbgC5cn@SWHO-om_1%Ds``(ioA}%>}iK4wIap86Z|UZiR)B?3`>?EH$oW z>pihJ9Ziw@g3*b!r?3`CB{x)_2Q>nsLF;HaRZIWDNox?cIQXD2(<5fDv^zH4?DSA% z_l~UHj}GGRZGsA@=W;JzaFWU#S+(r-@cAxA(Cq+i93?dXNLdyeYBt#A9kvqH_LS|a;(gnmw9C~xIWRTe5Ya`GdiT=nZ zTR>A(BlH3OEhU#-O!$vU3K9IW5hSPG`+*AWvg9+)IlDRA(&ak8{TC081s1CJj@bx& z?cipdVI;)FV-Ho|eT*}I_Kg0r(Z*#jK#0ILy85?7YN?V$`kr`|^Q;)UzS46|b8Kv^ z2=XB&hL}b_8A90jXCd57@K59Zsrm09iG{cL;kM&F0a`M#9Liid-INWfm|U|-wMtE> zV>TuSA0dSjAADIAn7wnt3CE9{>qRZ4EE&H+Da--2oy4Q@qm_QP?Z0m)wxdfhGHc=z zpd6yGADMyb0^4*uN6=ubGg>JU^aT3nz%m+FZOr1_*bZ#SgWe0gW9|CZEKqqcxG`FY zCYhIi0balhVQkj#x2$vexZ)AXfBWzfn>8Mn#RJ-*5W>byOxm34e1* zVJ{VxI+x3`xny3YV<@KLBK@5n%}#hKdg3#syDN2VVI>#-tLI0X6_8)0&#K4X=Gz8F zgxiK?`NE?!r2Kw-Vow`vw>^_gC2>BsNZX}qo&{VFCUZz!ZXlsr+6Q&R-nXS@zp2)e zqcap@X%{a|6#)D$d%z^>ZW4(x1|19|P?x3p?zT60OT2t3orI=!PC63Th*FZHQ+Ke-cet2?pm zjfusP9ttQl@YHDe&zsHH(!d0>BH8 zD-UgwCZ=!g>PWIDD$+8H)o-ml0S*S!Hmm!rbNyn|X1S+RQ1Ws+Axme@`2kmV9eGbx z_s^r=PE`Fua0#c|r5C5 zzf_G)eo3XZ8>59wQU6ENS#U-9yRcCIu48Le(CqO;r?{7NHnb?<_fE z39_z!+j<8Ss}`1=l9eCkx_vhxq@x`0k`J5{a?s;G?06FU4Xf4+fe2b&g1{*7v?0Ga zY&o5{<-(s6U`BKQau4tS*?gn4{&K(}59HFbM)kS}%WBhQ3PQ%_{uG77m5IH{D!N*2 zk`+|lD!s;*+vBKN2I?|C$0m5nSzRovRkgTDdmfzl;jtJ~TQKM;L;l2#nfD@~zVHQL zR__AoEV)@)_T9npNasf`G{i~7txQe-Z4sGm>RM%FF{ppBKaHXuoTP@;&sI$>I_O-M zjjr~SzQ;bldNfoh?CnuIB0hHso3ai5WgHU>>}ofYUUEe19*;Q@Tq?yCn`w?8x`SHr zjU(~!?G73@LE>zp|N64+vE16=j^n3{s%pHDYun9a+;Ls@kY~` zlXmk6oWv@uj||RuRw0UI@+2Y9>2#6{>8Gjs4Ny!b%#Pm+EaZ(ngwcPUtblSK^f3*Q zaTk{#+1eBmE_&xI63b3}ukFPgnfvp@_yK-D@$Y&6{hwKPGQCgKA79P8E_s<-TVpjF zctPlLWVyaD+(7zh6u6rAc%5SzrB1)b^y|JVT}KVLQa4S~c&l8M=80!d!xBI%Ku_40 zj4i4B--r2{x_3O^<>F;~;LTzahdef6eG$fSd;Oc2VE3p*y0<7yFAyq(QqH2{=fvrA zmFxBlr=j&jA5zRRilN_hzxEM|JzV9c^2FA7g$;(?E3~Y3(}cyy*9Co|G%eEyBWVK} zV{r-t@|TU&Wp1DAi150&bt$G#nQp7-iLG?gRdL}oHYPM*;Me~p)^UsV3@HH#?M-G) z>@8#ScyJ#E+D1ReiVywv9+=o#iBJ`?Fj1|t1Dp};x9uNa7&LkC$o^_{eA z%?Fj$ygGkkL`?aYMeX(F1{^wnXZ5t9tuC{S9wmximw^m?5E2G?R>Gf{Bpjg;^og&O zk5?lm*+1G(e4^XFJon)6N9>dt7Mr-ihN$K zRenb(Q`^Jg<_Cl3l;ldJ6I^$=E6rlpHN4O8L=Qo5^HyF6&-gIcj6|YszWiTQ~QI zK9xI--8!(MJe%kjf&{Mf_A)Q_-rLEl?_+YeeB5tKyZnf;%Y+Fj=>EV`+@JBhKg^e? zg*cWw1ro<7Pe57aKks$9KqlthQq9^!$s5Z`uE)FG&f+cmb<`48-f)Q6-gK$$|8a7{ z7X*qx9^U3^#|W4d%*acc6U|OJ-b=iV+gfB=6O#@x`40JR!{o;SCm~@)NjcV4(D3_v z@ZZzqJ5kYPiH$LKAyAI-R~?1!J#&bUunODJa{uxK`O;PrR*&-|`{aN%Be3Z5N2@z- ziS|W)DKAVZj!Le%nVto^pD+XnBZk1_^8wYK@>$B{Zi#o;v`h-jca3Myt7U&kO}C zna1E0F(2<2d$B)dM@1|$NpbaU2*_9yTmdsynv@$BY!WHkE6eLcE&Z%AYC8PZXmrNm z)h8_jQ}EbP+tKvf3w%QbUkbRU4pTV!2ePPb$C}RIca3sKMm!?qQ86>Z@Q;3 zsd;_7agVW(;<5R}vSih!D`R-yt{r_Ka~kIC%(_!qLv*0u=J&htXMR5By#|hXW&O9? z)1^|7J`s-#H9nWPFWge6H)q>~M@1$MzKMEW@2rvIUT+ggvtIotOOaH!>zmQ?RWWVB zv1VT+BB7F6A`Es_?*9HAc94jid^ZK7O--Jx885AtiEZ1xOLw#hSZCzy@u{)m%1x)F zU*pF*vILPo9!L8y{C=rLN1nGD5!KgeZDJt33p|I-h9bIEq&^xSPnG{Vq0ipgi?NVL zmGw*p*PaU_5)FZLB8|3{FuO9~9#1~E-4Qqq)mE6X&=ehOj0${$&iqf5;=A2QRm9Qj9Rwrm^rqh_*ia{0<9r%tFh}uj3lW>7CMeWQF{~3hrl;YM~ zECv^7pOc%e*Kc|xGw>3O#$PxLbh8$w`GbK}YA@785*n_{XxUNw5udxsz#bz~PSUSz0M| z06illlE_o3yY2TY`h;J%q&2)WFr#ZM5o@H{~t@-ADU-<0?K zDJUpdH%+XFA&SL>kY_U@eAx2&I+eN+a&W&%`+0JX>l?pSrry%0p->Syw!D8~HMNS; z-Z$IKPpPe_$Mi4r4ft)h3x_HPxGO*jlnXCv^)!>~&WN=tl8h-%@*=40ceblWX*3~_ z6MBuOpmw%Me}Kv`^vMQKsTA9w)fofsWgy}SZEYp;9DZ<1!fW%Y#G&GhR)DrbWeA8Z za(SJ1CSor2a|ZmvTjhkC)QVTxh>PdBjgJ?E0_ybK2yh*wHPOmpJb>MYx z*h#5=&M~|Bm-^9wSeW0GHN&+)D)fN$1U*cN8qDy0x$*T2E;@r!*$kH4$eRRI+j|bP zrpfu%-TKj?a*gnK|KUJr90<+Di5!(H!$ikhL#gxsxsQ@aS8s2KPfeT{=&VQ zw|S|AU7%iGUY$|Ow0SwzXW#W&Vf<5-29->_d8NK4>_EiEUF(fhjc0)jq5|j_EXlNd ze0+H}a*IEV52el=>6-dviNhj@O42-L%Cv5*&;_$AHZL9iqs+=%^c?{N&*3bX4 zT@M#ukQ2Pjv#BEz#y-D!J#dVF={FS1jJEsM;@Z;n-s>FJfty|wqR~xxwUJHu;9FI> zh)Sr8TmOvAULh6}6-DX#>r8+OhhFn0GiG_%bj2fMr$mN;a$?wd=|EX~L5Y_DwPz z&Ry6reXf8}(BBX#d8q?bQ1?L@|+75ysLlT zZKDE2!o(Q1Xtk1ztKu&y>0-sT*W~Ui2tT~>TiaFQHt+VTd^1<|W2P)}9+lwSywVr9 z{Eh4NfNPt%C>B)YtaEXLd-FW`P>?)@j4TT|1DZ=HvM@leUly1VD%-dPo{JCy_v*5< zsD&1HJ~lQsH8D?iK%8>0S1=Q-_9bLIj#+6jaqLYH#B8|&aK33EsE9dIt@|FRh1uHY zpK8C4(~}0Nd{U}L#FGF*|KeKpp$5oHyi?Wre(n-5H(DON?&X@;h8-n9%>K(fk0ak0h>BZ_!Q$kMqhosR zb1>iN2DY{r%@=r;UPjr@GFfgiOycWzgpTm5Rm{mlgQge9&0nDy7R|6YI}SLSTVnioaE5tA^C){O9#*7e2e?^^)3!iaY?v zOEKElEQ^(MNuiB4U6Ey#eG71gM0D0!GF~mnv@8Kp?4eT13(~(CCMP}B?dgj8a#LAn z%*+e~p|x^3mt&CVs}Vv9`%EH~l)(ZKb0-h`HPEOby}9Sw+_W#EvXRfo_>x?I_TA^z&^{B)J%RY20W* zcC@*j5^XBv@bctUVyh98b&sQ1L%Z7v6FwMv+$|=x7RExkw#gNTn338;@#)owq<4Zs z4Kyvi&0F_R^k_4&PxR3(v^lo5en3GPhdTA~P>RiqF3kGdmzt-nh3dxvgQ# z%ngLL@yr)VmBq;Zd6LXR(LHb-VmVloRqW5eXQsh<_)@&J?y7V_>8Xa9>i8j`d^)>ODT!=-y9GEfa#5 z7>op*_!?n%!UIyIdPMj@-b-ViokAwn&0^XEkfyhKat}t>oC5b1R5~Y+_n4Y9Pe01U zD6|OISp}MN!z;*LufWxa6hX?Z;ls`=hmQ??7A^&4%BVSfU!@luFryueL$x!5-+v`* zU#BLWvov0)q*ZaMJnqHs$TUe$TSQ5s+^_h zcJU`(1HBHNI^%E|w(oy`s?1ud&%=O^5!l< zQejjaV>bwL&;!|Ux)rX{mhzDN$mGQgQo3C@!dR#Oeye`Aecb^eKFN}B zGdONU2_JmSSsajGcF<$!z-G>Sg)ZHKOltRsf7cW>T`aL9h z-wXrBdI-|%67!WH!E(*Lek56cd=447s0q%m5izHrG8^h`|~=k#OI%u*$PMj_YEepd);L@jGD6u1?<@#J~})S zc^8+{Zr5qZQnrYva5XJpRU2mTbuRk6hexZ`zg8KEPmmmQVziQlgv1%|CD10GzW}Nh z+YQsK5jpu({Kf~`B;Swl`aHJQ_)1B z{g;(!kg7sxbx$(VClg}>jHNItrm_C>AR=j|;{d0-7;Oq(R?>|G@hZVVQONq=$wGM4AjgZCIQWKxk zCa%#d`!bWr21;@54Gs>fF&K=wcS?zVWC)KjSN3oh8${j)V<_o(Du#n!okI_Xx}6dP zb+vE;@-rt+ds~0z<2TfwoIfLAExO}XwwjnQ zvO(k-`5Tr9`n)y=;4BUOfFa+zN%MFm zmDU0b8g7%j~7)^_jr zmv~!3P$dX}DD_c7o0Vci9)dX)^ee!eX%ydNOa;UZGFw>15}a!@Nc~|`keV79nKQ0R zp9zNnurMi|v$s#yPbm&vZjwkT4@#e#D8$vNA3h}q6^sisMnHSzPR?MlA-4DIYxcbP|D3%^?s_0U?$8Q~giVBL1*1$=_(FZHgfHZx8*2fcv zO)2P*;WuSNetN4I9)PFn<4<1#1Oh)+(p-Yr7|fz4!8^1Ykc}^PORo=_57~TFXiYqL zNA2eA`-<;lB?XNo@O+vI-^aRbnKo0n+Dhr|!e2?{7Kudc#F~h=Oc6wWH3_dG z9~*q16BGww)9{t0t>cvw!7gxZ{uG_o{Q-Z>oPLRrSCFeyG}hyzLfyJe{Q^nVc|@f? z-0brLT(g38{YJ(gX$%=cAUo;3UEN-1TD1a(sLGq~UwfQ`^6rNkGvC-7&-4)ecH6I= zwgW`9jL8DJJagDQv&$h5c5^DF&GpW2U<`v|si?}uufP;tH`DgHmYWbI58sx;kRwg5 zY5d6TzyRS^wme3YW6&Er%9{-FtWt=4hJQU)F8*J=DalC~bB5ioC3*m?iS9A5ONiDm zv?@dBfTY}Z^CO|t6{u0_KYys8yRLKwm3#}!=ckj+ysKDfo=k?HU10b0Q7Gmu5zZ#3 zn7R+^D5s`}{CjPq_slH!yu(mHrP2Vf7z01rMCb;5p|+-yyd<5A0cdwm3aqik3h(Ik zRt}dXy?RE^ zmgR)Ta7H9AD=A4pAlusLHFW8*nBw|x0W`^{FQhEYwr=8nZ#KP2O4$_IeA{QMMjFs3 zL9pTH!%aVB`Rx2q)(8i&A#UxbmW2^_M85Es6H?s9rEp< zQC!DBc82lora|D;6^Qsyc@McVJ!QGwoPzwqhW#1Hi#>lB0tey5z)zg;KKkP8}mps0vSN!bV zXePzw_Av$jh-*Wta8$Hcat=d2M+r(#)XU$Y**K(7_Z8v|BN*&Dq>v07EqA@2clq2^ zU-|zmKwkW6vnsapxqjvA{27sN#Ue4=r)xBiZ&YrBz80vU__iLgYlNT6*dqosZtSry zsJAy>S2Q1BnA+0qvCpowW2X7U+N+j?#8O(9X^P?zNorUoh*L_FS%ye_+R#*&w+Ztg zv)Qn+F(W;fsINQiVv4*@R~B^0#Js7>?Y#q584HYD){G-x9d+z{c~F-F?^2&NRrsJG zcZ+z3bXx*1qECV3-214ND|woBrwaEV>V_fb0=c3%P}4D4O%Vf5NtKdNC8p7xk$bMl zAJC7L0`tOaO#t^+5miq8Jqf5(T}iz zElmRx3^*Z>1}+qbDkFM&T$zVcC9Q2V-Sk@5tWv#S2n`gzr0asF0Y!$7=$HcONI?Qg z%zWeqySE?+z{$W%K`1EUDfPD?#>GSF0zNU-74OTdplZ0~Gsftn-)IyGe4R9*=(t!F zAOKsQL#0O~_(YF|Ee@yhM~JDDh|nE0%a~fpo4%_S5k8^zYl3mZm`{AJM}!LFY9qFk z!|-*lO^Vl(Ot7K;20=Yd^#w%Q z&d#D~9Cmj9QHdd86I*FwPgIblOKY#knLhK}HyNYSsOuo{Qzv5ARwA~v*$gp?yw=&u zYSm)&bDw|x*P1?UJp`AA%d&?SenciR8fzScfJT;0=Z_Hm>>JjKF{1O_m^1bGb-7KS zA%gag1*h@%tDVU7u|-nc#MZ8OoMFIqlX^L_%)h*X#6{gQ$Z41MvPgyF%^&^y1O*CD z*3NIk!!yG| z92DclG?*WiR0f6y_jf--RzXeX(1JjwK&=lR>ptF$xiD%~#4X-IKMvU|^O#V5;5@02 z4Q4Os#TZh_K5`M?#at0zm_YAZy9H3*VIU*20rfQvug7jc#>ef9&-sNcE1imx6xz9Q z!SCcG+$oomRk4{SSev5K=P;Ql7SiX!TjPTMw8g?d0I@0`q8 zQTFJ+c7o6k=r~-7`T-wN^5qH#S@dgc}CZ>Lr zMIduSch_h;Y+pur_?b)U@bO=A7XK;1JC76MWct;y%EhcpR^cx7a;O7@o4KK73mf$@ zHEz(wW5!+^FzmRbp%_S$H5eMuohaktW`~nV8~I_T8Q-od@D%y&bD)@rWy!SaP%@I8 zZxCL6BP|dhyX)vdgNj}ZaMEX|{pX&q7?I?C{UU$&vwgV5%i+1(aOqtUXlu(-(x2B8 zpUId!mnR8o8ZP6LqL<#jiGBPh(8fCS(0Qbk`jBn?9FACYA8b^rI;0l-{7}?)t#!Ae zSz{x%XOkEqDNFBQ#rn@G+{zJP@yJ{U*>q$Q(dM&rNwSPOe~A&tPCio`qgm`HzGmr+ zz6Crh{@e+Ez6=Ha*oaZJpM)<@P%9Frq`X`#G^s=Mc29MpMYj1;ehvCMAX$zONyMA% z5g?9egp8gacvw~$_7vZ);^+@xTX}Sl<)VKl`>a@e4^l`7yp(S z853>i5%Q7nGa6}mOS-IgLqLq8`=b;3h01wC84~1ku{XP#))m7-6IiJflLpj&_4Jp1 z4$rZgP|-yr6sMWK?t1E?@(J-X9k7FjHQdlSr7e+e)lfsClCf24=H@3cil5Xd?XA0m zT!AMD9cR-=zpdW7R?bb8ZAdoF(tI^Yu}3Q(C|YCgwNAVcEpH{BY}LMUDS1zG#=b-tz^BB%Mqhs}4CE%KQr_V~}bvb1;Wqqpp;?qA!}1)lXv z1l4_aG~Age&IrJ?Z9ON^a~hS>8qN+}iT1gp7Xfqs0cO;vr@v-(b#Ql8I7Mz;)8_r` z5SKaTd;xAjZMukdrf}uI9ryO)Yb;f2o~&@8GumVJgX89CmkQo)}`XI5{**X=;Qdf8SUBb76p)%FOGt*;go0B1TV(yQqS5#5G)NXU1 zWp7`CQAv1KkH$;V1RmCaqX;&hjv#>!0CBLPR4EDXzlyEEc}&+v2^f;_=Mt+GAKBpG znLPdc;@1TAQK+OqG%dQ&ZbA5f>b$n;L%)wh;jWQSdqR)CB^o&mw`j{T$PlhN=1{If|$Mrr! zY}&8;`?iof*cjTHN#xnK&NnL|I5ZNRkyu*l%Jb`#e&>9eKirDrfV_Yp5dY-~CQ2B{ zY2K8$KOQ!2Qm>r)&sm*~h2WFOUsy(F^?2nEar-|n=$E+saQPWJFu_zCGfALsDL*gJ zy&4-eRVqI38toxmM^A+l`-{c5$zx(ms+iD6=|2I2C*;>4Y$1B2f5 ztTdIyVUcIT3u~hTRD3K1svr_3SzIXlo7!{aLDZ2xvU2Ma!HUu>Mm!XBJUJOLZw}Pf zt12Am@JqXAh^@5HR;0{H&iZ*3v^zn^&6{|b`bgy>w}XD@t!J6_kU_~R^S$U2Q4p9U z4h2JR_I#XS>*O=wWPQ=(dPto@AK|$3D}`?xm;{mSQ&*os7l;!W21bC+Ad{@?fS&F! z$eOeJo}aL$fkw#5T%86tF)O$1i;YK01!BbQi0nHAb$F35Ell=ZE1Tha8Ft23wg__* z6YD7$Vs^{oLSSMVto!*U1!&1c;uxZ7llx%S7OvRdy>7?F&cVk!1^atbXbG*bAt3nB z)MgZTWF3HLJa%N%g^|G=?tIQ@iZ}BwNXtlc%C(8|6S9G(lFRi0JMvQaw2qRo&#ooA zMYz#7NP9c);Glnn2q$xcw4Wen_fn#!z8t_3%I3V#g;Kv_wQD1AdIkjch;#)?tG?GW z(oij7*KPBdZfy3azsFWKsek2$hV-qLRvn&@^u@bf$mm4FB4#WK-=#1P*^Q`uMPTb5 z2hoI-mhgE1{`H46#@f15LED(}{kuOpvSJJQ<&6a&RwgGm3zp3y&hJ>SPUO6N&NnHD z-nCjD`2FHhw8+Z125@O^>*Zo|Ni@TsJ|0ggn~Me4CB4KyljY^*eq7x0T9AAdS&ej> zCd+22U{+Lq#)I1lj0K21lPAIwtGT#wguX*BOO^KKyCP)B4r$=clId`_1iaxa*q+O73M?qG;K3Qg^>+T ziOwlD!eN45+#iO_$aLgR?x*{WEdE6(UN>iDnAcZZF z#9?(2;dr}SG6@tYb&3#B^P@~&X7x5SPa>!+wB$4v+1yUm!x>+k50PKLi@XJEHd#Yy znL*6+t*Ks|zgr2_hbZ#cb}ygD-p6O&1!j0%%vK+QSg(B_Rr0xh5<9XOMvgvjnJ)oy zN>XNXo{H*`qm&mN>s3^aKG%5mqCVj&93~%17K~1VHTHN>-$uMi@7AIwZ0`CR2+b=s zhlLUE%Rg3p9kgU*`)?FWHQ?2|`Dw*3iu_UsyT&hfG&c|CBIeduRSO#6Pm+JcsCSJC zCJf3?v(@Omou91Xv`$RF+F)lFzDWPv0l;(^d@Y_?znU=I}Y(S2-MO?}^ zHOzXA)%8g+T}VCcb7jM7c}p z4a-$v9(j#2kq!U$@t*l@GS0=Gpx_5?iH@}mVk4b_NV(o-qU9OQagnKK{`)ra3D@3$uT2zjCwH{14iDD5TTH*7{;Fz- zt2s1d*X6^TmB6whZ$@1i%}V|EAVXBLW&Re0|0yRvtzT_-a1R((z0E!=@Ul-@vl4-w zWr5<7tYw?+tu4IROAzoLPt??tda8XX%IseDTN=FuysZ{i+SAa4oPVKig#qWS666#T zZ}3}dXmI9a>^a5~MHX|_2T8*FR8?g^o-SU|_5gzco{639s4tRIMtHv_ zes`A)$(~?aHl=Gh@Vg(5_L5EWejT!H-&bk%GhDQkj3_-_sXHP*uMKK9%aUEHUKrrW#a z)ADV|U$&REzrd!r4fxR3H%qxrSUGJJf@@#-pazdcfJV`AgG^$0^O2u+i2NaEdX>7e zRac=Ab?AGuQ@o-z)|5d~SC+qjYrQIj&2{pGi3ow%GF0a++E^GNbNHDJ>oeLtcJRhBeRn@83^z>+ z*nN3hXzsUUoIqe{6cA)V*X$^jq@3Dn{2o3aC_WA>Op<|At1B|(0gxk;dTqdo=S0p_ z(FG2kpjvP;hrCi#TBq(ceAKV?y&!)ReB(j38*B8o`g}@f^DRxH+5}jYGNVLqw!#~` z06O|Fr;_W}R+_J)C|97}EkG~zM|kn+So3=Ko`g@HB}c34fQg?fxd_hA&X9Kq0$4eQ zF{r!mOYm|M8cNKP9>meFYDY?9_iCY@3E}U* z@z6QQZ1`hx;UR7*({J_Pw>_L!_V0gwiFAM41oBfboOM1dRf1hhHV3hWD(1<)Rrko9 zoNhw)f1_da^|`R$Z7e-<3#`N~^Wt}a!>+9X&uf$P@DM@^8m;G3u?*dyLU~t^VX$4W zx#-n$fJJvGe$LzH!`ZI3@L21-vBwo$pR6wnox~12dQhJ$5WO|XvxdA|0fxYU!>KmiHn_MIWi);O|y%|~dzuz%KC*li>1P(140}y-imd&I#63>Mvch??;#)>M- z|LKEvQNO;jRP>up98)$B({;3t9N^JIho0MW_3ne-DWZTyys`cx$wT*qT!}kPf`)%< zFDPnVe7^i^>G5}bDSzs(|E19#R68N+(RYh<@nq9ME)z9F5d4N#j3OsD+66rp9AF*` zyz>8rTj3KD3rz7_O@+$7{xFmHoRr^=NVcq@P3l-!uIoc?50;J)_!@cbkZX zfAH8Mw5LXc^BjuEn7(OeO$Pqr^i%B4$o z#YNg3^;`mTj#GXU;h@_^Ja zj6_Z+@%)fJ*hum-ws%hp=ikz=$_xHBrYbn_`wRNBGfkTDUXzf++T#0JcI*!mJeBWo z@8tcrsC0KSJq`q8o1&Va9t*)-Dk65LSvMa$qm7=u+M%M^72%weL3oV0*B9tQDcDQT z4T#TEYh{OtJa?V%UqiQdRwkZTvWMPfjh{TFN1hunbxlyCbmmDyNTGI%j~hdSEA>w8V&9AJrC9k;u%FdFxLi(MGN4IG^Shyj5l`SXoJ@uxg<#hzH7-?xE-l(KJ z=2{`4$aBBwhL%CiENStslE`_&7Sym$DUV_w9lU!TU?3*I&bF5hn$VkgmfuxsTYt_@ z7@MmY*&dJ$N!X`K-};qjanM`Inx7V`U}3YVrCC{1_mg|NxI*-Hc8|!DJ=L~VsAaJg z`J-moV#rFu9oW+Nj!bzK%L5R$!L+R7yv5E~9ejr!)R*=K~fHp7DR6^+J3_Jeoy7@#rpm&SJb^6r54A zpg71V4|xvcc7g;D(h_Z(wwdtr?G)c}NKYj5h%91O!nV)T%g<(eCmqIL=pA9=pF>M% z>l>G?aEzDukPU{u`)9Utc+I`v`PmG6BXww%o^>mS=u{qBt?;G`N|jJY*M*65$Ku#{ zfARMnzo-Yu$3Vukxe0uQeF!In5}v;G@aH6>w`4kAh$q^8l^ZNjB_l+`%HIa&*{yDEiVI+#yT@s$P`9P+7r}`-4zJul4Vl8oWN2VeQ2^(7FszR%-US1bx5FBvsfWty%Si3 zwCu;6e!1w@JqwOfrJ=Rkw)lDZQx+MSsZrmn09zb{JyT2d${g@_|3br<;xNYe~0)7vFZxV z50PG)L^oH9NP5iCvTLBAj`9lG@FT4-TDlZjfi@Z6e_!op#6a3ANX~j>`Hq57z}=*t z%Dx*vM~z!HX7X}Ofjh{Ui2`Nj(dY4Y$&Z_ST0I|6`jSp}6Prrt)zJp8BC>DB5^be# z65i;Qwj1f|-O=csnr}s;<;dD9>5xRDeDBq^S!Ks0|4rg?5~f6kdk6dK8M0J zmvm}+OEnBJ+yK`z2DrsN9W@jY5w5+H+76u&>wFzjwjDYAiPZf8S`c2ZyLRM7OxRp4 zYP9{)tK6KEl7U1``ZU*N4Fy&rSst>_#*Jf>MLjH|iiOU*yi^qZnCt^OFF>Hpi{a0v z#B-W&MSdjy5)mGEuZ@oIMILf-<}T9#Q|f2S=ue+t*U=?NK%RIMf))<2$gmHxSgME| zJzV{s2l=%pQIa#Zi}Y8pKk~<>yRc!*hp8zIWhH7;yBu+B8rToV8&y+@1Zt5eT+qZF z+o}7f307~hoE8xerz9)7ZE-JGn0wneHEC6sdL^lPRBj=sC4b>?IyqU&dRH<-qqP)4 zpv|#)<P)+x{K?O8s2sW^wxv#(*&>+`ym?%~FQ8jY&ee!@+7(y<8=LZ$F)&ae^PF2&cS2duw+ zwHnX?X}c;8yp30E4C--(pHkg` zC?b2EkByBD1pDbnTR!ueXB0U_l`K$_;Szk*6{#+|5|q(WlZH6^4qaPY2+MlDOYkD6 zKkc;ZPhG!4puqi|4r)@Q;y5i@u9B#BvBtZ4;^2V*SWwEagt4Up&Q!G+A)t?;ApDaM=b=I&6^D2^dA|_hktR6GQGF|Gt5pnGbJ| z@;`UJlmLC!1&p^p8S|DmE7+~d7 zJ39Fd1jU-CVDi?}?lHrJM)Qp`hhKLBeXJh~NjZQD#eDkuH1qLo%|@3Yu3q5sgua~J z`y)_{B&254cKz@B_)~Iw=A<{0l60n8&G7D5xqJ$n4AO3XLnW&-e^E9nw$&c7A17;i zLqS-bn|_(jW_lBiDK+RunFE2Io}l++C|r6}c}NDJur|jgli6De7M7zgDqWH>ZT^rN zrIGr#CY;wKf2gr=*{pE9blhWlvbk`%IqXJK$^(AeVk+r!Aj|7QVUSJ@Gk2AiI= zR9MVpx-*6*$?ps0W%S7;XXqFB3`*&mS@lH2Qx~%wU2rWa<$i^nxi>zCg508V^ppM;y4G|)95&^uuFO3PZ0UEl(P3z!5R=dk*;IO; zveF5(kEk4fz09r%DfZ?$I_;&jUEf26!PkCy3oqnQ)3g}X@L8b z0VBnwv`tL8d^k1Z467;Yhw~551jDuZvPvm;5U}JkKuW??i!?`O ztLkfn)xBU{ zZ*-9i@vVeAPnN5Q5MLTFhaqt|G#L)Q5`l=gZ+Q~<->2|0PKdNOK5yGcdu>mYSxlfv zRaw$&h*`|`3&%?!`{t8wUack{Sk_cN|I(X(hlfQY+VT(X1~!K92CvKJCF0TN4Y{4m z!2=rr5hfw^E}Dc=z}ZE|x4o1ynN=!97h!j9_$eo=}vziJKsn;tGjMA63~`eNaBe(f>LR;Cx$*!z4Q5C5&7-6O z4?u#J*w1Vq0XYDX7af#~rWV;swW>hhzk3bb4P_dd3vp!s_fG+y?(64%geE>4r?}#% z>@6W~xP}zJcuYHAAZYk7T9$BYz2lJhdpR4psI9Z`C;3Xt`h&|zJM5B%@Y|_43-Z1( zC2~woFeO)0EVb*R`{o95kX{%79lvJ)wE-XfM1JWhLYjx|}QB@sD7(bJ~nqrkAAF z7cyFZ%wfOTpJvIkEl-1rFB=L%8J`Fyfws%w_tTKVu*v*9$ry7*GA&w)bN3|trcM|0gWfi2izPIO4*G9`F4f# z9$X7kgU;TQ^5Q`t^rCbA!Vi@iZGXX+XTi($ayPZe&zc(Aj^9|G(1%|g3OYyy?IU>2 z(w&o7d@v}H(2v|hvbad`PL4EKyA6=F60uaJ!EY_xV}0)xzViFCm@cW)`kTnSrSV^A z*6)uQ_$;*Nnid_sza<(H)fc>k;*EW__W<&}lp4ae<(t{noc~6$B=|X#KFUOEaE6Z1 zOA$sZn;k;)rCO&yL#aT6d?AU~K51Q9t$I1-(94J4%+={CM+?x_aR;!WeyG+bK0kT5 z>V5e4(k^1o)igU=O_gl%eYz0;bDb`p4nh;`T8wU#w={W&h~1)tigx;evj?E5N=k+b zdP(w!lBma(LcL{^lsROI+}e z88$Yy6F6!yxcyiF`u$%BQv1=alu3D1Kl@hmsoN1yG_0@W_ANLdL4--15ZqiSSl>Xz`>^0S%6bRw`QQ&bNA%f@P7nbkxT5h5Y{d>%)tb%H3hDO3Lw7=j17uyfXE8L?oQ@ z{pzRy!Doh{&KuXq3n<^tFLpRHH^ns(`w&SjJaxZmBW}-sSx{(#JoR~%)aBu{E%ftt z3Z_)Gw~KhHetfO<;iJDw#TS$wUu}RLA!^+$d}+S+E^kHjz<%+*vP_Er%y}7&?@P^% zPPHkf9kX_MN2Xge=2j9RZ}4kZ>=_!mgHIG z^RKh1AH0%0K{@&G{}FZF(NzEczhx(ctn94HCS_dV8kyN4ZbDqMvU0Cg_9mOGYh1Fj z=e<-$g{*5`T(Vtz+>49)z59MYpYQJ+=lGY`dA*;n=kxJ=jF@f**U=vW4i)Rm)}rQK z^6L1lYPVHGk3qvz*PeJz9l&)&AqUtn8e;E;i?A21e1YZ+t9TtDmCvE3+_Wk8Ixkbs z(mi|mEpcr;#HCU@IQK|6m2#9H@nn}xr2BG+E%lPMw^Unmb=fy1v8*EwiSR0fIo~JQ z?&Kf#P64$8o`Fx9+QuL()HmM@d){bx1GpjG*e-Vv=rnV4QooeUOt}OUPP0uGL=XKb zpdMtZBs>E@`N)`OLj`3Pfk4@m8vWzQQMMh>V|wN&KW3Cy|a zw%#^qQjzPaM;>=`@K=f3cxZR`JD8JK^RrfR;AL^_zB-AZtYuAvKH1_SoB{Qz;4if` zWx(6vCFf=1RqeM2s{l+b{!ZSoZ*nP?vqbkz^y!0dd@3b6mF*mhaJmwPIMjMyBfd%< zGLF5~ewqC#47W&81nltm)Mv#d1XOgg{&v}s;@}nl?00Jgtd)L@69p>Pq{000w0!f; zydSH7($pkWP!v%jW^M970bR7~=aKt;y+nr}4$tV{#=d2M%S(>beA2JrR^<{oauT_s z_ORWcSvlypzh%p72mLE@bKTEU@Oax7mj^G`7r$-x_3Z2IrYARRm(soG)i@fPnwK^F zIuj(mUF^M=V~E&dQXNF~fR^9sM~cYuAP;pEfBb#bB7XkY-@Q1i znir@g-^6}4_+Zs~8&Egg|LYi7(ehcB(%(z<7?9Vy5({^z=d&n6Qk-nrnP1LE&zai? zsguP6O~SLrf;qhpUpl2-^k%s4zd88Ga@04SRSLS{8^Bri6F@BiYv7)+NLy6ltgBL1={YxU#VJh?Yl;fHC@Xg;VD0E|n%^KX2vK3I)^|lu-AZG9E#|xw9 z0;01@(+7DEE{dG6VjfyVPD|~cC<0D9webbX--0}5sVyg_oJ+d0^R8R;c>whINLb5# zV@z_YQT6yR95ZIsXQGZu?GoR1bZg|=8r&!2amnIrqx)&8kB+76x#Wg?+)KrB?rh+q zVTSqZl;n9KXmtK)k#!YBi? zxq;38t8-S_MD@3>TJ_&zE)}QsN$Fl(I`sFY<~H@Ud&6}4g^iWo7N2;EHfp25B55{;Sg$RjB`8-%jDG{F|@pwgLVpWi$g6m#VejZWRE=@ zVSB~@idzo#J!0KIZ`_0Jt|qQ`|7wK)S9-|cm}b4MZUl#eo&lTRT$YV{x@`Wl&E(p< zHF_hN%5GP$vC6e@Dm|ylnpB#&VyxF!&CJ;U>ESJM!UwHC6=s3}nP2MTvs*o0);y8V zGGKt<+`G)~g6$t*^%Y6!D;n}v!K>*r+U#J*6eRID3`-QIhD%k+iJ_05w*tkXPGS35od-V z(2?t&E21V#;j(Tb8zQgiJ9pB6^K4T}v!K?J4VTbl_ojX~;eH(nMJ(9@jhP%7>h`WA zJ>O18t+OPm&b>t-v3OU%@9>Rvf3ja6<*+#UMf*O5y~#H}Z}NOwn^o>UpkMA06-_}) z<~8VgqjfFWv=T!umK>W?aKxT0%S4V7Z>rXKmE6u^XSX~4BTu*mmb^=A;szB|zXMx@*iM#Z3 zBj0Pfoiyz_TQ%>~07+97m4)AT_(&DwxIqs(QR@$8?;B0tuiOjRY9TvTIoSA(9=JEX zBzvaf_JGuSL#Kd`xon#CT!SrmA~-~dmwMqn1;?4LRd^u5=F6;&v_}wao-FIc;o7ho zrEvzsO1}rCS;m@e{hZj!){$eCg*NBf0o|*d5sy}<^x;!4{SHzrSF{}80h_d{hx&%} z%7Oh=u_p-5pJCuk6O9g$(acjfN?%Wj&J2UWTb~eTCHK=sF09K0l%EcLSZhy@;-c~Q zo!_{SY|s&so_ggA`IkUo6Kh&Gpz9aBtEL(GSBmF+idwzAy-fekM-5wbq1JmopF4H! zRT^`-2BUgK|7w42<4OGHO-EyON3x8zN+ShI647p`JMi{zdS-1V7$Cu zKdOJw()#j>mq`3dfdhRg4}ib#StPg#`W>?f>10U!&^oJi=O>(}=!BPv**nOCY z$1&5{F6g3!iE1h1eQD?&2b&oq=#Qe2wNxQ@SaiS-rWqJ}hb-kYbz)`j0!UjhB_&9D z02jMtm2_N@^2ek{dtHwI$gBvW*Gqx2Bghm?UpRO&8Am?LN4&c`tHLfkqEcQ!0fIgI z`n3E#OR8@D=M#XnlE92u2Ut;zIDv)>+MjIvmYG`tB&W5$_X;`M@ZDSCK&F|cn2Q|{ zz|*VgWtS#TDZ2`Va0YuJ8C2j&le@Jkmt2YSOU#D*0=kvs>vU(Bm-Su!h-+LNWn24w z*)C}#ryCvnA7~7BRMqeE5nslLhXQ=`;avEQ?YcEhC%dUq4(hY5#Db3S_S&bPZL`5vL48{cU5{l zn;6#`d=Eh|N=&v+T$JTK{NC(vcUav0mU~KV@mTg{G7+_AE5pF6Hz}>%bdhwoo-~l` zi?$snPVadCzBPJEnDE&KWjtrwc=F!JMLcJJ7QbyV-!=+}wp)SC9JD8vtsaQW({MkP zXpWx4)cuq%cjkMl>!9Lz2 z?YBZ?+f%p4)*~yll-NccyYw`UB!jfU#ATP($AcMAQpp>G;z~={gB-riEyB@r&ey@8 z?@~Q>-hVXsW3Hvm-6%7))n9`UiM81%(U2x}b03cqs=0lexa z4!c<(k&R)svmTxZHn^2+?A+!`<_lK0Om8UTW@p}I9`r*pCN%S`4Ecf_MSnG>O zmjU`c)gq*E_@ue<;b@5y%R5LvKUqgRn_<8Mr~DavFbM7^()}diaKheRIrZnq+4Rf{ zS=+~s*Df56T|2%jfFvqu652cqX9=cA=vh~gAmy?q5 z?c;#XXL27V_NFv4u?B#3xE8xM-^V*-cLM*gc}N6%0*ug64v%(TZI;K3V$vIX@2X*A%!baBE$R*jNWb zb9u=5KgyGJgf5zH5}Az5Jq+5?jkOG`OQzK(`B6in?BX67gQggvWK5>+2!gg8YOx`I zC)%C*HLAo0GuF5Ie(I97#)gPUcdI1jGw^&vifn*j<`1r`68PigjjWd6u_6o7QUS`v zb8_k^!y7yU=NL=>8J`VFlbb@~{3!o$*j*A0MGku+Z|$4C=Oh3l;_c`7dWB_;wHc7L zH0N>yz&Kw+mhC(8ygNnfbAT=h0YEm@9r|)JFV)mXaY>&kCcOOT9w)>dydKVUw`O|U zJFnE`Jpgca1S}nL0T)mQX43AL7Jv=}e(()guHGGoY-~}ReyVm_jydnWzY~Et!Dv24N2fbuqda+=Ej#5+4>C^Ut?K_{G8JIOL^B)--Ka6pp;Q! zkf%>``K8$5Bh}AQlu)FW>}?IN1q!-@#Rw*ELZH4NtA&O&NcU*J;+y#u)DvyJ z1UfiOIl(kUuIcTC8BOiKAz;uKR;BKH`%PUN4+y_oiDs}j-y;schgX+5w&%in@W8LP z01>m)7x5M*z+P%~S0!#w{-YX79*U`}diz{54&QKPte0^iP*YG3D(tFPxMCl%`$xwp z9Bcf@Sw{$JSDSP17GQd=ncX~>;*1nrY?0*@#iVQ}!b^kPnHW_Rd2{2w52ltVN5n`- z#%WmU2I=EE+`HUmY75evgVk=a$Q8vehGW$g6L#KK02yL+6FgUq?;i(bDZv{f6H$OV zd10Y}iM}9$chtRYOHeG@@VCib;fOEuW{>9&mYW1`Z1nn=yym#A&J-U+Ut!r0y#mnL z`oHX2`~g;&YQO+~&0mSNUv)JCnEEv-PXYVQG}BO@;z2yHu2T2=3LvUFf5ju`z=Ep? z7<|ZUH~*5eQbvC@T?J{*bn~-3P4B$PI(H}XH;`o*eG0HrrKI;}I`=~97~XL=H+D;E zJa7TE?t4g9Egx{2D?jUKdqSL%1Rwe$S=zglK&jt^L=-&~7PJgsEmdf)6B=~NeUuy5 zWixEBTTAVu1vZ?8XYM?rTpHrXDz}+#Dj&WI0J7w0ay6MnoE*Z~;ZJX!q8&Y*hkYB30?9$<$f8g#ge)cl3r?nl*Wt?>$`2`V5Tye z+_v98Dq27iWc)|2CY8r5vyunNK;@B9t1@<1o_4T3x71CyzL!523%msF84cpFlM9!2 zM22G^Dc4z{gg2G4V=Cp?Y^Z|^W58H#Jlfr(M7GS#QjN2oHy5GX|KkOHA4{H<$iA$n z#D!|N@yYZ+C4ZJ06#bqNrjJoJn@!F0uw&PtljZb!02tb1J=(ho`jZ#* zyXAPmSY=DH7NQY$Pg3_~1$lH;uU7z7m9mnTiE?m>aN(76vjeYZ^ zBIo24-h+qDNwilVbE=ICkV7*Tf-m$+dO)EJN5c-(o$c(a-u9guHLl@h&|NhUYt2<; z&f)c=d0IYfLt6Yb;?dr+=lW(sE+B_s8pNn%kA^4&3hkUpw7wlq=4_5Yfd0yQhyt~e zBjF^ojPs>#!A3*=m>Y&pe~j;DOhBLU>eg6S5kf`rGyy+Fqpup005Bj5tF4&EUv;eS z?d4C|{08n){*C$x1UYvP z!|3M3gB>b0biBj|iJIX!GGLR*C2mO@J-uodgo#8kofyq1Y(-4(IRczkYB2Sl!nbz` zwyY($GuMj;4`_RT7mjx+QB9mxuGJ<$iez&t878w9o?u&ZS7Zb97U6zvnFvB}sbOJg zsYXilRmat7_cpyE=m{Bgign9UqWx-b!NCa5^`2N88cpA*ahh5Hw2g?&tznqjEp^F0$rfY3@2~yiV+34Cc1BP z(Ol&2yMZ^t^xD^hUMNQNGVv?jcaMUcqfN(oqu7!HysbtxtQEW9w{xyD!0hRZj7b3u z-7Lj>t)0EMP2zSp-b8k;x3$!O4SKv@F(Ivr;aBZ=Wyjbc)AU$Jr|ymNu)1m* zvwiIk+7Uo0i<`E|SfffllY#EtcSCi*kg=V4f*+rB8pkg3v0r{J^-jEySgR9e>wrklXRg1$#Ph`VsqNXNcEsosN;NV0&->$S?dm zr4h*?&tIJ3HDE;-X%YzDIjAWqOodIzK34S(s6ZP+x~A)~ch(_>k6xZ$l!6j;R=-*=|C^X;ulyo*r`R zSdL3dRWk`}!^h1@J|5J*dr$kB2wApTj!}UFx}y`%Yc@Y$8n{l%;tQ08K6RX7G4$h| zOnyF^q{1_OK=n3H5<$%P89B}m6Gsp@vY~bd8qN&R9VEjS&ko_Lkb&9{{z@j+E*De^dj;Vt0c0=<_jbJY40|E5MtbuGy+j@wMEa++3w({;L3M!*4B}^hw%q07`i% z^x@?tYXC&a?d5ke+#%k#t9LLS*!}iM@jVWqbR%JO^3@{rtTEXMIKh#5-t3Hpr{kZ? z7px6hnh@KsGUYwMy8~K^-?jW3C3AtjW6`#;4+-U5;>f<7c> zf$^gYX!l&r6m9$hg!QbU!RVkTPIX4E$+c)a&CGdcZD<;o#84xLAl@3d*u8g4XDL~u zP~%!5m0eT*_G~NF=lIpST4vW0zw7#6fnsF~{6zD3*h||yq|*>iWdvAGeKc#oy=T{= zm=Ertm5M(#dvl7q%|0AGsP8%W$|AY}rm4xL+G$m+?^4NAcItX0$eDS zoVRSVgyM+(GH|udWZPCpP&ETBn_^;594-Y$+$^~I@QGX|th!`rdUUV2vx$TJdJOzO z7m~GylXAQOj9Hg8+J(j1V0qz_7jR8AYlUhA{~xj*^G`c^l&N z!yP{Svu#U1fOIHL)^Hm?!0x9gSU%BiAy+*qyHMI-;RZ{)pf-((&$`@)?Od<>v=uwsEr*{&C}Q4U=xC;@1aOiMfR2wV3sA)C zTLYZaf5ooxZK7ttofIaflfLSV9S$W ze#uEN+2`V`gZLZyltBx?;^^;NdUf&Vp}*U1X=fH(>gK_q0AjzjG4)~*>muU!vMj{p z9PO&rIO^h(HuN6A_#>3BFc}dI+qQ39{PXOqx!35b$I#%al7V%R7@mHbdR;u#45kj;0^-l#tY z>^}7AK*#w`uaFu}=NI;xfrNY5=~hz=HrGGFv=LB6NG1l4hxYU@3?>$Qb((`l480OM zPZTsbNqdQA`NBxplSGN(DtrxCRb@*idV1qpNv)w3Yzf)b0@o$W)1Hoxa3Ew2h4RuD zV|9A3CABwZ;41+H9Uno0&;3WVx+l1i;8v8Zo{?_IiGo71Mob&+ z^F;N-YB{4l5PPdcF3m zZ*S-Do!AhLx)yT(J}Io>yzidKXGa>H#H$x1!FO5sint+nj?xPu(r|`#X;8p# z(urztMyuzZYwO;DT zG-9Vfx6Poq&Sj18n*besXs~4}{^vz3=m^Fhc&Fiq%vstCXAgDuz&0C{c(=c$ho@u9 zFGk=!3u$DPjD>maq?vMy@02t6!9#7L{l;_$xhjwD>~bno{M*|sy7Q4#cuzMqXZV#* zJAmfKL4MnfW)4x#($4-?l$uL~uC;puY;cB)BDc(Ql_r;d-5s*BJC8|H7y=%Zd>b;> z&E>`Av6%G=y5AzTG*2gJdLzSg4;S2pj3EHf8FNDv0UUs7#z{+(0ng{-t7XRkN!siC zDLIeCUx~_Jwc$<35kKB^=Ms4RuV{D}m|MU*UXc)RRk|4Dk*D&>)8kvE3k<{Fv+ZIp zGM6$--oV(=V(7ROf3?I7?<1p9E4R)5&llTDCn~(7-(IqV#W7t*HNOD$D8zkh&(hD6 zirp$IoyggyEvP>HsA&QrR`u6EetRyM>(wP zq#&kGtHWHqg=v@Qgi?C$QQhVnJ*iNb2TU0}*E` zJO22|*J|Wr{EOE8(e$+vw~`jISHKeRud+=(`cyTP!I=mU5lY^L1$cf8No#U1VwiL;8`!ZPc1a zbtHU-2XAF%S;|Hrhz?-JM?SXwLXKLvZArC$VL56etZCTYW8(t{n3aT7R^iGTaofQ= zf=j3?{-9)~8^r%!H0#{u@N43f$_rdQ!wK}mCk<~x+l27zy>>nYcR-@Ylj><_?>p5n z3ZMedUY5GGVP?-YbGxIG!U#9xPo0pV_AD-od*=2uifymoK4?z#aHsrKT&Y)*+`$N> zv$9kX6F{U=`ZuI^IY9cx$#;W5KJYoPsW2G|tk`lbp1y(B>1hCPQDfOpk>op1lMW0x(!lo96Nr(m$ricE%}tuCW1u7V zFS$&H9)IqBO8n=p-F(wE1UO#^iE`_PQdm`Tdb3Tr==lt#Ol+4_%V+U_GW|`ZgHe!d zT)iM4RA;@uTd=5`l#u2>prO#VVuFl39CRUN{mee_U?|(svf`o-t8z{c>GP>K>z)A%Vi}6u;YNy=vTU<69Y2 zG-l5M>BSEA09Ov&y-hm#HJ(-=QmagJF!%6;1u}x9NjK7zDnkx(T$**YMMlHA&v8u0 zWPrg1Wqr+4y}l@|X_qZoy{HA3EUh3Gg!Q^oHT6-7ZrR`y2LH_Gc~bpbMrE-QKa!!x zB89%F*v+qwD);B*njU-hX-GDQ-18NVp`U}^9H}2J&mA$Dcw%4|eytd%>p;7wZSL$6)D$IkJ|v;Ph!t?bEXYaSg7T>XLwPM58G?YDFxEiuYfAeW0C zY>%HumGDTd$d2w*#PSYn&bd z3^MQ!X!*{w-CIRw0sU+}U|hgb&&gf!Cpz%-WaAKv7Z* z?LlLv=EJmdGdx$^B`dcC^|EfQ7@CReYMd)DUW>VACyO^H}Wj-;vNR+L918gPhidVS87;y-$E+Nz87uP9lu z3fOe;f+XP7x|(>5=CF6Wlq_*7n_u-HU4%Fc{QmNo?D+6nK}ebJAlCx~4wsGNs%g^P z*b3TOKeLe~p74=~gJl2o&>3O_3SgP3!Hc_d-?Guh={lxV9Bs) zbc|#+_aCD-C|O7HoF$9YnR5Q1JIwM+5%9vFZ&+n{zoL^nYrKbF+myJvfB2j5MBn-{ z_?!nnTJD&3fU+){-oXE*Wj@DM_MllA2EY8bF)8Wute(64Sa%8CVK_PW*hSMyV<*p2 zGVkZ{S{}nBpYLrEJWaWi_;cB;)5DI**L3ophx#VSmZmbbr5l&H%64zROj8DO=fPOe zm8S*Io-KrvRgVVf%pu23q#QeJ=FVlKhq^ERb@Mkov_JXe>T~7c_r71eto?^`0`8d-qxBE@@KrhlD2q!K-L0*3hVWa z2S3qfu7a@fMw@f)(=Zd<0Vl}Fz=hI(+TdiJHe36Sp1~Rs!Pg1QiYnl_@-aDEQex%W zAf#QbPR^hCZRPiF1M0v;1TL>aT-4HI6+38CY^>>K;Qi{z%%6VmuaAV2i=180Rs47* zhu(+#hRy2+Ir3xapETpnD`Rj7q?!Mw@FoM7hEB(#rmhzg(Vns%aCS9uOH2qD_TrFuTvj$#W+7+~2P_p>zV5~$4Fd1cD>wVh7c7FuOo%L7u;y9@&A7GE| zDuU&~{u=jZI2ev%#TE!8nANHL6w<~G?nvo#&{A*Y=~3Sdq+?6`QhOA#2JxtGaglc3 z7?$O+tN*GabIa*4@iV5$cd)`ISRY|iS!r&(vz0_r)!U!BdhlTz-m+3R|Ib^-M7)JC zKj%t(-mP>T87YXtD&$AkaSxubC^bH}>24~!VZ3|3&Z1YeSa^<$o7N>r+Zo<`uwe*; zZ%H|9kvH!A$R}|fHgR_7EyAvPF&*s#?qUhU0_jgxHzukF8D3?_vOdz}g7eXGKlpJy zRdPL;+x+RCY_!nA3mGMM*4ggV*3%EGuG>daS&+!mggXpQyff0;_Bi^J{Z}KAoyhl( zbozDD&%A!PdOJqLzY5HGjmRO6MEE(m|ESFKOVJf7Qo^QsxxRohb)y$Qj&(crCw`X6 zhveP$RF*UJU^#Lop^`UH>A*w2en16Nh8_ckTK^fcTV*`wnQi(n3JZ!tUJEhI+PG+1 zqduYS4`$aP5b^vHdrgPN?p3DL>jbpfvZ47-!_rUPEG|o8(#y3D=NS+>?XfUT_QXi? zot5k_AQuIOg8lXIvj$6?h2e>-q4??G#-%y!@8;?#2Q5nU8Urhl6A{};&G2vLWPv@g<9#G}O}p27c`P<8gGH7oI=}}@$bt|1 z@e~ZN>4lHv2L|ky%(3v{w`2+3m{AIc(%%2CmyR_Q`6=H5xKjiflbXW(Qu(^sJ{(k^ zRh%(XraHL^IaYa52ciYI75p#F-VgjZ1yQD~`m-qua~NPb{7g!1acQD~w>v2|90lvG zCropx5-ev-XLNo6u2x`RBZx3ihUmtjP7MPJH=C*sOOP2{1#;b}=k7?2H9L}wGj$-J zJ4>rs9;unWu?4{ua=6LR zXCb&mH$oW$vvl7k$ea;qGtshpo$XaoT{@V8gC&H>&m ztS!GO=0tU5XRGYE>z@M%|NerXfmR5Cuxv`b-U9KMJor;;Hb?)|_0LPpwZr3RKN*ij zxn&FFpvXH$!tkTdgGcze^uc0iNK<~9cHYVJxBS07SlacE#QgHGquG9H(YPwChx z0pxSfj-zOd6#_lVM0jMuQH_~Err>~D2C8gCOIuDq@;$HDT{N&TgeQg14U1uC8vd3- zb@0aYw513qKfAO)B`w=z$Glbm$PnVB7l0NY$?|c5XM#WAZd(xoo3h2{vu`iUA^RFJ(-X0blB*5pyeX4 z^71LTy;?`q*vij}^E5;FS5f6YkvWM$9jzpBV*)Kjfai$@3H=n^RBC{AUK2I+7@y!k z-~cf4ow8jCu8-*LoUOpR^Z8h;_$JLP)!cD5ucTJ6C5#t8ZCO@XoQfS<5t|jU#L}`2 z&9QzH*2PT6?VIod#l)Npm0l`DAvuWn2eS})FT>!b4yK8=ept4^rKk>$m{?FK)p#zR z`{&ZR_f*8&>O#Q{{ADJhO$^(UxtvD)gJ2?e{evy(XII^(>Vj~x0W*iR001cxFmfh~ z`sAEcx;$v18UJabf3+dDJ$pD$&#~*kn3Rw|7pD+R!pOdef<@x!R>kuZSON-NYzlU{Mfk4$ydpgr*Qvt8f z+9g#MvSgGSjJCv@bPX955Iz*ygTqK08f425i?}o({*k1K@w*IP0*S55+kJ9y%yfxF zC~E4fP05O5*Ja*)BpthsdBSurQ}agly%CO`K=6i~Yky_P;PnquZZ?e#bx#lMG?qZN z(LQ5J$_2=!^7V5`EBTHWnd@a5e(p&yAwxP}n1%R`Al{TwDal8C123&Xqj;0At{Rma zPiHtM7<-UKkUx#T$Bu21qvf2;!#@S1l$LR2D9#wze*x4I>bEY(I>oL&^b1_=>R^S5 z1tG0C+@GWDFt4*ygVc6i{m*n7m^M8+m1&`2E$^CU`b>J#8+X%F4Q$4y7X);rJ-JlK zx+{Aa#=GfluB1n-?{!(fYTe$w?S&btT(9faSx5QhQA)Z{6Ds6kO0V-I$bvpBP~9^ypYwqI0EV|9JNpXl@VJj0i!?aPsg;aK4q#>i>Hwgcr zMU?G&;I@?3$^5SKzpNQb1vaXp&WQO39@`!3HPtID4HgZ;_!u`g^L^OC(bZi7;DJ9K ziW2PLU=uJ^=7;EPyqnYNV@VhVd$YyGZ{O6V4i{(-|h0E8m5CmrmolJGBN}eZsiyv^BBVY?5X|jFKQJk zVkVBZa1Yf^j~EcMz%)$dHzqvjp0RpsbL$=Gs-Z_v|Np+r;2rwm*1CKrD=SyobHGR*kLnFG zegkT5u+9)U7KnxoM0Yvr;D{RQ-OM6RnL z?0;7(_>4!Gle4JVfVJ>iHd#YwX+g-TBS2+pR*oLBScyVi* zzBa3(JRx&5DRx^T4Vay;lE-HpxRDs_)&T!xquO%9u+d&Ig}GN%rlrw z*85iX@9X%ZD-(Q(IyIy_7@f5T(qgtzdz)@1|bS_uX(U(D?OL0ttk4*4{Y*h2INngw{7yYN)dBXy~#=b zyjVsvJ+#1nL&a*MDNlD3drs5LF0G{a$d0#ULaVPSMlIi0(6|a=b69Pzdr+$$pi$N! zcO_6z&%5R-!7b9UcupY-##S&Wgg?iO{qGO|7&{o{w2|+;B>?!??{4vs4%OLYl{y;g zLeS^ic89j3x9wuSG7>(#%L}50*W+S;POo~?6D>8MAxKKNBJLwF-n($<5-b4uhT%zF}5W}6Os$BJdpb6t*|GY>pYS`%4>qpJ( zxq4uQ(3;)SeCjiSfsM8KDZ1gTpTrNBf1P!THwfW9k#uI5WW7npzVl5G@kJ4i{+O)+ z2g)hf!or4rZ-u1zc$sAdJbm5#B?yzWUc9?=|^`& z@ZSYzEHMA@bR6dS`LdY65V(RZP~IgN&ZR5DG{a)wmz?cS#H@uTBCvpS(?|-;N2OE6xKNEQV{V*kHFMM7g0L2jUmitY&t!z#bfeaM_rwT)JWJ@ehvc zQyw=`KDGU)*7df+2;SXOl(^VnS0Irzp$Q;iaEw;PzsO-t@~+ ztzfgbcA3FZGXH*wN$D&8U!<^%g8~cTpC$@PtdtV)9u9;}(I~RQ@}!6lXZ*v{Sv2rA z0GTJ8Ssg9AoB%MMi)A#au-KZtc(RPrf(#jo{K z@AP!*G=CcHo1q zb1gnnMr3OQ3Zac??EUKIw?Qs{HmeC$hkL46b$b)k>l-~kn9KF`S*G!>NV5$@uOVsw zM8|GI^KJw&)Z}4D@&rprS%d=%!_y~Eqi`F~QC0eqBhuJuf$QE(qguXwa%sHkpwIx- zqQ;FL*he0NOz4r>fWH|Qg^%~p`OLDQTzr&2xNVxZ6)CQcx*2Pv9zc#jgH+-OKM|mk z!#s1c03c^gZ2NzIyX-?6hB$sivF*0@gD$1!50O=Od!uhhp?(MA!cDy}rwPW!KTrV$ z9aqkNP@ZZ{j5+F6I1p;(AadIHfeHG8!8MWHEQT83uAVj#k&nKkOTJq_m0!q~-G|-6 zd3`Z>5`UcL63EwQZ!kVpj@q!(uIGBL2)+MHt;p9$a9|anIlQPuT^|NMyc9nZW5tbE zSc&g-;DDZ&9V`emuS%SESPZP85%&ngVr2fmMr8fcQx&jz7%F@D`cWKMCLyvX#m*rW zT-njPH8utrdgS?AC1*Xwt-QGkuoovQp6`RRJ5a&(ewPC%-e^&3@0a`HZp6B|wqj*H zeS&FM886<>><UKArZQrJcK>||53tH)nyaXj>uLk>JiEV+l%6U(3Llbl*`T!p2^l8*A(z1z+T{o= z$kx$yFqZ-MdJAEr*9N?NIr#Peza~GMqSne@Z$5tjK$mh1oKJo-n^DUualgZh>)?vZ zZ=h{<$6s0pY#+uWa$kp`m`>Tgl0lLMVH)+I`DgpJ11mMZRJ@kBTJ&46xiuc#enisQ&QtyFGfL()kF zx(BmdH_!V~6nhBwFx)XS1}4|Bj(-~@R*l`K4+tJD-RA(-l9W^e8J(mh^0Wsw?dr9< zL4W_G`0#FL2&>=XIOnJbuwmR!*hCJP4Gn680(zX_>H4G1@myY7r}V}d8-xRGnzjr> zJMr#HnSt^c$A%_jm))^(KZ^!JIIBVQZ-R(`rN@5=?eOxSHo#YDy~CWWBxIPUzfL%S zal>_nfc9Zv9EoN!+!5TI{jihsVfX*fl6+*hNdRF4D>~&fTv=@Dl(>GZmpNhO!z079 z%lqt>^0cctHW8oSA@=9nE62BL8KECwJIgi)|4A_VL7G)iH z9*SuibPuw66V@xze9f%dn2M#K_z8NXJ75Zqu=y=&f|+*N$N|v2wSy=>p;rd~9&rYC z9{-ve{_)`w7lv^d9$xlvz|MC_6GkY`Lu=6m$qvF8{cnbf0Y>gYN_M_|y%gA$*gXC0 zhNCQo%3ipnaAv;Ubhxo^UcL#9XRrQeyB<@jU9|Y!R}f800~iM-Qc&M%;e*y8{KMOWwS_q4ay& zbXHnvLU(&f%64cz?3?H=eH_JQ;gIMBGTDo_7(6KGD3lTynf(-RGBBHT(%8JY&+`4N zI#^}-&3z|D-mEK}TF-gHL%&}U)RlVlLXb^JDSf*N3(s-fLXB6MwjEVf<(5@pO$p}@ z^-Rsp&3CY4CyVAgmOI(xYHhB+Hf$=)-mlqF>}>p5PPsC}9Q15#ZvAKv$$)SBV?jLL z{)BEPJVRH5&K}>!-~DV(zsWyMHP?JXbYIG`{?zdRx<+AOlOGqC$usHOGv(bg=biOy z>YUK{02j+0G4T?<*Y*PE_;$S6VQ+nels2uoSTvnR?g|7R9 zlfxWv-^eJclbMt2a}jcLvzi#{@vbXY@oy+V53hb8jfKNn{&0ayDK1>-w6C#UB7D6@ zWH)SXa!)<%eUFns%qg36OV}BZ6wto~vZB~Y2fgU#g@RAj=pi!Y=F#3ipQO=$Ebn1X zd5^Rw7F_P7p?8)}u;WiAINy9t+^lt{vcm7F0W?H9a&BkqL5cIksx&u73(ZR}Vn* zjQEQeE(DK0_uLjOydPHhByGnn?Jx|Q5wOGTINyYIL%HpUw*E?rRyw}>S)7g#SZ^&k zxmtiL>N`02dE98jEwmmm=eh7SAY=-E$u%W{;D44w+wyDV%>UL?{v5+OXa-BM4#9C2 z1gvBy~I8E$nt&vO6N?kQ}##P%YFP#;`fFu}#dzPQHuJ@AuyxkJt6O_Ikfg z&*$rXJ+AjPYy~R>Mu^&^{3XbC(~fB^e}-vbVD@wRxHx9hg8bWCLH<0(xC?5~te&f- zqN4J$q5XYy8qquNfk|HB0F69Ez#&IZ9_xU*t)upJxf^0~O-+3lbOD2ZS_HXtp!TqN zlYI_mT}2vf`yg>Ilr%z*>fHFGxpgAfBXS4%eJJ5J`X_-}+PzNNh(klu5o6-Iq{X&Z z)*d@gey3$l`k?#dIT7q_Y*i#UlhPd&p&D)0YE?X?8)CH^M4D}Fc!v@{I_qmgmU@2X zza_jdEzW%FQvij;<~RYAKHYfdCS@O_l<};Iys)fI?%*{LHZ?y)z?R=5dfrUDV=WEw zAFY#CZe1X285dv{V99ObZuJ~}c#`tWN+gBjI@+&lQE8H2G{SFRq#P%#?|!4{t)9^2 zzZR%+TRJl+lRO1fU2#n{gFyddfe0P?V#Bd~DQA45ueAY(S+;)Y9x4!b>*~;Ed7~fN zP2YQAP5>(zjs>9b20)C=?mo9DV`AG%@CTwJmcnn_)Er}1(B=g=VP&4xKVF;gQ__+t zGYeUyTmlX1{3`f-!*yDAV;QznmXP}x3Kac zd0sCX7x7VmY!V}18Y|-#+-L5st=$|jMrH|v1lIM_&0|V~RTK-bo zp1m}u#d(j-i-c3vwg{%w&p;>Si`S(e6iMU#r-MJ`+oSr89IyH=VXWrb2a|TRY1=Te zsxPYBXvS=dNQ?quv>s?)5rsh)#a_`ZVJ#hZhFVMF$$$x1_DoZx2sY2Dco7kPni6tI zz${tHA%izB3?G-d4)h$ZlVK0*WrL_!3qY>3@m`c;y`(#?7!Cf$1*M(;p0hXXU>S4q z9{6B{{3+3lF?8zb8@-`u5gXXkF(5v=FA!9FKD`{X;H4h5Zr^N-Dl%iL<{m{&2xC0| z(H?tWI5U>8f314jc)!z(sH0y6+@E#Sq9on4)84mPKeQXEK5;q!9-$*Id9-o6Jm4rM z1dP4j;P}{Q@wv252gvo!YwvkvbsiJqhzUVpTuN8HS0ti^Yuwo9;GW$mH-}U~P}nU= zK6k(VP9bB5dPgN+(BQYqWA4QkDczs)tocPyNF~Lh+PBKpl4%$fpfKAr?!`Wm<%<}w zKvx}OuanWzOu2P8Ci7vuOzZtyZF}2JVl;xnP5SI(+X zYpmg-*8-&i2;RKt*UXCE@5#)F-yWF7nURtU9)cW{dg3pXq@lU6L-@pQAf!=~u zLjL7BTy=#u_=~7F?gZBx+-enmh=&W8{ANwbXSFpVD!q#l+RR>OD-VRhISd#!>!{Nu z75}qR=tn~?&?|I!BpR*Qy}HWd5GTguR8p?K+M@1__*#V`Qy7sPtz(%LG6*H@r;Bes zhCe7dvN(S!I=c4)sGfLvE^(*sJ#8B(q5h(JKSpHzd7(QJ_|SKgl-IN#r3eY%^^kWJ zkvS`Ul@p6({Kif$yWhQ}z0rJZQrsFjl^kRxFKXS%5YIclXpY2Y3+6*Y+U{6&u%KU? zq~ld~#BNCd0Rvq_4n9ipf5vba$dNW4u~_yxyEy-01nkdbe!p0r`n!&FsHkv0X7hs2 ziRqOXpLG=Ag|)TWuWO%5FXOEAIe(g*O3R)9js0)?Tp$`Vy#wC8fJV@F6vuu&e`6Y> zOpW!GB&(>~tP7w5h3%V)gWw`&-C>l-1~*2V0|gX!n%MVO{0NJTTfDpU;Ed)>X3(eX z>Bj<~cT9Z|_8JM8rczZmBB7)~ZpMieshs2&_yWIfKpva8PmYsw;~M=vrAgBYg8(i z!kCj94XD?#p|O(ELTXyBN9=yb72c6@X5z7&9A>d4Y^%7(1gChnzSVep^FtuNDSCQM z6?ZgFOpG&GbRyk~0@YZ4#4HR~*kJoG!3d3+L)5)&ybd;#0pu5TxZdpjPjV0BcYbGt3m2-a zYlafV_1B?SQRX-aCwx(xlU^H#o)+uK1GXf2Ys;j{6F4(FY?Hk)vNEYKQ)-nr3hCd+ z^u!MVeBc;{_u%Dg58nMO62@1|?5MH;|48atPY&h0eow>XKleyUs+}LU^v{86L@>Uc zD*;m>5-)39JE6nMIizCB8#p%Vx!0pK@L(^3VA!4Poi2K8dNScoCQXSdVrl9*`8`qh zD*JgPSpxw>C$E`r9cHytsaNP#-9y)HUY}9}-+V(AEN+uGvK*)Rqcl7x?kROGRUEfA z@?G*K@pztKfT|Fjfc;HaxAg9wzy>7<9@eeYQGG4Znw404 z-(HaTrFJxeR~PXcJLTkO`}5dB;46E_*)%%Ej&6GTPfwL?&9LfaZgan zbh|`~#tE-N?8B_qjQ+tdzOOTQzz?nL-P+3D4akb;r77+%?~?$NqrBYAv!P|n#5JBl^bZG%?X9X zaTRF^_SIE6lUBxk*JHf@OfR=VDKy}E8gj!_Q* z?G?)&rV>LnN%#_`uJYCC&ciFb;N|!ecWZ3xsrtf@P2!#+9ZV^yZG6YOcRz9fuWa!S zT%J_nMmSXu^$`S+l>I}n8TmfDYr)#u*sMXCBQ-+^8pl>=srShx4TrZ;E>%VNpj?BE<}Nbk#mM3d0I) z`?ehFvW@K%uEfoAC}8h{Vj>`s_I6)YFoOIS-Q7QU?;)2;02|H@zfHvkvI9|fe_oeV zzu*@E&PmIi2+RJpd`CFEb}cw$%R+*Cl0cKIgJO;RSzCw+(wfRtnBPMVPKx_zJd*N5 z^yJgEAAzqwCK$GLh!|D8?Jtry0}OEJf_zu`4>E?bTyKa-1&Q3gAvzS zRkg`)l=yko>m$hhP4sIr5j+qbXN8>gs0_Rq-h(EoRx8S89FM!H3??}J!D3`_dy~H>{>|Qu2)a?t6VqEBOWp+L!XruZ@B5RNJ zG2ddPuXv9!hc?LkQaUpEb#@rPaZ0B%h%};^7u)K?9UgKWv@J3y~Xy~%QA zptydbPKSJv-dLSI60$`cx=#o`#Gxxm<>v+N8w4rPRt^B8l3C~>!|V-{BjSp+(N0pX zoO&e~?orvnKUs1fd)u7h29+J_>MvbR5uwQU{Lnq8KyzD!QAgS{I3|ObY`E8)3PAzd zm@$>Ue=2|2s%QYzYZGbOl}UVm#lx)tAnw7(O0fU05xwZuSH2)Ml>on!)kMBoTD422 zri7Nt??rt%nwUDeM4mM0o%&u!XN;a?pKIkCq4EA zUJ?Alkp9%6H|XRplN48O;HU+h>=@lH-^xA(@_&toN&hB8TKj7Rx;F9lx+a;&-uBrR z0GjH_TdC@4%~G zH1hl%0~qN)YJI$_D=fgFB)26G>B-Oi`N^19fV2tS=ewElH-NsW5+i3Nq>kjQCalqi zG;Iu?nAsvX$VySx@i}^HU|Hq*)~lK>E;6Gr(EBtYBTm5B5=SYBvDc-yirtIB|2YBI z%})=K`wHqDx>=ysj&s3+^0WOf0BCfEP2{(?)$e(yI%}S3Z!i z#vjmJZlePOtpBq95!6nS2{~2tawoso zUx!R33i_S(U4O=V&&znR)x}iv_qJwx>ur;(9JVU=Fd&ueoh+i!cg-rX2jvXq$_e9I z{wE#gp0@C~TM_*cGBKJvo-zNYnGJ~Ti?C%j+h^D_{R+gxRRIX){H=~yg~>9XHLcUy zgGNUY!xv)Fns*b7!TKEWhDF-0qD3zg@McM?yS=1$7{pnlD(HU6Yo3X8J;5xQmR?!v zP_5u~j@sdeeC_8Fi+X)W1?3J)9UjcrMa;=J;K5xpJO!$1)2oVB%8r>`VIBzmAiump zM+6g=%Yv)pyP<7G$}z#KnzU)PwZ7Vob)W+;(cO(hhCSS_%OW9yJxjlJJBJuzyF=xp zJROW|R!csvw8Znq7jUJ{B291c4Vr{u;G)u_PGemg9J>+{u`^>j%)M=Iz{6t3q?eh2 zP--o53-f504$vtxwSAuPw_NpX-@LG(N4#kv(Q2}bWZI>_So&zCrM^pt;aHY_`4I=3 z$mO2m!w>|J^IP}z?yK^0JH^$39`N42eCnxsTV)e-+s&=K)kH3+D@0rpJ1*%_raciA zkz+0NzToA7=3?V)|+b zp3l>8EaT^(fLl%SbdAwmeL^Ex=3e*I8;w z{BE!ORu{Akq2H5!zk>NaVU95jzotP(8x3;M4?r_e$zq|!C=n@4jj`&Mf8cJ9nqR!W}spWr( ze-Ak^-oWA!l2fN$}cgvF)}m!TIsF%`fD2(d@j##_%+C%E$;Q4RqOozGElFP zeEF$rI3E>56#r30y%w*cvdIo|!P2!#ubRGqN6%R^-31*!qvhScs=~O4*jJ|d`pX#vV&unU7#fQMG zX=PD$T9A6TQLDA7+-dCZ7?+BAqk+{_d+|(1{z<#74E|+poHQcMYC`$XY+cSF+{Dud zcfE@>=Hgvs<)s3F`z5AiK$ce1<>z!IC|jv`<{ zeizkqDqVaLtdxjWkYN*v35UO9i93H{HF7D7CM$@1Dli^bk|s3kMC#=m4)ohv)Zb^= zYs)K%2Inp6pF5eHBlSKZJ?Qu7moF`wFaK*5F>_@wa^#hk$G;t{7sz&U|Fl3)3?hJ` z`1h0L_3UTJAE*0~iVp^TSZU*Y;U8R@O?#gc3FolnV@+jXTyiPggDbSe=#uH}C@L}Y z4ey=fkeq00}l|!Va3R4d3V_i6^w&as7Dhle~DpO5M;v{(|b3{w6LPRUzKNqYf zzazjbY~e@#=aYwENRcTlith%?r9AivQUFwqc6V$GWo;Y#p;Cf|d1>ewm zDGY@~TpIPuS`23En_DWE$+##yXAfWP!^yE}Jay2A!f0_>3@UgSl70Ww54Vc@ zwTtx?T>&TQfZxBS4WV6zYxQPGhsQ{V|NpqJa{j&&3sQvRewO1qH2G4WgwyVHU2WLu z!qJ?y!|W?ACT!?TQ{PhapAufUdvW*~z`}F?_xQ0b;xtwCu0~`xuW%FAc#LG)3*Be? zQzO1gxjU?~`IE1<68A&z- zOJ3DbpdiHMJG{+9hW&k&p=lh|`~&Mk?|8;w9-fh?~I}4a#_H;N&fOmWAZM9NG8fxMi z`GWXocyYk%Mmawg<1djxzwt^_rC=PAm!WvQk%3sCFnA*!afOX!y>r;U(Z8!!<5YUb zYCi{D^VL#vwi>)nsq~;&ZOQ%VoSN<>mufhtmN33pxMAGoz-o8u`dUvks}4?f**`#d ztv?ZN0i(z1SVrHaBF{Oyu%U%hP|Ftgqp!ruAM(x0OnR7G1EUw|40aralUu6y1;b`M zdVQCNC@%CfGr|vkU|@bB;Unxr3d^)b-=pwOpH#4mK)M%>={ts2YODAk4zJ<5ZxrPx zeU+=+G_Gn=Y-8=`_}hl6c;EexJnyng2~%VE!K$l1IcI;3KAQ*E;7VR1HS-lhryAhH zK{e?NaOD1Ab^Vv6_qQ(=o{J>uN#A1O&MVvC=0=8KOS~so!szX){u!S1TKF{+*U=`s*f1?4hC#uhHPB(GCmYqVV#riI$tfDOFtJWiP~ zOcloAJWP4Izbj=QofpxN<@BH7P1fVs2O>L(0|Y#^=b}=g@=+W0eLM%hZ6+@6tNd=j zKf-t%R(amDQt~jw7eBQ8fyln)J~z9KZ#%smBKQZHhylG>y6{U=!`)PQx(_{58PG{DqpBJhLoAGw2JR$?aJkHbo^)%uvd;%6zFTTj69o1A;!D4 zi&CoM#MOdCALOjT<&5pTyuMLBCIJ|ZQLoY^e&9cq;*TlrDMDj)E+Sj>()jrX@k_xm z2r2x;Ji%=B?*AT-xQL<>0rsfJX3?SB`1!OGu+Ph0kpBO9bO-kU1Q?d92x5(K&Q=o~ zUJ&(lS3>X)P&fBB_l;1EvH7%PdslQ1YeFpxJanuPO2lLYXB?^>l;1CU&U5t?=_ z?|6i*-NxT`&kprMZ3%=&ZuI??iUp~p+H|FueeCE2@6iVF^@a)^Ocha(D*Rntu>uIm zTLgquVU=N5bk6SbK#IzE}&PZ*w@|dakag}v&cTNQF%!gj*L_M;AK|fF7ELB5@)z3dnBVHQD2p^Kzz9*JjyXqvO^mEM$qLT}bM*v@xhF(h?R_}p k_raXWj`fv?Im>JM6Rn>$X}XY Date: Thu, 2 Apr 2026 04:45:09 -0700 Subject: [PATCH 26/44] 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 27/44] 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 28/44] 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 29/44] 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 30/44] 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 31/44] 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 32/44] 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 33/44] 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 34/44] 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 35/44] 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 36/44] 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 37/44] 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 38/44] 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 39/44] 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 40/44] 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 41/44] 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 42/44] 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 43/44] 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 44/44] 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",