diff --git a/.circleci/config.yml b/.circleci/config.yml
index d8dc40433dc..90bab08235a 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -38,7 +38,7 @@ commands:
parameters:
category:
type: enum
- enum: ["backend", "client", "provider-harness", "redis-compat"]
+ enum: ["backend", "client"]
default: "backend"
steps:
- run:
@@ -160,7 +160,7 @@ commands:
exit 1
;;
esac
- curl -sSLf -o /tmp/rustup-init \
+ curl -sSLf --retry 3 --retry-all-errors --retry-max-time 120 --connect-timeout 15 --max-time 60 -o /tmp/rustup-init \
"https://static.rust-lang.org/rustup/archive/1.28.2/${RUSTUP_TRIPLE}/rustup-init"
echo "${RUSTUP_SHA256} /tmp/rustup-init" | sha256sum -c -
chmod +x /tmp/rustup-init
@@ -243,7 +243,7 @@ commands:
mkdir -p "$HOME/.local/bin"
mv /tmp/codecov "$HOME/.local/bin/codecov"
start_openai_record_replay_proxy:
- description: "Start the record/replay proxy (tests/_openai_record_replay_proxy.py) on host port 8090 and wait until healthy. Models whose api_base points here replay recorded provider responses, so the E2E run neither pays for nor depends on the live provider. The default upstream is OpenAI; a non-OpenAI model must point its api_base at /__recorder_upstream// so the recorder forwards there instead of defaulting to OpenAI. Run after uv deps are synced."
+ description: "Start the record/replay proxy (tests/_openai_record_replay_proxy.py) on host port 8090 and wait until healthy. Models whose api_base points here replay Redis cache hits. Cache misses call the live provider and store its response, so these legacy E2E jobs still require provider availability and may incur provider charges. The default upstream is OpenAI; a non-OpenAI model must point its api_base at /__recorder_upstream// so the recorder forwards there instead of defaulting to OpenAI. Run after uv deps are synced."
steps:
- run:
name: Start record/replay proxy
@@ -284,26 +284,6 @@ commands:
done
echo "fake OpenAI endpoint did not become ready" >&2
exit 1
- start_cost_center_service:
- description: "Start the stand-in cost center validation service (tests/store_model_in_db_tests/cost_center_service.py) on host port 9414 and wait until healthy. The proxy's team-metadata validator (team_metadata_validator_e2e.py, impl 'http') reaches it via TEAM_METADATA_VALIDATION_SERVICE_URL=http://host.docker.internal:9414/validate. Run after uv deps are synced."
- steps:
- - run:
- name: Start cost center validation service
- background: true
- command: |
- uv run --no-sync python tests/store_model_in_db_tests/cost_center_service.py --host 0.0.0.0 --port 9414
- - run:
- name: Wait for cost center validation service
- command: |
- for i in $(seq 1 30); do
- if curl -sf http://localhost:9414/health >/dev/null 2>&1; then
- echo "cost center validation service is up"
- exit 0
- fi
- sleep 1
- done
- echo "cost center validation service did not become ready" >&2
- exit 1
setup_litellm_enterprise_pip:
steps:
- run:
@@ -359,8 +339,15 @@ commands:
Add-Content -Path $PROFILE -Value "`$env:Path = `"$cargoBin;`$env:Path`""
}
setup_litellm_test_deps:
+ parameters:
+ checkout:
+ type: boolean
+ default: true
steps:
- - checkout
+ - when:
+ condition: << parameters.checkout >>
+ steps:
+ - checkout
- setup_google_dns
- install_uv
- install_rust
@@ -379,50 +366,6 @@ commands:
- save_cargo_target
jobs:
- # Add Windows testing job
- using_litellm_on_windows:
- executor:
- name: win/default
- shell: powershell.exe
- working_directory: ~/project
- environment:
- UV_PYTHON: "3.11"
- CARGO_HTTP_MULTIPLEXING: "false"
- CARGO_NET_RETRY: "5"
- steps:
- - checkout
- - run:
- name: Install Python
- command: |
- choco install python --version=3.11.0 -y --no-progress --force
- refreshenv
- python --version
- environment:
- CHOCOLATEY_CONFIRM_ALL: "true"
- - install_windows_toolchain
- - run:
- name: Install Dependencies
- no_output_timeout: 30m
- environment:
- UV_HTTP_TIMEOUT: "300"
- command: |
- $env:Path = "$HOME\.cargo\bin;$HOME\.local\bin;$env:Path"
- for ($attempt = 1; $attempt -le 5; $attempt++) {
- Write-Host "uv sync attempt $attempt/5"
- uv sync --frozen --group dev --python 3.11
- if ($LASTEXITCODE -eq 0) {
- break
- }
- if ($attempt -eq 5) {
- exit $LASTEXITCODE
- }
- Start-Sleep -Seconds 15
- }
- - run:
- name: Run Windows-specific test
- command: |
- uv run --no-sync python -m pytest --tb=short tests/windows_tests/ -v
-
windows_release_wheel:
executor:
name: win/default
@@ -440,13 +383,28 @@ jobs:
shell: bash.exe
command: bash .circleci/scripts/path_filter.sh windows-release
- run:
- name: Install Python
+ name: Install Python 3.11.9 from python.org
+ no_output_timeout: 6m
command: |
- choco install python --version=3.11.0 -y --no-progress --force
- refreshenv
- python --version
- environment:
- CHOCOLATEY_CONFIRM_ALL: "true"
+ $ProgressPreference = "SilentlyContinue"
+ $installer = Join-Path $env:TEMP "python-3.11.9-amd64.exe"
+ Invoke-WebRequest -Uri https://www.python.org/ftp/python/3.11.9/python-3.11.9-amd64.exe -OutFile $installer -TimeoutSec 120
+ $expected = "5ee42c4eee1e6b4464bb23722f90b45303f79442df63083f05322f1785f5fdde"
+ $actual = (Get-FileHash -Path $installer -Algorithm SHA256).Hash.ToLower()
+ if ($actual -ne $expected) {
+ throw "Python installer hash mismatch: expected $expected got $actual"
+ }
+ $arguments = "/quiet InstallAllUsers=1 PrependPath=1 Include_test=0 Include_doc=0 Include_launcher=0 TargetDir=C:\Python311"
+ $process = Start-Process -FilePath $installer -ArgumentList $arguments -PassThru
+ if (-not $process.WaitForExit(300000)) {
+ $process.Kill()
+ throw "Python installer did not finish within 5 minutes"
+ }
+ if ($process.ExitCode -ne 0) {
+ throw "Python installer exited with $($process.ExitCode)"
+ }
+ Remove-Item $installer
+ & C:\Python311\python.exe --version
- install_windows_toolchain
- run:
name: Record the Rust build environment for the release cargo cache key
@@ -469,7 +427,7 @@ jobs:
environment:
UV_HTTP_TIMEOUT: "300"
command: |
- $env:Path = "$HOME\.cargo\bin;$HOME\.local\bin;$env:Path"
+ $env:Path = "C:\Python311;C:\Python311\Scripts;$HOME\.cargo\bin;$HOME\.local\bin;$env:Path"
uv build --wheel --out-dir dist
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
@@ -517,1055 +475,6 @@ jobs:
command: |
python3 tests/windows_tests/check_windows_wheel_install.py --lengths-only
- local_testing_part1:
- docker:
- - &python312_image
- image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c
- auth:
- username: ${DOCKERHUB_USERNAME}
- password: ${DOCKERHUB_PASSWORD}
- - &postgres16_sidecar
- image: cimg/postgres:16.0@sha256:b125148bc76e8e8eee5eb3ad6020a3a14110a14e8192f1c645128afebe2e2f84
- environment:
- POSTGRES_USER: postgres
- POSTGRES_PASSWORD: postgres
- POSTGRES_DB: litellm_test
- working_directory: ~/project
- environment: &sidecar_database_url
- DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/litellm_test"
- parallelism: 4
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - restore_cache:
- keys:
- - v1-uv-cache-{{ checksum "uv.lock" }}
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - setup_litellm_enterprise_pip
- - save_cache:
- paths:
- - ~/.cache/uv
- key: v1-uv-cache-{{ checksum "uv.lock" }}
- - save_cargo_target
- - wait_for_service:
- url: tcp://localhost:5432
- timeout: "60"
- - run:
- name: Run prisma ./docker/entrypoint.sh
- command: |
- set +e
- chmod +x docker/entrypoint.sh
- ./docker/entrypoint.sh
- set -e
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests (Part 1 - A-M)
- command: |
- mkdir test-results
-
- # Discover test files (A-M)
- TEST_FILES=$(circleci tests glob "tests/local_testing/**/test_[a-mA-M]*.py")
-
- echo "$TEST_FILES" | circleci tests run \
- --split-by=timings \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv \
- --cov=./litellm --cov=./enterprise/litellm_enterprise \
- --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=20 \
- -k \"not test_python_38.py and not test_basic_python_version.py and not router and not assistants and not langfuse and not caching and not cache\" \
- -n 4 \
- --timeout=300 \
- --timeout_method=thread"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- # When CI reruns only the failed tests, a parallel node can receive
- # zero tests and pytest never writes coverage. Emit empty placeholders
- # so persist_to_workspace and the downstream coverage combine stay green.
- if [ -f coverage.xml ]; then
- mv coverage.xml local_testing_part1_coverage.xml
- mv .coverage local_testing_part1_coverage
- else
- touch local_testing_part1_coverage.xml local_testing_part1_coverage
- fi
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - local_testing_part1_coverage.xml
- - local_testing_part1_coverage
- local_testing_part2:
- docker:
- - *python312_image
- - *postgres16_sidecar
- working_directory: ~/project
- environment: *sidecar_database_url
- parallelism: 4
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - restore_cache:
- keys:
- - v1-uv-cache-{{ checksum "uv.lock" }}
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - setup_litellm_enterprise_pip
- - save_cache:
- paths:
- - ~/.cache/uv
- key: v1-uv-cache-{{ checksum "uv.lock" }}
- - wait_for_service:
- url: tcp://localhost:5432
- timeout: "60"
- - run:
- name: Run prisma ./docker/entrypoint.sh
- command: |
- set +e
- chmod +x docker/entrypoint.sh
- ./docker/entrypoint.sh
- set -e
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests (Part 2 - N-Z)
- command: |
- mkdir test-results
-
- # Discover test files (N-Z)
- TEST_FILES=$(circleci tests glob "tests/local_testing/**/test_[n-zN-Z]*.py")
-
- echo "$TEST_FILES" | circleci tests run \
- --split-by=timings \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv \
- --cov=./litellm --cov=./enterprise/litellm_enterprise \
- --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=20 \
- -k \"not test_python_38.py and not test_basic_python_version.py and not router and not assistants and not langfuse and not caching and not cache\" \
- -n 4 \
- --timeout=300 \
- --timeout_method=thread"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- # When CI reruns only the failed tests, a parallel node can receive
- # zero tests and pytest never writes coverage. Emit empty placeholders
- # so persist_to_workspace and the downstream coverage combine stay green.
- if [ -f coverage.xml ]; then
- mv coverage.xml local_testing_part2_coverage.xml
- mv .coverage local_testing_part2_coverage
- else
- touch local_testing_part2_coverage.xml local_testing_part2_coverage
- fi
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - local_testing_part2_coverage.xml
- - local_testing_part2_coverage
- langfuse_logging_unit_tests:
- docker:
- - *python312_image
- - *postgres16_sidecar
- working_directory: ~/project
- environment: *sidecar_database_url
- resource_class: medium
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - restore_cache:
- keys:
- - v1-uv-cache-{{ checksum "uv.lock" }}
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - setup_litellm_enterprise_pip
- - save_cache:
- paths:
- - ~/.cache/uv
- key: v1-uv-cache-{{ checksum "uv.lock" }}
- - wait_for_service:
- url: tcp://localhost:5432
- timeout: "60"
- - run:
- name: Run prisma ./docker/entrypoint.sh
- command: |
- set +e
- chmod +x docker/entrypoint.sh
- ./docker/entrypoint.sh
- set -e
-
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/local_testing/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -v \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- -k \"langfuse\""
- no_output_timeout: 15m
- # Store test results
- - store_test_results:
- path: test-results
- auth_ui_unit_tests:
- docker:
- - *python312_image
- - *postgres16_sidecar
- working_directory: ~/project
- environment: *sidecar_database_url
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - save_cache:
- paths:
- - ~/.cache/uv
- key: v1-uv-cache-{{ checksum "uv.lock" }}
- - wait_for_service:
- url: tcp://localhost:5432
- timeout: "60"
- - run:
- name: Seed DB schema via prisma db push
- command: |
- set +e
- uv run --no-sync litellm --skip_server_startup --use_prisma_db_push
- set -e
- - run:
- name: Generate Prisma Client
- command: uv run --no-sync python -m prisma generate
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/proxy_admin_ui_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -v \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- -n 2"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- mv coverage.xml auth_ui_unit_tests_coverage.xml
- mv .coverage auth_ui_unit_tests_coverage
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - auth_ui_unit_tests_coverage.xml
- - auth_ui_unit_tests_coverage
-
- litellm_router_testing: # Runs all tests with the "router" keyword
- docker:
- - *python312_image
- working_directory: ~/project
- resource_class: large
- parallelism: 4
- environment:
- FAKE_OPENAI_API_BASE: http://127.0.0.1:8190
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - restore_cache:
- keys:
- - v1-uv-cache-{{ checksum "uv.lock" }}
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - save_cache:
- paths:
- - ~/.cache/uv
- key: v1-uv-cache-{{ checksum "uv.lock" }}
- - start_fake_openai_endpoint
- # Run pytest and generate JUnit XML report
- - setup_litellm_enterprise_pip
- - run:
- name: Run tests
- command: |
- # On a "rerun failed tests" build a parallel node can receive no
- # tests, so the test command never creates test-results. Pre-create it
- # so store_test_results doesn't fail the node on a missing path.
- mkdir -p test-results
-
- TEST_FILES=$(circleci tests glob "tests/local_testing/**/test_*.py")
-
- echo "$TEST_FILES" | circleci tests run \
- --split-by=timings \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -v \
- -k 'router' \
- -n 4 \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- --timeout=300 --timeout_method=thread"
- no_output_timeout: 15m
- # Store test results
- - store_test_results:
- path: test-results
-
- litellm_router_unit_testing: # Runs all tests with the "router" keyword
- docker:
- - *python312_image
- working_directory: ~/project
- resource_class: large
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - restore_cache:
- keys:
- - v1-uv-cache-{{ checksum "uv.lock" }}
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - save_cache:
- paths:
- - ~/.cache/uv
- key: v1-uv-cache-{{ checksum "uv.lock" }}
- # Run pytest and generate JUnit XML report
- - setup_litellm_enterprise_pip
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/router_unit_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -v \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- -n 4"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- mv coverage.xml router_unit_tests_coverage.xml
- mv .coverage router_unit_tests_coverage
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - router_unit_tests_coverage.xml
- - router_unit_tests_coverage
- llm_translation_testing:
- docker:
- - *python312_image
- working_directory: ~/project
- resource_class: xlarge
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - restore_cache:
- keys:
- - v1-uv-cache-{{ checksum "uv.lock" }}
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - save_cache:
- paths:
- - ~/.cache/uv
- key: v1-uv-cache-{{ checksum "uv.lock" }}
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- # Add --timeout to kill hanging tests after 120s (2 min)
- # Add --durations=20 to show 20 slowest tests for debugging
- # Subdirectories with dedicated jobs (maintain this list as new jobs are added)
- mkdir -p test-results
- # Glob excludes the realtime/ subdirectory since it has its own job
- TEST_FILES=$(circleci tests glob "tests/llm_translation/**/test_*.py" | grep -v "^tests/llm_translation/realtime/")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -v \
- --junitxml=test-results/junit.xml \
- --durations=20 \
- -n 4 \
- --timeout=120 --timeout_method=thread \
- --retries 2 --retry-delay 5 \
- --max-worker-restart=5"
- no_output_timeout: 15m
-
- # Store test results
- - store_test_results:
- path: test-results
- realtime_translation_testing:
- docker:
- - *python312_image
- working_directory: ~/project
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- # Run pytest and generate JUnit XML report
- - run:
- name: Run realtime tests
- command: |
- # Add --timeout to kill hanging tests after 120s (2 min)
- # Add --durations=20 to show 20 slowest tests for debugging
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/llm_translation/realtime/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=20 \
- -n 4 \
- --timeout=120 --timeout_method=thread"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- mv coverage.xml realtime_translation_coverage.xml
- mv .coverage realtime_translation_coverage
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - realtime_translation_coverage.xml
- - realtime_translation_coverage
- guardrails_testing:
- docker:
- - *python312_image
- working_directory: ~/project
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- export LITELLM_LOG=WARNING
- TEST_FILES=$(circleci tests glob "tests/guardrails_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- -n 2 \
- --timeout=120 --timeout_method=thread"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- mv coverage.xml guardrails_coverage.xml
- mv .coverage guardrails_coverage
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - guardrails_coverage.xml
- - guardrails_coverage
-
- google_generate_content_endpoint_testing:
- docker:
- - *python312_image
- working_directory: ~/project
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/unified_google_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv -s \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- --retries 3 --retry-delay 5"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- mv coverage.xml google_generate_content_endpoint_coverage.xml
- mv .coverage google_generate_content_endpoint_coverage
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - google_generate_content_endpoint_coverage.xml
- - google_generate_content_endpoint_coverage
-
- llm_responses_api_testing:
- docker:
- - *python312_image
- working_directory: ~/project
- resource_class: large
- environment:
- REQUEST_TIMEOUT: "180"
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - restore_cache:
- keys:
- - v1-uv-cache-{{ checksum "uv.lock" }}
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - save_cache:
- paths:
- - ~/.cache/uv
- key: v1-uv-cache-{{ checksum "uv.lock" }}
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/llm_responses_api_testing/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -v \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- -n 8 \
- --reruns 1 --only-rerun Timeout"
- no_output_timeout: 15m
-
- # Store test results
- - store_test_results:
- path: test-results
- ocr_testing:
- docker:
- - *python312_image
- working_directory: ~/project
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/ocr_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- -n 4"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- mv coverage.xml ocr_coverage.xml
- mv .coverage ocr_coverage
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - ocr_coverage.xml
- - ocr_coverage
- search_testing:
- docker:
- - *python312_image
- working_directory: ~/project
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/search_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- -n 4"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- mv coverage.xml search_coverage.xml
- mv .coverage search_coverage
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - search_coverage.xml
- - search_coverage
- batches_testing:
- docker:
- - *python312_image
- working_directory: ~/project
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/batches_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv -s \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- -n 2"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- mv coverage.xml batches_coverage.xml
- mv .coverage batches_coverage
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - batches_coverage.xml
- - batches_coverage
- litellm_utils_testing:
- docker:
- - *python312_image
- working_directory: ~/project
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/litellm_utils_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv -s \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- -n 2"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- mv coverage.xml litellm_utils_coverage.xml
- mv .coverage litellm_utils_coverage
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - litellm_utils_coverage.xml
- - litellm_utils_coverage
-
- pass_through_unit_testing:
- docker:
- - *python312_image
- working_directory: ~/project
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/pass_through_unit_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- -n 4"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- mv coverage.xml pass_through_unit_tests_coverage.xml
- mv .coverage pass_through_unit_tests_coverage
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - pass_through_unit_tests_coverage.xml
- - pass_through_unit_tests_coverage
- image_gen_testing:
- docker:
- - *python312_image
- working_directory: ~/project
- resource_class: large
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/image_gen_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -v \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- -n 4"
- no_output_timeout: 15m
- # Store test results
- - store_test_results:
- path: test-results
- logging_testing:
- docker:
- - *python312_image
- working_directory: ~/project
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- # Run pytest and generate JUnit XML report
- - setup_litellm_enterprise_pip
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- export LITELLM_LOG=WARNING
- TEST_FILES=$(circleci tests glob "tests/logging_callback_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
- -n 4 \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- --timeout=120 --timeout_method=thread"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- mv coverage.xml logging_coverage.xml || true
- mv .coverage logging_coverage || true
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - logging_coverage.xml
- - logging_coverage
- audio_testing:
- docker:
- - *python312_image
- working_directory: ~/project
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/audio_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv -s \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=5"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- mv coverage.xml audio_coverage.xml
- mv .coverage audio_coverage
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - audio_coverage.xml
- - audio_coverage
- installing_litellm_on_python:
- docker:
- - *python312_image
- - *postgres16_sidecar
- working_directory: ~/project
- environment: *sidecar_database_url
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - setup_litellm_enterprise_pip
- - wait_for_service:
- url: tcp://localhost:5432
- timeout: "60"
- - run:
- name: Run tests
- command: |
- uv run --no-sync python -m pytest --tb=short -vv tests/local_testing/test_basic_python_version.py -k "not legacy_resolver"
-
- installing_litellm_on_python_3_13:
- docker:
- - image: cimg/python:3.13.1@sha256:87b243ae80d154db75ce5e58af16c72c5dd4b1e23e5c7264a816e85e0c440c13
- auth:
- username: ${DOCKERHUB_USERNAME}
- password: ${DOCKERHUB_PASSWORD}
- - *postgres16_sidecar
- working_directory: ~/project
- environment: *sidecar_database_url
- resource_class: medium
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.13
- - wait_for_service:
- url: tcp://localhost:5432
- timeout: "60"
- - run:
- name: Run tests
- command: |
- uv run --no-sync python -m pytest --tb=short -v tests/local_testing/test_basic_python_version.py -k "not legacy_resolver"
-
- installing_litellm_on_python_v2_migration_resolver:
- docker:
- - *python312_image
- - *postgres16_sidecar
- working_directory: ~/project
- environment: *sidecar_database_url
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - setup_litellm_enterprise_pip
- - wait_for_service:
- url: tcp://localhost:5432
- timeout: "60"
- - run:
- name: Run both migration resolvers against Postgres
- command: |
- uv run --no-sync python -m pytest --tb=short -vv \
- tests/local_testing/test_basic_python_version.py::test_litellm_proxy_server_config_no_general_settings \
- tests/local_testing/test_basic_python_version.py::test_litellm_proxy_server_config_no_general_settings_legacy_resolver
-
helm_chart_testing:
machine:
image: ubuntu-2204:2024.04.1 # Use machine executor instead of docker
@@ -1625,11 +534,12 @@ jobs:
--set image.repository=litellm-ci \
--set image.tag=${IMAGE_TAG} \
--set image.pullPolicy=Never
- # Wait for pod to be ready
- echo "Waiting 30 seconds for pod to be ready..."
- sleep 30
+ if ! kubectl rollout status deployment/litellm --timeout=180s; then
+ kubectl describe pods -l app.kubernetes.io/name=litellm
+ kubectl logs -l app.kubernetes.io/name=litellm --tail=200
+ exit 1
+ fi
- # Print pod logs before running tests
echo "Printing pod logs..."
kubectl logs $(kubectl get pods -l app.kubernetes.io/name=litellm -o jsonpath="{.items[0].metadata.name}")
@@ -1732,7 +642,7 @@ jobs:
name: Run Basic Proxy Startup Tests (Health Readiness and Chat Completion)
command: |
mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/basic_proxy_startup_tests/**/test_*.py")
+ TEST_FILES=$(circleci tests glob "tests/integration/configuration/test_basic_proxy_startup.py")
echo "$TEST_FILES" | circleci tests run \
--verbose \
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
@@ -1743,199 +653,6 @@ jobs:
- store_test_results:
path: test-results
- build_and_test:
- machine:
- image: ubuntu-2204:2024.04.1
- resource_class: large
- working_directory: ~/project
- steps:
- - checkout
- - run:
- name: Generate LiteLLM master key
- command: |
- key="$(openssl rand -hex 16)"
- printf 'export LITELLM_MASTER_KEY=sk-%s\n' "$key" >> "$BASH_ENV"
- - skip_if_unrelated_changes
- - attach_workspace:
- at: ~/project
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - start_postgres
- - run:
- name: Load Docker Database Image
- command: |
- zstd -d litellm-docker-database.tar.zst --stdout | docker load
- docker tag litellm-docker-database:ci my-app:latest
- - start_openai_record_replay_proxy
- - start_fake_openai_endpoint
- - run:
- name: Run Docker container
- command: |
- docker run -d \
- -p 4000:4000 \
- -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
- -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
- -e USE_PRISMA_MIGRATE=True \
- -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
- -e AZURE_API_KEY=$AZURE_API_KEY \
- -e REDIS_HOST=$REDIS_HOST \
- -e REDIS_PASSWORD=$REDIS_PASSWORD \
- -e REDIS_PORT=$REDIS_PORT \
- -e AZURE_FRANCE_API_KEY=$AZURE_FRANCE_API_KEY \
- -e AZURE_EUROPE_API_KEY=$AZURE_EUROPE_API_KEY \
- -e MISTRAL_API_KEY=$MISTRAL_API_KEY \
- -e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \
- -e GROQ_API_KEY=$GROQ_API_KEY \
- -e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \
- -e COHERE_API_KEY=$COHERE_API_KEY \
- -e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY \
- -e AWS_REGION_NAME=$AWS_REGION_NAME \
- -e AUTO_INFER_REGION=True \
- -e OPENAI_API_KEY=$OPENAI_API_KEY \
- -e USE_DDTRACE=True \
- -e DD_API_KEY=$DD_API_KEY \
- -e DD_SITE=$DD_SITE \
- -e LITELLM_LICENSE=$LITELLM_LICENSE \
- -e LANGFUSE_PROJECT1_PUBLIC=$LANGFUSE_PROJECT1_PUBLIC \
- -e LANGFUSE_PROJECT2_PUBLIC=$LANGFUSE_PROJECT2_PUBLIC \
- -e LANGFUSE_PROJECT1_SECRET=$LANGFUSE_PROJECT1_SECRET \
- -e LANGFUSE_PROJECT2_SECRET=$LANGFUSE_PROJECT2_SECRET \
- -e RECORDER_OPENAI_BASE_URL=http://host.docker.internal:8090/v1 \
- -e LITELLM_LOG=ERROR \
- --add-host host.docker.internal:host-gateway \
- --name my-app \
- -v $(pwd)/proxy_server_config.yaml:/app/config.yaml \
- my-app:latest \
- --config /app/config.yaml \
- --port 4000
- - run:
- name: Start outputting logs
- command: docker logs -f my-app
- background: true
- - wait_for_service:
- url: http://localhost:4000
- timeout: "300"
- - run:
- name: Seed the routing strategy through /config/update
- command: |
- curl --noproxy '*' -sSf -X POST http://localhost:4000/config/update \
- -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' \
- -d '{"router_settings": {"routing_strategy": "usage-based-routing-v2"}}'
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- # Original used `tests/*.py` (top-level only); the `--ignore=...`
- # flags were vestigial since shell globbing did not descend into
- # subdirectories. Replicate by globbing only top-level test files.
- TEST_FILES=$(circleci tests glob "tests/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -s -v \
- --junitxml=test-results/junit.xml \
- -n 4 \
- --durations=5"
- no_output_timeout: 15m
-
- # Store test results
- - store_test_results:
- path: test-results
- e2e_openai_endpoints:
- machine:
- image: ubuntu-2204:2024.04.1
- resource_class: large
- working_directory: ~/project
- steps:
- - checkout
- - run:
- name: Generate LiteLLM master key
- command: |
- key="$(openssl rand -hex 16)"
- printf 'export LITELLM_MASTER_KEY=sk-%s\n' "$key" >> "$BASH_ENV"
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - start_postgres
- - attach_workspace:
- at: ~/project
- - run:
- name: Load Docker Database Image
- command: |
- zstd -d litellm-docker-database.tar.zst --stdout | docker load
- docker images | grep litellm-docker-database
- - run:
- name: Run Docker container
- command: |
- docker run -d \
- -p 4000:4000 \
- -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
- -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
- -e AZURE_API_KEY=$AZURE_API_KEY \
- -e AZURE_API_BASE=$AZURE_API_BASE \
- -e AZURE_API_VERSION="2024-05-01-preview" \
- -e REDIS_HOST=$REDIS_HOST \
- -e REDIS_PASSWORD=$REDIS_PASSWORD \
- -e REDIS_PORT=$REDIS_PORT \
- -e AZURE_FRANCE_API_KEY=$AZURE_FRANCE_API_KEY \
- -e AZURE_EUROPE_API_KEY=$AZURE_EUROPE_API_KEY \
- -e MISTRAL_API_KEY=$MISTRAL_API_KEY \
- -e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \
- -e GROQ_API_KEY=$GROQ_API_KEY \
- -e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \
- -e COHERE_API_KEY=$COHERE_API_KEY \
- -e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY \
- -e AWS_REGION_NAME=$AWS_REGION_NAME \
- -e AUTO_INFER_REGION=True \
- -e USE_DDTRACE=True \
- -e DD_API_KEY=$DD_API_KEY \
- -e DD_SITE=$DD_SITE \
- -e OPENAI_API_KEY=$OPENAI_API_KEY \
- -e LITELLM_LICENSE=$LITELLM_LICENSE \
- -e LANGFUSE_PROJECT1_PUBLIC=$LANGFUSE_PROJECT1_PUBLIC \
- -e LANGFUSE_PROJECT2_PUBLIC=$LANGFUSE_PROJECT2_PUBLIC \
- -e LANGFUSE_PROJECT1_SECRET=$LANGFUSE_PROJECT1_SECRET \
- -e LANGFUSE_PROJECT2_SECRET=$LANGFUSE_PROJECT2_SECRET \
- -e LITELLM_LOG=ERROR \
- --add-host host.docker.internal:host-gateway \
- --name my-app \
- -v $(pwd)/litellm/proxy/example_config_yaml/oai_misc_config.yaml:/app/config.yaml \
- litellm-docker-database:ci \
- --config /app/config.yaml \
- --port 4000
- - run:
- name: Start outputting logs
- command: docker logs -f my-app
- background: true
- - wait_for_service:
- url: http://localhost:4000
- timeout: "300"
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/openai_endpoints_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -s -vv \
- --junitxml=test-results/junit.xml \
- --durations=5"
- no_output_timeout: 15m
-
- # Store test results
- - store_test_results:
- path: test-results
proxy_logging_guardrails_model_info_tests:
machine:
image: ubuntu-2204:2024.04.1
@@ -1966,66 +683,6 @@ jobs:
docker images | grep litellm-docker-database
- start_openai_record_replay_proxy
- start_fake_openai_endpoint
- - run:
- name: Run Docker container
- # intentionally give bad redis credentials here
- # the OTEL test - should get this as a trace
- command: |
- docker run -d \
- -p 4000:4000 \
- -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
- -e REDIS_HOST=$REDIS_HOST \
- -e REDIS_PASSWORD=$REDIS_PASSWORD \
- -e REDIS_PORT=$REDIS_PORT \
- -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
- -e OPENAI_API_KEY=$OPENAI_API_KEY \
- -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
- -e LITELLM_LICENSE=$LITELLM_LICENSE \
- -e OTEL_EXPORTER="in_memory" \
- -e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \
- -e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY \
- -e DEFAULT_NUM_WORKERS_LITELLM_PROXY=1 \
- -e USE_DDTRACE=True \
- -e DD_API_KEY=$DD_API_KEY \
- -e DD_SITE=$DD_SITE \
- -e AWS_REGION_NAME=$AWS_REGION_NAME \
- -e COHERE_API_KEY=$COHERE_API_KEY \
- -e RECORDER_COHERE_BASE_URL=http://host.docker.internal:8090/__recorder_upstream/api.cohere.com \
- -e GCS_FLUSH_INTERVAL="1" \
- -e LITELLM_LOG=ERROR \
- --add-host host.docker.internal:host-gateway \
- --name my-app \
- -v $(pwd)/litellm/proxy/example_config_yaml/otel_test_config.yaml:/app/config.yaml \
- -v $(pwd)/litellm/proxy/example_config_yaml/custom_guardrail.py:/app/custom_guardrail.py \
- litellm-docker-database:ci \
- --config /app/config.yaml \
- --port 4000
- - run:
- name: Start outputting logs
- command: docker logs -f my-app
- background: true
- - wait_for_service:
- url: http://localhost:4000
- timeout: "300"
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/otel_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -v \
- --junitxml=test-results/junit.xml \
- --durations=5"
- no_output_timeout: 15m
- # Clean up first container
- - run:
- name: Stop and remove first container
- command: |
- docker stop my-app
- docker rm my-app
-
# Second Docker Container Run with Different Config
# NOTE: We intentionally pass a "bad" license here. We need to ensure proxy starts and serves request even with bad license
- run:
@@ -2062,7 +719,7 @@ jobs:
name: Run second round of tests
command: |
mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/basic_proxy_startup_tests/**/test_*.py")
+ TEST_FILES=$(circleci tests glob "tests/integration/configuration/test_basic_proxy_startup.py")
echo "$TEST_FILES" | circleci tests run \
--verbose \
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
@@ -2074,566 +731,46 @@ jobs:
# Store test results
- store_test_results:
path: test-results
- proxy_spend_accuracy_tests:
- machine:
- image: ubuntu-2204:2024.04.1
- resource_class: large
- working_directory: ~/project
- steps:
- - checkout
- - run:
- name: Generate LiteLLM master key
- command: |
- key="$(openssl rand -hex 16)"
- printf 'export LITELLM_MASTER_KEY=sk-%s\n' "$key" >> "$BASH_ENV"
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - start_postgres
- - start_redis
- - start_fake_openai_endpoint
- - attach_workspace:
- at: ~/project
- - run:
- name: Load Docker Database Image
- command: |
- zstd -d litellm-docker-database.tar.zst --stdout | docker load
- docker images | grep litellm-docker-database
- - run:
- name: Run Docker container
- # Point the proxy at the job-local Redis (start_redis) instead of the
- # shared remote Redis. The Redis transaction buffer uses a single
- # global pod-lock key (cronjob_lock:db_spend_update_job) and a single
- # global buffer list (litellm_spend_update_buffer); sharing those
- # across concurrent CI pipelines causes spend flushes to stall or
- # land in the wrong DB, which is what makes this test flaky.
- command: |
- docker run -d \
- -p 4000:4000 \
- -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
- -e REDIS_HOST=host.docker.internal \
- -e REDIS_PORT=6379 \
- -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
- -e OPENAI_API_KEY=$OPENAI_API_KEY \
- -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
- -e LITELLM_LICENSE=$LITELLM_LICENSE \
- -e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \
- -e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY \
- -e USE_DDTRACE=True \
- -e DD_API_KEY=$DD_API_KEY \
- -e DD_SITE=$DD_SITE \
- -e AWS_REGION_NAME=$AWS_REGION_NAME \
- -e PROXY_BATCH_WRITE_AT=2 \
- -e LITELLM_LOG=ERROR \
- --add-host host.docker.internal:host-gateway \
- --name my-app \
- -v $(pwd)/litellm/proxy/example_config_yaml/spend_tracking_config.yaml:/app/config.yaml \
- litellm-docker-database:ci \
- --config /app/config.yaml \
- --port 4000
- - run:
- name: Start outputting logs
- command: docker logs -f my-app
- background: true
- - wait_for_service:
- url: http://localhost:4000
- timeout: "300"
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/spend_tracking_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv \
- --junitxml=test-results/junit.xml \
- --durations=5"
- no_output_timeout: 15m
- - store_test_results:
- path: test-results
- - run:
- name: Stop and remove first container
- when: always
- command: |
- docker stop my-app
- docker rm my-app
- docker stop redis-cache
- docker rm redis-cache
-
- proxy_multi_instance_tests:
- machine:
- image: ubuntu-2204:2024.04.1
- resource_class: large
- working_directory: ~/project
- steps:
- - checkout
- - run:
- name: Generate LiteLLM master key
- command: |
- key="$(openssl rand -hex 16)"
- printf 'export LITELLM_MASTER_KEY=sk-%s\n' "$key" >> "$BASH_ENV"
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - start_postgres
- - start_fake_openai_endpoint
- - attach_workspace:
- at: ~/project
- - run:
- name: Load Docker Database Image
- command: |
- zstd -d litellm-docker-database.tar.zst --stdout | docker load
- docker images | grep litellm-docker-database
- - run:
- name: Run Docker container 1
- # intentionally give bad redis credentials here
- # the OTEL test - should get this as a trace
- command: |
- docker run -d \
- -p 4000:4000 \
- -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
- -e REDIS_HOST=$REDIS_HOST \
- -e REDIS_PASSWORD=$REDIS_PASSWORD \
- -e REDIS_PORT=$REDIS_PORT \
- -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
- -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
- -e LITELLM_LICENSE=$LITELLM_LICENSE \
- -e USE_DDTRACE=True \
- -e DD_API_KEY=$DD_API_KEY \
- -e DD_SITE=$DD_SITE \
- -e LITELLM_LOG=ERROR \
- --add-host host.docker.internal:host-gateway \
- --name my-app \
- -v $(pwd)/litellm/proxy/example_config_yaml/multi_instance_simple_config.yaml:/app/config.yaml \
- litellm-docker-database:ci \
- --config /app/config.yaml \
- --port 4000
- - run:
- name: Run Docker container 2
- command: |
- docker run -d \
- -p 4001:4001 \
- -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
- -e REDIS_HOST=$REDIS_HOST \
- -e REDIS_PASSWORD=$REDIS_PASSWORD \
- -e REDIS_PORT=$REDIS_PORT \
- -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
- -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
- -e LITELLM_LICENSE=$LITELLM_LICENSE \
- -e USE_DDTRACE=True \
- -e DD_API_KEY=$DD_API_KEY \
- -e DD_SITE=$DD_SITE \
- -e LITELLM_LOG=ERROR \
- --add-host host.docker.internal:host-gateway \
- --name my-app-2 \
- -v $(pwd)/litellm/proxy/example_config_yaml/multi_instance_simple_config.yaml:/app/config.yaml \
- litellm-docker-database:ci \
- --config /app/config.yaml \
- --port 4001
- - run:
- name: Start outputting logs
- command: docker logs -f my-app
- background: true
- - wait_for_service:
- url: http://localhost:4000
- timeout: "300"
- - wait_for_service:
- url: http://localhost:4001
- timeout: "300"
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/multi_instance_e2e_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv \
- --junitxml=test-results/junit.xml \
- --durations=5"
- no_output_timeout: 15m
- # Clean up first container
- # Store test results
- - store_test_results:
- path: test-results
-
- proxy_store_model_in_db_tests:
- machine:
- image: ubuntu-2204:2024.04.1
- resource_class: large
- working_directory: ~/project
- steps:
- - checkout
- - run:
- name: Generate LiteLLM master key
- command: |
- key="$(openssl rand -hex 16)"
- printf 'export LITELLM_MASTER_KEY=sk-%s\n' "$key" >> "$BASH_ENV"
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - start_postgres
- - start_fake_openai_endpoint
- - start_cost_center_service
- - attach_workspace:
- at: ~/project
- - run:
- name: Load Docker Database Image
- command: |
- zstd -d litellm-docker-database.tar.zst --stdout | docker load
- docker images | grep litellm-docker-database
- - run:
- name: Run Docker container
- # intentionally give bad redis credentials here
- # the OTEL test - should get this as a trace
- command: |
- docker run -d \
- --restart on-failure \
- -p 4000:4000 \
- -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
- -e STORE_MODEL_IN_DB="True" \
- -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
- -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
- -e TEAM_METADATA_VALIDATION_SERVICE_URL=http://host.docker.internal:9414/validate \
- -e LITELLM_LICENSE=$LITELLM_LICENSE \
- -e LITELLM_LOG=ERROR \
- --add-host host.docker.internal:host-gateway \
- --name my-app \
- -v $(pwd)/litellm/proxy/example_config_yaml/store_model_db_config.yaml:/app/config.yaml \
- -v $(pwd)/litellm/proxy/example_config_yaml/team_metadata_validator_e2e.py:/app/team_metadata_validator_e2e.py \
- litellm-docker-database:ci \
- --config /app/config.yaml \
- --port 4000
- - run:
- name: Start outputting logs
- command: docker logs -f my-app
- background: true
- - wait_for_service:
- url: http://localhost:4000
- timeout: "300"
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/store_model_in_db_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv \
- --junitxml=test-results/junit.xml \
- --durations=5"
- no_output_timeout: 15m
- - run:
- name: Stop and remove containers
- command: |
- docker stop my-app || true
- docker rm my-app || true
- docker stop postgres-db || true
- docker rm postgres-db || true
- when: always
- - store_test_results:
- path: test-results
-
- proxy_build_from_pip_tests:
- # Change from docker to machine executor
- machine:
- image: ubuntu-2204:2024.04.1
- resource_class: large
- working_directory: ~/project
- steps:
- - checkout
- - run:
- name: Generate LiteLLM master key
- command: |
- key="$(openssl rand -hex 16)"
- printf 'export LITELLM_MASTER_KEY=sk-%s\n' "$key" >> "$BASH_ENV"
- - skip_if_unrelated_changes
- - setup_google_dns
- # Remove Docker CLI installation since it's already available in machine executor
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - run:
- name: Build Docker image
- command: |
- docker build -t my-app:latest -f docker/build_from_pip/Dockerfile.build_from_pip .
- - start_postgres
- - start_fake_openai_endpoint
- - run:
- name: Run Docker container
- # intentionally give bad redis credentials here
- # the OTEL test - should get this as a trace
- command: |
- docker run -d \
- -p 4000:4000 \
- -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
- -e REDIS_HOST=$REDIS_HOST \
- -e REDIS_PASSWORD=$REDIS_PASSWORD \
- -e REDIS_PORT=$REDIS_PORT \
- -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
- -e OPENAI_API_KEY=$OPENAI_API_KEY \
- -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
- -e LITELLM_LICENSE=$LITELLM_LICENSE \
- -e OTEL_EXPORTER="in_memory" \
- -e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \
- -e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY \
- -e AWS_REGION_NAME=$AWS_REGION_NAME \
- -e COHERE_API_KEY=$COHERE_API_KEY \
- -e USE_DDTRACE=True \
- -e DD_API_KEY=$DD_API_KEY \
- -e DD_SITE=$DD_SITE \
- -e GCS_FLUSH_INTERVAL="1" \
- -e LITELLM_LOG=ERROR \
- --add-host host.docker.internal:host-gateway \
- --name my-app \
- -v $(pwd)/docker/build_from_pip/litellm_config.yaml:/app/config.yaml \
- my-app:latest \
- --config /app/config.yaml \
- --port 4000
- - run:
- name: Start outputting logs
- command: docker logs -f my-app
- background: true
- - wait_for_service:
- url: http://localhost:4000
- timeout: "300"
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/basic_proxy_startup_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv \
- --junitxml=test-results/junit-2.xml \
- --durations=5"
- no_output_timeout: 15m
- # Clean up first container
- - store_test_results:
- path: test-results
- - run:
- name: Stop and remove first container
- command: |
- docker stop my-app || true
- docker rm my-app || true
- docker stop postgres-db || true
- docker rm postgres-db || true
- when: always
- proxy_pass_through_endpoint_tests:
- machine:
- image: ubuntu-2204:2024.04.1
- resource_class: large
- working_directory: ~/project
- steps:
- - checkout
- - run:
- name: Generate LiteLLM master key
- command: |
- key="$(openssl rand -hex 16)"
- printf 'export LITELLM_MASTER_KEY=sk-%s\n' "$key" >> "$BASH_ENV"
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - start_postgres
- - attach_workspace:
- at: ~/project
- - run:
- name: Load Docker Database Image
- command: |
- zstd -d litellm-docker-database.tar.zst --stdout | docker load
- docker images | grep litellm-docker-database
- - run:
- name: Run Docker container
- command: |
- docker run -d \
- -p 4000:4000 \
- -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
- -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
- -e OPENAI_API_KEY=$OPENAI_API_KEY \
- -e GEMINI_API_KEY=$GEMINI_API_KEY \
- -e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \
- -e ASSEMBLYAI_API_KEY=$ASSEMBLYAI_API_KEY \
- -e AZURE_API_KEY=$AZURE_API_KEY \
- -e AZURE_API_BASE=$AZURE_API_BASE \
- -e USE_DDTRACE=True \
- -e DD_API_KEY=$DD_API_KEY \
- -e DD_SITE=$DD_SITE \
- -e LITELLM_LICENSE=$LITELLM_LICENSE \
- -e LITELLM_USE_CHAT_COMPLETIONS_URL_FOR_ANTHROPIC_MESSAGES=true \
- -e LITELLM_LOG=ERROR \
- --add-host host.docker.internal:host-gateway \
- --name my-app \
- -v $(pwd)/litellm/proxy/example_config_yaml/pass_through_config.yaml:/app/config.yaml \
- -v $(pwd)/litellm/proxy/example_config_yaml/custom_auth_basic.py:/app/custom_auth_basic.py \
- litellm-docker-database:ci \
- --config /app/config.yaml \
- --port 4000
- - run:
- name: Start outputting logs
- command: docker logs -f my-app
- background: true
- - wait_for_service:
- url: http://localhost:4000
- timeout: "300"
- # Install Node.js directly from nodejs.org with SHA256 verification,
- # instead of piping NodeSource's setup_24.x apt-repo installer into
- # sudo bash (which runs a mutable upstream script unattended).
- - install_node
-
- - run:
- name: Install Node.js test dependencies
- command: |
- cd tests/pass_through_tests
- npm ci
-
- - run:
- name: Run Vertex AI, Google AI Studio Node.js tests
- command: |
- cd tests/pass_through_tests
- NODE_OPTIONS=--experimental-vm-modules npx jest . --verbose
- no_output_timeout: 30m
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/pass_through_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -v \
- --junitxml=test-results/junit.xml \
- --durations=5"
- no_output_timeout: 15m
-
- # Store test results
- - store_test_results:
- path: test-results
-
- proxy_e2e_anthropic_messages_tests:
- machine:
- image: ubuntu-2204:2024.04.1
- resource_class: large
- working_directory: ~/project
- steps:
- - checkout
- - run:
- name: Generate LiteLLM master key
- command: |
- key="$(openssl rand -hex 16)"
- printf 'export LITELLM_MASTER_KEY=sk-%s\n' "$key" >> "$BASH_ENV"
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - start_postgres
- - attach_workspace:
- at: ~/project
- - run:
- name: Load Docker Database Image
- command: |
- zstd -d litellm-docker-database.tar.zst --stdout | docker load
- docker images | grep litellm-docker-database
- - start_openai_record_replay_proxy
- - run:
- name: Run Docker container with test config
- command: |
- docker run -d \
- -p 4000:4000 \
- -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
- -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
- -e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \
- -e RECORDER_ANTHROPIC_BASE_URL=http://host.docker.internal:8090/__recorder_upstream/api.anthropic.com \
- -e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \
- -e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY \
- -e AWS_REGION_NAME="us-east-1" \
- -e LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS="True" \
- -e LITELLM_LOG=ERROR \
- --add-host host.docker.internal:host-gateway \
- --name my-app \
- -v $(pwd)/tests/proxy_e2e_anthropic_messages_tests/test_config.yaml:/app/config.yaml \
- litellm-docker-database:ci \
- --config /app/config.yaml \
- --port 4000
- - run:
- name: Start outputting logs
- command: docker logs -f my-app
- background: true
- - wait_for_service:
- url: http://localhost:4000
- timeout: "300"
- - run:
- name: Run Claude Agent SDK E2E Tests
- command: |
- mkdir -p test-results
- export LITELLM_PROXY_URL="http://localhost:4000"
- export LITELLM_API_KEY="$LITELLM_MASTER_KEY"
- TEST_FILES=$(circleci tests glob "tests/proxy_e2e_anthropic_messages_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv -s \
- --junitxml=test-results/junit.xml \
- --durations=5"
- no_output_timeout: 15m
-
- # Store test results
- - store_test_results:
- path: test-results
-
- upload-coverage:
+ e2e_ui_build:
docker:
- - *python312_image
+ - image: cimg/python:3.12-browsers@sha256:b432899af01c9a311bf74f4f22e9ada2e5306d4b1b4383f8d29e1228a5844ef2
+ auth:
+ username: ${DOCKERHUB_USERNAME}
+ password: ${DOCKERHUB_PASSWORD}
+ resource_class: large
+ working_directory: ~/project
steps:
- checkout
- - skip_if_unrelated_changes
- - attach_workspace:
- at: .
- # Check file locations
+ - skip_if_unrelated_changes:
+ category: client
+ - setup_google_dns
+ - install_node
+ - restore_cache:
+ keys:
+ - ui-dashboard-node-deps-v1-{{ checksum "ui/litellm-dashboard/package-lock.json" }}
- run:
- name: Check coverage file location
+ name: Install dashboard dependencies
command: |
- echo "Current directory:"
- ls -la
- echo "\nContents of tests/llm_translation:"
- ls -la tests/llm_translation
- - install_uv
+ cd ui/litellm-dashboard
+ npm ci
+ - save_cache:
+ key: ui-dashboard-node-deps-v1-{{ checksum "ui/litellm-dashboard/package-lock.json" }}
+ paths:
+ - ui/litellm-dashboard/node_modules
- run:
- name: Combine Coverage
+ name: Build UI bundle
command: |
- uv tool run --from 'coverage[toml]==7.10.6' coverage combine realtime_translation_coverage ocr_coverage search_coverage logging_coverage audio_coverage local_testing_part1_coverage local_testing_part2_coverage pass_through_unit_tests_coverage batches_coverage guardrails_coverage google_generate_content_endpoint_coverage litellm_utils_coverage router_unit_tests_coverage auth_ui_unit_tests_coverage
- uv tool run --from 'coverage[toml]==7.10.6' coverage xml
- - codecov/upload:
- file: ./coverage.xml
- flags: circleci
+ cd ui/litellm-dashboard
+ npm run build
+ mkdir -p ../../ui-bundle
+ mv out ../../ui-bundle/out
+ find ../../ui-bundle/out -name '*.html' ! -name 'index.html' | while read -r f; do
+ d="${f%.html}"; mkdir -p "$d"; mv "$f" "$d/index.html"
+ done
+ - persist_to_workspace:
+ root: ~/project
+ paths:
+ - ui-bundle/out
e2e_ui_testing:
docker:
@@ -2646,7 +783,7 @@ jobs:
POSTGRES_USER: e2euser
POSTGRES_PASSWORD: e2epassword
POSTGRES_DB: litellm_e2e
- resource_class: large
+ resource_class: xlarge
working_directory: ~/project
environment:
DATABASE_URL: "postgresql://e2euser:e2epassword@localhost:5432/litellm_e2e"
@@ -2665,6 +802,8 @@ jobs:
printf 'export LITELLM_MASTER_KEY=sk-%s\n' "$key" >> "$BASH_ENV"
- skip_if_unrelated_changes:
category: client
+ - attach_workspace:
+ at: ~/project
- setup_google_dns
- install_node
- install_uv
@@ -2683,7 +822,7 @@ jobs:
- ~/.cache/uv
- restore_cache:
keys:
- - ui-e2e-node-deps-v4-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
+ - ui-playwright-node-deps-v1-{{ checksum "tests/e2e/ui/package-lock.json" }}
- run:
name: Install Node dependencies and Playwright
# The cimg/python:3.12-browsers image already ships the Chromium system
@@ -2692,36 +831,19 @@ jobs:
# 5-10 minutes to the job and frequently stalls on flaky Ubuntu mirrors,
# so we install just the browser binary.
command: |
- cd ui/litellm-dashboard
- npm ci
- cd ../../tests/e2e/ui
+ cd tests/e2e/ui
npm ci
npx playwright install chromium
- save_cache:
- key: ui-e2e-node-deps-v4-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
+ key: ui-playwright-node-deps-v1-{{ checksum "tests/e2e/ui/package-lock.json" }}
paths:
- - ui/litellm-dashboard/node_modules
- tests/e2e/ui/node_modules
- ~/.cache/ms-playwright
- run:
- name: Build UI from source
- # Prior version used `cp -r out/ ../../litellm/proxy/_experimental/out/`.
- # GNU cp (used on CircleCI's Ubuntu image) interprets that as "copy the
- # source directory as a child of the destination" when the destination
- # already exists — silently creating `_experimental/out/out/` instead of
- # replacing the served bundle. The proxy continued serving whatever was
- # checked into `_experimental/out/*`, so this job was effectively testing
- # the pre-build bundle on every run. Replace-and-move guarantees the
- # freshly built bundle is what the proxy actually serves.
+ name: Use built UI bundle
command: |
- cd ui/litellm-dashboard
- npm run build
- rm -rf ../../litellm/proxy/_experimental/out
- mv out ../../litellm/proxy/_experimental/out
- # Restructure HTML so extensionless routes work (login.html -> login/index.html)
- find ../../litellm/proxy/_experimental/out -name '*.html' ! -name 'index.html' | while read -r f; do
- d="${f%.html}"; mkdir -p "$d"; mv "$f" "$d/index.html"
- done
+ rm -rf litellm/proxy/_experimental/out
+ cp -r ui-bundle/out litellm/proxy/_experimental/out
- wait_for_service:
url: tcp://localhost:5432
timeout: "30"
@@ -2788,7 +910,7 @@ jobs:
command: |
cd tests/e2e/ui
LITELLM_LICENSE="$LITELLM_LICENSE" \
- npx playwright test --config playwright.config.ts
+ npx playwright test --config playwright.config.ts --workers=3 --retries=0
no_output_timeout: 10m
- store_artifacts:
path: tests/e2e/ui/test-results
@@ -2826,6 +948,8 @@ jobs:
printf 'export LITELLM_MASTER_KEY=sk-%s\n' "$key" >> "$BASH_ENV"
- skip_if_unrelated_changes:
category: client
+ - attach_workspace:
+ at: ~/project
- setup_google_dns
- install_node
- install_uv
@@ -2844,31 +968,23 @@ jobs:
- ~/.cache/uv
- restore_cache:
keys:
- - ui-e2e-node-deps-v4-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
+ - ui-playwright-node-deps-v1-{{ checksum "tests/e2e/ui/package-lock.json" }}
- run:
name: Install Node dependencies and Playwright
command: |
- cd ui/litellm-dashboard
- npm ci
- cd ../../tests/e2e/ui
+ cd tests/e2e/ui
npm ci
npx playwright install chromium
- save_cache:
- key: ui-e2e-node-deps-v4-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
+ key: ui-playwright-node-deps-v1-{{ checksum "tests/e2e/ui/package-lock.json" }}
paths:
- - ui/litellm-dashboard/node_modules
- tests/e2e/ui/node_modules
- ~/.cache/ms-playwright
- run:
- name: Build UI from source
+ name: Use built UI bundle
command: |
- cd ui/litellm-dashboard
- npm run build
- rm -rf ../../litellm/proxy/_experimental/out
- mv out ../../litellm/proxy/_experimental/out
- find ../../litellm/proxy/_experimental/out -name '*.html' ! -name 'index.html' | while read -r f; do
- d="${f%.html}"; mkdir -p "$d"; mv "$f" "$d/index.html"
- done
+ rm -rf litellm/proxy/_experimental/out
+ cp -r ui-bundle/out litellm/proxy/_experimental/out
- wait_for_service:
url: tcp://localhost:5432
timeout: "30"
@@ -3119,41 +1235,6 @@ jobs:
! docker exec my-app true 2>/dev/null || fail "container is still running"
echo "Proxy refused to serve (exit $exit_code) and never reached startup. Test passed."
- provider_replay_harness:
- docker:
- - *python312_image
- - image: redis@sha256:e2debfb7956fa12c7ddc79d7e645c8cf26b30c99a6e9161ea9bf4171e1668a5f
- working_directory: ~/project
- resource_class: medium
- environment:
- E2E_CACHE_TEST_REDIS_URL: redis://127.0.0.1:6379/0
- E2E_PROVIDER_CACHE: "0"
- E2E_FIXTURE_MODE: live
- steps:
- - checkout
- - run:
- name: Generate LiteLLM master key
- command: |
- key="$(openssl rand -hex 16)"
- printf 'export LITELLM_MASTER_KEY=sk-%s\n' "$key" >> "$BASH_ENV"
- - skip_if_unrelated_changes:
- category: provider-harness
- - setup_litellm_test_deps
- - wait_for_service:
- url: tcp://localhost:6379
- - run:
- name: Test provider capture and replay harness
- command: |
- mkdir -p test-results/provider-replay-harness
- uv run --no-sync pytest --tb=short -q --noconftest -o addopts= -o "pythonpath=tests/e2e tests/e2e_harness" -p no:rerunfailures \
- --junitxml=test-results/provider-replay-harness/junit.xml \
- tests/e2e_harness/test_provider_edge.py tests/e2e_harness/test_fixture_bundle.py \
- tests/e2e_harness/test_fixture_canonical.py tests/e2e_harness/test_fixture_mode.py \
- tests/code_coverage_tests/test_provider_replay_harness.py \
- tests/code_coverage_tests/test_provider_cache.py
- - store_test_results:
- path: test-results/provider-replay-harness
-
integration_contracts:
parameters:
suite:
@@ -3202,9 +1283,19 @@ jobs:
- run:
name: Build the candidate dashboard
command: cd ui/litellm-dashboard && NEXT_TELEMETRY_DISABLED=1 npm run build
+ - when:
+ condition:
+ equal: [mcp, << parameters.suite >>]
+ steps:
+ - run:
+ name: Install MCP SDK1 peer
+ command: |
+ uv venv --python 3.12 .venv-mcp-peer
+ uv pip install --python .venv-mcp-peer 'mcp==1.28.1' 'langchain-mcp-adapters==0.2.1'
+ echo "export MCP_TEST_PEER_PYTHON=$PWD/.venv-mcp-peer/bin/python" >> "$BASH_ENV"
- start_postgres:
image: postgres:16@sha256:e17e86066e5ef83e0952a9347f5c792b7ece00972e2aa787a6986f471b3dd3d5
- server_args: "-c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c pg_stat_statements.max=20000"
+ server_args: "-c max_connections=300 -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c pg_stat_statements.max=20000"
- start_redis
- run:
name: Run owned integration contracts
@@ -3219,11 +1310,87 @@ jobs:
docker logs redis-cache > test-results/services-<< parameters.suite >>-<< parameters.mode >>/redis.log 2>&1 || true
docker rm -f postgres-db redis-cache
test -z "$(docker ps -aq --filter name=postgres-db --filter name=redis-cache)"
+ - when:
+ condition:
+ not:
+ equal: [1, << parameters.parallelism >>]
+ steps:
+ - run:
+ name: Prepare this shard's execution record
+ command: |
+ results="test-results/integration-<< parameters.suite >>"
+ if [ "<< parameters.mode >>" = replica ]; then
+ results="$results-replica"
+ fi
+ mkdir -p "/tmp/integration-shards/<< parameters.suite >>-<< parameters.mode >>/$CIRCLE_NODE_INDEX"
+ cp "$results/execution.json" "/tmp/integration-shards/<< parameters.suite >>-<< parameters.mode >>/$CIRCLE_NODE_INDEX/execution.json"
+ if [ "<< parameters.suite >>" = mcp ]; then
+ mkdir -p "/tmp/integration-shards/mcp-standard/$CIRCLE_NODE_INDEX/coverage"
+ cp test-results/integration-mcp/coverage/data "/tmp/integration-shards/mcp-standard/$CIRCLE_NODE_INDEX/coverage/data.$CIRCLE_NODE_INDEX"
+ fi
+ - persist_to_workspace:
+ root: /tmp/integration-shards
+ paths:
+ - << parameters.suite >>-<< parameters.mode >>
- store_test_results:
path: test-results
- store_artifacts:
path: test-results
+ integration_shard_coverage:
+ parameters:
+ suite:
+ type: enum
+ enum: [management, accounting, database, extensions, providers, mcp]
+ mode:
+ type: enum
+ enum: [standard, replica]
+ default: standard
+ shards:
+ type: integer
+ docker:
+ - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c
+ auth:
+ username: ${DOCKERHUB_USERNAME}
+ password: ${DOCKERHUB_PASSWORD}
+ resource_class: small
+ working_directory: ~/project
+ environment:
+ UV_PYTHON_DOWNLOADS: never
+ steps:
+ - checkout
+ - install_uv
+ - run:
+ name: Install the locked shard verifier and coverage dependencies
+ command: |
+ uv export --frozen --all-groups --all-extras --no-emit-project --no-emit-workspace --no-header --no-annotate \
+ | awk '/^[^ ]/ { keep = $0 ~ /^(annotated-types|coverage|pydantic|pydantic-core|typing-extensions|typing-inspection)==/ } keep' \
+ > /tmp/fan-in-requirements.txt
+ test "$(grep -c '^[^ ]' /tmp/fan-in-requirements.txt)" -eq 6
+ uv venv .venv --python 3.12
+ uv pip install --no-config --python .venv --require-hashes -r /tmp/fan-in-requirements.txt
+ - attach_workspace:
+ at: /tmp/integration-shards
+ - run:
+ name: Verify every << parameters.suite >> case ran exactly once
+ command: |
+ .venv/bin/python -m tests.integration._support.sharding /tmp/integration-shards/<< parameters.suite >>-<< parameters.mode >> \
+ --shards << parameters.shards >>
+ - when:
+ condition:
+ equal: [mcp, << parameters.suite >>]
+ steps:
+ - run:
+ name: Combine coverage from every MCP shard
+ command: |
+ mkdir -p test-results/integration-mcp/coverage
+ export COVERAGE_FILE="$PWD/test-results/integration-mcp/coverage/data"
+ .venv/bin/python -m coverage combine --rcfile=tests/integration/mcp_coverage.toml /tmp/integration-shards/mcp-standard/*/coverage
+ .venv/bin/python -m coverage report --rcfile=tests/integration/mcp_coverage.toml > test-results/integration-mcp/coverage/coverage.txt
+ .venv/bin/python -m coverage html --rcfile=tests/integration/mcp_coverage.toml -d test-results/integration-mcp/coverage/html
+ - store_artifacts:
+ path: test-results/integration-mcp/coverage
+
routing_parity:
parameters:
suite:
@@ -3305,6 +1472,9 @@ jobs:
default: ""
timeout_minutes:
type: integer
+ workers:
+ type: integer
+ default: 1
machine:
image: ubuntu-2204:2024.04.1
resource_class: large
@@ -3314,7 +1484,8 @@ jobs:
steps:
- checkout
- skip_if_unrelated_changes
- - setup_litellm_test_deps
+ - setup_litellm_test_deps:
+ checkout: false
- start_postgres:
db_name: litellm_test
image: postgres:16@sha256:e17e86066e5ef83e0952a9347f5c792b7ece00972e2aa787a6986f471b3dd3d5
@@ -3332,12 +1503,16 @@ jobs:
command: |
mkdir -p test-results
coverage_args=()
+ worker_args=()
+ if [ "<< parameters.workers >>" -gt 1 ]; then
+ worker_args=(-n << parameters.workers >> --dist=worksteal)
+ fi
if [ -n "<< parameters.coverage_flag >>" ]; then
coverage_args=(--cov=./litellm --cov-report=xml:coverage.xml)
fi
timeout --signal=TERM << parameters.timeout_minutes >>m uv run --no-sync pytest \
<< parameters.test_path >> -vv --tb=short --durations=10 -o junit_family=xunit1 \
- --junitxml=test-results/junit.xml "${coverage_args[@]}"
+ --junitxml=test-results/junit.xml "${coverage_args[@]}" "${worker_args[@]}"
- when:
condition: << parameters.coverage_flag >>
steps:
@@ -3355,112 +1530,15 @@ jobs:
- store_artifacts:
path: test-results
- mcp_integration:
- machine:
- image: ubuntu-2204:2024.04.1
- resource_class: large
- working_directory: ~/project
- environment:
- COVERAGE_CORE: sysmon
- LITELLM_LOCAL_MODEL_COST_MAP: "True"
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_litellm_test_deps
- - run:
- name: Generate Prisma client
- command: uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
- - run:
- name: Install MCP SDK1 peer
- command: |
- uv venv --python 3.12 .venv-mcp-peer
- uv pip install --python .venv-mcp-peer 'mcp==1.28.1' 'langchain-mcp-adapters==0.2.1'
- echo "export MCP_TEST_PEER_PYTHON=$PWD/.venv-mcp-peer/bin/python" >> "$BASH_ENV"
- - run:
- name: Run MCP integration tests
- command: |
- mkdir -p test-results
- env -u OPENAI_API_KEY -u ANTHROPIC_API_KEY \
- timeout --signal=TERM 20m uv run --no-sync pytest \
- tests/mcp_tests tests/unit/experimental_mcp_client tests/unit/proxy/_experimental/mcp_server \
- tests/unit/responses/mcp --tb=short -vv --maxfail=10 -n 2 --dist=loadscope --reruns 0 \
- --reruns-delay 1 --timeout=120 --rerun-except "from pytest-timeout" --durations=20 \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml:coverage.xml \
- --cov-config=pyproject.toml -o junit_family=xunit1 --junitxml=test-results/junit.xml
- - install_codecov_cli
- - run:
- name: Upload MCP integration coverage
- when: always
- command: |
- [ -f coverage.xml ] || { echo "no coverage.xml produced"; exit 1; }
- codecov upload-process --disable-search --fail-on-error -f coverage.xml \
- -F mcp-integration -C "$CIRCLE_SHA1" \
- -n "mcp-integration-${CIRCLE_BUILD_NUM}" --git-service github
- - store_test_results:
- path: test-results
- - store_artifacts:
- path: test-results
-
- redis_compat:
- parameters:
- redis_py:
- type: string
- machine:
- image: ubuntu-2204:2024.04.1
- resource_class: large
- working_directory: ~/project
- steps:
- - checkout
- - skip_if_unrelated_changes:
- category: redis-compat
- - setup_litellm_test_deps
- - run:
- name: Pin redis-py version
- command: |
- uv pip install "redis==<< parameters.redis_py >>"
- uv run --no-sync python -c "import redis; assert redis.__version__ == '<< parameters.redis_py >>', redis.__version__; print('redis-py', redis.__version__)"
- - run:
- name: Install redis-server 7.2.16
- command: |
- mkdir -p "$HOME/.local/bin"
- cid="$(docker create redis:7.2.16@sha256:0637954999d01b7c9ce9167db2da50656e2590d3b884f1c600c5f63bb6e6773c)"
- docker cp "${cid}":/usr/local/bin/redis-server "$HOME/.local/bin/redis-server"
- docker rm "$cid"
- redis-server --version | grep -q 'v=7.2.16'
- - run:
- name: Run Redis compatibility tests
- command: |
- mkdir -p test-results
- env -u CASSETTE_REDIS_URL -u AZURE_CLIENT_ID -u AZURE_CLIENT_SECRET -u AZURE_TENANT_ID \
- timeout --signal=TERM 15m uv run --no-sync pytest \
- tests/unit/test_redis.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 \
- tests/local_testing/test_caching.py::test_sync_cluster_authenticates_with_azure_credentials \
- tests/local_testing/test_caching.py::test_sync_cluster_authenticates_with_gcp_credentials \
- --tb=short -vv --reruns 2 --reruns-delay 1 --durations=20 --cov=./litellm \
- --cov-report=xml:coverage.xml -o junit_family=xunit1 --junitxml=test-results/junit.xml
- - when:
- condition:
- equal: ["5.3.1", << parameters.redis_py >>]
- steps:
- - install_codecov_cli
- - run:
- name: Upload Redis compatibility coverage
- when: always
- command: |
- [ -f coverage.xml ] || { echo "no coverage.xml produced"; exit 1; }
- codecov upload-process --disable-search --fail-on-error -f coverage.xml \
- -F redis-compat -C "$CIRCLE_SHA1" \
- -n "redis-compat-${CIRCLE_BUILD_NUM}" --git-service github
- - store_test_results:
- path: test-results
- - store_artifacts:
- path: test-results
-
workflows:
- migration_startup:
- when: << pipeline.parameters.run_migration_tests >>
- jobs: &migration_jobs
+ migration_startup_scheduled:
+ triggers:
+ - schedule:
+ cron: "17 0,6,12,18 * * *"
+ filters:
+ branches:
+ only: main
+ jobs:
- build_docker_database_image:
migration_qualification: true
- migration_startup_tests:
@@ -3485,168 +1563,192 @@ workflows:
suite: shaped
baseline: true
requires: [build_docker_database_image]
- migration_startup_scheduled:
- triggers:
- - schedule:
- cron: "17 0,6,12,18 * * *"
- filters:
- branches:
- only: main
- jobs: *migration_jobs
- routing_parity:
- when:
- not:
- equal: ["", << pipeline.parameters.routing_parity_base >>]
- jobs:
- - routing_parity:
- name: routing-parity-<< matrix.suite >>
- matrix:
- parameters:
- suite: [management, accounting, database, providers, extensions, cost, mcp]
integration:
- unless:
- or:
- - << pipeline.parameters.run_migration_tests >>
- - not:
- equal: ["", << pipeline.parameters.routing_parity_base >>]
jobs:
- integration_contracts:
name: integration-<< matrix.suite >>
+ filters: &integration_default_run not pipeline.parameters.run_migration_tests and pipeline.parameters.routing_parity_base == ""
matrix:
parameters:
- suite: [management, accounting, database, providers, sdk, cost, security, browser]
+ suite: [sdk, cost, browser]
+ parallelism: 1
+ - integration_contracts:
+ name: integration-security
+ suite: security
+ parallelism: 1
+ resource_class: xlarge
+ filters: *integration_default_run
+ - integration_contracts:
+ name: integration-management
+ suite: management
+ parallelism: 4
+ resource_class: xlarge
+ filters: *integration_default_run
+ - integration_shard_coverage:
+ name: integration-management-coverage
+ suite: management
+ shards: 4
+ requires: [integration-management]
+ filters: *integration_default_run
+ - integration_contracts:
+ name: integration-accounting
+ suite: accounting
+ parallelism: 4
+ resource_class: xlarge
+ filters: *integration_default_run
+ - integration_shard_coverage:
+ name: integration-accounting-coverage
+ suite: accounting
+ shards: 4
+ requires: [integration-accounting]
+ filters: *integration_default_run
+ - integration_contracts:
+ name: integration-providers
+ suite: providers
+ parallelism: 4
+ resource_class: xlarge
+ filters: *integration_default_run
+ - integration_shard_coverage:
+ name: integration-providers-coverage
+ suite: providers
+ shards: 4
+ requires:
+ - integration-providers
+ filters: *integration_default_run
+ - integration_contracts:
+ name: integration-database
+ suite: database
+ parallelism: 2
+ filters: *integration_default_run
+ - integration_shard_coverage:
+ name: integration-database-coverage
+ suite: database
+ shards: 2
+ requires: [integration-database]
+ filters: *integration_default_run
- integration_contracts:
name: integration-mcp
suite: mcp
- resource_class: xlarge
+ parallelism: 2
+ filters: *integration_default_run
+ - integration_shard_coverage:
+ name: integration-mcp-coverage
+ suite: mcp
+ shards: 2
+ requires:
+ - integration-mcp
+ filters: *integration_default_run
- integration_contracts:
name: integration-extensions
suite: extensions
- parallelism: 4
+ parallelism: 8
+ resource_class: xlarge
+ filters: *integration_default_run
+ - integration_shard_coverage:
+ name: integration-extensions-coverage
+ suite: extensions
+ shards: 8
+ requires:
+ - integration-extensions
+ filters: *integration_default_run
- integration_contracts:
- name: integration-<< matrix.suite >>-replica
- matrix:
- parameters:
- suite: [management, database]
- mode: [replica]
- - postgres_suite:
- name: proxy-behavior
- test_path: tests/proxy_behavior
- seed: true
- coverage_flag: lens-postgres
- timeout_minutes: 25
- - postgres_suite:
- name: proxy-security
- test_path: tests/proxy_security_tests
- seed: true
- timeout_minutes: 15
- - postgres_suite:
- name: schema-migration
- test_path: tests/proxy_migration_tests
- seed: false
- timeout_minutes: 20
+ name: integration-management-replica
+ suite: management
+ mode: replica
+ parallelism: 4
+ resource_class: xlarge
+ filters: *integration_default_run
+ - integration_shard_coverage:
+ name: integration-management-replica-coverage
+ suite: management
+ mode: replica
+ shards: 4
+ requires: [integration-management-replica]
+ filters: *integration_default_run
+ - integration_contracts:
+ name: integration-database-replica
+ suite: database
+ mode: replica
+ parallelism: 2
+ filters: *integration_default_run
+ - integration_shard_coverage:
+ name: integration-database-replica-coverage
+ suite: database
+ mode: replica
+ shards: 2
+ requires: [integration-database-replica]
+ filters: *integration_default_run
- postgres_suite:
name: roi-database
test_path: tests/integration/database/test_roi_observed.py
seed: false
coverage_flag: roi-postgres
timeout_minutes: 10
- - mcp_integration:
- name: mcp-integration
- - redis_compat:
- name: redis-compat-<< matrix.redis_py >>
- matrix:
- parameters:
- redis_py:
- - "5.3.1"
- - "6.4.0"
- - "7.4.1"
- - "8.0.1"
- build_and_test:
- unless:
- or:
- - << pipeline.parameters.run_migration_tests >>
- - not:
- equal: ["", << pipeline.parameters.routing_parity_base >>]
- jobs:
- - using_litellm_on_windows
- - windows_release_wheel
- - provider_replay_harness
- - base_sdk_install
- - local_testing_part1
- - local_testing_part2
- - langfuse_logging_unit_tests
- - litellm_router_testing
- - litellm_router_unit_testing
- - auth_ui_unit_tests
- - build_docker_database_image
- - e2e_ui_testing
- - e2e_ui_testing_server_root_path
- - build_and_test:
- requires:
- - build_docker_database_image
- - e2e_openai_endpoints:
- requires:
- - build_docker_database_image
+ filters: *integration_default_run
+ - windows_release_wheel:
+ filters: *integration_default_run
+ - base_sdk_install:
+ filters: *integration_default_run
+ - build_docker_database_image:
+ filters: *integration_default_run
- proxy_logging_guardrails_model_info_tests:
requires:
- build_docker_database_image
- - proxy_spend_accuracy_tests:
- requires:
- - build_docker_database_image
- - proxy_multi_instance_tests:
- requires:
- - build_docker_database_image
- - proxy_store_model_in_db_tests:
- requires:
- - build_docker_database_image
- - proxy_build_from_pip_tests
- - proxy_pass_through_endpoint_tests:
- requires:
- - build_docker_database_image
- - proxy_e2e_anthropic_messages_tests:
- requires:
- - build_docker_database_image
- - llm_translation_testing
- - realtime_translation_testing
- - guardrails_testing
- - google_generate_content_endpoint_testing
- - llm_responses_api_testing
- - ocr_testing
- - search_testing
- - batches_testing
- - litellm_utils_testing
- - pass_through_unit_testing
- - image_gen_testing
- - logging_testing
- - audio_testing
- - upload-coverage:
- requires:
- - realtime_translation_testing
- - google_generate_content_endpoint_testing
- - guardrails_testing
- - ocr_testing
- - search_testing
- - batches_testing
- - litellm_utils_testing
- - pass_through_unit_testing
- - image_gen_testing
- - logging_testing
- - audio_testing
- - langfuse_logging_unit_tests
- - local_testing_part1
- - local_testing_part2
- - litellm_router_unit_testing
- - auth_ui_unit_tests
+ filters: *integration_default_run
- db_migration_disable_update_check:
requires:
- build_docker_database_image
- - installing_litellm_on_python
- - installing_litellm_on_python_3_13
- - installing_litellm_on_python_v2_migration_resolver
- - helm_chart_testing:
- requires:
- - build_docker_database_image
+ filters: *integration_default_run
- test_bad_database_url:
requires:
- build_docker_database_image
+ filters: *integration_default_run
+ - helm_chart_testing:
+ requires:
+ - build_docker_database_image
+ filters: *integration_default_run
+ - e2e_ui_build:
+ filters: *integration_default_run
+ - e2e_ui_testing:
+ requires: [e2e_ui_build]
+ filters: *integration_default_run
+ - e2e_ui_testing_server_root_path:
+ requires: [e2e_ui_build]
+ filters: *integration_default_run
+ - build_docker_database_image:
+ name: build_docker_database_image-migration
+ migration_qualification: true
+ filters: pipeline.parameters.run_migration_tests
+ - migration_startup_tests:
+ name: migration-startup
+ suite: startup
+ requires: [build_docker_database_image-migration]
+ filters: pipeline.parameters.run_migration_tests
+ - migration_startup_tests:
+ name: migration-recovery
+ suite: recovery
+ requires: [build_docker_database_image-migration]
+ filters: pipeline.parameters.run_migration_tests
+ - migration_startup_tests:
+ name: migration-legacy-and-pooling
+ suite: legacy
+ requires: [build_docker_database_image-migration]
+ filters: pipeline.parameters.run_migration_tests
+ - migration_startup_tests:
+ name: migration-upgrade
+ suite: upgrade
+ baseline: true
+ requires: [build_docker_database_image-migration]
+ filters: pipeline.parameters.run_migration_tests
+ - migration_startup_tests:
+ name: migration-upgrade-shaped
+ suite: shaped
+ baseline: true
+ requires: [build_docker_database_image-migration]
+ filters: pipeline.parameters.run_migration_tests
+ - routing_parity:
+ name: routing-parity-<< matrix.suite >>
+ matrix:
+ parameters:
+ suite: [management, accounting, database, providers, extensions, cost, mcp]
+ filters: pipeline.parameters.routing_parity_base != ""
diff --git a/.circleci/scripts/classify_changes.sh b/.circleci/scripts/classify_changes.sh
index a8c827d6f92..d2ab3478397 100755
--- a/.circleci/scripts/classify_changes.sh
+++ b/.circleci/scripts/classify_changes.sh
@@ -1,12 +1,11 @@
#!/usr/bin/env bash
set -uo pipefail
-category="${1:?usage: classify_changes.sh }"
+category="${1:?usage: classify_changes.sh }"
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
@@ -15,7 +14,7 @@ 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)
+ 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 | .circleci/scripts/classify_changes.sh | tests/integration/run.py | pyproject.toml | uv.lock)
has_redis_compat=true ;;
esac
case "$file" in
@@ -23,11 +22,6 @@ while IFS= read -r file || [ -n "$file" ]; do
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 ;;
@@ -41,7 +35,7 @@ while IFS= read -r file || [ -n "$file" ]; do
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/*) : ;;
+ tests/unit/proxy/*) : ;;
*) outside_cost_map_set=true ;;
esac
done
@@ -53,9 +47,6 @@ case "$category" in
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
;;
diff --git a/.circleci/scripts/path_filter.sh b/.circleci/scripts/path_filter.sh
index ccc68a0750b..c3cf6e566ff 100755
--- a/.circleci/scripts/path_filter.sh
+++ b/.circleci/scripts/path_filter.sh
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -uo pipefail
-category="${1:?usage: path_filter.sh }"
+category="${1:?usage: path_filter.sh }"
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
run_full() {
diff --git a/.circleci/scripts/run_integration.sh b/.circleci/scripts/run_integration.sh
index 3ac47c3e507..00b3c5f8fdd 100644
--- a/.circleci/scripts/run_integration.sh
+++ b/.circleci/scripts/run_integration.sh
@@ -128,14 +128,31 @@ 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 [ "$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=4 INTEGRATION_COVERAGE=1
+ export INTEGRATION_WORKERS=2 INTEGRATION_COVERAGE=1
+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)
@@ -177,17 +194,19 @@ start_proxy() {
> "$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"
+if [ "$isolated_workers" = false ]; then
+ 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 ] || [ "$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 .circleci/scripts/wait_integration_services.py
+ fi
fi
if [ "$suite" = providers ]; then
@@ -213,30 +232,42 @@ if [ "$suite" = browser ]; then
exit 0
fi
-node_files=()
+shard_arguments=()
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"
+ .venv/bin/python tests/integration/run.py "$suite" --list \
+ | circleci tests split --split-by=timings --timings-type=filename > "$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" 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" \
+ DATABASE_URL="$DATABASE_URL" \
+ "${shared_services[@]}" "${worker_coverage[@]}" \
INTEGRATION_WORKERS="${INTEGRATION_WORKERS:-1}" \
- INTEGRATION_MASTER_KEY="$INTEGRATION_MASTER_KEY" LITELLM_MODE=PRODUCTION \
+ LITELLM_MODE=PRODUCTION \
LITELLM_LICENSE="${LITELLM_LICENSE:-}" \
INTEGRATION_SEED="$INTEGRATION_SEED" \
INTEGRATION_ORDER_SEED="$INTEGRATION_ORDER_SEED" \
+ INTEGRATION_CASE_SHARDS="${CIRCLE_NODE_TOTAL:-1}" INTEGRATION_CASE_SHARD_INDEX="${CIRCLE_NODE_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" \
- .venv/bin/python tests/integration/run.py "$suite" --results "$results" "${node_files[@]}"
+ 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
diff --git a/.circleci/scripts/run_migration_tests.py b/.circleci/scripts/run_migration_tests.py
index 5a73c54e3f5..50d01f1d50b 100644
--- a/.circleci/scripts/run_migration_tests.py
+++ b/.circleci/scripts/run_migration_tests.py
@@ -10,7 +10,7 @@ from typing import Final
from xml.etree import ElementTree
SUITES: Final = {
- "startup": (("test_startup.py",), 12),
+ "startup": (("test_startup.py",), 13),
"recovery": (("test_recovery.py",), 15),
"legacy": (("test_legacy.py", "test_pooling.py"), 11),
"upgrade": (("test_upgrade.py", "test_rolling_upgrade.py"), 5),
diff --git a/.circleci/scripts/wait_integration_services.py b/.circleci/scripts/wait_integration_services.py
index 486e37cba00..0649585580f 100644
--- a/.circleci/scripts/wait_integration_services.py
+++ b/.circleci/scripts/wait_integration_services.py
@@ -1,42 +1,31 @@
import os
-import time
+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")
- 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)
+ 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__":
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index d9009d42754..582cf0f5217 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,2 +1,2 @@
-/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 @kerrylu-berri
+/model_prices_and_context_window.json @mateo-berri @ryan-crabbe-berri @kerry-berri
+/litellm/model_prices_and_context_window_backup.json @mateo-berri @ryan-crabbe-berri @kerry-berri
diff --git a/.github/PULL_REQUEST_TEMPLATE/general.md b/.github/PULL_REQUEST_TEMPLATE/general.md
index d9e72d58632..644d1c4a450 100644
--- a/.github/PULL_REQUEST_TEMPLATE/general.md
+++ b/.github/PULL_REQUEST_TEMPLATE/general.md
@@ -68,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/.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's scope is as isolated as possible; it only solves 1 specific problem
-- [ ] 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)
+- [ ] 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
## Delays in PR merge?
diff --git a/.github/assets/lens-datasets/add-to-dataset-dialog.png b/.github/assets/lens-datasets/add-to-dataset-dialog.png
deleted file mode 100644
index 8590ad48dd5..00000000000
Binary files a/.github/assets/lens-datasets/add-to-dataset-dialog.png and /dev/null differ
diff --git a/.github/assets/lens-datasets/dataset-case-panel.png b/.github/assets/lens-datasets/dataset-case-panel.png
deleted file mode 100644
index dd579ac58af..00000000000
Binary files a/.github/assets/lens-datasets/dataset-case-panel.png and /dev/null differ
diff --git a/.github/assets/lens-datasets/dataset-detail.png b/.github/assets/lens-datasets/dataset-detail.png
deleted file mode 100644
index c68b9b6f73c..00000000000
Binary files a/.github/assets/lens-datasets/dataset-detail.png and /dev/null differ
diff --git a/.github/assets/lens-datasets/datasets-list.png b/.github/assets/lens-datasets/datasets-list.png
deleted file mode 100644
index 14a6200aae0..00000000000
Binary files a/.github/assets/lens-datasets/datasets-list.png and /dev/null differ
diff --git a/.github/assets/lens-datasets/old-revision.png b/.github/assets/lens-datasets/old-revision.png
deleted file mode 100644
index d6e369e2485..00000000000
Binary files a/.github/assets/lens-datasets/old-revision.png and /dev/null differ
diff --git a/.github/assets/lens-datasets/trace-header.png b/.github/assets/lens-datasets/trace-header.png
deleted file mode 100644
index 85bd759b5d0..00000000000
Binary files a/.github/assets/lens-datasets/trace-header.png and /dev/null differ
diff --git a/.github/assets/lens-result-retries/before-results.jpg b/.github/assets/lens-result-retries/before-results.jpg
deleted file mode 100644
index 26d2ae3f65e..00000000000
Binary files a/.github/assets/lens-result-retries/before-results.jpg and /dev/null differ
diff --git a/.github/assets/lens-result-retries/partial-results.jpg b/.github/assets/lens-result-retries/partial-results.jpg
deleted file mode 100644
index 1246ee760c0..00000000000
Binary files a/.github/assets/lens-result-retries/partial-results.jpg and /dev/null differ
diff --git a/.github/assets/roi-calculator-integrations/after-github.jpg b/.github/assets/roi-calculator-integrations/after-github.jpg
deleted file mode 100644
index 31789b9d309..00000000000
Binary files a/.github/assets/roi-calculator-integrations/after-github.jpg and /dev/null differ
diff --git a/.github/assets/roi-calculator-integrations/after-gitlab-detail-top.jpg b/.github/assets/roi-calculator-integrations/after-gitlab-detail-top.jpg
deleted file mode 100644
index 6846edf14f7..00000000000
Binary files a/.github/assets/roi-calculator-integrations/after-gitlab-detail-top.jpg and /dev/null differ
diff --git a/.github/assets/roi-calculator-integrations/after-gitlab-detail.jpg b/.github/assets/roi-calculator-integrations/after-gitlab-detail.jpg
deleted file mode 100644
index 2b8a541a4fb..00000000000
Binary files a/.github/assets/roi-calculator-integrations/after-gitlab-detail.jpg and /dev/null differ
diff --git a/.github/assets/roi-calculator-integrations/after-gitlab.jpg b/.github/assets/roi-calculator-integrations/after-gitlab.jpg
deleted file mode 100644
index 792c2218353..00000000000
Binary files a/.github/assets/roi-calculator-integrations/after-gitlab.jpg and /dev/null differ
diff --git a/.github/assets/roi-calculator-integrations/before-github.jpg b/.github/assets/roi-calculator-integrations/before-github.jpg
deleted file mode 100644
index 0154db63738..00000000000
Binary files a/.github/assets/roi-calculator-integrations/before-github.jpg and /dev/null differ
diff --git a/.github/assets/roi-calculator-integrations/demo-exit-loading.jpg b/.github/assets/roi-calculator-integrations/demo-exit-loading.jpg
deleted file mode 100644
index 3f194c59d82..00000000000
Binary files a/.github/assets/roi-calculator-integrations/demo-exit-loading.jpg and /dev/null differ
diff --git a/.github/assets/roi-calculator-integrations/demo-fallback-live.jpg b/.github/assets/roi-calculator-integrations/demo-fallback-live.jpg
deleted file mode 100644
index 4e4788cdb75..00000000000
Binary files a/.github/assets/roi-calculator-integrations/demo-fallback-live.jpg and /dev/null differ
diff --git a/.github/assets/roi-calculator-integrations/demo-overview.jpg b/.github/assets/roi-calculator-integrations/demo-overview.jpg
deleted file mode 100644
index db5005aa1ae..00000000000
Binary files a/.github/assets/roi-calculator-integrations/demo-overview.jpg and /dev/null differ
diff --git a/.github/assets/roi-calculator-integrations/demo-people.jpg b/.github/assets/roi-calculator-integrations/demo-people.jpg
deleted file mode 100644
index b698fe500ac..00000000000
Binary files a/.github/assets/roi-calculator-integrations/demo-people.jpg and /dev/null differ
diff --git a/.github/assets/roi-calculator-integrations/demo-pr-costs.jpg b/.github/assets/roi-calculator-integrations/demo-pr-costs.jpg
deleted file mode 100644
index 73c148d07e1..00000000000
Binary files a/.github/assets/roi-calculator-integrations/demo-pr-costs.jpg and /dev/null differ
diff --git a/.github/assets/roi-calculator-integrations/demo-pr-detail.jpg b/.github/assets/roi-calculator-integrations/demo-pr-detail.jpg
deleted file mode 100644
index 7a6c321439d..00000000000
Binary files a/.github/assets/roi-calculator-integrations/demo-pr-detail.jpg and /dev/null differ
diff --git a/.github/assets/roi-calculator-integrations/demo-preview-link.jpg b/.github/assets/roi-calculator-integrations/demo-preview-link.jpg
deleted file mode 100644
index b4a1d0e8244..00000000000
Binary files a/.github/assets/roi-calculator-integrations/demo-preview-link.jpg and /dev/null differ
diff --git a/.github/assets/roi-calculator-integrations/demo-with-live-errors.jpg b/.github/assets/roi-calculator-integrations/demo-with-live-errors.jpg
deleted file mode 100644
index 41fc1320560..00000000000
Binary files a/.github/assets/roi-calculator-integrations/demo-with-live-errors.jpg and /dev/null differ
diff --git a/.github/assets/roi-calculator-integrations/source-race-after.jpg b/.github/assets/roi-calculator-integrations/source-race-after.jpg
deleted file mode 100644
index fac19265807..00000000000
Binary files a/.github/assets/roi-calculator-integrations/source-race-after.jpg and /dev/null differ
diff --git a/.github/assets/roi-calculator-integrations/source-race-before.jpg b/.github/assets/roi-calculator-integrations/source-race-before.jpg
deleted file mode 100644
index d22ccd3deaa..00000000000
Binary files a/.github/assets/roi-calculator-integrations/source-race-before.jpg and /dev/null differ
diff --git a/.github/assets/roi-calculator/00-original-setup.png b/.github/assets/roi-calculator/00-original-setup.png
deleted file mode 100644
index 95bdeb56907..00000000000
Binary files a/.github/assets/roi-calculator/00-original-setup.png and /dev/null differ
diff --git a/.github/assets/roi-calculator/01-connect-github.png b/.github/assets/roi-calculator/01-connect-github.png
deleted file mode 100644
index 4214298785a..00000000000
Binary files a/.github/assets/roi-calculator/01-connect-github.png and /dev/null differ
diff --git a/.github/assets/roi-calculator/02-repositories.png b/.github/assets/roi-calculator/02-repositories.png
deleted file mode 100644
index 81e69c20c2b..00000000000
Binary files a/.github/assets/roi-calculator/02-repositories.png and /dev/null differ
diff --git a/.github/assets/roi-calculator/03-estimator-schedule.png b/.github/assets/roi-calculator/03-estimator-schedule.png
deleted file mode 100644
index 2934bc969d8..00000000000
Binary files a/.github/assets/roi-calculator/03-estimator-schedule.png and /dev/null differ
diff --git a/.github/assets/roi-calculator/04-backfill-progress.png b/.github/assets/roi-calculator/04-backfill-progress.png
deleted file mode 100644
index 19026b8042f..00000000000
Binary files a/.github/assets/roi-calculator/04-backfill-progress.png and /dev/null differ
diff --git a/.github/assets/roi-calculator/06-overview.png b/.github/assets/roi-calculator/06-overview.png
deleted file mode 100644
index abf2f7a0aaa..00000000000
Binary files a/.github/assets/roi-calculator/06-overview.png and /dev/null differ
diff --git a/.github/assets/roi-calculator/07-people-unmatched.png b/.github/assets/roi-calculator/07-people-unmatched.png
deleted file mode 100644
index a605d980f20..00000000000
Binary files a/.github/assets/roi-calculator/07-people-unmatched.png and /dev/null differ
diff --git a/.github/assets/roi-calculator/08-match-email.png b/.github/assets/roi-calculator/08-match-email.png
deleted file mode 100644
index 9f578fd783c..00000000000
Binary files a/.github/assets/roi-calculator/08-match-email.png and /dev/null differ
diff --git a/.github/assets/roi-calculator/09-people-matched.png b/.github/assets/roi-calculator/09-people-matched.png
deleted file mode 100644
index 6d72179ae67..00000000000
Binary files a/.github/assets/roi-calculator/09-people-matched.png and /dev/null differ
diff --git a/.github/assets/roi-calculator/10-pr-reasoning.png b/.github/assets/roi-calculator/10-pr-reasoning.png
deleted file mode 100644
index 423c6bdc3e3..00000000000
Binary files a/.github/assets/roi-calculator/10-pr-reasoning.png and /dev/null differ
diff --git a/.github/assets/roi-calculator/11-settings.png b/.github/assets/roi-calculator/11-settings.png
deleted file mode 100644
index 1ef5c446408..00000000000
Binary files a/.github/assets/roi-calculator/11-settings.png and /dev/null differ
diff --git a/.github/assets/roi-calculator/12-restart-setup.png b/.github/assets/roi-calculator/12-restart-setup.png
deleted file mode 100644
index 7a2f410a5e2..00000000000
Binary files a/.github/assets/roi-calculator/12-restart-setup.png and /dev/null differ
diff --git a/.github/assets/roi-calculator/13-advanced-settings.png b/.github/assets/roi-calculator/13-advanced-settings.png
deleted file mode 100644
index 61549454c88..00000000000
Binary files a/.github/assets/roi-calculator/13-advanced-settings.png and /dev/null differ
diff --git a/.github/assets/roi-calculator/14-overview-pulls.png b/.github/assets/roi-calculator/14-overview-pulls.png
deleted file mode 100644
index 0f07752c4c3..00000000000
Binary files a/.github/assets/roi-calculator/14-overview-pulls.png and /dev/null differ
diff --git a/.github/assets/roi-calculator/15-sample-preview.png b/.github/assets/roi-calculator/15-sample-preview.png
deleted file mode 100644
index 6128d0a5dff..00000000000
Binary files a/.github/assets/roi-calculator/15-sample-preview.png and /dev/null differ
diff --git a/.github/assets/roi-calculator/16-calculator-sidebar.png b/.github/assets/roi-calculator/16-calculator-sidebar.png
deleted file mode 100644
index 8ed3042f36c..00000000000
Binary files a/.github/assets/roi-calculator/16-calculator-sidebar.png and /dev/null differ
diff --git a/.github/assets/roi-calculator/19-matching-calculator-icons.png b/.github/assets/roi-calculator/19-matching-calculator-icons.png
deleted file mode 100644
index af12106e315..00000000000
Binary files a/.github/assets/roi-calculator/19-matching-calculator-icons.png and /dev/null differ
diff --git a/.github/assets/roi-calculator/20-partial-repository-report.png b/.github/assets/roi-calculator/20-partial-repository-report.png
deleted file mode 100644
index eac03deddae..00000000000
Binary files a/.github/assets/roi-calculator/20-partial-repository-report.png and /dev/null differ
diff --git a/.github/assets/roi-calculator/21-empty-repository-preserved-report.png b/.github/assets/roi-calculator/21-empty-repository-preserved-report.png
deleted file mode 100644
index 4c6add87f95..00000000000
Binary files a/.github/assets/roi-calculator/21-empty-repository-preserved-report.png and /dev/null differ
diff --git a/.github/assets/roi-calculator/22-partial-calculation-explanation.png b/.github/assets/roi-calculator/22-partial-calculation-explanation.png
deleted file mode 100644
index 5415956b3fa..00000000000
Binary files a/.github/assets/roi-calculator/22-partial-calculation-explanation.png and /dev/null differ
diff --git a/.github/assets/roi-calculator/23-estimator-outage-preserved-report.png b/.github/assets/roi-calculator/23-estimator-outage-preserved-report.png
deleted file mode 100644
index 346cc2acab7..00000000000
Binary files a/.github/assets/roi-calculator/23-estimator-outage-preserved-report.png and /dev/null differ
diff --git a/.github/assets/scaledown/provider-after.jpg b/.github/assets/scaledown/provider-after.jpg
new file mode 100644
index 00000000000..bcf1c59c96f
Binary files /dev/null and b/.github/assets/scaledown/provider-after.jpg differ
diff --git a/.github/assets/scaledown/provider-before.jpg b/.github/assets/scaledown/provider-before.jpg
new file mode 100644
index 00000000000..a1b89ae13ca
Binary files /dev/null and b/.github/assets/scaledown/provider-before.jpg differ
diff --git a/.github/ci-coverage-allowlist.yml b/.github/ci-coverage-allowlist.yml
index 8ca45ba3f75..db2f652b877 100644
--- a/.github/ci-coverage-allowlist.yml
+++ b/.github/ci-coverage-allowlist.yml
@@ -18,22 +18,11 @@ test_paths:
paths:
- tests/rust-python-harness
- reason: >-
- Live-provider caching cases in tests/local_testing that remain outside CI. Jobs that
- glob that directory either deselect them (local_testing_part1 and part2 carry `-k "... and
- 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
+ Owned by the open pull request that moves the Rust/Python parity checks (#45135). It ran in
+ the CircleCI build_and_test job that globbed tests/test_*.py, which this migration deletes,
+ and that pull request gives it a new home
paths:
- - tests/local_testing/test_caching.py
- - tests/local_testing/test_disk_cache_unit_tests.py
- - tests/local_testing/test_gcs_cache_unit_tests.py
+ - tests/test_rust_python_harness.py
- reason: >-
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
@@ -59,48 +48,6 @@ test_paths:
choice it informed is settled
paths:
- 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: >-
Third-party integration tests that skip themselves without OCI configuration or sandbox
credentials, neither of which a pull request job holds
diff --git a/.github/merge-smoke-tests.json b/.github/merge-smoke-tests.json
deleted file mode 100644
index 90d3b6a6d59..00000000000
--- a/.github/merge-smoke-tests.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "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"
- }
-}
diff --git a/.github/scripts/assert_ci_coverage.py b/.github/scripts/assert_ci_coverage.py
index e90589d3c95..fbdbfafa66d 100644
--- a/.github/scripts/assert_ci_coverage.py
+++ b/.github/scripts/assert_ci_coverage.py
@@ -9,6 +9,8 @@ import sys
import warnings
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass
+from functools import lru_cache
+from types import MappingProxyType
from typing import Final
import yaml
@@ -35,8 +37,15 @@ GLOB_CHARS = frozenset("*?")
# 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.
SHARDED_ROOTS: tuple[str, ...] = (
- "tests/test_litellm",
+ "tests/unit",
+ "tests/unit/enterprise",
+ "tests/unit/enterprise/enterprise_callbacks",
+ "tests/unit/enterprise/proxy",
+ "tests/unit/llms",
"tests/unit/proxy",
+ "tests/unit/proxy/_experimental",
+ "tests/unit/responses",
+ "tests/unit/skills",
)
@@ -308,6 +317,7 @@ def _keyword_terms(
def _slices() -> tuple[Slice, ...]:
if not CIRCLECI_CONFIG.exists():
return ()
+ test_files: Final = frozenset(_test_files())
jobs: Final = yaml.safe_load(CIRCLECI_CONFIG.read_text()).get("jobs", {})
return tuple(
Slice(job=job, globs=globs, named=named, required=required, excluded=excluded, understood=understood)
@@ -315,7 +325,7 @@ def _slices() -> tuple[Slice, ...]:
for text in ("\n".join(_strings(body)),)
if "pytest" in text
for globs in (tuple(GLOB_CALL_RE.findall(text)),)
- for named in (frozenset(TEST_TOKEN_RE.findall(text)) & frozenset(_test_files()),)
+ for named in (frozenset(TEST_TOKEN_RE.findall(text)) & test_files,)
for required, excluded, understood in (
_keyword_terms(tuple(KEYWORD_RE.findall(text)), attributable=len(globs) < 2),
)
@@ -355,6 +365,7 @@ def _workflow_named_tokens() -> frozenset[str]:
def _deselected_everywhere(allowlist: Allowlist) -> tuple[Finding, ...]:
+ matchable_names: Final = lru_cache(maxsize=None)(_matchable_names)
slices: Final = _slices()
named_by_workflow: Final = _workflow_named_tokens()
globbed: Final = tuple(
@@ -368,7 +379,7 @@ def _deselected_everywhere(allowlist: Allowlist) -> tuple[Finding, ...]:
for path in globbed
if not allowlist.covers_test(path)
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)
)
diff --git a/.github/scripts/run_merge_smoke.py b/.github/scripts/run_merge_smoke.py
index 7e74de324fc..81d47d5b45c 100644
--- a/.github/scripts/run_merge_smoke.py
+++ b/.github/scripts/run_merge_smoke.py
@@ -16,29 +16,11 @@ import socket
import subprocess
import sys
import time
-from collections import Counter
from collections.abc import Sequence
-from dataclasses import dataclass, field
+from dataclasses import dataclass
from pathlib import Path
-from types import MappingProxyType
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)
class CheckResult:
@@ -57,8 +39,6 @@ class _Args:
ready_deadline: float = 120.0
shutdown_deadline: float = 20.0
poll_interval: float = 0.5
- manifest: str = ""
- rootdir: str | None = None
def fail(reason: str) -> NoReturn:
@@ -345,120 +325,6 @@ def _terminate(proc: subprocess.Popen[bytes], log_file: TextIO) -> None:
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:
parser: Final = argparse.ArgumentParser(description=__doc__)
subs: Final = parser.add_subparsers(dest="command", required=True)
@@ -475,16 +341,12 @@ def main() -> int:
p_proxy.add_argument("--ready-deadline", type=float, default=120)
p_proxy.add_argument("--shutdown-deadline", type=float, default=20)
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())
handlers: Final = {
"verify-isolation": cmd_verify_isolation,
"interpreter": cmd_interpreter,
"cli": cmd_cli,
"proxy-startup": cmd_proxy_startup,
- "pytest": cmd_pytest,
}
return handlers[args.command](args)
diff --git a/.github/workflows/_test-unit-base.yml b/.github/workflows/_test-unit-base.yml
deleted file mode 100644
index ebb925c8b68..00000000000
--- a/.github/workflows/_test-unit-base.yml
+++ /dev/null
@@ -1,283 +0,0 @@
-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
diff --git a/.github/workflows/check-ui-api-types.yml b/.github/workflows/check-ui-api-types.yml
deleted file mode 100644
index 185c20d916d..00000000000
--- a/.github/workflows/check-ui-api-types.yml
+++ /dev/null
@@ -1,134 +0,0 @@
-name: Check UI API Types Sync
-
-on:
- pull_request:
- branches:
- - main
- - "litellm_**"
-
-permissions:
- contents: read
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
-
-jobs:
- check-sync:
- name: Verify schema.d.ts matches the proxy OpenAPI spec
- runs-on: ubuntu-latest
- timeout-minutes: 15
- steps:
- - name: Checkout repository
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- persist-credentials: false
- fetch-depth: 2
-
- - name: Detect changes that can affect the generated types
- id: changes
- run: |
- set -euo pipefail
- if ! base="$(git rev-parse --verify --quiet HEAD^2 >/dev/null && git rev-parse HEAD^1)"; then
- echo "Not a pull request merge commit, running the full check."
- echo "relevant=true" >> "$GITHUB_OUTPUT"
- exit 0
- fi
- files="$(git diff --name-only "$base" HEAD)"
- if grep -Eq '^(litellm/(proxy|types)/|ui/litellm-dashboard/(src/lib/http/schema\.d\.ts|scripts/gen-api-types\.mjs|package(-lock)?\.json)$|\.github/workflows/check-ui-api-types\.yml$)' <<< "$files"; then
- echo "relevant=true" >> "$GITHUB_OUTPUT"
- else
- echo "No proxy, types or generator changes in this pull request, nothing to verify."
- echo "relevant=false" >> "$GITHUB_OUTPUT"
- fi
-
- - name: Set up Python
- if: steps.changes.outputs.relevant == 'true'
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
- with:
- python-version: "3.12"
-
- - name: Set up uv
- if: steps.changes.outputs.relevant == 'true'
- uses: ./.github/actions/setup-uv-with-retries
- with:
- version: "0.10.9"
-
- - name: Cache uv dependencies
- if: steps.changes.outputs.relevant == 'true'
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
- with:
- path: |
- ~/.cache/uv
- .venv
- key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
- restore-keys: |
- ${{ runner.os }}-uv-
-
- - name: Cache the Rust build
- if: steps.changes.outputs.relevant == 'true'
- uses: ./.github/actions/cache-cargo-build
-
- - name: Install backend dependencies
- if: steps.changes.outputs.relevant == 'true'
- run: .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router
-
- - name: Cache Prisma binaries
- if: steps.changes.outputs.relevant == 'true'
- uses: ./.github/actions/cache-prisma-binaries
-
- - name: Generate Prisma client
- if: steps.changes.outputs.relevant == 'true'
- run: uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
-
- - name: Regenerate the lazy OpenAPI snapshot
- if: steps.changes.outputs.relevant == 'true'
- run: uv run --no-sync python -m litellm.proxy._lazy_openapi_snapshot
-
- - name: Fail if the lazy OpenAPI snapshot is stale
- if: steps.changes.outputs.relevant == 'true'
- run: |
- if ! git diff --exit-code -- litellm/proxy/_lazy_openapi_snapshot.json; then
- echo "::error file=litellm/proxy/_lazy_openapi_snapshot.json::The lazy OpenAPI snapshot is out of sync with the lazily loaded routes."
- echo ""
- echo "A lazily loaded route or model changed without regenerating the snapshot that /openapi.json serves for unloaded features."
- echo "To fix, run from the repo root:"
- echo " uv run python -m litellm.proxy._lazy_openapi_snapshot"
- echo "then run npm run gen:api from ui/litellm-dashboard and commit both files."
- exit 1
- fi
- echo "_lazy_openapi_snapshot.json is in sync with the lazily loaded routes."
-
- - name: Set up Node.js
- if: steps.changes.outputs.relevant == 'true'
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
- with:
- node-version-file: ui/litellm-dashboard/.nvmrc
- cache: "npm"
- cache-dependency-path: ui/litellm-dashboard/package-lock.json
-
- - name: Install dashboard dependencies
- if: steps.changes.outputs.relevant == 'true'
- working-directory: ui/litellm-dashboard
- run: npm ci
-
- - name: Regenerate types from the live spec
- if: steps.changes.outputs.relevant == 'true'
- working-directory: ui/litellm-dashboard
- env:
- LITELLM_PYTHON: "uv run --no-sync python"
- run: npm run gen:api
-
- - name: Fail if types are stale
- if: steps.changes.outputs.relevant == 'true'
- run: |
- if ! git diff --exit-code -- ui/litellm-dashboard/src/lib/http/schema.d.ts; then
- echo "::error file=ui/litellm-dashboard/src/lib/http/schema.d.ts::Generated API types are out of sync with the proxy OpenAPI spec."
- echo ""
- echo "A backend route or model changed without regenerating the dashboard types."
- echo "To fix, run from ui/litellm-dashboard:"
- echo " npm run gen:api"
- echo "then commit the updated src/lib/http/schema.d.ts."
- exit 1
- fi
- echo "schema.d.ts is in sync with the proxy OpenAPI spec."
diff --git a/.github/workflows/ci-coverage.yml b/.github/workflows/ci-coverage.yml
deleted file mode 100644
index cd36a9a7ed6..00000000000
--- a/.github/workflows/ci-coverage.yml
+++ /dev/null
@@ -1,48 +0,0 @@
-name: "CI Coverage"
-
-on:
- pull_request:
- branches:
- - main
- - "litellm_**"
- push:
- branches:
- - main
-
-permissions:
- contents: read
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
- cancel-in-progress: ${{ github.event_name == 'pull_request' }}
-
-jobs:
- assert-ci-coverage:
- name: assert-ci-coverage
- runs-on: ubuntu-latest
- timeout-minutes: 5
- permissions:
- contents: read
- steps:
- - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- persist-credentials: false
-
- - name: Set up Python
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
- with:
- python-version: "3.12"
-
- - name: Assert every test file and Dockerfile is invoked by a job
- run: |
- python -m pip install "pyyaml==6.0.3"
- python .github/scripts/assert_ci_coverage.py
-
- # The census asks whether a job names a file; this asks whether that job's -k
- # then throws it back out. A file both globbed and deselected everywhere runs
- # nowhere while counting as covered, which is how the caching suite went unrun.
- - name: Assert no -k expression deselects a file from every job that globs it
- run: python .github/scripts/assert_ci_coverage.py --slices
-
- - name: Assert .github/workflows/ holds only workflows, correctly named
- run: python .github/scripts/assert_workflow_dir_hygiene.py
diff --git a/.github/workflows/deploy-dev-on-main.yml b/.github/workflows/deploy-dev-on-main.yml
new file mode 100644
index 00000000000..1208a2dbbdb
--- /dev/null
+++ b/.github/workflows/deploy-dev-on-main.yml
@@ -0,0 +1,30 @@
+name: Deploy main to gateway-dev
+
+on:
+ push:
+ branches: [main]
+
+permissions: {}
+
+concurrency:
+ group: deploy-dev-dispatch
+ cancel-in-progress: true
+
+jobs:
+ dispatch:
+ if: github.repository == 'BerriAI/litellm'
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2
+ id: token
+ with:
+ app-id: ${{ secrets.LITELLM_OPS_DISPATCH_APP_ID }}
+ private-key: ${{ secrets.LITELLM_OPS_DISPATCH_APP_PRIVATE_KEY }}
+ owner: BerriAI
+ repositories: litellm-ops
+ permission-actions: write
+ - name: Dispatch the litellm-ops dev deploy
+ env:
+ GH_TOKEN: ${{ steps.token.outputs.token }}
+ run: gh api --method POST repos/BerriAI/litellm-ops/actions/workflows/deploy-dev.yml/dispatches -f ref=main
diff --git a/.github/workflows/helm_unit_test.yml b/.github/workflows/helm_unit_test.yml
deleted file mode 100644
index f95848945a0..00000000000
--- a/.github/workflows/helm_unit_test.yml
+++ /dev/null
@@ -1,54 +0,0 @@
-name: Helm unit test
-
-on:
- pull_request:
- push:
- branches:
- - main
-
-permissions:
- contents: read
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
- cancel-in-progress: ${{ github.event_name == 'pull_request' }}
-
-jobs:
- unit-test:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- persist-credentials: false
-
- - name: Set up Helm 3.11.1
- uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
- with:
- version: "3.11.1"
-
- - name: Download and verify Helm Unit Test Plugin
- run: |
- curl -fsSLo "$RUNNER_TEMP/helm-unittest.tgz" https://github.com/helm-unittest/helm-unittest/releases/download/v0.8.2/helm-unittest-linux-amd64-0.8.2.tgz
- echo "56ab3091e6fa52a7c92ee951def9bed957f295d9ce98483aed404e748d7b3a94 $RUNNER_TEMP/helm-unittest.tgz" | sha256sum -c -
-
- - name: Install Helm Unit Test Plugin
- run: |
- PLUGIN_DIR="$(helm env HELM_PLUGINS)/helm-unittest"
- mkdir -p "$PLUGIN_DIR"
- tar -xzf "$RUNNER_TEMP/helm-unittest.tgz" -C "$PLUGIN_DIR"
- helm plugin list
-
- - name: Run unit tests
- run: |
- for chart in helm/litellm-helm helm/litellm; do
- declared="$(grep -h '^suite:' "$chart"/tests/*.yaml | wc -l | tr -d '[:space:]')"
- output="$(mktemp)"
- helm unittest -f 'tests/*.yaml' "$chart" | tee "$output"
- executed="$(sed -n 's/^Test Suites:.*[[:space:]]\([0-9][0-9]*\) total$/\1/p' "$output")"
- if [ "$declared" != "$executed" ]; then
- echo "::error::$chart declares $declared test suites but helm-unittest ran $executed. Suites are being skipped silently, so their assertions never execute."
- exit 1
- fi
- echo "$chart: all $declared declared test suites ran"
- done
diff --git a/.github/workflows/image-scan.yml b/.github/workflows/image-scan.yml
index 0dc629cb3b0..93eb6c1b325 100644
--- a/.github/workflows/image-scan.yml
+++ b/.github/workflows/image-scan.yml
@@ -17,9 +17,8 @@ on:
- gateway/routes/allowlist.py
- backend/Dockerfile
- backend/main.py
- - deploy/lens/**
+ - litellm-rust/**
- litellm/proxy/lens/**
- - tests/e2e/migrations/lens_compose_smoke.sh
- docker/component_entrypoint.sh
- docker/entrypoint.sh
- litellm/proxy/prisma_migration.py
@@ -28,7 +27,7 @@ on:
- backend/routes/allowlist.py
- pyproject.toml
- litellm-proxy-extras/**
- - tests/proxy_migration_tests/**
+ - tests/integration/database/image/**
- uv.lock
- ui/litellm-dashboard/package-lock.json
- ui/Dockerfile
@@ -46,80 +45,6 @@ concurrency:
cancel-in-progress: true
jobs:
- lens-worker-image:
- name: lens-worker-image (${{ matrix.arch }})
- runs-on: ${{ matrix.runner }}
- if: >-
- github.event_name != 'pull_request' ||
- github.event.pull_request.head.repo.full_name == github.repository
- timeout-minutes: 15
- permissions:
- contents: read
- strategy:
- fail-fast: false
- matrix:
- include:
- - arch: amd64
- runner: ubuntu-latest
- grype_sha256: edda0968d8827daab01d32b3cd7de192ae0915005e7bbfcfef9e68e79bc43343
- - arch: arm64
- runner: ubuntu-24.04-arm
- grype_sha256: 553e4c36d9d61349830ba6034d43b8700a7f10576d3e2f4981c0fd2b96086465
- steps:
- - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- persist-credentials: false
- - name: Build the release worker
- env:
- RELEASE_TAG: sha-${{ github.sha }}
- run: docker build --build-arg LITELLM_RELEASE_TAG="${RELEASE_TAG}" -f deploy/lens/Dockerfile -t lens-worker-scan .
- - name: Verify the standalone worker on a read-only filesystem
- env:
- RELEASE_TAG: sha-${{ github.sha }}
- run: |
- docker run --rm --network none --read-only --cap-drop ALL \
- --tmpfs /tmp:rw,noexec,nosuid,size=1g --security-opt no-new-privileges \
- -e EXPECTED_RELEASE_TAG="${RELEASE_TAG}" --entrypoint python lens-worker-scan -c '
- import os
- import lens.worker
- from lens.release import release_tag
- from lens.trace_store import trace_store
- assert os.getuid() == 65532
- assert release_tag() == os.environ["EXPECTED_RELEASE_TAG"]
- with trace_store() as store:
- assert store.count() == 0
- '
- - name: Reject a dependency whose hash has changed
- run: |
- docker build --target builder -f deploy/lens/Dockerfile -t lens-worker-deps .
- sed -E 's/sha256:[0-9a-f]{64}/sha256:0000000000000000000000000000000000000000000000000000000000000000/g' \
- deploy/lens/requirements.lock > "$RUNNER_TEMP/tampered.lock"
- if docker run --rm -v "$RUNNER_TEMP/tampered.lock:/tmp/tampered.lock:ro" \
- --entrypoint uv lens-worker-deps pip sync --python /app/.venv/bin/python \
- --require-hashes --only-binary :all: --reinstall --no-cache /tmp/tampered.lock \
- > "$RUNNER_TEMP/hash-check.log" 2>&1; then
- echo "::error::Dependency hash mismatch was accepted"
- exit 1
- fi
- cat "$RUNNER_TEMP/hash-check.log"
- grep -qi 'hash mismatch' "$RUNNER_TEMP/hash-check.log"
- - name: Download Grype v0.114.0
- env:
- ARCH: ${{ matrix.arch }}
- GRYPE_SHA256: ${{ matrix.grype_sha256 }}
- run: |
- curl -fsSL --retry 3 -o "$RUNNER_TEMP/grype.tar.gz" \
- "https://github.com/anchore/grype/releases/download/v0.114.0/grype_0.114.0_linux_${ARCH}.tar.gz"
- echo "${GRYPE_SHA256} $RUNNER_TEMP/grype.tar.gz" | sha256sum -c -
- tar xzf "$RUNNER_TEMP/grype.tar.gz" -C "$RUNNER_TEMP" grype
- chmod +x "$RUNNER_TEMP/grype"
- - name: Scan the worker for fixable HIGH/CRITICAL CVEs
- env:
- GRYPE_MATCH_PYTHON_USING_CPES: "true"
- run: |
- "$RUNNER_TEMP/grype" lens-worker-scan \
- --config .grype.yaml --only-fixed --fail-on high --output table
-
image-scan:
name: image-scan
runs-on: ubuntu-latest
@@ -167,7 +92,7 @@ jobs:
LITELLM_ADMIN_MCP_SCHEMA_IMAGE: litellm-admin-mcp-schema:${{ github.sha }}
run: |
python -m pip install "pytest==9.0.3"
- python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py tests/proxy_migration_tests/test_image_bedrock_realtime_extra.py tests/proxy_migration_tests/test_image_admin_mcp.py -v
+ python -m pytest --noconftest tests/integration/database/image/test_offline_image_migration.py tests/integration/database/image/test_image_bedrock_realtime_extra.py tests/integration/database/image/test_image_admin_mcp.py -v
# Scans the whole shipped artifact: OS/apk plus every language package
# baked into the image, including ones no lockfile declares (e.g. prisma's
@@ -220,13 +145,8 @@ jobs:
LITELLM_ADMIN_MCP_SCHEMA_IMAGE: litellm-admin-mcp-schema:${{ github.sha }}
run: |
python -m pip install "pytest==9.0.3"
- python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py tests/proxy_migration_tests/test_image_bedrock_realtime_extra.py tests/proxy_migration_tests/test_image_admin_mcp.py -v
+ python -m pytest --noconftest tests/integration/database/image/test_offline_image_migration.py tests/integration/database/image/test_image_bedrock_realtime_extra.py tests/integration/database/image/test_image_admin_mcp.py -v
- - name: Verify the bundled Lens Compose installation and restart
- if: matrix.dockerfile == 'Dockerfile'
- env:
- LITELLM_IMAGE: litellm-runtime-scan:${{ github.sha }}
- run: bash tests/e2e/migrations/lens_compose_smoke.sh
migrations-image:
name: migrations-image
@@ -257,7 +177,7 @@ jobs:
LITELLM_MIGRATION_SCRIPT: /app/run.py
run: |
python -m pip install "pytest==9.0.3"
- python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py -v
+ python -m pytest --noconftest tests/integration/database/image/test_offline_image_migration.py -v
gateway-image:
name: gateway-image
@@ -288,7 +208,7 @@ jobs:
LITELLM_IMAGE_COMPONENT: gateway
run: |
python -m pip install "pytest==9.0.3"
- python -m pytest tests/proxy_migration_tests/test_component_image_serves_offline.py tests/proxy_migration_tests/test_image_bedrock_realtime_extra.py tests/proxy_migration_tests/test_image_admin_mcp.py -v
+ python -m pytest --noconftest tests/integration/database/image/test_component_image_serves_offline.py tests/integration/database/image/test_image_bedrock_realtime_extra.py tests/integration/database/image/test_image_admin_mcp.py -v
ui-image:
name: ui-image
@@ -317,7 +237,7 @@ jobs:
LITELLM_IMAGE: litellm-ui-scan:${{ github.sha }}
run: |
python -m pip install "pytest==9.0.3"
- python -m pytest tests/proxy_migration_tests/test_ui_image_serves_offline.py -v
+ python -m pytest --noconftest tests/integration/database/image/test_ui_image_serves_offline.py -v
backend-image:
name: backend-image
@@ -352,4 +272,4 @@ jobs:
LITELLM_IMAGE_COMPONENT: backend
run: |
python -m pip install "pytest==9.0.3"
- python -m pytest tests/proxy_migration_tests/test_component_image_serves_offline.py tests/proxy_migration_tests/test_image_admin_mcp.py -v
+ python -m pytest --noconftest tests/integration/database/image/test_component_image_serves_offline.py tests/integration/database/image/test_image_admin_mcp.py -v
diff --git a/.github/workflows/lens-install-smoke.yml b/.github/workflows/lens-install-smoke.yml
new file mode 100644
index 00000000000..9832a69bd90
--- /dev/null
+++ b/.github/workflows/lens-install-smoke.yml
@@ -0,0 +1,152 @@
+name: Lens installation smoke
+
+on:
+ workflow_dispatch:
+ inputs:
+ lens_image:
+ description: Verified baseline Lens image at ghcr.io/berriai/lens@sha256
+ required: true
+ type: string
+ lens_upgrade_image:
+ description: Compatible Lens upgrade image at ghcr.io/berriai/lens@sha256
+ required: true
+ type: string
+ gateway_baseline_ref:
+ description: Previously qualified gateway source commit, full SHA
+ required: true
+ type: string
+
+permissions:
+ contents: read
+
+concurrency:
+ group: lens-install-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build-images:
+ runs-on: ubuntu-latest-16-cores
+ timeout-minutes: 45
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - component: gateway
+ dockerfile: gateway/Dockerfile
+ - component: backend
+ dockerfile: backend/Dockerfile
+ - component: ui
+ dockerfile: ui/Dockerfile
+ - component: migrations
+ dockerfile: migrations/Dockerfile
+ - component: monolith
+ dockerfile: Dockerfile
+ - component: gateway
+ dockerfile: gateway/Dockerfile
+ baseline: true
+ - component: backend
+ dockerfile: backend/Dockerfile
+ baseline: true
+ - component: monolith
+ dockerfile: Dockerfile
+ baseline: true
+ env:
+ COMPONENT: ${{ matrix.component }}
+ DOCKERFILE: ${{ matrix.dockerfile }}
+ SOURCE_REF: ${{ matrix.baseline && inputs.gateway_baseline_ref || github.sha }}
+ IMAGE_TAG: ${{ matrix.baseline && 'v0.0.0-lens-ci-baseline' || 'v0.0.0-lens-ci' }}
+ CURRENT_REF: ${{ github.sha }}
+ steps:
+ - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ persist-credentials: false
+ ref: ${{ env.SOURCE_REF }}
+ - name: Verify the selected source
+ shell: bash -euo pipefail {0}
+ run: |
+ [[ "$SOURCE_REF" =~ ^[0-9a-f]{40}$ ]]
+ test "$(git rev-parse HEAD)" = "$SOURCE_REF"
+ if [[ "$IMAGE_TAG" == v0.0.0-lens-ci-baseline ]]; then
+ test "$SOURCE_REF" != "$CURRENT_REF"
+ fi
+ - name: Build the matching release image
+ run: |
+ docker build --build-arg "LITELLM_RELEASE_TAG=$IMAGE_TAG" \
+ -f "$DOCKERFILE" -t "lens-ci-$COMPONENT:$IMAGE_TAG" .
+ - name: Save the matching release image
+ run: |
+ docker save "lens-ci-$COMPONENT:$IMAGE_TAG" \
+ | gzip -1 > "$RUNNER_TEMP/lens-install-$COMPONENT-$IMAGE_TAG.tar.gz"
+ - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
+ with:
+ name: lens-install-${{ matrix.component }}-${{ env.IMAGE_TAG }}-${{ github.sha }}
+ path: ${{ runner.temp }}/lens-install-${{ matrix.component }}-${{ env.IMAGE_TAG }}.tar.gz
+ compression-level: 0
+ retention-days: 3
+ if-no-files-found: error
+ overwrite: true
+
+ helm-install:
+ needs: build-images
+ runs-on: ubuntu-latest-16-cores
+ timeout-minutes: 25
+ steps:
+ - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ persist-credentials: false
+ - uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
+ with:
+ pattern: lens-install-*-${{ github.sha }}
+ merge-multiple: true
+ path: ${{ runner.temp }}/lens-install-images
+ - name: Load the matching release images
+ run: |
+ for archive in "$RUNNER_TEMP"/lens-install-images/lens-install-*.tar.gz; do
+ gzip -dc "$archive" | docker load
+ rm "$archive"
+ done
+ - uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
+ with:
+ cosign-release: v2.4.3
+ - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
+ with:
+ registry: ghcr.io
+ username: ${{ vars.LENS_READ_USER || github.actor }}
+ password: ${{ secrets.LENS_READ_TOKEN }}
+ - name: Verify independent Lens images before loading them
+ env:
+ LENS_IMAGE: ${{ inputs.lens_image }}
+ LENS_UPGRADE_IMAGE: ${{ inputs.lens_upgrade_image }}
+ LENS_PUBLIC_KEY: ${{ vars.LENS_COSIGN_PUBLIC_KEY }}
+ LENS_PUBLIC_KEY_SHA256: ${{ vars.LENS_COSIGN_PUBLIC_KEY_SHA256 }}
+ shell: bash -euo pipefail {0}
+ run: |
+ [[ "$LENS_PUBLIC_KEY_SHA256" =~ ^[0-9a-f]{64}$ ]]
+ printf '%s' "$LENS_PUBLIC_KEY" > "$RUNNER_TEMP/lens.pub"
+ echo "$LENS_PUBLIC_KEY_SHA256 $RUNNER_TEMP/lens.pub" | sha256sum --check
+ for image in "$LENS_IMAGE" "$LENS_UPGRADE_IMAGE"; do
+ [[ "$image" =~ ^ghcr.io/berriai/lens@sha256:[0-9a-f]{64}$ ]]
+ cosign verify --key "$RUNNER_TEMP/lens.pub" "$image" > /dev/null
+ cosign verify-attestation --key "$RUNNER_TEMP/lens.pub" --type spdxjson "$image" > /dev/null
+ docker pull "$image"
+ done
+ docker tag "$LENS_IMAGE" lens-ci-worker:baseline
+ docker tag "$LENS_UPGRADE_IMAGE" lens-ci-worker:upgrade
+ - name: Install pinned Kubernetes test tools
+ run: |
+ curl --fail --location --output "$RUNNER_TEMP/kind" \
+ https://kind.sigs.k8s.io/dl/v0.27.0/kind-linux-amd64
+ echo "a6875aaea358acf0ac07786b1a6755d08fd640f4c79b7a2e46681cc13f49a04b $RUNNER_TEMP/kind" | sha256sum --check
+ chmod +x "$RUNNER_TEMP/kind"
+ curl --fail --location --output "$RUNNER_TEMP/kubectl" \
+ https://dl.k8s.io/release/v1.32.2/bin/linux/amd64/kubectl
+ echo "4f6a959dcc5b702135f8354cc7109b542a2933c46b808b248a214c1f69f817ea $RUNNER_TEMP/kubectl" | sha256sum --check
+ chmod +x "$RUNNER_TEMP/kubectl"
+ curl --fail --location --output "$RUNNER_TEMP/helm.tar.gz" \
+ https://get.helm.sh/helm-v3.19.0-linux-amd64.tar.gz
+ echo "a7f81ce08007091b86d8bd696eb4d86b8d0f2e1b9f6c714be62f82f96a594496 $RUNNER_TEMP/helm.tar.gz" | sha256sum --check
+ tar -xzf "$RUNNER_TEMP/helm.tar.gz" -C "$RUNNER_TEMP"
+ echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
+ echo "$RUNNER_TEMP/linux-amd64" >> "$GITHUB_PATH"
+ - name: Install, ingest, upgrade, and restart both charts
+ run: bash tests/e2e/migrations/lens_helm_smoke.sh
diff --git a/.github/workflows/lens-worker.yml b/.github/workflows/lens-worker.yml
deleted file mode 100644
index 80d274badd1..00000000000
--- a/.github/workflows/lens-worker.yml
+++ /dev/null
@@ -1,130 +0,0 @@
-name: Lens Worker Image
-
-on:
- pull_request:
- branches: [main, litellm_oss_branch, "litellm_**"]
- paths:
- - deploy/lens/**
- - litellm/proxy/lens/**
- - tests/proxy_behavior/lens/**
- - .github/workflows/lens-worker.yml
- push:
- branches: [main]
- paths:
- - deploy/lens/**
- - litellm/proxy/lens/**
- - tests/proxy_behavior/lens/**
- - .github/workflows/lens-worker.yml
- workflow_dispatch:
-
-permissions:
- contents: read
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
-
-jobs:
- lens-worker-image:
- permissions:
- contents: read
- packages: write
- id-token: write
- runs-on: ubuntu-latest
- timeout-minutes: 10
- steps:
- - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- persist-credentials: false
- - name: Build Lens worker
- run: docker build --build-arg LITELLM_RELEASE_TAG=sha-${{ github.sha }} -f deploy/lens/Dockerfile -t lens-worker:${{ github.sha }} .
- - name: Reject custom builds without a matching release tag
- run: |
- if docker build --progress plain -f deploy/lens/Dockerfile -t lens-worker:unversioned . > missing-tag.log 2>&1; then
- echo "::error::An unversioned worker build unexpectedly succeeded"
- exit 1
- fi
- grep -F 'LITELLM_RELEASE_TAG: Pass --build-arg LITELLM_RELEASE_TAG matching the gateway' missing-tag.log
- - name: Verify standalone imports with a read-only filesystem
- run: |
- docker run --rm --network none --read-only --cap-drop ALL --tmpfs /tmp:rw,noexec,nosuid,size=1g \
- --security-opt no-new-privileges --entrypoint python \
- lens-worker:${{ github.sha }} -c '
- import os
- import lens.worker
- from lens.trace_store import trace_store
- assert os.getuid() == 65532
- with trace_store() as store:
- assert store.count() == 0
- '
- - name: Prepare test-only coverage tool
- run: |
- coverage_directory=$(mktemp -d "$RUNNER_TEMP/lens-coverage.XXXXXX")
- curl --fail --silent --show-error --location \
- https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl \
- --output "$coverage_directory/coverage.whl"
- printf '%s %s\n' 8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1 \
- "$coverage_directory/coverage.whl" | sha256sum --check
- chmod 777 "$coverage_directory"
- echo "LENS_COVERAGE_DIRECTORY=$coverage_directory" >> "$GITHUB_ENV"
- - name: Verify confined Python execution
- run: |
- docker run --rm --network none --read-only --cap-drop ALL \
- --tmpfs /tmp:rw,noexec,nosuid,size=1g --security-opt no-new-privileges \
- -v "$PWD/tests/proxy_behavior/lens/worker_python_smoke.py:/app/python_smoke.py:ro" \
- -v "$PWD/tests/proxy_behavior/lens/coverage.ini:/coverage.ini:ro" \
- -v "$LENS_COVERAGE_DIRECTORY:/coverage" \
- -e PYTHONPATH=/coverage/coverage.whl -e COVERAGE_RCFILE=/coverage.ini \
- --entrypoint python lens-worker:${{ github.sha }} \
- -m coverage run --data-file=/coverage/.coverage.python /app/python_smoke.py
- - name: Verify workspace investigation and live review output
- run: |
- docker run --rm --network none --read-only --cap-drop ALL \
- --tmpfs /tmp:rw,noexec,nosuid,size=1g --security-opt no-new-privileges \
- -v "$PWD/tests/proxy_behavior/lens/worker_context_smoke.py:/app/context_smoke.py:ro" \
- -v "$PWD/tests/proxy_behavior/lens/coverage.ini:/coverage.ini:ro" \
- -v "$LENS_COVERAGE_DIRECTORY:/coverage" \
- -e PYTHONPATH=/coverage/coverage.whl -e COVERAGE_RCFILE=/coverage.ini \
- --entrypoint python lens-worker:${{ github.sha }} \
- -m coverage run --data-file=/coverage/.coverage.context /app/context_smoke.py
- - name: Verify default workspace recovery after Python scratch storage fills
- run: |
- docker run --rm --network none --read-only --cap-drop ALL \
- --tmpfs /tmp:rw,noexec,nosuid,size=64k --security-opt no-new-privileges \
- -v "$PWD/tests/proxy_behavior/lens/worker_storage_smoke.py:/app/storage_smoke.py:ro" \
- -v "$PWD/tests/proxy_behavior/lens/coverage.ini:/coverage.ini:ro" \
- -v "$LENS_COVERAGE_DIRECTORY:/coverage" \
- -e PYTHONPATH=/coverage/coverage.whl -e COVERAGE_RCFILE=/coverage.ini \
- --entrypoint python lens-worker:${{ github.sha }} \
- -m coverage run --data-file=/coverage/.coverage.storage /app/storage_smoke.py
- - name: Map native worker coverage to repository sources
- if: always() && env.LENS_COVERAGE_DIRECTORY != ''
- run: |
- docker run --rm --network none --read-only --cap-drop ALL \
- --security-opt no-new-privileges -w /workspace \
- -v "$PWD/litellm/proxy/lens:/workspace/litellm/proxy/lens:ro" \
- -v "$PWD/tests/proxy_behavior/lens/coverage.ini:/coverage.ini:ro" \
- -v "$LENS_COVERAGE_DIRECTORY:/coverage" \
- -e PYTHONPATH=/coverage/coverage.whl -e COVERAGE_RCFILE=/coverage.ini \
- --entrypoint /bin/sh lens-worker:${{ github.sha }} \
- -c 'python -m coverage combine && python -m coverage xml'
- - name: Upload native worker coverage
- if: always() && env.LENS_COVERAGE_DIRECTORY != ''
- uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5
- with:
- use_oidc: true
- files: ${{ env.LENS_COVERAGE_DIRECTORY }}/lens-worker.xml
- root_dir: ${{ github.workspace }}
- flags: lens-worker
- fail_ci_if_error: false
- - name: Publish versioned Lens worker
- if: github.event_name != 'pull_request' && github.repository == 'BerriAI/litellm' && github.ref == 'refs/heads/main'
- env:
- REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- REGISTRY_USER: ${{ github.actor }}
- IMAGE: ghcr.io/berriai/litellm-lens-worker-dev:sha-${{ github.sha }}
- run: |
- printf '%s' "$REGISTRY_TOKEN" | docker login ghcr.io -u "$REGISTRY_USER" --password-stdin
- docker tag lens-worker:${{ github.sha }} "$IMAGE"
- docker push "$IMAGE"
- printf 'Lens worker image: `%s`\n' "$IMAGE" >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/publish-basedpyright-base-counts.yml b/.github/workflows/publish-basedpyright-base-counts.yml
deleted file mode 100644
index 34f60d25980..00000000000
--- a/.github/workflows/publish-basedpyright-base-counts.yml
+++ /dev/null
@@ -1,65 +0,0 @@
-name: Publish basedpyright base counts
-
-# Every commit on main can become a future merge-base.
-# Publishing its per-rule basedpyright counts as an artifact lets
-# scripts/type_check_gate.py download them in seconds instead of paying a
-# 60-110s second basedpyright pass on every fresh worktree or moved merge-base.
-# No concurrency group on purpose: runs must never cancel each other, because
-# every sha's artifact matters (any of them can become a merge-base).
-
-on:
- push:
- branches:
- - main
- workflow_dispatch:
- inputs:
- ref:
- description: "Ref to compute and publish base counts for (defaults to the workflow run's commit)"
- required: false
-
-permissions:
- contents: read
-
-jobs:
- publish:
- runs-on: ubuntu-latest
- timeout-minutes: 20
-
- steps:
- - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- ref: ${{ inputs.ref || github.sha }}
- clean: true
- persist-credentials: false
-
- - name: Set up Python
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
- with:
- python-version: "3.12"
-
- - name: Set up uv
- uses: ./.github/actions/setup-uv-with-retries
- with:
- version: "0.10.9"
-
- - name: Cache the Rust build
- uses: ./.github/actions/cache-cargo-build
-
- - name: Cache Prisma binaries
- uses: ./.github/actions/cache-prisma-binaries
-
- # The gate provisions its own measurement env (.venv-typecheck: a frozen
- # uv sync of its canonical dependency groups plus a generated Prisma
- # client), so no install step here can drift from what local runs measure.
- - name: Emit basedpyright counts for HEAD
- run: |
- python scripts/type_check_gate.py --emit-counts-dir "$RUNNER_TEMP/basedpyright-counts"
- counts_file=$(ls "$RUNNER_TEMP"/basedpyright-counts/basedpyright-counts-*.json)
- echo "COUNTS_ARTIFACT_NAME=$(basename "$counts_file" .json)" >> "$GITHUB_ENV"
-
- - name: Upload counts artifact
- uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
- with:
- name: ${{ env.COUNTS_ARTIFACT_NAME }}
- path: ${{ runner.temp }}/basedpyright-counts/
- if-no-files-found: error
diff --git a/.github/workflows/publish-lint-base-counts.yml b/.github/workflows/publish-lint-base-counts.yml
new file mode 100644
index 00000000000..bbe9cadc893
--- /dev/null
+++ b/.github/workflows/publish-lint-base-counts.yml
@@ -0,0 +1,106 @@
+name: Publish lint base counts
+
+# Every commit on main can become a future merge-base.
+# Publishing its per-rule counts for each lint gate (strict ruff, type discipline,
+# test quality, basedpyright) as an artifact lets the gates download them through
+# scripts/lint_base_counts.py in seconds instead of scanning the merge-base tree in
+# a throwaway worktree on every fresh checkout or moved merge-base.
+# No concurrency group on purpose: runs must never cancel each other, because
+# every sha's artifact matters (any of them can become a merge-base).
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+ inputs:
+ ref:
+ description: "Ref to compute and publish base counts for (defaults to the workflow run's commit)"
+ required: false
+
+permissions:
+ contents: read
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - checker: ruff-strict
+ script: scripts/ruff_strict_gate.py
+ - checker: type-discipline
+ script: scripts/type_discipline_gate.py
+ - checker: test-quality
+ script: scripts/test_quality_gate.py
+ - checker: openapi-docs
+ script: scripts/openapi_docs_gate.py
+ - checker: basedpyright
+ script: scripts/type_check_gate.py
+
+ steps:
+ - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ ref: ${{ inputs.ref || github.sha }}
+ clean: true
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: "3.12"
+
+ - name: Set up uv
+ uses: ./.github/actions/setup-uv-with-retries
+ with:
+ version: "0.10.9"
+
+ - name: Cache the Rust build
+ if: matrix.checker == 'basedpyright'
+ uses: ./.github/actions/cache-cargo-build
+
+ - name: Cache Prisma binaries
+ if: matrix.checker == 'basedpyright' || matrix.checker == 'openapi-docs'
+ uses: ./.github/actions/cache-prisma-binaries
+
+ # The source scanners only need the pinned dev tools (ruff and the
+ # stdlib checkers), the same versions test-linting.yml's python job runs.
+ - name: Install the dev tools
+ if: matrix.checker != 'basedpyright' && matrix.checker != 'openapi-docs'
+ run: |
+ uv sync --frozen --only-group dev --no-install-project
+
+ # The OpenAPI gate imports the proxy app, so it needs the same install the
+ # test-linting.yml python job uses, Prisma client included.
+ - name: Install the proxy
+ if: matrix.checker == 'openapi-docs'
+ run: |
+ uv sync --frozen --group proxy-dev --group e2e-dev --extra proxy
+ uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
+
+ - name: Emit ${{ matrix.checker }} counts for HEAD
+ if: matrix.checker != 'basedpyright'
+ run: |
+ uv run --no-sync python ${{ matrix.script }} --emit-counts-dir "$RUNNER_TEMP/lint-counts"
+
+ # The basedpyright gate provisions its own measurement env (.venv-typecheck:
+ # a frozen uv sync of its canonical dependency groups plus a generated Prisma
+ # client), so no install step here can drift from what local runs measure.
+ - name: Emit basedpyright counts for HEAD
+ if: matrix.checker == 'basedpyright'
+ run: |
+ python scripts/type_check_gate.py --emit-counts-dir "$RUNNER_TEMP/lint-counts"
+
+ - name: Name the counts artifact
+ run: |
+ counts_file=$(ls "$RUNNER_TEMP"/lint-counts/${{ matrix.checker }}-counts-*.json)
+ echo "COUNTS_ARTIFACT_NAME=$(basename "$counts_file" .json)" >> "$GITHUB_ENV"
+
+ - name: Upload counts artifact
+ uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
+ with:
+ name: ${{ env.COUNTS_ARTIFACT_NAME }}
+ path: ${{ runner.temp }}/lint-counts/
+ if-no-files-found: error
diff --git a/.github/workflows/test-code-quality.yml b/.github/workflows/test-code-quality.yml
deleted file mode 100644
index 26a2c427f32..00000000000
--- a/.github/workflows/test-code-quality.yml
+++ /dev/null
@@ -1,220 +0,0 @@
-name: Code Quality Checks
-
-on:
- pull_request:
- branches:
- - main
- - "litellm_**"
- push:
- branches:
- - main
-
-permissions:
- contents: read
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
- cancel-in-progress: ${{ github.event_name == 'pull_request' }}
-
-jobs:
- code-quality:
- runs-on: ubuntu-latest
- timeout-minutes: 15
-
- steps:
- - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- persist-credentials: false
-
- - name: Checkout litellm-docs into docs/my-website (for documentation_tests)
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- repository: BerriAI/litellm-docs
- path: docs/my-website
- persist-credentials: false
-
- - name: Set up Python
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
- with:
- python-version: "3.12"
-
- - name: Set up uv
- uses: ./.github/actions/setup-uv-with-retries
- with:
- version: "0.10.9"
-
- - name: Cache uv dependencies
- if: github.ref == 'refs/heads/main'
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
- with:
- path: |
- ~/.cache/uv
- .venv
- key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
- restore-keys: |
- ${{ runner.os }}-uv-
-
- - name: Cache uv dependencies
- if: github.ref != 'refs/heads/main'
- uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
- with:
- path: |
- ~/.cache/uv
- .venv
- key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
- restore-keys: |
- ${{ runner.os }}-uv-
-
- - name: Cache the Rust build
- uses: ./.github/actions/cache-cargo-build
-
- - name: Install dependencies
- run: uv sync --frozen --all-groups --all-extras
-
- - name: check_licenses
- run: uv run --no-sync python ./tests/code_coverage_tests/check_licenses.py
-
- - name: check_provider_folders_documented
- run: uv run --no-sync python ./tests/code_coverage_tests/check_provider_folders_documented.py
-
- - name: check_prisma_binary_cache
- run: uv run --no-sync python ./tests/code_coverage_tests/check_prisma_binary_cache.py
-
- - name: check_workflow_startup_safety
- run: uv run --no-sync python ./tests/code_coverage_tests/check_workflow_startup_safety.py
-
- - name: check_workflow_job_name_collisions
- run: uv run --no-sync python ./tests/code_coverage_tests/check_workflow_job_name_collisions.py
-
- - name: test_workflow_job_name_collisions
- run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_workflow_job_name_collisions.py
-
- - name: test_e2e_changed_gate
- run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_e2e_changed_gate.py tests/code_coverage_tests/test_e2e_idp_stack.py
-
- - name: test_e2e_metadata
- env:
- PYTHONPATH: tests/e2e
- run: |
- export LITELLM_MASTER_KEY="sk-$(openssl rand -hex 16)"
- uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_e2e_metadata.py tests/code_coverage_tests/test_e2e_junit_report.py
-
- - name: Check merge smoke harness
- run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_merge_smoke.py
-
- - name: router_code_coverage
- run: uv run --no-sync python ./tests/code_coverage_tests/router_code_coverage.py
-
- - name: test_chat_completion_imports
- run: uv run --no-sync python ./tests/code_coverage_tests/test_chat_completion_imports.py
-
- - name: info_log_check
- run: uv run --no-sync python ./tests/code_coverage_tests/info_log_check.py
-
- - name: check_guardrail_apply_decorator
- run: uv run --no-sync python ./tests/code_coverage_tests/check_guardrail_apply_decorator.py
-
- - name: test_ban_set_verbose
- run: uv run --no-sync python ./tests/code_coverage_tests/test_ban_set_verbose.py
-
- - name: code_qa_check_tests
- run: uv run --no-sync python ./tests/code_coverage_tests/code_qa_check_tests.py
-
- - name: check_get_model_cost_key_performance
- run: uv run --no-sync python ./tests/code_coverage_tests/check_get_model_cost_key_performance.py
-
- - name: test_proxy_types_import
- run: uv run --no-sync python ./tests/code_coverage_tests/test_proxy_types_import.py
-
- - name: callback_manager_test
- run: uv run --no-sync python ./tests/code_coverage_tests/callback_manager_test.py
-
- - name: recursive_detector
- run: uv run --no-sync python ./tests/code_coverage_tests/recursive_detector.py
-
- - name: test_router_strategy_async
- run: uv run --no-sync python ./tests/code_coverage_tests/test_router_strategy_async.py
-
- - name: litellm_logging_code_coverage
- run: uv run --no-sync python ./tests/code_coverage_tests/litellm_logging_code_coverage.py
-
- - name: ensure_async_clients_test
- run: uv run --no-sync python ./tests/code_coverage_tests/ensure_async_clients_test.py
-
- - name: enforce_llms_folder_style
- run: uv run --no-sync python ./tests/code_coverage_tests/enforce_llms_folder_style.py
-
- - name: prevent_key_leaks_in_exceptions
- run: uv run --no-sync python ./tests/code_coverage_tests/prevent_key_leaks_in_exceptions.py
-
- - name: check_unsafe_enterprise_import
- run: uv run --no-sync python ./tests/code_coverage_tests/check_unsafe_enterprise_import.py
-
- - name: ban_copy_deepcopy_kwargs
- run: uv run --no-sync python ./tests/code_coverage_tests/ban_copy_deepcopy_kwargs.py
-
- - name: check_fastuuid_usage
- run: uv run --no-sync python ./tests/code_coverage_tests/check_fastuuid_usage.py
-
- - name: check_py310_typing_imports
- run: uv run --no-sync python ./tests/code_coverage_tests/check_py310_typing_imports.py
-
- - name: check_e2e_no_raw_requests
- run: uv run --no-sync python ./tests/code_coverage_tests/check_e2e_no_raw_requests.py
-
- - name: check_migrations_no_data_rewrites
- run: uv run --no-sync python ./tests/code_coverage_tests/check_migrations_no_data_rewrites.py
-
- - name: check_no_publicly_known_master_key
- run: uv run --no-sync python ./tests/code_coverage_tests/check_no_publicly_known_master_key.py
-
- - name: test_check_no_publicly_known_master_key
- run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_check_no_publicly_known_master_key.py
-
- - name: check_unbounded_in_lists (fails on findings not in the baseline)
- run: uv run --no-sync python ./tests/code_coverage_tests/check_unbounded_in_lists.py
-
- - name: memory_test
- run: uv run --no-sync python ./tests/code_coverage_tests/memory_test.py
-
- - name: documentation_test_env_keys
- run: uv run --no-sync python ./tests/documentation_tests/test_env_keys.py
-
- - name: documentation_test_router_settings
- run: uv run --no-sync python ./tests/documentation_tests/test_router_settings.py
-
- - name: documentation_test_api_docs
- run: uv run --no-sync python ./tests/documentation_tests/test_api_docs.py
-
- python-310-import-smoke:
- runs-on: ubuntu-latest
- timeout-minutes: 15
-
- steps:
- - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- persist-credentials: false
-
- - name: Set up Python
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
- with:
- python-version: "3.10"
-
- - name: Set up uv
- uses: ./.github/actions/setup-uv-with-retries
- with:
- version: "0.10.9"
-
- - name: Install dependencies
- run: uv sync --frozen --extra proxy --extra cli --python 3.10
-
- - run: uv run --no-sync python --version
-
- - name: Import litellm
- run: uv run --no-sync python -c "import litellm"
-
- - name: Check litellm CLI
- run: uv run --no-sync litellm --version
-
- - name: Check lite CLI
- run: uv run --no-sync lite version
diff --git a/.github/workflows/test-e2e-redis-chaos.yml b/.github/workflows/test-e2e-redis-chaos.yml
index c7412a63334..8f351a7a647 100644
--- a/.github/workflows/test-e2e-redis-chaos.yml
+++ b/.github/workflows/test-e2e-redis-chaos.yml
@@ -91,12 +91,12 @@ jobs:
- name: Run the Redis chaos load test
env:
- E2E_REDIS_CHAOS: "1"
+ E2E_PERF_TESTS: "1"
LITELLM_PROXY_URL: http://localhost:4000
REDIS_HOST: 127.0.0.1
REDIS_PORT: "6379"
run: |
- uv run --no-sync pytest tests/e2e/load/test_redis_chaos_e2e.py -v --tb=short -rA -s
+ uv run --no-sync pytest tests/e2e/perf/test_redis_chaos_e2e.py -v --tb=short -rA -s
- name: Show proxy log on failure
if: failure()
diff --git a/.github/workflows/test-linting.yml b/.github/workflows/test-linting.yml
index bed94a69873..b31f5a6d4e5 100644
--- a/.github/workflows/test-linting.yml
+++ b/.github/workflows/test-linting.yml
@@ -1,4 +1,4 @@
-name: LiteLLM Linting
+name: lint
on:
pull_request:
@@ -14,22 +14,26 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
- lint:
- runs-on: ubuntu-latest
- timeout-minutes: 15
- # actions: read lets scripts/type_check_gate.py download the base-counts
- # artifact published by publish-basedpyright-base-counts.yml instead of
- # re-running basedpyright over the merge-base tree.
+ python:
+ name: python
permissions:
contents: read
pull-requests: read
actions: read
-
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ services:
+ redis:
+ image: redis@sha256:e2debfb7956fa12c7ddc79d7e645c8cf26b30c99a6e9161ea9bf4171e1668a5f
+ ports:
+ - 6379:6379
+ options: >-
+ --health-cmd "redis-cli ping"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- # Check out the PR head, not the default refs/pull/N/merge: the merge ref
- # folds in newer base commits, which the diff-based gates (ruff delta,
- # Any-discipline) would otherwise blame on this branch.
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 1
@@ -100,9 +104,6 @@ jobs:
if: steps.changes.outputs.decision != 'skip'
uses: ./.github/actions/cache-prisma-binaries
- # basedpyright resolves Prisma's generated client (litellm/proxy/schema.prisma)
- # only after `prisma generate` writes prisma/client.py et al. Without this the
- # DB wrappers typed against the generated client would degrade to Unknown.
- name: Generate Prisma client
if: steps.changes.outputs.decision != 'skip'
run: |
@@ -144,27 +145,41 @@ jobs:
run: |
uv run --no-sync ruff check --config ruff-tests.toml tests
- - name: Check strict-rule budget (delta vs base)
+ - name: Check strict ruff rules (delta vs merge-base counts)
if: steps.changes.outputs.decision != 'skip'
+ env:
+ GH_TOKEN: ${{ github.token }}
run: |
uv run --no-sync python scripts/ruff_strict_gate.py --base "$GATE_BASE_SHA"
- - name: Check type-discipline budget (mutable collections / casts / type guards / kwargs / unexplained suppressions, delta vs base)
+ - name: Check type discipline (mutable collections / casts / type guards / kwargs / unexplained suppressions, delta vs merge-base counts)
if: steps.changes.outputs.decision != 'skip'
+ env:
+ GH_TOKEN: ${{ github.token }}
run: |
uv run --no-sync python scripts/type_discipline_gate.py --base "$GATE_BASE_SHA"
- - name: Check test-quality budget (zero-assert / mock-echo tests, sys.path.insert, raw env writes, litellm global mutation, credential-gated skips, conftest snapshot inventory, delta vs base)
+ - name: Check test quality (zero-assert / mock-echo tests, sys.path.insert, raw env writes, litellm global mutation, credential-gated skips, conftest snapshot inventory, delta vs merge-base counts)
if: steps.changes.outputs.decision != 'skip'
+ env:
+ GH_TOKEN: ${{ github.token }}
run: |
uv run --no-sync python scripts/test_quality_gate.py --base "$GATE_BASE_SHA"
+ - name: Check OpenAPI docs (POST routes without a request schema, description or 200 schema, delta vs merge-base counts)
+ if: steps.changes.outputs.decision != 'skip'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ uv sync --frozen --group proxy-dev --group e2e-dev --extra proxy
+ uv run --no-sync python scripts/openapi_docs_gate.py --base "$GATE_BASE_SHA"
+
- name: Print OpenAI version
if: steps.changes.outputs.decision != 'skip'
run: |
uv run --no-sync python -c "import openai; print(f'OpenAI version: {openai.__version__}')"
- - name: Check basedpyright budget (delta vs base)
+ - name: Check basedpyright (delta vs merge-base counts)
if: steps.changes.outputs.decision != 'skip'
env:
GH_TOKEN: ${{ github.token }}
@@ -184,8 +199,9 @@ jobs:
if: steps.changes.outputs.decision != 'skip'
env:
LITELLM_MASTER_KEY: sk-e2e-harness-tests-reach-no-proxy
+ E2E_CACHE_TEST_REDIS_URL: redis://127.0.0.1:6379/0
run: |
- if ! git diff --name-only --diff-filter=ACMRD "$GATE_BASE_SHA" HEAD -- tests/e2e tests/e2e_harness ':(exclude)tests/e2e/ui' pyproject.toml uv.lock .github/workflows/test-linting.yml | grep -q .; then
+ if ! git diff --name-only --diff-filter=ACMRD "$GATE_BASE_SHA" HEAD -- tests/e2e tests/e2e_harness ':(exclude)tests/e2e/ui' tests/code_coverage_tests/test_provider_replay_harness.py tests/code_coverage_tests/test_provider_cache.py pyproject.toml uv.lock .github/workflows/test-linting.yml | grep -q .; then
echo "No changed e2e harness files; skipping."
exit 0
fi
@@ -193,6 +209,9 @@ jobs:
CLAUDE_VERSION="$(retry uv run --no-sync python tests/e2e/claude_code/pr_gate_version_resolver.py)"
tests/e2e/claude_code/cron_vm/install_claude_code.sh "$CLAUDE_VERSION" "$RUNNER_TEMP/claude-cli"
PATH="$RUNNER_TEMP/claude-cli:$PATH" uv run --no-sync pytest -q tests/e2e_harness
+ uv run --no-sync pytest --tb=short -q --noconftest -o addopts= -o "pythonpath=tests/e2e tests/e2e_harness" -p no:rerunfailures \
+ tests/code_coverage_tests/test_provider_replay_harness.py \
+ tests/code_coverage_tests/test_provider_cache.py
- name: Check for circular imports
if: steps.changes.outputs.decision != 'skip'
@@ -205,47 +224,11 @@ jobs:
if: steps.changes.outputs.decision != 'skip'
run: |
uv run --no-sync python -c "from litellm import *" || (echo '🚨 import failed, this means you introduced unprotected imports! 🚨'; exit 1)
-
- # Intentionally NON-GATING. This job turns red when a *-budget.json ceiling is
- # raised (or a rule/budget is dropped) so a loosening is obvious in review, but it
- # must be kept OUT of the branch-protection required-checks list so a justified
- # bump can still be merged by a human who has seen and accepted the red.
- budget-ratchet:
- runs-on: ubuntu-latest
- timeout-minutes: 5
- permissions:
- contents: read
-
- steps:
- - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- fetch-depth: 1
- persist-credentials: false
-
- - name: Fetch ratchet base
- env:
- BASE_SHA: ${{ github.event.pull_request.base.sha }}
- run: |
- retry() { "$@" || { sleep 15; "$@"; } || { sleep 30; "$@"; }; }
- retry git fetch --no-tags --depth=1 origin "$BASE_SHA"
-
- - name: Set up Python
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
- with:
- python-version: "3.12"
-
- - name: Ratchet check (budgets may only decrease; non-gating)
- env:
- BASE_SHA: ${{ github.event.pull_request.base.sha }}
- run: |
- python scripts/budget_ratchet_check.py --base "$BASE_SHA"
-
secret-scan:
- runs-on: ubuntu-latest
- timeout-minutes: 5
permissions:
contents: read
-
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
@@ -277,3 +260,447 @@ jobs:
else
echo "GITGUARDIAN_API_KEY not set, skipping ggshield scan"
fi
+ ui:
+ name: ui
+ permissions:
+ contents: read
+ runs-on: ubuntu-latest
+ timeout-minutes: 8
+ defaults:
+ run:
+ working-directory: ui/litellm-dashboard
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ fetch-depth: 1
+ persist-credentials: false
+
+ - name: Collect changed files
+ id: changed
+ env:
+ GH_TOKEN: ${{ github.token }}
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ run: |
+ # base.sha is the base branch tip from when the PR was opened, while
+ # actions/checkout leaves HEAD on a merge of the PR into the *current*
+ # base tip. "$BASE_SHA"...HEAD therefore spans every base-branch commit
+ # landed since, so a PR that touches no UI file still gets linted
+ # against hundreds of other people's files. Diff the PR head against its
+ # own merge base instead, which is exactly what this PR changed.
+ merge_base=$(gh api "repos/${{ github.repository }}/compare/${BASE_SHA}...${HEAD_SHA}?per_page=1" --jq '.merge_base_commit.sha')
+ test -n "$merge_base"
+ git fetch --no-tags --depth=1 origin "$merge_base" "$HEAD_SHA"
+ : > "$RUNNER_TEMP/prettier_files.txt"
+ : > "$RUNNER_TEMP/eslint_files.txt"
+ while IFS= read -r f; do
+ [ -f "$f" ] || continue
+ case "$f" in
+ *.js | *.jsx | *.ts | *.tsx | *.mjs | *.cjs)
+ printf '%s\n' "$f" >> "$RUNNER_TEMP/prettier_files.txt"
+ printf '%s\n' "$f" >> "$RUNNER_TEMP/eslint_files.txt" ;;
+ *.json | *.css | *.scss | *.md | *.mdx | *.yml | *.yaml | *.html)
+ printf '%s\n' "$f" >> "$RUNNER_TEMP/prettier_files.txt" ;;
+ esac
+ done < <(git diff --name-only --diff-filter=ACMR --relative "$merge_base" "$HEAD_SHA" -- .)
+ if [ -s "$RUNNER_TEMP/prettier_files.txt" ] || [ -s "$RUNNER_TEMP/eslint_files.txt" ]; then
+ echo "has_files=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "has_files=false" >> "$GITHUB_OUTPUT"
+ echo "No lintable UI files changed in this PR; nothing to check."
+ fi
+
+ - name: Setup Node.js
+ if: steps.changed.outputs.has_files == 'true'
+ uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
+ with:
+ node-version-file: ui/litellm-dashboard/.nvmrc
+ cache: "npm"
+ cache-dependency-path: ui/litellm-dashboard/package-lock.json
+
+ - name: Install dependencies
+ if: steps.changed.outputs.has_files == 'true'
+ run: npm ci
+
+ - name: Lint changed files (prettier + eslint)
+ if: steps.changed.outputs.has_files == 'true'
+ run: |
+ prettier_files=()
+ eslint_files=()
+ while IFS= read -r f; do prettier_files+=("$f"); done < "$RUNNER_TEMP/prettier_files.txt"
+ while IFS= read -r f; do eslint_files+=("$f"); done < "$RUNNER_TEMP/eslint_files.txt"
+ status=0
+ if [ ${#prettier_files[@]} -gt 0 ]; then
+ echo "::group::Prettier (${#prettier_files[@]} files)"
+ npx prettier --check "${prettier_files[@]}" || { status=1; echo "::error::Unformatted files. Fix with: npm run format"; }
+ echo "::endgroup::"
+ fi
+ if [ ${#eslint_files[@]} -gt 0 ]; then
+ echo "::group::ESLint (${#eslint_files[@]} files)"
+ npx eslint --no-warn-ignored --pass-on-unpruned-suppressions "${eslint_files[@]}" || status=1
+ echo "::endgroup::"
+ fi
+ exit $status
+
+ - name: Check lint budgets
+ if: ${{ !cancelled() && steps.changed.outputs.has_files == 'true' }}
+ run: |
+ npx eslint . -f json -o "$RUNNER_TEMP/lint-report.json" || true
+ node scripts/check-lint-budgets.mjs "$RUNNER_TEMP/lint-report.json" eslint-budgets.json
+
+ - name: Check for dead code (knip)
+ if: ${{ !cancelled() && steps.changed.outputs.has_files == 'true' }}
+ run: npm run knip:ci
+ ui-api-types:
+ name: ui-api-types
+ permissions:
+ contents: read
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ persist-credentials: false
+ fetch-depth: 2
+
+ - name: Detect changes that can affect the generated types
+ id: changes
+ run: |
+ set -euo pipefail
+ if ! base="$(git rev-parse --verify --quiet HEAD^2 >/dev/null && git rev-parse HEAD^1)"; then
+ echo "Not a pull request merge commit, running the full check."
+ echo "relevant=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ files="$(git diff --name-only "$base" HEAD)"
+ if grep -Eq '^(litellm/(proxy|types)/|ui/litellm-dashboard/(src/lib/http/schema\.d\.ts|scripts/gen-api-types\.mjs|package(-lock)?\.json)$|\.github/workflows/test-linting\.yml$)' <<< "$files"; then
+ echo "relevant=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "No proxy, types or generator changes in this pull request, nothing to verify."
+ echo "relevant=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Set up Python
+ if: steps.changes.outputs.relevant == 'true'
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: "3.12"
+
+ - name: Set up uv
+ if: steps.changes.outputs.relevant == 'true'
+ uses: ./.github/actions/setup-uv-with-retries
+ with:
+ version: "0.10.9"
+
+ - name: Cache uv dependencies
+ if: steps.changes.outputs.relevant == 'true'
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: |
+ ~/.cache/uv
+ .venv
+ key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-uv-
+
+ - name: Cache the Rust build
+ if: steps.changes.outputs.relevant == 'true'
+ uses: ./.github/actions/cache-cargo-build
+
+ - name: Install backend dependencies
+ if: steps.changes.outputs.relevant == 'true'
+ run: .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router
+
+ - name: Cache Prisma binaries
+ if: steps.changes.outputs.relevant == 'true'
+ uses: ./.github/actions/cache-prisma-binaries
+
+ - name: Generate Prisma client
+ if: steps.changes.outputs.relevant == 'true'
+ run: uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
+
+ - name: Regenerate the lazy OpenAPI snapshot
+ if: steps.changes.outputs.relevant == 'true'
+ run: uv run --no-sync python -m litellm.proxy._lazy_openapi_snapshot
+
+ - name: Fail if the lazy OpenAPI snapshot is stale
+ if: steps.changes.outputs.relevant == 'true'
+ run: |
+ if ! git diff --exit-code -- litellm/proxy/_lazy_openapi_snapshot.json; then
+ echo "::error file=litellm/proxy/_lazy_openapi_snapshot.json::The lazy OpenAPI snapshot is out of sync with the lazily loaded routes."
+ echo ""
+ echo "A lazily loaded route or model changed without regenerating the snapshot that /openapi.json serves for unloaded features."
+ echo "To fix, run from the repo root:"
+ echo " uv run python -m litellm.proxy._lazy_openapi_snapshot"
+ echo "then run npm run gen:api from ui/litellm-dashboard and commit both files."
+ exit 1
+ fi
+ echo "_lazy_openapi_snapshot.json is in sync with the lazily loaded routes."
+
+ - name: Set up Node.js
+ if: steps.changes.outputs.relevant == 'true'
+ uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
+ with:
+ node-version-file: ui/litellm-dashboard/.nvmrc
+ cache: "npm"
+ cache-dependency-path: ui/litellm-dashboard/package-lock.json
+
+ - name: Install dashboard dependencies
+ if: steps.changes.outputs.relevant == 'true'
+ working-directory: ui/litellm-dashboard
+ run: npm ci
+
+ - name: Regenerate types from the live spec
+ if: steps.changes.outputs.relevant == 'true'
+ working-directory: ui/litellm-dashboard
+ env:
+ LITELLM_PYTHON: "uv run --no-sync python"
+ run: npm run gen:api
+
+ - name: Fail if types are stale
+ if: steps.changes.outputs.relevant == 'true'
+ run: |
+ if ! git diff --exit-code -- ui/litellm-dashboard/src/lib/http/schema.d.ts; then
+ echo "::error file=ui/litellm-dashboard/src/lib/http/schema.d.ts::Generated API types are out of sync with the proxy OpenAPI spec."
+ echo ""
+ echo "A backend route or model changed without regenerating the dashboard types."
+ echo "To fix, run from ui/litellm-dashboard:"
+ echo " npm run gen:api"
+ echo "then commit the updated src/lib/http/schema.d.ts."
+ exit 1
+ fi
+ echo "schema.d.ts is in sync with the proxy OpenAPI spec."
+ code-quality:
+ name: code-quality
+ permissions:
+ contents: read
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ steps:
+ - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ persist-credentials: false
+
+ - name: Checkout litellm-docs into docs/my-website (for documentation_tests)
+ uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ repository: BerriAI/litellm-docs
+ path: docs/my-website
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: "3.12"
+
+ - name: Set up uv
+ uses: ./.github/actions/setup-uv-with-retries
+ with:
+ version: "0.10.9"
+
+ - name: Cache uv dependencies
+ uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: |
+ ~/.cache/uv
+ .venv
+ key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-uv-
+
+ - name: Cache the Rust build
+ uses: ./.github/actions/cache-cargo-build
+
+ - name: Install dependencies
+ run: uv sync --frozen --all-groups --all-extras
+
+ - name: check_licenses
+ run: uv run --no-sync python ./tests/code_coverage_tests/check_licenses.py
+
+ - name: check_provider_folders_documented
+ run: uv run --no-sync python ./tests/code_coverage_tests/check_provider_folders_documented.py
+
+ - name: check_prisma_binary_cache
+ run: uv run --no-sync python ./tests/code_coverage_tests/check_prisma_binary_cache.py
+
+ - name: check_workflow_startup_safety
+ run: uv run --no-sync python ./tests/code_coverage_tests/check_workflow_startup_safety.py
+
+ - name: check_workflow_job_name_collisions
+ run: uv run --no-sync python ./tests/code_coverage_tests/check_workflow_job_name_collisions.py
+
+ - name: test_workflow_job_name_collisions
+ run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_workflow_job_name_collisions.py
+
+ - name: test_unit_passed_gate
+ run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_unit_passed_gate.py
+
+ - name: test_e2e_changed_gate
+ run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_e2e_changed_gate.py tests/code_coverage_tests/test_e2e_idp_stack.py
+
+ - name: test_e2e_metadata
+ env:
+ PYTHONPATH: tests/e2e
+ run: |
+ export LITELLM_MASTER_KEY="sk-$(openssl rand -hex 16)"
+ uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_e2e_metadata.py tests/code_coverage_tests/test_e2e_junit_report.py
+
+ - name: Check merge smoke harness
+ run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_merge_smoke.py
+
+ - name: router_code_coverage
+ run: uv run --no-sync python ./tests/code_coverage_tests/router_code_coverage.py
+
+ - name: test_chat_completion_imports
+ run: uv run --no-sync python ./tests/code_coverage_tests/test_chat_completion_imports.py
+
+ - name: info_log_check
+ run: uv run --no-sync python ./tests/code_coverage_tests/info_log_check.py
+
+ - name: check_guardrail_apply_decorator
+ run: uv run --no-sync python ./tests/code_coverage_tests/check_guardrail_apply_decorator.py
+
+ - name: test_ban_set_verbose
+ run: uv run --no-sync python ./tests/code_coverage_tests/test_ban_set_verbose.py
+
+ - name: code_qa_check_tests
+ run: uv run --no-sync python ./tests/code_coverage_tests/code_qa_check_tests.py
+
+ - name: check_get_model_cost_key_performance
+ run: uv run --no-sync python ./tests/code_coverage_tests/check_get_model_cost_key_performance.py
+
+ - name: test_proxy_types_import
+ run: uv run --no-sync python ./tests/code_coverage_tests/test_proxy_types_import.py
+
+ - name: callback_manager_test
+ run: uv run --no-sync python ./tests/code_coverage_tests/callback_manager_test.py
+
+ - name: recursive_detector
+ run: uv run --no-sync python ./tests/code_coverage_tests/recursive_detector.py
+
+ - name: test_router_strategy_async
+ run: uv run --no-sync python ./tests/code_coverage_tests/test_router_strategy_async.py
+
+ - name: litellm_logging_code_coverage
+ run: uv run --no-sync python ./tests/code_coverage_tests/litellm_logging_code_coverage.py
+
+ - name: ensure_async_clients_test
+ run: uv run --no-sync python ./tests/code_coverage_tests/ensure_async_clients_test.py
+
+ - name: enforce_llms_folder_style
+ run: uv run --no-sync python ./tests/code_coverage_tests/enforce_llms_folder_style.py
+
+ - name: prevent_key_leaks_in_exceptions
+ run: uv run --no-sync python ./tests/code_coverage_tests/prevent_key_leaks_in_exceptions.py
+
+ - name: check_unsafe_enterprise_import
+ run: uv run --no-sync python ./tests/code_coverage_tests/check_unsafe_enterprise_import.py
+
+ - name: check_telemetry_imports
+ run: uv run --no-sync python ./tests/code_coverage_tests/check_telemetry_imports.py
+
+ - name: ban_copy_deepcopy_kwargs
+ run: uv run --no-sync python ./tests/code_coverage_tests/ban_copy_deepcopy_kwargs.py
+
+ - name: check_fastuuid_usage
+ run: uv run --no-sync python ./tests/code_coverage_tests/check_fastuuid_usage.py
+
+ - name: check_py310_typing_imports
+ run: uv run --no-sync python ./tests/code_coverage_tests/check_py310_typing_imports.py
+
+ - name: check_e2e_no_raw_requests
+ run: uv run --no-sync python ./tests/code_coverage_tests/check_e2e_no_raw_requests.py
+
+ - name: check_migrations_no_data_rewrites
+ run: uv run --no-sync python ./tests/code_coverage_tests/check_migrations_no_data_rewrites.py
+
+ - name: check_migrations_lock_timeout
+ run: uv run --no-sync python ./tests/code_coverage_tests/check_migrations_lock_timeout.py
+
+ - name: check_no_publicly_known_master_key
+ run: uv run --no-sync python ./tests/code_coverage_tests/check_no_publicly_known_master_key.py
+
+ - name: test_check_no_publicly_known_master_key
+ run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_check_no_publicly_known_master_key.py
+
+ - name: check_unbounded_in_lists (fails on findings not in the baseline)
+ run: uv run --no-sync python ./tests/code_coverage_tests/check_unbounded_in_lists.py
+
+ - name: memory_test
+ run: uv run --no-sync python ./tests/code_coverage_tests/memory_test.py
+
+ - name: documentation_test_env_keys
+ run: uv run --no-sync python ./tests/documentation_tests/test_env_keys.py
+
+ - name: documentation_test_router_settings
+ run: uv run --no-sync python ./tests/documentation_tests/test_router_settings.py
+
+ - name: documentation_test_api_docs
+ run: uv run --no-sync python ./tests/documentation_tests/test_api_docs.py
+ ci-coverage:
+ name: ci-coverage
+ permissions:
+ contents: read
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: "3.12"
+
+ - name: Assert every test file and Dockerfile is invoked by a job
+ run: |
+ python -m pip install "pyyaml==6.0.3"
+ python .github/scripts/assert_ci_coverage.py
+
+ - name: Assert no -k expression deselects a file from every job that globs it
+ run: python .github/scripts/assert_ci_coverage.py --slices
+
+ - name: Assert .github/workflows/ holds only workflows, correctly named
+ run: python .github/scripts/assert_workflow_dir_hygiene.py
+ semgrep:
+ name: semgrep
+ permissions:
+ contents: read
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: "3.12"
+
+ - name: Set up uv
+ uses: ./.github/actions/setup-uv-with-retries
+ with:
+ version: "0.10.9"
+
+ - name: Run Semgrep (custom rules)
+ run: uv tool run --from 'semgrep==1.157.0' semgrep scan --config .semgrep/rules . --error
+ lint-passed:
+ name: lint passed
+ needs: [python, secret-scan, ui, ui-api-types, code-quality, ci-coverage, semgrep]
+ if: always()
+ runs-on: ubuntu-latest
+ timeout-minutes: 2
+ permissions: {}
+ steps:
+ - name: Require every lint job to succeed
+ env:
+ NEEDS: ${{ toJSON(needs) }}
+ run: |
+ jq -r 'to_entries[] | "\(.key): \(.value.result)"' <<< "$NEEDS"
+ jq -e 'all(.[]; .result == "success")' <<< "$NEEDS" > /dev/null
diff --git a/.github/workflows/test-litellm-ui-build.yml b/.github/workflows/test-litellm-ui-build.yml
deleted file mode 100644
index eace78fc2cb..00000000000
--- a/.github/workflows/test-litellm-ui-build.yml
+++ /dev/null
@@ -1,40 +0,0 @@
-name: UI Build Check
-permissions:
- contents: read
- pull-requests: read
-
-on:
- pull_request:
- branches:
- - main
- - "litellm_**"
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
- cancel-in-progress: ${{ github.event_name == 'pull_request' }}
-
-jobs:
- build-ui:
- runs-on: ubuntu-latest
- timeout-minutes: 10
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- persist-credentials: false
-
- - name: Detect relevant changes
- id: changes
- uses: ./.github/actions/detect-changes
- with:
- category: ui
-
- # Built through the image stage rather than the checkout, because the
- # stage copies ui/litellm-dashboard/ alone: an import reaching above the
- # dashboard root resolves in a checkout and fails in every image we ship.
- # Dockerfile, docker/Dockerfile.non_root and ui/Dockerfile share this
- # stage verbatim, so building one covers all three.
- - name: Build the dashboard as the shipped images build it
- if: steps.changes.outputs.decision != 'skip'
- run: docker build --target ui-builder -f Dockerfile .
diff --git a/.github/workflows/test-litellm-ui-lint.yml b/.github/workflows/test-litellm-ui-lint.yml
deleted file mode 100644
index 9ea5100e21b..00000000000
--- a/.github/workflows/test-litellm-ui-lint.yml
+++ /dev/null
@@ -1,105 +0,0 @@
-name: UI Lint
-permissions:
- contents: read
-
-on:
- pull_request:
- branches:
- - main
- - "litellm_**"
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
- cancel-in-progress: ${{ github.event_name == 'pull_request' }}
-
-jobs:
- frontend-lint:
- runs-on: ubuntu-latest
- timeout-minutes: 8
- defaults:
- run:
- working-directory: ui/litellm-dashboard
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- fetch-depth: 1
- persist-credentials: false
-
- - name: Collect changed files
- id: changed
- env:
- GH_TOKEN: ${{ github.token }}
- BASE_SHA: ${{ github.event.pull_request.base.sha }}
- HEAD_SHA: ${{ github.event.pull_request.head.sha }}
- run: |
- # base.sha is the base branch tip from when the PR was opened, while
- # actions/checkout leaves HEAD on a merge of the PR into the *current*
- # base tip. "$BASE_SHA"...HEAD therefore spans every base-branch commit
- # landed since, so a PR that touches no UI file still gets linted
- # against hundreds of other people's files. Diff the PR head against its
- # own merge base instead, which is exactly what this PR changed.
- merge_base=$(gh api "repos/${{ github.repository }}/compare/${BASE_SHA}...${HEAD_SHA}?per_page=1" --jq '.merge_base_commit.sha')
- test -n "$merge_base"
- git fetch --no-tags --depth=1 origin "$merge_base" "$HEAD_SHA"
- : > "$RUNNER_TEMP/prettier_files.txt"
- : > "$RUNNER_TEMP/eslint_files.txt"
- while IFS= read -r f; do
- [ -f "$f" ] || continue
- case "$f" in
- *.js | *.jsx | *.ts | *.tsx | *.mjs | *.cjs)
- printf '%s\n' "$f" >> "$RUNNER_TEMP/prettier_files.txt"
- printf '%s\n' "$f" >> "$RUNNER_TEMP/eslint_files.txt" ;;
- *.json | *.css | *.scss | *.md | *.mdx | *.yml | *.yaml | *.html)
- printf '%s\n' "$f" >> "$RUNNER_TEMP/prettier_files.txt" ;;
- esac
- done < <(git diff --name-only --diff-filter=ACMR --relative "$merge_base" "$HEAD_SHA" -- .)
- if [ -s "$RUNNER_TEMP/prettier_files.txt" ] || [ -s "$RUNNER_TEMP/eslint_files.txt" ]; then
- echo "has_files=true" >> "$GITHUB_OUTPUT"
- else
- echo "has_files=false" >> "$GITHUB_OUTPUT"
- echo "No lintable UI files changed in this PR; nothing to check."
- fi
-
- - name: Setup Node.js
- if: steps.changed.outputs.has_files == 'true'
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
- with:
- node-version-file: ui/litellm-dashboard/.nvmrc
- cache: "npm"
- cache-dependency-path: ui/litellm-dashboard/package-lock.json
-
- - name: Install dependencies
- if: steps.changed.outputs.has_files == 'true'
- run: npm ci
-
- - name: Lint changed files (prettier + eslint)
- if: steps.changed.outputs.has_files == 'true'
- run: |
- prettier_files=()
- eslint_files=()
- while IFS= read -r f; do prettier_files+=("$f"); done < "$RUNNER_TEMP/prettier_files.txt"
- while IFS= read -r f; do eslint_files+=("$f"); done < "$RUNNER_TEMP/eslint_files.txt"
- status=0
- if [ ${#prettier_files[@]} -gt 0 ]; then
- echo "::group::Prettier (${#prettier_files[@]} files)"
- npx prettier --check "${prettier_files[@]}" || { status=1; echo "::error::Unformatted files. Fix with: npm run format"; }
- echo "::endgroup::"
- fi
- if [ ${#eslint_files[@]} -gt 0 ]; then
- echo "::group::ESLint (${#eslint_files[@]} files)"
- npx eslint --no-warn-ignored --pass-on-unpruned-suppressions "${eslint_files[@]}" || status=1
- echo "::endgroup::"
- fi
- exit $status
-
- - name: Check lint budgets
- if: ${{ !cancelled() && steps.changed.outputs.has_files == 'true' }}
- run: |
- npx eslint . -f json -o "$RUNNER_TEMP/lint-report.json" || true
- node scripts/check-lint-budgets.mjs "$RUNNER_TEMP/lint-report.json" eslint-budgets.json
-
- - name: Check for dead code (knip)
- if: ${{ !cancelled() && steps.changed.outputs.has_files == 'true' }}
- run: npm run knip:ci
diff --git a/.github/workflows/test-litellm-ui-unit.yml b/.github/workflows/test-litellm-ui-unit.yml
deleted file mode 100644
index fcd61cedd50..00000000000
--- a/.github/workflows/test-litellm-ui-unit.yml
+++ /dev/null
@@ -1,99 +0,0 @@
-name: UI Unit Tests
-permissions:
- contents: read
- pull-requests: read
-
-on:
- pull_request:
- branches:
- - main
- - "litellm_**"
- push:
- branches:
- - main
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
-
-jobs:
- ui-unit-tests:
- runs-on: ubuntu-latest-16-cores
- timeout-minutes: 20
- defaults:
- run:
- working-directory: ui/litellm-dashboard
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- fetch-depth: 1
- persist-credentials: false
-
- - name: Detect relevant changes
- id: changes
- uses: ./.github/actions/detect-changes
- with:
- category: ui
-
- - name: Setup Node.js
- if: steps.changes.outputs.decision != 'skip'
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
- with:
- node-version-file: ui/litellm-dashboard/.nvmrc
- cache: "npm"
- cache-dependency-path: ui/litellm-dashboard/package-lock.json
-
- - name: Install dependencies
- if: steps.changes.outputs.decision != 'skip'
- run: npm ci
-
- - name: Check UI production source types
- if: steps.changes.outputs.decision != 'skip'
- run: npm run typecheck
-
- - name: Run UI type tests (Vitest)
- if: steps.changes.outputs.decision != 'skip'
- env:
- CI: "true"
- run: npm run test:types
-
- - name: Run UI unit tests (Vitest)
- if: steps.changes.outputs.decision != 'skip'
- env:
- CI: "true"
- GH_TOKEN: ${{ github.token }}
- BASE_SHA: ${{ github.event.pull_request.base.sha }}
- HEAD_SHA: ${{ github.event.pull_request.head.sha }}
- run: |
- full_suite() { npm run test -- --run --pool forks --maxWorkers=14; }
-
- if [ -z "$BASE_SHA" ]; then
- echo "Push to $GITHUB_REF_NAME: running the full suite"
- full_suite
- exit 0
- fi
-
- merge_base=$(gh api "repos/${{ github.repository }}/compare/${BASE_SHA}...${HEAD_SHA}?per_page=1" --jq '.merge_base_commit.sha')
- test -n "$merge_base"
- git fetch --no-tags --depth=1 origin "$merge_base" "$HEAD_SHA"
- changed_files=()
- while IFS= read -r f; do
- changed_files+=("$f")
- done < <(git diff --name-only --relative "$merge_base" "$HEAD_SHA" -- .)
- if [ ${#changed_files[@]} -eq 0 ]; then
- echo "No UI files changed in this PR; skipping unit tests."
- exit 0
- fi
-
- scope=$(printf '%s\n' "${changed_files[@]}" | bash "$GITHUB_WORKSPACE/.github/scripts/select_ui_test_scope.sh")
- if [ "$scope" != related ]; then
- echo "Pull request: ${#changed_files[@]} changed UI files reach outside src/, so related would miss their dependents; running the full suite"
- full_suite
- exit 0
- fi
-
- echo "Pull request: running tests related to ${#changed_files[@]} changed UI files"
- npm run test -- related "${changed_files[@]}" --run --passWithNoTests \
- --pool forks --maxWorkers=14
diff --git a/.github/workflows/test-mcp-dependency-resolution.yml b/.github/workflows/test-mcp-dependency-resolution.yml
index 1772bdeabc6..3f0d1260e49 100644
--- a/.github/workflows/test-mcp-dependency-resolution.yml
+++ b/.github/workflows/test-mcp-dependency-resolution.yml
@@ -13,6 +13,9 @@ on:
- "rust-toolchain.toml"
- "litellm-rust/**"
- "litellm/__init__.py"
+ - "litellm/_version.py"
+ - "litellm/_lazy_imports_registry.py"
+ - "scripts/build_core_distribution.py"
- "litellm/proxy/proxy_server.py"
- "litellm/**/*mcp*"
- "litellm/**/*mcp*/**"
@@ -86,6 +89,7 @@ jobs:
uv pip check --python ".venv-$extra"
if [ "$extra" = core ]; then
checker=("$GITHUB_WORKSPACE/tests/base_sdk_tests/check_base_sdk_install.py")
+ (cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/.venv-$extra/bin/python" -I "$GITHUB_WORKSPACE/tests/base_sdk_tests/check_sdk_http.py")
else
checker=("$GITHUB_WORKSPACE/scripts/check_mcp_sdk_install.py" --extra "$extra")
fi
@@ -115,3 +119,63 @@ jobs:
fi
(cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/.venv-lowest-$extra/bin/python" "${checker[@]}")
done
+
+ core-distribution:
+ permissions:
+ contents: read
+ id-token: write
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ env:
+ UV_PYTHON: ${{ matrix.python-version }}
+ LITELLM_LOCAL_MODEL_COST_MAP: "True"
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
+ steps:
+ - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ persist-credentials: false
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: ${{ matrix.python-version }}
+ - uses: ./.github/actions/setup-uv-with-retries
+ with:
+ version: "0.10.9"
+ - uses: ./.github/actions/cache-cargo-build
+ - name: Build the core wheel and source distribution
+ run: >-
+ uv run --no-project --with coverage==7.14.0 python -m coverage run --rcfile=/dev/null
+ --branch --include="*/scripts/build_core_distribution.py"
+ scripts/build_core_distribution.py --out-dir dist/core
+ - name: Verify metadata, resources, and independent source rebuild
+ env:
+ CORE_DISTRIBUTION_DIR: ${{ github.workspace }}/dist/core
+ run: >-
+ uv run --no-project --with pytest==9.0.3 --with pytest-cov==5.0.0 --with coverage==7.14.0 --with 'tomli==2.4.1; python_version < "3.11"'
+ python -m pytest tests/base_sdk_tests/test_core_distribution.py -v
+ --cov-config=/dev/null --cov=scripts.build_core_distribution --cov-branch --cov-append --cov-report=
+ - name: Verify isolated core installations
+ run: |
+ wheel=$(realpath dist/core/litellm_core-*.whl)
+ for resolution in highest lowest-direct; do
+ uv venv --python ${{ matrix.python-version }} ".venv-core-$resolution"
+ uv pip install --python ".venv-core-$resolution" --resolution "$resolution" "$wheel"
+ uv pip check --python ".venv-core-$resolution"
+ (cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/.venv-core-$resolution/bin/python" -I "$GITHUB_WORKSPACE/tests/base_sdk_tests/check_base_sdk_install.py" --profile core)
+ (cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/.venv-core-$resolution/bin/python" -I "$GITHUB_WORKSPACE/tests/base_sdk_tests/check_sdk_http.py")
+ done
+ uv pip install --python .venv-core-highest coverage==7.14.0
+ .venv-core-highest/bin/python -I -m coverage run --rcfile=/dev/null --append --branch --include="*/tests/base_sdk_tests/check_base_sdk_install.py" tests/base_sdk_tests/check_base_sdk_install.py --profile core
+ uv pip install --python .venv-core-highest boto3 tokenizers huggingface-hub
+ (cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/.venv-core-highest/bin/python" -I "$GITHUB_WORKSPACE/tests/base_sdk_tests/check_base_sdk_install.py" --profile dependencies)
+ (cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/.venv-core-highest/bin/python" -I "$GITHUB_WORKSPACE/tests/base_sdk_tests/check_sdk_http.py")
+ uv run --no-project --with coverage==7.14.0 python -m coverage xml --rcfile=/dev/null -o coverage-core.xml
+ - name: Upload core packaging coverage
+ uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5
+ with:
+ use_oidc: true
+ files: coverage-core.xml
+ flags: core-packaging
+ fail_ci_if_error: false
diff --git a/.github/workflows/test-merge-smoke.yml b/.github/workflows/test-merge-smoke.yml
index 910763c6af2..0d1cc6596de 100644
--- a/.github/workflows/test-merge-smoke.yml
+++ b/.github/workflows/test-merge-smoke.yml
@@ -1,4 +1,4 @@
-name: Merge smoke checks
+name: smoke
on:
pull_request:
@@ -14,7 +14,7 @@ concurrency:
jobs:
dashboard-build:
- name: Dashboard build
+ name: dashboard-build
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
@@ -27,7 +27,7 @@ jobs:
run: docker build --target ui-builder -f Dockerfile .
core-checks:
- name: Core checks (Python ${{ matrix.python-version }})
+ name: py${{ matrix.python-version }}
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
@@ -79,9 +79,6 @@ jobs:
- name: Proxy startup check
run: $IN_NETNS .venv/bin/python .github/scripts/run_merge_smoke.py proxy-startup --diagnostics-dir "${RUNNER_TEMP}/smoke-diagnostics"
- - name: Run curated smoke cases
- run: $IN_NETNS .venv/bin/python .github/scripts/run_merge_smoke.py pytest --manifest .github/merge-smoke-tests.json
-
- name: Upload smoke diagnostics
if: always()
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
@@ -93,3 +90,190 @@ jobs:
- name: Remove the network namespace
if: always()
run: sudo ip netns delete smoke
+
+ all-extras:
+ name: all-extras-py${{ matrix.python-version }}
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.12", "3.13"]
+ env:
+ LITELLM_LOCAL_MODEL_COST_MAP: "True"
+ steps:
+ - name: Checkout
+ uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Set up uv
+ uses: ./.github/actions/setup-uv-with-retries
+ with:
+ version: "0.10.9"
+
+ - name: Install every extra and dependency group
+ run: .github/scripts/uv_sync_with_retries.sh --frozen --all-groups --all-extras --python ${{ matrix.python-version }}
+
+ - name: Import the SDK, the proxy server and the enterprise package
+ run: .venv/bin/python -c "import litellm, litellm.proxy.proxy_server, litellm_enterprise"
+
+ windows-sdk:
+ name: windows-sdk
+ runs-on: windows-2025
+ timeout-minutes: 10
+ env:
+ LITELLM_LOCAL_MODEL_COST_MAP: "True"
+ defaults:
+ run:
+ shell: bash
+ steps:
+ - name: Checkout
+ uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: "3.11"
+
+ - name: Set up uv
+ uses: ./.github/actions/setup-uv-with-retries
+ with:
+ version: "0.10.9"
+
+ - name: Install dependencies
+ run: .github/scripts/uv_sync_with_retries.sh --frozen --no-install-workspace --group dev --python 3.11
+
+ - name: Run the Windows tests
+ run: uv run --no-sync python -m pytest --tb=short -v tests/windows_tests
+
+ proxy-build-from-pip:
+ name: proxy-build-from-pip
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ services:
+ postgres:
+ image: postgres:14@sha256:6a70deda415ec296f977890e11aba04a0db9f632a362e3fce45e845e3db74f26
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: circle_test
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U postgres"
+ --health-interval 2s
+ --health-timeout 5s
+ --health-retries 30
+ steps:
+ - name: Checkout
+ uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: "3.12"
+
+ - name: Set up uv
+ uses: ./.github/actions/setup-uv-with-retries
+ with:
+ version: "0.10.9"
+
+ - name: Install the test client dependencies
+ run: .github/scripts/uv_sync_with_retries.sh --frozen --no-install-workspace --extra proxy --group dev --python 3.12
+
+ - name: Build the image from the published package
+ run: docker build -t litellm-build-from-pip:smoke -f docker/build_from_pip/Dockerfile.build_from_pip .
+
+ - name: Start the fake OpenAI endpoint
+ run: |
+ nohup .venv/bin/python tests/_fake_openai_endpoint_server.py --host 0.0.0.0 --port 8190 > "${RUNNER_TEMP}/fake-openai.log" 2>&1 &
+ for _ in $(seq 1 30); do
+ curl -sf http://localhost:8190/health > /dev/null && exit 0
+ sleep 1
+ done
+ cat "${RUNNER_TEMP}/fake-openai.log"
+ exit 1
+
+ - name: Generate a master key
+ run: |
+ key="sk-$(openssl rand -hex 16)"
+ echo "::add-mask::${key}"
+ echo "LITELLM_MASTER_KEY=${key}" >> "${GITHUB_ENV}"
+
+ - name: Start the proxy container
+ run: |
+ docker run -d \
+ -p 4000:4000 \
+ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
+ -e LITELLM_MASTER_KEY \
+ -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
+ -e OTEL_EXPORTER=in_memory \
+ -e USE_DDTRACE=True \
+ -e GCS_FLUSH_INTERVAL=1 \
+ -e LITELLM_LOG=ERROR \
+ --add-host host.docker.internal:host-gateway \
+ --name litellm-build-from-pip \
+ -v "${GITHUB_WORKSPACE}/docker/build_from_pip/litellm_config.yaml:/app/config.yaml" \
+ litellm-build-from-pip:smoke \
+ --config /app/config.yaml \
+ --port 4000
+ for _ in $(seq 1 150); do
+ curl -sf --max-time 5 -o /dev/null http://localhost:4000 && exit 0
+ sleep 2
+ done
+ exit 1
+
+ - name: Run the startup tests
+ shell: bash
+ run: |
+ set -euo pipefail
+ base_url=http://localhost:4000
+ readiness_response="$(curl -sS --fail-with-body --write-out $'\n%{http_code}' "${base_url}/health/readiness")"
+ readiness_status="${readiness_response##*$'\n'}"
+ readiness_body="${readiness_response%$'\n'*}"
+ [[ "${readiness_status}" == "200" ]]
+ jq -e '.status == "healthy" or .status == "connected"' <<< "${readiness_body}" > /dev/null
+ liveness_status="$(curl -sS --fail-with-body --output /dev/null --write-out '%{http_code}' "${base_url}/health/liveness")"
+ [[ "${liveness_status}" == "200" ]]
+ completion_response="$(curl -sS --fail-with-body --write-out $'\n%{http_code}' \
+ --header "Authorization: Bearer ${LITELLM_MASTER_KEY}" \
+ --header 'Content-Type: application/json' \
+ --data '{"model":"gpt-4","messages":[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"Hello!"}]}' \
+ "${base_url}/chat/completions")"
+ completion_status="${completion_response##*$'\n'}"
+ completion_body="${completion_response%$'\n'*}"
+ [[ "${completion_status}" == "200" ]]
+ jq -e '
+ (.choices | length) == 1 and
+ .choices[0].message.role == "assistant" and
+ .choices[0].message.content == "Hello! This is a mock response from the fake OpenAI endpoint."
+ ' <<< "${completion_body}" > /dev/null
+
+ - name: Show the proxy logs
+ if: always()
+ run: docker logs litellm-build-from-pip || true
+
+ smoke-passed:
+ name: smoke passed
+ needs: [dashboard-build, core-checks, all-extras, windows-sdk, proxy-build-from-pip]
+ if: always()
+ runs-on: ubuntu-latest
+ timeout-minutes: 2
+ permissions: {}
+ steps:
+ - name: Require every smoke job to succeed
+ env:
+ NEEDS: ${{ toJSON(needs) }}
+ run: |
+ jq -r 'to_entries[] | "\(.key): \(.value.result)"' <<< "$NEEDS"
+ jq -e 'all(.[]; .result == "success")' <<< "$NEEDS" > /dev/null
diff --git a/.github/workflows/test-rust.yml b/.github/workflows/test-rust.yml
index 740cfc222a8..f8a7c38ef79 100644
--- a/.github/workflows/test-rust.yml
+++ b/.github/workflows/test-rust.yml
@@ -7,6 +7,8 @@ on:
- "litellm/rust_bridge/**"
- "scripts/generate_trace_types.py"
- "scripts/trace_codegen/**"
+ - "scripts/lens_assets/**"
+ - "litellm/tracing/**"
- "tests/test_litellm_rust/**"
- "litellm/integrations/custom_logger.py"
- "litellm/litellm_core_utils/litellm_logging.py"
@@ -16,7 +18,6 @@ on:
- "litellm/ocr/**"
- "litellm/llms/base_llm/ocr/**"
- "litellm/llms/custom_httpx/llm_http_handler.py"
- - "tests/test_litellm/conftest.py"
- "Makefile"
- ".cargo/**"
- "pyproject.toml"
@@ -36,6 +37,8 @@ on:
- "litellm/rust_bridge/**"
- "scripts/generate_trace_types.py"
- "scripts/trace_codegen/**"
+ - "scripts/lens_assets/**"
+ - "litellm/tracing/**"
- "tests/test_litellm_rust/**"
- "litellm/integrations/custom_logger.py"
- "litellm/litellm_core_utils/litellm_logging.py"
@@ -45,7 +48,6 @@ on:
- "litellm/ocr/**"
- "litellm/llms/base_llm/ocr/**"
- "litellm/llms/custom_httpx/llm_http_handler.py"
- - "tests/test_litellm/conftest.py"
- "Makefile"
- ".cargo/**"
- "pyproject.toml"
@@ -89,11 +91,14 @@ jobs:
cache-on-failure: true
save-if: ${{ github.ref == 'refs/heads/main' }}
- - run: cargo clippy --workspace --all-targets --locked --features litellm-traces/schema,litellm-traces-clickhouse/schema -- -D warnings
+ - run: cargo clippy --workspace --all-targets --locked -- -D warnings
rust-test:
runs-on: ubuntu-latest
timeout-minutes: 30
+ # Trim debuginfo to keep the job's target dir within the runner's disk
+ env:
+ CARGO_PROFILE_DEV_DEBUG: line-tables-only
defaults:
run:
working-directory: litellm-rust
@@ -132,7 +137,7 @@ jobs:
working-directory: .
run: uv run scripts/generate_trace_types.py --check
- - run: cargo nextest run --workspace --locked --features litellm-traces/schema,litellm-traces-clickhouse/schema
+ - run: cargo nextest run --workspace --locked
- run: cargo test --workspace --doc --locked
diff --git a/.github/workflows/test-semgrep.yml b/.github/workflows/test-semgrep.yml
deleted file mode 100644
index d375824e3c9..00000000000
--- a/.github/workflows/test-semgrep.yml
+++ /dev/null
@@ -1,37 +0,0 @@
-name: Semgrep
-
-on:
- pull_request:
- branches:
- - main
- - "litellm_**"
-
-permissions:
- contents: read
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
-
-jobs:
- semgrep:
- runs-on: ubuntu-latest
- timeout-minutes: 10
-
- steps:
- - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- persist-credentials: false
-
- - name: Set up Python
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
- with:
- python-version: "3.12"
-
- - name: Set up uv
- uses: ./.github/actions/setup-uv-with-retries
- with:
- version: "0.10.9"
-
- - name: Run Semgrep (custom rules)
- run: uv tool run --from 'semgrep==1.157.0' semgrep scan --config .semgrep/rules . --error
diff --git a/.github/workflows/test-unit-documentation.yml b/.github/workflows/test-unit-documentation.yml
deleted file mode 100644
index b042e182802..00000000000
--- a/.github/workflows/test-unit-documentation.yml
+++ /dev/null
@@ -1,103 +0,0 @@
-name: "Unit Tests: Documentation Validation"
-
-on:
- pull_request:
- branches:
- - main
- - "litellm_**"
- push:
- branches:
- - main
-
-permissions:
- contents: read
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
- cancel-in-progress: ${{ github.event_name == 'pull_request' }}
-
-jobs:
- documentation:
- runs-on: ubuntu-latest
- timeout-minutes: 10
- permissions:
- contents: read
- pull-requests: read
-
- steps:
- - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- persist-credentials: false
-
- - name: Detect relevant changes
- id: changes
- uses: ./.github/actions/detect-changes
-
- - name: Checkout litellm-docs into docs/my-website (for documentation_tests)
- if: steps.changes.outputs.decision != 'skip'
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- repository: BerriAI/litellm-docs
- path: docs/my-website
- persist-credentials: false
-
- - name: Set up Python
- if: steps.changes.outputs.decision != 'skip'
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
- with:
- python-version: "3.12"
-
- - name: Set up uv
- if: steps.changes.outputs.decision != 'skip'
- uses: ./.github/actions/setup-uv-with-retries
- with:
- version: "0.10.9"
-
- - name: Cache uv dependencies
- if: steps.changes.outputs.decision != 'skip' && github.ref == 'refs/heads/main'
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
- with:
- path: |
- ~/.cache/uv
- .venv
- key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
- restore-keys: |
- ${{ runner.os }}-uv-
-
- - name: Cache uv dependencies
- if: steps.changes.outputs.decision != 'skip' && github.ref != 'refs/heads/main'
- uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
- with:
- path: |
- ~/.cache/uv
- .venv
- key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
- restore-keys: |
- ${{ runner.os }}-uv-
-
- - name: Cache the Rust build
- if: steps.changes.outputs.decision != 'skip'
- uses: ./.github/actions/cache-cargo-build
-
- - name: Install dependencies
- if: steps.changes.outputs.decision != 'skip'
- run: |
- .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router
-
- - name: Cache Prisma binaries
- if: steps.changes.outputs.decision != 'skip'
- uses: ./.github/actions/cache-prisma-binaries
-
- - name: Generate Prisma client
- if: steps.changes.outputs.decision != 'skip'
- run: |
- uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
-
- # Run the same documentation tests that CircleCI ran (as direct Python scripts)
- - name: Run documentation validation tests
- if: steps.changes.outputs.decision != 'skip'
- run: |
- uv run --no-sync python ./tests/documentation_tests/test_env_keys.py
- uv run --no-sync python ./tests/documentation_tests/test_router_settings.py
- uv run --no-sync python ./tests/documentation_tests/test_api_docs.py
- uv run --no-sync python ./tests/documentation_tests/test_circular_imports.py
diff --git a/.github/workflows/test-unit-proxy-db.yml b/.github/workflows/test-unit-proxy-db.yml
deleted file mode 100644
index ba88464d652..00000000000
--- a/.github/workflows/test-unit-proxy-db.yml
+++ /dev/null
@@ -1,223 +0,0 @@
-name: "Unit Tests: Proxy DB Operations"
-
-on:
- pull_request:
- branches:
- - main
- - "litellm_**"
- push:
- branches:
- - main
-
-permissions:
- contents: read
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
- cancel-in-progress: ${{ github.event_name == 'pull_request' }}
-
-# Semantic matrix: each shard groups tests by concern (auth, server, logging, …)
-# rather than alphabetical letter ranges. Adding a new test file means adding it
-# to whichever group it belongs to, not reshuffling slices.
-#
-# Design targets:
-# * Every shard runs in <= 7 minutes of wall-clock on the default runner.
-# Most of a shard's time is pytest plugin load + xdist worker imports +
-# pytest-cov instrumentation, not the tests themselves. Keeping per-shard
-# work low and matching worker count to runner cores is what controls it.
-# * `timeout` bounds the pytest step only. Checkout, dependency install, and
-# Prisma client generation draw on a separate allowance in the base
-# workflow, so slow setup shows up as a slow job rather than as a
-# cancelled shard whose tests were passing.
-# * workers: 4 matches the 4-core ubuntu-latest runner. -n 8 on 4 cores
-# oversubscribes 2x and workers fight for CPU during their cold-start
-# imports (measured ~441% CPU for -n 8 locally, i.e. ~55% effective).
-# * test_key_generate_prisma.py stays serial (workers=0) — it has event-loop
-# conflicts with the logging worker when run in parallel.
-# * test_proxy_utils.py runs as a single shard with --dist=worksteal so
-# xdist balances its 188 parametrized cases across workers instead of
-# pinning the whole file to one worker (the default --dist=loadscope
-# behavior for single-file targets).
-jobs:
- lens-python-310:
- name: Lens Python 3.10
- permissions:
- contents: read
- id-token: write
- pull-requests: write
- uses: ./.github/workflows/_test-unit-base.yml
- with:
- python-version: "3.10"
- test-path: tests/unit/proxy/lens/test_inference.py
- workers: 0
- reruns: 0
- timeout-minutes: 5
- artifact-name: lens-python-310
-
- # Fast guard — fails the workflow when a test directory or file inside a sharded
- # tree is claimed by no shard. The semantic-shard design has no catch-all bucket,
- # so an unassigned child runs nowhere; assert_ci_coverage.py holds the tree list
- # and reads the same test-path keys the coverage census does.
- assert-shard-coverage:
- runs-on: ubuntu-latest
- timeout-minutes: 2
- permissions:
- contents: read
- steps:
- - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- persist-credentials: false
- - name: Assert every test directory and file is claimed by a shard
- run: python3 .github/scripts/assert_ci_coverage.py --shards
-
- proxy-db:
- needs: assert-shard-coverage
- # Display only the semantic shard name in the checks UI instead of GHA's
- # default "proxy-db (key-generation, tests/unit/proxy/…, 0, loadscope, 20)"
- # which includes every matrix field and gets truncated past the test-path.
- name: ${{ matrix.test-group }}
- permissions:
- contents: read
- id-token: write
- pull-requests: write
- strategy:
- fail-fast: false
- matrix:
- include:
- # Must run serially — event-loop conflict with the logging worker.
- - test-group: key-generation
- test-path: >-
- tests/unit/proxy/management_endpoints/test_key_generate_prisma.py
- workers: 0
- dist: loadscope
- timeout: 20
-
- # ---- auth: split into 2 shards ----
- - test-group: auth-checks
- test-path: >-
- tests/unit/proxy/auth/test_auth_checks.py
- tests/unit/proxy/auth/test_user_api_key_auth.py
- tests/unit/proxy/test_credential_slot_registry.py
- tests/unit/proxy/test_deprecated_key_grace_period.py
- workers: 4
- dist: loadscope
- timeout: 15
- - test-group: jwt-and-keys
- test-path: >-
- tests/unit/proxy/auth/test_jwt.py
- tests/unit/proxy/management_endpoints/test_jwt_key_mapping.py
- tests/unit/proxy/test_proxy_custom_auth.py
- workers: 4
- dist: loadscope
- timeout: 15
-
- # ---- test_proxy_utils.py, single shard, worksteal distribution ----
- - test-group: proxy-utils
- test-path: >-
- tests/unit/proxy/test_proxy_utils.py
- workers: 4
- dist: worksteal
- timeout: 15
-
- # ---- proxy server: split into 2 shards ----
- - test-group: proxy-server-core
- test-path: >-
- tests/proxy_unit_tests/test_proxy_server_gemini_pass_through.py
- tests/unit/proxy/test__lazy_features.py
- tests/unit/proxy/test_aproxy_startup.py
- tests/unit/proxy/test_proxy_server.py
- workers: 4
- dist: loadscope
- timeout: 15
- - test-group: proxy-runtime
- test-path: >-
- tests/unit/proxy/auth/test_multipart_bypass_repro.py
- tests/unit/proxy/auth/test_proxy_routes.py
- tests/unit/proxy/middleware/test_request_size_limit_middleware.py
- tests/unit/proxy/test_proxy_config_unit_test.py
- tests/unit/proxy/test_proxy_token_counter.py
- tests/unit/proxy/test_server_root_path.py
- workers: 4
- dist: loadscope
- timeout: 15
-
- # ---- logging: split into 2 shards ----
- - test-group: custom-logging
- test-path: >-
- tests/proxy_unit_tests/test_proxy_custom_logger.py
- tests/unit/proxy/test_custom_callback_input.py
- tests/unit/proxy/test_custom_logger_s3_gcs.py
- workers: 4
- dist: loadscope
- timeout: 15
- - test-group: logging-misc
- test-path: >-
- tests/unit/proxy/management_helpers/test_audit_logs_proxy.py
- tests/unit/proxy/spend_tracking/test_search_api_logging.py
- tests/unit/proxy/test_proxy_reject_logging.py
- workers: 4
- dist: loadscope
- timeout: 15
-
- - test-group: db-and-spend
- test-path: >-
- tests/unit/proxy/common_utils/test_proxy_encrypt_decrypt.py
- tests/unit/proxy/db/db_transaction_queue/test_e2e_pod_lock_manager.py
- tests/unit/proxy/db/test_update_daily_tag_spend.py
- tests/unit/proxy/test_db_schema_changes.py
- tests/unit/proxy/test_prisma_client_backoff_retry.py
- tests/unit/proxy/test_update_spend.py
- tests/unit/skills/test_skills_db.py
- workers: 4
- dist: loadscope
- timeout: 15
-
- # ---- guardrails + budget + hooks: split into 2 ----
- - test-group: guardrails-hooks
- test-path: >-
- tests/unit/proxy/hooks/test_banned_keyword_list.py
- tests/unit/proxy/test_proxy_setting_guardrails.py
- tests/unit/proxy/test_unit_test_proxy_hooks.py
- workers: 4
- dist: loadscope
- timeout: 15
- - test-group: budgets
- test-path: >-
- tests/unit/proxy/auth/test_default_end_user_budget_simple.py
- tests/unit/proxy/hooks/test_unit_test_max_model_budget_limiter.py
- tests/unit/proxy/test_zero_cost_model_budget_bypass.py
- workers: 4
- dist: loadscope
- timeout: 15
-
- - test-group: endpoints-and-responses
- test-path: >-
- tests/proxy_unit_tests/test_proxy_exception_mapping.py
- tests/unit/proxy/lens
- tests/unit/proxy/auth/test_models_fallback_endpoint.py
- tests/unit/proxy/common_utils/test_check_batch_cost.py
- tests/unit/proxy/common_utils/test_check_responses_cost.py
- tests/unit/proxy/common_utils/test_realtime_cache.py
- tests/unit/proxy/google_endpoints/test_gemini_agents_endpoints.py
- tests/unit/proxy/google_endpoints/test_google_endpoint_routing.py
- tests/unit/proxy/google_endpoints/test_google_gemini_proxy_request.py
- tests/unit/proxy/public_endpoints/test_blog_posts_endpoint.py
- tests/unit/proxy/response_polling
- tests/unit/proxy/test_custom_tokenizer_bug.py
- tests/unit/proxy/test_get_favicon.py
- tests/unit/proxy/test_get_image.py
- tests/unit/proxy/test_prompt_test_endpoint.py
- tests/unit/proxy/test_reducto_ocr_route.py
- tests/unit/proxy/test_response_polling_pre_call_checks.py
- tests/unit/proxy/test_ui_path_detection.py
- workers: 4
- dist: loadscope
- timeout: 15
- uses: ./.github/workflows/_test-unit-base.yml
- with:
- test-path: ${{ matrix.test-path }}
- workers: ${{ matrix.workers }}
- reruns: 2
- timeout-minutes: ${{ matrix.timeout }}
- dist: ${{ matrix.dist }}
- artifact-name: proxy-db-${{ matrix.test-group }}
diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml
index 6f0da90fd66..41823f51280 100644
--- a/.github/workflows/test-unit.yml
+++ b/.github/workflows/test-unit.yml
@@ -1,4 +1,4 @@
-name: "Unit Tests"
+name: unit
on:
pull_request:
@@ -17,31 +17,15 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
-# One caller for every tests/test_litellm shard, replacing the nine thin workflow
-# files that each wrapped a single call to _test-unit-base.yml. Adding a shard is
-# now one matrix entry rather than a new file.
-#
-# `name` is the shard id, and each check reports as " / Run tests".
-# Unit shard names are not required ruleset contexts, so matrix entries can be split freely.
-#
-# Every entry states its timeouts even when they equal the base workflow's
-# defaults. An absent matrix key renders as an empty string, which is not a
-# number, so a partially-specified entry would fail the call rather than fall
-# back to the default.
-#
-# tests/unit/proxy keeps its own caller (test-unit-proxy-db.yml): it is already
-# a matrix and carries a shard-coverage guard that reads that file by name.
-# Folding it in here is a follow-up, together with generalising that guard into
-# assert_ci_coverage.py.
jobs:
rust-bridge:
- name: Build the Rust bridge
- outputs:
- artifact: ${{ steps.rust-bridge-artifact.outputs.name }}
- runs-on: ubuntu-latest
+ name: rust-bridge
permissions:
contents: read
pull-requests: read
+ outputs:
+ artifact: ${{ steps.rust-bridge-artifact.outputs.name }}
+ runs-on: ubuntu-latest
env:
UV_PYTHON: "3.12"
steps:
@@ -124,32 +108,48 @@ jobs:
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
run: echo "name=rust-bridge-${RUN_ID}-${RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
-
- unit:
- name: ${{ matrix.shard }}
- needs: rust-bridge
- if: ${{ !cancelled() }}
+ assert-shard-coverage:
permissions:
contents: read
- id-token: write
- pull-requests: write
+ runs-on: ubuntu-latest
+ timeout-minutes: 2
+ steps:
+ - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ persist-credentials: false
+ - name: Assert every test directory and file is claimed by a shard
+ run: python3 .github/scripts/assert_ci_coverage.py --shards
+ unit:
+ name: ${{ matrix.shard }}
+ needs: [rust-bridge, assert-shard-coverage]
+ if: ${{ !cancelled() }}
+ runs-on: ubuntu-latest
+ timeout-minutes: ${{ matrix.job-timeout-minutes }}
+ permissions:
+ contents: read
+ pull-requests: read
+ env:
+ UV_PYTHON: ${{ matrix.python-version }}
+ LITELLM_LOCAL_MODEL_COST_MAP: "True"
strategy:
fail-fast: false
matrix:
include:
- shard: core-utils
- artifact-name: core-utils
- test-path: >-
+ test-path: |-
+ tests/unit/batch_completion
tests/unit/decisions
tests/unit/litellm_core_utils
+ tests/unit/systemone
+ python-version: "3.12"
workers: 4
reruns: 0
timeout-minutes: 20
job-timeout-minutes: 60
+ dist: loadscope
- shard: enterprise-routing
- artifact-name: enterprise-routing
- test-path: >-
+ test-path: |-
tests/unit/google_genai
tests/unit/router_strategy
tests/unit/router_utils
@@ -165,69 +165,243 @@ jobs:
tests/unit/enterprise/proxy/test_file_deletion_blocking.py
tests/unit/enterprise/proxy/test_managed_files_access_check.py
tests/unit/enterprise/proxy/test_managed_files_hook.py
+ python-version: "3.12"
workers: 4
reruns: 2
timeout-minutes: 20
job-timeout-minutes: 60
+ dist: loadscope
- shard: integrations
- artifact-name: integrations
- test-path: >-
- tests/test_litellm/integrations
- tests/test_litellm/tracing
+ test-path: |-
tests/unit/integrations
+ python-version: "3.12"
workers: 4
reruns: 0
timeout-minutes: 20
job-timeout-minutes: 60
+ dist: loadscope
- - shard: Vertex AI
- artifact-name: llm-vertex-ai
+ - shard: llms-vertex-ai
test-path: >-
tests/unit/llms/vertex_ai
+ python-version: "3.12"
workers: 4
reruns: 0
timeout-minutes: 20
job-timeout-minutes: 60
+ dist: loadscope
- - shard: All Other Providers
- artifact-name: llm-other-providers
+ - shard: llms-anthropic
test-path: >-
- tests/unit/llms
- --ignore=tests/unit/llms/vertex_ai
- --ignore=tests/unit/llms/openai
- --ignore=tests/unit/llms/meta
- --ignore=tests/unit/llms/base_llm/batches/base_batches_config_test.py
+ tests/unit/llms/anthropic
+ python-version: "3.12"
workers: 4
reruns: 0
timeout-minutes: 20
job-timeout-minutes: 60
+ dist: loadscope
- - shard: OpenAI and Meta Providers
- artifact-name: llm-openai-meta
+ - shard: llms-bedrock
+ test-path: |-
+ tests/unit/llms/bedrock
+ tests/unit/llms/bedrock_mantle
+ python-version: "3.12"
+ workers: 4
+ reruns: 0
+ timeout-minutes: 20
+ job-timeout-minutes: 60
+ dist: loadscope
+
+ - shard: llms-providers
+ test-path: |-
+ tests/unit/llms/test_cache_control_and_reasoning.py
+ tests/unit/llms/test_custom_llm.py
+ tests/unit/llms/test_file_content_block.py
+ tests/unit/llms/test_file_search_responses.py
+ tests/unit/llms/test_lifecycle_fix.py
+ tests/unit/llms/test_oss_decision.py
+ tests/unit/llms/test_polling_url_origin_match.py
+ tests/unit/llms/test_predibase_transformation.py
+ tests/unit/llms/a2a
+ tests/unit/llms/aiml
+ tests/unit/llms/aiohttp_openai
+ tests/unit/llms/apiserpent
+ tests/unit/llms/aws_polly
+ tests/unit/llms/azure
+ tests/unit/llms/azure_ai
+ tests/unit/llms/base_llm
+ tests/unit/llms/baseten
+ tests/unit/llms/black_forest_labs
+ tests/unit/llms/brave
+ tests/unit/llms/bytez
+ tests/unit/llms/cerebras
+ tests/unit/llms/chat
+ tests/unit/llms/chatgpt
+ tests/unit/llms/clarifai
+ tests/unit/llms/claude_code
+ tests/unit/llms/cloudflare
+ tests/unit/llms/codex
+ tests/unit/llms/cohere
+ tests/unit/llms/cometapi
+ tests/unit/llms/compactifai
+ tests/unit/llms/crusoe
+ tests/unit/llms/custom_httpx
+ tests/unit/llms/dashscope
+ tests/unit/llms/databricks
+ tests/unit/llms/dataforseo
+ tests/unit/llms/datarobot
+ tests/unit/llms/deepagents
+ tests/unit/llms/deepgram
+ tests/unit/llms/deepinfra
+ tests/unit/llms/deepseek
+ tests/unit/llms/docker_model_runner
+ tests/unit/llms/duckduckgo
+ tests/unit/llms/e2b
+ tests/unit/llms/edenai
+ tests/unit/llms/elevenlabs
+ tests/unit/llms/exa_ai
+ tests/unit/llms/fal_ai
+ tests/unit/llms/fastcrw
+ tests/unit/llms/featherless_ai
+ tests/unit/llms/firecrawl
+ tests/unit/llms/fireworks_ai
+ tests/unit/llms/gdc
+ tests/unit/llms/gemini
+ tests/unit/llms/gigachat
+ tests/unit/llms/google_pse
+ tests/unit/llms/github_copilot
+ tests/unit/llms/gradient_ai
+ tests/unit/llms/groq
+ tests/unit/llms/heroku
+ tests/unit/llms/hosted_vllm
+ tests/unit/llms/huggingface
+ tests/unit/llms/hyperbolic
+ tests/unit/llms/inception
+ tests/unit/llms/infinity
+ tests/unit/llms/jina_ai
+ tests/unit/llms/lambda_ai
+ tests/unit/llms/langflow
+ tests/unit/llms/langgraph
+ tests/unit/llms/laya
+ tests/unit/llms/lemonade
+ tests/unit/llms/linkup
+ tests/unit/llms/litellm_proxy
+ tests/unit/llms/llamafile
+ tests/unit/llms/lm_studio
+ tests/unit/llms/manus
+ tests/unit/llms/meta_llama
+ tests/unit/llms/microsoft_365_copilot
+ tests/unit/llms/milvus
+ tests/unit/llms/minimax
+ tests/unit/llms/mistral
+ tests/unit/llms/modelscope
+ tests/unit/llms/mongodb
+ tests/unit/llms/moonshot
+ tests/unit/llms/nadir
+ tests/unit/llms/nebius
+ tests/unit/llms/neosantara
+ tests/unit/llms/nimble
+ tests/unit/llms/nlp_cloud
+ tests/unit/llms/novita
+ tests/unit/llms/nscale
+ tests/unit/llms/nvidia_nim
+ tests/unit/llms/nvidia_riva
+ tests/unit/llms/oci
+ tests/unit/llms/ocr
+ tests/unit/llms/ollama
+ tests/unit/llms/oobabooga
+ tests/unit/llms/openai_like
+ tests/unit/llms/opencode
+ tests/unit/llms/opensandbox
+ tests/unit/llms/openrouter
+ tests/unit/llms/ovhcloud
+ tests/unit/llms/parallel_ai
+ tests/unit/llms/parasail
+ tests/unit/llms/pass_through
+ tests/unit/llms/perplexity
+ tests/unit/llms/pg_vector
+ tests/unit/llms/publicai
+ tests/unit/llms/ragflow
+ tests/unit/llms/recraft
+ tests/unit/llms/reducto
+ tests/unit/llms/replicate
+ tests/unit/llms/runwayml
+ tests/unit/llms/s3_vectors
+ tests/unit/llms/sagemaker
+ tests/unit/llms/sail
+ tests/unit/llms/sambanova
+ tests/unit/llms/scaledown
+ tests/unit/llms/sap
+ tests/unit/llms/scaleway
+ tests/unit/llms/searchapi
+ tests/unit/llms/searxng
+ tests/unit/llms/serper
+ tests/unit/llms/snowflake
+ tests/unit/llms/soniox
+ tests/unit/llms/stability
+ tests/unit/llms/strands_decider
+ tests/unit/llms/tavily
+ tests/unit/llms/tencent
+ tests/unit/llms/tinyfish
+ tests/unit/llms/together_ai
+ tests/unit/llms/tool_loop
+ tests/unit/llms/triton
+ tests/unit/llms/v0
+ tests/unit/llms/valkey
+ tests/unit/llms/vercel_ai_gateway
+ tests/unit/llms/vllm
+ tests/unit/llms/volcengine
+ tests/unit/llms/voyage
+ tests/unit/llms/wandb
+ tests/unit/llms/watsonx
+ tests/unit/llms/xai
+ tests/unit/llms/xinference
+ tests/unit/llms/you_com
+ tests/unit/llms/zai
+ --ignore=tests/unit/llms/base_llm/batches/base_batches_config_test.py
+ python-version: "3.12"
+ workers: 4
+ reruns: 0
+ timeout-minutes: 20
+ job-timeout-minutes: 60
+ dist: loadscope
+
+ - shard: llms-openai-meta
test-path: >-
tests/unit/llms/openai
tests/unit/llms/meta
+ python-version: "3.12"
workers: 4
reruns: 0
timeout-minutes: 20
job-timeout-minutes: 60
+ dist: loadscope
- - shard: misc
- artifact-name: misc
+ - shard: root
test-path: >-
- tests/test_litellm/test_*.py
tests/unit/test_*.py
+ python-version: "3.12"
workers: 4
reruns: 2
timeout-minutes: 20
job-timeout-minutes: 60
+ dist: loadscope
- - shard: misc-dirs
- artifact-name: misc-dirs
+ - shard: router
test-path: >-
tests/unit/test_router
+ python-version: "3.12"
+ workers: 4
+ reruns: 0
+ timeout-minutes: 20
+ job-timeout-minutes: 60
+ dist: loadscope
+
+ - shard: endpoints
+ test-path: >-
tests/unit/a2a_protocol
+ tests/unit/anthropic_interface
tests/unit/batches
tests/unit/chat_completions
tests/unit/completion_extras
@@ -235,25 +409,60 @@ jobs:
tests/unit/embeddings
tests/unit/endpoints
tests/unit/files
- tests/unit/harness
tests/unit/images
tests/unit/interactions
tests/unit/messages
+ tests/unit/ocr
+ tests/unit/passthrough
tests/unit/rag
+ tests/unit/realtime_api
tests/unit/rerank_api
- tests/unit/rust_bridge
- tests/unit/secret_managers
+ tests/unit/search
tests/unit/vector_stores
tests/unit/videos
- --ignore=tests/unit/rust_bridge/native_route_wheel_test.py
+ python-version: "3.12"
workers: 4
reruns: 0
timeout-minutes: 20
job-timeout-minutes: 60
+ dist: loadscope
+
+ - shard: rust-bridge-harness
+ test-path: >-
+ tests/unit/compression
+ tests/unit/harness
+ tests/unit/rust_bridge
+ tests/unit/sandbox
+ --ignore=tests/unit/rust_bridge/native_route_wheel_test.py
+ python-version: "3.12"
+ workers: 4
+ reruns: 0
+ timeout-minutes: 20
+ job-timeout-minutes: 60
+ dist: loadscope
+
+ - shard: enterprise-repositories-secrets
+ test-path: >-
+ tests/unit/enterprise/enterprise_callbacks/test_callback_controls.py
+ tests/unit/enterprise/enterprise_callbacks/test_llm_guard.py
+ tests/unit/enterprise/enterprise_callbacks/test_pagerduty_alerting.py
+ tests/unit/enterprise/enterprise_callbacks/test_secret_detection.py
+ tests/unit/integration_support
+ tests/unit/models
+ tests/unit/repositories
+ tests/unit/secret_managers
+ tests/unit/skills/test_skills_main.py
+ tests/unit/telemetry tests/unit/proxy/telemetry
+ tests/unit/tracing
+ python-version: "3.12"
+ workers: 2
+ reruns: 0
+ timeout-minutes: 20
+ job-timeout-minutes: 60
+ dist: loadscope
- shard: proxy-auth
- artifact-name: proxy-auth
- test-path: >-
+ test-path: |-
tests/unit/proxy/auth
--ignore=tests/unit/proxy/auth/test_auth_checks.py
--ignore=tests/unit/proxy/auth/test_user_api_key_auth.py
@@ -262,27 +471,29 @@ jobs:
--ignore=tests/unit/proxy/auth/test_models_fallback_endpoint.py
--ignore=tests/unit/proxy/auth/test_multipart_bypass_repro.py
--ignore=tests/unit/proxy/auth/test_proxy_routes.py
+ python-version: "3.12"
workers: 4
reruns: 0
timeout-minutes: 20
job-timeout-minutes: 60
+ dist: loadscope
- shard: proxy-hooks-client
- artifact-name: proxy-hooks-client
- test-path: >-
+ test-path: |-
tests/unit/proxy/hooks
tests/unit/proxy/policy_engine
tests/unit/proxy/client
--ignore=tests/unit/proxy/hooks/test_banned_keyword_list.py
--ignore=tests/unit/proxy/hooks/test_unit_test_max_model_budget_limiter.py
+ python-version: "3.12"
workers: 4
reruns: 0
timeout-minutes: 20
job-timeout-minutes: 60
+ dist: loadscope
- shard: proxy-endpoints
- artifact-name: proxy-endpoints
- test-path: >-
+ test-path: |-
tests/unit/proxy/management_endpoints
tests/unit/proxy/management_helpers
tests/unit/proxy/list_api
@@ -297,14 +508,15 @@ jobs:
--ignore=tests/unit/proxy/management_endpoints/test_key_generate_prisma.py
--ignore=tests/unit/proxy/management_endpoints/test_roi_calculator_endpoints.py
--ignore=tests/unit/proxy/management_helpers/test_audit_logs_proxy.py
+ python-version: "3.12"
workers: 4
reruns: 0
timeout-minutes: 20
job-timeout-minutes: 60
+ dist: loadscope
- shard: proxy-feature-endpoints
- artifact-name: proxy-feature-endpoints
- test-path: >-
+ test-path: |-
tests/unit/proxy/guardrails
--ignore=tests/unit/proxy/google_endpoints/test_gemini_agents_endpoints.py
--ignore=tests/unit/proxy/google_endpoints/test_google_endpoint_routing.py
@@ -333,22 +545,43 @@ jobs:
tests/unit/proxy/ui_crud_endpoints
tests/unit/proxy/config_resolvers
tests/unit/proxy/utils
+ python-version: "3.12"
workers: 4
reruns: 0
timeout-minutes: 20
job-timeout-minutes: 60
+ dist: loadscope
- shard: proxy-server
- artifact-name: proxy-server
- test-path: "tests/unit/proxy/proxy_server"
+ test-path: >-
+ tests/unit/proxy/proxy_server
+ python-version: "3.12"
workers: 4
reruns: 0
timeout-minutes: 60
job-timeout-minutes: 100
+ dist: loadscope
+
+ - shard: mcp-elicitation
+ test-path: |-
+ --override-ini=pythonpath=tests
+ tests/unit/experimental_mcp_client/test_mcp_client.py
+ tests/unit/proxy/_experimental/mcp_server/test_capabilities.py
+ tests/unit/proxy/_experimental/mcp_server/test_interactions.py
+ tests/unit/proxy/_experimental/mcp_server/test_mcp_elicitation_handler.py
+ tests/unit/proxy/_experimental/mcp_server/test_mcp_server_manager.py
+ tests/unit/proxy/_experimental/mcp_server/test_mcp_server_tool_calls_and_headers.py
+ tests/unit/proxy/_experimental/mcp_server/test_operations.py
+ tests/integration/mcp/test_interactions.py
+ python-version: "3.12"
+ workers: 2
+ reruns: 0
+ timeout-minutes: 20
+ job-timeout-minutes: 60
+ dist: loadscope
- shard: proxy-infra
- artifact-name: proxy-infra
- test-path: >-
+ test-path: |-
tests/unit/proxy/db
--ignore=tests/unit/proxy/db/db_transaction_queue/test_e2e_pod_lock_manager.py
--ignore=tests/unit/proxy/db/test_update_daily_tag_spend.py
@@ -357,8 +590,6 @@ jobs:
tests/unit/proxy/spend_tracking
--ignore=tests/unit/proxy/spend_tracking/test_search_api_logging.py
tests/unit/proxy/pass_through_endpoints
- tests/unit/proxy/_experimental
- --ignore=tests/unit/proxy/_experimental/mcp_server
tests/unit/proxy/experimental
tests/unit/proxy/common_utils
--ignore=tests/unit/proxy/common_utils/test_cache_aware_routing.py
@@ -373,14 +604,15 @@ jobs:
tests/unit/proxy/management
tests/unit/proxy/management_endpoints/test_roi_calculator_endpoints.py
tests/unit/proxy/roi_calculator
+ python-version: "3.12"
workers: 4
reruns: 0
timeout-minutes: 20
job-timeout-minutes: 60
+ dist: loadscope
- shard: proxy-infra-root
- artifact-name: proxy-infra-root
- test-path: >-
+ test-path: |-
tests/unit/proxy/test_*.py
--ignore=tests/unit/proxy/test_aproxy_startup.py
--ignore=tests/unit/proxy/test_credential_slot_registry.py
@@ -407,32 +639,35 @@ jobs:
--ignore=tests/unit/proxy/test_unit_test_proxy_hooks.py
--ignore=tests/unit/proxy/test_update_spend.py
--ignore=tests/unit/proxy/test_zero_cost_model_budget_bypass.py
+ python-version: "3.12"
workers: 4
reruns: 0
timeout-minutes: 20
job-timeout-minutes: 60
+ dist: loadscope
- shard: caching-local
- artifact-name: caching-local
test-path: >-
tests/unit/caching
+ python-version: "3.12"
workers: 2
reruns: 0
timeout-minutes: 20
job-timeout-minutes: 60
+ dist: loadscope
- shard: proxy-extras
- artifact-name: proxy-extras
test-path: >-
tests/unit/litellm_proxy_extras
+ python-version: "3.12"
workers: 2
reruns: 0
timeout-minutes: 20
job-timeout-minutes: 60
+ dist: loadscope
- shard: enterprise-package
- artifact-name: enterprise-package
- test-path: >-
+ test-path: |-
tests/unit/enterprise/integrations
tests/unit/enterprise/proxy/auth
tests/unit/enterprise/proxy/guardrails
@@ -440,15 +675,17 @@ jobs:
tests/unit/enterprise/proxy/test_audit_logging_endpoints.py
tests/unit/enterprise/proxy/test_liteadmin.py
tests/unit/enterprise/enterprise_callbacks/test_prometheus_logging_callbacks.py
+ python-version: "3.12"
workers: 4
reruns: 0
timeout-minutes: 20
job-timeout-minutes: 60
+ dist: loadscope
- shard: enterprise-managed-files
- artifact-name: enterprise-managed-files
test-path: >-
tests/unit/enterprise/proxy/hooks
+ python-version: "3.12"
workers: 4
reruns: 0
timeout-minutes: 20
@@ -456,44 +693,845 @@ jobs:
dist: load
- shard: responses-caching-types
- artifact-name: responses-caching-types
- test-path: >-
- tests/unit/responses
+ test-path: |-
+ tests/unit/responses/litellm_completion_transformation
+ tests/unit/responses/test_additional_tools.py
+ tests/unit/responses/test_custom_tool_call.py
+ tests/unit/responses/test_dispatch.py
+ tests/unit/responses/test_metadata_codex_callback.py
+ tests/unit/responses/test_no_duplicate_spend_logs.py
+ tests/unit/responses/test_null_test_fix.py
+ tests/unit/responses/test_responses_api_bridge_flag.py
+ tests/unit/responses/test_responses_api_lifecycle.py
+ tests/unit/responses/test_responses_api_request_body.py
+ tests/unit/responses/test_responses_prompt_management.py
+ tests/unit/responses/test_responses_router_cooldown.py
+ tests/unit/responses/test_responses_streaming_iterator.py
+ tests/unit/responses/test_responses_supported_endpoints_passthrough.py
+ tests/unit/responses/test_responses_utils.py
+ tests/unit/responses/test_responses_websocket_all_providers.py
+ tests/unit/responses/test_rust_bridge_websocket.py
+ tests/unit/responses/test_sse_output_recovery.py
+ tests/unit/responses/test_streaming_iterator.py
+ tests/unit/responses/test_streaming_iterator_error_events.py
+ tests/unit/responses/test_text_format_conversion.py
tests/unit/types
- --ignore=tests/unit/responses/mcp
+ python-version: "3.12"
workers: 2
reruns: 0
timeout-minutes: 20
job-timeout-minutes: 60
+ dist: loadscope
- - shard: unit
- artifact-name: unit
+ - shard: key-generation
test-path: >-
- tests/unit/anthropic_interface
- tests/unit/compression
- tests/unit/enterprise/enterprise_callbacks/test_callback_controls.py
- tests/unit/enterprise/enterprise_callbacks/test_llm_guard.py
- tests/unit/enterprise/enterprise_callbacks/test_secret_detection.py
- tests/unit/integration_support
- tests/unit/models
- tests/unit/ocr
- tests/unit/passthrough
- tests/unit/realtime_api
- tests/unit/repositories
- tests/unit/sandbox
- tests/unit/skills/test_skills_main.py
- tests/unit/tracing
- workers: 2
+ tests/unit/proxy/management_endpoints/test_key_generate_prisma.py
+ python-version: "3.12"
+ workers: 0
+ reruns: 2
+ timeout-minutes: 20
+ job-timeout-minutes: 60
+ dist: loadscope
+
+ - shard: auth-checks
+ test-path: |-
+ tests/unit/proxy/auth/test_auth_checks.py
+ tests/unit/proxy/auth/test_user_api_key_auth.py
+ tests/unit/proxy/test_credential_slot_registry.py
+ tests/unit/proxy/test_deprecated_key_grace_period.py
+ python-version: "3.12"
+ workers: 4
+ reruns: 2
+ timeout-minutes: 15
+ job-timeout-minutes: 60
+ dist: loadscope
+
+ - shard: jwt-and-keys
+ test-path: |-
+ tests/unit/proxy/auth/test_jwt.py
+ tests/unit/proxy/management_endpoints/test_jwt_key_mapping.py
+ tests/unit/proxy/test_proxy_custom_auth.py
+ python-version: "3.12"
+ workers: 4
+ reruns: 2
+ timeout-minutes: 15
+ job-timeout-minutes: 60
+ dist: loadscope
+
+ - shard: proxy-utils
+ test-path: >-
+ tests/unit/proxy/test_proxy_utils.py
+ python-version: "3.12"
+ workers: 4
+ reruns: 2
+ timeout-minutes: 15
+ job-timeout-minutes: 60
+ dist: worksteal
+
+ - shard: proxy-server-core
+ test-path: |-
+ tests/unit/proxy/pass_through_endpoints/test_llm_passthrough_endpoints.py
+ tests/unit/proxy/test__lazy_features.py
+ tests/unit/proxy/test_aproxy_startup.py
+ tests/unit/proxy/test_proxy_server.py
+ python-version: "3.12"
+ workers: 4
+ reruns: 2
+ timeout-minutes: 15
+ job-timeout-minutes: 60
+ dist: loadscope
+
+ - shard: proxy-runtime
+ test-path: |-
+ tests/unit/proxy/auth/test_multipart_bypass_repro.py
+ tests/unit/proxy/auth/test_proxy_routes.py
+ tests/unit/proxy/middleware/test_request_size_limit_middleware.py
+ tests/unit/proxy/test_proxy_config_unit_test.py
+ tests/unit/proxy/test_proxy_token_counter.py
+ tests/unit/proxy/test_server_root_path.py
+ python-version: "3.12"
+ workers: 4
+ reruns: 2
+ timeout-minutes: 15
+ job-timeout-minutes: 60
+ dist: loadscope
+
+ - shard: mcp-oauth
+ test-path: |-
+ tests/unit/proxy/_experimental/mcp_server/test_discoverable_endpoints.py
+ tests/unit/proxy/_experimental/mcp_server/test_mcp_server_manager.py
+ tests/unit/proxy/_experimental/mcp_server/test_db_credentials.py
+ tests/unit/proxy/_experimental/mcp_server/test_gateway_dcr_flow.py
+ tests/unit/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_tools.py
+ tests/unit/proxy/_experimental/mcp_server/test_mcp_max_concurrent_requests.py
+ tests/unit/proxy/_experimental/mcp_server/faults/test_list_outcomes.py
+ tests/unit/proxy/_experimental/mcp_server/outbound_credentials
+ python-version: "3.12"
+ workers: 4
+ reruns: 2
+ timeout-minutes: 15
+ job-timeout-minutes: 60
+ dist: loadscope
+
+ - shard: mcp-server
+ test-path: |-
+ tests/unit/experimental_mcp_client
+ tests/unit/proxy/_experimental/mcp_server
+ tests/unit/responses/mcp
+ --ignore=tests/unit/experimental_mcp_client/test_mcp_client.py
+ --ignore=tests/unit/proxy/_experimental/mcp_server/test_capabilities.py
+ --ignore=tests/unit/proxy/_experimental/mcp_server/test_interactions.py
+ --ignore=tests/unit/proxy/_experimental/mcp_server/test_mcp_elicitation_handler.py
+ --ignore=tests/unit/proxy/_experimental/mcp_server/test_mcp_server_manager.py
+ --ignore=tests/unit/proxy/_experimental/mcp_server/test_mcp_server_tool_calls_and_headers.py
+ --ignore=tests/unit/proxy/_experimental/mcp_server/test_operations.py
+ --ignore=tests/unit/proxy/_experimental/mcp_server/test_discoverable_endpoints.py
+ --ignore=tests/unit/proxy/_experimental/mcp_server/test_db_credentials.py
+ --ignore=tests/unit/proxy/_experimental/mcp_server/outbound_credentials
+ python-version: "3.12"
+ workers: 4
reruns: 0
timeout-minutes: 20
job-timeout-minutes: 60
- uses: ./.github/workflows/_test-unit-base.yml
- with:
- rust-bridge-artifact: ${{ needs.rust-bridge.outputs.artifact }}
- test-path: ${{ matrix.test-path }}
- workers: ${{ matrix.workers }}
- reruns: ${{ matrix.reruns }}
- timeout-minutes: ${{ matrix.timeout-minutes }}
- job-timeout-minutes: ${{ matrix.job-timeout-minutes }}
- dist: ${{ matrix.dist || 'loadscope' }}
- artifact-name: ${{ matrix.artifact-name }}
+ dist: loadscope
+
+ - shard: custom-logging
+ test-path: |-
+ tests/unit/proxy/test_proxy_server_custom_logger.py
+ tests/unit/proxy/test_custom_callback_input.py
+ tests/unit/proxy/test_custom_logger_s3_gcs.py
+ python-version: "3.12"
+ workers: 4
+ reruns: 2
+ timeout-minutes: 15
+ job-timeout-minutes: 60
+ dist: loadscope
+
+ - shard: logging-misc
+ test-path: |-
+ tests/unit/proxy/management_helpers/test_audit_logs_proxy.py
+ tests/unit/proxy/spend_tracking/test_search_api_logging.py
+ tests/unit/proxy/test_proxy_reject_logging.py
+ python-version: "3.12"
+ workers: 4
+ reruns: 2
+ timeout-minutes: 15
+ job-timeout-minutes: 60
+ dist: loadscope
+
+ - shard: db-and-spend
+ test-path: |-
+ tests/unit/proxy/common_utils/test_proxy_encrypt_decrypt.py
+ tests/unit/proxy/db/db_transaction_queue/test_e2e_pod_lock_manager.py
+ tests/unit/proxy/db/test_update_daily_tag_spend.py
+ tests/unit/proxy/test_db_schema_changes.py
+ tests/unit/proxy/test_prisma_client_backoff_retry.py
+ tests/unit/proxy/test_update_spend.py
+ tests/unit/skills/test_skills_db.py
+ python-version: "3.12"
+ workers: 4
+ reruns: 2
+ timeout-minutes: 15
+ job-timeout-minutes: 60
+ dist: loadscope
+
+ - shard: guardrails-hooks
+ test-path: |-
+ tests/unit/proxy/hooks/test_banned_keyword_list.py
+ tests/unit/proxy/test_proxy_setting_guardrails.py
+ tests/unit/proxy/test_unit_test_proxy_hooks.py
+ python-version: "3.12"
+ workers: 4
+ reruns: 2
+ timeout-minutes: 15
+ job-timeout-minutes: 60
+ dist: loadscope
+
+ - shard: budgets
+ test-path: |-
+ tests/unit/proxy/auth/test_default_end_user_budget_simple.py
+ tests/unit/proxy/hooks/test_unit_test_max_model_budget_limiter.py
+ tests/unit/proxy/test_zero_cost_model_budget_bypass.py
+ python-version: "3.12"
+ workers: 4
+ reruns: 2
+ timeout-minutes: 15
+ job-timeout-minutes: 60
+ dist: loadscope
+
+ - shard: endpoints-and-responses
+ test-path: |-
+ tests/unit/proxy/test_proxy_server_exception_mapping.py
+ tests/unit/proxy/lens
+ tests/unit/proxy/auth/test_models_fallback_endpoint.py
+ tests/unit/proxy/common_utils/test_check_batch_cost.py
+ tests/unit/proxy/common_utils/test_check_responses_cost.py
+ tests/unit/proxy/common_utils/test_realtime_cache.py
+ tests/unit/proxy/google_endpoints/test_gemini_agents_endpoints.py
+ tests/unit/proxy/google_endpoints/test_google_endpoint_routing.py
+ tests/unit/proxy/google_endpoints/test_google_gemini_proxy_request.py
+ tests/unit/proxy/public_endpoints/test_blog_posts_endpoint.py
+ tests/unit/proxy/response_polling
+ tests/unit/proxy/test_custom_tokenizer_bug.py
+ tests/unit/proxy/test_get_favicon.py
+ tests/unit/proxy/test_get_image.py
+ tests/unit/proxy/test_prompt_test_endpoint.py
+ tests/unit/proxy/test_reducto_ocr_route.py
+ tests/unit/proxy/test_response_polling_pre_call_checks.py
+ tests/unit/proxy/test_ui_path_detection.py
+ python-version: "3.12"
+ workers: 4
+ reruns: 2
+ timeout-minutes: 15
+ job-timeout-minutes: 60
+ dist: loadscope
+
+ - shard: lens-python-310
+ test-path: >-
+ tests/unit/proxy/lens
+ python-version: "3.10"
+ workers: 0
+ reruns: 0
+ timeout-minutes: 5
+ job-timeout-minutes: 60
+ dist: loadscope
+ steps:
+ - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ timeout-minutes: 3
+ with:
+ persist-credentials: false
+ fetch-depth: ${{ matrix.shard == 'db-and-spend' && '0' || '1' }}
+
+ - 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: ${{ needs.rust-bridge.outputs.artifact }}
+
+ - name: Install dependencies and check schema
+ if: steps.changes.outputs.decision != 'skip'
+ timeout-minutes: 8
+ env:
+ RUST_BRIDGE_ARTIFACT: ${{ needs.rust-bridge.outputs.artifact }}
+ UNIT_SHARD: ${{ matrix.shard }}
+ SCHEMA_BASE_REF: origin/${{ github.event.repository.default_branch }}
+ 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 --extra utils
+ 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 --extra utils --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"]'
+ if [ "$UNIT_SHARD" = "db-and-spend" ]; then
+ uv run --no-sync python scripts/check_schema_compatibility.py --base-ref "$SCHEMA_BASE_REF"
+ fi
+
+ - 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: ${{ matrix.timeout-minutes }}
+ env:
+ TEST_PATH: ${{ matrix.test-path }}
+ MAX_FAILURES: "10"
+ WORKERS: ${{ matrix.workers }}
+ RERUNS: ${{ matrix.reruns }}
+ TEST_TIMEOUT_SECONDS: "120"
+ DIST: ${{ matrix.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-${{ matrix.shard }}-${{ github.run_id }}-${{ github.run_attempt }}
+ path: coverage.xml
+ retention-days: 1
+ ui-unit:
+ name: ui-unit
+ permissions:
+ contents: read
+ pull-requests: read
+ runs-on: ubuntu-latest-16-cores
+ timeout-minutes: 20
+ defaults:
+ run:
+ working-directory: ui/litellm-dashboard
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ fetch-depth: 1
+ persist-credentials: false
+
+ - name: Detect relevant changes
+ id: changes
+ uses: ./.github/actions/detect-changes
+ with:
+ category: ui
+
+ - name: Setup Node.js
+ if: steps.changes.outputs.decision != 'skip'
+ uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
+ with:
+ node-version-file: ui/litellm-dashboard/.nvmrc
+ cache: "npm"
+ cache-dependency-path: ui/litellm-dashboard/package-lock.json
+
+ - name: Install dependencies
+ if: steps.changes.outputs.decision != 'skip'
+ run: npm ci
+
+ - name: Check UI production source types
+ if: steps.changes.outputs.decision != 'skip'
+ run: npm run typecheck
+
+ - name: Run UI type tests (Vitest)
+ if: steps.changes.outputs.decision != 'skip'
+ env:
+ CI: "true"
+ run: npm run test:types
+
+ - name: Run UI unit tests (Vitest)
+ if: steps.changes.outputs.decision != 'skip'
+ env:
+ CI: "true"
+ GH_TOKEN: ${{ github.token }}
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ run: |
+ full_suite() { npm run test -- --run --pool forks --maxWorkers=14; }
+
+ if [ -z "$BASE_SHA" ]; then
+ echo "Push to $GITHUB_REF_NAME: running the full suite"
+ full_suite
+ exit 0
+ fi
+
+ merge_base=$(gh api "repos/${{ github.repository }}/compare/${BASE_SHA}...${HEAD_SHA}?per_page=1" --jq '.merge_base_commit.sha')
+ test -n "$merge_base"
+ git fetch --no-tags --depth=1 origin "$merge_base" "$HEAD_SHA"
+ changed_files=()
+ while IFS= read -r f; do
+ changed_files+=("$f")
+ done < <(git diff --name-only --relative "$merge_base" "$HEAD_SHA" -- .)
+ if [ ${#changed_files[@]} -eq 0 ]; then
+ echo "No UI files changed in this PR; skipping unit tests."
+ exit 0
+ fi
+
+ scope=$(printf '%s\n' "${changed_files[@]}" | bash "$GITHUB_WORKSPACE/.github/scripts/select_ui_test_scope.sh")
+ if [ "$scope" != related ]; then
+ echo "Pull request: ${#changed_files[@]} changed UI files reach outside src/, so related would miss their dependents; running the full suite"
+ full_suite
+ exit 0
+ fi
+
+ echo "Pull request: running tests related to ${#changed_files[@]} changed UI files"
+ npm run test -- related "${changed_files[@]}" --run --passWithNoTests \
+ --pool forks --maxWorkers=14
+ docs:
+ name: docs
+ permissions:
+ contents: read
+ pull-requests: read
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ persist-credentials: false
+
+ - name: Detect relevant changes
+ id: changes
+ uses: ./.github/actions/detect-changes
+
+ - name: Checkout litellm-docs into docs/my-website (for documentation_tests)
+ if: steps.changes.outputs.decision != 'skip'
+ uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ repository: BerriAI/litellm-docs
+ path: docs/my-website
+ persist-credentials: false
+
+ - name: Set up Python
+ if: steps.changes.outputs.decision != 'skip'
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: "3.12"
+
+ - name: Set up uv
+ if: steps.changes.outputs.decision != 'skip'
+ uses: ./.github/actions/setup-uv-with-retries
+ with:
+ version: "0.10.9"
+
+ - name: Cache uv dependencies
+ if: steps.changes.outputs.decision != 'skip' && github.ref == 'refs/heads/main'
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: |
+ ~/.cache/uv
+ .venv
+ key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-uv-
+
+ - name: Cache uv dependencies
+ if: steps.changes.outputs.decision != 'skip' && github.ref != 'refs/heads/main'
+ uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: |
+ ~/.cache/uv
+ .venv
+ key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-uv-
+
+ - name: Cache the Rust build
+ if: steps.changes.outputs.decision != 'skip'
+ uses: ./.github/actions/cache-cargo-build
+
+ - name: Install dependencies
+ if: steps.changes.outputs.decision != 'skip'
+ run: |
+ .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router
+
+ - name: Cache Prisma binaries
+ if: steps.changes.outputs.decision != 'skip'
+ uses: ./.github/actions/cache-prisma-binaries
+
+ - name: Generate Prisma client
+ if: steps.changes.outputs.decision != 'skip'
+ run: |
+ uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
+
+ - name: Run documentation validation tests
+ if: steps.changes.outputs.decision != 'skip'
+ run: |
+ uv run --no-sync python ./tests/documentation_tests/test_env_keys.py
+ uv run --no-sync python ./tests/documentation_tests/test_router_settings.py
+ uv run --no-sync python ./tests/documentation_tests/test_api_docs.py
+ uv run --no-sync python ./tests/documentation_tests/test_circular_imports.py
+ helm:
+ name: helm
+ permissions:
+ contents: read
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Checkout
+ uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ persist-credentials: false
+
+ - name: Set up Helm 3.11.1
+ uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
+ with:
+ version: "3.11.1"
+
+ - name: Download and verify Helm Unit Test Plugin
+ run: |
+ curl -fsSLo "$RUNNER_TEMP/helm-unittest.tgz" https://github.com/helm-unittest/helm-unittest/releases/download/v0.8.2/helm-unittest-linux-amd64-0.8.2.tgz
+ echo "56ab3091e6fa52a7c92ee951def9bed957f295d9ce98483aed404e748d7b3a94 $RUNNER_TEMP/helm-unittest.tgz" | sha256sum -c -
+
+ - name: Install Helm Unit Test Plugin
+ run: |
+ PLUGIN_DIR="$(helm env HELM_PLUGINS)/helm-unittest"
+ mkdir -p "$PLUGIN_DIR"
+ tar -xzf "$RUNNER_TEMP/helm-unittest.tgz" -C "$PLUGIN_DIR"
+ helm plugin list
+
+ - name: Run unit tests
+ run: |
+ for chart in helm/litellm-helm helm/litellm; do
+ declared="$(grep -h '^suite:' "$chart"/tests/*.yaml | wc -l | tr -d '[:space:]')"
+ output="$(mktemp)"
+ helm unittest -f 'tests/*.yaml' "$chart" | tee "$output"
+ executed="$(sed -n 's/^Test Suites:.*[[:space:]]\([0-9][0-9]*\) total$/\1/p' "$output")"
+ if [ "$declared" != "$executed" ]; then
+ echo "::error::$chart declares $declared test suites but helm-unittest ran $executed. Suites are being skipped silently, so their assertions never execute."
+ exit 1
+ fi
+ echo "$chart: all $declared declared test suites ran"
+ done
+ openai-sdk-compat:
+ name: OpenAI ${{ matrix.openai-version }} compatibility
+ permissions:
+ contents: read
+ pull-requests: read
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ strategy:
+ fail-fast: false
+ matrix:
+ openai-version: ["2.20.0", "3.0.0", "3.25.0"]
+ env:
+ LITELLM_LOCAL_MODEL_COST_MAP: "True"
+ PYTHONPATH: ${{ github.workspace }}
+ UV_PYTHON: "3.10"
+ OPENAI_VERSION: ${{ matrix.openai-version }}
+ steps:
+ - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ 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'
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: ${{ env.UV_PYTHON }}
+
+ - name: Set up uv
+ if: steps.changes.outputs.decision != 'skip'
+ uses: ./.github/actions/setup-uv-with-retries
+ with:
+ version: "0.10.9"
+
+ - name: Install dependencies
+ if: steps.changes.outputs.decision != 'skip'
+ run: |
+ .github/scripts/uv_sync_with_retries.sh --frozen --group dev --group proxy-dev --extra proxy --no-install-workspace
+ uv pip install "openai==${OPENAI_VERSION:?}" --exclude-newer-package openai=2026-10-07T00:00:00Z
+ uv run --no-sync python -c 'import os, openai; assert openai.__version__ == os.environ["OPENAI_VERSION"], openai.__version__'
+
+ - name: Run OpenAI SDK compatibility tests
+ if: steps.changes.outputs.decision != 'skip'
+ run: |
+ uv run --no-sync pytest \
+ tests/unit/llms/openai/test_openai.py \
+ tests/unit/llms/openai/test_openai_common_utils.py \
+ tests/unit/llms/openai/test_openai_file_content_streaming.py \
+ tests/unit/test_openai_embedding_encoding_format_default.py \
+ tests/unit/llms/inception/test_inception_completion_transformation.py::test_inception_fim_targets_fim_endpoint \
+ tests/unit/llms/inception/test_inception_completion_transformation.py::test_inception_fim_async \
+ tests/unit/llms/azure/test_azure_exception_mapping.py \
+ tests/unit/test_completion_timeout_resolution.py \
+ tests/unit/types/test_router.py \
+ tests/unit/test_exception_header_preservation.py \
+ tests/unit/integrations/otel/test_otel_v2_sources_of_truth.py::test_speech_binary_response_still_streaming_reports_the_bytes_downloaded_so_far \
+ --tb=short -q
+
+ - name: Run workload identity tests with SDK 3
+ if: steps.changes.outputs.decision != 'skip' && startsWith(matrix.openai-version, '3.')
+ run: uv run --no-sync pytest tests/unit/llms/openai/test_openai_workload_identity.py --tb=short -q
+
+ - name: Check consumer types with the installed SDK
+ if: steps.changes.outputs.decision != 'skip'
+ run: uv run --no-sync basedpyright --pythonpath .venv/bin/python --project tests/typing/pyrightconfig.json
+ redis-compat:
+ name: redis-py ${{ matrix.redis-py }} compatibility
+ needs: rust-bridge
+ if: ${{ !cancelled() }}
+ permissions:
+ contents: read
+ id-token: write
+ pull-requests: read
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+ strategy:
+ fail-fast: false
+ matrix:
+ redis-py: ["5.3.1", "6.4.0", "7.4.1", "8.0.1"]
+ env:
+ UV_PYTHON: "3.12"
+ LITELLM_LOCAL_MODEL_COST_MAP: "True"
+ REDIS_PY_VERSION: ${{ matrix.redis-py }}
+ 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
+ with:
+ category: redis-compat
+
+ - 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: ${{ needs.rust-bridge.outputs.artifact }}
+
+ - name: Install dependencies with redis-py ${{ matrix.redis-py }}
+ if: steps.changes.outputs.decision != 'skip'
+ timeout-minutes: 8
+ env:
+ RUST_BRIDGE_ARTIFACT: ${{ needs.rust-bridge.outputs.artifact }}
+ run: |
+ 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 --extra utils
+ 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 --extra utils --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
+ fi
+ uv pip install --python .venv/bin/python "redis==${REDIS_PY_VERSION:?}"
+ uv run --no-sync python -c 'import os, redis; assert redis.__version__ == os.environ["REDIS_PY_VERSION"], redis.__version__'
+
+ - name: Install redis-server 7.2.16
+ if: steps.changes.outputs.decision != 'skip'
+ timeout-minutes: 5
+ run: |
+ mkdir -p "$RUNNER_TEMP/redis-bin"
+ cid="$(docker create redis:7.2.16@sha256:0637954999d01b7c9ce9167db2da50656e2590d3b884f1c600c5f63bb6e6773c)"
+ docker cp "${cid}:/usr/local/bin/redis-server" "$RUNNER_TEMP/redis-bin/redis-server"
+ docker rm "$cid"
+ "$RUNNER_TEMP/redis-bin/redis-server" --version | grep -q 'v=7.2.16'
+ echo "$RUNNER_TEMP/redis-bin" >> "$GITHUB_PATH"
+
+ - name: Run Redis compatibility tests
+ if: steps.changes.outputs.decision != 'skip'
+ timeout-minutes: 15
+ env:
+ COVERAGE_CORE: sysmon
+ run: |
+ uv run --no-sync pytest \
+ tests/unit/test_redis.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 \
+ tests/integration/sdk/test_redis_cluster_iam_auth.py \
+ --tb=short -vv -rs -p no:rerunfailures -p no:pytest-retry --timeout=120 --durations=20 \
+ --cov=./litellm --cov-report=xml:coverage.xml
+
+ - name: Upload redis-py ${{ matrix.redis-py }} coverage to Codecov
+ if: always() && steps.changes.outputs.decision != 'skip' && matrix.redis-py == '5.3.1'
+ continue-on-error: true
+ uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5
+ with:
+ use_oidc: true
+ files: coverage.xml
+ root_dir: ${{ github.workspace }}
+ flags: redis-compat
+ fail_ci_if_error: false
+ coverage:
+ name: coverage
+ needs: unit
+ if: always()
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ 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-*-${{ github.run_id }}-*
+ path: coverage-reports
+ merge-multiple: false
+
+ - 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: unit
+ 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: unit
+ fail_ci_if_error: false
+
+ - name: Upload MCP server coverage under its own flag
+ continue-on-error: true
+ uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5
+ with:
+ use_oidc: true
+ files: coverage-reports/coverage-mcp-server-${{ github.run_id }}-${{ github.run_attempt }}/coverage.xml
+ root_dir: ${{ github.workspace }}
+ flags: mcp-integration
+ fail_ci_if_error: false
+ unit-passed:
+ name: unit passed
+ permissions: {}
+ needs: [rust-bridge, assert-shard-coverage, unit, ui-unit, docs, helm, openai-sdk-compat, redis-compat]
+ if: always()
+ runs-on: ubuntu-latest
+ timeout-minutes: 2
+ steps:
+ - name: Require every unit job to succeed
+ env:
+ NEEDS: ${{ toJSON(needs) }}
+ run: |
+ jq -r 'to_entries[] | "\(.key): \(.value.result)"' <<< "$NEEDS"
+ jq -e 'all(.[]; .result == "success")' <<< "$NEEDS" > /dev/null
diff --git a/.github/workflows/weekly_load_anomaly.yml b/.github/workflows/weekly_load_anomaly.yml
index 3e1fca89645..0ba4df89cc4 100644
--- a/.github/workflows/weekly_load_anomaly.yml
+++ b/.github/workflows/weekly_load_anomaly.yml
@@ -63,7 +63,7 @@ jobs:
- name: Start the proxy
run: |
- nohup uv run --no-sync litellm --config tests/e2e/load/weekly_anomaly_config.yml --port 4000 > proxy.log 2>&1 &
+ nohup uv run --no-sync litellm --config tests/e2e/perf/weekly_anomaly_config.yml --port 4000 > proxy.log 2>&1 &
for _ in $(seq 1 90); do
if curl -fs http://localhost:4000/health/liveliness > /dev/null; then
exit 0
@@ -78,7 +78,7 @@ jobs:
env:
E2E_WEEKLY_ANOMALY: "1"
run: |
- uv run --no-sync pytest tests/e2e/load/test_weekly_session_anomaly_e2e.py -v --tb=short -rA
+ uv run --no-sync pytest tests/e2e/perf/test_weekly_session_anomaly_e2e.py -v --tb=short -rA
- name: Show proxy log on failure
if: failure()
diff --git a/.gitignore b/.gitignore
index 763f8db2940..5ca20f02274 100644
--- a/.gitignore
+++ b/.gitignore
@@ -154,3 +154,4 @@ coverage-rust.xml
# make lens-dev worker token, generated config and logs
.lens-dev/
+.mypy_cache/
diff --git a/AGENTS.md b/AGENTS.md
index a7d7256eeb4..87e4df49299 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -23,9 +23,9 @@ When adding new features, add meaningful tests. Don't add tests that don't check
Same thing for bug fixes. The tests should make it so that this specific bug can never happen again without failing tests (i.e., regression)
-Never test structure of code only function of it
+Never test structure or implementation details of code, only function of it. A test should catch a change that breaks behavior by accident. If the only way it can fail is someone deliberately undoing the change, don't write it
-A test must only fail when litellm code changes. Never pin facts we don't own (a vendor's price, a third party's field, an upstream default, today's date) as literals or as "X must be absent"; assert the invariant our code guarantees instead, e.g. two rows agree, a value is within range, a field is derived from another. If an outside fact is truly load-bearing, cite its source and date next to the assertion so a reader can tell stale from broken
+A test must only fail when litellm code changes. Never pin facts we don't own (a vendor's price, a third party's field, an upstream default, today's date) as literals or as "X must be absent". Assert the invariant our code guarantees instead, e.g. two rows agree, a value is within range, a field is derived from another. If an outside fact is truly load-bearing, cite its source and date next to the assertion so a reader can tell stale from broken
`tests/unit/` mirrors `litellm/` in a parallel path (see `tests/unit/AGENTS.md`). Name tests `test_.py`, but always match the existing test file in the directory you touch — many provider dirs use longer descriptive names (e.g. `test_anthropic_chat_transformation.py`) to avoid ambiguity across sibling folders. For bug fixes, extend the existing mapped test file rather than creating a new one. Only create a new test file for a new feature (provider, endpoint, or transformation module) that has no mapped test yet, following that directory's naming convention (or `test_.py` if you're the first test there). One focused regression test beats many shallow ones
@@ -33,20 +33,20 @@ End-to-end tests belong in `tests/e2e/` and must follow the harness conventions
When creating PRs, target the repository's current default branch for both internal and external / OSS contributions. Check it with `python3 scripts/default_branch.py --branch` instead of assuming a branch name or relying on cached `origin/HEAD`
-When writing a PR body, treat the comments and imperative instructions inside .github/pull_request_template.md as rules to follow, not just layout. Agent harnesses may strip HTML comments from copies of that file injected into context, so read .github/pull_request_template.md from disk before writing a PR body to make sure you see every comment rule. A section you have nothing to put in (Relevant issues, Affected release, Linear ticket, Caveats, QA runbook, and so on) is removed entirely, heading included, never left as an empty title
+When writing a PR body, treat the comments and imperative instructions inside .github/PULL_REQUEST_TEMPLATE/general.md as rules to follow, not just layout. Agent harnesses may strip HTML comments from copies of that file injected into context, so read .github/PULL_REQUEST_TEMPLATE/general.md from disk before writing a PR body to make sure you see every comment rule. A section you have nothing to put in (Relevant issues, Affected release, Linear ticket, Caveats, QA runbook, and so on) is removed entirely, heading included, never left as an empty title
Same applies for filing bug reports and feature requests, with .github/ISSUE_TEMPLATE/bug_report.yml and .github/ISSUE_TEMPLATE/feature_request.yml, respectively
If you're resolving a linear ticket, in the "## Linear ticket" section of the PR, say "Resolves LIT-1234", replacing "LIT-1234" with the actual ticket id that you're resolving. If you don't have the ticket id, don't make one up or search for it. Just drop the section
-Never use `pytest` commands or the like as "Screenshots / Proof of Fix". We prefer curl'ing a live proxy instance running on localhost:4000 (I like to run it with `python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log`; the Admin UI dev server is `npm run dev` in `ui/litellm-dashboard`, served on port 3000) and showing both the command run and the output. Also, it should hit real LLM provider APIs, not mocks, and cost real $$$ because that is the most realistic test. The proof of fix should be exactly what the end user / customer would see / do. The run logs in PR #27703 is a prime example of how to do it (not a huge fan of using a python test script that future me and the team will have no visibility into; I prefer just curl commands or a short list of bash commands (e.g., using `for`)). If it's a UI thing, or the main use case runs through a headful agentic coding tool like Claude Code or Codex, drive that surface yourself and embed your own before and after screenshots of it in the PR (the Admin UI page, or what the coding tool shows), next to an ordered list of the URLs to go to (e.g., http://localhost:4000/ui/?page=logs), where to click, and what fields to fill out so a reviewer can reproduce it
+Never use `pytest` commands or the like as "Screenshots / Proof of Fix". We prefer curl'ing a live proxy instance running on localhost:4000 (I like to run it with `python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log`. The Admin UI dev server is `npm run dev` in `ui/litellm-dashboard`, served on port 3000) and showing both the command run and the output. Also, it should hit real LLM provider APIs, not mocks, and cost real $$$ because that is the most realistic test. The proof of fix should be exactly what the end user / customer would see / do. The run logs in PR #27703 is a prime example of how to do it (not a huge fan of using a python test script that future me and the team will have no visibility into. I prefer just curl commands or a short list of bash commands (e.g., using `for`)). If it's a UI thing, or the main use case runs through a headful agentic coding tool like Claude Code or Codex, drive that surface yourself and embed your own before and after screenshots of it in the PR (the Admin UI page, or what the coding tool shows), next to an ordered list of the URLs to go to (e.g., http://localhost:4000/ui/?page=logs), where to click, and what fields to fill out so a reviewer can reproduce it
If you ever write any human-facing text (pull requests, issues, commit messages, discussion posts, github comments, release notes, docs, etc.), always follow these guidelines to sound less AI-y:
- don't use emojis
-- don't use "—". Instead, reach for ",", ".", conjunction words, ":", ";", etc. in descending order of preference: vary among them, weighted toward the front of the list, and skip "," where it would cause a comma splice or the sentence is getting long. Overusing any one of them, ";" especially, also feels AI-y. A word cap does not penalize you for adding more sentences: when writing under tight word budgets, prefer a period split or a conjunction over ";", and keep to at most one ";" per message
+- don't use "—". Don't use ";" or ":" unless it would be nonsensical not to. Instead, reach for ",", ".", conjunction words, etc. in descending order of preference: vary among them, weighted toward the front of the list, and skip "," where it would cause a comma splice or the sentence is getting long. Overusing any one of them also feels AI-y. A word cap does not penalize you for adding more sentences
- don't use the pattern "It's not X, it's Y", "You're not X, you're Y", etc.
- unless explicitly asked, don't use bulleted or numbered lists unless it would be nonsensical not to. Instead, prefer prose
-- don't add a trailing "." at the end of paragraphs (just like this file). That means every paragraph, not just the last one (of the markdown file, PR description, GitHub comment, etc.). Rule of thumb: if you're adding new line(s) before the next sentence, don't add a "."
+- don't add a trailing "." at the end of paragraphs (just like this file). That means every paragraph, not just the last one (of the markdown file, PR description, GitHub comment, etc.). Rule of thumb: if you're adding new line(s) before the next sentence, don't add a ".". Exception is that docs (i.e., litellm-docs) should have trailing "."
- don't use →. Instead, prefer not to use arrows, and if need be, use -> instead
- use plain, simple, everyday engineering language: the common phrase engineers actually say over rare compact phrasing, in grammatically complete sentences. When explicitly asked to use bullets or ordered lists and structure legitimately helps the reader, prefer nested bullets (any depth is fine) over dense lines in a flat structure
@@ -54,21 +54,21 @@ Don't hesitate to use values in .env to get needed API keys and other secrets, a
Python max line length is 120, not 88
-Never edit or commit `ruff-strict-budget.json`, `type-discipline-budget.json`, `basedpyright-code-budget.json`, or `test-quality-budget.json` on a PR branch, and don't run `make lint-budget-update` there. A scheduled Devin automation lowers the limits on the default branch in its own PR by exactly what landed since the last ratchet, so concurrent PRs don't fight over the same `"limit"` lines. Keep the hosted automation's target in sync when the repository default changes. If your branch already carries a budget edit, drop it before opening the PR
+The five lint gates (`scripts/ruff_strict_gate.py`, `scripts/type_discipline_gate.py`, `scripts/test_quality_gate.py`, `scripts/openapi_docs_gate.py`, `scripts/type_check_gate.py`) compare each rule's codebase count on your branch against the count at its merge-base with the default branch, and a rule may not grow. There are no budget files to edit or ratchet: when a gate fails, fix the violations the branch introduced or remove at least as many of that rule elsewhere in the tree. The one exception is `reportAny` / `reportExplicitAny`, which share a fixed codebase-wide cap in `ANY_CAPS` in `scripts/type_check_gate.py` because Any spreads past the lines you touch. A branch may add Anys while the total stays under that cap. Only lower `ANY_CAPS`, and only in its own PR to the default branch, never raise it on a feature branch
`make check` (f.k.a. `make pre-commit`, which still works identically as an alias) saves its complete output to a log file in .git (overwriting previous logs) and prints that path as its first and last output lines. To inspect a run, read or grep that log instead of re-running the multi-minute checks just to see a different slice
-`make check`, `make lint`, `scripts/pre_commit_lint.sh`, and the standalone budget gates (`scripts/ruff_strict_gate.py`, `scripts/type_discipline_gate.py`, `scripts/type_check_gate.py`) each hold one of 2 machine-wide slots, so when other sessions or worktrees on the same box are already running heavy work, yours prints "all N machine-wide slots are busy; queueing" and then stays quiet until a slot frees. Give the command a long timeout and let it wait rather than killing it, retrying it, or assuming it hung. Don't change the # of machine-wide slots or make it unlimited by setting `LITELLM_GATE_SLOTS=0`
+`make check`, `make lint`, `scripts/pre_commit_lint.sh`, and the standalone lint gates (`scripts/ruff_strict_gate.py`, `scripts/type_discipline_gate.py`, `scripts/type_check_gate.py`) each hold one of 2 machine-wide slots, so when other sessions or worktrees on the same box are already running heavy work, yours prints "all N machine-wide slots are busy" followed by "queueing" and then stays quiet until a slot frees. Give the command a long timeout and let it wait rather than killing it, retrying it, or assuming it hung. Don't change the # of machine-wide slots or make it unlimited by setting `LITELLM_GATE_SLOTS=0`
-If you're trying to create a new function that relies on untyped stuff, instead of adding more Any's and pushing `reportAny` / `reportExplicitAny` closer to their basedpyright ceilings, just validate it in the caller with Pydantic (a model or `TypeAdapter` that returns the typed thing or raises will do) and then pass the now typed variable in
+If you're trying to create a new function that relies on untyped stuff, instead of adding more Any's and pushing `reportAny` / `reportExplicitAny` closer to their cap in `ANY_CAPS`, just validate it in the caller with Pydantic (a model or `TypeAdapter` that returns the typed thing or raises will do) and then pass the now typed variable in
-If you get an LIT001 fail, refactor the code to follow functional programming best practices rather than introducing mutable data structures. For example, build values in one shot with comprehensions or generators wrapped in `tuple()` / `MappingProxyType()` / `frozenset()` instead of seeding an empty `list`/`dict`/`set` and mutating it over time. Ideally, `# mutable-ok` is never used; reach for it only as a genuine last resort when an immutable rewrite is truly impossible, and always pair it with a real reason
+If you get an LIT001 fail, refactor the code to follow functional programming best practices rather than introducing mutable data structures. For example, build values in one shot with comprehensions or generators wrapped in `tuple()` / `MappingProxyType()` / `frozenset()` instead of seeding an empty `list`/`dict`/`set` and mutating it over time. Ideally, `# mutable-ok` is never used. Reach for it only as a genuine last resort when an immutable rewrite is truly impossible, and always pair it with a real reason
Every lint or type suppression must name the exact rule inside brackets and carry a reason comment, e.g. `# pyright: ignore[reportArgumentType] # stubs lack async overload` or `# noqa: TID251 # `. `# type: ignore` is banned (LIT009): pyrightconfig.json sets `enableTypeIgnoreComments` to false, so it silently does nothing
Commit and push your work when you're done without asking
-When referencing or running models (coding, QA'ing, writing docs, writing tests, etc.), use the latest model in that model family unless otherwise specified; treat your training knowledge, memories, configs, and tests as stale, and determine the family's latest with model_prices_and_context_window.json or the web
+When referencing or running models (coding, QA'ing, writing docs, writing tests, etc.), use the latest model in that model family unless otherwise specified. Treat your training knowledge, memories, configs, and tests as stale, and determine the family's latest with model_prices_and_context_window.json or the web
Always pull before starting any work. The checkout or worktree may be sitting on a stale branch
@@ -84,7 +84,7 @@ Monkeypatching attributes of a class to do testing is an anti-pattern. Prefer de
Do not put names of customers or customer company names in code, PR descriptions, issue bodies, etc. This means never mention literally any company name. Especially if you're about to say a sentence mentioning that the reason the PR exists was a feature/model/bug fix/etc. requested by a company. That's the indication that you should replace that company name with "the customer". e.g. not "Model request from Acme (Pylon #1234)" but "Model request from a customer (Pylon #1234)". This is because the codebase is public. The only exception is for publicly known providers or vendors such as OpenAI, Anthropic, AWS Bedrock, etc. only IF we're adding support for that provider/vendor in general and NOT if that PR or whatnot was a request by one of them, and they're actually one of our customers.
-CI supply-chain safety: Never pipe a remote script into a shell (`curl ... | bash`, `wget ... | sh`); download the artifact to a file, verify its SHA-256 checksum, then install. Pin every external tool to a specific version with a full URL (not `latest` or `stable`). Verify checksums for all downloaded binaries, using the provider's official `.sha256` / `.sha256sum` sidecar when available. These rules apply to every download in CI
+CI supply-chain safety: Never pipe a remote script into a shell (`curl ... | bash`, `wget ... | sh`). Download the artifact to a file, verify its SHA-256 checksum, then install. Pin every external tool to a specific version with a full URL (not `latest` or `stable`). Verify checksums for all downloaded binaries, using the provider's official `.sha256` / `.sha256sum` sidecar when available. These rules apply to every download in CI
Prisma migrations apply synchronously at proxy boot, before it serves traffic, so a migration must only change schema, never rewrite rows. No `UPDATE`, `DELETE` or `MERGE`, and no `INSERT ... SELECT`: on a spend-log-sized table any of those is minutes of downtime plus a doubled heap that plain autovacuum won't give back. `tests/code_coverage_tests/check_migrations_no_data_rewrites.py` enforces this. When a rewrite is genuinely bounded and has to ship inside the migration, mark the statement `-- data-migration-ok: `
@@ -92,18 +92,19 @@ Follow these coding conventions for new/updated code (a three-line fix in a lega
- Composition over inheritance
- Never-nester: early returns over deep nesting
-- Don't throw; model failures as values (One function (e.g., raise_public) maps error union to existing public exception contracts via exhaustive match + assert_never)
-- No mutation; don't reassign variables, global or local. Instead of mutable lists and dicts, prefer tuples, frozen dataclasses (with slots=True), `MappingProxyType`, etc.
- - Annotate every variable with `: Final` (LIT010). Unpacking and walrus targets cannot carry the annotation, so they are implicitly final. Don't rebind them. Never rebind or mutate function parameters (LIT011); `self`/`cls` attribute stores are the exception. If rebinding or in-place mutation is truly unavoidable, suppress with `# rebind-ok: `
+- Don't throw. Model failures as values (One function (e.g., raise_public) maps error union to existing public exception contracts via exhaustive match + assert_never)
+- No mutation. Don't reassign variables, global or local. Instead of mutable lists and dicts, prefer tuples, frozen dataclasses (with slots=True), `MappingProxyType`, etc.
+ - Annotate every variable with `: Final` (LIT010). Unpacking and walrus targets cannot carry the annotation, so they are implicitly final. Don't rebind them. Never rebind or mutate function parameters (LIT011). `self`/`cls` attribute stores are the exception. If rebinding or in-place mutation is truly unavoidable, suppress with `# rebind-ok: `
- Qualify every TypedDict field with `ReadOnly[...]` (LIT012), which nests freely with `Required` / `NotRequired` / `Annotated` in any order. If making the key writable is truly unavoidable, suppress with `# writable-ok: `
- - Comprehensions take at most one `for` clause and one `if` clause (LIT014); split stacked clauses into a helper generator, a named intermediate, or a plain loop. Suppress with `# comprehension-ok: ` only when unavoidable
+ - Comprehensions take at most one `for` clause and one `if` clause (LIT014). Split stacked clauses into a helper generator, a named intermediate, or a plain loop. Suppress with `# comprehension-ok: ` only when unavoidable
+ - Every pydantic model must be frozen (LIT015), set via `model_config = ConfigDict(frozen=True)`, a dict-literal `model_config`, an inner `class Config`, or the class keywords. Subclasses inherit it unless they override it. Replace in-place field writes with `model_copy(update=...)`. If making the model mutable is truly unavoidable, suppress with `# frozen-ok: ` on the `class` line
- Use dependency injection
-- Fully typed; no `Any` or coarse types like `dict[str, Any]` or just `dict`. Every function parameter must be strongly typed
+- Fully typed. No `Any` or coarse types like `dict[str, Any]` or just `dict`. Every function parameter must be strongly typed
- Use tagged unions + match
- No monster files or god objects
- No file sprawl: deliberate file and folder structure
-- Standard over hand-rolled: use the official SDK or a library where one exists; where none does, follow industry standards instead of inventing local conventions
-- API-fragmentation-aware: when logic must branch on which API surface produced or consumes data (e.g. chat completions vs Anthropic Messages vs Responses API shapes), proactively look for an existing shared helper (e.g. `litellm_core_utils/prompt_templates/factory.py`) before writing per-surface parsing in the new module; if none exists, add one there instead of duplicating the same format-detection logic in every new guardrail/integration
+- Standard over hand-rolled: use the official SDK or a library where one exists. Where none does, follow industry standards instead of inventing local conventions
+- API-fragmentation-aware: when logic must branch on which API surface produced or consumes data (e.g. chat completions vs Anthropic Messages vs Responses API shapes), proactively look for an existing shared helper (e.g. `litellm_core_utils/prompt_templates/factory.py`) before writing per-surface parsing in the new module. If none exists, add one there instead of duplicating the same format-detection logic in every new guardrail/integration
Follow conventional commits for commit names and PR titles
@@ -129,4 +130,4 @@ Before implementing:
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify
-Before requesting maintainer review, verify the current PR tip passes required CI and code coverage, meets Greptile confidence of at least 4/5, and has acceptable Veria and Bugbot reviews. Inspect warnings and findings, fix actionable issues, and rerun the affected checks and reviewers after changes. Record evidence for any false positive or unavailable review; never treat a pending or missing bot result as a pass. Do not lower coverage thresholds or lint budgets to satisfy a check
+Before requesting maintainer review, verify the current PR tip passes required CI and code coverage and has acceptable reviews from the review bots on the PR, such as Veria and Bugbot. Inspect warnings and findings, fix actionable issues, and rerun the affected checks and reviewers after changes. Record evidence for any false positive or unavailable review. Never treat a pending or missing bot result as a pass. Do not lower coverage thresholds or raise `ANY_CAPS` to satisfy a check
diff --git a/Dockerfile b/Dockerfile
index 7f6775413d1..890c90fe449 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -39,6 +39,7 @@ ENV NEXT_TELEMETRY_DISABLED=1 \
WORKDIR /ui
COPY ui/litellm-dashboard/package.json ui/litellm-dashboard/package-lock.json ./
+COPY ui/litellm-dashboard/vendor/ ./vendor/
RUN --mount=type=cache,target=/root/.npm npm ci --prefer-offline
COPY ui/litellm-dashboard/ ./
diff --git a/Makefile b/Makefile
index 25b966e2b9a..1552187e906 100644
--- a/Makefile
+++ b/Makefile
@@ -4,11 +4,10 @@
.PHONY: help test test-unit test-unit-llms test-unit-proxy-guardrails test-unit-proxy-core test-unit-proxy-misc test-unit-proxy-root \
test-unit-integrations test-unit-core-utils test-unit-other test-unit-root \
test-proxy-unit-a test-proxy-unit-b test-integration test-unit-helm \
- test-rust-extension rust-sqlx-prepare lens-dev \
+ test-rust-extension rust-sqlx-prepare \
info lint lint-inner lint-dev lint-checks format \
- lint-basedpyright lint-e2e-basedpyright lint-basedpyright-budget-update lint-type-discipline lint-type-discipline-budget-update \
- lint-ruff-budget lint-ruff-budget-update lint-budget-update lint-gate \
- lint-test-quality lint-test-quality-budget-update \
+ lint-basedpyright lint-e2e-basedpyright lint-type-discipline \
+ lint-ruff-strict lint-gate lint-test-quality lint-openapi-docs \
install-dev install-proxy-dev install-test-deps install-hooks \
install-helm-unittest check-circular-imports check-import-safety check check-inner pre-commit \
lint-install lint-fetch-base bootstrap
@@ -32,17 +31,15 @@ help:
@echo " make lint-ruff - Run Ruff linting only"
@echo " make lint-basedpyright - Run basedpyright strict, gated by per-rule error counts"
@echo " make lint-e2e-basedpyright - Run basedpyright over tests/e2e and tests/e2e_harness (zero errors allowed)"
- @echo " make lint-basedpyright-budget-update - Ratchet basedpyright limits down by what this branch fixed"
@echo " make lint-format - Check ruff format formatting (matches CI)"
- @echo " make lint-ruff-budget - Gate the codebase total of each strict ruff rule against its limit"
+ @echo " make lint-ruff-strict - Gate each strict ruff rule's codebase total against its merge-base count"
@echo " make lint-gate - Strict ruff gate in CI-parity mode (fetches the default branch, simulates the merge)"
- @echo " make lint-ruff-budget-update - Ratchet ruff-strict-budget.json limits down by what this branch fixed"
- @echo " make lint-test-quality - Gate the test suite against test-quality-budget.json"
- @echo " make lint-budget-update - Ratchet all budgets down (ruff + type-discipline + test quality + basedpyright)"
+ @echo " make lint-test-quality - Gate the test suite's TQ counts against their merge-base counts"
+ @echo " make lint-openapi-docs - Gate undocumented POST routes in the proxy OpenAPI document against their merge-base counts"
@echo " make check-circular-imports - Check for circular imports"
@echo " make check-import-safety - Check import safety"
@echo " make test - Run all tests"
- @echo " make test-unit - Run unit tests (tests/unit and tests/test_litellm)"
+ @echo " make test-unit - Run unit tests (tests/unit)"
@echo " make test-unit-llms - Run LLM provider tests (~225 files)"
@echo " make test-unit-proxy-guardrails - Run proxy guardrails+mgmt tests (~51 files)"
@echo " make test-unit-proxy-core - Run proxy auth+client+db+hooks tests (~52 files)"
@@ -58,7 +55,6 @@ help:
@echo " make test-unit-helm - Run helm unit tests"
@echo " make test-rust-extension - Build the Rust extension and run its public Python tests"
@echo " make rust-sqlx-prepare - Refresh litellm-rust/crates/db/.sqlx against a migrated Postgres container"
- @echo " make lens-dev - Run proxy + Lens worker + hot-reload dashboard (ARGS=\"--seed large --seed-logs\", LENS_DEV_PROXY_PORT, LENS_DEV_UI_PORT)"
@echo ""
@echo "Heavy targets (check, lint) queue for LITELLM_GATE_SLOTS machine-wide"
@echo "slots (default 2; 0 disables) so parallel sessions don't thrash one machine."
@@ -141,9 +137,9 @@ format-check: install-dev
lint-fetch-base:
@$(RESOLVE_BASE)
-# Mirror test-linting.yml's lint job environment: the proxy-dev group plus a generated
+# Mirror test-linting.yml's python job environment: the proxy-dev group plus a generated
# Prisma client, so `basedpyright tests/e2e` resolves the same modules CI does. The
-# budget gate itself no longer measures here (scripts/type_check_gate.py provisions its
+# basedpyright gate itself no longer measures here (scripts/type_check_gate.py provisions its
# own .venv-typecheck). --inexact tops up the venv instead of pruning the proxy extras
# gen:api and the running proxy need.
lint-install:
@@ -213,25 +209,26 @@ lint-basedpyright: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
lint-e2e-basedpyright: $(LINT_E2E_DEP_INSTALL)
$(UV_RUN) basedpyright tests/e2e tests/e2e_harness
-# Type-discipline budget (mutable collections / casts / type guards / kwargs /
+# Type-discipline gate (mutable collections / casts / type guards / kwargs /
# unexplained suppressions), the test-linting.yml step `make lint` used to omit.
lint-type-discipline: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
$(UV_RUN) python scripts/type_discipline_gate.py --base "$(BASE_REF)"
-# Test-quality budget (zero-assert / mock-echo tests, sys.path.insert, raw env writes,
+# Test-quality gate (zero-assert / mock-echo tests, sys.path.insert, raw env writes,
# litellm module-global mutation, credential-gated skips, conftest snapshot
# inventory), counted across tests/ the same delta-vs-base way.
lint-test-quality: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
$(UV_RUN) python scripts/test_quality_gate.py --base "$(BASE_REF)"
-# --update lowers each limit by what this branch fixed since its branch point, so
-# it needs the base ref fetched to resolve the merge-base.
-lint-basedpyright-budget-update: install-dev
- $(UV_RUN) python scripts/type_check_gate.py --update --base "$(BASE_REF)"
+# OpenAPI docs gate (POST routes without a request body schema, a description or a
+# 200 schema in app.openapi()), counted the same delta-vs-base way so a new endpoint
+# has to show up documented in Swagger UI.
+lint-openapi-docs: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
+ $(UV_RUN) python scripts/openapi_docs_gate.py --base "$(BASE_REF)"
lint-format: format-check
-lint-ruff-budget: install-dev
+lint-ruff-strict: install-dev
$(UV_RUN) python scripts/ruff_strict_gate.py --base "$(BASE_REF)"
# Strict gate, invoked the same way CI does in test-linting.yml so a local pass
@@ -239,28 +236,16 @@ lint-ruff-budget: install-dev
lint-gate: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
$(UV_RUN) python scripts/ruff_strict_gate.py --base "$(BASE_REF)"
-lint-ruff-budget-update: install-dev
- $(UV_RUN) python scripts/ruff_strict_gate.py --update --base "$(BASE_REF)"
-
-lint-type-discipline-budget-update: install-dev
- $(UV_RUN) python scripts/type_discipline_gate.py --update --base "$(BASE_REF)"
-
-lint-test-quality-budget-update: install-dev
- $(UV_RUN) python scripts/test_quality_gate.py --update --base "$(BASE_REF)"
-
-# Ratchet all budgets in one shot (ruff strict + type-discipline + test quality + basedpyright)
-lint-budget-update: lint-ruff-budget-update lint-type-discipline-budget-update lint-test-quality-budget-update lint-basedpyright-budget-update
-
check-circular-imports: $(LINT_DEP_INSTALL)
cd litellm && $(UV_RUN) python ../tests/documentation_tests/test_circular_imports.py && cd ..
check-import-safety: $(LINT_DEP_INSTALL)
@$(UV_RUN) python -c "from litellm import *; print('[from litellm import *] OK! no issues!');" || (echo '🚨 import failed, this means you introduced unprotected imports! 🚨'; exit 1)
-# Combined linting, isomorphic to test-linting.yml's lint job so a local pass means a
+# Combined linting, isomorphic to test-linting.yml's python job so a local pass means a
# green CI lint: it installs the same env (proxy-dev + generated Prisma client) and then
# runs the diff-scoped ruff format check, whole-tree ruff check, the strict-rule /
-# type-discipline / basedpyright budgets as a delta vs the base, then the circular-import
+# type-discipline / basedpyright gates as a delta vs the base, then the circular-import
# and import-safety checks. Steps that compare against the base resolve it the same way CI
# does (merge-base with origin's current default branch). Setup (env sync, Prisma client,
# base fetch) runs once up front; the checks themselves are independent, so a sub-make
@@ -272,7 +257,7 @@ lint-inner: lint-install
@base_ref=$$($(RESOLVE_BASE)) && \
$(MAKE) BASE_REF="$$base_ref" -j $(LINT_JOBS) $(LINT_OUTPUT_SYNC) LINT_DEP_INSTALL= LINT_E2E_DEP_INSTALL= LINT_DEP_BASE= lint-checks
-lint-checks: lint-format-check-changed lint-ruff lint-gate lint-type-discipline lint-test-quality lint-basedpyright lint-e2e-basedpyright check-circular-imports check-import-safety
+lint-checks: lint-format-check-changed lint-ruff lint-gate lint-type-discipline lint-test-quality lint-openapi-docs lint-basedpyright lint-e2e-basedpyright check-circular-imports check-import-safety
# Faster linting for local development (only checks changed code)
lint-dev: lint-format-changed check-circular-imports check-import-safety
@@ -281,8 +266,7 @@ lint-dev: lint-format-changed check-circular-imports check-import-safety
# is staged (warning about changed files left unstaged); with nothing staged it falls
# back to the working tree's diff against the merge base with the base branch, so a
# fresh merge commit or an unstaged working tree still gets checked. Mirrors
-# test-linting.yml (Python), test-litellm-ui-build.yml's frontend-lint (dashboard), and
-# check-ui-api-types.yml (API-type drift), skipping any whose files aren't in scope.
+# test-linting.yml (Python, UI, and API types), skipping any whose files aren't in scope.
# Not auto-installed as a git hook so it never slows an unrelated human commit.
check:
@$(GATE_SLOT_LOCK) $(MAKE) check-inner
@@ -312,14 +296,11 @@ test-rust-extension:
rust-sqlx-prepare:
cd litellm-rust && cargo run -p litellm-db-testing --bin sqlx-prepare
-lens-dev:
- ./scripts/lens_dev.sh $(ARGS)
-
test: install-test-deps
$(UV_RUN) pytest tests/
test-unit: install-test-deps
- $(UV_RUN) pytest tests/unit tests/test_litellm -x -vv -n 4
+ $(UV_RUN) pytest tests/unit -x -vv -n 4
# Matrix test targets (matching CI workflow groups)
test-unit-llms: install-test-deps
@@ -347,7 +328,7 @@ test-unit-other: install-test-deps
$(UV_RUN) pytest tests/unit/caching tests/unit/responses tests/unit/secret_managers tests/unit/vector_stores tests/unit/a2a_protocol tests/unit/completion_extras tests/unit/containers tests/unit/enterprise tests/unit/experimental_mcp_client tests/unit/google_genai tests/unit/images tests/unit/interactions tests/unit/router_strategy tests/unit/router_utils tests/unit/types --tb=short -vv -n 4 --durations=20
test-unit-root: install-test-deps
- $(UV_RUN) pytest tests/unit/test_*.py tests/test_litellm/test_*.py --tb=short -vv -n 4 --durations=20
+ $(UV_RUN) pytest tests/unit/test_*.py --tb=short -vv -n 4 --durations=20
# Proxy unit tests (tests/unit/proxy split alphabetically)
test-proxy-unit-a: install-test-deps
@@ -357,7 +338,7 @@ test-proxy-unit-b: install-test-deps
$(UV_RUN) pytest tests/unit/proxy/test_[p-z]*.py tests/unit/skills --tb=short -vv -n 2 --durations=20
test-integration: install-test-deps
- $(UV_RUN) pytest tests/ -k "not test_litellm"
+ $(UV_RUN) pytest tests/
test-unit-helm: install-helm-unittest
helm unittest -f 'tests/*.yaml' helm/litellm-helm
diff --git a/README.md b/README.md
index 7ffc44854bb..3bd0fd54a65 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
-
+
@@ -90,6 +90,24 @@ Managing LLM calls across providers gets complicated fast — different SDKs, au
uv add litellm
```
+An independent `litellm-core` distribution provides the Python SDK with the same
+`import litellm` API and runtime dependencies. It has no optional extras, CLI entry
+points, or bundled dashboard. Install one SDK distribution per environment because
+`litellm` and `litellm-core` own overlapping Python files. Use `litellm` for the
+proxy, CLI, and optional extras
+
+Build and install core from this checkout while its release integration is pending:
+
+```shell
+python scripts/build_core_distribution.py --out-dir dist/core
+python -m pip install dist/core/litellm_core-*.whl
+```
+
+The builder requires Git, `uv` and the Rust build toolchain. It reads the release version
+from the root `pyproject.toml`, leaves source files unchanged, and produces a wheel
+and a self-contained sdist in `dist/core`. Run installation in a fresh environment
+without `litellm`
+
```python
from litellm import completion
import os
@@ -324,6 +342,7 @@ Set `LITELLM_PROXY_API_BASE` and `LITELLM_PROXY_API_KEY` and every model call th
| [Cohere Chat (`cohere_chat`)](https://docs.litellm.ai/docs/providers/cohere) | ✅ | ✅ | ✅ | | | | | | | |
| [CometAPI (`cometapi`)](https://docs.litellm.ai/docs/providers/cometapi) | ✅ | ✅ | ✅ | ✅ | | | | | | |
| [CompactifAI (`compactifai`)](https://docs.litellm.ai/docs/providers/compactifai) | ✅ | ✅ | ✅ | | | | | | | |
+| [CoralBricks (`coralbricks`)](https://docs.litellm.ai/docs/providers/coralbricks) | ✅ | ✅ | ✅ | | | | | | | |
| [Custom (`custom`)](https://docs.litellm.ai/docs/providers/custom_llm_server) | ✅ | ✅ | ✅ | | | | | | | |
| [Custom OpenAI (`custom_openai`)](https://docs.litellm.ai/docs/providers/openai_compatible) | ✅ | ✅ | ✅ | | | ✅ | ✅ | ✅ | ✅ | |
| [Dashscope (`dashscope`)](https://docs.litellm.ai/docs/providers/dashscope) | ✅ | ✅ | ✅ | ✅ | | | | | | ✅ |
@@ -389,6 +408,7 @@ Set `LITELLM_PROXY_API_BASE` and `LITELLM_PROXY_API_KEY` and every model call th
| [Sagemaker Chat (`sagemaker_chat`)](https://docs.litellm.ai/docs/providers/aws_sagemaker) | ✅ | ✅ | ✅ | | | | | | | |
| [Sail (`sail`)](https://docs.litellm.ai/docs/providers/sail) | ✅ | ✅ | ✅ | | | | | | | |
| [Sambanova (`sambanova`)](https://docs.litellm.ai/docs/providers/sambanova) | ✅ | ✅ | ✅ | | | | | | | |
+| [ScaleDown (`scaledown`)](https://docs.litellm.ai/docs/providers/scaledown) | ✅ | | | | | | | | | |
| [Snowflake (`snowflake`)](https://docs.litellm.ai/docs/providers/snowflake) | ✅ | ✅ | ✅ | | | | | | | |
| [Strands Decider (`strands_decider`)](https://docs.litellm.ai/docs/providers) | | | | | | | | | | |
| [Text Completion Codestral (`text-completion-codestral`)](https://docs.litellm.ai/docs/providers/codestral) | ✅ | ✅ | ✅ | | | | | | | |
@@ -401,7 +421,7 @@ Set `LITELLM_PROXY_API_BASE` and `LITELLM_PROXY_API_KEY` and every model call th
| [Vercel AI Gateway (`vercel_ai_gateway`)](https://docs.litellm.ai/docs/providers/vercel_ai_gateway) | ✅ | ✅ | ✅ | | | | | | | |
| [VLLM (`vllm`)](https://docs.litellm.ai/docs/providers/vllm) | ✅ | ✅ | ✅ | | | | | | | |
| [Volcengine (`volcengine`)](https://docs.litellm.ai/docs/providers/volcano) | ✅ | ✅ | ✅ | | | | | | | |
-| [Voyage AI (`voyage`)](https://docs.litellm.ai/docs/providers/voyage) | | | | ✅ | | | | | | |
+| [VoyageAI by MongoDB (`voyage`)](https://docs.litellm.ai/docs/providers/voyage) | | | | ✅ | | | | | | |
| [WandB Inference (`wandb`)](https://docs.litellm.ai/docs/providers/wandb_inference) | ✅ | ✅ | ✅ | | | | | | | |
| [Watsonx Text (`watsonx_text`)](https://docs.litellm.ai/docs/providers/watsonx) | ✅ | ✅ | ✅ | | | | | | | |
| [xAI (`xai`)](https://docs.litellm.ai/docs/providers/xai) | ✅ | ✅ | ✅ | | | | | | | |
@@ -507,9 +527,9 @@ Provider API keys live in AWS Secrets Manager; reference ARNs via `gateway_extra
#### GCP — Cloud Run + Cloud SQL + Memorystore + HTTPS LB
-[](https://ssh.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https%3A%2F%2Fgithub.com%2FBerriAI%2Flitellm&cloudshell_workspace=terraform%2Flitellm%2Fgcp%2Fexamples%2Fdefault&cloudshell_tutorial=TUTORIAL.md&cloudshell_image=gcr.io/ds-artifacts-cloudshell/deploystack_custom_image&shellonly=true)
+[](https://ssh.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https%3A%2F%2Fgithub.com%2FBerriAI%2Fterraform-google-litellm&cloudshell_git_branch=v1.105.0&cloudshell_workspace=examples%2Fdefault&cloudshell_tutorial=TUTORIAL.md&cloudshell_image=gcr.io/ds-artifacts-cloudshell/deploystack_custom_image&shellonly=true)
-Real 1-click. Opens Cloud Shell, clones this repo, and walks you through `terraform apply` via a built-in [DeployStack tutorial](./terraform/litellm/gcp/examples/default/TUTORIAL.md) — pick the project, the tutorial sets up the Artifact Registry remote repo, writes `terraform.tfvars` from your answers, and runs apply.
+Real 1-click. Opens Cloud Shell, clones the [terraform-google-litellm](https://github.com/BerriAI/terraform-google-litellm) module mirror at the latest stable release (a small download instead of the full litellm repo), and walks you through `terraform apply` via a built-in [DeployStack tutorial](./terraform/litellm/gcp/examples/default/TUTORIAL.md). Pick the project, the tutorial sets up the Artifact Registry remote repo, writes `terraform.tfvars` from your answers, and runs apply
[Module page →](https://registry.terraform.io/modules/BerriAI/litellm/google/latest)
diff --git a/backend/routes/allowlist.py b/backend/routes/allowlist.py
index ea56a1fd6df..60d695653b5 100644
--- a/backend/routes/allowlist.py
+++ b/backend/routes/allowlist.py
@@ -23,6 +23,7 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = (
"/end_user/",
"/sso/",
"/liteadmin/slack/connect/",
+ "/moyai/connect/",
"/login",
"/v2/login",
"/v3/login",
@@ -31,6 +32,7 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = (
"/token",
"/onboarding/",
"/audit",
+ "/telemetry/",
"/oauth/",
"/invitation/",
"/jwt/",
@@ -90,9 +92,10 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = (
"/user_agent",
"/usage/",
"/daily/",
- # Deployment-wide gateway request counts. Scoped to the analytics read rather
+ # Deployment-wide gateway request and error counts. Scoped to the analytics reads rather
# than all of /gateway/, which stays free for data-plane routes.
"/gateway/daily/",
+ "/gateway/errors/",
# CloudZero cost-export admin (init / settings / export / dry-run / delete)
"/cloudzero/",
# Caching admin
diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json
deleted file mode 100644
index 92dc89eb0b8..00000000000
--- a/basedpyright-code-budget.json
+++ /dev/null
@@ -1,146 +0,0 @@
-{
- "reportAny": {
- "limit": 13429
- },
- "reportArgumentType": {
- "limit": 2198
- },
- "reportAssignmentType": {
- "limit": 319
- },
- "reportAttributeAccessIssue": {
- "limit": 480
- },
- "reportCallIssue": {
- "limit": 112
- },
- "reportConstantRedefinition": {
- "limit": 40
- },
- "reportDeprecated": {
- "limit": 209
- },
- "reportDuplicateImport": {
- "limit": 19
- },
- "reportExplicitAny": {
- "limit": 3369
- },
- "reportFunctionMemberAccess": {
- "limit": 7
- },
- "reportGeneralTypeIssues": {
- "limit": 101
- },
- "reportIncompatibleMethodOverride": {
- "limit": 56
- },
- "reportIncompatibleVariableOverride": {
- "limit": 8
- },
- "reportInconsistentOverload": {
- "limit": 12
- },
- "reportIndexIssue": {
- "limit": 24
- },
- "reportInvalidTypeForm": {
- "limit": 30
- },
- "reportInvalidTypeVarUse": {
- "limit": 1
- },
- "reportMatchNotExhaustive": {
- "limit": 0
- },
- "reportMissingParameterType": {
- "limit": 5570
- },
- "reportMissingTypeArgument": {
- "limit": 15281
- },
- "reportMissingTypeStubs": {
- "limit": 40
- },
- "reportOperatorIssue": {
- "limit": 0
- },
- "reportOptionalCall": {
- "limit": 0
- },
- "reportOptionalIterable": {
- "limit": 0
- },
- "reportOptionalMemberAccess": {
- "limit": 0
- },
- "reportOptionalOperand": {
- "limit": 0
- },
- "reportOptionalSubscript": {
- "limit": 0
- },
- "reportPossiblyUnboundVariable": {
- "limit": 56
- },
- "reportPrivateUsage": {
- "limit": 1804
- },
- "reportRedeclaration": {
- "limit": 8
- },
- "reportReturnType": {
- "limit": 180
- },
- "reportTypedDictNotRequiredAccess": {
- "limit": 22
- },
- "reportUndefinedVariable": {
- "limit": 0
- },
- "reportUnknownArgumentType": {
- "limit": 44802
- },
- "reportUnknownLambdaType": {
- "limit": 109
- },
- "reportUnknownMemberType": {
- "limit": 38269
- },
- "reportUnknownParameterType": {
- "limit": 19584
- },
- "reportUnknownVariableType": {
- "limit": 29814
- },
- "reportUnnecessaryCast": {
- "limit": 110
- },
- "reportUnnecessaryComparison": {
- "limit": 687
- },
- "reportUnnecessaryContains": {
- "limit": 4
- },
- "reportUnnecessaryIsInstance": {
- "limit": 816
- },
- "reportUntypedBaseClass": {
- "limit": 0
- },
- "reportUntypedFunctionDecorator": {
- "limit": 27
- },
- "reportUnusedClass": {
- "limit": 21
- },
- "reportUnusedFunction": {
- "limit": 136
- },
- "reportUnusedImport": {
- "limit": 542
- },
- "reportUnusedVariable": {
- "limit": 137
- }
-}
diff --git a/ci_cd/cost_map_guard.py b/ci_cd/cost_map_guard.py
index 5842cf6f1ac..e7b873e3f5c 100644
--- a/ci_cd/cost_map_guard.py
+++ b/ci_cd/cost_map_guard.py
@@ -14,8 +14,9 @@ import argparse
import json
import subprocess
import sys
-from collections.abc import Sequence
+from collections.abc import Mapping, Sequence
from dataclasses import dataclass
+from types import MappingProxyType
from typing import Final
from generate_model_prices_schema import SPECIAL_ROOT_KEYS, build_schema, render, validation_errors
@@ -25,6 +26,7 @@ BACKUP_PATH: Final = "litellm/model_prices_and_context_window_backup.json"
SCHEMA_PATH: Final = "model_prices_and_context_window.schema.json"
GUARDED_PATHS: Final = (COST_MAP_PATH, BACKUP_PATH, SCHEMA_PATH)
BOT_BRANCH_PREFIX: Final = "litellm_cost_map_sync_"
+EVALUATION_OUTPUT_PRICE_SOURCES: Final[Mapping[str, str]] = MappingProxyType({})
CostMap = dict[str, object]
@@ -51,7 +53,31 @@ def _rendered_schema(cost_map: CostMap) -> str:
return str(error)
-def _file_failures(head: Snapshot, head_map: CostMap) -> tuple[str, ...]:
+def _evaluation_output_price_failure(
+ key: str, entry: object, evaluation_output_price_sources: Mapping[str, str]
+) -> str | None:
+ if not isinstance(entry, dict) or entry.get("mode") not in ("evaluation", "decisions"):
+ return None
+ output_cost: Final = entry.get("output_cost_per_token")
+ if (
+ not isinstance(output_cost, (int, float))
+ or isinstance(output_cost, bool)
+ or output_cost <= 0
+ or key in evaluation_output_price_sources
+ ):
+ return None
+ return (
+ f"{key}: mode evaluation and decisions rows bill input only; output_cost_per_token must be 0 unless the vendor "
+ "prices output tokens for this model (add the source to EVALUATION_OUTPUT_PRICE_SOURCES)"
+ )
+
+
+def _file_failures(
+ head: Snapshot,
+ head_map: CostMap,
+ *,
+ evaluation_output_price_sources: Mapping[str, str] = EVALUATION_OUTPUT_PRICE_SOURCES,
+) -> tuple[str, ...]:
schema_text: Final = _rendered_schema(head_map)
if not schema_text.startswith("{"):
return (schema_text,)
@@ -75,6 +101,16 @@ def _file_failures(head: Snapshot, head_map: CostMap) -> tuple[str, ...]:
f"{COST_MAP_PATH} does not validate against its schema: {error}"
for error in validation_errors(head_map, json.loads(schema_text))[:20]
),
+ *(
+ failure
+ for key, entry in head_map.items()
+ if (
+ failure := _evaluation_output_price_failure(
+ key, entry, evaluation_output_price_sources
+ )
+ )
+ is not None
+ ),
)
@@ -121,13 +157,25 @@ def contract_for(bot: bool, changed_files: Sequence[str]) -> str:
return "human PR, file checks only" if touches_cost_map(changed_files) else "human PR, cost map untouched"
-def guard_failures(base: Snapshot, head: Snapshot, changed_files: Sequence[str], bot: bool) -> tuple[str, ...]:
+def guard_failures(
+ base: Snapshot,
+ head: Snapshot,
+ changed_files: Sequence[str],
+ bot: bool,
+ *,
+ evaluation_output_price_sources: Mapping[str, str] = EVALUATION_OUTPUT_PRICE_SOURCES,
+) -> tuple[str, ...]:
if not bot and not touches_cost_map(changed_files):
return ()
head_map: Final = _parse_object(head.cost_map, COST_MAP_PATH)
if isinstance(head_map, str):
return (head_map,)
- return (*_file_failures(head, head_map), *(_bot_failures(base, head_map, changed_files) if bot else ()))
+ return (
+ *_file_failures(
+ head, head_map, evaluation_output_price_sources=evaluation_output_price_sources
+ ),
+ *(_bot_failures(base, head_map, changed_files) if bot else ()),
+ )
def _git(*args: str) -> str | None:
diff --git a/ci_cd/run_migration.py b/ci_cd/run_migration.py
index c737050f3a8..84d529d2a99 100644
--- a/ci_cd/run_migration.py
+++ b/ci_cd/run_migration.py
@@ -322,7 +322,12 @@ def create_migration(
# Write the SQL to migration.sql
migration_file = migration_dir / "migration.sql"
- migration_file.write_text(diff_sql)
+ migration_sql: Final = (
+ diff_sql
+ if "CONCURRENTLY" in diff_sql.upper()
+ else "SET LOCAL lock_timeout = '10s';\n\n" + diff_sql
+ )
+ migration_file.write_text(migration_sql)
print(f"Created migration in {migration_dir}")
return True
diff --git a/codecov.yaml b/codecov.yaml
index 4d93c18f3ac..5b30e78a8fb 100644
--- a/codecov.yaml
+++ b/codecov.yaml
@@ -6,7 +6,7 @@ codecov:
ignore:
- "litellm-rust/**"
-# Uploads are flagged per workflow/shard (GHA) or "circleci". carryforward makes
+# Uploads are flagged per workflow tier (GHA) or "circleci". carryforward makes
# a re-upload of a flag replace its prior session instead of accumulating a
# conflicting one, and lets a commit reuse a flag from its parent when that flag
# was not re-uploaded. Required because the same commit can receive the
@@ -27,6 +27,78 @@ flag_management:
carryforward: false
- name: circleci
carryforward: false
+ - name: core-utils
+ carryforward: false
+ - name: enterprise-routing
+ carryforward: false
+ - name: integrations
+ carryforward: false
+ - name: llm-vertex-ai
+ carryforward: false
+ - name: llm-other-providers
+ carryforward: false
+ - name: llm-openai-meta
+ carryforward: false
+ - name: misc
+ carryforward: false
+ - name: misc-dirs
+ carryforward: false
+ - name: proxy-auth
+ carryforward: false
+ - name: proxy-hooks-client
+ carryforward: false
+ - name: proxy-endpoints
+ carryforward: false
+ - name: proxy-feature-endpoints
+ carryforward: false
+ - name: proxy-server
+ carryforward: false
+ - name: mcp-elicitation
+ carryforward: false
+ - name: proxy-infra
+ carryforward: false
+ - name: proxy-infra-root
+ carryforward: false
+ - name: caching-local
+ carryforward: false
+ - name: proxy-extras
+ carryforward: false
+ - name: enterprise-package
+ carryforward: false
+ - name: enterprise-managed-files
+ carryforward: false
+ - name: responses-caching-types
+ carryforward: false
+ - name: lens-python-310
+ carryforward: false
+ - name: lens-postgres
+ carryforward: false
+ - name: proxy-db-key-generation
+ carryforward: false
+ - name: proxy-db-auth-checks
+ carryforward: false
+ - name: proxy-db-jwt-and-keys
+ carryforward: false
+ - name: proxy-db-proxy-utils
+ carryforward: false
+ - name: proxy-db-proxy-server-core
+ carryforward: false
+ - name: proxy-db-proxy-runtime
+ carryforward: false
+ - name: proxy-db-mcp-oauth
+ carryforward: false
+ - name: proxy-db-custom-logging
+ carryforward: false
+ - name: proxy-db-logging-misc
+ carryforward: false
+ - name: proxy-db-db-and-spend
+ carryforward: false
+ - name: proxy-db-guardrails-hooks
+ carryforward: false
+ - name: proxy-db-budgets
+ carryforward: false
+ - name: proxy-db-endpoints-and-responses
+ carryforward: false
component_management:
individual_components:
diff --git a/cookbook/litellm_proxy_server/grafana_dashboard/dashboard_all_metrics/grafana_dashboard.json b/cookbook/litellm_proxy_server/grafana_dashboard/dashboard_all_metrics/grafana_dashboard.json
index 4fb926a9658..996e440d77d 100644
--- a/cookbook/litellm_proxy_server/grafana_dashboard/dashboard_all_metrics/grafana_dashboard.json
+++ b/cookbook/litellm_proxy_server/grafana_dashboard/dashboard_all_metrics/grafana_dashboard.json
@@ -6436,6 +6436,133 @@
],
"title": "litellm_zero_cost_requests rate",
"type": "timeseries"
+ },
+ {
+ "collapsed": false,
+ "gridPos": {
+ "h": 1,
+ "w": 24,
+ "x": 0,
+ "y": 438
+ },
+ "id": 112,
+ "panels": [],
+ "title": "Project model rate limits",
+ "type": "row"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "description": "Configured rate limit for the Project on the requested model in the current window, by rate_limit_type",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10,
+ "lineWidth": 1,
+ "showPoints": "never",
+ "spanNulls": false
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 439
+ },
+ "id": 113,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom",
+ "showLegend": true
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "editorMode": "code",
+ "expr": "max by (project_id, project_alias, requested_model, rate_limit_type) (litellm_project_model_rate_limit_allowed_metric)",
+ "legendFormat": "{{project_alias}} ({{project_id}}) / {{requested_model}} / {{rate_limit_type}}",
+ "range": true,
+ "refId": "A"
+ }
+ ],
+ "title": "litellm_project_model_rate_limit_allowed_metric",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "description": "Requests or tokens the Project has consumed on the requested model in the current rate limit window, by rate_limit_type",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10,
+ "lineWidth": 1,
+ "showPoints": "never",
+ "spanNulls": false
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 439
+ },
+ "id": 114,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom",
+ "showLegend": true
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "editorMode": "code",
+ "expr": "max by (project_id, project_alias, requested_model, rate_limit_type) (litellm_project_model_rate_limit_used_metric)",
+ "legendFormat": "{{project_alias}} ({{project_id}}) / {{requested_model}} / {{rate_limit_type}}",
+ "range": true,
+ "refId": "A"
+ }
+ ],
+ "title": "litellm_project_model_rate_limit_used_metric",
+ "type": "timeseries"
}
],
"preload": false,
diff --git a/db_scripts/partition_spend_logs.sql b/db_scripts/partition_spend_logs.sql
index c153a67eaec..750c1a934aa 100644
--- a/db_scripts/partition_spend_logs.sql
+++ b/db_scripts/partition_spend_logs.sql
@@ -55,6 +55,8 @@ ALTER INDEX IF EXISTS "LiteLLM_SpendLogs_session_id_idx"
RENAME TO "LiteLLM_SpendLogs_legacy_session_id_idx";
ALTER INDEX IF EXISTS "LiteLLM_SpendLogs_api_key_startTime_idx"
RENAME TO "LiteLLM_SpendLogs_legacy_api_key_startTime_idx";
+ALTER INDEX IF EXISTS "LiteLLM_SpendLogs_litellm_call_id_idx"
+ RENAME TO "LiteLLM_SpendLogs_legacy_litellm_call_id_idx";
CREATE TABLE "LiteLLM_SpendLogs" (
LIKE "LiteLLM_SpendLogs_legacy" INCLUDING DEFAULTS INCLUDING GENERATED
diff --git a/deploy/lens/Dockerfile b/deploy/lens/Dockerfile
deleted file mode 100644
index 52091ea3f1c..00000000000
--- a/deploy/lens/Dockerfile
+++ /dev/null
@@ -1,36 +0,0 @@
-ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:1d95114038f76513a9ace6fca107d5582b08c65981f81f61cb56bf7fd2ef216d
-ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:1d95114038f76513a9ace6fca107d5582b08c65981f81f61cb56bf7fd2ef216d
-ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a
-
-FROM $UV_IMAGE AS uvbin
-
-FROM $LITELLM_BUILD_IMAGE AS builder
-COPY --from=uvbin /uv /usr/local/bin/uv
-RUN apk add --no-cache python-3.13 build-base libseccomp-dev
-ENV UV_PYTHON_DOWNLOADS=0 UV_LINK_MODE=copy
-WORKDIR /app
-COPY deploy/lens/requirements.lock /tmp/requirements.lock
-RUN uv venv --python python3.13 /app/.venv && \
- uv pip sync --python /app/.venv/bin/python --require-hashes --only-binary :all: /tmp/requirements.lock
-COPY deploy/lens/python_policy.c /tmp/python_policy.c
-RUN cc -std=c11 -D_GNU_SOURCE -O2 -Wall -Wextra -Werror /tmp/python_policy.c -lseccomp -o /tmp/python-policy && \
- /tmp/python-policy /app/python.seccomp
-
-FROM $LITELLM_RUNTIME_IMAGE AS runtime
-ARG LITELLM_RELEASE_TAG=""
-RUN : "${LITELLM_RELEASE_TAG:?Pass --build-arg LITELLM_RELEASE_TAG matching the gateway}"
-RUN apk add --no-cache python-3.13 setpriv
-ENV LITELLM_RELEASE_TAG=${LITELLM_RELEASE_TAG} \
- PATH="/app/.venv/bin:${PATH}" \
- PYTHONDONTWRITEBYTECODE=1
-WORKDIR /app
-COPY --from=builder /app/.venv /app/.venv
-COPY litellm/proxy/lens/__init__.py litellm/proxy/lens/models.py litellm/proxy/lens/trace_store.py litellm/proxy/lens/analysis.py litellm/proxy/lens/worker.py litellm/proxy/lens/release.py /app/lens/
-COPY litellm/proxy/lens/context_pipeline.py litellm/proxy/lens/agent_review.py litellm/proxy/lens/agent_runtime.py litellm/proxy/lens/agent_workspace.py litellm/proxy/lens/python_tool.py litellm/proxy/lens/activity.py litellm/proxy/lens/agent_context.py /app/lens/
-COPY litellm/proxy/lens/reviews.py litellm/proxy/lens/reconciliation.py /app/lens/
-COPY litellm/proxy/lens/prompts/ /app/lens/prompts/
-COPY --from=builder /app/python.seccomp /app/lens/python.seccomp
-COPY deploy/lens/python_runtime.py /tmp/python_runtime.py
-RUN python3.13 -S /tmp/python_runtime.py /app/lens/python-runtime.json && rm /tmp/python_runtime.py
-USER 65532:65532
-CMD ["python", "-m", "lens.worker"]
diff --git a/deploy/lens/Dockerfile.dockerignore b/deploy/lens/Dockerfile.dockerignore
deleted file mode 100644
index 72c1be4241b..00000000000
--- a/deploy/lens/Dockerfile.dockerignore
+++ /dev/null
@@ -1,12 +0,0 @@
-**
-!deploy/
-!deploy/lens/
-!deploy/lens/requirements.lock
-!deploy/lens/python_policy.c
-!deploy/lens/python_runtime.py
-!litellm/
-!litellm/proxy/
-!litellm/proxy/lens/
-!litellm/proxy/lens/*.py
-!litellm/proxy/lens/prompts/
-!litellm/proxy/lens/prompts/**
diff --git a/deploy/lens/README.md b/deploy/lens/README.md
deleted file mode 100644
index 522b2792d69..00000000000
--- a/deploy/lens/README.md
+++ /dev/null
@@ -1,292 +0,0 @@
-# Lens worker
-
-Lens reviews recorded activity and saves evidence-linked findings in the LiteLLM dashboard under Observability, Lens (`/ui/lens/`)
-
-## Install
-
-Build LiteLLM and its worker from the same source commit with the same release identity. The worker runs separately and connects to your gateway using a limited worker token
-
-### New local installation
-
-Install Docker with Compose and Git. This builds LiteLLM and its worker from the same checkout and starts the existing local tracing stack:
-
-```bash
-git clone https://github.com/BerriAI/litellm.git
-cd litellm
-export LITELLM_RELEASE_TAG="sha-$(git rev-parse HEAD)"
-export LENS_WORKER_IMAGE="litellm-lens-worker:${LITELLM_RELEASE_TAG}"
-export OPENAI_API_KEY='sk-...'
-docker build --build-arg LITELLM_RELEASE_TAG="$LITELLM_RELEASE_TAG" \
- -f deploy/lens/Dockerfile -t "$LENS_WORKER_IMAGE" .
-docker compose -f docker/docker-compose.tracing.yml up -d --build
-```
-
-Open `http://localhost:4002/ui/` and sign in as `admin` with the key saved in `.lens-dev/master_key`. Go to **Lens > Investigations > Connect worker**, choose a model and monthly budget, then **Get install command**. Expand **Using Docker Compose or Helm?** and copy the worker token. In the same terminal, run:
-
-```bash
-export LITELLM_URL=http://litellm:4000
-export LENS_WORKER_TOKEN=''
-docker compose -f docker/docker-compose.tracing.yml -f deploy/lens/compose.yaml up -d
-```
-
-The worker joins the gateway's Docker network, and the dashboard shows **Worker connected**. Save the token privately for restarts and upgrades
-
-This stack is for local evaluation: it binds to localhost and uses development database credentials. For a hosted deployment, keep your normal database, keys, networking, and deployment process. Build both images from one source revision with the same `LITELLM_RELEASE_TAG`, publish the worker to your registry, and set `LENS_WORKER_IMAGE` on LiteLLM to that image
-
-### Existing LiteLLM installation
-
-Keep your deployment and PostgreSQL database. A working gateway/worker pair can stay as it is until you upgrade both. For a gateway built from source, use its exact commit and `LITELLM_RELEASE_TAG`; a release version or the latest commit on `main` is not a substitute for that source identity
-
-The public development package is `ghcr.io/berriai/litellm-lens-worker-dev:sha-`. It publishes amd64 images on Lens-related changes, so an arbitrary source commit may have no image. Check the exact image exists before using it. If it is unavailable, your gateway uses a different release identity, or you need native arm64, build the worker from the gateway's checkout:
-
-```bash
-export LITELLM_RELEASE_TAG=''
-export LENS_WORKER_IMAGE='/litellm-lens-worker:'
-docker build --build-arg LITELLM_RELEASE_TAG="$LITELLM_RELEASE_TAG" \
- -f deploy/lens/Dockerfile -t "$LENS_WORKER_IMAGE" .
-```
-
-For a remote worker host, publish that image to a registry the host can pull from. Set the gateway's `LENS_WORKER_IMAGE` to the resulting image reference, restart the gateway using its normal deployment process, then copy its install command. Prefer the published image digest for hosted installations. Do not change the gateway's release identity just to accept another worker
-
-For Kubernetes or Render, run the standalone worker using `LITELLM_URL` and `LENS_WORKER_TOKEN` from setup. Keep existing databases and secrets. The worker needs no inbound port.
-
-## Helm
-
-The componentized source chart at `helm/litellm` includes an optional Lens worker. Use the chart from the same checkout as your gateway and keep your component image overrides in your values. Configure PostgreSQL and ClickHouse as usual, install the chart, then obtain a limited worker token from Lens setup. Store it in a Kubernetes Secret and enable the worker in your values:
-
-```yaml
-lensWorker:
- enabled: true
- image:
- repository:
- digest: sha256:
- tokenSecret:
- name: litellm-lens-worker
- key: token
-```
-
-Set the worker repository and digest explicitly to an image built from the gateway's source commit and release identity. The chart connects the worker to the backend service. Keep these values and the Secret when upgrading the chart and update the gateway and worker image overrides together. `lensWorker.replicaCount` controls simultaneous investigations. To use a private registry or external proxy, set `lensWorker.image.repository`, `lensWorker.image.digest` (or `tag` for a source build), and `lensWorker.url`. A digest takes precedence over the tag. The dashboard uses the chart's worker image for standalone install commands too
-
-## Standalone worker
-
-Start with a source deployment that includes Lens, PostgreSQL, and agent tracing, and prepare its matching worker as described above. Configure one ClickHouse URL for trace writes, bounded reads, and Lens queries:
-
-```yaml
-general_settings:
- tracing:
- store:
- type: clickhouse
- url: os.environ/CLICKHOUSE_URL
- retention_days: 14
-```
-
-The URL, database, and retention settings can also come from `CLICKHOUSE_URL`, `CLICKHOUSE_DATABASE`, and `AGENT_TRACING_RETENTION_DAYS` when omitted from YAML. A YAML value wins when both are set. The database defaults to `litellm`. `retention_days` defaults to 14 and applies to both traces and spend logs
-
-Retention changes require a proxy restart. ClickHouse removes expired rows during background merges, not immediately at startup. Enable request/response logging to analyze LLM requests. Lens can only inspect content you actually retain
-
-In **Lens > Investigations**, click **Connect worker**, choose an analysis model and monthly limit, then **Get install command**. Use **Advanced options** to select an existing virtual key or change the proxy URL if the server running Docker needs a different network address. Copy the command and run it on your server. The dashboard shows **Worker connected** when the container checks in
-
-The command already contains the compatible worker image and one worker token. The selected virtual key stays on the proxy; its secret is never sent to the worker. Once the matching image is available on the worker host, no second LiteLLM deployment is needed. Keep the command private because it includes the token. The LiteLLM release provides the dashboard and APIs; the container only runs background analysis
-
-The dashboard uses the gateway's `LENS_WORKER_IMAGE` override when set. Public `:sha-` development images must match both the gateway commit and release identity. Build from source for the worker host's native architecture
-
-After upgrading the gateway, update the worker image and redeploy it while keeping its proxy URL and token. Existing containers do not update automatically. If an investigation reports a worker compatibility error, update the image before retrying
-
-For deployments managed with Compose, download `compose.yaml` and provide `LITELLM_URL`, `LENS_WORKER_TOKEN`, and an explicit `LENS_WORKER_IMAGE` in a private environment file:
-
-```bash
-docker compose --env-file /path/to/lens.env -f compose.yaml up -d
-```
-
-To work on Lens itself, `make lens-dev` runs the proxy, a worker from source and the hot-reload dashboard together; set `LENS_DEV_PROXY_PORT` / `LENS_DEV_UI_PORT` to move them off 4000/3000. For a local container build, set `LENS_WORKER_IMAGE=litellm-lens-worker:local` and `LITELLM_RELEASE_TAG` to the gateway's release tag, then use `docker compose -f deploy/lens/compose.yaml -f deploy/lens/compose.build.yaml up -d --build`
-
-The generated command gives the worker 1 GiB of temporary memory-backed storage, shared across parallel reviews. Change `size=1g` in the Docker command or set `LENS_WORKER_TMP_SIZE` with Compose to fit your server and workload. Python reports storage failures to the reviewer and cleans up temporary files, so the reviewer can retry a smaller computation or report insufficient evidence. The worker remains available for other scans. Existing workers must be recreated with the new image and mount options
-
-The worker needs outbound HTTPS access to LiteLLM. It needs no inbound ports, provider keys, direct database access, or GPU. The proxy calls your selected model through its normal virtual-key authorization and inference pipeline; trace content reaches that model provider. Use a model with JSON output support and known token prices. One worker handles up to three investigations concurrently and can serve multiple lenses. For more throughput, start another worker with a separate credential
-
-If your deployment restricts `allowed_ips`, allow the worker's address. For workers behind a reverse proxy with `use_x_forwarded_for: true`, also configure `mcp_trusted_proxy_ranges` with that proxy's CIDRs and, when needed, `mcp_xff_num_trusted_hops`. Lens reuses these existing trusted-proxy settings. Forwarded addresses without an established trust boundary are rejected by the allowlist; accepting them would let a worker impersonate an allowed address
-
-Setup, manual runs, feedback, and worker credentials are restricted to proxy administrators. Proxy-admin viewers can inspect results. Regular user and team keys cannot access the Lens API. Worker credentials can serve the administrator’s lenses. Revoke it in the connection dialog when retiring a worker. Redeploy the worker alongside proxy upgrades so their API versions match
-
-## Configure a lens
-
-Choose agent runs, individual LLM requests, or both. The matching-activity preview updates as you choose an application (the recorded OpenTelemetry service.name) or, for request activity, a LiteLLM model group and add metadata conditions. It shows run names, timestamps, and trace IDs; open a run to inspect its original steps before starting analysis. Suggestions come from up to 100 recent executions and may not include every recorded attribute. You can enter other exact keys and values. Leave service and filters blank for all activity your account can access. Filters are exact key/value matches, combined with AND. Trace filters match span or resource attributes on the same span. Request filters match logged metadata, including caller metadata stored under `requester_metadata`; `tag=value` matches request tags. `swarm=research` works only if your instrumentation records that attribute
-
-Describe how the agent should behave and optionally add specific checks. Select the lookback window, team and metadata, then choose the percentage to review and an optional maximum. **100% with no maximum selects every matching run**. The preview pages through all matching activity and lets you select particular runs. Percentage sampling uses a stable hash order, rounds up, and applies the optional maximum after the percentage
-
-Choose your analysis model, parallelism and monthly budget. Parallelism controls simultaneous model calls, not the number of runs selected. New lenses run once by default. Turn on monitoring to repeat the same setup at a custom interval. **Run now** uses the same saved settings immediately, including the same lookback window and sampling. Each scan recalculates the window and reuses completed reviews when the selected trace content, expected behavior, enabled checks and analysis model are unchanged. Budget, name and schedule edits preserve reuse. Duplicate a lens when you want a separate investigation without changing an existing monitor
-
-Pausing stops future scheduled scans; cancel the active scan separately if needed. The worker polls every two seconds; creating a lens or clicking Run now queues a scan, and due schedules are queued when the worker polls. Scans for the same lens never overlap, and its next interval starts after completion. Closing the browser does not stop the worker. A running scan retains its analysis settings and selected execution IDs across retries. Budget edits apply to subsequent model calls, including those in an active scan
-
-## Read the results
-
-Needs attention shows issues, highest priority first. Patterns contains useful trends and successful behavior that may not need a fix. Each finding starts with a short explanation and a next step when useful. Expand the limitations for uncertainty and counterexamples. Evidence is grouped by run and collapsed until you need it; each quote opens the original step
-
-Use the **Investigation run** selector or **History** to reopen previous results. Each run keeps its new or updated findings, settings, selected traces, coverage and cost. An unchanged rerun adds no findings; choose **All accumulated findings** to see saved findings across runs. The **Agent traces** tab lists the selected sample, including traces without an observed issue and traces with insufficient evidence. The tab is called **LLM requests** or **Traces and requests** for those activity types. Findings distinguish distinct affected traces from contributing investigation runs. Counterexamples remain visible as evidence without increasing the affected count. Merged finding links continue to resolve to the retained finding
-
-Enter an explanation under **What should Lens remember?** and choose **This is expected** to dismiss expected behavior, **Mark resolved** after fixing an issue, or **Reopen** to reopen a resolved issue. These actions save the feedback together with the status; typing feedback alone neither saves it nor resolves the finding. Feedback informs later analysis and reconciliation without invalidating completed reviews. It stays with the finding when evidence recurs, does not alter historical evidence, and does not exempt different problems
-
-## What a scan does
-
-The proxy selects executions received or updated within the configured lookback window, with a two-minute settling period. Older rows without receipt timestamps use execution end time. Overlapping scans do not increment a finding's occurrence count for the same execution ID
-
-A trace is spans sharing a trace ID within one team, not an automatically reconstructed conversation session. Requests are individual LLM calls. When both sources are enabled, requests correlated to a recorded span by response ID are excluded to reduce double counting
-
-The worker reads complete selected trace content to compute fingerprints before making paid model calls. Completed review checkpoints are reused only within the same lens when both the complete content and investigation criteria match. Criteria are the expected behavior, enabled check IDs and instructions, and analysis model. Lens fingerprints these values rather than using the time of an unrelated settings edit. Scope and sampling changes preserve matching reviews for traces selected again; duplicating a lens starts an independent set of reviews. Initial reviews are confined to their assigned trace and retain observations, cited excerpts and metadata. Reviewers use catalog, read, search and optional Python tools; Python receives selected evidence as streamed input. Pending observation batches are grouped in parallel and investigated against the original evidence, then reconciled with saved findings. A recurring cause extends its existing finding, preserving feedback, earlier evidence and contributing run history
-
-If every selected review is already incorporated into findings, the run completes with a reuse count, zero model calls and zero analysis cost, including when the monthly budget is exhausted. The run remains in history. A partially failed run preserves completed review checkpoints; pending grouping or investigation can still need paid model calls even when all selected trace reviews are reused. A trace without a complete matching checkpoint needs a review. Live progress distinguishes reviews eligible for reuse from reviews actually recorded; failed or cancelled runs report only the reuse they completed
-
-There is no fixed total run, span, candidate or investigation-turn cutoff. Agents can replace their active conversation with working notes. If a request exceeds the configured model's context window, the worker compacts the conversation automatically and resumes with references to its archived tool history. Original evidence remains accessible through the gateway while it is available and retained. Tool results and working notes remain accessible during the investigation; character ranges make even a single oversized result readable in pieces. A review reports an error if the task or its replacement notes cannot fit. Context windows, the configured budget, worker resources and recorded evidence still bound practical work. The investigator has no browsing, code-editing or production-action tools
-
-The live review drawer shows loading, trace review, parallel grouping, reconciliation and candidate investigation. It reports current model and tool operations, including context compaction, and retains tool-call counts on completed trace reviews. These counts describe attempted calls, not successful executions. This progress channel contains operation metadata, not Python code or tool output. Preliminary observations remain separate from final findings and their validated evidence
-
-Each model response must match its JSON schema. A malformed response gets one repair attempt through the same budget controls. A session review that remains invalid or cannot fit marks that execution unassessable while other reviews continue. Broken evidence pagination or missing content pages return tool errors so the agent can inspect narrower spans or other evidence. Unreadable citations receive repair feedback. Verified excerpts remain available without fetching their source again. The affected source counts as partial, including failures discovered during later investigations, while the reviewer owns its assessment. Findings are published only after comparison with each other and saved findings finishes. If analysis stops before that comparison completes, completed trace reviews and their evidence remain saved for reuse, and the run retains its assessments and error. A later run can retry grouping and investigation. Runs with useful completed assessments or reconciled findings show partial results; total failures are marked failed. Transport errors, cancellation and budget exhaustion stop further analysis. Both the worker and proxy validate quoted evidence against original content. Per-run issue assessments follow supporting citations, including evidence found by another run's reviewer; counterexamples do not mark a run affected. Findings retain exact quotes and open the source trace or request. Resolve a finding after a fix, or dismiss it with a reason. A resolved finding reopens when new execution IDs support the same pattern; dismissed findings remain dismissed
-
-Coverage distinguishes eligible, sampled, reviewed, partial, and unassessable executions. Findings describe observations in the sample, not population-wide success rates or proven causes. A root span does not prove that a trace contains every expected span. Long, missing, redacted, or expired content limits the conclusions
-
-## Operations and limits
-
-PostgreSQL stores configurations, findings and all scan history, returned in pages of 50 jobs. Workers claim jobs with optimistic concurrency and a five-minute lease, renewed every 30 seconds. A disconnected job can be reclaimed up to three times. Cancellation stops subsequent work; a model call already in flight may finish and incur cost
-
-Lens checks which selected traces have reusable reviews before requesting model budget. Reuse needs no model call or reservation. New trace reviews and unfinished grouping or investigation can incur cost
-
-Before each model call, Lens reserves a conservative allowance based on the input and permitted output. The summary separates settled monthly spend, unexpired reservations and available budget. With a $100 limit, $45 spent and $10 reserved, $45 is available for additional calls. Successful calls settle to recorded cost and release unused capacity. Failed or timed-out requests release their hold; abandoned holds expire after the proxy request timeout plus a grace period
-
-Calls wait when concurrent reservations temporarily hold the remaining capacity. Waiting and model execution share the proxy's request timeout. If one request's allowance exceeds the unspent monthly budget, the error reports what the request needs and what remains. Reduce the deployment's output allowance or increase the limit. Paid analysis stops when the monthly limit is spent; completed reviews can still be reused. The monthly budget renews on the UTC calendar month
-
-The assigned virtual key has independent budgets, model permissions and rate limits. Several investigations can share that key, so its limit can stop analysis even when one lens has budget left. Every worker needs a billing key assigned through worker setup or **Settings**. Analysis spend appears under that key in **Virtual Keys** and normal request logs, with Lens, run and worker IDs in request metadata. Analysis prompts and responses are redacted from spend logs; source traces and findings remain available through the administrator-only Lens API. Terminal budget, authentication or transport failures stop the scan after applicable retries and preserve completed checkpoints
-
-Lens requires ClickHouse for both sources. It does not reconstruct sessions from unrelated trace IDs, guarantee exhaustive reviews, or automatically fix agent code. Trace contents can change as late spans arrive, even though a job's selected IDs are fixed. Changed content requires a matching review before reuse. Findings should be reviewed by a person before acting on them
-
-
-## API access
-
-The UI and API use the same scan lifecycle. Authenticate with a proxy administrator credential for writes, or a proxy-admin viewer credential for reads. Worker credentials are only for worker operations
-
-```bash
-curl "$LITELLM_URL/lens" -H "Authorization: Bearer $LITELLM_API_KEY" \
- -H 'Content-Type: application/json' -d '{
- "name": "Research quality", "model": "your-model-alias",
- "context": "Answer the requested question using cited, retrieved evidence.",
- "source": "traces", "lookback_hours": 24,
- "sample_percent": 100, "sample_size": null, "concurrency": 8,
- "enabled": true, "interval_minutes": 1440, "monthly_budget": 50
- }'
-
-curl "$LITELLM_URL/lens/$LENS_ID/runs" -X POST \
- -H "Authorization: Bearer $LITELLM_API_KEY" -H 'Content-Type: application/json' -d '{}'
-
-curl "$LITELLM_URL/lens/$LENS_ID/runs?offset=0" -H "Authorization: Bearer $LITELLM_API_KEY"
-curl "$LITELLM_URL/lens/$LENS_ID/runs/$BATCH_ID" -H "Authorization: Bearer $LITELLM_API_KEY"
-```
-
-Creation queues the first batch. Posting to `/lens/{id}/runs` queues another, or returns the existing active batch. The run response contains its ID under `jobs[0].id`. Poll the batch URL for status, findings and assessments. List responses omit large result payloads; request a batch to retrieve them. Supply an optional complete `settings` object on the runs POST for a one-off override; the saved lens stays unchanged. Selection accepts `team_id`, exact `filters`, and opaque `execution_ids` returned by `/lens/preview/sample`. Preview accepts `offset` and `as_of` to keep the time window fixed while paging. Feedback uses `PATCH /lens/{id}/findings/{finding_id}` with `status` and `reason`
-
-## Local development
-
-`make lens-dev ARGS=--seed` starts the full dev stack. The live dashboard is at `http://localhost:3000/ui/lens/`, with login at `http://localhost:3000/ui/login/`. Next.js forwards API requests to the proxy on port 4000, so login and navigation stay in the live UI and edits hot-reload
-
-The default is Next.js dev with no production build (`LENS_DEV_BUILD_UI=0`). Set `LENS_DEV_BUILD_UI=1` when you also want a fresh static dashboard at `http://localhost:4000/ui/`. Build output goes to `.lens-dev/logs/ui-build.log`; a failed build stops startup. Both modes keep the live dashboard on port 3000. Startup checks the live login route before seeding and fails with the UI log path if Next.js exits. `LENS_DEV_STARTUP_TIMEOUT_SECONDS` controls startup readiness retries (default 300; `LENS_DEV_READINESS_REQUEST_TIMEOUT_SECONDS` caps each HTTP probe, default 5)
-
-For local fixture data, run `make lens-dev ARGS=--seed`. Use `make lens-dev ARGS="--seed large"` for 2,000 fixture copies spread over the last 24 hours, about 860,000 spans with linked request logs, plus three long sessions of roughly 1,150, 9,200 and 92,000 spans in a single trace for drawer paging and the oversized read path. Their trace IDs are printed at the end. To seed a running stack without restarting it, use `make lens-dev ARGS="--seed-only --seed large --copies 100"`. Every profile replays one copy of every checked-in capture through authenticated `/v1/traces`, including failures, retries, streaming and multiple agent frameworks, and verifies linked spend totals through the proxy. Large seeds then copy that first copy inside ClickHouse and PostgreSQL with `INSERT ... SELECT`, rewriting trace, span and call IDs so each copy keeps its own spend, and verify the last copy through the proxy
-
-Seeds append fresh IDs on every invocation and spread copies over recent timestamps. Restarts without `SEED` do not add data. Lens excludes activity received in the last two minutes, so wait two minutes after seeding before checking investigation previews. `LENS_DEV_SEED_COPIES` overrides total copies. Large seeds test data volume and pagination, rather than concurrent ingestion throughput or review accuracy. They can use substantial disk space; adjust `--copies` for your machine. Seeding expects the generated local tracing configuration. The old `run_tracing_proxy_local.sh --seed` command forwards to Lens dev, using its ports and saved master key
-
-Local ingestion limits are explicit and configurable. Set OTLP and ClickHouse variables before starting the proxy and seeder so both processes use the same settings. Invalid, zero and negative values fail instead of silently falling back. Changing these limits does not require rebuilding Rust
-
-| Environment variable | Default | Controls |
-| --- | --- | --- |
-| `LENS_DEV_SEED_COPIES` | 1 default, 2000 large | Total fixture copies |
-| `LENS_DEV_SEED_TIMEOUT_SECONDS` | 120 | Seeder HTTP timeout |
-| `OTLP_MAX_BODY_BYTES` | 16777216 | HTTP body and decompressed payload bytes |
-| `OTLP_MAX_CONCURRENT_INGESTS` | 2 | Concurrent proxy ingestion requests |
-| `OTLP_MAX_ATTRIBUTE_VALUE_BYTES` | 65536 | Stored attribute/content bytes |
-| `OTLP_MAX_DECODE_DEPTH` | 32 | Nested decode depth |
-| `OTLP_MAX_DECODE_NODES` | 65536 | JSON values or protobuf fields per export |
-| `OTLP_MAX_SPANS` | 4096 | Spans per export |
-| `OTLP_MAX_ATTRIBUTES` | 256 | Attributes per resource, scope, span, event or link |
-| `OTLP_MAX_EVENTS` | 256 | Events per span |
-| `OTLP_MAX_LINKS` | 256 | Links per span |
-| `OTLP_MAX_DECODED_SPAN_BYTES` | 16777216 | Decoded span allocation budget |
-| `CLICKHOUSE_TRACE_MAX_INSERT_BYTES` | 67108864 | Encoded trace or spend insert bytes |
-| `CLICKHOUSE_INSERT_TIMEOUT_SECONDS` | 30 | ClickHouse insert HTTP timeout |
-
-The wire parsers also enforce their library recursion limits (128 levels for JSON, 100 for protobuf). Raising the configured depth does not remove those parser limits.
-
-## Quality evaluation
-
-Run the checked-in cases against a configured real model. Expected labels are used only for scoring, never passed to the model. Dev and held-out cases include missing outcomes, failed tools, recovery, handoffs, unsupported claims, repeated work, long evidence and prompt injection. The background option adds clean arithmetic traces to test rare-issue discovery at scale; those repeated synthetic cases do not establish accuracy on every production workload
-
-```bash
-python -m tests.proxy_behavior.lens.evaluate --api-base "$LITELLM_URL" \
- --model your-model-alias --split all --background 1000 --concurrency 16 \
- --output /tmp/lens-quality.json
-```
-
-Set `LITELLM_API_KEY` privately. This makes paid model calls. Inspect missed and unexpected per-run labels, final findings and coverage; do not equate a passing dataset with guaranteed detection on arbitrary traces
-
-The default workspace retrieves trace content on demand. Python calls have temporary scratch space that is removed after execution. The Docker command supplies a writable temporary mount while keeping the application filesystem read-only
-
-To check that accepted behavior stays accepted without hiding new problems, run the evaluator with `--dataset tests/proxy_behavior/lens/feedback_cases.json`. Reports include elapsed time, model call count, reported cost when the proxy provides it, missed checks, unexpected checks, and inconclusive candidates
-
-## Release compatibility
-
-Gateway and worker builds carry the same `LITELLM_RELEASE_TAG`. A worker announces its release and protocol before claiming an investigation. A mismatch returns HTTP 409 with the required image, leaving queued investigations untouched
-
-PostgreSQL stores complete review checkpoints in `LiteLLM_LensReview`, alongside Lens records and run history. Schema migrations preserve saved investigations, findings, history, worker credentials and billing assignments without rewriting stored Lens records
-
-Drain active scans, stop workers, back up the database, and deploy all gateway replicas as a coordinated replacement or traffic cutover. Keep traffic paused until every gateway replica uses the selected build and its migrations have completed. Mixed gateway versions sharing Lens data are not supported because every replica must understand the stored records. Pausing workers alone does not prevent dashboard or API writes. Recreate workers with the matching image and their existing tokens, then resume traffic and schedules. Scanning waits until a compatible worker connects
-
-Use the shipped migration history for databases containing Lens data. The schema guard stops `--use_prisma_db_push` before changes if it detects Lens tables that require renaming; run the shipped rename migration against the configured schema before retrying. Fresh databases and databases with the current table names can use database push
-
-A rollback to a gateway that cannot read saved Lens records requires restoring a compatible database backup. Database push from such a build can also remove the review table. Test recovery on a separate database and account for all gateway data written after the backup
-
-The dashboard reads its image from the running gateway. `LENS_WORKER_IMAGE` overrides the registry/image for private deployments. Set an explicit `LENS_WORKER_IMAGE` for worker-only Compose. Verify that the image exists and matches the gateway before deploying it
-
-For source development, use `make lens-dev`, which gives the proxy and source worker the same commit identity. For custom containers, build both from the same checkout with `--build-arg LITELLM_RELEASE_TAG=sha-$(git rev-parse HEAD)` and set the proxy's `LENS_WORKER_IMAGE` to the worker image you built. An unlabelled custom build refuses worker setup and claims instead of guessing from the Python package version. Normal package-index installations use their installed release version
-
-The hourly development pipeline pins all component images to the same selected commit and publishes its chart only after every build and worker smoke test succeeds. The public commit-tagged worker workflow publishes to `ghcr.io/berriai/litellm-lens-worker-dev` on Lens-related changes, so an arbitrary `main` commit may require building your own pair; do not substitute the newest available worker
-
-
-## Worker dependencies
-
-The worker uses the same digest-pinned Wolfi base and Python version as the component images. Python dependencies and their hashes are locked in `deploy/lens/requirements.lock`. To update them, edit `deploy/lens/requirements.in`, then run `uv pip compile --universal --python-version 3.13 --generate-hashes --no-emit-index-url deploy/lens/requirements.in -o deploy/lens/requirements.lock`. The image installs only the locked wheels with hash verification. CI builds and scans both native architectures
-
-## Python analysis boundary
-
-The `python` tool runs ordinary CPython with the standard library in a fresh child process inside the existing worker container. It receives the selected evidence as `data` over stdin and has its own temporary working directory. It creates no additional container or service. Read and search tools remain available independently of Python
-
-The native worker image builds a syscall policy with libseccomp and includes the full `setpriv` launcher. Each child starts with no inherited worker secrets or open worker files, isolated Python startup, Landlock filesystem restrictions and a default-deny seccomp filter. It can read the Python runtime and its own scratch files. Worker source, installed worker packages, other jobs' files and `/proc` contents are unavailable. Network sockets, child processes, cross-process memory operations, signals to other processes and filesystem metadata mutation are denied, including calls made through `ctypes`. Some metadata inspection, such as `stat`, `access` and `readlink` of known paths, remains possible
-
-Python execution requires a native Linux worker with Landlock ABI 3 or later and seccomp filtering. Build the image for the host architecture. Missing policy files, an incompatible kernel, or an unsupported host such as a macOS source worker returns a clear tool error. There is no unrestricted execution fallback. Keep the container's non-root user, dropped capabilities, no-new-privileges setting, read-only root and writable temporary mount
-
-The worker permits two Python children at once across all investigations. Set `LENS_PYTHON_CONCURRENCY` to a positive integer to change this worker-wide pool. Queued calls consume no child process or scratch directory; cancelling a queued call does not start it. Model, read and search concurrency are separate
-
-| Per-call resource | Default |
-| --- | --- |
-| Elapsed execution time | 60 seconds |
-| CPU time | 30 seconds |
-| Process address space | 512 MiB |
-| Captured stdout or stderr | 8 MiB per stream |
-| Individual scratch file size | 16 MiB |
-| Monitored scratch storage | 64 MiB |
-| Monitored scratch entries | 2,048 |
-| Scratch directory depth | 128 |
-| Open file descriptors | 64 |
-
-Evidence is streamed from gateway pages into the confined child without building another complete selection in worker memory. The child decodes the selected data under its memory limit before running the code. The execution wall clock starts after input delivery; gateway fetches keep their HTTP timeouts and remain cancellable. CPU, address-space and file-size limits apply during input decoding as well as computation. Scratch usage is monitored every 50 milliseconds, so a call can temporarily overshoot its scratch allowance. The worker's shared temporary mount supplies the hard aggregate storage ceiling, 1 GiB by default. Accounting includes unlinked open files and files retained only by memory mappings. A mapped scratch inode without an open descriptor or directory entry is conservatively charged at the individual file-size limit, which may overcount small files. Cancellation and limit failures kill and reap the child before removing its scratch directory
-
-Results include `stdout`, `stderr`, `exit_code`, `error` and `output_complete`. Nonzero interpreter exits, confinement failures and resource failures set `error` and `output_complete=false`. Available traceback output is retained. An output-size failure delivers no partial stdout/stderr; the agent can narrow its computation and retry. A successful result retains all captured output without truncation
-
-This is a process boundary sharing the worker's Linux kernel. The checked-in smoke test verifies useful Python operations, filesystem and process restrictions, raw syscall attempts, resource failures, mapping accounting, cleanup and cancellation in the actual image. Run it on the deployment's native architecture and kernel:
-
-```bash
-docker build --build-arg LITELLM_RELEASE_TAG=lens-python-test \
- -f deploy/lens/Dockerfile -t lens-worker:python-test .
-docker run --rm --pull never --read-only --cap-drop ALL \
- --security-opt no-new-privileges --network none \
- --tmpfs /tmp:rw,noexec,nosuid,size=1g --entrypoint python -i \
- lens-worker:python-test - < tests/proxy_behavior/lens/worker_python_smoke.py
-```
-
-The same checks can run through pytest by setting `LENS_TEST_WORKER_IMAGE` to an already-built native image. The worker image CI runs the standalone smoke without adding pytest to the production image
diff --git a/deploy/lens/compose.build.yaml b/deploy/lens/compose.build.yaml
deleted file mode 100644
index 52d59a84a79..00000000000
--- a/deploy/lens/compose.build.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-services:
- lens-worker:
- build:
- context: ../..
- dockerfile: deploy/lens/Dockerfile
- args:
- LITELLM_RELEASE_TAG: ${LITELLM_RELEASE_TAG:?Set the release tag used by the gateway}
- image: litellm-lens-worker:local
diff --git a/deploy/lens/compose.yaml b/deploy/lens/compose.yaml
deleted file mode 100644
index 1d507fc7b74..00000000000
--- a/deploy/lens/compose.yaml
+++ /dev/null
@@ -1,13 +0,0 @@
-services:
- lens-worker:
- image: ${LENS_WORKER_IMAGE:-${LITELLM_VERSION:+ghcr.io/berriai/litellm-lens-worker:v}${LITELLM_VERSION:-}}
- environment:
- LITELLM_URL: ${LITELLM_URL:?Set the URL reachable from this container}
- LENS_WORKER_TOKEN: ${LENS_WORKER_TOKEN:?Create a worker credential in the Lens UI}
- LENS_PYTHON_CONCURRENCY: ${LENS_PYTHON_CONCURRENCY:-2}
- restart: unless-stopped
- read_only: true
- tmpfs:
- - /tmp:rw,noexec,nosuid,size=${LENS_WORKER_TMP_SIZE:-1g}
- cap_drop: [ALL]
- security_opt: [no-new-privileges:true]
diff --git a/deploy/lens/config.yaml b/deploy/lens/config.yaml
deleted file mode 100644
index cb12a2b0919..00000000000
--- a/deploy/lens/config.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-general_settings:
- master_key: os.environ/LITELLM_MASTER_KEY
- tracing:
- store:
- type: clickhouse
- url: os.environ/CLICKHOUSE_URL
- retention_days: 14
diff --git a/deploy/lens/python_policy.c b/deploy/lens/python_policy.c
deleted file mode 100644
index d3a0f1d009e..00000000000
--- a/deploy/lens/python_policy.c
+++ /dev/null
@@ -1,63 +0,0 @@
-#include
-#include
-#include
-#include
-#include
-#include
-
-static int allow(scmp_filter_ctx policy, const char *name)
-{
- int number = seccomp_syscall_resolve_name(name);
- return number < 0 ? 0 : seccomp_rule_add(policy, SCMP_ACT_ALLOW, number, 0);
-}
-
-int main(int argc, char **argv)
-{
- const char *calls[] = {
- "read", "write", "readv", "writev", "pread64", "pwrite64", "close", "close_range",
- "open", "openat", "openat2", "fstat", "stat", "lstat", "newfstatat", "statx",
- "lseek", "getdents", "getdents64", "access", "faccessat", "faccessat2",
- "readlink", "readlinkat", "getcwd", "chdir", "fchdir", "statfs", "fstatfs",
- "mkdir", "mkdirat", "rmdir", "unlink", "unlinkat", "rename", "renameat", "renameat2",
- "link", "linkat", "symlink", "symlinkat", "truncate", "ftruncate", "fsync", "fdatasync",
- "mmap", "mmap2", "mprotect", "munmap", "mremap", "madvise", "brk",
- "rt_sigaction", "rt_sigprocmask", "rt_sigreturn", "rt_sigsuspend", "rt_sigtimedwait", "sigaltstack",
- "getpid", "getppid", "gettid", "getuid", "geteuid", "getgid", "getegid", "getgroups",
- "clock_gettime", "clock_getres", "clock_nanosleep", "gettimeofday", "time", "nanosleep",
- "futex", "futex_time64", "set_tid_address", "set_robust_list", "rseq", "arch_prctl",
- "sched_getaffinity", "sched_yield", "getrandom", "getrlimit", "setrlimit", "getrusage", "umask",
- "dup", "dup2", "dup3", "pipe", "pipe2", "poll", "ppoll", "select", "pselect6",
- "epoll_create", "epoll_create1", "epoll_ctl", "epoll_wait", "epoll_pwait", "epoll_pwait2",
- "capget", "capset", "prctl", "landlock_create_ruleset", "landlock_add_rule", "landlock_restrict_self",
- "execve", "exit", "exit_group", "uname", "sysinfo", "restart_syscall"
- };
- const int commands[] = {F_DUPFD, F_DUPFD_CLOEXEC, F_GETFD, F_SETFD, F_GETFL, F_GETLK, F_SETLK, F_SETLKW};
- if (argc != 2) {
- fputs("Usage: python-policy OUTPUT\n", stderr);
- return 1;
- }
- scmp_filter_ctx policy = seccomp_init(SCMP_ACT_ERRNO(EPERM));
- if (!policy)
- return 1;
- int result = 0;
- for (size_t i = 0; i < sizeof(calls) / sizeof(calls[0]); i++)
- result |= allow(policy, calls[i]);
- result |= seccomp_rule_add(policy, SCMP_ACT_ALLOW, SCMP_SYS(prlimit64), 1, SCMP_A0(SCMP_CMP_EQ, 0));
- for (size_t i = 0; i < sizeof(commands) / sizeof(commands[0]); i++)
- result |= seccomp_rule_add(policy, SCMP_ACT_ALLOW, SCMP_SYS(fcntl), 1, SCMP_A1(SCMP_CMP_EQ, commands[i]));
- result |= seccomp_rule_add(policy, SCMP_ACT_ALLOW, SCMP_SYS(fcntl), 2,
- SCMP_A1(SCMP_CMP_EQ, F_SETFL), SCMP_A2(SCMP_CMP_MASKED_EQ, O_ASYNC, 0));
- result |= seccomp_rule_add(policy, SCMP_ACT_ALLOW, SCMP_SYS(ioctl), 1, SCMP_A1(SCMP_CMP_EQ, FIOCLEX));
- result |= seccomp_rule_add(policy, SCMP_ACT_ALLOW, SCMP_SYS(ioctl), 1, SCMP_A1(SCMP_CMP_EQ, FIONCLEX));
- int output = open(argv[1], O_WRONLY | O_CREAT | O_TRUNC, 0444);
- if (output < 0)
- result = -1;
- if (!result)
- result = seccomp_export_bpf(policy, output);
- if (output >= 0)
- close(output);
- seccomp_release(policy);
- if (result)
- fputs("Could not build the Python syscall policy\n", stderr);
- return result ? 1 : 0;
-}
diff --git a/deploy/lens/python_runtime.py b/deploy/lens/python_runtime.py
deleted file mode 100644
index f0de8a9c1a7..00000000000
--- a/deploy/lens/python_runtime.py
+++ /dev/null
@@ -1,41 +0,0 @@
-import json
-import subprocess
-import sys
-import sysconfig
-from itertools import chain
-from pathlib import Path
-from typing import Final
-
-
-def dependencies(path: Path, loader: Path) -> tuple[Path, ...]:
- result: Final = subprocess.run((str(loader), "--list", str(path)), capture_output=True, text=True, check=True)
- if "not found" in result.stdout:
- raise RuntimeError(f"Missing Python runtime library: {path}")
- words: Final = tuple(result.stdout.split())
- return tuple(Path(word).resolve() for word in words if word.startswith("/"))
-
-
-def main() -> None:
- stdlib: Final = Path(sysconfig.get_path("stdlib")).resolve()
- executable: Final = Path(sys.executable).resolve()
- loaders: Final = tuple(Path("/usr/lib").glob("ld-linux-*.so.*"))
- if len(loaders) != 1:
- raise RuntimeError("Expected one native glibc dynamic loader in the Lens worker image")
- entries: Final = tuple(
- path for path in stdlib.iterdir() if path.name not in ("site-packages", "dist-packages", "__pycache__")
- )
- extensions: Final = tuple((stdlib / "lib-dynload").glob("*.so"))
- libraries: Final = frozenset(
- chain.from_iterable(dependencies(binary, loaders[0]) for binary in (executable, *extensions))
- )
- manifest: Final = {
- "executable": str(executable),
- "directories": (str(stdlib),),
- "read": tuple(sorted(str(path) for path in {*entries, *libraries})),
- "execute": tuple(sorted(str(path) for path in (executable, *loaders))),
- }
- Path(sys.argv[1]).write_text(json.dumps(manifest), encoding="utf-8")
-
-
-if __name__ == "__main__":
- main()
diff --git a/deploy/lens/requirements.in b/deploy/lens/requirements.in
deleted file mode 100644
index 3122d7bd6f2..00000000000
--- a/deploy/lens/requirements.in
+++ /dev/null
@@ -1,2 +0,0 @@
-httpx==0.28.1
-pydantic==2.13.4
diff --git a/deploy/lens/requirements.lock b/deploy/lens/requirements.lock
deleted file mode 100644
index a895b6d645e..00000000000
--- a/deploy/lens/requirements.lock
+++ /dev/null
@@ -1,172 +0,0 @@
-# This file was autogenerated by uv via the following command:
-# uv pip compile --universal --python-version 3.13 --generate-hashes --no-emit-index-url deploy/lens/requirements.in -o deploy/lens/requirements.lock
-annotated-types==0.8.0 \
- --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \
- --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
- # via pydantic
-anyio==4.15.1 \
- --hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101 \
- --hash=sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94
- # via httpx
-certifi==2026.7.22 \
- --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
- --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
- # via
- # httpcore
- # httpx
-h11==0.16.0 \
- --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
- --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
- # via httpcore
-httpcore==1.0.9 \
- --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \
- --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8
- # via httpx
-httpx==0.28.1 \
- --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
- --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
- # via -r deploy/lens/requirements.in
-idna==3.20 \
- --hash=sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44 \
- --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c
- # via
- # anyio
- # httpx
-pydantic==2.13.4 \
- --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \
- --hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6
- # via -r deploy/lens/requirements.in
-pydantic-core==2.46.4 \
- --hash=sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0 \
- --hash=sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262 \
- --hash=sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda \
- --hash=sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0 \
- --hash=sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e \
- --hash=sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b \
- --hash=sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594 \
- --hash=sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29 \
- --hash=sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2 \
- --hash=sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c \
- --hash=sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d \
- --hash=sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398 \
- --hash=sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d \
- --hash=sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3 \
- --hash=sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f \
- --hash=sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb \
- --hash=sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7 \
- --hash=sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5 \
- --hash=sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9 \
- --hash=sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462 \
- --hash=sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4 \
- --hash=sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b \
- --hash=sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d \
- --hash=sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df \
- --hash=sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2 \
- --hash=sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0 \
- --hash=sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519 \
- --hash=sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd \
- --hash=sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7 \
- --hash=sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac \
- --hash=sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6 \
- --hash=sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565 \
- --hash=sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898 \
- --hash=sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb \
- --hash=sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928 \
- --hash=sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6 \
- --hash=sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3 \
- --hash=sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a \
- --hash=sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596 \
- --hash=sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987 \
- --hash=sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e \
- --hash=sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d \
- --hash=sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712 \
- --hash=sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008 \
- --hash=sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd \
- --hash=sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1 \
- --hash=sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be \
- --hash=sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea \
- --hash=sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292 \
- --hash=sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33 \
- --hash=sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3 \
- --hash=sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4 \
- --hash=sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b \
- --hash=sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826 \
- --hash=sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac \
- --hash=sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7 \
- --hash=sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d \
- --hash=sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf \
- --hash=sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4 \
- --hash=sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc \
- --hash=sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15 \
- --hash=sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3 \
- --hash=sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b \
- --hash=sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914 \
- --hash=sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04 \
- --hash=sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c \
- --hash=sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b \
- --hash=sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9 \
- --hash=sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce \
- --hash=sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4 \
- --hash=sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a \
- --hash=sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f \
- --hash=sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424 \
- --hash=sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894 \
- --hash=sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9 \
- --hash=sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76 \
- --hash=sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201 \
- --hash=sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb \
- --hash=sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109 \
- --hash=sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4 \
- --hash=sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848 \
- --hash=sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526 \
- --hash=sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0 \
- --hash=sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01 \
- --hash=sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458 \
- --hash=sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e \
- --hash=sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba \
- --hash=sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a \
- --hash=sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39 \
- --hash=sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c \
- --hash=sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000 \
- --hash=sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b \
- --hash=sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf \
- --hash=sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4 \
- --hash=sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd \
- --hash=sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28 \
- --hash=sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9 \
- --hash=sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30 \
- --hash=sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983 \
- --hash=sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1 \
- --hash=sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76 \
- --hash=sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5 \
- --hash=sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4 \
- --hash=sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7 \
- --hash=sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c \
- --hash=sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066 \
- --hash=sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3 \
- --hash=sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02 \
- --hash=sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89 \
- --hash=sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50 \
- --hash=sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76 \
- --hash=sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49 \
- --hash=sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b \
- --hash=sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d \
- --hash=sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7 \
- --hash=sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4 \
- --hash=sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c \
- --hash=sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e \
- --hash=sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff \
- --hash=sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae
- # via pydantic
-typing-extensions==4.16.0 \
- --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
- --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
- # via
- # anyio
- # pydantic
- # pydantic-core
- # typing-inspection
-typing-inspection==0.4.4 \
- --hash=sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47 \
- --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147
- # via pydantic
diff --git a/deploy/lens/stack.yaml b/deploy/lens/stack.yaml
deleted file mode 100644
index 852aa9d8488..00000000000
--- a/deploy/lens/stack.yaml
+++ /dev/null
@@ -1,92 +0,0 @@
-name: litellm-lens
-
-services:
- litellm:
- image: ghcr.io/berriai/litellm:${LITELLM_VERSION:?Set LITELLM_VERSION to a published release, without the v prefix}
- entrypoint:
- - python3
- - -c
- - |
- import os, sys
- from urllib.parse import quote
- postgres_password = quote(os.environ["POSTGRES_PASSWORD"], safe="")
- clickhouse_password = quote(os.environ["CLICKHOUSE_PASSWORD"], safe="")
- os.environ["DATABASE_URL"] = f"postgresql://litellm:{postgres_password}@db:5432/litellm"
- os.environ["CLICKHOUSE_URL"] = f"http://default:{clickhouse_password}@clickhouse:8123"
- os.execv("docker/prod_entrypoint.sh", ["docker/prod_entrypoint.sh", *sys.argv[1:]])
- command: ["--config", "/app/lens-config.yaml", "--port", "4000"]
- environment:
- LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY:?Set a strong master key}
- LITELLM_SALT_KEY: ${LITELLM_SALT_KEY:?Set a permanent encryption key and keep it across upgrades}
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set a permanent database password}
- STORE_MODEL_IN_DB: "True"
- CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?Set a permanent ClickHouse password}
- LENS_WORKER_IMAGE: ghcr.io/berriai/litellm-lens-worker:v${LITELLM_VERSION}
- volumes:
- - ./config.yaml:/app/lens-config.yaml:ro
- ports:
- - "127.0.0.1:${LITELLM_PORT:-4000}:4000"
- networks: [proxy, storage]
- depends_on:
- db:
- condition: service_healthy
- clickhouse:
- condition: service_healthy
- restart: unless-stopped
-
- lens-worker:
- profiles: [lens]
- image: ghcr.io/berriai/litellm-lens-worker:v${LITELLM_VERSION}
- environment:
- LITELLM_URL: http://litellm:4000
- LENS_WORKER_TOKEN: ${LENS_WORKER_TOKEN:-}
- LENS_PYTHON_CONCURRENCY: ${LENS_PYTHON_CONCURRENCY:-2}
- depends_on: [litellm]
- networks: [proxy]
- restart: unless-stopped
- read_only: true
- tmpfs:
- - /tmp:rw,noexec,nosuid,size=${LENS_WORKER_TMP_SIZE:-1g}
- cap_drop: [ALL]
- security_opt: [no-new-privileges:true]
-
- db:
- image: postgres:16
- environment:
- POSTGRES_DB: litellm
- POSTGRES_USER: litellm
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
- networks: [storage]
- volumes:
- - postgres_data:/var/lib/postgresql/data
- healthcheck:
- test: ["CMD-SHELL", "pg_isready -U litellm -d litellm"]
- interval: 5s
- timeout: 5s
- retries: 20
- restart: unless-stopped
-
- clickhouse:
- image: clickhouse/clickhouse-server:26.9.6.6
- environment:
- CLICKHOUSE_USER: default
- CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD}
- CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1"
- volumes:
- - clickhouse_data:/var/lib/clickhouse
- healthcheck:
- test: ["CMD", "clickhouse-client", "--user", "default", "--password", "${CLICKHOUSE_PASSWORD}", "--query", "SELECT 1"]
- interval: 5s
- timeout: 5s
- retries: 20
- restart: unless-stopped
- networks: [storage]
-
-networks:
- proxy:
- storage:
- internal: true
-
-volumes:
- postgres_data:
- clickhouse_data:
diff --git a/docker/Dockerfile.database b/docker/Dockerfile.database
index bd1241ce345..56210458532 100644
--- a/docker/Dockerfile.database
+++ b/docker/Dockerfile.database
@@ -39,6 +39,7 @@ ENV NEXT_TELEMETRY_DISABLED=1 \
WORKDIR /ui
COPY ui/litellm-dashboard/package.json ui/litellm-dashboard/package-lock.json ./
+COPY ui/litellm-dashboard/vendor/ ./vendor/
RUN --mount=type=cache,target=/root/.npm npm ci --prefer-offline
COPY ui/litellm-dashboard/ ./
diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root
index 58304e0503d..086c38e0d60 100644
--- a/docker/Dockerfile.non_root
+++ b/docker/Dockerfile.non_root
@@ -37,6 +37,7 @@ ENV NEXT_TELEMETRY_DISABLED=1 \
WORKDIR /ui
COPY ui/litellm-dashboard/package.json ui/litellm-dashboard/package-lock.json ./
+COPY ui/litellm-dashboard/vendor/ ./vendor/
RUN --mount=type=cache,target=/root/.npm npm ci --prefer-offline
COPY ui/litellm-dashboard/ ./
diff --git a/docker/docker-compose.tracing.yml b/docker/docker-compose.tracing.yml
index 87d7197725e..d28d84e61dc 100644
--- a/docker/docker-compose.tracing.yml
+++ b/docker/docker-compose.tracing.yml
@@ -13,10 +13,11 @@ services:
LITELLM_SALT_KEY: sk-local-tracing-salt-key
DATABASE_URL: postgresql://litellm:litellm@db:5432/litellm
STORE_MODEL_IN_DB: "True"
- CLICKHOUSE_URL: http://default:local-tracing@clickhouse:8123
- CLICKHOUSE_DATABASE: litellm
+ LITELLM_LENS_URL: ${LITELLM_LENS_URL:?set the Lens URL reachable from the gateway container}
+ LITELLM_LENS_PUBLIC_URL: ${LITELLM_LENS_PUBLIC_URL:?set the Lens URL reachable from your browser and agents}
+ LITELLM_LENS_SERVICE_TOKEN: ${LITELLM_LENS_SERVICE_TOKEN:?set LITELLM_LENS_SERVICE_TOKEN}
+ LENS_GATEWAY_SECRET: ${LENS_GATEWAY_SECRET:?set the same signing secret on Lens and the gateway}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
- LENS_WORKER_IMAGE: ${LENS_WORKER_IMAGE:-}
volumes:
- ./tracing-config.yaml:/app/tracing-config.yaml:ro
ports:
@@ -24,8 +25,6 @@ services:
depends_on:
db:
condition: service_healthy
- clickhouse:
- condition: service_healthy
db:
image: postgres:16
@@ -43,22 +42,5 @@ services:
timeout: 5s
retries: 10
- clickhouse:
- image: clickhouse/clickhouse-server:26.9.6.6
- environment:
- CLICKHOUSE_USER: default
- CLICKHOUSE_PASSWORD: local-tracing
- CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1"
- volumes:
- - clickhouse_data:/var/lib/clickhouse
- ports:
- - "127.0.0.1:18123:8123"
- healthcheck:
- test: ["CMD", "clickhouse-client", "--user", "default", "--password", "local-tracing", "--query", "SELECT 1"]
- interval: 5s
- timeout: 5s
- retries: 20
-
volumes:
postgres_data:
- clickhouse_data:
diff --git a/docker/tracing-config.yaml b/docker/tracing-config.yaml
index d8e3759641f..7cdd10b4e35 100644
--- a/docker/tracing-config.yaml
+++ b/docker/tracing-config.yaml
@@ -8,6 +8,4 @@ general_settings:
master_key: os.environ/LITELLM_MASTER_KEY
tracing:
store:
- type: clickhouse
- url: os.environ/CLICKHOUSE_URL
- retention_days: 14
+ type: lens
diff --git a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py
index 84e319c8c37..2b2d0a82431 100644
--- a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py
+++ b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py
@@ -732,6 +732,7 @@ class CheckBatchCost:
another pod claimed it. Raises on results-fetch or cost-computation
failures so the caller can leave the job unprocessed and retry it on a
later poll.
+
"""
from litellm.batches.batch_utils import (
count_error_file_failed_requests,
@@ -815,15 +816,14 @@ class CheckBatchCost:
custom_llm_provider=custom_llm_provider,
)
- # CheckBatchCost bypasses async_post_call_success_hook, so convert raw
- # output/error file IDs to managed base64 IDs before the DB write here.
- managed_files_hook = self.proxy_logging_obj.get_proxy_hook("managed_files")
- if managed_files_hook is not None:
+ from litellm.proxy.openai_files_endpoints.common_utils import ManagedBatchOutputFileWriter
+
+ managed_files_hook: Final = self.proxy_logging_obj.get_proxy_hook("managed_files")
+ if isinstance(managed_files_hook, ManagedBatchOutputFileWriter):
+ managed_file_writer: Final = managed_files_hook
from litellm.proxy._types import UserAPIKeyAuth
- managed_file_model_name = self._get_managed_file_model_name(
- job=job, deployment_info=deployment_info
- )
+ managed_file_model_name = self._get_managed_file_model_name(job=job, deployment_info=deployment_info)
_minimal_auth = UserAPIKeyAuth(
user_id=job.created_by or "default-user-id",
team_id=getattr(job, "team_id", None),
@@ -832,17 +832,19 @@ class CheckBatchCost:
_raw_file_id = cast(str | None, getattr(response, _file_attr, None))
if _raw_file_id and not _is_base64_encoded_unified_file_id(_raw_file_id):
try:
- _unified_file_id = managed_files_hook.get_unified_output_file_id(
+ _unified_file_id = managed_file_writer.get_unified_output_file_id(
output_file_id=_raw_file_id,
model_id=model_id,
model_name=managed_file_model_name,
)
- await managed_files_hook.store_unified_file_id(
- file_id=_unified_file_id,
- file_object=None,
+ await managed_file_writer.store_batch_output_file(
+ unified_file_id=_unified_file_id,
+ provider_file_id=_raw_file_id,
+ model_id=model_id,
+ model_name=managed_file_model_name,
+ owner=_minimal_auth,
litellm_parent_otel_span=None,
- model_mappings={model_id: _raw_file_id},
- user_api_key_dict=_minimal_auth,
+ size_bytes=len(content_bytes) if _file_attr == "output_file_id" else None,
)
setattr(response, _file_attr, _unified_file_id)
verbose_proxy_logger.info(
diff --git a/enterprise/litellm_enterprise/proxy/common_utils/check_responses_cost.py b/enterprise/litellm_enterprise/proxy/common_utils/check_responses_cost.py
index 738b9ebe1e0..7dd1fd7a1ee 100644
--- a/enterprise/litellm_enterprise/proxy/common_utils/check_responses_cost.py
+++ b/enterprise/litellm_enterprise/proxy/common_utils/check_responses_cost.py
@@ -6,7 +6,7 @@ same route are non-inference and free.
"""
from datetime import datetime, timedelta, timezone
-from typing import TYPE_CHECKING, Dict, Final, Optional, Protocol, cast
+from typing import TYPE_CHECKING, Dict, Final, Protocol, cast
import litellm
from litellm._logging import verbose_proxy_logger
@@ -47,6 +47,10 @@ def _managed_object_table(prisma_client: "PrismaClient") -> "TableActions[_Manag
return ManagedObjectRepository(prisma_client).table
+def _is_response_gone_at_provider(error: Exception, provider_response_id: str) -> bool:
+ return getattr(error, "status_code", None) == 404 and provider_response_id in str(error)
+
+
class CheckResponsesCost:
def __init__(
self,
@@ -61,10 +65,15 @@ class CheckResponsesCost:
self.prisma_client: PrismaClient = prisma_client
self.llm_router: Router = llm_router
+ def _resolve_deployment(self, response_id: str) -> bool:
+ model_id: str | None = ResponsesAPIRequestUtils.get_model_id_from_response_id(response_id)
+ return model_id is not None and self.llm_router.get_deployment(model_id=model_id) is not None
+
async def _get_response(
self,
response_id: str,
litellm_metadata: Dict[str, str],
+ via_router: bool,
) -> ResponsesAPIResponse:
"""Fetch the upstream response, using deployment credentials when available.
@@ -75,8 +84,7 @@ class CheckResponsesCost:
sees provider env vars, so it fails for every deployment whose credentials
live in the config; the row then never leaves ``queued``.
"""
- model_id: Optional[str] = ResponsesAPIRequestUtils.get_model_id_from_response_id(response_id)
- if model_id is None or self.llm_router.get_deployment(model_id=model_id) is None:
+ if not via_router:
return await litellm.aget_responses(response_id=response_id, litellm_metadata=litellm_metadata)
router_response = await self.llm_router.aget_responses(
response_id=response_id, litellm_metadata=litellm_metadata
@@ -140,6 +148,8 @@ class CheckResponsesCost:
- Cost is tracked by the get-responses call, billed because the poll is stamped
with BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN
- Mark responses in a terminal state as complete in the database
+ - Mark responses the provider no longer has (404 through a resolved
+ deployment) as stale_expired
"""
try:
await self._cleanup_stale_managed_objects()
@@ -159,6 +169,7 @@ class CheckResponsesCost:
verbose_proxy_logger.debug(f"Found {len(jobs)} response jobs to check")
completed_jobs: Final[list[_ManagedObjectRow]] = []
+ expired_jobs: Final[list[_ManagedObjectRow]] = []
for job in jobs:
unified_object_id = job.unified_object_id
@@ -171,31 +182,48 @@ class CheckResponsesCost:
# Get the stored response object to extract model information
stored_response = job.file_object
model_name = stored_response.get("model", None)
-
+
# Decrypt the response ID
responses_id_security, _, _ = ResponsesIDSecurity()._decrypt_response_id(unified_object_id)
-
+
# Prepare metadata with model information for cost tracking
litellm_metadata = {
"user_api_key_user_id": job.created_by or "default-user-id",
INTERNAL_CALL_ORIGIN_METADATA_KEY: BACKGROUND_RESPONSE_COST_POLL_CALL_ORIGIN,
}
-
+
# Add model information if available
if model_name:
litellm_metadata["model"] = model_name
litellm_metadata["model_group"] = model_name # Use same value for model_group
-
+ via_router = self._resolve_deployment(responses_id_security)
+ except Exception as e:
+ verbose_proxy_logger.warning(
+ f"Skipping job {unified_object_id} due to error: {e}"
+ )
+ continue
+
+ provider_response_id = ResponsesAPIRequestUtils.decode_responses_api_response_id(
+ responses_id_security
+ ).get("response_id", responses_id_security)
+ try:
response = await self._get_response(
response_id=responses_id_security,
litellm_metadata=litellm_metadata,
+ via_router=via_router,
)
-
+
verbose_proxy_logger.debug(
f"Response {unified_object_id} status: {response.status}, model: {model_name}"
)
-
+
except Exception as e:
+ if via_router and _is_response_gone_at_provider(e, provider_response_id):
+ verbose_proxy_logger.info(
+ f"Response {unified_object_id} no longer available at provider (404), marking stale_expired: {e}"
+ )
+ expired_jobs.append(job)
+ continue
verbose_proxy_logger.warning(
f"Skipping job {unified_object_id} due to error: {e}"
)
@@ -210,6 +238,7 @@ class CheckResponsesCost:
# Mark completed jobs in the database
if len(completed_jobs) > 0:
await _managed_object_table(self.prisma_client).update_many(
+ # bounded-ok: at most MAX_OBJECTS_PER_POLL_CYCLE rows per cycle, the find_many take above
where={"id": {"in": [job.id for job in completed_jobs]}},
data={"status": "completed"},
)
@@ -217,3 +246,13 @@ class CheckResponsesCost:
f"Marked {len(completed_jobs)} response jobs as completed"
)
+ if len(expired_jobs) > 0:
+ await _managed_object_table(self.prisma_client).update_many(
+ # bounded-ok: at most MAX_OBJECTS_PER_POLL_CYCLE rows per cycle, the find_many take above
+ where={"id": {"in": [job.id for job in expired_jobs]}},
+ data={"status": "stale_expired"},
+ )
+ verbose_proxy_logger.info(
+ f"Marked {len(expired_jobs)} response jobs as stale_expired"
+ )
+
diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py
index c07bb216602..67894fbbf3c 100644
--- a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py
+++ b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py
@@ -1,9 +1,11 @@
# What is this?
## This hook is used to check for LiteLLM managed files in the request body, and replace them with model-specific file id
+import asyncio
import base64
import json
-from collections.abc import Iterator, Mapping, Sequence
+import time
+from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence
from types import MappingProxyType
from typing import (
TYPE_CHECKING,
@@ -23,21 +25,25 @@ from uuid import NAMESPACE_URL, uuid5
import httpx
from fastapi import HTTPException
from pydantic import ValidationError
-from typing_extensions import ReadOnly
+from typing_extensions import ReadOnly, Unpack
import litellm
from litellm import Router, verbose_logger
from litellm._internal_context import with_service_target
from litellm._uuid import uuid
from litellm.caching.caching import DualCache
-from litellm.constants import MAX_FILE_LIST_LIMIT
+from litellm.constants import (
+ BATCH_OUTPUT_FILE_FALLBACK_RETRY_AFTER_SECONDS,
+ BATCH_OUTPUT_FILE_LOOKUP_TIMEOUT_SECONDS,
+ MAX_FILE_LIST_LIMIT,
+)
from litellm.files.types import FileRetrieveCallOptions, FileRetrieveProvider
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.hidden_params import HIDDEN_PARAMS_ATTR
from litellm.litellm_core_utils.prompt_templates.common_utils import (
extract_file_metadata,
)
-from openai import AsyncOpenAI
+from openai import APIConnectionError, AsyncOpenAI
from openai.types.file_deleted import FileDeleted
from litellm.llms.base_llm.files.storage_backend_factory import get_storage_backend
@@ -55,7 +61,10 @@ from litellm.proxy._types import (
ProxyException,
UserAPIKeyAuth,
)
-from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
+from litellm.proxy.litellm_pre_call_utils import (
+ LiteLLMProxyRequestSetup,
+ sanitize_for_log,
+)
from litellm.proxy.openai_files_endpoints.common_utils import (
BATCH_CREATE_HIDDEN_PARAM,
FILE_LIST_CONTINUATION_CHUNK_SIZE,
@@ -77,6 +86,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import (
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.batch_attribution import (
request_tags_from_metadata,
)
+from litellm.repositories.managed_file_repository import ManagedFileRepository
from litellm.types.llms.openai import ( # pyright: ignore[reportAttributeAccessIssue]
AllMessageValues,
AsyncCursorPage,
@@ -96,6 +106,17 @@ from litellm.types.utils import (
SpecialEnums,
)
+
+class _ManagedFileRetrieve(Protocol):
+ async def __call__(
+ self,
+ *,
+ file_id: str,
+ _litellm_internal_model_credentials: Mapping[str, object] | None = None,
+ **kwargs: Unpack[FileRetrieveCallOptions],
+ ) -> OpenAIFileObject: ...
+
+
if TYPE_CHECKING:
from opentelemetry.trace import Span as _Span
from prisma.models import (
@@ -146,6 +167,91 @@ def _parse_managed_file_object(raw_file_object: object, unified_file_id: str) ->
return None
+def _batch_output_file_object(
+ unified_file_id: str, raw_file_id: str, size_bytes: int, *, fallback: bool
+) -> OpenAIFileObject:
+ filename: Final = raw_file_id.rsplit("/", 1)[-1] or raw_file_id
+ return OpenAIFileObject(
+ id=unified_file_id,
+ object="file",
+ purpose="batch_output",
+ filename=filename,
+ created_at=int(time.time()),
+ bytes=size_bytes,
+ status="processed",
+ litellm_details_fallback=True if fallback else None,
+ )
+
+
+def _public_file_object(file_object: OpenAIFileObject, unified_file_id: str) -> OpenAIFileObject:
+ return file_object.model_copy(update={"id": unified_file_id, "litellm_details_fallback": None})
+
+
+def _is_transient_file_retrieve_error(error: Exception) -> bool:
+ status_code: Final = getattr(error, "status_code", None)
+ if isinstance(status_code, int):
+ return status_code in {408, 429} or status_code >= 500
+ return isinstance(error, (httpx.TransportError, APIConnectionError, asyncio.TimeoutError))
+
+
+def _proxy_llm_router() -> Router | None:
+ import litellm.proxy.proxy_server as proxy_server_module
+
+ return cast(Router | None, getattr(proxy_server_module, "llm_router", None))
+
+
+def _provider_file_retrieve_credentials(
+ *,
+ llm_router: Router | None,
+ model_id: str | None,
+) -> Mapping[str, object] | None:
+ if llm_router is None or model_id is None:
+ return None
+ try:
+ credentials: Final = llm_router.get_deployment_credentials_with_provider(model_id)
+ except Exception as error:
+ verbose_logger.warning(
+ "Failed to retrieve credentials for provider file "
+ f"model_id={sanitize_for_log(model_id)}: {sanitize_for_log(error)}"
+ )
+ return None
+ return cast(Mapping[str, object], credentials) if credentials else None
+
+
+_PROVIDER_FILE_RETRIEVE_PROVIDERS: Final[frozenset[str]] = frozenset(
+ {
+ "openai",
+ "azure",
+ "gemini",
+ "vertex_ai",
+ "bedrock",
+ "hosted_vllm",
+ "litellm_proxy",
+ "manus",
+ "anthropic",
+ "mistral",
+ "xai",
+ }
+)
+
+
+def _model_name_file_retrieve_provider(model_name: str | None) -> FileRetrieveProvider | None:
+ if model_name is None:
+ return None
+ provider, separator, _ = model_name.partition("/")
+ if not separator or provider not in _PROVIDER_FILE_RETRIEVE_PROVIDERS:
+ return None
+ return cast(FileRetrieveProvider, provider)
+
+
+def _has_provider_file_retrieve_route(
+ *,
+ router_credentials: Mapping[str, object] | None,
+ model_name: str | None,
+) -> bool:
+ return bool(router_credentials) or _model_name_file_retrieve_provider(model_name) is not None
+
+
class _ManagedFileRow(Protocol):
unified_file_id: str
file_object: OpenAIFileObject
@@ -161,6 +267,8 @@ class _ManagedFileRow(Protocol):
class _ManagedFileTableActions(Protocol):
async def find_first(self, where: Mapping[str, object]) -> Optional[_ManagedFileRow]: ...
+ async def update_many(self, where: Mapping[str, object], data: Mapping[str, object]) -> int: ...
+
async def find_many(
self,
where: Mapping[str, object],
@@ -253,13 +361,21 @@ def _storage_metadata_of(file_object: OpenAIFileObject | None) -> Mapping[str, s
_MANAGED_FILES_TARGET: Final = "managed_files"
+_PROVIDER_FILE_RETRIEVE_RETRY_DELAYS_SECONDS: Final = (0.5, 1.0, 2.0)
-class PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
+class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
# Class variables or attributes
- def __init__(self, internal_usage_cache: InternalUsageCache, prisma_client: PrismaClient):
+ def __init__(
+ self,
+ internal_usage_cache: InternalUsageCache,
+ prisma_client: PrismaClient,
+ *,
+ sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
+ ):
self.internal_usage_cache = internal_usage_cache
self.prisma_client = prisma_client
+ self._sleep = sleep
@staticmethod
def _get_prometheus_logger():
@@ -595,14 +711,231 @@ class PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
for file_id in provider_file_ids:
model_name = decode_model_from_file_id(file_id)
raw_file_id = get_original_file_id(file_id)
- await self.store_unified_file_id(
- file_id=file_id,
- file_object=None,
+ await self.store_batch_output_file(
+ unified_file_id=file_id,
+ provider_file_id=raw_file_id,
+ model_id=model_name or None,
+ model_name=model_name,
litellm_parent_otel_span=litellm_parent_otel_span,
- model_mappings={model_name: raw_file_id} if model_name else {},
- user_api_key_dict=owner_identity,
+ owner=owner_identity,
)
+ async def _afile_retrieve_with_retries(
+ self,
+ *,
+ provider_file_id: str,
+ call_options: Mapping[str, object],
+ internal_model_credentials: Mapping[str, object] | None = None,
+ ) -> OpenAIFileObject:
+ """Retrieve provider file details with transient retries and SDK retries disabled."""
+ retrieve_file: Final = cast(_ManagedFileRetrieve, litellm.afile_retrieve)
+ retrieve_options: Final = cast(
+ FileRetrieveCallOptions,
+ {**call_options, "max_retries": 0},
+ )
+ for attempt in range(len(_PROVIDER_FILE_RETRIEVE_RETRY_DELAYS_SECONDS) + 1):
+ if attempt > 0:
+ await self._sleep(_PROVIDER_FILE_RETRIEVE_RETRY_DELAYS_SECONDS[attempt - 1])
+ try:
+ if internal_model_credentials is None:
+ return await retrieve_file(
+ file_id=provider_file_id,
+ **retrieve_options,
+ )
+ return await retrieve_file(
+ file_id=provider_file_id,
+ _litellm_internal_model_credentials=MappingProxyType(dict(internal_model_credentials)),
+ **retrieve_options,
+ )
+ except Exception as error:
+ if not _is_transient_file_retrieve_error(error) or attempt == len(
+ _PROVIDER_FILE_RETRIEVE_RETRY_DELAYS_SECONDS
+ ):
+ raise
+ raise RuntimeError("Provider file retrieve retry loop ended without a result")
+
+ async def _fetch_provider_file_object(
+ self,
+ *,
+ unified_file_id: str,
+ provider_file_id: str,
+ model_id: str | None,
+ model_name: str | None,
+ llm_router: Router | None = None,
+ raise_on_failure: bool = False,
+ allow_default_provider: bool = False,
+ ) -> tuple[OpenAIFileObject | None, bool]:
+ """Fetch provider file details through the configured route under a total timeout."""
+ route_llm_router: Final = llm_router if llm_router is not None else _proxy_llm_router()
+ router_credentials: Final = _provider_file_retrieve_credentials(
+ llm_router=route_llm_router,
+ model_id=model_id,
+ )
+ model_name_provider: Final = _model_name_file_retrieve_provider(model_name)
+ fetch_route_available: Final = _has_provider_file_retrieve_route(
+ router_credentials=router_credentials,
+ model_name=model_name,
+ )
+ default_provider_route: Final = allow_default_provider and route_llm_router is not None
+ if not fetch_route_available and not default_provider_route:
+ return None, False
+
+ try:
+ if router_credentials is not None:
+ provider_file_object: Final = await asyncio.wait_for(
+ self._afile_retrieve_with_retries(
+ provider_file_id=provider_file_id,
+ call_options=router_credentials,
+ internal_model_credentials=router_credentials,
+ ),
+ timeout=BATCH_OUTPUT_FILE_LOOKUP_TIMEOUT_SECONDS,
+ )
+ return provider_file_object.model_copy(update={"id": unified_file_id}), True
+ if model_name_provider is not None:
+ provider_file_object_by_model_name: Final = await asyncio.wait_for(
+ self._afile_retrieve_with_retries(
+ provider_file_id=provider_file_id,
+ call_options={"custom_llm_provider": model_name_provider},
+ ),
+ timeout=BATCH_OUTPUT_FILE_LOOKUP_TIMEOUT_SECONDS,
+ )
+ return (
+ provider_file_object_by_model_name.model_copy(update={"id": unified_file_id}),
+ True,
+ )
+ if default_provider_route:
+ provider_file_object_by_default_route: Final = await asyncio.wait_for(
+ self._afile_retrieve_with_retries(
+ provider_file_id=provider_file_id,
+ call_options=router_credentials or {},
+ ),
+ timeout=BATCH_OUTPUT_FILE_LOOKUP_TIMEOUT_SECONDS,
+ )
+ return (
+ provider_file_object_by_default_route.model_copy(update={"id": unified_file_id}),
+ True,
+ )
+ return None, False
+ except Exception as error:
+ verbose_logger.warning(
+ "Failed to retrieve batch file object for "
+ f"provider_file_id={sanitize_for_log(provider_file_id)}: "
+ f"{type(error).__name__} {sanitize_for_log(error)}"
+ )
+ if raise_on_failure:
+ if isinstance(error, TimeoutError) and not str(error):
+ raise TimeoutError(
+ "Provider file retrieve timed out "
+ f"after {BATCH_OUTPUT_FILE_LOOKUP_TIMEOUT_SECONDS} seconds"
+ ) from error
+ raise
+ return None, True
+
+ async def _save_refreshed_file_object(
+ self,
+ stored: LiteLLM_ManagedFileTable,
+ file_object: OpenAIFileObject,
+ ) -> None:
+ if not await ManagedFileRepository(self.prisma_client).update_file_object(stored.unified_file_id, file_object):
+ return
+ refreshed_row: Final = stored.model_copy(update={"file_object": file_object})
+ await self.internal_usage_cache.async_set_cache(
+ key=stored.unified_file_id,
+ value=refreshed_row.model_dump(),
+ litellm_parent_otel_span=None,
+ )
+
+ async def store_batch_output_file(
+ self,
+ *,
+ unified_file_id: str,
+ provider_file_id: str,
+ model_id: str | None,
+ model_name: str | None = None,
+ owner: UserAPIKeyAuth,
+ litellm_parent_otel_span: Span | None,
+ size_bytes: int | None = None,
+ fetch_provider_details: bool = True,
+ ) -> None:
+ """Register batch output or error file metadata, optionally fetching provider details."""
+ stored_file: Final = await self.get_unified_file_id(unified_file_id, litellm_parent_otel_span)
+ stored_object: Final = stored_file.file_object if stored_file is not None else None
+ if not fetch_provider_details:
+ if stored_file is not None:
+ return
+ router_credentials: Final = _provider_file_retrieve_credentials(
+ llm_router=_proxy_llm_router(),
+ model_id=model_id,
+ )
+ file_object_without_provider_details: Final = _batch_output_file_object(
+ unified_file_id,
+ provider_file_id,
+ size_bytes or 0,
+ fallback=_has_provider_file_retrieve_route(
+ router_credentials=router_credentials,
+ model_name=model_name,
+ ),
+ )
+ await self.store_unified_file_id(
+ file_id=unified_file_id,
+ file_object=file_object_without_provider_details,
+ litellm_parent_otel_span=litellm_parent_otel_span,
+ model_mappings={model_id: provider_file_id} if model_id else {},
+ user_api_key_dict=owner,
+ )
+ return
+ if stored_object is not None and not stored_object.litellm_details_fallback:
+ return
+
+ fallback_written_recently: Final = (
+ stored_object is not None
+ and time.time() - stored_object.created_at < BATCH_OUTPUT_FILE_FALLBACK_RETRY_AFTER_SECONDS
+ )
+ provider_fetch_result: Final = (
+ (None, True)
+ if fallback_written_recently
+ else await self._fetch_provider_file_object(
+ unified_file_id=unified_file_id,
+ provider_file_id=provider_file_id,
+ model_id=model_id,
+ model_name=model_name,
+ )
+ )
+ provider_object, fetch_route_available = provider_fetch_result
+ if (
+ stored_file is not None
+ and provider_object is None
+ and (size_bytes is None or (stored_object is not None and stored_object.bytes == size_bytes))
+ ):
+ return
+
+ file_object: Final = (
+ provider_object
+ if provider_object is not None
+ else (
+ stored_object.model_copy(update={"bytes": size_bytes})
+ if stored_object is not None and size_bytes is not None
+ else _batch_output_file_object(
+ unified_file_id,
+ provider_file_id,
+ size_bytes or 0,
+ fallback=fetch_route_available,
+ )
+ )
+ )
+
+ if stored_file is not None:
+ await self._save_refreshed_file_object(stored_file, file_object)
+ return
+
+ await self.store_unified_file_id(
+ file_id=unified_file_id,
+ file_object=file_object,
+ litellm_parent_otel_span=litellm_parent_otel_span,
+ model_mappings={model_id: provider_file_id} if model_id else {},
+ user_api_key_dict=owner,
+ )
+
async def list_user_batches(
self,
user_api_key_dict: UserAPIKeyAuth,
@@ -745,6 +1078,7 @@ class PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
user_api_key_dict=user_api_key_dict,
db_batch_object=row,
unified_batch_id=_is_base64_encoded_unified_file_id(row.unified_object_id),
+ fetch_provider_details=False,
)
except Exception as e:
verbose_logger.warning(f"Failed to resolve managed file ids for batch {row.unified_object_id}: {e}")
@@ -806,7 +1140,7 @@ class PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
}
)
return [
- parsed_file_object.model_copy(update={"id": row.unified_file_id})
+ _public_file_object(parsed_file_object, row.unified_file_id)
for row in file_ids
if (parsed_file_object := _parse_managed_file_object(row.file_object, row.unified_file_id)) is not None
]
@@ -828,7 +1162,7 @@ class PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
)
return False
- async def check_file_ids_access(self, file_ids: List[str], user_api_key_dict: UserAPIKeyAuth) -> None:
+ async def check_file_ids_access(self, file_ids: Sequence[str], user_api_key_dict: UserAPIKeyAuth) -> None:
"""
Check if the user has access to a list of file IDs.
Only checks managed (unified) file IDs.
@@ -935,6 +1269,7 @@ class PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
elif call_type == CallTypes.acreate_batch.value:
input_file_id = cast(Optional[str], data.get("input_file_id"))
if input_file_id:
+ await self.check_file_ids_access((input_file_id,), user_api_key_dict)
model_file_id_mapping = await self.get_model_file_id_mapping(
[input_file_id], user_api_key_dict.parent_otel_span
)
@@ -981,11 +1316,12 @@ class PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
elif retrieve_object_id and accessor_key == "batch_id":
await self.enforce_batch_object_access(retrieve_object_id, user_api_key_dict)
elif call_type == CallTypes.acreate_fine_tuning_job.value:
- input_file_id = cast(Optional[str], data.get("training_file"))
- if input_file_id:
- model_file_id_mapping = await self.get_model_file_id_mapping(
- [input_file_id], user_api_key_dict.parent_otel_span
- )
+ fine_tuning_file_ids: Final = tuple(
+ file_id
+ for file_id in (data.get("training_file"), data.get("validation_file"))
+ if isinstance(file_id, str) and file_id
+ )
+ await self.check_file_ids_access(fine_tuning_file_ids, user_api_key_dict)
return data
@@ -1474,42 +1810,13 @@ class PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
)
setattr(response, file_attr, unified_file_id)
- # Use llm_router credentials when available. Without credentials,
- # Azure and other auth-required providers return 500/401.
- file_object = None
- try:
- # Import module and use getattr for better testability with mocks
- import litellm.proxy.proxy_server as proxy_server_module
-
- _llm_router = getattr(proxy_server_module, "llm_router", None)
- if _llm_router is not None and model_id:
- _creds = _llm_router.get_deployment_credentials_with_provider(model_id) or {}
- file_object = await litellm.afile_retrieve(
- file_id=provider_file_id,
- **cast(FileRetrieveCallOptions, _creds),
- )
- else:
- file_object = await litellm.afile_retrieve(
- custom_llm_provider=cast(
- FileRetrieveProvider,
- model_name.split("/")[0] if model_name and "/" in model_name else "openai",
- ),
- file_id=provider_file_id,
- )
- verbose_logger.debug(
- f"Successfully retrieved file object for {file_attr}={provider_file_id}"
- )
- except Exception as e:
- verbose_logger.warning(
- f"Failed to retrieve file object for {file_attr}={provider_file_id}: {str(e)}. Storing with None and will fetch on-demand."
- )
-
- await self.store_unified_file_id(
- file_id=unified_file_id,
- file_object=file_object,
+ await self.store_batch_output_file(
+ unified_file_id=unified_file_id,
+ provider_file_id=provider_file_id,
+ model_id=model_id,
+ model_name=resolved_model_name,
+ owner=user_api_key_dict,
litellm_parent_otel_span=user_api_key_dict.parent_otel_span,
- model_mappings={model_id: provider_file_id},
- user_api_key_dict=user_api_key_dict,
)
request_metadata: Final = data.get("litellm_metadata")
await self.store_unified_object_id(
@@ -1612,6 +1919,7 @@ class PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
async def afile_retrieve(
self, file_id: str, litellm_parent_otel_span: Optional[Span], llm_router: Optional[Router] = None
) -> OpenAIFileObject:
+ """Return public details for a managed file ID, refreshing a basic entry when possible."""
stored_file_object = await self.get_unified_file_id(file_id, litellm_parent_otel_span)
# Case 1 : This is not a managed file
@@ -1621,30 +1929,57 @@ class PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
# Case 2: Managed file and the file object exists in the database
# The stored file_object has the raw provider ID. Replace with the unified ID
# so callers see a consistent ID (matching Case 3 which does response.id = file_id).
- if stored_file_object and stored_file_object.file_object:
- # Use model_copy to ensure the ID update persists (Pydantic v2 compatibility)
- response = stored_file_object.file_object.model_copy(update={"id": file_id})
- return response
+ if stored_file_object.file_object is not None:
+ file_object: Final = stored_file_object.file_object
+ if file_object.litellm_details_fallback and stored_file_object.model_mappings:
+ try:
+ model_id, provider_file_id = next(iter(stored_file_object.model_mappings.items()))
+ refreshed_file_object, _ = await self._fetch_provider_file_object(
+ unified_file_id=file_id,
+ provider_file_id=provider_file_id,
+ model_id=model_id,
+ model_name=model_id,
+ llm_router=llm_router,
+ )
+ if refreshed_file_object is None:
+ return _public_file_object(file_object, file_id)
+ await self._save_refreshed_file_object(stored_file_object, refreshed_file_object)
+ return _public_file_object(refreshed_file_object, file_id)
+ except Exception as error:
+ verbose_logger.warning(
+ "Failed to refresh batch file object for "
+ f"file_id={sanitize_for_log(file_id)}: {sanitize_for_log(error)}"
+ )
+ return _public_file_object(file_object, file_id)
# Case 3: Managed file exists in the database but not the file object (for. e.g the batch task might not have run)
# So we fetch the file object from the provider. We deliberately do not store the result to avoid interfering with batch cost tracking code.
- if not llm_router:
+ model_mapping: Final = next(iter(stored_file_object.model_mappings.items()), None)
+ if model_mapping is None:
raise Exception(
- f"LiteLLM Managed File object with id={file_id} has no file_object "
- f"and llm_router is required to fetch from provider"
+ f"LiteLLM Managed File object with id={file_id} has no file_object and no provider route to fetch it"
)
+ model_id, model_file_id = model_mapping
try:
- model_id, model_file_id = next(iter(stored_file_object.model_mappings.items()))
- credentials: Final = llm_router.get_deployment_credentials_with_provider(model_id) or {}
- response = await litellm.afile_retrieve(
- file_id=model_file_id,
- **cast(FileRetrieveCallOptions, credentials),
+ response, fetch_route_available = await self._fetch_provider_file_object(
+ unified_file_id=file_id,
+ provider_file_id=model_file_id,
+ model_id=model_id,
+ model_name=model_id,
+ llm_router=llm_router,
+ raise_on_failure=True,
+ allow_default_provider=True,
)
- response.id = file_id # Replace with unified ID
- return response
except Exception as e:
raise Exception(f"Failed to retrieve file {file_id} from provider: {str(e)}") from e
+ if not fetch_route_available:
+ raise Exception(
+ f"LiteLLM Managed File object with id={file_id} has no file_object and no provider route to fetch it"
+ )
+ if response is None:
+ raise ValueError("Provider file details could not be retrieved")
+ return _public_file_object(response, file_id)
async def afile_list(
self,
@@ -1707,7 +2042,7 @@ class PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
**cursor_args,
)
matches.extend(
- parsed_file_object.model_copy(update={"id": row.unified_file_id})
+ _public_file_object(parsed_file_object, row.unified_file_id)
for row in chunk
if (parsed_file_object := _parse_managed_file_object(row.file_object, row.unified_file_id)) is not None
and (purpose is None or parsed_file_object.purpose == purpose)
@@ -2110,4 +2445,4 @@ class PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
verbose_logger.debug(
f"Converted file {file_id} from storage backend to base64 with format {content_type}"
)
-_PROXY_LiteLLMManagedFiles = PROXY_LiteLLMManagedFiles
+PROXY_LiteLLMManagedFiles = _PROXY_LiteLLMManagedFiles
diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_vector_stores.py b/enterprise/litellm_enterprise/proxy/hooks/managed_vector_stores.py
index c53f359a468..0e9657aba96 100644
--- a/enterprise/litellm_enterprise/proxy/hooks/managed_vector_stores.py
+++ b/enterprise/litellm_enterprise/proxy/hooks/managed_vector_stores.py
@@ -38,7 +38,7 @@ else:
PrismaClient = Any
-class PROXY_LiteLLMManagedVectorStores(
+class _PROXY_LiteLLMManagedVectorStores(
CustomLogger, BaseManagedResource[VectorStoreCreateResponse]
):
"""
@@ -462,4 +462,4 @@ class PROXY_LiteLLMManagedVectorStores(
parent_otel_span=parent_otel_span,
resource_id_key="vector_store_id",
)
-_PROXY_LiteLLMManagedVectorStores = PROXY_LiteLLMManagedVectorStores
+PROXY_LiteLLMManagedVectorStores = _PROXY_LiteLLMManagedVectorStores
diff --git a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py
index 7277282968e..db419fde1e6 100644
--- a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py
+++ b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py
@@ -12,7 +12,7 @@ Endpoints for /project operations
import json
from collections.abc import Mapping, Sequence
-from typing import TYPE_CHECKING, Final
+from typing import TYPE_CHECKING, Final, cast
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import TypeAdapter
@@ -31,6 +31,7 @@ from litellm.proxy.management_helpers.utils import (
management_endpoint_wrapper,
)
from litellm.proxy.utils import PrismaClient, handle_exception_on_proxy
+from litellm.repositories.base_repository import record_to_dict
from litellm.repositories.budget_repository import BudgetRepository
from litellm.repositories.object_permission_repository import ObjectPermissionRepository
from litellm.repositories.prisma_protocols import TableActions
@@ -57,6 +58,15 @@ def _project_table(prisma_client: PrismaClient) -> TableActions["prisma_models.L
return ProjectRepository(prisma_client).table
+def _writer_project_table(
+ prisma_client: PrismaClient,
+) -> TableActions["prisma_models.LiteLLM_ProjectTable"]:
+ return cast( # cast-ok: writer_db exposes generated Prisma tables through a dynamic wrapper
+ "TableActions[prisma_models.LiteLLM_ProjectTable]",
+ prisma_client.writer_db.litellm_projecttable,
+ )
+
+
def _verification_token_table(
prisma_client: PrismaClient,
) -> TableActions["prisma_models.LiteLLM_VerificationToken"]:
@@ -824,6 +834,38 @@ async def update_project(
**({"max_budget": None} if "max_budget" in data.model_fields_set and data.max_budget is None else {}),
}
+ object_permission_data: Final = update_data.pop("object_permission", None)
+ object_permission_payload: Final = (
+ _OBJECT_PERMISSION_PAYLOAD.validate_python(object_permission_data) if object_permission_data else None
+ )
+
+ if data.team_id is not None:
+ current_project_record: Final = await _writer_project_table(prisma_client).find_unique(
+ where={"project_id": data.project_id}
+ )
+ current_project: Final = (
+ LiteLLM_ProjectTable.model_validate(record_to_dict(current_project_record))
+ if current_project_record is not None
+ else None
+ )
+ if current_project is not None and data.team_id != current_project.team_id:
+ mismatched_key_count: Final = await prisma_client.writer_db.litellm_verificationtoken.count(
+ where={
+ "project_id": data.project_id,
+ "OR": [{"team_id": {"not": data.team_id}}, {"team_id": None}],
+ }
+ )
+ if mismatched_key_count > 0:
+ raise ProxyException(
+ message=(
+ f"Project {data.project_id} has {mismatched_key_count} key(s) that do not belong to "
+ f"team {data.team_id}. Detach or delete them before moving the project."
+ ),
+ type="bad_request",
+ code=400,
+ param="team_id",
+ )
+
if budget_updates and existing_project.budget_id:
# Update existing budget
await _budget_table(prisma_client).update(
@@ -837,23 +879,17 @@ async def update_project(
for field in budget_updates.keys():
update_data.pop(field, None)
- # Handle object permissions
- if "object_permission" in update_data:
- object_permission_data = update_data.pop("object_permission")
- if object_permission_data:
- object_permission_payload: Final = _OBJECT_PERMISSION_PAYLOAD.validate_python(object_permission_data)
- if existing_project.object_permission_id:
- # Update existing permission
- await _object_permission_table(prisma_client).update(
- where={"object_permission_id": existing_project.object_permission_id},
- data=object_permission_payload,
- )
- else:
- # Create new permission
- created_permission: Final = await _object_permission_table(prisma_client).create(
- data=object_permission_payload,
- )
- update_data["object_permission_id"] = created_permission.object_permission_id
+ if object_permission_payload is not None:
+ if existing_project.object_permission_id:
+ await _object_permission_table(prisma_client).update(
+ where={"object_permission_id": existing_project.object_permission_id},
+ data=object_permission_payload,
+ )
+ else:
+ created_permission: Final = await _object_permission_table(prisma_client).create(
+ data=object_permission_payload,
+ )
+ update_data["object_permission_id"] = created_permission.object_permission_id
# Handle metadata fields
for field in LiteLLM_ManagementEndpoint_MetadataFields:
diff --git a/enterprise/pyproject.toml b/enterprise/pyproject.toml
index 08b049f0157..4ffb7169f24 100644
--- a/enterprise/pyproject.toml
+++ b/enterprise/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "litellm-enterprise"
-version = "0.1.74"
+version = "0.1.77"
description = "Package for LiteLLM Enterprise features"
readme = "README.md"
requires-python = ">=3.9"
@@ -26,7 +26,7 @@ required-version = ">=0.10.9"
module-root = ""
[tool.commitizen]
-version = "0.1.74"
+version = "0.1.77"
version_files = [
"pyproject.toml:^version",
"../pyproject.toml:litellm-enterprise==",
diff --git a/gateway/routes/allowlist.py b/gateway/routes/allowlist.py
index 31c05f21b1b..cced49f788a 100644
--- a/gateway/routes/allowlist.py
+++ b/gateway/routes/allowlist.py
@@ -62,6 +62,8 @@ GATEWAY_PATH_PREFIXES: tuple[str, ...] = (
"/rerank",
"/v1/decisions",
"/decisions",
+ "/v1/systemone",
+ "/systemone",
"/v1/ocr",
"/ocr",
"/v1/rag/",
@@ -119,6 +121,8 @@ GATEWAY_PATH_PREFIXES: tuple[str, ...] = (
# Realtime / streaming
"/v1/realtime",
"/realtime",
+ "/v1/live/sessions",
+ "/live/sessions",
# Health & ops
"/health",
"/metrics",
diff --git a/helm/litellm-helm/Chart.lock b/helm/litellm-helm/Chart.lock
index d626fbb472b..220e379ff5a 100644
--- a/helm/litellm-helm/Chart.lock
+++ b/helm/litellm-helm/Chart.lock
@@ -5,5 +5,8 @@ dependencies:
- name: redis
repository: oci://registry-1.docker.io/bitnamicharts
version: 18.19.1
-digest: sha256:38962e231f6596b93f82a8412bbe4cf5de696caecf5775dfbbd163383eb1c009
-generated: "2026-07-28T10:21:22.511401-07:00"
+- name: lens
+ repository: oci://ghcr.io/berriai/charts
+ version: 0.1.0-dev.0
+digest: sha256:16b92c3d7fc74632e8aa11e602c136d273c774ed39b2fe970b80273e0570898b
+generated: '2026-10-09T04:18:10.643228000Z'
diff --git a/helm/litellm-helm/Chart.yaml b/helm/litellm-helm/Chart.yaml
index a3cb388ffc6..a3a94241057 100644
--- a/helm/litellm-helm/Chart.yaml
+++ b/helm/litellm-helm/Chart.yaml
@@ -39,3 +39,6 @@ dependencies:
version: "18.19.1"
repository: oci://registry-1.docker.io/bitnamicharts
condition: redis.enabled
+ - name: lens
+ version: 0.1.0-dev.0
+ repository: oci://ghcr.io/berriai/charts
diff --git a/helm/litellm-helm/README.md b/helm/litellm-helm/README.md
index bf4089404db..a76af037045 100644
--- a/helm/litellm-helm/README.md
+++ b/helm/litellm-helm/README.md
@@ -225,3 +225,9 @@ At the time of writing, the Admin UI is unable to add models. This is because
it would need to update the `config.yaml` file which is a exposed ConfigMap, and
therefore, read-only. This is a limitation of this helm chart, not the Admin UI
itself.
+
+## Connect Lens
+
+`lensWorker.mode` selects `disabled`, `bundled` or `external`. Bundled mode generates separate service and identity-signing credentials. External mode requires `lensWorker.gateway.secretName` and `lensWorker.serviceTokenSecret.name`; their keys default to `gateway-secret` and `service-token`. Provision distinct values matching the external Lens deployment
+
+Follow the [Lens Helm connection guide](https://github.com/BerriAI/lens/blob/main/helm/lens/README.md#connect-a-gateway) for complete values, verification, GitOps credential requirements and existing-data migration. Source-chart installation requires a built Lens image until the first signed Lens release is published
diff --git a/helm/litellm-helm/charts/lens-0.1.0-dev.0.tgz b/helm/litellm-helm/charts/lens-0.1.0-dev.0.tgz
new file mode 100644
index 00000000000..79021702c2c
Binary files /dev/null and b/helm/litellm-helm/charts/lens-0.1.0-dev.0.tgz differ
diff --git a/helm/litellm-helm/templates/_helpers.tpl b/helm/litellm-helm/templates/_helpers.tpl
index 9630633912e..cde4bce7f47 100644
--- a/helm/litellm-helm/templates/_helpers.tpl
+++ b/helm/litellm-helm/templates/_helpers.tpl
@@ -321,3 +321,126 @@ through an emptyDir. Empty when the sidecar is off or uses 127.0.0.1 TCP.
- name: LITELLM_COLLECTOR_DRAIN_TIMEOUT_SECONDS
value: {{ .Values.collector.drainTimeoutSeconds | quote }}
{{- end -}}
+
+{{- define "litellm.lensWorker.image" -}}
+{{- include "lens.image" (dict "Values" .Values.lensWorker "Chart" .Subcharts.lens.Chart) -}}
+{{- end -}}
+
+{{- define "litellm.gateway.collectorSocketDir" -}}
+{{- if and .Values.gateway.collector.enabled (hasPrefix "unix://" .Values.gateway.collector.address) -}}
+{{- dir (trimPrefix "unix://" .Values.gateway.collector.address) -}}
+{{- end -}}
+{{- end -}}
+
+{{/*
+LITELLM_COLLECTOR_* env shared by the producer (gateway container) and the
+consumer (collector container), so both agree on the transport and the
+shutdown drain window.
+*/}}
+{{- define "litellm.gateway.collectorEnv" -}}
+{{- with .Values.gateway.collector }}
+- name: LITELLM_COLLECTOR_ENABLED
+ value: "true"
+- name: LITELLM_COLLECTOR_ADDRESS
+ value: {{ .address | quote }}
+- name: LITELLM_COLLECTOR_BUFFER_SIZE
+ value: {{ .bufferSize | quote }}
+- name: LITELLM_COLLECTOR_ON_UNAVAILABLE
+ value: {{ .onUnavailable | quote }}
+- name: LITELLM_COLLECTOR_DRAIN_TIMEOUT_SECONDS
+ value: {{ .drainTimeoutSeconds | quote }}
+{{- end }}
+{{- end -}}
+
+{{- define "litellm.lensWorker.labels" -}}
+{{- $labels := include "litellm.labels" . | fromYaml -}}
+{{- $_ := set $labels "app.kubernetes.io/name" (printf "%s-lens-worker" (include "litellm.name" . | trunc 51 | trimSuffix "-")) -}}
+{{- toYaml $labels -}}
+{{- end -}}
+
+{{- define "litellm.lensWorker.serviceTokenSecretName" -}}
+{{- .Values.lensWorker.serviceTokenSecret.name | default (printf "%s-lens-service" (include "litellm.fullname" .)) -}}
+{{- end -}}
+
+{{- define "litellm.lensWorker.gatewaySecretName" -}}
+{{- .Values.lensWorker.gateway.secretName | default (printf "%s-lens-gateway" (include "litellm.fullname" . | trunc 50 | trimSuffix "-")) -}}
+{{- end -}}
+
+{{- define "litellm.lensWorker.bundledClickhouse" -}}
+{{- if and (eq (include "litellm.lens.mode" .) "bundled") .Values.lensWorker.clickhouse.enabled (not .Values.lensWorker.clickhouseSecret.name) -}}true{{- end -}}
+{{- end -}}
+
+{{- define "litellm.lensWorker.publicUrl" -}}
+{{- if .Values.lensWorker.publicUrl -}}
+{{- .Values.lensWorker.publicUrl -}}
+{{- else if eq (include "litellm.lens.mode" .) "external" -}}
+{{- fail "lensWorker.publicUrl is required for external Lens" -}}
+{{- else if .Values.lensWorker.ingress.enabled -}}
+{{- $tls := or (not (empty .Values.lensWorker.ingress.tls)) (hasKey .Values.lensWorker.ingress.annotations "alb.ingress.kubernetes.io/certificate-arn") -}}
+{{- printf "%s://%s" (ternary "https" "http" $tls) (required "lensWorker.ingress.host is required" .Values.lensWorker.ingress.host) -}}
+{{- else if and .Values.ingress.enabled (eq (len .Values.ingress.hosts) 1) -}}
+{{- $host := required "ingress.hosts[0].host is required" (first .Values.ingress.hosts).host -}}
+{{- $tls := or (not (empty .Values.ingress.tls)) (hasKey .Values.ingress.annotations "alb.ingress.kubernetes.io/certificate-arn") -}}
+{{- printf "%s://%s/lens-ingest" (ternary "https" "http" $tls) $host -}}
+{{- else -}}
+{{- fail "lensWorker.publicUrl is required when there is no single ingress hostname" -}}
+{{- end -}}
+{{- end -}}
+
+{{- define "litellm.lensWorker.clickhouseName" -}}
+{{- printf "%s-lens-clickhouse" (include "litellm.fullname" . | trunc 47 | trimSuffix "-") -}}
+{{- end -}}
+
+{{- define "litellm.lensConnectionEnv" -}}
+{{- $mode := include "litellm.lens.mode" . -}}
+{{- if ne $mode "disabled" }}
+{{- if and (eq $mode "external") (not .Values.lensWorker.serviceTokenSecret.name) -}}
+{{- fail "lensWorker.serviceTokenSecret.name is required for external Lens" -}}
+{{- end }}
+{{- if and (eq $mode "external") (not .Values.lensWorker.gateway.secretName) -}}
+{{- fail "lensWorker.gateway.secretName is required for external Lens" -}}
+{{- end -}}
+- name: LITELLM_LENS_URL
+ value: {{ if eq $mode "external" }}{{ required "lensWorker.externalUrl is required for external Lens" .Values.lensWorker.externalUrl | quote }}{{ else }}{{ printf "http://%s-lens-worker:%v" (include "litellm.fullname" .) .Values.lensWorker.service.port | quote }}{{ end }}
+- name: LITELLM_LENS_PUBLIC_URL
+ value: {{ include "litellm.lensWorker.publicUrl" . | quote }}
+- name: LENS_GATEWAY_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "litellm.lensWorker.gatewaySecretName" . | quote }}
+ key: {{ .Values.lensWorker.gateway.secretKey | quote }}
+- name: LITELLM_LENS_SERVICE_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "litellm.lensWorker.serviceTokenSecretName" . | quote }}
+ key: {{ .Values.lensWorker.serviceTokenSecret.key | quote }}
+{{- end }}
+{{- end -}}
+
+{{- define "litellm.lens.mode" -}}
+{{- $mode := .Values.lensWorker.mode | default (ternary "bundled" "disabled" .Values.lensWorker.enabled) -}}
+{{- if not (has $mode (list "bundled" "external" "disabled")) -}}
+{{- fail "lensWorker.mode must be bundled, external or disabled" -}}
+{{- end -}}
+{{- $mode -}}
+{{- end -}}
+
+{{- define "litellm.lens.render" -}}
+{{- $root := .root -}}
+{{- $values := mergeOverwrite (deepCopy $root.Subcharts.lens.Values) (deepCopy $root.Values.lensWorker) -}}
+{{- $_ := set $values "fullnameOverride" (printf "%s-lens-worker" (include "litellm.fullname" $root)) -}}
+{{- $_ := set $values "component" "lens-worker" -}}
+{{- $_ := set $values "nameOverride" (printf "%s-lens-worker" (include "litellm.name" $root | trunc 51 | trimSuffix "-")) -}}
+{{- $_ := set $values "imagePullSecrets" $root.Values.imagePullSecrets -}}
+{{- $_ := set $values.gateway "enabled" true -}}
+{{- $_ := set $values.gateway "generatedName" (include "litellm.lensWorker.gatewaySecretName" $root) -}}
+{{- $_ := set $values.clickhouse "nameOverride" (include "litellm.lensWorker.clickhouseName" $root) -}}
+{{- $_ := set $values.serviceTokenSecret "generatedName" (include "litellm.lensWorker.serviceTokenSecretName" $root) -}}
+{{- $_ := set $values "publicUrl" ($root.Values.lensWorker.standaloneUrl | default $root.Subcharts.lens.Values.publicUrl) -}}
+{{- if and (eq .resource "deployment") (or $root.Values.lensWorker.publicUrl $root.Values.lensWorker.ingress.enabled $root.Values.ingress.enabled) -}}
+{{- $ingestion := include "litellm.lensWorker.publicUrl" $root -}}
+{{- $_ := set $values "ingestionUrl" $ingestion -}}
+{{- $_ := set $values "publicUrl" ($root.Values.lensWorker.standaloneUrl | default (trimSuffix "/lens-ingest" $ingestion)) -}}
+{{- end -}}
+{{- include (printf "lens.%s" .resource) (dict "Values" $values "Release" $root.Release "Chart" $root.Subcharts.lens.Chart "Capabilities" $root.Capabilities) -}}
+{{- end -}}
diff --git a/helm/litellm-helm/templates/deployment.yaml b/helm/litellm-helm/templates/deployment.yaml
index 299d41e2019..37e7eaec9e9 100644
--- a/helm/litellm-helm/templates/deployment.yaml
+++ b/helm/litellm-helm/templates/deployment.yaml
@@ -56,6 +56,7 @@ spec:
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
+ {{- include "litellm.lensConnectionEnv" . | nindent 12 }}
{{- include "litellm.proxyEnv" . | nindent 12 }}
{{- if .Values.liteadmin.enabled }}
- name: LITELLM_ADMIN_AGENT_URL
diff --git a/helm/litellm-helm/templates/ingress.yaml b/helm/litellm-helm/templates/ingress.yaml
index ea9ffcbb54c..97ebb054b04 100644
--- a/helm/litellm-helm/templates/ingress.yaml
+++ b/helm/litellm-helm/templates/ingress.yaml
@@ -1,6 +1,8 @@
{{- if .Values.ingress.enabled -}}
{{- $fullName := include "litellm.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
+{{- $lensMode := include "litellm.lens.mode" . -}}
+{{- $lensService := .Values.lensWorker.externalServiceName | default (printf "%s-lens-worker" $fullName) -}}
{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
{{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }}
{{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
@@ -44,6 +46,20 @@ spec:
- host: {{ .host | quote }}
http:
paths:
+ {{- if or (eq $lensMode "bundled") (and (eq $lensMode "external") $.Values.lensWorker.externalServiceName) }}
+ - path: /lens-ingest
+ pathType: Prefix
+ backend:
+ {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
+ service:
+ name: {{ if eq $lensMode "external" }}{{ $lensService }}{{ else }}{{ $fullName }}-lens-worker{{ end }}
+ port:
+ number: {{ $.Values.lensWorker.service.port }}
+ {{- else }}
+ serviceName: {{ if eq $lensMode "external" }}{{ $lensService }}{{ else }}{{ $fullName }}-lens-worker{{ end }}
+ servicePort: {{ $.Values.lensWorker.service.port }}
+ {{- end }}
+ {{- end }}
{{- range .paths }}
- path: {{ .path }}
{{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
diff --git a/helm/litellm-helm/templates/lens/clickhouse.yaml b/helm/litellm-helm/templates/lens/clickhouse.yaml
new file mode 100644
index 00000000000..d9daafda6ba
--- /dev/null
+++ b/helm/litellm-helm/templates/lens/clickhouse.yaml
@@ -0,0 +1,3 @@
+{{- if eq (include "litellm.lens.mode" .) "bundled" }}
+{{ include "litellm.lens.render" (dict "root" . "resource" "clickhouse") }}
+{{- end }}
diff --git a/helm/litellm-helm/templates/lens/deployment.yaml b/helm/litellm-helm/templates/lens/deployment.yaml
new file mode 100644
index 00000000000..01a6acb1908
--- /dev/null
+++ b/helm/litellm-helm/templates/lens/deployment.yaml
@@ -0,0 +1,3 @@
+{{- if eq (include "litellm.lens.mode" .) "bundled" }}
+{{ include "litellm.lens.render" (dict "root" . "resource" "deployment") }}
+{{- end }}
diff --git a/helm/litellm-helm/templates/lens/ingress.yaml b/helm/litellm-helm/templates/lens/ingress.yaml
new file mode 100644
index 00000000000..b5994b22d92
--- /dev/null
+++ b/helm/litellm-helm/templates/lens/ingress.yaml
@@ -0,0 +1,3 @@
+{{- if eq (include "litellm.lens.mode" .) "bundled" }}
+{{ include "litellm.lens.render" (dict "root" . "resource" "ingress") }}
+{{- end }}
diff --git a/helm/litellm-helm/templates/lens/secrets.yaml b/helm/litellm-helm/templates/lens/secrets.yaml
new file mode 100644
index 00000000000..76928c26e5f
--- /dev/null
+++ b/helm/litellm-helm/templates/lens/secrets.yaml
@@ -0,0 +1,3 @@
+{{- if eq (include "litellm.lens.mode" .) "bundled" }}
+{{ include "litellm.lens.render" (dict "root" . "resource" "secrets") }}
+{{- end }}
diff --git a/helm/litellm-helm/templates/lens/service.yaml b/helm/litellm-helm/templates/lens/service.yaml
new file mode 100644
index 00000000000..60c7e995134
--- /dev/null
+++ b/helm/litellm-helm/templates/lens/service.yaml
@@ -0,0 +1,3 @@
+{{- if eq (include "litellm.lens.mode" .) "bundled" }}
+{{ include "litellm.lens.render" (dict "root" . "resource" "service") }}
+{{- end }}
diff --git a/helm/litellm-helm/tests/lens_endpoint_defaults_tests.yaml b/helm/litellm-helm/tests/lens_endpoint_defaults_tests.yaml
new file mode 100644
index 00000000000..22dcc7e9f9c
--- /dev/null
+++ b/helm/litellm-helm/tests/lens_endpoint_defaults_tests.yaml
@@ -0,0 +1,84 @@
+suite: Lens endpoint defaults
+templates:
+ - deployment.yaml
+ - configmap-litellm.yaml
+set:
+ lensWorker.enabled: true
+ ingress.enabled: true
+ ingress.hosts: [{host: gateway.example, paths: [{path: /, pathType: Prefix}]}]
+ ingress.tls:
+ - hosts: [gateway.example]
+ secretName: tls
+tests:
+ - it: derives the trace endpoint from the deployment hostname and TLS
+ template: deployment.yaml
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_PUBLIC_URL
+ value: https://gateway.example/lens-ingest
+ - it: preserves an explicitly configured public address
+ set:
+ lensWorker.publicUrl: https://custom.example/prefix
+ template: deployment.yaml
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_PUBLIC_URL
+ value: https://custom.example/prefix
+ - it: uses the dedicated Lens ingress when configured
+ set:
+ lensWorker.ingress.enabled: true
+ lensWorker.ingress.host: traces.example
+ lensWorker.ingress.tls:
+ - hosts: [traces.example]
+ secretName: traces-tls
+ template: deployment.yaml
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_PUBLIC_URL
+ value: https://traces.example
+ - it: uses HTTPS for a dedicated ALB ingress with certificate annotations
+ set:
+ lensWorker.ingress.enabled: true
+ lensWorker.ingress.host: traces.example
+ lensWorker.ingress.className: alb
+ lensWorker.ingress.tls: []
+ lensWorker.ingress.annotations:
+ alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-west-2:123456789012:certificate/test
+ alb.ingress.kubernetes.io/listen-ports: '[{"HTTP":80},{"HTTPS":443}]'
+ alb.ingress.kubernetes.io/ssl-redirect: "443"
+ template: deployment.yaml
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_PUBLIC_URL
+ value: https://traces.example
+ - it: uses HTTP for a dedicated ingress without its own TLS
+ set:
+ lensWorker.ingress.enabled: true
+ lensWorker.ingress.host: traces.example
+ lensWorker.ingress.tls: []
+ lensWorker.ingress.annotations: {}
+ template: deployment.yaml
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_PUBLIC_URL
+ value: http://traces.example
+ - it: uses HTTP when ingress has no TLS
+ set:
+ ingress.tls: []
+ template: deployment.yaml
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_PUBLIC_URL
+ value: http://gateway.example/lens-ingest
diff --git a/helm/litellm-helm/tests/lens_modes_tests.yaml b/helm/litellm-helm/tests/lens_modes_tests.yaml
new file mode 100644
index 00000000000..e746db8f83e
--- /dev/null
+++ b/helm/litellm-helm/tests/lens_modes_tests.yaml
@@ -0,0 +1,187 @@
+---
+suite: Lens deployment modes
+templates:
+- ingress.yaml
+- configmap-litellm.yaml
+- deployment.yaml
+- lens/deployment.yaml
+- lens/service.yaml
+- lens/clickhouse.yaml
+- lens/secrets.yaml
+set:
+ lensWorker.publicUrl: https://traces.example
+tests:
+- it: retains the existing deployment before transferring its release ownership
+ template: lens/deployment.yaml
+ set:
+ lensWorker.mode: bundled
+ lensWorker.retainResources: true
+ asserts:
+ - equal:
+ path: metadata.annotations["helm.sh/resource-policy"]
+ value: keep
+- it: connects an external deployment without installing Lens resources
+ templates:
+ - lens/deployment.yaml
+ - lens/service.yaml
+ - lens/clickhouse.yaml
+ - lens/secrets.yaml
+ set:
+ lensWorker.mode: external
+ lensWorker.externalUrl: http://lens.other-namespace:4318
+ lensWorker.serviceTokenSecret.name: external-lens-signing
+ lensWorker.gateway.secretName: external-lens-signing
+ asserts:
+ - hasDocuments:
+ count: 0
+- it: sends delegated requests to the explicitly selected external Lens
+ template: deployment.yaml
+ set:
+ lensWorker.mode: external
+ lensWorker.externalUrl: http://lens.other-namespace:4318
+ lensWorker.serviceTokenSecret.name: external-lens-signing
+ lensWorker.gateway.secretName: external-lens-signing
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_URL
+ value: http://lens.other-namespace:4318
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LENS_GATEWAY_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: external-lens-signing
+ key: gateway-secret
+- it: disables Lens explicitly even with a retained legacy enable flag
+ template: lens/deployment.yaml
+ set:
+ lensWorker.mode: disabled
+ lensWorker.enabled: true
+ asserts:
+ - hasDocuments:
+ count: 0
+- it: bundles Lens through explicit mode selection
+ template: lens/deployment.yaml
+ set:
+ lensWorker.mode: bundled
+ asserts:
+ - hasDocuments:
+ count: 1
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LENS_MODE
+ value: standalone
+- it: uses separate credentials for external identity and service access
+ template: deployment.yaml
+ set:
+ lensWorker.mode: external
+ lensWorker.externalUrl: http://existing-lens:4318
+ lensWorker.serviceTokenSecret: {name: worker-service, key: token}
+ lensWorker.gateway: {secretName: delegated-identity, secretKey: signature}
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LENS_GATEWAY_SECRET
+ valueFrom:
+ secretKeyRef: {name: delegated-identity, key: signature}
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_SERVICE_TOKEN
+ valueFrom:
+ secretKeyRef: {name: worker-service, key: token}
+- it: supplies the same separate signing secret to the bundled service
+ template: lens/deployment.yaml
+ set:
+ lensWorker.mode: bundled
+ lensWorker.gateway: {secretName: delegated-identity, secretKey: signature}
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LENS_GATEWAY_SECRET
+ valueFrom:
+ secretKeyRef: {name: delegated-identity, key: signature}
+- it: routes explicit bundled mode with the enable flag off
+ template: ingress.yaml
+ set:
+ fullnameOverride: gateway
+ ingress.enabled: true
+ ingress.hosts: [{host: gateway.example, paths: [{path: /, pathType: Prefix}]}]
+ lensWorker.mode: bundled
+ lensWorker.enabled: false
+ lensWorker.externalUrl: http://existing-lens:4318
+ lensWorker.externalServiceName: ""
+ lensWorker.serviceTokenSecret.name: worker-service
+ lensWorker.gateway.secretName: external-lens-signing
+ asserts:
+ - contains:
+ path: spec.rules[0].http.paths
+ content:
+ path: /lens-ingest
+ pathType: Prefix
+ backend:
+ service:
+ name: gateway-lens-worker
+ port: {number: 4318}
+- it: routes external mode to its selected service
+ template: ingress.yaml
+ set:
+ fullnameOverride: gateway
+ ingress.enabled: true
+ ingress.hosts: [{host: gateway.example, paths: [{path: /, pathType: Prefix}]}]
+ lensWorker.mode: external
+ lensWorker.enabled: true
+ lensWorker.externalUrl: http://existing-lens:4318
+ lensWorker.externalServiceName: "existing-lens"
+ lensWorker.serviceTokenSecret.name: worker-service
+ lensWorker.gateway.secretName: external-lens-signing
+ asserts:
+ - contains:
+ path: spec.rules[0].http.paths
+ content:
+ path: /lens-ingest
+ pathType: Prefix
+ backend:
+ service:
+ name: existing-lens
+ port: {number: 4318}
+- it: omits ingestion when an external service has no ingress backend
+ template: ingress.yaml
+ set:
+ fullnameOverride: gateway
+ ingress.enabled: true
+ ingress.hosts: [{host: gateway.example, paths: [{path: /, pathType: Prefix}]}]
+ lensWorker.mode: external
+ lensWorker.enabled: true
+ lensWorker.externalUrl: http://existing-lens:4318
+ lensWorker.externalServiceName: ""
+ lensWorker.serviceTokenSecret.name: worker-service
+ lensWorker.gateway.secretName: external-lens-signing
+ asserts:
+ - notContains:
+ path: spec.rules[0].http.paths
+ content: {path: /lens-ingest}
+ any: true
+- it: omits ingestion for disabled mode with the enable flag on
+ template: ingress.yaml
+ set:
+ fullnameOverride: gateway
+ ingress.enabled: true
+ ingress.hosts: [{host: gateway.example, paths: [{path: /, pathType: Prefix}]}]
+ lensWorker.mode: disabled
+ lensWorker.enabled: true
+ lensWorker.externalUrl: http://existing-lens:4318
+ lensWorker.externalServiceName: ""
+ lensWorker.serviceTokenSecret.name: worker-service
+ lensWorker.gateway.secretName: external-lens-signing
+ asserts:
+ - notContains:
+ path: spec.rules[0].http.paths
+ content: {path: /lens-ingest}
+ any: true
diff --git a/helm/litellm-helm/tests/lens_modes_validation_tests.yaml b/helm/litellm-helm/tests/lens_modes_validation_tests.yaml
new file mode 100644
index 00000000000..eaf7d58f6ac
--- /dev/null
+++ b/helm/litellm-helm/tests/lens_modes_validation_tests.yaml
@@ -0,0 +1,52 @@
+---
+suite: Lens deployment mode validation
+templates:
+- configmap-litellm.yaml
+- deployment.yaml
+set:
+ lensWorker.publicUrl: https://traces.example
+tests:
+- it: requires a separate identity signing reference for external Lens
+ template: deployment.yaml
+ set:
+ lensWorker.mode: external
+ lensWorker.externalUrl: http://lens.other-namespace:4318
+ lensWorker.serviceTokenSecret.name: worker-service
+ asserts:
+ - failedTemplate:
+ errorMessage: lensWorker.gateway.secretName is required for external Lens
+- it: requires an external service address
+ template: deployment.yaml
+ set:
+ lensWorker.mode: external
+ lensWorker.serviceTokenSecret.name: external-lens-signing
+ lensWorker.gateway.secretName: external-lens-signing
+ asserts:
+ - failedTemplate:
+ errorMessage: lensWorker.externalUrl is required for external Lens
+- it: requires the shared server credential for an external service
+ template: deployment.yaml
+ set:
+ lensWorker.mode: external
+ lensWorker.externalUrl: http://lens.other-namespace:4318
+ asserts:
+ - failedTemplate:
+ errorMessage: lensWorker.serviceTokenSecret.name is required for external Lens
+- it: requires an agent reachable URL for external ingestion
+ template: deployment.yaml
+ set:
+ lensWorker.mode: external
+ lensWorker.externalUrl: http://lens.other-namespace:4318
+ lensWorker.serviceTokenSecret.name: external-lens-signing
+ lensWorker.gateway.secretName: external-lens-signing
+ lensWorker.publicUrl: ''
+ asserts:
+ - failedTemplate:
+ errorMessage: lensWorker.publicUrl is required for external Lens
+- it: rejects unsupported deployment modes
+ template: deployment.yaml
+ set:
+ lensWorker.mode: typo
+ asserts:
+ - failedTemplate:
+ errorMessage: lensWorker.mode must be bundled, external or disabled
diff --git a/helm/litellm-helm/tests/lens_saved_secrets_tests.yaml b/helm/litellm-helm/tests/lens_saved_secrets_tests.yaml
new file mode 100644
index 00000000000..c85f8d8c9e7
--- /dev/null
+++ b/helm/litellm-helm/tests/lens_saved_secrets_tests.yaml
@@ -0,0 +1,59 @@
+suite: Lens preserves credentials across Helm upgrades
+templates:
+ - lens/secrets.yaml
+set:
+ fullnameOverride: lens-test
+ lensWorker.enabled: true
+release:
+ namespace: lens
+ name: lens-test
+ upgrade: true
+kubernetesProvider:
+ scheme:
+ v1/Secret:
+ gvr:
+ version: v1
+ resource: secrets
+ namespaced: true
+ objects:
+ - apiVersion: v1
+ kind: Secret
+ metadata:
+ name: lens-test-lens-gateway
+ namespace: lens
+ data:
+ gateway-secret: c2F2ZWQtc2lnbmluZy1rZXk=
+ - apiVersion: v1
+ kind: Secret
+ metadata:
+ name: lens-test-lens-service
+ namespace: lens
+ data:
+ service-token: c2F2ZWQtc2VydmljZS10b2tlbg==
+ - apiVersion: v1
+ kind: Secret
+ metadata:
+ name: lens-test-lens-clickhouse
+ namespace: lens
+ data:
+ password: c2F2ZWQtZGF0YWJhc2UtcGFzc3dvcmQ=
+tests:
+ - it: preserves the separate signing credential across upgrades
+ documentSelector: {path: metadata.name, value: lens-test-lens-gateway}
+ asserts:
+ - equal:
+ path: data.gateway-secret
+ value: c2F2ZWQtc2lnbmluZy1rZXk=
+ - notExists: {path: data.service-token}
+ - it: reuses the service credential instead of breaking running services
+ documentIndex: 0
+ asserts:
+ - equal:
+ path: data.service-token
+ value: c2F2ZWQtc2VydmljZS10b2tlbg==
+ - it: reuses the database password instead of locking out stored traces
+ documentIndex: 1
+ asserts:
+ - equal:
+ path: data.password
+ value: c2F2ZWQtZGF0YWJhc2UtcGFzc3dvcmQ=
diff --git a/helm/litellm-helm/tests/lens_service_tests.yaml b/helm/litellm-helm/tests/lens_service_tests.yaml
new file mode 100644
index 00000000000..1beabca3064
--- /dev/null
+++ b/helm/litellm-helm/tests/lens_service_tests.yaml
@@ -0,0 +1,167 @@
+suite: Lens service isolation and ingestion routing
+templates:
+- configmap-litellm.yaml
+- deployment.yaml
+- ingress.yaml
+- lens/ingress.yaml
+- lens/service.yaml
+- lens/deployment.yaml
+tests:
+- it: connects deployment.yaml to the shared Lens service
+ template: deployment.yaml
+ set: &id001
+ fullnameOverride: lens-test
+ lensWorker.enabled: true
+ lensWorker.publicUrl: https://gateway.example/lens-ingest
+ lensWorker.serviceTokenSecret.name: lens-service
+ lensWorker.clickhouseSecret.name: lens-storage
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_URL
+ value: http://lens-test-lens-worker:4318
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_PUBLIC_URL
+ value: https://gateway.example/lens-ingest
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LENS_GATEWAY_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: lens-test-lens-gateway
+ key: gateway-secret
+- it: routes uploads directly to Lens instead of the gateway
+ template: ingress.yaml
+ set:
+ fullnameOverride: lens-test
+ lensWorker.enabled: true
+ lensWorker.publicUrl: https://gateway.example/lens-ingest
+ lensWorker.serviceTokenSecret.name: lens-service
+ lensWorker.clickhouseSecret.name: lens-storage
+ ingress.enabled: true
+ ingress.hosts:
+ - host: gateway.example
+ paths:
+ - path: /
+ pathType: Prefix
+ asserts:
+ - contains:
+ path: spec.rules[0].http.paths
+ content:
+ path: /lens-ingest
+ pathType: Prefix
+ backend:
+ service:
+ name: lens-test-lens-worker
+ port:
+ number: 4318
+- it: keeps internal routes out of a dedicated ingestion hostname
+ template: lens/ingress.yaml
+ set:
+ fullnameOverride: lens-test
+ lensWorker.enabled: true
+ lensWorker.publicUrl: https://gateway.example/lens-ingest
+ lensWorker.serviceTokenSecret.name: lens-service
+ lensWorker.clickhouseSecret.name: lens-storage
+ lensWorker.ingress.enabled: true
+ lensWorker.ingress.host: traces.example
+ asserts:
+ - equal:
+ path: spec.rules[0].http.paths
+ value:
+ - path: /v1/
+ pathType: Prefix
+ backend:
+ service:
+ name: lens-test-lens-worker
+ port:
+ name: otlp
+- it: maps the Lens service to the ingestion listener
+ template: lens/service.yaml
+ set: *id001
+ asserts:
+ - equal:
+ path: spec.selector
+ value:
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/component: lens-worker
+ - equal:
+ path: spec.ports
+ value:
+ - name: otlp
+ port: 4318
+ targetPort: otlp
+- it: gives only Lens the ClickHouse secret
+ template: lens/deployment.yaml
+ set: *id001
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: CLICKHOUSE_URL
+ valueFrom:
+ secretKeyRef:
+ name: lens-storage
+ key: url
+- it: requires an agent reachable ingestion URL
+ template: deployment.yaml
+ set:
+ fullnameOverride: lens-test
+ lensWorker.enabled: true
+ lensWorker.publicUrl: ''
+ lensWorker.serviceTokenSecret.name: lens-service
+ lensWorker.clickhouseSecret.name: lens-storage
+ asserts:
+ - failedTemplate:
+ errorMessage: lensWorker.publicUrl is required when there is no single ingress hostname
+- it: omits Lens connection settings when disabled in deployment.yaml
+ template: deployment.yaml
+ asserts:
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_URL
+ any: true
+- it: preserves an existing ClickHouse database and retention
+ template: lens/deployment.yaml
+ set:
+ lensWorker.enabled: true
+ lensWorker.publicUrl: https://traces.example
+ lensWorker.serviceTokenSecret.name: lens-service
+ lensWorker.clickhouseSecret.name: lens-storage
+ lensWorker.clickhouseDatabase: existing_traces
+ lensWorker.retentionDays: 45
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: CLICKHOUSE_DATABASE
+ value: existing_traces
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: AGENT_TRACING_RETENTION_DAYS
+ value: '45'
+- it: keeps Lens pods outside the gateway autoscaling selector
+ template: lens/deployment.yaml
+ set: &id002
+ nameOverride: inference
+ lensWorker.enabled: true
+ lensWorker.publicUrl: https://traces.example
+ lensWorker.serviceTokenSecret.name: lens-service
+ lensWorker.clickhouseSecret.name: lens-storage
+ asserts:
+ - equal:
+ path: spec.template.metadata.labels["app.kubernetes.io/name"]
+ value: inference-lens-worker
+- it: preserves the existing gateway deployment selector
+ template: deployment.yaml
+ set: *id002
+ asserts:
+ - equal:
+ path: spec.selector.matchLabels["app.kubernetes.io/name"]
+ value: inference
diff --git a/helm/litellm-helm/tests/lens_setup_tests.yaml b/helm/litellm-helm/tests/lens_setup_tests.yaml
new file mode 100644
index 00000000000..20cd8590153
--- /dev/null
+++ b/helm/litellm-helm/tests/lens_setup_tests.yaml
@@ -0,0 +1,162 @@
+suite: Lens managed setup
+set:
+ fullnameOverride: lens-test
+ lensWorker.enabled: true
+ lensWorker.publicUrl: https://traces.example
+release:
+ namespace: lens
+ name: lens-test
+templates:
+ - lens/deployment.yaml
+ - lens/clickhouse.yaml
+ - lens/secrets.yaml
+tests:
+ - it: generates private login, gateway and storage credentials
+ template: lens/secrets.yaml
+ asserts:
+ - hasDocuments:
+ count: 4
+ - matchRegex:
+ path: data.service-token
+ pattern: '^[A-Za-z0-9+/]{86}==$'
+ documentIndex: 0
+ - matchRegex:
+ path: data.password
+ pattern: '^[A-Za-z0-9+/]{86}==$'
+ documentIndex: 1
+ - equal:
+ path: metadata.annotations["helm.sh/resource-policy"]
+ value: keep
+ - it: connects Lens to its private bundled storage
+ template: lens/deployment.yaml
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: CLICKHOUSE_HOST
+ value: lens-test-lens-clickhouse
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: CLICKHOUSE_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: lens-test-lens-clickhouse
+ key: password
+ - it: stores traces on a persistent volume with the chosen storage class
+ template: lens/clickhouse.yaml
+ documentIndex: 1
+ set:
+ lensWorker.clickhouse.storage: 40Gi
+ lensWorker.clickhouse.storageClassName: fast
+ asserts:
+ - equal:
+ path: spec.volumeClaimTemplates[0].spec
+ value:
+ accessModes: [ReadWriteOnce]
+ storageClassName: fast
+ resources:
+ requests:
+ storage: 40Gi
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: CLICKHOUSE_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: lens-test-lens-clickhouse
+ key: password
+ - it: keeps an existing external database instead of creating a new one
+ template: lens/clickhouse.yaml
+ set:
+ lensWorker.clickhouseSecret.name: external-clickhouse
+ asserts:
+ - hasDocuments:
+ count: 0
+ - it: leaves supplied secrets under their existing manager
+ template: lens/secrets.yaml
+ set:
+ lensWorker.clickhouseSecret.name: external-clickhouse
+ lensWorker.serviceTokenSecret.name: external-service
+ lensWorker.adminTokenSecret.name: external-admin
+ lensWorker.gateway.secretName: external-signing
+ asserts:
+ - hasDocuments:
+ count: 0
+ - it: creates no credentials or database when Lens is disabled
+ templates:
+ - lens/secrets.yaml
+ - lens/clickhouse.yaml
+ set:
+ lensWorker.enabled: false
+ asserts:
+ - hasDocuments:
+ count: 0
+ - it: requires an external database when bundled storage is explicitly disabled
+ template: lens/deployment.yaml
+ set:
+ lensWorker.clickhouse.enabled: false
+ asserts:
+ - failedTemplate:
+ errorMessage: Lens clickhouseSecret.name is required when bundled storage is disabled
+ - it: keeps storage names valid and uses the same name for the connection
+ set:
+ fullnameOverride: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ asserts:
+ - matchRegex:
+ path: metadata.name
+ pattern: '^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$'
+ template: lens/clickhouse.yaml
+ documentIndex: 0
+ - matchRegex:
+ path: metadata.name
+ pattern: '^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$'
+ template: lens/clickhouse.yaml
+ documentIndex: 1
+ - equal:
+ path: metadata.name
+ value: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-lens-clickhouse
+ template: lens/clickhouse.yaml
+ documentIndex: 0
+ - equal:
+ path: spec.serviceName
+ value: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-lens-clickhouse
+ template: lens/clickhouse.yaml
+ documentIndex: 1
+ - equal:
+ path: metadata.name
+ value: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-lens-clickhouse
+ template: lens/secrets.yaml
+ documentIndex: 1
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: CLICKHOUSE_HOST
+ value: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-lens-clickhouse
+ template: lens/deployment.yaml
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: CLICKHOUSE_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-lens-clickhouse
+ key: password
+ template: lens/deployment.yaml
+
+ - it: allows cold storage startup and inherits registry credentials
+ template: lens/clickhouse.yaml
+ documentIndex: 1
+ set:
+ imagePullSecrets: [{name: registry-auth}]
+ asserts:
+ - equal:
+ path: spec.template.spec.imagePullSecrets
+ value: [{name: registry-auth}]
+ - equal:
+ path: spec.template.spec.containers[0].startupProbe
+ value:
+ httpGet: {path: /ping, port: http}
+ periodSeconds: 5
+ timeoutSeconds: 3
+ failureThreshold: 60
diff --git a/helm/litellm-helm/values.yaml b/helm/litellm-helm/values.yaml
index 42ed777e6bc..11954d121a5 100644
--- a/helm/litellm-helm/values.yaml
+++ b/helm/litellm-helm/values.yaml
@@ -440,10 +440,10 @@ db:
# Prefer the native /health/drain preStop hook over a fixed `sleep`: it marks
# the pod NotReady and blocks only until in-flight requests actually finish
# (bounded by GRACEFUL_SHUTDOWN_TIMEOUT, default 30s), instead of always
-# waiting the worst-case duration. The drain runs once (the preStop hook and
-# the SIGTERM handler share it), so set terminationGracePeriodSeconds a few
-# seconds above GRACEFUL_SHUTDOWN_TIMEOUT to leave room for teardown before
-# SIGKILL.
+# waiting the worst-case duration. After SIGTERM the server waits up to
+# GRACEFUL_SHUTDOWN_TIMEOUT again for connections still open and then cancels
+# them, so set terminationGracePeriodSeconds a few seconds above twice
+# GRACEFUL_SHUTDOWN_TIMEOUT to leave room for teardown before SIGKILL.
#
# /health/drain is off by default; enable it with
# general_settings.enable_drain_endpoint: true. The kubelet calls preStop
@@ -652,3 +652,72 @@ serviceMonitor:
namespaceSelector:
matchNames: []
# - test-namespace
+
+lens:
+ library: true
+
+lensWorker:
+ retainResources: false
+ mode: ""
+ externalUrl: ""
+ externalServiceName: ""
+ gateway:
+ secretName: ""
+ secretKey: gateway-secret
+ standaloneUrl: ""
+ adminTokenSecret:
+ name: ""
+ key: admin-token
+ extraEnv: []
+ extraEnvFrom: []
+ enabled: false
+ replicaCount: 1
+ image:
+ repository: ghcr.io/berriai/lens
+ tag: "0.1.0-dev.0"
+ digest: ""
+ pullPolicy: IfNotPresent
+ tokenSecret:
+ name: ""
+ key: token
+ serviceTokenSecret:
+ name: ""
+ key: service-token
+ clickhouse:
+ enabled: true
+ image: clickhouse/clickhouse-server:26.9.6.6@sha256:eb4870e7ca7ed70c259eebfcfbee6cf797017f6b5436c2926bbbfe3d4d28486e
+ storage: 20Gi
+ storageClassName: null
+ resources:
+ requests:
+ cpu: 100m
+ memory: 512Mi
+ limits:
+ memory: 2Gi
+ clickhouseDatabase: litellm
+ retentionDays: 14
+ clickhouseSecret:
+ name: ""
+ key: url
+ publicUrl: ""
+ service:
+ port: 4318
+ annotations: {}
+ ingress:
+ enabled: false
+ path: /v1/
+ className: ""
+ host: ""
+ annotations: {}
+ tls: []
+ url: ""
+ tmpSizeLimit: 1Gi
+ resources:
+ requests:
+ cpu: 100m
+ memory: 256Mi
+ limits:
+ memory: 2Gi
+ nodeSelector: {}
+ tolerations: []
+ affinity: {}
diff --git a/helm/litellm/Chart.lock b/helm/litellm/Chart.lock
new file mode 100644
index 00000000000..d9f261935ca
--- /dev/null
+++ b/helm/litellm/Chart.lock
@@ -0,0 +1,6 @@
+dependencies:
+- name: lens
+ repository: oci://ghcr.io/berriai/charts
+ version: 0.1.0-dev.0
+digest: sha256:6b244c878890f10a093a297d70c9e68b2c3bda00fbbb56ab287e7ecee1d7bbcf
+generated: '2026-10-09T04:18:10.642706000Z'
diff --git a/helm/litellm/Chart.yaml b/helm/litellm/Chart.yaml
index e67f5790c7e..36780b808dc 100644
--- a/helm/litellm/Chart.yaml
+++ b/helm/litellm/Chart.yaml
@@ -6,3 +6,8 @@ version: 0.1.0
appVersion: "0.1.0"
annotations:
org.opencontainers.image.source: "https://github.com/BerriAI/litellm"
+
+dependencies:
+ - name: lens
+ version: 0.1.0-dev.0
+ repository: oci://ghcr.io/berriai/charts
diff --git a/helm/litellm/README.md b/helm/litellm/README.md
new file mode 100644
index 00000000000..53dc300b886
--- /dev/null
+++ b/helm/litellm/README.md
@@ -0,0 +1,25 @@
+# LiteLLM componentized chart
+
+This chart deploys separate gateway, backend and UI services. Configure Lens through `lensWorker.mode`: `disabled` keeps it off, `bundled` installs the Lens dependency, and `external` connects an existing Lens service
+
+Bundled mode generates separate service and identity-signing credentials. External mode requires `lensWorker.gateway.secretName` and `lensWorker.serviceTokenSecret.name`; their keys default to `gateway-secret` and `service-token`. Provision distinct values matching the external Lens deployment
+
+Follow the [Lens Helm connection guide](https://github.com/BerriAI/lens/blob/main/helm/lens/README.md#connect-a-gateway) for the complete values and verification flow. It also links the GitOps credential requirements and existing-data migration. Source-chart installation requires a built Lens image until the first signed Lens release is published
+
+For an ingress with separate component hostnames, set all three `ingress.componentHosts` values, leave `ingress.host` and `ingress.extraPaths` empty, configure `ui.backendUrl` as the backend's public origin and `backend.gatewayUrl` as the gateway's public origin, and set `ui.publicUrl` to the browser-facing UI origin. The UI Deployment receives `LITELLM_BACKEND_URL` from `ui.backendUrl` only when `ingress.componentHosts` is configured.
+
+```yaml
+ingress:
+ enabled: true
+ host: ""
+ componentHosts:
+ ui: ui.example.com
+ backend: backend.example.com
+ gateway: gateway.example.com
+ extraPaths: []
+ui:
+ publicUrl: https://ui.example.com
+ backendUrl: https://backend.example.com
+backend:
+ gatewayUrl: https://gateway.example.com
+```
diff --git a/helm/litellm/charts/lens-0.1.0-dev.0.tgz b/helm/litellm/charts/lens-0.1.0-dev.0.tgz
new file mode 100644
index 00000000000..79021702c2c
Binary files /dev/null and b/helm/litellm/charts/lens-0.1.0-dev.0.tgz differ
diff --git a/helm/litellm/templates/NOTES.txt b/helm/litellm/templates/NOTES.txt
index 468cf621b32..135dcb876e0 100644
--- a/helm/litellm/templates/NOTES.txt
+++ b/helm/litellm/templates/NOTES.txt
@@ -22,12 +22,14 @@ Reminders:
- database.writer.{host,port,dbname} (writer connection pieces)
- database.writer.passwordSecret.{name,usernameKey,passwordKey}
(Secret holding the writer DB username + password)
+ - database.writer.passwordSecret.{hostKey,urlKey} (optional, read writer host or full URL from that Secret)
- database.writer.useIAMAuth: true (optional — chart sets IAM_TOKEN_DB_AUTH=true and
omits DATABASE_PASSWORD / DATABASE_URL so the proxy
mints the URL from an IAM token at startup)
- - database.reader.host (optional — enables read-replica routing; reader
- .passwordSecret.name is required when set, unless
- .useIAMAuth is true)
+ - database.reader.host / database.reader.passwordSecret.{hostKey,urlKey}
+ (optional, enables read-replica routing. The Secret
+ name is required for hostKey, urlKey, and for host
+ with password auth)
- database.reader.useIAMAuth: true (optional, requires database.writer.useIAMAuth: true —
chart emits DATABASE_*_READ_REPLICA env vars and
omits DATABASE_PASSWORD_READ_REPLICA /
@@ -35,6 +37,8 @@ Reminders:
reader URL from an IAM token at startup)
- redis.passwordSecret.name (optional — set when redis.host is provided and the
cache requires auth)
+ - redis.passwordSecret.{hostKey,urlKey} (optional, read Redis host or full URL from that Secret)
+ (the Secret name is required when either key is set)
- redis.cluster: true (optional — chart sets REDIS_CLUSTER_NODES from
redis.host / redis.port so the proxy's Cache()
constructs a RedisClusterCache; the cluster client
diff --git a/helm/litellm/templates/_helpers.tpl b/helm/litellm/templates/_helpers.tpl
index eb7433c279a..647e435bdf6 100644
--- a/helm/litellm/templates/_helpers.tpl
+++ b/helm/litellm/templates/_helpers.tpl
@@ -27,6 +27,30 @@ Common naming + label helpers shared by gateway, backend, and ui templates.
{{- printf "%s-ui" (include "litellm.fullname" .) | trunc 63 | trimSuffix "-" -}}
{{- end -}}
+{{- define "litellm.ui.publicOrigin" -}}
+{{- $origin := .Values.ui.publicUrl | trim | trimSuffix "/" -}}
+{{- if not (regexMatch "^https?://[^/\\s]+$" $origin) -}}
+{{- fail "ui.publicUrl must be an origin like https://ui.example.com, without a path" -}}
+{{- end -}}
+{{- $origin -}}
+{{- end -}}
+
+{{- define "litellm.ui.backendOrigin" -}}
+{{- $origin := .Values.ui.backendUrl | trim | trimSuffix "/" -}}
+{{- if not (regexMatch "^https?://[^/\\s]+$" $origin) -}}
+{{- fail "ui.backendUrl must be an origin like https://backend.example.com, without a path, when ingress.componentHosts is set" -}}
+{{- end -}}
+{{- $origin -}}
+{{- end -}}
+
+{{- define "litellm.backend.gatewayOrigin" -}}
+{{- $origin := .Values.backend.gatewayUrl | trim | trimSuffix "/" -}}
+{{- if not (regexMatch "^https?://[^/\\s]+$" $origin) -}}
+{{- fail "backend.gatewayUrl must be an origin like https://gateway.example.com, without a path, when ingress.componentHosts is set" -}}
+{{- end -}}
+{{- $origin -}}
+{{- end -}}
+
{{- define "litellm.commonLabels" -}}
app.kubernetes.io/name: {{ include "litellm.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
@@ -202,7 +226,9 @@ the chart context (needed for .Values), `component` selects which component's
Sensitive values (master key, DB username + password, Redis password) come
only from referenced Secrets; the chart never accepts inline values for them.
-The chart never assembles DATABASE_URL itself. It emits only the discrete
+The chart never assembles DATABASE_URL itself. When
+database.writer.passwordSecret.urlKey is set, the chart passes the operator's
+URL through from the Secret. Otherwise it emits only the discrete
DATABASE_HOST/PORT/USER/NAME/SCHEMA (+ DATABASE_PASSWORD for password auth)
vars; the proxy's entrypoint (DatabaseURLSettings in
litellm/proxy/db/db_url_settings.py) builds the URL from them and
@@ -218,11 +244,11 @@ token instead of a static password. `database.writer.useAzureEntraAuth: true`
does the same with AZURE_POSTGRESQL_AUTH=true and a Microsoft Entra ID token,
for Azure Database for PostgreSQL. The two are mutually exclusive.
-The read replica is opt-in via `database.reader.host`. The chart emits
-DATABASE_HOST_READ_REPLICA / DATABASE_PORT_READ_REPLICA /
-DATABASE_NAME_READ_REPLICA (+ DATABASE_SCHEMA_READ_REPLICA) for both auth
-modes, plus DATABASE_USER_READ_REPLICA / DATABASE_PASSWORD_READ_REPLICA for
-password auth. When `database.reader.useIAMAuth: true` (or
+The read replica is opt-in via `database.reader.host`, `hostKey`, or `urlKey`.
+The chart emits DATABASE_URL_READ_REPLICA alone for urlKey, or discrete
+DATABASE_*_READ_REPLICA vars for host and hostKey configurations, plus
+DATABASE_USER_READ_REPLICA / DATABASE_PASSWORD_READ_REPLICA for password auth.
+When `database.reader.useIAMAuth: true` (or
`database.reader.useAzureEntraAuth: true`) it omits
DATABASE_PASSWORD_READ_REPLICA and the entrypoint mints the reader URL the
same way. Reader token auth only takes effect when the writer uses the same
@@ -242,8 +268,36 @@ IAM_TOKEN_DB_AUTH / AZURE_POSTGRESQL_AUTH toggle that only the writer sets.
value: {{ $component.logLevel | quote }}
{{- end }}
{{- with $root.Values.database.writer }}
+{{- $hostKey := .passwordSecret.hostKey }}
+{{- $urlKey := .passwordSecret.urlKey }}
+{{- if and .host $hostKey }}
+{{- fail "database.writer.host and database.writer.passwordSecret.hostKey cannot both be set" }}
+{{- end }}
+{{- if and $urlKey (or .host $hostKey .dbname .schema .sslMode .sslRootCert .useIAMAuth .useAzureEntraAuth) }}
+{{- fail "database.writer.passwordSecret.urlKey cannot be combined with host, hostKey, dbname, schema, sslMode, sslRootCert, useIAMAuth, or useAzureEntraAuth" }}
+{{- end }}
+{{- if and (not .host) (not $hostKey) (not $urlKey) }}
+{{- fail "one of database.writer.host, database.writer.passwordSecret.hostKey, or database.writer.passwordSecret.urlKey is required" }}
+{{- end }}
+{{- if and (or $hostKey $urlKey) (not .passwordSecret.name) }}
+{{- fail "database.writer.passwordSecret.name is required when hostKey or urlKey is set" }}
+{{- end }}
+{{- if $urlKey }}
+- name: DATABASE_URL
+ valueFrom:
+ secretKeyRef:
+ name: {{ .passwordSecret.name | quote }}
+ key: {{ $urlKey | quote }}
+{{- else }}
- name: DATABASE_HOST
- value: {{ required "database.writer.host is required" .host | quote }}
+ {{- if $hostKey }}
+ valueFrom:
+ secretKeyRef:
+ name: {{ .passwordSecret.name | quote }}
+ key: {{ $hostKey | quote }}
+ {{- else }}
+ value: {{ .host | quote }}
+ {{- end }}
- name: DATABASE_PORT
value: {{ .port | default 5432 | quote }}
- name: DATABASE_USER
@@ -282,8 +336,29 @@ IAM_TOKEN_DB_AUTH / AZURE_POSTGRESQL_AUTH toggle that only the writer sets.
key: {{ .passwordSecret.passwordKey | default "password" }}
{{- end }}
{{- end }}
+{{- end }}
{{- with $root.Values.database.reader }}
-{{- if .host }}
+{{- $readerSecret := .passwordSecret | default dict }}
+{{- $hostKey := $readerSecret.hostKey }}
+{{- $urlKey := $readerSecret.urlKey }}
+{{- $readerEnabled := or .host $hostKey $urlKey }}
+{{- if and .host $hostKey }}
+{{- fail "database.reader.host and database.reader.passwordSecret.hostKey cannot both be set" }}
+{{- end }}
+{{- if and $urlKey (or .host $hostKey .dbname .schema .useIAMAuth .useAzureEntraAuth) }}
+{{- fail "database.reader.passwordSecret.urlKey cannot be combined with host, hostKey, dbname, schema, useIAMAuth, or useAzureEntraAuth" }}
+{{- end }}
+{{- if and (or $hostKey $urlKey) (not $readerSecret.name) }}
+{{- fail "database.reader.passwordSecret.name is required when hostKey or urlKey is set" }}
+{{- end }}
+{{- if $readerEnabled }}
+{{- if $urlKey }}
+- name: DATABASE_URL_READ_REPLICA
+ valueFrom:
+ secretKeyRef:
+ name: {{ $readerSecret.name | quote }}
+ key: {{ $urlKey | quote }}
+{{- else }}
{{- if and .useIAMAuth (not $root.Values.database.writer.useIAMAuth) }}
{{- fail "database.reader.useIAMAuth requires database.writer.useIAMAuth: true (the proxy gates IAM URL minting on IAM_TOKEN_DB_AUTH, which is only set by the writer)" }}
{{- end }}
@@ -291,11 +366,18 @@ IAM_TOKEN_DB_AUTH / AZURE_POSTGRESQL_AUTH toggle that only the writer sets.
{{- fail "database.reader.useAzureEntraAuth requires database.writer.useAzureEntraAuth: true (the proxy gates Entra URL minting on AZURE_POSTGRESQL_AUTH, which is only set by the writer)" }}
{{- end }}
- name: DATABASE_HOST_READ_REPLICA
+ {{- if $hostKey }}
+ valueFrom:
+ secretKeyRef:
+ name: {{ $readerSecret.name | quote }}
+ key: {{ $hostKey | quote }}
+ {{- else }}
value: {{ .host | quote }}
+ {{- end }}
- name: DATABASE_PORT_READ_REPLICA
value: {{ .port | default 5432 | quote }}
- name: DATABASE_NAME_READ_REPLICA
- value: {{ required "database.reader.dbname is required when database.reader.host is set" .dbname | quote }}
+ value: {{ required "database.reader.dbname is required when database.reader.host, hostKey, or urlKey is set" .dbname | quote }}
{{- if .schema }}
- name: DATABASE_SCHEMA_READ_REPLICA
value: {{ .schema | quote }}
@@ -325,6 +407,7 @@ IAM_TOKEN_DB_AUTH / AZURE_POSTGRESQL_AUTH toggle that only the writer sets.
{{- end }}
{{- end }}
{{- end }}
+{{- end }}
{{/*
The migrations Job (helm.sh/hook: pre-upgrade) is the single owner of
`prisma migrate deploy`. Without this, every gateway/backend pod also runs
@@ -341,9 +424,34 @@ harmless no-op for the Job and authoritative for the app pods.
tracking, pod lock manager) via its REDIS_* env fallback. An explicit
`general_settings.coordination_redis` block in proxy_config takes
precedence over anything emitted here. */}}
-{{- if $root.Values.redis.host }}
+{{- $redisSecret := $root.Values.redis.passwordSecret | default dict }}
+{{- $hostKey := $redisSecret.hostKey }}
+{{- $urlKey := $redisSecret.urlKey }}
+{{- if and $root.Values.redis.host $hostKey }}
+{{- fail "redis.host and redis.passwordSecret.hostKey cannot both be set" }}
+{{- end }}
+{{- if and $urlKey (or $root.Values.redis.host $hostKey $root.Values.redis.cluster) }}
+{{- fail "redis.passwordSecret.urlKey cannot be combined with host, hostKey, or cluster" }}
+{{- end }}
+{{- if and (or $hostKey $urlKey) (not $redisSecret.name) }}
+{{- fail "redis.passwordSecret.name is required when hostKey or urlKey is set" }}
+{{- end }}
+{{- if $urlKey }}
+- name: REDIS_URL
+ valueFrom:
+ secretKeyRef:
+ name: {{ $redisSecret.name | quote }}
+ key: {{ $urlKey | quote }}
+{{- else if or $root.Values.redis.host $hostKey }}
- name: REDIS_HOST
+ {{- if $hostKey }}
+ valueFrom:
+ secretKeyRef:
+ name: {{ $redisSecret.name | quote }}
+ key: {{ $hostKey | quote }}
+ {{- else }}
value: {{ $root.Values.redis.host | quote }}
+ {{- end }}
- name: REDIS_PORT
value: {{ $root.Values.redis.port | quote }}
{{- if $root.Values.redis.passwordSecret.name }}
@@ -360,7 +468,11 @@ harmless no-op for the Job and authoritative for the app pods.
configured endpoint; the cluster client discovers the remaining nodes from
CLUSTER SLOTS at startup. */}}
- name: REDIS_CLUSTER_NODES
+ {{- if $hostKey }}
+ value: {{ printf "[{\"host\":\"$(REDIS_HOST)\",\"port\":%v}]" (int $root.Values.redis.port) | quote }}
+ {{- else }}
value: {{ printf "[{\"host\":%q,\"port\":%v}]" $root.Values.redis.host (int $root.Values.redis.port) | quote }}
+ {{- end }}
{{- end }}
{{- end }}
{{- with $component.extraEnv }}
@@ -472,21 +584,7 @@ collector containers through an emptyDir. Empty when the sidecar is off
or gateway.collector.address is a tcp://127.0.0.1: address.
*/}}
{{- define "litellm.lensWorker.image" -}}
-{{- if .Values.lensWorker.image.digest -}}
-{{- if not (regexMatch "^sha256:[0-9a-f]{64}$" .Values.lensWorker.image.digest) -}}
-{{- fail "lensWorker.image.digest must be sha256 followed by 64 lowercase hex characters" -}}
-{{- end -}}
-{{- printf "%s@%s" .Values.lensWorker.image.repository .Values.lensWorker.image.digest -}}
-{{- else -}}
-{{- $backendTag := .Values.backend.image.tag | default .Chart.AppVersion -}}
-{{- $releaseTag := ternary (printf "v%s" $backendTag) $backendTag (regexMatch "^[0-9]" $backendTag) -}}
-{{- $tag := .Values.lensWorker.image.tag | default $releaseTag -}}
-{{- $repository := .Values.lensWorker.image.repository -}}
-{{- if and (hasPrefix "sha-" $tag) (eq $repository "ghcr.io/berriai/litellm-lens-worker") -}}
-{{- $repository = "ghcr.io/berriai/litellm-lens-worker-dev" -}}
-{{- end -}}
-{{- printf "%s:%s" $repository $tag -}}
-{{- end -}}
+{{- include "lens.image" (dict "Values" .Values.lensWorker "Chart" .Subcharts.lens.Chart) -}}
{{- end -}}
{{- define "litellm.gateway.collectorSocketDir" -}}
@@ -514,3 +612,95 @@ shutdown drain window.
value: {{ .drainTimeoutSeconds | quote }}
{{- end }}
{{- end -}}
+
+{{- define "litellm.lensConnectionEnv" -}}
+{{- $mode := include "litellm.lens.mode" . -}}
+{{- if ne $mode "disabled" }}
+{{- if and (eq $mode "external") (not .Values.lensWorker.serviceTokenSecret.name) -}}
+{{- fail "lensWorker.serviceTokenSecret.name is required for external Lens" -}}
+{{- end }}
+{{- if and (eq $mode "external") (not .Values.lensWorker.gateway.secretName) -}}
+{{- fail "lensWorker.gateway.secretName is required for external Lens" -}}
+{{- end -}}
+- name: LITELLM_LENS_URL
+ value: {{ if eq $mode "external" }}{{ required "lensWorker.externalUrl is required for external Lens" .Values.lensWorker.externalUrl | quote }}{{ else }}{{ printf "http://%s-lens-worker:%v" (include "litellm.fullname" .) .Values.lensWorker.service.port | quote }}{{ end }}
+- name: LITELLM_LENS_PUBLIC_URL
+ value: {{ include "litellm.lensWorker.publicUrl" . | quote }}
+- name: LENS_GATEWAY_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "litellm.lensWorker.gatewaySecretName" . | quote }}
+ key: {{ .Values.lensWorker.gateway.secretKey | quote }}
+- name: LITELLM_LENS_SERVICE_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "litellm.lensWorker.serviceTokenSecretName" . | quote }}
+ key: {{ .Values.lensWorker.serviceTokenSecret.key | quote }}
+{{- end }}
+{{- end -}}
+
+{{- define "litellm.lensWorker.labels" -}}
+{{- $labels := include "litellm.commonLabels" . | fromYaml -}}
+{{- $_ := set $labels "app.kubernetes.io/name" (printf "%s-lens-worker" (include "litellm.name" . | trunc 51 | trimSuffix "-")) -}}
+{{- toYaml $labels -}}
+{{- end -}}
+
+{{- define "litellm.lensWorker.serviceTokenSecretName" -}}
+{{- .Values.lensWorker.serviceTokenSecret.name | default (printf "%s-lens-service" (include "litellm.fullname" .)) -}}
+{{- end -}}
+
+{{- define "litellm.lensWorker.gatewaySecretName" -}}
+{{- .Values.lensWorker.gateway.secretName | default (printf "%s-lens-gateway" (include "litellm.fullname" . | trunc 50 | trimSuffix "-")) -}}
+{{- end -}}
+
+{{- define "litellm.lensWorker.bundledClickhouse" -}}
+{{- if and (eq (include "litellm.lens.mode" .) "bundled") .Values.lensWorker.clickhouse.enabled (not .Values.lensWorker.clickhouseSecret.name) -}}true{{- end -}}
+{{- end -}}
+
+{{- define "litellm.lensWorker.publicUrl" -}}
+{{- if .Values.lensWorker.publicUrl -}}
+{{- .Values.lensWorker.publicUrl -}}
+{{- else if eq (include "litellm.lens.mode" .) "external" -}}
+{{- fail "lensWorker.publicUrl is required for external Lens" -}}
+{{- else if .Values.lensWorker.ingress.enabled -}}
+{{- $tls := or (not (empty .Values.lensWorker.ingress.tls)) (hasKey .Values.lensWorker.ingress.annotations "alb.ingress.kubernetes.io/certificate-arn") -}}
+{{- printf "%s://%s" (ternary "https" "http" $tls) (required "lensWorker.ingress.host is required" .Values.lensWorker.ingress.host) -}}
+{{- else if and .Values.ingress.enabled .Values.ingress.host -}}
+{{- $tls := or (not (empty .Values.ingress.tls)) (hasKey .Values.ingress.annotations "alb.ingress.kubernetes.io/certificate-arn") -}}
+{{- printf "%s://%s/lens-ingest" (ternary "https" "http" $tls) .Values.ingress.host -}}
+{{- else -}}
+{{- fail "lensWorker.publicUrl is required when there is no single ingress hostname" -}}
+{{- end -}}
+{{- end -}}
+
+{{- define "litellm.lensWorker.clickhouseName" -}}
+{{- printf "%s-lens-clickhouse" (include "litellm.fullname" . | trunc 47 | trimSuffix "-") -}}
+{{- end -}}
+
+{{- define "litellm.lens.mode" -}}
+{{- $mode := .Values.lensWorker.mode | default (ternary "bundled" "disabled" .Values.lensWorker.enabled) -}}
+{{- if not (has $mode (list "bundled" "external" "disabled")) -}}
+{{- fail "lensWorker.mode must be bundled, external or disabled" -}}
+{{- end -}}
+{{- $mode -}}
+{{- end -}}
+
+{{- define "litellm.lens.render" -}}
+{{- $root := .root -}}
+{{- $values := mergeOverwrite (deepCopy $root.Subcharts.lens.Values) (deepCopy $root.Values.lensWorker) -}}
+{{- $_ := set $values "fullnameOverride" (printf "%s-lens-worker" (include "litellm.fullname" $root)) -}}
+{{- $_ := set $values "component" "lens-worker" -}}
+{{- $_ := set $values "nameOverride" (printf "%s-lens-worker" (include "litellm.name" $root | trunc 51 | trimSuffix "-")) -}}
+{{- $_ := set $values "imagePullSecrets" $root.Values.imagePullSecrets -}}
+{{- $_ := set $values.gateway "enabled" true -}}
+{{- $_ := set $values.gateway "generatedName" (include "litellm.lensWorker.gatewaySecretName" $root) -}}
+{{- $_ := set $values.clickhouse "nameOverride" (include "litellm.lensWorker.clickhouseName" $root) -}}
+{{- $_ := set $values.serviceTokenSecret "generatedName" (include "litellm.lensWorker.serviceTokenSecretName" $root) -}}
+{{- $_ := set $values "publicUrl" ($root.Values.lensWorker.standaloneUrl | default $root.Subcharts.lens.Values.publicUrl) -}}
+{{- if and (eq .resource "deployment") (or $root.Values.lensWorker.publicUrl $root.Values.lensWorker.ingress.enabled $root.Values.ingress.enabled) -}}
+{{- $ingestion := include "litellm.lensWorker.publicUrl" $root -}}
+{{- $_ := set $values "ingestionUrl" $ingestion -}}
+{{- $_ := set $values "publicUrl" ($root.Values.lensWorker.standaloneUrl | default (trimSuffix "/lens-ingest" $ingestion)) -}}
+{{- end -}}
+{{- include (printf "lens.%s" .resource) (dict "Values" $values "Release" $root.Release "Chart" $root.Subcharts.lens.Chart "Capabilities" $root.Capabilities) -}}
+{{- end -}}
diff --git a/helm/litellm/templates/backend/deployment.yaml b/helm/litellm/templates/backend/deployment.yaml
index 5d3be1439bd..7360261dab1 100644
--- a/helm/litellm/templates/backend/deployment.yaml
+++ b/helm/litellm/templates/backend/deployment.yaml
@@ -1,4 +1,6 @@
{{- if .Values.backend.enabled }}
+{{- $componentHosts := .Values.ingress.componentHosts | default dict -}}
+{{- $hasComponentHosts := or $componentHosts.ui $componentHosts.backend $componentHosts.gateway -}}
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -57,9 +59,18 @@ spec:
containerPort: 4001
protocol: TCP
env:
- - name: LENS_WORKER_IMAGE
- value: {{ include "litellm.lensWorker.image" . | quote }}
+ {{- include "litellm.lensConnectionEnv" . | nindent 12 }}
+ {{- if .Values.ui.publicUrl }}
+ - name: LITELLM_CORS_ORIGINS
+ value: {{ include "litellm.ui.publicOrigin" . | quote }}
+ - name: LITELLM_CORS_ALLOW_CREDENTIALS
+ value: "true"
+ {{- end }}
{{- include "litellm.serverEnv" (dict "root" $ "component" .Values.backend) | nindent 12 }}
+ {{- if .Values.backend.gatewayUrl }}
+ - name: LITELLM_GATEWAY_URL
+ value: {{ if $hasComponentHosts }}{{ include "litellm.backend.gatewayOrigin" . | quote }}{{ else }}{{ .Values.backend.gatewayUrl | quote }}{{ end }}
+ {{- end }}
{{- if .Values.gateway.config.create }}
- name: CONFIG_FILE_PATH
value: /app/config/config.yaml
diff --git a/helm/litellm/templates/gateway/deployment.yaml b/helm/litellm/templates/gateway/deployment.yaml
index 49b452b3053..b0e7f0dcebb 100644
--- a/helm/litellm/templates/gateway/deployment.yaml
+++ b/helm/litellm/templates/gateway/deployment.yaml
@@ -55,6 +55,11 @@ spec:
containerPort: 4000
protocol: TCP
env:
+ {{- include "litellm.lensConnectionEnv" . | nindent 12 }}
+ {{- if .Values.ui.publicUrl }}
+ - name: LITELLM_CORS_ORIGINS
+ value: {{ include "litellm.ui.publicOrigin" . | quote }}
+ {{- end }}
{{- include "litellm.serverEnv" (dict "root" $ "component" .Values.gateway) | nindent 12 }}
{{- if .Values.gateway.config.create }}
- name: CONFIG_FILE_PATH
diff --git a/helm/litellm/templates/ingress.yaml b/helm/litellm/templates/ingress.yaml
index e9f7ed4ec3f..22ff8a95566 100644
--- a/helm/litellm/templates/ingress.yaml
+++ b/helm/litellm/templates/ingress.yaml
@@ -1,3 +1,36 @@
+{{- $componentHosts := .Values.ingress.componentHosts | default dict -}}
+{{- $hasComponentHosts := or $componentHosts.ui $componentHosts.backend $componentHosts.gateway -}}
+{{- if $hasComponentHosts }}
+{{- if not (and $componentHosts.ui $componentHosts.backend $componentHosts.gateway) }}
+{{- fail "ingress.componentHosts.ui, backend, and gateway must all be set together" }}
+{{- end }}
+{{- if not .Values.gateway.enabled }}
+{{- fail "ingress.componentHosts requires gateway.enabled=true" }}
+{{- end }}
+{{- if not .Values.backend.enabled }}
+{{- fail "ingress.componentHosts requires backend.enabled=true" }}
+{{- end }}
+{{- if not .Values.ui.enabled }}
+{{- fail "ingress.componentHosts requires ui.enabled=true" }}
+{{- end }}
+{{- if .Values.ingress.host }}
+{{- fail "ingress.host must be empty when ingress.componentHosts is configured" }}
+{{- end }}
+{{- if .Values.ingress.extraPaths }}
+{{- fail "ingress.extraPaths must be empty when ingress.componentHosts is configured" }}
+{{- end }}
+{{- if not (trim .Values.ui.backendUrl) }}
+{{- fail "ui.backendUrl is required when ingress.componentHosts is configured" }}
+{{- end }}
+{{- $_ := include "litellm.ui.backendOrigin" . }}
+{{- if not (trim .Values.backend.gatewayUrl) }}
+{{- fail "backend.gatewayUrl is required when ingress.componentHosts is configured" }}
+{{- end }}
+{{- $_ := include "litellm.backend.gatewayOrigin" . }}
+{{- if not (trim .Values.ui.publicUrl) }}
+{{- fail "ui.publicUrl is required when ingress.componentHosts is configured" }}
+{{- end }}
+{{- end }}
{{- if .Values.ingress.enabled -}}
{{- $gatewayName := include "litellm.gateway.fullname" . -}}
{{- $backendName := include "litellm.backend.fullname" . -}}
@@ -90,6 +123,66 @@
adding to it.
*/}}
{{- $builtinPathKeys := list "/test|Exact" "/debug/memory/summary|Exact" "/|Prefix" -}}
+{{- if $hasComponentHosts }}
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: {{ include "litellm.fullname" . }}
+ labels:
+ {{- include "litellm.commonLabels" . | nindent 4 }}
+ {{- with .Values.ingress.annotations }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+spec:
+ {{- with .Values.ingress.className }}
+ ingressClassName: {{ . | quote }}
+ {{- end }}
+ {{- with .Values.ingress.tls }}
+ tls:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+ rules:
+ - host: {{ $componentHosts.ui | quote }}
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: {{ $uiName }}
+ port:
+ number: {{ $uiPort }}
+ - host: {{ $componentHosts.backend | quote }}
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: {{ $backendName }}
+ port:
+ number: {{ $backendPort }}
+ - host: {{ $componentHosts.gateway | quote }}
+ http:
+ paths:
+ {{- if or (eq (include "litellm.lens.mode" .) "bundled") (and (eq (include "litellm.lens.mode" .) "external") .Values.lensWorker.externalServiceName) }}
+ - path: /lens-ingest
+ pathType: Prefix
+ backend:
+ service:
+ name: {{ .Values.lensWorker.externalServiceName | default (printf "%s-lens-worker" (include "litellm.fullname" .)) }}
+ port:
+ number: {{ .Values.lensWorker.service.port }}
+ {{- end }}
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: {{ $gatewayName }}
+ port:
+ number: {{ $gatewayPort }}
+{{- else }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
@@ -156,6 +249,16 @@ spec:
port:
number: {{ $gatewayPort }}
{{- end }}
+ {{- if or (eq (include "litellm.lens.mode" .) "bundled") (and (eq (include "litellm.lens.mode" .) "external") .Values.lensWorker.externalServiceName) }}
+ {{- $builtinPathKeys = append $builtinPathKeys "/lens-ingest|Prefix" }}
+ - path: /lens-ingest
+ pathType: Prefix
+ backend:
+ service:
+ name: {{ .Values.lensWorker.externalServiceName | default (printf "%s-lens-worker" (include "litellm.fullname" .)) }}
+ port:
+ number: {{ .Values.lensWorker.service.port }}
+ {{- end }}
{{- /*
--- Operator-supplied extra paths (ingress.extraPaths) ---
Rendered after every built-in path so an entry can never take
@@ -207,3 +310,4 @@ spec:
port:
number: {{ $backendPort }}
{{- end }}
+{{- end }}
diff --git a/helm/litellm/templates/lens/clickhouse.yaml b/helm/litellm/templates/lens/clickhouse.yaml
new file mode 100644
index 00000000000..d9daafda6ba
--- /dev/null
+++ b/helm/litellm/templates/lens/clickhouse.yaml
@@ -0,0 +1,3 @@
+{{- if eq (include "litellm.lens.mode" .) "bundled" }}
+{{ include "litellm.lens.render" (dict "root" . "resource" "clickhouse") }}
+{{- end }}
diff --git a/helm/litellm/templates/lens/deployment.yaml b/helm/litellm/templates/lens/deployment.yaml
index 787581b9ad1..01a6acb1908 100644
--- a/helm/litellm/templates/lens/deployment.yaml
+++ b/helm/litellm/templates/lens/deployment.yaml
@@ -1,72 +1,3 @@
-{{- if .Values.lensWorker.enabled }}
-apiVersion: apps/v1
-kind: Deployment
-metadata:
- name: {{ include "litellm.fullname" . }}-lens-worker
- labels:
- {{- include "litellm.commonLabels" . | nindent 4 }}
- app.kubernetes.io/component: lens-worker
-spec:
- replicas: {{ .Values.lensWorker.replicaCount }}
- selector:
- matchLabels:
- app.kubernetes.io/instance: {{ .Release.Name }}
- app.kubernetes.io/component: lens-worker
- template:
- metadata:
- labels:
- {{- include "litellm.commonLabels" . | nindent 8 }}
- app.kubernetes.io/component: lens-worker
- spec:
- automountServiceAccountToken: false
- {{- with .Values.imagePullSecrets }}
- imagePullSecrets:
- {{- toYaml . | nindent 8 }}
- {{- end }}
- securityContext:
- runAsNonRoot: true
- runAsUser: 65532
- runAsGroup: 65532
- fsGroup: 65532
- seccompProfile:
- type: RuntimeDefault
- containers:
- - name: lens-worker
- image: {{ include "litellm.lensWorker.image" . | quote }}
- imagePullPolicy: {{ .Values.lensWorker.image.pullPolicy }}
- securityContext:
- allowPrivilegeEscalation: false
- readOnlyRootFilesystem: true
- capabilities:
- drop: [ALL]
- env:
- - name: LITELLM_URL
- value: {{ .Values.lensWorker.url | default (printf "http://%s:%v" (include "litellm.backend.fullname" .) .Values.backend.service.port) | quote }}
- - name: LENS_WORKER_TOKEN
- valueFrom:
- secretKeyRef:
- name: {{ required "lensWorker.tokenSecret.name must reference a Lens worker token" .Values.lensWorker.tokenSecret.name | quote }}
- key: {{ .Values.lensWorker.tokenSecret.key | quote }}
- resources:
- {{- toYaml .Values.lensWorker.resources | nindent 12 }}
- volumeMounts:
- - name: tmp
- mountPath: /tmp
- volumes:
- - name: tmp
- emptyDir:
- medium: Memory
- sizeLimit: {{ .Values.lensWorker.tmpSizeLimit }}
- {{- with .Values.lensWorker.nodeSelector }}
- nodeSelector:
- {{- toYaml . | nindent 8 }}
- {{- end }}
- {{- with .Values.lensWorker.tolerations }}
- tolerations:
- {{- toYaml . | nindent 8 }}
- {{- end }}
- {{- with .Values.lensWorker.affinity }}
- affinity:
- {{- toYaml . | nindent 8 }}
- {{- end }}
+{{- if eq (include "litellm.lens.mode" .) "bundled" }}
+{{ include "litellm.lens.render" (dict "root" . "resource" "deployment") }}
{{- end }}
diff --git a/helm/litellm/templates/lens/ingress.yaml b/helm/litellm/templates/lens/ingress.yaml
new file mode 100644
index 00000000000..b5994b22d92
--- /dev/null
+++ b/helm/litellm/templates/lens/ingress.yaml
@@ -0,0 +1,3 @@
+{{- if eq (include "litellm.lens.mode" .) "bundled" }}
+{{ include "litellm.lens.render" (dict "root" . "resource" "ingress") }}
+{{- end }}
diff --git a/helm/litellm/templates/lens/secrets.yaml b/helm/litellm/templates/lens/secrets.yaml
new file mode 100644
index 00000000000..76928c26e5f
--- /dev/null
+++ b/helm/litellm/templates/lens/secrets.yaml
@@ -0,0 +1,3 @@
+{{- if eq (include "litellm.lens.mode" .) "bundled" }}
+{{ include "litellm.lens.render" (dict "root" . "resource" "secrets") }}
+{{- end }}
diff --git a/helm/litellm/templates/lens/service.yaml b/helm/litellm/templates/lens/service.yaml
new file mode 100644
index 00000000000..60c7e995134
--- /dev/null
+++ b/helm/litellm/templates/lens/service.yaml
@@ -0,0 +1,3 @@
+{{- if eq (include "litellm.lens.mode" .) "bundled" }}
+{{ include "litellm.lens.render" (dict "root" . "resource" "service") }}
+{{- end }}
diff --git a/helm/litellm/templates/ui/deployment.yaml b/helm/litellm/templates/ui/deployment.yaml
index efee2d5fc34..394eea17451 100644
--- a/helm/litellm/templates/ui/deployment.yaml
+++ b/helm/litellm/templates/ui/deployment.yaml
@@ -1,3 +1,5 @@
+{{- $componentHosts := .Values.ingress.componentHosts | default dict -}}
+{{- $hasComponentHosts := or $componentHosts.ui $componentHosts.backend $componentHosts.gateway -}}
{{- if .Values.ui.enabled }}
apiVersion: apps/v1
kind: Deployment
@@ -56,9 +58,9 @@ spec:
- name: LITELLM_LOG
value: {{ .Values.ui.logLevel | quote }}
{{- end }}
- {{- if .Values.ui.backendUrl }}
+ {{- if and .Values.ui.backendUrl $hasComponentHosts }}
- name: LITELLM_BACKEND_URL
- value: {{ .Values.ui.backendUrl | quote }}
+ value: {{ include "litellm.ui.backendOrigin" . | quote }}
{{- end }}
{{- with .Values.ui.extraEnv }}
{{- toYaml . | nindent 12 }}
diff --git a/helm/litellm/tests/component_hosts_tests.yaml b/helm/litellm/tests/component_hosts_tests.yaml
new file mode 100644
index 00000000000..f9c9ce6fad2
--- /dev/null
+++ b/helm/litellm/tests/component_hosts_tests.yaml
@@ -0,0 +1,441 @@
+suite: ingress component hosts
+templates:
+ - ingress.yaml
+ - backend/deployment.yaml
+ - gateway/deployment.yaml
+ - gateway/configmap.yaml
+ - ui/deployment.yaml
+values:
+ - ./values/required.yaml
+tests:
+ - it: routes each hostname to its component and injects the external URLs
+ template: ingress.yaml
+ set:
+ ingress.enabled: true
+ ingress.componentHosts.ui: ui.example.com
+ ingress.componentHosts.backend: backend.example.com
+ ingress.componentHosts.gateway: gateway.example.com
+ lensWorker.mode: disabled
+ ingress.tls:
+ - hosts:
+ - ui.example.com
+ - backend.example.com
+ - gateway.example.com
+ secretName: component-hosts
+ ui.backendUrl: https://backend.example.com
+ ui.publicUrl: https://ui.example.com
+ backend.gatewayUrl: https://gateway.example.com
+ asserts:
+ - equal:
+ path: spec.rules[0].host
+ value: ui.example.com
+ - equal:
+ path: spec.tls[0]
+ value:
+ hosts:
+ - ui.example.com
+ - backend.example.com
+ - gateway.example.com
+ secretName: component-hosts
+ - equal:
+ path: spec.rules[0].http.paths
+ value:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: RELEASE-NAME-litellm-ui
+ port:
+ number: 3000
+ - equal:
+ path: spec.rules[1].host
+ value: backend.example.com
+ - equal:
+ path: spec.rules[1].http.paths
+ value:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: RELEASE-NAME-litellm-backend
+ port:
+ number: 4001
+ - equal:
+ path: spec.rules[2].host
+ value: gateway.example.com
+ - equal:
+ path: spec.rules[2].http.paths
+ value:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: RELEASE-NAME-litellm-gateway
+ port:
+ number: 4000
+
+ - it: keeps Lens ingestion ahead of the gateway catch-all
+ set:
+ ingress.enabled: true
+ ingress.componentHosts.ui: ui.example.com
+ ingress.componentHosts.backend: backend.example.com
+ ingress.componentHosts.gateway: gateway.example.com
+ ui.backendUrl: https://backend.example.com
+ ui.publicUrl: https://ui.example.com
+ backend.gatewayUrl: https://gateway.example.com
+ lensWorker.mode: external
+ lensWorker.externalServiceName: existing-lens
+ lensWorker.externalUrl: https://lens.example.com
+ lensWorker.publicUrl: https://lens.example.com
+ lensWorker.serviceTokenSecret.name: existing-lens-signing
+ lensWorker.gateway.secretName: existing-lens-signing
+ asserts:
+ - equal:
+ path: spec.rules[2].http.paths[0].path
+ value: /lens-ingest
+ - equal:
+ path: spec.rules[2].http.paths[0].backend.service.name
+ value: existing-lens
+ - equal:
+ path: spec.rules[2].http.paths[1].path
+ value: /
+ template: ingress.yaml
+
+ - it: rejects a partial component host configuration
+ set:
+ ingress.enabled: true
+ ingress.componentHosts.ui: ui.example.com
+ asserts:
+ - failedTemplate:
+ errorMessage: ingress.componentHosts.ui, backend, and gateway must all be set together
+ template: ingress.yaml
+
+ - it: rejects component hosts when the gateway is disabled
+ set:
+ ingress.enabled: true
+ ingress.componentHosts.ui: ui.example.com
+ ingress.componentHosts.backend: backend.example.com
+ ingress.componentHosts.gateway: gateway.example.com
+ ui.backendUrl: https://backend.example.com
+ ui.publicUrl: https://ui.example.com
+ backend.gatewayUrl: https://gateway.example.com
+ gateway.enabled: false
+ asserts:
+ - failedTemplate:
+ errorMessage: ingress.componentHosts requires gateway.enabled=true
+ template: ingress.yaml
+
+ - it: rejects component hosts when the backend is disabled
+ set:
+ ingress.enabled: true
+ ingress.componentHosts.ui: ui.example.com
+ ingress.componentHosts.backend: backend.example.com
+ ingress.componentHosts.gateway: gateway.example.com
+ ui.backendUrl: https://backend.example.com
+ ui.publicUrl: https://ui.example.com
+ backend.gatewayUrl: https://gateway.example.com
+ backend.enabled: false
+ asserts:
+ - failedTemplate:
+ errorMessage: ingress.componentHosts requires backend.enabled=true
+ template: ingress.yaml
+
+ - it: rejects component hosts when the UI is disabled
+ set:
+ ingress.enabled: true
+ ingress.componentHosts.ui: ui.example.com
+ ingress.componentHosts.backend: backend.example.com
+ ingress.componentHosts.gateway: gateway.example.com
+ ui.backendUrl: https://backend.example.com
+ ui.publicUrl: https://ui.example.com
+ backend.gatewayUrl: https://gateway.example.com
+ ui.enabled: false
+ asserts:
+ - failedTemplate:
+ errorMessage: ingress.componentHosts requires ui.enabled=true
+ template: ingress.yaml
+
+ - it: rejects a legacy host alongside component hosts
+ set:
+ ingress.enabled: true
+ ingress.host: legacy.example.com
+ ingress.componentHosts.ui: ui.example.com
+ ingress.componentHosts.backend: backend.example.com
+ ingress.componentHosts.gateway: gateway.example.com
+ asserts:
+ - failedTemplate:
+ errorMessage: ingress.host must be empty when ingress.componentHosts is configured
+ template: ingress.yaml
+
+ - it: rejects extra paths alongside component hosts
+ set:
+ ingress.enabled: true
+ ingress.componentHosts.ui: ui.example.com
+ ingress.componentHosts.backend: backend.example.com
+ ingress.componentHosts.gateway: gateway.example.com
+ ingress.extraPaths:
+ - path: /custom
+ asserts:
+ - failedTemplate:
+ errorMessage: ingress.extraPaths must be empty when ingress.componentHosts is configured
+ template: ingress.yaml
+
+ - it: requires the UI backend URL in component host mode
+ set:
+ ingress.enabled: true
+ ingress.componentHosts.ui: ui.example.com
+ ingress.componentHosts.backend: backend.example.com
+ ingress.componentHosts.gateway: gateway.example.com
+ ui.publicUrl: https://ui.example.com
+ backend.gatewayUrl: https://gateway.example.com
+ asserts:
+ - failedTemplate:
+ errorMessage: ui.backendUrl is required when ingress.componentHosts is configured
+ template: ingress.yaml
+
+ - it: requires the backend gateway URL in component host mode
+ set:
+ ingress.enabled: true
+ ingress.componentHosts.ui: ui.example.com
+ ingress.componentHosts.backend: backend.example.com
+ ingress.componentHosts.gateway: gateway.example.com
+ ui.backendUrl: https://backend.example.com
+ asserts:
+ - failedTemplate:
+ errorMessage: backend.gatewayUrl is required when ingress.componentHosts is configured
+ template: ingress.yaml
+
+ - it: requires the public UI origin in component host mode
+ set:
+ ingress.enabled: true
+ ingress.componentHosts.ui: ui.example.com
+ ingress.componentHosts.backend: backend.example.com
+ ingress.componentHosts.gateway: gateway.example.com
+ ui.backendUrl: https://backend.example.com
+ backend.gatewayUrl: https://gateway.example.com
+ asserts:
+ - failedTemplate:
+ errorMessage: ui.publicUrl is required when ingress.componentHosts is configured
+ template: ingress.yaml
+
+ - it: exposes the gateway URL in the backend Deployment
+ set:
+ ingress.enabled: true
+ ingress.componentHosts.ui: ui.example.com
+ ingress.componentHosts.backend: backend.example.com
+ ingress.componentHosts.gateway: gateway.example.com
+ ui.backendUrl: https://backend.example.com
+ ui.publicUrl: https://ui.example.com
+ backend.gatewayUrl: " https://gateway.example.com/ "
+ template: backend/deployment.yaml
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_GATEWAY_URL
+ value: https://gateway.example.com
+
+ - it: preserves the backend gateway URL verbatim in single-host mode
+ set:
+ ingress.enabled: true
+ ingress.host: litellm.example.com
+ backend.gatewayUrl: " https://gateway.example.com/api/ "
+ template: backend/deployment.yaml
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_GATEWAY_URL
+ value: " https://gateway.example.com/api/ "
+
+ - it: exposes the backend URL in the UI Deployment
+ set:
+ ingress.enabled: true
+ ingress.componentHosts.ui: ui.example.com
+ ingress.componentHosts.backend: backend.example.com
+ ingress.componentHosts.gateway: gateway.example.com
+ ui.backendUrl: " https://backend.example.com/ "
+ ui.publicUrl: https://ui.example.com
+ backend.gatewayUrl: https://gateway.example.com
+ template: ui/deployment.yaml
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_BACKEND_URL
+ value: https://backend.example.com
+
+ - it: omits the backend URL from the UI Deployment in single-host mode
+ set:
+ ingress.enabled: true
+ ingress.host: litellm.example.com
+ ui.backendUrl: https://litellm.example.com/api
+ template: ui/deployment.yaml
+ asserts:
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_BACKEND_URL
+ any: true
+
+ - it: enables credentialed CORS from the public UI origin on the backend
+ set:
+ ui.publicUrl: https://ui.example.com
+ template: backend/deployment.yaml
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_CORS_ORIGINS
+ value: https://ui.example.com
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_CORS_ALLOW_CREDENTIALS
+ value: "true"
+
+ - it: lets backend extraEnv override generated CORS values
+ set:
+ ui.publicUrl: https://ui.example.com
+ backend.extraEnv:
+ - name: LITELLM_CORS_ORIGINS
+ value: https://backend-override.example.com
+ - name: LITELLM_CORS_ALLOW_CREDENTIALS
+ value: "false"
+ template: backend/deployment.yaml
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].env[0].name
+ value: LITELLM_CORS_ORIGINS
+ - equal:
+ path: spec.template.spec.containers[0].env[0].value
+ value: https://ui.example.com
+ - equal:
+ path: spec.template.spec.containers[0].env[1].name
+ value: LITELLM_CORS_ALLOW_CREDENTIALS
+ - equal:
+ path: spec.template.spec.containers[0].env[1].value
+ value: "true"
+ - equal:
+ path: spec.template.spec.containers[0].env[10].name
+ value: LITELLM_CORS_ORIGINS
+ - equal:
+ path: spec.template.spec.containers[0].env[10].value
+ value: https://backend-override.example.com
+ - equal:
+ path: spec.template.spec.containers[0].env[11].name
+ value: LITELLM_CORS_ALLOW_CREDENTIALS
+ - equal:
+ path: spec.template.spec.containers[0].env[11].value
+ value: "false"
+
+ - it: exposes the public UI origin to the gateway without enabling credentials
+ set:
+ ui.publicUrl: https://ui.example.com
+ template: gateway/deployment.yaml
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_CORS_ORIGINS
+ value: https://ui.example.com
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_CORS_ALLOW_CREDENTIALS
+ value: "true"
+
+ - it: trims the public UI origin before setting backend CORS
+ set:
+ ui.publicUrl: " https://ui.example.com/ "
+ template: backend/deployment.yaml
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_CORS_ORIGINS
+ value: https://ui.example.com
+
+ - it: trims the public UI origin before setting gateway CORS
+ set:
+ ui.publicUrl: " https://ui.example.com/ "
+ template: gateway/deployment.yaml
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_CORS_ORIGINS
+ value: https://ui.example.com
+
+ - it: rejects a UI public URL with a path
+ set:
+ ui.publicUrl: https://ui.example.com/app
+ template: gateway/deployment.yaml
+ asserts:
+ - failedTemplate:
+ errorMessage: ui.publicUrl must be an origin like https://ui.example.com, without a path
+
+ - it: lets gateway extraEnv override the generated CORS origin
+ set:
+ ui.publicUrl: https://ui.example.com
+ gateway.extraEnv:
+ - name: LITELLM_CORS_ORIGINS
+ value: https://gateway-override.example.com
+ template: gateway/deployment.yaml
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].env[0].name
+ value: LITELLM_CORS_ORIGINS
+ - equal:
+ path: spec.template.spec.containers[0].env[0].value
+ value: https://ui.example.com
+ - equal:
+ path: spec.template.spec.containers[0].env[9].name
+ value: LITELLM_CORS_ORIGINS
+ - equal:
+ path: spec.template.spec.containers[0].env[9].value
+ value: https://gateway-override.example.com
+
+---
+suite: component host UI backend origin validation
+templates:
+ - ingress.yaml
+values:
+ - ./values/required.yaml
+tests:
+ - it: rejects a pathful UI backend URL in component host mode
+ set:
+ ingress.enabled: true
+ ingress.componentHosts.ui: ui.example.com
+ ingress.componentHosts.backend: backend.example.com
+ ingress.componentHosts.gateway: gateway.example.com
+ ui.backendUrl: https://backend.example.com/api
+ ui.publicUrl: https://ui.example.com
+ backend.gatewayUrl: https://gateway.example.com
+ asserts:
+ - failedTemplate:
+ errorMessage: ui.backendUrl must be an origin like https://backend.example.com, without a path, when ingress.componentHosts is set
+ - it: rejects a pathful backend gateway URL in component host mode
+ set:
+ ingress.enabled: true
+ ingress.componentHosts.ui: ui.example.com
+ ingress.componentHosts.backend: backend.example.com
+ ingress.componentHosts.gateway: gateway.example.com
+ ui.backendUrl: https://backend.example.com
+ ui.publicUrl: https://ui.example.com
+ backend.gatewayUrl: https://gateway.example.com/v1
+ asserts:
+ - failedTemplate:
+ errorMessage: backend.gatewayUrl must be an origin like https://gateway.example.com, without a path, when ingress.componentHosts is set
+ - it: rejects a backend gateway URL without a scheme in component host mode
+ set:
+ ingress.enabled: true
+ ingress.componentHosts.ui: ui.example.com
+ ingress.componentHosts.backend: backend.example.com
+ ingress.componentHosts.gateway: gateway.example.com
+ ui.backendUrl: https://backend.example.com
+ ui.publicUrl: https://ui.example.com
+ backend.gatewayUrl: gateway.example.com
+ asserts:
+ - failedTemplate:
+ errorMessage: backend.gatewayUrl must be an origin like https://gateway.example.com, without a path, when ingress.componentHosts is set
diff --git a/helm/litellm/tests/database_auth_tests.yaml b/helm/litellm/tests/database_auth_tests.yaml
index add531f68bb..bc6653de700 100644
--- a/helm/litellm/tests/database_auth_tests.yaml
+++ b/helm/litellm/tests/database_auth_tests.yaml
@@ -191,3 +191,400 @@ tests:
content:
name: DATABASE_PASSWORD_READ_REPLICA
any: true
+
+ - it: writer hostKey reads the host from the Secret and supports IAM auth
+ template: gateway/deployment.yaml
+ set:
+ database.writer.host: ""
+ database.writer.passwordSecret.hostKey: hostname
+ database.writer.useIAMAuth: true
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_HOST
+ valueFrom:
+ secretKeyRef:
+ name: litellm-writer-secret
+ key: hostname
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: IAM_TOKEN_DB_AUTH
+ value: "true"
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_PASSWORD
+ any: true
+
+ - it: writer urlKey supplies only DATABASE_URL to each database client container and keeps pool env
+ set:
+ database.writer.host: ""
+ database.writer.dbname: ""
+ database.writer.schema: ""
+ database.writer.sslMode: ""
+ database.writer.sslRootCert: ""
+ database.writer.passwordSecret.urlKey: connection-url
+ database.connectionPool.enabled: true
+ gateway.collector.enabled: true
+ gateway.metricsServer.enabled: true
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_URL
+ valueFrom:
+ secretKeyRef:
+ name: litellm-writer-secret
+ key: connection-url
+ template: gateway/deployment.yaml
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_HOST
+ any: true
+ template: gateway/deployment.yaml
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_PORT
+ any: true
+ template: gateway/deployment.yaml
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_USER
+ any: true
+ template: gateway/deployment.yaml
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_NAME
+ any: true
+ template: gateway/deployment.yaml
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_SCHEMA
+ any: true
+ template: gateway/deployment.yaml
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_SSLMODE
+ any: true
+ template: gateway/deployment.yaml
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_SSLROOTCERT
+ any: true
+ template: gateway/deployment.yaml
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_PASSWORD
+ any: true
+ template: gateway/deployment.yaml
+ - notExists:
+ path: >-
+ spec.template.spec.containers[0].env[?(@.name == "DATABASE_HOST" ||
+ @.name == "DATABASE_PORT" || @.name == "DATABASE_USER" ||
+ @.name == "DATABASE_NAME" || @.name == "DATABASE_SCHEMA" ||
+ @.name == "DATABASE_SSLMODE" || @.name == "DATABASE_SSLROOTCERT" ||
+ @.name == "DATABASE_PASSWORD")]
+ template: gateway/deployment.yaml
+ - notExists:
+ path: >-
+ spec.template.spec.containers[2].env[?(@.name == "DATABASE_HOST" ||
+ @.name == "DATABASE_PORT" || @.name == "DATABASE_USER" ||
+ @.name == "DATABASE_NAME" || @.name == "DATABASE_SCHEMA" ||
+ @.name == "DATABASE_SSLMODE" || @.name == "DATABASE_SSLROOTCERT" ||
+ @.name == "DATABASE_PASSWORD")]
+ template: gateway/deployment.yaml
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_PGBOUNCER_ENABLED
+ value: "true"
+ template: gateway/deployment.yaml
+ - contains:
+ path: spec.template.spec.containers[2].env
+ content:
+ name: DATABASE_URL
+ valueFrom:
+ secretKeyRef:
+ name: litellm-writer-secret
+ key: connection-url
+ template: gateway/deployment.yaml
+ - notContains:
+ path: spec.template.spec.containers[2].env
+ content:
+ name: DATABASE_USER
+ any: true
+ template: gateway/deployment.yaml
+ - notContains:
+ path: spec.template.spec.containers[2].env
+ content:
+ name: DATABASE_PASSWORD
+ any: true
+ template: gateway/deployment.yaml
+ - contains:
+ path: spec.template.spec.containers[2].env
+ content:
+ name: DATABASE_URL
+ valueFrom:
+ secretKeyRef:
+ name: litellm-writer-secret
+ key: connection-url
+ template: gateway/deployment.yaml
+ - contains:
+ path: spec.template.spec.containers[2].env
+ content:
+ name: LITELLM_PGBOUNCER_ENABLED
+ value: "true"
+ template: gateway/deployment.yaml
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_URL
+ valueFrom:
+ secretKeyRef:
+ name: litellm-writer-secret
+ key: connection-url
+ template: backend/deployment.yaml
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_USER
+ any: true
+ template: backend/deployment.yaml
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_PASSWORD
+ any: true
+ template: backend/deployment.yaml
+ - notExists:
+ path: >-
+ spec.template.spec.containers[0].env[?(@.name == "DATABASE_HOST" ||
+ @.name == "DATABASE_PORT" || @.name == "DATABASE_USER" ||
+ @.name == "DATABASE_NAME" || @.name == "DATABASE_SCHEMA" ||
+ @.name == "DATABASE_SSLMODE" || @.name == "DATABASE_SSLROOTCERT" ||
+ @.name == "DATABASE_PASSWORD")]
+ template: backend/deployment.yaml
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_URL
+ valueFrom:
+ secretKeyRef:
+ name: litellm-writer-secret
+ key: connection-url
+ template: migrations-job.yaml
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_USER
+ any: true
+ template: migrations-job.yaml
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_PASSWORD
+ any: true
+ template: migrations-job.yaml
+ - notExists:
+ path: >-
+ spec.template.spec.containers[0].env[?(@.name == "DATABASE_HOST" ||
+ @.name == "DATABASE_PORT" || @.name == "DATABASE_USER" ||
+ @.name == "DATABASE_NAME" || @.name == "DATABASE_SCHEMA" ||
+ @.name == "DATABASE_SSLMODE" || @.name == "DATABASE_SSLROOTCERT" ||
+ @.name == "DATABASE_PASSWORD")]
+ template: migrations-job.yaml
+
+ - it: writer rejects host together with hostKey
+ template: gateway/deployment.yaml
+ set:
+ database.writer.passwordSecret.hostKey: hostname
+ asserts:
+ - failedTemplate:
+ errorMessage: "database.writer.host and database.writer.passwordSecret.hostKey cannot both be set"
+
+ - it: writer rejects urlKey with discrete connection settings
+ template: gateway/deployment.yaml
+ set:
+ database.writer.host: ""
+ database.writer.passwordSecret.urlKey: connection-url
+ database.writer.schema: public
+ database.writer.sslMode: require
+ database.writer.sslRootCert: /etc/ssl/ca.crt
+ asserts:
+ - failedTemplate:
+ errorMessage: "database.writer.passwordSecret.urlKey cannot be combined with host, hostKey, dbname, schema, sslMode, sslRootCert, useIAMAuth, or useAzureEntraAuth"
+
+ - it: writer rejects urlKey with IAM auth
+ template: gateway/deployment.yaml
+ set:
+ database.writer.host: ""
+ database.writer.dbname: ""
+ database.writer.passwordSecret.urlKey: connection-url
+ database.writer.useIAMAuth: true
+ asserts:
+ - failedTemplate:
+ errorMessage: "database.writer.passwordSecret.urlKey cannot be combined with host, hostKey, dbname, schema, sslMode, sslRootCert, useIAMAuth, or useAzureEntraAuth"
+
+ - it: writer rejects urlKey with Entra auth
+ template: gateway/deployment.yaml
+ set:
+ database.writer.host: ""
+ database.writer.dbname: ""
+ database.writer.passwordSecret.urlKey: connection-url
+ database.writer.useAzureEntraAuth: true
+ asserts:
+ - failedTemplate:
+ errorMessage: "database.writer.passwordSecret.urlKey cannot be combined with host, hostKey, dbname, schema, sslMode, sslRootCert, useIAMAuth, or useAzureEntraAuth"
+
+ - it: writer requires an endpoint
+ template: gateway/deployment.yaml
+ set:
+ database.writer.host: ""
+ asserts:
+ - failedTemplate:
+ errorMessage: "one of database.writer.host, database.writer.passwordSecret.hostKey, or database.writer.passwordSecret.urlKey is required"
+
+ - it: writer hostKey requires a Secret name
+ template: gateway/deployment.yaml
+ set:
+ database.writer.host: ""
+ database.writer.passwordSecret.hostKey: hostname
+ database.writer.passwordSecret.name: ""
+ asserts:
+ - failedTemplate:
+ errorMessage: "database.writer.passwordSecret.name is required when hostKey or urlKey is set"
+
+ - it: reader renders without replica env vars when passwordSecret is null
+ template: gateway/deployment.yaml
+ set:
+ database.reader.passwordSecret: null
+ asserts:
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_HOST_READ_REPLICA
+ any: true
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_URL_READ_REPLICA
+ any: true
+
+ - it: reader hostKey enables replica routing and reads its host from the Secret
+ template: gateway/deployment.yaml
+ set:
+ database.writer.useIAMAuth: true
+ database.reader.passwordSecret.hostKey: hostname
+ database.reader.dbname: litellm
+ database.reader.useIAMAuth: true
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_HOST_READ_REPLICA
+ valueFrom:
+ secretKeyRef:
+ name: litellm-reader-secret
+ key: hostname
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_NAME_READ_REPLICA
+ value: litellm
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: IAM_TOKEN_DB_AUTH
+ value: "true"
+
+ - it: reader urlKey emits only the replica URL
+ template: gateway/deployment.yaml
+ set:
+ database.reader.passwordSecret.urlKey: connection-url
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_URL_READ_REPLICA
+ valueFrom:
+ secretKeyRef:
+ name: litellm-reader-secret
+ key: connection-url
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_HOST_READ_REPLICA
+ any: true
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_PORT_READ_REPLICA
+ any: true
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_USER_READ_REPLICA
+ any: true
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_PASSWORD_READ_REPLICA
+ any: true
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_NAME_READ_REPLICA
+ any: true
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DATABASE_SCHEMA_READ_REPLICA
+ any: true
+
+ - it: reader rejects host together with hostKey
+ template: gateway/deployment.yaml
+ set:
+ database.reader.host: reader.example.com
+ database.reader.passwordSecret.hostKey: hostname
+ asserts:
+ - failedTemplate:
+ errorMessage: "database.reader.host and database.reader.passwordSecret.hostKey cannot both be set"
+
+ - it: reader rejects urlKey with discrete connection settings
+ template: gateway/deployment.yaml
+ set:
+ database.reader.passwordSecret.urlKey: connection-url
+ database.reader.dbname: litellm
+ database.reader.schema: public
+ asserts:
+ - failedTemplate:
+ errorMessage: "database.reader.passwordSecret.urlKey cannot be combined with host, hostKey, dbname, schema, useIAMAuth, or useAzureEntraAuth"
+
+ - it: reader rejects urlKey with token auth
+ template: gateway/deployment.yaml
+ set:
+ database.writer.useIAMAuth: true
+ database.reader.passwordSecret.urlKey: connection-url
+ database.reader.useIAMAuth: true
+ asserts:
+ - failedTemplate:
+ errorMessage: "database.reader.passwordSecret.urlKey cannot be combined with host, hostKey, dbname, schema, useIAMAuth, or useAzureEntraAuth"
+
+ - it: reader urlKey requires a Secret name
+ template: gateway/deployment.yaml
+ set:
+ database.reader.passwordSecret.urlKey: connection-url
+ database.reader.passwordSecret.name: ""
+ asserts:
+ - failedTemplate:
+ errorMessage: "database.reader.passwordSecret.name is required when hostKey or urlKey is set"
diff --git a/helm/litellm/tests/lens_endpoint_defaults_tests.yaml b/helm/litellm/tests/lens_endpoint_defaults_tests.yaml
new file mode 100644
index 00000000000..2d98dcf471e
--- /dev/null
+++ b/helm/litellm/tests/lens_endpoint_defaults_tests.yaml
@@ -0,0 +1,86 @@
+suite: Lens endpoint defaults
+templates:
+ - gateway/deployment.yaml
+ - gateway/configmap.yaml
+set:
+ lensWorker.enabled: true
+ ingress.enabled: true
+ ingress.host: gateway.example
+ ingress.tls:
+ - hosts: [gateway.example]
+ secretName: tls
+tests:
+ - it: derives the trace endpoint from the deployment hostname and TLS
+ template: gateway/deployment.yaml
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_PUBLIC_URL
+ value: https://gateway.example/lens-ingest
+ - it: preserves an explicitly configured public address
+ set:
+ lensWorker.publicUrl: https://custom.example/prefix
+ template: gateway/deployment.yaml
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_PUBLIC_URL
+ value: https://custom.example/prefix
+ - it: uses the dedicated Lens ingress when configured
+ set:
+ lensWorker.ingress.enabled: true
+ lensWorker.ingress.host: traces.example
+ lensWorker.ingress.tls:
+ - hosts: [traces.example]
+ secretName: traces-tls
+ template: gateway/deployment.yaml
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_PUBLIC_URL
+ value: https://traces.example
+ - it: uses HTTPS for a dedicated ALB ingress with certificate annotations
+ set:
+ lensWorker.ingress.enabled: true
+ lensWorker.ingress.host: traces.example
+ lensWorker.ingress.className: alb
+ lensWorker.ingress.tls: []
+ lensWorker.ingress.annotations:
+ alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-west-2:123456789012:certificate/test
+ alb.ingress.kubernetes.io/listen-ports: '[{"HTTP":80},{"HTTPS":443}]'
+ alb.ingress.kubernetes.io/ssl-redirect: "443"
+ template: gateway/deployment.yaml
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_PUBLIC_URL
+ value: https://traces.example
+ - it: uses HTTP for a dedicated ingress without its own TLS
+ set:
+ lensWorker.ingress.enabled: true
+ lensWorker.ingress.host: traces.example
+ lensWorker.ingress.tls: []
+ lensWorker.ingress.annotations: {}
+ template: gateway/deployment.yaml
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_PUBLIC_URL
+ value: http://traces.example
+ - it: uses HTTP when ingress has no TLS
+ set:
+ ingress.tls: []
+ template: gateway/deployment.yaml
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_PUBLIC_URL
+ value: http://gateway.example/lens-ingest
+values:
+ - ./values/required.yaml
diff --git a/helm/litellm/tests/lens_modes_tests.yaml b/helm/litellm/tests/lens_modes_tests.yaml
new file mode 100644
index 00000000000..3166a88e094
--- /dev/null
+++ b/helm/litellm/tests/lens_modes_tests.yaml
@@ -0,0 +1,208 @@
+---
+suite: Lens deployment modes
+templates:
+- gateway/configmap.yaml
+- backend/deployment.yaml
+- lens/deployment.yaml
+- lens/service.yaml
+- lens/clickhouse.yaml
+- lens/secrets.yaml
+- ingress.yaml
+values:
+- "./values/required.yaml"
+set:
+ lensWorker.publicUrl: https://traces.example
+tests:
+- it: retains the existing deployment before transferring its release ownership
+ template: lens/deployment.yaml
+ set:
+ lensWorker.mode: bundled
+ lensWorker.retainResources: true
+ asserts:
+ - equal:
+ path: metadata.annotations["helm.sh/resource-policy"]
+ value: keep
+- it: keeps the ingestion endpoint on an independently deployed Lens service
+ template: ingress.yaml
+ set:
+ ingress.enabled: true
+ lensWorker.mode: external
+ lensWorker.externalUrl: http://existing-lens:4318
+ lensWorker.externalServiceName: existing-lens
+ lensWorker.serviceTokenSecret.name: external-lens-signing
+ lensWorker.gateway.secretName: external-lens-signing
+ asserts:
+ - contains:
+ path: spec.rules[0].http.paths
+ content:
+ path: /lens-ingest
+ pathType: Prefix
+ backend:
+ service:
+ name: existing-lens
+ port: {number: 4318}
+- it: connects an external deployment without installing Lens resources
+ templates:
+ - lens/deployment.yaml
+ - lens/service.yaml
+ - lens/clickhouse.yaml
+ - lens/secrets.yaml
+ set:
+ lensWorker.mode: external
+ lensWorker.externalUrl: http://lens.other-namespace:4318
+ lensWorker.serviceTokenSecret.name: external-lens-signing
+ lensWorker.gateway.secretName: external-lens-signing
+ asserts:
+ - hasDocuments:
+ count: 0
+- it: sends delegated requests to the explicitly selected external Lens
+ template: backend/deployment.yaml
+ set:
+ lensWorker.mode: external
+ lensWorker.externalUrl: http://lens.other-namespace:4318
+ lensWorker.serviceTokenSecret.name: external-lens-signing
+ lensWorker.gateway.secretName: external-lens-signing
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_URL
+ value: http://lens.other-namespace:4318
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LENS_GATEWAY_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: external-lens-signing
+ key: gateway-secret
+- it: disables Lens explicitly even with a retained legacy enable flag
+ template: lens/deployment.yaml
+ set:
+ lensWorker.mode: disabled
+ lensWorker.enabled: true
+ asserts:
+ - hasDocuments:
+ count: 0
+- it: bundles Lens through explicit mode selection
+ template: lens/deployment.yaml
+ set:
+ lensWorker.mode: bundled
+ asserts:
+ - hasDocuments:
+ count: 1
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LENS_MODE
+ value: standalone
+- it: uses separate credentials for external identity and service access
+ template: backend/deployment.yaml
+ set:
+ lensWorker.mode: external
+ lensWorker.externalUrl: http://existing-lens:4318
+ lensWorker.serviceTokenSecret: {name: worker-service, key: token}
+ lensWorker.gateway: {secretName: delegated-identity, secretKey: signature}
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LENS_GATEWAY_SECRET
+ valueFrom:
+ secretKeyRef: {name: delegated-identity, key: signature}
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_SERVICE_TOKEN
+ valueFrom:
+ secretKeyRef: {name: worker-service, key: token}
+- it: supplies the same separate signing secret to the bundled service
+ template: lens/deployment.yaml
+ set:
+ lensWorker.mode: bundled
+ lensWorker.gateway: {secretName: delegated-identity, secretKey: signature}
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LENS_GATEWAY_SECRET
+ valueFrom:
+ secretKeyRef: {name: delegated-identity, key: signature}
+- it: routes explicit bundled mode with the enable flag off
+ template: ingress.yaml
+ set:
+ fullnameOverride: gateway
+ ingress.enabled: true
+ ingress.host: gateway.example
+ lensWorker.mode: bundled
+ lensWorker.enabled: false
+ lensWorker.externalUrl: http://existing-lens:4318
+ lensWorker.externalServiceName: ""
+ lensWorker.serviceTokenSecret.name: worker-service
+ lensWorker.gateway.secretName: external-lens-signing
+ asserts:
+ - contains:
+ path: spec.rules[0].http.paths
+ content:
+ path: /lens-ingest
+ pathType: Prefix
+ backend:
+ service:
+ name: gateway-lens-worker
+ port: {number: 4318}
+- it: routes external mode to its selected service
+ template: ingress.yaml
+ set:
+ fullnameOverride: gateway
+ ingress.enabled: true
+ ingress.host: gateway.example
+ lensWorker.mode: external
+ lensWorker.enabled: true
+ lensWorker.externalUrl: http://existing-lens:4318
+ lensWorker.externalServiceName: "existing-lens"
+ lensWorker.serviceTokenSecret.name: worker-service
+ lensWorker.gateway.secretName: external-lens-signing
+ asserts:
+ - contains:
+ path: spec.rules[0].http.paths
+ content:
+ path: /lens-ingest
+ pathType: Prefix
+ backend:
+ service:
+ name: existing-lens
+ port: {number: 4318}
+- it: omits ingestion when an external service has no ingress backend
+ template: ingress.yaml
+ set:
+ fullnameOverride: gateway
+ ingress.enabled: true
+ ingress.host: gateway.example
+ lensWorker.mode: external
+ lensWorker.enabled: true
+ lensWorker.externalUrl: http://existing-lens:4318
+ lensWorker.externalServiceName: ""
+ lensWorker.serviceTokenSecret.name: worker-service
+ lensWorker.gateway.secretName: external-lens-signing
+ asserts:
+ - notContains:
+ path: spec.rules[0].http.paths
+ content: {path: /lens-ingest}
+ any: true
+- it: omits ingestion for disabled mode with the enable flag on
+ template: ingress.yaml
+ set:
+ fullnameOverride: gateway
+ ingress.enabled: true
+ ingress.host: gateway.example
+ lensWorker.mode: disabled
+ lensWorker.enabled: true
+ lensWorker.externalUrl: http://existing-lens:4318
+ lensWorker.externalServiceName: ""
+ lensWorker.serviceTokenSecret.name: worker-service
+ lensWorker.gateway.secretName: external-lens-signing
+ asserts:
+ - notContains:
+ path: spec.rules[0].http.paths
+ content: {path: /lens-ingest}
+ any: true
diff --git a/helm/litellm/tests/lens_modes_validation_tests.yaml b/helm/litellm/tests/lens_modes_validation_tests.yaml
new file mode 100644
index 00000000000..6380727893d
--- /dev/null
+++ b/helm/litellm/tests/lens_modes_validation_tests.yaml
@@ -0,0 +1,54 @@
+---
+suite: Lens deployment mode validation
+templates:
+- gateway/configmap.yaml
+- backend/deployment.yaml
+values:
+- "./values/required.yaml"
+set:
+ lensWorker.publicUrl: https://traces.example
+tests:
+- it: requires a separate identity signing reference for external Lens
+ template: backend/deployment.yaml
+ set:
+ lensWorker.mode: external
+ lensWorker.externalUrl: http://lens.other-namespace:4318
+ lensWorker.serviceTokenSecret.name: worker-service
+ asserts:
+ - failedTemplate:
+ errorMessage: lensWorker.gateway.secretName is required for external Lens
+- it: requires an external service address
+ template: backend/deployment.yaml
+ set:
+ lensWorker.mode: external
+ lensWorker.serviceTokenSecret.name: external-lens-signing
+ lensWorker.gateway.secretName: external-lens-signing
+ asserts:
+ - failedTemplate:
+ errorMessage: lensWorker.externalUrl is required for external Lens
+- it: requires the shared server credential for an external service
+ template: backend/deployment.yaml
+ set:
+ lensWorker.mode: external
+ lensWorker.externalUrl: http://lens.other-namespace:4318
+ asserts:
+ - failedTemplate:
+ errorMessage: lensWorker.serviceTokenSecret.name is required for external Lens
+- it: requires an agent reachable URL for external ingestion
+ template: backend/deployment.yaml
+ set:
+ lensWorker.mode: external
+ lensWorker.externalUrl: http://lens.other-namespace:4318
+ lensWorker.serviceTokenSecret.name: external-lens-signing
+ lensWorker.gateway.secretName: external-lens-signing
+ lensWorker.publicUrl: ''
+ asserts:
+ - failedTemplate:
+ errorMessage: lensWorker.publicUrl is required for external Lens
+- it: rejects unsupported deployment modes
+ template: backend/deployment.yaml
+ set:
+ lensWorker.mode: typo
+ asserts:
+ - failedTemplate:
+ errorMessage: lensWorker.mode must be bundled, external or disabled
diff --git a/helm/litellm/tests/lens_saved_secrets_tests.yaml b/helm/litellm/tests/lens_saved_secrets_tests.yaml
new file mode 100644
index 00000000000..a6e190ec15b
--- /dev/null
+++ b/helm/litellm/tests/lens_saved_secrets_tests.yaml
@@ -0,0 +1,61 @@
+suite: Lens preserves credentials across Helm upgrades
+templates:
+ - lens/secrets.yaml
+set:
+ fullnameOverride: lens-test
+ lensWorker.enabled: true
+release:
+ namespace: lens
+ name: lens-test
+ upgrade: true
+kubernetesProvider:
+ scheme:
+ v1/Secret:
+ gvr:
+ version: v1
+ resource: secrets
+ namespaced: true
+ objects:
+ - apiVersion: v1
+ kind: Secret
+ metadata:
+ name: lens-test-lens-gateway
+ namespace: lens
+ data:
+ gateway-secret: c2F2ZWQtc2lnbmluZy1rZXk=
+ - apiVersion: v1
+ kind: Secret
+ metadata:
+ name: lens-test-lens-service
+ namespace: lens
+ data:
+ service-token: c2F2ZWQtc2VydmljZS10b2tlbg==
+ - apiVersion: v1
+ kind: Secret
+ metadata:
+ name: lens-test-lens-clickhouse
+ namespace: lens
+ data:
+ password: c2F2ZWQtZGF0YWJhc2UtcGFzc3dvcmQ=
+tests:
+ - it: preserves the separate signing credential across upgrades
+ documentSelector: {path: metadata.name, value: lens-test-lens-gateway}
+ asserts:
+ - equal:
+ path: data.gateway-secret
+ value: c2F2ZWQtc2lnbmluZy1rZXk=
+ - notExists: {path: data.service-token}
+ - it: reuses the service credential instead of breaking running services
+ documentIndex: 0
+ asserts:
+ - equal:
+ path: data.service-token
+ value: c2F2ZWQtc2VydmljZS10b2tlbg==
+ - it: reuses the database password instead of locking out stored traces
+ documentIndex: 1
+ asserts:
+ - equal:
+ path: data.password
+ value: c2F2ZWQtZGF0YWJhc2UtcGFzc3dvcmQ=
+values:
+ - ./values/required.yaml
diff --git a/helm/litellm/tests/lens_service_tests.yaml b/helm/litellm/tests/lens_service_tests.yaml
new file mode 100644
index 00000000000..544549e364d
--- /dev/null
+++ b/helm/litellm/tests/lens_service_tests.yaml
@@ -0,0 +1,196 @@
+suite: Lens service isolation and ingestion routing
+templates:
+- gateway/configmap.yaml
+- gateway/deployment.yaml
+- backend/deployment.yaml
+- ingress.yaml
+- lens/ingress.yaml
+- lens/service.yaml
+- lens/deployment.yaml
+tests:
+- it: connects gateway/deployment.yaml to the shared Lens service
+ template: gateway/deployment.yaml
+ set: &id001
+ fullnameOverride: lens-test
+ lensWorker.enabled: true
+ lensWorker.publicUrl: https://gateway.example/lens-ingest
+ lensWorker.serviceTokenSecret.name: lens-service
+ lensWorker.clickhouseSecret.name: lens-storage
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_URL
+ value: http://lens-test-lens-worker:4318
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_PUBLIC_URL
+ value: https://gateway.example/lens-ingest
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LENS_GATEWAY_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: lens-test-lens-gateway
+ key: gateway-secret
+- it: connects backend/deployment.yaml to the shared Lens service
+ template: backend/deployment.yaml
+ set: *id001
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_URL
+ value: http://lens-test-lens-worker:4318
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_PUBLIC_URL
+ value: https://gateway.example/lens-ingest
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LENS_GATEWAY_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: lens-test-lens-gateway
+ key: gateway-secret
+- it: routes uploads directly to Lens instead of the gateway
+ template: ingress.yaml
+ set:
+ fullnameOverride: lens-test
+ lensWorker.enabled: true
+ lensWorker.publicUrl: https://gateway.example/lens-ingest
+ lensWorker.serviceTokenSecret.name: lens-service
+ lensWorker.clickhouseSecret.name: lens-storage
+ ingress.enabled: true
+ ingress.host: gateway.example
+ asserts:
+ - contains:
+ path: spec.rules[0].http.paths
+ content:
+ path: /lens-ingest
+ pathType: Prefix
+ backend:
+ service:
+ name: lens-test-lens-worker
+ port:
+ number: 4318
+- it: keeps internal routes out of a dedicated ingestion hostname
+ template: lens/ingress.yaml
+ set:
+ fullnameOverride: lens-test
+ lensWorker.enabled: true
+ lensWorker.publicUrl: https://gateway.example/lens-ingest
+ lensWorker.serviceTokenSecret.name: lens-service
+ lensWorker.clickhouseSecret.name: lens-storage
+ lensWorker.ingress.enabled: true
+ lensWorker.ingress.host: traces.example
+ asserts:
+ - equal:
+ path: spec.rules[0].http.paths
+ value:
+ - path: /v1/
+ pathType: Prefix
+ backend:
+ service:
+ name: lens-test-lens-worker
+ port:
+ name: otlp
+- it: maps the Lens service to the ingestion listener
+ template: lens/service.yaml
+ set: *id001
+ asserts:
+ - equal:
+ path: spec.selector
+ value:
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/component: lens-worker
+ - equal:
+ path: spec.ports
+ value:
+ - name: otlp
+ port: 4318
+ targetPort: otlp
+- it: gives only Lens the ClickHouse secret
+ template: lens/deployment.yaml
+ set: *id001
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: CLICKHOUSE_URL
+ valueFrom:
+ secretKeyRef:
+ name: lens-storage
+ key: url
+- it: requires an agent reachable ingestion URL
+ template: gateway/deployment.yaml
+ set:
+ fullnameOverride: lens-test
+ lensWorker.enabled: true
+ lensWorker.publicUrl: ''
+ lensWorker.serviceTokenSecret.name: lens-service
+ lensWorker.clickhouseSecret.name: lens-storage
+ asserts:
+ - failedTemplate:
+ errorMessage: lensWorker.publicUrl is required when there is no single ingress hostname
+- it: omits Lens connection settings when disabled in gateway/deployment.yaml
+ template: gateway/deployment.yaml
+ asserts:
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_URL
+ any: true
+- it: omits Lens connection settings when disabled in backend/deployment.yaml
+ template: backend/deployment.yaml
+ asserts:
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_LENS_URL
+ any: true
+- it: preserves an existing ClickHouse database and retention
+ template: lens/deployment.yaml
+ set:
+ lensWorker.enabled: true
+ lensWorker.publicUrl: https://traces.example
+ lensWorker.serviceTokenSecret.name: lens-service
+ lensWorker.clickhouseSecret.name: lens-storage
+ lensWorker.clickhouseDatabase: existing_traces
+ lensWorker.retentionDays: 45
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: CLICKHOUSE_DATABASE
+ value: existing_traces
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: AGENT_TRACING_RETENTION_DAYS
+ value: '45'
+- it: keeps Lens pods outside the gateway autoscaling selector
+ template: lens/deployment.yaml
+ set: &id002
+ nameOverride: inference
+ lensWorker.enabled: true
+ lensWorker.publicUrl: https://traces.example
+ lensWorker.serviceTokenSecret.name: lens-service
+ lensWorker.clickhouseSecret.name: lens-storage
+ asserts:
+ - equal:
+ path: spec.template.metadata.labels["app.kubernetes.io/name"]
+ value: inference-lens-worker
+- it: preserves the existing gateway deployment selector
+ template: gateway/deployment.yaml
+ set: *id002
+ asserts:
+ - equal:
+ path: spec.selector.matchLabels["app.kubernetes.io/name"]
+ value: inference
+values:
+- ./values/required.yaml
diff --git a/helm/litellm/tests/lens_setup_tests.yaml b/helm/litellm/tests/lens_setup_tests.yaml
new file mode 100644
index 00000000000..d61c7cad55b
--- /dev/null
+++ b/helm/litellm/tests/lens_setup_tests.yaml
@@ -0,0 +1,164 @@
+suite: Lens managed setup
+set:
+ fullnameOverride: lens-test
+ lensWorker.enabled: true
+ lensWorker.publicUrl: https://traces.example
+release:
+ namespace: lens
+ name: lens-test
+templates:
+ - lens/deployment.yaml
+ - lens/clickhouse.yaml
+ - lens/secrets.yaml
+tests:
+ - it: generates private login, gateway and storage credentials
+ template: lens/secrets.yaml
+ asserts:
+ - hasDocuments:
+ count: 4
+ - matchRegex:
+ path: data.service-token
+ pattern: '^[A-Za-z0-9+/]{86}==$'
+ documentIndex: 0
+ - matchRegex:
+ path: data.password
+ pattern: '^[A-Za-z0-9+/]{86}==$'
+ documentIndex: 1
+ - equal:
+ path: metadata.annotations["helm.sh/resource-policy"]
+ value: keep
+ - it: connects Lens to its private bundled storage
+ template: lens/deployment.yaml
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: CLICKHOUSE_HOST
+ value: lens-test-lens-clickhouse
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: CLICKHOUSE_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: lens-test-lens-clickhouse
+ key: password
+ - it: stores traces on a persistent volume with the chosen storage class
+ template: lens/clickhouse.yaml
+ documentIndex: 1
+ set:
+ lensWorker.clickhouse.storage: 40Gi
+ lensWorker.clickhouse.storageClassName: fast
+ asserts:
+ - equal:
+ path: spec.volumeClaimTemplates[0].spec
+ value:
+ accessModes: [ReadWriteOnce]
+ storageClassName: fast
+ resources:
+ requests:
+ storage: 40Gi
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: CLICKHOUSE_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: lens-test-lens-clickhouse
+ key: password
+ - it: keeps an existing external database instead of creating a new one
+ template: lens/clickhouse.yaml
+ set:
+ lensWorker.clickhouseSecret.name: external-clickhouse
+ asserts:
+ - hasDocuments:
+ count: 0
+ - it: leaves supplied secrets under their existing manager
+ template: lens/secrets.yaml
+ set:
+ lensWorker.clickhouseSecret.name: external-clickhouse
+ lensWorker.serviceTokenSecret.name: external-service
+ lensWorker.adminTokenSecret.name: external-admin
+ lensWorker.gateway.secretName: external-signing
+ asserts:
+ - hasDocuments:
+ count: 0
+ - it: creates no credentials or database when Lens is disabled
+ templates:
+ - lens/secrets.yaml
+ - lens/clickhouse.yaml
+ set:
+ lensWorker.enabled: false
+ asserts:
+ - hasDocuments:
+ count: 0
+ - it: requires an external database when bundled storage is explicitly disabled
+ template: lens/deployment.yaml
+ set:
+ lensWorker.clickhouse.enabled: false
+ asserts:
+ - failedTemplate:
+ errorMessage: Lens clickhouseSecret.name is required when bundled storage is disabled
+ - it: keeps storage names valid and uses the same name for the connection
+ set:
+ fullnameOverride: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ asserts:
+ - matchRegex:
+ path: metadata.name
+ pattern: '^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$'
+ template: lens/clickhouse.yaml
+ documentIndex: 0
+ - matchRegex:
+ path: metadata.name
+ pattern: '^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$'
+ template: lens/clickhouse.yaml
+ documentIndex: 1
+ - equal:
+ path: metadata.name
+ value: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-lens-clickhouse
+ template: lens/clickhouse.yaml
+ documentIndex: 0
+ - equal:
+ path: spec.serviceName
+ value: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-lens-clickhouse
+ template: lens/clickhouse.yaml
+ documentIndex: 1
+ - equal:
+ path: metadata.name
+ value: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-lens-clickhouse
+ template: lens/secrets.yaml
+ documentIndex: 1
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: CLICKHOUSE_HOST
+ value: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-lens-clickhouse
+ template: lens/deployment.yaml
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: CLICKHOUSE_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-lens-clickhouse
+ key: password
+ template: lens/deployment.yaml
+ - it: allows cold storage startup and inherits registry credentials
+ template: lens/clickhouse.yaml
+ documentIndex: 1
+ set:
+ imagePullSecrets: [{name: registry-auth}]
+ asserts:
+ - equal:
+ path: spec.template.spec.imagePullSecrets
+ value: [{name: registry-auth}]
+ - equal:
+ path: spec.template.spec.containers[0].startupProbe
+ value:
+ httpGet: {path: /ping, port: http}
+ periodSeconds: 5
+ timeoutSeconds: 3
+ failureThreshold: 60
+
+values:
+ - ./values/required.yaml
diff --git a/helm/litellm/tests/lens_worker_tests.yaml b/helm/litellm/tests/lens_worker_tests.yaml
index 9a83a4af6a4..614db9d6b2b 100644
--- a/helm/litellm/tests/lens_worker_tests.yaml
+++ b/helm/litellm/tests/lens_worker_tests.yaml
@@ -1,99 +1,73 @@
-suite: Lens worker release and credentials
+suite: Independent Lens release and credentials
templates:
- lens/deployment.yaml
- - backend/deployment.yaml
- - gateway/configmap.yaml
values:
- ./values/required.yaml
+set:
+ fullnameOverride: lens-test
+ lensWorker.enabled: true
+ lensWorker.publicUrl: https://traces.example
+ lensWorker.serviceTokenSecret.name: lens-service
+ lensWorker.clickhouseSecret.name: lens-storage
tests:
- - it: installs the development package for a source commit
- template: lens/deployment.yaml
+ - it: selects a Lens release independently of the gateway
+ chart:
+ appVersion: 9.8.7
set:
- backend.image.tag: sha-0123456789abcdef
- lensWorker.enabled: true
- lensWorker.tokenSecret.name: lens-credential
+ backend.image.tag: gateway-9.8.7
+ lensWorker.image.tag: 2.3.4
asserts:
- equal:
path: spec.template.spec.containers[0].image
- value: ghcr.io/berriai/litellm-lens-worker-dev:sha-0123456789abcdef
- - it: advertises the development package for standalone source workers
- template: backend/deployment.yaml
+ value: ghcr.io/berriai/lens:2.3.4
+ - it: retains the pinned Lens chart default when only the gateway changes
+ chart:
+ appVersion: 9.8.7
set:
- backend.image.tag: sha-0123456789abcdef
- asserts:
- - contains:
- path: spec.template.spec.containers[0].env
- content:
- name: LENS_WORKER_IMAGE
- value: ghcr.io/berriai/litellm-lens-worker-dev:sha-0123456789abcdef
- - it: preserves an explicit private source image repository
- template: lens/deployment.yaml
- set:
- backend.image.tag: sha-0123456789abcdef
- lensWorker.enabled: true
- lensWorker.tokenSecret.name: lens-credential
- lensWorker.image.repository: registry.example/lens-worker
+ backend.image.tag: gateway-9.8.7
asserts:
- equal:
path: spec.template.spec.containers[0].image
- value: registry.example/lens-worker:sha-0123456789abcdef
- - it: pins the worker to its approved digest even when its tag changes
- template: lens/deployment.yaml
+ value: ghcr.io/berriai/lens:0.1.0-dev.0
+ - it: selects development publications explicitly
set:
- lensWorker.enabled: true
- lensWorker.tokenSecret.name: lens-credential
- lensWorker.image.tag: replaced-release
+ lensWorker.image.repository: ghcr.io/berriai/lens-dev
+ lensWorker.image.tag: sha-0123456789abcdef
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].image
+ value: ghcr.io/berriai/lens-dev:sha-0123456789abcdef
+ - it: preserves a verified registry mirror and digest
+ set:
+ lensWorker.image.repository: registry.example/lens
+ lensWorker.image.tag: replaced-label
lensWorker.image.digest: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
asserts:
- equal:
path: spec.template.spec.containers[0].image
- value: ghcr.io/berriai/litellm-lens-worker@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
- - it: advertises the approved digest to standalone installers
- template: backend/deployment.yaml
- set:
- lensWorker.image.tag: replaced-release
- lensWorker.image.digest: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
- asserts:
- - contains:
- path: spec.template.spec.containers[0].env
- content:
- name: LENS_WORKER_IMAGE
- value: ghcr.io/berriai/litellm-lens-worker@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
- - it: refuses a malformed digest instead of falling back to the tag
- template: backend/deployment.yaml
+ value: registry.example/lens@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ - it: rejects a malformed digest
set:
lensWorker.image.digest: sha256:invalid
asserts:
- failedTemplate:
- errorMessage: lensWorker.image.digest must be sha256 followed by 64 lowercase hex characters
- - it: keeps the worker opt in
- template: lens/deployment.yaml
- asserts:
- - hasDocuments:
- count: 0
- - it: requires a limited worker credential when enabled
- template: lens/deployment.yaml
+ errorMessage: Lens image.digest must be sha256 followed by 64 lowercase hex characters
+ - it: keeps the legacy enablement switch working
set:
- lensWorker.enabled: true
+ lensWorker.enabled: false
asserts:
- - failedTemplate:
- errorMessage: lensWorker.tokenSecret.name must reference a Lens worker token
- - it: uses the chart release and a secret without granting Kubernetes access
- template: lens/deployment.yaml
- chart:
- appVersion: v1.2.3
- set:
- lensWorker.enabled: true
- lensWorker.tokenSecret.name: lens-credential
+ - hasDocuments: {count: 0}
+ - it: keeps isolated local analysis with delegated gateway access
asserts:
- - equal:
- path: spec.template.spec.containers[0].image
- value: ghcr.io/berriai/litellm-lens-worker:v1.2.3
- - equal:
- path: spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef
- value:
- name: lens-credential
- key: token
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content: {name: LENS_MODE, value: standalone}
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LENS_GATEWAY_SECRET
+ valueFrom:
+ secretKeyRef: {name: lens-test-lens-gateway, key: gateway-secret}
- equal:
path: spec.template.spec.automountServiceAccountToken
value: false
@@ -102,73 +76,4 @@ tests:
value: true
- equal:
path: spec.template.spec.volumes[0].emptyDir
- value:
- medium: Memory
- sizeLimit: 1Gi
- - it: advertises the same private dev image to standalone installers
- template: backend/deployment.yaml
- set:
- lensWorker.image.repository: registry.example/lens-worker
- lensWorker.image.tag: branch-main-1234567
- asserts:
- - contains:
- path: spec.template.spec.containers[0].env
- content:
- name: LENS_WORKER_IMAGE
- value: registry.example/lens-worker:branch-main-1234567
- - it: supports an external gateway and a registry override
- template: lens/deployment.yaml
- set:
- lensWorker.enabled: true
- lensWorker.tokenSecret.name: lens-credential
- lensWorker.url: https://gateway.example/proxy
- lensWorker.image.repository: registry.example/lens-worker
- lensWorker.image.tag: branch-main-1234567
- asserts:
- - equal:
- path: spec.template.spec.containers[0].image
- value: registry.example/lens-worker:branch-main-1234567
- - equal:
- path: spec.template.spec.containers[0].env[0].value
- value: https://gateway.example/proxy
- - it: prefixes a numeric chart release with v
- template: lens/deployment.yaml
- chart:
- appVersion: 1.2.3-rc.4
- set:
- lensWorker.enabled: true
- lensWorker.tokenSecret.name: lens-credential
- asserts:
- - equal:
- path: spec.template.spec.containers[0].image
- value: ghcr.io/berriai/litellm-lens-worker:v1.2.3-rc.4
- - it: follows a backend image override when no worker tag is set
- template: lens/deployment.yaml
- set:
- backend.image.tag: branch-main-1234567
- lensWorker.enabled: true
- lensWorker.tokenSecret.name: lens-credential
- asserts:
- - equal:
- path: spec.template.spec.containers[0].image
- value: ghcr.io/berriai/litellm-lens-worker:branch-main-1234567
- - it: recommends the overridden backend release for standalone installers
- template: backend/deployment.yaml
- set:
- backend.image.tag: v1.2.3-dev.4
- asserts:
- - contains:
- path: spec.template.spec.containers[0].env
- content:
- name: LENS_WORKER_IMAGE
- value: ghcr.io/berriai/litellm-lens-worker:v1.2.3-dev.4
- - it: normalizes a numeric backend tag to the published worker tag
- template: lens/deployment.yaml
- set:
- backend.image.tag: 1.2.3-dev.4
- lensWorker.enabled: true
- lensWorker.tokenSecret.name: lens-credential
- asserts:
- - equal:
- path: spec.template.spec.containers[0].image
- value: ghcr.io/berriai/litellm-lens-worker:v1.2.3-dev.4
+ value: {medium: Memory, sizeLimit: 1Gi}
diff --git a/helm/litellm/tests/redis_env_tests.yaml b/helm/litellm/tests/redis_env_tests.yaml
index 684d7071b35..c9304cedd9c 100644
--- a/helm/litellm/tests/redis_env_tests.yaml
+++ b/helm/litellm/tests/redis_env_tests.yaml
@@ -21,6 +21,27 @@ tests:
name: REDIS_CLUSTER_NODES
any: true
+ - it: gateway renders without redis env vars when passwordSecret is null
+ template: gateway/deployment.yaml
+ set:
+ redis.passwordSecret: null
+ asserts:
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: REDIS_HOST
+ any: true
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: REDIS_URL
+ any: true
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: REDIS_PASSWORD
+ any: true
+
- it: gateway emits host, port and password when redis is configured
template: gateway/deployment.yaml
set:
@@ -68,6 +89,27 @@ tests:
name: redis-secret
key: redis-password
+ - it: backend renders without redis env vars when passwordSecret is null
+ template: backend/deployment.yaml
+ set:
+ redis.passwordSecret: null
+ asserts:
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: REDIS_HOST
+ any: true
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: REDIS_URL
+ any: true
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: REDIS_PASSWORD
+ any: true
+
- it: gateway omits REDIS_PASSWORD for an auth-less redis
template: gateway/deployment.yaml
set:
@@ -107,3 +149,174 @@ tests:
content:
name: REDIS_CLUSTER_NODES
any: true
+
+ - it: gateway gets redis host from hostKey and keeps port and password
+ template: gateway/deployment.yaml
+ set:
+ redis.host: ""
+ redis.port: 6380
+ redis.passwordSecret.name: redis-secret
+ redis.passwordSecret.hostKey: hostname
+ redis.passwordSecret.passwordKey: redis-password
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: REDIS_HOST
+ valueFrom:
+ secretKeyRef:
+ name: redis-secret
+ key: hostname
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: REDIS_PORT
+ value: "6380"
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: REDIS_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: redis-secret
+ key: redis-password
+
+ - it: backend gets redis host from hostKey with the same coordination env
+ template: backend/deployment.yaml
+ set:
+ redis.host: ""
+ redis.passwordSecret.name: redis-secret
+ redis.passwordSecret.hostKey: hostname
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: REDIS_HOST
+ valueFrom:
+ secretKeyRef:
+ name: redis-secret
+ key: hostname
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: REDIS_PORT
+ value: "6379"
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: REDIS_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: redis-secret
+ key: password
+
+ - it: gateway expands the redis cluster seed from hostKey at runtime
+ template: gateway/deployment.yaml
+ set:
+ redis.host: ""
+ redis.passwordSecret.name: redis-secret
+ redis.passwordSecret.hostKey: hostname
+ redis.cluster: true
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: REDIS_CLUSTER_NODES
+ value: '[{"host":"$(REDIS_HOST)","port":6379}]'
+ - equal:
+ path: spec.template.spec.containers[0].env[8].name
+ value: REDIS_HOST
+ - equal:
+ path: spec.template.spec.containers[0].env[11].name
+ value: REDIS_CLUSTER_NODES
+
+ - it: gateway gets REDIS_URL from urlKey without discrete Redis env
+ template: gateway/deployment.yaml
+ set:
+ redis.passwordSecret.name: redis-secret
+ redis.passwordSecret.urlKey: connection-url
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: REDIS_URL
+ valueFrom:
+ secretKeyRef:
+ name: redis-secret
+ key: connection-url
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: REDIS_HOST
+ any: true
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: REDIS_PORT
+ any: true
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: REDIS_PASSWORD
+ any: true
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: REDIS_CLUSTER_NODES
+ any: true
+
+ - it: redis rejects host together with hostKey
+ template: gateway/deployment.yaml
+ set:
+ redis.host: redis.example.com
+ redis.passwordSecret.hostKey: hostname
+ redis.passwordSecret.name: redis-secret
+ asserts:
+ - failedTemplate:
+ errorMessage: "redis.host and redis.passwordSecret.hostKey cannot both be set"
+
+ - it: redis urlKey rejects host
+ template: gateway/deployment.yaml
+ set:
+ redis.host: redis.example.com
+ redis.passwordSecret.urlKey: connection-url
+ redis.passwordSecret.name: redis-secret
+ asserts:
+ - failedTemplate:
+ errorMessage: "redis.passwordSecret.urlKey cannot be combined with host, hostKey, or cluster"
+
+ - it: redis urlKey rejects hostKey
+ template: gateway/deployment.yaml
+ set:
+ redis.passwordSecret.hostKey: hostname
+ redis.passwordSecret.urlKey: connection-url
+ redis.passwordSecret.name: redis-secret
+ asserts:
+ - failedTemplate:
+ errorMessage: "redis.passwordSecret.urlKey cannot be combined with host, hostKey, or cluster"
+
+ - it: redis urlKey rejects cluster mode
+ template: gateway/deployment.yaml
+ set:
+ redis.cluster: true
+ redis.passwordSecret.urlKey: connection-url
+ redis.passwordSecret.name: redis-secret
+ asserts:
+ - failedTemplate:
+ errorMessage: "redis.passwordSecret.urlKey cannot be combined with host, hostKey, or cluster"
+
+ - it: redis hostKey requires a Secret name
+ template: gateway/deployment.yaml
+ set:
+ redis.passwordSecret.hostKey: hostname
+ asserts:
+ - failedTemplate:
+ errorMessage: "redis.passwordSecret.name is required when hostKey or urlKey is set"
+
+ - it: redis urlKey requires a Secret name
+ template: gateway/deployment.yaml
+ set:
+ redis.passwordSecret.urlKey: connection-url
+ asserts:
+ - failedTemplate:
+ errorMessage: "redis.passwordSecret.name is required when hostKey or urlKey is set"
diff --git a/helm/litellm/values.yaml b/helm/litellm/values.yaml
index cf3334f6156..d8e77085010 100644
--- a/helm/litellm/values.yaml
+++ b/helm/litellm/values.yaml
@@ -23,7 +23,11 @@ ingress:
# wildcard pathType, so that rule could never match there.
controller: alb
annotations: {}
- host: "" # optional; if set, becomes the rule's host
+ host: "" # optional; if set, becomes the single ingress rule's host
+ componentHosts:
+ ui: ""
+ backend: ""
+ gateway: ""
tls: []
# Extra HTTP paths appended to the ingress rule. Additive: every built-in
# UI / gateway / backend path is still rendered, these entries are placed
@@ -208,6 +212,8 @@ database:
name: litellm-writer-secret
usernameKey: username
passwordKey: password
+ hostKey: "" # Secret key containing the writer database host
+ urlKey: "" # Secret key containing the complete writer database URL
# libpq sslmode / sslrootcert applied to the writer and reader URLs (Prisma and the
# in-container PgBouncer); e.g. verify-full with /etc/ssl/certs/ca-certificates.crt for AWS RDS.
# sslRootCert on its own implies sslMode verify-full
@@ -229,6 +235,8 @@ database:
name: litellm-reader-secret
usernameKey: username
passwordKey: password
+ hostKey: "" # Secret key containing the reader database host
+ urlKey: "" # Secret key containing the complete reader database URL
# In-container connection pool (PgBouncer, transaction mode) shared by every
# gateway worker in the pod. Without it each of the `gateway.numWorkers`
@@ -274,6 +282,8 @@ redis:
passwordSecret:
name: "" # Leave empty for auth-less Redis
passwordKey: password
+ hostKey: "" # Secret key containing the Redis host
+ urlKey: "" # Secret key containing the complete Redis URL
# ---------- gateway (LLM data plane) ----------
gateway:
@@ -493,6 +503,7 @@ gateway:
backend:
enabled: true
logLevel: INFO
+ gatewayUrl: ""
extraEnv: []
envConfigMaps: []
envSecrets: []
@@ -575,9 +586,10 @@ ui:
service:
type: ClusterIP
port: 3000
- # The dashboard expects to know where to reach the backend API. Set this to
- # the externally-routable URL (typically the ingress host + /api or similar).
+ # The backend's public origin, passed to the UI only when ingress.componentHosts is configured.
backendUrl: ""
+ # Public origin of the UI, used for backend and gateway CORS.
+ publicUrl: ""
resources:
requests:
cpu: 500m
@@ -630,17 +642,63 @@ ui:
# Same shape as gateway.topologySpreadConstraints.
topologySpreadConstraints: []
+lens:
+ library: true
+
lensWorker:
+ retainResources: false
+ mode: ""
+ externalUrl: ""
+ externalServiceName: ""
+ gateway:
+ secretName: ""
+ secretKey: gateway-secret
+ standaloneUrl: ""
+ adminTokenSecret:
+ name: ""
+ key: admin-token
+ extraEnv: []
+ extraEnvFrom: []
enabled: false
replicaCount: 1
image:
- repository: ghcr.io/berriai/litellm-lens-worker
- tag: ""
+ repository: ghcr.io/berriai/lens
+ tag: "0.1.0-dev.0"
digest: ""
pullPolicy: IfNotPresent
tokenSecret:
name: ""
key: token
+ serviceTokenSecret:
+ name: ""
+ key: service-token
+ clickhouse:
+ enabled: true
+ image: clickhouse/clickhouse-server:26.9.6.6@sha256:eb4870e7ca7ed70c259eebfcfbee6cf797017f6b5436c2926bbbfe3d4d28486e
+ storage: 20Gi
+ storageClassName: null
+ resources:
+ requests:
+ cpu: 100m
+ memory: 512Mi
+ limits:
+ memory: 2Gi
+ clickhouseDatabase: litellm
+ retentionDays: 14
+ clickhouseSecret:
+ name: ""
+ key: url
+ publicUrl: ""
+ service:
+ port: 4318
+ annotations: {}
+ ingress:
+ enabled: false
+ path: /v1/
+ className: ""
+ host: ""
+ annotations: {}
+ tls: []
url: ""
tmpSizeLimit: 1Gi
resources:
diff --git a/litellm-proxy-extras/litellm_proxy_extras/migration_lock.py b/litellm-proxy-extras/litellm_proxy_extras/migration_lock.py
index bea5e36fd18..89857859134 100644
--- a/litellm-proxy-extras/litellm_proxy_extras/migration_lock.py
+++ b/litellm-proxy-extras/litellm_proxy_extras/migration_lock.py
@@ -7,26 +7,33 @@ from typing import TYPE_CHECKING, Final
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from litellm_proxy_extras._logging import logger
-from litellm_proxy_extras.prisma_toolchain import MIGRATION_LOCK_TIMEOUT_ENV_VAR, migration_lock_timeout
+from litellm_proxy_extras.prisma_toolchain import (
+ MIGRATION_LOCK_TIMEOUT_ENV_VAR,
+ migration_lock_timeout,
+)
MIGRATION_LOCK_KEY: Final = int.from_bytes(b"llm_mig2", "big")
+PRISMA_ADVISORY_LOCK_KEY: Final = 72707369
if TYPE_CHECKING:
import psycopg
-def migration_environment(environment: Mapping[str, str]) -> Mapping[str, str]:
- database_url: Final = environment.get("DATABASE_URL")
- direct_url: Final = environment.get("DIRECT_URL")
- if not database_url or not direct_url:
- return environment
+def _migration_url(database_url: str, direct_url: "str | None") -> str:
+ if not direct_url:
+ return database_url
schema: Final = next((value for key, value in parse_qsl(urlsplit(database_url).query) if key == "schema"), "public")
direct: Final = urlsplit(direct_url)
parameters: Final = tuple((key, value) for key, value in parse_qsl(direct.query) if key != "schema")
- return {
- **environment,
- "DATABASE_URL": urlunsplit(direct._replace(query=urlencode((*parameters, ("schema", schema))))),
- }
+ return urlunsplit(direct._replace(query=urlencode((*parameters, ("schema", schema)))))
+
+
+def migration_environment(environment: Mapping[str, str]) -> Mapping[str, str]:
+ database_url: Final = environment.get("DATABASE_URL")
+ if not database_url:
+ return environment
+ migration_url: Final = _migration_url(database_url, environment.get("DIRECT_URL"))
+ return {**environment, "DATABASE_URL": migration_url, "PRISMA_SCHEMA_DISABLE_ADVISORY_LOCK": "1"}
@dataclass(frozen=True, slots=True)
@@ -49,10 +56,15 @@ class MigrationCoordinator:
def check_connection(self) -> None:
self.connection.execute("SELECT 1")
+ def release_statement_snapshot(self) -> None:
+ # a parameterized statement leaves its portal snapshot registered on the backend until the
+ # next statement, and CREATE INDEX CONCURRENTLY waits for every such snapshot to go away
+ self.connection.execute("SELECT 1")
+
def acquire_prisma_lock(self) -> None:
deadline: Final = time.monotonic() + migration_lock_timeout()
while time.monotonic() < deadline:
- if _try_lock(self.connection, 72707369):
+ if _try_lock(self.connection, PRISMA_ADVISORY_LOCK_KEY):
return
time.sleep(min(random.uniform(0.5, 1.5), max(0.0, deadline - time.monotonic())))
raise RuntimeError(
@@ -89,6 +101,17 @@ def migration_lock(database_url: str) -> Generator[MigrationCoordinator, None, N
)
+@contextmanager
+def prisma_migration_lock(database_url: str) -> Generator[None, None, None]:
+ """Hold the coordinator lock and Prisma's own lock key as transaction-level locks on one
+ pinned connection while a Prisma command runs with its session advisory lock disabled, so a
+ transaction pooler can never strand that lock on an idle backend after the command exits."""
+ with migration_lock(database_url) as coordinator:
+ coordinator.acquire_prisma_lock()
+ coordinator.release_statement_snapshot()
+ yield
+
+
@contextmanager
def held_migration_lock(connection: "psycopg.Connection[tuple[object, ...]]") -> Generator[bool, None, None]:
"""A session-level, non-blocking hold of the migration coordinator lock on an autocommit
diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20261007000100_lens_ingestion_keys/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261007000100_lens_ingestion_keys/migration.sql
new file mode 100644
index 00000000000..a05c066da43
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261007000100_lens_ingestion_keys/migration.sql
@@ -0,0 +1,5 @@
+CREATE TABLE IF NOT EXISTS "LiteLLM_LensIngestionKey" (
+ "id" TEXT NOT NULL,
+ "data" JSONB NOT NULL,
+ CONSTRAINT "LiteLLM_LensIngestionKey_pkey" PRIMARY KEY ("id")
+);
diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20261008010000_add_daily_gateway_failed_requests/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261008010000_add_daily_gateway_failed_requests/migration.sql
new file mode 100644
index 00000000000..7933761526b
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261008010000_add_daily_gateway_failed_requests/migration.sql
@@ -0,0 +1,15 @@
+-- CreateTable
+CREATE TABLE IF NOT EXISTS "LiteLLM_DailyGatewayFailedRequests" (
+ "date" TEXT NOT NULL,
+ "category" TEXT NOT NULL,
+ "route" TEXT NOT NULL,
+ "status_code" INTEGER NOT NULL,
+ "failed_requests" BIGINT NOT NULL DEFAULT 0,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "LiteLLM_DailyGatewayFailedRequests_pkey" PRIMARY KEY ("date","category","route","status_code")
+);
+
+-- CreateIndex
+CREATE INDEX IF NOT EXISTS "LiteLLM_DailyGatewayFailedRequests_date_idx" ON "LiteLLM_DailyGatewayFailedRequests"("date");
diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20261008120000_add_daily_request_errors/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261008120000_add_daily_request_errors/migration.sql
new file mode 100644
index 00000000000..a1037334d7b
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261008120000_add_daily_request_errors/migration.sql
@@ -0,0 +1,17 @@
+-- CreateTable
+CREATE TABLE IF NOT EXISTS "LiteLLM_DailyRequestErrors" (
+ "date" TEXT NOT NULL,
+ "api_key" TEXT NOT NULL,
+ "team_id" TEXT NOT NULL DEFAULT '',
+ "user_id" TEXT NOT NULL DEFAULT '',
+ "model_group" TEXT NOT NULL DEFAULT '',
+ "status_code" INTEGER NOT NULL,
+ "failed_requests" BIGINT NOT NULL DEFAULT 0,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "LiteLLM_DailyRequestErrors_pkey" PRIMARY KEY ("date","api_key","team_id","user_id","model_group","status_code")
+);
+
+-- CreateIndex
+CREATE INDEX IF NOT EXISTS "LiteLLM_DailyRequestErrors_date_idx" ON "LiteLLM_DailyRequestErrors"("date");
diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20261008120000_user_provider_credentials/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261008120000_user_provider_credentials/migration.sql
new file mode 100644
index 00000000000..ea4fe9bc0ab
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261008120000_user_provider_credentials/migration.sql
@@ -0,0 +1,18 @@
+-- CreateTable
+CREATE TABLE IF NOT EXISTS "LiteLLM_UserProviderCredentials" (
+ "id" TEXT NOT NULL,
+ "user_id" TEXT NOT NULL,
+ "credential_name" TEXT NOT NULL,
+ "provider" TEXT NOT NULL,
+ "credential_b64" TEXT NOT NULL,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "LiteLLM_UserProviderCredentials_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX IF NOT EXISTS "LiteLLM_UserProviderCredentials_user_id_credential_name_key" ON "LiteLLM_UserProviderCredentials"("user_id", "credential_name");
+
+-- CreateIndex
+CREATE INDEX IF NOT EXISTS "LiteLLM_UserProviderCredentials_credential_name_idx" ON "LiteLLM_UserProviderCredentials"("credential_name");
diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20261009000000_add_credential_display_name/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261009000000_add_credential_display_name/migration.sql
new file mode 100644
index 00000000000..264bd88a807
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261009000000_add_credential_display_name/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "LiteLLM_CredentialsTable" ADD COLUMN IF NOT EXISTS "display_name" TEXT;
diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20261009000100_telemetry_reports/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261009000100_telemetry_reports/migration.sql
new file mode 100644
index 00000000000..a36bc8feb2b
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261009000100_telemetry_reports/migration.sql
@@ -0,0 +1,14 @@
+SET LOCAL lock_timeout = '10s';
+
+CREATE TABLE IF NOT EXISTS "LiteLLM_TelemetryReport" (
+ "id" TEXT NOT NULL,
+ "window_start" DOUBLE PRECISION NOT NULL,
+ "window_end" DOUBLE PRECISION NOT NULL,
+ "report" JSONB NOT NULL,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "LiteLLM_TelemetryReport_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX IF NOT EXISTS "LiteLLM_TelemetryReport_window_end_idx" ON "LiteLLM_TelemetryReport"("window_end");
+
+CREATE INDEX IF NOT EXISTS "LiteLLM_TelemetryReport_created_at_idx" ON "LiteLLM_TelemetryReport"("created_at");
diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20261013000000_access_group_mcp_tool_permissions/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261013000000_access_group_mcp_tool_permissions/migration.sql
new file mode 100644
index 00000000000..35197a9e63c
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261013000000_access_group_mcp_tool_permissions/migration.sql
@@ -0,0 +1,4 @@
+SET LOCAL lock_timeout = '10s';
+
+-- AlterTable
+ALTER TABLE "LiteLLM_AccessGroupTable" ADD COLUMN IF NOT EXISTS "mcp_tool_permissions" JSONB;
diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20261013000000_add_access_group_skill_names/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261013000000_add_access_group_skill_names/migration.sql
new file mode 100644
index 00000000000..eafb75d83ef
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261013000000_add_access_group_skill_names/migration.sql
@@ -0,0 +1,4 @@
+SET LOCAL lock_timeout = '10s';
+
+-- AlterTable
+ALTER TABLE "LiteLLM_AccessGroupTable" ADD COLUMN IF NOT EXISTS "access_skill_names" TEXT[] DEFAULT ARRAY[]::TEXT[];
diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
index dbfa8c9ce92..12438614755 100644
--- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
+++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
@@ -42,6 +42,7 @@ model LiteLLM_BudgetTable {
model LiteLLM_CredentialsTable {
credential_id String @id @default(uuid())
credential_name String @unique
+ display_name String?
credential_values Json
credential_info Json?
created_at DateTime @default(now()) @map("created_at")
@@ -452,6 +453,20 @@ model LiteLLM_MCPUserCredentials {
@@unique([user_id, server_id])
}
+// Per-user provider connections (e.g. GitHub Copilot OAuth) keyed by credential name
+model LiteLLM_UserProviderCredentials {
+ id String @id @default(uuid())
+ user_id String
+ credential_name String
+ provider String
+ credential_b64 String
+ created_at DateTime @default(now()) @map("created_at")
+ updated_at DateTime @default(now()) @updatedAt @map("updated_at")
+
+ @@unique([user_id, credential_name])
+ @@index([credential_name])
+}
+
// Per-user environment variable values for MCP servers.
// values_b64 is an encrypted JSON object: {VAR_NAME: "value", ...}.
model LiteLLM_MCPUserEnvVars {
@@ -1360,6 +1375,34 @@ model LiteLLM_DailyGatewayRequests {
@@index([date])
}
+model LiteLLM_DailyGatewayFailedRequests {
+ date String
+ category String
+ route String
+ status_code Int
+ failed_requests BigInt @default(0)
+ created_at DateTime @default(now())
+ updated_at DateTime @updatedAt
+
+ @@id([date, category, route, status_code])
+ @@index([date])
+}
+
+model LiteLLM_DailyRequestErrors {
+ date String
+ api_key String
+ team_id String @default("")
+ user_id String @default("")
+ model_group String @default("")
+ status_code Int
+ failed_requests BigInt @default(0)
+ created_at DateTime @default(now())
+ updated_at DateTime @updatedAt
+
+ @@id([date, api_key, team_id, user_id, model_group, status_code])
+ @@index([date])
+}
+
// Prompt table for storing prompt configurations
model LiteLLM_PromptTable {
id String @id @default(uuid())
@@ -1545,7 +1588,9 @@ model LiteLLM_AccessGroupTable {
// Resource memberships - explicit arrays per type
access_model_names String[] @default([])
access_mcp_server_ids String[] @default([])
+ mcp_tool_permissions Json?
access_agent_ids String[] @default([])
+ access_skill_names String[] @default([])
assigned_team_ids String[] @default([])
assigned_key_ids String[] @default([])
@@ -1972,6 +2017,11 @@ model LiteLLM_LensWorker {
data Json
}
+model LiteLLM_LensIngestionKey {
+ id String @id
+ data Json
+}
+
model LiteLLM_LensDataset {
id String
revision Int
@@ -1997,3 +2047,14 @@ model LiteLLM_LensTraceSignal {
@@id([trace_id, trace_ref])
}
+
+model LiteLLM_TelemetryReport {
+ id String @id
+ window_start Float
+ window_end Float
+ report Json
+ created_at DateTime @default(now())
+
+ @@index([window_end])
+ @@index([created_at])
+}
diff --git a/litellm-proxy-extras/litellm_proxy_extras/utils.py b/litellm-proxy-extras/litellm_proxy_extras/utils.py
index 1fd292b8137..4a311331fc0 100644
--- a/litellm-proxy-extras/litellm_proxy_extras/utils.py
+++ b/litellm-proxy-extras/litellm_proxy_extras/utils.py
@@ -9,9 +9,10 @@ import tempfile
import threading
import time
from collections.abc import Callable
+from contextlib import AbstractContextManager, nullcontext
from dataclasses import dataclass, replace
from pathlib import Path
-from typing import TYPE_CHECKING, Final, Optional, Union
+from typing import TYPE_CHECKING, Final, Optional, Protocol, Union
from urllib.parse import unquote, urlsplit
from litellm_proxy_extras import prisma_toolchain
@@ -35,6 +36,12 @@ if TYPE_CHECKING:
import psycopg.sql
+class LensCheckConnect(Protocol):
+ def __call__(
+ self, conninfo: str, *, connect_timeout: int, autocommit: bool
+ ) -> "psycopg.Connection[tuple[object, ...]]": ...
+
+
def str_to_bool(value: Optional[str]) -> bool:
if value is None:
return False
@@ -56,6 +63,7 @@ def _get_prisma_env() -> dict:
_MIGRATION_TS_RE = re.compile(r"^(\d{14})_")
_MIGRATION_DEADLOCK_MARKER = "deadlock detected"
+_MIGRATION_LOCK_TIMEOUT_MARKER = "canceling statement due to lock timeout"
INDEX_REPAIR_ADVISORY_LOCK_KEY: Final = int.from_bytes(b"litellm", "big")
_TRANSIENT_INDEX_SUFFIX_RE: Final = re.compile(r"_cc(?:new|old)\d*$")
_INVALID_LITELLM_INDEXES_SQL: Final = (
@@ -243,11 +251,9 @@ def _redact_credentials(text: str) -> str:
passwords: Final = sorted(_configured_database_passwords(), key=len, reverse=True)
alternation: Final = "|".join(re.escape(password) for password in passwords)
password_pattern: Final = (
- re.compile(rf"(?P:|password=)(?:{alternation})(?=@|&|$|[\s'\"\]),])", re.IGNORECASE)
- if passwords
- else None
+ re.compile(rf"(?{_REDACTED}", text) if password_pattern is not None else text
+ result: Final = password_pattern.sub(_REDACTED, text) if password_pattern is not None else text
return _secret_shape_redactor()(result)
@@ -690,7 +696,7 @@ class ProxyExtrasDBManager:
)
@staticmethod
- def raise_if_lens_rename_pending() -> None:
+ def raise_if_lens_rename_pending(connect: LensCheckConnect | None = None) -> None:
database_url: Final = os.environ.get("DATABASE_URL")
if not database_url:
return
@@ -698,8 +704,9 @@ class ProxyExtrasDBManager:
import psycopg
except ImportError as exc:
raise RuntimeError("Install psycopg to verify Lens data safety before prisma db push.") from exc
+ open_connection: Final = connect if connect is not None else psycopg.connect
try:
- with psycopg.connect(
+ with open_connection(
ProxyExtrasDBManager._strip_prisma_query_params(database_url), connect_timeout=10, autocommit=True
) as connection:
legacy: Final = connection.execute(
@@ -710,7 +717,8 @@ class ProxyExtrasDBManager:
).fetchone()
except psycopg.Error as exc:
raise RuntimeError(
- "Cannot verify Lens data safety; refusing prisma db push. Check database connectivity and psycopg installation."
+ "Cannot verify Lens data safety; refusing prisma db push. "
+ f"The database check failed: {_redact_credentials(str(exc)).strip()}"
) from exc
if legacy is not None:
raise RuntimeError(
@@ -1014,7 +1022,11 @@ class ProxyExtrasDBManager:
def _setup_database_v2(use_migrate: bool) -> bool:
if not use_migrate:
return ProxyExtrasDBManager._run_database_v2(False)
- from litellm_proxy_extras.migration_lock import migration_environment, migration_lock
+ from litellm_proxy_extras.migration_lock import (
+ migration_environment,
+ migration_lock,
+ prisma_migration_lock,
+ )
from litellm_proxy_extras.migration_recovery import (
baseline_current_schema,
recover_completed_migration,
@@ -1048,7 +1060,9 @@ class ProxyExtrasDBManager:
migration_environment(_get_prisma_env()),
)
- while not ProxyExtrasDBManager._run_database_v2(True, recover_completed, baseline_existing):
+ while not ProxyExtrasDBManager._run_database_v2(
+ True, recover_completed, baseline_existing, lambda: prisma_migration_lock(lock_url)
+ ):
continue
return True
@@ -1057,6 +1071,7 @@ class ProxyExtrasDBManager:
use_migrate: bool,
recover_completed: Callable[[str], bool] = lambda name: False,
baseline_existing: "Callable[[str], None] | None" = None,
+ deploy_lock: Callable[[], AbstractContextManager[None]] = nullcontext,
) -> bool:
"""
v2 migration resolver (opt-in via --use_v2_migration_resolver).
@@ -1119,11 +1134,12 @@ class ProxyExtrasDBManager:
while not budget.exhausted:
attempt_started = time.monotonic()
try:
- result = prisma_toolchain.run_prisma(
- [_get_prisma_command(), "migrate", "deploy"],
- timeout=deploy_timeout,
- env=migration_env,
- )
+ with deploy_lock():
+ result = prisma_toolchain.run_prisma(
+ [_get_prisma_command(), "migrate", "deploy"],
+ timeout=deploy_timeout,
+ env=migration_env,
+ )
logger.info(f"prisma migrate deploy stdout: {result.stdout}")
return True
@@ -1148,6 +1164,7 @@ class ProxyExtrasDBManager:
e,
budget,
time.monotonic() - attempt_started,
+ deploy_lock=deploy_lock,
)
if next_budget.attempts_left < budget.attempts_left:
@@ -1156,9 +1173,10 @@ class ProxyExtrasDBManager:
raise RuntimeError(
f"Database migration failed after {MAX_MIGRATE_DEPLOY_ATTEMPTS} "
- "attempts that made no progress (timeouts or deadlock retries). Check database connectivity, "
- "load, and _prisma_migrations ledger state, and raise "
- f"{PRISMA_MIGRATE_DEPLOY_TIMEOUT_ENV_VAR} if the attempts timed out."
+ "attempts that made no progress (timeouts, lock timeouts or deadlock retries). "
+ "Check database connectivity, load and _prisma_migrations ledger state. "
+ "If a migration hit SET LOCAL lock_timeout, check for long-running transactions on the migrated tables. "
+ f"Raise {PRISMA_MIGRATE_DEPLOY_TIMEOUT_ENV_VAR} if the attempts timed out."
)
finally:
os.chdir(original_dir)
@@ -1179,15 +1197,19 @@ class ProxyExtrasDBManager:
return match.group(1) if match else None
@staticmethod
- def _v2_roll_back_migration_best_effort(migration_name: str) -> None:
+ def _v2_roll_back_migration_best_effort(
+ migration_name: str,
+ deploy_lock: Callable[[], AbstractContextManager[None]] = nullcontext,
+ ) -> None:
from litellm_proxy_extras.migration_lock import migration_environment
try:
- prisma_toolchain.run_prisma(
- [_get_prisma_command(), "migrate", "resolve", "--rolled-back", migration_name],
- timeout=prisma_command_timeout(),
- env=migration_environment(_get_prisma_env()),
- )
+ with deploy_lock():
+ prisma_toolchain.run_prisma(
+ [_get_prisma_command(), "migrate", "resolve", "--rolled-back", migration_name],
+ timeout=prisma_command_timeout(),
+ env=migration_environment(_get_prisma_env()),
+ )
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
pass
@@ -1196,6 +1218,7 @@ class ProxyExtrasDBManager:
error: subprocess.CalledProcessError,
budget: "_MigrateAttemptBudget",
attempt_seconds: float = 0.0,
+ deploy_lock: Callable[[], AbstractContextManager[None]] = nullcontext,
) -> "_MigrateAttemptBudget":
"""Recover from one failed `prisma migrate deploy`, and price the pass.
@@ -1217,7 +1240,14 @@ class ProxyExtrasDBManager:
"deadlock race, rolling its ledger row back and retrying",
migration_name,
)
- ProxyExtrasDBManager._v2_roll_back_migration_best_effort(migration_name)
+ ProxyExtrasDBManager._v2_roll_back_migration_best_effort(migration_name, deploy_lock)
+ return budget.spend()
+ if ledger_logs and _MIGRATION_LOCK_TIMEOUT_MARKER in ledger_logs:
+ logger.info(
+ "Migration %s timed out waiting for a table lock, rolling its ledger row back and retrying",
+ migration_name,
+ )
+ ProxyExtrasDBManager._v2_roll_back_migration_best_effort(migration_name, deploy_lock)
return budget.spend()
if ProxyExtrasDBManager._failed_migration_recovered(migration_name, started_at):
logger.info(
@@ -1261,7 +1291,17 @@ class ProxyExtrasDBManager:
"rolling its ledger row back and retrying",
migration_name,
)
- ProxyExtrasDBManager._v2_roll_back_migration_best_effort(migration_name)
+ ProxyExtrasDBManager._v2_roll_back_migration_best_effort(migration_name, deploy_lock)
+ return budget.spend()
+
+ if migration_name and _MIGRATION_LOCK_TIMEOUT_MARKER in stderr:
+ ProxyExtrasDBManager._log_migration_lock_holders(migration_name)
+ logger.warning(
+ "Migration %s timed out waiting for a table lock after hitting SET LOCAL lock_timeout, rolling "
+ "its ledger row back and retrying. Check for long-running transactions on the migrated tables.",
+ migration_name,
+ )
+ ProxyExtrasDBManager._v2_roll_back_migration_best_effort(migration_name, deploy_lock)
return budget.spend()
raise RuntimeError(
@@ -1276,6 +1316,13 @@ class ProxyExtrasDBManager:
)
return budget.spend()
+ if _MIGRATION_LOCK_TIMEOUT_MARKER in stderr:
+ logger.info(
+ "Waiting for the advisory lock held by another Prisma migration; "
+ "contention does not spend a migration failure attempt"
+ )
+ return budget.after_contention(attempt_seconds)
+
if "P1002" in stderr and "advisory lock" in stderr:
logger.info(
"Waiting for the advisory lock held by another Prisma migration; "
@@ -1288,6 +1335,68 @@ class ProxyExtrasDBManager:
f"Manual intervention required.\n\nPrisma error:\n{stderr}"
) from error
+ @staticmethod
+ def _log_migration_lock_holders(migration_name: str) -> None:
+ """Best-effort log of the sessions holding locks on the tables a timed-out migration touches."""
+ try:
+ migration_sql: Final = (
+ Path(ProxyExtrasDBManager._get_prisma_dir()) / "migrations" / migration_name / "migration.sql"
+ ).read_text()
+ except OSError:
+ return
+ relations: Final = sorted(set(re.findall(r'"(LiteLLM_\w+)"', migration_sql)))
+ database_url: Final = os.getenv("DATABASE_URL")
+ if not relations or not database_url:
+ return
+ try:
+ import psycopg
+ except ImportError:
+ return
+ try:
+ with psycopg.connect(
+ ProxyExtrasDBManager._strip_prisma_query_params(database_url),
+ connect_timeout=10,
+ autocommit=True,
+ options="-c statement_timeout=5000",
+ ) as conn:
+ rows: Final = conn.execute(
+ "SELECT l.pid, c.relname, l.mode, a.state, a.application_name, "
+ "date_trunc('second', now() - a.xact_start) "
+ "FROM pg_locks l "
+ "JOIN pg_class c ON c.oid = l.relation "
+ "JOIN pg_namespace n ON n.oid = c.relnamespace "
+ "JOIN pg_stat_activity a ON a.pid = l.pid "
+ "WHERE l.granted AND n.nspname = %s AND c.relname = ANY(%s) AND l.pid <> pg_backend_pid() "
+ "ORDER BY a.xact_start NULLS LAST "
+ "LIMIT 10",
+ (
+ ProxyExtrasDBManager._prisma_schema_param(database_url) or "public",
+ relations,
+ ),
+ ).fetchall()
+ except psycopg.Error:
+ return
+ if not rows:
+ logger.warning(
+ "Migration %s timed out waiting for a lock, but no current lock holder "
+ "was found on %s; the holder has likely since committed or rolled back",
+ migration_name,
+ ", ".join(relations),
+ )
+ return
+ for pid, relname, mode, state, application_name, age in rows:
+ logger.warning(
+ "Migration %s timed out waiting for a lock on %s held by pid %s "
+ "(mode %s, state %s, application %s, transaction open for %s)",
+ migration_name,
+ relname,
+ pid,
+ mode,
+ state,
+ application_name,
+ age,
+ )
+
@staticmethod
def _mark_migration_applied(name: str) -> None:
"""Roll a failed ledger row back if it is still there, then mark it applied."""
diff --git a/litellm-proxy-extras/pyproject.toml b/litellm-proxy-extras/pyproject.toml
index 00984dbbf82..61d56c6b301 100644
--- a/litellm-proxy-extras/pyproject.toml
+++ b/litellm-proxy-extras/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "litellm-proxy-extras"
-version = "0.4.106"
+version = "0.4.109"
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
readme = "README.md"
requires-python = ">=3.9"
@@ -30,7 +30,7 @@ required-version = ">=0.10.9"
module-root = ""
[tool.commitizen]
-version = "0.4.106"
+version = "0.4.109"
version_files = [
"pyproject.toml:^version",
"../pyproject.toml:litellm-proxy-extras==",
diff --git a/litellm-rust/.agents/skills/rust-string-enums/SKILL.md b/litellm-rust/.agents/skills/rust-string-enums/SKILL.md
index fc6988639d0..5fc067790a0 100644
--- a/litellm-rust/.agents/skills/rust-string-enums/SKILL.md
+++ b/litellm-rust/.agents/skills/rust-string-enums/SKILL.md
@@ -35,7 +35,7 @@ pub enum EventType {
}
```
-Use `#[strum(serialize_all = "snake_case")]` or another supported case style when it exactly matches the contract. Use explicit variant spellings otherwise. With multiple accepted aliases, set `to_string` to the existing canonical output: Strum Display otherwise selects the longest `serialize` spelling
+Spell each variant explicitly with `#[strum(serialize = "...")]`; do not use `serialize_all`. With multiple accepted aliases, set `to_string` to the existing canonical output: Strum Display otherwise selects the longest `serialize` spelling
Use only the derives the contract needs. A deserialize-only type should remain deserialize-only. Do not add an unknown variant to a closed enum, or derive Serde for a type that currently has no serialization contract
@@ -43,7 +43,7 @@ Plain Serde derives with `rename` or `rename_all` remain appropriate for closed
## Preserve behavior during migration
-Read the type, its callers, and existing tests before changing it. Preserve canonical output, accepted aliases, case sensitivity, whitespace handling, unknown values, malformed-input rejection, and existing public conversion APIs. Keep conversions needed by callers or compatibility even when Serde no longer uses them
+Read the type, its callers, and existing tests before changing it. Preserve canonical output, accepted aliases, case sensitivity, whitespace handling, unknown values, malformed-input rejection, and existing public conversion contracts. Wrappers that only delegate to a Strum-derived trait are removed rather than kept: callers use `FromStr` and `From for &'static str` directly. Keep conversions that add behavior needed by callers or compatibility even when Serde no longer uses them
Use the workspace dependencies and enable `serde_with.workspace = true` in a crate only when needed. Check the versions and enabled features in `Cargo.toml` and `Cargo.lock` rather than upgrading dependencies for this refactor
diff --git a/litellm-rust/AGENTS.md b/litellm-rust/AGENTS.md
index 442dfd8e957..069d9fd99ea 100644
--- a/litellm-rust/AGENTS.md
+++ b/litellm-rust/AGENTS.md
@@ -4,24 +4,31 @@ For diagnostic tracing changes, follow [.agents/skills/rust-tracing/SKILL.md](.a
For string-valued enums and their Serde conversions, follow [.agents/skills/rust-string-enums/SKILL.md](.agents/skills/rust-string-enums/SKILL.md)
+For fieldless enums, derive `strum::VariantArray` and use `VARIANTS` instead of a hand-listed `ALL` array. Derive Strum string conversions instead of hand-written variant-to-string matches
+
+Use the derived conversions directly (`<&'static str>::from(x)` / `.into()`, `str::parse`) with no `as_str`/`parse` wrapper that only delegates, and spell each variant with explicit `#[strum(serialize = "...")]` instead of `serialize_all`
+
## Test placement
- Never create a `tests.rs` (or `test.rs`) file under `src/`, and never `#[path = "tests.rs"] mod tests;`
-- A test that reaches private items lives inline, in a `#[cfg(test)] mod tests { ... }` at the bottom of the file that owns those items
-- A test that only uses the crate's public API lives in `crates//tests/.rs`, next to `src/`
-- Split a mixed test file along that line instead of widening visibility to move it
+- Tests live inline by default, in a `#[cfg(test)] mod tests { ... }` at the bottom of the file that owns the code under test, whether or not they touch private items
+- `crates//tests/` is only for tests that drive several modules together end to end, such as a full request through a route. A test of one type or function never goes there
+- A helper shared by several inline test modules goes in a `#[cfg(test)] mod test_support;` at the crate root, and only once a second module needs it
- A test for another crate's item belongs in that crate, not in a downstream one
-- Never set `autotests = false` or hand-list `[[test]]` targets; every file directly under `tests/` is discovered by cargo, and a shared helper goes in `tests//mod.rs` or `tests//support.rs` so it is not picked up as a test crate of its own
+- Never set `autotests = false` or hand-list `[[test]]` targets. Every file directly under `tests/` is discovered by cargo, and a shared helper goes in `tests//mod.rs` or `tests//support.rs` so it is not picked up as a test crate of its own
## Test fixtures and cases
Use [`#[rstest]`](https://docs.rs/rstest/latest/rstest/attr.rstest.html) for new and updated tests and [`#[fixture]`](https://docs.rs/rstest/latest/rstest/attr.fixture.html) for reusable setup, injected through typed test arguments. Express input variations as named `#[case::name(...)]` cases instead of loops or duplicated tests so each failure identifies its case. Keep behavior assertions in the test body and fixtures focused on setup. Use the workspace `rstest` dependency
+- Never loop over inputs (`for`, `.iter().for_each`, `.all`) inside a test body. Give each input its own `#[case::name(...)]`, or use `#[values(...)]` for a cross product
+- Exception: a test pinning a Rust table against a repo-owned data file (for example `include_str!` of a JSON config) may iterate that file's entries
+
## Error definitions
- A crate's errors live in `src/error.rs`, defined with `thiserror`, and re-exported from `lib.rs`
-- Put message templates in the variant's `#[error(...)]` declaration. Callers pass only the small typed arguments needed to fill them, never `Error::Variant(format!(...))` or a preformatted message. Keep the smallest set of neutral variants that callers need to distinguish; different wording or providers do not justify new variants
-- Default to one top-level `Error` enum per crate, with one variant per failure mode and a `#[error(...)]` message on each. A failure mode is something a caller handles differently (phase, status code, retry, a message Python parity pins exactly); failures no caller tells apart share one variant and differ only in its message
+- Put message templates in the variant's `#[error(...)]` declaration. Callers pass only the small typed arguments needed to fill them, never `Error::Variant(format!(...))` or a preformatted message. Keep the smallest set of neutral variants that callers need to distinguish. Different wording or providers do not justify new variants
+- Default to one top-level `Error` enum per crate, with one variant per failure mode and a `#[error(...)]` message on each. A failure mode is something a caller handles differently (phase, status code, retry, a message Python parity pins exactly). Failures no caller tells apart share one variant and differ only in its message
- Keep shared error enums minimal and provider-neutral. Provider names, credential types, configuration fields, and setup guidance belong in caller-supplied data, not dedicated variants or hardcoded shared messages. Reuse a variant for the same failure mode across providers, such as `MissingApiBase { provider: "Azure", guidance: "..." }`. An exact parity message does not justify a provider-specific variant when caller-supplied context can preserve it
- Wrap a lower-level error as a variant with `#[from]` or `#[source]` instead of flattening it to a string
- Exception: split into separate types when different functions fail in disjoint ways, especially when different callers see them. A shared enum would force every caller to match variants its function can never return
diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock
index a3ec1f5150c..dedd41b2efc 100644
--- a/litellm-rust/Cargo.lock
+++ b/litellm-rust/Cargo.lock
@@ -97,53 +97,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
-[[package]]
-name = "askama"
-version = "0.16.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6024d73179f43f15ccd2b881bfea6fee7f3a46ec53f33b52210dea749ebebaa4"
-dependencies = [
- "askama_macros",
- "itoa",
- "percent-encoding",
- "serde",
- "serde_json",
-]
-
-[[package]]
-name = "askama_derive"
-version = "0.16.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "071ee5ebf2138e3ad180e0aacf6940c2cab5e6d8333741d9925c7bee2b153f39"
-dependencies = [
- "askama_parser",
- "memchr",
- "proc-macro2",
- "quote",
- "rustc-hash",
- "syn 3.0.6",
-]
-
-[[package]]
-name = "askama_macros"
-version = "0.16.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "643e1c7cbb6aec1d920332fe51a7c0d8219e273dcb8602db03f5263e4d16487b"
-dependencies = [
- "askama_derive",
-]
-
-[[package]]
-name = "askama_parser"
-version = "0.16.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2c5ae75772275d268b03ab8bdccdd12117b6169ee23256942b34e46c9f476583"
-dependencies = [
- "rustc-hash",
- "unicode-ident",
- "winnow 1.0.4",
-]
-
[[package]]
name = "asn1-rs"
version = "0.7.2"
@@ -1100,6 +1053,15 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
+[[package]]
+name = "bs58"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
+dependencies = [
+ "tinyvec",
+]
+
[[package]]
name = "bstr"
version = "1.13.1"
@@ -1321,18 +1283,6 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
-[[package]]
-name = "const-hex"
-version = "1.19.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e59eef12462b0f9b0a3620219be5d639afd79fe39dff0a42c3997061f9298b4"
-dependencies = [
- "cfg-if",
- "cpufeatures 0.2.17",
- "proptest",
- "serde_core",
-]
-
[[package]]
name = "const-oid"
version = "0.9.6"
@@ -1608,12 +1558,12 @@ dependencies = [
[[package]]
name = "darling"
-version = "0.21.3"
+version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
- "darling_core 0.21.3",
- "darling_macro 0.21.3",
+ "darling_core 0.23.0",
+ "darling_macro 0.23.0",
]
[[package]]
@@ -1632,11 +1582,10 @@ dependencies = [
[[package]]
name = "darling_core"
-version = "0.21.3"
+version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
- "fnv",
"ident_case",
"proc-macro2",
"quote",
@@ -1657,11 +1606,11 @@ dependencies = [
[[package]]
name = "darling_macro"
-version = "0.21.3"
+version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
- "darling_core 0.21.3",
+ "darling_core 0.23.0",
"quote",
"syn 2.0.119",
]
@@ -2431,9 +2380,9 @@ dependencies = [
"http-body-util",
"hyper 1.10.1",
"lazy_static",
- "opentelemetry 0.32.0",
+ "opentelemetry",
"opentelemetry-semantic-conventions",
- "opentelemetry_sdk 0.32.1",
+ "opentelemetry_sdk",
"percent-encoding",
"pin-project",
"prost",
@@ -3429,10 +3378,12 @@ dependencies = [
"moka",
"reqwest 0.12.28",
"rstest",
+ "serde",
"serde_json",
"sha2 0.10.9",
"thiserror 2.0.19",
"tokio",
+ "veil",
]
[[package]]
@@ -3461,9 +3412,12 @@ dependencies = [
"litellm-auth-types",
"moka",
"rstest",
+ "serde",
"serde_json",
"sha2 0.10.9",
+ "tempfile",
"tokio",
+ "veil",
]
[[package]]
@@ -3472,6 +3426,7 @@ version = "0.1.0"
dependencies = [
"rstest",
"serde",
+ "serde_json",
"subtle",
"thiserror 2.0.19",
"tokio",
@@ -3485,6 +3440,7 @@ dependencies = [
"rstest",
"serde",
"serde_json",
+ "strum",
"thiserror 2.0.19",
"tokio",
]
@@ -3690,9 +3646,12 @@ name = "litellm-config"
version = "0.1.0"
dependencies = [
"litellm-auth-types",
+ "litellm-router-types",
"rstest",
"serde",
+ "serde_json",
"serde_yaml_ng",
+ "strum",
"tempfile",
"thiserror 2.0.19",
]
@@ -3963,13 +3922,16 @@ version = "0.1.0"
dependencies = [
"bytes",
"futures-util",
+ "litellm-coroutine",
"litellm-host",
+ "parking_lot",
"pyo3",
"pyo3-async-runtimes",
"pythonize",
"rstest",
"serde",
"serde_json",
+ "thiserror 2.0.19",
"tokio",
]
@@ -3990,6 +3952,7 @@ dependencies = [
"rustls-native-certs",
"serde",
"serde_json",
+ "strum",
"tempfile",
"thiserror 2.0.19",
"tokio",
@@ -4064,6 +4027,7 @@ dependencies = [
"litellm-cache-memory",
"litellm-cache-response",
"litellm-core-utils",
+ "litellm-framer",
"litellm-host",
"litellm-host-native",
"litellm-http",
@@ -4071,6 +4035,7 @@ dependencies = [
"litellm-inference-testing",
"litellm-llms",
"litellm-llms-types",
+ "litellm-router-types",
"litellm-secrets",
"litellm-tracing",
"reqwest 0.12.28",
@@ -4146,9 +4111,11 @@ name = "litellm-inference-testing"
version = "0.1.0"
dependencies = [
"futures-util",
+ "litellm-auth",
"litellm-http",
"litellm-inference",
"litellm-secrets",
+ "tokio",
]
[[package]]
@@ -4191,6 +4158,7 @@ dependencies = [
"litellm-http",
"litellm-llms-types",
"litellm-python-compat",
+ "litellm-router-types",
"litellm-secrets",
"reqwest 0.12.28",
"rstest",
@@ -4209,6 +4177,7 @@ dependencies = [
name = "litellm-llms-types"
version = "0.1.0"
dependencies = [
+ "indexmap 2.14.0",
"macro_rules_attribute",
"rstest",
"schemars 1.2.2",
@@ -4261,14 +4230,13 @@ dependencies = [
"litellm-inference-transcription",
"litellm-llms",
"litellm-llms-types",
+ "litellm-router-types",
"litellm-secrets",
"litellm-secrets-aws",
"litellm-secrets-types",
+ "litellm-spend-clickhouse",
"litellm-storage-clickhouse",
"litellm-token-counter",
- "litellm-traces",
- "litellm-traces-cache",
- "litellm-traces-clickhouse",
"litellm-tracing",
"prost",
"pyo3",
@@ -4311,9 +4279,21 @@ dependencies = [
"litellm-config",
"litellm-inference",
"litellm-inference-messages",
+ "litellm-router-types",
"rstest",
]
+[[package]]
+name = "litellm-router-types"
+version = "0.1.0"
+dependencies = [
+ "litellm-auth-types",
+ "rstest",
+ "serde",
+ "serde_json",
+ "serde_with",
+]
+
[[package]]
name = "litellm-secrets"
version = "0.1.0"
@@ -4469,6 +4449,26 @@ dependencies = [
"veil",
]
+[[package]]
+name = "litellm-spend-clickhouse"
+version = "0.1.0"
+dependencies = [
+ "flate2",
+ "litellm-http",
+ "litellm-storage-clickhouse",
+ "rstest",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+ "sqlx",
+ "testcontainers-modules",
+ "thiserror 2.0.19",
+ "time",
+ "tokio",
+ "uuid",
+ "wiremock",
+]
+
[[package]]
name = "litellm-storage-clickhouse"
version = "0.1.0"
@@ -4499,6 +4499,7 @@ dependencies = [
"serde",
"serde_json",
"sha2 0.10.9",
+ "strum",
"tar",
"target-lexicon",
"tempfile",
@@ -4563,76 +4564,6 @@ dependencies = [
"tiktoken-rs",
]
-[[package]]
-name = "litellm-traces"
-version = "0.1.0"
-dependencies = [
- "askama",
- "base64 0.22.1",
- "criterion",
- "indexmap 2.14.0",
- "litellm-llms-types",
- "macro_rules_attribute",
- "opentelemetry-proto",
- "prost",
- "rstest",
- "schemars 1.2.2",
- "serde",
- "serde_json",
- "serde_with",
- "sha2 0.10.9",
- "strum",
- "thiserror 2.0.19",
- "time",
-]
-
-[[package]]
-name = "litellm-traces-cache"
-version = "0.1.0"
-dependencies = [
- "base64 0.22.1",
- "litellm-traces",
- "moka",
- "rstest",
- "serde",
- "serde_json",
- "sha2 0.10.9",
- "thiserror 2.0.19",
- "time",
- "tokio",
- "tracing",
-]
-
-[[package]]
-name = "litellm-traces-clickhouse"
-version = "0.1.0"
-dependencies = [
- "askama",
- "flate2",
- "futures-util",
- "hmac 0.12.1",
- "jsonschema",
- "litellm-http",
- "litellm-storage-clickhouse",
- "litellm-traces",
- "litellm-traces-cache",
- "macro_rules_attribute",
- "moka",
- "rstest",
- "schemars 1.2.2",
- "serde",
- "serde_json",
- "sha2 0.10.9",
- "sqlx",
- "strum",
- "testcontainers-modules",
- "thiserror 2.0.19",
- "time",
- "tokio",
- "url",
- "wiremock",
-]
-
[[package]]
name = "litellm-tracing"
version = "0.1.0"
@@ -5042,36 +4973,6 @@ dependencies = [
"tracing",
]
-[[package]]
-name = "opentelemetry"
-version = "0.33.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6cdb0b1b267eb9db3331b434ed9ddab10d50e280a9adf9d13e5233e2002b61b5"
-dependencies = [
- "futures-core",
- "futures-sink",
- "js-sys",
- "pin-project-lite",
- "thiserror 2.0.19",
- "tracing",
-]
-
-[[package]]
-name = "opentelemetry-proto"
-version = "0.33.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "25da1ac11a0aeccf38d7f77ee0348715adaf8340f65ad46c94a02c6b20e2f65d"
-dependencies = [
- "base64 0.22.1",
- "const-hex",
- "opentelemetry 0.33.0",
- "opentelemetry_sdk 0.33.0",
- "prost",
- "serde",
- "tonic",
- "tonic-prost",
-]
-
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.32.1"
@@ -5087,23 +4988,7 @@ dependencies = [
"futures-channel",
"futures-executor",
"futures-util",
- "opentelemetry 0.32.0",
- "percent-encoding",
- "portable-atomic",
- "rand 0.9.5",
- "thiserror 2.0.19",
-]
-
-[[package]]
-name = "opentelemetry_sdk"
-version = "0.33.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb39533d9d1c912123efd7d41d7e0c29d16917b60ce15b4c8d87cb1af7f67520"
-dependencies = [
- "futures-channel",
- "futures-executor",
- "futures-util",
- "opentelemetry 0.33.0",
+ "opentelemetry",
"percent-encoding",
"portable-atomic",
"rand 0.9.5",
@@ -6441,6 +6326,7 @@ checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
dependencies = [
"chrono",
"dyn-clone",
+ "indexmap 2.14.0",
"ref-cast",
"schemars_derive",
"serde",
@@ -6631,11 +6517,12 @@ dependencies = [
[[package]]
name = "serde_with"
-version = "3.16.1"
+version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7"
+checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
dependencies = [
"base64 0.22.1",
+ "bs58",
"chrono",
"hex",
"indexmap 1.9.3",
@@ -6650,11 +6537,11 @@ dependencies = [
[[package]]
name = "serde_with_macros"
-version = "3.16.1"
+version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c"
+checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
dependencies = [
- "darling 0.21.3",
+ "darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.119",
@@ -7825,7 +7712,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26"
dependencies = [
"js-sys",
- "opentelemetry 0.32.0",
+ "opentelemetry",
"tracing",
"tracing-core",
"tracing-subscriber",
diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml
index 051082329f3..f04eb115071 100644
--- a/litellm-rust/Cargo.toml
+++ b/litellm-rust/Cargo.toml
@@ -11,10 +11,9 @@ repository = "https://github.com/BerriAI/litellm"
[workspace.dependencies]
litellm-config = { path = "crates/config" }
litellm-router = { path = "crates/router" }
+litellm-router-types = { path = "crates/router-types" }
litellm-tracing = { path = "crates/tracing" }
-litellm-traces = { path = "crates/traces" }
-litellm-traces-cache = { path = "crates/traces-cache" }
-litellm-traces-clickhouse = { path = "crates/traces-clickhouse" }
+litellm-spend-clickhouse = { path = "crates/spend-clickhouse" }
litellm-storage-clickhouse = { path = "crates/storage-clickhouse" }
litellm-inference = { path = "crates/inference" }
litellm-inference-transcription = { path = "crates/inference-transcription" }
@@ -81,6 +80,7 @@ http = "1"
google-cloud-auth = { version = "1.16.0", default-features = false }
jsonwebtoken = { version = "11.1.0", default-features = false }
hyper-util = { version = "0.1.20", default-features = false, features = ["client-proxy"] }
+parking_lot = "0.12.5"
proptest = "1.7.0"
pyo3 = "0.29.2"
pyo3-async-runtimes = { version = "0.29.0", features = ["tokio-runtime"] }
@@ -100,7 +100,7 @@ vaultrs = { version = "=0.8.0", default-features = false, features = ["rustls"]
rustls-native-certs = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", features = ["float_roundtrip"] }
-serde_with = { version = "=3.16.1", default-features = false, features = ["std", "macros"] }
+serde_with = { version = "=3.21.0", default-features = false, features = ["std", "macros"] }
sha2 = "0.10"
syn = { version = "2", default-features = false }
sqlx = { version = "0.9.0", default-features = false, features = ["json", "macros", "postgres", "runtime-tokio", "chrono", "tls-rustls-ring-native-roots"] }
diff --git a/litellm-rust/crates/auth-aws/Cargo.toml b/litellm-rust/crates/auth-aws/Cargo.toml
index 2a9a9e4768c..65b411ffaef 100644
--- a/litellm-rust/crates/auth-aws/Cargo.toml
+++ b/litellm-rust/crates/auth-aws/Cargo.toml
@@ -10,9 +10,11 @@ litellm-auth-types.workspace = true
litellm-http.workspace = true
moka = { workspace = true, features = ["sync"] }
-serde_json.workspace = true
+serde.workspace = true
+serde_json = { workspace = true, features = ["preserve_order"] }
sha2.workspace = true
thiserror.workspace = true
+veil.workspace = true
aws-config = { version = "1.9.0", default-features = false, features = ["rustls", "rt-tokio"] }
aws-credential-types = { version = "1.3.0", features = ["hardcoded-credentials"] }
diff --git a/litellm-rust/crates/auth-aws/src/aws.rs b/litellm-rust/crates/auth-aws/src/aws.rs
index 69eb4265159..8fdbba21711 100644
--- a/litellm-rust/crates/auth-aws/src/aws.rs
+++ b/litellm-rust/crates/auth-aws/src/aws.rs
@@ -3,7 +3,6 @@ use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
use moka::sync::Cache;
-use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use aws_credential_types::Credentials;
@@ -14,10 +13,12 @@ use aws_sigv4::http_request::{
use aws_sigv4::sign::v4;
use aws_smithy_runtime_api::client::identity::Identity;
+use litellm_auth_types::AwsParams;
+
use super::Error;
use super::constants::{
- AWS_ACCESS_KEY_ID, AWS_EXTERNAL_ID, AWS_PROFILE_NAME, AWS_REGION, AWS_REGION_NAME,
- AWS_ROLE_ARN, AWS_ROLE_NAME, AWS_SECRET_ACCESS_KEY, AWS_SESSION_NAME, AWS_SESSION_TOKEN,
+ AWS_ACCESS_KEY_ID, AWS_EXTERNAL_ID, AWS_PROFILE_NAME, AWS_REGION_NAME, AWS_ROLE_ARN,
+ AWS_ROLE_NAME, AWS_SECRET_ACCESS_KEY, AWS_SESSION_NAME, AWS_SESSION_TOKEN,
AWS_SIGNED_HEADER_NAMES, AWS_STS_ENDPOINT, AWS_WEB_IDENTITY_TOKEN, AWS_WEB_IDENTITY_TOKEN_FILE,
DEFAULT_BEDROCK_REGION, DEFAULT_SESSION_NAME_PREFIX, SIGV4_COMPUTED_HEADER_NAMES,
};
@@ -543,50 +544,39 @@ fn is_bedrock_region(value: &str) -> bool {
/// region, then the environment. Each service decides what a missing one means.
pub fn resolve_aws_region(
model_region: Option<&str>,
- optional_params: &Map,
+ params: &AwsParams,
env_lookup: &dyn Fn(&str) -> Option,
) -> Option {
- optional_params
- .get("aws_region_name")
- .and_then(Value::as_str)
- .or(model_region)
- .map(str::to_string)
- .or_else(|| env_lookup(AWS_REGION_NAME))
- .or_else(|| env_lookup(AWS_REGION))
+ params
+ .resolve(&AwsParams::REGION, &|_| None)
+ .or_else(|| model_region.map(str::to_string))
+ .or_else(|| AwsParams::REGION.resolve(&|_| None, env_lookup))
}
pub fn resolve_bedrock_region(
model_region: Option<&str>,
- optional_params: &Map,
+ params: &AwsParams,
env_lookup: &dyn Fn(&str) -> Option,
) -> String {
- resolve_aws_region(model_region, optional_params, env_lookup)
+ resolve_aws_region(model_region, params, env_lookup)
.unwrap_or_else(|| DEFAULT_BEDROCK_REGION.to_string())
}
pub fn aws_auth_config(
- optional_params: &Map,
+ params: &AwsParams,
env_lookup: &dyn Fn(&str) -> Option,
) -> AwsAuthConfig {
- let value = |key: &str| {
- optional_params
- .get(key)
- .and_then(Value::as_str)
- .map(str::to_string)
- };
- let env = |key: &str| env_lookup(key);
AwsAuthConfig {
- access_key_id: value("aws_access_key_id").or_else(|| env("AWS_ACCESS_KEY_ID")),
- secret_access_key: value("aws_secret_access_key").or_else(|| env("AWS_SECRET_ACCESS_KEY")),
- session_token: value("aws_session_token").or_else(|| env("AWS_SESSION_TOKEN")),
- region_name: value("aws_region_name").or_else(|| env(AWS_REGION_NAME)),
- session_name: value("aws_session_name").or_else(|| env("AWS_SESSION_NAME")),
- profile_name: value("aws_profile_name").or_else(|| env("AWS_PROFILE_NAME")),
- role_name: value("aws_role_name").or_else(|| env("AWS_ROLE_NAME")),
- web_identity_token: value("aws_web_identity_token")
- .or_else(|| env("AWS_WEB_IDENTITY_TOKEN")),
- sts_endpoint: value("aws_sts_endpoint").or_else(|| env("AWS_STS_ENDPOINT")),
- external_id: value("aws_external_id").or_else(|| env("AWS_EXTERNAL_ID")),
+ access_key_id: params.resolve(&AwsParams::ACCESS_KEY_ID, env_lookup),
+ secret_access_key: params.resolve(&AwsParams::SECRET_ACCESS_KEY, env_lookup),
+ session_token: params.resolve(&AwsParams::SESSION_TOKEN, env_lookup),
+ region_name: params.resolve(&AwsParams::REGION, env_lookup),
+ session_name: params.resolve(&AwsParams::SESSION_NAME, env_lookup),
+ profile_name: params.resolve(&AwsParams::PROFILE_NAME, env_lookup),
+ role_name: params.resolve(&AwsParams::ROLE_NAME, env_lookup),
+ web_identity_token: params.resolve(&AwsParams::WEB_IDENTITY_TOKEN, env_lookup),
+ sts_endpoint: params.resolve(&AwsParams::STS_ENDPOINT, env_lookup),
+ external_id: params.resolve(&AwsParams::EXTERNAL_ID, env_lookup),
}
}
@@ -599,13 +589,10 @@ pub enum AwsCredentialSource {
}
impl AwsCredentialSource {
- pub fn from_params(
- optional_params: &Map,
- env_lookup: &dyn Fn(&str) -> Option,
- ) -> Self {
- match host_supplied_credentials(optional_params) {
+ pub fn from_params(params: &AwsParams, env_lookup: &dyn Fn(&str) -> Option) -> Self {
+ match host_supplied_credentials(params) {
Some(credentials) => Self::HostSupplied(credentials),
- None => Self::Chain(aws_auth_config(optional_params, env_lookup)),
+ None => Self::Chain(aws_auth_config(params, env_lookup)),
}
}
@@ -629,20 +616,20 @@ impl AwsCredentialSource {
/// state, where an unrelated `AWS_ROLE_NAME` or `AWS_PROFILE_NAME` in the
/// environment outranks explicit keys in [`classify_auth`] and the two sides
/// would sign as different principals.
-pub fn host_supplied_credentials(optional_params: &Map) -> Option {
- let value = |key: &str| {
- optional_params
- .get(key)
- .and_then(Value::as_str)
+pub fn host_supplied_credentials(params: &AwsParams) -> Option {
+ let value = |value: &Option| {
+ value
+ .as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
+ .map(str::to_string)
};
- let access_key_id = value("aws_access_key_id")?;
- let secret_access_key = value("aws_secret_access_key")?;
+ let access_key_id = value(¶ms.aws_access_key_id)?;
+ let secret_access_key = value(¶ms.aws_secret_access_key)?;
Some(Credentials::new(
access_key_id,
secret_access_key,
- value("aws_session_token").map(str::to_string),
+ value(¶ms.aws_session_token),
None,
"litellm-host-supplied",
))
@@ -651,7 +638,7 @@ pub fn host_supplied_credentials(optional_params: &Map) -> Option
#[cfg(test)]
mod tests {
use super::*;
- use crate::constants::BEDROCK_SERVICE;
+ use crate::constants::{AWS_REGION, BEDROCK_SERVICE};
fn no_env(_: &str) -> Option {
None
@@ -667,37 +654,80 @@ mod tests {
recorded.lock().unwrap().insert(name.to_string());
None
};
- resolve_aws_region(None, &Map::new(), &env);
- aws_auth_config(&Map::new(), &env);
+ resolve_aws_region(None, &AwsParams::default(), &env);
+ aws_auth_config(&AwsParams::default(), &env);
assert!(
seen.lock()
.unwrap()
.iter()
- .all(|name| crate::constants::SECRET_NAMES.contains(&name.as_str()))
+ .all(|name| AwsParams::secret_names().contains(&name.as_str()))
);
}
- #[test]
- fn a_region_comes_from_the_call_then_the_model_then_the_environment() {
- let params = Map::from_iter([("aws_region_name".to_string(), Value::from("eu-west-1"))]);
- let region_name = |key: &str| (key == AWS_REGION_NAME).then(|| "ap-south-1".to_string());
- let region = |key: &str| (key == AWS_REGION).then(|| "sa-east-1".to_string());
-
- let resolved = [
- resolve_aws_region(Some("us-east-2"), ¶ms, ®ion_name),
- resolve_aws_region(Some("us-east-2"), &Map::new(), ®ion_name),
- resolve_aws_region(None, &Map::new(), ®ion_name),
- resolve_aws_region(None, &Map::new(), ®ion),
- resolve_aws_region(None, &Map::new(), &no_env),
- ];
+ #[rstest::rstest]
+ #[case::param_wins(Some("from-params"), &[(AWS_SECRET_ACCESS_KEY, "from-env")], Some("from-params"))]
+ #[case::blank_param_falls_to_environment(Some(" "), &[(AWS_SECRET_ACCESS_KEY, "from-env")], Some("from-env"))]
+ #[case::absent_param_falls_to_environment(None, &[(AWS_SECRET_ACCESS_KEY, "from-env")], Some("from-env"))]
+ #[case::nothing(None, &[], None)]
+ fn auth_config_reads_each_spec_from_params_then_the_environment(
+ #[case] aws_secret_access_key: Option<&str>,
+ #[case] environment: &[(&str, &str)],
+ #[case] expected: Option<&str>,
+ ) {
+ let params = AwsParams {
+ aws_secret_access_key: aws_secret_access_key.map(str::to_string),
+ ..AwsParams::default()
+ };
+ let env = |key: &str| {
+ environment
+ .iter()
+ .find(|(name, _)| *name == key)
+ .map(|(_, value)| value.to_string())
+ };
assert_eq!(
- resolved.map(|region| region.unwrap_or_else(|| "none".into())),
- ["eu-west-1", "us-east-2", "ap-south-1", "sa-east-1", "none"]
+ aws_auth_config(¶ms, &env).secret_access_key.as_deref(),
+ expected
);
assert_eq!(
- resolve_bedrock_region(None, &Map::new(), &no_env),
- DEFAULT_BEDROCK_REGION
+ aws_auth_config(&AwsParams::default(), &|key: &str| (key == AWS_REGION)
+ .then(|| "sa-east-1".to_string()))
+ .region_name
+ .as_deref(),
+ Some("sa-east-1")
+ );
+ }
+
+ #[rstest::rstest]
+ #[case::call_params(Some("us-east-2"), Some("eu-west-1"), &[(AWS_REGION_NAME, "ap-south-1")], Some("eu-west-1"))]
+ #[case::model_region(Some("us-east-2"), None, &[(AWS_REGION_NAME, "ap-south-1")], Some("us-east-2"))]
+ #[case::aws_region_name(None, None, &[(AWS_REGION_NAME, "ap-south-1")], Some("ap-south-1"))]
+ #[case::aws_region(None, None, &[(AWS_REGION, "sa-east-1")], Some("sa-east-1"))]
+ #[case::nothing(None, None, &[], None)]
+ fn a_region_comes_from_the_call_then_the_model_then_the_environment(
+ #[case] model_region: Option<&str>,
+ #[case] aws_region_name: Option<&str>,
+ #[case] environment: &[(&str, &str)],
+ #[case] expected: Option<&str>,
+ ) {
+ let params = AwsParams {
+ aws_region_name: aws_region_name.map(str::to_string),
+ ..AwsParams::default()
+ };
+ let env = |key: &str| {
+ environment
+ .iter()
+ .find(|(name, _)| *name == key)
+ .map(|(_, value)| value.to_string())
+ };
+
+ assert_eq!(
+ resolve_aws_region(model_region, ¶ms, &env).as_deref(),
+ expected
+ );
+ assert_eq!(
+ resolve_bedrock_region(model_region, ¶ms, &env),
+ expected.unwrap_or(DEFAULT_BEDROCK_REGION)
);
}
diff --git a/litellm-rust/crates/auth-aws/src/constants.rs b/litellm-rust/crates/auth-aws/src/constants.rs
index 26df4f2a350..789fde156bd 100644
--- a/litellm-rust/crates/auth-aws/src/constants.rs
+++ b/litellm-rust/crates/auth-aws/src/constants.rs
@@ -1,33 +1,21 @@
-pub const AWS_ACCESS_KEY_ID: &str = "AWS_ACCESS_KEY_ID";
-pub const AWS_SECRET_ACCESS_KEY: &str = "AWS_SECRET_ACCESS_KEY";
-pub const AWS_SESSION_TOKEN: &str = "AWS_SESSION_TOKEN";
-pub const AWS_REGION_NAME: &str = "AWS_REGION_NAME";
-pub const AWS_REGION: &str = "AWS_REGION";
+use litellm_auth_types::AwsParams;
+
+pub const AWS_ACCESS_KEY_ID: &str = AwsParams::ACCESS_KEY_ID.env[0];
+pub const AWS_SECRET_ACCESS_KEY: &str = AwsParams::SECRET_ACCESS_KEY.env[0];
+pub const AWS_SESSION_TOKEN: &str = AwsParams::SESSION_TOKEN.env[0];
+pub const AWS_REGION_NAME: &str = AwsParams::REGION.env[0];
+pub const AWS_REGION: &str = AwsParams::REGION.env[1];
pub const AWS_DEFAULT_REGION: &str = "AWS_DEFAULT_REGION";
-pub const AWS_BEDROCK_RUNTIME_ENDPOINT: &str = "AWS_BEDROCK_RUNTIME_ENDPOINT";
-pub const AWS_SESSION_NAME: &str = "AWS_SESSION_NAME";
-pub const AWS_PROFILE_NAME: &str = "AWS_PROFILE_NAME";
-pub const AWS_ROLE_NAME: &str = "AWS_ROLE_NAME";
-pub const AWS_WEB_IDENTITY_TOKEN: &str = "AWS_WEB_IDENTITY_TOKEN";
+pub const AWS_BEDROCK_RUNTIME_ENDPOINT: &str = AwsParams::BEDROCK_RUNTIME_ENDPOINT.env[0];
+pub const AWS_SESSION_NAME: &str = AwsParams::SESSION_NAME.env[0];
+pub const AWS_PROFILE_NAME: &str = AwsParams::PROFILE_NAME.env[0];
+pub const AWS_ROLE_NAME: &str = AwsParams::ROLE_NAME.env[0];
+pub const AWS_WEB_IDENTITY_TOKEN: &str = AwsParams::WEB_IDENTITY_TOKEN.env[0];
pub const AWS_ROLE_ARN: &str = "AWS_ROLE_ARN";
pub const AWS_WEB_IDENTITY_TOKEN_FILE: &str = "AWS_WEB_IDENTITY_TOKEN_FILE";
-pub const AWS_STS_ENDPOINT: &str = "AWS_STS_ENDPOINT";
-pub const AWS_EXTERNAL_ID: &str = "AWS_EXTERNAL_ID";
+pub const AWS_STS_ENDPOINT: &str = AwsParams::STS_ENDPOINT.env[0];
+pub const AWS_EXTERNAL_ID: &str = AwsParams::EXTERNAL_ID.env[0];
pub const AWS_BEARER_TOKEN_BEDROCK: &str = "AWS_BEARER_TOKEN_BEDROCK";
-pub const SECRET_NAMES: &[&str] = &[
- AWS_ACCESS_KEY_ID,
- AWS_SECRET_ACCESS_KEY,
- AWS_SESSION_TOKEN,
- AWS_REGION_NAME,
- AWS_REGION,
- AWS_SESSION_NAME,
- AWS_PROFILE_NAME,
- AWS_ROLE_NAME,
- AWS_WEB_IDENTITY_TOKEN,
- AWS_STS_ENDPOINT,
- AWS_EXTERNAL_ID,
-];
-
/// Headers SigV4 covers, beyond the `x-amz-` / `x-amzn-` prefixes. Mirrors
/// Python's `_filter_headers_for_aws_signature` allowlist.
pub const AWS_SIGNED_HEADER_NAMES: &[&str] = &[
diff --git a/litellm-rust/crates/auth-gcp/Cargo.toml b/litellm-rust/crates/auth-gcp/Cargo.toml
index 8a3598234e1..24791ce7b53 100644
--- a/litellm-rust/crates/auth-gcp/Cargo.toml
+++ b/litellm-rust/crates/auth-gcp/Cargo.toml
@@ -12,9 +12,11 @@ google-sdk = ["dep:google-cloud-auth", "dep:http"]
litellm-auth-types.workspace = true
moka.workspace = true
+serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
tokio.workspace = true
+veil.workspace = true
gcp_auth = "0.12.7"
google-cloud-auth = { workspace = true, optional = true }
@@ -22,3 +24,4 @@ http = { workspace = true, optional = true }
[dev-dependencies]
rstest.workspace = true
+tempfile.workspace = true
diff --git a/litellm-rust/crates/auth-gcp/src/auth.rs b/litellm-rust/crates/auth-gcp/src/auth.rs
new file mode 100644
index 00000000000..d7c434fe26e
--- /dev/null
+++ b/litellm-rust/crates/auth-gcp/src/auth.rs
@@ -0,0 +1,274 @@
+use std::{future::Future, path::Path, pin::Pin, sync::Arc};
+
+use gcp_auth::{CustomServiceAccount, TokenProvider};
+use litellm_auth_types::{CredentialPlacement, Error, ErrorDetail, http::apply_credential};
+use moka::future::Cache;
+use serde_json::Value;
+use sha2::{Digest, Sha256};
+
+use crate::{
+ config::{
+ CredentialSource, VertexConfig, credential_source, get_vertex_ai_project, non_empty_env,
+ },
+ constants::{
+ CLOUD_PLATFORM_SCOPE, GOOGLE_OAUTH_TOKEN_ENDPOINT, VERTEX_AI_API_KEY_ENV,
+ VERTEXAI_API_KEY_ENV,
+ },
+};
+
+pub struct VertexEnvironment {
+ pub headers: Vec<(String, String)>,
+ pub project_id: String,
+}
+
+struct VertexAccessToken {
+ token: String,
+ project_id: String,
+}
+
+#[derive(Clone)]
+pub struct VertexAuth {
+ providers: Cache>,
+ loader: Arc,
+}
+
+impl Default for VertexAuth {
+ fn default() -> Self {
+ Self::new(Arc::new(GcpProviderLoader))
+ }
+}
+
+impl VertexAuth {
+ pub fn new(loader: Arc) -> Self {
+ Self {
+ providers: Cache::builder().max_capacity(64).build(),
+ loader,
+ }
+ }
+
+ pub async fn access_token(
+ &self,
+ config: &VertexConfig,
+ env_lookup: &(dyn Fn(&str) -> Option + Sync),
+ ) -> Result {
+ self.load_provider(config, env_lookup).await?.token().await
+ }
+
+ pub async fn validate_environment(
+ &self,
+ headers: Vec<(String, String)>,
+ api_key: Option<&str>,
+ config: &VertexConfig,
+ env_lookup: &(dyn Fn(&str) -> Option + Sync),
+ ) -> Result {
+ let has_authorization = headers
+ .iter()
+ .any(|(name, _)| name.eq_ignore_ascii_case("Authorization"));
+ let static_token = api_key
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(str::to_string)
+ .or_else(|| non_empty_env(env_lookup, VERTEX_AI_API_KEY_ENV))
+ .or_else(|| non_empty_env(env_lookup, VERTEXAI_API_KEY_ENV));
+ let project_id = get_vertex_ai_project(config, env_lookup);
+
+ if !has_authorization && static_token.is_none() {
+ let access = self.get_access_token(config, env_lookup).await?;
+ return Ok(VertexEnvironment {
+ headers: apply_credential(headers, &access.token, CredentialPlacement::Bearer)?,
+ project_id: project_id.unwrap_or(access.project_id),
+ });
+ }
+
+ let project_id = match project_id {
+ Some(project_id) => project_id,
+ None => {
+ self.load_provider(config, env_lookup)
+ .await?
+ .project_id()
+ .await?
+ }
+ };
+ let headers = if has_authorization {
+ headers
+ } else {
+ apply_credential(
+ headers,
+ static_token.as_deref().expect("static token was checked"),
+ CredentialPlacement::Bearer,
+ )?
+ };
+ Ok(VertexEnvironment {
+ headers,
+ project_id,
+ })
+ }
+
+ async fn get_access_token(
+ &self,
+ config: &VertexConfig,
+ env_lookup: &(dyn Fn(&str) -> Option + Sync),
+ ) -> Result {
+ let provider = self.load_provider(config, env_lookup).await?;
+ let (token, project_id) = tokio::try_join!(provider.token(), provider.project_id())?;
+ Ok(VertexAccessToken { token, project_id })
+ }
+
+ async fn load_provider(
+ &self,
+ config: &VertexConfig,
+ env_lookup: &(dyn Fn(&str) -> Option + Sync),
+ ) -> Result, Error> {
+ let source = credential_source(config, env_lookup);
+ let key = source.cache_key();
+ self.providers
+ .try_get_with(key, self.loader.load(source))
+ .await
+ .map_err(|error| (*error).clone())
+ }
+}
+
+pub trait VertexTokenSource: Send + Sync {
+ fn project_id(&self) -> VertexAuthFuture<'_, String>;
+ fn token(&self) -> VertexAuthFuture<'_, String>;
+}
+
+pub trait VertexProviderLoader: Send + Sync {
+ fn load(&self, source: CredentialSource) -> VertexAuthFuture<'_, Arc>;
+}
+
+pub type VertexAuthFuture<'a, T> = Pin> + Send + 'a>>;
+
+struct GcpTokenSource(Arc);
+
+impl VertexTokenSource for GcpTokenSource {
+ fn project_id(&self) -> VertexAuthFuture<'_, String> {
+ Box::pin(async move {
+ self.0
+ .project_id()
+ .await
+ .map(|project| project.to_string())
+ .map_err(auth_acquisition_error)
+ })
+ }
+
+ fn token(&self) -> VertexAuthFuture<'_, String> {
+ Box::pin(async move {
+ self.0
+ .token(&[CLOUD_PLATFORM_SCOPE])
+ .await
+ .map(|token| token.as_str().to_string())
+ .map_err(auth_acquisition_error)
+ })
+ }
+}
+
+struct GcpProviderLoader;
+
+impl VertexProviderLoader for GcpProviderLoader {
+ fn load(&self, source: CredentialSource) -> VertexAuthFuture<'_, Arc> {
+ Box::pin(async move {
+ let provider: Arc = match source {
+ CredentialSource::Inline(configured) => Arc::new(
+ CustomServiceAccount::from_json(validate_request_credentials(
+ configured.expose(),
+ )?)
+ .map_err(auth_acquisition_error)?,
+ ),
+ CredentialSource::Trusted(configured) => {
+ let configured = configured.expose();
+ let service_account = if Path::new(configured).is_file() {
+ CustomServiceAccount::from_file(configured)
+ } else {
+ CustomServiceAccount::from_json(configured)
+ }
+ .map_err(auth_acquisition_error)?;
+ Arc::new(service_account)
+ }
+ CredentialSource::ApplicationCredentials(path) => {
+ Arc::new(CustomServiceAccount::from_file(path).map_err(auth_acquisition_error)?)
+ }
+ CredentialSource::Adc => {
+ gcp_auth::provider().await.map_err(auth_acquisition_error)?
+ }
+ };
+ Ok(Arc::new(GcpTokenSource(provider)) as Arc)
+ })
+ }
+}
+
+fn validate_request_credentials(configured: &str) -> Result<&str, Error> {
+ let token_uri = serde_json::from_str::(configured)
+ .ok()
+ .and_then(|credentials| {
+ credentials
+ .get("token_uri")
+ .and_then(Value::as_str)
+ .map(str::to_string)
+ });
+ if token_uri.as_deref() != Some(GOOGLE_OAUTH_TOKEN_ENDPOINT) {
+ return Err(Error::InvalidConfiguration(ErrorDetail::InvalidType {
+ field: "request-controlled vertex_credentials token_uri".into(),
+ expected: "the canonical Google OAuth token endpoint",
+ }));
+ }
+ Ok(configured)
+}
+
+impl CredentialSource {
+ fn cache_key(&self) -> CredentialCacheKey {
+ match self {
+ Self::Inline(configured) => {
+ CredentialCacheKey::Inline(Sha256::digest(configured.expose()).into())
+ }
+ Self::Trusted(configured) => {
+ CredentialCacheKey::Trusted(Sha256::digest(configured.expose()).into())
+ }
+ Self::ApplicationCredentials(path) => {
+ CredentialCacheKey::ApplicationCredentials(path.clone())
+ }
+ Self::Adc => CredentialCacheKey::Adc,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Hash, PartialEq, Eq)]
+enum CredentialCacheKey {
+ Inline([u8; 32]),
+ Trusted([u8; 32]),
+ ApplicationCredentials(String),
+ Adc,
+}
+
+fn auth_acquisition_error(error: gcp_auth::Error) -> Error {
+ Error::CredentialAcquisition(litellm_auth_types::ErrorDetail::failed(
+ "Vertex AI credentials",
+ error,
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use litellm_auth_types::SecretValue;
+
+ use super::*;
+
+ #[rstest::rstest]
+ #[case::canonical_endpoint(r#"{"token_uri":"https://oauth2.googleapis.com/token"}"#, true)]
+ #[case::noncanonical_endpoint(r#"{"token_uri":"http://127.0.0.1/token"}"#, false)]
+ #[case::missing_endpoint("{}", false)]
+ fn request_credentials_require_canonical_token_endpoint(
+ #[case] credentials: &str,
+ #[case] accepted: bool,
+ ) {
+ assert_eq!(validate_request_credentials(credentials).is_ok(), accepted);
+ }
+
+ #[rstest::rstest]
+ fn inline_and_trusted_credentials_never_share_a_cache_entry() {
+ assert_ne!(
+ CredentialSource::Inline(SecretValue::new("same-value")).cache_key(),
+ CredentialSource::Trusted(SecretValue::new("same-value")).cache_key()
+ );
+ }
+}
diff --git a/litellm-rust/crates/auth-gcp/src/config.rs b/litellm-rust/crates/auth-gcp/src/config.rs
new file mode 100644
index 00000000000..0a76bf93cf0
--- /dev/null
+++ b/litellm-rust/crates/auth-gcp/src/config.rs
@@ -0,0 +1,311 @@
+use std::{collections::BTreeMap, path::Path};
+
+use litellm_auth_types::{Error, ErrorDetail, InputSource, SecretValue, Sourced};
+use serde_json::{Map, Value};
+
+use litellm_auth_types::VertexParams;
+
+use crate::constants::GOOGLE_APPLICATION_CREDENTIALS_ENV;
+
+#[derive(Clone, Debug, Default)]
+pub struct VertexConfig {
+ credentials: Option>,
+ project_id: Option,
+ location: Option,
+}
+
+impl VertexConfig {
+ pub fn new(
+ credentials: Option>,
+ project_id: Option,
+ location: Option,
+ ) -> Self {
+ Self {
+ credentials: credentials.filter(|value| !value.value().expose().trim().is_empty()),
+ project_id: project_id.filter(|value| !value.trim().is_empty()),
+ location: location.filter(|value| !value.trim().is_empty()),
+ }
+ }
+
+ pub fn from_sourced_optional_params(
+ params: &Map,
+ sources: &BTreeMap,
+ ) -> Result {
+ Ok(Self::new(
+ optional_credentials(params, sources, VertexParams::CREDENTIALS.wire)?,
+ optional_string(params, VertexParams::PROJECT.wire)?,
+ optional_string(params, VertexParams::LOCATION.wire)?,
+ ))
+ }
+
+ /// The deployment's `litellm_params`, which a host already trusts the way it trusts its
+ /// own configuration.
+ pub fn from_params(params: &VertexParams) -> Self {
+ Self::new(
+ params
+ .credentials()
+ .map(|value| Sourced::new(SecretValue::new(value), InputSource::Deployment)),
+ params.project(),
+ params.location(),
+ )
+ }
+
+ pub fn project_id(&self) -> Option<&str> {
+ self.project_id.as_deref()
+ }
+
+ pub fn location(&self) -> Option<&str> {
+ self.location.as_deref()
+ }
+}
+
+pub fn get_vertex_ai_project(
+ config: &VertexConfig,
+ env_lookup: &dyn Fn(&str) -> Option,
+) -> Option {
+ config
+ .project_id()
+ .map(str::to_string)
+ .or_else(|| VertexParams::PROJECT.resolve(&|_| None, env_lookup))
+}
+
+/// The project a request is billed to when nothing names it: the `project_id` inside the
+/// service account the call would authenticate with. Application default credentials carry
+/// no project that can be read without a token exchange, so they resolve to `None`.
+pub fn get_vertex_ai_project_from_credentials(
+ config: &VertexConfig,
+ env_lookup: &dyn Fn(&str) -> Option,
+) -> Option {
+ let text = match credential_source(config, env_lookup) {
+ CredentialSource::Inline(configured) => configured.expose().to_string(),
+ CredentialSource::Trusted(configured) => {
+ let configured = configured.expose();
+ match std::fs::read_to_string(configured) {
+ Ok(contents) if Path::new(configured).is_file() => contents,
+ _ => configured.to_string(),
+ }
+ }
+ CredentialSource::ApplicationCredentials(path) => std::fs::read_to_string(path).ok()?,
+ CredentialSource::Adc => return None,
+ };
+ serde_json::from_str::(&text)
+ .ok()?
+ .get("project_id")
+ .and_then(Value::as_str)
+ .map(str::to_string)
+}
+
+pub fn get_vertex_ai_location(
+ config: &VertexConfig,
+ env_lookup: &dyn Fn(&str) -> Option,
+) -> Option {
+ config
+ .location()
+ .map(str::to_string)
+ .or_else(|| VertexParams::LOCATION.resolve(&|_| None, env_lookup))
+}
+
+#[derive(Clone, Debug)]
+pub enum CredentialSource {
+ Inline(SecretValue),
+ Trusted(SecretValue),
+ ApplicationCredentials(String),
+ Adc,
+}
+
+pub(crate) fn credential_source(
+ config: &VertexConfig,
+ env_lookup: &dyn Fn(&str) -> Option,
+) -> CredentialSource {
+ if let Some(configured) = config.credentials.clone() {
+ return match configured.source() {
+ InputSource::Request => CredentialSource::Inline(configured.into_value()),
+ InputSource::Deployment | InputSource::Environment => {
+ CredentialSource::Trusted(configured.into_value())
+ }
+ };
+ }
+ if let Some(configured) = VertexParams::CREDENTIALS.resolve(&|_| None, env_lookup) {
+ return CredentialSource::Trusted(SecretValue::new(configured));
+ }
+ non_empty_env(env_lookup, GOOGLE_APPLICATION_CREDENTIALS_ENV)
+ .map(CredentialSource::ApplicationCredentials)
+ .unwrap_or(CredentialSource::Adc)
+}
+
+fn optional_credentials(
+ params: &Map,
+ sources: &BTreeMap,
+ names: &[&str],
+) -> Result