mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
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:
parent
dd31692282
commit
fd14e51ecf
2 changed files with 1022 additions and 64 deletions
643
scripts/quickstart.ps1
Normal file
643
scripts/quickstart.ps1
Normal 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 }
|
||||
|
|
@ -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 "$@"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue