feat(docker): add a Windows quickstart in PowerShell, and a styled terminal for both quickstarts (#44310)

* feat(docker): Windows quickstart in PowerShell, and a styled terminal for both quickstarts

scripts/quickstart.ps1 does what scripts/quickstart.sh does, for Windows:
  powershell -c "irm https://raw.githubusercontent.com/BerriAI/litellm/main/scripts/quickstart.ps1 | iex"
It asks the same two questions, prints the same lines, writes the same .env
(UTF-8 without a byte order mark, LF endings, readable only by the current
Windows account), and runs in Windows PowerShell 5.1 and PowerShell 7.

Both scripts now give a person at a terminal colors, a check mark per step, a
spinner while Docker starts, and a framed summary. Agents, CI, log files, and
NO_COLOR get the same lines as plain text.

* feat(docker): support podman and rancher desktop in the quickstart scripts

Both quickstarts hard-required the docker CLI. They now pick the first
available engine among docker, podman, and nerdctl (Rancher Desktop in
containerd mode; its dockerd mode already provides a docker CLI), route
every invocation through it, and tailor the start hint (podman machine
start) and the printed stop/logs/volume commands to that engine

* fix(docker): test port availability by binding instead of connecting

On a WSL2-backed engine (Podman, Rancher Desktop), the Windows localhost
relay swallows connection refusals on closed ports, so every connect
waits out its 2-second timeout and Test-PortFree reported ports 4000 to
4099 all taken on a machine with none of them in use. Binding the port
answers instantly and accurately

* fix(quickstart): address review findings

- The PowerShell script names the project with the same POSIX cksum as the
  shell script, so the earlier-install check sees the database from either
  script.
- Both scripts stop when git tracks .env in the install folder.
- .env is written to a temp file with owner-only permissions and moved into
  place, so a failed write never leaves a partial file.
- Errors stay plain text when stderr is redirected.
- The spinners remove their temp files on exit and Ctrl+C, and the shell
  spinner no longer runs date on every frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(quickstart): offer to copy the admin password to the clipboard

After the summary, the quickstarts ask "What next?": copy the admin
password, open the admin UI, or finish. The password is piped to the
clipboard (pbcopy, wl-copy, xclip, xsel, clip.exe, or Set-Clipboard), so
it never appears on screen or in the process list. Over SSH, or with no
clipboard, the option is left out and the summary points to .env as
before. The PowerShell menu now honours Ctrl+C.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(quickstart): write .env through mktemp, let Ctrl+C cancel the PowerShell menu, drop redundant comments

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(quickstart): remove the in-progress .env temp file when interrupted

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Mubashir Osmani <mubashir.osmani777@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Misbah Syed 2026-10-03 13:35:32 -07:00 • committed by GitHub
parent dd31692282
commit fd14e51ecf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 1022 additions and 64 deletions

643
scripts/quickstart.ps1 Normal file
View file

@ -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 }

View file

@ -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/tty) 2>/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/tty 2>/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
printf '\033[?25l' >/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/tty 2>/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 <<EOF
Found a database from an earlier install (Docker volume ${project}_postgres_data)
but no $DIR/.env with its password.
but no $(tildify "$DIR")/.env with its password.
Restore that .env file and run this again to keep your models and keys, or
delete the old database and start fresh (this removes its models and keys):
docker volume rm ${project}_postgres_data
$ENGINE volume rm ${project}_postgres_data
EOF
exit 1
fi
}
open_browser() {
url="$1"
menu "Open the admin UI in your browser?" 1 "Yes" "No"
[ "$INTERACTIVE" = 1 ] && [ "$CHOICE" = 1 ] || return 0
if command -v open >/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 "$@"