Merge remote-tracking branch 'origin/main' into aea-698-lib-name

This commit is contained in:
Jonathan Neufeld 2026-10-08 08:53:35 -06:00
commit b9e2b36f07
No known key found for this signature in database
3197 changed files with 244463 additions and 131831 deletions

View file

@ -524,7 +524,15 @@ jobs:
auth: auth:
username: ${DOCKERHUB_USERNAME} username: ${DOCKERHUB_USERNAME}
password: ${DOCKERHUB_PASSWORD} 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 working_directory: ~/project
environment: &sidecar_database_url
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/litellm_test"
parallelism: 4 parallelism: 4
steps: steps:
- checkout - checkout
@ -545,6 +553,9 @@ jobs:
- ~/.cache/uv - ~/.cache/uv
key: v1-uv-cache-{{ checksum "uv.lock" }} key: v1-uv-cache-{{ checksum "uv.lock" }}
- save_cargo_target - save_cargo_target
- wait_for_service:
url: tcp://localhost:5432
timeout: "60"
- run: - run:
name: Run prisma ./docker/entrypoint.sh name: Run prisma ./docker/entrypoint.sh
command: | command: |
@ -599,7 +610,9 @@ jobs:
local_testing_part2: local_testing_part2:
docker: docker:
- *python312_image - *python312_image
- *postgres16_sidecar
working_directory: ~/project working_directory: ~/project
environment: *sidecar_database_url
parallelism: 4 parallelism: 4
steps: steps:
- checkout - checkout
@ -619,6 +632,9 @@ jobs:
paths: paths:
- ~/.cache/uv - ~/.cache/uv
key: v1-uv-cache-{{ checksum "uv.lock" }} key: v1-uv-cache-{{ checksum "uv.lock" }}
- wait_for_service:
url: tcp://localhost:5432
timeout: "60"
- run: - run:
name: Run prisma ./docker/entrypoint.sh name: Run prisma ./docker/entrypoint.sh
command: | command: |
@ -673,7 +689,9 @@ jobs:
langfuse_logging_unit_tests: langfuse_logging_unit_tests:
docker: docker:
- *python312_image - *python312_image
- *postgres16_sidecar
working_directory: ~/project working_directory: ~/project
environment: *sidecar_database_url
resource_class: medium resource_class: medium
steps: steps:
@ -694,6 +712,9 @@ jobs:
paths: paths:
- ~/.cache/uv - ~/.cache/uv
key: v1-uv-cache-{{ checksum "uv.lock" }} key: v1-uv-cache-{{ checksum "uv.lock" }}
- wait_for_service:
url: tcp://localhost:5432
timeout: "60"
- run: - run:
name: Run prisma ./docker/entrypoint.sh name: Run prisma ./docker/entrypoint.sh
command: | command: |
@ -722,14 +743,9 @@ jobs:
auth_ui_unit_tests: auth_ui_unit_tests:
docker: docker:
- *python312_image - *python312_image
- image: cimg/postgres:16.0@sha256:b125148bc76e8e8eee5eb3ad6020a3a14110a14e8192f1c645128afebe2e2f84 - *postgres16_sidecar
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: litellm_test
working_directory: ~/project working_directory: ~/project
environment: environment: *sidecar_database_url
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/litellm_test"
steps: steps:
- checkout - checkout
@ -892,40 +908,6 @@ jobs:
paths: paths:
- router_unit_tests_coverage.xml - router_unit_tests_coverage.xml
- router_unit_tests_coverage - router_unit_tests_coverage
litellm_assistants_api_testing: # Runs all tests with the "assistants" keyword
docker:
- *python312_image
working_directory: ~/project
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.12
# 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/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 \"assistants\""
no_output_timeout: 15m
# Store test results
- store_test_results:
path: test-results
llm_translation_testing: llm_translation_testing:
docker: docker:
- *python312_image - *python312_image
@ -1021,49 +1003,6 @@ jobs:
paths: paths:
- realtime_translation_coverage.xml - realtime_translation_coverage.xml
- realtime_translation_coverage - realtime_translation_coverage
agent_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/agent_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 agent_coverage.xml
mv .coverage agent_coverage
# Store test results
- store_test_results:
path: test-results
- persist_to_workspace:
root: .
paths:
- agent_coverage.xml
- agent_coverage
guardrails_testing: guardrails_testing:
docker: docker:
- *python312_image - *python312_image
@ -1547,7 +1486,9 @@ jobs:
installing_litellm_on_python: installing_litellm_on_python:
docker: docker:
- *python312_image - *python312_image
- *postgres16_sidecar
working_directory: ~/project working_directory: ~/project
environment: *sidecar_database_url
steps: steps:
- checkout - checkout
@ -1560,6 +1501,9 @@ jobs:
command: | command: |
uv sync --frozen --all-groups --all-extras --python 3.12 uv sync --frozen --all-groups --all-extras --python 3.12
- setup_litellm_enterprise_pip - setup_litellm_enterprise_pip
- wait_for_service:
url: tcp://localhost:5432
timeout: "60"
- run: - run:
name: Run tests name: Run tests
command: | command: |
@ -1571,7 +1515,9 @@ jobs:
auth: auth:
username: ${DOCKERHUB_USERNAME} username: ${DOCKERHUB_USERNAME}
password: ${DOCKERHUB_PASSWORD} password: ${DOCKERHUB_PASSWORD}
- *postgres16_sidecar
working_directory: ~/project working_directory: ~/project
environment: *sidecar_database_url
resource_class: medium resource_class: medium
steps: steps:
@ -1584,6 +1530,9 @@ jobs:
name: Install Dependencies name: Install Dependencies
command: | command: |
uv sync --frozen --all-groups --all-extras --python 3.13 uv sync --frozen --all-groups --all-extras --python 3.13
- wait_for_service:
url: tcp://localhost:5432
timeout: "60"
- run: - run:
name: Run tests name: Run tests
command: | command: |
@ -1592,14 +1541,9 @@ jobs:
installing_litellm_on_python_v2_migration_resolver: installing_litellm_on_python_v2_migration_resolver:
docker: docker:
- *python312_image - *python312_image
- image: cimg/postgres:16.0@sha256:b125148bc76e8e8eee5eb3ad6020a3a14110a14e8192f1c645128afebe2e2f84 - *postgres16_sidecar
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: litellm_test
working_directory: ~/project working_directory: ~/project
environment: environment: *sidecar_database_url
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/litellm_test"
steps: steps:
- checkout - checkout
@ -1706,6 +1650,11 @@ jobs:
working_directory: ~/project working_directory: ~/project
steps: steps:
- checkout - 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 - skip_if_unrelated_changes
- setup_google_dns - setup_google_dns
- install_uv - install_uv
@ -1729,9 +1678,8 @@ jobs:
command: | command: |
docker run -d \ docker run -d \
-p 4001:4000 \ -p 4001:4000 \
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
-e DATABASE_URL="postgresql://postgres:postgres@host.docker.internal:5432/litellm_test" \ -e DATABASE_URL="postgresql://postgres:postgres@host.docker.internal:5432/litellm_test" \
-e LITELLM_MASTER_KEY="sk-1234" \ -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
--name schema-seed \ --name schema-seed \
--add-host=host.docker.internal:host-gateway \ --add-host=host.docker.internal:host-gateway \
-v $(pwd)/litellm/proxy/example_config_yaml/simple_config.yaml:/app/config.yaml \ -v $(pwd)/litellm/proxy/example_config_yaml/simple_config.yaml:/app/config.yaml \
@ -1802,6 +1750,11 @@ jobs:
working_directory: ~/project working_directory: ~/project
steps: steps:
- checkout - 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 - skip_if_unrelated_changes
- attach_workspace: - attach_workspace:
at: ~/project at: ~/project
@ -1825,9 +1778,8 @@ jobs:
command: | command: |
docker run -d \ docker run -d \
-p 4000:4000 \ -p 4000:4000 \
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e LITELLM_MASTER_KEY="sk-1234" \ -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
-e USE_PRISMA_MIGRATE=True \ -e USE_PRISMA_MIGRATE=True \
-e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \ -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
-e AZURE_API_KEY=$AZURE_API_KEY \ -e AZURE_API_KEY=$AZURE_API_KEY \
@ -1872,7 +1824,7 @@ jobs:
name: Seed the routing strategy through /config/update name: Seed the routing strategy through /config/update
command: | command: |
curl --noproxy '*' -sSf -X POST http://localhost:4000/config/update \ curl --noproxy '*' -sSf -X POST http://localhost:4000/config/update \
-H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' \
-d '{"router_settings": {"routing_strategy": "usage-based-routing-v2"}}' -d '{"router_settings": {"routing_strategy": "usage-based-routing-v2"}}'
- run: - run:
name: Run tests name: Run tests
@ -1901,6 +1853,11 @@ jobs:
working_directory: ~/project working_directory: ~/project
steps: steps:
- checkout - 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 - skip_if_unrelated_changes
- setup_google_dns - setup_google_dns
- install_uv - install_uv
@ -1922,9 +1879,8 @@ jobs:
command: | command: |
docker run -d \ docker run -d \
-p 4000:4000 \ -p 4000:4000 \
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e LITELLM_MASTER_KEY="sk-1234" \ -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
-e AZURE_API_KEY=$AZURE_API_KEY \ -e AZURE_API_KEY=$AZURE_API_KEY \
-e AZURE_API_BASE=$AZURE_API_BASE \ -e AZURE_API_BASE=$AZURE_API_BASE \
-e AZURE_API_VERSION="2024-05-01-preview" \ -e AZURE_API_VERSION="2024-05-01-preview" \
@ -1987,6 +1943,11 @@ jobs:
working_directory: ~/project working_directory: ~/project
steps: steps:
- checkout - 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 - skip_if_unrelated_changes
- setup_google_dns - setup_google_dns
- install_uv - install_uv
@ -2012,12 +1973,11 @@ jobs:
command: | command: |
docker run -d \ docker run -d \
-p 4000:4000 \ -p 4000:4000 \
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e REDIS_HOST=$REDIS_HOST \ -e REDIS_HOST=$REDIS_HOST \
-e REDIS_PASSWORD=$REDIS_PASSWORD \ -e REDIS_PASSWORD=$REDIS_PASSWORD \
-e REDIS_PORT=$REDIS_PORT \ -e REDIS_PORT=$REDIS_PORT \
-e LITELLM_MASTER_KEY="sk-1234" \ -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
-e OPENAI_API_KEY=$OPENAI_API_KEY \ -e OPENAI_API_KEY=$OPENAI_API_KEY \
-e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \ -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
-e LITELLM_LICENSE=$LITELLM_LICENSE \ -e LITELLM_LICENSE=$LITELLM_LICENSE \
@ -2073,12 +2033,11 @@ jobs:
command: | command: |
docker run -d \ docker run -d \
-p 4000:4000 \ -p 4000:4000 \
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e REDIS_HOST=$REDIS_HOST \ -e REDIS_HOST=$REDIS_HOST \
-e REDIS_PASSWORD=$REDIS_PASSWORD \ -e REDIS_PASSWORD=$REDIS_PASSWORD \
-e REDIS_PORT=$REDIS_PORT \ -e REDIS_PORT=$REDIS_PORT \
-e LITELLM_MASTER_KEY="sk-1234" \ -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
-e OPENAI_API_KEY=$OPENAI_API_KEY \ -e OPENAI_API_KEY=$OPENAI_API_KEY \
-e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \ -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
-e LITELLM_LICENSE="bad-license" \ -e LITELLM_LICENSE="bad-license" \
@ -2115,94 +2074,6 @@ jobs:
# Store test results # Store test results
- store_test_results: - store_test_results:
path: test-results path: test-results
proxy_spend_accuracy_tests:
machine:
image: ubuntu-2204:2024.04.1
resource_class: large
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
- 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 LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
-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="sk-1234" \
-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: proxy_multi_instance_tests:
machine: machine:
image: ubuntu-2204:2024.04.1 image: ubuntu-2204:2024.04.1
@ -2210,6 +2081,11 @@ jobs:
working_directory: ~/project working_directory: ~/project
steps: steps:
- checkout - 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 - skip_if_unrelated_changes
- setup_google_dns - setup_google_dns
- install_uv - install_uv
@ -2234,12 +2110,11 @@ jobs:
command: | command: |
docker run -d \ docker run -d \
-p 4000:4000 \ -p 4000:4000 \
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e REDIS_HOST=$REDIS_HOST \ -e REDIS_HOST=$REDIS_HOST \
-e REDIS_PASSWORD=$REDIS_PASSWORD \ -e REDIS_PASSWORD=$REDIS_PASSWORD \
-e REDIS_PORT=$REDIS_PORT \ -e REDIS_PORT=$REDIS_PORT \
-e LITELLM_MASTER_KEY="sk-1234" \ -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
-e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \ -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
-e LITELLM_LICENSE=$LITELLM_LICENSE \ -e LITELLM_LICENSE=$LITELLM_LICENSE \
-e USE_DDTRACE=True \ -e USE_DDTRACE=True \
@ -2257,12 +2132,11 @@ jobs:
command: | command: |
docker run -d \ docker run -d \
-p 4001:4001 \ -p 4001:4001 \
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e REDIS_HOST=$REDIS_HOST \ -e REDIS_HOST=$REDIS_HOST \
-e REDIS_PASSWORD=$REDIS_PASSWORD \ -e REDIS_PASSWORD=$REDIS_PASSWORD \
-e REDIS_PORT=$REDIS_PORT \ -e REDIS_PORT=$REDIS_PORT \
-e LITELLM_MASTER_KEY="sk-1234" \ -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
-e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \ -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
-e LITELLM_LICENSE=$LITELLM_LICENSE \ -e LITELLM_LICENSE=$LITELLM_LICENSE \
-e USE_DDTRACE=True \ -e USE_DDTRACE=True \
@ -2309,6 +2183,11 @@ jobs:
working_directory: ~/project working_directory: ~/project
steps: steps:
- checkout - 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 - skip_if_unrelated_changes
- setup_google_dns - setup_google_dns
- install_uv - install_uv
@ -2335,10 +2214,9 @@ jobs:
docker run -d \ docker run -d \
--restart on-failure \ --restart on-failure \
-p 4000:4000 \ -p 4000:4000 \
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e STORE_MODEL_IN_DB="True" \ -e STORE_MODEL_IN_DB="True" \
-e LITELLM_MASTER_KEY="sk-1234" \ -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
-e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \ -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
-e TEAM_METADATA_VALIDATION_SERVICE_URL=http://host.docker.internal:9414/validate \ -e TEAM_METADATA_VALIDATION_SERVICE_URL=http://host.docker.internal:9414/validate \
-e LITELLM_LICENSE=$LITELLM_LICENSE \ -e LITELLM_LICENSE=$LITELLM_LICENSE \
@ -2388,6 +2266,11 @@ jobs:
working_directory: ~/project working_directory: ~/project
steps: steps:
- checkout - 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 - skip_if_unrelated_changes
- setup_google_dns - setup_google_dns
# Remove Docker CLI installation since it's already available in machine executor # Remove Docker CLI installation since it's already available in machine executor
@ -2410,12 +2293,11 @@ jobs:
command: | command: |
docker run -d \ docker run -d \
-p 4000:4000 \ -p 4000:4000 \
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e REDIS_HOST=$REDIS_HOST \ -e REDIS_HOST=$REDIS_HOST \
-e REDIS_PASSWORD=$REDIS_PASSWORD \ -e REDIS_PASSWORD=$REDIS_PASSWORD \
-e REDIS_PORT=$REDIS_PORT \ -e REDIS_PORT=$REDIS_PORT \
-e LITELLM_MASTER_KEY="sk-1234" \ -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
-e OPENAI_API_KEY=$OPENAI_API_KEY \ -e OPENAI_API_KEY=$OPENAI_API_KEY \
-e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \ -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
-e LITELLM_LICENSE=$LITELLM_LICENSE \ -e LITELLM_LICENSE=$LITELLM_LICENSE \
@ -2472,6 +2354,11 @@ jobs:
working_directory: ~/project working_directory: ~/project
steps: steps:
- checkout - 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 - skip_if_unrelated_changes
- setup_google_dns - setup_google_dns
- install_uv - install_uv
@ -2493,9 +2380,8 @@ jobs:
command: | command: |
docker run -d \ docker run -d \
-p 4000:4000 \ -p 4000:4000 \
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e LITELLM_MASTER_KEY="sk-1234" \ -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
-e OPENAI_API_KEY=$OPENAI_API_KEY \ -e OPENAI_API_KEY=$OPENAI_API_KEY \
-e GEMINI_API_KEY=$GEMINI_API_KEY \ -e GEMINI_API_KEY=$GEMINI_API_KEY \
-e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \ -e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \
@ -2563,6 +2449,11 @@ jobs:
working_directory: ~/project working_directory: ~/project
steps: steps:
- checkout - 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 - skip_if_unrelated_changes
- setup_google_dns - setup_google_dns
- install_uv - install_uv
@ -2585,9 +2476,8 @@ jobs:
command: | command: |
docker run -d \ docker run -d \
-p 4000:4000 \ -p 4000:4000 \
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e LITELLM_MASTER_KEY="sk-1234" \ -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
-e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \ -e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \
-e RECORDER_ANTHROPIC_BASE_URL=http://host.docker.internal:8090/__recorder_upstream/api.anthropic.com \ -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_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \
@ -2613,7 +2503,7 @@ jobs:
command: | command: |
mkdir -p test-results mkdir -p test-results
export LITELLM_PROXY_URL="http://localhost:4000" export LITELLM_PROXY_URL="http://localhost:4000"
export LITELLM_API_KEY="sk-1234" export LITELLM_API_KEY="$LITELLM_MASTER_KEY"
TEST_FILES=$(circleci tests glob "tests/proxy_e2e_anthropic_messages_tests/**/test_*.py") TEST_FILES=$(circleci tests glob "tests/proxy_e2e_anthropic_messages_tests/**/test_*.py")
echo "$TEST_FILES" | circleci tests run \ echo "$TEST_FILES" | circleci tests run \
--verbose \ --verbose \
@ -2647,7 +2537,7 @@ jobs:
- run: - run:
name: Combine Coverage name: Combine Coverage
command: | 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 agent_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 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 uv tool run --from 'coverage[toml]==7.10.6' coverage xml
- codecov/upload: - codecov/upload:
file: ./coverage.xml file: ./coverage.xml
@ -2676,6 +2566,11 @@ jobs:
PROXY_LOGOUT_URL: "https://www.example.com" PROXY_LOGOUT_URL: "https://www.example.com"
steps: steps:
- checkout - 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: - skip_if_unrelated_changes:
category: client category: client
- setup_google_dns - setup_google_dns
@ -2766,8 +2661,6 @@ jobs:
- run: - run:
name: Start LiteLLM proxy name: Start LiteLLM proxy
environment: environment:
LITELLM_MASTER_KEY: "sk-1234"
LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY: "true"
MOCK_LLM_URL: "http://127.0.0.1:8090/v1" MOCK_LLM_URL: "http://127.0.0.1:8090/v1"
DISABLE_SCHEMA_UPDATE: "true" DISABLE_SCHEMA_UPDATE: "true"
SERVER_ROOT_PATH: "" SERVER_ROOT_PATH: ""
@ -2787,7 +2680,7 @@ jobs:
name: Wait for proxy to be ready name: Wait for proxy to be ready
command: | command: |
for i in $(seq 1 60); do for i in $(seq 1 60); do
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:4000/health -H "Authorization: Bearer sk-1234" 2>/dev/null || true) HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:4000/health -H "Authorization: Bearer $LITELLM_MASTER_KEY" 2>/dev/null || true)
if [ "$HTTP_CODE" = "200" ]; then if [ "$HTTP_CODE" = "200" ]; then
echo "Proxy is ready" echo "Proxy is ready"
exit 0 exit 0
@ -2834,6 +2727,11 @@ jobs:
SERVER_ROOT_PATH: "/litellm" SERVER_ROOT_PATH: "/litellm"
steps: steps:
- checkout - 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: - skip_if_unrelated_changes:
category: client category: client
- setup_google_dns - setup_google_dns
@ -2910,8 +2808,6 @@ jobs:
- run: - run:
name: Start LiteLLM proxy under a server root path name: Start LiteLLM proxy under a server root path
environment: environment:
LITELLM_MASTER_KEY: "sk-1234"
LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY: "true"
MOCK_LLM_URL: "http://127.0.0.1:8090/v1" MOCK_LLM_URL: "http://127.0.0.1:8090/v1"
DISABLE_SCHEMA_UPDATE: "true" DISABLE_SCHEMA_UPDATE: "true"
# Output flows to this step's own log, so a boot crash is visible here # Output flows to this step's own log, so a boot crash is visible here
@ -2926,7 +2822,7 @@ jobs:
name: Wait for prefixed proxy to be ready name: Wait for prefixed proxy to be ready
command: | command: |
for i in $(seq 1 60); do for i in $(seq 1 60); do
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 -H "Authorization: Bearer sk-1234" http://127.0.0.1:4000/litellm/health 2>/dev/null || true) HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 -H "Authorization: Bearer $LITELLM_MASTER_KEY" http://127.0.0.1:4000/litellm/health 2>/dev/null || true)
if [ "$HTTP_CODE" = "200" ]; then if [ "$HTTP_CODE" = "200" ]; then
echo "Prefixed proxy is ready" echo "Prefixed proxy is ready"
exit 0 exit 0
@ -3019,6 +2915,11 @@ jobs:
PYTHONPATH: tests/e2e PYTHONPATH: tests/e2e
steps: steps:
- checkout_migration_source - checkout_migration_source
- run:
name: Generate LiteLLM master key
command: |
key="$(openssl rand -hex 16)"
printf 'export LITELLM_MASTER_KEY=sk-%s\n' "$key" >> "$BASH_ENV"
- install_uv - install_uv
- install_rust - install_rust
- restore_cache: - restore_cache:
@ -3138,6 +3039,11 @@ jobs:
E2E_FIXTURE_MODE: live E2E_FIXTURE_MODE: live
steps: steps:
- checkout - 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: - skip_if_unrelated_changes:
category: provider-harness category: provider-harness
- setup_litellm_test_deps - setup_litellm_test_deps
@ -3147,10 +3053,10 @@ jobs:
name: Test provider capture and replay harness name: Test provider capture and replay harness
command: | command: |
mkdir -p test-results/provider-replay-harness mkdir -p test-results/provider-replay-harness
uv run --no-sync pytest --tb=short -q --noconftest -o addopts= -o pythonpath=tests/e2e -p no:rerunfailures \ 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 \ --junitxml=test-results/provider-replay-harness/junit.xml \
tests/e2e/test_provider_edge.py tests/e2e/test_fixture_bundle.py \ tests/e2e_harness/test_provider_edge.py tests/e2e_harness/test_fixture_bundle.py \
tests/e2e/test_fixture_canonical.py tests/e2e/test_fixture_mode.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_replay_harness.py \
tests/code_coverage_tests/test_provider_cache.py tests/code_coverage_tests/test_provider_cache.py
- store_test_results: - store_test_results:
@ -3167,9 +3073,13 @@ jobs:
parallelism: parallelism:
type: integer type: integer
default: 1 default: 1
resource_class:
type: enum
enum: [large, xlarge]
default: large
machine: machine:
image: ubuntu-2204:2024.04.1 image: ubuntu-2204:2024.04.1
resource_class: large resource_class: << parameters.resource_class >>
working_directory: ~/project working_directory: ~/project
parallelism: << parameters.parallelism >> parallelism: << parameters.parallelism >>
steps: steps:
@ -3512,7 +3422,11 @@ workflows:
name: integration-<< matrix.suite >> name: integration-<< matrix.suite >>
matrix: matrix:
parameters: parameters:
suite: [management, accounting, database, providers, mcp, sdk, cost, security, browser] suite: [management, accounting, database, providers, sdk, cost, security, browser]
- integration_contracts:
name: integration-mcp
suite: mcp
resource_class: xlarge
- integration_contracts: - integration_contracts:
name: integration-extensions name: integration-extensions
suite: extensions suite: extensions
@ -3570,7 +3484,6 @@ workflows:
- local_testing_part1 - local_testing_part1
- local_testing_part2 - local_testing_part2
- langfuse_logging_unit_tests - langfuse_logging_unit_tests
- litellm_assistants_api_testing
- litellm_router_testing - litellm_router_testing
- litellm_router_unit_testing - litellm_router_unit_testing
- auth_ui_unit_tests - auth_ui_unit_tests
@ -3586,9 +3499,6 @@ workflows:
- proxy_logging_guardrails_model_info_tests: - proxy_logging_guardrails_model_info_tests:
requires: requires:
- build_docker_database_image - build_docker_database_image
- proxy_spend_accuracy_tests:
requires:
- build_docker_database_image
- proxy_multi_instance_tests: - proxy_multi_instance_tests:
requires: requires:
- build_docker_database_image - build_docker_database_image
@ -3604,7 +3514,6 @@ workflows:
- build_docker_database_image - build_docker_database_image
- llm_translation_testing - llm_translation_testing
- realtime_translation_testing - realtime_translation_testing
- agent_testing
- guardrails_testing - guardrails_testing
- google_generate_content_endpoint_testing - google_generate_content_endpoint_testing
- llm_responses_api_testing - llm_responses_api_testing
@ -3619,7 +3528,6 @@ workflows:
- upload-coverage: - upload-coverage:
requires: requires:
- realtime_translation_testing - realtime_translation_testing
- agent_testing
- google_generate_content_endpoint_testing - google_generate_content_endpoint_testing
- guardrails_testing - guardrails_testing
- ocr_testing - ocr_testing
@ -3633,7 +3541,6 @@ workflows:
- langfuse_logging_unit_tests - langfuse_logging_unit_tests
- local_testing_part1 - local_testing_part1
- local_testing_part2 - local_testing_part2
- litellm_assistants_api_testing
- litellm_router_unit_testing - litellm_router_unit_testing
- auth_ui_unit_tests - auth_ui_unit_tests
- db_migration_disable_update_check: - db_migration_disable_update_check:

