mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
feat(cli): add thin litellm[cli] install path (install-cli.sh + brew) for the lite CLI
On a developer laptop the `lite` CLI only needs `lite login` and running coding agents through a proxy, but the sole install path was `litellm[proxy]`, which drags in the whole server tree (fastapi, uvicorn, boto3, polars, cryptography, litellm-enterprise). The CLI's heavy imports are all guarded, so it runs on the base SDK plus just rich, pyyaml and requests. Add a `cli` extra carrying exactly those three, a `scripts/install-cli.sh` curl one-liner that installs `litellm[cli]`, and a `BerriAI/homebrew-litellm` tap formula with a release runbook under `packaging/homebrew/`. The installer passes no `--python`, so uv honours litellm's requires-python and provisions a managed interpreter, skipping a too-old (3.9) or too-new (3.14+) system Python instead of failing to resolve. A pyproject thin-contract test asserts the `cli` extra keeps the deps the CLI imports and never leaks a server-only dependency from `proxy`, so the laptop install cannot silently re-bloat
This commit is contained in:
parent
19203473f0
commit
d3d7dc803e
6 changed files with 256 additions and 2 deletions
27
packaging/homebrew/README.md
Normal file
27
packaging/homebrew/README.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Homebrew formula for the `lite` CLI
|
||||
|
||||
[`lite.rb`](./lite.rb) is the canonical source for the Homebrew formula that installs the thin LiteLLM CLI (`litellm[cli]`). It lives here so it is versioned with the code, but Homebrew serves formulae from a tap, so it has to be published to the `BerriAI/homebrew-litellm` tap to be installable.
|
||||
|
||||
Once published, end users install with
|
||||
|
||||
```shell
|
||||
brew install BerriAI/litellm/lite
|
||||
```
|
||||
|
||||
which gives them the `lite` command (`lite login`, `lite claude`, `lite models list`, ...) without the proxy server runtime. For the full proxy server, they keep using pip/uv with `litellm[proxy]` or the Docker image.
|
||||
|
||||
## Why a tap and not homebrew-core
|
||||
|
||||
The formula builds the published `litellm` sdist with the `cli` extra and resolves that extra's dependencies from PyPI at build time. homebrew-core forbids network access during `install` and would require every transitive dependency declared as a pinned `resource`, regenerated on each release. For a fast-moving CLI that tradeoff is not worth it, so this stays a tap formula.
|
||||
|
||||
## Release runbook
|
||||
|
||||
The formula can only point at a published artifact, so it activates with the first `litellm` release that ships the `cli` extra (added in [pyproject.toml](../../pyproject.toml)).
|
||||
|
||||
1. Cut a `litellm` release whose `pyproject.toml` includes the `cli` extra and confirm it is on PyPI.
|
||||
2. Fetch the sdist URL and checksum for that version: `curl -fsSL https://pypi.org/pypi/litellm/<version>/json | jq -r '.urls[] | select(.packagetype=="sdist") | "\(.url)\n\(.digests.sha256)"'`
|
||||
3. Set `url` and `sha256` in `lite.rb` to those values; `version` is parsed from `url`.
|
||||
4. Copy `lite.rb` into the tap repo under `Formula/lite.rb`, then run `brew install --build-from-source ./Formula/lite.rb` and `brew test lite` to verify a clean build and that `lite --help` works.
|
||||
5. Commit and push to `BerriAI/homebrew-litellm`.
|
||||
|
||||
Keep `lite.rb` here in sync with the tap copy so the in-repo formula stays the source of truth.
|
||||
33
packaging/homebrew/lite.rb
Normal file
33
packaging/homebrew/lite.rb
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Homebrew formula for the thin LiteLLM `lite` CLI (litellm[cli]).
|
||||
#
|
||||
# Ships in the BerriAI/homebrew-litellm tap, not homebrew-core: it builds the
|
||||
# published litellm sdist with the `cli` extra into a dedicated virtualenv and
|
||||
# pulls the extra's deps from PyPI. That is the low-maintenance path for a
|
||||
# fast-moving Python CLI; the resource-stanza alternative would need every
|
||||
# transitive dep re-pinned with a fresh sha256 on each release.
|
||||
#
|
||||
# RELEASE STEP (see README.md in this directory): point `url` + `sha256` at the
|
||||
# PyPI sdist of the first litellm version that ships the `cli` extra. `version`
|
||||
# is parsed from `url`, and the build installs exactly that version, so the three
|
||||
# stay in lockstep automatically.
|
||||
class Lite < Formula
|
||||
include Language::Python::Virtualenv
|
||||
|
||||
desc "Thin client for the LiteLLM proxy: lite login, lite claude/codex/opencode"
|
||||
homepage "https://docs.litellm.ai/docs/proxy/management_cli"
|
||||
url "https://files.pythonhosted.org/packages/source/l/litellm/litellm-REPLACE_AT_RELEASE.tar.gz"
|
||||
sha256 "REPLACE_AT_RELEASE"
|
||||
license "MIT"
|
||||
|
||||
depends_on "python@3.13"
|
||||
|
||||
def install
|
||||
virtualenv_create(libexec, "python3.13")
|
||||
system libexec/"bin/pip", "install", "#{buildpath}[cli]"
|
||||
bin.install_symlink libexec/"bin/lite"
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match "login", shell_output("#{bin}/lite --help")
|
||||
end
|
||||
end
|
||||
|
|
@ -71,6 +71,14 @@ proxy = [
|
|||
"pyroscope-io>=0.8.16,<1.0; sys_platform != 'win32'",
|
||||
"pydantic-settings>=2.14.1,<3.0",
|
||||
]
|
||||
# Thin client install for the `lite` CLI on developer laptops. The CLI's heavy
|
||||
# imports (fastapi, cryptography, ...) are all guarded, so it runs on the base
|
||||
# SDK plus just these three; none of the server runtime in `proxy` is pulled in.
|
||||
cli = [
|
||||
"rich>=13.9.4,<14.0",
|
||||
"pyyaml>=6.0.3,<7.0",
|
||||
"requests>=2.32.0,<3.0",
|
||||
]
|
||||
extra_proxy = [
|
||||
"prisma>=0.11.0,<1.0",
|
||||
"azure-identity>=1.25.2,<2.0",
|
||||
|
|
|
|||
128
scripts/install-cli.sh
Executable file
128
scripts/install-cli.sh
Executable file
|
|
@ -0,0 +1,128 @@
|
|||
#!/usr/bin/env bash
|
||||
# LiteLLM CLI Installer (the thin `lite` client)
|
||||
# Usage: curl -fsSL https://raw.githubusercontent.com/BerriAI/litellm/main/scripts/install-cli.sh | sh
|
||||
#
|
||||
# Installs only litellm[cli]: the `lite` command for authenticating to a LiteLLM
|
||||
# proxy and running coding agents (lite claude / codex / opencode) through it.
|
||||
# None of the proxy server runtime is pulled in. To run a proxy server instead,
|
||||
# use scripts/install.sh, which installs litellm[proxy].
|
||||
#
|
||||
# Needs only curl: uv is bootstrapped if missing, and uv provisions a compatible
|
||||
# Python itself (honouring litellm's requires-python), downloading a managed one
|
||||
# when the host has no suitable interpreter.
|
||||
#
|
||||
# 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
|
||||
|
||||
# NOTE: before merging, this must stay as "litellm[cli]" to install from PyPI.
|
||||
LITELLM_PACKAGE="litellm[cli]"
|
||||
UV_VERSION="0.10.9"
|
||||
|
||||
# ── 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 CLI Installer${RESET} ${GREY}the thin 'lite' client for your proxy${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"
|
||||
|
||||
# ── uv detection / install ────────────────────────────────────────────────
|
||||
UV_BIN=""
|
||||
CURRENT_UV_VERSION=""
|
||||
for candidate in uv "$HOME/.local/bin/uv"; do
|
||||
if command -v "$candidate" >/dev/null 2>&1; then
|
||||
UV_BIN="$(command -v "$candidate")"
|
||||
break
|
||||
elif [ -x "$candidate" ]; then
|
||||
UV_BIN="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$UV_BIN" ]; then
|
||||
CURRENT_UV_VERSION="$("$UV_BIN" --version 2>/dev/null | awk '{print $2}' | head -1 || true)"
|
||||
fi
|
||||
|
||||
if [ -z "$UV_BIN" ] || [ "${CURRENT_UV_VERSION:-}" != "$UV_VERSION" ]; then
|
||||
header "Installing uv…"
|
||||
if [ -n "${CURRENT_UV_VERSION:-}" ]; then
|
||||
info "Upgrading uv from ${CURRENT_UV_VERSION} to ${UV_VERSION}"
|
||||
fi
|
||||
curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" | env UV_NO_MODIFY_PATH=1 sh \
|
||||
|| die "uv installation failed. Try manually: curl -LsSf https://astral.sh/uv/${UV_VERSION}/install.sh | sh"
|
||||
UV_BIN="$HOME/.local/bin/uv"
|
||||
fi
|
||||
|
||||
# ── install ────────────────────────────────────────────────────────────────
|
||||
# No --python: uv selects an interpreter that satisfies litellm's requires-python
|
||||
# and downloads a managed one when the host has none, so a too-old (3.9) or
|
||||
# too-new (3.14+) system Python is skipped instead of causing a resolve failure.
|
||||
echo ""
|
||||
header "Installing litellm[cli]…"
|
||||
echo ""
|
||||
|
||||
"$UV_BIN" tool install --force "${LITELLM_PACKAGE}" \
|
||||
|| die "uv tool install failed. Try manually: $UV_BIN tool install '${LITELLM_PACKAGE}'"
|
||||
|
||||
# ── find the lite binary installed by uv tool ──────────────────────────────
|
||||
SCRIPTS_DIR="$("$UV_BIN" tool dir --bin)"
|
||||
LITE_BIN="${SCRIPTS_DIR}/lite"
|
||||
|
||||
if [ ! -x "$LITE_BIN" ]; then
|
||||
die "lite binary not found after install. Try: $UV_BIN tool install '${LITELLM_PACKAGE}'"
|
||||
fi
|
||||
|
||||
# ── success banner ─────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
success "LiteLLM CLI installed"
|
||||
|
||||
installed_ver="$("$LITE_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 lite >/dev/null 2>&1; then
|
||||
info "Note: add lite to your PATH: export PATH=\"\$PATH:${SCRIPTS_DIR}\""
|
||||
fi
|
||||
|
||||
# ── next steps ─────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
header "Next steps:"
|
||||
echo ""
|
||||
info " export LITELLM_PROXY_URL=https://your-proxy # point at your gateway"
|
||||
info " lite login # authenticate via SSO"
|
||||
info " lite claude # run Claude Code through the proxy"
|
||||
echo ""
|
||||
info "Docs: https://docs.litellm.ai/docs/proxy/management_cli"
|
||||
echo ""
|
||||
|
|
@ -92,6 +92,56 @@ def test_package_dependencies():
|
|||
)
|
||||
|
||||
|
||||
def test_cli_extra_is_a_thin_client_install():
|
||||
"""The `cli` extra must install a working `lite` client without dragging in the
|
||||
proxy server runtime. It therefore has to declare the CLI's real third-party
|
||||
deps (rich, pyyaml, requests) and must never contain a server-only dependency
|
||||
from the `proxy` extra; a leak there silently re-bloats the laptop install.
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
import litellm
|
||||
from packaging.requirements import Requirement
|
||||
|
||||
try:
|
||||
import tomllib as tomli
|
||||
except ImportError:
|
||||
try:
|
||||
import tomli
|
||||
except ImportError:
|
||||
pytest.skip("tomli/tomllib not available - skipping dependency check")
|
||||
|
||||
pyproject_path = pathlib.Path(litellm.__file__).parent.parent / "pyproject.toml"
|
||||
with open(pyproject_path, "rb") as f:
|
||||
optional_deps = tomli.load(f)["project"]["optional-dependencies"]
|
||||
|
||||
assert "cli" in optional_deps, "Expected a `cli` extra for the thin lite install"
|
||||
|
||||
cli_names = {Requirement(req).name.lower() for req in optional_deps["cli"]}
|
||||
|
||||
missing = {"rich", "pyyaml", "requests"} - cli_names
|
||||
assert not missing, f"`cli` extra is missing deps the lite CLI imports: {missing}"
|
||||
|
||||
server_only = {
|
||||
"fastapi",
|
||||
"uvicorn",
|
||||
"gunicorn",
|
||||
"granian",
|
||||
"starlette",
|
||||
"boto3",
|
||||
"polars",
|
||||
"soundfile",
|
||||
"mcp",
|
||||
"cryptography",
|
||||
"apscheduler",
|
||||
"rq",
|
||||
"litellm-enterprise",
|
||||
"litellm-proxy-extras",
|
||||
}
|
||||
leaked = cli_names & server_only
|
||||
assert not leaked, f"`cli` extra leaks proxy-server deps onto laptops: {leaked}"
|
||||
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
|
|
|
|||
12
uv.lock
generated
12
uv.lock
generated
|
|
@ -9,7 +9,7 @@ resolution-markers = [
|
|||
]
|
||||
|
||||
[options]
|
||||
exclude-newer = "2026-05-28T03:32:27.927695Z"
|
||||
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
|
||||
exclude-newer-span = "P3D"
|
||||
|
||||
[manifest]
|
||||
|
|
@ -3297,6 +3297,11 @@ dependencies = [
|
|||
caching = [
|
||||
{ name = "diskcache" },
|
||||
]
|
||||
cli = [
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
extra-proxy = [
|
||||
{ name = "a2a-sdk" },
|
||||
{ name = "azure-identity" },
|
||||
|
|
@ -3528,10 +3533,13 @@ requires-dist = [
|
|||
{ name = "pyroscope-io", marker = "sys_platform != 'win32' and extra == 'proxy'", specifier = ">=0.8.16,<1.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.0,<2.0" },
|
||||
{ name = "python-multipart", marker = "extra == 'proxy'", specifier = ">=0.0.27,<1.0" },
|
||||
{ name = "pyyaml", marker = "extra == 'cli'", specifier = ">=6.0.3,<7.0" },
|
||||
{ name = "pyyaml", marker = "extra == 'proxy'", specifier = ">=6.0.3,<7.0" },
|
||||
{ name = "redisvl", marker = "python_full_version < '3.14' and extra == 'extra-proxy'", specifier = ">=0.4.1,<1.0" },
|
||||
{ name = "requests", marker = "extra == 'cli'", specifier = ">=2.32.0,<3.0" },
|
||||
{ name = "resend", marker = "extra == 'extra-proxy'", specifier = ">=2.23.0,<3.0" },
|
||||
{ name = "restrictedpython", marker = "extra == 'proxy'", specifier = ">=8.1,<9.0" },
|
||||
{ name = "rich", marker = "extra == 'cli'", specifier = ">=13.9.4,<14.0" },
|
||||
{ name = "rich", marker = "extra == 'proxy'", specifier = ">=13.9.4,<14.0" },
|
||||
{ name = "rq", marker = "extra == 'proxy'", specifier = ">=2.7.0,<3.0" },
|
||||
{ name = "semantic-router", marker = "python_full_version < '3.14' and extra == 'semantic-router'", specifier = ">=0.1.15,<1.0" },
|
||||
|
|
@ -3545,7 +3553,7 @@ requires-dist = [
|
|||
{ name = "uvloop", marker = "sys_platform != 'win32' and extra == 'proxy'", specifier = ">=0.21.0,<1.0" },
|
||||
{ name = "websockets", marker = "extra == 'proxy'", specifier = ">=15.0.1,<16.0" },
|
||||
]
|
||||
provides-extras = ["proxy", "extra-proxy", "utils", "caching", "semantic-router", "mlflow", "grpc", "stt-nvidia-riva", "google", "proxy-runtime"]
|
||||
provides-extras = ["proxy", "cli", "extra-proxy", "utils", "caching", "semantic-router", "mlflow", "grpc", "stt-nvidia-riva", "google", "proxy-runtime"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
ci = [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue