mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
feat(setup): interactive setup wizard + install.sh (#23644)
* feat(setup): add interactive setup wizard + install.sh 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 * fix(install.sh): remove orange color, add LITELLM_BRANCH env var for branch installs * fix(install.sh): install from git branch so --setup is available for QA * fix(install.sh): remove stale LITELLM_BRANCH reference that caused unbound variable error * fix(install.sh): force-reinstall from git to bypass cached PyPI version * fix(install.sh): show pip progress bar during install * fix(install.sh): always launch wizard via $PYTHON_BIN -m litellm, not PATH binary * fix(install.sh): use litellm.proxy.proxy_cli module (no __main__.py exists) * fix(install.sh): suppress RuntimeWarning from module invocation * fix(install.sh): use Python bin-dir litellm binary to avoid CWD sys.path shadowing * fix(install.sh): use sysconfig.get_path('scripts') to find pip-installed litellm binary * fix(install.sh): redirect stdin from /dev/tty on exec so wizard gets terminal, not exhausted pipe * fix(install.sh): warn about git clone duration, drop --no-cache-dir so re-runs are faster * feat(setup_wizard): arrow-key selector, updated model names * fix(setup_wizard): use sysconfig binary to start proxy, not python -m litellm * feat(setup_wizard): credential validation after key entry + clear next-steps after proxy start * style(install.sh): show git clone warning in blue * refactor(setup_wizard): class with static methods, use check_valid_key from litellm.utils * address greptile review: fix yaml escaping, port validation, display name collisions, tests - setup_wizard.py: add _yaml_escape() for safe YAML embedding of API keys - setup_wizard.py: add _styled_input() with readline ANSI ignore markers - setup_wizard.py: change DIVIDER to _divider() fn to avoid import-time color capture - setup_wizard.py: validate port range 1-65535, initialize before loop - setup_wizard.py: qualify azure display names (azure-gpt-4o) to avoid collision with openai - setup_wizard.py: work on env_copy in _build_config to avoid mutating caller's dict - setup_wizard.py: skip model_list entries for providers with no credentials - setup_wizard.py: prompt for azure deployment name - setup_wizard.py: wrap os.execlp in try/except with friendly fallback - setup_wizard.py: wrap config write in try/except OSError - setup_wizard.py: fix _validate_and_report to use two print lines (no \r overwrite) - setup_wizard.py: add .gitignore tip next to key storage notice - setup_wizard.py: fix run_setup_wizard() return type annotation to None - scripts/install.sh: drop pipefail (not supported by dash on Ubuntu when invoked as sh) - scripts/install.sh: use litellm[proxy] from PyPI (not hardcoded dev branch) - scripts/install.sh: guard /dev/tty read with -r check for Docker/CI compat - scripts/install.sh: remove --force-reinstall to avoid downgrading dependencies - tests/test_litellm/test_setup_wizard.py: 13 unit tests for _build_config and _yaml_escape * style: black format setup_wizard.py * fix: address remaining greptile issues - Windows compat, YAML quoting, credential flow - guard termios/tty imports with try/except ImportError for Windows compat - quote master_key as YAML double-quoted scalar (same as env vars) - remove unused port param from _build_config signature - _validate_and_report now returns the final key so re-entered creds are stored - add test for master_key YAML quoting * fix: add --port to suggested command, guard /dev/tty exec in install.sh * fix: quote api_base in YAML, skip azure if no deployment, only redraw on state change * fix: address greptile review comments - _yaml_escape: add control character escaping (\n, \r, \t) - test: fix tautological assertion in test_build_config_azure_no_deployment_skipped - test: add tests for control character escaping in _yaml_escape
This commit is contained in:
parent
6b2e56f420
commit
212f29f127
5 changed files with 1017 additions and 1 deletions
|
|
@ -140,6 +140,11 @@ LiteLLM is a unified interface for 100+ LLM providers with two main components:
|
|||
- **Check index coverage.** For new or modified queries, check `schema.prisma` for a supporting index. Prefer extending an existing index (e.g. `@@index([a])` → `@@index([a, b])`) over adding a new one, unless it's a `@@unique`. Only add indexes for large/frequent queries.
|
||||
- **Keep schema files in sync.** Apply schema changes to all `schema.prisma` copies (`schema.prisma`, `litellm/proxy/`, `litellm-proxy-extras/`, `litellm-js/spend-logs/` for SpendLogs) with a migration under `litellm-proxy-extras/litellm_proxy_extras/migrations/`.
|
||||
|
||||
### Setup Wizard (`litellm/setup_wizard.py`)
|
||||
- The wizard is implemented as a single `SetupWizard` class with `@staticmethod` methods — keep it that way. No module-level functions except `run_setup_wizard()` (the public entrypoint) and pure helpers (color, ANSI).
|
||||
- Use `litellm.utils.check_valid_key(model, api_key)` for credential validation — never roll a custom completion call.
|
||||
- Do not hardcode provider env-key names or model lists that already exist in the codebase. Add a `test_model` field to each provider entry to drive `check_valid_key`; set it to `None` for providers that can't be validated with a single API key (Azure, Bedrock, Ollama).
|
||||
|
||||
### Enterprise Features
|
||||
- Enterprise-specific code in `enterprise/` directory
|
||||
- Optional features enabled via environment variables
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
667
litellm/setup_wizard.py
Normal file
667
litellm/setup_wizard.py
Normal file
|
|
@ -0,0 +1,667 @@
|
|||
# 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 re
|
||||
import secrets
|
||||
import sys
|
||||
import sysconfig
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
# termios / tty are Unix-only; fall back gracefully on Windows
|
||||
try:
|
||||
import termios
|
||||
import tty
|
||||
|
||||
_HAS_RAW_TERMINAL: bool = True
|
||||
except ImportError:
|
||||
termios = None # type: ignore[assignment]
|
||||
tty = None # type: ignore[assignment]
|
||||
_HAS_RAW_TERMINAL = False
|
||||
|
||||
from litellm.utils import check_valid_key
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
# Each entry describes one provider card shown in the wizard.
|
||||
# `env_key` — primary env var name (None = no key needed, e.g. Ollama)
|
||||
# `test_model` — model passed to check_valid_key for credential validation
|
||||
# (None = skip validation, e.g. Azure needs a deployment name)
|
||||
# `models` — default models written into the generated config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROVIDERS: List[Dict] = [
|
||||
{
|
||||
"id": "openai",
|
||||
"name": "OpenAI",
|
||||
"description": "GPT-4o, GPT-4o-mini, o3-mini",
|
||||
"env_key": "OPENAI_API_KEY",
|
||||
"key_hint": "sk-...",
|
||||
"test_model": "gpt-4o-mini",
|
||||
"models": ["gpt-4o", "gpt-4o-mini"],
|
||||
},
|
||||
{
|
||||
"id": "anthropic",
|
||||
"name": "Anthropic",
|
||||
"description": "Claude Opus 4.6, Sonnet 4.6, Haiku 4.5",
|
||||
"env_key": "ANTHROPIC_API_KEY",
|
||||
"key_hint": "sk-ant-...",
|
||||
"test_model": "claude-haiku-4-5-20251001",
|
||||
"models": ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"],
|
||||
},
|
||||
{
|
||||
"id": "gemini",
|
||||
"name": "Google Gemini",
|
||||
"description": "Gemini 2.0 Flash, Gemini 2.5 Pro",
|
||||
"env_key": "GEMINI_API_KEY",
|
||||
"key_hint": "AIza...",
|
||||
"test_model": "gemini/gemini-2.0-flash",
|
||||
"models": ["gemini/gemini-2.0-flash", "gemini/gemini-2.5-pro"],
|
||||
},
|
||||
{
|
||||
"id": "azure",
|
||||
"name": "Azure OpenAI",
|
||||
"description": "GPT-4o via Azure",
|
||||
"env_key": "AZURE_API_KEY",
|
||||
"key_hint": "your-azure-key",
|
||||
"test_model": None, # needs deployment name — skip validation
|
||||
"models": [],
|
||||
"needs_api_base": True,
|
||||
"api_base_hint": "https://<resource>.openai.azure.com/",
|
||||
"api_version": "2024-07-01-preview",
|
||||
},
|
||||
{
|
||||
"id": "bedrock",
|
||||
"name": "AWS Bedrock",
|
||||
"description": "Claude 3.5, Llama 3 via AWS",
|
||||
"env_key": "AWS_ACCESS_KEY_ID",
|
||||
"key_hint": "AKIA...",
|
||||
"test_model": None, # multi-key auth — skip validation
|
||||
"models": ["bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0"],
|
||||
"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.2, mistral, etc.)",
|
||||
"env_key": None,
|
||||
"key_hint": None,
|
||||
"test_model": None, # local — no remote validation
|
||||
"models": ["ollama/llama3.2", "ollama/mistral"],
|
||||
"api_base": "http://localhost:11434",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ANSI colour helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ANSI_RE = re.compile(r"\033\[[^m]*m")
|
||||
|
||||
_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 = "✔"
|
||||
_CROSS = "✘"
|
||||
|
||||
_CURSOR_HIDE = "\033[?25l"
|
||||
_CURSOR_SHOW = "\033[?25h"
|
||||
_MOVE_UP = "\033[{}A"
|
||||
|
||||
|
||||
def _supports_color() -> bool:
|
||||
return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
|
||||
|
||||
|
||||
def _c(code: str, text: str) -> str:
|
||||
return f"{code}{text}{_RESET}" if _supports_color() else 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)
|
||||
|
||||
|
||||
def _divider() -> str:
|
||||
"""Return a styled divider line (evaluated at call-time, not import-time)."""
|
||||
return dim(" " + "╌" * 74)
|
||||
|
||||
|
||||
def _styled_input(prompt: str) -> str:
|
||||
"""
|
||||
Like input() but wraps ANSI sequences in readline ignore markers
|
||||
(\\001...\\002) so readline correctly tracks the cursor column.
|
||||
In non-TTY contexts, strips ANSI entirely so no escape codes appear.
|
||||
"""
|
||||
if sys.stdout.isatty():
|
||||
rl_prompt = _ANSI_RE.sub(lambda m: f"\001{m.group()}\002", prompt)
|
||||
else:
|
||||
rl_prompt = _ANSI_RE.sub("", prompt)
|
||||
return input(rl_prompt).strip()
|
||||
|
||||
|
||||
def _yaml_escape(value: str) -> str:
|
||||
"""Escape a string for safe embedding in a double-quoted YAML scalar."""
|
||||
return (
|
||||
value.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layout constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LITELLM_ASCII = r"""
|
||||
██╗ ██╗████████╗███████╗██╗ ██╗ ███╗ ███╗
|
||||
██║ ██║╚══██╔══╝██╔════╝██║ ██║ ████╗ ████║
|
||||
██║ ██║ ██║ █████╗ ██║ ██║ ██╔████╔██║
|
||||
██║ ██║ ██║ ██╔══╝ ██║ ██║ ██║╚██╔╝██║
|
||||
███████╗██║ ██║ ███████╗███████╗███████╗██║ ╚═╝ ██║
|
||||
╚══════╝╚═╝ ╚═╝ ╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Setup wizard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SetupWizard:
|
||||
"""
|
||||
Interactive onboarding wizard: provider selection → API keys → config file.
|
||||
|
||||
All methods are static — the class is purely a namespace with clear
|
||||
single-responsibility sections. Entry point: SetupWizard.run().
|
||||
"""
|
||||
|
||||
# ── entry point ─────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def run() -> None:
|
||||
try:
|
||||
SetupWizard._wizard()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print(f"\n\n {grey('Setup cancelled.')}\n")
|
||||
|
||||
# ── wizard steps ────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _wizard() -> None:
|
||||
SetupWizard._print_welcome()
|
||||
print(f" {bold('Lets get started.')}")
|
||||
print()
|
||||
|
||||
providers = SetupWizard._select_providers()
|
||||
env_vars = SetupWizard._collect_keys(providers)
|
||||
port, master_key = SetupWizard._proxy_settings()
|
||||
|
||||
config_path = Path(os.getcwd()) / "litellm_config.yaml"
|
||||
try:
|
||||
config_path.write_text(
|
||||
SetupWizard._build_config(providers, env_vars, master_key)
|
||||
)
|
||||
except OSError as exc:
|
||||
print(f"\n {bold(_CROSS + ' Could not write config:')} {exc}")
|
||||
print(" Try running from a directory you have write access to.\n")
|
||||
return
|
||||
|
||||
SetupWizard._print_success(config_path, port, master_key)
|
||||
SetupWizard._offer_start(config_path, port, master_key)
|
||||
|
||||
# ── welcome ─────────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
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 selector ───────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _select_providers() -> List[Dict]:
|
||||
"""Arrow-key multi-select. Falls back to number input if /dev/tty unavailable."""
|
||||
if not _HAS_RAW_TERMINAL:
|
||||
return SetupWizard._select_fallback()
|
||||
try:
|
||||
return SetupWizard._select_interactive()
|
||||
except OSError:
|
||||
return SetupWizard._select_fallback()
|
||||
|
||||
@staticmethod
|
||||
def _read_key() -> str:
|
||||
"""Read one keypress from /dev/tty in raw mode."""
|
||||
assert (
|
||||
termios is not None and tty is not None
|
||||
) # only called when _HAS_RAW_TERMINAL
|
||||
with open("/dev/tty", "rb") as tty_fh:
|
||||
fd = tty_fh.fileno()
|
||||
old = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setraw(fd)
|
||||
ch = tty_fh.read(1)
|
||||
if ch == b"\x1b":
|
||||
ch2 = tty_fh.read(1)
|
||||
if ch2 == b"[":
|
||||
ch3 = tty_fh.read(1)
|
||||
return "\x1b[" + ch3.decode("utf-8", errors="replace")
|
||||
return "\x1b" + ch2.decode("utf-8", errors="replace")
|
||||
return ch.decode("utf-8", errors="replace")
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
||||
|
||||
@staticmethod
|
||||
def _render_selector(cursor: int, selected: Set[int], first_render: bool) -> int:
|
||||
"""Draw or redraw the provider list. Returns the number of lines printed."""
|
||||
lines = [
|
||||
f"\n {bold('Add your first model')}\n",
|
||||
grey(" ↑↓ to navigate · Space to select · Enter to confirm") + "\n",
|
||||
"\n",
|
||||
]
|
||||
for i, p in enumerate(PROVIDERS):
|
||||
arrow = blue("❯") if i == cursor else " "
|
||||
bullet = green("◉") if i in selected else grey("○")
|
||||
name_str = bold(p["name"]) if i == cursor else p["name"]
|
||||
lines.append(f" {arrow} {bullet} {name_str} {grey(p['description'])}\n")
|
||||
lines.append("\n")
|
||||
|
||||
content = "".join(lines)
|
||||
if not first_render and _supports_color():
|
||||
sys.stdout.write(_MOVE_UP.format(content.count("\n")))
|
||||
sys.stdout.write(content)
|
||||
sys.stdout.flush()
|
||||
return content.count("\n")
|
||||
|
||||
@staticmethod
|
||||
def _select_interactive() -> List[Dict]:
|
||||
cursor, selected = 0, set()
|
||||
|
||||
if _supports_color():
|
||||
sys.stdout.write(_CURSOR_HIDE)
|
||||
sys.stdout.flush()
|
||||
try:
|
||||
SetupWizard._render_selector(cursor, selected, first_render=True)
|
||||
while True:
|
||||
key = SetupWizard._read_key()
|
||||
dirty = False
|
||||
if key == "\x1b[A":
|
||||
cursor = (cursor - 1) % len(PROVIDERS)
|
||||
dirty = True
|
||||
elif key == "\x1b[B":
|
||||
cursor = (cursor + 1) % len(PROVIDERS)
|
||||
dirty = True
|
||||
elif key == " ":
|
||||
selected.symmetric_difference_update({cursor})
|
||||
dirty = True
|
||||
elif key in ("\r", "\n"):
|
||||
if not selected:
|
||||
selected.add(cursor)
|
||||
break
|
||||
elif key in ("\x03", "\x04"):
|
||||
raise KeyboardInterrupt
|
||||
if dirty:
|
||||
SetupWizard._render_selector(cursor, selected, first_render=False)
|
||||
finally:
|
||||
if _supports_color():
|
||||
sys.stdout.write(_CURSOR_SHOW)
|
||||
sys.stdout.flush()
|
||||
|
||||
return [PROVIDERS[i] for i in sorted(selected)]
|
||||
|
||||
@staticmethod
|
||||
def _select_fallback() -> List[Dict]:
|
||||
"""Number-based fallback when raw terminal input is unavailable."""
|
||||
print()
|
||||
print(f" {bold('Add your first model')}")
|
||||
print(
|
||||
grey(
|
||||
" Enter numbers separated by commas (e.g. 1,2). Press Enter to confirm."
|
||||
)
|
||||
)
|
||||
print()
|
||||
for i, p in enumerate(PROVIDERS, 1):
|
||||
print(f" {grey(str(i) + '.')} {bold(p['name'])} {grey(p['description'])}")
|
||||
print()
|
||||
|
||||
while True:
|
||||
raw = _styled_input(f" {blue('❯')} Provider(s): ")
|
||||
if not raw:
|
||||
print(grey(" Please select at least one provider."))
|
||||
continue
|
||||
try:
|
||||
nums = [
|
||||
int(x.strip())
|
||||
for x in raw.replace(" ", ",").split(",")
|
||||
if x.strip()
|
||||
]
|
||||
valid = sorted({n for n in nums if 1 <= n <= len(PROVIDERS)})
|
||||
if not valid:
|
||||
print(grey(f" Enter numbers between 1 and {len(PROVIDERS)}."))
|
||||
continue
|
||||
return [PROVIDERS[i - 1] for i in valid]
|
||||
except ValueError:
|
||||
print(grey(" Enter numbers separated by commas, e.g. 1,3"))
|
||||
|
||||
# ── key collection ───────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
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(
|
||||
grey(
|
||||
" Tip: add litellm_config.yaml to .gitignore to avoid committing secrets."
|
||||
)
|
||||
)
|
||||
print()
|
||||
|
||||
for p in providers:
|
||||
if p["env_key"] is None:
|
||||
print(
|
||||
f" {green(p['name'])}: {grey('no key needed (uses local Ollama)')}"
|
||||
)
|
||||
continue
|
||||
|
||||
key = SetupWizard._prompt_key(p)
|
||||
if not key:
|
||||
continue
|
||||
|
||||
for extra_key, extra_hint in zip(
|
||||
p.get("extra_keys", []), p.get("extra_hints", [])
|
||||
):
|
||||
val = _styled_input(f" {blue('❯')} {extra_key} {grey(extra_hint)}: ")
|
||||
if val:
|
||||
env_vars[extra_key] = val
|
||||
|
||||
if p.get("needs_api_base"):
|
||||
api_base = _styled_input(
|
||||
f" {blue('❯')} Azure endpoint URL {grey(p.get('api_base_hint', ''))}: "
|
||||
)
|
||||
if api_base:
|
||||
env_vars[f"_LITELLM_AZURE_API_BASE_{p['id'].upper()}"] = api_base
|
||||
deployment = _styled_input(
|
||||
f" {blue('❯')} Azure deployment name {grey('(e.g. my-gpt4o)')}: "
|
||||
)
|
||||
if deployment:
|
||||
env_vars[f"_LITELLM_AZURE_DEPLOYMENT_{p['id'].upper()}"] = (
|
||||
deployment
|
||||
)
|
||||
|
||||
# Store the key returned by validation — may be a re-entered replacement
|
||||
env_vars[p["env_key"]] = SetupWizard._validate_and_report(p, key)
|
||||
|
||||
return env_vars
|
||||
|
||||
@staticmethod
|
||||
def _prompt_key(provider: Dict) -> str:
|
||||
"""Prompt for a provider's API key, with skip option. Returns the key or ''."""
|
||||
hint = grey(provider.get("key_hint", ""))
|
||||
while True:
|
||||
key = _styled_input(
|
||||
f" {blue('❯')} {bold(provider['name'])} API key {hint}: "
|
||||
)
|
||||
if key:
|
||||
return key
|
||||
print(grey(" Key is required. Leave blank to skip this provider."))
|
||||
if _styled_input(grey(" Skip? (y/N): ")).lower() == "y":
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _validate_and_report(provider: Dict, api_key: str) -> str:
|
||||
"""
|
||||
Validate credentials using litellm.utils.check_valid_key and print result.
|
||||
Offers a re-entry loop on failure. Returns the final (possibly re-entered) key.
|
||||
"""
|
||||
test_model: Optional[str] = provider.get("test_model")
|
||||
if not test_model:
|
||||
return api_key # Azure / Bedrock / Ollama — skip validation
|
||||
|
||||
while True:
|
||||
print(
|
||||
f" {grey('Testing connection to ' + provider['name'] + '...')}",
|
||||
flush=True,
|
||||
)
|
||||
valid = check_valid_key(model=test_model, api_key=api_key)
|
||||
if valid:
|
||||
print(
|
||||
f" {green(_CHECK)} {bold(provider['name'])} connected successfully"
|
||||
)
|
||||
return api_key
|
||||
|
||||
print(f" {_CROSS} {bold(provider['name'])} {grey('— invalid API key')}")
|
||||
if (
|
||||
_styled_input(f" {blue('❯')} Re-enter key? {grey('(y/N)')}: ").lower()
|
||||
!= "y"
|
||||
):
|
||||
return api_key
|
||||
|
||||
hint = grey(provider.get("key_hint", ""))
|
||||
new_key = _styled_input(
|
||||
f" {blue('❯')} {bold(provider['name'])} API key {hint}: "
|
||||
)
|
||||
if not new_key:
|
||||
return api_key
|
||||
api_key = new_key
|
||||
|
||||
# ── proxy settings ───────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _proxy_settings() -> "tuple[int, str]":
|
||||
print()
|
||||
print(_divider())
|
||||
print()
|
||||
print(f" {bold('Proxy settings')}")
|
||||
print()
|
||||
port = 4000
|
||||
while True:
|
||||
port_raw = _styled_input(f" {blue('❯')} Port {grey('[4000]')}: ")
|
||||
if not port_raw:
|
||||
break
|
||||
if port_raw.isdigit() and 1 <= int(port_raw) <= 65535:
|
||||
port = int(port_raw)
|
||||
break
|
||||
print(grey(" Enter a valid port number (1–65535)."))
|
||||
key_raw = _styled_input(f" {blue('❯')} Master key {grey('[auto-generate]')}: ")
|
||||
master_key = key_raw if key_raw else f"sk-{secrets.token_urlsafe(32)}"
|
||||
return port, master_key
|
||||
|
||||
# ── config generation ────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _build_config(
|
||||
providers: List[Dict],
|
||||
env_vars: Dict[str, str],
|
||||
master_key: str,
|
||||
) -> str:
|
||||
env_copy = dict(env_vars) # work on a copy — do not mutate caller's dict
|
||||
lines = ["model_list:"]
|
||||
for p in providers:
|
||||
# Only emit models for providers that actually have credentials
|
||||
has_creds = p["env_key"] is None or p["env_key"] in env_copy
|
||||
if not has_creds:
|
||||
continue
|
||||
|
||||
if p["id"] == "azure":
|
||||
deployment = env_copy.pop(
|
||||
f"_LITELLM_AZURE_DEPLOYMENT_{p['id'].upper()}", ""
|
||||
)
|
||||
if not deployment:
|
||||
continue # skip Azure entirely if no deployment name was provided
|
||||
models = [f"azure/{deployment}"]
|
||||
else:
|
||||
models = p["models"]
|
||||
|
||||
for model in models:
|
||||
raw_display = model.split("/")[-1] if "/" in model else model
|
||||
# Qualify azure display names to avoid collision with OpenAI model names
|
||||
display = f"azure-{raw_display}" if p["id"] == "azure" else raw_display
|
||||
lines += [
|
||||
f" - model_name: {display}",
|
||||
" litellm_params:",
|
||||
f" model: {model}",
|
||||
]
|
||||
if p["env_key"] and p["env_key"] in env_copy:
|
||||
lines.append(f" api_key: os.environ/{p['env_key']}")
|
||||
if p.get("api_base"):
|
||||
lines.append(
|
||||
f' api_base: "{_yaml_escape(str(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_copy:
|
||||
lines.append(
|
||||
f' api_base: "{_yaml_escape(env_copy.pop(azure_base_key))}"'
|
||||
)
|
||||
if p.get("api_version"):
|
||||
lines.append(f" api_version: {p['api_version']}")
|
||||
|
||||
lines += [
|
||||
"",
|
||||
"general_settings:",
|
||||
f' master_key: "{_yaml_escape(master_key)}"',
|
||||
"",
|
||||
]
|
||||
|
||||
real_vars = {k: v for k, v in env_copy.items() if not k.startswith("_LITELLM_")}
|
||||
if real_vars:
|
||||
lines.append("environment_variables:")
|
||||
for k, v in real_vars.items():
|
||||
lines.append(f' {k}: "{_yaml_escape(v)}"')
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── success + launch ─────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _print_success(config_path: Path, port: int, master_key: str) -> None:
|
||||
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} --port {port}")
|
||||
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()
|
||||
|
||||
@staticmethod
|
||||
def _offer_start(config_path: Path, port: int, master_key: str) -> None:
|
||||
start = _styled_input(
|
||||
f" {blue('❯')} Start the proxy now? {grey('(Y/n)')}: "
|
||||
).lower()
|
||||
if start not in ("", "y", "yes"):
|
||||
print()
|
||||
print(
|
||||
f" Run {bold(f'litellm --config {config_path}')} whenever you're ready."
|
||||
)
|
||||
print()
|
||||
print(
|
||||
grey(f" Quick test once running: curl http://localhost:{port}/health")
|
||||
)
|
||||
print()
|
||||
return
|
||||
|
||||
print()
|
||||
print(_divider())
|
||||
print()
|
||||
print(f" {bold('Proxy is starting on')} http://localhost:{port}")
|
||||
print()
|
||||
print(grey(" Your proxy is OpenAI-compatible. Point any OpenAI SDK at it:"))
|
||||
print()
|
||||
print(f" export OPENAI_BASE_URL=http://localhost:{port}")
|
||||
print(f" export OPENAI_API_KEY={master_key}")
|
||||
print()
|
||||
print(grey(" Quick test (in another terminal):"))
|
||||
print()
|
||||
print(f" curl http://localhost:{port}/health")
|
||||
print()
|
||||
print(grey(" Dashboard:"))
|
||||
print()
|
||||
print(f" http://localhost:{port}/ui {grey('(login with your master key)')}")
|
||||
print()
|
||||
print(_divider())
|
||||
print()
|
||||
print(f" {green(_CHECK)} Starting… {grey('(Ctrl+C to stop)')}")
|
||||
print()
|
||||
|
||||
scripts_dir = sysconfig.get_path("scripts")
|
||||
litellm_bin = os.path.join(scripts_dir or "", "litellm")
|
||||
try:
|
||||
os.execlp(
|
||||
litellm_bin,
|
||||
litellm_bin,
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--port",
|
||||
str(port),
|
||||
) # noqa: S606
|
||||
except OSError as exc:
|
||||
print(f"\n {bold(_CROSS + ' Could not start proxy:')} {exc}")
|
||||
print(f" Run manually: litellm --config {config_path} --port {port}\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entrypoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_setup_wizard() -> None:
|
||||
"""Run the interactive setup wizard. Called by `litellm --setup`."""
|
||||
SetupWizard.run()
|
||||
143
scripts/install.sh
Executable file
143
scripts/install.sh
Executable file
|
|
@ -0,0 +1,143 @@
|
|||
#!/usr/bin/env bash
|
||||
# LiteLLM Installer
|
||||
# Usage: curl -fsSL https://raw.githubusercontent.com/BerriAI/litellm/main/scripts/install.sh | sh
|
||||
#
|
||||
# NOTE: set -e without pipefail for POSIX sh compatibility (dash on Ubuntu/Debian
|
||||
# ignores the shebang when invoked as `sh` and does not support `pipefail`).
|
||||
set -eu
|
||||
|
||||
MIN_PYTHON_MAJOR=3
|
||||
MIN_PYTHON_MINOR=9
|
||||
|
||||
# NOTE: before merging, this must stay as "litellm[proxy]" to install from PyPI.
|
||||
LITELLM_PACKAGE="litellm[proxy]"
|
||||
|
||||
# ── colours ────────────────────────────────────────────────────────────────
|
||||
if [ -t 1 ]; then
|
||||
BOLD='\033[1m'
|
||||
GREEN='\033[38;2;78;186;101m'
|
||||
GREY='\033[38;2;153;153;153m'
|
||||
RESET='\033[0m'
|
||||
else
|
||||
BOLD='' GREEN='' GREY='' RESET=''
|
||||
fi
|
||||
|
||||
info() { printf "${GREY} %s${RESET}\n" "$*"; }
|
||||
success() { printf "${GREEN} ✔ %s${RESET}\n" "$*"; }
|
||||
header() { printf "${BOLD} %s${RESET}\n" "$*"; }
|
||||
die() { printf "\n Error: %s\n\n" "$*" >&2; exit 1; }
|
||||
|
||||
# ── banner ─────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
cat << 'EOF'
|
||||
██╗ ██╗████████╗███████╗██╗ ██╗ ███╗ ███╗
|
||||
██║ ██║╚══██╔══╝██╔════╝██║ ██║ ████╗ ████║
|
||||
██║ ██║ ██║ █████╗ ██║ ██║ ██╔████╔██║
|
||||
██║ ██║ ██║ ██╔══╝ ██║ ██║ ██║╚██╔╝██║
|
||||
███████╗██║ ██║ ███████╗███████╗███████╗██║ ╚═╝ ██║
|
||||
╚══════╝╚═╝ ╚═╝ ╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝
|
||||
EOF
|
||||
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
|
||||
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="$(command -v "$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"
|
||||
fi
|
||||
|
||||
# ── install ────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
header "Installing litellm[proxy]…"
|
||||
echo ""
|
||||
|
||||
"$PYTHON_BIN" -m pip install --upgrade "${LITELLM_PACKAGE}" \
|
||||
|| die "pip install failed. Try manually: $PYTHON_BIN -m pip install '${LITELLM_PACKAGE}'"
|
||||
|
||||
# ── find the litellm binary installed by pip for this Python ───────────────
|
||||
# sysconfig.get_path('scripts') is where pip puts console scripts — reliable
|
||||
# even when the Python lives in a libexec/ symlink tree (e.g. Homebrew).
|
||||
SCRIPTS_DIR="$("$PYTHON_BIN" -c 'import sysconfig; print(sysconfig.get_path("scripts"))')"
|
||||
LITELLM_BIN="${SCRIPTS_DIR}/litellm"
|
||||
|
||||
if [ ! -x "$LITELLM_BIN" ]; then
|
||||
# Fall back to user-base bin (pip install --user)
|
||||
USER_BIN="$("$PYTHON_BIN" -c 'import site; print(site.getuserbase())')/bin"
|
||||
LITELLM_BIN="${USER_BIN}/litellm"
|
||||
fi
|
||||
|
||||
if [ ! -x "$LITELLM_BIN" ]; then
|
||||
die "litellm binary not found after install. Try: $PYTHON_BIN -m pip install --user '${LITELLM_PACKAGE}'"
|
||||
fi
|
||||
|
||||
# ── success banner ─────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
success "LiteLLM installed"
|
||||
|
||||
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"
|
||||
|
||||
# ── PATH hint ──────────────────────────────────────────────────────────────
|
||||
if ! command -v litellm >/dev/null 2>&1; then
|
||||
info "Note: add litellm to your PATH: export PATH=\"\$PATH:${SCRIPTS_DIR}\""
|
||||
fi
|
||||
|
||||
# ── launch setup wizard ────────────────────────────────────────────────────
|
||||
echo ""
|
||||
printf " ${BOLD}Run the interactive setup wizard?${RESET} ${GREY}(Y/n)${RESET}: "
|
||||
# /dev/tty may be unavailable in Docker/CI — default to yes if it can't be read
|
||||
answer=""
|
||||
if [ -r /dev/tty ]; then
|
||||
read -r answer </dev/tty || answer=""
|
||||
fi
|
||||
|
||||
if [ -z "$answer" ] || [ "$answer" = "y" ] || [ "$answer" = "Y" ]; then
|
||||
echo ""
|
||||
# Use /dev/tty for interactive input when available (stdin is a pipe from curl)
|
||||
if [ -r /dev/tty ]; then
|
||||
exec "$LITELLM_BIN" --setup </dev/tty
|
||||
else
|
||||
exec "$LITELLM_BIN" --setup
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
header "Quick start:"
|
||||
echo ""
|
||||
info " litellm --setup # interactive wizard"
|
||||
info " litellm --model gpt-4o # single-model quickstart"
|
||||
echo ""
|
||||
info "Docs: https://docs.litellm.ai"
|
||||
echo ""
|
||||
fi
|
||||
188
tests/test_litellm/test_setup_wizard.py
Normal file
188
tests/test_litellm/test_setup_wizard.py
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
"""Unit tests for litellm.setup_wizard — pure functions only, no network calls."""
|
||||
|
||||
from litellm.setup_wizard import SetupWizard, _yaml_escape
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _yaml_escape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_yaml_escape_plain():
|
||||
assert _yaml_escape("sk-abc123") == "sk-abc123"
|
||||
|
||||
|
||||
def test_yaml_escape_double_quote():
|
||||
assert _yaml_escape('sk-ab"cd') == 'sk-ab\\"cd'
|
||||
|
||||
|
||||
def test_yaml_escape_backslash():
|
||||
assert _yaml_escape("sk-ab\\cd") == "sk-ab\\\\cd"
|
||||
|
||||
|
||||
def test_yaml_escape_combined():
|
||||
assert _yaml_escape('ab\\"cd') == 'ab\\\\\\"cd'
|
||||
|
||||
|
||||
def test_yaml_escape_newline():
|
||||
assert _yaml_escape("sk-abc\ndef") == "sk-abc\\ndef"
|
||||
|
||||
|
||||
def test_yaml_escape_carriage_return():
|
||||
assert _yaml_escape("sk-abc\rdef") == "sk-abc\\rdef"
|
||||
|
||||
|
||||
def test_yaml_escape_tab():
|
||||
assert _yaml_escape("sk-abc\tdef") == "sk-abc\\tdef"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SetupWizard._build_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_OPENAI = {
|
||||
"id": "openai",
|
||||
"name": "OpenAI",
|
||||
"env_key": "OPENAI_API_KEY",
|
||||
"models": ["gpt-4o", "gpt-4o-mini"],
|
||||
"test_model": "gpt-4o-mini",
|
||||
}
|
||||
|
||||
_ANTHROPIC = {
|
||||
"id": "anthropic",
|
||||
"name": "Anthropic",
|
||||
"env_key": "ANTHROPIC_API_KEY",
|
||||
"models": ["claude-opus-4-6"],
|
||||
"test_model": "claude-haiku-4-5-20251001",
|
||||
}
|
||||
|
||||
_AZURE = {
|
||||
"id": "azure",
|
||||
"name": "Azure OpenAI",
|
||||
"env_key": "AZURE_API_KEY",
|
||||
"models": [],
|
||||
"test_model": None,
|
||||
"needs_api_base": True,
|
||||
"api_base_hint": "https://<resource>.openai.azure.com/",
|
||||
"api_version": "2024-07-01-preview",
|
||||
}
|
||||
|
||||
_OLLAMA = {
|
||||
"id": "ollama",
|
||||
"name": "Ollama",
|
||||
"env_key": None,
|
||||
"models": ["ollama/llama3.2"],
|
||||
"test_model": None,
|
||||
"api_base": "http://localhost:11434",
|
||||
}
|
||||
|
||||
|
||||
def test_build_config_basic_openai():
|
||||
config = SetupWizard._build_config(
|
||||
[_OPENAI],
|
||||
{"OPENAI_API_KEY": "sk-test"},
|
||||
"sk-master",
|
||||
)
|
||||
assert "model_list:" in config
|
||||
assert "model_name: gpt-4o" in config
|
||||
assert "model: gpt-4o" in config
|
||||
assert "api_key: os.environ/OPENAI_API_KEY" in config
|
||||
assert 'master_key: "sk-master"' in config
|
||||
|
||||
|
||||
def test_build_config_skipped_provider_omitted():
|
||||
"""Provider with no key in env_vars should not appear in model_list."""
|
||||
config = SetupWizard._build_config(
|
||||
[_OPENAI, _ANTHROPIC],
|
||||
{"ANTHROPIC_API_KEY": "sk-ant-test"}, # OpenAI key missing
|
||||
"sk-master",
|
||||
)
|
||||
assert "gpt-4o" not in config
|
||||
assert "claude-opus-4-6" in config
|
||||
|
||||
|
||||
def test_build_config_env_vars_written_escaped():
|
||||
"""API keys with special chars should be YAML-escaped."""
|
||||
config = SetupWizard._build_config(
|
||||
[_OPENAI],
|
||||
{"OPENAI_API_KEY": 'sk-ab"cd'},
|
||||
"sk-master",
|
||||
)
|
||||
assert 'OPENAI_API_KEY: "sk-ab\\"cd"' in config
|
||||
|
||||
|
||||
def test_build_config_master_key_quoted():
|
||||
"""master_key must be quoted in YAML to handle special characters."""
|
||||
config = SetupWizard._build_config(
|
||||
[_OPENAI],
|
||||
{"OPENAI_API_KEY": "sk-test"},
|
||||
'sk-master"special',
|
||||
)
|
||||
assert 'master_key: "sk-master\\"special"' in config
|
||||
|
||||
|
||||
def test_build_config_does_not_mutate_env_vars():
|
||||
"""_build_config must not modify the caller's env_vars dict."""
|
||||
env_vars = {
|
||||
"AZURE_API_KEY": "az-key",
|
||||
"_LITELLM_AZURE_API_BASE_AZURE": "https://my.azure.com",
|
||||
"_LITELLM_AZURE_DEPLOYMENT_AZURE": "my-deployment",
|
||||
}
|
||||
original_keys = set(env_vars.keys())
|
||||
SetupWizard._build_config([_AZURE], env_vars, "sk-master")
|
||||
assert set(env_vars.keys()) == original_keys
|
||||
|
||||
|
||||
def test_build_config_azure_uses_deployment_name():
|
||||
env_vars = {
|
||||
"AZURE_API_KEY": "az-key",
|
||||
"_LITELLM_AZURE_API_BASE_AZURE": "https://my.azure.com",
|
||||
"_LITELLM_AZURE_DEPLOYMENT_AZURE": "my-gpt4o",
|
||||
}
|
||||
config = SetupWizard._build_config([_AZURE], env_vars, "sk-master")
|
||||
assert "model: azure/my-gpt4o" in config
|
||||
assert "model_name: azure-my-gpt4o" in config
|
||||
# api_base must be quoted to survive YAML special chars
|
||||
assert 'api_base: "https://my.azure.com"' in config
|
||||
|
||||
|
||||
def test_build_config_azure_no_deployment_skipped():
|
||||
"""Azure without a deployment name should emit nothing (not fallback to gpt-4o)."""
|
||||
env_vars = {"AZURE_API_KEY": "az-key"} # no deployment sentinel
|
||||
config = SetupWizard._build_config([_AZURE], env_vars, "sk-master")
|
||||
# No azure model entry should be emitted when deployment name is absent
|
||||
assert "model: azure/" not in config
|
||||
|
||||
|
||||
def test_build_config_no_display_name_collision_openai_and_azure():
|
||||
"""OpenAI gpt-4o and azure gpt-4o should get distinct model_name values."""
|
||||
env_vars = {
|
||||
"OPENAI_API_KEY": "sk-openai",
|
||||
"AZURE_API_KEY": "az-key",
|
||||
"_LITELLM_AZURE_DEPLOYMENT_AZURE": "gpt-4o",
|
||||
}
|
||||
config = SetupWizard._build_config([_OPENAI, _AZURE], env_vars, "sk-master")
|
||||
assert "model_name: gpt-4o" in config # OpenAI
|
||||
assert "model_name: azure-gpt-4o" in config # Azure — qualified
|
||||
|
||||
|
||||
def test_build_config_ollama_no_api_key_line():
|
||||
"""Ollama has no env_key — config should not contain an api_key line for it."""
|
||||
config = SetupWizard._build_config([_OLLAMA], {}, "sk-master")
|
||||
assert "ollama/llama3.2" in config
|
||||
assert "api_key:" not in config
|
||||
|
||||
|
||||
def test_build_config_master_key_in_general_settings():
|
||||
"""master_key is written to general_settings."""
|
||||
config = SetupWizard._build_config([_OPENAI], {"OPENAI_API_KEY": "k"}, "sk-m")
|
||||
assert 'master_key: "sk-m"' in config
|
||||
|
||||
|
||||
def test_build_config_internal_sentinel_keys_excluded():
|
||||
"""_LITELLM_ prefixed sentinel keys must not appear in environment_variables."""
|
||||
env_vars = {
|
||||
"OPENAI_API_KEY": "sk-real",
|
||||
"_LITELLM_AZURE_API_BASE_AZURE": "https://x.azure.com",
|
||||
}
|
||||
config = SetupWizard._build_config([_OPENAI], env_vars, "sk-master")
|
||||
assert "_LITELLM_" not in config
|
||||
Loading…
Add table
Reference in a new issue