Compare commits
No commits in common. "main" and "v1.106.0-dev.2" have entirely different histories.
main
...
v1.106.0-d
3652
.circleci/config.yml
Normal file
77
.circleci/scripts/classify_changes.sh
Executable file
|
|
@ -0,0 +1,77 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
category="${1:?usage: classify_changes.sh <backend|client|ui|provider-harness|cost-map-only|mcp-dependencies|windows-release|redis-compat>}"
|
||||||
|
|
||||||
|
has_client=false
|
||||||
|
has_backend=false
|
||||||
|
has_ci=false
|
||||||
|
has_provider_harness=false
|
||||||
|
has_cost_map=false
|
||||||
|
has_mcp_dependencies=false
|
||||||
|
has_windows_release=false
|
||||||
|
has_redis_compat=false
|
||||||
|
outside_cost_map_set=false
|
||||||
|
while IFS= read -r file || [ -n "$file" ]; do
|
||||||
|
[ -n "$file" ] || continue
|
||||||
|
case "$file" in
|
||||||
|
litellm/_redis.py | litellm/_redis_credential_provider.py | litellm/caching/redis_cache.py | litellm/caching/evicted_client_closer.py | tests/unit/test_redis.py | tests/local_testing/test_caching.py | tests/unit/caching/test_redis_connection_pool.py | tests/unit/caching/test_redis_cluster_cache.py | tests/unit/caching/test_evicted_client_closer.py | .circleci/config.yml | .circleci/scripts/classify_changes.sh | .circleci/scripts/path_filter.sh | pyproject.toml | uv.lock)
|
||||||
|
has_redis_compat=true ;;
|
||||||
|
esac
|
||||||
|
case "$file" in
|
||||||
|
*.md | *.mdx) : ;;
|
||||||
|
pyproject.toml | */pyproject.toml | uv.lock | uv.toml | .python-version | rust-toolchain.toml | litellm-rust/* | litellm/__init__.py | litellm/proxy/proxy_server.py | litellm/*mcp* | tests/*mcp* | litellm/integrations/arize/* | tests/base_sdk_tests/* | scripts/check_mcp_sdk_install.py | .github/workflows/test-mcp-dependency-resolution.yml | .github/actions/detect-changes/* | .github/actions/setup-uv-with-retries/* | .github/actions/cache-cargo-build/* | .github/scripts/detect_changes.sh | .github/scripts/uv_sync_with_retries.sh | .circleci/scripts/classify_changes.sh | tests/unit/test_circleci_path_filter.py | tests/unit/test_detect_changes.py)
|
||||||
|
has_mcp_dependencies=true ;;
|
||||||
|
esac
|
||||||
|
case "$file" in
|
||||||
|
tests/e2e/*/*.py | tests/e2e_harness/*/*.py) : ;;
|
||||||
|
tests/e2e/*.py | tests/e2e_harness/*.py | tests/code_coverage_tests/test_provider_cache.py | tests/code_coverage_tests/test_provider_replay_harness.py | tests/unit/test_circleci_path_filter.py | .circleci/* | pyproject.toml | uv.lock)
|
||||||
|
has_provider_harness=true ;;
|
||||||
|
esac
|
||||||
|
case "$file" in
|
||||||
|
litellm-rust/* | litellm/rust_bridge/* | rust-toolchain.toml | pyproject.toml | uv.lock | tests/windows_tests/* | .circleci/*)
|
||||||
|
has_windows_release=true ;;
|
||||||
|
esac
|
||||||
|
case "$file" in
|
||||||
|
ui/* | tests/e2e/ui/*) has_client=true ;;
|
||||||
|
docs/* | *.md | *.mdx) : ;;
|
||||||
|
.github/* | .circleci/*) has_ci=true; has_backend=true ;;
|
||||||
|
*) has_backend=true ;;
|
||||||
|
esac
|
||||||
|
case "$file" in
|
||||||
|
model_prices_and_context_window.json | litellm/model_prices_and_context_window_backup.json | model_prices_and_context_window.schema.json)
|
||||||
|
has_cost_map=true ;;
|
||||||
|
tests/test_litellm/* | tests/proxy_unit_tests/* | tests/unit/proxy/*) : ;;
|
||||||
|
*) outside_cost_map_set=true ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
case "$category" in
|
||||||
|
mcp-dependencies)
|
||||||
|
[ "$has_mcp_dependencies" = true ] && echo run || echo skip
|
||||||
|
;;
|
||||||
|
cost-map-only)
|
||||||
|
{ [ "$has_cost_map" = true ] && [ "$outside_cost_map_set" = false ]; } && echo run || echo skip
|
||||||
|
;;
|
||||||
|
provider-harness)
|
||||||
|
[ "$has_provider_harness" = true ] && echo run || echo skip
|
||||||
|
;;
|
||||||
|
windows-release)
|
||||||
|
[ "$has_windows_release" = true ] && echo run || echo skip
|
||||||
|
;;
|
||||||
|
redis-compat)
|
||||||
|
[ "$has_redis_compat" = true ] && echo run || echo skip
|
||||||
|
;;
|
||||||
|
backend)
|
||||||
|
[ "$has_backend" = true ] && echo run || echo skip
|
||||||
|
;;
|
||||||
|
client)
|
||||||
|
{ [ "$has_client" = true ] || [ "$has_backend" = true ]; } && echo run || echo skip
|
||||||
|
;;
|
||||||
|
ui)
|
||||||
|
{ [ "$has_client" = true ] || [ "$has_ci" = true ]; } && echo run || echo skip
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo run
|
||||||
|
;;
|
||||||
|
esac
|
||||||
40
.circleci/scripts/path_filter.sh
Executable file
|
|
@ -0,0 +1,40 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
category="${1:?usage: path_filter.sh <backend|client|provider-harness|redis-compat>}"
|
||||||
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
run_full() {
|
||||||
|
echo "path-filter[$category]: running job ($1)"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
[ -n "${CIRCLE_PULL_REQUEST:-}" ] || run_full "not a pull request"
|
||||||
|
|
||||||
|
candidate_bases="${PATH_FILTER_BASE_BRANCH:-main}"
|
||||||
|
merge_base=""
|
||||||
|
for base in $candidate_bases; do
|
||||||
|
git fetch --quiet origin "$base" 2>/dev/null || continue
|
||||||
|
candidate="$(git merge-base HEAD FETCH_HEAD 2>/dev/null)" || continue
|
||||||
|
[ -n "$candidate" ] || continue
|
||||||
|
if [ -z "$merge_base" ] || git merge-base --is-ancestor "$merge_base" "$candidate" 2>/dev/null; then
|
||||||
|
merge_base="$candidate"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$merge_base" ] || run_full "could not resolve a merge base against $candidate_bases"
|
||||||
|
|
||||||
|
changed="$(git diff --name-only "$merge_base" HEAD 2>/dev/null)" || run_full "git diff failed"
|
||||||
|
[ -n "$changed" ] || run_full "no files changed vs $merge_base"
|
||||||
|
|
||||||
|
echo "path-filter[$category]: changed files vs ${merge_base}:"
|
||||||
|
printf '%s\n' "$changed" | sed 's/^/ /' || true
|
||||||
|
|
||||||
|
decision="$(printf '%s\n' "$changed" | bash "$here/classify_changes.sh" "$category")" || run_full "classify_changes.sh failed"
|
||||||
|
|
||||||
|
if [ "$decision" = run ]; then
|
||||||
|
run_full "$category-relevant changes detected"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "path-filter[$category]: only unrelated changes detected; halting job as successful"
|
||||||
|
circleci-agent step halt
|
||||||
259
.circleci/scripts/run_integration.sh
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "${GITHUB_ACTIONS:-}" = true ]; then
|
||||||
|
echo "Integration contracts are owned by CircleCI" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
suite="${1:?integration suite required}"
|
||||||
|
mode="${2:-standard}"
|
||||||
|
side="${3:-}"
|
||||||
|
if [ "$mode" = replica ]; then
|
||||||
|
results="test-results/integration-${suite}-replica"
|
||||||
|
elif [ "$mode" = parity ]; then
|
||||||
|
results="test-results/parity-${suite}/${side:?parity side required}"
|
||||||
|
else
|
||||||
|
results="test-results/integration-${suite}"
|
||||||
|
fi
|
||||||
|
mkdir -p "$results"
|
||||||
|
integration_identity="$(.venv/bin/python -c 'import uuid; print(uuid.uuid4().hex)')"
|
||||||
|
upstream_pid=""
|
||||||
|
proxy_pid=""
|
||||||
|
peer_pid=""
|
||||||
|
launched_pid=""
|
||||||
|
guard_created=false
|
||||||
|
guard_installed=false
|
||||||
|
guard6_created=false
|
||||||
|
guard6_installed=false
|
||||||
|
egress_cgroup=litellm-integration
|
||||||
|
cleanup() {
|
||||||
|
original_status=$?
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
sudo .venv/bin/python .circleci/scripts/stop_integration_processes.py \
|
||||||
|
"$integration_identity" "$(id -u)" "$proxy_pid" "$peer_pid" "$upstream_pid" \
|
||||||
|
> "$results/process-cleanup.txt" 2>&1 || original_status=1
|
||||||
|
for owned_pid in "$peer_pid" "$proxy_pid" "$upstream_pid"; do
|
||||||
|
if [ -n "$owned_pid" ]; then
|
||||||
|
kill -- "-$owned_pid" 2>/dev/null || true
|
||||||
|
for _ in {1..50}; do
|
||||||
|
kill -0 -- "-$owned_pid" 2>/dev/null || break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
if kill -0 -- "-$owned_pid" 2>/dev/null; then
|
||||||
|
kill -KILL -- "-$owned_pid" 2>/dev/null || true
|
||||||
|
original_status=1
|
||||||
|
fi
|
||||||
|
wait "$owned_pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$guard_installed" = true ]; then
|
||||||
|
sudo iptables -D OUTPUT -m cgroup --path "$egress_cgroup" -j integration_only || original_status=1
|
||||||
|
fi
|
||||||
|
if [ "$guard_created" = true ]; then
|
||||||
|
sudo iptables -F integration_only || original_status=1
|
||||||
|
sudo iptables -X integration_only || original_status=1
|
||||||
|
fi
|
||||||
|
if [ "$guard6_installed" = true ]; then
|
||||||
|
sudo ip6tables -D OUTPUT -m cgroup --path "$egress_cgroup" -j integration_only || original_status=1
|
||||||
|
fi
|
||||||
|
if [ "$guard6_created" = true ]; then
|
||||||
|
sudo ip6tables -F integration_only || original_status=1
|
||||||
|
sudo ip6tables -X integration_only || original_status=1
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$original_status" > "$results/exit-status.txt"
|
||||||
|
exit "$original_status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
export PATH="$PWD/.venv/bin:$PATH"
|
||||||
|
export PYTHONPATH="$PWD:$PWD/tests:$PWD/tests/e2e"
|
||||||
|
export DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:5432/circle_test"
|
||||||
|
export REDIS_HOST=127.0.0.1 REDIS_PORT=6379
|
||||||
|
export LITELLM_MASTER_KEY="sk-$(openssl rand -hex 16)" LITELLM_SALT_KEY=sk-integration-salt
|
||||||
|
export LITELLM_MODE=PRODUCTION LITELLM_LOCAL_MODEL_COST_MAP=True
|
||||||
|
export STORE_MODEL_IN_DB=True AWS_EC2_METADATA_DISABLED=true DO_NOT_TRACK=1
|
||||||
|
export INTEGRATION_PROXY_URL=http://127.0.0.1:4000
|
||||||
|
export INTEGRATION_PEER_URL=""
|
||||||
|
export INTEGRATION_UPSTREAM_URL=http://127.0.0.1:8190
|
||||||
|
export INTEGRATION_MASTER_KEY="$LITELLM_MASTER_KEY"
|
||||||
|
export LITELLM_UI_PATH="$PWD/litellm/proxy/_experimental/out"
|
||||||
|
if [ "$suite" = browser ]; then
|
||||||
|
export LITELLM_UI_PATH="$PWD/ui/litellm-dashboard/out"
|
||||||
|
test -f "$LITELLM_UI_PATH/index.html"
|
||||||
|
fi
|
||||||
|
export INTEGRATION_SEED="$(.venv/bin/python -c 'import hashlib,os; print(int(hashlib.sha256((os.environ.get("CIRCLE_SHA1", "local") + os.environ.get("CIRCLE_WORKFLOW_ID", "local")).encode()).hexdigest()[:8],16))')"
|
||||||
|
export INTEGRATION_ORDER_SEED="$INTEGRATION_SEED"
|
||||||
|
|
||||||
|
uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma > "$results/prisma-generate.log" 2>&1
|
||||||
|
|
||||||
|
export INTEGRATION_PROXY_DATABASE_URL=""
|
||||||
|
export INTEGRATION_PROXY_READ_REPLICA_URL=""
|
||||||
|
export INTEGRATION_ROUTING=""
|
||||||
|
if [ "$mode" = replica ] || [ "$mode" = parity ]; then
|
||||||
|
.venv/bin/python .circleci/scripts/prepare_replica_roles.py > "$results/prepare-replica-roles.log" 2>&1
|
||||||
|
export INTEGRATION_PROXY_DATABASE_URL="postgresql://litellm_writer:litellm-writer@127.0.0.1:5432/circle_test"
|
||||||
|
export INTEGRATION_PROXY_READ_REPLICA_URL="postgresql://litellm_reader:litellm-reader@127.0.0.1:5432/circle_test"
|
||||||
|
fi
|
||||||
|
if [ "$mode" = parity ]; then
|
||||||
|
export INTEGRATION_ROUTING=capture
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo mkdir -p "/sys/fs/cgroup/$egress_cgroup"
|
||||||
|
echo "$$" | sudo tee "/sys/fs/cgroup/$egress_cgroup/cgroup.procs" > /dev/null
|
||||||
|
sudo iptables -N integration_only
|
||||||
|
guard_created=true
|
||||||
|
sudo iptables -A integration_only -o lo -j ACCEPT
|
||||||
|
sudo iptables -A integration_only -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
||||||
|
for service in postgres-db redis-cache; do
|
||||||
|
address="$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$service")"
|
||||||
|
sudo iptables -A integration_only -d "$address" -j ACCEPT
|
||||||
|
done
|
||||||
|
sudo iptables -A integration_only -j REJECT
|
||||||
|
sudo iptables -I OUTPUT 1 -m cgroup --path "$egress_cgroup" -j integration_only
|
||||||
|
guard_installed=true
|
||||||
|
sudo ip6tables -N integration_only
|
||||||
|
guard6_created=true
|
||||||
|
sudo ip6tables -A integration_only -o lo -j ACCEPT
|
||||||
|
sudo ip6tables -A integration_only -j REJECT
|
||||||
|
sudo ip6tables -I OUTPUT 1 -m cgroup --path "$egress_cgroup" -j integration_only
|
||||||
|
guard6_installed=true
|
||||||
|
|
||||||
|
if curl --noproxy '*' --connect-timeout 2 -s http://198.51.100.1 >/dev/null 2>&1; then
|
||||||
|
echo "Unexpected outbound network access" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sudo iptables -L integration_only -n -v -x > "$results/egress-guard.txt"
|
||||||
|
awk '$3 == "REJECT" && $1 > 0 { rejected=1 } END { exit !rejected }' "$results/egress-guard.txt"
|
||||||
|
|
||||||
|
setsid env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" INTEGRATION_RUN_ID="$integration_identity" \
|
||||||
|
.venv/bin/python -m integration._support.upstream > "$results/upstream.log" 2>&1 &
|
||||||
|
upstream_pid=$!
|
||||||
|
if [ "$suite" = cost ]; then
|
||||||
|
export INTEGRATION_WORKERS=8
|
||||||
|
fi
|
||||||
|
if [ "$suite" = mcp ]; then
|
||||||
|
export INTEGRATION_WORKERS=4 INTEGRATION_COVERAGE=1
|
||||||
|
fi
|
||||||
|
coverage_data="$PWD/$results/coverage/data"
|
||||||
|
proxy_command=(.venv/bin/python -m integration._support.proxy)
|
||||||
|
if [ "${INTEGRATION_COVERAGE:-0}" = 1 ]; then
|
||||||
|
mkdir -p "$(dirname "$coverage_data")"
|
||||||
|
proxy_command=(.venv/bin/python -m coverage run --rcfile=tests/integration/mcp_coverage.toml -m integration._support.proxy)
|
||||||
|
fi
|
||||||
|
start_proxy() {
|
||||||
|
local port="$1"
|
||||||
|
local log_name="$2"
|
||||||
|
local -a cost_map_env
|
||||||
|
if [ "$suite" = cost ]; then
|
||||||
|
cost_map_env=(
|
||||||
|
"LITELLM_MODEL_COST_MAP_URL=$INTEGRATION_UPSTREAM_URL/_cost_map"
|
||||||
|
"MODEL_COST_MAP_MIN_MODEL_COUNT=1"
|
||||||
|
"MODEL_COST_MAP_MAX_SHRINK_RATIO=0"
|
||||||
|
"GEMINI_API_BASE=$INTEGRATION_UPSTREAM_URL"
|
||||||
|
"ANTHROPIC_API_BASE=$INTEGRATION_UPSTREAM_URL"
|
||||||
|
"GEMINI_API_KEY=sk-scripted-provider"
|
||||||
|
"ANTHROPIC_API_KEY=sk-scripted-provider"
|
||||||
|
)
|
||||||
|
else
|
||||||
|
cost_map_env=("LITELLM_LOCAL_MODEL_COST_MAP=True")
|
||||||
|
fi
|
||||||
|
local -a database_env=("DATABASE_URL=${INTEGRATION_PROXY_DATABASE_URL:-$DATABASE_URL}")
|
||||||
|
if [ -n "$INTEGRATION_PROXY_READ_REPLICA_URL" ]; then
|
||||||
|
database_env+=("DATABASE_URL_READ_REPLICA=$INTEGRATION_PROXY_READ_REPLICA_URL")
|
||||||
|
fi
|
||||||
|
setsid env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" INTEGRATION_RUN_ID="$integration_identity" \
|
||||||
|
"${database_env[@]}" REDIS_HOST="$REDIS_HOST" REDIS_PORT="$REDIS_PORT" \
|
||||||
|
INTEGRATION_UPSTREAM_URL="$INTEGRATION_UPSTREAM_URL" \
|
||||||
|
LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" LITELLM_SALT_KEY="$LITELLM_SALT_KEY" LITELLM_UI_PATH="$LITELLM_UI_PATH" PROXY_BASE_URL="http://127.0.0.1:$port" \
|
||||||
|
LITELLM_LICENSE="${LITELLM_LICENSE:-}" \
|
||||||
|
LITELLM_MODE=PRODUCTION STORE_MODEL_IN_DB=True LITELLM_ENABLE_MCP_STDIO=true "${cost_map_env[@]}" \
|
||||||
|
AWS_EC2_METADATA_DISABLED=true DO_NOT_TRACK=1 COVERAGE_FILE="$coverage_data" \
|
||||||
|
"${proxy_command[@]}" --config tests/integration/proxy_config.yaml \
|
||||||
|
--host 127.0.0.1 --port "$port" --num_workers 1 --telemetry False \
|
||||||
|
--use_prisma_db_push \
|
||||||
|
> "$results/$log_name" 2>&1 &
|
||||||
|
launched_pid=$!
|
||||||
|
}
|
||||||
|
start_proxy 4000 proxy.log
|
||||||
|
proxy_pid="$launched_pid"
|
||||||
|
.venv/bin/python .circleci/scripts/wait_integration_services.py
|
||||||
|
curl --noproxy '*' -sSf -X POST "$INTEGRATION_PROXY_URL/config/update" \
|
||||||
|
-H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"router_settings": {"num_retries": 0}}' > "$results/seed-router-settings.json"
|
||||||
|
if [ "$suite" = management ] || [ "$suite" = mcp ]; then
|
||||||
|
export INTEGRATION_PEER_URL=http://127.0.0.1:4001
|
||||||
|
start_proxy 4001 peer.log
|
||||||
|
peer_pid="$launched_pid"
|
||||||
|
.venv/bin/python .circleci/scripts/wait_integration_services.py
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$suite" = providers ]; then
|
||||||
|
INTEGRATION_RUN_ID="$integration_identity" .venv/bin/python -m pytest --tb=short --noconftest -o addopts= \
|
||||||
|
--strict-markers --strict-config -p no:pytest-retry -p no:rerunfailures --timeout=30 \
|
||||||
|
tests/e2e_harness/test_provider_edge.py::TestReplayMode::test_content_drift_returns_the_miss_status_naming_both_keys \
|
||||||
|
tests/e2e_harness/test_provider_edge.py::TestReplayMode::test_exhausted_key_returns_the_miss_status \
|
||||||
|
tests/e2e_harness/test_provider_edge.py::TestReplayLeftover::test_partially_consumed_recording_names_the_leftover \
|
||||||
|
tests/e2e_harness/test_provider_edge.py::TestStreamingFidelity::test_replay_of_a_stream_makes_no_provider_connection \
|
||||||
|
--junitxml="$results/replay-controls.xml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$suite" = browser ]; then
|
||||||
|
export E2E_UI_BASE_URL="$INTEGRATION_PROXY_URL" E2E_UI_ARTIFACT_DIR="$PWD/$results"
|
||||||
|
export INTEGRATION_PYTHON="$PWD/.venv/bin/python"
|
||||||
|
timeout --signal=TERM --kill-after=20s 3m env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" \
|
||||||
|
INTEGRATION_RUN_ID="$integration_identity" DATABASE_URL="$DATABASE_URL" \
|
||||||
|
INTEGRATION_UPSTREAM_URL="$INTEGRATION_UPSTREAM_URL" INTEGRATION_PYTHON="$INTEGRATION_PYTHON" \
|
||||||
|
E2E_UI_BASE_URL="$E2E_UI_BASE_URL" E2E_UI_ARTIFACT_DIR="$E2E_UI_ARTIFACT_DIR" \
|
||||||
|
LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" CI=true \
|
||||||
|
node tests/e2e/ui/node_modules/@playwright/test/cli.js test --config tests/e2e/ui/integration.config.ts
|
||||||
|
.venv/bin/python .circleci/scripts/verify_integration_browser.py "$results/browser-results.json"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
node_files=()
|
||||||
|
if [ "${CIRCLE_NODE_TOTAL:-1}" -gt 1 ]; then
|
||||||
|
split="$(.venv/bin/python tests/integration/run.py "$suite" --list \
|
||||||
|
| circleci tests split --split-by=timings --timings-type=filename)"
|
||||||
|
read -r -a node_files <<< "$(printf '%s' "$split" | tr '\n' ' ')"
|
||||||
|
test "${#node_files[@]}" -gt 0
|
||||||
|
printf '%s\n' "${node_files[@]}" > "$results/node-files.txt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" \
|
||||||
|
INTEGRATION_RUN_ID="$integration_identity" \
|
||||||
|
DATABASE_URL="$DATABASE_URL" REDIS_HOST="$REDIS_HOST" REDIS_PORT="$REDIS_PORT" \
|
||||||
|
INTEGRATION_PROXY_URL="$INTEGRATION_PROXY_URL" INTEGRATION_PEER_URL="$INTEGRATION_PEER_URL" \
|
||||||
|
INTEGRATION_UPSTREAM_URL="$INTEGRATION_UPSTREAM_URL" \
|
||||||
|
INTEGRATION_WORKERS="${INTEGRATION_WORKERS:-1}" \
|
||||||
|
INTEGRATION_MASTER_KEY="$INTEGRATION_MASTER_KEY" LITELLM_MODE=PRODUCTION \
|
||||||
|
LITELLM_LICENSE="${LITELLM_LICENSE:-}" \
|
||||||
|
INTEGRATION_SEED="$INTEGRATION_SEED" \
|
||||||
|
INTEGRATION_ORDER_SEED="$INTEGRATION_ORDER_SEED" \
|
||||||
|
LITELLM_LOCAL_MODEL_COST_MAP=True AWS_EC2_METADATA_DISABLED=true DO_NOT_TRACK=1 \
|
||||||
|
INTEGRATION_PROXY_DATABASE_URL="$INTEGRATION_PROXY_DATABASE_URL" \
|
||||||
|
INTEGRATION_PROXY_READ_REPLICA_URL="$INTEGRATION_PROXY_READ_REPLICA_URL" \
|
||||||
|
INTEGRATION_ROUTING="$INTEGRATION_ROUTING" \
|
||||||
|
.venv/bin/python tests/integration/run.py "$suite" --results "$results" "${node_files[@]}"
|
||||||
|
|
||||||
|
if [ "${INTEGRATION_COVERAGE:-0}" = 1 ]; then
|
||||||
|
for covered_pid in "$proxy_pid" "$peer_pid"; do
|
||||||
|
[ -n "$covered_pid" ] || continue
|
||||||
|
kill -TERM -- "-$covered_pid"
|
||||||
|
for _ in {1..300}; do
|
||||||
|
kill -0 "$covered_pid" 2>/dev/null || break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
wait "$covered_pid" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
proxy_pid=""
|
||||||
|
peer_pid=""
|
||||||
|
COVERAGE_FILE="$coverage_data" .venv/bin/python -m coverage combine --rcfile=tests/integration/mcp_coverage.toml
|
||||||
|
COVERAGE_FILE="$coverage_data" .venv/bin/python -m coverage report --rcfile=tests/integration/mcp_coverage.toml \
|
||||||
|
> "$results/coverage/coverage.txt"
|
||||||
|
COVERAGE_FILE="$coverage_data" .venv/bin/python -m coverage html --rcfile=tests/integration/mcp_coverage.toml \
|
||||||
|
-d "$results/coverage/html"
|
||||||
|
tail -n 1 "$results/coverage/coverage.txt"
|
||||||
|
fi
|
||||||
116
.circleci/scripts/run_migration_tests.py
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
|
SUITES: Final = {
|
||||||
|
"startup": (("test_startup.py",), 12),
|
||||||
|
"recovery": (("test_recovery.py",), 15),
|
||||||
|
"legacy": (("test_legacy.py", "test_pooling.py"), 11),
|
||||||
|
"upgrade": (("test_upgrade.py", "test_rolling_upgrade.py"), 5),
|
||||||
|
"shaped": (("test_shaped_database.py",), 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def successful_junit(path: Path, expected: int, exit_code: int) -> bool:
|
||||||
|
if exit_code != 0 or not path.is_file():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
root: Final = ElementTree.parse(path).getroot()
|
||||||
|
except ElementTree.ParseError:
|
||||||
|
return False
|
||||||
|
cases: Final = tuple(root.iter("testcase"))
|
||||||
|
identities: Final = frozenset((case.get("classname"), case.get("name")) for case in cases)
|
||||||
|
return len(cases) == len(identities) == expected and all(
|
||||||
|
not any(case.find(tag) is not None for tag in ("failure", "error", "skipped")) for case in cases
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def output(*command: str) -> str:
|
||||||
|
return subprocess.check_output(command, text=True, timeout=90).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def record_image() -> None:
|
||||||
|
source: Final = output("git", "rev-parse", "HEAD")
|
||||||
|
image: Final = output("docker", "image", "inspect", "litellm-docker-database:ci", "--format", "{{.Id}}")
|
||||||
|
revision: Final = output(
|
||||||
|
"docker",
|
||||||
|
"image",
|
||||||
|
"inspect",
|
||||||
|
"litellm-docker-database:ci",
|
||||||
|
"--format",
|
||||||
|
'{{index .Config.Labels "org.opencontainers.image.revision"}}',
|
||||||
|
)
|
||||||
|
assert re.fullmatch(r"[0-9a-f]{40}", source), "Invalid source revision"
|
||||||
|
assert revision == source, "Candidate image revision differs from the tested source"
|
||||||
|
Path("migration-image.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"source_sha": source,
|
||||||
|
"image_id": image,
|
||||||
|
"candidate_image": os.environ.get("MIGRATION_CANDIDATE_IMAGE", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
suite: Final = os.environ["MIGRATION_TEST_SUITE"]
|
||||||
|
files, expected = SUITES[suite]
|
||||||
|
metadata: Final = json.loads(Path("migration-image.json").read_text())
|
||||||
|
assert metadata["source_sha"] == output("git", "rev-parse", "HEAD"), "Image and test source revisions differ"
|
||||||
|
assert metadata["image_id"] == output(
|
||||||
|
"docker", "image", "inspect", os.environ["LITELLM_MIGRATION_TEST_IMAGE"], "--format", "{{.Id}}"
|
||||||
|
), "Loaded image differs from the build output"
|
||||||
|
assert metadata["candidate_image"] == os.environ.get("MIGRATION_CANDIDATE_IMAGE", ""), "Wrong release candidate"
|
||||||
|
destination: Final = Path(os.environ["MIGRATION_TEST_OUTPUT"])
|
||||||
|
junit: Final = destination / "junit" / "results.xml"
|
||||||
|
junit.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
result: Final = subprocess.run(
|
||||||
|
(
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
*(f"tests/e2e/migrations/{name}" for name in files),
|
||||||
|
"-vv",
|
||||||
|
"--tb=short",
|
||||||
|
"--durations=10",
|
||||||
|
f"--junitxml={junit}",
|
||||||
|
"-o",
|
||||||
|
"addopts=",
|
||||||
|
"--reruns=0",
|
||||||
|
),
|
||||||
|
check=False,
|
||||||
|
timeout=1200,
|
||||||
|
)
|
||||||
|
passed: Final = successful_junit(junit, expected, result.returncode)
|
||||||
|
(destination / "verdict.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
**metadata,
|
||||||
|
"suite": suite,
|
||||||
|
"baseline_image": os.environ.get("LITELLM_MIGRATION_BASELINE_IMAGE", ""),
|
||||||
|
"expected_cases": expected,
|
||||||
|
"passed": passed,
|
||||||
|
"pytest_exit_code": result.returncode,
|
||||||
|
"test_revision": metadata["source_sha"],
|
||||||
|
"workflow_id": os.environ.get("CIRCLE_WORKFLOW_ID", ""),
|
||||||
|
"job_number": os.environ.get("CIRCLE_BUILD_NUM", ""),
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0 if passed else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if sys.argv[1:] == ["record-image"]:
|
||||||
|
record_image()
|
||||||
|
else:
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -31,7 +31,7 @@ def main() -> None:
|
||||||
result: Final = json.loads(Path(sys.argv[1]).read_text())
|
result: Final = json.loads(Path(sys.argv[1]).read_text())
|
||||||
assert not result.get("errors"), result.get("errors")
|
assert not result.get("errors"), result.get("errors")
|
||||||
expected: Final = json.loads(
|
expected: Final = json.loads(
|
||||||
(Path(__file__).resolve().parents[3] / "tests/e2e/ui/tests/integrationCritical/expected.json").read_text()
|
(Path(__file__).resolve().parents[2] / "tests/e2e/ui/tests/integrationCritical/expected.json").read_text()
|
||||||
)
|
)
|
||||||
assert expected and result["stats"]["expected"] == len(expected)
|
assert expected and result["stats"]["expected"] == len(expected)
|
||||||
assert all(result["stats"][name] == 0 for name in ("unexpected", "flaky", "skipped"))
|
assert all(result["stats"][name] == 0 for name in ("unexpected", "flaky", "skipped"))
|
||||||
|
|
@ -41,7 +41,7 @@ def main() -> None:
|
||||||
|
|
||||||
suites: Final = TypeAdapter(list[BrowserSuite]).validate_python(result["suites"], strict=True)
|
suites: Final = TypeAdapter(list[BrowserSuite]).validate_python(result["suites"], strict=True)
|
||||||
specs: Final = tuple(spec for suite in suites for spec in cases(suite))
|
specs: Final = tuple(spec for suite in suites for spec in cases(suite))
|
||||||
repository: Final = Path(__file__).resolve().parents[3]
|
repository: Final = Path(__file__).resolve().parents[2]
|
||||||
report_root: Final = Path(result["config"]["rootDir"])
|
report_root: Final = Path(result["config"]["rootDir"])
|
||||||
assert report_root.is_absolute(), "Playwright rootDir must be explicit"
|
assert report_root.is_absolute(), "Playwright rootDir must be explicit"
|
||||||
observed: Final = tuple(
|
observed: Final = tuple(
|
||||||
43
.circleci/scripts/wait_integration_services.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from redis import Redis
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
primary: Final = os.environ["INTEGRATION_PROXY_URL"]
|
||||||
|
peer: Final = os.environ.get("INTEGRATION_PEER_URL")
|
||||||
|
proxies: Final = (primary, peer) if peer else (primary,)
|
||||||
|
deadline: Final = time.monotonic() + 90
|
||||||
|
headers: Final = {"Authorization": f"Bearer {os.environ['INTEGRATION_MASTER_KEY']}"}
|
||||||
|
with httpx.Client(trust_env=False, timeout=2) as client, Redis(
|
||||||
|
host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"]), socket_timeout=2
|
||||||
|
) as cache:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
ready: Final = (
|
||||||
|
client.get(f"{os.environ['INTEGRATION_UPSTREAM_URL']}/health").status_code == 200
|
||||||
|
and all(client.get(f"{url}/health/readiness").status_code == 200 for url in proxies)
|
||||||
|
)
|
||||||
|
if ready:
|
||||||
|
for url in proxies:
|
||||||
|
response: Final = client.get(f"{url}/cache/ping", headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
result: Final = response.json()
|
||||||
|
assert result["status"] == "healthy", result
|
||||||
|
assert result["cache_type"] == "redis", result
|
||||||
|
assert result["ping_response"] is True, result
|
||||||
|
assert result["set_cache_response"] == "success", result
|
||||||
|
if cache.pubsub_numsub("litellm_proxy.auth_cache_invalidation")[0][1] >= len(proxies):
|
||||||
|
return
|
||||||
|
except httpx.TransportError:
|
||||||
|
pass
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise SystemExit("Integration services or auth-cache subscribers did not become ready")
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
57
.devcontainer/devcontainer.json
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
{
|
||||||
|
"name": "Python 3.11",
|
||||||
|
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||||
|
"image": "mcr.microsoft.com/devcontainers/python:3.11-bookworm",
|
||||||
|
// https://github.com/devcontainers/images/tree/main/src/python
|
||||||
|
// https://mcr.microsoft.com/en-us/product/devcontainers/python/tags
|
||||||
|
|
||||||
|
// "build": {
|
||||||
|
// "dockerfile": "Dockerfile",
|
||||||
|
// "context": ".."
|
||||||
|
// },
|
||||||
|
|
||||||
|
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/node:1": {
|
||||||
|
"version": "lts"
|
||||||
|
},
|
||||||
|
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Configure tool-specific properties.
|
||||||
|
"customizations": {
|
||||||
|
// Configure properties specific to VS Code.
|
||||||
|
"vscode": {
|
||||||
|
"settings": {},
|
||||||
|
"extensions": [
|
||||||
|
"ms-python.python",
|
||||||
|
"ms-python.vscode-pylance",
|
||||||
|
"GitHub.copilot",
|
||||||
|
"GitHub.copilot-chat",
|
||||||
|
"ms-python.autopep8"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||||
|
"forwardPorts": [4000],
|
||||||
|
|
||||||
|
"containerEnv": {
|
||||||
|
"LITELLM_LOG": "DEBUG"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Use 'portsAttributes' to set default properties for specific forwarded ports.
|
||||||
|
// More info: https://containers.dev/implementors/json_reference/#port-attributes
|
||||||
|
"portsAttributes": {
|
||||||
|
"4000": {
|
||||||
|
"label": "LiteLLM Server",
|
||||||
|
"onAutoForward": "notify"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// More info: https://aka.ms/dev-containers-non-root.
|
||||||
|
// "remoteUser": "litellm",
|
||||||
|
|
||||||
|
// Use 'postCreateCommand' to run commands after the container is created.
|
||||||
|
"postCreateCommand": "bash ./.devcontainer/post-create.sh"
|
||||||
|
}
|
||||||
17
.devcontainer/post-create.sh
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "[post-create] Installing uv"
|
||||||
|
curl -LsSf https://astral.sh/uv/0.10.9/install.sh | env UV_NO_MODIFY_PATH=1 sh
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
|
||||||
|
echo "[post-create] Installing Python dependencies (uv)"
|
||||||
|
uv sync --frozen --group proxy-dev --extra proxy
|
||||||
|
|
||||||
|
echo "[post-create] Generating Prisma client"
|
||||||
|
uv run --no-sync prisma generate
|
||||||
|
|
||||||
|
echo "[post-create] Installing npm dependencies"
|
||||||
|
cd ui/litellm-dashboard && npm ci
|
||||||
|
|
||||||
|
echo "[post-create] Done"
|
||||||
58
.dockerignore
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
docs
|
||||||
|
cookbook
|
||||||
|
.circleci
|
||||||
|
.github
|
||||||
|
tests
|
||||||
|
.git
|
||||||
|
.devcontainer
|
||||||
|
*.tgz
|
||||||
|
log.txt
|
||||||
|
docker/Dockerfile.*
|
||||||
|
|
||||||
|
# Claude Flow generated files (must be excluded from Docker build)
|
||||||
|
.claude/
|
||||||
|
.claude-flow/
|
||||||
|
.swarm/
|
||||||
|
.hive-mind/
|
||||||
|
memory/
|
||||||
|
coordination/
|
||||||
|
claude-flow
|
||||||
|
.mcp.json
|
||||||
|
hive-mind-prompt-*.txt
|
||||||
|
|
||||||
|
# Python virtual environments and version managers
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
**/.venv/
|
||||||
|
**/venv/
|
||||||
|
.python-version
|
||||||
|
.pyenv/
|
||||||
|
__pycache__/
|
||||||
|
**/__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.mypy_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
**/pyvenv.cfg
|
||||||
|
|
||||||
|
# Common project exclusions
|
||||||
|
.vscode
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
.pytest_cache
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
.DS_Store
|
||||||
|
**/node_modules
|
||||||
|
ui/litellm-dashboard/.next
|
||||||
|
ui/litellm-dashboard/out
|
||||||
|
litellm-rust/target/
|
||||||
|
litellm/rust_bridge/_native*.so
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
32
.env.example
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# OpenAI
|
||||||
|
OPENAI_API_KEY = ""
|
||||||
|
OPENAI_BASE_URL = ""
|
||||||
|
# Cohere
|
||||||
|
COHERE_API_KEY = ""
|
||||||
|
# OpenRouter
|
||||||
|
OR_SITE_URL = ""
|
||||||
|
OR_APP_NAME = "LiteLLM Example app"
|
||||||
|
OR_API_KEY = ""
|
||||||
|
# Azure API base URL
|
||||||
|
AZURE_API_BASE = ""
|
||||||
|
# Azure API version
|
||||||
|
AZURE_API_VERSION = ""
|
||||||
|
# Azure API key
|
||||||
|
AZURE_API_KEY = ""
|
||||||
|
# Replicate
|
||||||
|
REPLICATE_API_KEY = ""
|
||||||
|
REPLICATE_API_TOKEN = ""
|
||||||
|
# Anthropic
|
||||||
|
ANTHROPIC_API_KEY = ""
|
||||||
|
# Infisical
|
||||||
|
INFISICAL_TOKEN = ""
|
||||||
|
# Novita AI
|
||||||
|
NOVITA_API_KEY = ""
|
||||||
|
# INFINITY
|
||||||
|
INFINITY_API_KEY = ""
|
||||||
|
|
||||||
|
# Development Configs
|
||||||
|
# Generate one with: echo "LITELLM_MASTER_KEY=sk-$(openssl rand -hex 32)"
|
||||||
|
LITELLM_MASTER_KEY = ""
|
||||||
|
DATABASE_URL = "postgresql://llmproxy:dbpassword9090@db:5432/litellm"
|
||||||
|
STORE_MODEL_IN_DB = "True"
|
||||||
40
.git-blame-ignore-revs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Add the commit hash of any commit you want to ignore in `git blame` here.
|
||||||
|
# One commit hash per line.
|
||||||
|
#
|
||||||
|
# The GitHub Blame UI will use this file automatically!
|
||||||
|
#
|
||||||
|
# Run this command to always ignore formatting commits in `git blame`
|
||||||
|
# git config blame.ignoreRevsFile .git-blame-ignore-revs
|
||||||
|
|
||||||
|
# Update pydantic code to fix warnings (GH-3600)
|
||||||
|
876840e9957bc7e9f7d6a2b58c4d7c53dad16481
|
||||||
|
|
||||||
|
# style(ui): run prettier --write across the dashboard (#29622)
|
||||||
|
7edf3a9cb55548b143df1692f4ed7c4681d7fcf7
|
||||||
|
|
||||||
|
# style: reformat litellm/ with ruff format (#31317)
|
||||||
|
17bfd415aeb5a57fb646b5cc67da1c730aa7c50b
|
||||||
|
|
||||||
|
# style: unify ruff format width on 120 (#31518)
|
||||||
|
48b5a5a0cc5a694a11219416ee0b6eb6e620e74e
|
||||||
|
|
||||||
|
# refactor(imports): move collections.abc names out of typing (#35495)
|
||||||
|
397e8e4918777e4e60a7f5e88699e0a9a7dabb3d
|
||||||
|
|
||||||
|
# refactor(lint): apply every safe ruff autofix and zero 28 strict-rule budgets (#35495)
|
||||||
|
b604e2b20c6db2099085a2f0e59b7e99e87eed6f
|
||||||
|
|
||||||
|
# refactor(logging): drop redundant !s conversion flags from f-strings (#35546)
|
||||||
|
7b2d3440cba3160277470f7a0180098ae9b87864
|
||||||
|
|
||||||
|
# perf: build log messages lazily so filtered-out log records cost nothing (#35703)
|
||||||
|
c9887a1f94bc1e7e4bdfe64d640f0509a0bc19dd
|
||||||
|
|
||||||
|
# feat(lint): enforce Final on locals and freeze function parameters (#35807)
|
||||||
|
2708620d6a599cc73c1950a942d26ac26a7ed3d4
|
||||||
|
|
||||||
|
# chore(lint): remove litellm/types from the ruff lint exclusion (#35926)
|
||||||
|
4e32a8bf6a1e1af1e04b67c759841ccef44b2235
|
||||||
|
|
||||||
|
# chore(lint): strip inert type: ignore comments and zero LIT009/LIT010/LIT011 headroom (#35928)
|
||||||
|
338e411103ad5d7003e97f34f04fa36bca542dbe
|
||||||
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
*.ipynb linguist-vendored
|
||||||
|
ui/litellm-dashboard/src/lib/http/schema.d.ts linguist-generated
|
||||||
106
.gitguardian.yaml
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
version: 2
|
||||||
|
|
||||||
|
secret:
|
||||||
|
# Exclude files and paths by globbing
|
||||||
|
ignored_paths:
|
||||||
|
- "**/*.whl"
|
||||||
|
- "**/*.pyc"
|
||||||
|
- "**/__pycache__/**"
|
||||||
|
- "**/node_modules/**"
|
||||||
|
- "**/dist/**"
|
||||||
|
- "**/build/**"
|
||||||
|
- "**/.git/**"
|
||||||
|
- "**/venv/**"
|
||||||
|
- "**/.venv/**"
|
||||||
|
|
||||||
|
# Large data/metadata files that don't need scanning
|
||||||
|
- "**/model_prices_and_context_window*.json"
|
||||||
|
- "**/*_metadata/*.txt"
|
||||||
|
- "**/tokenizers/*.json"
|
||||||
|
- "**/tokenizers/*"
|
||||||
|
- "miniconda.sh"
|
||||||
|
|
||||||
|
# Build outputs and static assets
|
||||||
|
- "litellm/proxy/_experimental/out/**"
|
||||||
|
- "ui/litellm-dashboard/public/**"
|
||||||
|
- "**/swagger/*.js"
|
||||||
|
- "**/*.woff"
|
||||||
|
- "**/*.woff2"
|
||||||
|
- "**/*.avif"
|
||||||
|
- "**/*.webp"
|
||||||
|
|
||||||
|
# Test data files
|
||||||
|
- "**/tests/**/data_map.txt"
|
||||||
|
- "tests/**/*.txt"
|
||||||
|
|
||||||
|
# Documentation and other non-code files
|
||||||
|
- "docs/**"
|
||||||
|
- "**/*.md"
|
||||||
|
- "**/*.lock"
|
||||||
|
- "uv.lock"
|
||||||
|
- "package-lock.json"
|
||||||
|
|
||||||
|
# Ignore security incidents with the SHA256 of the occurrence (false positives)
|
||||||
|
ignored_matches:
|
||||||
|
# === Current detected false positives (SHA-based) ===
|
||||||
|
|
||||||
|
# gcs_pub_sub_body - folder name, not a password
|
||||||
|
- name: GCS pub/sub test folder name
|
||||||
|
match: 75f377c456eede69e5f6e47399ccee6016a2a93cc5dd11db09cc5b1359ae569a
|
||||||
|
|
||||||
|
# os.environ/APORIA_API_KEY_1 - environment variable reference
|
||||||
|
- name: Environment variable reference APORIA_API_KEY_1
|
||||||
|
match: e2ddeb8b88eca97a402559a2be2117764e11c074d86159ef9ad2375dea188094
|
||||||
|
|
||||||
|
# os.environ/APORIA_API_KEY_2 - environment variable reference
|
||||||
|
- name: Environment variable reference APORIA_API_KEY_2
|
||||||
|
match: 09aa39a29e050b86603aa55138af1ff08fb86a4582aa965c1bd0672e1575e052
|
||||||
|
|
||||||
|
# oidc/circleci_v2/ - test authentication path, not a secret
|
||||||
|
- name: OIDC CircleCI test path
|
||||||
|
match: feb3475e1f89a65b7b7815ac4ec597e18a9ec1847742ad445c36ca617b536e15
|
||||||
|
|
||||||
|
# text-davinci-003 - OpenAI model identifier, not a secret
|
||||||
|
- name: OpenAI model identifier text-davinci-003
|
||||||
|
match: c489000cf6c7600cee0eefb80ad0965f82921cfb47ece880930eb7e7635cf1f1
|
||||||
|
|
||||||
|
# Base64 Basic Auth in test_pass_through_endpoints.py - test fixture, not a real secret
|
||||||
|
- name: Test Base64 Basic Auth header in pass_through_endpoints test
|
||||||
|
match: 61bac0491f395040617df7ef6d06029eac4d92a4457ac784978db80d97be1ae0
|
||||||
|
|
||||||
|
# PostgreSQL password "postgres" in CI configs - standard test database password
|
||||||
|
- name: Test PostgreSQL password in CI configurations
|
||||||
|
match: 6e0d657eb1f0fbc40cf0b8f3c3873ef627cc9cb7c4108d1c07d979c04bc8a4bb
|
||||||
|
|
||||||
|
# Bearer token in locustfile.py - test/example API key for load testing
|
||||||
|
- name: Test Bearer token in locustfile load test
|
||||||
|
match: 2a0abc2b0c3c1760a51ffcdf8d6b1d384cef69af740504b1cfa82dd70cdc7ff9
|
||||||
|
|
||||||
|
# Inkeep API key in docusaurus.config.js - public documentation site key
|
||||||
|
- name: Inkeep API key in documentation config
|
||||||
|
match: c366657791bfb5fc69045ec11d49452f09a0aebbc8648f94e2469b4025e29a75
|
||||||
|
|
||||||
|
# Langfuse credentials in test_completion.py - test credentials for integration test
|
||||||
|
- name: Langfuse test credentials in test_completion
|
||||||
|
match: c39310f68cc3d3e22f7b298bb6353c4f45759adcc37080d8b7f4e535d3cfd7f4
|
||||||
|
|
||||||
|
# === Preventive patterns for test keys (pattern-based) ===
|
||||||
|
|
||||||
|
# Test API keys (124 instances across 45 files)
|
||||||
|
- name: Test API keys with sk-test prefix
|
||||||
|
match: sk-test-
|
||||||
|
|
||||||
|
# Mock API keys
|
||||||
|
- name: Mock API keys with sk-mock prefix
|
||||||
|
match: sk-mock-
|
||||||
|
|
||||||
|
# Fake API keys
|
||||||
|
- name: Fake API keys with sk-fake prefix
|
||||||
|
match: sk-fake-
|
||||||
|
|
||||||
|
# Generic test API key patterns
|
||||||
|
- name: Test API key patterns
|
||||||
|
match: test-api-key
|
||||||
|
|
||||||
|
- name: Short fake sk keys (1–9 digits only)
|
||||||
|
match: \bsk-\d{1,9}\b
|
||||||
90
.githooks/pre-push
Executable file
|
|
@ -0,0 +1,90 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# pre-push — enforce Conventional Branches
|
||||||
|
# https://conventional-branch.github.io/
|
||||||
|
#
|
||||||
|
# Branch format: <type>/<description>
|
||||||
|
# <type> must be one of: feature, bugfix, hotfix, release, chore
|
||||||
|
#
|
||||||
|
# Protected branches (always allowed):
|
||||||
|
# - main
|
||||||
|
# - dependabot/*
|
||||||
|
# - gh-readonly-queue/*
|
||||||
|
#
|
||||||
|
# Tag pushes and branch deletions are skipped.
|
||||||
|
# Bypass: git push --no-verify.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ZERO_OID="0000000000000000000000000000000000000000"
|
||||||
|
ZERO_OID_SHA256="0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
ALLOWED_TYPES="feature|bugfix|hotfix|release|chore"
|
||||||
|
BRANCH_PATTERN="^(${ALLOWED_TYPES})/.+"
|
||||||
|
|
||||||
|
PROTECTED_NAMES="main"
|
||||||
|
PROTECTED_PREFIXES="dependabot/ gh-readonly-queue/"
|
||||||
|
|
||||||
|
is_protected() {
|
||||||
|
branch="$1"
|
||||||
|
for name in $PROTECTED_NAMES; do
|
||||||
|
if [ "$branch" = "$name" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
for prefix in $PROTECTED_PREFIXES; do
|
||||||
|
case "$branch" in "$prefix"*) return 0 ;; esac
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
invalid=""
|
||||||
|
|
||||||
|
while read -r local_ref local_oid remote_ref remote_oid; do
|
||||||
|
# Branch deletion (no local commit being pushed).
|
||||||
|
if [ "$local_oid" = "$ZERO_OID" ] || [ "$local_oid" = "$ZERO_OID_SHA256" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Only validate branch pushes; ignore tags and other ref namespaces.
|
||||||
|
case "$remote_ref" in
|
||||||
|
refs/heads/*) ;;
|
||||||
|
*) continue ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
branch="${remote_ref#refs/heads/}"
|
||||||
|
|
||||||
|
if is_protected "$branch"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! printf '%s' "$branch" | grep -Eq "$BRANCH_PATTERN"; then
|
||||||
|
invalid="$invalid $branch"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$invalid" ]; then
|
||||||
|
cat >&2 <<EOF
|
||||||
|
✗ Branch name does not follow Conventional Branches.
|
||||||
|
|
||||||
|
Invalid:$invalid
|
||||||
|
|
||||||
|
Expected: <type>/<description>
|
||||||
|
|
||||||
|
Allowed types: feature, bugfix, hotfix, release, chore
|
||||||
|
Examples:
|
||||||
|
feature/weighted-round-robin
|
||||||
|
bugfix/streaming-empty-chunks
|
||||||
|
chore/bump-deps
|
||||||
|
hotfix/auth-bypass
|
||||||
|
|
||||||
|
Protected (always allowed): main, dependabot/*, gh-readonly-queue/*.
|
||||||
|
|
||||||
|
See https://conventional-branch.github.io/
|
||||||
|
|
||||||
|
Rename with: git branch -m <new-name>
|
||||||
|
To bypass (use sparingly): git push --no-verify
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
5
.github/AGENTS.md
vendored
|
|
@ -1,5 +0,0 @@
|
||||||
# CI
|
|
||||||
|
|
||||||
Integration groups and replica runs remain in `workflows/test-integration.yml`. Do not duplicate them in another workflow.
|
|
||||||
|
|
||||||
CI helpers used by existing workflows live in `scripts/ci/`. Jobs that need merge bases check out full history. Removing a CI job does not count its tests as covered. List tests that no remaining job invokes explicitly in `ci-coverage-allowlist.yml`.
|
|
||||||
8
.github/CODEOWNERS
vendored
|
|
@ -1,6 +1,2 @@
|
||||||
/model_prices_and_context_window.json @mateo-berri @ryan-crabbe-berri @kerry-berri @joshua-berri
|
/model_prices_and_context_window.json @mateo-berri @ryan-crabbe-berri @kerrylu-berri
|
||||||
/litellm/model_prices_and_context_window_backup.json @mateo-berri @ryan-crabbe-berri @kerry-berri @joshua-berri
|
/litellm/model_prices_and_context_window_backup.json @mateo-berri @ryan-crabbe-berri @kerrylu-berri
|
||||||
/litellm/scripts/ci_cd/cost_map_pins.json @mateo-berri @ryan-crabbe-berri @kerry-berri @joshua-berri
|
|
||||||
/litellm/deploy/helm/ @yassin-berriai
|
|
||||||
/litellm/deploy/terraform/ @yassin-berriai
|
|
||||||
/.github/workflows/zizmor.yml @yuneng-berri
|
|
||||||
|
|
|
||||||
388
.github/CONTRIBUTING.md
vendored
|
|
@ -1,388 +0,0 @@
|
||||||
# Contributing to LiteLLM
|
|
||||||
|
|
||||||
Thank you for your interest in contributing to LiteLLM! We welcome contributions of all kinds - from bug fixes and documentation improvements to new features and integrations.
|
|
||||||
|
|
||||||
## **Checklist before submitting a PR**
|
|
||||||
|
|
||||||
Here are the core requirements for any PR submitted to LiteLLM:
|
|
||||||
|
|
||||||
- [ ] **Sign the Contributor License Agreement (CLA)** - [see details](#contributor-license-agreement-cla)
|
|
||||||
- [ ] **Keep scope isolated** - Your changes should address 1 specific problem at a time
|
|
||||||
- [ ] **Prove it works end to end** - If your change affects what the proxy or SDK does, paste the commands you ran against a live proxy and their output in the PR - [see details](#proving-your-change-works-end-to-end)
|
|
||||||
|
|
||||||
#### Proxy (Backend) PRs
|
|
||||||
|
|
||||||
- [ ] **Add testing** - Adding at least 1 test is a hard requirement - [see details](#adding-testing)
|
|
||||||
- [ ] **Ensure your PR passes all checks**:
|
|
||||||
- [ ] [Linting / Formatting](#running-linting-and-formatting-checks) - `make lint`
|
|
||||||
- [ ] [The tests covering your change](#running-unit-tests) pass, e.g. `uv run pytest tests/unit/<your_test_file>.py -v`. CI runs the full unit test matrix, so you don't need to run the whole suite locally
|
|
||||||
|
|
||||||
#### UI PRs
|
|
||||||
|
|
||||||
- [ ] **Ensure the UI builds successfully** - `npm run build`
|
|
||||||
- [ ] **Ensure all UI unit tests pass** - `npm run test`
|
|
||||||
- [ ] **Add tests for new components or logic** - If you are adding a new component or new logic, add corresponding tests
|
|
||||||
|
|
||||||
## **Contributor License Agreement (CLA)**
|
|
||||||
|
|
||||||
Before contributing code to LiteLLM, you must sign our [Contributor License Agreement (CLA)](https://cla-assistant.io/BerriAI/litellm). This is a legal requirement for all contributions to be merged into the main repository.
|
|
||||||
|
|
||||||
**Important:** We strongly recommend reviewing and signing the CLA before starting work on your contribution to avoid any delays in the PR process.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### 1. Setup Your Local Development Environment
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Fork the repository on GitHub (click the Fork button at https://github.com/BerriAI/litellm)
|
|
||||||
# Then clone your fork locally
|
|
||||||
git clone https://github.com/YOUR_USERNAME/litellm.git
|
|
||||||
cd litellm
|
|
||||||
|
|
||||||
# Create a new branch for your feature (see "Commit and Branch Conventions" below)
|
|
||||||
git checkout -b litellm_your_feature
|
|
||||||
|
|
||||||
# Install development dependencies
|
|
||||||
make install-dev
|
|
||||||
|
|
||||||
# Install git hooks that enforce commit conventions (one-time, opt-in)
|
|
||||||
make install-hooks
|
|
||||||
|
|
||||||
# Verify your setup works
|
|
||||||
make help
|
|
||||||
```
|
|
||||||
|
|
||||||
That's it! Your local development environment is ready.
|
|
||||||
|
|
||||||
## Commit and Branch Conventions
|
|
||||||
|
|
||||||
Commits follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). Internal contributors name branches with the `litellm_` prefix and no `/`, and fork branches can use any name. Run `make install-hooks` once per clone to enable the local hook that enforces the commit convention. See the [contributor docs](https://docs.litellm.ai/docs/extras/contributing_code#commit-and-branch-conventions) for details
|
|
||||||
|
|
||||||
### 2. Development Workflow
|
|
||||||
|
|
||||||
Here's the recommended workflow for making changes:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Make your changes to the code
|
|
||||||
# ...
|
|
||||||
|
|
||||||
# Format your code (auto-fixes formatting issues)
|
|
||||||
make format
|
|
||||||
|
|
||||||
# Run all linting checks (matches CI exactly)
|
|
||||||
make lint
|
|
||||||
|
|
||||||
# Run the tests covering your change (CI runs the full suite)
|
|
||||||
uv run pytest tests/unit/<your_test_file>.py -v
|
|
||||||
|
|
||||||
# Commit your changes (must follow Conventional Commits — see above)
|
|
||||||
git add .
|
|
||||||
git commit -m "feat(scope): your descriptive commit message"
|
|
||||||
|
|
||||||
# Push and create a PR
|
|
||||||
git push origin litellm_your_feature
|
|
||||||
```
|
|
||||||
|
|
||||||
## Adding Testing
|
|
||||||
|
|
||||||
**Adding at least 1 test is a hard requirement for all PRs.**
|
|
||||||
|
|
||||||
### Where to Add Tests
|
|
||||||
|
|
||||||
Add your tests to the [`tests/unit/` directory](https://github.com/BerriAI/litellm/tree/main/tests/unit).
|
|
||||||
|
|
||||||
- This directory mirrors the structure of the `litellm/` directory
|
|
||||||
- **Only add mocked tests** - no real LLM API calls in this directory
|
|
||||||
- For integration tests with real APIs, use the appropriate test directories
|
|
||||||
|
|
||||||
Tests that call `litellm.register_model` or change `litellm.model_cost` should use the `local_model_cost_map` fixture from `tests/unit/conftest.py`. It provides a fresh catalog and restores the previous map, so pricing and capability overrides cannot leak into later tests
|
|
||||||
|
|
||||||
### File Naming Convention
|
|
||||||
|
|
||||||
The `tests/unit/` directory follows the same structure as `litellm/`:
|
|
||||||
|
|
||||||
- `litellm/proxy/caching_routes.py` → `tests/unit/proxy/test_caching_routes.py`
|
|
||||||
- `litellm/utils.py` → `tests/unit/test_utils.py`
|
|
||||||
|
|
||||||
### Example Test
|
|
||||||
|
|
||||||
```python
|
|
||||||
import pytest
|
|
||||||
from litellm import completion
|
|
||||||
|
|
||||||
def test_your_feature():
|
|
||||||
"""Test your feature with a descriptive docstring."""
|
|
||||||
# Arrange
|
|
||||||
messages = [{"role": "user", "content": "Hello"}]
|
|
||||||
|
|
||||||
# Act
|
|
||||||
# Use mocked responses, not real API calls
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
assert expected_result == actual_result
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running Tests and Checks
|
|
||||||
|
|
||||||
### Running Unit Tests
|
|
||||||
|
|
||||||
Run the tests covering your change:
|
|
||||||
```bash
|
|
||||||
uv run pytest tests/unit/test_your_file.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
`tests/unit` holds thousands of tests, so running all of it locally takes a long time. CI runs it as a parallel matrix (`make test-unit-llms`, `make test-unit-proxy-core`, and the other `test-unit-*` targets) on beefier boxes, so if, for whatever reason, you must run the whole suite, it's better to rely on CI to do that.
|
|
||||||
|
|
||||||
If you're running broader test suites, proxy tests, or anything that touches PostgreSQL-backed fixtures/plugins, install the full local test environment first:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make install-test-deps
|
|
||||||
```
|
|
||||||
|
|
||||||
This syncs the locked test environment used across the repo, including `psycopg` v3 plus `psycopg-binary`, `psycopg2-binary` (used by some proxy E2E tests), and a generated Prisma client for DB-backed proxy tests, so pytest startup matches CI without manual package installs.
|
|
||||||
|
|
||||||
### Running Linting and Formatting Checks
|
|
||||||
|
|
||||||
Run all linting checks (matches CI exactly):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make lint
|
|
||||||
```
|
|
||||||
|
|
||||||
Individual linting commands:
|
|
||||||
```bash
|
|
||||||
make format-check # Check ruff format formatting
|
|
||||||
make lint-ruff # Run Ruff linting
|
|
||||||
make lint-basedpyright # Run basedpyright type checking
|
|
||||||
make check-circular-imports # Check for circular imports
|
|
||||||
make check-import-safety # Check import safety
|
|
||||||
```
|
|
||||||
|
|
||||||
Apply formatting (auto-fixes issues):
|
|
||||||
```bash
|
|
||||||
make format
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Formatting is enforced in CI.** All PRs must pass the `ruff format --check` step.
|
|
||||||
>
|
|
||||||
> - **AI coding agents** (Claude Code, Copilot, Cursor, etc.): follow `AGENTS.md` and run `make format` before committing.
|
|
||||||
> - **VS Code users**: Install the [Ruff extension](https://marketplace.visualstudio.com/items?itemName=charliermarsh.ruff) and enable format-on-save:
|
|
||||||
> ```json
|
|
||||||
> {
|
|
||||||
> "[python]": {
|
|
||||||
> "editor.defaultFormatter": "charliermarsh.ruff",
|
|
||||||
> "editor.formatOnSave": true
|
|
||||||
> }
|
|
||||||
> }
|
|
||||||
> ```
|
|
||||||
|
|
||||||
### CI Compatibility
|
|
||||||
|
|
||||||
To ensure your changes will pass CI, run the exact same checks locally:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# This runs the same checks as the GitHub workflows
|
|
||||||
make lint
|
|
||||||
make test-unit
|
|
||||||
```
|
|
||||||
|
|
||||||
For exact CI compatibility (pins OpenAI version like CI):
|
|
||||||
```bash
|
|
||||||
make install-dev-ci # Installs exact CI dependencies
|
|
||||||
```
|
|
||||||
|
|
||||||
## Available Make Commands
|
|
||||||
|
|
||||||
Run `make help` to see all available commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make help # Show all available commands
|
|
||||||
make install-dev # Install development dependencies
|
|
||||||
make install-proxy-dev # Install proxy development dependencies
|
|
||||||
make install-test-deps # Install the full local test environment
|
|
||||||
make format # Apply ruff format code formatting
|
|
||||||
make format-check # Check ruff format formatting (matches CI)
|
|
||||||
make lint # Run all linting checks
|
|
||||||
make test-unit # Run unit tests
|
|
||||||
make test-integration # Run integration tests
|
|
||||||
make test-unit-helm # Run Helm unit tests
|
|
||||||
```
|
|
||||||
|
|
||||||
## Code Quality Standards
|
|
||||||
|
|
||||||
LiteLLM follows the [Google Python Style Guide](https://google.github.io/styleguide/pyguide.html).
|
|
||||||
|
|
||||||
Our automated quality checks include:
|
|
||||||
- **Ruff** for formatting, linting, and code quality
|
|
||||||
- **basedpyright** for static type checking
|
|
||||||
- **Circular import detection**
|
|
||||||
- **Import safety validation**
|
|
||||||
|
|
||||||
All checks must pass before your PR can be merged.
|
|
||||||
|
|
||||||
## Common Issues and Solutions
|
|
||||||
|
|
||||||
### 1. Linting Failures
|
|
||||||
|
|
||||||
If `make lint` fails:
|
|
||||||
|
|
||||||
1. **Formatting issues**: Run `make format` to auto-fix
|
|
||||||
2. **Ruff issues**: Check the output and fix manually
|
|
||||||
3. **basedpyright issues**: Add proper type hints
|
|
||||||
4. **Circular imports**: Refactor import dependencies
|
|
||||||
5. **Import safety**: Fix any unprotected imports
|
|
||||||
|
|
||||||
### 2. Test Failures
|
|
||||||
|
|
||||||
If `make test-unit` fails:
|
|
||||||
|
|
||||||
1. Check if you broke existing functionality
|
|
||||||
2. Add tests for your new code
|
|
||||||
3. Ensure tests use mocks, not real API calls
|
|
||||||
4. Check test file naming conventions
|
|
||||||
|
|
||||||
### 3. Common Development Tips
|
|
||||||
|
|
||||||
- **Use type hints**: basedpyright requires proper type annotations
|
|
||||||
- **Write descriptive commit messages**: Help reviewers understand your changes
|
|
||||||
- **Keep PRs focused**: One feature/fix per PR
|
|
||||||
- **Test edge cases**: Don't just test the happy path
|
|
||||||
- **Update documentation**: If you change APIs, update docs
|
|
||||||
|
|
||||||
## Proving Your Change Works End to End
|
|
||||||
|
|
||||||
Tests are required, but they do not prove that your change works for a user. A PR that changes what the proxy or SDK does shows a real run in its "Screenshots / Proof of Fix" section, meaning the exact commands you sent to a live proxy and the output that came back, against real provider APIs and not mocks. `pytest` output does not count as proof, because a passing test only shows that the code does what its mocks expect. A PR that changes only docs or CI shows the rendered page or the CI run instead
|
|
||||||
|
|
||||||
A good proof looks like this:
|
|
||||||
|
|
||||||
1. Install the proxy dependencies with `make install-proxy-dev`, then start the proxy from your branch with a config that has the model you changed, `uv run litellm --config your_config.yaml --detailed_debug`
|
|
||||||
2. Send the request a user would send with `curl`, and paste both the command and the response. When cost or routing is involved, include the response headers, since `x-litellm-response-cost` is what the user sees
|
|
||||||
3. For a bug fix, do this twice, once at the merge base with BerriAI/litellm's main (`git merge-base origin/main HEAD`, with your remote for BerriAI/litellm in place of `origin` in a fork clone) to show the bug and once at your branch's latest commit to show the fix. Name both commit hashes
|
|
||||||
4. If your change is reachable from more than one endpoint (`/v1/chat/completions`, `/v1/responses`, `/v1/messages`), show each one
|
|
||||||
5. If the request can stream, show a `"stream": true` run too, because streaming builds usage and cost on a different path. A stream sends its headers before its usage exists, so show the `usage.cost` on the last chunk, which `/v1/chat/completions` sends when the request has `"stream_options": {"include_usage": true}` and other endpoints send with `include_cost_in_streaming_usage: true` under `litellm_settings`, or, when the proxy has a database, the request's row on the Admin UI's Logs page or `GET /spend/logs`
|
|
||||||
6. For UI changes, add before and after screenshots and name the page
|
|
||||||
7. If users reach your change through a coding tool such as Claude Code or Codex, drive that tool the way a user does and add before and after screenshots of it, because a `curl` or a headless run alone does not show what the user sees
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sD - http://localhost:4000/v1/chat/completions \
|
|
||||||
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"model": "your-model", "messages": [{"role": "user", "content": "hi"}]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
When the provider reports its own usage or spend numbers, compare the proxy's numbers against them in the proof
|
|
||||||
|
|
||||||
## Contributing an LLM Provider Integration
|
|
||||||
|
|
||||||
Provider work (a new provider, a new parameter, a new usage or cost field) is the most common kind of contribution, and it is where PRs get reworked most often. Follow these rules
|
|
||||||
|
|
||||||
**Preserve zero prices.** In [`model_prices_and_context_window.json`](../model_prices_and_context_window.json), an explicit `0.0` means that the priced operation is free. A missing field or `null` means no rate is specified, so the relevant calculator may use a fallback. For example, when no batch input rate is configured, batch cache writes use `cache_creation_input_token_cost` if set, otherwise the regular input rate, then apply the batch discount. A zero ordinary input rate does not make separately priced cache reads or writes free. Test `rate is None` before substituting a fallback, since `row.get("cache_creation_input_token_cost") or fallback` would discard a valid zero. Speech keeps character pricing ahead of token pricing even when the character rate is zero. If neither rate is configured, speech cost calculation raises an error. Its model lookup preserves unset rates instead of treating compatibility defaults as explicit zero prices. Perplexity keeps a configured legacy search rate ahead of the current search rate, including zero. When registering custom prices, `None` leaves an existing rate unchanged and `0.0` overrides it
|
|
||||||
|
|
||||||
**Find the existing machinery before you add plumbing.** LiteLLM already has generic paths that move data between the endpoints, the provider transformations, and cost tracking. A second, hand-rolled path for the same data is a common reason a provider PR gets rewritten before merge. If you cannot find a mechanism for what you need, say so in the PR or ask in [#pr-review on Slack](https://join.slack.com/t/litellmossslack/shared_invite/zt-3o7nkuyfr-p_kbNJj8taRfXGgQI1~YyA) before you build one
|
|
||||||
|
|
||||||
**Bill a new provider usage field from the usage object.** Leave the field on the usage object as an extra and read it in that provider's `cost_calculator.py`. A provider without one gets a new `cost_calculator.py` and an arm for it in `cost_per_token` (`litellm/cost_calculator.py`), which picks the calculator by provider. If the provider's transform builds `Usage` from named fields, as the Anthropic, Gemini, and Bedrock Converse ones do, pass the new field there too. Streamed requests rebuild usage in `ChunkProcessor.calculate_usage` (`litellm/litellm_core_utils/streaming_chunk_builder_utils.py`), which does not copy extras yet the way the Responses-to-chat bridge does (minus a short exclusion list), so until it does, bill the field on streams by naming it there, as Anthropic's `inference_geo` is. A provider that reports its own charge for the request, as OpenRouter does in `usage.cost`, is billed at that charge from `_hidden_params` before any calculator runs, unless the deployment sets its own prices. When the field counts a built-in tool such as web search, add the provider to `get_cost_for_web_search_request` in `litellm/llms/__init__.py`, and add a gate to `litellm/litellm_core_utils/llm_cost_calc/tool_call_cost_tracking.py` only when no existing gate fires. Then show the field's cost in your proof on every endpoint it reaches, streaming included
|
|
||||||
|
|
||||||
**Do not change what a caller sees to make internals easier.** Each endpoint keeps the schema of the API it emulates. A `/v1/responses` caller gets `input_tokens` and a `/v1/chat/completions` caller gets `prompt_tokens`, whatever the provider sent and whatever cost tracking needs. A provider usage field that shows up as an extra key is fine, since it only adds a key, but renaming, removing, or reshaping a key the endpoint defines is not. Reshaping a public response to feed an internal consumer breaks every user of that endpoint
|
|
||||||
|
|
||||||
## Building and Running Locally
|
|
||||||
|
|
||||||
### LiteLLM Proxy Server
|
|
||||||
|
|
||||||
To run the proxy server locally:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install proxy dependencies
|
|
||||||
make install-proxy-dev
|
|
||||||
|
|
||||||
# Start the proxy server
|
|
||||||
uv run litellm --config your_config.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker Development
|
|
||||||
|
|
||||||
If you want to build the Docker image yourself:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build the image (runs as a non-root user by default)
|
|
||||||
docker build -f litellm/deploy/docker/Dockerfile -t litellm_dev .
|
|
||||||
|
|
||||||
# Generate a master key. Requests send it as the bearer token
|
|
||||||
export LITELLM_MASTER_KEY="sk-$(openssl rand -hex 32)"
|
|
||||||
|
|
||||||
# Run with your config
|
|
||||||
docker run \
|
|
||||||
-v $(pwd)/proxy_config.yaml:/app/config.yaml \
|
|
||||||
-e LITELLM_MASTER_KEY \
|
|
||||||
-p 4000:4000 \
|
|
||||||
litellm_dev \
|
|
||||||
--config /app/config.yaml --detailed_debug
|
|
||||||
```
|
|
||||||
|
|
||||||
## UI Development
|
|
||||||
|
|
||||||
### 1. Setup Your Local UI Development Environment
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clone the repo (if you haven't already)
|
|
||||||
git clone https://github.com/YOUR_USERNAME/litellm.git
|
|
||||||
cd litellm
|
|
||||||
|
|
||||||
# Navigate to the UI dashboard directory
|
|
||||||
cd litellm/ui/litellm-dashboard
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Start the development server
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Adding UI Tests
|
|
||||||
|
|
||||||
If you are adding a **new component** or **new logic**, you must add corresponding tests.
|
|
||||||
|
|
||||||
### 3. Running UI Unit Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run test
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Building the UI
|
|
||||||
|
|
||||||
Ensure the UI builds successfully before submitting your PR:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
Local lint and budget checks follow origin's current default branch. They refresh it from the remote instead of trusting cached `origin/HEAD`. For an intentional comparison against another branch or commit, use `make check BASE_REF=<ref>` or the standalone gate's `--base <ref>` option. An explicit ref can also be used offline once it has been fetched locally. Without an override, unavailable remote metadata stops the check
|
|
||||||
|
|
||||||
## Submitting Your PR
|
|
||||||
|
|
||||||
1. **Push your branch**: `git push origin your-feature-branch`
|
|
||||||
2. **Create a PR**: Go to GitHub and open a pull request against the repository's current default branch. Run `python3 litellm/scripts/default_branch.py --branch` to check its name
|
|
||||||
3. **Fill out the PR template**: Provide clear description of changes
|
|
||||||
4. **Wait for review**: Maintainers will review and provide feedback
|
|
||||||
5. **Address feedback**: Make requested changes and push updates
|
|
||||||
6. **Merge**: Once approved, your PR will be merged!
|
|
||||||
|
|
||||||
## Getting Help
|
|
||||||
|
|
||||||
If you need help:
|
|
||||||
|
|
||||||
- 💬 [Join our Discord](https://discord.gg/wuPM9dRgDw)
|
|
||||||
- 💬 [Join our Slack](https://www.litellm.ai/support)
|
|
||||||
- 📧 Email us: ishaan@berri.ai / krrish@berri.ai
|
|
||||||
- 🐛 [Create an issue](https://github.com/BerriAI/litellm/issues/new)
|
|
||||||
|
|
||||||
## What to Contribute
|
|
||||||
|
|
||||||
Looking for ideas? Check out:
|
|
||||||
|
|
||||||
- 🐛 [Good first issues](https://github.com/BerriAI/litellm/labels/good%20first%20issue)
|
|
||||||
- 🚀 [Feature requests](https://github.com/BerriAI/litellm/labels/enhancement)
|
|
||||||
- 📚 Documentation improvements
|
|
||||||
- 🧪 Test coverage improvements
|
|
||||||
- 🔌 New LLM provider integrations
|
|
||||||
|
|
||||||
Thank you for contributing to LiteLLM! 🚀
|
|
||||||
24
.github/PULL_REQUEST_TEMPLATE/general.md
vendored
|
|
@ -4,18 +4,6 @@
|
||||||
Drop every section you have nothing to put in, heading included: a bare "## Relevant issues" or
|
Drop every section you have nothing to put in, heading included: a bare "## Relevant issues" or
|
||||||
"## Affected release" with nothing under it must not appear in the final description -->
|
"## Affected release" with nothing under it must not appear in the final description -->
|
||||||
|
|
||||||
## Handwritten 1-3 sentence description
|
|
||||||
|
|
||||||
<!-- The author writes this in their own words, and it can be left empty. AI agents never write here and leave the
|
|
||||||
placeholder line below as is, and when updating an existing PR they keep whatever the author wrote. Keep this heading
|
|
||||||
Say what the PR does for users and why, then anything that keeps it working over time (a sync job, a config someone owns)
|
|
||||||
No bullets, tables, or code names here. Everything else goes inside the Details block below -->
|
|
||||||
|
|
||||||
[insert handwritten description here]
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Details</summary>
|
|
||||||
|
|
||||||
## TLDR
|
## TLDR
|
||||||
|
|
||||||
<!-- Fill in the bullets below and keep each one short and concrete: one line per bullet, roughly 10 words max
|
<!-- Fill in the bullets below and keep each one short and concrete: one line per bullet, roughly 10 words max
|
||||||
|
|
@ -80,7 +68,7 @@ After: the same request comes back with real token counts, so the dashboard show
|
||||||
- [ ] The handful of test files covering my change pass locally, e.g. `uv run pytest tests/unit/<your_test_file>.py -v`. Leave the suites (`make test-unit-*`, `make test-unit`) to CI: it finishes in ~15 minutes where a laptop takes an hour or more
|
- [ ] The handful of test files covering my change pass locally, e.g. `uv run pytest tests/unit/<your_test_file>.py -v`. Leave the suites (`make test-unit-*`, `make test-unit`) to CI: it finishes in ~15 minutes where a laptop takes an hour or more
|
||||||
- [ ] My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
|
- [ ] My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
|
||||||
- [ ] My PR's scope is as isolated as possible; it only solves 1 specific problem
|
- [ ] My PR's scope is as isolated as possible; it only solves 1 specific problem
|
||||||
- [ ] Every automated review on my PR's latest commit has finished, and I have addressed or replied to each of its findings before requesting a maintainer review
|
- [ ] I have received a Greptile **Confidence Score of at least 4/5** before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment `@greptileai` to re-request a review after pushing changes)
|
||||||
|
|
||||||
## Delays in PR merge?
|
## Delays in PR merge?
|
||||||
|
|
||||||
|
|
@ -89,11 +77,9 @@ If you're seeing a delay in your PR being merged, ping the LiteLLM Team on [Slac
|
||||||
## Screenshots / Proof of Fix
|
## Screenshots / Proof of Fix
|
||||||
|
|
||||||
<!-- Include screenshots, screen recordings, or command (e.g., curl) + output demonstrating that your changes work as expected
|
<!-- Include screenshots, screen recordings, or command (e.g., curl) + output demonstrating that your changes work as expected
|
||||||
Simple PR exception (2026-10-28, #49030). A mechanical change fully proven by focused checks may skip before/after e2e. Follow AGENTS.md's eligibility rule, trace callers, and verify bug-fix regression sensitivity. Record E2E exception at <tip SHA>, scope, rationale, commands and results here. Tests/static checks suffice only for this exception. Keep CI and bot gates, reassess changed diffs, and honor explicit e2e requests. Omit the Before/After scaffold for an exception
|
The proof must be completely e2e with no mocks, using actual LLM calls costing real $$$ if applicable. `pytest` commands are not enough
|
||||||
Otherwise proof must be fully e2e, no mocks, with real provider calls where applicable. Pytest alone is insufficient. The following format applies to live proof
|
Show ONLY the latest run: capture Before at the merge base and After at the PR's current tip, and when new commits change behavior, replace this whole section with the fresh run instead of stacking it on top of older ones. The run must be up to date. As soon as a new commit is made and it makes this PR description's after sha stale (it's no longer tip of PR), you must re-run the QA
|
||||||
Show only the latest proof. Before uses the merge base and After the tested tip. Rerun affected QA after behavior changes. Verified neutral diffs may carry proof with the tested SHA, current tip, equivalence reason and focused checks, following AGENTS.md and /qa
|
Structure the section exactly as below: Before and After one heading level below this section, each naming the commit hash it was captured at, one lower-level heading per case inside each, the same case names in the same order on both sides, and numbered steps (command, observed output) under every case, never loose prose; shared setup (config, payloads) goes above Before, and with a single case, drop the case headings and number the steps directly
|
||||||
Use matching Before/After case headings with numbered commands and observed output. Put shared setup above them. For one case, omit case headings
|
|
||||||
A reviewer must be able to rerun every case from the body alone, so the shared setup gives the proxy start command, the config.yaml, env var names (never values) and the client code or curl used, all exactly as run. Wrap any block longer than about 20 lines in a collapsed <details> with a one-line summary
|
|
||||||
|
|
||||||
### Before (<hash>)
|
### Before (<hash>)
|
||||||
|
|
||||||
|
|
@ -178,5 +164,3 @@ Example checklists:
|
||||||
## Final Attestation
|
## Final Attestation
|
||||||
|
|
||||||
- [ ] The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR
|
- [ ] The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
|
||||||
89
.github/SECURITY.md
vendored
|
|
@ -1,89 +0,0 @@
|
||||||
# Data Privacy and Security
|
|
||||||
|
|
||||||
## Security Announcements
|
|
||||||
|
|
||||||
LiteLLM maintains a security announcements mailing list that is open to anyone. Subscribers receive advance notice, typically one to two days, before we release a fix for a particularly severe vulnerability or for any vulnerability exploitable by an unauthenticated attacker. This notice is provided on a best-effort basis
|
|
||||||
|
|
||||||
To subscribe, visit [https://security.litellm.ai/mailinglist/](https://security.litellm.ai/mailinglist/)
|
|
||||||
|
|
||||||
## Security Vulnerability Reporting Guidelines
|
|
||||||
|
|
||||||
|
|
||||||
We value the security community's role in protecting our systems and users.
|
|
||||||
To report a security vulnerability, please submit a report through our vulnerability disclosure form:
|
|
||||||
|
|
||||||
[LiteLLM VDP](https://security.litellm.ai/vdp/)
|
|
||||||
|
|
||||||
These reports are submitted to HackerOne and are triaged there, though no account is needed.
|
|
||||||
|
|
||||||
**Please do not submit vulnerabilities as draft GHSAs. As of 2026-11-11 new GHSA submissions will be closed.**
|
|
||||||
|
|
||||||
### Vulnerability Categories
|
|
||||||
|
|
||||||
We classify vulnerabilities into the following categories:
|
|
||||||
|
|
||||||
**P0: Supply Chain Attacks**
|
|
||||||
|
|
||||||
Attacks that compromise our CI/CD pipeline, allowing a malicious actor to point our PyPI package or Docker images (GHCR or Docker Hub) to vulnerable or tampered artifacts.
|
|
||||||
|
|
||||||
**P1: Unauthenticated Proxy Access**
|
|
||||||
|
|
||||||
Application-level attacks where an unauthenticated user is able to gain access to protected data on a LiteLLM proxy instance that should be protected (e.g api keys).
|
|
||||||
|
|
||||||
**P2: Authenticated Malicious Actions**
|
|
||||||
|
|
||||||
Application-level attacks where an authenticated user is able to perform actions beyond their intended permissions, such as privilege escalation or unauthorized data access.
|
|
||||||
|
|
||||||
### Bug Bounty Program
|
|
||||||
|
|
||||||
We offer bounties for responsibly disclosed vulnerabilities based on severity:
|
|
||||||
|
|
||||||
**Note that currently only P0/P1 reports are eligible for a bounty, though submissions for P2 bugs are still encouraged**
|
|
||||||
|
|
||||||
| Severity | Bounty Range | Example |
|
|
||||||
|----------|-------------|---------|
|
|
||||||
| **Critical** | $1,500 - $3,000 | P0 supply chain compromise |
|
|
||||||
| **High** | $500 - $1,500 | P1 unauthenticated proxy access |
|
|
||||||
| **Medium** | N/A | P2 authenticated privilege escalation |
|
|
||||||
| **Low** | N/A | Minor information disclosure, low-impact misconfigurations |
|
|
||||||
|
|
||||||
To qualify for a bounty, reports must include clear reproduction steps, a reproduction video as described above, and must not involve systems or accounts you do not own. We review all submissions promptly and will follow up within 5 business days.
|
|
||||||
|
|
||||||
### Known Non-Issues
|
|
||||||
|
|
||||||
- **Misconfigurations:** attacks that require misconfiguration of the application (e.g. not setting a `master_key` on the proxy configuration).
|
|
||||||
- **Insecure configurations:** attacks that require deliberate configuration changes to disable security functionality. (e.g. IDOR on response IDs requiring configuration of `disable_responses_id_security` to `true`)
|
|
||||||
- **Social engineering:** attacks that rely on social engineering as their primary mechanism (e.g. convincing a user's agent to install a malicious package we did not publish).
|
|
||||||
|
|
||||||
## Security Measures
|
|
||||||
|
|
||||||
### LiteLLM Github
|
|
||||||
|
|
||||||
- All commits run through Github's CodeQL checking
|
|
||||||
|
|
||||||
### Self-hosted Instances LiteLLM
|
|
||||||
|
|
||||||
- **No data or telemetry is stored on LiteLLM Servers when you self host**
|
|
||||||
- For installation and configuration, see: [Self-hosting guided](https://docs.litellm.ai/docs/proxy/deploy)
|
|
||||||
- **Telemetry** We run no telemetry when you self host LiteLLM
|
|
||||||
|
|
||||||
### LiteLLM Cloud
|
|
||||||
|
|
||||||
- We encrypt all data stored using your `LITELLM_MASTER_KEY` and in transit using TLS.
|
|
||||||
- Our database and application run on GCP, AWS infrastructure, partly managed by NeonDB.
|
|
||||||
- US data region: Northern California (AWS/GCP `us-west-1`) & Virginia (AWS `us-east-1`)
|
|
||||||
- EU data region Germany/Frankfurt (AWS/GCP `eu-central-1`)
|
|
||||||
- All users have access to SSO (Single Sign-On) through OAuth 2.0 with Google, Okta, Microsoft, KeyCloak.
|
|
||||||
- Audit Logs with retention policy
|
|
||||||
- Control Allowed IP Addresses that can access your Cloud LiteLLM Instance
|
|
||||||
|
|
||||||
For security inquiries, please contact us at security@berri.ai
|
|
||||||
|
|
||||||
#### Supported data regions for LiteLLM Cloud
|
|
||||||
|
|
||||||
LiteLLM supports the following data regions:
|
|
||||||
|
|
||||||
- US, Northern California (AWS/GCP `us-west-1`)
|
|
||||||
- Europe, Frankfurt, Germany (AWS/GCP `eu-central-1`)
|
|
||||||
|
|
||||||
All data, user accounts, and infrastructure are completely separated between these two regions
|
|
||||||
10
.github/actions/cache-cargo-build/action.yml
vendored
|
|
@ -3,7 +3,7 @@ description: >-
|
||||||
Cache the Cargo registry and target directory the root package's build needs,
|
Cache the Cargo registry and target directory the root package's build needs,
|
||||||
so only the first job on a given Cargo.lock compiles the bridge from scratch.
|
so only the first job on a given Cargo.lock compiles the bridge from scratch.
|
||||||
|
|
||||||
litellm builds through maturin, which compiles litellm/rust/crates/python-bridge
|
litellm builds through maturin, which compiles litellm-rust/crates/python-bridge
|
||||||
in the dev profile for editable installs. `uv sync` therefore pays a full build
|
in the dev profile for editable installs. `uv sync` therefore pays a full build
|
||||||
in every job that installs the workspace. Nothing caught it, because the uv cache
|
in every job that installs the workspace. Nothing caught it, because the uv cache
|
||||||
holds wheels uv downloads rather than wheels it builds, and a path dependency
|
holds wheels uv downloads rather than wheels it builds, and a path dependency
|
||||||
|
|
@ -31,8 +31,8 @@ runs:
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
~/.cargo/git
|
~/.cargo/git
|
||||||
litellm/rust/target
|
litellm-rust/target
|
||||||
key: ${{ runner.os }}-maturin-${{ inputs.profile }}-${{ hashFiles('litellm/rust/Cargo.lock') }}
|
key: ${{ runner.os }}-maturin-${{ inputs.profile }}-${{ hashFiles('litellm-rust/Cargo.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-maturin-${{ inputs.profile }}-
|
${{ runner.os }}-maturin-${{ inputs.profile }}-
|
||||||
|
|
||||||
|
|
@ -43,7 +43,7 @@ runs:
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
~/.cargo/git
|
~/.cargo/git
|
||||||
litellm/rust/target
|
litellm-rust/target
|
||||||
key: ${{ runner.os }}-maturin-${{ inputs.profile }}-${{ hashFiles('litellm/rust/Cargo.lock') }}
|
key: ${{ runner.os }}-maturin-${{ inputs.profile }}-${{ hashFiles('litellm-rust/Cargo.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-maturin-${{ inputs.profile }}-
|
${{ runner.os }}-maturin-${{ inputs.profile }}-
|
||||||
|
|
|
||||||
39
.github/actions/cache-go-build/action.yml
vendored
|
|
@ -1,39 +0,0 @@
|
||||||
name: "Cache the Go build"
|
|
||||||
description: >-
|
|
||||||
Restore the Go module and build caches on every run and save them only from
|
|
||||||
main, so pull requests reuse main's cache instead of evicting it with their
|
|
||||||
own copies.
|
|
||||||
|
|
||||||
actions/setup-go's built-in cache saves from every ref, and a cache saved by a
|
|
||||||
pull request is readable only by that pull request. Callers must pass
|
|
||||||
`cache: false` to setup-go and run this action after it.
|
|
||||||
|
|
||||||
inputs:
|
|
||||||
dependency-path:
|
|
||||||
description: "Path to the go.sum that keys the cache"
|
|
||||||
required: true
|
|
||||||
|
|
||||||
runs:
|
|
||||||
using: composite
|
|
||||||
steps:
|
|
||||||
- name: Restore and save the Go module and build caches
|
|
||||||
if: github.ref == 'refs/heads/main'
|
|
||||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/go/pkg/mod
|
|
||||||
~/.cache/go-build
|
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles(inputs.dependency-path) }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-go-
|
|
||||||
|
|
||||||
- name: Restore the Go module and build caches
|
|
||||||
if: github.ref != 'refs/heads/main'
|
|
||||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/go/pkg/mod
|
|
||||||
~/.cache/go-build
|
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles(inputs.dependency-path) }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-go-
|
|
||||||
4
.github/actions/detect-changes/action.yml
vendored
|
|
@ -1,7 +1,7 @@
|
||||||
name: "Detect relevant changes"
|
name: "Detect relevant changes"
|
||||||
description: >-
|
description: >-
|
||||||
Classify the pull request's changed files with .github/scripts/ci/classify_changes.sh
|
Classify the pull request's changed files with .circleci/scripts/classify_changes.sh
|
||||||
and expose decision=run|skip for one category. backend means anything outside litellm/ui/,
|
and expose decision=run|skip for one category. backend means anything outside ui/,
|
||||||
docs/ and markdown; ui means the dashboard sources alone. decision=skip lets callers
|
docs/ and markdown; ui means the dashboard sources alone. decision=skip lets callers
|
||||||
short-circuit expensive steps while the job still completes successfully and satisfies
|
short-circuit expensive steps while the job still completes successfully and satisfies
|
||||||
its required status check, which a paths: filter cannot do because a workflow that
|
its required status check, which a paths: filter cannot do because a workflow that
|
||||||
|
|
|
||||||
37
.github/actions/restore-rust-bridge/action.yml
vendored
|
|
@ -1,37 +0,0 @@
|
||||||
name: "Restore the Rust bridge"
|
|
||||||
description: >-
|
|
||||||
Restore the prebuilt Rust bridge that the unit workflow's rust-bridge job saves
|
|
||||||
on main, keyed on every input of the Rust build.
|
|
||||||
|
|
||||||
The bridge is abi3, so one build serves every supported Python version. A job
|
|
||||||
whose Rust inputs match main installs the restored wheel and native library
|
|
||||||
instead of compiling them inside `uv sync`. On a miss the caller builds from
|
|
||||||
source as before.
|
|
||||||
|
|
||||||
The restored `rust-bridge-dist` directory holds the editable litellm wheel, the
|
|
||||||
litellm-proxy gateway wheel, and `litellm/rust_bridge/_native.abi3.so`.
|
|
||||||
|
|
||||||
outputs:
|
|
||||||
key:
|
|
||||||
description: "Cache key for the bridge built from this checkout"
|
|
||||||
value: ${{ steps.key.outputs.key }}
|
|
||||||
cache-hit:
|
|
||||||
description: "'true' when the bridge for this exact key was restored"
|
|
||||||
value: ${{ steps.restore.outputs.cache-hit }}
|
|
||||||
|
|
||||||
runs:
|
|
||||||
using: composite
|
|
||||||
steps:
|
|
||||||
- name: Define the Rust bridge cache key
|
|
||||||
id: key
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
RUST_BRIDGE_CACHE_KEY: ${{ runner.os }}-rust-bridge-editable-${{ hashFiles('litellm/rust/Cargo.lock', 'litellm/rust/Cargo.toml', 'litellm/rust/crates/**', 'model_prices_and_context_window.json', 'pyproject.toml', 'litellm/packaging/litellm-proxy/pyproject.toml') }}
|
|
||||||
run: echo "key=$RUST_BRIDGE_CACHE_KEY" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Restore the Rust bridge
|
|
||||||
id: restore
|
|
||||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
|
||||||
with:
|
|
||||||
path: rust-bridge-dist
|
|
||||||
key: ${{ steps.key.outputs.key }}
|
|
||||||
|
|
@ -11,11 +11,6 @@ inputs:
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- name: Configure the Rust build environment
|
|
||||||
shell: bash
|
|
||||||
working-directory: ${{ github.workspace }}
|
|
||||||
run: bash .github/scripts/setup_rust_environment.sh
|
|
||||||
|
|
||||||
- name: Set up uv (attempt 1)
|
- name: Set up uv (attempt 1)
|
||||||
id: attempt-1
|
id: attempt-1
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
|
||||||
BIN
.github/assets/lens-datasets/add-to-dataset-dialog.png
vendored
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
.github/assets/lens-datasets/dataset-case-panel.png
vendored
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
.github/assets/lens-datasets/dataset-detail.png
vendored
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
.github/assets/lens-datasets/datasets-list.png
vendored
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
.github/assets/lens-datasets/old-revision.png
vendored
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
.github/assets/lens-datasets/trace-header.png
vendored
Normal file
|
After Width: | Height: | Size: 259 KiB |
BIN
.github/assets/lens-result-retries/before-results.jpg
vendored
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
.github/assets/lens-result-retries/partial-results.jpg
vendored
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
.github/assets/roi-calculator-integrations/after-github.jpg
vendored
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
.github/assets/roi-calculator-integrations/after-gitlab-detail-top.jpg
vendored
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
.github/assets/roi-calculator-integrations/after-gitlab-detail.jpg
vendored
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
.github/assets/roi-calculator-integrations/after-gitlab.jpg
vendored
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
.github/assets/roi-calculator-integrations/before-github.jpg
vendored
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
.github/assets/roi-calculator-integrations/demo-exit-loading.jpg
vendored
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
.github/assets/roi-calculator-integrations/demo-fallback-live.jpg
vendored
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
.github/assets/roi-calculator-integrations/demo-overview.jpg
vendored
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
.github/assets/roi-calculator-integrations/demo-people.jpg
vendored
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
.github/assets/roi-calculator-integrations/demo-pr-costs.jpg
vendored
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
.github/assets/roi-calculator-integrations/demo-pr-detail.jpg
vendored
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
.github/assets/roi-calculator-integrations/demo-preview-link.jpg
vendored
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
.github/assets/roi-calculator-integrations/demo-with-live-errors.jpg
vendored
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
.github/assets/roi-calculator-integrations/source-race-after.jpg
vendored
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
.github/assets/roi-calculator-integrations/source-race-before.jpg
vendored
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
.github/assets/roi-calculator/00-original-setup.png
vendored
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
.github/assets/roi-calculator/01-connect-github.png
vendored
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
.github/assets/roi-calculator/02-repositories.png
vendored
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
.github/assets/roi-calculator/03-estimator-schedule.png
vendored
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
.github/assets/roi-calculator/04-backfill-progress.png
vendored
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
.github/assets/roi-calculator/06-overview.png
vendored
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
.github/assets/roi-calculator/07-people-unmatched.png
vendored
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
.github/assets/roi-calculator/08-match-email.png
vendored
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
.github/assets/roi-calculator/09-people-matched.png
vendored
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
.github/assets/roi-calculator/10-pr-reasoning.png
vendored
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
.github/assets/roi-calculator/11-settings.png
vendored
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
.github/assets/roi-calculator/12-restart-setup.png
vendored
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
.github/assets/roi-calculator/13-advanced-settings.png
vendored
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
.github/assets/roi-calculator/14-overview-pulls.png
vendored
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
.github/assets/roi-calculator/15-sample-preview.png
vendored
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
.github/assets/roi-calculator/16-calculator-sidebar.png
vendored
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
.github/assets/roi-calculator/19-matching-calculator-icons.png
vendored
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
.github/assets/roi-calculator/20-partial-repository-report.png
vendored
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
.github/assets/roi-calculator/21-empty-repository-preserved-report.png
vendored
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
.github/assets/roi-calculator/22-partial-calculation-explanation.png
vendored
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
.github/assets/roi-calculator/23-estimator-outage-preserved-report.png
vendored
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
.github/assets/scaledown/provider-after.jpg
vendored
|
Before Width: | Height: | Size: 108 KiB |
BIN
.github/assets/scaledown/provider-before.jpg
vendored
|
Before Width: | Height: | Size: 109 KiB |
84
.github/ci-coverage-allowlist.yml
vendored
|
|
@ -4,18 +4,8 @@ description: >-
|
||||||
by a job nor listed here, so every entry below is a decision on the record.
|
by a job nor listed here, so every entry below is a decision on the record.
|
||||||
|
|
||||||
test_paths:
|
test_paths:
|
||||||
- reason: ran only in CircleCI, removed
|
|
||||||
paths:
|
|
||||||
- tests/e2e/migrations/test_legacy.py
|
|
||||||
- tests/e2e/migrations/test_pooling.py
|
|
||||||
- tests/e2e/migrations/test_recovery.py
|
|
||||||
- tests/e2e/migrations/test_rolling_upgrade.py
|
|
||||||
- tests/e2e/migrations/test_shaped_database.py
|
|
||||||
- tests/e2e/migrations/test_startup.py
|
|
||||||
- tests/e2e/migrations/test_upgrade.py
|
|
||||||
- tests/integration/database/test_roi_observed.py
|
|
||||||
- reason: >-
|
- reason: >-
|
||||||
litellm.agent() end-to-end suite. It drives the real claude, codex, opencode and pi CLIs and
|
litellm.agent() end-to-end suite. It drives the real claude, codex and opencode CLIs and
|
||||||
deepagents against a live LiteLLM AI Gateway, so it needs those binaries on PATH plus
|
deepagents against a live LiteLLM AI Gateway, so it needs those binaries on PATH plus
|
||||||
LITELLM_PROXY_API_BASE / LITELLM_PROXY_API_KEY, and skips without them. Run manually
|
LITELLM_PROXY_API_BASE / LITELLM_PROXY_API_KEY, and skips without them. Run manually
|
||||||
before changing litellm/harness; the mocked coverage runs in tests/unit/harness and
|
before changing litellm/harness; the mocked coverage runs in tests/unit/harness and
|
||||||
|
|
@ -28,11 +18,22 @@ test_paths:
|
||||||
paths:
|
paths:
|
||||||
- tests/rust-python-harness
|
- tests/rust-python-harness
|
||||||
- reason: >-
|
- reason: >-
|
||||||
Owned by the open pull request that moves the Rust/Python parity checks (#45135). It ran in
|
Live-provider caching cases in tests/local_testing that remain outside CI. Jobs that
|
||||||
the retired build_and_test job that globbed tests/test_*.py,
|
glob that directory either deselect them (local_testing_part1 and part2 carry `-k "... and
|
||||||
and that pull request gives it a new home
|
not caching and not cache"`) or keep only another keyword (langfuse, router, assistants).
|
||||||
|
Separately, the CircleCI redis-compat jobs select two IAM cluster authentication tests in
|
||||||
|
test_caching.py by node ID. It does not run that file's other tests.
|
||||||
|
The gap was eight files and 118 tests when measured 2026-08-20; the five keyless files now
|
||||||
|
run in the caching-local shard, leaving live cases in these three. Measured 2026-08-21 with no provider
|
||||||
|
credentials and no Redis: test_caching.py needs both (37 of 65 fail without them),
|
||||||
|
test_disk_cache_unit_tests.py needs OPENAI_API_KEY for 2 of its 4, and
|
||||||
|
test_gcs_cache_unit_tests.py needs GCS credentials for all 4. They want the keyless/live
|
||||||
|
split that porting tests/local_testing off CircleCI will force, not a job that is red by
|
||||||
|
construction
|
||||||
paths:
|
paths:
|
||||||
- tests/test_rust_python_harness.py
|
- tests/local_testing/test_caching.py
|
||||||
|
- tests/local_testing/test_disk_cache_unit_tests.py
|
||||||
|
- tests/local_testing/test_gcs_cache_unit_tests.py
|
||||||
- reason: >-
|
- reason: >-
|
||||||
The end-to-end suite runs against a deployed proxy from its own in-cluster rig rather than
|
The end-to-end suite runs against a deployed proxy from its own in-cluster rig rather than
|
||||||
from a pull request; it needs a live gateway and provider credentials no PR job holds
|
from a pull request; it needs a live gateway and provider credentials no PR job holds
|
||||||
|
|
@ -58,6 +59,48 @@ test_paths:
|
||||||
choice it informed is settled
|
choice it informed is settled
|
||||||
paths:
|
paths:
|
||||||
- tests/code_coverage_tests/test_aio_http_image_conversion.py
|
- tests/code_coverage_tests/test_aio_http_image_conversion.py
|
||||||
|
- reason: >-
|
||||||
|
No job invokes this suite and its files mix pure transformation tests with ones driving live
|
||||||
|
vendor vector stores, so assigning them needs a per-file decision
|
||||||
|
paths:
|
||||||
|
- tests/vector_store_tests/rag/test_rag_bedrock.py
|
||||||
|
- tests/vector_store_tests/rag/test_rag_openai.py
|
||||||
|
- tests/vector_store_tests/rag/test_rag_s3_vectors.py
|
||||||
|
- tests/vector_store_tests/rag/test_rag_vertex_ai.py
|
||||||
|
- tests/vector_store_tests/test_azure_ai_vector_store.py
|
||||||
|
- tests/vector_store_tests/test_azure_vector_store.py
|
||||||
|
- tests/vector_store_tests/test_bedrock_vector_store.py
|
||||||
|
- tests/vector_store_tests/test_gemini_vector_store.py
|
||||||
|
- tests/vector_store_tests/test_milvus_vector_store.py
|
||||||
|
- tests/vector_store_tests/test_openai_vector_store.py
|
||||||
|
- tests/vector_store_tests/test_ragflow_vector_store.py
|
||||||
|
- tests/vector_store_tests/test_s3_vectors_vector_store.py
|
||||||
|
- tests/vector_store_tests/test_vertex_ai_search_api_vector_store.py
|
||||||
|
- tests/vector_store_tests/test_vertex_ai_vector_store.py
|
||||||
|
- reason: >-
|
||||||
|
Throughput and memory-growth measurements whose runtime and variance make them unsuitable for
|
||||||
|
a per-pull-request job
|
||||||
|
paths:
|
||||||
|
- tests/load_tests/test_datadog_load_test.py
|
||||||
|
- tests/load_tests/test_langsmith_load_test.py
|
||||||
|
- tests/load_tests/test_linear_memory_growth.py
|
||||||
|
- tests/load_tests/test_memory_usage.py
|
||||||
|
- tests/load_tests/test_otel_load_test.py
|
||||||
|
- tests/load_tests/test_vertex_embeddings_load_test.py
|
||||||
|
- tests/load_tests/test_vertex_load_tests.py
|
||||||
|
- reason: >-
|
||||||
|
Env-gated saturation benchmark requires a live proxy and provider credentials, so it is run
|
||||||
|
locally rather than in pull-request jobs
|
||||||
|
paths:
|
||||||
|
- tests/load_tests/test_granian_admission_saturation.py
|
||||||
|
- reason: >-
|
||||||
|
A local-only agent rig: test_a2a_completion_bridge.py needs a LangGraph server on
|
||||||
|
localhost:2024 and test_a2a.py drives a live A2A endpoint, so neither can run in a
|
||||||
|
pull request job. Until 2026-08-20 the CircleCI agent job hid them behind a grep -v
|
||||||
|
that this census could not see; the glob now excludes them structurally and this entry
|
||||||
|
is the decision on the record. Revisit when the A2A bridge gets a recorded-wire fixture
|
||||||
|
paths:
|
||||||
|
- tests/agent_tests/local_only_agent_tests
|
||||||
- reason: >-
|
- reason: >-
|
||||||
Third-party integration tests that skip themselves without OCI configuration or sandbox
|
Third-party integration tests that skip themselves without OCI configuration or sandbox
|
||||||
credentials, neither of which a pull request job holds
|
credentials, neither of which a pull request job holds
|
||||||
|
|
@ -67,10 +110,19 @@ test_paths:
|
||||||
- tests/integration/test_oci_proxy_integration.py
|
- tests/integration/test_oci_proxy_integration.py
|
||||||
|
|
||||||
dockerfiles:
|
dockerfiles:
|
||||||
|
- reason: >-
|
||||||
|
The dashboard container is a static Next.js export served by nginx, and the dashboard build
|
||||||
|
and lint workflows already exercise that output, so building the image adds no signal about it
|
||||||
|
paths:
|
||||||
|
- ui/Dockerfile
|
||||||
|
- reason: >-
|
||||||
|
An example image under cookbook/ that is documentation rather than a shipped artifact
|
||||||
|
paths:
|
||||||
|
- cookbook/litellm-ollama-docker-image/Dockerfile
|
||||||
- reason: >-
|
- reason: >-
|
||||||
The Rust gateway image compiles the whole workspace in release mode, which is too slow for
|
The Rust gateway image compiles the whole workspace in release mode, which is too slow for
|
||||||
a per-pull-request job while the gateway binary is still being assembled; the Rust lint,
|
a per-pull-request job while the gateway binary is still being assembled; the Rust lint,
|
||||||
clippy, and compile jobs already cover the code it packages. Revisit when the gateway is
|
clippy, and compile jobs already cover the code it packages. Revisit when the gateway is
|
||||||
published
|
published
|
||||||
paths:
|
paths:
|
||||||
- litellm/rust/crates/gateway/Dockerfile
|
- litellm-rust/crates/gateway/Dockerfile
|
||||||
|
|
|
||||||
72
.github/codecov.yml
vendored
|
|
@ -1,72 +0,0 @@
|
||||||
codecov:
|
|
||||||
require_ci_to_pass: false # post coverage status even if CI has unrelated failures
|
|
||||||
notify:
|
|
||||||
wait_for_ci: false # post as soon as expected uploads arrive, don't wait on CI
|
|
||||||
|
|
||||||
ignore:
|
|
||||||
- "litellm/rust/**"
|
|
||||||
|
|
||||||
# Flagged uploads avoid overlapping sessions on reruns.
|
|
||||||
# Only the flags below reuse parent coverage, avoiding stale line maps elsewhere.
|
|
||||||
flag_management:
|
|
||||||
default_rules:
|
|
||||||
carryforward: false
|
|
||||||
individual_flags:
|
|
||||||
- name: unit
|
|
||||||
carryforward: true
|
|
||||||
- name: core-packaging
|
|
||||||
carryforward: true
|
|
||||||
- name: mcp-integration
|
|
||||||
carryforward: true
|
|
||||||
|
|
||||||
component_management:
|
|
||||||
individual_components:
|
|
||||||
- component_id: "Router"
|
|
||||||
paths:
|
|
||||||
- "router"
|
|
||||||
- component_id: "LLMs"
|
|
||||||
paths:
|
|
||||||
- "*/llms/*"
|
|
||||||
- component_id: "Caching"
|
|
||||||
paths:
|
|
||||||
- "*/caching/*"
|
|
||||||
- ".*redis.*"
|
|
||||||
- component_id: "litellm_logging"
|
|
||||||
paths:
|
|
||||||
- "*/integrations/*"
|
|
||||||
- ".*litellm_logging.*"
|
|
||||||
- component_id: "Proxy_Authentication"
|
|
||||||
paths:
|
|
||||||
- "*/proxy/auth/**"
|
|
||||||
- component_id: "Enterprise"
|
|
||||||
paths:
|
|
||||||
- "litellm/enterprise/**"
|
|
||||||
- component_id: "Batches"
|
|
||||||
paths:
|
|
||||||
- "*/proxy/batches_endpoints/**"
|
|
||||||
- "litellm/batches/**"
|
|
||||||
- "*/llms/*/batches/**"
|
|
||||||
- component_id: "Videos"
|
|
||||||
paths:
|
|
||||||
- "litellm/videos/**"
|
|
||||||
- "*/proxy/video_endpoints/**"
|
|
||||||
- "*/llms/*/videos/**"
|
|
||||||
- component_id: "Realtime"
|
|
||||||
paths:
|
|
||||||
- "litellm/realtime_api/**"
|
|
||||||
- "*/proxy/realtime_endpoints/**"
|
|
||||||
- "*/llms/*/realtime/**"
|
|
||||||
- "litellm/litellm_core_utils/realtime_streaming.py"
|
|
||||||
comment:
|
|
||||||
layout: "header, diff, flags, components" # show component info in the PR comment
|
|
||||||
|
|
||||||
coverage:
|
|
||||||
status:
|
|
||||||
project:
|
|
||||||
default:
|
|
||||||
target: auto
|
|
||||||
threshold: 0% # do not allow project coverage to drop
|
|
||||||
patch:
|
|
||||||
default:
|
|
||||||
target: auto
|
|
||||||
threshold: 0% # patch coverage should be 100%
|
|
||||||
69
.github/e2e-stack/assert_tests_ran.py
vendored
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tests/e2e"))
|
||||||
|
from coverage_registry.management_cases import MANAGEMENT_CASES
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
selected: Final = tuple(sys.argv[2:])
|
||||||
|
try:
|
||||||
|
report: Final = ET.parse(Path(sys.argv[1])).getroot()
|
||||||
|
except (ET.ParseError, OSError):
|
||||||
|
_ = sys.stdout.write("::error::could not read the test execution report\n")
|
||||||
|
return 1
|
||||||
|
cases: Final = tuple(report.iter("testcase"))
|
||||||
|
expected_count: Final = os.environ.get("E2E_REQUIRED_TEST_COUNT")
|
||||||
|
passed: Final = frozenset(
|
||||||
|
case.get("file") for case in cases if all(case.find(tag) is None for tag in ("skipped", "failure", "error"))
|
||||||
|
)
|
||||||
|
missing: Final = tuple(path for path in selected if path not in passed)
|
||||||
|
required_nodes: Final = frozenset(case.node for case in MANAGEMENT_CASES if case.node.split("::", 1)[0] in selected)
|
||||||
|
passed_nodes: Final = frozenset(
|
||||||
|
prop.get("value")
|
||||||
|
for case in cases
|
||||||
|
if all(case.find(tag) is None for tag in ("skipped", "failure", "error"))
|
||||||
|
for prop in case.findall("./properties/property")
|
||||||
|
if prop.get("name") == "management_node"
|
||||||
|
)
|
||||||
|
missing_nodes: Final = required_nodes - passed_nodes
|
||||||
|
for node in sorted(missing_nodes):
|
||||||
|
_ = sys.stdout.write(f"::error::required management case did not pass: {node}\n")
|
||||||
|
for path in selected:
|
||||||
|
collected: Final = sum(case.get("file") == path for case in cases)
|
||||||
|
skipped: Final = sum(case.get("file") == path and case.find("skipped") is not None for case in cases)
|
||||||
|
_ = sys.stdout.write(f"{path}: {collected} collected, {skipped} skipped\n")
|
||||||
|
for case in cases:
|
||||||
|
if case.get("file") != path or all(case.find(tag) is None for tag in ("failure", "error", "skipped")):
|
||||||
|
continue
|
||||||
|
outcome = "skipped" if case.find("skipped") is not None else "failed"
|
||||||
|
_ = sys.stdout.write(f" {outcome}: {case.get('classname', '')}::{case.get('name', '')}\n")
|
||||||
|
for prop in case.findall("./properties/property"):
|
||||||
|
name = prop.get("name", "")
|
||||||
|
value = prop.get("value", "")
|
||||||
|
if name in ("oauth_failure_phase", "oauth_exception_type", "oauth_frame") and re.fullmatch(
|
||||||
|
r"[A-Za-z0-9_.:<>-]{1,240}", value
|
||||||
|
):
|
||||||
|
_ = sys.stdout.write(f" {name}: {value}\n")
|
||||||
|
if expected_count is not None and (
|
||||||
|
len(cases) != int(expected_count) or any(case.find("skipped") is not None for case in cases)
|
||||||
|
):
|
||||||
|
_ = sys.stdout.write("::error::required test count was not met or a required case was skipped\n")
|
||||||
|
return 1
|
||||||
|
if (
|
||||||
|
selected
|
||||||
|
and not missing
|
||||||
|
and not missing_nodes
|
||||||
|
and not any(case.find(tag) is not None for case in cases for tag in ("failure", "error"))
|
||||||
|
):
|
||||||
|
return 0
|
||||||
|
_ = sys.stdout.write("::error::every selected file must execute a passing test, with no failures or errors\n")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
105
.github/gitguardian.yaml
vendored
|
|
@ -1,105 +0,0 @@
|
||||||
version: 2
|
|
||||||
|
|
||||||
secret:
|
|
||||||
# Exclude files and paths by globbing
|
|
||||||
ignored_paths:
|
|
||||||
- "**/*.whl"
|
|
||||||
- "**/*.pyc"
|
|
||||||
- "**/__pycache__/**"
|
|
||||||
- "**/node_modules/**"
|
|
||||||
- "**/dist/**"
|
|
||||||
- "**/build/**"
|
|
||||||
- "**/.git/**"
|
|
||||||
- "**/venv/**"
|
|
||||||
- "**/.venv/**"
|
|
||||||
|
|
||||||
# Large data/metadata files that don't need scanning
|
|
||||||
- "**/model_prices_and_context_window*.json"
|
|
||||||
- "**/*_metadata/*.txt"
|
|
||||||
- "**/tokenizers/*.json"
|
|
||||||
- "**/tokenizers/*"
|
|
||||||
- "miniconda.sh"
|
|
||||||
|
|
||||||
# Build outputs and static assets
|
|
||||||
- "litellm/proxy/_experimental/out/**"
|
|
||||||
- "litellm/ui/litellm-dashboard/public/**"
|
|
||||||
- "**/swagger/*.js"
|
|
||||||
- "**/*.woff"
|
|
||||||
- "**/*.woff2"
|
|
||||||
- "**/*.avif"
|
|
||||||
- "**/*.webp"
|
|
||||||
|
|
||||||
# Test data files
|
|
||||||
- "**/tests/**/data_map.txt"
|
|
||||||
- "tests/**/*.txt"
|
|
||||||
|
|
||||||
# Documentation and other non-code files
|
|
||||||
- "**/*.md"
|
|
||||||
- "**/*.lock"
|
|
||||||
- "uv.lock"
|
|
||||||
- "package-lock.json"
|
|
||||||
|
|
||||||
# Ignore security incidents with the SHA256 of the occurrence (false positives)
|
|
||||||
ignored_matches:
|
|
||||||
# === Current detected false positives (SHA-based) ===
|
|
||||||
|
|
||||||
# gcs_pub_sub_body - folder name, not a password
|
|
||||||
- name: GCS pub/sub test folder name
|
|
||||||
match: 75f377c456eede69e5f6e47399ccee6016a2a93cc5dd11db09cc5b1359ae569a
|
|
||||||
|
|
||||||
# os.environ/APORIA_API_KEY_1 - environment variable reference
|
|
||||||
- name: Environment variable reference APORIA_API_KEY_1
|
|
||||||
match: e2ddeb8b88eca97a402559a2be2117764e11c074d86159ef9ad2375dea188094
|
|
||||||
|
|
||||||
# os.environ/APORIA_API_KEY_2 - environment variable reference
|
|
||||||
- name: Environment variable reference APORIA_API_KEY_2
|
|
||||||
match: 09aa39a29e050b86603aa55138af1ff08fb86a4582aa965c1bd0672e1575e052
|
|
||||||
|
|
||||||
# oidc/circleci_v2/ - test authentication path, not a secret
|
|
||||||
- name: OIDC CircleCI test path
|
|
||||||
match: feb3475e1f89a65b7b7815ac4ec597e18a9ec1847742ad445c36ca617b536e15
|
|
||||||
|
|
||||||
# text-davinci-003 - OpenAI model identifier, not a secret
|
|
||||||
- name: OpenAI model identifier text-davinci-003
|
|
||||||
match: c489000cf6c7600cee0eefb80ad0965f82921cfb47ece880930eb7e7635cf1f1
|
|
||||||
|
|
||||||
# Base64 Basic Auth in test_pass_through_endpoints.py - test fixture, not a real secret
|
|
||||||
- name: Test Base64 Basic Auth header in pass_through_endpoints test
|
|
||||||
match: 61bac0491f395040617df7ef6d06029eac4d92a4457ac784978db80d97be1ae0
|
|
||||||
|
|
||||||
# PostgreSQL password "postgres" in CI configs - standard test database password
|
|
||||||
- name: Test PostgreSQL password in CI configurations
|
|
||||||
match: 6e0d657eb1f0fbc40cf0b8f3c3873ef627cc9cb7c4108d1c07d979c04bc8a4bb
|
|
||||||
|
|
||||||
# Bearer token in locustfile.py - test/example API key for load testing
|
|
||||||
- name: Test Bearer token in locustfile load test
|
|
||||||
match: 2a0abc2b0c3c1760a51ffcdf8d6b1d384cef69af740504b1cfa82dd70cdc7ff9
|
|
||||||
|
|
||||||
# Inkeep API key in docusaurus.config.js - public documentation site key
|
|
||||||
- name: Inkeep API key in documentation config
|
|
||||||
match: c366657791bfb5fc69045ec11d49452f09a0aebbc8648f94e2469b4025e29a75
|
|
||||||
|
|
||||||
# Langfuse credentials in test_completion.py - test credentials for integration test
|
|
||||||
- name: Langfuse test credentials in test_completion
|
|
||||||
match: c39310f68cc3d3e22f7b298bb6353c4f45759adcc37080d8b7f4e535d3cfd7f4
|
|
||||||
|
|
||||||
# === Preventive patterns for test keys (pattern-based) ===
|
|
||||||
|
|
||||||
# Test API keys (124 instances across 45 files)
|
|
||||||
- name: Test API keys with sk-test prefix
|
|
||||||
match: sk-test-
|
|
||||||
|
|
||||||
# Mock API keys
|
|
||||||
- name: Mock API keys with sk-mock prefix
|
|
||||||
match: sk-mock-
|
|
||||||
|
|
||||||
# Fake API keys
|
|
||||||
- name: Fake API keys with sk-fake prefix
|
|
||||||
match: sk-fake-
|
|
||||||
|
|
||||||
# Generic test API key patterns
|
|
||||||
- name: Test API key patterns
|
|
||||||
match: test-api-key
|
|
||||||
|
|
||||||
- name: Short fake sk keys (1–9 digits only)
|
|
||||||
match: \bsk-\d{1,9}\b
|
|
||||||
22
.github/grype.yaml
vendored
|
|
@ -1,22 +0,0 @@
|
||||||
# Wolfi's security database names zlib 1.3.3-r0 as the fix for CVE-2026-85091,
|
|
||||||
# but the newest zlib published to the Wolfi apk repo is 1.3.2-r7, so every
|
|
||||||
# wolfi-base digest reports it and no `apk upgrade` can clear it.
|
|
||||||
# Drop this once Wolfi ships zlib >= 1.3.3-r0; expected by 2026-10-15.
|
|
||||||
ignore:
|
|
||||||
- vulnerability: CVE-2026-85091
|
|
||||||
package:
|
|
||||||
name: zlib
|
|
||||||
type: apk
|
|
||||||
- vulnerability: GHSA-g5fp-32jq-cfw2
|
|
||||||
package:
|
|
||||||
name: zlib
|
|
||||||
type: apk
|
|
||||||
- vulnerability: GHSA-v4x8-3wr5-3wq8
|
|
||||||
reason: >-
|
|
||||||
filelock ReadWriteLock advisory, and no package in the image takes ReadWriteLock;
|
|
||||||
the fix (4.2.1) is blocked by litellm's own filelock<4.0 cap until #48635 or #48802 lifts it,
|
|
||||||
and the image then moves off 3.32.7, which retires this version-scoped rule
|
|
||||||
package:
|
|
||||||
name: filelock
|
|
||||||
version: 3.32.7
|
|
||||||
type: python
|
|
||||||
77
.github/linters/ruff-strict.toml
vendored
|
|
@ -1,77 +0,0 @@
|
||||||
extend = "../../pyproject.toml"
|
|
||||||
|
|
||||||
[lint]
|
|
||||||
preview = true
|
|
||||||
select = ["ANN", "ASYNC230", "B004", "B006", "B008", "B009", "B010", "B018", "B019", "B021", "B026", "B033", "BLE", "C401", "C404", "C405", "C408", "C414", "C419", "C901", "D202", "D403", "D417", "D419", "DTZ001", "DTZ003", "DTZ005", "DTZ006", "DTZ007", "DTZ011", "EXE001", "EXE002", "F401", "FURB136", "FURB168", "FURB188", "I001", "LOG015", "N999", "PERF102", "PERF401", "PERF402", "PERF403", "PIE790", "PIE800", "PIE804", "PIE810", "PLC0206", "PLC0208", "PLC0414", "PLR0124", "PLR0206", "PLR0402", "PLR1704", "PLR1711", "PLR1714", "PLR1730", "PLR2044", "PLW0127", "PLW0133", "PLW0602", "PLW0603", "PLW1508", "PLW1510", "PYI030", "PYI036", "PYI041", "PYI064", "RET501", "RET504", "RUF010", "RUF012", "RUF015", "RUF019", "RUF022", "RUF023", "RUF046", "RUF051", "RUF059", "RUF100", "S110", "S112", "SIM101", "SIM102", "SIM103", "SIM113", "SIM114", "SIM115", "SIM117", "SIM118", "SIM201", "SIM210", "SIM211", "SIM222", "SIM401", "TC004", "TC005", "TID251", "TRY002", "TRY004", "TRY201", "TRY203", "TRY300", "UP006", "UP007", "UP008", "UP012", "UP018", "UP024", "UP028", "UP031", "UP032", "UP034", "UP035", "UP036", "UP037", "UP045"]
|
|
||||||
extend-select = []
|
|
||||||
# Overrides the inherited list: rules this gate enforces itself must NOT be external here,
|
|
||||||
# so this config's RUF100 flags their stale `# noqa` directives. What remains external is
|
|
||||||
# only what other tooling enforces: every base pyproject.toml rule this select list doesn't
|
|
||||||
# re-enable (all of the default E/F families plus T20/PGH004/RUF008/RUF009, minus the
|
|
||||||
# strict-selected F401 and RUF100; F4 is split out so stale F401 noqas stay detectable),
|
|
||||||
# plus upstream litellm's ruff config.
|
|
||||||
external = [
|
|
||||||
"T20", "PGH004", "RUF008", "RUF009", "E4", "E7", "E9",
|
|
||||||
"F402", "F404", "F406", "F407", "F5", "F6", "F7", "F8", "F9",
|
|
||||||
"PLC0415", "E402", "BLE001", "ARG002", "S102", "S324", "S606", "D401", "F403", "F405",
|
|
||||||
]
|
|
||||||
|
|
||||||
[lint.per-file-ignores]
|
|
||||||
# ANN401 (explicit `Any` disallowed) has no per-line/function-level ignore mechanism
|
|
||||||
# in ruff, only file-level. These two files each have a handful of parameters that
|
|
||||||
# are genuinely heterogeneous with no fitting concrete type: a response object that
|
|
||||||
# varies across every LLM call type (completion/embedding/transcription/etc. each
|
|
||||||
# return a different shape), and *args/**kwargs forwarded verbatim with no fixed
|
|
||||||
# shape. Tried the closest existing union (CostResponseTypes) first; basedpyright
|
|
||||||
# caught a real mismatch, confirming Any is correct here, not a shortcut.
|
|
||||||
"litellm/litellm_core_utils/litellm_logging.py" = ["ANN401"]
|
|
||||||
"litellm/utils.py" = ["ANN401"]
|
|
||||||
# `**kwargs` forwards verbatim to CustomGuardrail.__init__, whose param list is wide and
|
|
||||||
# grows over time; typing it concretely (`object`) broke that forwarding call outright —
|
|
||||||
# basedpyright turned every named param into a reportArgumentType error. Any is correct here.
|
|
||||||
"litellm/proxy/guardrails/guardrail_hooks/alice/alice.py" = ["ANN401"]
|
|
||||||
"litellm/proxy/guardrails/guardrail_hooks/airia/airia.py" = ["ANN401"]
|
|
||||||
# Same reason: `**kwargs` forwards verbatim to CustomGuardrail.__init__, and the lifecycle
|
|
||||||
# hook signatures inherit `Any` for `response` from CustomLogger, so narrowing them here
|
|
||||||
# would break the override rather than describe it.
|
|
||||||
"litellm/proxy/guardrails/guardrail_hooks/llm_shield_proxy/llm_shield_proxy.py" = ["ANN401"]
|
|
||||||
|
|
||||||
[lint.mccabe]
|
|
||||||
max-complexity = 15
|
|
||||||
|
|
||||||
[lint.pydocstyle]
|
|
||||||
convention = "google"
|
|
||||||
|
|
||||||
[lint.pylint]
|
|
||||||
max-args = 5
|
|
||||||
|
|
||||||
[lint.flake8-tidy-imports.banned-api]
|
|
||||||
"typing.Any".msg = "Use a concrete type. Frozen slots=True dataclass (preferred) / NamedTuple / ReadOnly TypedDict for payloads."
|
|
||||||
"typing_extensions.Any".msg = "Same as typing.Any."
|
|
||||||
"typing.List".msg = "tuple[X, ...] for state, Sequence[X] for params."
|
|
||||||
"typing.Dict".msg = "Frozen dataclass / NamedTuple / ReadOnly TypedDict; if truly dynamic, use MappingProxyType."
|
|
||||||
"typing.Set".msg = "frozenset[X] or AbstractSet[X]."
|
|
||||||
"typing.MutableSequence".msg = "Sequence[X]."
|
|
||||||
"typing.MutableMapping".msg = "See typing.Dict."
|
|
||||||
# Unchecked casts: cast() lies to the type checker with no runtime guarantee.
|
|
||||||
# Validate into a concrete frozen type at the boundary (pydantic) instead.
|
|
||||||
# Per-call-site coverage lives in check_type_discipline.py (LIT006); this freezes
|
|
||||||
# new cast imports. Suppress (with a reason) via `# noqa: TID251 # <reason>`.
|
|
||||||
"typing.cast".msg = "No unchecked casts: validate into a frozen dataclass/NamedTuple/ReadOnly TypedDict at the boundary (pydantic)."
|
|
||||||
"typing_extensions.cast".msg = "Same as typing.cast."
|
|
||||||
# Unverified narrowing predicates: the checker never validates the guard body, so a
|
|
||||||
# wrong guard silently corrupts types. Banned outright (there are none today).
|
|
||||||
"typing.TypeGuard".msg = "Unverified narrowing. Parse into a concrete type, or use isinstance for a runtime-checked narrowing."
|
|
||||||
"typing_extensions.TypeGuard".msg = "Same as typing.TypeGuard."
|
|
||||||
"typing.TypeIs".msg = "Unverified narrowing (the body is trusted). Parse into a concrete type instead."
|
|
||||||
"typing_extensions.TypeIs".msg = "Same as typing.TypeIs."
|
|
||||||
# Dispatched public entry points: import them from their dispatch module so every
|
|
||||||
# supported call path selects Rust or Python in one place. Only the dispatch
|
|
||||||
# modules and internal recursive calls may reach the Python implementation
|
|
||||||
# directly, each with a `# noqa: TID251 # <reason>`.
|
|
||||||
"litellm.responses.main.responses".msg = "Import litellm.responses.dispatch.responses so the call routes through dispatch."
|
|
||||||
"litellm.responses.main.aresponses".msg = "Import litellm.responses.dispatch.aresponses so the call routes through dispatch."
|
|
||||||
"litellm.llms.anthropic.messages.native.handler.anthropic_messages".msg = "Import litellm.messages.anthropic_messages so the call routes through dispatch."
|
|
||||||
"litellm.llms.anthropic.messages.native.handler.anthropic_messages_handler".msg = "Import litellm.messages.anthropic_messages_handler so the call routes through dispatch."
|
|
||||||
"litellm.main.completion".msg = "Import litellm.completion so the call routes through dispatch."
|
|
||||||
"litellm.main.acompletion".msg = "Import litellm.acompletion so the call routes through dispatch."
|
|
||||||
15
.github/merge-smoke-tests.json
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"cases": {
|
||||||
|
"CHAT-JSON": "tests/unit/llms/openai/test_openai.py::test_acompletion_returns_json_reply_over_injected_transport",
|
||||||
|
"CHAT-TEXT-STREAM": "tests/unit/llms/openai/test_openai.py::test_acompletion_streams_text_deltas_over_injected_transport",
|
||||||
|
"CHAT-TOOL-STREAM": "tests/unit/llms/openai/test_openai.py::test_acompletion_streams_tool_call_arguments_over_injected_transport",
|
||||||
|
"MODEL-ALLOW": "tests/unit/proxy/auth/test_auth_checks_object_access_and_lookup.py::test_can_object_call_model_allows_listed_model_for_key",
|
||||||
|
"MODEL-DENY": "tests/unit/proxy/auth/test_auth_checks_object_access_and_lookup.py::test_can_object_call_model_denials_return_forbidden[key-key_model_access_denied]",
|
||||||
|
"COST-EXPLICIT": "tests/unit/test_cost_calculator.py::test_completion_cost_charges_explicit_per_token_rates_over_registered_ones",
|
||||||
|
"COST-ZERO": "tests/unit/test_cost_calculator.py::test_completion_cost_is_zero_when_explicit_rates_are_zero",
|
||||||
|
"LOG-CONTENT-ON": "tests/unit/litellm_core_utils/test_litellm_logging.py::test_standard_logging_payload_keeps_message_content_when_message_logging_is_on",
|
||||||
|
"LOG-CONTENT-OFF": "tests/unit/litellm_core_utils/test_litellm_logging.py::test_standard_logging_payload_redacts_message_content_when_message_logging_is_off",
|
||||||
|
"CALLBACK-SUCCESS": "tests/unit/litellm_core_utils/test_litellm_logging.py::test_async_success_handler_delivers_standard_logging_payload_to_custom_logger",
|
||||||
|
"CALLBACK-FAILURE": "tests/unit/litellm_core_utils/test_litellm_logging.py::test_async_failure_handler_delivers_failure_payload_to_custom_logger"
|
||||||
|
}
|
||||||
|
}
|
||||||
37
.github/osv-scanner.toml
vendored
|
|
@ -1,37 +0,0 @@
|
||||||
[[IgnoredVulns]]
|
|
||||||
id = "GHSA-69hj-f68j-2879"
|
|
||||||
ignoreUntil = 2027-02-01
|
|
||||||
reason = "filelock UnixFileLock truncates and chmods a file another user renames or hard-links in at the lock path, in every release before 4.2.1, which needs Python >= 3.11. Python 3.11+ now locks 4.2.4 and only the Python 3.10 resolution stays on 3.20.3, held below 3.20.4 by a [tool.uv] constraint because every newer 3.10-compatible release carries more filelock advisories. litellm's CLI takes FileLock only inside ~/.litellm, which it creates owner-only, and huggingface_hub takes it in the Hugging Face cache, which litellm reaches only to download the Kimi K3 tokenizer for Bedrock token counting, so the attack needs another local user who can rename or link files in that cache. Remove once 3.10 is dropped"
|
|
||||||
|
|
||||||
[[IgnoredVulns]]
|
|
||||||
id = "GHSA-8mx5-7m5m-r77g"
|
|
||||||
ignoreUntil = 2027-02-01
|
|
||||||
reason = "filelock WindowsFileLock ignores mode in every release from 3.10.0 before 4.2.1, which needs Python >= 3.11. Python 3.11+ now locks 4.2.4 and only the Python 3.10 resolution stays on 3.20.3, held below 3.20.4 by a [tool.uv] constraint because every newer 3.10-compatible release carries more filelock advisories. The proxy image is Linux, so only Windows installs on Python 3.10 run it, and there litellm's CLI takes FileLock only inside the owner-only ~/.litellm, whose lock file holds no data, while huggingface_hub takes it in the Hugging Face cache, which litellm reaches only to download the Kimi K3 tokenizer. Remove once 3.10 is dropped"
|
|
||||||
|
|
||||||
[[IgnoredVulns]]
|
|
||||||
id = "GHSA-pcg2-g6cj-q92c"
|
|
||||||
ignoreUntil = 2027-02-01
|
|
||||||
reason = "tornado 6.6 (published 2026-10-21) is the only fix and requires Python >= 3.11, so the Python 3.10 resolution stays on 6.5.10 while 3.11+ locks 6.6; litellm starts no tornado server or client, since semantic-router and aurelio-sdk only declare it and tenacity and sentry-sdk import it only to wrap tornado code when it is present, so tornado's HTTP header parsing is never reached; remove this entry once 3.10 is dropped"
|
|
||||||
|
|
||||||
[[IgnoredVulns]]
|
|
||||||
id = "GHSA-pw9p-v7r6-gx4p"
|
|
||||||
ignoreUntil = 2027-02-01
|
|
||||||
reason = "tornado 6.6 (published 2026-10-21) is the only fix and requires Python >= 3.11, so the Python 3.10 resolution stays on 6.5.10 while 3.11+ locks 6.6; litellm starts no tornado server or client, since semantic-router and aurelio-sdk only declare it and tenacity and sentry-sdk import it only to wrap tornado code when it is present, so tornado's RedirectHandler is never reached; remove this entry once 3.10 is dropped"
|
|
||||||
|
|
||||||
[[IgnoredVulns]]
|
|
||||||
id = "GHSA-r4j5-9xpp-3985"
|
|
||||||
ignoreUntil = 2027-02-01
|
|
||||||
reason = "tornado 6.6 (published 2026-10-21) is the only fix and requires Python >= 3.11, so the Python 3.10 resolution stays on 6.5.10 while 3.11+ locks 6.6; litellm starts no tornado server or client, since semantic-router and aurelio-sdk only declare it and tenacity and sentry-sdk import it only to wrap tornado code when it is present, so tornado's chunked body reading is never reached; remove this entry once 3.10 is dropped"
|
|
||||||
|
|
||||||
[[IgnoredVulns]]
|
|
||||||
id = "GHSA-45c3-73f7-pv4m"
|
|
||||||
ignoreUntil = 2027-02-01
|
|
||||||
reason = "dnspython 2.9.0 (published 2026-10-09) is the only fix and requires Python >= 3.11, so the Python 3.10 resolution stays on 2.8.0 while 3.11+ locks 2.9.0; the advisory is CPU use in dns.name.from_wire_parser and litellm only reaches dnspython through pydantic EmailStr, which never does DNS lookups; remove this entry once 3.10 is dropped or a 3.10-compatible fix ships"
|
|
||||||
|
|
||||||
[[PackageOverrides]]
|
|
||||||
name = "pillow"
|
|
||||||
version = "12.3.0"
|
|
||||||
ecosystem = "PyPI"
|
|
||||||
vulnerability.ignore = true
|
|
||||||
effectiveUntil = 2027-02-01
|
|
||||||
reason = "pillow 13.0.0 requires Python >= 3.11, so the Python 3.10 resolution of the ci and dev groups and of the mlflow extra stays on 12.3.0; 3.11+ is on 13.0.0; pillow has never shipped an older-major release after the next major (no 10.x after 11.0.0, no 11.x after 12.0.0), so 13.x is the only fix line; extended to 2027-02-01 with maintainer approval, same date as every other Python 3.10 entry; remove once 3.10 is dropped"
|
|
||||||
49
.github/packaging/build_backend.py
vendored
|
|
@ -1,49 +0,0 @@
|
||||||
import sys
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
import maturin
|
|
||||||
from cli_package import PACKAGE_DIR, map_wheel
|
|
||||||
from maturin import (
|
|
||||||
build_sdist as build_sdist,
|
|
||||||
)
|
|
||||||
from maturin import (
|
|
||||||
get_requires_for_build_editable as get_requires_for_build_editable,
|
|
||||||
)
|
|
||||||
from maturin import (
|
|
||||||
get_requires_for_build_sdist as get_requires_for_build_sdist,
|
|
||||||
)
|
|
||||||
from maturin import (
|
|
||||||
get_requires_for_build_wheel as get_requires_for_build_wheel,
|
|
||||||
)
|
|
||||||
from maturin import (
|
|
||||||
prepare_metadata_for_build_editable as prepare_metadata_for_build_editable,
|
|
||||||
)
|
|
||||||
from maturin import (
|
|
||||||
prepare_metadata_for_build_wheel as prepare_metadata_for_build_wheel,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_wheel(
|
|
||||||
wheel_directory: str,
|
|
||||||
config_settings: Mapping[str, str | list[str]] | None = None,
|
|
||||||
metadata_directory: str | None = None,
|
|
||||||
) -> str:
|
|
||||||
filename: Final = maturin.build_wheel(wheel_directory, config_settings, metadata_directory)
|
|
||||||
map_wheel(Path(wheel_directory) / filename)
|
|
||||||
return filename
|
|
||||||
|
|
||||||
|
|
||||||
def build_editable(
|
|
||||||
wheel_directory: str,
|
|
||||||
config_settings: Mapping[str, str | list[str]] | None = None,
|
|
||||||
metadata_directory: str | None = None,
|
|
||||||
) -> str:
|
|
||||||
filename: Final = maturin.build_editable(wheel_directory, config_settings, metadata_directory)
|
|
||||||
map_wheel(Path(wheel_directory) / filename, source=Path(__file__).resolve().parents[2] / PACKAGE_DIR)
|
|
||||||
return filename
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
build_editable(sys.argv[1])
|
|
||||||
93
.github/packaging/cli_package.py
vendored
|
|
@ -1,93 +0,0 @@
|
||||||
import argparse
|
|
||||||
import base64
|
|
||||||
import copy
|
|
||||||
import csv
|
|
||||||
import hashlib
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from tempfile import NamedTemporaryFile
|
|
||||||
from typing import Final
|
|
||||||
from zipfile import ZipFile, ZipInfo
|
|
||||||
|
|
||||||
PACKAGE_DIR: Final = "litellm/cli"
|
|
||||||
PACKAGE_NAME: Final = "litellm_cli"
|
|
||||||
|
|
||||||
|
|
||||||
def editable_support(source: Path) -> tuple[tuple[str, bytes], ...]:
|
|
||||||
module: Final = "_litellm_cli_editable"
|
|
||||||
finder: Final = (
|
|
||||||
"import importlib.util\nimport sys\n\n"
|
|
||||||
"class _Finder:\n"
|
|
||||||
" @staticmethod\n"
|
|
||||||
" def find_spec(fullname, path=None, target=None):\n"
|
|
||||||
f" if fullname == {PACKAGE_NAME!r}:\n"
|
|
||||||
" return importlib.util.spec_from_file_location(\n"
|
|
||||||
f" fullname, {str(source / '__init__.py')!r},\n"
|
|
||||||
f" submodule_search_locations=[{str(source)!r}])\n"
|
|
||||||
" return None\n\n"
|
|
||||||
"sys.meta_path.insert(0, _Finder())\n"
|
|
||||||
)
|
|
||||||
return ((f"{module}.py", finder.encode()), (f"{module}.pth", f"import {module}\n".encode()))
|
|
||||||
|
|
||||||
|
|
||||||
def _name(name: str) -> str:
|
|
||||||
prefix: Final = f"{PACKAGE_DIR}/"
|
|
||||||
return f"{PACKAGE_NAME}/{name.removeprefix(prefix)}" if name.startswith(prefix) else name
|
|
||||||
|
|
||||||
|
|
||||||
def _info(original: ZipInfo) -> ZipInfo:
|
|
||||||
mapped: Final = copy.copy(original)
|
|
||||||
mapped.filename = _name(original.filename)
|
|
||||||
return mapped
|
|
||||||
|
|
||||||
|
|
||||||
def _record(entries: tuple[tuple[ZipInfo, bytes], ...], record: str) -> bytes:
|
|
||||||
buffer: Final = io.StringIO(newline="")
|
|
||||||
writer: Final = csv.writer(buffer, lineterminator="\n")
|
|
||||||
writer.writerows(
|
|
||||||
(
|
|
||||||
info.filename,
|
|
||||||
"sha256=" + base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=").decode(),
|
|
||||||
str(len(data)),
|
|
||||||
)
|
|
||||||
for info, data in entries
|
|
||||||
)
|
|
||||||
writer.writerow((record, "", ""))
|
|
||||||
return buffer.getvalue().encode()
|
|
||||||
|
|
||||||
|
|
||||||
def map_wheel(wheel: Path, *, source: Path | None = None) -> None:
|
|
||||||
with ZipFile(wheel) as archive:
|
|
||||||
records: Final = tuple(info for info in archive.infolist() if info.filename.endswith(".dist-info/RECORD"))
|
|
||||||
if len(records) != 1:
|
|
||||||
raise ValueError("A wheel must contain exactly one RECORD")
|
|
||||||
entries: Final = tuple(
|
|
||||||
(_info(info), archive.read(info))
|
|
||||||
for info in archive.infolist()
|
|
||||||
if info != records[0] and (source is None or not info.filename.startswith(f"{PACKAGE_DIR}/"))
|
|
||||||
)
|
|
||||||
additions: Final = () if source is None else tuple((ZipInfo(name), data) for name, data in editable_support(source))
|
|
||||||
contents: Final = (*entries, *additions)
|
|
||||||
names: Final = tuple(info.filename for info, _ in contents)
|
|
||||||
if len(names) != len(frozenset(names)):
|
|
||||||
raise ValueError("The CLI package mapping would overwrite a wheel member")
|
|
||||||
if source is None and not any(name.startswith(f"{PACKAGE_NAME}/") for name in names):
|
|
||||||
raise ValueError("The wheel does not contain the CLI package")
|
|
||||||
with NamedTemporaryFile(dir=wheel.parent, suffix=".whl", delete=False) as temporary:
|
|
||||||
destination: Final = Path(temporary.name)
|
|
||||||
try:
|
|
||||||
with ZipFile(destination, "w") as archive:
|
|
||||||
for info, data in contents:
|
|
||||||
archive.writestr(info, data)
|
|
||||||
archive.writestr(records[0], _record(contents, records[0].filename))
|
|
||||||
os.replace(destination, wheel)
|
|
||||||
finally:
|
|
||||||
destination.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser: Final = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("wheels", nargs="+", type=Path)
|
|
||||||
for wheel in parser.parse_args().wheels:
|
|
||||||
map_wheel(wheel)
|
|
||||||
170
.github/scripts/assert_ci_coverage.py
vendored
|
|
@ -9,15 +9,13 @@ import sys
|
||||||
import warnings
|
import warnings
|
||||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import lru_cache
|
|
||||||
from itertools import chain
|
|
||||||
from types import MappingProxyType
|
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
|
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||||
WORKFLOW_DIR = REPO_ROOT / ".github" / "workflows"
|
WORKFLOW_DIR = REPO_ROOT / ".github" / "workflows"
|
||||||
|
CIRCLECI_CONFIG = REPO_ROOT / ".circleci" / "config.yml"
|
||||||
ALLOWLIST_FILE = REPO_ROOT / ".github" / "ci-coverage-allowlist.yml"
|
ALLOWLIST_FILE = REPO_ROOT / ".github" / "ci-coverage-allowlist.yml"
|
||||||
TESTS_ROOT = REPO_ROOT / "tests"
|
TESTS_ROOT = REPO_ROOT / "tests"
|
||||||
|
|
||||||
|
|
@ -25,32 +23,20 @@ ALLOWLIST_KEYS = frozenset({"description", "test_paths", "dockerfiles"})
|
||||||
PATH_FILTER_KEYS = frozenset({"paths", "paths-ignore"})
|
PATH_FILTER_KEYS = frozenset({"paths", "paths-ignore"})
|
||||||
TEST_PATH_KEYS = frozenset({"test-path", "test-paths", "test_path"})
|
TEST_PATH_KEYS = frozenset({"test-path", "test-paths", "test_path"})
|
||||||
DOCKERFILE_INPUT_KEYS = frozenset({"file", "dockerfile"})
|
DOCKERFILE_INPUT_KEYS = frozenset({"file", "dockerfile"})
|
||||||
TEST_RUNNER_RE = re.compile(r"\bpytest\b|\bhelm unittest\b|\bplaywright test\b|\bpython[0-9.]*\s")
|
TEST_RUNNER_RE = re.compile(r"\bpytest\b|\bcircleci tests\b|\bhelm unittest\b|\bplaywright test\b|\bpython[0-9.]*\s")
|
||||||
IMAGE_BUILD_RE = re.compile(r"\bdocker\s+(?:buildx\s+)?build\b")
|
IMAGE_BUILD_RE = re.compile(r"\bdocker\s+(?:buildx\s+)?build\b")
|
||||||
TEST_TOKEN_RE = re.compile(r"tests/[A-Za-z0-9_./*?\[\]-]+")
|
TEST_TOKEN_RE = re.compile(r"tests/[A-Za-z0-9_./*?-]+")
|
||||||
IGNORE_ARG_RE: Final = re.compile(r"--(?:ignore(?:-glob)?|deselect)[= ](\S+)")
|
IGNORE_ARG_RE: Final = re.compile(r"--ignore(?:-glob)?[= ](\S+)")
|
||||||
DOCKERFILE_TOKEN_RE = re.compile(r"[A-Za-z0-9_./-]*Dockerfile[A-Za-z0-9_.-]*")
|
DOCKERFILE_TOKEN_RE = re.compile(r"[A-Za-z0-9_./-]*Dockerfile[A-Za-z0-9_.-]*")
|
||||||
COMMENT_RE = re.compile(r"^\s*#.*$", re.MULTILINE)
|
COMMENT_RE = re.compile(r"^\s*#.*$", re.MULTILINE)
|
||||||
GLOB_CHARS = frozenset("*?[")
|
GLOB_CHARS = frozenset("*?")
|
||||||
DEDICATED_INTEGRATION_WORKFLOWS: Final = MappingProxyType(
|
|
||||||
{
|
|
||||||
"image-scan.yml": "tests/integration/database/image/",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Trees whose jobs are sharded with no catch-all bucket, so every child that holds
|
# Trees whose jobs are sharded with no catch-all bucket, so every child that holds
|
||||||
# tests has to be named by some shard or it runs nowhere. A child listed here is
|
# tests has to be named by some shard or it runs nowhere. A child listed here is
|
||||||
# itself decomposed one level deeper and is checked through its own entry.
|
# itself decomposed one level deeper and is checked through its own entry.
|
||||||
SHARDED_ROOTS: tuple[str, ...] = (
|
SHARDED_ROOTS: tuple[str, ...] = (
|
||||||
"tests/unit",
|
"tests/test_litellm",
|
||||||
"tests/unit/enterprise",
|
|
||||||
"tests/unit/enterprise/enterprise_callbacks",
|
|
||||||
"tests/unit/enterprise/proxy",
|
|
||||||
"tests/unit/llms",
|
|
||||||
"tests/unit/proxy",
|
"tests/unit/proxy",
|
||||||
"tests/unit/proxy/_experimental",
|
|
||||||
"tests/unit/responses",
|
|
||||||
"tests/unit/skills",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -119,7 +105,9 @@ def _scalars(node: object, key: str) -> tuple[Scalar, ...]:
|
||||||
|
|
||||||
|
|
||||||
def _config_files() -> tuple[pathlib.Path, ...]:
|
def _config_files() -> tuple[pathlib.Path, ...]:
|
||||||
return tuple(sorted(path for path in WORKFLOW_DIR.iterdir() if path.suffix in (".yml", ".yaml")))
|
workflows = tuple(sorted(path for path in WORKFLOW_DIR.iterdir() if path.suffix in (".yml", ".yaml")))
|
||||||
|
circleci = (CIRCLECI_CONFIG,) if CIRCLECI_CONFIG.is_file() else ()
|
||||||
|
return workflows + circleci
|
||||||
|
|
||||||
|
|
||||||
def _all_scalars() -> tuple[Scalar, ...]:
|
def _all_scalars() -> tuple[Scalar, ...]:
|
||||||
|
|
@ -245,6 +233,7 @@ def _describe(paths: tuple[str, ...]) -> str:
|
||||||
return f"{len(paths)} test file(s) invoked by no job: {names}{suffix}"
|
return f"{len(paths)} test file(s) invoked by no job: {names}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
GLOB_CALL_RE = re.compile(r'circleci tests glob "([^"]+)"')
|
||||||
KEYWORD_RE = re.compile(r"-k\s+\\?[\"']([^\"'\\]+)")
|
KEYWORD_RE = re.compile(r"-k\s+\\?[\"']([^\"'\\]+)")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -317,15 +306,16 @@ def _keyword_terms(
|
||||||
|
|
||||||
|
|
||||||
def _slices() -> tuple[Slice, ...]:
|
def _slices() -> tuple[Slice, ...]:
|
||||||
test_files: Final = frozenset(_test_files())
|
if not CIRCLECI_CONFIG.exists():
|
||||||
|
return ()
|
||||||
|
jobs: Final = yaml.safe_load(CIRCLECI_CONFIG.read_text()).get("jobs", {})
|
||||||
return tuple(
|
return tuple(
|
||||||
Slice(job=f"{path.name}:{job}", globs=globs, named=named, required=required, excluded=excluded, understood=understood)
|
Slice(job=job, globs=globs, named=named, required=required, excluded=excluded, understood=understood)
|
||||||
for path in _config_files()
|
for job, body in jobs.items()
|
||||||
for job, body in yaml.safe_load(path.read_text()).get("jobs", {}).items()
|
|
||||||
for text in ("\n".join(_strings(body)),)
|
for text in ("\n".join(_strings(body)),)
|
||||||
if "pytest" in text
|
if "pytest" in text
|
||||||
for globs in (tuple(token for token in TEST_TOKEN_RE.findall(text) if GLOB_CHARS & set(token)),)
|
for globs in (tuple(GLOB_CALL_RE.findall(text)),)
|
||||||
for named in (frozenset(TEST_TOKEN_RE.findall(text)) & test_files,)
|
for named in (frozenset(TEST_TOKEN_RE.findall(text)) & frozenset(_test_files()),)
|
||||||
for required, excluded, understood in (
|
for required, excluded, understood in (
|
||||||
_keyword_terms(tuple(KEYWORD_RE.findall(text)), attributable=len(globs) < 2),
|
_keyword_terms(tuple(KEYWORD_RE.findall(text)), attributable=len(globs) < 2),
|
||||||
)
|
)
|
||||||
|
|
@ -351,16 +341,20 @@ def _matchable_names(relative_path: str) -> frozenset[str]:
|
||||||
|
|
||||||
|
|
||||||
def _workflow_named_tokens() -> frozenset[str]:
|
def _workflow_named_tokens() -> frozenset[str]:
|
||||||
|
"""Test tokens a GitHub Actions job names directly.
|
||||||
|
|
||||||
|
A CircleCI `-k` that deselects a file no longer means the file runs nowhere once a
|
||||||
|
workflow names it, so the slice check has to credit those the same way the census does.
|
||||||
|
"""
|
||||||
return _invoked_test_tokens(
|
return _invoked_test_tokens(
|
||||||
scalar
|
scalar
|
||||||
for path in _config_files()
|
for path in _config_files()
|
||||||
|
if path != CIRCLECI_CONFIG
|
||||||
for scalar in _scalars(yaml.safe_load(path.read_text(encoding="utf-8")), path.name)
|
for scalar in _scalars(yaml.safe_load(path.read_text(encoding="utf-8")), path.name)
|
||||||
if not KEYWORD_RE.search(scalar.value)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _deselected_everywhere(allowlist: Allowlist) -> tuple[Finding, ...]:
|
def _deselected_everywhere(allowlist: Allowlist) -> tuple[Finding, ...]:
|
||||||
matchable_names: Final = lru_cache(maxsize=None)(_matchable_names)
|
|
||||||
slices: Final = _slices()
|
slices: Final = _slices()
|
||||||
named_by_workflow: Final = _workflow_named_tokens()
|
named_by_workflow: Final = _workflow_named_tokens()
|
||||||
globbed: Final = tuple(
|
globbed: Final = tuple(
|
||||||
|
|
@ -374,7 +368,7 @@ def _deselected_everywhere(allowlist: Allowlist) -> tuple[Finding, ...]:
|
||||||
for path in globbed
|
for path in globbed
|
||||||
if not allowlist.covers_test(path)
|
if not allowlist.covers_test(path)
|
||||||
and not any(_token_covers(token, path) for token in named_by_workflow)
|
and not any(_token_covers(token, path) for token in named_by_workflow)
|
||||||
and not any(slice_.claims(path, matchable_names(path)) for slice_ in slices)
|
and not any(slice_.claims(path, _matchable_names(path)) for slice_ in slices)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -555,33 +549,7 @@ def _integration_github_files(runner: pathlib.Path) -> frozenset[str]:
|
||||||
return frozenset(ast.literal_eval(values))
|
return frozenset(ast.literal_eval(values))
|
||||||
|
|
||||||
|
|
||||||
def _owns(workflow: str, relative_path: str) -> bool:
|
def _integration_ownership(repo_root: pathlib.Path = REPO_ROOT) -> tuple[frozenset[str], tuple[Finding, ...]]:
|
||||||
owned_root: Final = DEDICATED_INTEGRATION_WORKFLOWS.get(workflow)
|
|
||||||
return owned_root is not None and relative_path.startswith(owned_root)
|
|
||||||
|
|
||||||
|
|
||||||
def _expression_suites(value: str) -> tuple[str, ...]:
|
|
||||||
suites: Final = chain.from_iterable(map(json.loads, re.findall(r"'(\[[^']*\])'", value)))
|
|
||||||
return tuple(suite for suite in suites if isinstance(suite, str) and "{" not in suite)
|
|
||||||
|
|
||||||
|
|
||||||
def _matrix_suites(value: object) -> tuple[str, ...]:
|
|
||||||
if isinstance(value, list):
|
|
||||||
return tuple(suite for suite in value if isinstance(suite, str))
|
|
||||||
if isinstance(value, str):
|
|
||||||
return _expression_suites(value)
|
|
||||||
return ()
|
|
||||||
|
|
||||||
|
|
||||||
def _tokens_outside_owner(relative_path: str, workflow_tokens: Mapping[str, frozenset[str]]) -> frozenset[str]:
|
|
||||||
return frozenset[str]().union(
|
|
||||||
*(tokens for workflow, tokens in workflow_tokens.items() if not _owns(workflow, relative_path))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _integration_ownership(
|
|
||||||
repo_root: pathlib.Path = REPO_ROOT, *, allowlist: Allowlist | None = None
|
|
||||||
) -> tuple[frozenset[str], tuple[Finding, ...]]:
|
|
||||||
runner: Final = repo_root / "tests/integration/run.py"
|
runner: Final = repo_root / "tests/integration/run.py"
|
||||||
if not runner.exists():
|
if not runner.exists():
|
||||||
return frozenset(), ()
|
return frozenset(), ()
|
||||||
|
|
@ -598,19 +566,26 @@ def _integration_ownership(
|
||||||
browser_manifest: Final = repo_root / "tests/e2e/ui/tests/integrationCritical/expected.json"
|
browser_manifest: Final = repo_root / "tests/e2e/ui/tests/integrationCritical/expected.json"
|
||||||
browser_nodes: Final = json.loads(browser_manifest.read_text()) if browser_manifest.exists() else ()
|
browser_nodes: Final = json.loads(browser_manifest.read_text()) if browser_manifest.exists() else ()
|
||||||
browser_paths: Final = frozenset(node.split("::", 1)[0] for node in browser_nodes)
|
browser_paths: Final = frozenset(node.split("::", 1)[0] for node in browser_nodes)
|
||||||
integration_path: Final = repo_root / ".github/workflows/test-integration.yml"
|
circle_path: Final = repo_root / ".circleci/config.yml"
|
||||||
integration: Final = yaml.safe_load(integration_path.read_text()) if integration_path.exists() else {}
|
circle: Final = yaml.safe_load(circle_path.read_text()) if circle_path.exists() else {}
|
||||||
integration_jobs: Final = integration.get("jobs", {})
|
circle_test_path_tokens: Final = _invoked_test_tokens(
|
||||||
|
scalar for scalar in _scalars(circle, "config.yml") if scalar.key == "test_path"
|
||||||
|
)
|
||||||
|
steps: Final = circle.get("jobs", {}).get("integration_contracts", {}).get("steps", ())
|
||||||
invoked: Final = any(
|
invoked: Final = any(
|
||||||
".github/scripts/ci/run_integration.sh" in scalar.value
|
".circleci/scripts/run_integration.sh" in scalar.value
|
||||||
for scalar in _scalars(integration_jobs, "integration")
|
for scalar in _scalars(steps, "integration_contracts")
|
||||||
if scalar.key == "run"
|
if scalar.key == "command"
|
||||||
)
|
)
|
||||||
scheduled: Final = frozenset(
|
scheduled: Final = frozenset(
|
||||||
suite
|
suite
|
||||||
for body in integration_jobs.values()
|
for job in circle.get("workflows", {}).get("integration", {}).get("jobs", ())
|
||||||
if any(".github/scripts/ci/run_integration.sh" in scalar.value for scalar in _scalars(body, "integration"))
|
if isinstance(job, dict) and "integration_contracts" in job
|
||||||
for suite in _matrix_suites(body.get("strategy", {}).get("matrix", {}).get("suite", ()))
|
for suite in job["integration_contracts"]
|
||||||
|
.get("matrix", {})
|
||||||
|
.get("parameters", {})
|
||||||
|
.get("suite", (job["integration_contracts"].get("suite"),))
|
||||||
|
if isinstance(suite, str)
|
||||||
)
|
)
|
||||||
required: Final = (frozenset({"browser"}) if browser_paths else frozenset()) | frozenset(
|
required: Final = (frozenset({"browser"}) if browser_paths else frozenset()) | frozenset(
|
||||||
group
|
group
|
||||||
|
|
@ -625,50 +600,31 @@ def _integration_ownership(
|
||||||
)
|
)
|
||||||
!= 1
|
!= 1
|
||||||
)
|
)
|
||||||
workflow_tokens: Final = MappingProxyType(
|
gha_tokens: Final = _invoked_test_tokens(
|
||||||
{
|
scalar
|
||||||
path.name: _invoked_test_tokens(_scalars(yaml.safe_load(path.read_text()), path.name))
|
|
||||||
for path in (repo_root / ".github/workflows").glob("*.y*ml")
|
for path in (repo_root / ".github/workflows").glob("*.y*ml")
|
||||||
}
|
for scalar in _scalars(yaml.safe_load(path.read_text()), path.name)
|
||||||
)
|
|
||||||
gha_tokens: Final = frozenset[str]().union(*workflow_tokens.values())
|
|
||||||
invoked_github_files: Final = frozenset(
|
|
||||||
path for path in github_files if any(_token_covers(token, path) for token in gha_tokens)
|
|
||||||
)
|
|
||||||
grouped: Final = (
|
|
||||||
frozenset(str(path.relative_to(repo_root)) for path in integration_root.rglob("test_*.py")) - github_files
|
|
||||||
)
|
)
|
||||||
findings: Final = (
|
findings: Final = (
|
||||||
tuple(
|
tuple(
|
||||||
Finding(path, "integration file outside GITHUB_FILES is selected by GitHub Actions")
|
Finding(path, "integration contract is also selected by GitHub Actions")
|
||||||
for path in sorted(grouped)
|
for path in paths
|
||||||
if any(
|
if any(_token_covers(token, path) for token in gha_tokens)
|
||||||
_token_covers(token, path)
|
|
||||||
for workflow, tokens in workflow_tokens.items()
|
|
||||||
if workflow != "test-integration.yml"
|
|
||||||
for token in tokens
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
+ tuple(
|
+ tuple(
|
||||||
Finding(path, "GitHub-owned integration contract has no invoking job")
|
Finding(path, "GitHub-owned integration contract has no invoking job")
|
||||||
for path in sorted(github_files)
|
for path in sorted(github_files)
|
||||||
if path not in invoked_github_files and not (allowlist is not None and allowlist.covers_test(path))
|
if not any(_token_covers(token, path) for token in gha_tokens | circle_test_path_tokens)
|
||||||
)
|
)
|
||||||
+ tuple(
|
+ tuple(
|
||||||
Finding(path, "GitHub-owned integration file is missing")
|
Finding(path, "GitHub-owned integration file is missing")
|
||||||
for path in sorted(github_files)
|
for path in sorted(github_files)
|
||||||
if not (repo_root / path).is_file()
|
if not (repo_root / path).is_file()
|
||||||
)
|
)
|
||||||
+ tuple(
|
|
||||||
Finding(path, "GitHub-owned integration contract runs outside a dedicated integration workflow")
|
|
||||||
for path in sorted(github_files)
|
|
||||||
if any(_token_covers(token, path) for token in _tokens_outside_owner(path, workflow_tokens))
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
browser_commands: Final = tuple(
|
browser_commands: Final = tuple(
|
||||||
scalar.value
|
scalar.value
|
||||||
for path in (repo_root / ".github/workflows").glob("*.y*ml")
|
for path in (repo_root / ".github/workflows").glob("*.y*ml")
|
||||||
if path.name != "test-integration.yml"
|
|
||||||
for scalar in _scalars(yaml.safe_load(path.read_text()), path.name)
|
for scalar in _scalars(yaml.safe_load(path.read_text()), path.name)
|
||||||
if scalar.key in {"run", "command"}
|
if scalar.key in {"run", "command"}
|
||||||
)
|
)
|
||||||
|
|
@ -701,14 +657,14 @@ def _integration_ownership(
|
||||||
else ()
|
else ()
|
||||||
)
|
)
|
||||||
group_findings: Final = tuple(
|
group_findings: Final = tuple(
|
||||||
Finding(group, "canonical integration group is not scheduled by GitHub Actions")
|
Finding(group, "canonical integration group is not scheduled by CircleCI")
|
||||||
for group in sorted(required - scheduled)
|
for group in sorted(required - scheduled)
|
||||||
) + tuple(Finding(path, "canonical node must have exactly one integration group") for path in sorted(ungrouped))
|
) + tuple(Finding(path, "canonical node must have exactly one integration group") for path in sorted(ungrouped))
|
||||||
if not paths or not invoked or not scheduled:
|
if not paths or not invoked or not scheduled:
|
||||||
return frozenset(), findings + (
|
return frozenset(), findings + (
|
||||||
Finding(str(runner.relative_to(repo_root)), "dedicated GitHub Actions runner is missing"),
|
Finding(str(runner.relative_to(repo_root)), "dedicated CircleCI runner is missing"),
|
||||||
)
|
)
|
||||||
return paths | browser_paths | invoked_github_files, findings + group_findings + browser_findings + exclusion_findings
|
return paths | browser_paths | github_files, findings + group_findings + browser_findings + exclusion_findings
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
|
@ -720,10 +676,14 @@ def main() -> int:
|
||||||
allowlist = _load_allowlist()
|
allowlist = _load_allowlist()
|
||||||
scalars = _all_scalars()
|
scalars = _all_scalars()
|
||||||
|
|
||||||
integration_paths, ownership_findings = _integration_ownership(allowlist=allowlist)
|
integration_paths, ownership_findings = _integration_ownership()
|
||||||
test_findings = _uncovered_tests(
|
test_findings = (
|
||||||
|
_uncovered_tests(
|
||||||
allowlist,
|
allowlist,
|
||||||
_invoked_selections(scalars) + (Selection(included=integration_paths, ignored=frozenset()),),
|
_invoked_selections(scalars)
|
||||||
|
+ (Selection(included=integration_paths, ignored=frozenset()),),
|
||||||
|
)
|
||||||
|
+ ownership_findings
|
||||||
)
|
)
|
||||||
dockerfile_findings = _uncovered_dockerfiles(allowlist, _built_dockerfile_tokens(scalars))
|
dockerfile_findings = _uncovered_dockerfiles(allowlist, _built_dockerfile_tokens(scalars))
|
||||||
stale_findings = _stale_allowlist_paths(allowlist, test_files=_test_files(), dockerfiles=_dockerfiles())
|
stale_findings = _stale_allowlist_paths(allowlist, test_files=_test_files(), dockerfiles=_dockerfiles())
|
||||||
|
|
@ -740,25 +700,13 @@ def main() -> int:
|
||||||
test_findings,
|
test_findings,
|
||||||
"Add each to a job's test path, or list it in .github/ci-coverage-allowlist.yml with a reason.",
|
"Add each to a job's test path, or list it in .github/ci-coverage-allowlist.yml with a reason.",
|
||||||
)
|
)
|
||||||
if ownership_findings:
|
|
||||||
browser_remedy = (
|
|
||||||
" The browser group in test-integration.yml owns the integrationCritical Playwright contracts. "
|
|
||||||
"Other workflows and the default Playwright config must exclude them."
|
|
||||||
if any("browser" in finding.detail or "Playwright" in finding.detail for finding in ownership_findings)
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
_report(
|
|
||||||
"integration contracts with the wrong CI owner",
|
|
||||||
ownership_findings,
|
|
||||||
"Follow tests/integration/AGENTS.md, which says which CI job owns each integration file." + browser_remedy,
|
|
||||||
)
|
|
||||||
if dockerfile_findings:
|
if dockerfile_findings:
|
||||||
_report(
|
_report(
|
||||||
"Dockerfiles that no CI job builds",
|
"Dockerfiles that no CI job builds",
|
||||||
dockerfile_findings,
|
dockerfile_findings,
|
||||||
"Build each in a workflow, or list it in .github/ci-coverage-allowlist.yml with a reason.",
|
"Build each in a workflow, or list it in .github/ci-coverage-allowlist.yml with a reason.",
|
||||||
)
|
)
|
||||||
if stale_findings or test_findings or ownership_findings or dockerfile_findings:
|
if stale_findings or test_findings or dockerfile_findings:
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
_write(
|
_write(
|
||||||
|
|
|
||||||
119
.github/scripts/assert_secret_absent.py
vendored
|
|
@ -1,119 +0,0 @@
|
||||||
import base64
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import urllib.parse
|
|
||||||
import zipfile
|
|
||||||
from collections.abc import Callable, Iterator, Mapping
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from functools import reduce
|
|
||||||
from itertools import chain
|
|
||||||
from pathlib import Path
|
|
||||||
from types import MappingProxyType
|
|
||||||
from typing import Final, TextIO
|
|
||||||
|
|
||||||
SECRET_VARIABLE: Final = "SCANNED_SECRET"
|
|
||||||
MIN_ENCODED_LENGTH: Final = 12
|
|
||||||
MAX_ARCHIVE_DEPTH: Final = 3
|
|
||||||
MASK: Final = "***"
|
|
||||||
|
|
||||||
Forms = tuple[tuple[str, str], ...]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Hit:
|
|
||||||
location: str
|
|
||||||
form: str
|
|
||||||
|
|
||||||
|
|
||||||
def _aligned_encodings(raw: bytes, name: str, encode: Callable[[bytes], bytes]) -> Iterator[tuple[str, str]]:
|
|
||||||
for offset in range(3):
|
|
||||||
chunk: Final = raw[offset:]
|
|
||||||
yield f"{name} offset {offset}", encode(chunk[: len(chunk) - len(chunk) % 3]).decode()
|
|
||||||
|
|
||||||
|
|
||||||
def secret_forms(secret: str) -> Forms:
|
|
||||||
encoded: Final = (
|
|
||||||
("url-encoded", urllib.parse.quote(secret, safe="")),
|
|
||||||
*_aligned_encodings(secret.encode(), "base64", base64.b64encode),
|
|
||||||
*_aligned_encodings(secret.encode(), "base64url", base64.urlsafe_b64encode),
|
|
||||||
)
|
|
||||||
by_value: Final = MappingProxyType(
|
|
||||||
{value: name for name, value in encoded if value != secret and len(value) >= MIN_ENCODED_LENGTH}
|
|
||||||
)
|
|
||||||
return (("raw", secret), *((name, value) for value, name in by_value.items()))
|
|
||||||
|
|
||||||
|
|
||||||
def mask(text: str, forms: Forms) -> str:
|
|
||||||
return reduce(lambda masked, form: masked.replace(form[1], MASK), forms, text)
|
|
||||||
|
|
||||||
|
|
||||||
def _name_hits(location: str, name: str, forms: Forms) -> Iterator[Hit]:
|
|
||||||
for form, value in forms:
|
|
||||||
if value in name:
|
|
||||||
yield Hit(mask(location, forms), f"{form} in name")
|
|
||||||
|
|
||||||
|
|
||||||
def _content_hits(location: str, data: bytes, forms: Forms, depth: int) -> Iterator[Hit]:
|
|
||||||
for form, value in forms:
|
|
||||||
if value.encode() in data:
|
|
||||||
yield Hit(mask(location, forms), form)
|
|
||||||
if depth >= MAX_ARCHIVE_DEPTH or not zipfile.is_zipfile(io.BytesIO(data)):
|
|
||||||
return
|
|
||||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
|
||||||
for member in archive.infolist():
|
|
||||||
if member.is_dir():
|
|
||||||
continue
|
|
||||||
yield from _content_hits(f"{location}!{member.filename}", archive.read(member), forms, depth + 1)
|
|
||||||
|
|
||||||
|
|
||||||
def _walk(root: Path) -> Iterator[tuple[Path, bool]]:
|
|
||||||
for directory, subdirectories, files in os.walk(root):
|
|
||||||
yield from ((Path(directory, name), True) for name in subdirectories if Path(directory, name).is_symlink())
|
|
||||||
yield from ((Path(directory, name), False) for name in files)
|
|
||||||
|
|
||||||
|
|
||||||
def _path_hits(root: Path, path: Path, linked_directory: bool, forms: Forms) -> Iterator[Hit]:
|
|
||||||
location: Final = str(path.relative_to(root))
|
|
||||||
if linked_directory:
|
|
||||||
yield Hit(mask(location, forms), "directory symlink, not scanned")
|
|
||||||
return
|
|
||||||
yield from _name_hits(location, location, forms)
|
|
||||||
yield from _content_hits(location, path.read_bytes(), forms, 0)
|
|
||||||
|
|
||||||
|
|
||||||
def scan(root: Path, secret: str) -> tuple[int, tuple[Hit, ...]]:
|
|
||||||
forms: Final = secret_forms(secret)
|
|
||||||
entries: Final = tuple(_walk(root))
|
|
||||||
hits: Final = tuple(chain.from_iterable(_path_hits(root, path, linked, forms) for path, linked in entries))
|
|
||||||
return len(entries), hits
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: tuple[str, ...], environ: Mapping[str, str], out: TextIO) -> int:
|
|
||||||
if len(argv) != 1:
|
|
||||||
print(f"usage: assert_secret_absent.py <directory>, with the secret in ${SECRET_VARIABLE}", file=out)
|
|
||||||
return 2
|
|
||||||
secret: Final = environ.get(SECRET_VARIABLE, "")
|
|
||||||
root: Final = Path(argv[0])
|
|
||||||
if not secret:
|
|
||||||
print(f"{SECRET_VARIABLE} is empty, nothing to scan for", file=out)
|
|
||||||
return 0
|
|
||||||
if not root.is_dir():
|
|
||||||
print(f"{root} is not a directory, nothing to scan", file=out)
|
|
||||||
return 0
|
|
||||||
try:
|
|
||||||
scanned, hits = scan(root, secret)
|
|
||||||
except Exception as error:
|
|
||||||
print(f"scan failed with {type(error).__name__}, treating the results as unsafe", file=out)
|
|
||||||
return 1
|
|
||||||
for hit in hits:
|
|
||||||
print(f"secret found: {hit.location} ({hit.form})", file=out)
|
|
||||||
if hits:
|
|
||||||
print(f"{len(hits)} copies of the secret under {root}, refusing to upload", file=out)
|
|
||||||
return 1
|
|
||||||
print(f"no copy of the secret in {scanned} files under {root}", file=out)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main(tuple(sys.argv[1:]), os.environ, sys.stdout))
|
|
||||||
319
.github/scripts/auto_update_price_and_context_window_file.py
vendored
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Asynchronously fetch data from a given URL
|
||||||
|
async def fetch_data(url):
|
||||||
|
try:
|
||||||
|
# Create an asynchronous session
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
# Send a GET request to the URL
|
||||||
|
async with session.get(url) as resp:
|
||||||
|
# Raise an error if the response status is not OK
|
||||||
|
resp.raise_for_status()
|
||||||
|
# Parse the response JSON
|
||||||
|
resp_json = await resp.json()
|
||||||
|
print("Fetch the data from URL.")
|
||||||
|
# Return the 'data' field from the JSON response
|
||||||
|
return resp_json['data']
|
||||||
|
except Exception as e:
|
||||||
|
# Print an error message if fetching data fails
|
||||||
|
print("Error fetching data from URL:", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
FRIENDLI_API_URL = "https://api.friendli.ai/serverless/v1/models"
|
||||||
|
FRIENDLI_PROVIDER = "friendliai"
|
||||||
|
|
||||||
|
INHERITABLE_BASE_KEYS = (
|
||||||
|
"supports_pdf_input",
|
||||||
|
"supports_assistant_prefill",
|
||||||
|
"supports_adaptive_thinking",
|
||||||
|
"supports_output_config",
|
||||||
|
)
|
||||||
|
|
||||||
|
REASONING_EFFORT_LEVEL_ORDER = ("none", "minimal", "low", "medium", "high", "xhigh", "max")
|
||||||
|
|
||||||
|
|
||||||
|
def _find_base_model_entry(base_model: str, local_data: dict) -> str | None:
|
||||||
|
if not base_model:
|
||||||
|
return None
|
||||||
|
bm_tail = base_model.split("/")[-1].lower()
|
||||||
|
if base_model in local_data:
|
||||||
|
return base_model
|
||||||
|
for key in local_data:
|
||||||
|
if key.startswith("sample_spec") or key == "fallback_generalizations":
|
||||||
|
continue
|
||||||
|
if key.split("/")[-1].lower() == bm_tail:
|
||||||
|
return key
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _reasoning_effort_levels(reasoning_options: list) -> list:
|
||||||
|
offered = {
|
||||||
|
val
|
||||||
|
for opt in reasoning_options or []
|
||||||
|
if opt.get("type") == "effort"
|
||||||
|
for val in opt.get("values", [])
|
||||||
|
}
|
||||||
|
return [level for level in REASONING_EFFORT_LEVEL_ORDER if level in offered]
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_token_price(value: object) -> bool:
|
||||||
|
try:
|
||||||
|
price = float(value) # pyright: ignore[reportArgumentType] # non-numeric values are rejected via the except
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
return math.isfinite(price) and price >= 0
|
||||||
|
|
||||||
|
|
||||||
|
def _has_valid_token_prices(pricing: dict | None) -> bool:
|
||||||
|
prices = pricing or {}
|
||||||
|
return _valid_token_price(prices.get("input")) and _valid_token_price(prices.get("output"))
|
||||||
|
|
||||||
|
|
||||||
|
def _pricing(pricing: dict) -> dict:
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
if not pricing:
|
||||||
|
return out
|
||||||
|
if "input" in pricing:
|
||||||
|
out["input_cost_per_token"] = float(pricing["input"])
|
||||||
|
if "output" in pricing:
|
||||||
|
out["output_cost_per_token"] = float(pricing["output"])
|
||||||
|
if "input_cache_read" in pricing and pricing["input_cache_read"] is not None:
|
||||||
|
out["cache_read_input_token_cost"] = float(pricing["input_cache_read"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _modality_flags(input_mods: list) -> dict:
|
||||||
|
mods = input_mods or []
|
||||||
|
has_image = "image" in mods
|
||||||
|
return {
|
||||||
|
"supports_vision": has_image,
|
||||||
|
"supports_image_input": has_image,
|
||||||
|
"supports_video_input": "video" in mods,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def transform_friendli_data(data: list, local_data: dict) -> dict:
|
||||||
|
transformed: dict[str, dict] = {}
|
||||||
|
if not data:
|
||||||
|
return transformed
|
||||||
|
for model in data:
|
||||||
|
# An unpriced row must never wholesale-replace an already priced local entry:
|
||||||
|
# missing prices cost-calculate as zero, silently zeroing tracked spend
|
||||||
|
if not _has_valid_token_prices(model.get("pricing")):
|
||||||
|
continue
|
||||||
|
model_id = model["id"]
|
||||||
|
base_model = model.get("base_model") or ""
|
||||||
|
entry: dict[str, Any] = {
|
||||||
|
"litellm_provider": FRIENDLI_PROVIDER,
|
||||||
|
}
|
||||||
|
|
||||||
|
base_key = _find_base_model_entry(base_model, local_data)
|
||||||
|
if base_key:
|
||||||
|
base_entry = local_data[base_key]
|
||||||
|
for k in INHERITABLE_BASE_KEYS:
|
||||||
|
if k in base_entry:
|
||||||
|
entry[k] = base_entry[k]
|
||||||
|
|
||||||
|
ctx = model.get("context_length")
|
||||||
|
if ctx is not None:
|
||||||
|
entry["max_input_tokens"] = int(ctx)
|
||||||
|
max_out = model.get("max_completion_tokens")
|
||||||
|
if max_out is not None:
|
||||||
|
entry["max_output_tokens"] = int(max_out)
|
||||||
|
entry["max_tokens"] = int(max_out)
|
||||||
|
|
||||||
|
pricing = _pricing(model.get("pricing", {}))
|
||||||
|
entry.update(pricing)
|
||||||
|
entry["supports_prompt_caching"] = "cache_read_input_token_cost" in pricing
|
||||||
|
|
||||||
|
reasoning = model.get("reasoning") is True
|
||||||
|
entry["supports_reasoning"] = reasoning
|
||||||
|
if reasoning:
|
||||||
|
entry["reasoning_effort_levels"] = _reasoning_effort_levels(
|
||||||
|
model.get("reasoning_options", [])
|
||||||
|
)
|
||||||
|
|
||||||
|
func = model.get("functionality", {})
|
||||||
|
entry["supports_function_calling"] = func.get("tool_call") is True
|
||||||
|
entry["supports_parallel_function_calling"] = func.get("parallel_tool_call") is True
|
||||||
|
is_struct = func.get("structured_output") is True
|
||||||
|
entry["supports_response_schema"] = is_struct
|
||||||
|
entry["supports_native_structured_output"] = is_struct
|
||||||
|
entry["supports_system_messages"] = func.get("system_messages") is True
|
||||||
|
entry["supports_tool_choice"] = func.get("tool_choice") is True
|
||||||
|
|
||||||
|
entry.update(_modality_flags(model.get("input_modalities", [])))
|
||||||
|
|
||||||
|
entry["mode"] = model.get("mode", "chat")
|
||||||
|
|
||||||
|
desc = model.get("description")
|
||||||
|
if desc:
|
||||||
|
entry["comment"] = desc
|
||||||
|
|
||||||
|
dep = model.get("deprecation_date")
|
||||||
|
if dep:
|
||||||
|
entry["deprecation_date"] = dep.split("T")[0]
|
||||||
|
|
||||||
|
entry["source"] = FRIENDLI_API_URL
|
||||||
|
|
||||||
|
transformed[f"{FRIENDLI_PROVIDER}/{model_id}"] = entry
|
||||||
|
return transformed
|
||||||
|
|
||||||
|
# Synchronize local data with remote data
|
||||||
|
def sync_local_data_with_remote(local_data, remote_data, replace_keys=frozenset()):
|
||||||
|
# Update existing keys in local_data with values from remote_data
|
||||||
|
# (replace_keys entries are swapped wholesale so a field the remote catalog
|
||||||
|
# dropped, e.g. cache pricing, cannot survive as a stale value)
|
||||||
|
for key in (set(local_data) & set(remote_data)):
|
||||||
|
if key in replace_keys:
|
||||||
|
local_data[key] = remote_data[key]
|
||||||
|
else:
|
||||||
|
local_data[key].update(remote_data[key])
|
||||||
|
|
||||||
|
# Add new keys from remote_data to local_data
|
||||||
|
for key in (set(remote_data) - set(local_data)):
|
||||||
|
local_data[key] = remote_data[key]
|
||||||
|
|
||||||
|
# Write data to the json file
|
||||||
|
def write_to_file(file_path, data):
|
||||||
|
try:
|
||||||
|
# Open the file in write mode
|
||||||
|
with open(file_path, "w") as file:
|
||||||
|
# Dump the data as JSON into the file
|
||||||
|
json.dump(data, file, indent=4)
|
||||||
|
print("Values updated successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
# Print an error message if writing to file fails
|
||||||
|
print("Error updating JSON file:", e)
|
||||||
|
|
||||||
|
# Update the existing models and add the missing models for OpenRouter
|
||||||
|
def transform_openrouter_data(data):
|
||||||
|
transformed = {}
|
||||||
|
if not data:
|
||||||
|
return transformed
|
||||||
|
for row in data:
|
||||||
|
# Add the fields 'max_tokens' and 'input_cost_per_token'
|
||||||
|
obj = {
|
||||||
|
"max_tokens": row["context_length"],
|
||||||
|
"input_cost_per_token": float(row["pricing"]["prompt"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add 'max_output_tokens' as a field if it is not None
|
||||||
|
if "top_provider" in row and "max_completion_tokens" in row["top_provider"] and row["top_provider"]["max_completion_tokens"] is not None:
|
||||||
|
obj['max_output_tokens'] = int(row["top_provider"]["max_completion_tokens"])
|
||||||
|
|
||||||
|
# Add the field 'output_cost_per_token'
|
||||||
|
obj.update({
|
||||||
|
"output_cost_per_token": float(row["pricing"]["completion"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add field 'input_cost_per_image' if it exists and is non-zero
|
||||||
|
if "pricing" in row and "image" in row["pricing"] and float(row["pricing"]["image"]) != 0.0:
|
||||||
|
obj['input_cost_per_image'] = float(row["pricing"]["image"])
|
||||||
|
|
||||||
|
# Add the fields 'litellm_provider' and 'mode'
|
||||||
|
obj.update({
|
||||||
|
"litellm_provider": "openrouter",
|
||||||
|
"mode": "chat"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add the 'supports_vision' field if the modality is 'multimodal'
|
||||||
|
if row.get('architecture', {}).get('modality') == 'multimodal':
|
||||||
|
obj['supports_vision'] = True
|
||||||
|
|
||||||
|
# Use a composite key to store the transformed object
|
||||||
|
transformed[f'openrouter/{row["id"]}'] = obj
|
||||||
|
|
||||||
|
return transformed
|
||||||
|
|
||||||
|
# Update the existing models and add the missing models for Vercel AI Gateway
|
||||||
|
def transform_vercel_ai_gateway_data(data):
|
||||||
|
transformed = {}
|
||||||
|
if not data:
|
||||||
|
return transformed
|
||||||
|
for row in data:
|
||||||
|
# Rows without token pricing or token limits (video/embedding models) previously KeyError'd the whole sync
|
||||||
|
if any(row.get(k) is None for k in ("context_window", "max_tokens")) or any(
|
||||||
|
row.get("pricing", {}).get(k) is None for k in ("input", "output")
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
obj = {
|
||||||
|
"max_tokens": row["context_window"],
|
||||||
|
"input_cost_per_token": float(row["pricing"]["input"]),
|
||||||
|
"output_cost_per_token": float(row["pricing"]["output"]),
|
||||||
|
'max_output_tokens': row['max_tokens'],
|
||||||
|
'max_input_tokens': row["context_window"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle cache pricing if available
|
||||||
|
if "pricing" in row:
|
||||||
|
if "input_cache_read" in row["pricing"] and row["pricing"]["input_cache_read"] is not None:
|
||||||
|
obj['cache_read_input_token_cost'] = float(f"{float(row['pricing']['input_cache_read']):e}")
|
||||||
|
|
||||||
|
if "input_cache_write" in row["pricing"] and row["pricing"]["input_cache_write"] is not None:
|
||||||
|
obj['cache_creation_input_token_cost'] = float(f"{float(row['pricing']['input_cache_write']):e}")
|
||||||
|
|
||||||
|
mode = "embedding" if "embedding" in row["id"].lower() else "chat"
|
||||||
|
|
||||||
|
obj.update({"litellm_provider": "vercel_ai_gateway", "mode": mode})
|
||||||
|
|
||||||
|
transformed[f'vercel_ai_gateway/{row["id"]}'] = obj
|
||||||
|
|
||||||
|
return transformed
|
||||||
|
|
||||||
|
|
||||||
|
# Load local data from a specified file
|
||||||
|
def load_local_data(file_path):
|
||||||
|
try:
|
||||||
|
# Open the file in read mode
|
||||||
|
with open(file_path, "r") as file:
|
||||||
|
# Load and return the JSON data
|
||||||
|
return json.load(file)
|
||||||
|
except FileNotFoundError:
|
||||||
|
# Print an error message if the file is not found
|
||||||
|
print("File not found:", file_path)
|
||||||
|
return None
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
# Print an error message if JSON decoding fails
|
||||||
|
print("Error decoding JSON:", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def main():
|
||||||
|
local_file_path = "model_prices_and_context_window.json" # Path to the local data file
|
||||||
|
openrouter_url = "https://openrouter.ai/api/v1/models" # URL to fetch OpenRouter data
|
||||||
|
vercel_ai_gateway_url = "https://ai-gateway.vercel.sh/v1/models" # URL to fetch Vercel AI Gateway data
|
||||||
|
|
||||||
|
# Load local data from file
|
||||||
|
local_data = load_local_data(local_file_path)
|
||||||
|
|
||||||
|
# Fetch OpenRouter data
|
||||||
|
openrouter_data = asyncio.run(fetch_data(openrouter_url))
|
||||||
|
# Transform the fetched OpenRouter data
|
||||||
|
openrouter_data = transform_openrouter_data(openrouter_data)
|
||||||
|
|
||||||
|
# Fetch Vercel AI Gateway data
|
||||||
|
vercel_data = asyncio.run(fetch_data(vercel_ai_gateway_url))
|
||||||
|
# Transform the fetched Vercel AI Gateway data
|
||||||
|
vercel_data = transform_vercel_ai_gateway_data(vercel_data)
|
||||||
|
|
||||||
|
friendli_data = asyncio.run(fetch_data(FRIENDLI_API_URL))
|
||||||
|
friendli_data = transform_friendli_data(friendli_data, local_data)
|
||||||
|
|
||||||
|
# Combine both datasets
|
||||||
|
all_remote_data = {**openrouter_data, **vercel_data, **friendli_data}
|
||||||
|
|
||||||
|
# If both local and openrouter data are available, synchronize and save
|
||||||
|
if local_data and all_remote_data:
|
||||||
|
sync_local_data_with_remote(local_data, all_remote_data, replace_keys=frozenset(friendli_data))
|
||||||
|
write_to_file(local_file_path, local_data)
|
||||||
|
else:
|
||||||
|
print("Failed to fetch model data from either local file or URL.")
|
||||||
|
|
||||||
|
# Entry point of the script
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
68
.github/scripts/ci/classify_changes.sh
vendored
|
|
@ -1,68 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
category="${1:?usage: classify_changes.sh <backend|client|ui|cost-map-only|mcp-dependencies|windows-release|redis-compat>}"
|
|
||||||
|
|
||||||
has_client=false
|
|
||||||
has_backend=false
|
|
||||||
has_ci=false
|
|
||||||
has_cost_map=false
|
|
||||||
has_mcp_dependencies=false
|
|
||||||
has_windows_release=false
|
|
||||||
has_redis_compat=false
|
|
||||||
outside_cost_map_set=false
|
|
||||||
while IFS= read -r file || [ -n "$file" ]; do
|
|
||||||
[ -n "$file" ] || continue
|
|
||||||
case "$file" in
|
|
||||||
litellm/_redis.py | litellm/_redis_credential_provider.py | litellm/caching/redis_cache.py | litellm/caching/evicted_client_closer.py | tests/unit/test_redis.py | tests/integration/sdk/test_redis_cluster_iam_auth.py | tests/unit/caching/test_redis_connection_pool.py | tests/unit/caching/test_redis_cluster_cache.py | tests/unit/caching/test_evicted_client_closer.py | .github/workflows/test-unit.yml | .github/actions/detect-changes/* | .github/scripts/detect_changes.sh | .github/scripts/ci/classify_changes.sh | tests/integration/run.py | pyproject.toml | uv.lock)
|
|
||||||
has_redis_compat=true ;;
|
|
||||||
esac
|
|
||||||
case "$file" in
|
|
||||||
*.md | *.mdx) : ;;
|
|
||||||
pyproject.toml | */pyproject.toml | uv.lock | uv.toml | .python-version | litellm/rust/* | litellm/__init__.py | litellm/proxy/proxy_server.py | litellm/*mcp* | tests/*mcp* | litellm/integrations/arize/* | tests/base_sdk_tests/* | litellm/scripts/check_mcp_sdk_install.py | .github/workflows/test-mcp-dependency-resolution.yml | .github/actions/detect-changes/* | .github/actions/setup-uv-with-retries/* | .github/actions/cache-cargo-build/* | .github/scripts/detect_changes.sh | .github/scripts/uv_sync_with_retries.sh | .github/scripts/ci/classify_changes.sh | tests/unit/test_ci_path_filter.py | tests/unit/test_detect_changes.py)
|
|
||||||
has_mcp_dependencies=true ;;
|
|
||||||
esac
|
|
||||||
case "$file" in
|
|
||||||
litellm/rust/* | litellm/rust_bridge/* | pyproject.toml | uv.lock | tests/windows_tests/* | .github/scripts/ci/* | .github/actions/*)
|
|
||||||
has_windows_release=true ;;
|
|
||||||
esac
|
|
||||||
case "$file" in
|
|
||||||
litellm/ui/* | tests/e2e/ui/*) has_client=true ;;
|
|
||||||
*.md | *.mdx) : ;;
|
|
||||||
.github/*) has_ci=true; has_backend=true ;;
|
|
||||||
*) has_backend=true ;;
|
|
||||||
esac
|
|
||||||
case "$file" in
|
|
||||||
model_prices_and_context_window.json | litellm/model_prices_and_context_window_backup.json)
|
|
||||||
has_cost_map=true ;;
|
|
||||||
tests/unit/proxy/*) : ;;
|
|
||||||
*) outside_cost_map_set=true ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
case "$category" in
|
|
||||||
mcp-dependencies)
|
|
||||||
[ "$has_mcp_dependencies" = true ] && echo run || echo skip
|
|
||||||
;;
|
|
||||||
cost-map-only)
|
|
||||||
{ [ "$has_cost_map" = true ] && [ "$outside_cost_map_set" = false ]; } && echo run || echo skip
|
|
||||||
;;
|
|
||||||
windows-release)
|
|
||||||
[ "$has_windows_release" = true ] && echo run || echo skip
|
|
||||||
;;
|
|
||||||
redis-compat)
|
|
||||||
[ "$has_redis_compat" = true ] && echo run || echo skip
|
|
||||||
;;
|
|
||||||
backend)
|
|
||||||
[ "$has_backend" = true ] && echo run || echo skip
|
|
||||||
;;
|
|
||||||
client)
|
|
||||||
{ [ "$has_client" = true ] || [ "$has_backend" = true ]; } && echo run || echo skip
|
|
||||||
;;
|
|
||||||
ui)
|
|
||||||
{ [ "$has_client" = true ] || [ "$has_ci" = true ]; } && echo run || echo skip
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo run
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
43
.github/scripts/ci/path_filter.sh
vendored
|
|
@ -1,43 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
category="${1:?usage: path_filter.sh <backend|client|windows-release>}"
|
|
||||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
run_full() {
|
|
||||||
echo "path-filter[$category]: running job ($1)"
|
|
||||||
echo "decision=run"
|
|
||||||
[ -z "${GITHUB_OUTPUT:-}" ] || echo "decision=run" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
[ "${GITHUB_EVENT_NAME:-}" = pull_request ] || run_full "not a pull request"
|
|
||||||
|
|
||||||
candidate_bases="${PATH_FILTER_BASE_BRANCH:-main}"
|
|
||||||
merge_base=""
|
|
||||||
for base in $candidate_bases; do
|
|
||||||
git fetch --quiet origin "$base" 2>/dev/null || continue
|
|
||||||
candidate="$(git merge-base HEAD FETCH_HEAD 2>/dev/null)" || continue
|
|
||||||
[ -n "$candidate" ] || continue
|
|
||||||
if [ -z "$merge_base" ] || git merge-base --is-ancestor "$merge_base" "$candidate" 2>/dev/null; then
|
|
||||||
merge_base="$candidate"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
[ -n "$merge_base" ] || run_full "could not resolve a merge base against $candidate_bases"
|
|
||||||
|
|
||||||
changed="$(git diff --name-only "$merge_base" HEAD 2>/dev/null)" || run_full "git diff failed"
|
|
||||||
[ -n "$changed" ] || run_full "no files changed vs $merge_base"
|
|
||||||
|
|
||||||
echo "path-filter[$category]: changed files vs ${merge_base}:"
|
|
||||||
printf '%s\n' "$changed" | sed 's/^/ /' || true
|
|
||||||
|
|
||||||
decision="$(printf '%s\n' "$changed" | bash "$here/classify_changes.sh" "$category")" || run_full "classify_changes.sh failed"
|
|
||||||
|
|
||||||
if [ "$decision" = run ]; then
|
|
||||||
run_full "$category-relevant changes detected"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "path-filter[$category]: only unrelated changes detected"
|
|
||||||
echo "decision=skip"
|
|
||||||
[ -z "${GITHUB_OUTPUT:-}" ] || echo "decision=skip" >> "$GITHUB_OUTPUT"
|
|
||||||
294
.github/scripts/ci/run_integration.sh
vendored
|
|
@ -1,294 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
suite="${1:?integration suite required}"
|
|
||||||
mode="${2:-standard}"
|
|
||||||
side="${3:-}"
|
|
||||||
requested_workers="${INTEGRATION_WORKERS:-}"
|
|
||||||
license_env=()
|
|
||||||
if [ -n "${LITELLM_LICENSE:-}" ]; then
|
|
||||||
license_env=("LITELLM_LICENSE=$LITELLM_LICENSE")
|
|
||||||
fi
|
|
||||||
if [ "$mode" = replica ]; then
|
|
||||||
results="test-results/integration-${suite}-replica"
|
|
||||||
elif [ "$mode" = parity ]; then
|
|
||||||
results="test-results/parity-${suite}/${side:?parity side required}"
|
|
||||||
else
|
|
||||||
results="test-results/integration-${suite}"
|
|
||||||
fi
|
|
||||||
mkdir -p "$results"
|
|
||||||
integration_identity="$(.venv/bin/python -c 'import uuid; print(uuid.uuid4().hex)')"
|
|
||||||
upstream_pid=""
|
|
||||||
proxy_pid=""
|
|
||||||
peer_pid=""
|
|
||||||
launched_pid=""
|
|
||||||
guard_created=false
|
|
||||||
guard_installed=false
|
|
||||||
guard6_created=false
|
|
||||||
guard6_installed=false
|
|
||||||
egress_cgroup=litellm-integration
|
|
||||||
cleanup() {
|
|
||||||
original_status=$?
|
|
||||||
trap - EXIT INT TERM
|
|
||||||
sudo .venv/bin/python .github/scripts/ci/stop_integration_processes.py \
|
|
||||||
"$integration_identity" "$(id -u)" "$proxy_pid" "$peer_pid" "$upstream_pid" \
|
|
||||||
> "$results/process-cleanup.txt" 2>&1 || original_status=1
|
|
||||||
for owned_pid in "$peer_pid" "$proxy_pid" "$upstream_pid"; do
|
|
||||||
if [ -n "$owned_pid" ]; then
|
|
||||||
kill -- "-$owned_pid" 2>/dev/null || true
|
|
||||||
for _ in {1..50}; do
|
|
||||||
kill -0 -- "-$owned_pid" 2>/dev/null || break
|
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
if kill -0 -- "-$owned_pid" 2>/dev/null; then
|
|
||||||
kill -KILL -- "-$owned_pid" 2>/dev/null || true
|
|
||||||
original_status=1
|
|
||||||
fi
|
|
||||||
wait "$owned_pid" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [ "$guard_installed" = true ]; then
|
|
||||||
sudo iptables -D OUTPUT -m cgroup --path "$egress_cgroup" -j integration_only || original_status=1
|
|
||||||
fi
|
|
||||||
if [ "$guard_created" = true ]; then
|
|
||||||
sudo iptables -F integration_only || original_status=1
|
|
||||||
sudo iptables -X integration_only || original_status=1
|
|
||||||
fi
|
|
||||||
if [ "$guard6_installed" = true ]; then
|
|
||||||
sudo ip6tables -D OUTPUT -m cgroup --path "$egress_cgroup" -j integration_only || original_status=1
|
|
||||||
fi
|
|
||||||
if [ "$guard6_created" = true ]; then
|
|
||||||
sudo ip6tables -F integration_only || original_status=1
|
|
||||||
sudo ip6tables -X integration_only || original_status=1
|
|
||||||
fi
|
|
||||||
printf '%s\n' "$original_status" > "$results/exit-status.txt"
|
|
||||||
exit "$original_status"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
trap 'exit 130' INT
|
|
||||||
trap 'exit 143' TERM
|
|
||||||
|
|
||||||
export PATH="$PWD/.venv/bin:$PATH"
|
|
||||||
export PYTHONPATH="$PWD:$PWD/tests:$PWD/tests/e2e"
|
|
||||||
export DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:5432/circle_test"
|
|
||||||
export REDIS_HOST=127.0.0.1 REDIS_PORT=6379
|
|
||||||
export LITELLM_MASTER_KEY="sk-$(openssl rand -hex 16)" LITELLM_SALT_KEY=sk-integration-salt
|
|
||||||
export LITELLM_MODE=PRODUCTION LITELLM_LOCAL_MODEL_COST_MAP=True
|
|
||||||
export STORE_MODEL_IN_DB=True AWS_EC2_METADATA_DISABLED=true DO_NOT_TRACK=1
|
|
||||||
export INTEGRATION_PROXY_URL=http://127.0.0.1:4000
|
|
||||||
export INTEGRATION_PEER_URL=""
|
|
||||||
export INTEGRATION_UPSTREAM_URL=http://127.0.0.1:8190
|
|
||||||
export INTEGRATION_MASTER_KEY="$LITELLM_MASTER_KEY"
|
|
||||||
export LITELLM_UI_PATH="$PWD/litellm/proxy/_experimental/out"
|
|
||||||
if [ "$suite" = browser ]; then
|
|
||||||
export LITELLM_UI_PATH="$PWD/litellm/ui/litellm-dashboard/out"
|
|
||||||
test -f "$LITELLM_UI_PATH/index.html"
|
|
||||||
fi
|
|
||||||
export INTEGRATION_SEED="$(.venv/bin/python -c 'import hashlib,os; print(int(hashlib.sha256(((os.environ.get("GITHUB_SHA") or "local") + (os.environ.get("GITHUB_RUN_ID") or "local")).encode()).hexdigest()[:8],16))')"
|
|
||||||
export INTEGRATION_ORDER_SEED="$INTEGRATION_SEED"
|
|
||||||
|
|
||||||
uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma > "$results/prisma-generate.log" 2>&1
|
|
||||||
|
|
||||||
export INTEGRATION_PROXY_DATABASE_URL=""
|
|
||||||
export INTEGRATION_PROXY_READ_REPLICA_URL=""
|
|
||||||
export INTEGRATION_ROUTING=""
|
|
||||||
if [ "$mode" = replica ] || [ "$mode" = parity ]; then
|
|
||||||
.venv/bin/python .github/scripts/ci/prepare_replica_roles.py > "$results/prepare-replica-roles.log" 2>&1
|
|
||||||
export INTEGRATION_PROXY_DATABASE_URL="postgresql://litellm_writer:litellm-writer@127.0.0.1:5432/circle_test"
|
|
||||||
export INTEGRATION_PROXY_READ_REPLICA_URL="postgresql://litellm_reader:litellm-reader@127.0.0.1:5432/circle_test"
|
|
||||||
fi
|
|
||||||
if [ "$mode" = parity ]; then
|
|
||||||
export INTEGRATION_ROUTING=capture
|
|
||||||
fi
|
|
||||||
|
|
||||||
sudo mkdir -p "/sys/fs/cgroup/$egress_cgroup"
|
|
||||||
echo "$$" | sudo tee "/sys/fs/cgroup/$egress_cgroup/cgroup.procs" > /dev/null
|
|
||||||
sudo iptables -N integration_only
|
|
||||||
guard_created=true
|
|
||||||
sudo iptables -A integration_only -o lo -j ACCEPT
|
|
||||||
sudo iptables -A integration_only -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
|
||||||
for service in "${INTEGRATION_POSTGRES_CONTAINER:-postgres-db}" "${INTEGRATION_REDIS_CONTAINER:-redis-cache}"; do
|
|
||||||
address="$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$service")"
|
|
||||||
sudo iptables -A integration_only -d "$address" -j ACCEPT
|
|
||||||
done
|
|
||||||
sudo iptables -A integration_only -j REJECT
|
|
||||||
sudo iptables -I OUTPUT 1 -m cgroup --path "$egress_cgroup" -j integration_only
|
|
||||||
guard_installed=true
|
|
||||||
sudo ip6tables -N integration_only
|
|
||||||
guard6_created=true
|
|
||||||
sudo ip6tables -A integration_only -o lo -j ACCEPT
|
|
||||||
sudo ip6tables -A integration_only -j REJECT
|
|
||||||
sudo ip6tables -I OUTPUT 1 -m cgroup --path "$egress_cgroup" -j integration_only
|
|
||||||
guard6_installed=true
|
|
||||||
|
|
||||||
if curl --noproxy '*' --connect-timeout 2 -s http://198.51.100.1 >/dev/null 2>&1; then
|
|
||||||
echo "Unexpected outbound network access" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sudo iptables -L integration_only -n -v -x > "$results/egress-guard.txt"
|
|
||||||
awk '$3 == "REJECT" && $1 > 0 { rejected=1 } END { exit !rejected }' "$results/egress-guard.txt"
|
|
||||||
|
|
||||||
export UV_OFFLINE=true
|
|
||||||
|
|
||||||
if [ "$mode" = parity ]; then
|
|
||||||
export INTEGRATION_WORKERS=1
|
|
||||||
else
|
|
||||||
case "$suite" in
|
|
||||||
management|accounting) export INTEGRATION_WORKERS=2 ;;
|
|
||||||
providers|extensions) export INTEGRATION_WORKERS=4 ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
if [ "$suite" = cost ]; then
|
|
||||||
export INTEGRATION_WORKERS=8
|
|
||||||
fi
|
|
||||||
if [ "$suite" = security ]; then
|
|
||||||
export INTEGRATION_WORKERS=4
|
|
||||||
fi
|
|
||||||
if [ "$suite" = mcp ]; then
|
|
||||||
export INTEGRATION_WORKERS=2 INTEGRATION_COVERAGE=1
|
|
||||||
fi
|
|
||||||
if [ -n "$requested_workers" ]; then
|
|
||||||
export INTEGRATION_WORKERS="$requested_workers"
|
|
||||||
fi
|
|
||||||
isolated_workers=false
|
|
||||||
if [ "${INTEGRATION_WORKERS:-1}" -gt 1 ]; then
|
|
||||||
isolated_workers=true
|
|
||||||
fi
|
|
||||||
if [ "$isolated_workers" = false ]; then
|
|
||||||
setsid env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" INTEGRATION_RUN_ID="$integration_identity" \
|
|
||||||
.venv/bin/python -m integration._support.upstream > "$results/upstream.log" 2>&1 &
|
|
||||||
upstream_pid=$!
|
|
||||||
fi
|
|
||||||
coverage_data="$PWD/$results/coverage/data"
|
|
||||||
proxy_command=(.venv/bin/python -m integration._support.proxy)
|
|
||||||
if [ "${INTEGRATION_COVERAGE:-0}" = 1 ]; then
|
|
||||||
mkdir -p "$(dirname "$coverage_data")"
|
|
||||||
proxy_command=(.venv/bin/python -m coverage run --rcfile=tests/integration/mcp_coverage.toml -m integration._support.proxy)
|
|
||||||
fi
|
|
||||||
start_proxy() {
|
|
||||||
local port="$1"
|
|
||||||
local log_name="$2"
|
|
||||||
local -a cost_map_env
|
|
||||||
if [ "$suite" = cost ]; then
|
|
||||||
cost_map_env=(
|
|
||||||
"LITELLM_MODEL_COST_MAP_URL=$INTEGRATION_UPSTREAM_URL/_cost_map"
|
|
||||||
"MODEL_COST_MAP_MIN_MODEL_COUNT=1"
|
|
||||||
"MODEL_COST_MAP_MAX_SHRINK_RATIO=0"
|
|
||||||
"GEMINI_API_BASE=$INTEGRATION_UPSTREAM_URL"
|
|
||||||
"ANTHROPIC_API_BASE=$INTEGRATION_UPSTREAM_URL"
|
|
||||||
"GEMINI_API_KEY=sk-scripted-provider"
|
|
||||||
"ANTHROPIC_API_KEY=sk-scripted-provider"
|
|
||||||
)
|
|
||||||
else
|
|
||||||
cost_map_env=("LITELLM_LOCAL_MODEL_COST_MAP=True")
|
|
||||||
fi
|
|
||||||
local -a database_env=("DATABASE_URL=${INTEGRATION_PROXY_DATABASE_URL:-$DATABASE_URL}")
|
|
||||||
if [ -n "$INTEGRATION_PROXY_READ_REPLICA_URL" ]; then
|
|
||||||
database_env+=("DATABASE_URL_READ_REPLICA=$INTEGRATION_PROXY_READ_REPLICA_URL")
|
|
||||||
fi
|
|
||||||
setsid env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" INTEGRATION_RUN_ID="$integration_identity" \
|
|
||||||
"${database_env[@]}" REDIS_HOST="$REDIS_HOST" REDIS_PORT="$REDIS_PORT" \
|
|
||||||
INTEGRATION_UPSTREAM_URL="$INTEGRATION_UPSTREAM_URL" \
|
|
||||||
LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" LITELLM_SALT_KEY="$LITELLM_SALT_KEY" LITELLM_UI_PATH="$LITELLM_UI_PATH" PROXY_BASE_URL="http://127.0.0.1:$port" \
|
|
||||||
"${license_env[@]}" \
|
|
||||||
LITELLM_MODE=PRODUCTION STORE_MODEL_IN_DB=True LITELLM_ENABLE_MCP_STDIO=true "${cost_map_env[@]}" \
|
|
||||||
AWS_EC2_METADATA_DISABLED=true DO_NOT_TRACK=1 COVERAGE_FILE="$coverage_data" \
|
|
||||||
"${proxy_command[@]}" --config tests/integration/proxy_config.yaml \
|
|
||||||
--host 127.0.0.1 --port "$port" --num_workers 1 --telemetry False \
|
|
||||||
--use_prisma_db_push \
|
|
||||||
> "$results/$log_name" 2>&1 &
|
|
||||||
launched_pid=$!
|
|
||||||
}
|
|
||||||
if [ "$isolated_workers" = false ]; then
|
|
||||||
start_proxy 4000 proxy.log
|
|
||||||
proxy_pid="$launched_pid"
|
|
||||||
.venv/bin/python .github/scripts/ci/wait_integration_services.py
|
|
||||||
curl --noproxy '*' -sSf -X POST "$INTEGRATION_PROXY_URL/config/update" \
|
|
||||||
-H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' \
|
|
||||||
-d '{"router_settings": {"num_retries": 0}}' > "$results/seed-router-settings.json"
|
|
||||||
if [ "$suite" = management ] || [ "$suite" = mcp ] || [ "$suite" = accounting ]; then
|
|
||||||
export INTEGRATION_PEER_URL=http://127.0.0.1:4001
|
|
||||||
start_proxy 4001 peer.log
|
|
||||||
peer_pid="$launched_pid"
|
|
||||||
.venv/bin/python .github/scripts/ci/wait_integration_services.py
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$suite" = providers ]; then
|
|
||||||
INTEGRATION_RUN_ID="$integration_identity" .venv/bin/python -m pytest --tb=short --noconftest -o addopts= \
|
|
||||||
--strict-markers --strict-config -p no:pytest-retry -p no:rerunfailures --timeout=30 \
|
|
||||||
tests/e2e_harness/test_provider_edge.py::TestReplayMode::test_content_drift_returns_the_miss_status_naming_both_keys \
|
|
||||||
tests/e2e_harness/test_provider_edge.py::TestReplayMode::test_exhausted_key_returns_the_miss_status \
|
|
||||||
tests/e2e_harness/test_provider_edge.py::TestReplayLeftover::test_partially_consumed_recording_names_the_leftover \
|
|
||||||
tests/e2e_harness/test_provider_edge.py::TestStreamingFidelity::test_replay_of_a_stream_makes_no_provider_connection \
|
|
||||||
--junitxml="$results/replay-controls.xml"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$suite" = browser ]; then
|
|
||||||
export E2E_UI_BASE_URL="$INTEGRATION_PROXY_URL" E2E_UI_ARTIFACT_DIR="$PWD/$results"
|
|
||||||
export INTEGRATION_PYTHON="$PWD/.venv/bin/python"
|
|
||||||
timeout --signal=TERM --kill-after=20s 3m env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" \
|
|
||||||
INTEGRATION_RUN_ID="$integration_identity" DATABASE_URL="$DATABASE_URL" \
|
|
||||||
INTEGRATION_UPSTREAM_URL="$INTEGRATION_UPSTREAM_URL" INTEGRATION_PYTHON="$INTEGRATION_PYTHON" \
|
|
||||||
E2E_UI_BASE_URL="$E2E_UI_BASE_URL" E2E_UI_ARTIFACT_DIR="$E2E_UI_ARTIFACT_DIR" \
|
|
||||||
LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" CI=true \
|
|
||||||
node tests/e2e/ui/node_modules/@playwright/test/cli.js test --config tests/e2e/ui/integration.config.ts
|
|
||||||
.venv/bin/python .github/scripts/ci/verify_integration_browser.py "$results/browser-results.json"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
shard_arguments=()
|
|
||||||
if [ -n "${INTEGRATION_SHARD_FILES:-}" ]; then
|
|
||||||
cp "$INTEGRATION_SHARD_FILES" "$results/node-files.txt"
|
|
||||||
shard_arguments=(--shard-files "$results/node-files.txt")
|
|
||||||
fi
|
|
||||||
|
|
||||||
shared_services=()
|
|
||||||
worker_coverage=()
|
|
||||||
if [ "$isolated_workers" = false ]; then
|
|
||||||
shared_services=(
|
|
||||||
"REDIS_HOST=$REDIS_HOST" "REDIS_PORT=$REDIS_PORT"
|
|
||||||
"INTEGRATION_PROXY_URL=$INTEGRATION_PROXY_URL" "INTEGRATION_PEER_URL=$INTEGRATION_PEER_URL"
|
|
||||||
"INTEGRATION_UPSTREAM_URL=$INTEGRATION_UPSTREAM_URL" "INTEGRATION_MASTER_KEY=$INTEGRATION_MASTER_KEY"
|
|
||||||
)
|
|
||||||
elif [ "${INTEGRATION_COVERAGE:-0}" = 1 ]; then
|
|
||||||
worker_coverage=("INTEGRATION_COVERAGE_FILE=$coverage_data")
|
|
||||||
fi
|
|
||||||
|
|
||||||
env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" \
|
|
||||||
INTEGRATION_RUN_ID="$integration_identity" \
|
|
||||||
DATABASE_URL="$DATABASE_URL" \
|
|
||||||
"${shared_services[@]}" "${worker_coverage[@]}" \
|
|
||||||
INTEGRATION_WORKERS="${INTEGRATION_WORKERS:-1}" \
|
|
||||||
LITELLM_MODE=PRODUCTION \
|
|
||||||
"${license_env[@]}" \
|
|
||||||
INTEGRATION_SEED="$INTEGRATION_SEED" \
|
|
||||||
INTEGRATION_ORDER_SEED="$INTEGRATION_ORDER_SEED" \
|
|
||||||
INTEGRATION_CASE_SHARDS="${INTEGRATION_CASE_SHARDS:-1}" INTEGRATION_CASE_SHARD_INDEX="${INTEGRATION_CASE_SHARD_INDEX:-0}" \
|
|
||||||
LITELLM_LOCAL_MODEL_COST_MAP=True AWS_EC2_METADATA_DISABLED=true DO_NOT_TRACK=1 \
|
|
||||||
INTEGRATION_PROXY_DATABASE_URL="$INTEGRATION_PROXY_DATABASE_URL" \
|
|
||||||
INTEGRATION_PROXY_READ_REPLICA_URL="$INTEGRATION_PROXY_READ_REPLICA_URL" \
|
|
||||||
INTEGRATION_ROUTING="$INTEGRATION_ROUTING" \
|
|
||||||
MCP_TEST_PEER_PYTHON="${MCP_TEST_PEER_PYTHON:-}" \
|
|
||||||
.venv/bin/python tests/integration/run.py "$suite" --results "$results" \
|
|
||||||
"${shard_arguments[@]}"
|
|
||||||
|
|
||||||
if [ "${INTEGRATION_COVERAGE:-0}" = 1 ]; then
|
|
||||||
for covered_pid in "$proxy_pid" "$peer_pid"; do
|
|
||||||
[ -n "$covered_pid" ] || continue
|
|
||||||
kill -TERM -- "-$covered_pid"
|
|
||||||
for _ in {1..300}; do
|
|
||||||
kill -0 "$covered_pid" 2>/dev/null || break
|
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
wait "$covered_pid" 2>/dev/null || true
|
|
||||||
done
|
|
||||||
proxy_pid=""
|
|
||||||
peer_pid=""
|
|
||||||
COVERAGE_FILE="$coverage_data" .venv/bin/python -m coverage combine --rcfile=tests/integration/mcp_coverage.toml
|
|
||||||
COVERAGE_FILE="$coverage_data" .venv/bin/python -m coverage report --rcfile=tests/integration/mcp_coverage.toml \
|
|
||||||
> "$results/coverage/coverage.txt"
|
|
||||||
COVERAGE_FILE="$coverage_data" .venv/bin/python -m coverage html --rcfile=tests/integration/mcp_coverage.toml \
|
|
||||||
-d "$results/coverage/html"
|
|
||||||
tail -n 1 "$results/coverage/coverage.txt"
|
|
||||||
fi
|
|
||||||
32
.github/scripts/ci/wait_integration_services.py
vendored
|
|
@ -1,32 +0,0 @@
|
||||||
import os
|
|
||||||
from contextlib import ExitStack
|
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from integration._support.client import Gateway
|
|
||||||
from integration._support.workers import wait_for_services
|
|
||||||
from redis import Redis
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
primary: Final = os.environ["INTEGRATION_PROXY_URL"]
|
|
||||||
peer: Final = os.environ.get("INTEGRATION_PEER_URL")
|
|
||||||
urls: Final = (primary, peer) if peer else (primary,)
|
|
||||||
upstream: Final = os.environ["INTEGRATION_UPSTREAM_URL"]
|
|
||||||
with ExitStack() as stack:
|
|
||||||
proxies: Final = tuple(
|
|
||||||
Gateway(
|
|
||||||
stack.enter_context(httpx.Client(base_url=url, trust_env=False, timeout=2)),
|
|
||||||
os.environ["INTEGRATION_MASTER_KEY"],
|
|
||||||
upstream,
|
|
||||||
)
|
|
||||||
for url in urls
|
|
||||||
)
|
|
||||||
cache: Final = stack.enter_context(
|
|
||||||
Redis(host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"]), socket_timeout=2)
|
|
||||||
)
|
|
||||||
wait_for_services(upstream, proxies, cache)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
2
.github/scripts/detect_changes.sh
vendored
|
|
@ -16,7 +16,7 @@ run_full() {
|
||||||
}
|
}
|
||||||
|
|
||||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
classify="${here}/ci/classify_changes.sh"
|
classify="${here}/../../.circleci/scripts/classify_changes.sh"
|
||||||
|
|
||||||
[ -n "${PR_NUMBER:-}" ] || run_full "not a pull_request event"
|
[ -n "${PR_NUMBER:-}" ] || run_full "not a pull_request event"
|
||||||
[ -n "${REPO:-}" ] || run_full "no repository in the environment"
|
[ -n "${REPO:-}" ] || run_full "no repository in the environment"
|
||||||
|
|
|
||||||
140
.github/scripts/run_merge_smoke.py
vendored
|
|
@ -16,11 +16,29 @@ import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from collections import Counter
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import MappingProxyType
|
||||||
from typing import Final, NoReturn, TextIO, cast
|
from typing import Final, NoReturn, TextIO, cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
EXPECTED_CASES: Final = (
|
||||||
|
"CHAT-JSON",
|
||||||
|
"CHAT-TEXT-STREAM",
|
||||||
|
"CHAT-TOOL-STREAM",
|
||||||
|
"MODEL-ALLOW",
|
||||||
|
"MODEL-DENY",
|
||||||
|
"COST-EXPLICIT",
|
||||||
|
"COST-ZERO",
|
||||||
|
"LOG-CONTENT-ON",
|
||||||
|
"LOG-CONTENT-OFF",
|
||||||
|
"CALLBACK-SUCCESS",
|
||||||
|
"CALLBACK-FAILURE",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class CheckResult:
|
class CheckResult:
|
||||||
|
|
@ -39,6 +57,8 @@ class _Args:
|
||||||
ready_deadline: float = 120.0
|
ready_deadline: float = 120.0
|
||||||
shutdown_deadline: float = 20.0
|
shutdown_deadline: float = 20.0
|
||||||
poll_interval: float = 0.5
|
poll_interval: float = 0.5
|
||||||
|
manifest: str = ""
|
||||||
|
rootdir: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def fail(reason: str) -> NoReturn:
|
def fail(reason: str) -> NoReturn:
|
||||||
|
|
@ -325,6 +345,120 @@ def _terminate(proc: subprocess.Popen[bytes], log_file: TextIO) -> None:
|
||||||
log_file.close()
|
log_file.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_manifest(path: Path) -> MappingProxyType[str, str]:
|
||||||
|
def no_duplicates(pairs: list[tuple[object, object]]) -> dict[object, object]:
|
||||||
|
seen: dict[object, object] = {}
|
||||||
|
for key, value in pairs:
|
||||||
|
if key in seen:
|
||||||
|
raise ValueError(f"duplicate key in manifest: {key}")
|
||||||
|
seen[key] = value
|
||||||
|
return seen
|
||||||
|
|
||||||
|
raw_value: object = cast(object, json.loads(path.read_text(), object_pairs_hook=no_duplicates))
|
||||||
|
if not isinstance(raw_value, dict):
|
||||||
|
raise ValueError("manifest must be an object")
|
||||||
|
loaded: Final = cast(dict[object, object], raw_value)
|
||||||
|
cases_value: object = loaded.get("cases")
|
||||||
|
if not isinstance(cases_value, dict):
|
||||||
|
raise ValueError("manifest must be an object with a 'cases' object")
|
||||||
|
cases_any: Final = cast(dict[object, object], cases_value)
|
||||||
|
cases: Final = {k: v for k, v in cases_any.items() if isinstance(k, str) and isinstance(v, str)}
|
||||||
|
if len(cases) != len(cases_any):
|
||||||
|
raise ValueError("manifest 'cases' must map string ids to string node ids")
|
||||||
|
return MappingProxyType(cases)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, eq=False)
|
||||||
|
class _Recorder:
|
||||||
|
collect_failed: list[str] = field(default_factory=list)
|
||||||
|
collected: tuple[str, ...] = ()
|
||||||
|
reports: dict[str, list[tuple[str, str, bool]]] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def pytest_collectreport(self, report: pytest.CollectReport) -> None:
|
||||||
|
if report.failed:
|
||||||
|
self.collect_failed.append(report.nodeid)
|
||||||
|
|
||||||
|
def pytest_collection_finish(self, session: pytest.Session) -> None:
|
||||||
|
self.collected = tuple(item.nodeid for item in session.items)
|
||||||
|
|
||||||
|
def pytest_runtest_logreport(self, report: pytest.TestReport) -> None:
|
||||||
|
self.reports.setdefault(report.nodeid, []).append((report.when, report.outcome, hasattr(report, "wasxfail")))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_pytest(args: _Args) -> int:
|
||||||
|
try:
|
||||||
|
cases: Final = _load_manifest(Path(args.manifest))
|
||||||
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
fail(f"manifest invalid: {exc}")
|
||||||
|
if tuple(cases) != EXPECTED_CASES:
|
||||||
|
fail(f"manifest case ids must be exactly {list(EXPECTED_CASES)} in order, got {list(cases)}")
|
||||||
|
node_ids: Final = tuple(cases.values())
|
||||||
|
if len(set(node_ids)) != len(node_ids):
|
||||||
|
fail("manifest node ids are not unique")
|
||||||
|
argv: Final = [
|
||||||
|
*node_ids,
|
||||||
|
"-p",
|
||||||
|
"no:cacheprovider",
|
||||||
|
"-p",
|
||||||
|
"no:xdist",
|
||||||
|
"-p",
|
||||||
|
"no:rerunfailures",
|
||||||
|
"-p",
|
||||||
|
"no:randomly",
|
||||||
|
"-rA",
|
||||||
|
"-q",
|
||||||
|
*(["--rootdir", args.rootdir] if args.rootdir else []),
|
||||||
|
]
|
||||||
|
|
||||||
|
recorder: Final = _Recorder()
|
||||||
|
code: Final = pytest.main(argv, plugins=[recorder])
|
||||||
|
name_of: Final = MappingProxyType({node_id: case_id for case_id, node_id in cases.items()})
|
||||||
|
problems: Final[list[str]] = []
|
||||||
|
if code != 0:
|
||||||
|
problems.append(f"pytest exit code {code}")
|
||||||
|
for failed_id in recorder.collect_failed:
|
||||||
|
problems.append(f"collection failed: {name_of.get(failed_id, failed_id)}")
|
||||||
|
expected: Final = Counter(node_ids)
|
||||||
|
collected: Final = Counter(recorder.collected)
|
||||||
|
for node_id in expected - collected:
|
||||||
|
problems.append(f"missing case {name_of[node_id]} ({node_id})")
|
||||||
|
for node_id in collected - expected:
|
||||||
|
problems.append(f"unexpected test collected: {node_id}")
|
||||||
|
for node_id, count in collected.items():
|
||||||
|
if count > 1:
|
||||||
|
problems.append(f"duplicated test id: {node_id}")
|
||||||
|
if len(recorder.collected) != len(EXPECTED_CASES):
|
||||||
|
problems.append(f"collected {len(recorder.collected)} tests, expected {len(EXPECTED_CASES)}")
|
||||||
|
rows: Final[list[tuple[str, bool]]] = []
|
||||||
|
for case_id, node_id in cases.items():
|
||||||
|
reports = recorder.reports.get(node_id, [])
|
||||||
|
case_ok = (
|
||||||
|
bool(reports)
|
||||||
|
and all(outcome == "passed" and not wasxfail for _, outcome, wasxfail in reports)
|
||||||
|
and {when for when, _, _ in reports} >= {"setup", "call", "teardown"}
|
||||||
|
)
|
||||||
|
rows.append((case_id, case_ok))
|
||||||
|
if not reports:
|
||||||
|
problems.append(f"{case_id} ({node_id}) produced no runtest reports")
|
||||||
|
continue
|
||||||
|
for when, outcome, wasxfail in reports:
|
||||||
|
if outcome != "passed":
|
||||||
|
problems.append(f"{case_id} ({node_id}) {when} outcome={outcome}")
|
||||||
|
if wasxfail:
|
||||||
|
problems.append(f"{case_id} ({node_id}) {when} was xfail/xpass")
|
||||||
|
missing_phases = {"setup", "call", "teardown"} - {when for when, _, _ in reports}
|
||||||
|
for phase in sorted(missing_phases):
|
||||||
|
problems.append(f"{case_id} ({node_id}) missing {phase} report")
|
||||||
|
for case_id, passed in rows:
|
||||||
|
print(f"{case_id} {'PASS' if passed else 'FAIL'} {cases[case_id]}")
|
||||||
|
if problems:
|
||||||
|
for problem in problems:
|
||||||
|
print(f"merge-smoke: {problem}", file=sys.stderr)
|
||||||
|
fail("pytest verdict failed")
|
||||||
|
ok("pytest 11 cases")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser: Final = argparse.ArgumentParser(description=__doc__)
|
parser: Final = argparse.ArgumentParser(description=__doc__)
|
||||||
subs: Final = parser.add_subparsers(dest="command", required=True)
|
subs: Final = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
@ -341,12 +475,16 @@ def main() -> int:
|
||||||
p_proxy.add_argument("--ready-deadline", type=float, default=120)
|
p_proxy.add_argument("--ready-deadline", type=float, default=120)
|
||||||
p_proxy.add_argument("--shutdown-deadline", type=float, default=20)
|
p_proxy.add_argument("--shutdown-deadline", type=float, default=20)
|
||||||
p_proxy.add_argument("--poll-interval", type=float, default=0.5)
|
p_proxy.add_argument("--poll-interval", type=float, default=0.5)
|
||||||
|
p_test: Final = subs.add_parser("pytest")
|
||||||
|
p_test.add_argument("--manifest", required=True)
|
||||||
|
p_test.add_argument("--rootdir", default=None)
|
||||||
args: Final = parser.parse_args(namespace=_Args())
|
args: Final = parser.parse_args(namespace=_Args())
|
||||||
handlers: Final = {
|
handlers: Final = {
|
||||||
"verify-isolation": cmd_verify_isolation,
|
"verify-isolation": cmd_verify_isolation,
|
||||||
"interpreter": cmd_interpreter,
|
"interpreter": cmd_interpreter,
|
||||||
"cli": cmd_cli,
|
"cli": cmd_cli,
|
||||||
"proxy-startup": cmd_proxy_startup,
|
"proxy-startup": cmd_proxy_startup,
|
||||||
|
"pytest": cmd_pytest,
|
||||||
}
|
}
|
||||||
return handlers[args.command](args)
|
return handlers[args.command](args)
|
||||||
|
|
||||||
|
|
|
||||||
13
.github/scripts/setup_rust_environment.sh
vendored
|
|
@ -1,13 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
root=$(git rev-parse --show-toplevel)
|
|
||||||
toolchain=$(sed -n 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "$root/litellm/rust/rust-toolchain.toml")
|
|
||||||
test -n "$toolchain"
|
|
||||||
rustup toolchain install "$toolchain" --profile minimal --component rustfmt --component clippy --no-self-update
|
|
||||||
{
|
|
||||||
echo "RUSTUP_TOOLCHAIN=$toolchain"
|
|
||||||
echo "CARGO_NET_RETRY=5"
|
|
||||||
echo "CARGO_HTTP_MULTIPLEXING=false"
|
|
||||||
echo "SQLX_OFFLINE=${SQLX_OFFLINE:-true}"
|
|
||||||
} >> "${GITHUB_ENV:?GITHUB_ENV is required}"
|
|
||||||
9
.github/scripts/smoke_test_native_wheel.py
vendored
|
|
@ -7,8 +7,6 @@ import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
COST_MAP: Final = Path(__file__).resolve().parents[2] / "model_prices_and_context_window.json"
|
|
||||||
|
|
||||||
CHILD_SCRIPT: Final = """
|
CHILD_SCRIPT: Final = """
|
||||||
from importlib.util import module_from_spec, spec_from_file_location
|
from importlib.util import module_from_spec, spec_from_file_location
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -36,9 +34,6 @@ else:
|
||||||
after = module.gil_stats()
|
after = module.gil_stats()
|
||||||
if not isinstance(after.get("releases"), int):
|
if not isinstance(after.get("releases"), int):
|
||||||
raise AssertionError(f"native module unusable after panic: {after!r}")
|
raise AssertionError(f"native module unusable after panic: {after!r}")
|
||||||
|
|
||||||
if module.bundled_model_cost_map() != Path(sys.argv[2]).read_bytes():
|
|
||||||
raise AssertionError("the wheel embeds a different model_prices_and_context_window.json than this checkout")
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -60,9 +55,7 @@ def main() -> int:
|
||||||
|
|
||||||
native_path: Final = Path(temporary_directory) / Path(native_members[0].filename).name
|
native_path: Final = Path(temporary_directory) / Path(native_members[0].filename).name
|
||||||
native_path.write_bytes(archive.read(native_members[0]))
|
native_path.write_bytes(archive.read(native_members[0]))
|
||||||
result: Final = subprocess.run(
|
result: Final = subprocess.run((sys.executable, "-c", CHILD_SCRIPT, str(native_path)), check=False)
|
||||||
(sys.executable, "-c", CHILD_SCRIPT, str(native_path), str(COST_MAP)), check=False
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
sys.stderr.write(f"native wheel smoke test exited with status {result.returncode}\n")
|
sys.stderr.write(f"native wheel smoke test exited with status {result.returncode}\n")
|
||||||
|
|
|
||||||
16
.github/scripts/verify_linux_native_wheel.py
vendored
|
|
@ -128,20 +128,6 @@ def main(
|
||||||
member.filename
|
member.filename
|
||||||
for member in wheel_members
|
for member in wheel_members
|
||||||
if member.filename.endswith((".pdb", ".dwp", ".rlib", ".rmeta", "Cargo.toml", "Cargo.lock"))
|
if member.filename.endswith((".pdb", ".dwp", ".rlib", ".rmeta", "Cargo.toml", "Cargo.lock"))
|
||||||
or member.filename.startswith(
|
|
||||||
(
|
|
||||||
"litellm/deploy/",
|
|
||||||
"litellm/ui/",
|
|
||||||
"litellm/vscode-extension/",
|
|
||||||
"litellm/enterprise/",
|
|
||||||
"litellm/proxy-extras/",
|
|
||||||
"litellm/rust/",
|
|
||||||
"litellm/packaging/",
|
|
||||||
"litellm/scripts/",
|
|
||||||
"litellm/runtime/",
|
|
||||||
"litellm/schema.prisma",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
or any(part.endswith(".dSYM") for part in PurePosixPath(member.filename).parts)
|
or any(part.endswith(".dSYM") for part in PurePosixPath(member.filename).parts)
|
||||||
)
|
)
|
||||||
native_member: Final = native_members[0]
|
native_member: Final = native_members[0]
|
||||||
|
|
@ -228,7 +214,7 @@ def main(
|
||||||
native_module: Final = load_native_module(native_path)
|
native_module: Final = load_native_module(native_path)
|
||||||
native_module_loads: Final = native_module is not None
|
native_module_loads: Final = native_module is not None
|
||||||
panic_test_hook_absent: Final = native_module is not None and not hasattr(native_module, "_panic_for_test")
|
panic_test_hook_absent: Final = native_module is not None and not hasattr(native_module, "_panic_for_test")
|
||||||
native_size_limit: Final = 55_000_000
|
native_size_limit: Final = 48_000_000
|
||||||
native_size_within_limit: Final = native_member.file_size <= native_size_limit
|
native_size_within_limit: Final = native_member.file_size <= native_size_limit
|
||||||
validations: Final = (
|
validations: Final = (
|
||||||
(f"Python tag is {EXPECTED_PYTHON_TAG}", python_tag == EXPECTED_PYTHON_TAG),
|
(f"Python tag is {EXPECTED_PYTHON_TAG}", python_tag == EXPECTED_PYTHON_TAG),
|
||||||
|
|
|
||||||
22
.github/semgrep/rules/README.md
vendored
|
|
@ -1,22 +0,0 @@
|
||||||
# Custom Semgrep rules for LiteLLM
|
|
||||||
|
|
||||||
Add custom rule YAML files here. Semgrep loads all `.yml`/`.yaml` files under this directory.
|
|
||||||
|
|
||||||
**Run only custom rules (CI / fail on findings):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
semgrep scan --config .github/semgrep/rules . --error
|
|
||||||
```
|
|
||||||
|
|
||||||
**Run with registry + custom rules:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
semgrep scan --config auto --config .github/semgrep/rules .
|
|
||||||
```
|
|
||||||
|
|
||||||
**Layout:**
|
|
||||||
|
|
||||||
- `python/` – Python-specific rules (security, patterns)
|
|
||||||
- Add more subdirs as needed (e.g. `generic/` for language-agnostic rules)
|
|
||||||
|
|
||||||
See [Semgrep rule syntax](https://semgrep.dev/docs/writing-rules/rule-syntax/).
|
|
||||||
283
.github/workflows/_test-unit-base.yml
vendored
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
name: _Unit Test Base (Reusable)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
python-version:
|
||||||
|
description: "Python version used to install dependencies and run tests"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: "3.12"
|
||||||
|
test-path:
|
||||||
|
description: >-
|
||||||
|
Space-separated pytest paths to run. A path that no longer exists is
|
||||||
|
dropped with a warning instead of being passed to pytest, because one
|
||||||
|
missing path makes pytest-xdist collect nothing and report exit 5, which
|
||||||
|
the step treats as a drained shard. Options are passed through as
|
||||||
|
written, so use the `--flag=value` form: a bare `--ignore path` would
|
||||||
|
have its path existence-checked like any other token.
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
workers:
|
||||||
|
description: "Number of pytest-xdist workers"
|
||||||
|
required: false
|
||||||
|
type: number
|
||||||
|
default: 2
|
||||||
|
reruns:
|
||||||
|
description: "Number of reruns for flaky tests"
|
||||||
|
required: false
|
||||||
|
type: number
|
||||||
|
default: 2
|
||||||
|
timeout-minutes:
|
||||||
|
description: >-
|
||||||
|
Timeout for the test step alone. Setup (checkout, dependency install,
|
||||||
|
Prisma client generation) gets its own allowance on top, so a slow
|
||||||
|
runner or a cold binary download can never cancel passing tests.
|
||||||
|
required: false
|
||||||
|
type: number
|
||||||
|
default: 20
|
||||||
|
job-timeout-minutes:
|
||||||
|
description: >-
|
||||||
|
Backstop for the whole job. Keep it >= `timeout-minutes` plus 40: 35 for
|
||||||
|
the per-step ceilings on the setup steps below, and 5 for the runner
|
||||||
|
overhead the job clock charges but no step owns (job init, step
|
||||||
|
transitions, post-job cleanup). That headroom is what makes the test
|
||||||
|
budget a floor rather than a hope, since setup cannot overrun into it
|
||||||
|
without failing its own step first. GitHub expressions have no
|
||||||
|
arithmetic, so the sum is passed in rather than computed.
|
||||||
|
required: false
|
||||||
|
type: number
|
||||||
|
default: 60
|
||||||
|
test-timeout-seconds:
|
||||||
|
description: >-
|
||||||
|
Per-test ceiling enforced by pytest-timeout, covering fixture setup and
|
||||||
|
teardown as well as the test body. A test that hangs fails with a
|
||||||
|
traceback of where it was stuck instead of idling the shard until
|
||||||
|
`timeout-minutes` cancels it. Timed-out tests are excluded from reruns
|
||||||
|
because pytest-timeout arms its timer once per test and
|
||||||
|
pytest-rerunfailures reruns inside that same window, so a rerun of a
|
||||||
|
timed-out test would run with no timer at all.
|
||||||
|
required: false
|
||||||
|
type: number
|
||||||
|
default: 120
|
||||||
|
max-failures:
|
||||||
|
description: "Stop after this many failures"
|
||||||
|
required: false
|
||||||
|
type: number
|
||||||
|
default: 10
|
||||||
|
dist:
|
||||||
|
description: "pytest-xdist distribution mode (loadscope|load|worksteal|loadfile|no)"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: "loadscope"
|
||||||
|
artifact-name:
|
||||||
|
description: "Unique name for the coverage artifact (must be unique per run)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
rust-bridge-artifact:
|
||||||
|
description: "Prebuilt editable Rust bridge artifact"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
UV_PYTHON: ${{ inputs.python-version }}
|
||||||
|
LITELLM_LOCAL_MODEL_COST_MAP: "True"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
run:
|
||||||
|
name: Run tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: ${{ inputs.job-timeout-minutes }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
outputs:
|
||||||
|
decision: ${{ steps.changes.outputs.decision }}
|
||||||
|
has-coverage: ${{ steps.tests.outputs.has-coverage }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||||
|
timeout-minutes: 3
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Detect relevant changes
|
||||||
|
id: changes
|
||||||
|
timeout-minutes: 2
|
||||||
|
uses: ./.github/actions/detect-changes
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
if: steps.changes.outputs.decision != 'skip'
|
||||||
|
timeout-minutes: 3
|
||||||
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.UV_PYTHON }}
|
||||||
|
|
||||||
|
- name: Set up uv
|
||||||
|
if: steps.changes.outputs.decision != 'skip'
|
||||||
|
timeout-minutes: 3
|
||||||
|
uses: ./.github/actions/setup-uv-with-retries
|
||||||
|
with:
|
||||||
|
version: "0.10.9"
|
||||||
|
|
||||||
|
- name: Cache uv dependencies
|
||||||
|
if: steps.changes.outputs.decision != 'skip'
|
||||||
|
timeout-minutes: 5
|
||||||
|
uses: ./.github/actions/cache-uv-downloads
|
||||||
|
|
||||||
|
- name: Set up the Rust build
|
||||||
|
if: steps.changes.outputs.decision != 'skip'
|
||||||
|
timeout-minutes: 5
|
||||||
|
uses: ./.github/actions/rust-bridge
|
||||||
|
with:
|
||||||
|
artifact: ${{ inputs.rust-bridge-artifact }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.changes.outputs.decision != 'skip'
|
||||||
|
timeout-minutes: 8
|
||||||
|
env:
|
||||||
|
RUST_BRIDGE_ARTIFACT: ${{ inputs.rust-bridge-artifact }}
|
||||||
|
run: |
|
||||||
|
diff -u model_prices_and_context_window.json litellm/model_prices_and_context_window_backup.json
|
||||||
|
if [ -z "$RUST_BRIDGE_ARTIFACT" ]; then
|
||||||
|
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml --extra caching --extra extra_proxy --extra proxy-runtime
|
||||||
|
else
|
||||||
|
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml --extra caching --extra extra_proxy --extra proxy-runtime --no-install-project
|
||||||
|
uv pip install --no-deps --python .venv/bin/python rust-bridge-dist/*.whl
|
||||||
|
cp rust-bridge-dist/litellm/rust_bridge/_native.abi3.so litellm/rust_bridge/_native.abi3.so
|
||||||
|
uv run --no-sync python -c "import importlib.metadata; import litellm.rust_bridge._native; print(importlib.metadata.version('litellm'))"
|
||||||
|
fi
|
||||||
|
uv run --no-sync python -c 'import os, sys; print(sys.version); assert f"{sys.version_info.major}.{sys.version_info.minor}" == os.environ["UV_PYTHON"]'
|
||||||
|
|
||||||
|
- name: Cache Prisma binaries
|
||||||
|
if: steps.changes.outputs.decision != 'skip'
|
||||||
|
timeout-minutes: 3
|
||||||
|
uses: ./.github/actions/cache-prisma-binaries
|
||||||
|
|
||||||
|
- name: Generate Prisma client
|
||||||
|
if: steps.changes.outputs.decision != 'skip'
|
||||||
|
timeout-minutes: 3
|
||||||
|
run: |
|
||||||
|
uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
id: tests
|
||||||
|
if: steps.changes.outputs.decision != 'skip'
|
||||||
|
timeout-minutes: ${{ inputs.timeout-minutes }}
|
||||||
|
env:
|
||||||
|
TEST_PATH: ${{ inputs.test-path }}
|
||||||
|
MAX_FAILURES: ${{ inputs.max-failures }}
|
||||||
|
WORKERS: ${{ inputs.workers }}
|
||||||
|
RERUNS: ${{ inputs.reruns }}
|
||||||
|
TEST_TIMEOUT_SECONDS: ${{ inputs.test-timeout-seconds }}
|
||||||
|
DIST: ${{ inputs.dist }}
|
||||||
|
COVERAGE_CORE: sysmon
|
||||||
|
run: |
|
||||||
|
echo "has-coverage=false" >> "$GITHUB_OUTPUT"
|
||||||
|
selection="${TEST_PATH}"
|
||||||
|
if [ -z "${selection// /}" ]; then
|
||||||
|
echo "shard selection is empty; nothing to run"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
pytest_args=()
|
||||||
|
existing_paths=0
|
||||||
|
for token in ${selection}; do
|
||||||
|
case "${token}" in
|
||||||
|
-*) pytest_args+=("${token}") ;;
|
||||||
|
*)
|
||||||
|
if [ -e "${token%%::*}" ]; then
|
||||||
|
pytest_args+=("${token}")
|
||||||
|
existing_paths=$((existing_paths + 1))
|
||||||
|
else
|
||||||
|
echo "::warning::${token} does not exist; drop it from this shard's test-path"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [ "${existing_paths}" -eq 0 ]; then
|
||||||
|
echo "No path in the selection exists (${selection}); nothing to run"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
xdist_args=()
|
||||||
|
if [ "${WORKERS}" != "0" ]; then
|
||||||
|
xdist_args=(-n "${WORKERS}" --dist="${DIST}")
|
||||||
|
fi
|
||||||
|
set +e
|
||||||
|
uv run --no-sync pytest "${pytest_args[@]}" \
|
||||||
|
--tb=short -vv \
|
||||||
|
--maxfail="${MAX_FAILURES}" \
|
||||||
|
"${xdist_args[@]}" \
|
||||||
|
--reruns "${RERUNS}" \
|
||||||
|
--reruns-delay 1 \
|
||||||
|
--timeout="${TEST_TIMEOUT_SECONDS}" \
|
||||||
|
--rerun-except "from pytest-timeout" \
|
||||||
|
--durations=20 \
|
||||||
|
--cov=./litellm --cov=./enterprise/litellm_enterprise \
|
||||||
|
--cov-report=xml:coverage.xml \
|
||||||
|
--cov-config=pyproject.toml
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
if [ -f coverage.xml ]; then
|
||||||
|
echo "has-coverage=true" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
if [ "$status" -eq 5 ]; then
|
||||||
|
echo "pytest collected no tests from ${selection}; passing"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exit "$status"
|
||||||
|
|
||||||
|
- name: Save coverage report
|
||||||
|
if: always() && steps.changes.outputs.decision != 'skip'
|
||||||
|
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
|
||||||
|
with:
|
||||||
|
name: coverage-${{ inputs.artifact-name }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
|
path: coverage.xml
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
upload-coverage:
|
||||||
|
name: Upload coverage to Codecov
|
||||||
|
needs: run
|
||||||
|
if: always() && needs.run.outputs.decision != 'skip' && needs.run.outputs.has-coverage == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Download coverage report
|
||||||
|
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
|
||||||
|
with:
|
||||||
|
pattern: coverage-${{ inputs.artifact-name }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
|
path: coverage-reports
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Upload to Codecov
|
||||||
|
id: codecov-upload
|
||||||
|
continue-on-error: true
|
||||||
|
uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5
|
||||||
|
with:
|
||||||
|
use_oidc: true
|
||||||
|
directory: coverage-reports
|
||||||
|
root_dir: ${{ github.workspace }}
|
||||||
|
flags: ${{ inputs.artifact-name }}
|
||||||
|
fail_ci_if_error: false
|
||||||
|
|
||||||
|
- name: Upload to Codecov (retry)
|
||||||
|
if: steps.codecov-upload.outcome == 'failure'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5
|
||||||
|
with:
|
||||||
|
use_oidc: true
|
||||||
|
directory: coverage-reports
|
||||||
|
root_dir: ${{ github.workspace }}
|
||||||
|
flags: ${{ inputs.artifact-name }}
|
||||||
|
fail_ci_if_error: false
|
||||||
8
.github/workflows/auto-close-duplicates.yml
vendored
|
|
@ -16,8 +16,8 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- .github/workflows/auto-close-duplicates.yml
|
- .github/workflows/auto-close-duplicates.yml
|
||||||
- litellm/scripts/auto-close-duplicates.ts
|
- scripts/auto-close-duplicates.ts
|
||||||
- litellm/scripts/auto-close-duplicates.test.ts
|
- scripts/auto-close-duplicates.test.ts
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
|
|
@ -40,7 +40,7 @@ jobs:
|
||||||
bun-version: "1.4.0"
|
bun-version: "1.4.0"
|
||||||
|
|
||||||
- name: Test the sweep
|
- name: Test the sweep
|
||||||
run: bun test litellm/scripts/auto-close-duplicates.test.ts
|
run: bun test scripts/auto-close-duplicates.test.ts
|
||||||
|
|
||||||
sweep:
|
sweep:
|
||||||
if: github.event_name != 'pull_request' && github.repository == 'BerriAI/litellm'
|
if: github.event_name != 'pull_request' && github.repository == 'BerriAI/litellm'
|
||||||
|
|
@ -62,7 +62,7 @@ jobs:
|
||||||
bun-version: "1.4.0"
|
bun-version: "1.4.0"
|
||||||
|
|
||||||
- name: Close unanswered duplicates, reopen ones the reporter answered
|
- name: Close unanswered duplicates, reopen ones the reporter answered
|
||||||
run: bun run litellm/scripts/auto-close-duplicates.ts
|
run: bun run scripts/auto-close-duplicates.ts
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
DRY_RUN: ${{ inputs.dry_run == true }}
|
DRY_RUN: ${{ inputs.dry_run == true }}
|
||||||
|
|
|
||||||