View file

@ -24,8 +24,8 @@ while IFS= read -r file || [ -n "$file" ]; do
has_mcp_dependencies=true ;; has_mcp_dependencies=true ;;
esac esac
case "$file" in case "$file" in
tests/e2e/*/*.py) : ;; tests/e2e/*/*.py | tests/e2e_harness/*/*.py) : ;;
tests/e2e/*.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) 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 ;; has_provider_harness=true ;;
esac esac
case "$file" in case "$file" in

View file

@ -72,7 +72,7 @@ export PATH="$PWD/.venv/bin:$PATH"
export PYTHONPATH="$PWD:$PWD/tests:$PWD/tests/e2e" export PYTHONPATH="$PWD:$PWD/tests:$PWD/tests/e2e"
export DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:5432/circle_test" export DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:5432/circle_test"
export REDIS_HOST=127.0.0.1 REDIS_PORT=6379 export REDIS_HOST=127.0.0.1 REDIS_PORT=6379
export LITELLM_MASTER_KEY=sk-integration-master LITELLM_SALT_KEY=sk-integration-salt export LITELLM_MASTER_KEY="sk-$(openssl rand -hex 16)" LITELLM_SALT_KEY=sk-integration-salt
export LITELLM_MODE=PRODUCTION LITELLM_LOCAL_MODEL_COST_MAP=True export LITELLM_MODE=PRODUCTION LITELLM_LOCAL_MODEL_COST_MAP=True
export STORE_MODEL_IN_DB=True AWS_EC2_METADATA_DISABLED=true DO_NOT_TRACK=1 export STORE_MODEL_IN_DB=True AWS_EC2_METADATA_DISABLED=true DO_NOT_TRACK=1
export INTEGRATION_PROXY_URL=http://127.0.0.1:4000 export INTEGRATION_PROXY_URL=http://127.0.0.1:4000
@ -193,10 +193,10 @@ fi
if [ "$suite" = providers ]; then if [ "$suite" = providers ]; then
INTEGRATION_RUN_ID="$integration_identity" .venv/bin/python -m pytest --tb=short --noconftest -o addopts= \ INTEGRATION_RUN_ID="$integration_identity" .venv/bin/python -m pytest --tb=short --noconftest -o addopts= \
--strict-markers --strict-config -p no:pytest-retry -p no:rerunfailures --timeout=30 \ --strict-markers --strict-config -p no:pytest-retry -p no:rerunfailures --timeout=30 \
tests/e2e/test_provider_edge.py::TestReplayMode::test_content_drift_returns_the_miss_status_naming_both_keys \ tests/e2e_harness/test_provider_edge.py::TestReplayMode::test_content_drift_returns_the_miss_status_naming_both_keys \
tests/e2e/test_provider_edge.py::TestReplayMode::test_exhausted_key_returns_the_miss_status \ tests/e2e_harness/test_provider_edge.py::TestReplayMode::test_exhausted_key_returns_the_miss_status \
tests/e2e/test_provider_edge.py::TestReplayLeftover::test_partially_consumed_recording_names_the_leftover \ tests/e2e_harness/test_provider_edge.py::TestReplayLeftover::test_partially_consumed_recording_names_the_leftover \
tests/e2e/test_provider_edge.py::TestStreamingFidelity::test_replay_of_a_stream_makes_no_provider_connection \ tests/e2e_harness/test_provider_edge.py::TestStreamingFidelity::test_replay_of_a_stream_makes_no_provider_connection \
--junitxml="$results/replay-controls.xml" --junitxml="$results/replay-controls.xml"
fi fi

View file

@ -84,10 +84,6 @@ secret:
- name: Langfuse test credentials in test_completion - name: Langfuse test credentials in test_completion
match: c39310f68cc3d3e22f7b298bb6353c4f45759adcc37080d8b7f4e535d3cfd7f4 match: c39310f68cc3d3e22f7b298bb6353c4f45759adcc37080d8b7f4e535d3cfd7f4
# Test password "sk-1234" in e2e test fixtures - test fixture, not a real secret
- name: Test password in e2e test fixtures
match: ce32b547202e209ec1dd50107b64be4cfcf2eb15c3b4f8e9dc611ef747af634f
# === Preventive patterns for test keys (pattern-based) === # === Preventive patterns for test keys (pattern-based) ===
# Test API keys (124 instances across 45 files) # Test API keys (124 instances across 45 files)
@ -108,4 +104,3 @@ secret:
- name: Short fake sk keys (1–9 digits only) - name: Short fake sk keys (1–9 digits only)
match: \bsk-\d{1,9}\b match: \bsk-\d{1,9}\b

4
.github/CODEOWNERS vendored
View file

@ -1,2 +1,2 @@
/model_prices_and_context_window.json @mateo-berri @ryan-crabbe-berri @kerry-berri /model_prices_and_context_window.json @mateo-berri @ryan-crabbe-berri @kerrylu-berri
/litellm/model_prices_and_context_window_backup.json @mateo-berri @ryan-crabbe-berri @kerry-berri /litellm/model_prices_and_context_window_backup.json @mateo-berri @ryan-crabbe-berri @kerrylu-berri

View file

@ -150,7 +150,7 @@ For each e2e test you added or changed, list the manual steps a reviewer can fol
Example checklists: Example checklists:
- tests/e2e/quota_management/ratelimit/test_rate_limit_e2e.py::TestKeyRateLimits::test_rpm_limit_blocks_over_limit - a key allowed 2 requests a minute serves exactly 2 and refuses the 3rd - tests/e2e/quota_management/ratelimit/test_rate_limit_e2e.py::TestKeyRateLimits::test_rpm_limit_blocks_over_limit - a key allowed 2 requests a minute serves exactly 2 and refuses the 3rd
- [ ] Generate a limited key: curl -X POST http://localhost:4000/key/generate -H "Authorization: Bearer sk-1234" -d '{"rpm_limit": 2}' - [ ] Generate a limited key: curl -X POST http://localhost:4000/key/generate -H "Authorization: Bearer $LITELLM_MASTER_KEY" -d '{"rpm_limit": 2}'
- [ ] Send three /v1/chat/completions requests with that key inside one minute - [ ] Send three /v1/chat/completions requests with that key inside one minute
- [ ] Expect the first two to return 200 and the third to return 429 naming the rpm limit - [ ] Expect the first two to return 200 and the third to return 429 naming the rpm limit
- [ ] Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky - [ ] Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky
@ -164,4 +164,3 @@ Example checklists:
## Final Attestation ## Final Attestation
- [ ] The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR - [ ] The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

View file

@ -1,8 +1,5 @@
name: "LiteLLM CodeQL config" name: "LiteLLM CodeQL config"
queries:
- uses: security-and-quality
# Known OOM queries on large Python codebases: # Known OOM queries on large Python codebases:
# CodeQL builds a full data flow graph in memory. These two queries trace # CodeQL builds a full data flow graph in memory. These two queries trace
# sensitive data through every log call / regex pattern, causing combinatorial # sensitive data through every log call / regex pattern, causing combinatorial
@ -14,17 +11,6 @@ query-filters:
id: py/clear-text-logging-sensitive-data # CWE-312 id: py/clear-text-logging-sensitive-data # CWE-312
- exclude: - exclude:
id: py/polynomial-redos # CWE-730 id: py/polynomial-redos # CWE-730
# Import resolution confuses stdlib types with management_endpoints/types.py.
# The generic cycle query also reports intentional deferred imports.
- exclude:
id: py/cyclic-import
- exclude:
id: py/unsafe-cyclic-import
# Known false positives on live settings and Protocol placeholders.
- exclude:
id: py/unused-global-variable
- exclude:
id: py/ineffectual-statement
paths-ignore: paths-ignore:
- tests - tests

View file

@ -3,6 +3,11 @@ name: _Unit Test Base (Reusable)
on: on:
workflow_call: workflow_call:
inputs: inputs:
python-version:
description: "Python version used to install dependencies and run tests"
required: false
type: string
default: "3.12"
test-path: test-path:
description: >- description: >-
Space-separated pytest paths to run. A path that no longer exists is Space-separated pytest paths to run. A path that no longer exists is
@ -78,7 +83,7 @@ permissions:
contents: read contents: read
env: env:
UV_PYTHON: "3.12" UV_PYTHON: ${{ inputs.python-version }}
LITELLM_LOCAL_MODEL_COST_MAP: "True" LITELLM_LOCAL_MODEL_COST_MAP: "True"
jobs: jobs:
@ -138,9 +143,9 @@ jobs:
run: | run: |
diff -u model_prices_and_context_window.json litellm/model_prices_and_context_window_backup.json diff -u model_prices_and_context_window.json litellm/model_prices_and_context_window_backup.json
if [ -z "$RUST_BRIDGE_ARTIFACT" ]; then 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 .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 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 .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 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 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'))" uv run --no-sync python -c "import importlib.metadata; import litellm.rust_bridge._native; print(importlib.metadata.version('litellm'))"

View file

@ -1,8 +1,6 @@
name: "CodeQL" name: "CodeQL"
on: on:
push:
branches: [main]
pull_request: pull_request:
branches: [main] branches: [main]
schedule: schedule:
@ -43,14 +41,15 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@ebcb5b36ded6beda4ceefea6a8bc4cc885255bb3 # v3.34.1 uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }} build-mode: ${{ matrix.build-mode }}
config-file: ./.github/codeql/codeql-config.yml config-file: ./.github/codeql/codeql-config.yml
queries: ${{ github.event_name == 'pull_request' && '+security-extended' || '' }}
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@ebcb5b36ded6beda4ceefea6a8bc4cc885255bb3 # v3.34.1 uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with: with:
category: "/language:${{ matrix.language }}" category: "/language:${{ matrix.language }}"
output: sarif-results output: sarif-results
@ -83,7 +82,7 @@ jobs:
output: sarif-results/python.sarif output: sarif-results/python.sarif
- name: Upload SARIF - name: Upload SARIF
uses: github/codeql-action/upload-sarif@ebcb5b36ded6beda4ceefea6a8bc4cc885255bb3 # v3.34.1 uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with: with:
sarif_file: sarif-results sarif_file: sarif-results
category: "/language:${{ matrix.language }}" category: "/language:${{ matrix.language }}"

View file

@ -22,6 +22,12 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Check Lens Compose configuration
run: |
python3 -m unittest discover -s deploy/lens -p 'test_*.py'
python3 deploy/lens/configure.py --version 1.2.3 --env-file "$RUNNER_TEMP/lens.env"
docker compose --env-file "$RUNNER_TEMP/lens.env" -f deploy/lens/stack.yaml config --quiet
- name: Set up Helm 3.11.1 - name: Set up Helm 3.11.1
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
with: with:

View file

@ -18,6 +18,7 @@ on:
- backend/Dockerfile - backend/Dockerfile
- backend/main.py - backend/main.py
- deploy/lens/** - deploy/lens/**
- litellm-rust/**
- litellm/proxy/lens/** - litellm/proxy/lens/**
- tests/e2e/migrations/lens_compose_smoke.sh - tests/e2e/migrations/lens_compose_smoke.sh
- docker/component_entrypoint.sh - docker/component_entrypoint.sh
@ -52,7 +53,7 @@ jobs:
if: >- if: >-
github.event_name != 'pull_request' || github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 15 timeout-minutes: 45
permissions: permissions:
contents: read contents: read
strategy: strategy:
@ -79,30 +80,7 @@ jobs:
run: | run: |
docker run --rm --network none --read-only --cap-drop ALL \ docker run --rm --network none --read-only --cap-drop ALL \
--tmpfs /tmp:rw,noexec,nosuid,size=1g --security-opt no-new-privileges \ --tmpfs /tmp:rw,noexec,nosuid,size=1g --security-opt no-new-privileges \
-e EXPECTED_RELEASE_TAG="${RELEASE_TAG}" --entrypoint python lens-worker-scan -c ' lens-worker-scan --version | grep -F "litellm-lens $RELEASE_TAG protocol="
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 - name: Download Grype v0.114.0
env: env:
ARCH: ${{ matrix.arch }} ARCH: ${{ matrix.arch }}

View file

@ -0,0 +1,94 @@
name: Lens installation smoke
on:
workflow_dispatch:
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: worker
dockerfile: deploy/lens/Dockerfile
env:
COMPONENT: ${{ matrix.component }}
DOCKERFILE: ${{ matrix.dockerfile }}
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Build the matching release image
run: |
docker build --build-arg LITELLM_RELEASE_TAG=v0.0.0-lens-ci \
-f "$DOCKERFILE" -t "lens-ci-$COMPONENT:v0.0.0-lens-ci" .
- name: Save the matching release image
run: |
docker save "lens-ci-$COMPONENT:v0.0.0-lens-ci" \
| gzip -1 > "$RUNNER_TEMP/lens-install-$COMPONENT.tar.gz"
- uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: lens-install-${{ matrix.component }}-${{ github.sha }}
path: ${{ runner.temp }}/lens-install-${{ matrix.component }}.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 component in gateway backend ui migrations monolith worker; do
archive="$RUNNER_TEMP/lens-install-images/lens-install-$component.tar.gz"
gzip -dc "$archive" | docker load
rm "$archive"
done
- 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

View file

@ -5,6 +5,7 @@ on:
branches: [main, litellm_oss_branch, "litellm_**"] branches: [main, litellm_oss_branch, "litellm_**"]
paths: paths:
- deploy/lens/** - deploy/lens/**
- litellm-rust/**
- litellm/proxy/lens/** - litellm/proxy/lens/**
- tests/proxy_behavior/lens/** - tests/proxy_behavior/lens/**
- .github/workflows/lens-worker.yml - .github/workflows/lens-worker.yml
@ -12,6 +13,7 @@ on:
branches: [main] branches: [main]
paths: paths:
- deploy/lens/** - deploy/lens/**
- litellm-rust/**
- litellm/proxy/lens/** - litellm/proxy/lens/**
- tests/proxy_behavior/lens/** - tests/proxy_behavior/lens/**
- .github/workflows/lens-worker.yml - .github/workflows/lens-worker.yml
@ -29,15 +31,35 @@ jobs:
permissions: permissions:
contents: read contents: read
packages: write packages: write
id-token: write runs-on: ${{ matrix.runner }}
runs-on: ubuntu-latest timeout-minutes: 45
timeout-minutes: 10 strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
steps: steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with: with:
persist-credentials: false persist-credentials: false
- name: Build Lens worker - name: Build native Lens service
run: docker build --build-arg LITELLM_RELEASE_TAG=sha-${{ github.sha }} -f deploy/lens/Dockerfile -t lens-worker:${{ github.sha }} . env:
RELEASE_TAG: sha-${{ github.sha }}
run: docker build --build-arg LITELLM_RELEASE_TAG="$RELEASE_TAG" -f deploy/lens/Dockerfile -t lens-worker .
- name: Verify version and unprivileged runtime
env:
RELEASE_TAG: sha-${{ github.sha }}
run: bash deploy/lens/smoke.sh lens-worker "$RELEASE_TAG"
- name: Verify confined Python on the native architecture
env:
RELEASE_TAG: sha-${{ github.sha }}
run: |
docker build --target smoke --build-arg LITELLM_RELEASE_TAG="$RELEASE_TAG" -f deploy/lens/Dockerfile -t lens-smoke .
docker run --rm --network none --read-only --cap-drop ALL \
--tmpfs /tmp:rw,noexec,nosuid,size=1g --security-opt no-new-privileges lens-smoke
- name: Reject custom builds without a matching release tag - name: Reject custom builds without a matching release tag
run: | run: |
if docker build --progress plain -f deploy/lens/Dockerfile -t lens-worker:unversioned . > missing-tag.log 2>&1; then if docker build --progress plain -f deploy/lens/Dockerfile -t lens-worker:unversioned . > missing-tag.log 2>&1; then
@ -45,86 +67,45 @@ jobs:
exit 1 exit 1
fi fi
grep -F 'LITELLM_RELEASE_TAG: Pass --build-arg LITELLM_RELEASE_TAG matching the gateway' missing-tag.log 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 - name: Publish development architecture
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' 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 }}-${{ matrix.arch }}
ARCH: ${{ matrix.arch }}
run: |
printf '%s' "$REGISTRY_TOKEN" | docker login ghcr.io -u "$REGISTRY_USER" --password-stdin
docker tag lens-worker "$IMAGE"
docker push "$IMAGE"
mkdir -p digests
docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE" > "digests/$ARCH"
- uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
if: github.event_name != 'pull_request' && github.repository == 'BerriAI/litellm' && github.ref == 'refs/heads/main'
with:
name: lens-digest-${{ matrix.arch }}
path: digests/
retention-days: 1
publish:
name: Publish Lens development index
needs: lens-worker-image
if: github.event_name != 'pull_request' && github.repository == 'BerriAI/litellm' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
with:
pattern: lens-digest-*
merge-multiple: true
path: digests
- name: Publish both tested architectures
env: env:
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REGISTRY_USER: ${{ github.actor }} REGISTRY_USER: ${{ github.actor }}
IMAGE: ghcr.io/berriai/litellm-lens-worker-dev:sha-${{ github.sha }} IMAGE: ghcr.io/berriai/litellm-lens-worker-dev:sha-${{ github.sha }}
run: | run: |
printf '%s' "$REGISTRY_TOKEN" | docker login ghcr.io -u "$REGISTRY_USER" --password-stdin printf '%s' "$REGISTRY_TOKEN" | docker login ghcr.io -u "$REGISTRY_USER" --password-stdin
docker tag lens-worker:${{ github.sha }} "$IMAGE" docker buildx imagetools create --tag "$IMAGE" "$(cat digests/amd64)" "$(cat digests/arm64)"
docker push "$IMAGE"
printf 'Lens worker image: `%s`\n' "$IMAGE" >> "$GITHUB_STEP_SUMMARY" printf 'Lens worker image: `%s`\n' "$IMAGE" >> "$GITHUB_STEP_SUMMARY"

View file

@ -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

View file

@ -0,0 +1,96 @@
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: 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'
uses: ./.github/actions/cache-prisma-binaries
# The three source scanners only need the pinned dev tools (ruff and the
# stdlib checkers), the same versions test-linting.yml's lint job runs.
- name: Install the dev tools
if: matrix.checker != 'basedpyright'
run: |
uv sync --frozen --only-group dev --no-install-project
- 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

View file

@ -95,7 +95,9 @@ jobs:
- name: test_e2e_metadata - name: test_e2e_metadata
env: env:
PYTHONPATH: tests/e2e PYTHONPATH: tests/e2e
run: 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 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 - 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 run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_merge_smoke.py
@ -163,6 +165,12 @@ jobs:
- name: check_migrations_no_data_rewrites - name: check_migrations_no_data_rewrites
run: uv run --no-sync python ./tests/code_coverage_tests/check_migrations_no_data_rewrites.py 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) - 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 run: uv run --no-sync python ./tests/code_coverage_tests/check_unbounded_in_lists.py

View file

@ -17,9 +17,9 @@ jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
# actions: read lets scripts/type_check_gate.py download the base-counts # actions: read lets the four lint gates download the base-counts artifacts
# artifact published by publish-basedpyright-base-counts.yml instead of # published by publish-lint-base-counts.yml instead of re-scanning the
# re-running basedpyright over the merge-base tree. # merge-base tree in a throwaway worktree.
permissions: permissions:
contents: read contents: read
pull-requests: read pull-requests: read
@ -144,18 +144,24 @@ jobs:
run: | run: |
uv run --no-sync ruff check --config ruff-tests.toml tests 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' if: steps.changes.outputs.decision != 'skip'
env:
GH_TOKEN: ${{ github.token }}
run: | run: |
uv run --no-sync python scripts/ruff_strict_gate.py --base "$GATE_BASE_SHA" 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' if: steps.changes.outputs.decision != 'skip'
env:
GH_TOKEN: ${{ github.token }}
run: | run: |
uv run --no-sync python scripts/type_discipline_gate.py --base "$GATE_BASE_SHA" 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' if: steps.changes.outputs.decision != 'skip'
env:
GH_TOKEN: ${{ github.token }}
run: | run: |
uv run --no-sync python scripts/test_quality_gate.py --base "$GATE_BASE_SHA" uv run --no-sync python scripts/test_quality_gate.py --base "$GATE_BASE_SHA"
@ -164,7 +170,7 @@ jobs:
run: | run: |
uv run --no-sync python -c "import openai; print(f'OpenAI version: {openai.__version__}')" 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' if: steps.changes.outputs.decision != 'skip'
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
@ -174,23 +180,25 @@ jobs:
- name: Check tests/e2e basedpyright (zero errors) - name: Check tests/e2e basedpyright (zero errors)
if: steps.changes.outputs.decision != 'skip' if: steps.changes.outputs.decision != 'skip'
run: | run: |
if git diff --name-only --diff-filter=ACMRD "$GATE_BASE_SHA" HEAD -- ':(glob)tests/e2e/**/*.py' | grep -q .; then if git diff --name-only --diff-filter=ACMRD "$GATE_BASE_SHA" HEAD -- ':(glob)tests/e2e/**/*.py' ':(glob)tests/e2e_harness/**/*.py' pyrightconfig.json | grep -q .; then
uv run --no-sync basedpyright tests/e2e uv run --no-sync basedpyright tests/e2e tests/e2e_harness
else else
echo "No changed tests/e2e Python files; skipping." echo "No changed tests/e2e Python files; skipping."
fi fi
- name: Run the claude_code harness unit tests - name: Run the e2e harness tests
if: steps.changes.outputs.decision != 'skip' if: steps.changes.outputs.decision != 'skip'
env:
LITELLM_MASTER_KEY: sk-e2e-harness-tests-reach-no-proxy
run: | run: |
if ! git diff --name-only --diff-filter=ACMRD "$GATE_BASE_SHA" HEAD -- ':(glob)tests/e2e/claude_code/**/*.py' ':(glob)tests/e2e/*.py' tests/e2e/claude_code/cron_vm/install_claude_code.sh 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' pyproject.toml uv.lock .github/workflows/test-linting.yml | grep -q .; then
echo "No changed claude_code harness files; skipping." echo "No changed e2e harness files; skipping."
exit 0 exit 0
fi fi
retry() { "$@" || { sleep 15; "$@"; } || { sleep 30; "$@"; }; } retry() { "$@" || { sleep 15; "$@"; } || { sleep 30; "$@"; }; }
CLAUDE_VERSION="$(retry uv run --no-sync python tests/e2e/claude_code/pr_gate_version_resolver.py)" 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" 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 --noconftest -o addopts= -o pythonpath=tests/e2e -p no:rerunfailures tests/e2e/claude_code/_*_unit_tests PATH="$RUNNER_TEMP/claude-cli:$PATH" uv run --no-sync pytest -q tests/e2e_harness
- name: Check for circular imports - name: Check for circular imports
if: steps.changes.outputs.decision != 'skip' if: steps.changes.outputs.decision != 'skip'
@ -204,40 +212,6 @@ jobs:
run: | run: |
uv run --no-sync python -c "from litellm import *" || (echo '🚨 import failed, this means you introduced unprotected imports! 🚨'; exit 1) 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: secret-scan:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5

