mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
* test(e2e): move the harness self-tests out of tests/e2e The nightly Buildkite run copies tests/e2e into the runner image and runs bare pytest, so the 672 tests of the harness itself (fixture parsing, JUnit properties, the stack lock, the load aggregators, the Claude Code driver) counted as e2e tests on the status page even though none of them reaches a proxy. They now live in tests/e2e_harness, mirroring the tests/e2e layout, and run in the GitHub Actions lint job and the CircleCI provider_replay_harness job instead * fix(ci): point the providers replay controls at tests/e2e_harness The providers integration job still selected the four replay-control tests under tests/e2e/test_provider_edge.py, so pytest exited before they ran. The raw-HTTP check's file walk also drops to one loop per comprehension * style(tests): mark the raw-HTTP check's bindings Final
353 lines
16 KiB
Bash
Executable file
353 lines
16 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
#
|
|
# pre_commit_lint.sh — shift CI lint left. Run it (via `make check`, formerly
|
|
# `make pre-commit`) before `git commit`, or after committing (e.g. a merge
|
|
# commit) to predict CI for the branch. It picks the files in scope and runs
|
|
# only the matching gating CI checks, so a clean run means a green CI lint:
|
|
# - anything staged -> scope is the staged files; changed-but-unstaged files
|
|
# whose checks were skipped are called out
|
|
# - nothing staged -> scope is the working tree's diff against the merge base
|
|
# with origin's current default branch, untracked files included
|
|
# The per-area checks:
|
|
# - litellm/ Python -> `make lint` (test-linting.yml's lint job)
|
|
# - tests/e2e and tests/e2e_harness Python
|
|
# -> `make lint-e2e-basedpyright` (test-linting.yml's e2e type-check step)
|
|
# + raw HTTP client ban (test-code-quality.yml's check_e2e_no_raw_requests)
|
|
# - tests/ Python, ruff-tests.toml, test-quality-budget.json, scripts/check_test_quality.py,
|
|
# scripts/test_quality_gate.py
|
|
# -> ruff over ruff-tests.toml + `make lint-test-quality` (test-linting.yml's
|
|
# test-tree ruff and test-quality budget steps)
|
|
# - dashboard -> prettier + eslint + lint budgets (test-litellm-ui-build.yml's frontend-lint)
|
|
# - proxy/types -> regenerate the lazy OpenAPI snapshot and dashboard API types, fail on drift (check-ui-api-types.yml)
|
|
#
|
|
# Each block is skipped when no matching files are in scope, so unrelated commits
|
|
# stay fast. This is intentionally not auto-installed as a git hook (see
|
|
# scripts/install_git_hooks.sh): the dashboard and basedpyright passes can take
|
|
# minutes, so it's run on demand rather than firing on every human commit. It is
|
|
# hook-compatible if you want that anyway:
|
|
# `ln -s ../../scripts/pre_commit_lint.sh .git/hooks/pre-commit`.
|
|
|
|
set -eu
|
|
|
|
# Queue for one of the machine-wide heavy-work slots (see scripts/gate_slot_lock.py)
|
|
# before anything else, so N parallel `make check` runs across worktrees execute two
|
|
# at a time instead of thrashing the machine. The wrapper exports
|
|
# LITELLM_GATE_SLOT_HELD, so this re-exec happens exactly once and everything this
|
|
# script spawns (make lint, the budget gates) skips its own acquisition.
|
|
script_dir=$(python3 -c 'import os, sys; print(os.path.dirname(os.path.realpath(sys.argv[1])))' "$0")
|
|
if [ -z "${LITELLM_GATE_SLOT_HELD:-}" ]; then
|
|
exec python3 "$script_dir/gate_slot_lock.py" "$0" "$@"
|
|
fi
|
|
|
|
if [ -z "${PRE_COMMIT_LINT_INNER:-}" ]; then
|
|
log_file=$(git rev-parse --path-format=absolute --git-path pre_commit_lint.log)
|
|
if : > "$log_file" 2>/dev/null; then
|
|
echo "check: logging full output to $log_file"
|
|
PRE_COMMIT_LINT_INNER=1 "$0" "$@" 2>&1 | tee "$log_file"
|
|
pipe_status=("${PIPESTATUS[@]}")
|
|
if [ "${pipe_status[1]}" -eq 0 ]; then
|
|
echo "check: full log: $log_file"
|
|
else
|
|
echo "check: WARNING - writing $log_file failed; the log may be incomplete" >&2
|
|
fi
|
|
exit "${pipe_status[0]}"
|
|
fi
|
|
echo "check: WARNING - cannot write $log_file; output will not be saved" >&2
|
|
PRE_COMMIT_LINT_INNER=1 exec "$0" "$@"
|
|
fi
|
|
|
|
repo_root=$(git rev-parse --show-toplevel)
|
|
cd "$repo_root"
|
|
|
|
staged=$(git diff --cached --name-only --diff-filter=ACMRD)
|
|
unstaged=$(git diff --name-only)
|
|
untracked=$(git ls-files --others --exclude-standard)
|
|
|
|
if [ -n "$staged" ]; then
|
|
scope=$staged
|
|
else
|
|
base_ref=$(python3 "$script_dir/default_branch.py" --base "${BASE_REF:-}") || {
|
|
echo "check: FAIL"
|
|
exit 1
|
|
}
|
|
export BASE_REF="$base_ref"
|
|
merge_base=$(git merge-base "$base_ref" HEAD 2>/dev/null) || {
|
|
echo "check: cannot resolve the merge base with $base_ref." >&2
|
|
echo " Fix: fetch the base ref and provide BASE_REF=<ref>" >&2
|
|
echo "check: FAIL"
|
|
exit 1
|
|
}
|
|
scope=$(printf '%s\n' "$(git diff --name-only --diff-filter=ACMRD "$merge_base")" "$untracked" | sed '/^$/d' | sort -u)
|
|
if [ -z "$scope" ]; then
|
|
echo "check: nothing to check (no staged files, no working-tree changes, no branch changes vs $base_ref)"
|
|
echo "check: PASS"
|
|
exit 0
|
|
fi
|
|
echo "check: nothing staged; scoping to the working tree's diff against the merge base with $base_ref:"
|
|
printf '%s\n' "$scope" | sed 's/^/ /'
|
|
fi
|
|
|
|
scope_match() { printf '%s\n' "$scope" | grep -E "$1" || true; }
|
|
|
|
existing_files() {
|
|
while IFS= read -r f; do
|
|
if [ -f "$f" ]; then printf '%s\n' "$f"; fi
|
|
done
|
|
}
|
|
|
|
litellm_py_pattern='^litellm/.*\.py$'
|
|
e2e_py_pattern='^tests/e2e(_harness)?/.*\.py$'
|
|
test_tree_pattern='^(tests/.*\.py|ruff-tests\.toml|test-quality-budget\.json|scripts/(check_test_quality|test_quality_gate)\.py)$'
|
|
spec_pattern='^(litellm/(proxy|types)/.*|ui/litellm-dashboard/(scripts/gen-api-types\.mjs|package\.json|package-lock\.json|src/lib/http/schema\.d\.ts))$'
|
|
ui_prettier_pattern='^ui/litellm-dashboard/.*\.(js|jsx|ts|tsx|mjs|cjs|json|css|scss|md|mdx|yml|yaml|html)$'
|
|
ui_eslint_pattern='^ui/litellm-dashboard/.*\.(js|jsx|ts|tsx|mjs|cjs)$'
|
|
|
|
litellm_py_files=$(scope_match "$litellm_py_pattern")
|
|
if [ -n "$(scope_match '^(litellm/proxy/_experimental/mcp_server/|scripts/check_mcp_operation_boundary\.py)')" ]; then
|
|
uv run --no-sync python scripts/check_mcp_operation_boundary.py || exit 1
|
|
fi
|
|
e2e_py_files=$(scope_match "$e2e_py_pattern")
|
|
test_tree_files=$(scope_match "$test_tree_pattern")
|
|
# ruff format (and CI's format step) skip enterprise; the rest of make lint covers it.
|
|
fmt_files=$(printf '%s\n' "$litellm_py_files" | grep -v '^litellm/enterprise/' | existing_files)
|
|
# check-ui-api-types.yml triggers on any file under litellm/proxy or litellm/types
|
|
# (Prisma schema and configs included, not just Python) plus the generator and its
|
|
# lockfiles, so match that whole trigger set rather than a Python subset.
|
|
spec_files=$(scope_match "$spec_pattern")
|
|
# CI's frontend-lint runs prettier over a wider extension set than eslint; keep that
|
|
# split so this flags exactly what the job would.
|
|
ui_prettier_changed=$(scope_match "$ui_prettier_pattern")
|
|
ui_eslint_changed=$(scope_match "$ui_eslint_pattern")
|
|
ui_prettier_files=$(printf '%s\n' "$ui_prettier_changed" | existing_files)
|
|
ui_eslint_files=$(printf '%s\n' "$ui_eslint_changed" | existing_files)
|
|
|
|
# CI lints the committed tree, so with staged files this script predicts CI for
|
|
# what you have STAGED (every trigger above reads `git diff --cached`). The tools
|
|
# it runs, though, read the working tree, so unstaged edits to tracked files and
|
|
# untracked files fold into the result and a green/red here won't match a commit
|
|
# of just the staged changes. There's no safe way to lint the index in place, so
|
|
# surface the gap instead of hiding it: stage everything you intend to commit
|
|
# before trusting a pass. This only warns; it never blocks or touches your changes.
|
|
if [ -n "$staged" ]; then
|
|
not_staged=$(printf '%s\n' "$unstaged" "$untracked" | sed '/^$/d' | sort -u)
|
|
if [ -n "$not_staged" ]; then
|
|
echo "check: NOTE - unstaged/untracked changes are included in these checks but" >&2
|
|
echo " won't be in a commit of only your staged changes, so this result may differ from" >&2
|
|
echo " CI. Stage everything you intend to commit (git add) for an accurate prediction:" >&2
|
|
printf '%s\n' "$not_staged" | sed 's/^/ /' >&2
|
|
fi
|
|
warn_skipped() {
|
|
local check_name=$1 pattern=$2 triggered=$3
|
|
[ -n "$triggered" ] && return 0
|
|
local missed
|
|
missed=$(printf '%s\n' "$not_staged" | grep -E "$pattern" || true)
|
|
[ -z "$missed" ] && return 0
|
|
echo "check: SKIPPED $check_name because these changed files are not staged:" >&2
|
|
printf '%s\n' "$missed" | sed 's/^/ /' >&2
|
|
}
|
|
warn_skipped "Python lint (make lint)" "$litellm_py_pattern" "$litellm_py_files"
|
|
warn_skipped "tests/e2e checks (basedpyright + raw HTTP client ban)" "$e2e_py_pattern" "$e2e_py_files"
|
|
warn_skipped "test-tree lint (ruff-tests.toml + test-quality budget)" "$test_tree_pattern" "$test_tree_files"
|
|
warn_skipped "dashboard lint (prettier + eslint + lint budgets)" "$ui_prettier_pattern" "$ui_prettier_changed"
|
|
warn_skipped "dashboard API-type sync (npm run gen:api)" "$spec_pattern" "$spec_files"
|
|
fi
|
|
|
|
lint_dashboard() {
|
|
(
|
|
trap 'exit 143' TERM
|
|
trap 'rm -f "${report:-}"' EXIT
|
|
rc=0
|
|
prettier_rel=()
|
|
eslint_rel=()
|
|
while IFS= read -r f; do
|
|
[ -n "$f" ] && prettier_rel+=("${f#ui/litellm-dashboard/}")
|
|
done <<EOF
|
|
$ui_prettier_files
|
|
EOF
|
|
while IFS= read -r f; do
|
|
[ -n "$f" ] && eslint_rel+=("${f#ui/litellm-dashboard/}")
|
|
done <<EOF
|
|
$ui_eslint_files
|
|
EOF
|
|
cd ui/litellm-dashboard
|
|
if [ ${#prettier_rel[@]} -gt 0 ]; then
|
|
npx prettier --check "${prettier_rel[@]}" || rc=1
|
|
fi
|
|
if [ ${#eslint_rel[@]} -gt 0 ]; then
|
|
npx eslint --no-warn-ignored --pass-on-unpruned-suppressions "${eslint_rel[@]}" || rc=1
|
|
fi
|
|
# Whole-folder lint budgets, exactly as the frontend-lint job runs them: the
|
|
# counts are not diff-scoped, so a local pass here means the budget step will
|
|
# pass in CI too.
|
|
report=$(mktemp)
|
|
npx eslint . -f json -o "$report" || true
|
|
node scripts/check-lint-budgets.mjs "$report" eslint-budgets.json || rc=1
|
|
exit $rc
|
|
)
|
|
}
|
|
|
|
status=0
|
|
|
|
bootstrap_hint() {
|
|
echo " This checkout looks unprovisioned (fresh worktree or clone)." >&2
|
|
echo " Fix: make bootstrap" >&2
|
|
}
|
|
|
|
python_checks() {
|
|
local rc=0
|
|
echo "check: linting Python (make lint)"
|
|
make lint || { echo "✗ Python lint failed. Fix the reds above, then re-run make check." >&2; rc=1; }
|
|
# `make lint` format-checks files in origin/base...HEAD, which at pre-commit time
|
|
# predates the staged change, so format-check the scoped litellm files directly to
|
|
# cover a brand-new commit before it lands.
|
|
if [ -n "$fmt_files" ]; then
|
|
echo "check: ruff format --check (scoped litellm files)"
|
|
printf '%s\n' "$fmt_files" | xargs uv run --no-sync ruff format --check --exclude '/enterprise/' \
|
|
|| { echo "✗ Unformatted files in scope. Fix with: make format, then re-stage." >&2; rc=1; }
|
|
fi
|
|
return $rc
|
|
}
|
|
|
|
on_interrupt() {
|
|
trap - INT TERM
|
|
rm -f "${python_log:-}" "${dash_log:-}" "${gen_log:-}"
|
|
for job_pid in ${python_pid:-} ${dash_pid:-} ${gen_pid:-}; do
|
|
kill -- "-$job_pid" 2>/dev/null || true
|
|
done
|
|
exit 130
|
|
}
|
|
trap on_interrupt INT TERM
|
|
|
|
if [ -n "$litellm_py_files" ]; then
|
|
python_log=$(mktemp)
|
|
set -m
|
|
python_checks > "$python_log" 2>&1 &
|
|
python_pid=$!
|
|
set +m
|
|
fi
|
|
|
|
if [ -n "$e2e_py_files" ] && [ -z "$litellm_py_files" ]; then
|
|
echo "check: type-checking tests/e2e (make lint-e2e-basedpyright)"
|
|
make lint-e2e-basedpyright || { echo "✗ tests/e2e basedpyright failed. Fix the errors above, then re-run make check." >&2; status=1; }
|
|
fi
|
|
|
|
if [ -n "$e2e_py_files" ]; then
|
|
echo "check: checking tests/e2e raw HTTP client ban (check_e2e_no_raw_requests)"
|
|
uv run --no-sync python tests/code_coverage_tests/check_e2e_no_raw_requests.py \
|
|
|| { echo "✗ Raw HTTP client import in tests/e2e. Route the call through tests/e2e/e2e_http.py, then re-run make check." >&2; status=1; }
|
|
fi
|
|
|
|
dashboard_checks() {
|
|
echo "check: linting dashboard (prettier + eslint + lint budgets)"
|
|
if [ ! -d ui/litellm-dashboard/node_modules ]; then
|
|
echo "✗ ui/litellm-dashboard/node_modules is missing; dashboard lint cannot run." >&2
|
|
bootstrap_hint
|
|
return 1
|
|
fi
|
|
lint_dashboard || { echo "✗ Dashboard lint failed. See above; format with: (cd ui/litellm-dashboard && npm run format)." >&2; return 1; }
|
|
}
|
|
|
|
if [ -n "$ui_prettier_changed" ] || [ -n "$ui_eslint_changed" ]; then
|
|
dash_log=$(mktemp)
|
|
set -m
|
|
dashboard_checks > "$dash_log" 2>&1 &
|
|
dash_pid=$!
|
|
set +m
|
|
fi
|
|
|
|
genapi_checks() {
|
|
local status=0
|
|
echo "check: checking the lazy OpenAPI snapshot and dashboard API types are in sync (npm run gen:api)"
|
|
# gen-api-types.mjs imports litellm.proxy.proxy_server, which needs the proxy deps
|
|
# and an up-to-date Prisma client; check-ui-api-types.yml installs those and runs
|
|
# prisma generate before gen:api, so mirror that here or a stale client can mask
|
|
# drift that CI will still flag.
|
|
if [ ! -d ui/litellm-dashboard/node_modules ]; then
|
|
echo "✗ ui/litellm-dashboard/node_modules is missing; the gen:api sync check cannot run." >&2
|
|
bootstrap_hint
|
|
status=1
|
|
elif ! uv run --no-sync python -c "import orjson, prisma" 2>/dev/null; then
|
|
echo "✗ The Python env lacks the proxy deps (orjson/prisma) that gen:api needs." >&2
|
|
bootstrap_hint
|
|
status=1
|
|
elif ! uv run --no-sync python scripts/prisma_generate_if_needed.py; then
|
|
echo "✗ Could not regenerate Prisma client (prisma generate failed)." >&2
|
|
status=1
|
|
elif ! uv run --no-sync python -m litellm.proxy._lazy_openapi_snapshot; then
|
|
echo "✗ Could not regenerate the lazy OpenAPI snapshot (python -m litellm.proxy._lazy_openapi_snapshot failed)." >&2
|
|
status=1
|
|
elif ( cd ui/litellm-dashboard && LITELLM_PYTHON="uv run --no-sync python" npm run gen:api ); then
|
|
if ! git diff --quiet -- litellm/proxy/_lazy_openapi_snapshot.json; then
|
|
echo "✗ The lazy OpenAPI snapshot is stale; regenerated litellm/proxy/_lazy_openapi_snapshot.json. Stage it and commit; re-run make check only if other checks failed too." >&2
|
|
status=1
|
|
fi
|
|
if ! git diff --quiet -- ui/litellm-dashboard/src/lib/http/schema.d.ts; then
|
|
echo "✗ Dashboard API types are stale; regenerated src/lib/http/schema.d.ts. Stage it and commit; re-run make check only if other checks failed too." >&2
|
|
status=1
|
|
fi
|
|
else
|
|
echo "✗ Could not regenerate API types (npm run gen:api failed)." >&2
|
|
status=1
|
|
fi
|
|
return $status
|
|
}
|
|
|
|
if [ -n "$spec_files" ]; then
|
|
gen_log=$(mktemp)
|
|
set -m
|
|
genapi_checks > "$gen_log" 2>&1 &
|
|
gen_pid=$!
|
|
set +m
|
|
fi
|
|
|
|
if [ -n "$test_tree_files" ] && [ -z "$litellm_py_files" ]; then
|
|
echo "check: linting the test tree (ruff check --config ruff-tests.toml tests)"
|
|
uv run --no-sync ruff check --config ruff-tests.toml tests \
|
|
|| { echo "✗ Test-tree ruff failed. Fix the errors above, then re-run make check." >&2; status=1; }
|
|
echo "check: checking the test-quality budget (make lint-test-quality)"
|
|
make lint-test-quality \
|
|
|| { echo "✗ Test-quality budget failed. Fix the errors above, then re-run make check." >&2; status=1; }
|
|
fi
|
|
|
|
if [ -n "${python_pid:-}" ]; then
|
|
wait "$python_pid" || status=1
|
|
cat "$python_log"; rm -f "$python_log"
|
|
fi
|
|
if [ -n "${dash_pid:-}" ]; then
|
|
wait "$dash_pid" || status=1
|
|
cat "$dash_log"; rm -f "$dash_log"
|
|
fi
|
|
if [ -n "${gen_pid:-}" ]; then
|
|
wait "$gen_pid" || status=1
|
|
cat "$gen_log"; rm -f "$gen_log"
|
|
fi
|
|
|
|
summary_item() {
|
|
local check_name=$1 triggered=$2 skip_reason=$3
|
|
if [ -n "$triggered" ]; then
|
|
echo " ran: $check_name"
|
|
else
|
|
echo " skipped: $check_name ($skip_reason)"
|
|
fi
|
|
}
|
|
|
|
echo "check: summary"
|
|
summary_item "Python lint (make lint)" "$litellm_py_files" "no litellm/ Python files in scope"
|
|
summary_item "tests/e2e checks (basedpyright + raw HTTP client ban)" "$e2e_py_files" "no tests/e2e Python files in scope"
|
|
summary_item "test-tree lint (ruff-tests.toml + test-quality budget)" "$test_tree_files" \
|
|
"no tests/ Python files or test-tree lint inputs in scope"
|
|
summary_item "dashboard lint (prettier + eslint + lint budgets)" "$ui_prettier_changed$ui_eslint_changed" "no dashboard files in scope"
|
|
summary_item "dashboard API-type sync (npm run gen:api)" "$spec_files" "no litellm/proxy, litellm/types, or generator files in scope"
|
|
|
|
if [ -z "$litellm_py_files$e2e_py_files$test_tree_files$ui_prettier_changed$ui_eslint_changed$spec_files" ]; then
|
|
echo "check: NOTE - no gating lint check matches the files in scope, so nothing ran:" >&2
|
|
printf '%s\n' "$scope" | sed 's/^/ /' >&2
|
|
echo " A pass here is a no-op, not a lint verdict." >&2
|
|
fi
|
|
|
|
if [ "$status" -eq 0 ]; then
|
|
echo "check: PASS"
|
|
else
|
|
echo "check: FAIL"
|
|
fi
|
|
exit $status
|