litellm/scripts/quickstart.ps1
Misbah Syed fd14e51ecf
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>
2026-10-03 13:35:32 -07:00

643 lines
31 KiB
PowerShell

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