Created
December 1, 2025 13:37
-
-
Save adde88/7293042b7fd3667ec7416f3d49a78d73 to your computer and use it in GitHub Desktop.
Ollama-HardLinker: Interactive PowerShell script to import GGUF models using NTFS Hard Links (Zero-Duplication of Models to Save Storage) on Windows 11.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @echo off | |
| :: Copyright (C) 2025 - Author: Andreas Nilsen | |
| :: Launcher for Ollama Symlink Optimizer | |
| :: Ensures script runs with Admin privileges | |
| TITLE Ollama Model Manager & Symlinker | |
| CLS | |
| :checkPrivileges | |
| NET FILE 1>NUL 2>NUL | |
| if '%errorlevel%' == '0' ( goto gotPrivileges ) else ( goto getPrivileges ) | |
| :getPrivileges | |
| if '%1'=='ELEV' (echo ELEV & shift /1 & goto gotPrivileges) | |
| ECHO. | |
| ECHO *************************************** | |
| ECHO Requesting Administrator Privileges... | |
| ECHO *************************************** | |
| setlocal DisableDelayedExpansion | |
| set "batchPath=%~0" | |
| setlocal EnableDelayedExpansion | |
| ECHO Set UAC = CreateObject^("Shell.Application"^) > "%temp%\OEgetPriv_ad.vbs" | |
| ECHO UAC.ShellExecute "!batchPath!", "ELEV", "", "runas", 1 >> "%temp%\OEgetPriv_ad.vbs" | |
| "%temp%\OEgetPriv_ad.vbs" | |
| exit /B | |
| :gotPrivileges | |
| setlocal & pushd . | |
| cd /d %~dp0 | |
| if exist "%temp%\OEgetPriv_ad.vbs" ( del "%temp%\OEgetPriv_ad.vbs" ) | |
| ECHO. | |
| ECHO Running PowerShell Script... | |
| PowerShell -NoProfile -ExecutionPolicy Bypass -File "Ollama-Symlinker.ps1" | |
| ECHO. | |
| ECHO Script finished. Press any key to exit. | |
| PAUSE >NUL |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .SYNOPSIS | |
| Interactive Ollama Model Creator & HardLink Optimizer | |
| .DESCRIPTION | |
| Scans for GGUF models and Modelfiles, creates Ollama models, | |
| and replaces storage-heavy blobs with Hard Links to save space. | |
| .NOTES | |
| Copyright © 2025 - Author: Andreas Nilsen | |
| Github: https://www.github.com/adde88 - [email protected] | |
| Date: 01.12.25 (Updated for HardLink support) | |
| #> | |
| # --- Configuration --- | |
| $BaseModelDir = "G:\LLM-MODELS\Models" | |
| $BaseModelfileDir = "G:\LLM-MODELS\Modelfiles" | |
| $OllamaBlobDir = "G:\LLM-MODELS\blobs" | |
| $TempModelfile = "$env:TEMP\temp_create_ollama.Modelfile" | |
| # --- Setup Console --- | |
| [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 | |
| Clear-Host | |
| Write-Host "==========================================================" -ForegroundColor Cyan | |
| Write-Host " Ollama Model Manager & HardLink Optimizer - v1.1" -ForegroundColor White | |
| Write-Host " Author: Andreas Nilsen (adde88)" -ForegroundColor DarkGray | |
| Write-Host "==========================================================" -ForegroundColor Cyan | |
| Write-Host "" | |
| # --- Helper Functions --- | |
| function Get-UserSelection { | |
| param($Items, $PromptText) | |
| Write-Host $PromptText -ForegroundColor Yellow | |
| for ($i = 0; $i -lt $Items.Count; $i++) { | |
| $filesize = "" | |
| if ($Items[$i] -is [System.IO.FileInfo]) { | |
| $gb = "{0:N2} GB" -f ($Items[$i].Length / 1GB) | |
| $filesize = " [$gb]" | |
| } | |
| Write-Host " [$($i+1)] $($Items[$i].Name)$filesize" -ForegroundColor Green | |
| } | |
| while ($true) { | |
| $selection = Read-Host "Select number (1-$($Items.Count))" | |
| if ($selection -match "^\d+$" -and [int]$selection -ge 1 -and [int]$selection -le $Items.Count) { | |
| return $Items[[int]$selection - 1] | |
| } | |
| Write-Host "Invalid selection. Try again." -ForegroundColor Red | |
| } | |
| } | |
| function Sanitize-Input { | |
| param($InputString) | |
| return $InputString -replace '[^a-zA-Z0-9\.\-_:]', '' | |
| } | |
| # --- Step 1: Find GGUF Models --- | |
| Write-Host "[1/5] Scanning for GGUF Models in: $BaseModelDir" -ForegroundColor Cyan | |
| if (-not (Test-Path $BaseModelDir)) { | |
| Write-Error "Model directory not found: $BaseModelDir" | |
| pause; exit | |
| } | |
| $ggufFiles = Get-ChildItem -Path $BaseModelDir -Recurse -Filter "*.gguf" | Sort-Object Name | |
| if ($ggufFiles.Count -eq 0) { | |
| Write-Warning "No .gguf files found in $BaseModelDir" | |
| pause; exit | |
| } | |
| $selectedGguf = Get-UserSelection -Items $ggufFiles -PromptText "Select the GGUF Model file you want to use:" | |
| Write-Host "Selected Model: $($selectedGguf.FullName)" -ForegroundColor Magenta | |
| Write-Host "" | |
| # --- Step 2: Find Modelfile Templates --- | |
| Write-Host "[2/5] Scanning for Modelfile templates in: $BaseModelfileDir" -ForegroundColor Cyan | |
| if (-not (Test-Path $BaseModelfileDir)) { | |
| Write-Error "Modelfile directory not found: $BaseModelfileDir" | |
| pause; exit | |
| } | |
| $modelfiles = Get-ChildItem -Path $BaseModelfileDir -Filter "*Modelfile*" | Where-Object { $_.Extension -ne ".gguf" } | Sort-Object Name | |
| if ($modelfiles.Count -eq 0) { | |
| Write-Warning "No Modelfiles found in $BaseModelfileDir" | |
| pause; exit | |
| } | |
| $selectedModelfile = Get-UserSelection -Items $modelfiles -PromptText "Select the Modelfile template to use:" | |
| Write-Host "Selected Template: $($selectedModelfile.Name)" -ForegroundColor Magenta | |
| Write-Host "" | |
| # --- Step 3: Define Model Name --- | |
| Write-Host "[3/5] Configuration" -ForegroundColor Cyan | |
| $defaultName = $selectedModelfile.BaseName.ToLower().Replace(" ", "-") | |
| $modelNameInput = Read-Host "Enter name for new Ollama model (Default: $defaultName)" | |
| if ([string]::IsNullOrWhiteSpace($modelNameInput)) { | |
| $modelName = $defaultName | |
| } else { | |
| $modelName = Sanitize-Input -InputString $modelNameInput | |
| } | |
| Write-Host "Target Model Name: $modelName" -ForegroundColor Magenta | |
| Write-Host "" | |
| # --- Step 4: Construct Temporary Modelfile --- | |
| Write-Host "[4/5] Preparing Modelfile..." -ForegroundColor Cyan | |
| # Read template content | |
| $content = Get-Content -Path $selectedModelfile.FullName -Raw | |
| # Convert Windows path to Forward Slashes for Ollama/Go compatibility | |
| $ggufPath = $selectedGguf.FullName.Replace("\", "/") | |
| # Logic: Check if FROM exists. If so, replace it. If not, prepend it. | |
| if ($content -match "(?m)^FROM\s+.*") { | |
| $newContent = $content -replace "(?m)^FROM\s+.*", "FROM $ggufPath" | |
| } else { | |
| $newContent = "FROM $ggufPath`n" + $content | |
| } | |
| # Save temp file | |
| Set-Content -Path $TempModelfile -Value $newContent -Encoding UTF8 | |
| Write-Host "Temporary Modelfile generated with correct path." -ForegroundColor Green | |
| Write-Host "" | |
| # --- Step 5: Create & Optimize --- | |
| Write-Host "[5/5] Running 'ollama create' and monitoring for Hash..." -ForegroundColor Cyan | |
| Write-Host "Please wait. This may take a moment while Ollama processes the GGUF." -ForegroundColor Yellow | |
| # Run Ollama and redirect output to capture the hash | |
| $processInfo = New-Object System.Diagnostics.ProcessStartInfo | |
| $processInfo.FileName = "ollama" | |
| $processInfo.Arguments = "create $modelName -f ""$TempModelfile""" | |
| $processInfo.RedirectStandardOutput = $true | |
| $processInfo.RedirectStandardError = $true | |
| $processInfo.UseShellExecute = $false | |
| $processInfo.CreateNoWindow = $true | |
| $process = New-Object System.Diagnostics.Process | |
| $process.StartInfo = $processInfo | |
| $outputData = new-object System.Text.StringBuilder | |
| $errorData = new-object System.Text.StringBuilder | |
| # Event handlers to capture output in real-time | |
| $outputHandler = { param($sender, $e) if ($e.Data) { $null = $outputData.AppendLine($e.Data); Write-Host $e.Data } } | |
| $errorHandler = { param($sender, $e) if ($e.Data) { $null = $errorData.AppendLine($e.Data); Write-Host $e.Data -ForegroundColor DarkGray } } | |
| $process.add_OutputDataReceived($outputHandler) | |
| $process.add_ErrorDataReceived($errorHandler) | |
| $process.Start() | Out-Null | |
| $process.BeginOutputReadLine() | |
| $process.BeginErrorReadLine() | |
| $process.WaitForExit() | |
| # --- Analysis & HardLinking --- | |
| $fullLog = $outputData.ToString() + $errorData.ToString() | |
| # Look for the sha256 hash in the logs (Format: "copying file sha256:...") | |
| if ($fullLog -match "sha256:([a-f0-9]{64})") { | |
| $hash = $matches[1] | |
| Write-Host "`nModel Hash Detected: $hash" -ForegroundColor Green | |
| # Construct Blob Path | |
| $blobName = "sha256-$hash" | |
| $blobPath = Join-Path -Path $OllamaBlobDir -ChildPath $blobName | |
| # Verify Blob Exists | |
| if (Test-Path $blobPath) { | |
| Write-Host "Found created blob at: $blobPath" -ForegroundColor Cyan | |
| # Check drives (Hard Link requirement) | |
| $sourceDrive = [System.IO.Path]::GetPathRoot($selectedGguf.FullName) | |
| $destDrive = [System.IO.Path]::GetPathRoot($blobPath) | |
| if ($sourceDrive -ne $destDrive) { | |
| Write-Error "Cannot create Hard Link! Source ($sourceDrive) and Destination ($destDrive) are on different drives." | |
| Write-Host "The model was created, but duplicate space is being used." -ForegroundColor Red | |
| } | |
| else { | |
| # Check if it is already a Hard Link (File with >1 link count) | |
| # PowerShell doesn't have a simple built-in property for LinkCount, so we try to create it. | |
| # If we delete the blob and recreate as hardlink, we save space. | |
| Write-Host "Optimizing storage... Deleting blob and creating Hard Link." -ForegroundColor Yellow | |
| try { | |
| Remove-Item -Path $blobPath -Force -ErrorAction Stop | |
| # Create Hard Link | |
| New-Item -ItemType HardLink -Path $blobPath -Target $selectedGguf.FullName -Force | Out-Null | |
| Write-Host "SUCCESS: Blob replaced with Hard Link!" -ForegroundColor Green | |
| Write-Host "Source: $($selectedGguf.FullName)" -ForegroundColor DarkGray | |
| Write-Host "Target: $blobPath" -ForegroundColor DarkGray | |
| Write-Host "Note: Hard Links look like normal files but share the same physical data." -ForegroundColor Gray | |
| } | |
| catch { | |
| Write-Error "Failed to create Hard Link: $_" | |
| Write-Host "Restoring blob..." | |
| Copy-Item -Path $selectedGguf.FullName -Destination $blobPath | |
| } | |
| } | |
| } else { | |
| Write-Warning "Could not locate the blob file at $blobPath. Check your Ollama data directory configuration." | |
| } | |
| } else { | |
| Write-Error "Could not parse SHA256 hash from Ollama output. Optimization skipped." | |
| } | |
| # Cleanup | |
| if (Test-Path $TempModelfile) { Remove-Item $TempModelfile } | |
| Write-Host "`nOperation Complete." -ForegroundColor Cyan | |
| Write-Host "Copyright © 2025 - Author: Andreas Nilsen" -ForegroundColor DarkGray | |
| Pause |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment