From db7075b080dd8cd077f26aa102cdf4166653688e Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Sat, 14 Mar 2026 11:24:47 -0700 Subject: [PATCH] feat(setup): add interactive setup wizard + install.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `litellm --setup` — a Claude Code-style TUI onboarding wizard that guides users through provider selection, API key entry, and proxy config generation, then optionally starts the proxy immediately. - litellm/setup_wizard.py: wizard with ASCII art, numbered provider menu (OpenAI, Anthropic, Azure, Gemini, Bedrock, Ollama), API key prompts, port/master-key config, and litellm_config.yaml generation - litellm/proxy/proxy_cli.py: adds --setup flag that invokes the wizard - scripts/install.sh: curl-installable script (detect OS/Python, pip install litellm[proxy], launch wizard) Usage: curl -fsSL https://raw.githubusercontent.com/BerriAI/litellm/main/scripts/install.sh | sh litellm --setup --- litellm/proxy/proxy_cli.py | 15 +- litellm/setup_wizard.py | 409 +++++++++++++++++++++++++++++++++++++ scripts/install.sh | 134 ++++++++++++ 3 files changed, 557 insertions(+), 1 deletion(-) create mode 100644 litellm/setup_wizard.py create mode 100755 scripts/install.sh diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index e5a34ae8bdd..97d5de0d53d 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -468,6 +468,12 @@ class ProxyInitializationHelpers: type=str, help="Path to the logging configuration file", ) +@click.option( + "--setup", + is_flag=True, + default=False, + help="Run the interactive setup wizard to configure providers and generate a config file", +) @click.option( "--version", "-v", @@ -598,6 +604,7 @@ def run_server( # noqa: PLR0915 num_requests, use_queue, health, + setup, version, run_gunicorn, run_hypercorn, @@ -611,6 +618,12 @@ def run_server( # noqa: PLR0915 max_requests_before_restart, enforce_prisma_migration_check: bool, ): + if setup: + from litellm.setup_wizard import run_setup_wizard + + run_setup_wizard() + return + args = locals() if local: from proxy_server import ( @@ -904,7 +917,7 @@ def run_server( # noqa: PLR0915 # Auto-create PROMETHEUS_MULTIPROC_DIR for multi-worker setups ProxyInitializationHelpers._maybe_setup_prometheus_multiproc_dir( num_workers=num_workers, - litellm_settings=litellm_settings if config else None, + litellm_settings=litellm_settings if config else None, # type: ignore[possibly-unbound] ) # --- SEPARATE HEALTH APP LOGIC --- diff --git a/litellm/setup_wizard.py b/litellm/setup_wizard.py new file mode 100644 index 00000000000..f344e99e4ae --- /dev/null +++ b/litellm/setup_wizard.py @@ -0,0 +1,409 @@ +# ruff: noqa: T201 +# flake8: noqa: T201 +""" +LiteLLM Interactive Setup Wizard + +Guides users through selecting LLM providers, entering API keys, +and generating a proxy config file — mirroring the Claude Code onboarding UX. +""" + +import importlib.metadata +import os +import secrets +import sys +from pathlib import Path +from typing import Dict, List, Optional + +# --------------------------------------------------------------------------- +# Provider definitions +# --------------------------------------------------------------------------- + +PROVIDERS = [ + { + "id": "openai", + "name": "OpenAI", + "description": "GPT-4o, GPT-4o-mini, o1", + "env_key": "OPENAI_API_KEY", + "key_hint": "sk-...", + "models": ["gpt-4o", "gpt-4o-mini"], + }, + { + "id": "anthropic", + "name": "Anthropic", + "description": "Claude Opus, Sonnet, Haiku", + "env_key": "ANTHROPIC_API_KEY", + "key_hint": "sk-ant-...", + "models": ["claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5-20251001"], + }, + { + "id": "azure", + "name": "Azure OpenAI", + "description": "GPT-4o via Azure", + "env_key": "AZURE_API_KEY", + "key_hint": "your-azure-key", + "models": [], + "needs_api_base": True, + "api_base_hint": "https://.openai.azure.com/", + "api_version": "2024-07-01-preview", + }, + { + "id": "gemini", + "name": "Google Gemini", + "description": "Gemini 2.0 Flash, Gemini 1.5 Pro", + "env_key": "GEMINI_API_KEY", + "key_hint": "AIza...", + "models": ["gemini/gemini-2.0-flash", "gemini/gemini-1.5-pro"], + }, + { + "id": "bedrock", + "name": "AWS Bedrock", + "description": "Claude, Llama via AWS", + "env_key": "AWS_ACCESS_KEY_ID", + "key_hint": "AKIA...", + "models": ["bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0"], + "needs_extra": True, + "extra_keys": ["AWS_SECRET_ACCESS_KEY", "AWS_REGION_NAME"], + "extra_hints": ["your-secret-key", "us-east-1"], + }, + { + "id": "ollama", + "name": "Ollama", + "description": "Local models (llama3, mistral, etc.)", + "env_key": None, + "key_hint": None, + "models": ["ollama/llama3.2", "ollama/mistral"], + "api_base": "http://localhost:11434", + }, +] + + +# --------------------------------------------------------------------------- +# ANSI colour helpers (no external deps needed) +# --------------------------------------------------------------------------- + +_ORANGE = "\033[38;2;215;119;87m" +_DIM = "\033[2m" +_BOLD = "\033[1m" +_GREEN = "\033[38;2;78;186;101m" +_BLUE = "\033[38;2;177;185;249m" +_GREY = "\033[38;2;153;153;153m" +_RESET = "\033[0m" +_CHECK = "✔" + + +def _supports_color() -> bool: + return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None + + +def _c(code: str, text: str) -> str: + if _supports_color(): + return f"{code}{text}{_RESET}" + return text + + +def orange(t: str) -> str: + return _c(_ORANGE, t) + + +def bold(t: str) -> str: + return _c(_BOLD, t) + + +def green(t: str) -> str: + return _c(_GREEN, t) + + +def blue(t: str) -> str: + return _c(_BLUE, t) + + +def grey(t: str) -> str: + return _c(_GREY, t) + + +def dim(t: str) -> str: + return _c(_DIM, t) + + +# --------------------------------------------------------------------------- +# ASCII art +# --------------------------------------------------------------------------- + +LITELLM_ASCII = r""" + ██╗ ██╗████████╗███████╗██╗ ██╗ ███╗ ███╗ + ██║ ██║╚══██╔══╝██╔════╝██║ ██║ ████╗ ████║ + ██║ ██║ ██║ █████╗ ██║ ██║ ██╔████╔██║ + ██║ ██║ ██║ ██╔══╝ ██║ ██║ ██║╚██╔╝██║ + ███████╗██║ ██║ ███████╗███████╗███████╗██║ ╚═╝ ██║ + ╚══════╝╚═╝ ╚═╝ ╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝ +""" + +DIVIDER = dim(" " + "╌" * 74) + + +def _print_welcome() -> None: + try: + version = importlib.metadata.version("litellm") + except Exception: + version = "unknown" + + print() + print(orange(LITELLM_ASCII.rstrip("\n"))) + print(f" {orange('Welcome')} to {bold('LiteLLM')} {grey('v' + version)}") + print() + print(DIVIDER) + print() + + +# --------------------------------------------------------------------------- +# Provider selection +# --------------------------------------------------------------------------- + +def _print_provider_menu(selected: List[int]) -> None: + print() + print(f" {bold('Choose your LLM providers')}") + print(grey(" Enter numbers separated by commas (e.g. 1,2). Press Enter to confirm.")) + print() + for i, p in enumerate(PROVIDERS, 1): + bullet = green(f"◉ {i}.") if (i in selected) else grey(f"○ {i}.") + name = bold(p["name"]) + desc = grey(p["description"]) + print(f" {bullet} {name} {desc}") + print() + + +def _select_providers() -> List[Dict]: + selected_nums: List[int] = [] + _print_provider_menu(selected_nums) + + while True: + raw = input(f" {blue('❯')} Provider(s): ").strip() + if not raw: + if not selected_nums: + print(grey(" Please select at least one provider.")) + continue + break + try: + nums = [int(x.strip()) for x in raw.replace(" ", ",").split(",") if x.strip()] + valid = [n for n in nums if 1 <= n <= len(PROVIDERS)] + if not valid: + print(grey(f" Enter numbers between 1 and {len(PROVIDERS)}.")) + continue + selected_nums = sorted(set(valid)) + _print_provider_menu(selected_nums) + except ValueError: + print(grey(" Enter numbers separated by commas, e.g. 1,3")) + + return [PROVIDERS[i - 1] for i in selected_nums] + + +# --------------------------------------------------------------------------- +# API key collection +# --------------------------------------------------------------------------- + +def _collect_keys(providers: List[Dict]) -> Dict[str, str]: + env_vars: Dict[str, str] = {} + print() + print(DIVIDER) + print() + print(f" {bold('Enter your API keys')}") + print(grey(" Keys are stored only in the generated config file.")) + print() + + for p in providers: + if p["env_key"] is None: + # Ollama — no key needed + print(f" {green(p['name'])}: {grey('no key needed (uses local Ollama)')}") + continue + + hint = grey(p.get("key_hint", "")) + key = "" + while not key: + key = input(f" {blue('❯')} {bold(p['name'])} API key {hint}: ").strip() + if not key: + print(grey(" Key is required. Leave blank to skip this provider.")) + skip = input(grey(" Skip? (y/N): ")).strip().lower() + if skip == "y": + break + + if key: + env_vars[p["env_key"]] = key + + # Extra keys (e.g. AWS secret + region) + if p.get("needs_extra") and key: + for extra_key, extra_hint in zip( + p.get("extra_keys", []), p.get("extra_hints", []) + ): + val = input( + f" {blue('❯')} {extra_key} {grey(extra_hint)}: " + ).strip() + if val: + env_vars[extra_key] = val + + # API base for Azure + if p.get("needs_api_base") and key: + api_base = input( + f" {blue('❯')} Azure endpoint URL {grey(p.get('api_base_hint', ''))}: " + ).strip() + if api_base: + env_vars[f"_LITELLM_AZURE_API_BASE_{p['id'].upper()}"] = api_base + + return env_vars + + +# --------------------------------------------------------------------------- +# Config generation +# --------------------------------------------------------------------------- + +def _build_config( + providers: List[Dict], + env_vars: Dict[str, str], + port: int, + master_key: str, +) -> str: + lines = ["model_list:"] + + for p in providers: + if not p["models"] and p["id"] == "azure": + # Azure — add a generic placeholder + models_to_add = ["azure/gpt-4o"] + else: + models_to_add = p["models"] + + for model in models_to_add: + # User-facing model name (strip provider prefix for display) + display_name = model.split("/")[-1] if "/" in model else model + lines.append(f" - model_name: {display_name}") + lines.append(f" litellm_params:") + lines.append(f" model: {model}") + + if p["env_key"] and p["env_key"] in env_vars: + lines.append(f" api_key: os.environ/{p['env_key']}") + + if p.get("api_base"): + lines.append(f" api_base: {p['api_base']}") + elif p.get("needs_api_base"): + azure_base_key = f"_LITELLM_AZURE_API_BASE_{p['id'].upper()}" + if azure_base_key in env_vars: + lines.append(f" api_base: {env_vars.pop(azure_base_key)}") + if p.get("api_version"): + lines.append(f" api_version: {p['api_version']}") + + lines.append("") + lines.append("general_settings:") + lines.append(f" master_key: {master_key}") + lines.append("") + + # Write env vars inline so the config is self-contained + real_env_vars = {k: v for k, v in env_vars.items() if not k.startswith("_LITELLM_")} + if real_env_vars: + lines.append("environment_variables:") + for k, v in real_env_vars.items(): + lines.append(f" {k}: \"{v}\"") + lines.append("") + + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Proxy settings +# --------------------------------------------------------------------------- + +def _proxy_settings() -> tuple[int, str]: + print() + print(DIVIDER) + print() + print(f" {bold('Proxy settings')}") + print() + + port_raw = input(f" {blue('❯')} Port {grey('[4000]')}: ").strip() + port = int(port_raw) if port_raw.isdigit() else 4000 + + key_raw = input( + f" {blue('❯')} Master key {grey('[auto-generate]')}: " + ).strip() + master_key = key_raw if key_raw else f"sk-{secrets.token_urlsafe(32)}" + + return port, master_key + + +# --------------------------------------------------------------------------- +# Main wizard entrypoint +# --------------------------------------------------------------------------- + +def run_setup_wizard() -> Optional[str]: + """ + Run the interactive setup wizard. + + Returns the path to the generated config file, or None if aborted. + """ + try: + _run_wizard() + except (KeyboardInterrupt, EOFError): + print(f"\n\n {grey('Setup cancelled.')}\n") + return None + return None # caller receives path via side effect (printed to stdout) + + +def _run_wizard() -> None: + _print_welcome() + + print(f" {bold('Lets get started.')}") + print() + + # Step 1: providers + providers = _select_providers() + + # Step 2: API keys + env_vars = _collect_keys(providers) + + # Step 3: proxy settings + port, master_key = _proxy_settings() + + # Step 4: write config + config_content = _build_config(providers, env_vars, port, master_key) + + config_path = Path(os.getcwd()) / "litellm_config.yaml" + config_path.write_text(config_content) + + # Step 5: print success + print() + print(DIVIDER) + print() + print(f" {green(_CHECK + ' Config saved')} → {bold(str(config_path))}") + print() + print(f" {bold('To start your proxy:')}") + print() + print(f" {grey('$')} litellm --config {config_path}") + print() + print(f" {bold('Then set your client:')}") + print() + print(f" export OPENAI_BASE_URL=http://localhost:{port}") + print(f" export OPENAI_API_KEY={master_key}") + print() + print(DIVIDER) + print() + + # Step 6: offer to start now + start = input(f" {blue('❯')} Start the proxy now? {grey('(Y/n)')}: ").strip().lower() + if start in ("", "y", "yes"): + print() + print(f" {green(_CHECK)} Starting LiteLLM proxy on port {bold(str(port))}…") + print() + # exec replaces this process with the proxy server + os.execlp( # noqa: S606 + sys.executable, + sys.executable, + "-m", + "litellm", + "--config", + str(config_path), + "--port", + str(port), + ) + else: + print() + print( + f" Run {bold(f'litellm --config {config_path}')} whenever you're ready." + ) + print() diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 00000000000..cc3d1efd412 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# LiteLLM Installer +# Usage: curl -fsSL https://litellm.ai/install.sh | sh +set -euo pipefail + +LITELLM_PACKAGE="litellm[proxy]" +MIN_PYTHON_MAJOR=3 +MIN_PYTHON_MINOR=9 + +# ── colours ──────────────────────────────────────────────────────────────── +if [ -t 1 ] && command -v tput >/dev/null 2>&1; then + ORANGE='\033[38;2;215;119;87m' + BOLD='\033[1m' + GREEN='\033[38;2;78;186;101m' + GREY='\033[38;2;153;153;153m' + RESET='\033[0m' +else + ORANGE='' BOLD='' GREEN='' GREY='' RESET='' +fi + +info() { printf "${GREY} %s${RESET}\n" "$*"; } +success() { printf "${GREEN} ✔ %s${RESET}\n" "$*"; } +header() { printf "${ORANGE} %s${RESET}\n" "$*"; } +die() { printf "\n Error: %s\n\n" "$*" >&2; exit 1; } + +# ── banner ───────────────────────────────────────────────────────────────── +echo "" +printf "${ORANGE}" +cat << 'EOF' + ██╗ ██╗████████╗███████╗██╗ ██╗ ███╗ ███╗ + ██║ ██║╚══██╔══╝██╔════╝██║ ██║ ████╗ ████║ + ██║ ██║ ██║ █████╗ ██║ ██║ ██╔████╔██║ + ██║ ██║ ██║ ██╔══╝ ██║ ██║ ██║╚██╔╝██║ + ███████╗██║ ██║ ███████╗███████╗███████╗██║ ╚═╝ ██║ + ╚══════╝╚═╝ ╚═╝ ╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝ +EOF +printf "${RESET}" +printf " ${BOLD}LiteLLM Installer${RESET} ${GREY}— unified gateway for 100+ LLM providers${RESET}\n\n" + +# ── OS detection ─────────────────────────────────────────────────────────── +OS="$(uname -s)" +ARCH="$(uname -m)" + +case "$OS" in + Darwin) PLATFORM="macOS ($ARCH)" ;; + Linux) PLATFORM="Linux ($ARCH)" ;; + *) die "Unsupported OS: $OS. LiteLLM supports macOS and Linux." ;; +esac + +info "Platform: $PLATFORM" + +# ── Python detection ─────────────────────────────────────────────────────── +PYTHON_BIN="" +for candidate in python3 python; do + if command -v "$candidate" >/dev/null 2>&1; then + py_ver="$("$candidate" -c 'import sys; print(sys.version_info[:2])' 2>/dev/null || true)" + major="$("$candidate" -c 'import sys; print(sys.version_info.major)' 2>/dev/null || true)" + minor="$("$candidate" -c 'import sys; print(sys.version_info.minor)' 2>/dev/null || true)" + if [ "${major:-0}" -ge "$MIN_PYTHON_MAJOR" ] && [ "${minor:-0}" -ge "$MIN_PYTHON_MINOR" ]; then + PYTHON_BIN="$candidate" + info "Python: $("$candidate" --version 2>&1)" + break + fi + fi +done + +if [ -z "$PYTHON_BIN" ]; then + die "Python ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR}+ is required but not found. + Install it from https://python.org/downloads or via your package manager: + macOS: brew install python@3 + Ubuntu: sudo apt install python3 python3-pip" +fi + +# ── pip detection ────────────────────────────────────────────────────────── +if ! "$PYTHON_BIN" -m pip --version >/dev/null 2>&1; then + die "pip is not available. Install it with: + $PYTHON_BIN -m ensurepip --upgrade + or: + curl https://bootstrap.pypa.io/get-pip.py | $PYTHON_BIN" +fi + +# ── install ──────────────────────────────────────────────────────────────── +echo "" +header "Installing ${LITELLM_PACKAGE}…" +echo "" + +# Use --quiet to avoid wall of pip output; keep --progress-bar off for cleaner CI +"$PYTHON_BIN" -m pip install --quiet --progress-bar off "${LITELLM_PACKAGE}" \ + || die "pip install failed. Try manually: $PYTHON_BIN -m pip install '${LITELLM_PACKAGE}'" + +# Verify litellm is on PATH (or accessible as python -m litellm) +LITELLM_BIN="$(command -v litellm 2>/dev/null || true)" +if [ -z "$LITELLM_BIN" ]; then + # Might be installed in a user PATH that isn't active yet + USER_BIN="$("$PYTHON_BIN" -c 'import site,os; print(site.getuserbase())')/bin" + if [ -x "$USER_BIN/litellm" ]; then + LITELLM_BIN="$USER_BIN/litellm" + info "Note: $LITELLM_BIN is not in your PATH yet." + info "Add this to your shell profile:" + info " export PATH=\"\$PATH:$USER_BIN\"" + fi +fi + +echo "" +success "LiteLLM installed" + +# ── version check ────────────────────────────────────────────────────────── +if [ -n "$LITELLM_BIN" ]; then + installed_ver="$("$LITELLM_BIN" --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" + [ -n "$installed_ver" ] && info "Version: $installed_ver" +fi + +# ── launch setup wizard ──────────────────────────────────────────────────── +echo "" +printf " ${BOLD}Run the interactive setup wizard?${RESET} ${GREY}(Y/n)${RESET}: " +read -r answer