litellm/scripts/quickstart.sh
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

629 lines
23 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 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. 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=""
detect_terminal() {
# Piped from curl, stdin is the script itself, so questions go to /dev/tty.
if (exec </dev/tty) 2>/dev/null && [ "${TERM:-dumb}" != "dumb" ]; then
INTERACTIVE=1
if STTY_SAVED="$(stty -g </dev/tty 2>/dev/null)" && [ -n "$STTY_SAVED" ]; then
ARROWS=1
fi
fi
}
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
}
read_key() {
# One keypress in raw mode. Enter comes back empty (command substitution
# drops the newline); arrows come back as "up" or "down".
k="$(dd bs=1 count=1 2>/dev/null </dev/tty)"
if [ "$k" = "$(printf '\033')" ]; then
rest="$(dd bs=1 count=2 2>/dev/null </dev/tty)"
case "$rest" in
'[A' | 'OA') k=up ;;
'[B' | 'OB') k=down ;;
*) k=other ;;
esac
fi
printf '%s' "$k"
}
# 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"
shift 2
count=$#
if [ "$INTERACTIVE" != 1 ]; then return 0; fi
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
stty -icanon -echo min 1 time 0 </dev/tty
printf '\033[?25l' >/dev/tty
first=1
while :; do
[ "$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 %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 %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)) ;;
down | j) [ "$CHOICE" -lt "$count" ] && CHOICE=$((CHOICE + 1)) ;;
[1-9]) [ "$key" -le "$count" ] && CHOICE="$key" ;;
'' | "$(printf '\r')") break ;;
esac
done
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
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
answer=""
read -r answer </dev/tty || answer=""
case "$answer" in
'' | *[!0-9]*) ;;
*) [ "$answer" -ge 1 ] && [ "$answer" -le "$count" ] && CHOICE="$answer" ;;
esac
fi
return 0
}
# ---------------------------------------------------------------- steps
port_free() {
# curl exits 7 when nothing accepts the connection.
rc=0
curl -s -o /dev/null --max-time 2 "http://127.0.0.1:$1/" || rc=$?
[ "$rc" = 7 ]
}
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" found=1 # installed in this folder before
elif [ -f "$home_dir/.env" ]; then
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 \
"$(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
[ -d "$DIR" ] || created=1
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
elif command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1 &&
! git check-ignore -q .env 2>/dev/null; then
# 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="$(git rev-parse --git-path info/exclude)"
mkdir -p "$(dirname "$exclude")"
exclude="$(cd "$(dirname "$exclude")" && pwd)/exclude"
printf '/%s.env\n' "$(git rev-parse --show-prefix)" >>"$exclude"
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.
if ! grep -qxF '.env' .gitignore 2>/dev/null; then
# Start on a new line if the file does not end with one.
if [ -s .gitignore ] && [ -n "$(tail -c 1 .gitignore)" ]; then printf '\n' >>.gitignore; fi
printf '.env\n' >>.gitignore
fi
fi
}
pick_port() {
saved=""
[ -f .env ] && saved="$(sed -n 's/^LITELLM_PORT=//p' .env | tail -n 1)"
if [ -n "${LITELLM_PORT:-}" ]; then
PORT="$LITELLM_PORT"
elif [ -n "$saved" ]; then
PORT="$saved"
elif [ -f .env ]; then
# An existing install without a saved port runs on the compose default.
PORT=4000
else
PORT=4000
while ! port_free "$PORT"; do
PORT=$((PORT + 1))
if [ "$PORT" -gt 4099 ]; then
fail "Ports 4000 to 4099 are all in use." "Set LITELLM_PORT to a free port and run this again."
exit 1
fi
done
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"
}
# Docker 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.
check_new_install() {
project=litellm-gateway
[ "$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 "$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
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):
$ENGINE volume rm ${project}_postgres_data
EOF
exit 1
fi
}
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
case "$arg" in
-y | --yes) NO_QUESTIONS=1 ;;
*) echo "Unknown option: $arg" >&2; exit 1 ;;
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 [ "$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'
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
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"
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
step "Reusing .env, so existing keys and data keep working"
else
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
# inherited ones: .env stays the only source for keys and the project name.
unset LITELLM_MASTER_KEY LITELLM_SALT_KEY POSTGRES_PASSWORD COMPOSE_PROJECT_NAME
# The bind address follows .env when .env sets it (every install this script
# creates does). For an older .env without it, a value exported in the shell
# 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
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
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"
next_steps "$url"
}
main "$@"