param( [string]$ManifestPath = '', [string]$AiDirectory = '' ) $ErrorActionPreference = 'Stop' $root = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')) if (!$ManifestPath) { $ManifestPath = Join-Path $root 'ai/model.json' } if (!$AiDirectory) { $AiDirectory = Join-Path $root 'ai' } $lock = $null function Test-Model([string]$Path, $Manifest) { if (!(Test-Path -LiteralPath $Path -PathType Leaf)) { return $false } if ((Get-Item -LiteralPath $Path).Length -ne [long]$Manifest.bytes) { return $false } return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash -eq $Manifest.sha256 } try { $manifest = Get-Content -LiteralPath $ManifestPath -Raw -Encoding UTF8 | ConvertFrom-Json if ([long]$manifest.bytes -le 0 -or $manifest.sha256 -notmatch '^[a-fA-F0-9]{64}$') { throw 'Invalid model size or SHA-256 in manifest' } $ai = [IO.Path]::GetFullPath($AiDirectory).TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar $model = [IO.Path]::GetFullPath((Join-Path $ai $manifest.file)) if (!$model.StartsWith($ai, [StringComparison]::OrdinalIgnoreCase)) { throw 'Model destination must be inside the ai directory' } if (Test-Model $model $manifest) { Write-Host 'SUCCESS: model already present; size and SHA-256 verified. Nothing to download.' exit 0 } $curl = (Get-Command curl.exe -ErrorAction Stop).Source New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName($model)) | Out-Null $lock = [IO.File]::Open("$model.download.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) $part = "$model.part" $urls = @($manifest.download_urls) if (!$urls.Count -or !$urls[0]) { throw 'No download URLs in model manifest' } Write-Host ('Download size: {0:N2} GB. Keep at least 3 GB free.' -f ([long]$manifest.bytes / 1e9)) Write-Host "Destination: $model" foreach ($url in $urls) { $uri = [Uri]$url if ($uri.Scheme -ne 'https' -and !($uri.Scheme -eq 'http' -and $uri.IsLoopback)) { throw 'Model downloads require HTTPS' } Write-Host "Source: $($uri.Host)" if (Test-Path -LiteralPath $part) { $length = (Get-Item -LiteralPath $part).Length if ($length -eq [long]$manifest.bytes -and (Test-Model $part $manifest)) { Move-Item -LiteralPath $part -Destination $model -Force Write-Host 'SUCCESS: completed partial file verified and installed.' exit 0 } if ($length -ge [long]$manifest.bytes) { Remove-Item -LiteralPath $part } } # curl validates range responses before appending. Keep partial data on outages. $arguments = @('--fail', '--location', '--proto', '=http,https', '--proto-redir', '=http,https', '--retry', '2', '--retry-delay', '1', '--connect-timeout', '30', '--speed-limit', '1024', '--speed-time', '60', '--continue-at', '-', '--output', $part, $url) & $curl @arguments $result = $LASTEXITCODE if ($result -eq 33 -or $result -eq 36) { # Some mirrors ignore Range; retry this source once from the beginning. if (Test-Path -LiteralPath $part) { Remove-Item -LiteralPath $part } & $curl @arguments $result = $LASTEXITCODE } if ($result -ne 0) { Write-Warning "Download interrupted (curl $result). Trying next source; partial data retained." continue } Write-Host 'Verifying downloaded size and SHA-256...' if (!(Test-Model $part $manifest)) { Write-Warning 'Downloaded file failed integrity verification. Trying next source.' if (Test-Path -LiteralPath $part) { Remove-Item -LiteralPath $part } continue } # Never replace an existing destination with unverified data. Move-Item -LiteralPath $part -Destination $model -Force Write-Host 'SUCCESS: offline model downloaded, verified and installed.' exit 0 } throw 'All model sources failed. Run download_fortune_model.bat again to retry/resume.' } catch { Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red exit 1 } finally { if ($lock) { $lock.Dispose() } }