View file

@ -6,6 +6,8 @@ on:
- "litellm-rust/**" - "litellm-rust/**"
- "litellm/rust_bridge/**" - "litellm/rust_bridge/**"
- "scripts/generate_trace_types.py" - "scripts/generate_trace_types.py"
- "scripts/generate_lens_contract.py"
- "litellm/proxy/lens/**"
- "scripts/trace_codegen/**" - "scripts/trace_codegen/**"
- "tests/test_litellm_rust/**" - "tests/test_litellm_rust/**"
- "litellm/integrations/custom_logger.py" - "litellm/integrations/custom_logger.py"
@ -35,6 +37,8 @@ on:
- "litellm-rust/**" - "litellm-rust/**"
- "litellm/rust_bridge/**" - "litellm/rust_bridge/**"
- "scripts/generate_trace_types.py" - "scripts/generate_trace_types.py"
- "scripts/generate_lens_contract.py"
- "litellm/proxy/lens/**"
- "scripts/trace_codegen/**" - "scripts/trace_codegen/**"
- "tests/test_litellm_rust/**" - "tests/test_litellm_rust/**"
- "litellm/integrations/custom_logger.py" - "litellm/integrations/custom_logger.py"
@ -132,6 +136,10 @@ jobs:
working-directory: . working-directory: .
run: uv run scripts/generate_trace_types.py --check run: uv run scripts/generate_trace_types.py --check
- name: Check generated Lens contracts
working-directory: .
run: uv run scripts/generate_lens_contract.py --check
- run: cargo nextest run --workspace --locked --features litellm-traces/schema,litellm-traces-clickhouse/schema - run: cargo nextest run --workspace --locked --features litellm-traces/schema,litellm-traces-clickhouse/schema
- run: cargo test --workspace --doc --locked - run: cargo test --workspace --doc --locked

View file

@ -39,6 +39,21 @@ concurrency:
# pinning the whole file to one worker (the default --dist=loadscope # pinning the whole file to one worker (the default --dist=loadscope
# behavior for single-file targets). # behavior for single-file targets).
jobs: 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 # 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, # 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 # so an unassigned child runs nowhere; assert_ci_coverage.py holds the tree list
@ -126,6 +141,16 @@ jobs:
dist: loadscope dist: loadscope
timeout: 15 timeout: 15
- test-group: 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/outbound_credentials
workers: 4
dist: loadscope
timeout: 15
# ---- logging: split into 2 shards ---- # ---- logging: split into 2 shards ----
- test-group: custom-logging - test-group: custom-logging
test-path: >- test-path: >-

View file

@ -483,6 +483,7 @@ jobs:
tests/unit/sandbox tests/unit/sandbox
tests/unit/skills/test_skills_main.py tests/unit/skills/test_skills_main.py
tests/unit/tracing tests/unit/tracing
tests/proxy_behavior/lens/test_connection.py
workers: 2 workers: 2
reruns: 0 reruns: 0
timeout-minutes: 20 timeout-minutes: 20

View file

