diff --git a/.circleci/config.yml b/.circleci/config.yml
index bfca8310c0e..90bab08235a 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -38,7 +38,7 @@ commands:
parameters:
category:
type: enum
- enum: ["backend", "client", "provider-harness", "redis-compat"]
+ enum: ["backend", "client"]
default: "backend"
steps:
- run:
@@ -160,7 +160,7 @@ commands:
exit 1
;;
esac
- curl -sSLf -o /tmp/rustup-init \
+ curl -sSLf --retry 3 --retry-all-errors --retry-max-time 120 --connect-timeout 15 --max-time 60 -o /tmp/rustup-init \
"https://static.rust-lang.org/rustup/archive/1.28.2/${RUSTUP_TRIPLE}/rustup-init"
echo "${RUSTUP_SHA256} /tmp/rustup-init" | sha256sum -c -
chmod +x /tmp/rustup-init
@@ -284,26 +284,6 @@ commands:
done
echo "fake OpenAI endpoint did not become ready" >&2
exit 1
- start_cost_center_service:
- description: "Start the stand-in cost center validation service (tests/store_model_in_db_tests/cost_center_service.py) on host port 9414 and wait until healthy. The proxy's team-metadata validator (team_metadata_validator_e2e.py, impl 'http') reaches it via TEAM_METADATA_VALIDATION_SERVICE_URL=http://host.docker.internal:9414/validate. Run after uv deps are synced."
- steps:
- - run:
- name: Start cost center validation service
- background: true
- command: |
- uv run --no-sync python tests/store_model_in_db_tests/cost_center_service.py --host 0.0.0.0 --port 9414
- - run:
- name: Wait for cost center validation service
- command: |
- for i in $(seq 1 30); do
- if curl -sf http://localhost:9414/health >/dev/null 2>&1; then
- echo "cost center validation service is up"
- exit 0
- fi
- sleep 1
- done
- echo "cost center validation service did not become ready" >&2
- exit 1
setup_litellm_enterprise_pip:
steps:
- run:
@@ -359,8 +339,15 @@ commands:
Add-Content -Path $PROFILE -Value "`$env:Path = `"$cargoBin;`$env:Path`""
}
setup_litellm_test_deps:
+ parameters:
+ checkout:
+ type: boolean
+ default: true
steps:
- - checkout
+ - when:
+ condition: << parameters.checkout >>
+ steps:
+ - checkout
- setup_google_dns
- install_uv
- install_rust
@@ -396,13 +383,28 @@ jobs:
shell: bash.exe
command: bash .circleci/scripts/path_filter.sh windows-release
- run:
- name: Install Python
+ name: Install Python 3.11.9 from python.org
+ no_output_timeout: 6m
command: |
- choco install python --version=3.11.0 -y --no-progress --force
- refreshenv
- python --version
- environment:
- CHOCOLATEY_CONFIRM_ALL: "true"
+ $ProgressPreference = "SilentlyContinue"
+ $installer = Join-Path $env:TEMP "python-3.11.9-amd64.exe"
+ Invoke-WebRequest -Uri https://www.python.org/ftp/python/3.11.9/python-3.11.9-amd64.exe -OutFile $installer -TimeoutSec 120
+ $expected = "5ee42c4eee1e6b4464bb23722f90b45303f79442df63083f05322f1785f5fdde"
+ $actual = (Get-FileHash -Path $installer -Algorithm SHA256).Hash.ToLower()
+ if ($actual -ne $expected) {
+ throw "Python installer hash mismatch: expected $expected got $actual"
+ }
+ $arguments = "/quiet InstallAllUsers=1 PrependPath=1 Include_test=0 Include_doc=0 Include_launcher=0 TargetDir=C:\Python311"
+ $process = Start-Process -FilePath $installer -ArgumentList $arguments -PassThru
+ if (-not $process.WaitForExit(300000)) {
+ $process.Kill()
+ throw "Python installer did not finish within 5 minutes"
+ }
+ if ($process.ExitCode -ne 0) {
+ throw "Python installer exited with $($process.ExitCode)"
+ }
+ Remove-Item $installer
+ & C:\Python311\python.exe --version
- install_windows_toolchain
- run:
name: Record the Rust build environment for the release cargo cache key
@@ -425,7 +427,7 @@ jobs:
environment:
UV_HTTP_TIMEOUT: "300"
command: |
- $env:Path = "$HOME\.cargo\bin;$HOME\.local\bin;$env:Path"
+ $env:Path = "C:\Python311;C:\Python311\Scripts;$HOME\.cargo\bin;$HOME\.local\bin;$env:Path"
uv build --wheel --out-dir dist
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
@@ -473,873 +475,6 @@ jobs:
command: |
python3 tests/windows_tests/check_windows_wheel_install.py --lengths-only
- local_testing_part1:
- docker:
- - &python312_image
- image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c
- auth:
- username: ${DOCKERHUB_USERNAME}
- password: ${DOCKERHUB_PASSWORD}
- - &postgres16_sidecar
- image: cimg/postgres:16.0@sha256:b125148bc76e8e8eee5eb3ad6020a3a14110a14e8192f1c645128afebe2e2f84
- environment:
- POSTGRES_USER: postgres
- POSTGRES_PASSWORD: postgres
- POSTGRES_DB: litellm_test
- working_directory: ~/project
- environment: &sidecar_database_url
- DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/litellm_test"
- parallelism: 4
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - restore_cache:
- keys:
- - v1-uv-cache-{{ checksum "uv.lock" }}
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - setup_litellm_enterprise_pip
- - save_cache:
- paths:
- - ~/.cache/uv
- key: v1-uv-cache-{{ checksum "uv.lock" }}
- - save_cargo_target
- - wait_for_service:
- url: tcp://localhost:5432
- timeout: "60"
- - run:
- name: Run prisma ./docker/entrypoint.sh
- command: |
- set +e
- chmod +x docker/entrypoint.sh
- ./docker/entrypoint.sh
- set -e
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests (Part 1 - A-M)
- command: |
- mkdir test-results
-
- # Discover test files (A-M)
- TEST_FILES=$(circleci tests glob "tests/local_testing/**/test_[a-mA-M]*.py")
-
- echo "$TEST_FILES" | circleci tests run \
- --split-by=timings \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv \
- --cov=./litellm --cov=./enterprise/litellm_enterprise \
- --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=20 \
- -k \"not test_python_38.py and not test_basic_python_version.py and not router and not assistants and not langfuse and not caching and not cache\" \
- -n 4 \
- --timeout=300 \
- --timeout_method=thread"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- # When CI reruns only the failed tests, a parallel node can receive
- # zero tests and pytest never writes coverage. Emit empty placeholders
- # so persist_to_workspace and the downstream coverage combine stay green.
- if [ -f coverage.xml ]; then
- mv coverage.xml local_testing_part1_coverage.xml
- mv .coverage local_testing_part1_coverage
- else
- touch local_testing_part1_coverage.xml local_testing_part1_coverage
- fi
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - local_testing_part1_coverage.xml
- - local_testing_part1_coverage
- local_testing_part2:
- docker:
- - *python312_image
- - *postgres16_sidecar
- working_directory: ~/project
- environment: *sidecar_database_url
- parallelism: 4
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - restore_cache:
- keys:
- - v1-uv-cache-{{ checksum "uv.lock" }}
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - setup_litellm_enterprise_pip
- - save_cache:
- paths:
- - ~/.cache/uv
- key: v1-uv-cache-{{ checksum "uv.lock" }}
- - wait_for_service:
- url: tcp://localhost:5432
- timeout: "60"
- - run:
- name: Run prisma ./docker/entrypoint.sh
- command: |
- set +e
- chmod +x docker/entrypoint.sh
- ./docker/entrypoint.sh
- set -e
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests (Part 2 - N-Z)
- command: |
- mkdir test-results
-
- # Discover test files (N-Z)
- TEST_FILES=$(circleci tests glob "tests/local_testing/**/test_[n-zN-Z]*.py")
-
- echo "$TEST_FILES" | circleci tests run \
- --split-by=timings \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv \
- --cov=./litellm --cov=./enterprise/litellm_enterprise \
- --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=20 \
- -k \"not test_python_38.py and not test_basic_python_version.py and not router and not assistants and not langfuse and not caching and not cache\" \
- -n 4 \
- --timeout=300 \
- --timeout_method=thread"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- # When CI reruns only the failed tests, a parallel node can receive
- # zero tests and pytest never writes coverage. Emit empty placeholders
- # so persist_to_workspace and the downstream coverage combine stay green.
- if [ -f coverage.xml ]; then
- mv coverage.xml local_testing_part2_coverage.xml
- mv .coverage local_testing_part2_coverage
- else
- touch local_testing_part2_coverage.xml local_testing_part2_coverage
- fi
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - local_testing_part2_coverage.xml
- - local_testing_part2_coverage
- langfuse_logging_unit_tests:
- docker:
- - *python312_image
- - *postgres16_sidecar
- working_directory: ~/project
- environment: *sidecar_database_url
- resource_class: medium
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - restore_cache:
- keys:
- - v1-uv-cache-{{ checksum "uv.lock" }}
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - setup_litellm_enterprise_pip
- - save_cache:
- paths:
- - ~/.cache/uv
- key: v1-uv-cache-{{ checksum "uv.lock" }}
- - wait_for_service:
- url: tcp://localhost:5432
- timeout: "60"
- - run:
- name: Run prisma ./docker/entrypoint.sh
- command: |
- set +e
- chmod +x docker/entrypoint.sh
- ./docker/entrypoint.sh
- set -e
-
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/local_testing/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -v \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- -k \"langfuse\""
- no_output_timeout: 15m
- # Store test results
- - store_test_results:
- path: test-results
- auth_ui_unit_tests:
- docker:
- - *python312_image
- - *postgres16_sidecar
- working_directory: ~/project
- environment: *sidecar_database_url
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - save_cache:
- paths:
- - ~/.cache/uv
- key: v1-uv-cache-{{ checksum "uv.lock" }}
- - wait_for_service:
- url: tcp://localhost:5432
- timeout: "60"
- - run:
- name: Seed DB schema via prisma db push
- command: |
- set +e
- uv run --no-sync litellm --skip_server_startup --use_prisma_db_push
- set -e
- - run:
- name: Generate Prisma Client
- command: uv run --no-sync python -m prisma generate
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/proxy_admin_ui_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -v \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- -n 2"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- mv coverage.xml auth_ui_unit_tests_coverage.xml
- mv .coverage auth_ui_unit_tests_coverage
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - auth_ui_unit_tests_coverage.xml
- - auth_ui_unit_tests_coverage
-
- litellm_router_testing: # Runs all tests with the "router" keyword
- docker:
- - *python312_image
- working_directory: ~/project
- resource_class: large
- parallelism: 1
- environment:
- FAKE_OPENAI_API_BASE: http://127.0.0.1:8190
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - restore_cache:
- keys:
- - v1-uv-cache-{{ checksum "uv.lock" }}
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - save_cache:
- paths:
- - ~/.cache/uv
- key: v1-uv-cache-{{ checksum "uv.lock" }}
- - start_fake_openai_endpoint
- # Run pytest and generate JUnit XML report
- - setup_litellm_enterprise_pip
- - run:
- name: Run tests
- command: |
- # On a "rerun failed tests" build a parallel node can receive no
- # tests, so the test command never creates test-results. Pre-create it
- # so store_test_results doesn't fail the node on a missing path.
- mkdir -p test-results
-
- TEST_FILES=$(circleci tests glob "tests/local_testing/**/test_*.py")
-
- echo "$TEST_FILES" | circleci tests run \
- --split-by=timings \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -v \
- -k 'router' \
- -n 4 \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- --timeout=300 --timeout_method=thread"
- no_output_timeout: 15m
- # Store test results
- - store_test_results:
- path: test-results
-
- llm_translation_testing:
- docker:
- - *python312_image
- working_directory: ~/project
- resource_class: xlarge
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - restore_cache:
- keys:
- - v1-uv-cache-{{ checksum "uv.lock" }}
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - save_cache:
- paths:
- - ~/.cache/uv
- key: v1-uv-cache-{{ checksum "uv.lock" }}
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- # Add --timeout to kill hanging tests after 120s (2 min)
- # Add --durations=20 to show 20 slowest tests for debugging
- # Subdirectories with dedicated jobs (maintain this list as new jobs are added)
- mkdir -p test-results
- # Glob excludes the realtime/ subdirectory since it has its own job
- TEST_FILES=$(circleci tests glob "tests/llm_translation/**/test_*.py" | grep -v "^tests/llm_translation/realtime/")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -v \
- --junitxml=test-results/junit.xml \
- --durations=20 \
- -n 4 \
- --timeout=120 --timeout_method=thread \
- --retries 2 --retry-delay 5 \
- --max-worker-restart=5"
- no_output_timeout: 15m
-
- # Store test results
- - store_test_results:
- path: test-results
- realtime_translation_testing:
- docker:
- - *python312_image
- working_directory: ~/project
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- # Run pytest and generate JUnit XML report
- - run:
- name: Run realtime tests
- command: |
- # Add --timeout to kill hanging tests after 120s (2 min)
- # Add --durations=20 to show 20 slowest tests for debugging
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/llm_translation/realtime/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=20 \
- -n 4 \
- --timeout=120 --timeout_method=thread"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- mv coverage.xml realtime_translation_coverage.xml
- mv .coverage realtime_translation_coverage
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - realtime_translation_coverage.xml
- - realtime_translation_coverage
- guardrails_testing:
- docker:
- - *python312_image
- working_directory: ~/project
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- export LITELLM_LOG=WARNING
- uv run --no-sync python -m pytest --tb=short \
- -vv \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- --timeout=120 --timeout_method=thread \
- "tests/guardrails_tests/test_custom_guardrail.py::test_get_guardrail_dynamic_request_body_params"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- mv coverage.xml guardrails_coverage.xml
- mv .coverage guardrails_coverage
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - guardrails_coverage.xml
- - guardrails_coverage
-
- google_generate_content_endpoint_testing:
- docker:
- - *python312_image
- working_directory: ~/project
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/unified_google_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv -s \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- --retries 3 --retry-delay 5"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- mv coverage.xml google_generate_content_endpoint_coverage.xml
- mv .coverage google_generate_content_endpoint_coverage
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - google_generate_content_endpoint_coverage.xml
- - google_generate_content_endpoint_coverage
-
- ocr_testing:
- docker:
- - *python312_image
- working_directory: ~/project
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/ocr_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- -n 4"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- mv coverage.xml ocr_coverage.xml
- mv .coverage ocr_coverage
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - ocr_coverage.xml
- - ocr_coverage
- search_testing:
- docker:
- - *python312_image
- working_directory: ~/project
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/search_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- -n 4"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- mv coverage.xml search_coverage.xml
- mv .coverage search_coverage
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - search_coverage.xml
- - search_coverage
- batches_testing:
- docker:
- - *python312_image
- working_directory: ~/project
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/batches_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv -s \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- -n 2"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- mv coverage.xml batches_coverage.xml
- mv .coverage batches_coverage
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - batches_coverage.xml
- - batches_coverage
- litellm_utils_testing:
- docker:
- - *python312_image
- working_directory: ~/project
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/litellm_utils_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv -s \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- -n 2"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- mv coverage.xml litellm_utils_coverage.xml
- mv .coverage litellm_utils_coverage
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - litellm_utils_coverage.xml
- - litellm_utils_coverage
-
- pass_through_unit_testing:
- docker:
- - *python312_image
- working_directory: ~/project
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/pass_through_unit_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- -n 4"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- mv coverage.xml pass_through_unit_tests_coverage.xml
- mv .coverage pass_through_unit_tests_coverage
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - pass_through_unit_tests_coverage.xml
- - pass_through_unit_tests_coverage
- image_gen_testing:
- docker:
- - *python312_image
- working_directory: ~/project
- resource_class: large
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/image_gen_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -v \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- -n 4"
- no_output_timeout: 15m
- # Store test results
- - store_test_results:
- path: test-results
- logging_testing:
- docker:
- - *python312_image
- working_directory: ~/project
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- # Run pytest and generate JUnit XML report
- - setup_litellm_enterprise_pip
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- export LITELLM_LOG=WARNING
- TEST_FILES=$(circleci tests glob "tests/logging_callback_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
- -n 4 \
- --junitxml=test-results/junit.xml \
- --durations=5 \
- --timeout=120 --timeout_method=thread"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- mv coverage.xml logging_coverage.xml || true
- mv .coverage logging_coverage || true
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - logging_coverage.xml
- - logging_coverage
- audio_testing:
- docker:
- - *python312_image
- working_directory: ~/project
-
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- # Run pytest and generate JUnit XML report
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/audio_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv -s \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
- --junitxml=test-results/junit.xml \
- --durations=5"
- no_output_timeout: 15m
- - run:
- name: Rename the coverage files
- command: |
- mv coverage.xml audio_coverage.xml
- mv .coverage audio_coverage
-
- # Store test results
- - store_test_results:
- path: test-results
- - persist_to_workspace:
- root: .
- paths:
- - audio_coverage.xml
- - audio_coverage
helm_chart_testing:
machine:
image: ubuntu-2204:2024.04.1 # Use machine executor instead of docker
@@ -1399,11 +534,12 @@ jobs:
--set image.repository=litellm-ci \
--set image.tag=${IMAGE_TAG} \
--set image.pullPolicy=Never
- # Wait for pod to be ready
- echo "Waiting 30 seconds for pod to be ready..."
- sleep 30
+ if ! kubectl rollout status deployment/litellm --timeout=180s; then
+ kubectl describe pods -l app.kubernetes.io/name=litellm
+ kubectl logs -l app.kubernetes.io/name=litellm --tail=200
+ exit 1
+ fi
- # Print pod logs before running tests
echo "Printing pod logs..."
kubectl logs $(kubectl get pods -l app.kubernetes.io/name=litellm -o jsonpath="{.items[0].metadata.name}")
@@ -1506,7 +642,7 @@ jobs:
name: Run Basic Proxy Startup Tests (Health Readiness and Chat Completion)
command: |
mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/basic_proxy_startup_tests/**/test_*.py")
+ TEST_FILES=$(circleci tests glob "tests/integration/configuration/test_basic_proxy_startup.py")
echo "$TEST_FILES" | circleci tests run \
--verbose \
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
@@ -1517,199 +653,6 @@ jobs:
- store_test_results:
path: test-results
- build_and_test:
- machine:
- image: ubuntu-2204:2024.04.1
- resource_class: large
- working_directory: ~/project
- steps:
- - checkout
- - run:
- name: Generate LiteLLM master key
- command: |
- key="$(openssl rand -hex 16)"
- printf 'export LITELLM_MASTER_KEY=sk-%s\n' "$key" >> "$BASH_ENV"
- - skip_if_unrelated_changes
- - attach_workspace:
- at: ~/project
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - start_postgres
- - run:
- name: Load Docker Database Image
- command: |
- zstd -d litellm-docker-database.tar.zst --stdout | docker load
- docker tag litellm-docker-database:ci my-app:latest
- - start_openai_record_replay_proxy
- - start_fake_openai_endpoint
- - run:
- name: Run Docker container
- command: |
- docker run -d \
- -p 4000:4000 \
- -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
- -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
- -e USE_PRISMA_MIGRATE=True \
- -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
- -e AZURE_API_KEY=$AZURE_API_KEY \
- -e REDIS_HOST=$REDIS_HOST \
- -e REDIS_PASSWORD=$REDIS_PASSWORD \
- -e REDIS_PORT=$REDIS_PORT \
- -e AZURE_FRANCE_API_KEY=$AZURE_FRANCE_API_KEY \
- -e AZURE_EUROPE_API_KEY=$AZURE_EUROPE_API_KEY \
- -e MISTRAL_API_KEY=$MISTRAL_API_KEY \
- -e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \
- -e GROQ_API_KEY=$GROQ_API_KEY \
- -e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \
- -e COHERE_API_KEY=$COHERE_API_KEY \
- -e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY \
- -e AWS_REGION_NAME=$AWS_REGION_NAME \
- -e AUTO_INFER_REGION=True \
- -e OPENAI_API_KEY=$OPENAI_API_KEY \
- -e USE_DDTRACE=True \
- -e DD_API_KEY=$DD_API_KEY \
- -e DD_SITE=$DD_SITE \
- -e LITELLM_LICENSE=$LITELLM_LICENSE \
- -e LANGFUSE_PROJECT1_PUBLIC=$LANGFUSE_PROJECT1_PUBLIC \
- -e LANGFUSE_PROJECT2_PUBLIC=$LANGFUSE_PROJECT2_PUBLIC \
- -e LANGFUSE_PROJECT1_SECRET=$LANGFUSE_PROJECT1_SECRET \
- -e LANGFUSE_PROJECT2_SECRET=$LANGFUSE_PROJECT2_SECRET \
- -e RECORDER_OPENAI_BASE_URL=http://host.docker.internal:8090/v1 \
- -e LITELLM_LOG=ERROR \
- --add-host host.docker.internal:host-gateway \
- --name my-app \
- -v $(pwd)/proxy_server_config.yaml:/app/config.yaml \
- my-app:latest \
- --config /app/config.yaml \
- --port 4000
- - run:
- name: Start outputting logs
- command: docker logs -f my-app
- background: true
- - wait_for_service:
- url: http://localhost:4000
- timeout: "300"
- - run:
- name: Seed the routing strategy through /config/update
- command: |
- curl --noproxy '*' -sSf -X POST http://localhost:4000/config/update \
- -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' \
- -d '{"router_settings": {"routing_strategy": "usage-based-routing-v2"}}'
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- # Original used `tests/*.py` (top-level only); the `--ignore=...`
- # flags were vestigial since shell globbing did not descend into
- # subdirectories. Replicate by globbing only top-level test files.
- TEST_FILES=$(circleci tests glob "tests/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -s -v \
- --junitxml=test-results/junit.xml \
- -n 4 \
- --durations=5"
- no_output_timeout: 15m
-
- # Store test results
- - store_test_results:
- path: test-results
- e2e_openai_endpoints:
- machine:
- image: ubuntu-2204:2024.04.1
- resource_class: large
- working_directory: ~/project
- steps:
- - checkout
- - run:
- name: Generate LiteLLM master key
- command: |
- key="$(openssl rand -hex 16)"
- printf 'export LITELLM_MASTER_KEY=sk-%s\n' "$key" >> "$BASH_ENV"
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - start_postgres
- - attach_workspace:
- at: ~/project
- - run:
- name: Load Docker Database Image
- command: |
- zstd -d litellm-docker-database.tar.zst --stdout | docker load
- docker images | grep litellm-docker-database
- - run:
- name: Run Docker container
- command: |
- docker run -d \
- -p 4000:4000 \
- -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
- -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
- -e AZURE_API_KEY=$AZURE_API_KEY \
- -e AZURE_API_BASE=$AZURE_API_BASE \
- -e AZURE_API_VERSION="2024-05-01-preview" \
- -e REDIS_HOST=$REDIS_HOST \
- -e REDIS_PASSWORD=$REDIS_PASSWORD \
- -e REDIS_PORT=$REDIS_PORT \
- -e AZURE_FRANCE_API_KEY=$AZURE_FRANCE_API_KEY \
- -e AZURE_EUROPE_API_KEY=$AZURE_EUROPE_API_KEY \
- -e MISTRAL_API_KEY=$MISTRAL_API_KEY \
- -e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \
- -e GROQ_API_KEY=$GROQ_API_KEY \
- -e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \
- -e COHERE_API_KEY=$COHERE_API_KEY \
- -e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY \
- -e AWS_REGION_NAME=$AWS_REGION_NAME \
- -e AUTO_INFER_REGION=True \
- -e USE_DDTRACE=True \
- -e DD_API_KEY=$DD_API_KEY \
- -e DD_SITE=$DD_SITE \
- -e OPENAI_API_KEY=$OPENAI_API_KEY \
- -e LITELLM_LICENSE=$LITELLM_LICENSE \
- -e LANGFUSE_PROJECT1_PUBLIC=$LANGFUSE_PROJECT1_PUBLIC \
- -e LANGFUSE_PROJECT2_PUBLIC=$LANGFUSE_PROJECT2_PUBLIC \
- -e LANGFUSE_PROJECT1_SECRET=$LANGFUSE_PROJECT1_SECRET \
- -e LANGFUSE_PROJECT2_SECRET=$LANGFUSE_PROJECT2_SECRET \
- -e LITELLM_LOG=ERROR \
- --add-host host.docker.internal:host-gateway \
- --name my-app \
- -v $(pwd)/litellm/proxy/example_config_yaml/oai_misc_config.yaml:/app/config.yaml \
- litellm-docker-database:ci \
- --config /app/config.yaml \
- --port 4000
- - run:
- name: Start outputting logs
- command: docker logs -f my-app
- background: true
- - wait_for_service:
- url: http://localhost:4000
- timeout: "300"
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/openai_endpoints_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -s -vv \
- --junitxml=test-results/junit.xml \
- --durations=5"
- no_output_timeout: 15m
-
- # Store test results
- - store_test_results:
- path: test-results
proxy_logging_guardrails_model_info_tests:
machine:
image: ubuntu-2204:2024.04.1
@@ -1740,66 +683,6 @@ jobs:
docker images | grep litellm-docker-database
- start_openai_record_replay_proxy
- start_fake_openai_endpoint
- - run:
- name: Run Docker container
- # intentionally give bad redis credentials here
- # the OTEL test - should get this as a trace
- command: |
- docker run -d \
- -p 4000:4000 \
- -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
- -e REDIS_HOST=$REDIS_HOST \
- -e REDIS_PASSWORD=$REDIS_PASSWORD \
- -e REDIS_PORT=$REDIS_PORT \
- -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
- -e OPENAI_API_KEY=$OPENAI_API_KEY \
- -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
- -e LITELLM_LICENSE=$LITELLM_LICENSE \
- -e OTEL_EXPORTER="in_memory" \
- -e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \
- -e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY \
- -e DEFAULT_NUM_WORKERS_LITELLM_PROXY=1 \
- -e USE_DDTRACE=True \
- -e DD_API_KEY=$DD_API_KEY \
- -e DD_SITE=$DD_SITE \
- -e AWS_REGION_NAME=$AWS_REGION_NAME \
- -e COHERE_API_KEY=$COHERE_API_KEY \
- -e RECORDER_COHERE_BASE_URL=http://host.docker.internal:8090/__recorder_upstream/api.cohere.com \
- -e GCS_FLUSH_INTERVAL="1" \
- -e LITELLM_LOG=ERROR \
- --add-host host.docker.internal:host-gateway \
- --name my-app \
- -v $(pwd)/litellm/proxy/example_config_yaml/otel_test_config.yaml:/app/config.yaml \
- -v $(pwd)/litellm/proxy/example_config_yaml/custom_guardrail.py:/app/custom_guardrail.py \
- litellm-docker-database:ci \
- --config /app/config.yaml \
- --port 4000
- - run:
- name: Start outputting logs
- command: docker logs -f my-app
- background: true
- - wait_for_service:
- url: http://localhost:4000
- timeout: "300"
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/otel_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -v \
- --junitxml=test-results/junit.xml \
- --durations=5"
- no_output_timeout: 15m
- # Clean up first container
- - run:
- name: Stop and remove first container
- command: |
- docker stop my-app
- docker rm my-app
-
# Second Docker Container Run with Different Config
# NOTE: We intentionally pass a "bad" license here. We need to ensure proxy starts and serves request even with bad license
- run:
@@ -1836,7 +719,7 @@ jobs:
name: Run second round of tests
command: |
mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/basic_proxy_startup_tests/**/test_*.py")
+ TEST_FILES=$(circleci tests glob "tests/integration/configuration/test_basic_proxy_startup.py")
echo "$TEST_FILES" | circleci tests run \
--verbose \
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
@@ -1848,385 +731,46 @@ jobs:
# Store test results
- store_test_results:
path: test-results
- proxy_multi_instance_tests:
- machine:
- image: ubuntu-2204:2024.04.1
- resource_class: large
- working_directory: ~/project
- steps:
- - checkout
- - run:
- name: Generate LiteLLM master key
- command: |
- key="$(openssl rand -hex 16)"
- printf 'export LITELLM_MASTER_KEY=sk-%s\n' "$key" >> "$BASH_ENV"
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - start_postgres
- - start_fake_openai_endpoint
- - attach_workspace:
- at: ~/project
- - run:
- name: Load Docker Database Image
- command: |
- zstd -d litellm-docker-database.tar.zst --stdout | docker load
- docker images | grep litellm-docker-database
- - run:
- name: Run Docker container 1
- # intentionally give bad redis credentials here
- # the OTEL test - should get this as a trace
- command: |
- docker run -d \
- -p 4000:4000 \
- -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
- -e REDIS_HOST=$REDIS_HOST \
- -e REDIS_PASSWORD=$REDIS_PASSWORD \
- -e REDIS_PORT=$REDIS_PORT \
- -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
- -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
- -e LITELLM_LICENSE=$LITELLM_LICENSE \
- -e USE_DDTRACE=True \
- -e DD_API_KEY=$DD_API_KEY \
- -e DD_SITE=$DD_SITE \
- -e LITELLM_LOG=ERROR \
- --add-host host.docker.internal:host-gateway \
- --name my-app \
- -v $(pwd)/litellm/proxy/example_config_yaml/multi_instance_simple_config.yaml:/app/config.yaml \
- litellm-docker-database:ci \
- --config /app/config.yaml \
- --port 4000
- - run:
- name: Run Docker container 2
- command: |
- docker run -d \
- -p 4001:4001 \
- -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
- -e REDIS_HOST=$REDIS_HOST \
- -e REDIS_PASSWORD=$REDIS_PASSWORD \
- -e REDIS_PORT=$REDIS_PORT \
- -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
- -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
- -e LITELLM_LICENSE=$LITELLM_LICENSE \
- -e USE_DDTRACE=True \
- -e DD_API_KEY=$DD_API_KEY \
- -e DD_SITE=$DD_SITE \
- -e LITELLM_LOG=ERROR \
- --add-host host.docker.internal:host-gateway \
- --name my-app-2 \
- -v $(pwd)/litellm/proxy/example_config_yaml/multi_instance_simple_config.yaml:/app/config.yaml \
- litellm-docker-database:ci \
- --config /app/config.yaml \
- --port 4001
- - run:
- name: Start outputting logs
- command: docker logs -f my-app
- background: true
- - wait_for_service:
- url: http://localhost:4000
- timeout: "300"
- - wait_for_service:
- url: http://localhost:4001
- timeout: "300"
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/multi_instance_e2e_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv \
- --junitxml=test-results/junit.xml \
- --durations=5"
- no_output_timeout: 15m
- # Clean up first container
- # Store test results
- - store_test_results:
- path: test-results
-
- proxy_store_model_in_db_tests:
- machine:
- image: ubuntu-2204:2024.04.1
- resource_class: large
- working_directory: ~/project
- steps:
- - checkout
- - run:
- name: Generate LiteLLM master key
- command: |
- key="$(openssl rand -hex 16)"
- printf 'export LITELLM_MASTER_KEY=sk-%s\n' "$key" >> "$BASH_ENV"
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - start_postgres
- - start_fake_openai_endpoint
- - start_cost_center_service
- - attach_workspace:
- at: ~/project
- - run:
- name: Load Docker Database Image
- command: |
- zstd -d litellm-docker-database.tar.zst --stdout | docker load
- docker images | grep litellm-docker-database
- - run:
- name: Run Docker container
- # intentionally give bad redis credentials here
- # the OTEL test - should get this as a trace
- command: |
- docker run -d \
- --restart on-failure \
- -p 4000:4000 \
- -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
- -e STORE_MODEL_IN_DB="True" \
- -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
- -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
- -e TEAM_METADATA_VALIDATION_SERVICE_URL=http://host.docker.internal:9414/validate \
- -e LITELLM_LICENSE=$LITELLM_LICENSE \
- -e LITELLM_LOG=ERROR \
- --add-host host.docker.internal:host-gateway \
- --name my-app \
- -v $(pwd)/litellm/proxy/example_config_yaml/store_model_db_config.yaml:/app/config.yaml \
- -v $(pwd)/litellm/proxy/example_config_yaml/team_metadata_validator_e2e.py:/app/team_metadata_validator_e2e.py \
- litellm-docker-database:ci \
- --config /app/config.yaml \
- --port 4000
- - run:
- name: Start outputting logs
- command: docker logs -f my-app
- background: true
- - wait_for_service:
- url: http://localhost:4000
- timeout: "300"
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/store_model_in_db_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv \
- --junitxml=test-results/junit.xml \
- --durations=5"
- no_output_timeout: 15m
- - run:
- name: Stop and remove containers
- command: |
- docker stop my-app || true
- docker rm my-app || true
- docker stop postgres-db || true
- docker rm postgres-db || true
- when: always
- - store_test_results:
- path: test-results
-
- proxy_pass_through_endpoint_tests:
- machine:
- image: ubuntu-2204:2024.04.1
- resource_class: large
- working_directory: ~/project
- steps:
- - checkout
- - run:
- name: Generate LiteLLM master key
- command: |
- key="$(openssl rand -hex 16)"
- printf 'export LITELLM_MASTER_KEY=sk-%s\n' "$key" >> "$BASH_ENV"
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - start_postgres
- - attach_workspace:
- at: ~/project
- - run:
- name: Load Docker Database Image
- command: |
- zstd -d litellm-docker-database.tar.zst --stdout | docker load
- docker images | grep litellm-docker-database
- - run:
- name: Run Docker container
- command: |
- docker run -d \
- -p 4000:4000 \
- -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
- -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
- -e OPENAI_API_KEY=$OPENAI_API_KEY \
- -e GEMINI_API_KEY=$GEMINI_API_KEY \
- -e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \
- -e ASSEMBLYAI_API_KEY=$ASSEMBLYAI_API_KEY \
- -e AZURE_API_KEY=$AZURE_API_KEY \
- -e AZURE_API_BASE=$AZURE_API_BASE \
- -e USE_DDTRACE=True \
- -e DD_API_KEY=$DD_API_KEY \
- -e DD_SITE=$DD_SITE \
- -e LITELLM_LICENSE=$LITELLM_LICENSE \
- -e LITELLM_USE_CHAT_COMPLETIONS_URL_FOR_ANTHROPIC_MESSAGES=true \
- -e LITELLM_LOG=ERROR \
- --add-host host.docker.internal:host-gateway \
- --name my-app \
- -v $(pwd)/litellm/proxy/example_config_yaml/pass_through_config.yaml:/app/config.yaml \
- -v $(pwd)/litellm/proxy/example_config_yaml/custom_auth_basic.py:/app/custom_auth_basic.py \
- litellm-docker-database:ci \
- --config /app/config.yaml \
- --port 4000
- - run:
- name: Start outputting logs
- command: docker logs -f my-app
- background: true
- - wait_for_service:
- url: http://localhost:4000
- timeout: "300"
- # Install Node.js directly from nodejs.org with SHA256 verification,
- # instead of piping NodeSource's setup_24.x apt-repo installer into
- # sudo bash (which runs a mutable upstream script unattended).
- - install_node
-
- - run:
- name: Install Node.js test dependencies
- command: |
- cd tests/pass_through_tests
- npm ci
-
- - run:
- name: Run Vertex AI, Google AI Studio Node.js tests
- command: |
- cd tests/pass_through_tests
- NODE_OPTIONS=--experimental-vm-modules npx jest . --verbose
- no_output_timeout: 30m
- - run:
- name: Run tests
- command: |
- mkdir -p test-results
- TEST_FILES=$(circleci tests glob "tests/pass_through_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -v \
- --junitxml=test-results/junit.xml \
- --durations=5"
- no_output_timeout: 15m
-
- # Store test results
- - store_test_results:
- path: test-results
-
- proxy_e2e_anthropic_messages_tests:
- machine:
- image: ubuntu-2204:2024.04.1
- resource_class: large
- working_directory: ~/project
- steps:
- - checkout
- - run:
- name: Generate LiteLLM master key
- command: |
- key="$(openssl rand -hex 16)"
- printf 'export LITELLM_MASTER_KEY=sk-%s\n' "$key" >> "$BASH_ENV"
- - skip_if_unrelated_changes
- - setup_google_dns
- - install_uv
- - install_rust
- - run:
- name: Install Dependencies
- command: |
- uv sync --frozen --all-groups --all-extras --python 3.12
- - start_postgres
- - attach_workspace:
- at: ~/project
- - run:
- name: Load Docker Database Image
- command: |
- zstd -d litellm-docker-database.tar.zst --stdout | docker load
- docker images | grep litellm-docker-database
- - start_openai_record_replay_proxy
- - run:
- name: Run Docker container with test config
- command: |
- docker run -d \
- -p 4000:4000 \
- -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
- -e LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" \
- -e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \
- -e RECORDER_ANTHROPIC_BASE_URL=http://host.docker.internal:8090/__recorder_upstream/api.anthropic.com \
- -e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \
- -e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY \
- -e AWS_REGION_NAME="us-east-1" \
- -e LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS="True" \
- -e LITELLM_LOG=ERROR \
- --add-host host.docker.internal:host-gateway \
- --name my-app \
- -v $(pwd)/tests/proxy_e2e_anthropic_messages_tests/test_config.yaml:/app/config.yaml \
- litellm-docker-database:ci \
- --config /app/config.yaml \
- --port 4000
- - run:
- name: Start outputting logs
- command: docker logs -f my-app
- background: true
- - wait_for_service:
- url: http://localhost:4000
- timeout: "300"
- - run:
- name: Run Claude Agent SDK E2E Tests
- command: |
- mkdir -p test-results
- export LITELLM_PROXY_URL="http://localhost:4000"
- export LITELLM_API_KEY="$LITELLM_MASTER_KEY"
- TEST_FILES=$(circleci tests glob "tests/proxy_e2e_anthropic_messages_tests/**/test_*.py")
- echo "$TEST_FILES" | circleci tests run \
- --verbose \
- --command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest --tb=short \
- -vv -s \
- --junitxml=test-results/junit.xml \
- --durations=5"
- no_output_timeout: 15m
-
- # Store test results
- - store_test_results:
- path: test-results
-
- upload-coverage:
+ e2e_ui_build:
docker:
- - *python312_image
+ - image: cimg/python:3.12-browsers@sha256:b432899af01c9a311bf74f4f22e9ada2e5306d4b1b4383f8d29e1228a5844ef2
+ auth:
+ username: ${DOCKERHUB_USERNAME}
+ password: ${DOCKERHUB_PASSWORD}
+ resource_class: large
+ working_directory: ~/project
steps:
- checkout
- - skip_if_unrelated_changes
- - attach_workspace:
- at: .
- # Check file locations
+ - skip_if_unrelated_changes:
+ category: client
+ - setup_google_dns
+ - install_node
+ - restore_cache:
+ keys:
+ - ui-dashboard-node-deps-v1-{{ checksum "ui/litellm-dashboard/package-lock.json" }}
- run:
- name: Check coverage file location
+ name: Install dashboard dependencies
command: |
- echo "Current directory:"
- ls -la
- echo "\nContents of tests/llm_translation:"
- ls -la tests/llm_translation
- - install_uv
+ cd ui/litellm-dashboard
+ npm ci
+ - save_cache:
+ key: ui-dashboard-node-deps-v1-{{ checksum "ui/litellm-dashboard/package-lock.json" }}
+ paths:
+ - ui/litellm-dashboard/node_modules
- run:
- name: Combine Coverage
+ name: Build UI bundle
command: |
- uv tool run --from 'coverage[toml]==7.10.6' coverage combine realtime_translation_coverage ocr_coverage search_coverage logging_coverage audio_coverage local_testing_part1_coverage local_testing_part2_coverage pass_through_unit_tests_coverage batches_coverage google_generate_content_endpoint_coverage litellm_utils_coverage
- uv tool run --from 'coverage[toml]==7.10.6' coverage xml
- - codecov/upload:
- file: ./coverage.xml
- flags: circleci
+ cd ui/litellm-dashboard
+ npm run build
+ mkdir -p ../../ui-bundle
+ mv out ../../ui-bundle/out
+ find ../../ui-bundle/out -name '*.html' ! -name 'index.html' | while read -r f; do
+ d="${f%.html}"; mkdir -p "$d"; mv "$f" "$d/index.html"
+ done
+ - persist_to_workspace:
+ root: ~/project
+ paths:
+ - ui-bundle/out
e2e_ui_testing:
docker:
@@ -2239,7 +783,7 @@ jobs:
POSTGRES_USER: e2euser
POSTGRES_PASSWORD: e2epassword
POSTGRES_DB: litellm_e2e
- resource_class: large
+ resource_class: xlarge
working_directory: ~/project
environment:
DATABASE_URL: "postgresql://e2euser:e2epassword@localhost:5432/litellm_e2e"
@@ -2258,6 +802,8 @@ jobs:
printf 'export LITELLM_MASTER_KEY=sk-%s\n' "$key" >> "$BASH_ENV"
- skip_if_unrelated_changes:
category: client
+ - attach_workspace:
+ at: ~/project
- setup_google_dns
- install_node
- install_uv
@@ -2276,7 +822,7 @@ jobs:
- ~/.cache/uv
- restore_cache:
keys:
- - ui-e2e-node-deps-v4-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
+ - ui-playwright-node-deps-v1-{{ checksum "tests/e2e/ui/package-lock.json" }}
- run:
name: Install Node dependencies and Playwright
# The cimg/python:3.12-browsers image already ships the Chromium system
@@ -2285,36 +831,19 @@ jobs:
# 5-10 minutes to the job and frequently stalls on flaky Ubuntu mirrors,
# so we install just the browser binary.
command: |
- cd ui/litellm-dashboard
- npm ci
- cd ../../tests/e2e/ui
+ cd tests/e2e/ui
npm ci
npx playwright install chromium
- save_cache:
- key: ui-e2e-node-deps-v4-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
+ key: ui-playwright-node-deps-v1-{{ checksum "tests/e2e/ui/package-lock.json" }}
paths:
- - ui/litellm-dashboard/node_modules
- tests/e2e/ui/node_modules
- ~/.cache/ms-playwright
- run:
- name: Build UI from source
- # Prior version used `cp -r out/ ../../litellm/proxy/_experimental/out/`.
- # GNU cp (used on CircleCI's Ubuntu image) interprets that as "copy the
- # source directory as a child of the destination" when the destination
- # already exists — silently creating `_experimental/out/out/` instead of
- # replacing the served bundle. The proxy continued serving whatever was
- # checked into `_experimental/out/*`, so this job was effectively testing
- # the pre-build bundle on every run. Replace-and-move guarantees the
- # freshly built bundle is what the proxy actually serves.
+ name: Use built UI bundle
command: |
- cd ui/litellm-dashboard
- npm run build
- rm -rf ../../litellm/proxy/_experimental/out
- mv out ../../litellm/proxy/_experimental/out
- # Restructure HTML so extensionless routes work (login.html -> login/index.html)
- find ../../litellm/proxy/_experimental/out -name '*.html' ! -name 'index.html' | while read -r f; do
- d="${f%.html}"; mkdir -p "$d"; mv "$f" "$d/index.html"
- done
+ rm -rf litellm/proxy/_experimental/out
+ cp -r ui-bundle/out litellm/proxy/_experimental/out
- wait_for_service:
url: tcp://localhost:5432
timeout: "30"
@@ -2381,7 +910,7 @@ jobs:
command: |
cd tests/e2e/ui
LITELLM_LICENSE="$LITELLM_LICENSE" \
- npx playwright test --config playwright.config.ts
+ npx playwright test --config playwright.config.ts --workers=3 --retries=0
no_output_timeout: 10m
- store_artifacts:
path: tests/e2e/ui/test-results
@@ -2419,6 +948,8 @@ jobs:
printf 'export LITELLM_MASTER_KEY=sk-%s\n' "$key" >> "$BASH_ENV"
- skip_if_unrelated_changes:
category: client
+ - attach_workspace:
+ at: ~/project
- setup_google_dns
- install_node
- install_uv
@@ -2437,31 +968,23 @@ jobs:
- ~/.cache/uv
- restore_cache:
keys:
- - ui-e2e-node-deps-v4-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
+ - ui-playwright-node-deps-v1-{{ checksum "tests/e2e/ui/package-lock.json" }}
- run:
name: Install Node dependencies and Playwright
command: |
- cd ui/litellm-dashboard
- npm ci
- cd ../../tests/e2e/ui
+ cd tests/e2e/ui
npm ci
npx playwright install chromium
- save_cache:
- key: ui-e2e-node-deps-v4-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
+ key: ui-playwright-node-deps-v1-{{ checksum "tests/e2e/ui/package-lock.json" }}
paths:
- - ui/litellm-dashboard/node_modules
- tests/e2e/ui/node_modules
- ~/.cache/ms-playwright
- run:
- name: Build UI from source
+ name: Use built UI bundle
command: |
- cd ui/litellm-dashboard
- npm run build
- rm -rf ../../litellm/proxy/_experimental/out
- mv out ../../litellm/proxy/_experimental/out
- find ../../litellm/proxy/_experimental/out -name '*.html' ! -name 'index.html' | while read -r f; do
- d="${f%.html}"; mkdir -p "$d"; mv "$f" "$d/index.html"
- done
+ rm -rf litellm/proxy/_experimental/out
+ cp -r ui-bundle/out litellm/proxy/_experimental/out
- wait_for_service:
url: tcp://localhost:5432
timeout: "30"
@@ -2712,41 +1235,6 @@ jobs:
! docker exec my-app true 2>/dev/null || fail "container is still running"
echo "Proxy refused to serve (exit $exit_code) and never reached startup. Test passed."
- provider_replay_harness:
- docker:
- - *python312_image
- - image: redis@sha256:e2debfb7956fa12c7ddc79d7e645c8cf26b30c99a6e9161ea9bf4171e1668a5f
- working_directory: ~/project
- resource_class: medium
- environment:
- E2E_CACHE_TEST_REDIS_URL: redis://127.0.0.1:6379/0
- E2E_PROVIDER_CACHE: "0"
- E2E_FIXTURE_MODE: live
- steps:
- - checkout
- - run:
- name: Generate LiteLLM master key
- command: |
- key="$(openssl rand -hex 16)"
- printf 'export LITELLM_MASTER_KEY=sk-%s\n' "$key" >> "$BASH_ENV"
- - skip_if_unrelated_changes:
- category: provider-harness
- - setup_litellm_test_deps
- - wait_for_service:
- url: tcp://localhost:6379
- - run:
- name: Test provider capture and replay harness
- command: |
- mkdir -p test-results/provider-replay-harness
- uv run --no-sync pytest --tb=short -q --noconftest -o addopts= -o "pythonpath=tests/e2e tests/e2e_harness" -p no:rerunfailures \
- --junitxml=test-results/provider-replay-harness/junit.xml \
- tests/e2e_harness/test_provider_edge.py tests/e2e_harness/test_fixture_bundle.py \
- tests/e2e_harness/test_fixture_canonical.py tests/e2e_harness/test_fixture_mode.py \
- tests/code_coverage_tests/test_provider_replay_harness.py \
- tests/code_coverage_tests/test_provider_cache.py
- - store_test_results:
- path: test-results/provider-replay-harness
-
integration_contracts:
parameters:
suite:
@@ -2795,9 +1283,19 @@ jobs:
- run:
name: Build the candidate dashboard
command: cd ui/litellm-dashboard && NEXT_TELEMETRY_DISABLED=1 npm run build
+ - when:
+ condition:
+ equal: [mcp, << parameters.suite >>]
+ steps:
+ - run:
+ name: Install MCP SDK1 peer
+ command: |
+ uv venv --python 3.12 .venv-mcp-peer
+ uv pip install --python .venv-mcp-peer 'mcp==1.28.1' 'langchain-mcp-adapters==0.2.1'
+ echo "export MCP_TEST_PEER_PYTHON=$PWD/.venv-mcp-peer/bin/python" >> "$BASH_ENV"
- start_postgres:
image: postgres:16@sha256:e17e86066e5ef83e0952a9347f5c792b7ece00972e2aa787a6986f471b3dd3d5
- server_args: "-c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c pg_stat_statements.max=20000"
+ server_args: "-c max_connections=300 -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c pg_stat_statements.max=20000"
- start_redis
- run:
name: Run owned integration contracts
@@ -2850,12 +1348,27 @@ jobs:
default: standard
shards:
type: integer
- machine:
- image: ubuntu-2204:2024.04.1
- resource_class: medium
+ docker:
+ - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c
+ auth:
+ username: ${DOCKERHUB_USERNAME}
+ password: ${DOCKERHUB_PASSWORD}
+ resource_class: small
working_directory: ~/project
+ environment:
+ UV_PYTHON_DOWNLOADS: never
steps:
- - setup_litellm_test_deps
+ - checkout
+ - install_uv
+ - run:
+ name: Install the locked shard verifier and coverage dependencies
+ command: |
+ uv export --frozen --all-groups --all-extras --no-emit-project --no-emit-workspace --no-header --no-annotate \
+ | awk '/^[^ ]/ { keep = $0 ~ /^(annotated-types|coverage|pydantic|pydantic-core|typing-extensions|typing-inspection)==/ } keep' \
+ > /tmp/fan-in-requirements.txt
+ test "$(grep -c '^[^ ]' /tmp/fan-in-requirements.txt)" -eq 6
+ uv venv .venv --python 3.12
+ uv pip install --no-config --python .venv --require-hashes -r /tmp/fan-in-requirements.txt
- attach_workspace:
at: /tmp/integration-shards
- run:
@@ -2971,7 +1484,8 @@ jobs:
steps:
- checkout
- skip_if_unrelated_changes
- - setup_litellm_test_deps
+ - setup_litellm_test_deps:
+ checkout: false
- start_postgres:
db_name: litellm_test
image: postgres:16@sha256:e17e86066e5ef83e0952a9347f5c792b7ece00972e2aa787a6986f471b3dd3d5
@@ -3016,112 +1530,15 @@ jobs:
- store_artifacts:
path: test-results
- mcp_integration:
- machine:
- image: ubuntu-2204:2024.04.1
- resource_class: large
- working_directory: ~/project
- environment:
- COVERAGE_CORE: sysmon
- LITELLM_LOCAL_MODEL_COST_MAP: "True"
- steps:
- - checkout
- - skip_if_unrelated_changes
- - setup_litellm_test_deps
- - run:
- name: Generate Prisma client
- command: uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
- - run:
- name: Install MCP SDK1 peer
- command: |
- uv venv --python 3.12 .venv-mcp-peer
- uv pip install --python .venv-mcp-peer 'mcp==1.28.1' 'langchain-mcp-adapters==0.2.1'
- echo "export MCP_TEST_PEER_PYTHON=$PWD/.venv-mcp-peer/bin/python" >> "$BASH_ENV"
- - run:
- name: Run MCP integration tests
- command: |
- mkdir -p test-results
- env -u OPENAI_API_KEY -u ANTHROPIC_API_KEY \
- timeout --signal=TERM 20m uv run --no-sync pytest \
- tests/mcp_tests tests/unit/experimental_mcp_client tests/unit/proxy/_experimental/mcp_server \
- tests/unit/responses/mcp --tb=short -vv --maxfail=10 -n 2 --dist=loadscope --reruns 0 \
- --reruns-delay 1 --timeout=120 --rerun-except "from pytest-timeout" --durations=20 \
- --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml:coverage.xml \
- --cov-config=pyproject.toml -o junit_family=xunit1 --junitxml=test-results/junit.xml
- - install_codecov_cli
- - run:
- name: Upload MCP integration coverage
- when: always
- command: |
- [ -f coverage.xml ] || { echo "no coverage.xml produced"; exit 1; }
- codecov upload-process --disable-search --fail-on-error -f coverage.xml \
- -F mcp-integration -C "$CIRCLE_SHA1" \
- -n "mcp-integration-${CIRCLE_BUILD_NUM}" --git-service github
- - store_test_results:
- path: test-results
- - store_artifacts:
- path: test-results
-
- redis_compat:
- parameters:
- redis_py:
- type: string
- machine:
- image: ubuntu-2204:2024.04.1
- resource_class: large
- working_directory: ~/project
- steps:
- - checkout
- - skip_if_unrelated_changes:
- category: redis-compat
- - setup_litellm_test_deps
- - run:
- name: Pin redis-py version
- command: |
- uv pip install "redis==<< parameters.redis_py >>"
- uv run --no-sync python -c "import redis; assert redis.__version__ == '<< parameters.redis_py >>', redis.__version__; print('redis-py', redis.__version__)"
- - run:
- name: Install redis-server 7.2.16
- command: |
- mkdir -p "$HOME/.local/bin"
- cid="$(docker create redis:7.2.16@sha256:0637954999d01b7c9ce9167db2da50656e2590d3b884f1c600c5f63bb6e6773c)"
- docker cp "${cid}":/usr/local/bin/redis-server "$HOME/.local/bin/redis-server"
- docker rm "$cid"
- redis-server --version | grep -q 'v=7.2.16'
- - run:
- name: Run Redis compatibility tests
- command: |
- mkdir -p test-results
- env -u CASSETTE_REDIS_URL -u AZURE_CLIENT_ID -u AZURE_CLIENT_SECRET -u AZURE_TENANT_ID \
- timeout --signal=TERM 15m uv run --no-sync pytest \
- tests/unit/test_redis.py tests/unit/caching/test_redis_connection_pool.py \
- tests/unit/caching/test_redis_cluster_cache.py tests/unit/caching/test_evicted_client_closer.py \
- tests/integration/sdk/test_redis_cluster_iam_auth.py::test_sync_cluster_authenticates_with_azure_credentials \
- tests/integration/sdk/test_redis_cluster_iam_auth.py::test_sync_cluster_authenticates_with_gcp_credentials \
- --tb=short -vv --reruns 2 --reruns-delay 1 --durations=20 --cov=./litellm \
- --cov-report=xml:coverage.xml -o junit_family=xunit1 --junitxml=test-results/junit.xml
- - when:
- condition:
- equal: ["5.3.1", << parameters.redis_py >>]
- steps:
- - install_codecov_cli
- - run:
- name: Upload Redis compatibility coverage
- when: always
- command: |
- [ -f coverage.xml ] || { echo "no coverage.xml produced"; exit 1; }
- codecov upload-process --disable-search --fail-on-error -f coverage.xml \
- -F redis-compat -C "$CIRCLE_SHA1" \
- -n "redis-compat-${CIRCLE_BUILD_NUM}" --git-service github
- - store_test_results:
- path: test-results
- - store_artifacts:
- path: test-results
-
workflows:
- migration_startup:
- when: << pipeline.parameters.run_migration_tests >>
- jobs: &migration_jobs
+ migration_startup_scheduled:
+ triggers:
+ - schedule:
+ cron: "17 0,6,12,18 * * *"
+ filters:
+ branches:
+ only: main
+ jobs:
- build_docker_database_image:
migration_qualification: true
- migration_startup_tests:
@@ -3146,33 +1563,11 @@ workflows:
suite: shaped
baseline: true
requires: [build_docker_database_image]
- migration_startup_scheduled:
- triggers:
- - schedule:
- cron: "17 0,6,12,18 * * *"
- filters:
- branches:
- only: main
- jobs: *migration_jobs
- routing_parity:
- when:
- not:
- equal: ["", << pipeline.parameters.routing_parity_base >>]
- jobs:
- - routing_parity:
- name: routing-parity-<< matrix.suite >>
- matrix:
- parameters:
- suite: [management, accounting, database, providers, extensions, cost, mcp]
integration:
- unless:
- or:
- - << pipeline.parameters.run_migration_tests >>
- - not:
- equal: ["", << pipeline.parameters.routing_parity_base >>]
jobs:
- integration_contracts:
name: integration-<< matrix.suite >>
+ filters: &integration_default_run not pipeline.parameters.run_migration_tests and pipeline.parameters.routing_parity_base == ""
matrix:
parameters:
suite: [sdk, cost, browser]
@@ -3182,192 +1577,178 @@ workflows:
suite: security
parallelism: 1
resource_class: xlarge
+ filters: *integration_default_run
- integration_contracts:
name: integration-management
suite: management
parallelism: 4
+ resource_class: xlarge
+ filters: *integration_default_run
- integration_shard_coverage:
name: integration-management-coverage
suite: management
shards: 4
requires: [integration-management]
+ filters: *integration_default_run
- integration_contracts:
name: integration-accounting
suite: accounting
parallelism: 4
+ resource_class: xlarge
+ filters: *integration_default_run
- integration_shard_coverage:
name: integration-accounting-coverage
suite: accounting
shards: 4
requires: [integration-accounting]
+ filters: *integration_default_run
- integration_contracts:
name: integration-providers
suite: providers
- parallelism: 8
+ parallelism: 4
+ resource_class: xlarge
+ filters: *integration_default_run
- integration_shard_coverage:
name: integration-providers-coverage
suite: providers
- shards: 8
+ shards: 4
requires:
- integration-providers
+ filters: *integration_default_run
- integration_contracts:
name: integration-database
suite: database
parallelism: 2
+ filters: *integration_default_run
- integration_shard_coverage:
name: integration-database-coverage
suite: database
shards: 2
requires: [integration-database]
+ filters: *integration_default_run
- integration_contracts:
name: integration-mcp
suite: mcp
- resource_class: xlarge
parallelism: 2
+ filters: *integration_default_run
- integration_shard_coverage:
name: integration-mcp-coverage
suite: mcp
shards: 2
requires:
- integration-mcp
+ filters: *integration_default_run
- integration_contracts:
name: integration-extensions
suite: extensions
parallelism: 8
+ resource_class: xlarge
+ filters: *integration_default_run
- integration_shard_coverage:
name: integration-extensions-coverage
suite: extensions
shards: 8
requires:
- integration-extensions
+ filters: *integration_default_run
- integration_contracts:
name: integration-management-replica
suite: management
mode: replica
parallelism: 4
+ resource_class: xlarge
+ filters: *integration_default_run
- integration_shard_coverage:
name: integration-management-replica-coverage
suite: management
mode: replica
shards: 4
requires: [integration-management-replica]
+ filters: *integration_default_run
- integration_contracts:
name: integration-database-replica
suite: database
mode: replica
parallelism: 2
+ filters: *integration_default_run
- integration_shard_coverage:
name: integration-database-replica-coverage
suite: database
mode: replica
shards: 2
requires: [integration-database-replica]
- - postgres_suite:
- name: proxy-behavior
- test_path: tests/proxy_behavior
- seed: true
- coverage_flag: lens-postgres
- timeout_minutes: 25
- - postgres_suite:
- name: proxy-security
- test_path: tests/proxy_security_tests
- seed: true
- timeout_minutes: 15
- - postgres_suite:
- name: schema-migration
- test_path: tests/proxy_migration_tests
- seed: false
- timeout_minutes: 20
- workers: 2
+ filters: *integration_default_run
- postgres_suite:
name: roi-database
test_path: tests/integration/database/test_roi_observed.py
seed: false
coverage_flag: roi-postgres
timeout_minutes: 10
- - mcp_integration:
- name: mcp-integration
- - redis_compat:
- name: redis-compat-<< matrix.redis_py >>
- matrix:
- parameters:
- redis_py:
- - "5.3.1"
- - "6.4.0"
- - "7.4.1"
- - "8.0.1"
- - windows_release_wheel
- - base_sdk_install
- build_and_test:
- unless:
- or:
- - << pipeline.parameters.run_migration_tests >>
- - not:
- equal: ["", << pipeline.parameters.routing_parity_base >>]
- jobs:
- - provider_replay_harness
- - local_testing_part1
- - local_testing_part2
- - langfuse_logging_unit_tests
- - litellm_router_testing
- - auth_ui_unit_tests
- - build_docker_database_image
- - e2e_ui_testing
- - e2e_ui_testing_server_root_path
- - build_and_test:
- requires:
- - build_docker_database_image
- - e2e_openai_endpoints:
- requires:
- - build_docker_database_image
+ filters: *integration_default_run
+ - windows_release_wheel:
+ filters: *integration_default_run
+ - base_sdk_install:
+ filters: *integration_default_run
+ - build_docker_database_image:
+ filters: *integration_default_run
- proxy_logging_guardrails_model_info_tests:
requires:
- build_docker_database_image
- - proxy_multi_instance_tests:
- requires:
- - build_docker_database_image
- - proxy_store_model_in_db_tests:
- requires:
- - build_docker_database_image
- - proxy_pass_through_endpoint_tests:
- requires:
- - build_docker_database_image
- - proxy_e2e_anthropic_messages_tests:
- requires:
- - build_docker_database_image
- - llm_translation_testing
- - realtime_translation_testing
- - guardrails_testing
- - google_generate_content_endpoint_testing
- - ocr_testing
- - search_testing
- - batches_testing
- - litellm_utils_testing
- - pass_through_unit_testing
- - image_gen_testing
- - logging_testing
- - audio_testing
- - upload-coverage:
- requires:
- - realtime_translation_testing
- - google_generate_content_endpoint_testing
- - ocr_testing
- - search_testing
- - batches_testing
- - litellm_utils_testing
- - pass_through_unit_testing
- - image_gen_testing
- - logging_testing
- - audio_testing
- - langfuse_logging_unit_tests
- - local_testing_part1
- - local_testing_part2
+ filters: *integration_default_run
- db_migration_disable_update_check:
requires:
- build_docker_database_image
- - helm_chart_testing:
- requires:
- - build_docker_database_image
+ filters: *integration_default_run
- test_bad_database_url:
requires:
- build_docker_database_image
+ filters: *integration_default_run
+ - helm_chart_testing:
+ requires:
+ - build_docker_database_image
+ filters: *integration_default_run
+ - e2e_ui_build:
+ filters: *integration_default_run
+ - e2e_ui_testing:
+ requires: [e2e_ui_build]
+ filters: *integration_default_run
+ - e2e_ui_testing_server_root_path:
+ requires: [e2e_ui_build]
+ filters: *integration_default_run
+ - build_docker_database_image:
+ name: build_docker_database_image-migration
+ migration_qualification: true
+ filters: pipeline.parameters.run_migration_tests
+ - migration_startup_tests:
+ name: migration-startup
+ suite: startup
+ requires: [build_docker_database_image-migration]
+ filters: pipeline.parameters.run_migration_tests
+ - migration_startup_tests:
+ name: migration-recovery
+ suite: recovery
+ requires: [build_docker_database_image-migration]
+ filters: pipeline.parameters.run_migration_tests
+ - migration_startup_tests:
+ name: migration-legacy-and-pooling
+ suite: legacy
+ requires: [build_docker_database_image-migration]
+ filters: pipeline.parameters.run_migration_tests
+ - migration_startup_tests:
+ name: migration-upgrade
+ suite: upgrade
+ baseline: true
+ requires: [build_docker_database_image-migration]
+ filters: pipeline.parameters.run_migration_tests
+ - migration_startup_tests:
+ name: migration-upgrade-shaped
+ suite: shaped
+ baseline: true
+ requires: [build_docker_database_image-migration]
+ filters: pipeline.parameters.run_migration_tests
+ - routing_parity:
+ name: routing-parity-<< matrix.suite >>
+ matrix:
+ parameters:
+ suite: [management, accounting, database, providers, extensions, cost, mcp]
+ filters: pipeline.parameters.routing_parity_base != ""
diff --git a/.circleci/scripts/classify_changes.sh b/.circleci/scripts/classify_changes.sh
index 9fe2e99fb93..d2ab3478397 100755
--- a/.circleci/scripts/classify_changes.sh
+++ b/.circleci/scripts/classify_changes.sh
@@ -1,12 +1,11 @@
#!/usr/bin/env bash
set -uo pipefail
-category="${1:?usage: classify_changes.sh }"
+category="${1:?usage: classify_changes.sh }"
has_client=false
has_backend=false
has_ci=false
-has_provider_harness=false
has_cost_map=false
has_mcp_dependencies=false
has_windows_release=false
@@ -15,7 +14,7 @@ outside_cost_map_set=false
while IFS= read -r file || [ -n "$file" ]; do
[ -n "$file" ] || continue
case "$file" in
- litellm/_redis.py | litellm/_redis_credential_provider.py | litellm/caching/redis_cache.py | litellm/caching/evicted_client_closer.py | tests/unit/test_redis.py | tests/integration/sdk/test_redis_cluster_iam_auth.py | tests/unit/caching/test_redis_connection_pool.py | tests/unit/caching/test_redis_cluster_cache.py | tests/unit/caching/test_evicted_client_closer.py | .circleci/config.yml | .circleci/scripts/classify_changes.sh | .circleci/scripts/path_filter.sh | pyproject.toml | uv.lock)
+ litellm/_redis.py | litellm/_redis_credential_provider.py | litellm/caching/redis_cache.py | litellm/caching/evicted_client_closer.py | tests/unit/test_redis.py | tests/integration/sdk/test_redis_cluster_iam_auth.py | tests/unit/caching/test_redis_connection_pool.py | tests/unit/caching/test_redis_cluster_cache.py | tests/unit/caching/test_evicted_client_closer.py | .github/workflows/test-unit.yml | .github/actions/detect-changes/* | .github/scripts/detect_changes.sh | .circleci/scripts/classify_changes.sh | tests/integration/run.py | pyproject.toml | uv.lock)
has_redis_compat=true ;;
esac
case "$file" in
@@ -23,11 +22,6 @@ while IFS= read -r file || [ -n "$file" ]; do
pyproject.toml | */pyproject.toml | uv.lock | uv.toml | .python-version | rust-toolchain.toml | litellm-rust/* | litellm/__init__.py | litellm/proxy/proxy_server.py | litellm/*mcp* | tests/*mcp* | litellm/integrations/arize/* | tests/base_sdk_tests/* | scripts/check_mcp_sdk_install.py | .github/workflows/test-mcp-dependency-resolution.yml | .github/actions/detect-changes/* | .github/actions/setup-uv-with-retries/* | .github/actions/cache-cargo-build/* | .github/scripts/detect_changes.sh | .github/scripts/uv_sync_with_retries.sh | .circleci/scripts/classify_changes.sh | tests/unit/test_circleci_path_filter.py | tests/unit/test_detect_changes.py)
has_mcp_dependencies=true ;;
esac
- case "$file" in
- tests/e2e/*/*.py | tests/e2e_harness/*/*.py) : ;;
- tests/e2e/*.py | tests/e2e_harness/*.py | tests/code_coverage_tests/test_provider_cache.py | tests/code_coverage_tests/test_provider_replay_harness.py | tests/unit/test_circleci_path_filter.py | .circleci/* | pyproject.toml | uv.lock)
- has_provider_harness=true ;;
- esac
case "$file" in
litellm-rust/* | litellm/rust_bridge/* | rust-toolchain.toml | pyproject.toml | uv.lock | tests/windows_tests/* | .circleci/*)
has_windows_release=true ;;
@@ -41,7 +35,7 @@ while IFS= read -r file || [ -n "$file" ]; do
case "$file" in
model_prices_and_context_window.json | litellm/model_prices_and_context_window_backup.json | model_prices_and_context_window.schema.json)
has_cost_map=true ;;
- tests/test_litellm/* | tests/proxy_unit_tests/* | tests/unit/proxy/*) : ;;
+ tests/unit/proxy/*) : ;;
*) outside_cost_map_set=true ;;
esac
done
@@ -53,9 +47,6 @@ case "$category" in
cost-map-only)
{ [ "$has_cost_map" = true ] && [ "$outside_cost_map_set" = false ]; } && echo run || echo skip
;;
- provider-harness)
- [ "$has_provider_harness" = true ] && echo run || echo skip
- ;;
windows-release)
[ "$has_windows_release" = true ] && echo run || echo skip
;;
diff --git a/.circleci/scripts/path_filter.sh b/.circleci/scripts/path_filter.sh
index ccc68a0750b..c3cf6e566ff 100755
--- a/.circleci/scripts/path_filter.sh
+++ b/.circleci/scripts/path_filter.sh
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -uo pipefail
-category="${1:?usage: path_filter.sh }"
+category="${1:?usage: path_filter.sh }"
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
run_full() {
diff --git a/.circleci/scripts/run_integration.sh b/.circleci/scripts/run_integration.sh
index 28d1fba68c7..be7e70d491c 100644
--- a/.circleci/scripts/run_integration.sh
+++ b/.circleci/scripts/run_integration.sh
@@ -128,9 +128,14 @@ fi
sudo iptables -L integration_only -n -v -x > "$results/egress-guard.txt"
awk '$3 == "REJECT" && $1 > 0 { rejected=1 } END { exit !rejected }' "$results/egress-guard.txt"
-setsid env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" INTEGRATION_RUN_ID="$integration_identity" \
- .venv/bin/python -m integration._support.upstream > "$results/upstream.log" 2>&1 &
-upstream_pid=$!
+if [ "$mode" = parity ]; then
+ export INTEGRATION_WORKERS=1
+else
+ case "$suite" in
+ management|accounting) export INTEGRATION_WORKERS=2 ;;
+ providers|extensions) export INTEGRATION_WORKERS=4 ;;
+ esac
+fi
if [ "$suite" = cost ]; then
export INTEGRATION_WORKERS=8
fi
@@ -138,7 +143,16 @@ if [ "$suite" = security ]; then
export INTEGRATION_WORKERS=4
fi
if [ "$suite" = mcp ]; then
- export INTEGRATION_WORKERS=4 INTEGRATION_COVERAGE=1
+ export INTEGRATION_WORKERS=2 INTEGRATION_COVERAGE=1
+fi
+isolated_workers=false
+if [ "${INTEGRATION_WORKERS:-1}" -gt 1 ]; then
+ isolated_workers=true
+fi
+if [ "$isolated_workers" = false ]; then
+ setsid env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" INTEGRATION_RUN_ID="$integration_identity" \
+ .venv/bin/python -m integration._support.upstream > "$results/upstream.log" 2>&1 &
+ upstream_pid=$!
fi
coverage_data="$PWD/$results/coverage/data"
proxy_command=(.venv/bin/python -m integration._support.proxy)
@@ -180,17 +194,19 @@ start_proxy() {
> "$results/$log_name" 2>&1 &
launched_pid=$!
}
-start_proxy 4000 proxy.log
-proxy_pid="$launched_pid"
-.venv/bin/python .circleci/scripts/wait_integration_services.py
-curl --noproxy '*' -sSf -X POST "$INTEGRATION_PROXY_URL/config/update" \
- -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' \
- -d '{"router_settings": {"num_retries": 0}}' > "$results/seed-router-settings.json"
-if [ "$suite" = management ] || [ "$suite" = mcp ]; then
- export INTEGRATION_PEER_URL=http://127.0.0.1:4001
- start_proxy 4001 peer.log
- peer_pid="$launched_pid"
+if [ "$isolated_workers" = false ]; then
+ start_proxy 4000 proxy.log
+ proxy_pid="$launched_pid"
.venv/bin/python .circleci/scripts/wait_integration_services.py
+ curl --noproxy '*' -sSf -X POST "$INTEGRATION_PROXY_URL/config/update" \
+ -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' \
+ -d '{"router_settings": {"num_retries": 0}}' > "$results/seed-router-settings.json"
+ if [ "$suite" = management ] || [ "$suite" = mcp ]; then
+ export INTEGRATION_PEER_URL=http://127.0.0.1:4001
+ start_proxy 4001 peer.log
+ peer_pid="$launched_pid"
+ .venv/bin/python .circleci/scripts/wait_integration_services.py
+ fi
fi
if [ "$suite" = providers ]; then
@@ -223,13 +239,24 @@ if [ "${CIRCLE_NODE_TOTAL:-1}" -gt 1 ]; then
shard_arguments=(--shard-files "$results/node-files.txt")
fi
+shared_services=()
+worker_coverage=()
+if [ "$isolated_workers" = false ]; then
+ shared_services=(
+ "REDIS_HOST=$REDIS_HOST" "REDIS_PORT=$REDIS_PORT"
+ "INTEGRATION_PROXY_URL=$INTEGRATION_PROXY_URL" "INTEGRATION_PEER_URL=$INTEGRATION_PEER_URL"
+ "INTEGRATION_UPSTREAM_URL=$INTEGRATION_UPSTREAM_URL" "INTEGRATION_MASTER_KEY=$INTEGRATION_MASTER_KEY"
+ )
+elif [ "${INTEGRATION_COVERAGE:-0}" = 1 ]; then
+ worker_coverage=("INTEGRATION_COVERAGE_FILE=$coverage_data")
+fi
+
env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" \
INTEGRATION_RUN_ID="$integration_identity" \
- DATABASE_URL="$DATABASE_URL" REDIS_HOST="$REDIS_HOST" REDIS_PORT="$REDIS_PORT" \
- INTEGRATION_PROXY_URL="$INTEGRATION_PROXY_URL" INTEGRATION_PEER_URL="$INTEGRATION_PEER_URL" \
- INTEGRATION_UPSTREAM_URL="$INTEGRATION_UPSTREAM_URL" \
+ DATABASE_URL="$DATABASE_URL" \
+ "${shared_services[@]}" "${worker_coverage[@]}" \
INTEGRATION_WORKERS="${INTEGRATION_WORKERS:-1}" \
- INTEGRATION_MASTER_KEY="$INTEGRATION_MASTER_KEY" LITELLM_MODE=PRODUCTION \
+ LITELLM_MODE=PRODUCTION \
LITELLM_LICENSE="${LITELLM_LICENSE:-}" \
INTEGRATION_SEED="$INTEGRATION_SEED" \
INTEGRATION_ORDER_SEED="$INTEGRATION_ORDER_SEED" \
@@ -238,6 +265,7 @@ env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" \
INTEGRATION_PROXY_DATABASE_URL="$INTEGRATION_PROXY_DATABASE_URL" \
INTEGRATION_PROXY_READ_REPLICA_URL="$INTEGRATION_PROXY_READ_REPLICA_URL" \
INTEGRATION_ROUTING="$INTEGRATION_ROUTING" \
+ MCP_TEST_PEER_PYTHON="${MCP_TEST_PEER_PYTHON:-}" \
.venv/bin/python tests/integration/run.py "$suite" --results "$results" \
"${shard_arguments[@]}"
diff --git a/.circleci/scripts/run_migration_tests.py b/.circleci/scripts/run_migration_tests.py
index 5a73c54e3f5..50d01f1d50b 100644
--- a/.circleci/scripts/run_migration_tests.py
+++ b/.circleci/scripts/run_migration_tests.py
@@ -10,7 +10,7 @@ from typing import Final
from xml.etree import ElementTree
SUITES: Final = {
- "startup": (("test_startup.py",), 12),
+ "startup": (("test_startup.py",), 13),
"recovery": (("test_recovery.py",), 15),
"legacy": (("test_legacy.py", "test_pooling.py"), 11),
"upgrade": (("test_upgrade.py", "test_rolling_upgrade.py"), 5),
diff --git a/.circleci/scripts/wait_integration_services.py b/.circleci/scripts/wait_integration_services.py
index 486e37cba00..0649585580f 100644
--- a/.circleci/scripts/wait_integration_services.py
+++ b/.circleci/scripts/wait_integration_services.py
@@ -1,42 +1,31 @@
import os
-import time
+from contextlib import ExitStack
from typing import Final
import httpx
+from integration._support.client import Gateway
+from integration._support.workers import wait_for_services
from redis import Redis
def main() -> None:
primary: Final = os.environ["INTEGRATION_PROXY_URL"]
peer: Final = os.environ.get("INTEGRATION_PEER_URL")
- proxies: Final = (primary, peer) if peer else (primary,)
- deadline: Final = time.monotonic() + 90
- headers: Final = {"Authorization": f"Bearer {os.environ['INTEGRATION_MASTER_KEY']}"}
- with httpx.Client(trust_env=False, timeout=2) as client, Redis(
- host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"]), socket_timeout=2
- ) as cache:
- while True:
- try:
- ready: Final = (
- client.get(f"{os.environ['INTEGRATION_UPSTREAM_URL']}/health").status_code == 200
- and all(client.get(f"{url}/health/readiness").status_code == 200 for url in proxies)
- )
- if ready:
- for url in proxies:
- response: Final = client.get(f"{url}/cache/ping", headers=headers)
- response.raise_for_status()
- result: Final = response.json()
- assert result["status"] == "healthy", result
- assert result["cache_type"] == "redis", result
- assert result["ping_response"] is True, result
- assert result["set_cache_response"] == "success", result
- if cache.pubsub_numsub("litellm_proxy.auth_cache_invalidation")[0][1] >= len(proxies):
- return
- except httpx.TransportError:
- pass
- if time.monotonic() >= deadline:
- raise SystemExit("Integration services or auth-cache subscribers did not become ready")
- time.sleep(0.2)
+ urls: Final = (primary, peer) if peer else (primary,)
+ upstream: Final = os.environ["INTEGRATION_UPSTREAM_URL"]
+ with ExitStack() as stack:
+ proxies: Final = tuple(
+ Gateway(
+ stack.enter_context(httpx.Client(base_url=url, trust_env=False, timeout=2)),
+ os.environ["INTEGRATION_MASTER_KEY"],
+ upstream,
+ )
+ for url in urls
+ )
+ cache: Final = stack.enter_context(
+ Redis(host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"]), socket_timeout=2)
+ )
+ wait_for_services(upstream, proxies, cache)
if __name__ == "__main__":
diff --git a/.github/PULL_REQUEST_TEMPLATE/general.md b/.github/PULL_REQUEST_TEMPLATE/general.md
index d9e72d58632..644d1c4a450 100644
--- a/.github/PULL_REQUEST_TEMPLATE/general.md
+++ b/.github/PULL_REQUEST_TEMPLATE/general.md
@@ -68,7 +68,7 @@ After: the same request comes back with real token counts, so the dashboard show
- [ ] The handful of test files covering my change pass locally, e.g. `uv run pytest tests/unit/.py -v`. Leave the suites (`make test-unit-*`, `make test-unit`) to CI: it finishes in ~15 minutes where a laptop takes an hour or more
- [ ] My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
- [ ] My PR's scope is as isolated as possible; it only solves 1 specific problem
-- [ ] I have received a Greptile **Confidence Score of at least 4/5** before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment `@greptileai` to re-request a review after pushing changes)
+- [ ] Every automated review on my PR's latest commit has finished, and I have addressed or replied to each of its findings before requesting a maintainer review
## Delays in PR merge?
diff --git a/.github/ci-coverage-allowlist.yml b/.github/ci-coverage-allowlist.yml
index b3217059e81..db2f652b877 100644
--- a/.github/ci-coverage-allowlist.yml
+++ b/.github/ci-coverage-allowlist.yml
@@ -18,26 +18,11 @@ test_paths:
paths:
- tests/rust-python-harness
- reason: >-
- Live-provider caching cases in tests/local_testing that remain outside CI. Jobs that
- glob that directory either deselect them (local_testing_part1 and part2 carry `-k "... and
- not caching and not cache"`) or keep only another keyword (langfuse, router, assistants).
- The two Redis Cluster IAM authentication tests moved to tests/integration/sdk and remain
- selected by node ID in redis_compat, where redis-server is installed.
- The five keyless files now run in the caching-local shard, leaving live cases in these two.
- Measured 2026-08-21 with no provider credentials: test_disk_cache_unit_tests.py needs
- OPENAI_API_KEY for 2 of its 4 tests, and test_gcs_cache_unit_tests.py needs GCS credentials
- for all 4. Keep these live cases outside PR CI rather than making a job red by construction
+ Owned by the open pull request that moves the Rust/Python parity checks (#45135). It ran in
+ the CircleCI build_and_test job that globbed tests/test_*.py, which this migration deletes,
+ and that pull request gives it a new home
paths:
- - tests/local_testing/test_disk_cache_unit_tests.py
- - tests/local_testing/test_gcs_cache_unit_tests.py
- - reason: >-
- Mixed file that no job has ever run in full. Every CircleCI job that globs
- tests/local_testing deselects it by name ("caching") or keeps only another keyword, so only
- its router test ran, and that test now lives in tests/unit/router_utils/pre_call_checks.
- Its two offline tests now live in tests/unit/llms/anthropic/chat, and the five left all need
- ANTHROPIC_API_KEY or Vertex credentials
- paths:
- - tests/local_testing/test_anthropic_prompt_caching.py
+ - tests/test_rust_python_harness.py
- reason: >-
The end-to-end suite runs against a deployed proxy from its own in-cluster rig rather than
from a pull request; it needs a live gateway and provider credentials no PR job holds
@@ -63,48 +48,6 @@ test_paths:
choice it informed is settled
paths:
- tests/code_coverage_tests/test_aio_http_image_conversion.py
- - reason: >-
- No job invokes this suite and its files mix pure transformation tests with ones driving live
- vendor vector stores, so assigning them needs a per-file decision
- paths:
- - tests/vector_store_tests/rag/test_rag_bedrock.py
- - tests/vector_store_tests/rag/test_rag_openai.py
- - tests/vector_store_tests/rag/test_rag_s3_vectors.py
- - tests/vector_store_tests/rag/test_rag_vertex_ai.py
- - tests/vector_store_tests/test_azure_ai_vector_store.py
- - tests/vector_store_tests/test_azure_vector_store.py
- - tests/vector_store_tests/test_bedrock_vector_store.py
- - tests/vector_store_tests/test_gemini_vector_store.py
- - tests/vector_store_tests/test_milvus_vector_store.py
- - tests/vector_store_tests/test_openai_vector_store.py
- - tests/vector_store_tests/test_ragflow_vector_store.py
- - tests/vector_store_tests/test_s3_vectors_vector_store.py
- - tests/vector_store_tests/test_vertex_ai_search_api_vector_store.py
- - tests/vector_store_tests/test_vertex_ai_vector_store.py
- - reason: >-
- Throughput and memory-growth measurements whose runtime and variance make them unsuitable for
- a per-pull-request job
- paths:
- - tests/load_tests/test_datadog_load_test.py
- - tests/load_tests/test_langsmith_load_test.py
- - tests/load_tests/test_linear_memory_growth.py
- - tests/load_tests/test_memory_usage.py
- - tests/load_tests/test_otel_load_test.py
- - tests/load_tests/test_vertex_embeddings_load_test.py
- - tests/load_tests/test_vertex_load_tests.py
- - reason: >-
- Env-gated saturation benchmark requires a live proxy and provider credentials, so it is run
- locally rather than in pull-request jobs
- paths:
- - tests/load_tests/test_granian_admission_saturation.py
- - reason: >-
- A local-only agent rig: test_a2a_completion_bridge.py needs a LangGraph server on
- localhost:2024 and test_a2a.py drives a live A2A endpoint, so neither can run in a
- pull request job. Until 2026-08-20 the CircleCI agent job hid them behind a grep -v
- that this census could not see; the glob now excludes them structurally and this entry
- is the decision on the record. Revisit when the A2A bridge gets a recorded-wire fixture
- paths:
- - tests/agent_tests/local_only_agent_tests
- reason: >-
Third-party integration tests that skip themselves without OCI configuration or sandbox
credentials, neither of which a pull request job holds
diff --git a/.github/scripts/assert_ci_coverage.py b/.github/scripts/assert_ci_coverage.py
index 5c581c99d12..fbdbfafa66d 100644
--- a/.github/scripts/assert_ci_coverage.py
+++ b/.github/scripts/assert_ci_coverage.py
@@ -37,7 +37,6 @@ GLOB_CHARS = frozenset("*?")
# tests has to be named by some shard or it runs nowhere. A child listed here is
# itself decomposed one level deeper and is checked through its own entry.
SHARDED_ROOTS: tuple[str, ...] = (
- "tests/test_litellm",
"tests/unit",
"tests/unit/enterprise",
"tests/unit/enterprise/enterprise_callbacks",
diff --git a/.github/workflows/deploy-dev-on-main.yml b/.github/workflows/deploy-dev-on-main.yml
new file mode 100644
index 00000000000..1208a2dbbdb
--- /dev/null
+++ b/.github/workflows/deploy-dev-on-main.yml
@@ -0,0 +1,30 @@
+name: Deploy main to gateway-dev
+
+on:
+ push:
+ branches: [main]
+
+permissions: {}
+
+concurrency:
+ group: deploy-dev-dispatch
+ cancel-in-progress: true
+
+jobs:
+ dispatch:
+ if: github.repository == 'BerriAI/litellm'
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2
+ id: token
+ with:
+ app-id: ${{ secrets.LITELLM_OPS_DISPATCH_APP_ID }}
+ private-key: ${{ secrets.LITELLM_OPS_DISPATCH_APP_PRIVATE_KEY }}
+ owner: BerriAI
+ repositories: litellm-ops
+ permission-actions: write
+ - name: Dispatch the litellm-ops dev deploy
+ env:
+ GH_TOKEN: ${{ steps.token.outputs.token }}
+ run: gh api --method POST repos/BerriAI/litellm-ops/actions/workflows/deploy-dev.yml/dispatches -f ref=main
diff --git a/.github/workflows/image-scan.yml b/.github/workflows/image-scan.yml
index e2a3783af3f..93eb6c1b325 100644
--- a/.github/workflows/image-scan.yml
+++ b/.github/workflows/image-scan.yml
@@ -27,7 +27,7 @@ on:
- backend/routes/allowlist.py
- pyproject.toml
- litellm-proxy-extras/**
- - tests/proxy_migration_tests/**
+ - tests/integration/database/image/**
- uv.lock
- ui/litellm-dashboard/package-lock.json
- ui/Dockerfile
@@ -92,7 +92,7 @@ jobs:
LITELLM_ADMIN_MCP_SCHEMA_IMAGE: litellm-admin-mcp-schema:${{ github.sha }}
run: |
python -m pip install "pytest==9.0.3"
- python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py tests/proxy_migration_tests/test_image_bedrock_realtime_extra.py tests/proxy_migration_tests/test_image_admin_mcp.py -v
+ python -m pytest --noconftest tests/integration/database/image/test_offline_image_migration.py tests/integration/database/image/test_image_bedrock_realtime_extra.py tests/integration/database/image/test_image_admin_mcp.py -v
# Scans the whole shipped artifact: OS/apk plus every language package
# baked into the image, including ones no lockfile declares (e.g. prisma's
@@ -145,7 +145,7 @@ jobs:
LITELLM_ADMIN_MCP_SCHEMA_IMAGE: litellm-admin-mcp-schema:${{ github.sha }}
run: |
python -m pip install "pytest==9.0.3"
- python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py tests/proxy_migration_tests/test_image_bedrock_realtime_extra.py tests/proxy_migration_tests/test_image_admin_mcp.py -v
+ python -m pytest --noconftest tests/integration/database/image/test_offline_image_migration.py tests/integration/database/image/test_image_bedrock_realtime_extra.py tests/integration/database/image/test_image_admin_mcp.py -v
migrations-image:
@@ -177,7 +177,7 @@ jobs:
LITELLM_MIGRATION_SCRIPT: /app/run.py
run: |
python -m pip install "pytest==9.0.3"
- python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py -v
+ python -m pytest --noconftest tests/integration/database/image/test_offline_image_migration.py -v
gateway-image:
name: gateway-image
@@ -208,7 +208,7 @@ jobs:
LITELLM_IMAGE_COMPONENT: gateway
run: |
python -m pip install "pytest==9.0.3"
- python -m pytest tests/proxy_migration_tests/test_component_image_serves_offline.py tests/proxy_migration_tests/test_image_bedrock_realtime_extra.py tests/proxy_migration_tests/test_image_admin_mcp.py -v
+ python -m pytest --noconftest tests/integration/database/image/test_component_image_serves_offline.py tests/integration/database/image/test_image_bedrock_realtime_extra.py tests/integration/database/image/test_image_admin_mcp.py -v
ui-image:
name: ui-image
@@ -237,7 +237,7 @@ jobs:
LITELLM_IMAGE: litellm-ui-scan:${{ github.sha }}
run: |
python -m pip install "pytest==9.0.3"
- python -m pytest tests/proxy_migration_tests/test_ui_image_serves_offline.py -v
+ python -m pytest --noconftest tests/integration/database/image/test_ui_image_serves_offline.py -v
backend-image:
name: backend-image
@@ -272,4 +272,4 @@ jobs:
LITELLM_IMAGE_COMPONENT: backend
run: |
python -m pip install "pytest==9.0.3"
- python -m pytest tests/proxy_migration_tests/test_component_image_serves_offline.py tests/proxy_migration_tests/test_image_admin_mcp.py -v
+ python -m pytest --noconftest tests/integration/database/image/test_component_image_serves_offline.py tests/integration/database/image/test_image_admin_mcp.py -v
diff --git a/.github/workflows/test-e2e-redis-chaos.yml b/.github/workflows/test-e2e-redis-chaos.yml
index c7412a63334..8f351a7a647 100644
--- a/.github/workflows/test-e2e-redis-chaos.yml
+++ b/.github/workflows/test-e2e-redis-chaos.yml
@@ -91,12 +91,12 @@ jobs:
- name: Run the Redis chaos load test
env:
- E2E_REDIS_CHAOS: "1"
+ E2E_PERF_TESTS: "1"
LITELLM_PROXY_URL: http://localhost:4000
REDIS_HOST: 127.0.0.1
REDIS_PORT: "6379"
run: |
- uv run --no-sync pytest tests/e2e/load/test_redis_chaos_e2e.py -v --tb=short -rA -s
+ uv run --no-sync pytest tests/e2e/perf/test_redis_chaos_e2e.py -v --tb=short -rA -s
- name: Show proxy log on failure
if: failure()
diff --git a/.github/workflows/test-linting.yml b/.github/workflows/test-linting.yml
index 2d0766e0468..15cda063626 100644
--- a/.github/workflows/test-linting.yml
+++ b/.github/workflows/test-linting.yml
@@ -22,6 +22,16 @@ jobs:
actions: read
runs-on: ubuntu-latest
timeout-minutes: 15
+ services:
+ redis:
+ image: redis@sha256:e2debfb7956fa12c7ddc79d7e645c8cf26b30c99a6e9161ea9bf4171e1668a5f
+ ports:
+ - 6379:6379
+ options: >-
+ --health-cmd "redis-cli ping"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
@@ -189,8 +199,9 @@ jobs:
if: steps.changes.outputs.decision != 'skip'
env:
LITELLM_MASTER_KEY: sk-e2e-harness-tests-reach-no-proxy
+ E2E_CACHE_TEST_REDIS_URL: redis://127.0.0.1:6379/0
run: |
- if ! git diff --name-only --diff-filter=ACMRD "$GATE_BASE_SHA" HEAD -- tests/e2e tests/e2e_harness ':(exclude)tests/e2e/ui' pyproject.toml uv.lock .github/workflows/test-linting.yml | grep -q .; then
+ if ! git diff --name-only --diff-filter=ACMRD "$GATE_BASE_SHA" HEAD -- tests/e2e tests/e2e_harness ':(exclude)tests/e2e/ui' tests/code_coverage_tests/test_provider_replay_harness.py tests/code_coverage_tests/test_provider_cache.py pyproject.toml uv.lock .github/workflows/test-linting.yml | grep -q .; then
echo "No changed e2e harness files; skipping."
exit 0
fi
@@ -198,6 +209,9 @@ jobs:
CLAUDE_VERSION="$(retry uv run --no-sync python tests/e2e/claude_code/pr_gate_version_resolver.py)"
tests/e2e/claude_code/cron_vm/install_claude_code.sh "$CLAUDE_VERSION" "$RUNNER_TEMP/claude-cli"
PATH="$RUNNER_TEMP/claude-cli:$PATH" uv run --no-sync pytest -q tests/e2e_harness
+ uv run --no-sync pytest --tb=short -q --noconftest -o addopts= -o "pythonpath=tests/e2e tests/e2e_harness" -p no:rerunfailures \
+ tests/code_coverage_tests/test_provider_replay_harness.py \
+ tests/code_coverage_tests/test_provider_cache.py
- name: Check for circular imports
if: steps.changes.outputs.decision != 'skip'
@@ -601,12 +615,6 @@ jobs:
- name: check_migrations_no_data_rewrites
run: uv run --no-sync python ./tests/code_coverage_tests/check_migrations_no_data_rewrites.py
- - name: check_greptile_files_json
- run: uv run --no-sync python ./tests/code_coverage_tests/check_greptile_files_json.py
-
- - name: test_check_greptile_files_json
- run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_check_greptile_files_json.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
diff --git a/.github/workflows/test-merge-smoke.yml b/.github/workflows/test-merge-smoke.yml
index 0133629d24a..0d1cc6596de 100644
--- a/.github/workflows/test-merge-smoke.yml
+++ b/.github/workflows/test-merge-smoke.yml
@@ -234,7 +234,30 @@ jobs:
exit 1
- name: Run the startup tests
- run: .venv/bin/python -m pytest --tb=short -vv tests/basic_proxy_startup_tests
+ shell: bash
+ run: |
+ set -euo pipefail
+ base_url=http://localhost:4000
+ readiness_response="$(curl -sS --fail-with-body --write-out $'\n%{http_code}' "${base_url}/health/readiness")"
+ readiness_status="${readiness_response##*$'\n'}"
+ readiness_body="${readiness_response%$'\n'*}"
+ [[ "${readiness_status}" == "200" ]]
+ jq -e '.status == "healthy" or .status == "connected"' <<< "${readiness_body}" > /dev/null
+ liveness_status="$(curl -sS --fail-with-body --output /dev/null --write-out '%{http_code}' "${base_url}/health/liveness")"
+ [[ "${liveness_status}" == "200" ]]
+ completion_response="$(curl -sS --fail-with-body --write-out $'\n%{http_code}' \
+ --header "Authorization: Bearer ${LITELLM_MASTER_KEY}" \
+ --header 'Content-Type: application/json' \
+ --data '{"model":"gpt-4","messages":[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"Hello!"}]}' \
+ "${base_url}/chat/completions")"
+ completion_status="${completion_response##*$'\n'}"
+ completion_body="${completion_response%$'\n'*}"
+ [[ "${completion_status}" == "200" ]]
+ jq -e '
+ (.choices | length) == 1 and
+ .choices[0].message.role == "assistant" and
+ .choices[0].message.content == "Hello! This is a mock response from the fake OpenAI endpoint."
+ ' <<< "${completion_body}" > /dev/null
- name: Show the proxy logs
if: always()
diff --git a/.github/workflows/test-rust.yml b/.github/workflows/test-rust.yml
index fe1d2c025b3..f8a7c38ef79 100644
--- a/.github/workflows/test-rust.yml
+++ b/.github/workflows/test-rust.yml
@@ -18,7 +18,6 @@ on:
- "litellm/ocr/**"
- "litellm/llms/base_llm/ocr/**"
- "litellm/llms/custom_httpx/llm_http_handler.py"
- - "tests/test_litellm/conftest.py"
- "Makefile"
- ".cargo/**"
- "pyproject.toml"
@@ -49,7 +48,6 @@ on:
- "litellm/ocr/**"
- "litellm/llms/base_llm/ocr/**"
- "litellm/llms/custom_httpx/llm_http_handler.py"
- - "tests/test_litellm/conftest.py"
- "Makefile"
- ".cargo/**"
- "pyproject.toml"
diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml
index faa02c8f1a9..e9236ee7787 100644
--- a/.github/workflows/test-unit.yml
+++ b/.github/workflows/test-unit.yml
@@ -140,6 +140,7 @@ jobs:
tests/unit/batch_completion
tests/unit/decisions
tests/unit/litellm_core_utils
+ tests/unit/systemone
python-version: "3.12"
workers: 4
reruns: 0
@@ -173,8 +174,6 @@ jobs:
- shard: integrations
test-path: |-
- tests/test_litellm/integrations
- tests/test_litellm/tracing
tests/unit/integrations
python-version: "3.12"
workers: 4
@@ -291,6 +290,7 @@ jobs:
tests/unit/llms/manus
tests/unit/llms/meta_llama
tests/unit/llms/microsoft_365_copilot
+ tests/unit/llms/milvus
tests/unit/llms/minimax
tests/unit/llms/mistral
tests/unit/llms/modelscope
@@ -375,7 +375,6 @@ jobs:
- shard: root
test-path: >-
- tests/test_litellm/test_*.py
tests/unit/test_*.py
python-version: "3.12"
workers: 4
@@ -394,16 +393,6 @@ jobs:
job-timeout-minutes: 60
dist: loadscope
- - shard: router-unit-tests
- test-path: >-
- tests/router_unit_tests
- python-version: "3.12"
- workers: 0
- reruns: 0
- timeout-minutes: 10
- job-timeout-minutes: 60
- dist: loadscope
-
- shard: endpoints
test-path: >-
tests/unit/a2a_protocol
@@ -450,6 +439,7 @@ jobs:
test-path: >-
tests/unit/enterprise/enterprise_callbacks/test_callback_controls.py
tests/unit/enterprise/enterprise_callbacks/test_llm_guard.py
+ tests/unit/enterprise/enterprise_callbacks/test_pagerduty_alerting.py
tests/unit/enterprise/enterprise_callbacks/test_secret_detection.py
tests/unit/integration_support
tests/unit/models
@@ -773,7 +763,7 @@ jobs:
- shard: proxy-server-core
test-path: |-
- tests/proxy_unit_tests/test_proxy_server_gemini_pass_through.py
+ tests/unit/proxy/pass_through_endpoints/test_llm_passthrough_endpoints.py
tests/unit/proxy/test__lazy_features.py
tests/unit/proxy/test_aproxy_startup.py
tests/unit/proxy/test_proxy_server.py
@@ -804,6 +794,10 @@ jobs:
tests/unit/proxy/_experimental/mcp_server/test_discoverable_endpoints.py
tests/unit/proxy/_experimental/mcp_server/test_mcp_server_manager.py
tests/unit/proxy/_experimental/mcp_server/test_db_credentials.py
+ tests/unit/proxy/_experimental/mcp_server/test_gateway_dcr_flow.py
+ tests/unit/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_tools.py
+ tests/unit/proxy/_experimental/mcp_server/test_mcp_max_concurrent_requests.py
+ tests/unit/proxy/_experimental/mcp_server/faults/test_list_outcomes.py
tests/unit/proxy/_experimental/mcp_server/outbound_credentials
python-version: "3.12"
workers: 4
@@ -812,9 +806,31 @@ jobs:
job-timeout-minutes: 60
dist: loadscope
+ - shard: mcp-server
+ test-path: |-
+ tests/unit/experimental_mcp_client
+ tests/unit/proxy/_experimental/mcp_server
+ tests/unit/responses/mcp
+ --ignore=tests/unit/experimental_mcp_client/test_mcp_client.py
+ --ignore=tests/unit/proxy/_experimental/mcp_server/test_capabilities.py
+ --ignore=tests/unit/proxy/_experimental/mcp_server/test_interactions.py
+ --ignore=tests/unit/proxy/_experimental/mcp_server/test_mcp_elicitation_handler.py
+ --ignore=tests/unit/proxy/_experimental/mcp_server/test_mcp_server_manager.py
+ --ignore=tests/unit/proxy/_experimental/mcp_server/test_mcp_server_tool_calls_and_headers.py
+ --ignore=tests/unit/proxy/_experimental/mcp_server/test_operations.py
+ --ignore=tests/unit/proxy/_experimental/mcp_server/test_discoverable_endpoints.py
+ --ignore=tests/unit/proxy/_experimental/mcp_server/test_db_credentials.py
+ --ignore=tests/unit/proxy/_experimental/mcp_server/outbound_credentials
+ python-version: "3.12"
+ workers: 4
+ reruns: 0
+ timeout-minutes: 20
+ job-timeout-minutes: 60
+ dist: loadscope
+
- shard: custom-logging
test-path: |-
- tests/proxy_unit_tests/test_proxy_custom_logger.py
+ tests/unit/proxy/test_proxy_server_custom_logger.py
tests/unit/proxy/test_custom_callback_input.py
tests/unit/proxy/test_custom_logger_s3_gcs.py
python-version: "3.12"
@@ -864,17 +880,6 @@ jobs:
job-timeout-minutes: 60
dist: loadscope
- - shard: guardrails-tests
- test-path: >-
- tests/guardrails_tests
- --deselect=tests/guardrails_tests/test_custom_guardrail.py::test_get_guardrail_dynamic_request_body_params
- python-version: "3.12"
- workers: 4
- reruns: 2
- timeout-minutes: 15
- job-timeout-minutes: 60
- dist: loadscope
-
- shard: budgets
test-path: |-
tests/unit/proxy/auth/test_default_end_user_budget_simple.py
@@ -889,7 +894,7 @@ jobs:
- shard: endpoints-and-responses
test-path: |-
- tests/proxy_unit_tests/test_proxy_exception_mapping.py
+ tests/unit/proxy/test_proxy_server_exception_mapping.py
tests/unit/proxy/lens
tests/unit/proxy/auth/test_models_fallback_endpoint.py
tests/unit/proxy/common_utils/test_check_batch_cost.py
@@ -1345,6 +1350,115 @@ jobs:
- name: Check consumer types with the installed SDK
if: steps.changes.outputs.decision != 'skip'
run: uv run --no-sync basedpyright --pythonpath .venv/bin/python --project tests/typing/pyrightconfig.json
+ redis-compat:
+ name: redis-py ${{ matrix.redis-py }} compatibility
+ needs: rust-bridge
+ if: ${{ !cancelled() }}
+ permissions:
+ contents: read
+ id-token: write
+ pull-requests: read
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+ strategy:
+ fail-fast: false
+ matrix:
+ redis-py: ["5.3.1", "6.4.0", "7.4.1", "8.0.1"]
+ env:
+ UV_PYTHON: "3.12"
+ LITELLM_LOCAL_MODEL_COST_MAP: "True"
+ REDIS_PY_VERSION: ${{ matrix.redis-py }}
+ steps:
+ - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ timeout-minutes: 3
+ with:
+ persist-credentials: false
+
+ - name: Detect relevant changes
+ id: changes
+ timeout-minutes: 2
+ uses: ./.github/actions/detect-changes
+ with:
+ category: redis-compat
+
+ - name: Set up Python
+ if: steps.changes.outputs.decision != 'skip'
+ timeout-minutes: 3
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: ${{ env.UV_PYTHON }}
+
+ - name: Set up uv
+ if: steps.changes.outputs.decision != 'skip'
+ timeout-minutes: 3
+ uses: ./.github/actions/setup-uv-with-retries
+ with:
+ version: "0.10.9"
+
+ - name: Cache uv dependencies
+ if: steps.changes.outputs.decision != 'skip'
+ timeout-minutes: 5
+ uses: ./.github/actions/cache-uv-downloads
+
+ - name: Set up the Rust build
+ if: steps.changes.outputs.decision != 'skip'
+ timeout-minutes: 5
+ uses: ./.github/actions/rust-bridge
+ with:
+ artifact: ${{ needs.rust-bridge.outputs.artifact }}
+
+ - name: Install dependencies with redis-py ${{ matrix.redis-py }}
+ if: steps.changes.outputs.decision != 'skip'
+ timeout-minutes: 8
+ env:
+ RUST_BRIDGE_ARTIFACT: ${{ needs.rust-bridge.outputs.artifact }}
+ run: |
+ if [ -z "$RUST_BRIDGE_ARTIFACT" ]; then
+ .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml --extra caching --extra extra_proxy --extra proxy-runtime --extra utils
+ else
+ .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml --extra caching --extra extra_proxy --extra proxy-runtime --extra utils --no-install-project
+ uv pip install --no-deps --python .venv/bin/python rust-bridge-dist/*.whl
+ cp rust-bridge-dist/litellm/rust_bridge/_native.abi3.so litellm/rust_bridge/_native.abi3.so
+ fi
+ uv pip install --python .venv/bin/python "redis==${REDIS_PY_VERSION:?}"
+ uv run --no-sync python -c 'import os, redis; assert redis.__version__ == os.environ["REDIS_PY_VERSION"], redis.__version__'
+
+ - name: Install redis-server 7.2.16
+ if: steps.changes.outputs.decision != 'skip'
+ timeout-minutes: 5
+ run: |
+ mkdir -p "$RUNNER_TEMP/redis-bin"
+ cid="$(docker create redis:7.2.16@sha256:0637954999d01b7c9ce9167db2da50656e2590d3b884f1c600c5f63bb6e6773c)"
+ docker cp "${cid}:/usr/local/bin/redis-server" "$RUNNER_TEMP/redis-bin/redis-server"
+ docker rm "$cid"
+ "$RUNNER_TEMP/redis-bin/redis-server" --version | grep -q 'v=7.2.16'
+ echo "$RUNNER_TEMP/redis-bin" >> "$GITHUB_PATH"
+
+ - name: Run Redis compatibility tests
+ if: steps.changes.outputs.decision != 'skip'
+ timeout-minutes: 15
+ env:
+ COVERAGE_CORE: sysmon
+ run: |
+ uv run --no-sync pytest \
+ tests/unit/test_redis.py \
+ tests/unit/caching/test_redis_connection_pool.py \
+ tests/unit/caching/test_redis_cluster_cache.py \
+ tests/unit/caching/test_evicted_client_closer.py \
+ tests/integration/sdk/test_redis_cluster_iam_auth.py \
+ --tb=short -vv -rs -p no:rerunfailures -p no:pytest-retry --timeout=120 --durations=20 \
+ --cov=./litellm --cov-report=xml:coverage.xml
+
+ - name: Upload redis-py ${{ matrix.redis-py }} coverage to Codecov
+ if: always() && steps.changes.outputs.decision != 'skip' && matrix.redis-py == '5.3.1'
+ continue-on-error: true
+ uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5
+ with:
+ use_oidc: true
+ files: coverage.xml
+ root_dir: ${{ github.workspace }}
+ flags: redis-compat
+ fail_ci_if_error: false
coverage:
name: coverage
needs: unit
@@ -1390,10 +1504,20 @@ jobs:
root_dir: ${{ github.workspace }}
flags: unit
fail_ci_if_error: false
+
+ - name: Upload MCP server coverage under its own flag
+ continue-on-error: true
+ uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5
+ with:
+ use_oidc: true
+ files: coverage-reports/coverage-mcp-server-${{ github.run_id }}-${{ github.run_attempt }}/coverage.xml
+ root_dir: ${{ github.workspace }}
+ flags: mcp-integration
+ fail_ci_if_error: false
unit-passed:
name: unit passed
permissions: {}
- needs: [rust-bridge, assert-shard-coverage, unit, ui-unit, docs, helm, openai-sdk-compat]
+ needs: [rust-bridge, assert-shard-coverage, unit, ui-unit, docs, helm, openai-sdk-compat, redis-compat]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 2
diff --git a/.github/workflows/weekly_load_anomaly.yml b/.github/workflows/weekly_load_anomaly.yml
index 3e1fca89645..0ba4df89cc4 100644
--- a/.github/workflows/weekly_load_anomaly.yml
+++ b/.github/workflows/weekly_load_anomaly.yml
@@ -63,7 +63,7 @@ jobs:
- name: Start the proxy
run: |
- nohup uv run --no-sync litellm --config tests/e2e/load/weekly_anomaly_config.yml --port 4000 > proxy.log 2>&1 &
+ nohup uv run --no-sync litellm --config tests/e2e/perf/weekly_anomaly_config.yml --port 4000 > proxy.log 2>&1 &
for _ in $(seq 1 90); do
if curl -fs http://localhost:4000/health/liveliness > /dev/null; then
exit 0
@@ -78,7 +78,7 @@ jobs:
env:
E2E_WEEKLY_ANOMALY: "1"
run: |
- uv run --no-sync pytest tests/e2e/load/test_weekly_session_anomaly_e2e.py -v --tb=short -rA
+ uv run --no-sync pytest tests/e2e/perf/test_weekly_session_anomaly_e2e.py -v --tb=short -rA
- name: Show proxy log on failure
if: failure()
diff --git a/.greptile/files.json b/.greptile/files.json
deleted file mode 100644
index cb54d7f3c5e..00000000000
--- a/.greptile/files.json
+++ /dev/null
@@ -1,473 +0,0 @@
-{
- "files": [
- {
- "path": "litellm/_v2/AGENTS.md",
- "description": "Conventions for code under litellm/_v2/",
- "scope": [
- "litellm/_v2/**"
- ]
- },
- {
- "path": "litellm/_v2/cache/AGENTS.md",
- "description": "Conventions for code under litellm/_v2/cache/",
- "scope": [
- "litellm/_v2/cache/**"
- ]
- },
- {
- "path": "litellm/proxy/_experimental/mcp_server/AGENTS.md",
- "description": "Conventions for code under litellm/proxy/_experimental/mcp_server/",
- "scope": [
- "litellm/proxy/_experimental/mcp_server/**"
- ]
- },
- {
- "path": "litellm/rust_bridge/AGENTS.md",
- "description": "Conventions for code under litellm/rust_bridge/",
- "scope": [
- "litellm/rust_bridge/**"
- ]
- },
- {
- "path": "litellm/tracing/AGENTS.md",
- "description": "Conventions for code under litellm/tracing/",
- "scope": [
- "litellm/tracing/**"
- ]
- },
- {
- "path": "litellm-rust/AGENTS.md",
- "description": "Conventions for code under litellm-rust/",
- "scope": [
- "litellm-rust/**"
- ]
- },
- {
- "path": "litellm-rust/crates/cache-response/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/cache-response/",
- "scope": [
- "litellm-rust/crates/cache-response/**"
- ]
- },
- {
- "path": "litellm-rust/crates/callbacks-legacy-python/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/callbacks-legacy-python/",
- "scope": [
- "litellm-rust/crates/callbacks-legacy-python/**"
- ]
- },
- {
- "path": "litellm-rust/crates/coroutine/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/coroutine/",
- "scope": [
- "litellm-rust/crates/coroutine/**"
- ]
- },
- {
- "path": "litellm-rust/crates/gateway/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/gateway/",
- "scope": [
- "litellm-rust/crates/gateway/**"
- ]
- },
- {
- "path": "litellm-rust/crates/gateway-auth/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/gateway-auth/",
- "scope": [
- "litellm-rust/crates/gateway-auth/**"
- ]
- },
- {
- "path": "litellm-rust/crates/gateway-inference/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/gateway-inference/",
- "scope": [
- "litellm-rust/crates/gateway-inference/**"
- ]
- },
- {
- "path": "litellm-rust/crates/gateway-mcp/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/gateway-mcp/",
- "scope": [
- "litellm-rust/crates/gateway-mcp/**"
- ]
- },
- {
- "path": "litellm-rust/crates/host/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/host/",
- "scope": [
- "litellm-rust/crates/host/**"
- ]
- },
- {
- "path": "litellm-rust/crates/host/src/machine/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/host/src/machine/",
- "scope": [
- "litellm-rust/crates/host/src/machine/**"
- ]
- },
- {
- "path": "litellm-rust/crates/host-http/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/host-http/",
- "scope": [
- "litellm-rust/crates/host-http/**"
- ]
- },
- {
- "path": "litellm-rust/crates/host-native/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/host-native/",
- "scope": [
- "litellm-rust/crates/host-native/**"
- ]
- },
- {
- "path": "litellm-rust/crates/host-python/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/host-python/",
- "scope": [
- "litellm-rust/crates/host-python/**"
- ]
- },
- {
- "path": "litellm-rust/crates/http/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/http/",
- "scope": [
- "litellm-rust/crates/http/**"
- ]
- },
- {
- "path": "litellm-rust/crates/inference/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/inference/",
- "scope": [
- "litellm-rust/crates/inference/**"
- ]
- },
- {
- "path": "litellm-rust/crates/inference-chat/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/inference-chat/",
- "scope": [
- "litellm-rust/crates/inference-chat/**"
- ]
- },
- {
- "path": "litellm-rust/crates/inference-messages/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/inference-messages/",
- "scope": [
- "litellm-rust/crates/inference-messages/**"
- ]
- },
- {
- "path": "litellm-rust/crates/inference-ocr/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/inference-ocr/",
- "scope": [
- "litellm-rust/crates/inference-ocr/**"
- ]
- },
- {
- "path": "litellm-rust/crates/inference-responses/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/inference-responses/",
- "scope": [
- "litellm-rust/crates/inference-responses/**"
- ]
- },
- {
- "path": "litellm-rust/crates/inference-transcription/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/inference-transcription/",
- "scope": [
- "litellm-rust/crates/inference-transcription/**"
- ]
- },
- {
- "path": "litellm-rust/crates/llms/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/llms/",
- "scope": [
- "litellm-rust/crates/llms/**"
- ]
- },
- {
- "path": "litellm-rust/crates/llms/src/anthropic/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/llms/src/anthropic/",
- "scope": [
- "litellm-rust/crates/llms/src/anthropic/**"
- ]
- },
- {
- "path": "litellm-rust/crates/llms/src/anthropic/batches/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/llms/src/anthropic/batches/",
- "scope": [
- "litellm-rust/crates/llms/src/anthropic/batches/**"
- ]
- },
- {
- "path": "litellm-rust/crates/llms/src/anthropic/count_tokens/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/llms/src/anthropic/count_tokens/",
- "scope": [
- "litellm-rust/crates/llms/src/anthropic/count_tokens/**"
- ]
- },
- {
- "path": "litellm-rust/crates/llms/src/anthropic/messages/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/llms/src/anthropic/messages/",
- "scope": [
- "litellm-rust/crates/llms/src/anthropic/messages/**"
- ]
- },
- {
- "path": "litellm-rust/crates/llms/src/aws_textract/ocr/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/llms/src/aws_textract/ocr/",
- "scope": [
- "litellm-rust/crates/llms/src/aws_textract/ocr/**"
- ]
- },
- {
- "path": "litellm-rust/crates/llms/src/azure_ai/messages/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/llms/src/azure_ai/messages/",
- "scope": [
- "litellm-rust/crates/llms/src/azure_ai/messages/**"
- ]
- },
- {
- "path": "litellm-rust/crates/llms/src/base_llm/messages/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/llms/src/base_llm/messages/",
- "scope": [
- "litellm-rust/crates/llms/src/base_llm/messages/**"
- ]
- },
- {
- "path": "litellm-rust/crates/llms/src/bedrock/messages/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/llms/src/bedrock/messages/",
- "scope": [
- "litellm-rust/crates/llms/src/bedrock/messages/**"
- ]
- },
- {
- "path": "litellm-rust/crates/llms-types/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/llms-types/",
- "scope": [
- "litellm-rust/crates/llms-types/**"
- ]
- },
- {
- "path": "litellm-rust/crates/llms-types/src/formats/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/llms-types/src/formats/",
- "scope": [
- "litellm-rust/crates/llms-types/src/formats/**"
- ]
- },
- {
- "path": "litellm-rust/crates/llms-types/src/formats/messages/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/llms-types/src/formats/messages/",
- "scope": [
- "litellm-rust/crates/llms-types/src/formats/messages/**"
- ]
- },
- {
- "path": "litellm-rust/crates/llms-types/src/providers/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/llms-types/src/providers/",
- "scope": [
- "litellm-rust/crates/llms-types/src/providers/**"
- ]
- },
- {
- "path": "litellm-rust/crates/model-catalog/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/model-catalog/",
- "scope": [
- "litellm-rust/crates/model-catalog/**"
- ]
- },
- {
- "path": "litellm-rust/crates/python-bridge/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/python-bridge/",
- "scope": [
- "litellm-rust/crates/python-bridge/**"
- ]
- },
- {
- "path": "litellm-rust/crates/python-bridge/src/cache/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/python-bridge/src/cache/",
- "scope": [
- "litellm-rust/crates/python-bridge/src/cache/**"
- ]
- },
- {
- "path": "litellm-rust/crates/python-bridge/src/cache/native/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/python-bridge/src/cache/native/",
- "scope": [
- "litellm-rust/crates/python-bridge/src/cache/native/**"
- ]
- },
- {
- "path": "litellm-rust/crates/python-bridge/src/cache/python/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/python-bridge/src/cache/python/",
- "scope": [
- "litellm-rust/crates/python-bridge/src/cache/python/**"
- ]
- },
- {
- "path": "litellm-rust/crates/python-bridge/src/routes/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/python-bridge/src/routes/",
- "scope": [
- "litellm-rust/crates/python-bridge/src/routes/**"
- ]
- },
- {
- "path": "litellm-rust/crates/python-compat/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/python-compat/",
- "scope": [
- "litellm-rust/crates/python-compat/**"
- ]
- },
- {
- "path": "litellm-rust/crates/secrets-aws/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/secrets-aws/",
- "scope": [
- "litellm-rust/crates/secrets-aws/**"
- ]
- },
- {
- "path": "litellm-rust/crates/secrets-azure/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/secrets-azure/",
- "scope": [
- "litellm-rust/crates/secrets-azure/**"
- ]
- },
- {
- "path": "litellm-rust/crates/secrets-cyberark/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/secrets-cyberark/",
- "scope": [
- "litellm-rust/crates/secrets-cyberark/**"
- ]
- },
- {
- "path": "litellm-rust/crates/secrets-google/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/secrets-google/",
- "scope": [
- "litellm-rust/crates/secrets-google/**"
- ]
- },
- {
- "path": "litellm-rust/crates/secrets-hashicorp/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/secrets-hashicorp/",
- "scope": [
- "litellm-rust/crates/secrets-hashicorp/**"
- ]
- },
- {
- "path": "litellm-rust/crates/storage-clickhouse/AGENTS.md",
- "description": "Conventions for code under litellm-rust/crates/storage-clickhouse/",
- "scope": [
- "litellm-rust/crates/storage-clickhouse/**"
- ]
- },
- {
- "path": "litellm-rust/docs/adrs/AGENTS.md",
- "description": "Conventions for code under litellm-rust/docs/adrs/",
- "scope": [
- "litellm-rust/docs/adrs/**"
- ]
- },
- {
- "path": "tests/AGENTS.md",
- "description": "Conventions for code under tests/",
- "scope": [
- "tests/**"
- ]
- },
- {
- "path": "tests/e2e/AGENTS.md",
- "description": "Conventions for code under tests/e2e/",
- "scope": [
- "tests/e2e/**"
- ]
- },
- {
- "path": "tests/e2e_harness/AGENTS.md",
- "description": "Conventions for code under tests/e2e_harness/",
- "scope": [
- "tests/e2e_harness/**"
- ]
- },
- {
- "path": "tests/integration/AGENTS.md",
- "description": "Conventions for code under tests/integration/",
- "scope": [
- "tests/integration/**"
- ]
- },
- {
- "path": "tests/integration/translation/AGENTS.md",
- "description": "Conventions for code under tests/integration/translation/",
- "scope": [
- "tests/integration/translation/**"
- ]
- },
- {
- "path": "tests/rust-python-harness/AGENTS.md",
- "description": "Conventions for code under tests/rust-python-harness/",
- "scope": [
- "tests/rust-python-harness/**"
- ]
- },
- {
- "path": "tests/rust-python-harness/strategies/e2e_parity/AGENTS.md",
- "description": "Conventions for code under tests/rust-python-harness/strategies/e2e_parity/",
- "scope": [
- "tests/rust-python-harness/strategies/e2e_parity/**"
- ]
- },
- {
- "path": "tests/rust-python-harness/strategies/trace_parity/AGENTS.md",
- "description": "Conventions for code under tests/rust-python-harness/strategies/trace_parity/",
- "scope": [
- "tests/rust-python-harness/strategies/trace_parity/**"
- ]
- },
- {
- "path": "tests/rust-python-harness/strategies/unit_tests_parity/AGENTS.md",
- "description": "Conventions for code under tests/rust-python-harness/strategies/unit_tests_parity/",
- "scope": [
- "tests/rust-python-harness/strategies/unit_tests_parity/**"
- ]
- },
- {
- "path": "tests/rust-python-harness/strategies/unit_tests_rust/AGENTS.md",
- "description": "Conventions for code under tests/rust-python-harness/strategies/unit_tests_rust/",
- "scope": [
- "tests/rust-python-harness/strategies/unit_tests_rust/**"
- ]
- },
- {
- "path": "tests/test_litellm_rust/AGENTS.md",
- "description": "Conventions for code under tests/test_litellm_rust/",
- "scope": [
- "tests/test_litellm_rust/**"
- ]
- },
- {
- "path": "tests/unit/AGENTS.md",
- "description": "Conventions for code under tests/unit/",
- "scope": [
- "tests/unit/**"
- ]
- },
- {
- "path": "tests/unit/rust_bridge/AGENTS.md",
- "description": "Conventions for code under tests/unit/rust_bridge/",
- "scope": [
- "tests/unit/rust_bridge/**"
- ]
- },
- {
- "path": "ui/litellm-dashboard/AGENTS.md",
- "description": "Conventions for code under ui/litellm-dashboard/",
- "scope": [
- "ui/litellm-dashboard/**"
- ]
- },
- {
- "path": "ui/litellm-dashboard/src/components/chat/AGENTS.md",
- "description": "Conventions for code under ui/litellm-dashboard/src/components/chat/",
- "scope": [
- "ui/litellm-dashboard/src/components/chat/**"
- ]
- }
- ]
-}
diff --git a/AGENTS.md b/AGENTS.md
index 7f8e9cedc26..2f254a2dadf 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -130,4 +130,4 @@ Before implementing:
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify
-Before requesting maintainer review, verify the current PR tip passes required CI and code coverage, meets Greptile confidence of at least 4/5, and has acceptable Veria and Bugbot reviews. Inspect warnings and findings, fix actionable issues, and rerun the affected checks and reviewers after changes. Record evidence for any false positive or unavailable review. Never treat a pending or missing bot result as a pass. Do not lower coverage thresholds or raise `ANY_CAPS` to satisfy a check
+Before requesting maintainer review, verify the current PR tip passes required CI and code coverage and has acceptable reviews from the review bots on the PR, such as Veria and Bugbot. Inspect warnings and findings, fix actionable issues, and rerun the affected checks and reviewers after changes. Record evidence for any false positive or unavailable review. Never treat a pending or missing bot result as a pass. Do not lower coverage thresholds or raise `ANY_CAPS` to satisfy a check
diff --git a/Makefile b/Makefile
index 2a96a8a5e93..1552187e906 100644
--- a/Makefile
+++ b/Makefile
@@ -39,7 +39,7 @@ help:
@echo " make check-circular-imports - Check for circular imports"
@echo " make check-import-safety - Check import safety"
@echo " make test - Run all tests"
- @echo " make test-unit - Run unit tests (tests/unit and tests/test_litellm)"
+ @echo " make test-unit - Run unit tests (tests/unit)"
@echo " make test-unit-llms - Run LLM provider tests (~225 files)"
@echo " make test-unit-proxy-guardrails - Run proxy guardrails+mgmt tests (~51 files)"
@echo " make test-unit-proxy-core - Run proxy auth+client+db+hooks tests (~52 files)"
@@ -300,7 +300,7 @@ test: install-test-deps
$(UV_RUN) pytest tests/
test-unit: install-test-deps
- $(UV_RUN) pytest tests/unit tests/test_litellm -x -vv -n 4
+ $(UV_RUN) pytest tests/unit -x -vv -n 4
# Matrix test targets (matching CI workflow groups)
test-unit-llms: install-test-deps
@@ -328,7 +328,7 @@ test-unit-other: install-test-deps
$(UV_RUN) pytest tests/unit/caching tests/unit/responses tests/unit/secret_managers tests/unit/vector_stores tests/unit/a2a_protocol tests/unit/completion_extras tests/unit/containers tests/unit/enterprise tests/unit/experimental_mcp_client tests/unit/google_genai tests/unit/images tests/unit/interactions tests/unit/router_strategy tests/unit/router_utils tests/unit/types --tb=short -vv -n 4 --durations=20
test-unit-root: install-test-deps
- $(UV_RUN) pytest tests/unit/test_*.py tests/test_litellm/test_*.py --tb=short -vv -n 4 --durations=20
+ $(UV_RUN) pytest tests/unit/test_*.py --tb=short -vv -n 4 --durations=20
# Proxy unit tests (tests/unit/proxy split alphabetically)
test-proxy-unit-a: install-test-deps
@@ -338,7 +338,7 @@ test-proxy-unit-b: install-test-deps
$(UV_RUN) pytest tests/unit/proxy/test_[p-z]*.py tests/unit/skills --tb=short -vv -n 2 --durations=20
test-integration: install-test-deps
- $(UV_RUN) pytest tests/ -k "not test_litellm"
+ $(UV_RUN) pytest tests/
test-unit-helm: install-helm-unittest
helm unittest -f 'tests/*.yaml' helm/litellm-helm
diff --git a/README.md b/README.md
index 9987f13d127..3bd0fd54a65 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
-
+
@@ -342,6 +342,7 @@ Set `LITELLM_PROXY_API_BASE` and `LITELLM_PROXY_API_KEY` and every model call th
| [Cohere Chat (`cohere_chat`)](https://docs.litellm.ai/docs/providers/cohere) | ✅ | ✅ | ✅ | | | | | | | |
| [CometAPI (`cometapi`)](https://docs.litellm.ai/docs/providers/cometapi) | ✅ | ✅ | ✅ | ✅ | | | | | | |
| [CompactifAI (`compactifai`)](https://docs.litellm.ai/docs/providers/compactifai) | ✅ | ✅ | ✅ | | | | | | | |
+| [CoralBricks (`coralbricks`)](https://docs.litellm.ai/docs/providers/coralbricks) | ✅ | ✅ | ✅ | | | | | | | |
| [Custom (`custom`)](https://docs.litellm.ai/docs/providers/custom_llm_server) | ✅ | ✅ | ✅ | | | | | | | |
| [Custom OpenAI (`custom_openai`)](https://docs.litellm.ai/docs/providers/openai_compatible) | ✅ | ✅ | ✅ | | | ✅ | ✅ | ✅ | ✅ | |
| [Dashscope (`dashscope`)](https://docs.litellm.ai/docs/providers/dashscope) | ✅ | ✅ | ✅ | ✅ | | | | | | ✅ |
@@ -526,9 +527,9 @@ Provider API keys live in AWS Secrets Manager; reference ARNs via `gateway_extra
#### GCP — Cloud Run + Cloud SQL + Memorystore + HTTPS LB
-[](https://ssh.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https%3A%2F%2Fgithub.com%2FBerriAI%2Flitellm&cloudshell_workspace=terraform%2Flitellm%2Fgcp%2Fexamples%2Fdefault&cloudshell_tutorial=TUTORIAL.md&cloudshell_image=gcr.io/ds-artifacts-cloudshell/deploystack_custom_image&shellonly=true)
+[](https://ssh.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https%3A%2F%2Fgithub.com%2FBerriAI%2Fterraform-google-litellm&cloudshell_git_branch=v1.105.0&cloudshell_workspace=examples%2Fdefault&cloudshell_tutorial=TUTORIAL.md&cloudshell_image=gcr.io/ds-artifacts-cloudshell/deploystack_custom_image&shellonly=true)
-Real 1-click. Opens Cloud Shell, clones this repo, and walks you through `terraform apply` via a built-in [DeployStack tutorial](./terraform/litellm/gcp/examples/default/TUTORIAL.md) — pick the project, the tutorial sets up the Artifact Registry remote repo, writes `terraform.tfvars` from your answers, and runs apply.
+Real 1-click. Opens Cloud Shell, clones the [terraform-google-litellm](https://github.com/BerriAI/terraform-google-litellm) module mirror at the latest stable release (a small download instead of the full litellm repo), and walks you through `terraform apply` via a built-in [DeployStack tutorial](./terraform/litellm/gcp/examples/default/TUTORIAL.md). Pick the project, the tutorial sets up the Artifact Registry remote repo, writes `terraform.tfvars` from your answers, and runs apply
[Module page →](https://registry.terraform.io/modules/BerriAI/litellm/google/latest)
diff --git a/ci_cd/cost_map_guard.py b/ci_cd/cost_map_guard.py
index 14eecf01461..e7b873e3f5c 100644
--- a/ci_cd/cost_map_guard.py
+++ b/ci_cd/cost_map_guard.py
@@ -56,7 +56,7 @@ def _rendered_schema(cost_map: CostMap) -> str:
def _evaluation_output_price_failure(
key: str, entry: object, evaluation_output_price_sources: Mapping[str, str]
) -> str | None:
- if not isinstance(entry, dict) or entry.get("mode") != "evaluation":
+ if not isinstance(entry, dict) or entry.get("mode") not in ("evaluation", "decisions"):
return None
output_cost: Final = entry.get("output_cost_per_token")
if (
@@ -67,7 +67,7 @@ def _evaluation_output_price_failure(
):
return None
return (
- f"{key}: mode evaluation rows bill input only; output_cost_per_token must be 0 unless the vendor "
+ f"{key}: mode evaluation and decisions rows bill input only; output_cost_per_token must be 0 unless the vendor "
"prices output tokens for this model (add the source to EVALUATION_OUTPUT_PRICE_SOURCES)"
)
diff --git a/codecov.yaml b/codecov.yaml
index ae72f6d5286..5b30e78a8fb 100644
--- a/codecov.yaml
+++ b/codecov.yaml
@@ -71,6 +71,8 @@ flag_management:
carryforward: false
- name: lens-python-310
carryforward: false
+ - name: lens-postgres
+ carryforward: false
- name: proxy-db-key-generation
carryforward: false
- name: proxy-db-auth-checks
diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py
index 4c06f0b9802..67894fbbf3c 100644
--- a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py
+++ b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py
@@ -1162,7 +1162,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
)
return False
- async def check_file_ids_access(self, file_ids: List[str], user_api_key_dict: UserAPIKeyAuth) -> None:
+ async def check_file_ids_access(self, file_ids: Sequence[str], user_api_key_dict: UserAPIKeyAuth) -> None:
"""
Check if the user has access to a list of file IDs.
Only checks managed (unified) file IDs.
@@ -1269,6 +1269,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
elif call_type == CallTypes.acreate_batch.value:
input_file_id = cast(Optional[str], data.get("input_file_id"))
if input_file_id:
+ await self.check_file_ids_access((input_file_id,), user_api_key_dict)
model_file_id_mapping = await self.get_model_file_id_mapping(
[input_file_id], user_api_key_dict.parent_otel_span
)
@@ -1315,11 +1316,12 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
elif retrieve_object_id and accessor_key == "batch_id":
await self.enforce_batch_object_access(retrieve_object_id, user_api_key_dict)
elif call_type == CallTypes.acreate_fine_tuning_job.value:
- input_file_id = cast(Optional[str], data.get("training_file"))
- if input_file_id:
- model_file_id_mapping = await self.get_model_file_id_mapping(
- [input_file_id], user_api_key_dict.parent_otel_span
- )
+ fine_tuning_file_ids: Final = tuple(
+ file_id
+ for file_id in (data.get("training_file"), data.get("validation_file"))
+ if isinstance(file_id, str) and file_id
+ )
+ await self.check_file_ids_access(fine_tuning_file_ids, user_api_key_dict)
return data
diff --git a/litellm-rust/AGENTS.md b/litellm-rust/AGENTS.md
index b028425c7c1..069d9fd99ea 100644
--- a/litellm-rust/AGENTS.md
+++ b/litellm-rust/AGENTS.md
@@ -11,9 +11,9 @@ Use the derived conversions directly (`<&'static str>::from(x)` / `.into()`, `st
## Test placement
- Never create a `tests.rs` (or `test.rs`) file under `src/`, and never `#[path = "tests.rs"] mod tests;`
-- A test that reaches private items lives inline, in a `#[cfg(test)] mod tests { ... }` at the bottom of the file that owns those items
-- A test that only uses the crate's public API lives in `crates//tests/.rs`, next to `src/`
-- Split a mixed test file along that line instead of widening visibility to move it
+- Tests live inline by default, in a `#[cfg(test)] mod tests { ... }` at the bottom of the file that owns the code under test, whether or not they touch private items
+- `crates//tests/` is only for tests that drive several modules together end to end, such as a full request through a route. A test of one type or function never goes there
+- A helper shared by several inline test modules goes in a `#[cfg(test)] mod test_support;` at the crate root, and only once a second module needs it
- A test for another crate's item belongs in that crate, not in a downstream one
- Never set `autotests = false` or hand-list `[[test]]` targets. Every file directly under `tests/` is discovered by cargo, and a shared helper goes in `tests//mod.rs` or `tests//support.rs` so it is not picked up as a test crate of its own
diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock
index b0c5b02a245..2f77b09221a 100644
--- a/litellm-rust/Cargo.lock
+++ b/litellm-rust/Cargo.lock
@@ -4024,6 +4024,7 @@ dependencies = [
"litellm-cache-memory",
"litellm-cache-response",
"litellm-core-utils",
+ "litellm-framer",
"litellm-host",
"litellm-host-native",
"litellm-http",
@@ -4107,9 +4108,11 @@ name = "litellm-inference-testing"
version = "0.1.0"
dependencies = [
"futures-util",
+ "litellm-auth",
"litellm-http",
"litellm-inference",
"litellm-secrets",
+ "tokio",
]
[[package]]
diff --git a/litellm-rust/crates/callbacks-legacy-python/src/adapter.rs b/litellm-rust/crates/callbacks-legacy-python/src/adapter.rs
index dd3da8991cc..9f56f12a8f9 100644
--- a/litellm-rust/crates/callbacks-legacy-python/src/adapter.rs
+++ b/litellm-rust/crates/callbacks-legacy-python/src/adapter.rs
@@ -442,7 +442,7 @@ impl LegacyLogging {
py: Python<'_>,
facts: &litellm_host::interceptors::ExecutionFacts,
) -> PyResult> {
- use litellm_host::interceptors::ResultSource;
+ use litellm_host::interceptors::{Cost, ResultSource};
let logger = self.logger()?.object(py);
let params = logger
@@ -462,6 +462,12 @@ impl LegacyLogging {
),
)?;
let details = logger.getattr("model_call_details")?;
+ let reported_cost = match &facts.cost {
+ Cost::Reported { amount } => amount.as_f64(),
+ Cost::CacheHit | Cost::Deferred => None,
+ };
+ logger.setattr("_provider_reported_cost", reported_cost)?;
+ details.call_method1("pop", ("response_cost", py.None()))?;
self.cache_key = match &facts.source {
ResultSource::Provider => None,
ResultSource::Cache { key } => Some(key.clone()),
@@ -958,6 +964,81 @@ on_pre_call = lambda additional_args: None
check = lambda: None
";
+ #[rstest]
+ #[case::reported(Some(0.37), false)]
+ #[case::free(Some(0.0), false)]
+ #[case::deferred(None, false)]
+ #[case::cached(None, true)]
+ fn result_ready_hands_only_a_reported_cost_to_the_spend_logging_record(
+ #[case] reported: Option,
+ #[case] cache_hit: bool,
+ #[values(0.001, 7.0)] existing_cost: f64,
+ ) {
+ Python::initialize();
+ Python::attach(|py| {
+ let locals = namespace(py, PAYLOAD_LOGGER);
+ run(
+ py,
+ &locals,
+ c"
+logger.litellm_params = {}
+logger.optional_params = {}
+logger.model_call_details = {'response_cost': 7.0}
+logger._provider_reported_cost = None
+",
+ );
+ let mut logging = LegacyLogging {
+ logger: Some(PythonLogger::new(local(&locals, "logger").unbind())),
+ ..legacy_call(py, &locals, false)
+ };
+ let source = if cache_hit {
+ litellm_host::interceptors::ResultSource::Cache {
+ key: "cached-test".into(),
+ }
+ } else {
+ litellm_host::interceptors::ResultSource::Provider
+ };
+ let facts = litellm_host::interceptors::ExecutionFacts {
+ provider: litellm_host::interceptors::ProviderIdentity {
+ model: "native-test".into(),
+ provider: "edenai".into(),
+ },
+ cost: litellm_host::interceptors::Cost::settle(
+ &source,
+ reported.and_then(serde_json::Number::from_f64),
+ ),
+ source,
+ };
+ let previous = litellm_host::interceptors::ExecutionFacts {
+ provider: facts.provider.clone(),
+ cost: litellm_host::interceptors::Cost::Reported {
+ amount: serde_json::Number::from_f64(9.0).unwrap(),
+ },
+ source: litellm_host::interceptors::ResultSource::Provider,
+ };
+ logging.result_ready(py, &previous).unwrap();
+ local(&locals, "logger")
+ .getattr("model_call_details")
+ .unwrap()
+ .set_item("response_cost", existing_cost)
+ .unwrap();
+ let step = logging.result_ready(py, &facts).unwrap();
+ assert!(matches!(step, HookStep::Ready(())));
+ locals.set_item("reported", reported).unwrap();
+ locals.set_item("cache_hit", cache_hit).unwrap();
+ run(
+ py,
+ &locals,
+ c"
+assert logger.model_call_details.get('response_cost') is None, logger.model_call_details
+assert logger._provider_reported_cost == reported, logger._provider_reported_cost
+assert logger.model_call_details['cache_hit'] is cache_hit, logger.model_call_details
+assert logger.update['custom_llm_provider'] == 'edenai', logger.update
+",
+ );
+ });
+ }
+
const DOCUMENT: &str = "data:application/pdf;base64,YWJj";
const EDITED: &str = "data:application/pdf;base64,ZWRpdGVk";
diff --git a/litellm-rust/crates/callbacks-legacy-python/src/python.rs b/litellm-rust/crates/callbacks-legacy-python/src/python.rs
index 47331f369f5..6f1ee9dfd78 100644
--- a/litellm-rust/crates/callbacks-legacy-python/src/python.rs
+++ b/litellm-rust/crates/callbacks-legacy-python/src/python.rs
@@ -1,7 +1,7 @@
use pyo3::prelude::*;
use strum::{IntoStaticStr, VariantArray};
-const MODULE: &str = "litellm.rust_bridge.callbacks_legacy_python";
+const MODULE: &str = "litellm.rust_bridge.host.callbacks_legacy_python";
/// Every litellm Python internal the native call still borrows, grouped by the subsystem it
/// belongs to. Rust drives the call; these exist only so behaviour that Python owns today
@@ -9,7 +9,7 @@ const MODULE: &str = "litellm.rust_bridge.callbacks_legacy_python";
/// A group is deleted once Rust owns that subsystem, so this enum only shrinks. Calling a
/// user's own callback is not borrowing and does not belong here.
///
-/// `litellm/rust_bridge/callbacks_legacy_python.py` is the only Python module behind it, and
+/// `litellm/rust_bridge/host/callbacks_legacy_python.py` is the only Python module behind it, and
/// `python_contract.json` pins each function's parameters on both sides.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum LegacyPython {
diff --git a/litellm-rust/crates/callbacks-legacy-python/src/test_support.rs b/litellm-rust/crates/callbacks-legacy-python/src/test_support.rs
index a7b28f1b0ca..093ce60e510 100644
--- a/litellm-rust/crates/callbacks-legacy-python/src/test_support.rs
+++ b/litellm-rust/crates/callbacks-legacy-python/src/test_support.rs
@@ -6,7 +6,7 @@ use pyo3::types::{PyDict, PyTuple};
use crate::{LegacyLogging, PublicCall};
/// The parameters of every `callbacks_legacy_python` function, as the real module declares them.
-/// `tests/unit/rust_bridge/test_callbacks_legacy_python.py` pins this file to the Python
+/// `tests/unit/rust_bridge/host/test_callbacks_legacy_python.py` pins this file to the Python
/// signatures, and [`namespace`] binds every fake call against it.
pub(crate) const PYTHON_CONTRACT: &str = include_str!("../python_contract.json");
@@ -23,10 +23,10 @@ import sys
import traceback
import types
-for name in ('litellm', 'litellm.rust_bridge', 'litellm.rust_bridge.callbacks_legacy_python'):
+for name in ('litellm', 'litellm.rust_bridge', 'litellm.rust_bridge.host', 'litellm.rust_bridge.host.callbacks_legacy_python'):
sys.modules.setdefault(name, types.ModuleType(name))
-legacy = sys.modules['litellm.rust_bridge.callbacks_legacy_python']
+legacy = sys.modules['litellm.rust_bridge.host.callbacks_legacy_python']
CONTRACT = json.loads(python_contract)
diff --git a/litellm-rust/crates/core-utils/src/get_llm_provider_logic.rs b/litellm-rust/crates/core-utils/src/get_llm_provider_logic.rs
index 6a82511db5c..acea48799c0 100644
--- a/litellm-rust/crates/core-utils/src/get_llm_provider_logic.rs
+++ b/litellm-rust/crates/core-utils/src/get_llm_provider_logic.rs
@@ -16,18 +16,28 @@ pub enum LlmProviders {
AzureAi,
#[strum(serialize = "bedrock")]
Bedrock,
+ #[strum(serialize = "bedrock_mantle")]
+ BedrockMantle,
#[strum(serialize = "cohere")]
Cohere,
#[strum(serialize = "deepseek")]
Deepseek,
+ #[strum(serialize = "edenai")]
+ Edenai,
+ #[strum(serialize = "minimax")]
+ Minimax,
#[strum(serialize = "mistral")]
Mistral,
#[strum(serialize = "openai")]
Openai,
#[strum(serialize = "openai_like")]
OpenaiLike,
+ #[strum(serialize = "openrouter")]
+ Openrouter,
#[strum(serialize = "reducto")]
Reducto,
+ #[strum(serialize = "tencent")]
+ Tencent,
#[strum(serialize = "vertex_ai")]
VertexAi,
}
diff --git a/litellm-rust/crates/gateway-inference/src/messages.rs b/litellm-rust/crates/gateway-inference/src/messages.rs
index e56308b7ae1..c7a8e3e3294 100644
--- a/litellm-rust/crates/gateway-inference/src/messages.rs
+++ b/litellm-rust/crates/gateway-inference/src/messages.rs
@@ -19,7 +19,12 @@ use crate::{Deployment, Error, Gateway, JsonObject, RequestId, request};
/// Client headers Python forwards to Anthropic-speaking providers on every call.
const ANTHROPIC_API_HEADERS: [&str; 2] = ["anthropic-version", "anthropic-beta"];
-const ANTHROPIC_API_HEADER_PROVIDERS: &str = "anthropic,bedrock,bedrock_mantle,vertex_ai";
+const OPENROUTER_API_HEADERS: [&str; 2] = [
+ "x-openrouter-metadata",
+ "x-openrouter-experimental-metadata",
+];
+const ANTHROPIC_API_HEADER_PROVIDERS: &str =
+ "anthropic,bedrock,bedrock_mantle,vertex_ai,openrouter";
pub async fn create(
State(gateway): State>,
@@ -88,17 +93,28 @@ fn project(
}
fn anthropic_api_headers(headers: &HeaderMap) -> Option {
- let extra_headers: Map = ANTHROPIC_API_HEADERS
+ let scopes = [
+ (
+ ANTHROPIC_API_HEADER_PROVIDERS,
+ ANTHROPIC_API_HEADERS.as_slice(),
+ ),
+ ("openrouter", OPENROUTER_API_HEADERS.as_slice()),
+ ];
+ let forwarded: Vec = scopes
.into_iter()
- .filter_map(|name| {
- let value = headers.get(name)?.to_str().ok()?;
- Some((name.to_owned(), Value::from(value)))
+ .filter_map(|(provider, names)| {
+ let extra_headers: Map = names
+ .iter()
+ .filter_map(|name| {
+ let value = headers.get(*name)?.to_str().ok()?;
+ Some(((*name).to_owned(), Value::from(value)))
+ })
+ .collect();
+ (!extra_headers.is_empty()).then(|| ProviderSpecificHeader {
+ custom_llm_provider: provider.into(),
+ extra_headers,
+ })
})
.collect();
- (!extra_headers.is_empty()).then(|| {
- ProviderSpecificHeaders::One(ProviderSpecificHeader {
- custom_llm_provider: ANTHROPIC_API_HEADER_PROVIDERS.into(),
- extra_headers,
- })
- })
+ (!forwarded.is_empty()).then_some(ProviderSpecificHeaders::Many(forwarded))
}
diff --git a/litellm-rust/crates/gateway-inference/tests/messages.rs b/litellm-rust/crates/gateway-inference/tests/messages.rs
index be500bad86c..b0a9baa2fac 100644
--- a/litellm-rust/crates/gateway-inference/tests/messages.rs
+++ b/litellm-rust/crates/gateway-inference/tests/messages.rs
@@ -192,3 +192,52 @@ async fn model_permissions_prevent_provider_calls(#[case] path: &str) {
assert_eq!(response.status(), 403);
assert!(upstream.received_requests().await.unwrap().is_empty());
}
+
+#[rstest]
+#[case::completion(false)]
+#[case::streaming(true)]
+#[tokio::test]
+async fn openrouter_receives_native_messages_headers_and_its_deployment_bearer(
+ #[case] streaming: bool,
+) {
+ let upstream = MockServer::start().await;
+ let message = json!({"id": "msg_test", "type": "message", "role": "assistant", "model": "anthropic/test-model", "content": [{"type": "text", "text": "hello"}], "stop_reason": "end_turn", "stop_sequence": null, "usage": {"input_tokens": 1, "output_tokens": 1, "cost": 0.001, "cost_details": "unexpected"}, "openrouter_metadata": {"provider": "test-provider"}});
+ let sse = "event: message_delta\ndata: {\"type\":\"message_delta\",\"usage\":{\"cost\":0.001}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";
+ let template = if streaming {
+ ResponseTemplate::new(200).set_body_raw(sse, "text/event-stream")
+ } else {
+ ResponseTemplate::new(200).set_body_json(message.clone())
+ };
+ Mock::given(method("POST"))
+ .and(path("/api/v1/messages"))
+ .and(header("authorization", "Bearer test-key"))
+ .and(header("anthropic-beta", "test-feature"))
+ .and(header("anthropic-version", "test-version"))
+ .and(header("x-openrouter-metadata", "enabled"))
+ .and(body_json(json!({"model": "anthropic/test-model", "messages": [{"role": "user", "content": "hi"}], "max_tokens": 16, "stream": streaming, "provider": {"order": ["test-provider"]}})))
+ .respond_with(template).expect(1).mount(&upstream).await;
+ let request = Request::post("/v1/messages")
+ .header("content-type", "application/json")
+ .header("anthropic-beta", "test-feature")
+ .header("anthropic-version", "test-version")
+ .header("x-openrouter-metadata", "enabled")
+ .header("x-api-key", "proxy-key")
+ .header("authorization", "Bearer proxy-key")
+ .body(Body::from(json!({"model": "public/model", "messages": [{"role": "user", "content": "hi"}], "max_tokens": 16, "stream": streaming, "provider": {"order": ["test-provider"]}}).to_string())).unwrap();
+ let response = support::app(
+ "openrouter/anthropic/test-model",
+ &format!("{}/api/v1", upstream.uri()),
+ )
+ .oneshot(request)
+ .await
+ .unwrap();
+ assert_eq!(response.status(), 200);
+ if streaming {
+ assert_eq!(to_bytes(response.into_body(), 4096).await.unwrap(), sse);
+ } else {
+ assert_eq!(support::json(response).await, message);
+ }
+ let requests = upstream.received_requests().await.unwrap();
+ assert_eq!(requests.len(), 1);
+ assert!(!requests[0].headers.contains_key("x-api-key"));
+}
diff --git a/litellm-rust/crates/gateway-mcp/AGENTS.md b/litellm-rust/crates/gateway-mcp/AGENTS.md
index 6f2171769b4..aa608f1e720 100644
--- a/litellm-rust/crates/gateway-mcp/AGENTS.md
+++ b/litellm-rust/crates/gateway-mcp/AGENTS.md
@@ -24,7 +24,7 @@ From the repository root, this loopback-only example runs the existing Python MC
```sh
cargo run --manifest-path litellm-rust/Cargo.toml -p litellm-gateway-mcp --example stdio_proxy -- \
- .venv/bin/python -c 'import runpy; runpy.run_path("tests/mcp_tests/mcp_e2e_upstream_server.py")["mcp"].run(transport="stdio")'
+ .venv/bin/python -c 'import runpy; runpy.run_path("tests/integration/_support/mcp_e2e_upstream_server.py")["mcp"].run(transport="stdio")'
```
```sh
diff --git a/litellm-rust/crates/gateway/README.md b/litellm-rust/crates/gateway/README.md
index c6c169e6115..125c2b07448 100644
--- a/litellm-rust/crates/gateway/README.md
+++ b/litellm-rust/crates/gateway/README.md
@@ -7,7 +7,7 @@ The binary reads YAML from `LITELLM_CONFIG` and mounts configured MCP servers al
From the repository root, start the Python fixture in one terminal:
```sh
-MCP_HOST=127.0.0.1 MCP_PORT=8090 .venv/bin/python tests/mcp_tests/mcp_e2e_upstream_server.py
+MCP_HOST=127.0.0.1 MCP_PORT=8090 .venv/bin/python tests/integration/_support/mcp_e2e_upstream_server.py
```
Start the gateway in another terminal with the example configuration:
diff --git a/litellm-rust/crates/host/src/interceptors.rs b/litellm-rust/crates/host/src/interceptors.rs
index 3633fc7ee39..f0fd7434c6d 100644
--- a/litellm-rust/crates/host/src/interceptors.rs
+++ b/litellm-rust/crates/host/src/interceptors.rs
@@ -42,10 +42,32 @@ pub enum ResultSource {
Cache { key: String },
}
+/// What the call cost, settled once by the route. A host records it; it never re-derives it.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum Cost {
+ /// The amount the provider itself billed, taken from its response.
+ Reported { amount: serde_json::Number },
+ /// Served from the cache, so nothing was billed.
+ CacheHit,
+ /// No provider figure: the host estimates from usage and its price table.
+ Deferred,
+}
+
+impl Cost {
+ pub fn settle(source: &ResultSource, reported: Option) -> Self {
+ match (source, reported) {
+ (ResultSource::Cache { .. }, _) => Self::CacheHit,
+ (ResultSource::Provider, Some(amount)) => Self::Reported { amount },
+ (ResultSource::Provider, None) => Self::Deferred,
+ }
+ }
+}
+
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExecutionFacts {
pub provider: ProviderIdentity,
pub source: ResultSource,
+ pub cost: Cost,
}
pub trait Interceptors: Send + Sync {
diff --git a/litellm-rust/crates/http/src/request.rs b/litellm-rust/crates/http/src/request.rs
index 7faae7f5c38..cfebe8c6b01 100644
--- a/litellm-rust/crates/http/src/request.rs
+++ b/litellm-rust/crates/http/src/request.rs
@@ -138,7 +138,9 @@ pub fn has_bearer_auth(headers: &[(String, String)]) -> bool {
}
let value = value.trim();
value.len() > 7
- && value[..7].eq_ignore_ascii_case("bearer ")
+ && value
+ .get(..7)
+ .is_some_and(|prefix| prefix.eq_ignore_ascii_case("bearer "))
&& !value[7..].trim().is_empty()
})
}
@@ -279,31 +281,19 @@ mod tests {
assert!(!has_header(&headers, "authorization"));
}
- #[test]
- fn bearer_detection_requires_a_non_empty_token() {
- assert!(has_bearer_auth(&[(
- "Authorization".to_string(),
- "Bearer abc".to_string()
- )]));
- assert!(has_bearer_auth(&[(
- "authorization".to_string(),
- "bearer abc".to_string()
- )]));
- assert!(!has_bearer_auth(&[(
- "Authorization".to_string(),
- "Bearer ".to_string()
- )]));
- assert!(!has_bearer_auth(&[(
- "authorization".to_string(),
- String::new()
- )]));
- assert!(!has_bearer_auth(&[(
- "Authorization".to_string(),
- "Basic abc".to_string()
- )]));
- assert!(!has_bearer_auth(&[(
- "x-api-key".to_string(),
- "abc".to_string()
- )]));
+ #[rstest::rstest]
+ #[case::bearer("Authorization", "Bearer abc", true)]
+ #[case::lowercase("authorization", "bearer abc", true)]
+ #[case::blank_token("Authorization", "Bearer ", false)]
+ #[case::empty("authorization", "", false)]
+ #[case::basic("Authorization", "Basic abc", false)]
+ #[case::different_header("x-api-key", "abc", false)]
+ #[case::non_ascii("authorization", "invali\u{00e9}auth", false)]
+ fn bearer_detection_requires_a_non_empty_token(
+ #[case] name: &str,
+ #[case] value: &str,
+ #[case] expected: bool,
+ ) {
+ assert_eq!(has_bearer_auth(&[(name.into(), value.into())]), expected);
}
}
diff --git a/litellm-rust/crates/inference-messages/Cargo.toml b/litellm-rust/crates/inference-messages/Cargo.toml
index 6801c758e54..e310a0be9b1 100644
--- a/litellm-rust/crates/inference-messages/Cargo.toml
+++ b/litellm-rust/crates/inference-messages/Cargo.toml
@@ -26,6 +26,7 @@ serde_json = { workspace = true, features = ["preserve_order"] }
tracing.workspace = true
[dev-dependencies]
+litellm-framer = { workspace = true, features = ["sse"] }
litellm-cache-memory.workspace = true
litellm-host-native.workspace = true
litellm-http = { workspace = true, features = ["test-support"] }
diff --git a/litellm-rust/crates/inference-messages/src/common_utils.rs b/litellm-rust/crates/inference-messages/src/common_utils.rs
index ea8c7cca4f7..66e7e1cfe25 100644
--- a/litellm-rust/crates/inference-messages/src/common_utils.rs
+++ b/litellm-rust/crates/inference-messages/src/common_utils.rs
@@ -1,65 +1,68 @@
use litellm_core_utils::get_llm_provider_logic::LlmProviders;
use litellm_http::request::string_headers as shared_string_headers;
pub(super) use litellm_http::request::truncate_error_body;
+use litellm_llms::tencent::messages::transformation::TENCENT_MESSAGES_CONFIG;
use litellm_llms::{
anthropic::messages::transformation::ANTHROPIC_MESSAGES_CONFIG,
azure_ai::messages::transformation::AZURE_ANTHROPIC_MESSAGES_CONFIG,
base_llm::messages::transformation::BaseMessagesConfig,
bedrock::messages::invoke_transformations::anthropic_claude3_transformation::BEDROCK_ANTHROPIC_MESSAGES_CONFIG,
deepseek::messages::transformation::DEEPSEEK_ANTHROPIC_MESSAGES_CONFIG,
+ edenai::messages::transformation::EDENAI_MESSAGES_CONFIG,
+ minimax::messages::transformation::MINIMAX_MESSAGES_CONFIG,
vertex_ai::messages::transformation::VERTEX_ANTHROPIC_MESSAGES_CONFIG,
};
+use litellm_llms::{
+ bedrock::claude_platform::messages::transformation::BEDROCK_CLAUDE_PLATFORM_MESSAGES_CONFIG,
+ bedrock_mantle::messages::transformation::BEDROCK_MANTLE_MESSAGES_CONFIG,
+};
use serde_json::{Map, Value};
use super::Error;
const HEADER_CONTEXT: &str = "messages";
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub(crate) enum MessagesProvider {
- Anthropic,
- AzureAi,
- Bedrock,
- Deepseek,
- VertexAi,
+#[derive(Clone, Copy)]
+pub(crate) struct MessagesProvider {
+ provider: LlmProviders,
+ config: &'static dyn BaseMessagesConfig,
}
impl MessagesProvider {
pub(crate) fn as_str(self) -> &'static str {
- match self {
- Self::Anthropic => LlmProviders::Anthropic,
- Self::AzureAi => LlmProviders::AzureAi,
- Self::Bedrock => LlmProviders::Bedrock,
- Self::Deepseek => LlmProviders::Deepseek,
- Self::VertexAi => LlmProviders::VertexAi,
- }
- .into()
+ self.provider.into()
}
pub(crate) fn config(self) -> &'static dyn BaseMessagesConfig {
- match self {
- Self::Anthropic => &ANTHROPIC_MESSAGES_CONFIG,
- Self::AzureAi => &AZURE_ANTHROPIC_MESSAGES_CONFIG,
- Self::Bedrock => &BEDROCK_ANTHROPIC_MESSAGES_CONFIG,
- Self::Deepseek => &DEEPSEEK_ANTHROPIC_MESSAGES_CONFIG,
- Self::VertexAi => &VERTEX_ANTHROPIC_MESSAGES_CONFIG,
- }
+ self.config
}
}
/// Python's `get_provider_anthropic_messages_config`: Vertex AI serves only its Claude
/// partner models on this route.
pub(crate) fn messages_provider(provider: LlmProviders, model: &str) -> Option {
- match provider {
- LlmProviders::Anthropic => Some(MessagesProvider::Anthropic),
- LlmProviders::AzureAi => Some(MessagesProvider::AzureAi),
- LlmProviders::Bedrock => Some(MessagesProvider::Bedrock),
- LlmProviders::Deepseek => Some(MessagesProvider::Deepseek),
- LlmProviders::VertexAi if model.to_ascii_lowercase().contains("claude") => {
- Some(MessagesProvider::VertexAi)
+ use litellm_llms::openrouter::messages::transformation::OPENROUTER_MESSAGES_CONFIG;
+
+ let config: &'static dyn BaseMessagesConfig = match provider {
+ LlmProviders::Tencent => &TENCENT_MESSAGES_CONFIG,
+ LlmProviders::Anthropic => &ANTHROPIC_MESSAGES_CONFIG,
+ LlmProviders::AzureAi => &AZURE_ANTHROPIC_MESSAGES_CONFIG,
+ LlmProviders::Bedrock if model.starts_with("claude_platform/") => {
+ &BEDROCK_CLAUDE_PLATFORM_MESSAGES_CONFIG
}
- _ => None,
- }
+ LlmProviders::Bedrock if model.starts_with("mantle/") => &BEDROCK_MANTLE_MESSAGES_CONFIG,
+ LlmProviders::Bedrock => &BEDROCK_ANTHROPIC_MESSAGES_CONFIG,
+ LlmProviders::BedrockMantle => &BEDROCK_MANTLE_MESSAGES_CONFIG,
+ LlmProviders::Deepseek => &DEEPSEEK_ANTHROPIC_MESSAGES_CONFIG,
+ LlmProviders::Edenai => &EDENAI_MESSAGES_CONFIG,
+ LlmProviders::Minimax => &MINIMAX_MESSAGES_CONFIG,
+ LlmProviders::VertexAi if model.to_ascii_lowercase().contains("claude") => {
+ &VERTEX_ANTHROPIC_MESSAGES_CONFIG
+ }
+ LlmProviders::Openrouter => &OPENROUTER_MESSAGES_CONFIG,
+ _ => return None,
+ };
+ Some(MessagesProvider { provider, config })
}
pub(super) fn string_headers(
@@ -74,24 +77,22 @@ mod tests {
use rstest::rstest;
- use super::{MessagesProvider, messages_provider, string_headers, truncate_error_body};
+ use super::{messages_provider, string_headers, truncate_error_body};
use crate::Error;
use litellm_core_utils::get_llm_provider_logic::LlmProviders;
#[rstest]
- #[case::anthropic("anthropic", MessagesProvider::Anthropic)]
- #[case::azure_ai("azure_ai", MessagesProvider::AzureAi)]
- #[case::bedrock("bedrock", MessagesProvider::Bedrock)]
- #[case::deepseek("deepseek", MessagesProvider::Deepseek)]
- #[case::vertex_ai("vertex_ai", MessagesProvider::VertexAi)]
- fn provider_round_trips_through_its_python_name(
- #[case] name: &str,
- #[case] provider: MessagesProvider,
- ) {
- assert_eq!(
- messages_provider(name.parse::().unwrap(), "claude-sonnet-4-5"),
- Some(provider)
- );
+ #[case::tencent("tencent")]
+ #[case::anthropic("anthropic")]
+ #[case::azure_ai("azure_ai")]
+ #[case::bedrock("bedrock")]
+ #[case::deepseek("deepseek")]
+ #[case::edenai("edenai")]
+ #[case::minimax("minimax")]
+ #[case::vertex_ai("vertex_ai")]
+ fn provider_keeps_its_python_name(#[case] name: &str) {
+ let provider =
+ messages_provider(name.parse::().unwrap(), "claude-sonnet-4-5").unwrap();
assert_eq!(provider.as_str(), name);
}
@@ -102,7 +103,7 @@ mod tests {
#[case] provider: LlmProviders,
#[case] model: &str,
) {
- assert_eq!(messages_provider(provider, model), None);
+ assert!(messages_provider(provider, model).is_none());
}
#[test]
diff --git a/litellm-rust/crates/inference-messages/src/handler.rs b/litellm-rust/crates/inference-messages/src/handler.rs
index 4e883320303..178f96c4c33 100644
--- a/litellm-rust/crates/inference-messages/src/handler.rs
+++ b/litellm-rust/crates/inference-messages/src/handler.rs
@@ -25,7 +25,7 @@ use litellm_inference::{context::CallContext, outbound::outbound_request};
pub(super) struct ProviderCall {
pub identity: ProviderIdentity,
pub wire: WireRequest,
- provider: super::common_utils::MessagesProvider,
+ pub(super) provider: super::common_utils::MessagesProvider,
signer: Option,
timeout: Option,
stream: bool,
diff --git a/litellm-rust/crates/inference-messages/src/lib.rs b/litellm-rust/crates/inference-messages/src/lib.rs
index 6cf7e34e52a..c690c7a2ef4 100644
--- a/litellm-rust/crates/inference-messages/src/lib.rs
+++ b/litellm-rust/crates/inference-messages/src/lib.rs
@@ -7,7 +7,7 @@ mod types;
use futures_util::FutureExt;
use litellm_auth::AuthServices;
-use litellm_host::interceptors::{ExecutionFacts, Interceptors, ResultSource};
+use litellm_host::interceptors::{Cost, ExecutionFacts, Interceptors, ResultSource};
use litellm_inference::{caching::CallCache, context::CallContext};
use litellm_secrets::source::SecretSource;
@@ -87,6 +87,7 @@ impl MessagesRoute {
&request.wire,
);
let identity = request.identity.clone();
+ let config = request.provider.config();
let (output, source) = match cache.lookup().await {
Some(hit) => hit,
None => (
@@ -94,9 +95,14 @@ impl MessagesRoute {
ResultSource::Provider,
),
};
+ let reported = match &output {
+ MessagesCallResponse::Complete(response) => config.reported_cost(response),
+ MessagesCallResponse::Stream { .. } => None,
+ };
context
.result_ready(ExecutionFacts {
provider: identity,
+ cost: Cost::settle(&source, reported),
source: source.clone(),
})
.await?;
diff --git a/litellm-rust/crates/inference-messages/src/prepare.rs b/litellm-rust/crates/inference-messages/src/prepare.rs
index 50a079d5eb3..3ec756b65d0 100644
--- a/litellm-rust/crates/inference-messages/src/prepare.rs
+++ b/litellm-rust/crates/inference-messages/src/prepare.rs
@@ -76,6 +76,10 @@ fn prepare_provider_request(
shaping,
..
} = call;
+ let connection_params = litellm_router_types::LitellmParams {
+ api_base: api_base.clone().or(litellm_params.api_base.clone()),
+ ..litellm_params
+ };
let config = provider.config();
let env_lookup = |key: &str| secrets.get(key);
@@ -95,25 +99,22 @@ fn prepare_provider_request(
let forwarded = string_headers(Some(
extra_headers.into_iter().flatten().chain(scoped).collect(),
))?;
- let validated = config.validate_environment(
+ let forwarded = config.request_headers(
+ with_default_headers(forwarded, config.default_headers()),
+ &transformed,
+ );
+ let environment = config.validate_environment(
forwarded,
api_key.as_deref(),
&transformed.model,
- &litellm_params,
+ &connection_params,
&env_lookup,
)?;
- let environment = ValidatedEnvironment {
- headers: config.request_headers(
- with_default_headers(validated.headers, config.default_headers()),
- &transformed,
- ),
- auth: validated.auth,
- };
let url = config.get_complete_url(
api_base.as_deref(),
&transformed.model,
- &litellm_params,
+ &connection_params,
transformed.params.stream == Some(true),
&env_lookup,
)?;
@@ -475,4 +476,44 @@ mod tests {
))
);
}
+ #[rstest]
+ #[case::mantle(
+ "bedrock_mantle/claude-test",
+ "https://bedrock-mantle.us-west-2.api.aws",
+ "/anthropic/v1/messages",
+ "bedrock"
+ )]
+ #[case::platform(
+ "bedrock/claude_platform/claude-test",
+ "https://aws-external-anthropic.us-west-2.api.aws",
+ "/v1/messages",
+ "aws-external-anthropic"
+ )]
+ fn explicit_endpoint_region_is_used_by_the_signer(
+ shaping: MessagesShaping,
+ #[case] model: &str,
+ #[case] base: &str,
+ #[case] path: &str,
+ #[case] service: &str,
+ ) {
+ let prepared = prepare_with_secrets(MessagesCall {
+ body: body(json!({"model": model, "max_tokens": 16, "messages": [{"role": "user", "content": "hi"}]})),
+ api_key: None,
+ api_base: Some(base.into()),
+ custom_llm_provider: None,
+ litellm_params: Default::default(),
+ extra_headers: None,
+ provider_specific_header: None,
+ timeout: None,
+ shaping,
+ }, &|name: &str| match name {
+ "AWS_REGION_NAME" => Some("us-east-1".into()),
+ "ANTHROPIC_AWS_WORKSPACE_ID" => Some("workspace".into()),
+ _ => None,
+ }).unwrap();
+ assert_eq!(prepared.url, format!("{base}{path}"));
+ assert!(
+ matches!(prepared.environment.auth, litellm_llms::base_llm::auth::AuthScheme::AwsSigV4 { region, service: actual, .. } if region == "us-west-2" && actual == service)
+ );
+ }
}
diff --git a/litellm-rust/crates/inference-messages/tests/bedrock_native/main.rs b/litellm-rust/crates/inference-messages/tests/bedrock_native/main.rs
new file mode 100644
index 00000000000..5420477c34a
--- /dev/null
+++ b/litellm-rust/crates/inference-messages/tests/bedrock_native/main.rs
@@ -0,0 +1,323 @@
+use std::{sync::Arc, time::Duration};
+
+use futures_util::TryStreamExt;
+use litellm_inference_messages::{Error, MessagesCall, MessagesCallResponse, MessagesShaping};
+use litellm_inference_testing::{RecordingSecrets, no_secrets};
+use litellm_llms_types::formats::messages::{MessagesRequest, MessagesResponse};
+use rstest::{fixture, rstest};
+use serde_json::{Value, json};
+use wiremock::ResponseTemplate;
+
+#[path = "../support/mod.rs"]
+mod support;
+use support::*;
+
+const MODEL: &str = "claude-test";
+
+#[fixture]
+fn call() -> MessagesCall {
+ MessagesCall {
+ body: serde_json::from_value(json!({"model": MODEL, "max_tokens": 16, "messages": [{"role": "user", "content": "hi"}]})).unwrap(),
+ api_key: None,
+ api_base: None,
+ custom_llm_provider: None,
+ litellm_params: Default::default(),
+ extra_headers: None,
+ provider_specific_header: None,
+ timeout: Some(Duration::from_secs(5)),
+ shaping: MessagesShaping::default(),
+ }
+}
+
+fn with_fields(call: MessagesCall, fields: Value) -> MessagesCall {
+ let body = serde_json::to_value(call.body).unwrap();
+ MessagesCall {
+ body: serde_json::from_value(Value::Object(
+ body.as_object()
+ .unwrap()
+ .clone()
+ .into_iter()
+ .chain(fields.as_object().unwrap().clone())
+ .collect(),
+ ))
+ .unwrap(),
+ ..call
+ }
+}
+
+fn with_model(call: MessagesCall, model: &str) -> MessagesCall {
+ MessagesCall {
+ body: MessagesRequest {
+ model: model.into(),
+ ..call.body
+ },
+ ..call
+ }
+}
+
+fn message_response() -> ResponseTemplate {
+ json_response(
+ json!({"id": "msg_1", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "hi"}], "model": MODEL, "stop_reason": "end_turn", "usage": {"input_tokens": 1, "output_tokens": 2}}),
+ )
+}
+
+async fn run_with(
+ secrets: Arc,
+ call: MessagesCall,
+) -> Result {
+ messages_route(secrets).execute(call, &(), None).await
+}
+
+async fn run(call: MessagesCall) -> Result {
+ run_with(Arc::new(RecordingSecrets::empty()), call).await
+}
+
+async fn run_message(call: MessagesCall) -> MessagesResponse {
+ match run(call).await.unwrap() {
+ MessagesCallResponse::Complete(message) => *message,
+ MessagesCallResponse::Stream { .. } => panic!("expected a completed message"),
+ }
+}
+
+#[rstest]
+#[case::platform(
+ "bedrock/claude_platform/claude-test",
+ "/v1/messages",
+ "x-api-key",
+ "key"
+)]
+#[case::mantle(
+ "bedrock_mantle/anthropic.claude-test",
+ "/anthropic/v1/messages",
+ "authorization",
+ "Bearer key"
+)]
+#[case::mantle_alias(
+ "bedrock/mantle/anthropic.claude-test",
+ "/anthropic/v1/messages",
+ "authorization",
+ "Bearer key"
+)]
+#[tokio::test]
+async fn prefixes_select_native_messages_and_strip_connection_fields(
+ call: MessagesCall,
+ #[case] model: &str,
+ #[case] path: &str,
+ #[case] header: &str,
+ #[case] credential: &str,
+) {
+ let upstream = upstream([message_response()]).await;
+ let message = run_message(MessagesCall {
+ api_key: Some("key".into()),
+ api_base: Some(upstream.uri()),
+ custom_llm_provider: None,
+ ..with_fields(
+ call,
+ json!({"model": model, "workspace_id": "request-workspace"}),
+ )
+ })
+ .await;
+ assert_eq!(message.model, MODEL);
+ let request = only_request(&upstream).await;
+ assert_eq!(request.url.path(), path);
+ assert_eq!(request.header(header), Some(credential));
+ assert_eq!(request.json()["model"], model.rsplit('/').next().unwrap());
+ if path == "/v1/messages" {
+ assert_eq!(
+ request.header("anthropic-workspace-id"),
+ Some("request-workspace")
+ );
+ assert_eq!(request.json().get("workspace_id"), None);
+ }
+}
+
+#[rstest]
+#[case::platform(
+ "bedrock/claude_platform/claude-test",
+ "ANTHROPIC_AWS_API_KEY",
+ "ANTHROPIC_AWS_BASE_URL",
+ "/v1/messages",
+ "x-api-key",
+ "managed"
+)]
+#[case::mantle(
+ "bedrock_mantle/claude-test",
+ "AWS_BEARER_TOKEN_BEDROCK",
+ "BEDROCK_MANTLE_API_BASE",
+ "/anthropic/v1/messages",
+ "authorization",
+ "Bearer managed"
+)]
+#[tokio::test]
+async fn managed_keys_bases_and_workspaces_reach_the_upstream(
+ call: MessagesCall,
+ #[case] model: &str,
+ #[case] key_name: &str,
+ #[case] base_name: &str,
+ #[case] path: &str,
+ #[case] header: &str,
+ #[case] credential: &str,
+) {
+ let upstream = upstream([message_response()]).await;
+ let base = upstream.uri();
+ let secrets = Arc::new(RecordingSecrets::new([
+ (key_name, "managed"),
+ (base_name, base.as_str()),
+ ("ANTHROPIC_AWS_WORKSPACE_ID", "managed-workspace"),
+ ]));
+ let output = run_with(
+ secrets,
+ MessagesCall {
+ custom_llm_provider: None,
+ ..with_model(call, model)
+ },
+ )
+ .await
+ .unwrap();
+ assert!(matches!(output, MessagesCallResponse::Complete(_)));
+ let request = only_request(&upstream).await;
+ assert_eq!(request.url.path(), path);
+ assert_eq!(request.header(header), Some(credential));
+ if path == "/v1/messages" {
+ assert_eq!(
+ request.header("anthropic-workspace-id"),
+ Some("managed-workspace")
+ );
+ }
+}
+
+#[rstest]
+#[case::platform("bedrock/claude_platform/claude-test", "aws-external-anthropic")]
+#[case::mantle("bedrock_mantle/claude-test", "bedrock")]
+#[tokio::test]
+async fn managed_aws_credentials_sign_the_exact_native_request(
+ call: MessagesCall,
+ #[case] model: &str,
+ #[case] service: &str,
+) {
+ let upstream = upstream([message_response()]).await;
+ let secrets = Arc::new(RecordingSecrets::new([
+ ("AWS_ACCESS_KEY_ID", "test-access"),
+ ("AWS_SECRET_ACCESS_KEY", "test-secret"),
+ ("AWS_SESSION_TOKEN", "test-session"),
+ ("AWS_REGION_NAME", "us-west-2"),
+ ("ANTHROPIC_AWS_WORKSPACE_ID", "managed-workspace"),
+ ]));
+ run_with(
+ secrets,
+ MessagesCall {
+ api_base: Some(upstream.uri()),
+ custom_llm_provider: None,
+ ..with_model(call, model)
+ },
+ )
+ .await
+ .unwrap();
+ let request = only_request(&upstream).await;
+ let auth = request.header("authorization").unwrap();
+ assert!(auth.starts_with("AWS4-HMAC-SHA256 Credential=test-access/"));
+ assert!(auth.contains(&format!("/us-west-2/{service}/aws4_request")));
+ assert_eq!(request.header("x-amz-security-token"), Some("test-session"));
+ assert_eq!(request.header("x-api-key"), None);
+ assert_eq!(request.json()["model"], "claude-test");
+}
+
+#[rstest]
+#[tokio::test]
+async fn private_mantle_endpoint_is_used_and_signed(call: MessagesCall) {
+ let upstream = upstream([message_response()]).await;
+ let params = litellm_router_types::LitellmParams {
+ aws: litellm_auth::AwsParams {
+ aws_bedrock_runtime_endpoint: Some(upstream.uri()),
+ aws_access_key_id: Some("private-access".into()),
+ aws_secret_access_key: Some("private-secret".into()),
+ aws_region_name: Some("us-west-2".into()),
+ ..Default::default()
+ },
+ ..Default::default()
+ };
+ run(MessagesCall {
+ custom_llm_provider: None,
+ litellm_params: params,
+ ..with_model(call, "bedrock/mantle/claude-test")
+ })
+ .await
+ .unwrap();
+ let request = only_request(&upstream).await;
+ assert_eq!(request.url.path(), "/anthropic/v1/messages");
+ assert!(
+ request
+ .header("authorization")
+ .unwrap()
+ .contains("/us-west-2/bedrock/aws4_request")
+ );
+}
+
+#[rstest]
+#[case::platform("bedrock/claude_platform/claude-test")]
+#[case::mantle("bedrock_mantle/claude-test")]
+#[tokio::test]
+async fn native_streams_relay_every_sse_byte(call: MessagesCall, #[case] model: &str) {
+ let sse = "event: message_start\ndata: {\"type\":\"message_start\"}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";
+ let upstream =
+ upstream([ResponseTemplate::new(200).set_body_raw(sse, "text/event-stream")]).await;
+ let response = messages_route(no_secrets())
+ .execute(
+ MessagesCall {
+ api_key: Some("key".into()),
+ api_base: Some(upstream.uri()),
+ custom_llm_provider: None,
+ ..with_fields(
+ call,
+ json!({"model": model, "stream": true, "workspace_id": "workspace"}),
+ )
+ },
+ &(),
+ None,
+ )
+ .await
+ .unwrap();
+ let MessagesCallResponse::Stream { chunks, .. } = response else {
+ panic!("expected SSE");
+ };
+ assert_eq!(
+ chunks.try_collect::>().await.unwrap().concat(),
+ sse.as_bytes()
+ );
+ assert_eq!(only_request(&upstream).await.json()["stream"], true);
+}
+
+#[rstest]
+#[case::missing(false)]
+#[case::configured(true)]
+#[tokio::test]
+async fn deployment_workspace_is_required_and_sent_as_a_header(
+ call: MessagesCall,
+ #[case] configured: bool,
+) {
+ let upstream = upstream([message_response()]).await;
+ let params = if configured {
+ serde_json::from_value(json!({"model": "bedrock/claude_platform/claude-test", "workspace_id": "deployment-workspace"})).unwrap()
+ } else {
+ Default::default()
+ };
+ let result = run(MessagesCall {
+ api_key: Some("key".into()),
+ api_base: Some(upstream.uri()),
+ litellm_params: params,
+ ..with_model(call, "bedrock/claude_platform/claude-test")
+ })
+ .await;
+ if !configured {
+ assert!(matches!(result, Err(Error::Auth(_))));
+ assert!(received(&upstream).await.is_empty());
+ return;
+ }
+ assert!(matches!(result.unwrap(), MessagesCallResponse::Complete(_)));
+ let sent = only_request(&upstream).await;
+ assert_eq!(
+ sent.header("anthropic-workspace-id"),
+ Some("deployment-workspace")
+ );
+ assert_eq!(sent.json().get("workspace_id"), None);
+}
diff --git a/litellm-rust/crates/inference-messages/tests/caching.rs b/litellm-rust/crates/inference-messages/tests/caching.rs
index e9005123119..32c962091ad 100644
--- a/litellm-rust/crates/inference-messages/tests/caching.rs
+++ b/litellm-rust/crates/inference-messages/tests/caching.rs
@@ -13,8 +13,8 @@ use litellm_cache_response::{
CacheOptions, CacheScope, ResponseCache, ResponseCacheConfig, ResponseCacheService, ScopedCache,
};
use litellm_host::interceptors::{
- ExecutionFacts, Interceptors, ProviderIdentity, RawResponse, RequestContext, ResultSource,
- WireRequest,
+ Cost, ExecutionFacts, Interceptors, ProviderIdentity, RawResponse, RequestContext,
+ ResultSource, WireRequest,
};
use litellm_inference::{
RouteError,
@@ -241,3 +241,55 @@ async fn messages_cache_identity_follows_resolved_configuration_and_request_call
first.verify().await;
second.verify().await;
}
+
+#[rstest]
+#[case::reported(json!(0.37), Some(0.37))]
+#[case::free(json!(0), Some(0.0))]
+#[case::malformed(json!("invalid"), None)]
+#[case::missing(Value::Null, None)]
+#[tokio::test]
+async fn provider_reported_cost_is_delivered_once_and_never_charged_for_a_cache_hit(
+ cache: Arc,
+ #[case] cost: Value,
+ #[case] expected: Option,
+) {
+ use litellm_inference_messages::{MessagesCall, MessagesCallResponse};
+ let response = json!({"id": "msg_cost", "type": "message", "role": "assistant", "model": "native-test",
+ "content": [{"type": "text", "text": "answer"}], "stop_reason": "end_turn", "stop_sequence": null,
+ "usage": {"input_tokens": 3, "output_tokens": 2}, "cost": cost});
+ let upstream = support::upstream([support::json_response(response)]).await;
+ let hooks = ChangingHooks::default();
+ let route = support::messages_route(litellm_inference_testing::no_secrets())
+ .with_cache(ScopedCache::new(cache, CacheScope::Shared));
+ let request = || {
+ MessagesCall {
+ body: serde_json::from_value(json!({"model": "native-test", "messages": [{"role": "user", "content": "hi"}], "max_tokens": 16})).unwrap(),
+ api_key: Some("eden-key".into()), api_base: Some(upstream.uri()), custom_llm_provider: Some("edenai".into()),
+ litellm_params: Default::default(), extra_headers: None, provider_specific_header: None,
+ timeout: None, shaping: Default::default(),
+ }
+ };
+ let first = route.execute(request(), &hooks, None).await.unwrap();
+ let second = route.execute(request(), &hooks, None).await.unwrap();
+ let (MessagesCallResponse::Complete(first), MessagesCallResponse::Complete(second)) =
+ (first, second)
+ else {
+ panic!("expected Messages responses");
+ };
+ assert_eq!(first, second);
+ assert_eq!(first.extra.get("cost"), Some(&cost));
+ assert_eq!(support::received(&upstream).await.len(), 1);
+ let facts = hooks.facts.lock().unwrap();
+ let [provider, cached] = facts.as_slice() else {
+ panic!("expected provider and cache results");
+ };
+ assert_eq!(provider.source, ResultSource::Provider);
+ assert_eq!(
+ provider.cost,
+ expected.map_or(Cost::Deferred, |amount| Cost::Reported {
+ amount: serde_json::Number::from_f64(amount).unwrap()
+ })
+ );
+ assert!(matches!(cached.source, ResultSource::Cache { .. }));
+ assert_eq!(cached.cost, Cost::CacheHit);
+}
diff --git a/litellm-rust/crates/inference-messages/tests/live.rs b/litellm-rust/crates/inference-messages/tests/live.rs
new file mode 100644
index 00000000000..24468598141
--- /dev/null
+++ b/litellm-rust/crates/inference-messages/tests/live.rs
@@ -0,0 +1,8 @@
+#[path = "live/support.rs"]
+mod support;
+
+#[path = "live/edenai.rs"]
+mod edenai;
+
+#[path = "live/vertex_ai.rs"]
+mod vertex_ai;
diff --git a/litellm-rust/crates/inference-messages/tests/live/edenai.rs b/litellm-rust/crates/inference-messages/tests/live/edenai.rs
new file mode 100644
index 00000000000..28c17679865
--- /dev/null
+++ b/litellm-rust/crates/inference-messages/tests/live/edenai.rs
@@ -0,0 +1,92 @@
+use litellm_host::interceptors::Cost;
+use litellm_inference_messages::MessagesRoute;
+use litellm_inference_testing::live::within_deadline;
+use rstest::rstest;
+use serde_json::{Number, Value, json};
+
+use super::support::{LiveCall, assert_text, call, complete, request, route, stream_events};
+
+const PROVIDER: &str = "edenai";
+
+#[rstest]
+#[case::basic(json!({"messages": [{"role": "user", "content": "Say hello."}]}))]
+#[case::system_text(json!({
+ "system": "You are a helpful assistant.",
+ "messages": [{"role": "user", "content": "Say hello."}]
+}))]
+#[case::portable_cache_hint(json!({
+ "system": [{"type": "text", "text": "You are a helpful assistant.", "cache_control": {"type": "ephemeral", "ttl": "1h"}}],
+ "messages": [{"role": "user", "content": "Say hello."}]
+}))]
+#[ignore = "calls a real provider, requires credentials and a live model"]
+#[tokio::test]
+async fn completion_and_cost_handoff(route: MessagesRoute, #[case] payload: Value) {
+ within_deadline(async {
+ let host = LiveCall::new(PROVIDER);
+ let message = complete(request(&route, call(PROVIDER, payload), &host).await);
+ assert_text(&message);
+ host.assert_provider_result();
+ let expected_cost = message
+ .extra
+ .get("cost")
+ .and_then(|value| match value {
+ Value::Number(number) => number.as_f64(),
+ Value::String(text) => text.parse::().ok(),
+ _ => None,
+ })
+ .filter(|cost| *cost >= 0.0)
+ .and_then(Number::from_f64)
+ .map_or(Cost::Deferred, |amount| Cost::Reported { amount });
+ assert_eq!(host.facts().cost, expected_cost);
+ println!("{}", serde_json::to_string(&message).unwrap());
+ })
+ .await;
+}
+
+#[rstest]
+#[ignore = "calls a real provider, requires credentials and a live model"]
+#[tokio::test]
+async fn streaming(route: MessagesRoute) {
+ within_deadline(async {
+ let host = LiveCall::new(PROVIDER);
+ let events = stream_events(
+ request(
+ &route,
+ call(
+ PROVIDER,
+ json!({
+ "stream": true, "messages": [{"role": "user", "content": "Say hello."}]
+ }),
+ ),
+ &host,
+ )
+ .await,
+ )
+ .await;
+ assert!(events.iter().any(|event| {
+ event["delta"]["text"]
+ .as_str()
+ .is_some_and(|text| !text.is_empty())
+ }));
+ host.assert_provider_result();
+ assert_eq!(host.facts().cost, Cost::Deferred);
+ })
+ .await;
+}
+
+#[rstest]
+#[case::complete(false)]
+#[case::streaming(true)]
+#[ignore = "calls a real provider, requires credentials and a live model"]
+#[tokio::test]
+async fn tool_round_trip(route: MessagesRoute, #[case] stream: bool) {
+ super::support::tool_round_trip(
+ route,
+ PROVIDER,
+ stream,
+ json!({
+ "max_tokens": 512, "tool_choice": {"type": "auto"}
+ }),
+ )
+ .await;
+}
diff --git a/litellm-rust/crates/inference-messages/tests/live/support.rs b/litellm-rust/crates/inference-messages/tests/live/support.rs
new file mode 100644
index 00000000000..81e295e34ad
--- /dev/null
+++ b/litellm-rust/crates/inference-messages/tests/live/support.rs
@@ -0,0 +1,265 @@
+use std::{sync::OnceLock, time::Duration};
+
+use futures_util::{FutureExt, TryStreamExt};
+use litellm_host::interceptors::{
+ ExecutionFacts, Interceptors, RawResponse, RequestContext, ResultSource, WireRequest,
+};
+use litellm_inference_messages::{Error, MessagesCall, MessagesCallResponse, MessagesRoute};
+use litellm_inference_testing::live::{LiveResources, model, within_deadline};
+use litellm_llms_types::formats::messages::MessagesResponse;
+use serde_json::{Value, json};
+
+pub(super) struct LiveCall {
+ provider: &'static str,
+ facts: OnceLock,
+}
+
+impl LiveCall {
+ pub(super) fn new(provider: &'static str) -> Self {
+ Self {
+ provider,
+ facts: OnceLock::new(),
+ }
+ }
+
+ pub(super) fn facts(&self) -> &ExecutionFacts {
+ self.facts
+ .get()
+ .expect("the route must deliver execution facts")
+ }
+
+ pub(super) fn assert_provider_result(&self) {
+ let facts = self.facts();
+ assert_eq!(facts.provider.provider, self.provider);
+ assert_eq!(facts.source, ResultSource::Provider);
+ println!(
+ "{}",
+ json!({"route": "messages", "provider": self.provider, "source": "provider"})
+ );
+ }
+}
+
+impl Interceptors for LiveCall {
+ async fn result_ready(&self, facts: ExecutionFacts) -> Result<(), Error> {
+ assert!(
+ self.facts.set(facts).is_ok(),
+ "result must be accepted exactly once"
+ );
+ Ok(())
+ }
+
+ async fn before_provider_request(
+ &self,
+ wire: WireRequest,
+ context: RequestContext,
+ ) -> Result {
+ assert_eq!(context.custom_llm_provider, self.provider);
+ println!(
+ "{}",
+ json!({"route": "messages", "provider": self.provider, "model": context.model})
+ );
+ Ok(wire)
+ }
+
+ async fn after_provider_response(&self, _: RawResponse) -> Result<(), Error> {
+ Ok(())
+ }
+}
+
+#[rstest::fixture]
+pub(super) fn route() -> MessagesRoute {
+ let resources = LiveResources::default();
+ MessagesRoute::new(resources.http, resources.auth, resources.secrets)
+}
+
+pub(super) async fn request(
+ route: &MessagesRoute,
+ call: MessagesCall,
+ host: &LiveCall,
+) -> MessagesCallResponse {
+ route
+ .execute(call, host, None)
+ .boxed()
+ .await
+ .expect("live request failed")
+}
+
+pub(super) fn call(provider: &str, payload: Value) -> MessagesCall {
+ let Value::Object(fields) = payload else {
+ panic!("live payload must be an object")
+ };
+ let body = serde_json::from_value(Value::Object(
+ [("max_tokens".into(), json!(128))]
+ .into_iter()
+ .chain(fields)
+ .chain([("model".into(), json!(model("messages", provider)))])
+ .collect(),
+ ))
+ .expect("live payload must be a valid Messages request");
+ MessagesCall {
+ body,
+ api_key: None,
+ api_base: None,
+ custom_llm_provider: Some(provider.into()),
+ litellm_params: Default::default(),
+ extra_headers: None,
+ provider_specific_header: None,
+ timeout: Some(Duration::from_secs(60)),
+ shaping: Default::default(),
+ }
+}
+
+pub(super) fn complete(response: MessagesCallResponse) -> Box {
+ match response {
+ MessagesCallResponse::Complete(message) => message,
+ MessagesCallResponse::Stream { .. } => panic!("a complete request returned a stream"),
+ }
+}
+
+pub(super) fn assert_text(message: &MessagesResponse) {
+ assert!(!message.id.is_empty());
+ assert_eq!(message.role, "assistant");
+ assert!(message.stop_reason.is_some());
+ assert!(
+ message
+ .content
+ .iter()
+ .any(|block| block["text"].as_str().is_some_and(|text| !text.is_empty()))
+ );
+ assert!(
+ message
+ .usage
+ .as_ref()
+ .and_then(|usage| usage["output_tokens"].as_u64())
+ .is_some_and(|count| count > 0)
+ );
+}
+
+pub(super) async fn stream_events(response: MessagesCallResponse) -> Vec {
+ let MessagesCallResponse::Stream { head, chunks } = response else {
+ panic!("stream request returned a complete response")
+ };
+ let content_types: Vec<_> = head
+ .headers
+ .iter()
+ .filter(|(name, _)| name.eq_ignore_ascii_case("content-type"))
+ .map(|(_, value)| value.split(';').next().unwrap().trim())
+ .collect();
+ assert_eq!(content_types, ["text/event-stream"]);
+ let frames = litellm_framer::frames(chunks, litellm_framer::sse::SseCodec::default())
+ .try_collect::>()
+ .await
+ .expect("live SSE framing failed");
+ let data_frames = match frames.last() {
+ Some(frame) if frame.data == "[DONE]" => &frames[..frames.len() - 1],
+ _ => frames.as_slice(),
+ };
+ let events: Vec = data_frames
+ .iter()
+ .map(|frame| serde_json::from_str(&frame.data).expect("SSE data must be JSON"))
+ .collect();
+ assert_eq!(
+ events.first().map(|event| event["type"].as_str()),
+ Some(Some("message_start"))
+ );
+ assert_eq!(
+ events.last().map(|event| event["type"].as_str()),
+ Some(Some("message_stop"))
+ );
+ assert!(!events.iter().any(|event| event["type"] == "error"));
+ assert!(events.iter().any(|event| {
+ event["usage"]["output_tokens"]
+ .as_u64()
+ .is_some_and(|count| count > 0)
+ }));
+ println!("{}", json!({"events": events}));
+ events
+}
+
+fn prompt() -> Value {
+ json!({"role": "user", "content": "Use the echo tool with value live-check, then tell me the result."})
+}
+
+fn tools() -> Value {
+ json!([{"name": "echo", "description": "Return a value", "input_schema": {
+ "type": "object", "properties": {"value": {"type": "string", "enum": ["live-check"]}}, "required": ["value"]
+ }}])
+}
+
+fn fragments(events: &[Value], index: u64, field: &str) -> String {
+ events
+ .iter()
+ .filter(|event| event["index"].as_u64() == Some(index))
+ .filter_map(|event| event["delta"][field].as_str())
+ .collect()
+}
+
+fn streamed_block(start: &Value, events: &[Value]) -> Value {
+ let index = start["index"]
+ .as_u64()
+ .expect("content block must have an index");
+ let block = &start["content_block"];
+ match block["type"].as_str() {
+ Some("tool_use") => {
+ let partial = fragments(events, index, "partial_json");
+ let input = match partial.is_empty() {
+ true => block["input"].clone(),
+ false => {
+ serde_json::from_str(&partial).expect("streamed tool arguments must be JSON")
+ }
+ };
+ json!({"type": "tool_use", "id": block["id"], "name": block["name"], "input": input})
+ }
+ Some("text") => {
+ json!({"type": "text", "text": format!("{}{}", block["text"].as_str().unwrap_or_default(), fragments(events, index, "text"))})
+ }
+ Some("thinking") => {
+ json!({"type": "thinking", "thinking": format!("{}{}", block["thinking"].as_str().unwrap_or_default(), fragments(events, index, "thinking")),
+ "signature": format!("{}{}", block["signature"].as_str().unwrap_or_default(), fragments(events, index, "signature"))})
+ }
+ _ => block.clone(),
+ }
+}
+
+pub(super) async fn tool_round_trip(
+ route: MessagesRoute,
+ provider: &'static str,
+ stream: bool,
+ tool_params: Value,
+) {
+ within_deadline(async {
+ let first_host = LiveCall::new(provider);
+ let payload = json!({"messages": [prompt()], "tools": tools(), "stream": stream});
+ let fields = payload.as_object().expect("tool payload must be an object").clone();
+ let params = tool_params.as_object().expect("tool parameters must be an object").clone();
+ let first = request(&route, call(provider, Value::Object(fields.into_iter().chain(params).collect())), &first_host).await;
+ let content = match stream {
+ false => {
+ let message = complete(first);
+ assert_eq!(message.stop_reason.as_deref(), Some("tool_use"));
+ message.content
+ }
+ true => {
+ let events = stream_events(first).await;
+ assert!(events.iter().any(|event| event["delta"]["stop_reason"] == "tool_use"));
+ events.iter().filter(|event| event["type"] == "content_block_start")
+ .map(|event| streamed_block(event, &events)).collect()
+ }
+ };
+ let tool = content.iter().find(|block| block["type"] == "tool_use").expect("requested tool must be returned");
+ assert_eq!(tool["name"], "echo");
+ assert_eq!(tool["input"]["value"], "live-check");
+ let tool_id = tool["id"].as_str().filter(|id| !id.is_empty()).expect("tool must have an id");
+ first_host.assert_provider_result();
+ let second_host = LiveCall::new(provider);
+ let second = complete(request(&route, call(provider, json!({
+ "tools": tools(), "messages": [prompt(),
+ {"role": "assistant", "content": content},
+ {"role": "user", "content": [{"type": "tool_result", "tool_use_id": tool_id, "content": "live-check"}]}]
+ })), &second_host).await);
+ assert_text(&second);
+ second_host.assert_provider_result();
+ println!("{}", serde_json::to_string(&second).unwrap());
+ })
+ .await;
+}
diff --git a/litellm-rust/crates/inference-messages/tests/live/vertex_ai.rs b/litellm-rust/crates/inference-messages/tests/live/vertex_ai.rs
new file mode 100644
index 00000000000..1f46f295e6b
--- /dev/null
+++ b/litellm-rust/crates/inference-messages/tests/live/vertex_ai.rs
@@ -0,0 +1,68 @@
+use litellm_inference_messages::MessagesRoute;
+use litellm_inference_testing::live::within_deadline;
+use rstest::rstest;
+use serde_json::{Value, json};
+
+use super::support::{LiveCall, assert_text, call, complete, request, route, stream_events};
+
+const PROVIDER: &str = "vertex_ai";
+
+#[rstest]
+#[case::basic(json!({"messages": [{"role": "user", "content": "Say hello."}]}))]
+#[case::system_text(json!({
+ "system": "You are a helpful assistant.",
+ "messages": [{"role": "user", "content": "Say hello."}]
+}))]
+#[ignore = "calls a real provider, requires credentials and a live model"]
+#[tokio::test]
+async fn completion(route: MessagesRoute, #[case] payload: Value) {
+ within_deadline(async {
+ let host = LiveCall::new(PROVIDER);
+ let message = complete(request(&route, call(PROVIDER, payload), &host).await);
+ assert_text(&message);
+ host.assert_provider_result();
+ println!("{}", serde_json::to_string(&message).unwrap());
+ })
+ .await;
+}
+
+#[rstest]
+#[ignore = "calls a real provider, requires credentials and a live model"]
+#[tokio::test]
+async fn streaming(route: MessagesRoute) {
+ within_deadline(async {
+ let host = LiveCall::new(PROVIDER);
+ let response = request(
+ &route,
+ call(
+ PROVIDER,
+ json!({"stream": true, "messages": [{"role": "user", "content": "Say hello."}]}),
+ ),
+ &host,
+ )
+ .await;
+ let events = stream_events(response).await;
+ assert!(events.iter().any(|event| {
+ event["delta"]["text"]
+ .as_str()
+ .is_some_and(|text| !text.is_empty())
+ }));
+ host.assert_provider_result();
+ })
+ .await;
+}
+
+#[rstest]
+#[case::complete(false)]
+#[case::streaming(true)]
+#[ignore = "calls a real provider, requires credentials and a live model"]
+#[tokio::test]
+async fn tool_round_trip(route: MessagesRoute, #[case] stream: bool) {
+ super::support::tool_round_trip(
+ route,
+ PROVIDER,
+ stream,
+ json!({"tool_choice": {"type": "auto"}}),
+ )
+ .await;
+}
diff --git a/litellm-rust/crates/inference-messages/tests/messages/main.rs b/litellm-rust/crates/inference-messages/tests/messages/main.rs
index 8f92b31c999..fcc432acb76 100644
--- a/litellm-rust/crates/inference-messages/tests/messages/main.rs
+++ b/litellm-rust/crates/inference-messages/tests/messages/main.rs
@@ -24,6 +24,7 @@ mod request;
mod response;
mod secrets;
mod stream;
+mod tencent;
const MODEL: &str = "claude-sonnet-4-5";
diff --git a/litellm-rust/crates/inference-messages/tests/messages/request.rs b/litellm-rust/crates/inference-messages/tests/messages/request.rs
index 25a318ecad5..aa8cb05b170 100644
--- a/litellm-rust/crates/inference-messages/tests/messages/request.rs
+++ b/litellm-rust/crates/inference-messages/tests/messages/request.rs
@@ -11,6 +11,9 @@ use super::*;
#[rstest]
#[case::anthropic_key("anthropic", Some("sk-ant"), &[], ("x-api-key", "sk-ant"), &["authorization"])]
+#[case::minimax_key("minimax", Some("sk-test"), &[], ("x-api-key", "sk-test"), &["authorization"])]
+#[case::minimax_forwarded_key("minimax", Some("deployment"), &[("X-Api-Key", "caller")], ("x-api-key", "caller"), &["authorization"])]
+#[case::minimax_forwarded_bearer("minimax", None, &[("Authorization", "Bearer caller")], ("authorization", "Bearer caller"), &["x-api-key"])]
#[case::azure_key("azure_ai", Some("sk-azure"), &[], ("x-api-key", "sk-azure"), &["authorization"])]
#[case::deepseek_key("deepseek", Some("sk-deepseek"), &[], ("x-api-key", "sk-deepseek"), &["authorization"])]
#[case::deepseek_forwards_caller_authorization(
@@ -88,6 +91,7 @@ async fn credentials_become_exactly_one_auth_header(
#[case::anthropic("anthropic")]
#[case::azure_ai("azure_ai")]
#[case::deepseek("deepseek")]
+#[case::minimax("minimax")]
#[tokio::test]
async fn a_call_without_credentials_fails_before_sending(
call: MessagesCall,
@@ -123,6 +127,9 @@ async fn a_call_without_credentials_fails_before_sending(
"/v1/messages",
"/v1/messages"
)]
+#[case::minimax(MODEL, Some("minimax"), "", "/v1/messages")]
+#[case::minimax_prefix("minimax/claude-sonnet-4-5", None, "/v1/messages", "/v1/messages")]
+#[case::minimax_endpoint_slash(MODEL, Some("minimax"), "/v1/messages/", "/v1/messages")]
#[case::azure_ai(MODEL, Some("azure_ai"), "", "/anthropic/v1/messages")]
#[case::deepseek(MODEL, Some("deepseek"), "", "/anthropic/v1/messages")]
#[case::deepseek_openai_compatible_base(MODEL, Some("deepseek"), "/beta", "/anthropic/v1/messages")]
@@ -301,6 +308,7 @@ fn sent_betas(request: &wiremock::Request) -> BetaSet {
#[tokio::test]
async fn feature_betas_join_the_callers_betas_in_one_sorted_header(
call: MessagesCall,
+ #[values("anthropic", "minimax")] provider: &str,
#[case] fields: Value,
#[case] features: &[AnthropicBeta],
) {
@@ -312,6 +320,7 @@ async fn feature_betas_join_the_callers_betas_in_one_sorted_header(
run_message(with_fields(
MessagesCall {
+ custom_llm_provider: Some(provider.into()),
api_key: Some("sk".into()),
api_base: Some(upstream.uri()),
extra_headers: headers([("Anthropic-Beta", "caller-beta-2025-01-01")]),
@@ -362,6 +371,7 @@ async fn an_oauth_key_sends_the_browser_access_header_and_the_oauth_beta(call: M
#[case::anthropic("anthropic")]
#[case::azure_ai("azure_ai")]
#[case::deepseek("deepseek")]
+#[case::minimax("minimax")]
#[tokio::test]
async fn caller_protocol_headers_win_over_the_defaults(call: MessagesCall, #[case] provider: &str) {
let upstream = upstream([message_response()]).await;
@@ -452,6 +462,7 @@ async fn unsupported_params_are_dropped_under_drop_params_and_rejected_without_i
#[tokio::test]
async fn reasoning_auto_summary_marks_active_thinking_on_the_wire(
call: MessagesCall,
+ #[values("anthropic", "minimax")] provider: &str,
#[case] thinking: Value,
#[case] expected: Value,
) {
@@ -459,6 +470,7 @@ async fn reasoning_auto_summary_marks_active_thinking_on_the_wire(
run_message(with_fields(
MessagesCall {
+ custom_llm_provider: Some(provider.into()),
api_key: Some("sk".into()),
api_base: Some(upstream.uri()),
shaping: MessagesShaping {
@@ -517,6 +529,7 @@ async fn reasoning_auto_summary_marks_active_thinking_on_the_wire(
#[tokio::test]
async fn reasoning_is_translated_by_the_model_capabilities(
call: MessagesCall,
+ #[values("anthropic", "minimax")] provider: &str,
#[case] capabilities: MessagesModelCapabilities,
#[case] fields: Value,
#[case] expected: Value,
@@ -525,6 +538,7 @@ async fn reasoning_is_translated_by_the_model_capabilities(
run_message(with_fields(
MessagesCall {
+ custom_llm_provider: Some(provider.into()),
api_key: Some("sk".into()),
api_base: Some(upstream.uri()),
shaping: MessagesShaping {
@@ -592,6 +606,7 @@ async fn replayed_history_is_cleaned_before_sending(
#[case::anthropic("anthropic")]
#[case::azure("azure_ai")]
#[case::deepseek("deepseek")]
+#[case::minimax("minimax")]
#[tokio::test]
async fn metadata_is_reduced_to_the_user_id(call: MessagesCall, #[case] provider: &str) {
let upstream = upstream([message_response()]).await;
@@ -703,11 +718,16 @@ async fn vertex_ai_addresses_the_model_in_the_url_and_not_in_the_body(
#[case::numeric_user_id(json!({"metadata": {"user_id": 7}}))]
#[case::missing_max_tokens(json!({"max_tokens": null}))]
#[tokio::test]
-async fn an_invalid_request_fails_before_sending(call: MessagesCall, #[case] fields: Value) {
+async fn an_invalid_request_fails_before_sending(
+ call: MessagesCall,
+ #[values("anthropic", "minimax")] provider: &str,
+ #[case] fields: Value,
+) {
let upstream = upstream([message_response()]).await;
let error = run(with_fields(
MessagesCall {
+ custom_llm_provider: Some(provider.into()),
api_key: Some("sk".into()),
api_base: Some(upstream.uri()),
..call
@@ -721,6 +741,45 @@ async fn an_invalid_request_fails_before_sending(call: MessagesCall, #[case] fie
assert!(received(&upstream).await.is_empty());
}
+#[rstest]
+#[case::billing_string(json!("x-anthropic-billing-header: private"), None)]
+#[case::billing_blocks(json!([{ "type": "text", "text": "x-anthropic-billing-header: private" }]), None)]
+#[case::mixed_blocks(
+ json!([{ "type": "text", "text": "x-anthropic-billing-header: private" }, { "type": "text", "text": "keep" }]),
+ Some(json!([{ "type": "text", "text": "keep" }]))
+)]
+#[case::ordinary_system(json!("keep"), Some(json!("keep")))]
+#[tokio::test]
+async fn minimax_strips_only_billing_system_blocks(
+ call: MessagesCall,
+ #[case] system: Value,
+ #[case] expected: Option,
+) {
+ let upstream = upstream([message_response()]).await;
+ let fields = json!({
+ "system": system,
+ "tools": [{"name": "echo", "input_schema": {"type": "object"}}],
+ "messages": [{"role": "user", "content": [{
+ "type": "text", "text": "hi", "cache_control": {"type": "ephemeral", "ttl": "1h"}
+ }]}]
+ });
+ run_message(with_fields(
+ MessagesCall {
+ custom_llm_provider: Some("minimax".into()),
+ api_key: Some("sk".into()),
+ api_base: Some(upstream.uri()),
+ ..call
+ },
+ fields.clone(),
+ ))
+ .await;
+
+ let sent = only_request(&upstream).await.json();
+ assert_eq!(sent.get("system"), expected.as_ref());
+ assert_eq!(sent["tools"], fields["tools"]);
+ assert_eq!(sent["messages"], fields["messages"]);
+}
+
#[rstest]
#[case::azure("azure_ai", &[], json!([
{"type": "text", "text": "top level"},
@@ -835,3 +894,38 @@ async fn provider_validation_runs_before_caller_parameter_removal(
);
assert!(received(&upstream).await.is_empty());
}
+
+#[rstest]
+#[tokio::test]
+async fn edenai_preserves_native_thinking_and_strips_billing_and_cache_extensions(
+ call: MessagesCall,
+) {
+ let upstream = upstream([message_response()]).await;
+ let thinking = json!({"type": "enabled", "budget_tokens": 512, "native_field": true});
+ run_message(MessagesCall {
+ custom_llm_provider: Some("edenai".into()),
+ api_base: Some(format!("{}/v3", upstream.uri())),
+ api_key: Some("eden-key".into()),
+ extra_headers: headers([("Anthropic-Beta", "unregistered-native-beta")]),
+ ..with_fields(call, json!({
+ "thinking": thinking, "temperature": 0.25,
+ "system": [{"type": "text", "text": "x-anthropic-billing-header: hidden"},
+ {"type": "text", "text": "keep", "cache_control": {"type": "ephemeral", "ttl": "1h", "scope": "global"}}],
+ }))
+ }).await;
+ let sent = only_request(&upstream).await;
+ assert_eq!(sent.url.path(), "/v3/v1/messages");
+ assert_eq!(sent.header("authorization"), Some("Bearer eden-key"));
+ assert_eq!(
+ sent.header("anthropic-beta"),
+ Some("unregistered-native-beta")
+ );
+ assert_eq!(
+ sent.json(),
+ json!({
+ "model": MODEL, "max_tokens": 16, "messages": [{"role": "user", "content": "hi"}],
+ "thinking": thinking, "temperature": 0.25,
+ "system": [{"type": "text", "text": "keep", "cache_control": {"type": "ephemeral"}}],
+ })
+ );
+}
diff --git a/litellm-rust/crates/inference-messages/tests/messages/secrets.rs b/litellm-rust/crates/inference-messages/tests/messages/secrets.rs
index 203cc620d35..d4f7d2bf3a7 100644
--- a/litellm-rust/crates/inference-messages/tests/messages/secrets.rs
+++ b/litellm-rust/crates/inference-messages/tests/messages/secrets.rs
@@ -25,6 +25,13 @@ use super::*;
"/anthropic/v1/messages",
&["DEEPSEEK_API_KEY", "DEEPSEEK_ANTHROPIC_API_BASE", "DEEPSEEK_API_BASE"]
)]
+#[case::minimax(
+ "minimax",
+ "MINIMAX_API_KEY",
+ "MINIMAX_API_BASE",
+ "/v1/messages",
+ &["MINIMAX_API_KEY", "MINIMAX_API_BASE"]
+)]
#[tokio::test]
async fn the_credential_and_base_come_from_the_secret_source(
call: MessagesCall,
diff --git a/litellm-rust/crates/inference-messages/tests/messages/stream.rs b/litellm-rust/crates/inference-messages/tests/messages/stream.rs
index 049a620e454..cda688107b5 100644
--- a/litellm-rust/crates/inference-messages/tests/messages/stream.rs
+++ b/litellm-rust/crates/inference-messages/tests/messages/stream.rs
@@ -150,9 +150,21 @@ async fn stream_through(host: &RecordingStreamHost) -> Result MessagesCall {
+ MessagesCall {
+ custom_llm_provider: Some("tencent".into()),
+ api_key: Some("deployment-key".into()),
+ ..call
+ }
+}
+
+#[rstest]
+#[case::deployment_key(&[], "x-api-key", "deployment-key")]
+#[case::forwarded_key(&[("X-Api-Key", "caller-key")], "x-api-key", "caller-key")]
+#[case::forwarded_bearer(&[("Authorization", "Bearer caller")], "authorization", "Bearer caller")]
+#[tokio::test]
+async fn credentials_reach_the_selected_provider(
+ tencent_call: MessagesCall,
+ #[case] forwarded: &[(&str, &str)],
+ #[case] header: &str,
+ #[case] credential: &str,
+) {
+ let upstream = upstream([message_response()]).await;
+ run_message(MessagesCall {
+ api_base: Some(upstream.uri()),
+ extra_headers: headers(forwarded.iter().copied()),
+ ..tencent_call
+ })
+ .await;
+ let sent = only_request(&upstream).await;
+ assert_eq!(sent.header_values(header), [credential]);
+ let version = DEFAULT_HEADERS
+ .iter()
+ .find_map(|(name, value)| (*name == "anthropic-version").then_some(*value))
+ .unwrap();
+ assert_eq!(sent.header_values("anthropic-version"), [version]);
+ assert_eq!(sent.json()["model"], MODEL);
+}
+
+#[rstest]
+#[tokio::test]
+async fn credentials_and_base_resolve_from_the_secret_source(tencent_call: MessagesCall) {
+ let upstream = upstream([message_response()]).await;
+ let base = format!("{}/v1/chat/completions/", upstream.uri());
+ let secrets = Arc::new(RecordingSecrets::new([
+ ("TENCENT_API_KEY", "secret-key"),
+ ("TENCENT_ANTHROPIC_API_BASE", base.as_str()),
+ ("TENCENT_API_BASE", UNREACHABLE_BASE),
+ ]));
+ run_with(
+ secrets,
+ MessagesCall {
+ api_key: None,
+ ..tencent_call
+ },
+ )
+ .await
+ .unwrap();
+ let sent = only_request(&upstream).await;
+ assert_eq!(sent.url.path(), "/v1/messages");
+ assert_eq!(sent.header("x-api-key"), Some("secret-key"));
+}
+
+#[rstest]
+#[tokio::test]
+async fn missing_credentials_fail_before_sending(tencent_call: MessagesCall) {
+ let upstream = upstream([message_response()]).await;
+ let error = run(MessagesCall {
+ api_key: None,
+ api_base: Some(upstream.uri()),
+ ..tencent_call
+ })
+ .await
+ .unwrap_err();
+ assert!(matches!(
+ error,
+ Error::Auth(litellm_auth::Error::MissingApiKey {
+ environment_variable: "TENCENT_API_KEY",
+ ..
+ })
+ ));
+ assert!(received(&upstream).await.is_empty());
+}
+
+#[rstest]
+#[case::missing_max_tokens(json!({"max_tokens": null}))]
+#[case::invalid_metadata(json!({"metadata": {"user_id": 7}}))]
+#[case::invalid_reasoning_effort(json!({"reasoning_effort": "unknown"}))]
+#[tokio::test]
+async fn malformed_requests_fail_before_sending(tencent_call: MessagesCall, #[case] fields: Value) {
+ let upstream = upstream([message_response()]).await;
+ let error = run(with_fields(
+ MessagesCall {
+ api_base: Some(upstream.uri()),
+ ..tencent_call
+ },
+ fields,
+ ))
+ .await
+ .unwrap_err();
+ assert!(error.is_request(), "{error:?}");
+ assert!(received(&upstream).await.is_empty());
+}
+
+#[rstest]
+#[case::legacy(false, json!({"type": "enabled", "budget_tokens": 4095}))]
+#[case::adaptive(true, json!({"type": "adaptive", "display": "summarized"}))]
+#[tokio::test]
+async fn reasoning_effort_becomes_model_appropriate_thinking(
+ tencent_call: MessagesCall,
+ #[case] adaptive: bool,
+ #[case] thinking: Value,
+) {
+ let upstream = upstream([message_response()]).await;
+ run_message(with_fields(
+ MessagesCall {
+ api_base: Some(upstream.uri()),
+ shaping: MessagesShaping {
+ capabilities: MessagesModelCapabilities {
+ supports_reasoning: true,
+ supports_adaptive_thinking: adaptive,
+ ..Default::default()
+ },
+ ..Default::default()
+ },
+ ..tencent_call
+ },
+ json!({"max_tokens": 4096, "reasoning_effort": "high"}),
+ ))
+ .await;
+ let sent = only_request(&upstream).await.json();
+ assert_eq!(sent["thinking"], thinking);
+ assert_eq!(sent.get("reasoning_effort"), None);
+ assert_eq!(
+ sent.get("output_config"),
+ adaptive.then(|| json!({"effort": "high"})).as_ref()
+ );
+}
+
+#[rstest]
+#[case::encrypted_reasoning(json!({"type": "thinking", "thinking": "private", "signature": "litellm_encrypted_reasoning:private"}))]
+#[case::orphaned_advisor(json!({"type": "server_tool_use", "name": "advisor", "id": "srvtoolu_advisor", "input": {}}))]
+#[tokio::test]
+async fn replayed_history_is_cleaned_without_losing_text(
+ tencent_call: MessagesCall,
+ #[case] block: Value,
+) {
+ let upstream = upstream([message_response()]).await;
+ run_message(with_fields(
+ MessagesCall {
+ api_base: Some(upstream.uri()),
+ ..tencent_call
+ },
+ json!({"messages": [
+ {"role": "assistant", "content": [block, {"type": "text", "text": "keep"}]},
+ {"role": "user", "content": "continue"}
+ ]}),
+ ))
+ .await;
+ assert_eq!(
+ only_request(&upstream).await.json()["messages"],
+ json!([
+ {"role": "assistant", "content": [{"type": "text", "text": "keep"}]},
+ {"role": "user", "content": "continue"}
+ ])
+ );
+}
+
+#[rstest]
+#[case::billing_string(json!("x-anthropic-billing-header: private"), None)]
+#[case::mixed_blocks(json!([
+ {"type": "text", "text": "x-anthropic-billing-header: private"},
+ {"type": "text", "text": "keep"}
+]), Some(json!([{ "type": "text", "text": "keep" }])))]
+#[case::ordinary_system(json!("keep"), Some(json!("keep")))]
+#[tokio::test]
+async fn billing_is_removed_and_tools_and_cache_hints_are_preserved(
+ tencent_call: MessagesCall,
+ #[case] system: Value,
+ #[case] expected: Option,
+) {
+ let upstream = upstream([message_response()]).await;
+ let fields = json!({
+ "system": system,
+ "tools": [{"name": "echo", "input_schema": {"type": "object"}}],
+ "messages": [{"role": "user", "content": [{
+ "type": "text", "text": "hi", "cache_control": {"type": "ephemeral", "ttl": "1h"}
+ }]}]
+ });
+ run_message(with_fields(
+ MessagesCall {
+ api_base: Some(upstream.uri()),
+ ..tencent_call
+ },
+ fields.clone(),
+ ))
+ .await;
+ let sent = only_request(&upstream).await.json();
+ assert_eq!(sent.get("system"), expected.as_ref());
+ assert_eq!(sent["tools"], fields["tools"]);
+ assert_eq!(sent["messages"], fields["messages"]);
+}
+
+#[derive(Default)]
+struct StreamSink(Mutex>);
+
+impl litellm_host_native::in_process::StreamConsumer for StreamSink {
+ async fn open_stream(
+ &self,
+ _: litellm_inference_messages::route::MessagesStreamHead,
+ ) -> Result, Error> {
+ Ok(ControlFlow::Continue(()))
+ }
+
+ async fn send_chunk(&self, chunk: Bytes) -> Result, Error> {
+ self.0.lock().unwrap().extend_from_slice(&chunk);
+ Ok(ControlFlow::Continue(()))
+ }
+}
+
+#[rstest]
+#[tokio::test]
+async fn streaming_relays_provider_events_unchanged(tencent_call: MessagesCall) {
+ let events = concat!(
+ "event: message_start\ndata: {\"type\":\"message_start\"}\n\n",
+ "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"OK\"}}\n\n",
+ "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
+ );
+ let upstream =
+ upstream([ResponseTemplate::new(200).set_body_raw(events, "text/event-stream")]).await;
+ let host = LocalMessagesHost::new(with_fields(
+ MessagesCall {
+ api_base: Some(upstream.uri()),
+ ..tencent_call
+ },
+ json!({"stream": true}),
+ ));
+ let sink = StreamSink::default();
+ let outcome = litellm_host_native::in_process::run_hosted(
+ machine(Arc::new(RecordingSecrets::empty()))(host.request().unwrap()),
+ litellm_host_native::in_process::Host {
+ services: &(),
+ interceptors: &host,
+ stream: &sink,
+ observers: None,
+ },
+ )
+ .await
+ .unwrap();
+ assert!(matches!(outcome, MessagesOutput::StreamEnded));
+ assert_eq!(sink.0.into_inner().unwrap(), events.as_bytes());
+ assert_eq!(only_request(&upstream).await.json()["stream"], true);
+}
diff --git a/litellm-rust/crates/inference-messages/tests/openrouter/main.rs b/litellm-rust/crates/inference-messages/tests/openrouter/main.rs
new file mode 100644
index 00000000000..74444142d37
--- /dev/null
+++ b/litellm-rust/crates/inference-messages/tests/openrouter/main.rs
@@ -0,0 +1,147 @@
+use std::{sync::Arc, time::Duration};
+
+use futures_util::TryStreamExt;
+use litellm_inference_messages::{Error, MessagesCall, MessagesCallResponse, MessagesShaping};
+use litellm_inference_testing::{RecordingSecrets, no_secrets};
+use rstest::{fixture, rstest};
+use serde_json::json;
+use wiremock::ResponseTemplate;
+
+#[path = "../support/mod.rs"]
+mod support;
+use support::*;
+
+#[fixture]
+fn call() -> MessagesCall {
+ MessagesCall {
+ body: serde_json::from_value(json!({"model": "openrouter/anthropic/test-model", "max_tokens": 16, "messages": [{"role": "user", "content": "hi"}]})).unwrap(),
+ api_key: None,
+ api_base: None,
+ custom_llm_provider: None,
+ litellm_params: Default::default(),
+ extra_headers: None,
+ provider_specific_header: None,
+ timeout: Some(Duration::from_secs(5)),
+ shaping: MessagesShaping::default(),
+ }
+}
+
+fn message_response() -> ResponseTemplate {
+ json_response(
+ json!({"id": "msg_1", "type": "message", "role": "assistant", "model": "anthropic/test-model", "content": [{"type": "text", "text": "hi"}], "stop_reason": "end_turn", "usage": {"input_tokens": 1, "output_tokens": 1}}),
+ )
+}
+
+#[rstest]
+#[case::explicit(Some("explicit"), None, "Bearer explicit")]
+#[case::managed(None, None, "Bearer managed")]
+#[case::forwarded(None, Some("bEaReR caller"), "bEaReR caller")]
+#[case::blank(Some("explicit"), Some("Bearer "), "Bearer explicit")]
+#[case::malformed(Some("explicit"), Some("invali\u{00e9}auth"), "Bearer explicit")]
+#[tokio::test]
+async fn only_a_valid_bearer_can_override_the_configured_key(
+ call: MessagesCall,
+ #[case] api_key: Option<&str>,
+ #[case] authorization: Option<&str>,
+ #[case] expected: &str,
+) {
+ let upstream = upstream([message_response()]).await;
+ let extra_headers = [("x-api-key".to_string(), json!("caller-anthropic-key"))]
+ .into_iter()
+ .chain(authorization.map(|value| ("Authorization".to_string(), json!(value))))
+ .collect();
+ let secrets = Arc::new(RecordingSecrets::new([("OPENROUTER_API_KEY", "managed")]));
+ let output = messages_route(secrets)
+ .execute(
+ MessagesCall {
+ api_key: api_key.map(str::to_string),
+ api_base: Some(format!("{}/api/v1", upstream.uri())),
+ extra_headers: Some(extra_headers),
+ ..call
+ },
+ &(),
+ None,
+ )
+ .await
+ .unwrap();
+ assert!(matches!(output, MessagesCallResponse::Complete(_)));
+ let request = only_request(&upstream).await;
+ assert_eq!(request.url.path(), "/api/v1/messages");
+ assert_eq!(request.header_values("authorization"), [expected]);
+ assert_eq!(request.header("x-api-key"), None);
+ assert_eq!(request.json()["model"], "anthropic/test-model");
+}
+
+#[rstest]
+#[tokio::test]
+async fn an_anthropic_key_alone_is_not_an_openrouter_credential(call: MessagesCall) {
+ let upstream = upstream([message_response()]).await;
+ let result = messages_route(no_secrets())
+ .execute(
+ MessagesCall {
+ api_base: Some(upstream.uri()),
+ extra_headers: Some(
+ json!({"x-api-key": "caller-key"})
+ .as_object()
+ .unwrap()
+ .clone(),
+ ),
+ ..call
+ },
+ &(),
+ None,
+ )
+ .await;
+ assert!(matches!(
+ result,
+ Err(Error::Auth(litellm_auth::Error::MissingApiKey {
+ provider: "OpenRouter",
+ ..
+ }))
+ ));
+ assert!(received(&upstream).await.is_empty());
+}
+
+#[rstest]
+#[tokio::test]
+async fn the_secret_manager_supplies_the_native_base_and_key(call: MessagesCall) {
+ let upstream = upstream([message_response()]).await;
+ let base = format!("{}/api/v1", upstream.uri());
+ let secrets = Arc::new(RecordingSecrets::new([
+ ("OPENROUTER_API_KEY", "managed"),
+ ("OPENROUTER_API_BASE", base.as_str()),
+ ]));
+ messages_route(secrets)
+ .execute(call, &(), None)
+ .await
+ .unwrap();
+ let request = only_request(&upstream).await;
+ assert_eq!(request.url.path(), "/api/v1/messages");
+ assert_eq!(request.header("authorization"), Some("Bearer managed"));
+}
+
+#[rstest]
+#[tokio::test]
+async fn streaming_relay_preserves_every_event_and_billed_usage(call: MessagesCall) {
+ let sse = "event: message_delta\ndata: {\"type\":\"message_delta\",\"usage\":{\"cost\":\"0.001\",\"cost_details\":\"unexpected\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";
+ let upstream =
+ upstream([ResponseTemplate::new(200).set_body_raw(sse, "text/event-stream")]).await;
+ let streamed = MessagesCall {
+ body: serde_json::from_value(json!({"model": "openrouter/anthropic/test-model", "max_tokens": 16, "messages": [{"role": "user", "content": "hi"}], "stream": true})).unwrap(),
+ api_key: Some("key".into()),
+ api_base: Some(upstream.uri()),
+ ..call
+ };
+ let output = messages_route(no_secrets())
+ .execute(streamed, &(), None)
+ .await
+ .unwrap();
+ let MessagesCallResponse::Stream { chunks, .. } = output else {
+ panic!("expected SSE");
+ };
+ assert_eq!(
+ chunks.try_collect::>().await.unwrap().concat(),
+ sse.as_bytes()
+ );
+ assert_eq!(only_request(&upstream).await.json()["stream"], true);
+}
diff --git a/litellm-rust/crates/inference-testing/Cargo.toml b/litellm-rust/crates/inference-testing/Cargo.toml
index b4b9d6df79a..748b1df0d3b 100644
--- a/litellm-rust/crates/inference-testing/Cargo.toml
+++ b/litellm-rust/crates/inference-testing/Cargo.toml
@@ -7,7 +7,9 @@ repository.workspace = true
publish = false
[dependencies]
+litellm-auth.workspace = true
futures-util.workspace = true
litellm-http.workspace = true
litellm-inference.workspace = true
litellm-secrets.workspace = true
+tokio.workspace = true
diff --git a/litellm-rust/crates/inference-testing/README.md b/litellm-rust/crates/inference-testing/README.md
new file mode 100644
index 00000000000..caf66073c35
--- /dev/null
+++ b/litellm-rust/crates/inference-testing/README.md
@@ -0,0 +1,46 @@
+# Live provider route tests
+
+Each route crate owns `tests/live.rs`. Use named `rstest` provider cases for the behaviors that route supports. Shared setup lives in `litellm_inference_testing::live`, with production HTTP settings, authentication services, environment secrets and a deadline
+
+These tests execute the Rust route against a real provider. They do not prove Python parity, proxy authorization, billing persistence or agent compatibility. Those belong in the corresponding boundary and gateway suites
+
+All live cases use `#[ignore = "calls a real provider, requires credentials and a live model"]`. Normal Cargo runs report them as ignored. An explicitly selected case fails on missing credentials, unavailable models, provider errors or timeouts. Never return early and count missing coverage as a pass
+
+Set `LITELLM_LIVE___MODEL` to the latest model available to the test account. The model has no compiled default because vendor availability changes independently of LiteLLM. Credentials use the provider's existing configuration rather than a second test-only key convention. Export credentials in the shell before running tests. The harness does not load `.env` or modify process environment while tests run
+
+## Messages
+
+Completion checks text and usage. Streaming consumes SSE to completion and checks text, usage and the terminal event. Tool round trips request a tool call and send its returned ID back with a result. Each case checks provider execution facts and rejects cache results
+
+Messages setup and assertions live in `inference-messages/tests/live/support.rs`. Provider modules under `tests/live/` select their payloads and tool policy. The shared helpers consume streams completely and reconstruct text, thinking signatures and tool arguments for the follow-up request
+
+The initial provider cases are `vertex_ai` and `edenai`. A registered ignored case is available coverage, not evidence that it passed against that provider
+
+Run one provider's cases explicitly
+
+```bash
+LITELLM_LIVE_MESSAGES_VERTEX_AI_MODEL= \
+ cargo test --manifest-path litellm-rust/Cargo.toml \
+ -p litellm-inference-messages --test live vertex_ai \
+ -- --ignored --nocapture --test-threads=1
+```
+
+For Vertex AI, set `LITELLM_LIVE_MESSAGES_VERTEX_AI_MODEL` to a Claude model published in the project's location. Credentials, project and location resolve through the production Rust GCP authentication, from `GOOGLE_APPLICATION_CREDENTIALS` or `VERTEXAI_CREDENTIALS`, with `VERTEXAI_PROJECT` and `VERTEXAI_LOCATION`
+
+Vertex AI cases live in `inference-messages/tests/live/vertex_ai.rs`. They cover plain and system messages, text streaming, and complete and streaming tool round trips. Tool cases use automatic selection because current Claude models reject forced `tool_choice`. Each case still requires a tool call, checks its arguments, and sends the tool result back
+
+For EdenAI, set `LITELLM_LIVE_MESSAGES_EDENAI_MODEL` and `EDENAI_API_KEY`, and select `edenai`. EdenAI requires a production token for live coverage, because sandbox tokens return dummy responses
+
+EdenAI cases live in `inference-messages/tests/live/edenai.rs`. They cover system messages, portable cache hints, provider-reported cost handoff, text streaming, and complete and streaming tool round trips. The cost assertion compares the public response with the execution facts instead of pinning a vendor price
+EdenAI tool cases use automatic selection because current Claude models reject forced `tool_choice`. The test still requires a tool call and verifies its arguments and follow-up response
+
+Other tool cases require a model that supports forced `tool_choice`, except where a provider's section says otherwise. A provider rejection fails the selected case instead of skipping it
+
+Select one behavior by its test name, for example `completion::case_1_basic`. To list every available cell without contacting any provider
+
+```bash
+cargo test --manifest-path litellm-rust/Cargo.toml \
+ -p litellm-inference-messages --test live -- --list
+```
+
+For another route, reuse `LiveResources`, `model` and `within_deadline`. Keep payload construction and response assertions in that route's test file. Add only supported provider behaviors, and document any gap instead of substituting mocks or skipping failures. Run cases serially to keep spending and rate limits predictable
diff --git a/litellm-rust/crates/inference-testing/src/lib.rs b/litellm-rust/crates/inference-testing/src/lib.rs
index c42a032e1d8..2e20d5a9e91 100644
--- a/litellm-rust/crates/inference-testing/src/lib.rs
+++ b/litellm-rust/crates/inference-testing/src/lib.rs
@@ -9,6 +9,8 @@ use litellm_secrets::{SecretValue, source::SecretSource};
use litellm_inference::resources::CoreResources;
+pub mod live;
+
pub fn http_pool() -> HttpClientPool {
HttpClientPool::new(Arc::new(PublicDnsResolver))
}
diff --git a/litellm-rust/crates/inference-testing/src/live.rs b/litellm-rust/crates/inference-testing/src/live.rs
new file mode 100644
index 00000000000..b63bfd91a38
--- /dev/null
+++ b/litellm-rust/crates/inference-testing/src/live.rs
@@ -0,0 +1,53 @@
+use std::{future::Future, sync::Arc, time::Duration};
+
+use litellm_http::{ClientVariant, HttpSettings, HttpSettingsLayer, Resolution};
+use litellm_inference::resources::CoreResources;
+use litellm_secrets::source::{EnvironmentSecrets, SecretSource};
+
+pub const DEADLINE: Duration = Duration::from_secs(120);
+
+pub struct LiveResources {
+ pub http: litellm_http::Client,
+ pub auth: Arc,
+ pub secrets: Arc,
+}
+
+impl Default for LiveResources {
+ fn default() -> Self {
+ let resources = CoreResources::new(Arc::new(super::http_pool()));
+ let settings =
+ HttpSettings::from_layers([HttpSettingsLayer::from_environment(&|key: &str| {
+ std::env::var(key).ok()
+ })]);
+ let resolution = Resolution::from(&settings);
+ let http = resources
+ .pool
+ .client(&resolution.config, ClientVariant::Provider)
+ .expect("live provider HTTP settings must be valid");
+ let secrets_http = resources
+ .pool
+ .client(&resolution.config, ClientVariant::NoRedirect)
+ .expect("live secret HTTP settings must be valid");
+ Self {
+ http,
+ auth: resources.auth,
+ secrets: Arc::new(EnvironmentSecrets::python_compatible(secrets_http)),
+ }
+ }
+}
+
+pub fn model(route: &str, provider: &str) -> String {
+ let name = format!("LITELLM_LIVE_{route}_{provider}_MODEL").to_ascii_uppercase();
+ std::env::var(&name)
+ .ok()
+ .filter(|value| !value.trim().is_empty())
+ .unwrap_or_else(|| {
+ panic!("set {name} to an available current model before running this live case")
+ })
+}
+
+pub async fn within_deadline(operation: impl Future