* feat(xai): add grok-4.20 beta 2 models with pricing (#23900)
Add three grok-4.20 beta 2 model variants from xAI:
- grok-4.20-multi-agent-beta-0309 (reasoning + multi-agent)
- grok-4.20-beta-0309-reasoning (reasoning)
- grok-4.20-beta-0309-non-reasoning
Pricing (from https://docs.x.ai/docs/models):
- Input: $2.00/1M tokens ($0.20/1M cached)
- Output: $6.00/1M tokens
- Context: 2M tokens
All variants support vision, function calling, tool choice, and web search.
Closes LIT-2171
* docs: add Quick Install section for litellm --setup wizard (#23905)
* docs: add Quick Install section for litellm --setup wizard
* docs: clarify setup wizard is for local/beginner use
* 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
* feat(ui): remove Chat UI page link and banner from sidebar and playground (#23908)
* feat(guardrails): MCPJWTSigner - built-in guardrail for zero trust MCP auth (#23897)
* Allow pre_mcp_call guardrail hooks to mutate outbound MCP headers
* Enhance MCPServerManager to support hook-modified arguments and extra headers. Update tests to validate argument mutation and header injection behavior, including warnings for OpenAPI-backed servers when headers are present.
* Refactor MCPServerManager to raise HTTPException for extra headers in OpenAPI-backed servers. Update tests to reflect this change, ensuring proper exception handling instead of logging warnings.
* Allow pre_mcp_call guardrail hooks to mutate outbound MCP headers
* Enhance MCPServerManager to support hook-modified arguments and extra headers. Update tests to validate argument mutation and header injection behavior, including warnings for OpenAPI-backed servers when headers are present.
* Refactor MCPServerManager to raise HTTPException for extra headers in OpenAPI-backed servers. Update tests to reflect this change, ensuring proper exception handling instead of logging warnings.
* feat(guardrails): add MCPJWTSigner built-in guardrail for zero trust MCP auth
Signs outbound MCP tool calls with a LiteLLM-issued RS256 JWT so MCP servers
can trust a single signing authority instead of every upstream IdP.
Enable in config.yaml:
guardrails:
- guardrail_name: mcp-jwt-signer
litellm_params:
guardrail: mcp_jwt_signer
mode: pre_mcp_call
default_on: true
JWT carries sub (user_id), act.sub (team_id, RFC 8693), tool-level scope, iss,
aud, iat/exp/nbf. RSA-2048 keypair auto-generated at startup unless
MCP_JWT_SIGNING_KEY env var is set.
Adds /.well-known/jwks.json endpoint and jwks_uri to /.well-known/openid-configuration
so MCP servers can verify LiteLLM-issued tokens via OIDC discovery.
* Update MCPServerManager to raise HTTPException with status code 400 for extra headers in OpenAPI-backed servers. Adjust tests to verify the correct status code and exception message.
* fix: address P1 issues in MCPJWTSigner
- OpenAPI servers: warn + skip header injection instead of 500
- JWKS Cache-Control: 5min for auto-generated keys, 1h for persistent
- sub claim: fallback to apikey:{token_hash} for anonymous callers
- ttl_seconds: validate > 0 at init time
* docs: add MCP zero trust auth guide with architecture diagram
* docs: add FastMCP JWT verification guide to zero trust doc
* fix: address remaining Greptile review issues (round 2)
- mcp_server_manager: warn when hook Authorization overwrites existing header
- __init__: remove _mcp_jwt_signer_instance from __all__ (private internal)
- discoverable_endpoints: copy dict instead of mutating in-place on OIDC augmentation
- test docstring: reflect warn-and-continue behavior for OpenAPI servers
- test: update scope assertions for least-privilege (no mcp:tools/list on tool-call JWTs)
* fix: address Greptile round 3 feedback
- initialize_guardrail: validate mode='pre_mcp_call' at init time — misconfigured
mode silently bypasses JWT injection, which is a zero-trust bypass
- _build_claims: remove duplicate inline 'import re' (module-level import already present)
- _types.py: add TODO comment explaining jwt_claims is forward-compat plumbing
for a follow-up PR that will forward upstream IdP claims into outbound MCP JWTs
* feat(mcp_jwt_signer): add verify+re-sign, claim ops, two-token model, configurable scopes
Addresses all missing pieces from the scoping doc review:
FR-5 (Verify + re-sign): MCPJWTSigner now accepts access_token_discovery_uri
and token_introspection_endpoint. When set, the incoming Bearer token is
extracted from raw_headers (threaded through pre_call_tool_check), verified
against the IdP's JWKS (JWT) or introspected (opaque), and only re-signed if
valid. Falls back to user_api_key_dict.jwt_claims for LiteLLM JWT-auth mode.
FR-12 (Configurable end-user identity mapping): end_user_claim_sources
ordered list drives sub resolution — sources: token:<claim>, litellm:user_id,
litellm:email, litellm:end_user_id, litellm:team_id.
FR-13 (Claim operations): add_claims (insert-if-absent), set_claims (always
override), remove_claims (delete) applied in that order.
FR-14 (Two-token model): channel_token_audience + channel_token_ttl issue a
second JWT injected as x-mcp-channel-token: Bearer <token>.
FR-15 (Incoming claim validation): required_claims raises HTTP 403 when any
listed claim is absent; optional_claims passes listed claims from verified
token into the outbound JWT.
FR-9 (Debug headers): debug_headers: true emits x-litellm-mcp-debug with kid,
sub, iss, exp, scope.
FR-10 (Configurable scopes): allowed_scopes replaces auto-generation. Also
fixed: tool-call JWTs no longer grant mcp:tools/list (overpermission).
P1 fixes:
- proxy/utils.py: _convert_mcp_hook_response_to_kwargs merges rather than
replaces extra_headers, preserving headers from prior guardrails.
- mcp_server_manager.py: warns when hook injects Authorization alongside a
server-configured authentication_token (previously silent).
- mcp_server_manager.py: pre_call_tool_check now accepts raw_headers and
extracts incoming_bearer_token so FR-5 verification has the raw token.
- proxy/utils.py: remove stray inline import inspect inside loop (pre-existing
lint error, now cleaned up).
Tests: 43 passing (28 new tests covering all FR flags + P1 fixes).
* feat(mcp_jwt_signer): add verify+re-sign, claim ops, two-token model, configurable scopes (core)
Remaining files from the FR implementation:
mcp_jwt_signer.py — full rewrite with all new params:
FR-5: access_token_discovery_uri, token_introspection_endpoint,
verify_issuer, verify_audience + _verify_incoming_jwt(),
_introspect_opaque_token()
FR-12: end_user_claim_sources ordered resolution chain
FR-13: add_claims, set_claims, remove_claims
FR-14: channel_token_audience, channel_token_ttl → x-mcp-channel-token
FR-15: required_claims (raises 403), optional_claims (passthrough)
FR-9: debug_headers → x-litellm-mcp-debug
FR-10: allowed_scopes; tool-call JWTs no longer over-grant tools/list
mcp_server_manager.py:
- pre_call_tool_check gains raw_headers param to extract incoming_bearer_token
- Silent Authorization override warning fixed: now fires when server has
authentication_token AND hook injects Authorization
tests/test_mcp_jwt_signer.py:
28 new tests covering all FR flags + P1 fixes (43 total, all passing)
* fix(mcp_jwt_signer): address pre-landing review issues
- Remove stale TODO comment on UserAPIKeyAuth.jwt_claims — the field is
already populated and consumed by MCPJWTSigner in the same PR
- Fix _get_oidc_discovery to only cache the OIDC discovery doc when
jwks_uri is present; a malformed/empty doc now retries on the next
request instead of being permanently cached until proxy restart
- Add FR-5 test coverage for _fetch_jwks (cache hit/miss),
_get_oidc_discovery (cache/no-cache on bad doc), _verify_incoming_jwt
(valid token, expired token), _introspect_opaque_token (active,
inactive, no endpoint), and the end-to-end 401 hook path — 53 tests
total, all passing
* docs(mcp_zero_trust): rewrite as use-case guide covering all new JWT signer features
Add scenario-driven sections for each new config area:
- Verify+re-sign with Okta/Azure AD (access_token_discovery_uri,
end_user_claim_sources, token_introspection_endpoint)
- Enforcing caller attributes with required_claims / optional_claims
- Adding metadata via add_claims / set_claims / remove_claims
- Two-token model for AWS Bedrock AgentCore Gateway
(channel_token_audience / channel_token_ttl)
- Controlling scopes with allowed_scopes
- Debugging JWT rejections with debug_headers
Update JWT claims table to reflect configurable sub (end_user_claim_sources)
* fix(mcp_jwt_signer): wire all config.yaml params through initialize_guardrail
The factory was only passing issuer/audience/ttl_seconds to MCPJWTSigner.
All FR-5/9/10/12/13/14/15 params (access_token_discovery_uri,
end_user_claim_sources, add/set/remove_claims, channel_token_audience,
required/optional_claims, debug_headers, allowed_scopes, etc.) were
silently dropped, making every advertised advanced feature non-functional
when loaded from config.yaml.
Add regression test that asserts every param is wired through correctly.
* docs(mcp_zero_trust): add hero image
* docs(mcp_zero_trust): apply Linear-style edits
- Lead with the problem (unsigned direct calls bypass access controls)
- Shorter statement section headers instead of question-form headers
- Move diagram/OIDC discovery block after the reader is bought in
- Add 'read further only if you need to' callout after basic setup
- Two-token section now opens from the user problem not product jargon
- Add concrete 403 error response example in required_claims section
- Debug section opens from the symptom (MCP server returning 401)
- Lowercase claims reference header for consistency
* fix(mcp_jwt_signer): fix algorithm confusion attack + add OIDC discovery 24h TTL
- Remove alg from unverified JWT header; use signing_jwk.algorithm_name from JWKS key instead.
Reading alg from attacker-controlled headers enables alg:none / HS256 confusion attacks.
- Add _oidc_discovery_fetched_at timestamp and _OIDC_DISCOVERY_TTL = 86400 (24h).
Without a TTL the cached discovery doc never refreshes, so IdP key rotation is invisible.
---------
Co-authored-by: Noah Nistler <60981020+noahnistler@users.noreply.github.com>
* fix(ci): stabilize CI - formatting, type errors, test polling, security CVEs, router bug, batch resolution
Fix 1: Run Black formatter on 35 files
Fix 2: Fix MyPy type errors:
- setup_wizard.py: add type annotation for 'selected' set variable
- user_api_key_auth.py: remove redundant type annotation on jwt_claims reassignment
Fix 3: Fix spend accuracy test burst 2 polling to wait for expected total
spend instead of just 'any increase' from burst 2
Fix 4: Bump Next.js 16.1.6 -> 16.1.7 to fix CVE-2026-27978, CVE-2026-27979,
CVE-2026-27980, CVE-2026-29057
Fix 5: Fix router _pre_call_checks model variable being overwritten inside
loop, causing wrong model lookups on subsequent deployments. Use local
_deployment_model variable instead.
Fix 6: Add missing resolve_output_file_ids_to_unified call in batch retrieve
non-terminal-to-terminal path (matching the terminal path behavior)
Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
* chore: regenerate poetry.lock to sync with pyproject.toml
Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
* fix: format merged files from main and regenerate poetry.lock
Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
* fix(mypy): annotate jwt_claims as Optional[dict] to fix type incompatibility
Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
* fix(ci): update router region test to use gpt-4.1-mini (fix flaky model lookup)
Replace deprecated gpt-3.5-turbo-1106 with gpt-4.1-mini + mock_response in
test_router_region_pre_call_check, following the same pattern used in commit
717d37cc5b for test_router_context_window_check_pre_call_check_out_group.
Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
* ci: retry flaky logging_testing (async event loop race condition)
Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
* fix(ci): aggregate all mock calls in langfuse e2e test to fix race condition
The _verify_langfuse_call helper only inspected the last mock call
(mock_post.call_args), but the Langfuse SDK may split trace-create and
generation-create events across separate HTTP flush cycles. This caused
an IndexError when the last call's batch contained only one event type.
Fix: iterate over mock_post.call_args_list to collect batch items from
ALL calls. Also add a safety assertion after filtering by trace_id and
mark all langfuse e2e tests with @pytest.mark.flaky(retries=3) as an
extra safety net for any residual timing issues.
Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
* fix(ci): black formatting + update OpenAPI compliance tests for spec changes
- Apply Black 26.x formatting to litellm_logging.py (parenthesized style)
- Update test_input_types_match_spec to follow $ref to InteractionsInput schema
(Google updated their OpenAPI spec to use $ref instead of inline oneOf)
- Update test_content_schema_uses_discriminator to handle discriminator without
explicit mapping (Google removed the mapping key from Content discriminator)
Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
* revert: undo incorrect Black 26.x formatting on litellm_logging.py
The file was correctly formatted for Black 23.12.1 (the version pinned
in pyproject.toml). The previous commit applied Black 26.x formatting
which was incompatible with the CI's Black version.
Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
* fix(ci): deduplicate and sort langfuse batch events after aggregation
The Langfuse SDK may send the same event (e.g., trace-create) in
multiple flush cycles, causing duplicates when we aggregate from all
mock calls. After filtering by trace_id, deduplicate by keeping only
the first event of each type, then sort to ensure trace-create is at
index 0 and generation-create at index 1.
Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
---------
Co-authored-by: Noah Nistler <60981020+noahnistler@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
669 lines
24 KiB
Python
669 lines
24 KiB
Python
# 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 = 0
|
||
selected: set[int] = 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()
|