@ -54,13 +54,13 @@ 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 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 four lint gates (`scripts/ruff_strict_gate.py`, `scripts/type_discipline_gate.py`, `scripts/test_quality_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` (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; 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
@ -97,6 +97,7 @@ Follow these coding conventions for new/updated code (a three-line fix in a lega
- 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: <reason>` - 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: <reason>`
- 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: <reason>` - 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: <reason>`
- 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: <reason>` 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: <reason>` 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: <reason>` on the `class` line
- Use dependency injection - 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 - Use tagged unions + match
@ -129,4 +130,4 @@ Before implementing:
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify 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, 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 raise `ANY_CAPS` to satisfy a check

View file

@ -9,8 +9,8 @@ ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2
# Pinned by digest like the other base images; bump explicitly on Node upgrades. # Pinned by digest like the other base images; bump explicitly on Node upgrades.
ARG UI_BUILD_IMAGE=node:24.19-alpine3.24@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 ARG UI_BUILD_IMAGE=node:24.19-alpine3.24@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43
# Checksum from https://www.pgbouncer.org/downloads/ (the Wolfi repo only carries 1.24.x) # Checksum from https://www.pgbouncer.org/downloads/ (the Wolfi repo only carries 1.24.x)
ARG PGBOUNCER_VERSION=1.25.2 ARG PGBOUNCER_VERSION=1.26.0
ARG PGBOUNCER_SHA256=924ad35113fd0a71c8e2dbe85b5d03445532e2b7b37a9f8a48983beea238b332 ARG PGBOUNCER_SHA256=afd25dd61ee6775d37b40629b87ce08736b3e6955f3057bb212e410fbf21c71d
FROM $UV_IMAGE AS uvbin FROM $UV_IMAGE AS uvbin

View file

@ -6,9 +6,8 @@
test-proxy-unit-a test-proxy-unit-b test-integration test-unit-helm \ 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 lens-dev \
info lint lint-inner lint-dev lint-checks format \ 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-basedpyright lint-e2e-basedpyright lint-type-discipline \
lint-ruff-budget lint-ruff-budget-update lint-budget-update lint-gate \ lint-ruff-strict lint-gate lint-test-quality \
lint-test-quality lint-test-quality-budget-update \
install-dev install-proxy-dev install-test-deps install-hooks \ install-dev install-proxy-dev install-test-deps install-hooks \
install-helm-unittest check-circular-imports check-import-safety check check-inner pre-commit \ install-helm-unittest check-circular-imports check-import-safety check check-inner pre-commit \
lint-install lint-fetch-base bootstrap lint-install lint-fetch-base bootstrap
@ -31,14 +30,11 @@ help:
@echo " make lint - Run all linting (Ruff, basedpyright, format check, circular imports, import safety)" @echo " make lint - Run all linting (Ruff, basedpyright, format check, circular imports, import safety)"
@echo " make lint-ruff - Run Ruff linting only" @echo " make lint-ruff - Run Ruff linting only"
@echo " make lint-basedpyright - Run basedpyright strict, gated by per-rule error counts" @echo " make lint-basedpyright - Run basedpyright strict, gated by per-rule error counts"
@echo " make lint-e2e-basedpyright - Run basedpyright over tests/e2e (zero errors allowed)" @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-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-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's TQ counts against their merge-base counts"
@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 check-circular-imports - Check for circular imports" @echo " make check-circular-imports - Check for circular imports"
@echo " make check-import-safety - Check import safety" @echo " make check-import-safety - Check import safety"
@echo " make test - Run all tests" @echo " make test - Run all tests"
@ -143,7 +139,7 @@ lint-fetch-base:
# Mirror test-linting.yml's lint job environment: the proxy-dev group plus a generated # Mirror test-linting.yml's lint job environment: the proxy-dev group plus a generated
# Prisma client, so `basedpyright tests/e2e` resolves the same modules CI does. The # 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 # own .venv-typecheck). --inexact tops up the venv instead of pruning the proxy extras
# gen:api and the running proxy need. # gen:api and the running proxy need.
lint-install: lint-install:
@ -211,27 +207,22 @@ lint-basedpyright: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
$(UV_RUN) python scripts/type_check_gate.py --base "$(BASE_REF)" $(UV_RUN) python scripts/type_check_gate.py --base "$(BASE_REF)"
lint-e2e-basedpyright: $(LINT_E2E_DEP_INSTALL) lint-e2e-basedpyright: $(LINT_E2E_DEP_INSTALL)
$(UV_RUN) basedpyright tests/e2e $(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. # unexplained suppressions), the test-linting.yml step `make lint` used to omit.
lint-type-discipline: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE) lint-type-discipline: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
$(UV_RUN) python scripts/type_discipline_gate.py --base "$(BASE_REF)" $(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 # litellm module-global mutation, credential-gated skips, conftest snapshot
# inventory), counted across tests/ the same delta-vs-base way. # inventory), counted across tests/ the same delta-vs-base way.
lint-test-quality: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE) lint-test-quality: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
$(UV_RUN) python scripts/test_quality_gate.py --base "$(BASE_REF)" $(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)"
lint-format: format-check 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)" $(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 # Strict gate, invoked the same way CI does in test-linting.yml so a local pass
@ -239,18 +230,6 @@ lint-ruff-budget: install-dev
lint-gate: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE) lint-gate: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
$(UV_RUN) python scripts/ruff_strict_gate.py --base "$(BASE_REF)" $(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) check-circular-imports: $(LINT_DEP_INSTALL)
cd litellm && $(UV_RUN) python ../tests/documentation_tests/test_circular_imports.py && cd .. cd litellm && $(UV_RUN) python ../tests/documentation_tests/test_circular_imports.py && cd ..
@ -260,7 +239,7 @@ check-import-safety: $(LINT_DEP_INSTALL)
# Combined linting, isomorphic to test-linting.yml's lint job so a local pass means a # Combined linting, isomorphic to test-linting.yml's lint job so a local pass means a
# green CI lint: it installs the same env (proxy-dev + generated Prisma client) and then # 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 / # 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 # 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, # 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 # base fetch) runs once up front; the checks themselves are independent, so a sub-make

View file

@ -23,6 +23,7 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = (
"/end_user/", "/end_user/",
"/sso/", "/sso/",
"/liteadmin/slack/connect/", "/liteadmin/slack/connect/",
"/moyai/connect/",
"/login", "/login",
"/v2/login", "/v2/login",
"/v3/login", "/v3/login",

View file

@ -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
}
}

View file

@ -5,7 +5,7 @@ Standard patterns for test/mock keys and credentials in the LiteLLM codebase to
## How GitGuardian Works ## How GitGuardian Works
GitGuardian uses **machine learning and entropy analysis**, not just pattern matching: GitGuardian uses **machine learning and entropy analysis**, not just pattern matching:
- **Low entropy** values (like `sk-1234`, `postgres`) are automatically ignored - **Low entropy** values (like `sk-test`, `postgres`) are automatically ignored
- **High entropy** values (realistic-looking secrets) trigger detection - **High entropy** values (realistic-looking secrets) trigger detection
- **Context-aware** detection understands code syntax like `os.environ["KEY"]` - **Context-aware** detection understands code syntax like `os.environ["KEY"]`
@ -15,8 +15,8 @@ GitGuardian uses **machine learning and entropy analysis**, not just pattern mat
These won't trigger GitGuardian's ML detector: These won't trigger GitGuardian's ML detector:
```python ```python
api_key = "sk-1234" api_key = "sk-test"
api_key = "sk-12345" api_key = "sk-mock"
database_password = "postgres" database_password = "postgres"
token = "test123" token = "test123"
``` ```

View file

@ -16,6 +16,8 @@
"\n", "\n",
"These are **selected examples**. LiteLLM Proxy is **OpenAI-Compatible**, it works with any project that calls OpenAI. Just change the `base_url`, `api_key` and `model`.\n", "These are **selected examples**. LiteLLM Proxy is **OpenAI-Compatible**, it works with any project that calls OpenAI. Just change the `base_url`, `api_key` and `model`.\n",
"\n", "\n",
"Set `LITELLM_MASTER_KEY` in the notebook environment to the same key configured on the proxy before running authenticated examples\n",
"\n",
"For more examples, [go here](https://docs.litellm.ai/docs/proxy/user_keys)\n", "For more examples, [go here](https://docs.litellm.ai/docs/proxy/user_keys)\n",
"\n", "\n",
"To pass provider-specific args, [go here](https://docs.litellm.ai/docs/completion/provider_specific_params#proxy-usage)\n", "To pass provider-specific args, [go here](https://docs.litellm.ai/docs/completion/provider_specific_params#proxy-usage)\n",
@ -95,9 +97,10 @@
}, },
"outputs": [], "outputs": [],
"source": [ "source": [
"import os\n",
"from openai import OpenAI\n", "from openai import OpenAI\n",
"client = OpenAI(\n", "client = OpenAI(\n",
" api_key=\"sk-1234\", # [OPTIONAL] set if you set one on proxy, else set \"\"\n", " api_key=os.environ[\"LITELLM_MASTER_KEY\"],\n",
" base_url=\"http://0.0.0.0:4000\",\n", " base_url=\"http://0.0.0.0:4000\",\n",
")\n", ")\n",
"\n", "\n",
@ -298,14 +301,14 @@
" engine=\"azure-gpt-3.5\", # model_name on litellm proxy\n", " engine=\"azure-gpt-3.5\", # model_name on litellm proxy\n",
" temperature=0.0,\n", " temperature=0.0,\n",
" azure_endpoint=\"http://0.0.0.0:4000\", # litellm proxy endpoint\n", " azure_endpoint=\"http://0.0.0.0:4000\", # litellm proxy endpoint\n",
" api_key=\"sk-1234\", # litellm proxy API Key\n", " api_key=os.environ[\"LITELLM_MASTER_KEY\"],\n",
" api_version=\"2023-07-01-preview\",\n", " api_version=\"2023-07-01-preview\",\n",
")\n", ")\n",
"\n", "\n",
"embed_model = AzureOpenAIEmbedding(\n", "embed_model = AzureOpenAIEmbedding(\n",
" deployment_name=\"azure-embedding-model\",\n", " deployment_name=\"azure-embedding-model\",\n",
" azure_endpoint=\"http://0.0.0.0:4000\",\n", " azure_endpoint=\"http://0.0.0.0:4000\",\n",
" api_key=\"sk-1234\",\n", " api_key=os.environ[\"LITELLM_MASTER_KEY\"],\n",
" api_version=\"2023-07-01-preview\",\n", " api_version=\"2023-07-01-preview\",\n",
")\n", ")\n",
"\n", "\n",
@ -341,7 +344,7 @@
"\n", "\n",
"const model = new ChatOpenAI({\n", "const model = new ChatOpenAI({\n",
" modelName: \"gpt-4\",\n", " modelName: \"gpt-4\",\n",
" openAIApiKey: \"sk-1234\",\n", " openAIApiKey: process.env.LITELLM_MASTER_KEY,\n",
" modelKwargs: {\"metadata\": \"hello world\"} // 👈 PASS Additional params here\n", " modelKwargs: {\"metadata\": \"hello world\"} // 👈 PASS Additional params here\n",
"}, {\n", "}, {\n",
" basePath: \"http://0.0.0.0:4000\",\n", " basePath: \"http://0.0.0.0:4000\",\n",
@ -372,7 +375,7 @@
"const { OpenAI } = require('openai');\n", "const { OpenAI } = require('openai');\n",
"\n", "\n",
"const openai = new OpenAI({\n", "const openai = new OpenAI({\n",
" apiKey: \"sk-1234\", // This is the default and can be omitted\n", " apiKey: process.env.LITELLM_MASTER_KEY,\n",
" baseURL: \"http://0.0.0.0:4000\"\n", " baseURL: \"http://0.0.0.0:4000\"\n",
"});\n", "});\n",
"\n", "\n",

View file

@ -6,7 +6,9 @@
"id": "680oRk1af-xJ" "id": "680oRk1af-xJ"
}, },
"source": [ "source": [
"# Environment Setup" "# Environment Setup\n",
"\n",
"Set `LITELLM_MASTER_KEY` in the notebook environment to the same key configured on the proxy before running the cells"
] ]
}, },
{ {
@ -17,6 +19,7 @@
}, },
"outputs": [], "outputs": [],
"source": [ "source": [
"import os\n",
"import csv\n", "import csv\n",
"from typing import Optional\n", "from typing import Optional\n",
"import httpx\n", "import httpx\n",
@ -24,7 +27,7 @@
"import asyncio\n", "import asyncio\n",
"\n", "\n",
"proxy_base_url = \"http://0.0.0.0:4000\" # 👈 SET TO PROXY URL\n", "proxy_base_url = \"http://0.0.0.0:4000\" # 👈 SET TO PROXY URL\n",
"master_key = \"sk-1234\" # 👈 SET TO PROXY MASTER KEY" "master_key = os.environ[\"LITELLM_MASTER_KEY\"]"
] ]
}, },
{ {

View file

@ -47,7 +47,7 @@ Set your environment variables:
```bash ```bash
export ANTHROPIC_API_KEY="your-anthropic-api-key" export ANTHROPIC_API_KEY="your-anthropic-api-key"
export LITELLM_MASTER_KEY="sk-1234567890" # Generate a secure key export LITELLM_MASTER_KEY="sk-$(openssl rand -hex 32)"
``` ```
## Step 2: Start Proxy ## Step 2: Start Proxy
@ -292,4 +292,3 @@ model_list:
- [LiteLLM Documentation](https://docs.litellm.ai/) - [LiteLLM Documentation](https://docs.litellm.ai/)
- [Claude Code Documentation](https://docs.anthropic.com/en/docs/claude-code/overview) - [Claude Code Documentation](https://docs.anthropic.com/en/docs/claude-code/overview)
- [Anthropic's LiteLLM Configuration Guide](https://docs.anthropic.com/en/docs/claude-code/llm-gateway#litellm-configuration) - [Anthropic's LiteLLM Configuration Guide](https://docs.anthropic.com/en/docs/claude-code/llm-gateway#litellm-configuration)

View file

@ -20,6 +20,12 @@ litellm --model claude-sonnet-4-20250514
litellm --config config.yaml litellm --config config.yaml
``` ```
Generate a master key before starting the proxy:
```bash
export LITELLM_MASTER_KEY="sk-$(openssl rand -hex 32)"
```
### 3. Run the chat ### 3. Run the chat
**Basic Agent (no MCP):** **Basic Agent (no MCP):**
@ -58,7 +64,7 @@ Set these environment variables if needed:
```bash ```bash
export LITELLM_PROXY_URL="http://localhost:4000" export LITELLM_PROXY_URL="http://localhost:4000"
export LITELLM_API_KEY="sk-1234" export LITELLM_API_KEY="$LITELLM_MASTER_KEY"
export LITELLM_MODEL="bedrock-claude-sonnet-4.5" export LITELLM_MODEL="bedrock-claude-sonnet-4.5"
``` ```
@ -98,7 +104,7 @@ The key is pointing the Agent SDK to LiteLLM instead of directly to Anthropic:
```python ```python
# Point to LiteLLM gateway (not Anthropic) # Point to LiteLLM gateway (not Anthropic)
os.environ["ANTHROPIC_BASE_URL"] = "http://localhost:4000" os.environ["ANTHROPIC_BASE_URL"] = "http://localhost:4000"
os.environ["ANTHROPIC_API_KEY"] = "sk-1234" # Your LiteLLM key os.environ["ANTHROPIC_API_KEY"] = os.environ["LITELLM_API_KEY"]
# Use any model configured in LiteLLM # Use any model configured in LiteLLM
options = ClaudeAgentOptions( options = ClaudeAgentOptions(

View file

@ -13,7 +13,7 @@ class Config:
LITELLM_PROXY_URL = os.getenv("LITELLM_PROXY_URL", "http://localhost:4000") LITELLM_PROXY_URL = os.getenv("LITELLM_PROXY_URL", "http://localhost:4000")
# LiteLLM API key (master key or virtual key) # LiteLLM API key (master key or virtual key)
LITELLM_API_KEY = os.getenv("LITELLM_API_KEY", "sk-1234") LITELLM_API_KEY = os.environ["LITELLM_API_KEY"]
# Model name as configured in LiteLLM (e.g., "bedrock-claude-sonnet-4", "gpt-4", etc.) # Model name as configured in LiteLLM (e.g., "bedrock-claude-sonnet-4", "gpt-4", etc.)
LITELLM_MODEL = os.getenv("LITELLM_MODEL", "bedrock-claude-sonnet-4.5") LITELLM_MODEL = os.getenv("LITELLM_MODEL", "bedrock-claude-sonnet-4.5")

View file

@ -1,8 +1,10 @@
import os
from openai import OpenAI from openai import OpenAI
client = OpenAI( client = OpenAI(
base_url="http://0.0.0.0:4000", base_url="http://0.0.0.0:4000",
api_key="sk-1234", api_key=os.environ["LITELLM_MASTER_KEY"],
) )
BEDROCK_BATCH_MODEL = "bedrock/batch-anthropic.claude-3-5-sonnet-20240620-v1:0" BEDROCK_BATCH_MODEL = "bedrock/batch-anthropic.claude-3-5-sonnet-20240620-v1:0"

View file

@ -4,10 +4,12 @@ Use LiteLLM Proxy MCP Gateway to call MCP tools.
When using LiteLLM Proxy, you can use the same MCP tools across all your LLM providers. When using LiteLLM Proxy, you can use the same MCP tools across all your LLM providers.
""" """
import os
import openai import openai
client = openai.OpenAI( client = openai.OpenAI(
api_key="sk-1234", # paste your litellm proxy api key here api_key=os.environ["LITELLM_MASTER_KEY"],
base_url="http://localhost:4000", # paste your litellm proxy base url here base_url="http://localhost:4000", # paste your litellm proxy base url here
) )
print("Making API request to Responses API with MCP tools") print("Making API request to Responses API with MCP tools")

View file

@ -15,12 +15,14 @@ pip install livekit-agents[xai] websockets
```bash ```bash
# With xAI # With xAI
export XAI_API_KEY="your-xai-key" export XAI_API_KEY="your-xai-key"
export LITELLM_MASTER_KEY="sk-$(openssl rand -hex 32)"
litellm --config config.yaml --port 4000 litellm --config config.yaml --port 4000
``` ```
### 3. Run the voice agent ### 3. Run the voice agent
```bash ```bash
export LITELLM_API_KEY="$LITELLM_MASTER_KEY"
python main.py python main.py
``` ```
@ -32,7 +34,7 @@ Set these environment variables if needed:
```bash ```bash
export LITELLM_PROXY_URL="http://localhost:4000" export LITELLM_PROXY_URL="http://localhost:4000"
export LITELLM_API_KEY="sk-1234" export LITELLM_API_KEY="$LITELLM_MASTER_KEY"
export LITELLM_MODEL="grok-voice-agent" export LITELLM_MODEL="grok-voice-agent"
``` ```
@ -59,7 +61,7 @@ model_list:
mode: realtime mode: realtime
general_settings: general_settings:
master_key: sk-1234 master_key: os.environ/LITELLM_MASTER_KEY
``` ```
Then start: `litellm --config config.yaml --port 4000` Then start: `litellm --config config.yaml --port 4000`
@ -73,7 +75,7 @@ from livekit.plugins import xai
model = xai.realtime.RealtimeModel( model = xai.realtime.RealtimeModel(
voice="ara", voice="ara",
api_key="sk-1234", # LiteLLM proxy key api_key=os.environ["LITELLM_MASTER_KEY"],
base_url="http://localhost:4000", # Point to LiteLLM base_url="http://localhost:4000", # Point to LiteLLM
) )
``` ```

View file

@ -17,4 +17,4 @@ litellm_settings:
drop_params: True drop_params: True
general_settings: general_settings:
master_key: sk-1234 # Change this to a secure key master_key: os.environ/LITELLM_MASTER_KEY

View file

@ -13,7 +13,7 @@ import websockets
# Configuration # Configuration
PROXY_URL = os.getenv("LITELLM_PROXY_URL", "http://localhost:4000") PROXY_URL = os.getenv("LITELLM_PROXY_URL", "http://localhost:4000")
API_KEY = os.getenv("LITELLM_API_KEY", "sk-1234") API_KEY = os.environ["LITELLM_API_KEY"]
MODEL = os.getenv("LITELLM_MODEL", "grok-voice-agent") MODEL = os.getenv("LITELLM_MODEL", "grok-voice-agent")

View file

@ -58,7 +58,7 @@ litellm_settings:
context_window_fallbacks: [{"gpt-3.5-turbo": ["gpt-3.5-turbo-large"]}] context_window_fallbacks: [{"gpt-3.5-turbo": ["gpt-3.5-turbo-large"]}]
general_settings: general_settings:
master_key: sk-1234 # [OPTIONAL] Use to enforce auth on proxy. See - https://docs.litellm.ai/docs/proxy/virtual_keys master_key: os.environ/LITELLM_MASTER_KEY
store_model_in_db: True store_model_in_db: True
proxy_budget_rescheduler_min_time: 60 proxy_budget_rescheduler_min_time: 60
proxy_budget_rescheduler_max_time: 64 proxy_budget_rescheduler_max_time: 64

View file

@ -12,6 +12,8 @@ Step 2: reads `model_list` and loops through all models
Step 3: calls `<proxy-base-url>/model/new` for each model Step 3: calls `<proxy-base-url>/model/new` for each model
""" """
import os
import yaml import yaml
import requests import requests
@ -89,7 +91,7 @@ def migrate_models(config_file, proxy_base_url):
# Usage # Usage
config_file = "config.yaml" config_file = "config.yaml"
proxy_base_url = "http://0.0.0.0:4000" proxy_base_url = "http://0.0.0.0:4000"
master_key = "sk-1234" master_key = os.environ["LITELLM_MASTER_KEY"]
print(f"config_file: {config_file}") print(f"config_file: {config_file}")
print(f"proxy_base_url: {proxy_base_url}") print(f"proxy_base_url: {proxy_base_url}")
migrate_models(config_file, proxy_base_url) migrate_models(config_file, proxy_base_url)

View file

@ -1,8 +1,10 @@
import base64 import base64
import os
from openai import OpenAI from openai import OpenAI
import time import time
client = OpenAI(base_url="http://0.0.0.0:4001", api_key="sk-1234") client = OpenAI(base_url="http://0.0.0.0:4001", api_key=os.environ["LITELLM_MASTER_KEY"])
# Function to encode the image # Function to encode the image

View file

@ -4,7 +4,9 @@
"cell_type": "markdown", "cell_type": "markdown",
"metadata": {}, "metadata": {},
"source": [ "source": [
"# Databricks Notebook with MLFlow AutoLogging for LiteLLM Proxy calls\n" "# Databricks Notebook with MLFlow AutoLogging for LiteLLM Proxy calls\n",
"\n",
"Set `LITELLM_PROXY_API_KEY` in the notebook environment to a key accepted by the proxy before running the model cell\n"
] ]
}, },
{ {
@ -160,11 +162,13 @@
}, },
"outputs": [], "outputs": [],
"source": [ "source": [
"import os\n",
"\n",
"model = ChatOpenAI(\n", "model = ChatOpenAI(\n",
" openai_api_base=\"LITELLM_PROXY_BASE_URL\", # e.g.: http://0.0.0.0:4000\n", " openai_api_base=\"LITELLM_PROXY_BASE_URL\", # e.g.: http://0.0.0.0:4000\n",
" model = \"gpt-3.5-turbo\", # LITELLM 'model_name'\n", " model = \"gpt-3.5-turbo\", # LITELLM 'model_name'\n",
" temperature=0.1, \n", " temperature=0.1, \n",
" api_key=\"LITELLM_PROXY_API_KEY\" # e.g.: \"sk-1234\"\n", " api_key=os.environ[\"LITELLM_PROXY_API_KEY\"]\n",
")" ")"
] ]
}, },

View file

@ -69,7 +69,7 @@ litellm --config config.yaml
```bash ```bash
curl http://0.0.0.0:4000/v1/chat/completions \ curl http://0.0.0.0:4000/v1/chat/completions \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "Authorization: Bearer sk-1234" \ -H "Authorization: Bearer $LITELLM_MASTER_KEY" \
-d '{ -d '{
"model": "gpt-3.5-turbo", "model": "gpt-3.5-turbo",
"prompt_id": "hello-world-prompt", "prompt_id": "hello-world-prompt",
@ -290,4 +290,3 @@ Before deploying to production:
## Questions? ## Questions?
This is a reference implementation for the LiteLLM Generic Prompt Management API. For questions or issues, please open an issue on the [LiteLLM GitHub repository](https://github.com/BerriAI/litellm). This is a reference implementation for the LiteLLM Generic Prompt Management API. For questions or issues, please open an issue on the [LiteLLM GitHub repository](https://github.com/BerriAI/litellm).

View file

@ -10,6 +10,7 @@ Prerequisites:
- websockets installed: pip install websockets - websockets installed: pip install websockets
Usage: Usage:
export LITELLM_MASTER_KEY="sk-$(openssl rand -hex 32)"
python nova_sonic_realtime.py python nova_sonic_realtime.py
""" """
@ -33,7 +34,7 @@ CHUNK_SIZE = 1024
# LiteLLM proxy configuration # LiteLLM proxy configuration
LITELLM_PROXY_URL = "ws://localhost:4000/v1/realtime?model=bedrock-sonic" LITELLM_PROXY_URL = "ws://localhost:4000/v1/realtime?model=bedrock-sonic"
LITELLM_API_KEY = "sk-12345" # Your LiteLLM API key LITELLM_API_KEY = os.environ["LITELLM_MASTER_KEY"]
class RealtimeClient: class RealtimeClient:

View file

@ -24,8 +24,8 @@ class VeoVideoGenerator:
def __init__( def __init__(
self, self,
api_key: str,
base_url: str = "http://localhost:4000/gemini/v1beta", base_url: str = "http://localhost:4000/gemini/v1beta",
api_key: str = "sk-1234",
): ):
""" """
Initialize the Veo video generator. Initialize the Veo video generator.
@ -274,12 +274,12 @@ def main():
Configure these environment variables: Configure these environment variables:
- LITELLM_BASE_URL: Your LiteLLM proxy URL (default: http://localhost:4000/gemini/v1beta) - LITELLM_BASE_URL: Your LiteLLM proxy URL (default: http://localhost:4000/gemini/v1beta)
- LITELLM_API_KEY: Your LiteLLM API key (default: sk-1234) - LITELLM_API_KEY: API key for LiteLLM proxy authentication
""" """
# Configuration from environment or defaults # Configuration from environment or defaults
base_url = os.getenv("LITELLM_BASE_URL", "http://localhost:4000/gemini/v1beta") base_url = os.getenv("LITELLM_BASE_URL", "http://localhost:4000/gemini/v1beta")
api_key = os.getenv("LITELLM_API_KEY", "sk-1234") api_key = os.environ["LITELLM_API_KEY"]
print("🚀 Starting Veo Video Generation Example") print("🚀 Starting Veo Video Generation Example")
print(f"📡 Using LiteLLM proxy at: {base_url}") print(f"📡 Using LiteLLM proxy at: {base_url}")

View file

@ -1,35 +1,41 @@
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:1d95114038f76513a9ace6fca107d5582b08c65981f81f61cb56bf7fd2ef216d ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:1d95114038f76513a9ace6fca107d5582b08c65981f81f61cb56bf7fd2ef216d
ARG LITELLM_RUNTIME_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 FROM $LITELLM_BUILD_IMAGE AS builder
COPY --from=uvbin /uv /usr/local/bin/uv RUN apk add --no-cache rust build-base cmake perl pkgconf openssl-dev libseccomp-dev python-3.13
RUN apk add --no-cache python-3.13 build-base libseccomp-dev WORKDIR /src
ENV UV_PYTHON_DOWNLOADS=0 UV_LINK_MODE=copy COPY .cargo/ .cargo/
WORKDIR /app COPY litellm-rust/ litellm-rust/
COPY deploy/lens/requirements.lock /tmp/requirements.lock COPY litellm/proxy/lens/prompts/ litellm/proxy/lens/prompts/
RUN uv venv --python python3.13 /app/.venv && \ WORKDIR /src/litellm-rust
uv pip sync --python /app/.venv/bin/python --require-hashes --only-binary :all: /tmp/requirements.lock ENV CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_PROFILE_RELEASE_STRIP=symbols
RUN cargo build --locked --release -p litellm-lens
COPY deploy/lens/python_policy.c /tmp/python_policy.c 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 && \ 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 /tmp/python-policy /tmp/python.seccomp
FROM $LITELLM_RUNTIME_IMAGE AS runtime FROM builder AS test-builder
RUN cargo test --locked --release -p litellm-lens --test sandbox --no-run --message-format=json > /tmp/test-artifacts.json && \
python3.13 -c 'import json, pathlib, shutil; rows = [json.loads(line) for line in pathlib.Path("/tmp/test-artifacts.json").read_text().splitlines()]; artifact, = [r["executable"] for r in rows if r.get("executable") and r["target"]["name"] == "sandbox"]; shutil.copyfile(artifact, "/tmp/lens-sandbox-tests")' && \
chmod 755 /tmp/lens-sandbox-tests
FROM $LITELLM_RUNTIME_IMAGE AS service
ARG LITELLM_RELEASE_TAG="" ARG LITELLM_RELEASE_TAG=""
RUN : "${LITELLM_RELEASE_TAG:?Pass --build-arg LITELLM_RELEASE_TAG matching the gateway}" RUN : "${LITELLM_RELEASE_TAG:?Pass --build-arg LITELLM_RELEASE_TAG matching the gateway}"
RUN apk add --no-cache python-3.13 setpriv RUN apk add --no-cache python-3.13 setpriv libgcc libstdc++ openssl ca-certificates
ENV LITELLM_RELEASE_TAG=${LITELLM_RELEASE_TAG} \ ENV LITELLM_RELEASE_TAG=${LITELLM_RELEASE_TAG} PYTHONDONTWRITEBYTECODE=1
PATH="/app/.venv/bin:${PATH}" \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app WORKDIR /app
COPY --from=builder /app/.venv /app/.venv COPY --from=builder /src/litellm-rust/target/release/litellm-lens /usr/local/bin/litellm-lens
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 --from=builder /tmp/python.seccomp /app/lens/python.seccomp
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/prompts/ /app/lens/prompts/
COPY --from=builder /app/python.seccomp /app/lens/python.seccomp
COPY deploy/lens/python_runtime.py /tmp/python_runtime.py 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 RUN python3.13 -S /tmp/python_runtime.py /app/lens/python-runtime.json && rm /tmp/python_runtime.py
USER 65532:65532 USER 65532:65532
CMD ["python", "-m", "lens.worker"] EXPOSE 4318
ENTRYPOINT ["/usr/local/bin/litellm-lens"]
FROM service AS smoke
COPY --from=test-builder /tmp/lens-sandbox-tests /usr/local/bin/lens-sandbox-tests
ENTRYPOINT ["/usr/local/bin/lens-sandbox-tests"]
CMD ["--ignored", "--nocapture", "--test-threads=1"]
FROM service AS runtime

View file

@ -1,15 +1,15 @@
** **
!deploy/ !deploy/
!deploy/lens/ !deploy/lens/
!deploy/lens/requirements.lock !deploy/lens/python_policy.c
!deploy/lens/python_runtime.py
!litellm/ !litellm/
!litellm/proxy/ !litellm/proxy/
!litellm/proxy/lens/ !litellm/proxy/lens/
!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
!litellm/proxy/lens/prompts/ !litellm/proxy/lens/prompts/
!litellm/proxy/lens/prompts/** !litellm/proxy/lens/prompts/**
!.cargo/
!.cargo/**
!litellm-rust/
!litellm-rust/**
litellm-rust/target/

View file

@ -1,112 +1,121 @@
# Lens worker # Lens service
Lens reviews recorded activity and saves evidence-linked findings in the LiteLLM dashboard under Observability, Lens (`/ui/lens/`) Lens records agent activity and investigates it in a separate Rust service. LiteLLM serves model requests, the dashboard, and investigation settings. Lens owns trace ingestion and ClickHouse access; PostgreSQL stays with LiteLLM
## Install Agent exporters send traces directly to Lens. LiteLLM sends its optional request logs through a bounded background queue. If Lens or ClickHouse is unavailable, model requests continue; traces can be delayed or dropped according to the exporter's retry policy. The gateway never waits for ClickHouse during startup or inference
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
### New local installation Install Docker with Compose, Python 3.10 or later, and Git. Clone LiteLLM, select a published release that includes Lens, and start the existing Compose stack:
Install Docker with Compose and Git. This builds LiteLLM and its worker from the same checkout and starts the existing local tracing stack:
```bash ```bash
git clone https://github.com/BerriAI/litellm.git git clone https://github.com/BerriAI/litellm.git
cd litellm cd litellm
export LITELLM_RELEASE_TAG="sha-$(git rev-parse HEAD)" python3 deploy/lens/configure.py --version <release-version>
export LENS_WORKER_IMAGE="litellm-lens-worker:${LITELLM_RELEASE_TAG}" docker compose --env-file deploy/lens/.env -f deploy/lens/stack.yaml up -d --wait
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 password `sk-1234`. 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: The configuration command generates your keys and database passwords once, saves them in `deploy/lens/.env` with owner-only permissions, and preserves them on subsequent runs. Back up this file alongside your database volumes. Both images use the selected release; there is no local image build
```bash Open `http://localhost:4000/ui/` and sign in as `admin` using `LITELLM_MASTER_KEY` from the saved file. Open **Lens**, select your framework, generate a tracing key, and copy the displayed configuration. The trace endpoint is already filled in. Keep your agent's existing model credentials; the tracing key only authorizes trace uploads
export LITELLM_URL=http://litellm:4000
export LENS_WORKER_TOKEN='<paste-your-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 PostgreSQL and ClickHouse use persistent Docker volumes and have no host ports. The dashboard and trace listener bind to localhost. Use your normal TLS and ingress for a hosted deployment. Stop the stack with `docker compose --env-file deploy/lens/.env -f deploy/lens/stack.yaml down`; omit `-v` to retain data
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 Under **Lens > Investigations > Connect worker**, choose an analysis model and monthly budget. The deployed service connects automatically after you save these settings. There is no worker command or second token to copy
### Existing LiteLLM installation ## 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 Keep your gateway, PostgreSQL database, deployment tool, and existing encryption keys. Deploy the matching Lens image, give it access to ClickHouse, and configure the service connection on LiteLLM
The public development package is `ghcr.io/berriai/litellm-lens-worker-dev:sha-<full-commit>`. 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: | Variable | LiteLLM | Lens service |
| --- | --- | --- |
| `LITELLM_LENS_SERVICE_TOKEN` | Same private random secret, at least 32 characters | Same secret |
| `LITELLM_LENS_URL` | Internal Lens URL, such as `http://lens-worker:4318` | Not needed |
| `LITELLM_LENS_PUBLIC_URL` | Ingestion base URL reachable by your agents | Not needed |
| `LITELLM_URL` | Not needed | LiteLLM URL reachable from Lens |
| `CLICKHOUSE_URL` | Remove it from Lens tracing configuration | ClickHouse HTTP URL with credentials |
| `CLICKHOUSE_DATABASE` | Not needed for Lens | Existing database name, defaults to `litellm` |
| `AGENT_TRACING_RETENTION_DAYS` | Not needed for Lens | Retention for traces and Lens request logs, defaults to `14` |
Remove the old `general_settings.tracing.store` configuration used for Lens from LiteLLM. Keep unrelated logging integrations and their configuration. Only Lens should reach its ClickHouse database. The shared service secret is an infrastructure credential: keep it out of browser code, agent exporters, screenshots, and public ingress headers
Expose the Lens HTTP listener on port 4318 through TLS. Route `/lens-ingest` on your existing hostname directly to Lens at the load balancer, then set `LITELLM_LENS_PUBLIC_URL=https://<your-host>/lens-ingest`. The gateway must not proxy these uploads. Alternatively use a separate hostname and forward `/v1/` to Lens. Keep `/internal/` private; it requires the service secret
### Standalone Docker or a container host
Build from the same source commit and `LITELLM_RELEASE_TAG` as your running gateway:
```bash ```bash
export LITELLM_RELEASE_TAG='<gateway-release-identity>' export LITELLM_RELEASE_TAG='<gateway-release-identity>'
export LENS_WORKER_IMAGE='<your-registry>/litellm-lens-worker:<your-image-tag>' export LENS_WORKER_IMAGE='<your-registry>/litellm-lens-worker:<image-tag>'
docker build --build-arg LITELLM_RELEASE_TAG="$LITELLM_RELEASE_TAG" \ docker build --build-arg LITELLM_RELEASE_TAG="$LITELLM_RELEASE_TAG" \
-f deploy/lens/Dockerfile -t "$LENS_WORKER_IMAGE" . -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 Publish that image to a registry your host can pull from. Prefer a digest reference for hosted deployments. Public development images use `ghcr.io/berriai/litellm-lens-worker-dev:sha-<full-commit>`; check that the exact image exists before selecting it. An arbitrary commit may not have a published image
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. The image supports native amd64 and arm64. For worker-only Compose, use `deploy/lens/compose.yaml` with a private environment file containing `LENS_WORKER_IMAGE`, `LITELLM_URL`, `LITELLM_LENS_SERVICE_TOKEN`, and `CLICKHOUSE_URL`:
## Helm ```bash
docker compose --env-file /path/to/private/lens.env \
-f deploy/lens/compose.yaml up -d
```
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: The Compose listener binds to localhost. Your reverse proxy must reach it. On Render, run Lens as a web service with the same environment and listener port 4318, not an outbound-only background worker. Use `/health/live` for process health and `/health/ready` to check storage and tracing credentials
Lens does not need provider credentials, PostgreSQL credentials, a GPU, or the LiteLLM Python package. The image includes a small CPython runtime only for the investigator's confined calculation tool. Keep the shipped security settings, temporary filesystem, and resource limits
### Kubernetes with Helm
Both `helm/litellm` and `helm/litellm-helm` support Lens. Keep your existing chart, release name, namespace, and values. Add:
```yaml ```yaml
lensWorker: lensWorker:
enabled: true enabled: true
image:
repository: <your-worker-image-repository>
digest: sha256:<matching-worker-image-digest>
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 Then run your usual Helm deployment command using the matching published chart. The chart supplies the matching Lens image, generates the shared service secret, starts a single ClickHouse instance with a persistent volume, and connects the services. Your cluster needs a default storage class, or set `lensWorker.clickhouse.storageClassName`. Bundled storage defaults to 20 GiB; set `lensWorker.clickhouse.storage` before installation to choose another size
## Standalone worker When your chart manages an ingress with one hostname, the chart fills in the public tracing address and routes `/lens-ingest` directly to Lens. TLS is detected from `ingress.tls` or an ALB certificate annotation. With custom ingress, multiple hostnames, or TLS terminated elsewhere, set the address explicitly:
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 ```yaml
general_settings: lensWorker:
tracing: enabled: true
store: publicUrl: https://<your-litellm-host>/lens-ingest
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 For a dedicated trace hostname, configure `lensWorker.ingress.enabled`, `host`, `className`, and `tls`. Its hostname supplies the public address unless you override `publicUrl`. Internal Lens routes stay private
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 To use an existing ClickHouse database and secrets managed by your platform, keep these overrides:
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 ```yaml
lensWorker:
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 enabled: true
serviceTokenSecret:
The dashboard uses the gateway's `LENS_WORKER_IMAGE` override when set. Public `:sha-<commit>` development images must match both the gateway commit and release identity. Build from source for the worker host's native architecture name: litellm-lens-service
key: service-token
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 clickhouseSecret:
name: litellm-lens-clickhouse
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: key: url
clickhouseDatabase: litellm
```bash retentionDays: 14
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` Supplying `clickhouseSecret.name` uses that database and disables bundled storage. Keep your database name and retention policy. For GitOps tools that render Helm without cluster access, supply both existing secrets so rendering cannot regenerate credentials
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 Normal Helm upgrades reuse the generated credentials. Secrets are retained on uninstall, and the ClickHouse volume is retained by Kubernetes. Back them up together. Treat changing the database, storage class, or secret reference as an infrastructure change, not a routine version update
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 After deployment, open **Lens**. If it was already open, click **Check setup**. The setup section moves to your framework and tracing key when Lens is reachable and storage is ready. Investigation setup asks for the analysis model and budget; the installed service connects automatically
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 ## Upgrade
V1 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 Upgrade LiteLLM and Lens from the same source commit and release identity. For a coordinated published release, use its matching worker version; `deploy/lens/stack.yaml` starts LiteLLM, Lens, PostgreSQL, and ClickHouse for new installations. Standalone images remain available. Publishing an image does not update running containers
Keep the same databases, encryption keys, shared service secret, and public ingestion URL. Pause scheduled investigations and finish or cancel active runs, update both images through your usual deployment process, then check ingestion and run an investigation before resuming schedules. Do not run `docker compose down -v`
## Development
`make lens-dev` starts LiteLLM, Lens, and the hot-reload dashboard. Set `LENS_DEV_PROXY_PORT` and `LENS_DEV_UI_PORT` to change the local ports. For containers, pass the same release identity to both builds. Unversioned or incompatible workers are refused before claiming work
## Configure a lens ## Configure a lens
@ -114,17 +123,17 @@ Choose agent runs, individual LLM requests, or both. The matching-activity previ
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 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. Every scan recalculates the window, so overlapping windows can review the same activity again. Duplicate a lens when you want a separate investigation without changing an existing monitor 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. Configuration edits apply to the next scan. A running scan retains its settings and selected execution IDs across retries Pausing stops future scheduled scans; cancel the active scan separately if needed. The worker polls every 2 to 15 seconds, backing off while idle; 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 ## 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 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 batch selector or Scans tab to reopen previous results. Each batch keeps its own findings, settings, selected runs, coverage and cost. Older batches created before snapshot support remain available through accumulated findings. The Runs tab lists the selected batch's sample and can filter per-run observations, including runs without an observed issue and runs with insufficient evidence. These observations precede the final evidence investigation. Linked-run counts on findings include cited counterexamples, so they are not failure counts 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
Choose **This is expected** and explain why to teach later scans about acceptable behavior. Feedback is kept with the lens and included in subsequent reviews. It does not alter historical evidence or exempt different problems 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 ## What a scan does
@ -132,13 +141,15 @@ The proxy selects executions received or updated within the configured lookback
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 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 prepares a workspace containing the selected execution metadata and reviews executions in parallel. Reviewers receive their assignment and use catalog, read, search and optional Python tools to inspect evidence, including nested agents and other sampled executions. Tools retrieve original content from the gateway when requested; the worker does not preload the sampled traces or inject them into each model request. Python receives selected evidence as streamed input. Completed reviews retain cited excerpts and metadata. Observation batches are grouped in parallel, reconciled, and investigated against the original evidence. Grouping retains supporting run IDs in code, so a pattern occurring thousands of times does not require a model to repeat thousands of IDs 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 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; the final finding format and evidence links are unchanged 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. Candidate investigation errors preserve completed findings. Source and analysis errors remain visible and mark the final scan as failed; transport errors, cancellation and budget exhaustion stop the scan. 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 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 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
@ -146,9 +157,15 @@ Coverage distinguishes eligible, sampled, reviewed, partial, and unassessable ex
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 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
Before every model call, Lens reserves a conservative amount against the monthly lens budget. Successful calls reconcile to reported cost where pricing is available. Interrupted calls retain their reservation because the provider may have charged. A scan stops when the next reservation would exceed the limit, so it can stop with some budget remaining. Both the Lens budget and the selected virtual key’s budgets, model permissions, and rate limits apply. Analysis spend appears under that key in Virtual Keys and normal request logs, with Lens, scan, 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. Existing workers need a billing key assigned in **Set up analysis** before they can resume 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
V1 requires ClickHouse for both sources. It does not reconstruct sessions from unrelated trace IDs, guarantee exhaustive reviews, cache all per-execution observations across scans, or automatically fix agent code. Trace contents can change as late spans arrive, even though a job's selected IDs are fixed. Findings should be reviewed by a person before acting on them 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 ## API access
@ -184,32 +201,14 @@ For local fixture data, run `make lens-dev ARGS=--seed`. Use `make lens-dev ARGS
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 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 The Rust receiver bounds each upload and its decompressed body to 16 MiB and permits two ingestion requests at once per replica. Exporters should split large batches and retry backpressure. `LENS_DEV_SEED_COPIES` and `LENS_DEV_SEED_TIMEOUT_SECONDS` control the seeder; the receiver's limits are compiled into the service
| 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 ## 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 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 ```bash
cargo build --manifest-path litellm-rust/Cargo.toml -p litellm-lens --example worker_once --locked
python -m tests.proxy_behavior.lens.evaluate --api-base "$LITELLM_URL" \ python -m tests.proxy_behavior.lens.evaluate --api-base "$LITELLM_URL" \
--model your-model-alias --split all --background 1000 --concurrency 16 \ --model your-model-alias --split all --background 1000 --concurrency 16 \
--output /tmp/lens-quality.json --output /tmp/lens-quality.json
@ -221,18 +220,17 @@ The default workspace retrieves trace content on demand. Python calls have tempo
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 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
## Upgrading from the original Lens API
The Lens API now uses `/lens` instead of `/engine`, list responses use `lenses`, and worker claims use `lens_id`. Upgrade the proxy and recreate every worker with the image shown by the upgraded dashboard before starting new scans. Update API clients to the new paths and response fields. Old worker images cannot poll the renamed API
Stop workers and let active scans finish before upgrading. Deploy proxy instances together: older proxies cannot use the renamed database tables. The schema migration renames the three Lens tables and the run-history identifier column in place, preserving saved investigations, findings, history, worker credentials, and billing assignments. Existing migration files retain their original names and checksums
Upgrades using `--use_prisma_db_push` stop before schema changes if any legacy Lens table exists, preventing Prisma from dropping saved data. Apply `litellm-proxy-extras/litellm_proxy_extras/migrations/20261001100000_rename_lens/migration.sql` to the configured database schema before retrying. Deployments already using migration history can instead start without `--use_prisma_db_push` to apply the shipped migration normally. Fresh databases and databases already using the renamed tables can continue using database push
## Release compatibility ## 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. During a rolling upgrade, workers wait for a gateway from their release 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 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
@ -243,7 +241,7 @@ The hourly development pipeline pins all component images to the same selected c
## Worker dependencies ## 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 The service builds from the workspace Cargo.lock with a pinned Rust toolchain and a digest-pinned Wolfi runtime. It has no Python package dependencies. CPython and libseccomp support the confined calculation tool. CI builds, runs, and scans native amd64 and arm64 images
## Python analysis boundary ## Python analysis boundary
@ -253,14 +251,14 @@ The native worker image builds a syscall policy with libseccomp and includes the
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 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 The worker permits two Python children at once across all investigations. 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 | | Per-call resource | Default |
| --- | --- | | --- | --- |
| Elapsed execution time | 60 seconds | | Elapsed execution time | 60 seconds |
| CPU time | 30 seconds | | CPU time | 30 seconds |
| Process address space | 512 MiB | | Process address space | 512 MiB |
| Captured stdout or stderr | 8 MiB per stream | | Captured stdout or stderr | 4 MiB per stream |
| Individual scratch file size | 16 MiB | | Individual scratch file size | 16 MiB |
| Monitored scratch storage | 64 MiB | | Monitored scratch storage | 64 MiB |
| Monitored scratch entries | 2,048 | | Monitored scratch entries | 2,048 |
@ -274,12 +272,11 @@ Results include `stdout`, `stderr`, `exit_code`, `error` and `output_complete`.
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: 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 ```bash
docker build --build-arg LITELLM_RELEASE_TAG=lens-python-test \ docker build --target smoke --build-arg LITELLM_RELEASE_TAG=lens-python-test \
-f deploy/lens/Dockerfile -t lens-worker:python-test . -f deploy/lens/Dockerfile -t lens-worker:smoke .
docker run --rm --pull never --read-only --cap-drop ALL \ docker run --rm --read-only --cap-drop ALL \
--security-opt no-new-privileges --network none \ --security-opt no-new-privileges --network none \
--tmpfs /tmp:rw,noexec,nosuid,size=1g --entrypoint python -i \ --tmpfs /tmp:rw,noexec,nosuid,size=1g lens-worker:smoke
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 The smoke target runs the Rust sandbox integration tests. The production image contains neither Cargo nor the test executable

View file

@ -3,8 +3,16 @@ services:
image: ${LENS_WORKER_IMAGE:-${LITELLM_VERSION:+ghcr.io/berriai/litellm-lens-worker:v}${LITELLM_VERSION:-}} image: ${LENS_WORKER_IMAGE:-${LITELLM_VERSION:+ghcr.io/berriai/litellm-lens-worker:v}${LITELLM_VERSION:-}}
environment: environment:
LITELLM_URL: ${LITELLM_URL:?Set the URL reachable from this container} 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_WORKER_TOKEN: ${LENS_WORKER_TOKEN:-}
LENS_PYTHON_CONCURRENCY: ${LENS_PYTHON_CONCURRENCY:-2} LITELLM_LENS_SERVICE_TOKEN: ${LITELLM_LENS_SERVICE_TOKEN:?Set the same secret on LiteLLM and Lens}
CLICKHOUSE_URL: ${CLICKHOUSE_URL:?Set the ClickHouse URL reachable from Lens}
CLICKHOUSE_DATABASE: ${CLICKHOUSE_DATABASE:-litellm}
AGENT_TRACING_RETENTION_DAYS: ${AGENT_TRACING_RETENTION_DAYS:-14}
ports:
- "127.0.0.1:${LENS_PORT:-4318}:4318"
mem_limit: 2g
cpus: 2
pids_limit: 64
restart: unless-stopped restart: unless-stopped
read_only: true read_only: true
tmpfs: tmpfs:

View file

@ -1,7 +1,7 @@
model_list: []
general_settings: general_settings:
master_key: os.environ/LITELLM_MASTER_KEY master_key: os.environ/LITELLM_MASTER_KEY
tracing: tracing:
store: store:
type: clickhouse type: lens
url: os.environ/CLICKHOUSE_URL
retention_days: 14

73
deploy/lens/configure.py Normal file
View file

@ -0,0 +1,73 @@
from __future__ import annotations
import argparse
import os
import re
import secrets
import shlex
import sys
from pathlib import Path
from typing import Final
SECRET_NAMES: Final = (
"LITELLM_MASTER_KEY",
"LITELLM_SALT_KEY",
"LITELLM_LENS_SERVICE_TOKEN",
"POSTGRES_PASSWORD",
"CLICKHOUSE_PASSWORD",
)
def environment_content(path: Path) -> str:
if not path.exists():
return "".join(
f"{name}={'sk-' if name.endswith('KEY') else ''}{secrets.token_hex(32)}\n" for name in SECRET_NAMES
)
saved: Final = path.read_text().splitlines()
values: Final = dict(line.split("=", 1) for line in saved if "=" in line)
if any(not values.get(name) for name in SECRET_NAMES):
raise ValueError(f"{path} is incomplete. Restore your saved credentials before continuing")
return "\n".join(line for line in saved if not line.startswith("LITELLM_VERSION=")) + "\n"
def configure(path: Path, version: str) -> None:
release: Final = version.removeprefix("v")
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:[-.][a-zA-Z0-9.-]+)?", release):
raise ValueError("Use a published release version, such as 1.82.0 or 1.82.0-nightly")
if path.is_symlink():
raise ValueError(f"Refusing to replace a symlink: {path}")
existing: Final = path.exists()
content: Final = environment_content(path)
temporary: Final = path.with_name(f".{path.name}.{secrets.token_hex(8)}")
descriptor: Final = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
with os.fdopen(descriptor, "w") as output:
output.write(content + f"LITELLM_VERSION={release}\n")
if existing:
os.replace(temporary, path)
else:
os.link(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def main() -> None:
parser: Final = argparse.ArgumentParser(description="Create or update the configuration for the Lens Compose stack")
parser.add_argument("--version", required=True, help="Published LiteLLM release; Lens uses the matching version")
parser.add_argument("--env-file", type=Path, default=Path(__file__).with_name(".env"))
arguments: Final = parser.parse_args()
try:
configure(arguments.env_file, arguments.version)
except (OSError, ValueError) as error:
parser.exit(1, f"Could not configure Lens: {error}\n")
sys.stdout.write(
f"Saved {arguments.env_file}. Existing keys and database passwords are preserved\n"
f"Start with: docker compose --env-file {shlex.quote(str(arguments.env_file))} "
"-f deploy/lens/stack.yaml up -d --wait\n"
"Open http://localhost:4000/ui/ and sign in as admin with LITELLM_MASTER_KEY from the saved file\n"
"Back up this file with your database volumes. Do not commit it\n"
)
if __name__ == "__main__":
main()

View file

@ -1,2 +0,0 @@
httpx==0.28.1
pydantic==2.13.4

View file

@ -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

40
deploy/lens/smoke.sh Normal file
View file

@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
image="${1:?pass the built image reference}"
release="${2:?pass the expected release tag}"
version="$(docker run --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges "$image" --version)"
test "$version" = "litellm-lens $release protocol=7"
container="$(docker run -d --network none --read-only --cap-drop ALL \
--security-opt no-new-privileges --pids-limit 64 --memory 2g --cpus 2 \
--tmpfs /tmp:rw,noexec,nosuid,size=256m \
-e LITELLM_URL=http://127.0.0.1:1 \
-e CLICKHOUSE_URL=http://127.0.0.1:1 \
-e LITELLM_LENS_SERVICE_TOKEN=isolated-runtime-smoke-secret-32-characters \
"$image")"
trap 'docker rm -f "$container" >/dev/null' EXIT
test "$(docker exec "$container" id -u)" = 65532
docker exec -i "$container" python3.13 -I -S - <<'PY'
import time
import urllib.error
import urllib.request
for attempt in range(50):
try:
with urllib.request.urlopen("http://127.0.0.1:4318/health/live", timeout=1) as response:
assert response.status == 200
break
except urllib.error.URLError:
if attempt == 49:
raise
time.sleep(0.1)
for path, expected in (("health/ready", 503), ("internal/status", 401)):
try:
urllib.request.urlopen(f"http://127.0.0.1:4318/{path}", timeout=1)
except urllib.error.HTTPError as error:
assert error.code == expected, (path, error.code)
else:
raise AssertionError(f"{path} should return {expected}")
print("Unprivileged Lens service remains live with unavailable dependencies")
PY

View file

@ -10,9 +10,7 @@ services:
import os, sys import os, sys
from urllib.parse import quote from urllib.parse import quote
postgres_password = quote(os.environ["POSTGRES_PASSWORD"], safe="") 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["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:]]) os.execv("docker/prod_entrypoint.sh", ["docker/prod_entrypoint.sh", *sys.argv[1:]])
command: ["--config", "/app/lens-config.yaml", "--port", "4000"] command: ["--config", "/app/lens-config.yaml", "--port", "4000"]
environment: environment:
@ -20,29 +18,37 @@ services:
LITELLM_SALT_KEY: ${LITELLM_SALT_KEY:?Set a permanent encryption key and keep it across upgrades} 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} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set a permanent database password}
STORE_MODEL_IN_DB: "True" STORE_MODEL_IN_DB: "True"
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?Set a permanent ClickHouse password} LITELLM_LENS_URL: http://lens-worker:4318
LITELLM_LENS_PUBLIC_URL: ${LITELLM_LENS_PUBLIC_URL:-http://localhost:4318}
LITELLM_LENS_SERVICE_TOKEN: ${LITELLM_LENS_SERVICE_TOKEN:?Set the shared Lens service secret}
LENS_WORKER_IMAGE: ghcr.io/berriai/litellm-lens-worker:v${LITELLM_VERSION} LENS_WORKER_IMAGE: ghcr.io/berriai/litellm-lens-worker:v${LITELLM_VERSION}
volumes: volumes:
- ./config.yaml:/app/lens-config.yaml:ro - ./config.yaml:/app/lens-config.yaml:ro
ports: ports:
- "127.0.0.1:${LITELLM_PORT:-4000}:4000" - "127.0.0.1:${LITELLM_PORT:-4000}:4000"
networks: [proxy, storage] networks: [proxy, database]
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
clickhouse:
condition: service_healthy
restart: unless-stopped restart: unless-stopped
lens-worker: lens-worker:
profiles: [lens]
image: ghcr.io/berriai/litellm-lens-worker:v${LITELLM_VERSION} image: ghcr.io/berriai/litellm-lens-worker:v${LITELLM_VERSION}
environment: environment:
LITELLM_URL: http://litellm:4000 LITELLM_URL: http://litellm:4000
LENS_WORKER_TOKEN: ${LENS_WORKER_TOKEN:-} LENS_WORKER_TOKEN: ${LENS_WORKER_TOKEN:-}
LENS_PYTHON_CONCURRENCY: ${LENS_PYTHON_CONCURRENCY:-2} LITELLM_LENS_SERVICE_TOKEN: ${LITELLM_LENS_SERVICE_TOKEN}
CLICKHOUSE_HOST: clickhouse
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?Set a permanent ClickHouse password}
CLICKHOUSE_DATABASE: ${CLICKHOUSE_DATABASE:-litellm}
AGENT_TRACING_RETENTION_DAYS: ${AGENT_TRACING_RETENTION_DAYS:-14}
depends_on: [litellm] depends_on: [litellm]
networks: [proxy] networks: [proxy, storage]
ports:
- "127.0.0.1:${LENS_PORT:-4318}:4318"
mem_limit: 2g
cpus: 2
pids_limit: 64
restart: unless-stopped restart: unless-stopped
read_only: true read_only: true
tmpfs: tmpfs:
@ -56,7 +62,7 @@ services:
POSTGRES_DB: litellm POSTGRES_DB: litellm
POSTGRES_USER: litellm POSTGRES_USER: litellm
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
networks: [storage] networks: [database]
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck:
@ -84,6 +90,8 @@ services:
networks: networks:
proxy: proxy:
database:
internal: true
storage: storage:
internal: true internal: true

View file

@ -0,0 +1,52 @@
import tempfile
import unittest
from pathlib import Path
from configure import SECRET_NAMES, configure
class ComposeConfigurationTests(unittest.TestCase):
def test_restart_and_upgrade_preserve_private_credentials_and_custom_settings(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / ".env"
configure(path, "v1.2.3")
original = dict(line.split("=", 1) for line in path.read_text().splitlines())
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
self.assertEqual(len({original[name] for name in SECRET_NAMES}), len(SECRET_NAMES))
self.assertTrue(all(len(original[name]) >= 64 for name in SECRET_NAMES))
with path.open("a") as output:
output.write("LITELLM_LENS_PUBLIC_URL=https://traces.example/prefix\n")
configure(path, "1.2.3")
configure(path, "v1.2.4-nightly")
updated = dict(line.split("=", 1) for line in path.read_text().splitlines())
self.assertEqual({name: updated[name] for name in SECRET_NAMES}, {name: original[name] for name in SECRET_NAMES})
self.assertEqual(updated["LITELLM_VERSION"], "1.2.4-nightly")
self.assertEqual(updated["LITELLM_LENS_PUBLIC_URL"], "https://traces.example/prefix")
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
def test_incomplete_configuration_is_never_replaced_with_new_database_passwords(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / ".env"
original = "POSTGRES_PASSWORD=existing\n"
path.write_text(original)
with self.assertRaisesRegex(ValueError, "incomplete"):
configure(path, "1.2.3")
self.assertEqual(path.read_text(), original)
def test_invalid_release_and_symlink_leave_existing_files_untouched(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / ".env"
target = Path(directory) / "saved"
target.write_text("preserve")
path.symlink_to(target)
with self.assertRaisesRegex(ValueError, "symlink"):
configure(path, "1.2.3")
self.assertEqual(target.read_text(), "preserve")
path.unlink()
with self.assertRaisesRegex(ValueError, "published release"):
configure(path, "1.2.3\nPOSTGRES_PASSWORD=replaced")
self.assertFalse(path.exists())
if __name__ == "__main__":
unittest.main()

View file

@ -9,8 +9,8 @@ ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2
# Pinned by digest like the other base images; bump explicitly on Node upgrades. # Pinned by digest like the other base images; bump explicitly on Node upgrades.
ARG UI_BUILD_IMAGE=node:24.19-alpine3.24@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 ARG UI_BUILD_IMAGE=node:24.19-alpine3.24@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43
# Checksum from https://www.pgbouncer.org/downloads/ (the Wolfi repo only carries 1.24.x) # Checksum from https://www.pgbouncer.org/downloads/ (the Wolfi repo only carries 1.24.x)
ARG PGBOUNCER_VERSION=1.25.2 ARG PGBOUNCER_VERSION=1.26.0
ARG PGBOUNCER_SHA256=924ad35113fd0a71c8e2dbe85b5d03445532e2b7b37a9f8a48983beea238b332 ARG PGBOUNCER_SHA256=afd25dd61ee6775d37b40629b87ce08736b3e6955f3057bb212e410fbf21c71d
FROM $UV_IMAGE AS uvbin FROM $UV_IMAGE AS uvbin

View file

@ -7,8 +7,8 @@ ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2
# Pinned by digest like the other base images; bump explicitly on Node upgrades. # Pinned by digest like the other base images; bump explicitly on Node upgrades.
ARG UI_BUILD_IMAGE=node:24.19-alpine3.24@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 ARG UI_BUILD_IMAGE=node:24.19-alpine3.24@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43
# Checksum from https://www.pgbouncer.org/downloads/ (the Wolfi repo only carries 1.24.x) # Checksum from https://www.pgbouncer.org/downloads/ (the Wolfi repo only carries 1.24.x)
ARG PGBOUNCER_VERSION=1.25.2 ARG PGBOUNCER_VERSION=1.26.0
ARG PGBOUNCER_SHA256=924ad35113fd0a71c8e2dbe85b5d03445532e2b7b37a9f8a48983beea238b332 ARG PGBOUNCER_SHA256=afd25dd61ee6775d37b40629b87ce08736b3e6955f3057bb212e410fbf21c71d
FROM $UV_IMAGE AS uvbin FROM $UV_IMAGE AS uvbin

View file

@ -9,13 +9,13 @@ services:
LITELLM_RELEASE_TAG: ${LITELLM_RELEASE_TAG:-} LITELLM_RELEASE_TAG: ${LITELLM_RELEASE_TAG:-}
command: ["--config", "/app/tracing-config.yaml", "--port", "4000"] command: ["--config", "/app/tracing-config.yaml", "--port", "4000"]
environment: environment:
LITELLM_MASTER_KEY: sk-1234 LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY:?set LITELLM_MASTER_KEY}
LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY: "true"
LITELLM_SALT_KEY: sk-local-tracing-salt-key LITELLM_SALT_KEY: sk-local-tracing-salt-key
DATABASE_URL: postgresql://litellm:litellm@db:5432/litellm DATABASE_URL: postgresql://litellm:litellm@db:5432/litellm
STORE_MODEL_IN_DB: "True" STORE_MODEL_IN_DB: "True"
CLICKHOUSE_URL: http://default:local-tracing@clickhouse:8123 LITELLM_LENS_URL: http://lens-worker:4318
CLICKHOUSE_DATABASE: litellm LITELLM_LENS_PUBLIC_URL: http://localhost:4318
LITELLM_LENS_SERVICE_TOKEN: ${LITELLM_LENS_SERVICE_TOKEN:?set LITELLM_LENS_SERVICE_TOKEN}
OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-}
LENS_WORKER_IMAGE: ${LENS_WORKER_IMAGE:-} LENS_WORKER_IMAGE: ${LENS_WORKER_IMAGE:-}
volumes: volumes:
@ -25,8 +25,29 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
clickhouse:
condition: service_healthy lens-worker:
build:
context: ..
dockerfile: deploy/lens/Dockerfile
args:
LITELLM_RELEASE_TAG: ${LITELLM_RELEASE_TAG:?set LITELLM_RELEASE_TAG to the source commit}
environment:
LITELLM_URL: http://litellm:4000
LITELLM_LENS_SERVICE_TOKEN: ${LITELLM_LENS_SERVICE_TOKEN:?set LITELLM_LENS_SERVICE_TOKEN}
CLICKHOUSE_URL: http://default:local-tracing@clickhouse:8123
CLICKHOUSE_DATABASE: litellm
ports:
- "127.0.0.1:4318:4318"
read_only: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=${LENS_WORKER_TMP_SIZE:-1g},mode=1777
mem_limit: 2g
cpus: 2
pids_limit: 64
restart: unless-stopped
db: db:
image: postgres:16 image: postgres:16

View file

@ -8,6 +8,4 @@ general_settings:
master_key: os.environ/LITELLM_MASTER_KEY master_key: os.environ/LITELLM_MASTER_KEY
tracing: tracing:
store: store:
type: clickhouse type: lens
url: os.environ/CLICKHOUSE_URL
retention_days: 14

View file

@ -1,15 +1,15 @@
from typing import Dict, Literal, Type, Union from typing import Dict, Literal, Type, Union
from litellm_enterprise.proxy.hooks.managed_files import _PROXY_LiteLLMManagedFiles from litellm_enterprise.proxy.hooks.managed_files import PROXY_LiteLLMManagedFiles
from litellm_enterprise.proxy.hooks.managed_vector_stores import ( from litellm_enterprise.proxy.hooks.managed_vector_stores import (
_PROXY_LiteLLMManagedVectorStores, PROXY_LiteLLMManagedVectorStores,
) )
from litellm.integrations.custom_logger import CustomLogger from litellm.integrations.custom_logger import CustomLogger
ENTERPRISE_PROXY_HOOKS: Dict[str, Type[CustomLogger]] = { ENTERPRISE_PROXY_HOOKS: Dict[str, Type[CustomLogger]] = {
"managed_files": _PROXY_LiteLLMManagedFiles, "managed_files": PROXY_LiteLLMManagedFiles,
"managed_vector_stores": _PROXY_LiteLLMManagedVectorStores, "managed_vector_stores": PROXY_LiteLLMManagedVectorStores,
} }

View file

@ -20,7 +20,7 @@ from litellm._logging import verbose_proxy_logger
from fastapi import HTTPException from fastapi import HTTPException
class _ENTERPRISE_BannedKeywords(CustomLogger): class ENTERPRISE_BannedKeywords(CustomLogger):
enforces_request_content: bool = True enforces_request_content: bool = True
# Class variables or attributes # Class variables or attributes
def __init__(self): def __init__(self):
@ -114,3 +114,4 @@ class _ENTERPRISE_BannedKeywords(CustomLogger):
response: str, response: str,
): ):
self.test_violation(test_str=response) self.test_violation(test_str=response)
_ENTERPRISE_BannedKeywords = ENTERPRISE_BannedKeywords

View file

@ -21,7 +21,7 @@ from litellm.proxy.common_utils.user_api_key_cache import AUTH_OBJECTS_TARGET
from litellm.proxy.utils import PrismaClient from litellm.proxy.utils import PrismaClient
class _ENTERPRISE_BlockedUserList(CustomLogger): class ENTERPRISE_BlockedUserList(CustomLogger):
enforces_request_content: bool = True enforces_request_content: bool = True
# Class variables or attributes # Class variables or attributes
def __init__(self, prisma_client: Optional[PrismaClient]): def __init__(self, prisma_client: Optional[PrismaClient]):
@ -128,3 +128,4 @@ class _ENTERPRISE_BlockedUserList(CustomLogger):
str(e) str(e)
) )
) )
_ENTERPRISE_BlockedUserList = ENTERPRISE_BlockedUserList

View file

@ -16,7 +16,7 @@ from litellm.proxy.guardrails._content_utils import iter_message_text
from litellm.types.utils import CallTypesLiteral from litellm.types.utils import CallTypesLiteral
class _ENTERPRISE_GoogleTextModeration(CustomLogger): class ENTERPRISE_GoogleTextModeration(CustomLogger):
user_api_key_cache = None user_api_key_cache = None
confidence_categories = [ confidence_categories = [
"toxic", "toxic",
@ -125,9 +125,10 @@ class _ENTERPRISE_GoogleTextModeration(CustomLogger):
) )
# Handle the response # Handle the response
return data return data
_ENTERPRISE_GoogleTextModeration = ENTERPRISE_GoogleTextModeration
# google_text_moderation_obj = _ENTERPRISE_GoogleTextModeration() # google_text_moderation_obj = ENTERPRISE_GoogleTextModeration()
# asyncio.run( # asyncio.run(
# google_text_moderation_obj.async_moderation_hook( # google_text_moderation_obj.async_moderation_hook(
# data={"messages": [{"role": "user", "content": "Hey, how's it going?"}]} # data={"messages": [{"role": "user", "content": "Hey, how's it going?"}]}

View file

@ -24,7 +24,7 @@ from litellm.proxy.guardrails._content_utils import iter_message_text
from litellm.types.utils import CallTypesLiteral from litellm.types.utils import CallTypesLiteral
class _ENTERPRISE_OpenAI_Moderation(CustomLogger): class ENTERPRISE_OpenAI_Moderation(CustomLogger):
@property @property
def model_name(self) -> str: def model_name(self) -> str:
return litellm.openai_moderations_model_name or DEFAULT_OPENAI_MODERATIONS_MODEL return litellm.openai_moderations_model_name or DEFAULT_OPENAI_MODERATIONS_MODEL
@ -55,3 +55,4 @@ class _ENTERPRISE_OpenAI_Moderation(CustomLogger):
status_code=403, detail={"error": "Violated content safety policy"} status_code=403, detail={"error": "Violated content safety policy"}
) )
pass pass
_ENTERPRISE_OpenAI_Moderation = ENTERPRISE_OpenAI_Moderation

View file

@ -3,9 +3,10 @@ Endpoints for managing email alerts on litellm
""" """
import json import json
from typing import Dict from typing import Dict, Final, cast
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import JsonValue
from litellm_enterprise.types.enterprise_callbacks.send_emails import ( from litellm_enterprise.types.enterprise_callbacks.send_emails import (
DefaultEmailSettings, DefaultEmailSettings,
EmailEvent, EmailEvent,
@ -38,14 +39,15 @@ async def _get_email_settings(prisma_client) -> Dict[str, bool]:
and general_settings_entry.param_value is not None and general_settings_entry.param_value is not None
): ):
# Get general settings value # Get general settings value
if isinstance(general_settings_entry.param_value, str): general_settings: Final = (
general_settings = json.loads(general_settings_entry.param_value) cast(Dict[str, object], json.loads(general_settings_entry.param_value))
else: if isinstance(general_settings_entry.param_value, str)
general_settings = general_settings_entry.param_value else cast(Dict[str, object], general_settings_entry.param_value)
)
# Extract email_settings from general settings if it exists # Extract email_settings from general settings if it exists
if general_settings and "email_settings" in general_settings: if general_settings and "email_settings" in general_settings:
email_settings = general_settings["email_settings"] email_settings: Final = cast(Dict[str, bool], general_settings["email_settings"])
# Update settings_dict with values from general_settings # Update settings_dict with values from general_settings
for event_name, enabled in email_settings.items(): for event_name, enabled in email_settings.items():
settings_dict[event_name] = enabled settings_dict[event_name] = enabled
@ -64,7 +66,7 @@ async def _save_email_settings(prisma_client, settings: Dict[str, bool]):
from litellm.proxy.proxy_server import proxy_config from litellm.proxy.proxy_server import proxy_config
proxy_config.reject_config_owned_writes( proxy_config.reject_config_owned_writes(
section_name="general_settings", changed_keys={"email_settings": settings} section_name="general_settings", changed_keys={"email_settings": cast(JsonValue, settings)}
) )
try: try:
verbose_proxy_logger.debug( verbose_proxy_logger.debug(
@ -77,16 +79,15 @@ async def _save_email_settings(prisma_client, settings: Dict[str, bool]):
) )
# Initialize general settings dict # Initialize general settings dict
if ( general_settings: Final = (
general_settings_entry is not None (
and general_settings_entry.param_value is not None cast(Dict[str, object], json.loads(general_settings_entry.param_value))
): if isinstance(general_settings_entry.param_value, str)
if isinstance(general_settings_entry.param_value, str): else cast(Dict[str, object], dict(general_settings_entry.param_value))
general_settings = json.loads(general_settings_entry.param_value) )
else: if general_settings_entry is not None and general_settings_entry.param_value is not None
general_settings = dict(general_settings_entry.param_value) else {}
else: )
general_settings = {}
# Update email_settings in general_settings # Update email_settings in general_settings
general_settings["email_settings"] = settings general_settings["email_settings"] = settings

View file

@ -36,7 +36,7 @@ class EnterpriseCustomGuardrailHelper:
proxy_server_request = data.get("proxy_server_request", {}) proxy_server_request = data.get("proxy_server_request", {})
request_tags = StandardLoggingPayloadSetup._get_request_tags( request_tags = StandardLoggingPayloadSetup.get_request_tags(
litellm_params=data, litellm_params=data,
proxy_server_request=proxy_server_request, proxy_server_request=proxy_server_request,
) )

View file

@ -60,6 +60,32 @@ class _ManagedObjectRow(Protocol):
@property @property
def file_object(self) -> object: ... def file_object(self) -> object: ...
@property
def org_id(self) -> str | None: ...
@property
def api_key(self) -> str | None: ...
@property
def team_id(self) -> str | None: ...
class _ReadableFileContent(Protocol):
async def read(self) -> bytes: ...
class _HasFileContent(Protocol):
@property
def content(self) -> bytes: ...
async def _file_content_bytes(file_content: object) -> bytes:
if hasattr(file_content, "content"):
return cast(_HasFileContent, file_content).content
if hasattr(file_content, "read"):
return await cast(_ReadableFileContent, file_content).read()
return cast(bytes, file_content)
def _managed_object_table(prisma_client: "PrismaClient") -> "TableActions[_ManagedObjectRow]": def _managed_object_table(prisma_client: "PrismaClient") -> "TableActions[_ManagedObjectRow]":
return ManagedObjectRepository(prisma_client).table return ManagedObjectRepository(prisma_client).table
@ -175,17 +201,21 @@ class CheckBatchCost:
return None return None
async def _get_org_id(self, job: "_ManagedObjectRow", batch_id: str) -> str | None: async def _get_org_id(self, job: "_ManagedObjectRow", batch_id: str) -> str | None:
org_id = getattr(job, "org_id", None) org_id: Final = getattr(job, "org_id", None)
if org_id: if org_id:
return org_id return org_id
api_key = getattr(job, "api_key", None) api_key: Final = getattr(job, "api_key", None)
team_id = getattr(job, "team_id", None) team_id: Final = getattr(job, "team_id", None)
if api_key: if api_key:
try: try:
key_row: prisma_models.LiteLLM_VerificationToken | None = await _token_table( key_row: prisma_models.LiteLLM_VerificationToken | None = await _token_table(
self.prisma_client self.prisma_client
).find_unique(where={"token": api_key}) ).find_unique(where={"token": api_key})
key_org_id = getattr(key_row, "organization_id", None) if key_row is not None else None key_org_id: Final = (
cast(str | None, getattr(key_row, "organization_id", None))
if key_row is not None
else None
)
if key_org_id: if key_org_id:
return key_org_id return key_org_id
except Exception as e: except Exception as e:
@ -199,7 +229,7 @@ class CheckBatchCost:
team_row: prisma_models.LiteLLM_TeamTable | None = await _team_table(self.prisma_client).find_unique( team_row: prisma_models.LiteLLM_TeamTable | None = await _team_table(self.prisma_client).find_unique(
where={"team_id": team_id} where={"team_id": team_id}
) )
return getattr(team_row, "organization_id", None) if team_row is not None else None return cast(str | None, getattr(team_row, "organization_id", None)) if team_row is not None else None
except Exception as e: except Exception as e:
verbose_proxy_logger.error(f"CheckBatchCost: could not resolve the team's org for batch {batch_id}: {e}") verbose_proxy_logger.error(f"CheckBatchCost: could not resolve the team's org for batch {batch_id}: {e}")
return None return None
@ -673,16 +703,17 @@ class CheckBatchCost:
from litellm.types.utils import LiteLLMBatch from litellm.types.utils import LiteLLMBatch
file_object = job.file_object file_object: Final = job.file_object
if isinstance(file_object, str): try:
try: parsed_file_object: Final[object] = (
file_object = json.loads(file_object) json.loads(file_object) if isinstance(file_object, str) else file_object
except (json.JSONDecodeError, ValueError): )
return None except (json.JSONDecodeError, ValueError):
if not isinstance(file_object, dict): return None
if not isinstance(parsed_file_object, dict):
return None return None
try: try:
return LiteLLMBatch.model_validate(file_object).input_file_id return LiteLLMBatch.model_validate(parsed_file_object).input_file_id
except Exception: except Exception:
return None return None
@ -704,10 +735,11 @@ class CheckBatchCost:
""" """
from litellm.batches.batch_utils import ( from litellm.batches.batch_utils import (
count_error_file_failed_requests, count_error_file_failed_requests,
_get_file_content_as_dictionary, get_file_content_as_dictionary,
calculate_batch_cost_and_usage, calculate_batch_cost_and_usage,
) )
from litellm.files.main import afile_content from litellm.files.main import afile_content
from litellm.files.types import FileContentCallOptions
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
from litellm.litellm_core_utils.litellm_logging import deployment_pricing_model_info, mask_api_base_credentials from litellm.litellm_core_utils.litellm_logging import deployment_pricing_model_info, mask_api_base_credentials
@ -745,30 +777,22 @@ class CheckBatchCost:
except (IndexError, AttributeError): except (IndexError, AttributeError):
pass pass
credentials = self.llm_router.get_deployment_credentials_with_provider(model_id) or {} credentials: Final = self.llm_router.get_deployment_credentials_with_provider(model_id) or {}
_file_content = await afile_content( _file_content = await afile_content(
file_id=raw_output_file_id, file_id=cast(str, raw_output_file_id),
_litellm_internal_model_credentials=MappingProxyType(dict(credentials)), _litellm_internal_model_credentials=MappingProxyType(dict(credentials)),
**credentials, **cast(FileContentCallOptions, credentials),
) )
# Access content - handle both direct attribute and method call content_bytes: Final = await _file_content_bytes(_file_content)
if hasattr(_file_content, 'content'):
content_bytes = _file_content.content # type: ignore[union-attr]
elif hasattr(_file_content, 'read'):
content_bytes = await _file_content.read() # type: ignore[misc]
else:
content_bytes = _file_content # type: ignore[assignment]
file_content_as_dict = _get_file_content_as_dictionary( file_content_as_dict = get_file_content_as_dictionary(content_bytes)
content_bytes # type: ignore[arg-type]
)
# Record output file size # Record output file size
if prom_logger and content_bytes: if prom_logger and content_bytes:
try: try:
prom_logger.record_managed_file_size( prom_logger.record_managed_file_size(
size_bytes=len(content_bytes), # type: ignore size_bytes=len(content_bytes),
purpose="batch", purpose="batch",
file_type="output", file_type="output",
model=model_id, model=model_id,
@ -805,7 +829,7 @@ class CheckBatchCost:
team_id=getattr(job, "team_id", None), team_id=getattr(job, "team_id", None),
) )
for _file_attr in ["output_file_id", "error_file_id"]: for _file_attr in ["output_file_id", "error_file_id"]:
_raw_file_id = getattr(response, _file_attr, None) _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): if _raw_file_id and not _is_base64_encoded_unified_file_id(_raw_file_id):
try: try:
_unified_file_id = managed_files_hook.get_unified_output_file_id( _unified_file_id = managed_files_hook.get_unified_output_file_id(

View file

@ -8,7 +8,7 @@ from . import ui_crud_endpoints # side-effect: registers extra UI settings
from .audit_logging_endpoints import router as audit_logging_router from .audit_logging_endpoints import router as audit_logging_router
from .liteadmin import router as liteadmin_router from .liteadmin import router as liteadmin_router
from .management_endpoints import management_endpoints_router from .management_endpoints import management_endpoints_router
from .utils import _should_block_robots from .utils import should_block_robots
__all__ = ["router", "ui_crud_endpoints"] __all__ = ["router", "ui_crud_endpoints"]
@ -25,7 +25,7 @@ async def get_robots():
Block all web crawlers from indexing the proxy server endpoints Block all web crawlers from indexing the proxy server endpoints
This is useful for ensuring that the API endpoints aren't indexed by search engines This is useful for ensuring that the API endpoints aren't indexed by search engines
""" """
if _should_block_robots(): if should_block_robots():
return Response(content="User-agent: *\nDisallow: /", media_type="text/plain") return Response(content="User-agent: *\nDisallow: /", media_type="text/plain")
else: else:
return Response(status_code=404) return Response(status_code=404)

View file

@ -23,6 +23,7 @@ from uuid import NAMESPACE_URL, uuid5
import httpx import httpx
from fastapi import HTTPException from fastapi import HTTPException
from pydantic import ValidationError from pydantic import ValidationError
from typing_extensions import ReadOnly
import litellm import litellm
from litellm import Router, verbose_logger from litellm import Router, verbose_logger
@ -30,10 +31,13 @@ from litellm._internal_context import with_service_target
from litellm._uuid import uuid from litellm._uuid import uuid
from litellm.caching.caching import DualCache from litellm.caching.caching import DualCache
from litellm.constants import MAX_FILE_LIST_LIMIT from litellm.constants import MAX_FILE_LIST_LIMIT
from litellm.files.types import FileRetrieveCallOptions, FileRetrieveProvider
from litellm.integrations.custom_logger import CustomLogger 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 ( from litellm.litellm_core_utils.prompt_templates.common_utils import (
extract_file_metadata, extract_file_metadata,
) )
from openai import AsyncOpenAI
from openai.types.file_deleted import FileDeleted from openai.types.file_deleted import FileDeleted
from litellm.llms.base_llm.files.storage_backend_factory import get_storage_backend from litellm.llms.base_llm.files.storage_backend_factory import get_storage_backend
@ -190,6 +194,19 @@ class _ManagedObjectTableActions(Protocol):
async def update_many(self, where: Mapping[str, object], data: Mapping[str, object]) -> int: ... async def update_many(self, where: Mapping[str, object], data: Mapping[str, object]) -> int: ...
class _ManagedResourceDatabase(Protocol):
@property
def litellm_managedfiletable(self) -> _ManagedFileTableActions: ...
@property
def litellm_managedobjecttable(self) -> _ManagedObjectTableActions: ...
class _ManagedResourcePrismaClient(Protocol):
@property
def db(self) -> _ManagedResourceDatabase: ...
class _SchedulerWithJobLookup(Protocol): class _SchedulerWithJobLookup(Protocol):
def get_job(self, job_id: str) -> object: ... def get_job(self, job_id: str) -> object: ...
@ -199,7 +216,12 @@ class _CursorPageArgs(TypedDict, total=False):
skip: int skip: int
def _managed_file_table(prisma_client: PrismaClient) -> _ManagedFileTableActions: class _RouterFileCallKwargs(TypedDict, total=False):
client: ReadOnly[AsyncOpenAI | None]
custom_llm_provider: ReadOnly[str | None]
def _managed_file_table(prisma_client: _ManagedResourcePrismaClient) -> _ManagedFileTableActions:
return prisma_client.db.litellm_managedfiletable return prisma_client.db.litellm_managedfiletable
@ -213,7 +235,7 @@ def _iter_provider_file_id_pairs(
yield provider_file_id, row.unified_file_id yield provider_file_id, row.unified_file_id
def _managed_object_table(prisma_client: PrismaClient) -> _ManagedObjectTableActions: def _managed_object_table(prisma_client: _ManagedResourcePrismaClient) -> _ManagedObjectTableActions:
return prisma_client.db.litellm_managedobjecttable return prisma_client.db.litellm_managedobjecttable
@ -1282,7 +1304,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
target_model_names_list=target_model_names_list, target_model_names_list=target_model_names_list,
litellm_parent_otel_span=litellm_parent_otel_span, litellm_parent_otel_span=litellm_parent_otel_span,
) )
response = await _PROXY_LiteLLMManagedFiles.return_unified_file_id( response = await PROXY_LiteLLMManagedFiles.return_unified_file_id(
file_objects=responses, file_objects=responses,
create_file_request=create_file_request, create_file_request=create_file_request,
internal_usage_cache=self.internal_usage_cache, internal_usage_cache=self.internal_usage_cache,
@ -1294,7 +1316,10 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
model_mappings: Dict[str, str] = {} model_mappings: Dict[str, str] = {}
for file_object in responses: for file_object in responses:
model_file_id_mapping = file_object._hidden_params.get("model_file_id_mapping") file_hidden_params = cast( # cast-ok: preserve mapping operations on dynamic file metadata
dict[str, object], getattr(file_object, HIDDEN_PARAMS_ATTR)
)
model_file_id_mapping = file_hidden_params.get("model_file_id_mapping")
if model_file_id_mapping and isinstance(model_file_id_mapping, dict): if model_file_id_mapping and isinstance(model_file_id_mapping, dict):
model_mappings.update(model_file_id_mapping) model_mappings.update(model_file_id_mapping)
@ -1344,7 +1369,8 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
_, file_type = extract_file_metadata(create_file_request["file"]) _, file_type = extract_file_metadata(create_file_request["file"])
output_file_id = file_objects[0].id output_file_id = file_objects[0].id
model_id = file_objects[0]._hidden_params.get("model_id") file_hidden_params: Final = cast(dict[str, object], getattr(file_objects[0], HIDDEN_PARAMS_ATTR))
model_id = file_hidden_params.get("model_id")
unified_file_id = SpecialEnums.LITELLM_MANAGED_FILE_COMPLETE_STR.value.format( unified_file_id = SpecialEnums.LITELLM_MANAGED_FILE_COMPLETE_STR.value.format(
file_type, file_type,
@ -1410,11 +1436,12 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
if decoded_batch_id and is_litellm_executed_batch(decoded_batch_id): if decoded_batch_id and is_litellm_executed_batch(decoded_batch_id):
return response return response
## Check if unified_file_id is in the response ## Check if unified_file_id is in the response
unified_file_id = response._hidden_params.get("unified_file_id") # managed file id response_hidden_params: Final = cast(dict[str, object], getattr(response, HIDDEN_PARAMS_ATTR))
unified_batch_id = response._hidden_params.get("unified_batch_id") # managed batch id unified_file_id = response_hidden_params.get("unified_file_id")
is_batch_create: Final = response._hidden_params.get(BATCH_CREATE_HIDDEN_PARAM) is True unified_batch_id = response_hidden_params.get("unified_batch_id")
model_id = cast(Optional[str], response._hidden_params.get("model_id")) is_batch_create: Final = response_hidden_params.get(BATCH_CREATE_HIDDEN_PARAM) is True
model_name = cast(Optional[str], response._hidden_params.get("model_name")) model_id = cast(Optional[str], response_hidden_params.get("model_id"))
model_name = cast(Optional[str], response_hidden_params.get("model_name"))
resolved_model_name = resolve_managed_output_file_model_name( resolved_model_name = resolve_managed_output_file_model_name(
unified_input_file_id=unified_file_id if isinstance(unified_file_id, str) else response.input_file_id, unified_input_file_id=unified_file_id if isinstance(unified_file_id, str) else response.input_file_id,
@ -1459,13 +1486,14 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
_creds = _llm_router.get_deployment_credentials_with_provider(model_id) or {} _creds = _llm_router.get_deployment_credentials_with_provider(model_id) or {}
file_object = await litellm.afile_retrieve( file_object = await litellm.afile_retrieve(
file_id=provider_file_id, file_id=provider_file_id,
**_creds, **cast(FileRetrieveCallOptions, _creds),
) )
else: else:
file_object = await litellm.afile_retrieve( file_object = await litellm.afile_retrieve(
custom_llm_provider=model_name.split("/")[0] custom_llm_provider=cast(
if model_name and "/" in model_name FileRetrieveProvider,
else "openai", # type: ignore[arg-type] model_name.split("/")[0] if model_name and "/" in model_name else "openai",
),
file_id=provider_file_id, file_id=provider_file_id,
) )
verbose_logger.debug( verbose_logger.debug(
@ -1525,12 +1553,12 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
elif isinstance(response, LiteLLMFineTuningJob): elif isinstance(response, LiteLLMFineTuningJob):
## Check if unified_file_id is in the response ## Check if unified_file_id is in the response
unified_file_id = response._hidden_params.get("unified_file_id") # managed file id finetuning_response_hidden_params: Final = cast( # cast-ok: preserve dynamic mapping behavior
unified_finetuning_job_id = response._hidden_params.get( dict[str, object], getattr(response, HIDDEN_PARAMS_ATTR)
"unified_finetuning_job_id" )
) # managed finetuning job id unified_file_id = finetuning_response_hidden_params.get("unified_file_id")
model_id = cast(Optional[str], response._hidden_params.get("model_id")) unified_finetuning_job_id = finetuning_response_hidden_params.get("unified_finetuning_job_id")
model_name = cast(Optional[str], response._hidden_params.get("model_name")) model_id = cast(Optional[str], finetuning_response_hidden_params.get("model_id"))
original_response_id = response.id original_response_id = response.id
if (unified_file_id or unified_finetuning_job_id) and model_id: if (unified_file_id or unified_finetuning_job_id) and model_id:
response.id = self.get_unified_generic_response_id(model_id=model_id, generic_response_id=response.id) response.id = self.get_unified_generic_response_id(model_id=model_id, generic_response_id=response.id)
@ -1608,8 +1636,11 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
try: try:
model_id, model_file_id = next(iter(stored_file_object.model_mappings.items())) model_id, model_file_id = next(iter(stored_file_object.model_mappings.items()))
credentials = llm_router.get_deployment_credentials_with_provider(model_id) or {} credentials: Final = llm_router.get_deployment_credentials_with_provider(model_id) or {}
response = await litellm.afile_retrieve(file_id=model_file_id, **credentials) response = await litellm.afile_retrieve(
file_id=model_file_id,
**cast(FileRetrieveCallOptions, credentials),
)
response.id = file_id # Replace with unified ID response.id = file_id # Replace with unified ID
return response return response
except Exception as e: except Exception as e:
@ -1904,7 +1935,11 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
else {} else {}
), ),
} }
await llm_router.afile_delete(model=model_id, file_id=model_file_id, **delete_data) await llm_router.afile_delete(
model=model_id,
file_id=model_file_id,
**cast(_RouterFileCallKwargs, delete_data),
)
async def afile_content( async def afile_content(
self, self,
@ -1940,7 +1975,14 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
data["_litellm_internal_model_credentials"] = cast(Dict, MappingProxyType(dict(credentials))) data["_litellm_internal_model_credentials"] = cast(Dict, MappingProxyType(dict(credentials)))
else: else:
data.pop("_litellm_internal_model_credentials", None) data.pop("_litellm_internal_model_credentials", None)
return await llm_router.afile_content(model=model_id, file_id=provider_file_id, **data) # type: ignore return cast(
HttpxBinaryResponseContent,
await llm_router.afile_content(
model=model_id,
file_id=provider_file_id,
**cast(_RouterFileCallKwargs, data),
),
)
except Exception as e: except Exception as e:
exception_dict[model_id] = str(e) exception_dict[model_id] = str(e)
raise Exception( raise Exception(
@ -2068,3 +2110,4 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
verbose_logger.debug( verbose_logger.debug(
f"Converted file {file_id} from storage backend to base64 with format {content_type}" f"Converted file {file_id} from storage backend to base64 with format {content_type}"
) )
PROXY_LiteLLMManagedFiles = _PROXY_LiteLLMManagedFiles

View file

@ -2,11 +2,11 @@
## This hook is used to manage vector stores with target_model_names support ## This hook is used to manage vector stores with target_model_names support
## It allows creating vector stores across multiple models and managing them with unified IDs ## It allows creating vector stores across multiple models and managing them with unified IDs
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union, cast from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, Dict, Final, List, Optional, Union, cast
from fastapi import HTTPException from fastapi import HTTPException
import litellm
from litellm import Router, verbose_logger from litellm import Router, verbose_logger
from litellm._uuid import uuid from litellm._uuid import uuid
from litellm.integrations.custom_logger import CustomLogger from litellm.integrations.custom_logger import CustomLogger
@ -89,10 +89,10 @@ class _PROXY_LiteLLMManagedVectorStores(
# Model ID is stored in hidden params if the response object supports it # Model ID is stored in hidden params if the response object supports it
# For TypedDict responses, we need to check if _hidden_params was added # For TypedDict responses, we need to check if _hidden_params was added
hidden_params: Dict[str, Any] = {} hidden_params: Mapping[str, object] = {}
if hasattr(resource_object, "_hidden_params"): if hasattr(resource_object, "_hidden_params"):
hidden_params = getattr(resource_object, "_hidden_params", {}) or {} hidden_params = cast(Mapping[str, object], getattr(resource_object, "_hidden_params", {}) or {})
model_id = hidden_params.get("model_id", "") model_id: Final = cast(str, hidden_params.get("model_id", ""))
return generate_unified_id_string( return generate_unified_id_string(
resource_type=self.resource_type, resource_type=self.resource_type,
@ -106,7 +106,7 @@ class _PROXY_LiteLLMManagedVectorStores(
self, self,
llm_router: Router, llm_router: Router,
model: str, model: str,
request_data: Dict[str, Any], request_data: dict[str, object] | VectorStoreCreateOptionalRequestParams,
litellm_parent_otel_span: Span, litellm_parent_otel_span: Span,
) -> VectorStoreCreateResponse: ) -> VectorStoreCreateResponse:
""" """
@ -122,10 +122,8 @@ class _PROXY_LiteLLMManagedVectorStores(
VectorStoreCreateResponse from the provider VectorStoreCreateResponse from the provider
""" """
# Use the router to create the vector store # Use the router to create the vector store
response = await llm_router.avector_store_create( response: Final = await llm_router.avector_store_create(model=model, **request_data)
model=model, **request_data return cast(VectorStoreCreateResponse, response)
)
return response
# ============================================================================ # ============================================================================
# VECTOR STORE CRUD OPERATIONS # VECTOR STORE CRUD OPERATIONS
@ -464,3 +462,4 @@ class _PROXY_LiteLLMManagedVectorStores(
parent_otel_span=parent_otel_span, parent_otel_span=parent_otel_span,
resource_id_key="vector_store_id", resource_id_key="vector_store_id",
) )
PROXY_LiteLLMManagedVectorStores = _PROXY_LiteLLMManagedVectorStores

View file

@ -22,7 +22,9 @@ from litellm._uuid import uuid
from litellm.proxy._types import * from litellm.proxy._types import *
from litellm.proxy.auth.auth_checks import delete_cached_project_object from litellm.proxy.auth.auth_checks import delete_cached_project_object
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.management.teams.authz import is_team_admin from litellm.proxy.management.teams.authz import TEAM_OR_ORG_ADMIN, TeamAccess, is_team_admin, is_team_member
from litellm.proxy.management.teams.dependencies import get_team_access
from litellm.proxy.management.users.service import org_admin_org_ids
from litellm.proxy.management_endpoints.common_utils import _set_object_metadata_field from litellm.proxy.management_endpoints.common_utils import _set_object_metadata_field
from litellm.proxy.management_endpoints.team_admin_field_permissions import team_admin_may_manage_projects from litellm.proxy.management_endpoints.team_admin_field_permissions import team_admin_may_manage_projects
from litellm.proxy.management_helpers.utils import ( from litellm.proxy.management_helpers.utils import (
@ -118,6 +120,37 @@ async def _check_user_permission_for_project(
return is_team_admin(user_api_key_dict, team) or user_api_key_dict.user_id in (team.admins or []) return is_team_admin(user_api_key_dict, team) or user_api_key_dict.user_id in (team.admins or [])
async def _can_view_team_projects(
user_api_key_dict: UserAPIKeyAuth,
team_id: str | None,
prisma_client: PrismaClient,
team_access: TeamAccess,
) -> bool:
if user_api_key_has_admin_view(user_api_key_dict):
return True
if not team_id or not user_api_key_dict.user_id:
return False
team_row: Final = await _team_table(prisma_client).find_unique(where={"team_id": team_id})
if team_row is None:
return False
team: Final = LiteLLM_TeamTable.model_validate(team_row.model_dump())
return is_team_member(user_api_key_dict, team) or await team_access.allows(
user_api_key_dict, team, TEAM_OR_ORG_ADMIN
)
def _visible_projects_where(team_ids: list[str], admin_org_ids: frozenset[str]) -> dict[str, object]:
# bounded-ok: one caller's team memberships
member_scope: Final[dict[str, object]] = {"team_id": {"in": team_ids}}
if not admin_org_ids:
return member_scope
org_scope: Final[dict[str, object]] = {
# bounded-ok: orgs one caller administers
"litellm_team_table": {"is": {"organization_id": {"in": sorted(admin_org_ids)}}}
}
return {"OR": [member_scope, org_scope]}
async def _validate_team_exists( async def _validate_team_exists(
team_id: str, team_id: str,
prisma_client: PrismaClient, prisma_client: PrismaClient,
@ -433,7 +466,7 @@ async def new_project(
```bash ```bash
curl --location 'http://0.0.0.0:4000/project/new' \\ curl --location 'http://0.0.0.0:4000/project/new' \\
--header 'Authorization: Bearer sk-1234' \\ --header "Authorization: Bearer $LITELLM_MASTER_KEY" \\
--header 'Content-Type: application/json' \\ --header 'Content-Type: application/json' \\
--data '{ --data '{
"project_alias": "flight-search-assistant", "project_alias": "flight-search-assistant",
@ -460,7 +493,7 @@ async def new_project(
```bash ```bash
curl --location 'http://0.0.0.0:4000/project/new' \\ curl --location 'http://0.0.0.0:4000/project/new' \\
--header 'Authorization: Bearer sk-1234' \\ --header "Authorization: Bearer $LITELLM_MASTER_KEY" \\
--header 'Content-Type: application/json' \\ --header 'Content-Type: application/json' \\
--data '{ --data '{
"project_alias": "hotel-recommendations", "project_alias": "hotel-recommendations",
@ -648,7 +681,7 @@ async def update_project(
Example: Example:
```bash ```bash
curl --location 'http://0.0.0.0:4000/project/update' \\ curl --location 'http://0.0.0.0:4000/project/update' \\
--header 'Authorization: Bearer sk-1234' \\ --header "Authorization: Bearer $LITELLM_MASTER_KEY" \\
--header 'Content-Type: application/json' \\ --header 'Content-Type: application/json' \\
--data '{ --data '{
"project_id": "project-123", "project_id": "project-123",
@ -876,7 +909,7 @@ async def delete_project(
Example: Example:
```bash ```bash
curl --location --request DELETE 'http://0.0.0.0:4000/project/delete' \\ curl --location --request DELETE 'http://0.0.0.0:4000/project/delete' \\
--header 'Authorization: Bearer sk-1234' \\ --header "Authorization: Bearer $LITELLM_MASTER_KEY" \\
--header 'Content-Type: application/json' \\ --header 'Content-Type: application/json' \\
--data '{ --data '{
"project_ids": ["project-123", "project-456"] "project_ids": ["project-123", "project-456"]
@ -973,6 +1006,7 @@ async def delete_project(
async def project_info( async def project_info(
project_id: str, project_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
team_access: TeamAccess = Depends(get_team_access),
): ):
""" """
Get information about a specific project Get information about a specific project
@ -983,7 +1017,7 @@ async def project_info(
Example: Example:
```bash ```bash
curl --location 'http://0.0.0.0:4000/project/info?project_id=project-123' \\ curl --location 'http://0.0.0.0:4000/project/info?project_id=project-123' \\
--header 'Authorization: Bearer sk-1234' --header "Authorization: Bearer $LITELLM_MASTER_KEY"
``` ```
""" """
from litellm.proxy.proxy_server import prisma_client from litellm.proxy.proxy_server import prisma_client
@ -1009,21 +1043,7 @@ async def project_info(
param="project_id", param="project_id",
) )
# Check if user has access to this project (admin or team member) if not await _can_view_team_projects(user_api_key_dict, project.team_id, prisma_client, team_access):
is_admin = user_api_key_has_admin_view(user_api_key_dict)
is_team_member = False
if project.team_id and user_api_key_dict.user_id:
team = await _team_table(prisma_client).find_unique(where={"team_id": project.team_id})
if team:
caller_user_id = user_api_key_dict.user_id
for m in team.members_with_roles or []:
m_user_id = m.get("user_id") if isinstance(m, dict) else getattr(m, "user_id", None)
if m_user_id == caller_user_id:
is_team_member = True
break
if not (is_admin or is_team_member):
raise HTTPException( raise HTTPException(
status_code=403, status_code=403,
detail={"error": "You don't have access to this project"}, detail={"error": "You don't have access to this project"},
@ -1052,7 +1072,7 @@ async def list_projects(
Example: Example:
```bash ```bash
curl --location 'http://0.0.0.0:4000/project/list' \\ curl --location 'http://0.0.0.0:4000/project/list' \\
--header 'Authorization: Bearer sk-1234' --header "Authorization: Bearer $LITELLM_MASTER_KEY"
``` ```
""" """
from litellm.proxy.proxy_server import prisma_client from litellm.proxy.proxy_server import prisma_client
@ -1072,16 +1092,13 @@ async def list_projects(
include={"litellm_budget_table": True, "object_permission": True} include={"litellm_budget_table": True, "object_permission": True}
) )
else: else:
# Look up the user's team memberships via the reverse-index on
# LiteLLM_UserTable.teams (maintained by team_member_add alongside
# members_with_roles). This avoids a full scan of all team rows.
user_record: Final = await _user_table(prisma_client).find_unique( user_record: Final = await _user_table(prisma_client).find_unique(
where={"user_id": user_api_key_dict.user_id}, where={"user_id": user_api_key_dict.user_id},
include={"organization_memberships": True},
) )
user_team_ids: list[str] = user_record.teams if user_record is not None and user_record.teams else [] user: Final = None if user_record is None else LiteLLM_UserTable.model_validate(user_record.model_dump())
projects = await _project_table(prisma_client).find_many( projects = await _project_table(prisma_client).find_many(
where={"team_id": {"in": user_team_ids}}, where=_visible_projects_where(user.teams if user is not None else [], org_admin_org_ids(user)),
include={"litellm_budget_table": True, "object_permission": True}, include={"litellm_budget_table": True, "object_permission": True},
) )

View file

@ -9,7 +9,7 @@ custom_auth_settings: Optional[CustomAuthSettings] = None
class EnterpriseProxyConfig: class EnterpriseProxyConfig:
async def load_custom_auth_settings( async def load_custom_auth_settings(
self, general_settings: dict self, general_settings: dict
) -> CustomAuthSettings: ) -> Optional[CustomAuthSettings]:
custom_auth_settings = general_settings.get("custom_auth_settings", None) custom_auth_settings = general_settings.get("custom_auth_settings", None)
if custom_auth_settings is not None: if custom_auth_settings is not None:
custom_auth_settings = CustomAuthSettings( custom_auth_settings = CustomAuthSettings(

View file

@ -3,7 +3,7 @@ from typing import Optional, Union
from litellm.secret_managers.main import str_to_bool from litellm.secret_managers.main import str_to_bool
def _should_block_robots(): def should_block_robots():
""" """
Returns True if the robots.txt file should block web crawlers Returns True if the robots.txt file should block web crawlers
@ -33,3 +33,4 @@ def _should_block_robots():
) )
return True return True
return False return False
_should_block_robots = should_block_robots

View file

@ -171,7 +171,7 @@ async def list_vector_stores(
try: try:
# Get vector stores from database (source of truth) # Get vector stores from database (source of truth)
# Only return what's in the database to ensure consistency across instances # Only return what's in the database to ensure consistency across instances
vector_stores_from_db = await VectorStoreRegistry._get_vector_stores_from_db( vector_stores_from_db = await VectorStoreRegistry.get_vector_stores_from_db(
prisma_client=prisma_client prisma_client=prisma_client
) )

View file

@ -1,6 +1,6 @@
[project] [project]
name = "litellm-enterprise" name = "litellm-enterprise"
version = "0.1.73" version = "0.1.75"
description = "Package for LiteLLM Enterprise features" description = "Package for LiteLLM Enterprise features"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
@ -26,7 +26,7 @@ required-version = ">=0.10.9"
module-root = "" module-root = ""
[tool.commitizen] [tool.commitizen]
version = "0.1.73" version = "0.1.75"
version_files = [ version_files = [
"pyproject.toml:^version", "pyproject.toml:^version",
"../pyproject.toml:litellm-enterprise==", "../pyproject.toml:litellm-enterprise==",

View file

@ -2,8 +2,8 @@ ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:1d95114038f76513a9a
ARG LITELLM_RUNTIME_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 ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a
# Checksum from https://www.pgbouncer.org/downloads/ (the Wolfi repo only carries 1.24.x) # Checksum from https://www.pgbouncer.org/downloads/ (the Wolfi repo only carries 1.24.x)
ARG PGBOUNCER_VERSION=1.25.2 ARG PGBOUNCER_VERSION=1.26.0
ARG PGBOUNCER_SHA256=924ad35113fd0a71c8e2dbe85b5d03445532e2b7b37a9f8a48983beea238b332 ARG PGBOUNCER_SHA256=afd25dd61ee6775d37b40629b87ce08736b3e6955f3057bb212e410fbf21c71d
FROM $UV_IMAGE AS uvbin FROM $UV_IMAGE AS uvbin

View file

@ -62,6 +62,8 @@ GATEWAY_PATH_PREFIXES: tuple[str, ...] = (
"/rerank", "/rerank",
"/v1/decisions", "/v1/decisions",
"/decisions", "/decisions",
"/v1/systemone",
"/systemone",
"/v1/ocr", "/v1/ocr",
"/ocr", "/ocr",
"/v1/rag/", "/v1/rag/",

View file

@ -321,3 +321,80 @@ through an emptyDir. Empty when the sidecar is off or uses 127.0.0.1 TCP.
- name: LITELLM_COLLECTOR_DRAIN_TIMEOUT_SECONDS - name: LITELLM_COLLECTOR_DRAIN_TIMEOUT_SECONDS
value: {{ .Values.collector.drainTimeoutSeconds | quote }} value: {{ .Values.collector.drainTimeoutSeconds | quote }}
{{- end -}} {{- end -}}
{{- 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.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 -}}
{{- 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.bundledClickhouse" -}}
{{- if and .Values.lensWorker.enabled .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 .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 -}}

View file

@ -56,6 +56,17 @@ spec:
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }} imagePullPolicy: {{ .Values.image.pullPolicy }}
env: env:
{{- if .Values.lensWorker.enabled }}
- name: LITELLM_LENS_URL
value: {{ printf "http://%s-lens-worker:%v" (include "litellm.fullname" .) .Values.lensWorker.service.port | quote }}
- name: LITELLM_LENS_PUBLIC_URL
value: {{ include "litellm.lensWorker.publicUrl" . | quote }}
- name: LITELLM_LENS_SERVICE_TOKEN
valueFrom:
secretKeyRef:
name: {{ include "litellm.lensWorker.serviceTokenSecretName" . | quote }}
key: {{ .Values.lensWorker.serviceTokenSecret.key | quote }}
{{- end }}
{{- include "litellm.proxyEnv" . | nindent 12 }} {{- include "litellm.proxyEnv" . | nindent 12 }}
{{- if .Values.liteadmin.enabled }} {{- if .Values.liteadmin.enabled }}
- name: LITELLM_ADMIN_AGENT_URL - name: LITELLM_ADMIN_AGENT_URL

View file

@ -44,6 +44,20 @@ spec:
- host: {{ .host | quote }} - host: {{ .host | quote }}
http: http:
paths: paths:
{{- if $.Values.lensWorker.enabled }}
- path: /lens-ingest
pathType: Prefix
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}-lens-worker
port:
number: {{ $.Values.lensWorker.service.port }}
{{- else }}
serviceName: {{ $fullName }}-lens-worker
servicePort: {{ $.Values.lensWorker.service.port }}
{{- end }}
{{- end }}
{{- range .paths }} {{- range .paths }}
- path: {{ .path }} - path: {{ .path }}
{{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}

View file

@ -0,0 +1,98 @@
{{- if include "litellm.lensWorker.bundledClickhouse" . }}
{{- $name := include "litellm.lensWorker.clickhouseName" . }}
apiVersion: v1
kind: Service
metadata:
name: {{ $name }}
spec:
clusterIP: None
selector:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: lens-clickhouse
ports:
- name: http
port: 8123
targetPort: http
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ $name }}
spec:
serviceName: {{ $name }}
replicas: 1
selector:
matchLabels:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: lens-clickhouse
template:
metadata:
labels:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: lens-clickhouse
spec:
automountServiceAccountToken: false
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
runAsNonRoot: true
runAsUser: 101
runAsGroup: 101
fsGroup: 101
seccompProfile:
type: RuntimeDefault
containers:
- name: clickhouse
image: {{ .Values.lensWorker.clickhouse.image | quote }}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
env:
- name: CLICKHOUSE_USER
value: default
- name: CLICKHOUSE_PASSWORD
valueFrom:
secretKeyRef:
name: {{ $name }}
key: password
- name: CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT
value: "1"
ports:
- name: http
containerPort: 8123
startupProbe:
httpGet:
path: /ping
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 60
readinessProbe:
httpGet:
path: /ping
port: http
livenessProbe:
httpGet:
path: /ping
port: http
timeoutSeconds: 3
resources:
{{- toYaml .Values.lensWorker.clickhouse.resources | nindent 12 }}
volumeMounts:
- name: data
mountPath: /var/lib/clickhouse
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: [ReadWriteOnce]
{{- if ne .Values.lensWorker.clickhouse.storageClassName nil }}
storageClassName: {{ .Values.lensWorker.clickhouse.storageClassName | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.lensWorker.clickhouse.storage | quote }}
{{- end }}

View file

@ -0,0 +1,109 @@
{{- if .Values.lensWorker.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "litellm.fullname" . }}-lens-worker
labels:
{{- include "litellm.lensWorker.labels" . | 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.lensWorker.labels" . | 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.fullname" .) .Values.service.port) | quote }}
- name: LITELLM_LENS_SERVICE_TOKEN
valueFrom:
secretKeyRef:
name: {{ include "litellm.lensWorker.serviceTokenSecretName" . | quote }}
key: {{ .Values.lensWorker.serviceTokenSecret.key | quote }}
{{- if include "litellm.lensWorker.bundledClickhouse" . }}
- name: CLICKHOUSE_HOST
value: {{ include "litellm.lensWorker.clickhouseName" . | quote }}
- name: CLICKHOUSE_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "litellm.lensWorker.clickhouseName" . | quote }}
key: password
{{- else }}
- name: CLICKHOUSE_URL
valueFrom:
secretKeyRef:
name: {{ required "lensWorker.clickhouseSecret.name is required" .Values.lensWorker.clickhouseSecret.name | quote }}
key: {{ .Values.lensWorker.clickhouseSecret.key | quote }}
{{- end }}
- name: CLICKHOUSE_DATABASE
value: {{ .Values.lensWorker.clickhouseDatabase | quote }}
- name: AGENT_TRACING_RETENTION_DAYS
value: {{ .Values.lensWorker.retentionDays | quote }}
{{- if .Values.lensWorker.tokenSecret.name }}
- name: LENS_WORKER_TOKEN
valueFrom:
secretKeyRef:
name: {{ .Values.lensWorker.tokenSecret.name | quote }}
key: {{ .Values.lensWorker.tokenSecret.key | quote }}
{{- end }}
ports:
- name: otlp
containerPort: 4318
livenessProbe:
httpGet:
path: /health/live
port: otlp
readinessProbe:
httpGet:
path: /health/ready
port: otlp
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 }}
{{- end }}

View file

@ -0,0 +1,29 @@
{{- if and .Values.lensWorker.enabled .Values.lensWorker.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "litellm.fullname" . }}-lens-worker
{{- with .Values.lensWorker.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.lensWorker.ingress.className }}
ingressClassName: {{ . | quote }}
{{- end }}
{{- with .Values.lensWorker.ingress.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
- host: {{ required "lensWorker.ingress.host is required" .Values.lensWorker.ingress.host | quote }}
http:
paths:
- path: /v1/
pathType: Prefix
backend:
service:
name: {{ include "litellm.fullname" . }}-lens-worker
port:
name: otlp
{{- end }}

View file

@ -0,0 +1,27 @@
{{- if and .Values.lensWorker.enabled (not .Values.lensWorker.serviceTokenSecret.name) }}
{{- $name := include "litellm.lensWorker.serviceTokenSecretName" . }}
{{- $existing := lookup "v1" "Secret" .Release.Namespace $name }}
apiVersion: v1
kind: Secret
metadata:
name: {{ $name }}
annotations:
helm.sh/resource-policy: keep
type: Opaque
data:
{{ .Values.lensWorker.serviceTokenSecret.key }}: {{ if $existing }}{{ required "Saved Lens service secret is missing its key" (index $existing.data .Values.lensWorker.serviceTokenSecret.key) | quote }}{{ else }}{{ randAlphaNum 64 | b64enc | quote }}{{ end }}
{{- end }}
{{- if include "litellm.lensWorker.bundledClickhouse" . }}
{{- $name := include "litellm.lensWorker.clickhouseName" . }}
{{- $existing := lookup "v1" "Secret" .Release.Namespace $name }}
---
apiVersion: v1
kind: Secret
metadata:
name: {{ $name }}
annotations:
helm.sh/resource-policy: keep
type: Opaque
data:
password: {{ if $existing }}{{ required "Saved Lens ClickHouse secret is missing its password" (index $existing.data "password") | quote }}{{ else }}{{ randAlphaNum 64 | b64enc | quote }}{{ end }}
{{- end }}

View file

@ -0,0 +1,18 @@
{{- if .Values.lensWorker.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "litellm.fullname" . }}-lens-worker
{{- with .Values.lensWorker.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
selector:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: lens-worker
ports:
- name: otlp
port: {{ .Values.lensWorker.service.port }}
targetPort: otlp
{{- end }}

View file

@ -6,6 +6,9 @@ metadata:
name: {{ include "litellm.fullname" . }}-migrations name: {{ include "litellm.fullname" . }}-migrations
labels: labels:
{{- include "litellm.labels" . | nindent 4 }} {{- include "litellm.labels" . | nindent 4 }}
{{- with .Values.migrationJob.jobLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
annotations: annotations:
{{- if .Values.migrationJob.hooks.argocd.enabled }} {{- if .Values.migrationJob.hooks.argocd.enabled }}
argocd.argoproj.io/hook: PreSync argocd.argoproj.io/hook: PreSync
@ -17,6 +20,9 @@ metadata:
helm.sh/hook-weight: {{ .Values.migrationJob.hooks.helm.weight | default "1" | quote }} helm.sh/hook-weight: {{ .Values.migrationJob.hooks.helm.weight | default "1" | quote }}
{{- end }} {{- end }}
checksum/config: {{ toYaml .Values | sha256sum }} checksum/config: {{ toYaml .Values | sha256sum }}
{{- with .Values.migrationJob.jobAnnotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec: spec:
template: template:
metadata: metadata:
@ -25,6 +31,9 @@ spec:
{{- with .Values.podLabels }} {{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }} {{- toYaml . | nindent 8 }}
{{- end }} {{- end }}
{{- with .Values.migrationJob.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
annotations: annotations:
{{- with .Values.migrationJob.annotations }} {{- with .Values.migrationJob.annotations }}
{{- toYaml . | nindent 8 }} {{- toYaml . | nindent 8 }}
@ -47,7 +56,16 @@ spec:
imagePullPolicy: {{ .Values.image.pullPolicy }} imagePullPolicy: {{ .Values.image.pullPolicy }}
securityContext: securityContext:
{{- toYaml .Values.securityContext | nindent 12 }} {{- toYaml .Values.securityContext | nindent 12 }}
{{- if .Values.migrationJob.command }}
command: {{ toYaml .Values.migrationJob.command | nindent 12 }}
{{- else }}
command: ["python", "litellm/proxy/prisma_migration.py"] command: ["python", "litellm/proxy/prisma_migration.py"]
{{- end }}
{{- if .Values.migrationJob.args }}
args: {{ toYaml .Values.migrationJob.args | nindent 12 }}
{{- else if .Values.migrationJob.command }}
args: []
{{- end }}
workingDir: "/app" workingDir: "/app"
env: env:
{{- if .Values.db.useExisting }} {{- if .Values.db.useExisting }}

View file

@ -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

View file

@ -0,0 +1,45 @@
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-service
namespace: lens
data:
service-token: c2F2ZWQtc2VydmljZS10b2tlbg==
- apiVersion: v1
kind: Secret
metadata:
name: lens-test-lens-clickhouse
namespace: lens
data:
password: c2F2ZWQtZGF0YWJhc2UtcGFzc3dvcmQ=
tests:
- 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=

View file

@ -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: LITELLM_LENS_SERVICE_TOKEN
valueFrom:
secretKeyRef:
name: lens-service
key: service-token
- 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

View file

@ -0,0 +1,160 @@
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 both private credentials for a new installation
template: lens/secrets.yaml
asserts:
- hasDocuments:
count: 2
- 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
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: lensWorker.clickhouseSecret.name is required
- 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

View file

@ -360,3 +360,73 @@ tests:
asserts: asserts:
- notExists: - notExists:
path: spec.activeDeadlineSeconds path: spec.activeDeadlineSeconds
- it: should set custom jobLabels and jobAnnotations on Job metadata
template: migrations-job.yaml
set:
migrationJob:
enabled: true
jobLabels:
environment: production
team: platform
jobAnnotations:
example.com/cost-center: "1234"
asserts:
- equal:
path: metadata.labels.environment
value: production
- equal:
path: metadata.labels.team
value: platform
- equal:
path: metadata.annotations['example.com/cost-center']
value: "1234"
- it: should set custom podLabels on Pod template
template: migrations-job.yaml
set:
migrationJob:
enabled: true
podLabels:
custom.io/pod-role: migration
asserts:
- equal:
path: spec.template.metadata.labels['custom.io/pod-role']
value: migration
- it: should override container command and args
template: migrations-job.yaml
set:
migrationJob:
enabled: true
command:
- sh
args:
- -c
- echo migrating
asserts:
- equal:
path: spec.template.spec.containers[0].command
value:
- sh
- equal:
path: spec.template.spec.containers[0].args
value:
- -c
- echo migrating
- it: should clear container args when only command is specified
template: migrations-job.yaml
set:
migrationJob:
enabled: true
command:
- sh
asserts:
- equal:
path: spec.template.spec.containers[0].command
value:
- sh
- equal:
path: spec.template.spec.containers[0].args
value: []

View file

@ -572,6 +572,11 @@ migrationJob:
# In that case, pre-install/pre-upgrade hooks run before normal resources, so this defaults to "default". # In that case, pre-install/pre-upgrade hooks run before normal resources, so this defaults to "default".
serviceAccountName: "" serviceAccountName: ""
annotations: {} annotations: {}
jobLabels: {} # Custom labels for the Job metadata
jobAnnotations: {} # Custom annotations for the Job metadata
podLabels: {} # Custom labels for the Job pod template
command: [] # Override container command (defaults to ["python", "litellm/proxy/prisma_migration.py"])
args: [] # Override container args
ttlSecondsAfterFinished: 120 ttlSecondsAfterFinished: 120
resources: {} resources: {}
# Unset by default. This job runs the database migration and exits, so it does not # Unset by default. This job runs the database migration and exits, so it does not
@ -647,3 +652,55 @@ serviceMonitor:
namespaceSelector: namespaceSelector:
matchNames: [] matchNames: []
# - test-namespace # - test-namespace
lensWorker:
enabled: false
replicaCount: 1
image:
repository: ghcr.io/berriai/litellm-lens-worker
tag: ""
digest: ""
pullPolicy: IfNotPresent
tokenSecret:
name: ""
key: token
serviceTokenSecret:
name: ""
key: service-token
clickhouse:
enabled: true
image: clickhouse/clickhouse-server:26.9.6.6
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
className: ""
host: ""
annotations: {}
tls: []
url: ""
tmpSizeLimit: 1Gi
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 2Gi
nodeSelector: {}
tolerations: []
affinity: {}

View file

@ -514,3 +514,49 @@ shutdown drain window.
value: {{ .drainTimeoutSeconds | quote }} value: {{ .drainTimeoutSeconds | quote }}
{{- end }} {{- end }}
{{- end -}} {{- end -}}
{{- define "litellm.lensConnectionEnv" -}}
{{- if .Values.lensWorker.enabled }}
- name: LITELLM_LENS_URL
value: {{ printf "http://%s-lens-worker:%v" (include "litellm.fullname" .) .Values.lensWorker.service.port | quote }}
- name: LITELLM_LENS_PUBLIC_URL
value: {{ include "litellm.lensWorker.publicUrl" . | 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.bundledClickhouse" -}}
{{- if and .Values.lensWorker.enabled .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 .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 -}}

View file

@ -57,6 +57,7 @@ spec:
containerPort: 4001 containerPort: 4001
protocol: TCP protocol: TCP
env: env:
{{- include "litellm.lensConnectionEnv" . | nindent 12 }}
- name: LENS_WORKER_IMAGE - name: LENS_WORKER_IMAGE
value: {{ include "litellm.lensWorker.image" . | quote }} value: {{ include "litellm.lensWorker.image" . | quote }}
{{- include "litellm.serverEnv" (dict "root" $ "component" .Values.backend) | nindent 12 }} {{- include "litellm.serverEnv" (dict "root" $ "component" .Values.backend) | nindent 12 }}

View file

@ -55,6 +55,7 @@ spec:
containerPort: 4000 containerPort: 4000
protocol: TCP protocol: TCP
env: env:
{{- include "litellm.lensConnectionEnv" . | nindent 12 }}
{{- include "litellm.serverEnv" (dict "root" $ "component" .Values.gateway) | nindent 12 }} {{- include "litellm.serverEnv" (dict "root" $ "component" .Values.gateway) | nindent 12 }}
{{- if .Values.gateway.config.create }} {{- if .Values.gateway.config.create }}
- name: CONFIG_FILE_PATH - name: CONFIG_FILE_PATH

Some files were not shown because too many files have changed in this diff Show more