diff --git a/scripts/quickstart.ps1 b/scripts/quickstart.ps1 new file mode 100644 index 00000000000..aa6de6e247d --- /dev/null +++ b/scripts/quickstart.ps1 @@ -0,0 +1,643 @@ +# LiteLLM Gateway quickstart for Windows: the gateway, Postgres, and the admin UI in one command. +# powershell -c "irm https://raw.githubusercontent.com/BerriAI/litellm/main/scripts/quickstart.ps1 | iex" +# On macOS and Linux, scripts/quickstart.sh does the same. +# +# To read it before running it: +# irm https://raw.githubusercontent.com/BerriAI/litellm/main/scripts/quickstart.ps1 -OutFile quickstart.ps1 +# notepad quickstart.ps1 +# powershell -ExecutionPolicy Bypass -File quickstart.ps1 +# +# Asks where to keep the files, then offers to copy the admin password and +# open the admin UI; every question has a default you accept by pressing Enter. +# It asks nothing when input is not a console, under CI or Claude Code, or +# with -Yes. +# +# -Yes, or LITELLM_YES=1 no questions: install to ~\litellm-gateway, don't open a browser +# LITELLM_DIR folder to install into (skips the folder question) +# LITELLM_PORT port for the gateway (default 4000, or the next free one) +# NO_COLOR plain output, which agents, CI, and log files always get +# +# New installs listen on this machine only (127.0.0.1). To reach the gateway +# from other machines, remove LITELLM_BIND from .env and put it behind TLS. +# +# Keys and the database password are random, written only to .env, which only +# your Windows account can read, and never printed. When you ask, the admin +# password goes straight to your clipboard. Needs Docker Desktop, Podman, or +# Rancher Desktop. +# Works in Windows PowerShell 5.1 and PowerShell 7. Everything runs inside +# Invoke-LiteLLMQuickstart, so a partial download runs nothing. +param([switch]$Yes) + +function Invoke-LiteLLMQuickstart { + param([switch]$Yes) + $ErrorActionPreference = 'Stop' + # Windows PowerShell draws a progress bar for every web request, which makes them crawl. + $ProgressPreference = 'SilentlyContinue' + Set-StrictMode -Version 2 + + $composeUrl = 'https://raw.githubusercontent.com/BerriAI/litellm/main/docker/docker-compose.quickstart.yml' + if ($env:LITELLM_COMPOSE_URL) { $composeUrl = $env:LITELLM_COMPOSE_URL } + $onWindows = ($PSVersionTable.PSEdition -eq 'Desktop') -or ((Test-Path variable:IsWindows) -and $IsWindows) + + # ------------------------------------------------------------ output + + # A person at a console gets colors, step marks, and a spinner. Agents, CI, + # log files, and NO_COLOR get the same lines as plain text. + $style = (-not [Console]::IsOutputRedirected) -and (-not $env:NO_COLOR) -and (-not $env:CI) -and + (-not $env:CLAUDECODE) -and ($env:TERM -ne 'dumb') + # Escape sequences give the brand blue and bold; older consoles get the + # sixteen console colors instead. + $vt = $style -and $Host.UI.PSObject.Properties['SupportsVirtualTerminal'] -and $Host.UI.SupportsVirtualTerminal + # Symbols need a console font that has them: Windows Terminal, VS Code, or + # any macOS or Linux terminal. The classic console gets plain ASCII. + $unicode = $style -and ((-not $onWindows) -or $env:WT_SESSION -or ($env:TERM_PROGRAM -eq 'vscode')) + if ($unicode) { + $sym = @{ Ok = [char]0x2713; Warn = '!'; Err = [char]0x2717; Head = [char]0x25C6; Ask = '?'; Pointer = [char]0x276F + Frames = @([char]0x280B, [char]0x2819, [char]0x2839, [char]0x2838, [char]0x283C, [char]0x2834, [char]0x2826, [char]0x2827, [char]0x2807, [char]0x280F) + Hint = "$([char]0x2191)/$([char]0x2193) to move, Enter to choose"; Dot = [char]0x00B7 + TL = [char]0x256D; TR = [char]0x256E; BL = [char]0x2570; BR = [char]0x256F; H = [char]0x2500; V = [char]0x2502 } + } else { + $sym = @{ Ok = '+'; Warn = '!'; Err = 'x'; Head = '*'; Ask = '?'; Pointer = '>'; Frames = @('|', '/', '-', '\') + Hint = 'Up/Down to move, Enter to choose'; Dot = '-'; TL = '+'; TR = '+'; BL = '+'; BR = '+'; H = '-'; V = '|' } + } + $e = [char]27 + $ansi = @{ a = "$e[38;2;91;108;255m"; b = "$e[1m"; d = "$e[90m"; g = "$e[32m"; y = "$e[33m"; r = "$e[1;31m"; u = "$e[1;38;2;91;108;255m"; off = "$e[0m" } + $colors = @{ a = 'Blue'; b = $null; d = 'DarkGray'; g = 'Green'; y = 'Yellow'; r = 'Red'; u = 'Blue' } + + # Say "text with {a:accent} {b:bold} {d:dim} {g:green} {y:yellow} {r:red} {u:link} spans" + function Say { + param([string]$Text, [switch]$Err, [switch]$NoNewline) + $parts = [regex]::Split($Text, '(\{[abdgyru]:[^}]*\})') + # Errors go out plain when stderr is redirected, so a log file gets no escape codes. + if (-not $style -or ($Err -and [Console]::IsErrorRedirected)) { + $plain = ($parts | ForEach-Object { if ($_ -match '^\{[abdgyru]:(.*)\}$') { $Matches[1] } else { $_ } }) -join '' + $stream = if ($Err) { [Console]::Error } else { [Console]::Out } + if ($NoNewline) { $stream.Write($plain) } else { $stream.WriteLine($plain) } + return + } + if ($vt) { + $line = ($parts | ForEach-Object { if ($_ -match '^\{([abdgyru]):(.*)\}$') { $ansi[$Matches[1]] + $Matches[2] + $ansi.off } else { $_ } }) -join '' + Write-Host $line -NoNewline:$NoNewline + return + } + foreach ($p in $parts) { + if ($p -match '^\{([abdgyru]):(.*)\}$') { + $c = $colors[$Matches[1]] + if ($c) { Write-Host $Matches[2] -NoNewline -ForegroundColor $c } else { Write-Host $Matches[2] -NoNewline } + } elseif ($p) { Write-Host $p -NoNewline } + } + if (-not $NoNewline) { Write-Host '' } + } + # Markup characters in a value (a path, a URL) must not open a span. + function Lit([string]$s) { return $s.Replace('{', '(').Replace('}', ')') } + function Step([string]$Text) { if ($style) { Say ("{g:$($sym.Ok)} " + $Text) } else { Say $Text } } + function Warn([string]$Text) { if ($style) { Say ("{y:$($sym.Warn)} " + $Text) } else { Say $Text } } + function Fail([string]$Head, [string]$Rest = '') { + $tail = if ($Rest) { " $Rest" } else { '' } + if ($style) { Say ("{r:$($sym.Err) $Head}" + $tail) -Err } else { Say ($Head + $tail) -Err } + } + function Tildify([string]$Path) { + if ($Path -eq $HOME) { return '~' } + if ($Path.StartsWith($HOME + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { return '~' + $Path.Substring($HOME.Length) } + return $Path + } + function Elapsed([datetime]$Since) { + $s = [int]((Get-Date) - $Since).TotalSeconds + if ($s -lt 60) { return "${s}s" } else { return "$([math]::Floor($s / 60))m $($s % 60)s" } + } + function Clear-Line { + if ($vt) { Write-Host "`r$e[2K" -NoNewline } else { Write-Host ("`r" + (' ' * ([Console]::WindowWidth - 1)) + "`r") -NoNewline } + } + function Show-Cursor([bool]$Shown) { try { [Console]::CursorVisible = $Shown } catch { <# a host without a cursor #> } } + + # Run a native program; returns its exit code and stdout lines. stderr is + # dropped so Windows PowerShell does not turn it into a terminating error. + function Invoke-Native { + param([string]$File, [string[]]$Arguments) + $prev = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + $out = & $File @Arguments 2>$null + $code = $LASTEXITCODE + } catch { + $out = @(); $code = 1 + } finally { + $ErrorActionPreference = $prev + } + return [pscustomobject]@{ Code = $code; Out = @($out) } + } + + # Write a text file as UTF-8 without a byte order mark, with LF line endings, + # which is what Docker Compose and git expect. + function Write-PlainText([string]$Path, [string]$Text, [switch]$Append) { + $enc = New-Object System.Text.UTF8Encoding $false + if ($Append) { [IO.File]::AppendAllText($Path, $Text, $enc) } else { [IO.File]::WriteAllText($Path, $Text, $enc) } + } + + # The POSIX cksum of a string's UTF-8 bytes: CRC-32 over the bytes and then + # their length. quickstart.sh names projects with the same number, so both + # scripts find the same database for a folder. Int64 keeps the arithmetic + # unsigned; hex literals with the top bit set would be negative here. + function Get-Cksum([string]$Text) { + $bytes = New-Object System.Collections.Generic.List[byte] + $bytes.AddRange([Text.Encoding]::UTF8.GetBytes($Text)) + for ($n = [int64]$bytes.Count; $n -gt 0; $n = $n -shr 8) { $bytes.Add([byte]($n -band 255)) } + [int64]$crc = 0 + foreach ($b in $bytes) { + $crc = $crc -bxor ([int64]$b -shl 24) + for ($i = 0; $i -lt 8; $i++) { + $crc = if ($crc -band 2147483648) { (($crc -shl 1) -bxor 79764919) -band 4294967295 } else { ($crc -shl 1) -band 4294967295 } + } + } + return (-bnot $crc) -band 4294967295 + } + + # ------------------------------------------------------------ questions + + $interactive = (-not $Yes) -and (-not $env:LITELLM_YES) -and (-not $env:CI) -and (-not $env:CLAUDECODE) -and + (-not [Console]::IsInputRedirected) -and [Environment]::UserInteractive + + # Menu "Question" Default @(@('label', 'note'), ...) -> the 1-based pick. + # ReadKey holds on to Ctrl+C, so take it as a key and stop the way PowerShell + # does; finally blocks still run and put the terminal back. + function Read-Key { + $saved = [Console]::TreatControlCAsInput + try { + [Console]::TreatControlCAsInput = $true + $key = [Console]::ReadKey($true) + } finally { + [Console]::TreatControlCAsInput = $saved + } + if ($key.Key -eq 'C' -and ($key.Modifiers -band [ConsoleModifiers]::Control)) { + throw [System.Management.Automation.PipelineStoppedException]::new() + } + return $key + } + + function Menu { + param([string]$Question, [int]$Default, [object[]]$Options) + if (-not $interactive) { return $Default } + $count = $Options.Count + $pad = ($Options | ForEach-Object { $_[0].Length } | Measure-Object -Maximum).Maximum + Write-Host '' + if ($style) { Say ("{a:$($sym.Ask)} {b:" + (Lit $Question) + '}') } else { Write-Host $Question } + $choice = $Default + try { + Show-Cursor $false + $first = $true + $top = 0 + while ($true) { + # Escape-code consoles move up relative to the cursor; the classic + # Windows console jumps back to the row the menu started on. + if (-not $first) { if ($vt) { Write-Host "$e[$($count + 1)A" -NoNewline } else { [Console]::SetCursorPosition(0, $top) } } + $first = $false + for ($i = 1; $i -le $count; $i++) { + $label = (Lit $Options[$i - 1][0]).PadRight($pad) + $note = if ($Options[$i - 1].Count -gt 1) { Lit $Options[$i - 1][1] } else { '' } + Clear-Line + if ($i -eq $choice) { Say " {u:$($sym.Pointer) $label} {d:$note}" } else { Say " $label {d:$note}" } + } + Clear-Line + Say " {d:$($sym.Hint)}" + if (-not $vt) { $top = [Console]::CursorTop - ($count + 1) } + $key = Read-Key + if ($key.Key -eq 'UpArrow' -or $key.KeyChar -eq 'k') { if ($choice -gt 1) { $choice-- } } + elseif ($key.Key -eq 'DownArrow' -or $key.KeyChar -eq 'j') { if ($choice -lt $count) { $choice++ } } + elseif ($key.KeyChar -match '^[1-9]$' -and [int]"$($key.KeyChar)" -le $count) { $choice = [int]"$($key.KeyChar)" } + elseif ($key.Key -eq 'Enter') { break } + } + if ($vt) { Write-Host "$e[1A" -NoNewline } else { [Console]::SetCursorPosition(0, $top + $count) } + Clear-Line + } catch [System.Management.Automation.PipelineStoppedException] { + throw + } catch { + # No cursor control here: fall back to a numbered list. + for ($i = 1; $i -le $count; $i++) { Write-Host (" $i) " + $Options[$i - 1][0]) } + $answer = Read-Host "Choose [$Default]" + if ($answer -match '^[1-9]$' -and [int]$answer -le $count) { $choice = [int]$answer } + } finally { + Show-Cursor $true + } + return $choice + } + + # ------------------------------------------------------------ steps + + function Test-PortFree([int]$Port) { + # Binding answers right away and is accurate. Probing with a connect is + # not: the WSL2 localhost relay (Podman, Rancher Desktop) swallows the + # refusal on closed ports, so every connect waits out its timeout and + # closed ports look taken. + $listener = New-Object System.Net.Sockets.TcpListener([Net.IPAddress]::Loopback, $Port) + try { + $listener.Start() + return $true + } catch { + return $false + } finally { + $listener.Stop() + } + } + + function Test-Ready([int]$Port) { + try { + $r = Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 -Uri "http://127.0.0.1:$Port/health/readiness" + return $r.StatusCode -eq 200 + } catch { return $false } + } + + # Run a program with a spinner and the elapsed time, keeping its output to + # show if it fails. Without styling it runs in the open. + function Invoke-WithSpinner { + param([string]$Label, [string]$Detail, [string]$File, [string[]]$Arguments) + if (-not $style) { + $prev = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { & $File @Arguments 2>&1 | ForEach-Object { [Console]::Out.WriteLine("$_") }; $code = $LASTEXITCODE } + finally { $ErrorActionPreference = $prev } + return $code + } + $out = [IO.Path]::GetTempFileName(); $err = [IO.Path]::GetTempFileName() + try { + $p = Start-Process -FilePath $File -ArgumentList $Arguments -NoNewWindow -PassThru -RedirectStandardOutput $out -RedirectStandardError $err + $null = $p.Handle # keeps ExitCode available after exit in Windows PowerShell + $start = Get-Date + $n = 0 + Show-Cursor $false + try { + while (-not $p.HasExited) { + $s = [int]((Get-Date) - $start).TotalSeconds + $clock = '{0}:{1:00}' -f [math]::Floor($s / 60), ($s % 60) + $extra = if ($Detail) { "$Detail $($sym.Dot) " } else { '' } + Clear-Line + Say ("{a:$($sym.Frames[$n % $sym.Frames.Count])} $Label {d:$($sym.Dot) $extra$clock}") -NoNewline + $n++ + Start-Sleep -Milliseconds 100 + } + $p.WaitForExit() + Clear-Line + } finally { Show-Cursor $true } + $code = $p.ExitCode + if ($code -ne 0) { + Get-Content $out, $err -ErrorAction SilentlyContinue | ForEach-Object { [Console]::Error.WriteLine($_) } + } + return $code + } finally { + # Also on Ctrl+C, so a cancelled run leaves no temporary files. + Remove-Item -LiteralPath $out, $err -Force -ErrorAction SilentlyContinue + } + } + + function Wait-Ready([int]$Port) { + # Up to 3 minutes, like the shell version. A spinner when styled. + $start = Get-Date + $n = 0 + $nextCheck = Get-Date + if ($style) { Show-Cursor $false } + try { + while (((Get-Date) - $start).TotalSeconds -lt 180) { + if ((Get-Date) -ge $nextCheck) { + if (Test-Ready $Port) { if ($style) { Clear-Line }; return $true } + $nextCheck = (Get-Date).AddSeconds(2) + } + if ($style) { + $s = [int]((Get-Date) - $start).TotalSeconds + Clear-Line + Say ("{a:$($sym.Frames[$n % $sym.Frames.Count])} Waiting for the gateway to be ready {d:$($sym.Dot) $('{0}:{1:00}' -f [math]::Floor($s / 60), ($s % 60))}") -NoNewline + $n++ + } + Start-Sleep -Milliseconds 100 + } + if ($style) { Clear-Line } + return $false + } finally { if ($style) { Show-Cursor $true } } + } + + # The closing summary. Plain output, and a console too narrow for the frame, + # get the same lines without it. + function Open-Url([string]$Url) { + try { + if ($onWindows) { Start-Process $Url } elseif (Get-Command open -ErrorAction SilentlyContinue) { & open $Url } else { & xdg-open $Url } + Step "Opened {a:$Url} in your browser" + } catch { + # No browser to open; print the address instead. + Say " {d:Open} {a:$Url} {d:when you are ready.}" + } + } + + # The admin password is the master key. It goes straight to the clipboard, + # never to the screen. + function Copy-Password([string]$Folder) { + $pw = Get-Content -LiteralPath (Join-Path $Folder '.env') | + Where-Object { $_.StartsWith('LITELLM_MASTER_KEY=') } | Select-Object -First 1 + if (-not $pw) { Warn ('No LITELLM_MASTER_KEY in ' + (Lit (Join-Path (Tildify $Folder) '.env')) + '.'); return } + try { + Set-Clipboard -Value $pw.Substring('LITELLM_MASTER_KEY='.Length) -ErrorAction Stop + Step 'Copied the admin password. Paste it into the sign-in page.' + } catch { + # Another app can hold the Windows clipboard open. + Warn ('Could not copy it. The password is LITELLM_MASTER_KEY in ' + (Lit (Join-Path (Tildify $Folder) '.env')) + '.') + } finally { + $pw = $null + } + } + + function Show-Box([string]$Title, [object[]]$Rows) { + $width = $Title.Length + foreach ($r in $Rows) { if (11 + $r[1].Length -gt $width) { $width = 11 + $r[1].Length } } + $cols = 0 + try { $cols = [Console]::WindowWidth } catch { <# no console: print without the frame #> } + if (-not $style -or $cols -lt $width + 4) { + if ($style) { Say "{u:$Title}" } else { Say "$Title." } + foreach ($r in $Rows) { Say (' ' + ($r[0] + ':').PadRight(12) + (Lit $r[1])) } + return + } + $rule = "$($sym.H)" * ($width + 2) + Say "{a:$($sym.TL)$rule$($sym.TR)}" + Say ("{a:$($sym.V)} {u:" + $Title.PadRight($width) + "} {a:$($sym.V)}") + foreach ($r in $Rows) { + $value = (Lit $r[1]).PadRight($width - 11) + if ($r[0] -eq 'Admin UI') { $value = "{u:$value}" } + Say ("{a:$($sym.V)} {d:" + $r[0].PadRight(10) + "} $value {a:$($sym.V)}") + } + Say "{a:$($sym.BL)$rule$($sym.BR)}" + } + + # ------------------------------------------------------------ main + + $savedEncoding = $null + try { + if ($unicode -and $onWindows) { + # Windows PowerShell writes the console in the OEM code page, which has no check marks. + $savedEncoding = [Console]::OutputEncoding + [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false + } + if ($PSVersionTable.PSEdition -eq 'Desktop') { + # Windows PowerShell can default to TLS 1.0, which GitHub refuses. + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + } + + if ($style) { + Write-Host '' + Say "{u:$($sym.Head) LiteLLM quickstart}" + Say '{d: The gateway, Postgres, and the admin UI in one command}' + Write-Host '' + } else { + Say 'LiteLLM quickstart' + } + + # Podman and Rancher Desktop (nerdctl in containerd mode; its dockerd mode + # already provides a docker CLI) work the same way through Compose v2. + $engineCmd = foreach ($cand in 'docker', 'podman', 'nerdctl') { + $c = Get-Command $cand -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($c) { $c; break } + } + if (-not $engineCmd) { + Fail 'No container engine found (docker, podman, or nerdctl).' 'The LiteLLM Gateway runs in a container alongside a Postgres database.' + $install = if ($onWindows) { 'https://docs.docker.com/desktop/setup/install/windows-install/' } else { 'https://docs.docker.com/get-docker/' } + Say '' -Err + Say " Install Docker Desktop ($install)," -Err + Say ' Podman (https://podman.io), or Rancher Desktop (https://rancherdesktop.io), then run this again.' -Err + Say ' Or deploy in one click (Railway or Render): https://docs.litellm.ai/docs/proxy/docker_quick_start' -Err + Say ' Only need to call models from Python? pip install litellm' -Err + return 1 + } + $docker = $engineCmd.Source + $engine = $engineCmd.Name -replace '\.exe$', '' + $engineName = switch ($engine) { 'podman' { 'Podman' } 'nerdctl' { 'nerdctl' } default { 'Docker' } } + $compose = Invoke-Native $docker @('compose', 'version', '--short') + if ($compose.Code -ne 0) { Fail "Compose v2 ('$engine compose') is required."; return 1 } + if ((Invoke-Native $docker @('info')).Code -ne 0) { + $startHint = switch ($engine) { + 'podman' { 'Start it (podman machine start) and run this again.' } + 'nerdctl' { 'Start Rancher Desktop and run this again.' } + default { 'Start Docker Desktop and run this again.' } + } + Fail "$engineName is installed but not running." $startHint + return 1 + } + $server = Invoke-Native $docker @('version', '--format', '{{.Server.Version}}') + $engineVersion = if ($server.Code -eq 0 -and $server.Out.Count) { "$($server.Out[0]) " } else { '' } + $composeVersion = "$($compose.Out[0])".TrimStart('v') + Step "$engineName ${engineVersion}with Compose $composeVersion is running" + + $homeDir = Join-Path $HOME 'litellm-gateway' + $hereDir = Join-Path (Get-Location).ProviderPath 'litellm-gateway' + $found = $false + if ($env:LITELLM_DIR) { + $dir = $env:LITELLM_DIR + } elseif (Test-Path -LiteralPath (Join-Path $hereDir '.env')) { + $dir = $hereDir; $found = $true # installed in this folder before + } elseif (Test-Path -LiteralPath (Join-Path $homeDir '.env')) { + $dir = $homeDir; $found = $true # installed in the home folder before + } elseif ($hereDir -eq $homeDir) { + $dir = $homeDir + } else { + $pick = Menu 'Where should LiteLLM keep its files (.env with your keys, and the compose file)?' 1 @( + , @((Tildify $homeDir), 'recommended, reruns always find it') + , @((Tildify $hereDir), 'this folder')) + $dir = if ($pick -eq 2) { $hereDir } else { $homeDir } + } + $created = -not (Test-Path -LiteralPath $dir) + $null = New-Item -ItemType Directory -Force -Path $dir + $dir = (Resolve-Path -LiteralPath $dir).ProviderPath + Set-Location -LiteralPath $dir + if ($found) { Step ('Found your install in {b:' + (Lit (Tildify $dir)) + '}') } else { Step ('Files go in {b:' + (Lit (Tildify $dir)) + '}') } + + $git = Get-Command git -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + $inRepo = $git -and (Invoke-Native $git.Source @('rev-parse', '--is-inside-work-tree')).Code -eq 0 + if ($inRepo -and -not (Test-Path -LiteralPath (Join-Path $dir '.env')) -and + (Invoke-Native $git.Source @('ls-files', '--error-unmatch', '.env')).Code -eq 0) { + # An ignore rule does not cover a tracked file, so new keys written here + # would show up as a change to commit. + Fail 'Git tracks a .env file in this folder,' 'so your keys could be committed.' + Say '' -Err + Say ' Install into another folder (set LITELLM_DIR), or stop tracking the file' -Err + Say ' first with: git rm --cached .env' -Err + return 1 + } + if ($created) { + # A folder this script made holds only its own files, so keep all of it out of git. + Write-PlainText (Join-Path $dir '.gitignore') "*`n" + } elseif ($inRepo -and (Invoke-Native $git.Source @('check-ignore', '-q', '.env')).Code -ne 0) { + # In a folder that already existed, such as a repository root, leave the + # tracked .gitignore alone and add only .env to this clone's local exclude + # list, so the generated keys cannot be committed. + $exclude = "$((Invoke-Native $git.Source @('rev-parse', '--git-path', 'info/exclude')).Out[0])" + $exclude = [IO.Path]::GetFullPath([IO.Path]::Combine($dir, $exclude)) + $null = New-Item -ItemType Directory -Force -Path (Split-Path $exclude) + $prefix = "$((Invoke-Native $git.Source @('rev-parse', '--show-prefix')).Out | Select-Object -First 1)" + Write-PlainText $exclude "/$prefix.env`n" -Append + Step ("Kept .env out of git {d:(added it to this repository's local exclude list, " + (Lit (Tildify $exclude)) + ')}') + } elseif (-not $inRepo) { + # An existing folder outside git: ignore only .env, so it stays out of + # commits if the folder becomes a repository later. + $ignore = Join-Path $dir '.gitignore' + $current = if (Test-Path -LiteralPath $ignore) { [IO.File]::ReadAllText($ignore) } else { '' } + if (($current -split "`r?`n") -notcontains '.env') { + # Start on a new line if the file does not end with one. + $lead = if ($current.Length -gt 0 -and -not $current.EndsWith("`n")) { "`n" } else { '' } + Write-PlainText $ignore "$lead.env`n" -Append + } + } + + # The engine names containers and the database volume after the project, so + # an install outside the home folder gets its own name and never shares a + # database with another litellm-gateway folder. Windows paths ignore case, + # so there the name does too. + $envFile = Join-Path $dir '.env' + $project = 'litellm-gateway' + if ($dir -ne $homeDir) { + $key = if ($onWindows) { $dir.ToLowerInvariant() } else { $dir } + $project = "litellm-gateway-$(Get-Cksum $key)" + } + if (-not (Test-Path -LiteralPath $envFile)) { + # Postgres keeps the password it was created with, so a new password over an + # old database volume would lock the gateway out. Stop and explain instead. + if ((Invoke-Native $docker @('volume', 'inspect', "${project}_postgres_data")).Code -eq 0) { + Fail 'Found a database from an earlier install' "($engineName volume ${project}_postgres_data)" + Say ('but no ' + (Lit (Join-Path (Tildify $dir) '.env')) + ' with its password.') -Err + Say '' -Err + Say ' Restore that .env file and run this again to keep your models and keys, or' -Err + Say ' delete the old database and start fresh (this removes its models and keys):' -Err + Say " $engine volume rm ${project}_postgres_data" -Err + return 1 + } + } + + Invoke-WebRequest -UseBasicParsing -Uri $composeUrl -OutFile (Join-Path $dir 'docker-compose.quickstart.yml') + Step 'Downloaded docker-compose.quickstart.yml' + + $saved = '' + if (Test-Path -LiteralPath $envFile) { + $m = Get-Content -LiteralPath $envFile | Where-Object { $_ -match '^LITELLM_PORT=(.*)$' } | Select-Object -Last 1 + if ($m) { $saved = ($m -split '=', 2)[1] } + } + if ($env:LITELLM_PORT) { + $port = [int]$env:LITELLM_PORT + } elseif ($saved) { + $port = [int]$saved + } elseif (Test-Path -LiteralPath $envFile) { + $port = 4000 # an existing install without a saved port runs on the compose default + } else { + $port = 4000 + while (-not (Test-PortFree $port)) { + $port++ + if ($port -gt 4099) { + Fail 'Ports 4000 to 4099 are all in use.' 'Set LITELLM_PORT to a free port and run this again.' + return 1 + } + } + if ($port -eq 4000) { Step 'Port {b:4000} is free' } else { Warn "Port 4000 is in use, so LiteLLM will use {b:$port}" } + } + + if (Test-Path -LiteralPath $envFile) { + Step 'Reusing .env, so existing keys and data keep working' + } else { + $rng = [Security.Cryptography.RandomNumberGenerator]::Create() + $hex = { + param([int]$n) + $bytes = New-Object byte[] $n + $rng.GetBytes($bytes) + -join ($bytes | ForEach-Object { $_.ToString('x2') }) + } + # Lock a temporary file down before anything secret goes into it, then + # rename it, so a failed write never leaves a partial .env that a rerun + # would mistake for a finished install. + $tmp = "$envFile.tmp" + try { + Write-PlainText $tmp '' + if ($onWindows) { + $acl = New-Object System.Security.AccessControl.FileSecurity + $acl.SetAccessRuleProtection($true, $false) + $me = [Security.Principal.WindowsIdentity]::GetCurrent().User + $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($me, 'FullControl', 'Allow'))) + Set-Acl -LiteralPath $tmp -AclObject $acl + } else { + & chmod 600 $tmp + if ($LASTEXITCODE -ne 0) { throw "Could not restrict the permissions of $tmp." } + } + Write-PlainText $tmp (("LITELLM_MASTER_KEY=sk-{0}`nLITELLM_SALT_KEY=sk-{1}`nPOSTGRES_PASSWORD={2}`n" + + "LITELLM_PORT={3}`nLITELLM_BIND=127.0.0.1:`nCOMPOSE_PROJECT_NAME={4}`n") -f (& $hex 32), (& $hex 32), (& $hex 24), $port, $project) + [IO.File]::Move($tmp, $envFile) + } finally { + Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue + } + Step 'Generated .env with your master key, salt key, and database password {d:(keep this file; only you can read it)}' + } + + # Compose prefers values already set in the environment over .env, so drop + # inherited ones: .env stays the only source for keys and the project name. + foreach ($name in 'LITELLM_MASTER_KEY', 'LITELLM_SALT_KEY', 'POSTGRES_PASSWORD', 'COMPOSE_PROJECT_NAME') { + Remove-Item "Env:$name" -ErrorAction SilentlyContinue + } + # The bind address follows .env when .env sets it. For an older .env without + # it, a value already in the environment is kept. + if (Get-Content -LiteralPath $envFile | Where-Object { $_ -match '^LITELLM_BIND=' }) { Remove-Item Env:LITELLM_BIND -ErrorAction SilentlyContinue } + $env:LITELLM_PORT = "$port" + + $started = Get-Date + $detail = '' + foreach ($image in (Invoke-Native $docker @('compose', '-f', 'docker-compose.quickstart.yml', 'config', '--images')).Out) { + if ($image -and (Invoke-Native $docker @('image', 'inspect', "$image")).Code -ne 0) { $detail = 'downloading images, first run only' } + } + if (-not $style) { Say 'Starting LiteLLM and Postgres (the first run downloads the images)...' } + $code = Invoke-WithSpinner 'Starting LiteLLM and Postgres' $detail $docker @('compose', '-f', 'docker-compose.quickstart.yml', 'up', '-d') + if ($code -ne 0) { + Fail "$engineName could not start LiteLLM and Postgres." 'Its output is above.' + return 1 + } + if (-not (Wait-Ready $port)) { + Fail 'The gateway did not become ready in 3 minutes.' 'See what it logged:' + Say (' cd ' + (Lit (Tildify $dir)) + "; $engine compose -f docker-compose.quickstart.yml logs litellm") -Err + return 1 + } + Step ("LiteLLM and Postgres are up {d:$($sym.Dot) $(Elapsed $started)}") + + $url = "http://localhost:$port/ui" + $where = Tildify $dir + if ($where -match ' ') { $where = "`"$where`"" } + Write-Host '' + # Over SSH the clipboard would be the server's, not yours. On Linux, + # Set-Clipboard without xclip quietly keeps the text inside PowerShell, so + # only Windows and macOS, which always have a clipboard, get the option. + $canCopy = $interactive -and (-not $env:SSH_CONNECTION) -and + ($onWindows -or ((Test-Path variable:IsMacOS) -and $IsMacOS)) + $password = "the LITELLM_MASTER_KEY value in $(Join-Path (Tildify $dir) '.env')" + if ($canCopy) { $password = "copy it below, or LITELLM_MASTER_KEY in $(Join-Path (Tildify $dir) '.env')" } + Show-Box 'LiteLLM is running' @( + , @('Admin UI', $url) + , @('Username', 'admin') + , @('Password', $password) + , @('Next', 'in the UI, open Models + Endpoints > Add Model and paste a provider API key') + , @('Stop it', "cd $where; $engine compose -f docker-compose.quickstart.yml down")) + + if ($canCopy) { + # Come back to the menu after each choice, with the next step as the default. + $pick = 1 + while ($true) { + $pick = Menu 'What next?' $pick @( + , @('Copy the admin password', 'to the clipboard, ready to paste') + , @('Open the admin UI in your browser') + , @('Done')) + if ($pick -eq 1) { Copy-Password $dir; $pick = 2 } + elseif ($pick -eq 2) { Open-Url $url; $pick = 3 } + else { break } + } + } elseif ($interactive) { + if ((Menu 'Open the admin UI in your browser?' 1 @(, @('Yes'), @('No'))) -eq 1) { Open-Url $url } + } + return 0 + } finally { + if ($savedEncoding) { [Console]::OutputEncoding = $savedEncoding } + } +} + +$code = Invoke-LiteLLMQuickstart -Yes:$Yes | Select-Object -Last 1 +$global:LASTEXITCODE = $code +# Run from a file, or as `powershell -c "irm ... | iex"`, the exit code should +# reach the caller. Pasted into an open window, `exit` would close that window, +# so there it only sets $LASTEXITCODE. +$hostArgs = [Environment]::GetCommandLineArgs() +$oneShot = ($hostArgs -match '^-(c|command)$') -and -not ($hostArgs -match '^-noexit$') +if ($code -ne 0 -and ($PSCommandPath -or $oneShot)) { exit $code } diff --git a/scripts/quickstart.sh b/scripts/quickstart.sh index 469f8f2a37f..66f69f34ff3 100755 --- a/scripts/quickstart.sh +++ b/scripts/quickstart.sh @@ -1,36 +1,183 @@ #!/bin/sh # LiteLLM Gateway quickstart: the gateway, Postgres, and the admin UI in one command. # curl -fsSL https://raw.githubusercontent.com/BerriAI/litellm/main/scripts/quickstart.sh | sh +# On Windows, scripts/quickstart.ps1 does the same in PowerShell. # # To read it before running it: # curl -fsSL https://raw.githubusercontent.com/BerriAI/litellm/main/scripts/quickstart.sh -o quickstart.sh # less quickstart.sh # sh quickstart.sh # -# Asks at most two questions (where to keep the files, and whether to open the -# admin UI), each with a default you accept by pressing Enter. It asks nothing -# when there is no terminal, under CI or Claude Code, or when run with --yes. +# Asks where to keep the files, then offers to copy the admin password and +# open the admin UI; every question has a default you accept by pressing Enter. +# It asks nothing when there is no terminal, under CI or Claude Code, or when +# run with --yes. # # --yes, -y no questions: install to ~/litellm-gateway, don't open a browser # LITELLM_DIR folder to install into (skips the folder question) # LITELLM_PORT port for the gateway (default 4000, or the next free one) +# NO_COLOR plain output, which agents, CI, and log files always get # # New installs listen on this machine only (127.0.0.1). To reach the gateway # from other machines, remove LITELLM_BIND from .env and put it behind TLS. # # Keys and the database password are random (openssl rand), written only to -# .env with permissions 600, and never printed. Needs Docker with Compose v2. +# .env with permissions 600, and never printed. When you ask, the admin +# password goes straight to your clipboard. Needs Docker, Podman, or Rancher +# Desktop, each with Compose v2. # Everything runs inside main(), so a partial download runs nothing. set -eu COMPOSE_URL="${LITELLM_COMPOSE_URL:-https://raw.githubusercontent.com/BerriAI/litellm/main/docker/docker-compose.quickstart.yml}" +# ---------------------------------------------------------------- output + +# A person watching a terminal gets colors, step marks, and a spinner. Agents, +# CI, log files, and NO_COLOR get the same lines as plain text. +STYLE=0 +ERR_STYLE=0 # stderr is styled only when it is a terminal too +C_ACC='' C_OK='' C_WARN='' C_ERR='' C_DIM='' C_BOLD='' C_OFF='' +POINTER='>' S_OK='+' S_WARN='!' S_ERR='x' S_HEAD='*' S_ASK='?' +# shellcheck disable=SC1003 # the last frame is a backslash +SPIN_FRAMES='| / - \' +HINT='Up/Down to move, Enter to choose' +BOX_TL='+' BOX_TR='+' BOX_BL='+' BOX_BR='+' BOX_H='-' BOX_V='|' +SPIN_PID='' SPIN_LOG='' ENV_TMP='' + +setup_output() { + case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in + *UTF-8* | *utf-8* | *UTF8* | *utf8*) + POINTER='❯' S_OK='✓' S_WARN='!' S_ERR='✗' S_HEAD='◆' S_ASK='?' + SPIN_FRAMES='⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏' + HINT='↑/↓ to move, Enter to choose' + BOX_TL='╭' BOX_TR='╮' BOX_BL='╰' BOX_BR='╯' BOX_H='─' BOX_V='│' + ;; + esac + if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != "dumb" ] && + [ -z "${CI:-}" ] && [ -z "${CLAUDECODE:-}" ]; then + STYLE=1 + e="$(printf '\033')" + # The accent is the LiteLLM blue, lightened so it reads on dark and light themes. + case "${COLORTERM:-}" in + truecolor | 24bit) C_ACC="${e}[38;2;91;108;255m" ;; + *) C_ACC="${e}[94m" ;; + esac + C_OK="${e}[32m" C_WARN="${e}[33m" C_ERR="${e}[31m" C_DIM="${e}[90m" C_BOLD="${e}[1m" C_OFF="${e}[0m" + if [ -t 2 ]; then ERR_STYLE=1; fi + fi +} + +step() { + if [ "$STYLE" = 1 ]; then + printf '%s%s%s %s%s\n' "${C_OK}" "$S_OK" "${C_OFF}" "$1" "${C_OFF}" + else + printf '%s\n' "$1" + fi +} + +warn() { + if [ "$STYLE" = 1 ]; then + printf '%s%s%s %s%s\n' "${C_WARN}" "$S_WARN" "${C_OFF}" "$1" "${C_OFF}" + else + printf '%s\n' "$1" + fi +} + +fail() { + if [ "$ERR_STYLE" = 1 ]; then + printf '%s%s %s%s%s\n' "${C_ERR}${C_BOLD}" "$S_ERR" "$1" "${C_OFF}" "${2:+ $2}" >&2 + else + printf '%s%s\n' "$1" "${2:+ $2}" >&2 + fi +} + +tildify() { + case "$1" in + "$HOME") printf '~' ;; + "$HOME"/*) printf '~%s' "${1#"$HOME"}" ;; + *) printf '%s' "$1" ;; + esac +} + +elapsed_since() { + s=$(($(date +%s) - $1)) + if [ "$s" -lt 60 ]; then printf '%ss' "$s"; else printf '%sm %ss' "$((s / 60))" "$((s % 60))"; fi +} + +# spin "label" "detail" command...: run the command behind a spinner with the +# elapsed time, keeping its output to show if it fails. Without styling the +# command runs in the open, as before. +spin() { + label="$1" detail="$2" + shift 2 + if [ "$STYLE" != 1 ]; then + "$@" + return + fi + SPIN_LOG="$(mktemp)" + "$@" >"$SPIN_LOG" 2>&1 & + SPIN_PID=$! + start="$(date +%s)" s=0 n=0 + frames="$SPIN_FRAMES " + printf '\033[?25l' + while kill -0 "$SPIN_PID" 2>/dev/null; do + # Rotate the frames in the shell and read the clock once a second, so a + # redraw starts no process besides sleep. + frame="${frames%% *}" + frames="${frames#* }$frame " + if [ $((n % 10)) = 0 ]; then s=$(($(date +%s) - start)); fi + printf '\r\033[2K%s%s%s %s %s· %s%d:%02d%s' "${C_ACC}" "$frame" "${C_OFF}" "$label" "${C_DIM}" \ + "${detail:+$detail · }" "$((s / 60))" "$((s % 60))" "${C_OFF}" + n=$((n + 1)) + sleep 0.1 + done + rc=0 + wait "$SPIN_PID" || rc=$? + SPIN_PID='' + printf '\r\033[2K\033[?25h' + if [ "$rc" != 0 ]; then cat "$SPIN_LOG" >&2; fi + rm -f "$SPIN_LOG" + SPIN_LOG='' + return "$rc" +} + +# box "Title" "Label|value"...: the closing summary. Plain output, and a +# terminal too narrow for the frame, get the same lines without it. +box() { + title="$1" + shift + cols='' + if [ "$STYLE" = 1 ]; then cols="$( (stty size /dev/null | cut -d ' ' -f 2)" || cols=''; fi + width=${#title} + for row in "$@"; do + value="${row#*|}" + if [ $((11 + ${#value})) -gt "$width" ]; then width=$((11 + ${#value})); fi + done + if [ "$STYLE" != 1 ] || [ -z "$cols" ] || [ $((width + 4)) -gt "$cols" ]; then + if [ "$STYLE" = 1 ]; then printf '%s%s%s\n' "${C_ACC}${C_BOLD}" "$title" "${C_OFF}"; else printf '%s.\n' "$title"; fi + for row in "$@"; do + printf ' %-12s%s\n' "${row%%|*}:" "${row#*|}" + done + return 0 + fi + rule="$(printf "%$((width + 2))s" '' | sed "s/ /$BOX_H/g")" + printf '%s%s%s%s%s\n' "${C_ACC}" "$BOX_TL" "$rule" "$BOX_TR" "${C_OFF}" + printf '%s%s%s %s%-*s%s %s%s%s\n' "${C_ACC}" "$BOX_V" "${C_OFF}" "${C_ACC}${C_BOLD}" "$width" "$title" "${C_OFF}" \ + "${C_ACC}" "$BOX_V" "${C_OFF}" + for row in "$@"; do + label="${row%%|*}" value="${row#*|}" shade='' + if [ "$label" = "Admin UI" ]; then shade="${C_ACC}${C_BOLD}"; fi + printf '%s%s%s %s%-10s%s %s%-*s%s %s%s%s\n' "${C_ACC}" "$BOX_V" "${C_OFF}" "${C_DIM}" "$label" "${C_OFF}" \ + "$shade" "$((width - 11))" "$value" "${C_OFF}" "${C_ACC}" "$BOX_V" "${C_OFF}" + done + printf '%s%s%s%s%s\n' "${C_ACC}" "$BOX_BL" "$rule" "$BOX_BR" "${C_OFF}" +} + # ---------------------------------------------------------------- terminal INTERACTIVE=0 # a person is at a terminal we can ask ARROWS=0 # that terminal supports the arrow-key menu STTY_SAVED="" -POINTER='>' detect_terminal() { # Piped from curl, stdin is the script itself, so questions go to /dev/tty. @@ -40,19 +187,22 @@ detect_terminal() { ARROWS=1 fi fi - case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in - *UTF-8* | *utf-8* | *UTF8* | *utf8*) POINTER='❯' ;; - esac } restore_terminal() { if [ -n "$STTY_SAVED" ]; then stty "$STTY_SAVED" /dev/null || true + fi + if [ "$STYLE" = 1 ] || [ -n "$STTY_SAVED" ]; then printf '\033[?25h' >/dev/tty 2>/dev/null || true fi } on_interrupt() { + if [ -n "$SPIN_PID" ]; then kill "$SPIN_PID" 2>/dev/null || true; fi + if [ -n "$SPIN_LOG" ]; then rm -f "$SPIN_LOG"; fi + if [ -n "$ENV_TMP" ]; then rm -f "$ENV_TMP"; fi + if [ "$STYLE" = 1 ]; then printf '\r\033[2K'; fi restore_terminal printf '\nCancelled.\n' >&2 exit 130 @@ -74,6 +224,7 @@ read_key() { } # menu "Question" DEFAULT OPTION... -> sets CHOICE to the 1-based pick. +# An option may carry a note after a tab, shown dimmed and lined up. menu() { question="$1" CHOICE="$2" @@ -81,24 +232,38 @@ menu() { count=$# if [ "$INTERACTIVE" != 1 ]; then return 0; fi - printf '\n%s\n' "$question" >/dev/tty + tab="$(printf '\t')" + pad=0 + for opt in "$@"; do + label="${opt%%"$tab"*}" + if [ ${#label} -gt "$pad" ]; then pad=${#label}; fi + done + + if [ "$STYLE" = 1 ]; then + printf '\n%s%s%s %s%s%s\n' "${C_ACC}" "$S_ASK" "${C_OFF}" "${C_BOLD}" "$question" "${C_OFF}" >/dev/tty + else + printf '\n%s\n' "$question" >/dev/tty + fi if [ "$ARROWS" = 1 ]; then - trap on_interrupt INT TERM stty -icanon -echo min 1 time 0 /dev/tty first=1 while :; do - [ "$first" = 1 ] || printf '\033[%sA' "$count" >/dev/tty + [ "$first" = 1 ] || printf '\033[%sA' "$((count + 1))" >/dev/tty first=0 i=1 for opt in "$@"; do + label="${opt%%"$tab"*}" note='' + [ "$label" = "$opt" ] || note="${opt#*"$tab"}" if [ "$i" = "$CHOICE" ]; then - printf '\033[2K \033[1;36m%s %s\033[0m\n' "$POINTER" "$opt" >/dev/tty + printf '\033[2K %s%s %-*s%s %s%s%s\n' "${C_ACC}${C_BOLD}" "$POINTER" "$pad" "$label" "${C_OFF}" \ + "${C_DIM}" "$note" "${C_OFF}" >/dev/tty else - printf '\033[2K %s\n' "$opt" >/dev/tty + printf '\033[2K %-*s %s%s%s\n' "$pad" "$label" "${C_DIM}" "$note" "${C_OFF}" >/dev/tty fi i=$((i + 1)) done + printf '\033[2K %s%s%s\n' "${C_DIM}" "$HINT" "${C_OFF}" >/dev/tty key="$(read_key)" case "$key" in up | k) [ "$CHOICE" -gt 1 ] && CHOICE=$((CHOICE - 1)) ;; @@ -107,12 +272,15 @@ menu() { '' | "$(printf '\r')") break ;; esac done - restore_terminal - trap - INT TERM + printf '\033[1A\033[2K' >/dev/tty + stty "$STTY_SAVED" /dev/null || true + printf '\033[?25h' >/dev/tty else i=1 for opt in "$@"; do - printf ' %s) %s\n' "$i" "$opt" >/dev/tty + label="${opt%%"$tab"*}" note='' + [ "$label" = "$opt" ] || note=" ${opt#*"$tab"}" + printf ' %s) %s%s\n' "$i" "$label" "$note" >/dev/tty i=$((i + 1)) done printf 'Choose [%s]: ' "$CHOICE" >/dev/tty @@ -138,18 +306,20 @@ port_free() { pick_folder() { home_dir="$HOME/litellm-gateway" here_dir="$(pwd)/litellm-gateway" + found=0 if [ -n "${LITELLM_DIR:-}" ]; then DIR="$LITELLM_DIR" elif [ -f "$here_dir/.env" ]; then - DIR="$here_dir" # installed in this folder before + DIR="$here_dir" found=1 # installed in this folder before elif [ -f "$home_dir/.env" ]; then - DIR="$home_dir" # installed in the home folder before + DIR="$home_dir" found=1 # installed in the home folder before elif [ "$here_dir" = "$home_dir" ]; then DIR="$home_dir" else + tab="$(printf '\t')" menu "Where should LiteLLM keep its files (.env with your keys, and the compose file)?" 1 \ - "$home_dir recommended, reruns always find it" \ - "$here_dir this folder" + "$(tildify "$home_dir")${tab}recommended, reruns always find it" \ + "$(tildify "$here_dir")${tab}this folder" if [ "$CHOICE" = 2 ]; then DIR="$here_dir"; else DIR="$home_dir"; fi fi created=0 @@ -157,6 +327,23 @@ pick_folder() { mkdir -p "$DIR" cd "$DIR" DIR="$(pwd)" + if [ "$found" = 1 ]; then + step "Found your install in ${C_BOLD}$(tildify "$DIR")${C_OFF}" + else + step "Files go in ${C_BOLD}$(tildify "$DIR")${C_OFF}" + fi + if [ ! -f .env ] && command -v git >/dev/null 2>&1 && + git ls-files --error-unmatch .env >/dev/null 2>&1; then + # An ignore rule does not cover a tracked file, so new keys written here + # would show up as a change to commit. + fail "Git tracks a .env file in this folder," "so your keys could be committed." + cat >&2 <<'EOF' + + Install into another folder (set LITELLM_DIR), or stop tracking the file + first with: git rm --cached .env +EOF + exit 1 + fi if [ "$created" = 1 ]; then # A folder this script made holds only its own files, so keep all of it out of git. printf '*\n' >.gitignore @@ -169,7 +356,7 @@ pick_folder() { mkdir -p "$(dirname "$exclude")" exclude="$(cd "$(dirname "$exclude")" && pwd)/exclude" printf '/%s.env\n' "$(git rev-parse --show-prefix)" >>"$exclude" - echo "Added .env to this repository's local git exclude list ($exclude), so your keys stay out of commits." + step "Kept .env out of git ${C_DIM}(added it to this repository's local exclude list, $(tildify "$exclude"))" elif ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then # An existing folder outside git: ignore only .env, so it stays out of # commits if the folder becomes a repository later. @@ -196,11 +383,15 @@ pick_port() { while ! port_free "$PORT"; do PORT=$((PORT + 1)) if [ "$PORT" -gt 4099 ]; then - echo "Ports 4000 to 4099 are all in use. Set LITELLM_PORT to a free port and run this again." >&2 + fail "Ports 4000 to 4099 are all in use." "Set LITELLM_PORT to a free port and run this again." exit 1 fi done - [ "$PORT" = 4000 ] || echo "Port 4000 is in use, so LiteLLM will use $PORT." + if [ "$PORT" = 4000 ]; then + step "Port ${C_BOLD}4000${C_OFF} is free" + else + warn "Port 4000 is in use, so LiteLLM will use ${C_BOLD}$PORT${C_OFF}" + fi fi export LITELLM_PORT="$PORT" } @@ -213,30 +404,96 @@ check_new_install() { [ "$DIR" = "$HOME/litellm-gateway" ] || project="litellm-gateway-$(printf '%s' "$DIR" | cksum | cut -d ' ' -f 1)" # Postgres keeps the password it was created with, so a new password over an # old database volume would lock the gateway out. Stop and explain instead. - if docker volume inspect "${project}_postgres_data" >/dev/null 2>&1; then + if "$ENGINE" volume inspect "${project}_postgres_data" >/dev/null 2>&1; then + fail "Found a database from an earlier install" "($ENGINE_NAME volume ${project}_postgres_data)" cat >&2 </dev/null 2>&1; then - open "$url" >/dev/null 2>&1 || true - elif command -v xdg-open >/dev/null 2>&1; then - xdg-open "$url" >/dev/null 2>&1 || true +start_stack() { + "$ENGINE" compose -f docker-compose.quickstart.yml up -d +} + +wait_ready() { + i=0 + until curl -fsS "http://127.0.0.1:$PORT/health/readiness" >/dev/null 2>&1; do + i=$((i + 1)) + if [ "$i" -gt 90 ]; then return 1; fi + sleep 2 + done +} + +open_url() { + for opener in open xdg-open; do + if command -v "$opener" >/dev/null 2>&1 && "$opener" "$1" >/dev/null 2>&1; then + step "Opened ${C_ACC}$1${C_OFF} in your browser" + return 0 + fi + done + printf ' %sOpen%s %s%s%s %swhen you are ready.%s\n' "${C_DIM}" "${C_OFF}" "${C_ACC}" "$1" "${C_OFF}" "${C_DIM}" "${C_OFF}" +} + +# A clipboard to copy the admin password to, or nothing. Over SSH the +# clipboard would be the server's, not yours. +find_clipboard() { + [ -z "${SSH_CONNECTION:-}${SSH_TTY:-}" ] || return 0 + if command -v pbcopy >/dev/null 2>&1; then CLIP=pbcopy + elif [ -n "${WAYLAND_DISPLAY:-}" ] && command -v wl-copy >/dev/null 2>&1; then CLIP=wl-copy + elif [ -n "${DISPLAY:-}" ] && command -v xclip >/dev/null 2>&1; then CLIP="xclip -selection clipboard" + elif [ -n "${DISPLAY:-}" ] && command -v xsel >/dev/null 2>&1; then CLIP="xsel --clipboard --input" + elif command -v clip.exe >/dev/null 2>&1; then CLIP=clip.exe fi } +# The admin password is the master key. It goes through a pipe, so it is +# never on screen or in the process list. +copy_password() { + pw="$(sed -n 's/^LITELLM_MASTER_KEY=//p' .env | head -n 1)" + if [ -z "$pw" ]; then + warn "No LITELLM_MASTER_KEY in $(tildify "$DIR")/.env." + return 0 + fi + # shellcheck disable=SC2086 # CLIP is a command and its flags + if printf '%s' "$pw" | $CLIP >/dev/null 2>&1; then + step "Copied the admin password. Paste it into the sign-in page." + else + warn "Could not copy it. The password is LITELLM_MASTER_KEY in $(tildify "$DIR")/.env." + fi + pw='' +} + +# After the summary: copy the password, open the admin UI, or finish. The +# menu comes back after each choice with the next step as its default. +next_steps() { + url="$1" + [ "$INTERACTIVE" = 1 ] || return 0 + if [ -z "$CLIP" ]; then + menu "Open the admin UI in your browser?" 1 "Yes" "No" + if [ "$CHOICE" = 1 ]; then open_url "$url"; fi + return 0 + fi + tab="$(printf '\t')" + pick=1 + while :; do + menu "What next?" "$pick" \ + "Copy the admin password${tab}to the clipboard, ready to paste" \ + "Open the admin UI in your browser" \ + "Done" + case "$CHOICE" in + 1) copy_password; pick=2 ;; + 2) open_url "$url"; pick=3 ;; + *) return 0 ;; + esac + done +} + main() { NO_QUESTIONS=0 for arg in "$@"; do @@ -246,37 +503,84 @@ main() { esac done + setup_output detect_terminal # Agents and CI get the defaults even inside a terminal, so nothing waits on a keypress. if [ "$NO_QUESTIONS" = 1 ] || [ -n "${CI:-}" ] || [ -n "${CLAUDECODE:-}" ]; then INTERACTIVE=0; fi trap restore_terminal EXIT + trap on_interrupt INT TERM - if ! command -v docker >/dev/null 2>&1; then + if [ "$STYLE" = 1 ]; then + printf '\n%s%s LiteLLM quickstart%s\n' "${C_ACC}${C_BOLD}" "$S_HEAD" "${C_OFF}" + printf '%s The gateway, Postgres, and the admin UI in one command%s\n\n' "${C_DIM}" "${C_OFF}" + else + echo "LiteLLM quickstart" + fi + + # Podman and Rancher Desktop (nerdctl in containerd mode; its dockerd mode + # already provides a docker CLI) work the same way through Compose v2. + ENGINE='' + for cand in docker podman nerdctl; do + if command -v "$cand" >/dev/null 2>&1; then + ENGINE="$cand" + break + fi + done + if [ -z "$ENGINE" ]; then + fail "No container engine found (docker, podman, or nerdctl)." "The LiteLLM Gateway runs in a container alongside a Postgres database." cat >&2 <<'EOF' -Docker is not installed. The LiteLLM Gateway runs in Docker alongside a Postgres database. - Install Docker, then run this again: https://docs.docker.com/get-docker/ + Install Docker (https://docs.docker.com/get-docker/), Podman (https://podman.io), + or Rancher Desktop (https://rancherdesktop.io), then run this again. Or deploy in one click (Railway or Render): https://docs.litellm.ai/docs/proxy/docker_quick_start Only need to call models from Python? pip install litellm EOF exit 1 fi - docker compose version >/dev/null 2>&1 || { echo "Docker Compose v2 ('docker compose') is required." >&2; exit 1; } - docker info >/dev/null 2>&1 || { echo "Docker is installed but not running. Start it and run this again." >&2; exit 1; } - command -v openssl >/dev/null 2>&1 || { echo "openssl is required to generate keys." >&2; exit 1; } + case "$ENGINE" in + podman) ENGINE_NAME=Podman ;; + nerdctl) ENGINE_NAME=nerdctl ;; + *) ENGINE_NAME=Docker ;; + esac + compose_version="$("$ENGINE" compose version --short 2>/dev/null)" || + { fail "Compose v2 ('$ENGINE compose') is required."; exit 1; } + if ! "$ENGINE" info >/dev/null 2>&1; then + if [ "$ENGINE" = podman ]; then + fail "Podman is installed but not running." "Start it (podman machine start) and run this again." + else + fail "$ENGINE_NAME is installed but not running." "Start it and run this again." + fi + exit 1 + fi + command -v openssl >/dev/null 2>&1 || { fail "openssl is required to generate keys."; exit 1; } + engine_version="$("$ENGINE" version --format '{{.Server.Version}}' 2>/dev/null)" || engine_version='' + step "$ENGINE_NAME ${engine_version:+$engine_version }with Compose ${compose_version#v} is running" - echo "LiteLLM quickstart" pick_folder [ -f .env ] || check_new_install curl -fsSL -o docker-compose.quickstart.yml "$COMPOSE_URL" + step "Downloaded docker-compose.quickstart.yml" pick_port if [ -f .env ]; then - echo "Reusing $DIR/.env, so existing keys and data keep working." + step "Reusing .env, so existing keys and data keep working" else - (umask 077 && printf 'LITELLM_MASTER_KEY=sk-%s\nLITELLM_SALT_KEY=sk-%s\nPOSTGRES_PASSWORD=%s\nLITELLM_PORT=%s\nLITELLM_BIND=127.0.0.1:\nCOMPOSE_PROJECT_NAME=%s\n' \ - "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" "$(openssl rand -hex 24)" "$PORT" "$project" >.env) - echo "Generated $DIR/.env with your master key, salt key, and database password. Keep this file." + master="$(openssl rand -hex 32)" + salt="$(openssl rand -hex 32)" + db_password="$(openssl rand -hex 24)" + # Write a fresh mktemp file (mode 600, never a reused one) and rename it, so a + # failed write never leaves a partial .env that a rerun would mistake for a + # finished install. + if ! ENV_TMP="$(mktemp .env.XXXXXX)" || + ! printf 'LITELLM_MASTER_KEY=sk-%s\nLITELLM_SALT_KEY=sk-%s\nPOSTGRES_PASSWORD=%s\nLITELLM_PORT=%s\nLITELLM_BIND=127.0.0.1:\nCOMPOSE_PROJECT_NAME=%s\n' \ + "$master" "$salt" "$db_password" "$PORT" "$project" >"$ENV_TMP" || ! mv -f "$ENV_TMP" .env; then + if [ -n "$ENV_TMP" ]; then rm -f "$ENV_TMP"; fi + fail "Could not write $(tildify "$DIR")/.env." + exit 1 + fi + ENV_TMP='' + unset master salt db_password + step "Generated .env with your master key, salt key, and database password ${C_DIM}(keep this file; only you can read it)" fi # Compose prefers values already set in the shell over .env, so drop any @@ -287,28 +591,39 @@ EOF # is kept, so an intentional LITELLM_BIND=127.0.0.1: is not dropped. if grep -q '^LITELLM_BIND=' .env; then unset LITELLM_BIND; fi - echo "Starting LiteLLM and Postgres (the first run downloads the images)..." - docker compose -f docker-compose.quickstart.yml up -d - - i=0 - until curl -fsS "http://127.0.0.1:$PORT/health/readiness" >/dev/null 2>&1; do - i=$((i + 1)) - if [ "$i" -gt 90 ]; then - echo "The gateway did not become ready in 3 minutes. Check: cd $DIR && docker compose -f docker-compose.quickstart.yml logs litellm" >&2 - exit 1 - fi - sleep 2 + started="$(date +%s)" + detail='' + for image in $("$ENGINE" compose -f docker-compose.quickstart.yml config --images 2>/dev/null); do + "$ENGINE" image inspect "$image" >/dev/null 2>&1 || detail="downloading images, first run only" done + [ "$STYLE" = 1 ] || echo "Starting LiteLLM and Postgres (the first run downloads the images)..." + if ! spin "Starting LiteLLM and Postgres" "$detail" start_stack; then + fail "$ENGINE_NAME could not start LiteLLM and Postgres." "Its output is above." + exit 1 + fi + if ! spin "Waiting for the gateway to be ready" "" wait_ready; then + fail "The gateway did not become ready in 3 minutes." "See what it logged:" + echo " cd $(tildify "$DIR") && $ENGINE compose -f docker-compose.quickstart.yml logs litellm" >&2 + exit 1 + fi + step "LiteLLM and Postgres are up ${C_DIM}· $(elapsed_since "$started")" + url="http://localhost:$PORT/ui" + password="the LITELLM_MASTER_KEY value in $(tildify "$DIR")/.env" + CLIP='' + if [ "$INTERACTIVE" = 1 ]; then find_clipboard; fi + if [ -n "$CLIP" ]; then + password="copy it below, or LITELLM_MASTER_KEY in $(tildify "$DIR")/.env" + fi echo - echo "LiteLLM is running." - echo " Admin UI: http://localhost:$PORT/ui" - echo " Username: admin" - echo " Password: the LITELLM_MASTER_KEY value in $DIR/.env" - echo " Next: in the UI, open Models + Endpoints > Add Model and paste a provider API key" - echo " Stop it: cd $DIR && docker compose -f docker-compose.quickstart.yml down" + box "LiteLLM is running" \ + "Admin UI|$url" \ + "Username|admin" \ + "Password|$password" \ + "Next|in the UI, open Models + Endpoints > Add Model and paste a provider API key" \ + "Stop it|cd $(tildify "$DIR") && $ENGINE compose -f docker-compose.quickstart.yml down" - open_browser "http://localhost:$PORT/ui" + next_steps "$url" } main "$@"