diff --git a/.circleci/config.yml b/.circleci/config.yml index 3d1e22eebd3..9f01bae3e5b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -66,6 +66,51 @@ commands: echo "513a7213d6d3332dd9ef27c24dab35e5ef10a04fa27274fe1c14d8a246493ded /tmp/kind" | sha256sum -c - chmod +x /tmp/kind sudo mv /tmp/kind /usr/local/bin/kind + install_uv: + description: "Install pinned uv (0.10.9) with checksum verification. Adds ~/.local/bin to PATH." + steps: + - run: + name: Install uv (pinned 0.10.9) + command: | + curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh + echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - + env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh + rm -f /tmp/uv-install.sh + echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" + export PATH="$HOME/.local/bin:$PATH" + start_postgres: + description: "Start a postgres-db container on port 5432 and wait until it accepts connections." + parameters: + db_name: + type: string + default: circle_test + steps: + - run: + name: Start PostgreSQL + command: | + docker run -d \ + --name postgres-db \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_DB=<< parameters.db_name >> \ + -p 5432:5432 \ + postgres:14@sha256:6a70deda415ec296f977890e11aba04a0db9f632a362e3fce45e845e3db74f26 + - wait_for_service: + url: tcp://localhost:5432 + timeout: "60" + start_redis: + description: "Start a redis container on port 6379 and wait until it accepts connections. Use this to isolate a job from the shared remote Redis so concurrent CI pipelines don't contend for pod locks or buffer keys." + steps: + - run: + name: Start Redis + command: | + docker run -d \ + --name redis-cache \ + -p 6379:6379 \ + redis:7-alpine@sha256:7aec734b2bb298a1d769fd8729f13b8514a41bf90fcdd1f38ec52267fbaa8ee6 + - wait_for_service: + url: tcp://localhost:6379 + timeout: "60" setup_litellm_enterprise_pip: steps: - run: @@ -80,26 +125,19 @@ commands: steps: - checkout - setup_google_dns + - install_uv - restore_cache: keys: - - v3-litellm-uv-deps-{{ checksum "uv.lock" }}-{{ checksum ".circleci/config.yml" }} + - v1-uv-cache-{{ checksum "uv.lock" }} - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 - setup_litellm_enterprise_pip - save_cache: paths: - - ~/.local/lib - - ~/.local/bin - ~/.cache/uv - key: v3-litellm-uv-deps-{{ checksum "uv.lock" }}-{{ checksum ".circleci/config.yml" }} + key: v1-uv-cache-{{ checksum "uv.lock" }} jobs: # Add Windows testing job @@ -121,7 +159,15 @@ jobs: - run: name: Install Dependencies command: | - Invoke-RestMethod https://astral.sh/uv/0.10.9/install.ps1 | Invoke-Expression + $installer = Join-Path $env:TEMP "uv-install.ps1" + Invoke-WebRequest -Uri https://astral.sh/uv/0.10.9/install.ps1 -OutFile $installer + $expected = "d43ffff8d28e7d1e7d1831a212465f12b24a43c7f87f386e95e2a5915aee5d7d" + $actual = (Get-FileHash -Path $installer -Algorithm SHA256).Hash.ToLower() + if ($actual -ne $expected) { + throw "uv installer hash mismatch: expected $expected got $actual" + } + & $installer + Remove-Item $installer $uvBin = Join-Path $HOME ".local\bin" $env:Path = "$uvBin;$env:Path" if (!(Test-Path $PROFILE)) { @@ -136,67 +182,10 @@ jobs: command: | uv run --no-sync python -m pytest tests/windows_tests/test_litellm_on_windows.py -v - mypy_linting: - docker: - - image: cimg/python:3.12 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} - working_directory: ~/project - resource_class: medium - - steps: - - checkout - - setup_google_dns - - run: - name: Install Dependencies - command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - uv sync --frozen --group dev --python "$(which python)" --no-install-package fastuuid - - run: - name: MyPy Type Checking - command: | - cd litellm - # Use the same approach as GitHub Actions, explicitly exclude fastuuid to avoid segfaults - uv run --no-sync python -m mypy . - cd .. - no_output_timeout: 10m - - semgrep: - docker: - - image: cimg/python:3.12 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} - working_directory: ~/project - resource_class: medium - steps: - - checkout - - setup_google_dns - - run: - name: Install Semgrep - command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - - run: - name: Run Semgrep (custom rules only) - command: | - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - uv tool run --from 'semgrep==1.157.0' semgrep scan --config .semgrep/rules . --error - local_testing_part1: docker: - - image: cimg/python:3.12 + - &python312_image + image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c auth: username: ${DOCKERHUB_USERNAME} password: ${DOCKERHUB_PASSWORD} @@ -205,34 +194,19 @@ jobs: steps: - checkout - setup_google_dns - - run: - name: Show git commit hash - command: | - echo "Git commit hash: $CIRCLE_SHA1" - - restore_cache: keys: - - v2-dependencies-{{ checksum "uv.lock" }}-{{ checksum ".circleci/config.yml" }} + - v1-uv-cache-{{ checksum "uv.lock" }} + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 - setup_litellm_enterprise_pip - save_cache: paths: - - ./.venv - key: v2-dependencies-{{ checksum "uv.lock" }}-{{ checksum ".circleci/config.yml" }} + - ~/.cache/uv + key: v1-uv-cache-{{ checksum "uv.lock" }} - run: name: Run prisma ./docker/entrypoint.sh command: | @@ -240,13 +214,6 @@ jobs: chmod +x docker/entrypoint.sh ./docker/entrypoint.sh set -e - - run: - name: Black Formatting - command: | - cd litellm - uv run --no-sync python -m black . - cd .. - # Run pytest and generate JUnit XML report - run: name: Run tests (Part 1 - A-M) @@ -286,43 +253,25 @@ jobs: - local_testing_part1_coverage local_testing_part2: docker: - - image: cimg/python:3.12 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project parallelism: 4 steps: - checkout - setup_google_dns - - run: - name: Show git commit hash - command: | - echo "Git commit hash: $CIRCLE_SHA1" - - restore_cache: keys: - - v2-dependencies-{{ checksum "uv.lock" }}-{{ checksum ".circleci/config.yml" }} + - v1-uv-cache-{{ checksum "uv.lock" }} + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 - setup_litellm_enterprise_pip - save_cache: paths: - - ./.venv - key: v2-dependencies-{{ checksum "uv.lock" }}-{{ checksum ".circleci/config.yml" }} + - ~/.cache/uv + key: v1-uv-cache-{{ checksum "uv.lock" }} - run: name: Run prisma ./docker/entrypoint.sh command: | @@ -330,13 +279,6 @@ jobs: chmod +x docker/entrypoint.sh ./docker/entrypoint.sh set -e - - run: - name: Black Formatting - command: | - cd litellm - uv run --no-sync python -m black . - cd .. - # Run pytest and generate JUnit XML report - run: name: Run tests (Part 2 - N-Z) @@ -376,44 +318,26 @@ jobs: - local_testing_part2_coverage langfuse_logging_unit_tests: docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project resource_class: medium steps: - checkout - setup_google_dns - - run: - name: Show git commit hash - command: | - echo "Git commit hash: $CIRCLE_SHA1" - - restore_cache: keys: - - v2-dependencies-{{ checksum "uv.lock" }}-{{ checksum ".circleci/config.yml" }} + - v1-uv-cache-{{ checksum "uv.lock" }} + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 - setup_litellm_enterprise_pip - save_cache: paths: - - ./.venv - key: v2-dependencies-{{ checksum "uv.lock" }}-{{ checksum ".circleci/config.yml" }} + - ~/.cache/uv + key: v1-uv-cache-{{ checksum "uv.lock" }} - run: name: Run prisma ./docker/entrypoint.sh command: | @@ -426,8 +350,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -v tests/local_testing -x --junitxml=test-results/junit.xml --durations=5 -k "langfuse" no_output_timeout: 15m # Store test results @@ -435,40 +357,36 @@ jobs: path: test-results auth_ui_unit_tests: docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image + - image: cimg/postgres:16.0@sha256:b125148bc76e8e8eee5eb3ad6020a3a14110a14e8192f1c645128afebe2e2f84 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: litellm_test working_directory: ~/project + environment: + DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/litellm_test" steps: - checkout - setup_google_dns + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 - save_cache: paths: - - ./.venv - key: v2-dependencies-{{ checksum "uv.lock" }}-{{ checksum ".circleci/config.yml" }} + - ~/.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 + name: Seed DB schema via prisma db push command: | set +e - chmod +x docker/entrypoint.sh - ./docker/entrypoint.sh + uv run --no-sync litellm --skip_server_startup --use_prisma_db_push set -e - run: name: Generate Prisma Client @@ -477,8 +395,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -v tests/proxy_admin_ui_tests -x --junitxml=test-results/junit.xml --durations=5 -n 2 no_output_timeout: 15m @@ -488,46 +404,30 @@ jobs: litellm_router_testing: # Runs all tests with the "router" keyword docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project resource_class: large parallelism: 4 steps: - checkout - setup_google_dns + - install_uv - restore_cache: keys: - - v1-router-testing-deps-{{ checksum "uv.lock" }} + - v1-uv-cache-{{ checksum "uv.lock" }} - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 - save_cache: paths: - - /home/circleci/.pyenv - - /home/circleci/.local - key: v1-router-testing-deps-{{ checksum "uv.lock" }} + - ~/.cache/uv + key: v1-uv-cache-{{ checksum "uv.lock" }} # Run pytest and generate JUnit XML report - setup_litellm_enterprise_pip - run: name: Run tests command: | - pwd - ls TEST_FILES=$(circleci tests glob "tests/local_testing/**/test_*.py") echo "$TEST_FILES" | circleci tests run \ @@ -547,46 +447,30 @@ jobs: litellm_router_unit_testing: # Runs all tests with the "router" keyword docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project resource_class: large steps: - checkout - setup_google_dns + - install_uv - restore_cache: keys: - - v1-router-unit-deps-{{ checksum "uv.lock" }} + - v1-uv-cache-{{ checksum "uv.lock" }} - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 - save_cache: paths: - - /home/circleci/.pyenv - - /home/circleci/.local - key: v1-router-unit-deps-{{ checksum "uv.lock" }} + - ~/.cache/uv + key: v1-uv-cache-{{ checksum "uv.lock" }} # Run pytest and generate JUnit XML report - setup_litellm_enterprise_pip - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -v tests/router_unit_tests -x --junitxml=test-results/junit.xml --durations=5 -n 4 no_output_timeout: 15m # Store test results @@ -594,38 +478,23 @@ jobs: path: test-results litellm_assistants_api_testing: # Runs all tests with the "assistants" keyword docker: - - image: cimg/python:3.13.1 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project resource_class: medium steps: - checkout - setup_google_dns + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + 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: | - pwd - ls uv run --no-sync python -m pytest tests/local_testing/ -v -k "assistants" -x --junitxml=test-results/junit.xml --durations=5 no_output_timeout: 15m # Store test results @@ -633,45 +502,29 @@ jobs: path: test-results llm_translation_testing: docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project resource_class: xlarge steps: - checkout - setup_google_dns + - install_uv - restore_cache: keys: - - v1-llm-translation-deps-{{ checksum "uv.lock" }} + - v1-uv-cache-{{ checksum "uv.lock" }} - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 - save_cache: paths: - - /home/circleci/.pyenv - - /home/circleci/.local - key: v1-llm-translation-deps-{{ checksum "uv.lock" }} + - ~/.cache/uv + key: v1-uv-cache-{{ checksum "uv.lock" }} # Run pytest and generate JUnit XML report - run: name: Run tests command: | - pwd - ls # 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) @@ -690,36 +543,21 @@ jobs: path: test-results realtime_translation_testing: docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: - checkout - setup_google_dns + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 # Run pytest and generate JUnit XML report - run: name: Run realtime tests command: | - pwd - ls # Add --timeout to kill hanging tests after 120s (2 min) # Add --durations=20 to show 20 slowest tests for debugging uv run --no-sync python -m pytest -vv tests/llm_translation/realtime --cov=litellm --cov-report=xml -v --junitxml=test-results/junit.xml --durations=20 -n 4 --timeout=120 --timeout_method=thread @@ -738,86 +576,23 @@ jobs: paths: - realtime_translation_coverage.xml - realtime_translation_coverage - mcp_testing: - docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} - working_directory: ~/project - - steps: - - checkout - - setup_google_dns - - run: - name: Install Dependencies - command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" - # Run pytest and generate JUnit XML report - - run: - name: Run tests - command: | - pwd - ls - uv run --no-sync python -m pytest -vv tests/mcp_tests --cov=litellm --cov-report=xml -x -s -v --junitxml=test-results/junit.xml --durations=5 -n 2 - no_output_timeout: 15m - - run: - name: Rename the coverage files - command: | - mv coverage.xml mcp_coverage.xml - mv .coverage mcp_coverage - - # Store test results - - store_test_results: - path: test-results - - persist_to_workspace: - root: . - paths: - - mcp_coverage.xml - - mcp_coverage agent_testing: docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: - checkout - setup_google_dns + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 # Run pytest and generate JUnit XML report - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -vv tests/agent_tests --ignore=tests/agent_tests/local_only_agent_tests --cov=litellm --cov-report=xml -x -s -v --junitxml=test-results/junit.xml --durations=5 no_output_timeout: 15m - run: @@ -836,36 +611,21 @@ jobs: - agent_coverage guardrails_testing: docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: - checkout - setup_google_dns + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 # Run pytest and generate JUnit XML report - run: name: Run tests command: | - pwd - ls LITELLM_LOG=WARNING uv run --no-sync python -m pytest tests/guardrails_tests -vv --cov=litellm --cov-report=xml --junitxml=test-results/junit.xml --durations=5 -n 2 --timeout=120 --timeout_method=thread no_output_timeout: 15m - run: @@ -885,36 +645,21 @@ jobs: google_generate_content_endpoint_testing: docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: - checkout - setup_google_dns + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 # Run pytest and generate JUnit XML report - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -vv tests/unified_google_tests --cov=litellm --cov-report=xml -x -s -v --junitxml=test-results/junit.xml --durations=5 --retries 3 --retry-delay 5 no_output_timeout: 15m - run: @@ -934,45 +679,29 @@ jobs: llm_responses_api_testing: docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project resource_class: large steps: - checkout - setup_google_dns + - install_uv - restore_cache: keys: - - v1-llm-responses-deps-{{ checksum "uv.lock" }} + - v1-uv-cache-{{ checksum "uv.lock" }} - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 - save_cache: paths: - - /home/circleci/.pyenv - - /home/circleci/.local - key: v1-llm-responses-deps-{{ checksum "uv.lock" }} + - ~/.cache/uv + key: v1-uv-cache-{{ checksum "uv.lock" }} # Run pytest and generate JUnit XML report - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -v tests/llm_responses_api_testing -x --junitxml=test-results/junit.xml --durations=5 -n 8 no_output_timeout: 15m @@ -981,36 +710,21 @@ jobs: path: test-results ocr_testing: docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: - checkout - setup_google_dns + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 # Run pytest and generate JUnit XML report - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -vv tests/ocr_tests --cov=litellm --cov-report=xml -x -v --junitxml=test-results/junit.xml --durations=5 -n 4 no_output_timeout: 15m - run: @@ -1029,36 +743,21 @@ jobs: - ocr_coverage search_testing: docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: - checkout - setup_google_dns + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 # Run pytest and generate JUnit XML report - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -vv tests/search_tests --cov=litellm --cov-report=xml -x -v --junitxml=test-results/junit.xml --durations=5 -n 4 no_output_timeout: 15m - run: @@ -1075,78 +774,24 @@ jobs: paths: - search_coverage.xml - search_coverage - # Split litellm_mapped_tests into parallel jobs - litellm_mapped_tests_proxy_part1: - docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} - working_directory: ~/project - resource_class: large - steps: - - setup_litellm_test_deps - - run: - name: Run proxy tests part 1 (high-volume directories) - command: | - uv run --no-sync python -m prisma generate - export PYTHONUNBUFFERED=1 - uv run --no-sync python -m pytest tests/test_litellm/proxy/guardrails tests/test_litellm/proxy/management_endpoints tests/test_litellm/proxy/_experimental tests/test_litellm/proxy/client tests/test_litellm/proxy/auth --junitxml=test-results/junit-proxy-part1.xml --durations=10 -n 4 --maxfail=5 --timeout=60 -vv --log-cli-level=WARNING -r A - no_output_timeout: 15m - - store_test_results: - path: test-results - litellm_mapped_tests_proxy_part2: - docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} - working_directory: ~/project - resource_class: large - steps: - - setup_litellm_test_deps - - run: - name: Run proxy tests part 2 (all other tests) - command: | - uv run --no-sync python -m prisma generate - export PYTHONUNBUFFERED=1 - uv run --no-sync python -m pytest tests/test_litellm/proxy --ignore=tests/test_litellm/proxy/guardrails --ignore=tests/test_litellm/proxy/management_endpoints --ignore=tests/test_litellm/proxy/_experimental --ignore=tests/test_litellm/proxy/client --ignore=tests/test_litellm/proxy/auth --junitxml=test-results/junit-proxy-part2.xml --durations=10 -n 4 --maxfail=5 --timeout=120 -vv --log-cli-level=WARNING -r A - no_output_timeout: 15m - - store_test_results: - path: test-results litellm_mapped_enterprise_tests: docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project resource_class: large steps: - checkout - setup_google_dns + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 - setup_litellm_enterprise_pip - run: name: Run enterprise tests command: | - pwd - ls uv run --no-sync python -m prisma generate uv run --no-sync python -m pytest -v tests/enterprise -x --junitxml=test-results/junit-enterprise.xml --durations=10 -n 4 no_output_timeout: 15m @@ -1155,36 +800,21 @@ jobs: path: test-results batches_testing: docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: - checkout - setup_google_dns + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 # Run pytest and generate JUnit XML report - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -vv tests/batches_tests --cov=litellm --cov-report=xml -x -s -v --junitxml=test-results/junit.xml --durations=5 -n 2 no_output_timeout: 15m - run: @@ -1203,36 +833,21 @@ jobs: - batches_coverage litellm_utils_testing: docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: - checkout - setup_google_dns + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 # Run pytest and generate JUnit XML report - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -vv tests/litellm_utils_tests --cov=litellm --cov-report=xml -x -s -v --junitxml=test-results/junit.xml --durations=5 -n 2 no_output_timeout: 15m - run: @@ -1252,36 +867,21 @@ jobs: pass_through_unit_testing: docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: - checkout - setup_google_dns + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 # Run pytest and generate JUnit XML report - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -vv tests/pass_through_unit_tests --cov=litellm --cov-report=xml -x -v --junitxml=test-results/junit.xml --durations=5 -n 4 no_output_timeout: 15m - run: @@ -1300,37 +900,22 @@ jobs: - pass_through_unit_tests_coverage image_gen_testing: docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project resource_class: large steps: - checkout - setup_google_dns + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 # Run pytest and generate JUnit XML report - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -v tests/image_gen_tests -n 4 -x --junitxml=test-results/junit.xml --durations=5 no_output_timeout: 15m # Store test results @@ -1338,37 +923,22 @@ jobs: path: test-results logging_testing: docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: - checkout - setup_google_dns + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + 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: | - pwd - ls LITELLM_LOG=WARNING uv run --no-sync python -m pytest tests/logging_callback_tests -vv --cov=litellm --cov-report=xml -n 4 --junitxml=test-results/junit.xml --durations=5 --timeout=120 --timeout_method=thread no_output_timeout: 15m - run: @@ -1387,36 +957,21 @@ jobs: - logging_coverage audio_testing: docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: - checkout - setup_google_dns + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 # Run pytest and generate JUnit XML report - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -vv tests/audio_tests --cov=litellm --cov-report=xml -x -s -v --junitxml=test-results/junit.xml --durations=5 no_output_timeout: 15m - run: @@ -1435,10 +990,7 @@ jobs: - audio_coverage redis_caching_unit_tests: docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: @@ -1446,21 +998,16 @@ jobs: - setup_google_dns - restore_cache: keys: - - v2-dependencies-{{ checksum "uv.lock" }}-{{ checksum ".circleci/config.yml" }} + - v1-uv-cache-{{ checksum "uv.lock" }} + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 - save_cache: paths: - - ./.venv - key: v2-dependencies-{{ checksum "uv.lock" }}-{{ checksum ".circleci/config.yml" }} + - ~/.cache/uv + key: v1-uv-cache-{{ checksum "uv.lock" }} # Run pytest and generate JUnit XML report - run: name: Run tests @@ -1490,41 +1037,26 @@ jobs: - redis_caching_coverage installing_litellm_on_python: docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: - checkout - setup_google_dns + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 - setup_litellm_enterprise_pip - run: name: Run tests command: | - pwd - ls - uv run --no-sync python -m pytest -vv tests/local_testing/test_basic_python_version.py + uv run --no-sync python -m pytest -vv tests/local_testing/test_basic_python_version.py -k "not v2_resolver" installing_litellm_on_python_3_13: docker: - - image: cimg/python:3.13.1 + - image: cimg/python:3.13.1@sha256:87b243ae80d154db75ce5e58af16c72c5dd4b1e23e5c7264a816e85e0c440c13 auth: username: ${DOCKERHUB_USERNAME} password: ${DOCKERHUB_PASSWORD} @@ -1534,30 +1066,49 @@ jobs: steps: - checkout - setup_google_dns + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.13 - run: name: Run tests command: | - pwd - ls - uv run --no-sync python -m pytest -v tests/local_testing/test_basic_python_version.py + uv run --no-sync python -m pytest -v tests/local_testing/test_basic_python_version.py -k "not v2_resolver" + + installing_litellm_on_python_v2_migration_resolver: + docker: + - *python312_image + - image: cimg/postgres:16.0@sha256:b125148bc76e8e8eee5eb3ad6020a3a14110a14e8192f1c645128afebe2e2f84 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: litellm_test + working_directory: ~/project + environment: + DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/litellm_test" + + steps: + - checkout + - setup_google_dns + - install_uv + - run: + name: Install Dependencies + command: | + uv sync --frozen --all-groups --all-extras --python 3.12 + - setup_litellm_enterprise_pip + - wait_for_service: + url: tcp://localhost:5432 + timeout: "60" + - run: + name: Run v2 migration resolver proxy smoke test + command: | + uv run --no-sync python -m pytest -vv \ + tests/local_testing/test_basic_python_version.py::test_litellm_proxy_server_config_no_general_settings_v2_resolver + helm_chart_testing: machine: - image: ubuntu-2204:2023.10.1 # Use machine executor instead of docker + image: ubuntu-2204:2024.04.1 # Use machine executor instead of docker resource_class: medium working_directory: ~/project @@ -1569,18 +1120,15 @@ jobs: - install_helm - install_kind - # Install kubectl (pinned version with official checksum verification) + # Install kubectl (pinned version with hardcoded checksum) - run: name: Install kubectl v1.31.4 command: | curl -sSLf -o /tmp/kubectl \ https://dl.k8s.io/release/v1.31.4/bin/linux/amd64/kubectl - curl -sSLf -o /tmp/kubectl.sha256 \ - https://dl.k8s.io/release/v1.31.4/bin/linux/amd64/kubectl.sha256 - echo "$(cat /tmp/kubectl.sha256) /tmp/kubectl" | sha256sum -c - + echo "298e19e9c6c17199011404278f0ff8168a7eca4217edad9097af577023a5620f /tmp/kubectl" | sha256sum -c - chmod +x /tmp/kubectl sudo mv /tmp/kubectl /usr/local/bin/ - rm -f /tmp/kubectl.sha256 # Create kind cluster - run: @@ -1626,7 +1174,6 @@ jobs: # Run the helm tests helm test litellm --logs - helm test litellm --logs # Cleanup - run: @@ -1635,109 +1182,21 @@ jobs: kind delete cluster --name litellm-test when: always # This ensures cleanup runs even if previous steps fail - check_code_and_doc_quality: - docker: - - image: cimg/python:3.11 - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} - working_directory: ~/project/litellm - - steps: - - checkout - - setup_google_dns - - run: - name: Install Dependencies - command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" - - run: uv run --no-sync python -c "from litellm import *" || (echo '🚨 import failed, this means you introduced unprotected imports! 🚨'; exit 1) - - run: uv run --no-sync ruff check ./litellm - # - run: python ./tests/documentation_tests/test_general_setting_keys.py - - run: uv run --no-sync python ./tests/code_coverage_tests/check_licenses.py - - run: uv run --no-sync python ./tests/code_coverage_tests/check_provider_folders_documented.py - - run: uv run --no-sync python ./tests/code_coverage_tests/router_code_coverage.py - - run: uv run --no-sync python ./tests/code_coverage_tests/test_chat_completion_imports.py - - run: uv run --no-sync python ./tests/code_coverage_tests/info_log_check.py - - run: uv run --no-sync python ./tests/code_coverage_tests/check_guardrail_apply_decorator.py - - run: uv run --no-sync python ./tests/code_coverage_tests/test_ban_set_verbose.py - - run: uv run --no-sync python ./tests/code_coverage_tests/code_qa_check_tests.py - - run: uv run --no-sync python ./tests/code_coverage_tests/check_get_model_cost_key_performance.py - - run: uv run --no-sync python ./tests/code_coverage_tests/test_proxy_types_import.py - - run: uv run --no-sync python ./tests/code_coverage_tests/callback_manager_test.py - - run: uv run --no-sync python ./tests/code_coverage_tests/recursive_detector.py - - run: uv run --no-sync python ./tests/code_coverage_tests/test_router_strategy_async.py - - run: uv run --no-sync python ./tests/code_coverage_tests/litellm_logging_code_coverage.py - - run: uv run --no-sync python ./tests/documentation_tests/test_env_keys.py - - run: uv run --no-sync python ./tests/documentation_tests/test_router_settings.py - - run: uv run --no-sync python ./tests/documentation_tests/test_api_docs.py - - run: uv run --no-sync python ./tests/code_coverage_tests/ensure_async_clients_test.py - - run: uv run --no-sync python ./tests/code_coverage_tests/enforce_llms_folder_style.py - - run: uv run --no-sync python ./tests/documentation_tests/test_circular_imports.py - - run: uv run --no-sync python ./tests/code_coverage_tests/prevent_key_leaks_in_exceptions.py - - run: uv run --no-sync python ./tests/code_coverage_tests/check_unsafe_enterprise_import.py - - run: uv run --no-sync python ./tests/code_coverage_tests/ban_copy_deepcopy_kwargs.py - - run: uv run --no-sync python ./tests/code_coverage_tests/check_fastuuid_usage.py - - run: uv run --no-sync python ./tests/code_coverage_tests/memory_test.py - # helm lint is handled by the dedicated helm_chart_testing job - db_migration_disable_update_check: machine: - image: ubuntu-2204:2023.10.1 + image: ubuntu-2204:2024.04.1 resource_class: medium working_directory: ~/project steps: - checkout - setup_google_dns - - run: - name: Install Python 3.9 - command: | - curl https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh --output miniconda.sh - bash miniconda.sh -b -p $HOME/miniconda - export PATH="$HOME/miniconda/bin:$PATH" - conda init bash - source ~/.bashrc - conda create -n myenv python=3.10 -y - conda activate myenv - python --version + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" - - run: - name: Start PostgreSQL Database - command: | - docker run -d \ - --name postgres-db \ - -e POSTGRES_USER=postgres \ - -e POSTGRES_PASSWORD=postgres \ - -e POSTGRES_DB=litellm_test \ - -p 5432:5432 \ - postgres:14 - - wait_for_service: - url: tcp://localhost:5432 - timeout: "60" + uv sync --frozen --all-groups --all-extras --python 3.12 + - start_postgres: + db_name: litellm_test - attach_workspace: at: ~/project - run: @@ -1806,7 +1265,7 @@ jobs: build_and_test: machine: - image: ubuntu-2204:2023.10.1 + image: ubuntu-2204:2024.04.1 resource_class: large working_directory: ~/project steps: @@ -1814,45 +1273,12 @@ jobs: - attach_workspace: at: ~/project - setup_google_dns - - run: - name: Install Python 3.9 - command: | - curl https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh --output miniconda.sh - bash miniconda.sh -b -p $HOME/miniconda - export PATH="$HOME/miniconda/bin:$PATH" - conda init bash - source ~/.bashrc - conda create -n myenv python=3.10 -y - conda activate myenv - python --version + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" - - run: - name: Start PostgreSQL Database - command: | - docker run -d \ - --name postgres-db \ - -e POSTGRES_USER=postgres \ - -e POSTGRES_PASSWORD=postgres \ - -e POSTGRES_DB=circle_test \ - -p 5432:5432 \ - postgres:14 - - wait_for_service: - url: tcp://localhost:5432 - timeout: "60" + uv sync --frozen --all-groups --all-extras --python 3.12 + - start_postgres - run: name: Load Docker Database Image command: | @@ -1895,11 +1321,6 @@ jobs: --config /app/config.yaml \ --port 4000 \ --detailed_debug \ - - run: - name: Install curl - command: | - sudo apt-get update - sudo apt-get install -y curl - run: name: Start outputting logs command: docker logs -f my-app @@ -1910,8 +1331,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -s -v tests/*.py -x --junitxml=test-results/junit.xml -n 4 --durations=5 --ignore=tests/otel_tests --ignore=tests/spend_tracking_tests --ignore=tests/pass_through_tests --ignore=tests/proxy_admin_ui_tests --ignore=tests/load_tests --ignore=tests/llm_translation --ignore=tests/llm_responses_api_testing --ignore=tests/mcp_tests --ignore=tests/guardrails_tests --ignore=tests/image_gen_tests --ignore=tests/pass_through_unit_tests no_output_timeout: 15m @@ -1920,55 +1339,18 @@ jobs: path: test-results e2e_openai_endpoints: machine: - image: ubuntu-2204:2023.10.1 + image: ubuntu-2204:2024.04.1 resource_class: large working_directory: ~/project steps: - checkout - setup_google_dns - - run: - name: Verify Docker is available - command: | - docker version - - run: - name: Install Python 3.10 - command: | - curl https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh --output miniconda.sh - bash miniconda.sh -b -p $HOME/miniconda - export PATH="$HOME/miniconda/bin:$PATH" - conda init bash - source ~/.bashrc - conda create -n myenv python=3.10 -y - conda activate myenv - python --version + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" - - run: - name: Start PostgreSQL Database - command: | - docker run -d \ - --name postgres-db \ - -e POSTGRES_USER=postgres \ - -e POSTGRES_PASSWORD=postgres \ - -e POSTGRES_DB=circle_test \ - -p 5432:5432 \ - postgres:14 - - wait_for_service: - url: tcp://localhost:5432 - timeout: "60" + uv sync --frozen --all-groups --all-extras --python 3.12 + - start_postgres - attach_workspace: at: ~/project - run: @@ -2014,11 +1396,6 @@ jobs: --config /app/config.yaml \ --port 4000 \ --detailed_debug \ - - run: - name: Install curl - command: | - sudo apt-get update - sudo apt-get install -y curl - run: name: Start outputting logs command: docker logs -f my-app @@ -2029,8 +1406,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -s -vv tests/openai_endpoints_tests --junitxml=test-results/junit.xml --durations=5 no_output_timeout: 15m @@ -2039,55 +1414,18 @@ jobs: path: test-results proxy_logging_guardrails_model_info_tests: machine: - image: ubuntu-2204:2023.10.1 + image: ubuntu-2204:2024.04.1 resource_class: large working_directory: ~/project steps: - checkout - setup_google_dns - - run: - name: Verify Docker is available - command: | - docker version - - run: - name: Install Python 3.9 - command: | - curl https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh --output miniconda.sh - bash miniconda.sh -b -p $HOME/miniconda - export PATH="$HOME/miniconda/bin:$PATH" - conda init bash - source ~/.bashrc - conda create -n myenv python=3.10 -y - conda activate myenv - python --version + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" - - run: - name: Start PostgreSQL Database - command: | - docker run -d \ - --name postgres-db \ - -e POSTGRES_USER=postgres \ - -e POSTGRES_PASSWORD=postgres \ - -e POSTGRES_DB=circle_test \ - -p 5432:5432 \ - postgres:14 - - wait_for_service: - url: tcp://localhost:5432 - timeout: "60" + uv sync --frozen --all-groups --all-extras --python 3.12 + - start_postgres - attach_workspace: at: ~/project - run: @@ -2137,8 +1475,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -v tests/otel_tests -x --junitxml=test-results/junit.xml --durations=5 no_output_timeout: 15m # Clean up first container @@ -2190,55 +1526,19 @@ jobs: path: test-results proxy_spend_accuracy_tests: machine: - image: ubuntu-2204:2023.10.1 + image: ubuntu-2204:2024.04.1 resource_class: large working_directory: ~/project steps: - checkout - setup_google_dns - - run: - name: Verify Docker is available - command: | - docker version - - run: - name: Install Python 3.9 - command: | - curl https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh --output miniconda.sh - bash miniconda.sh -b -p $HOME/miniconda - export PATH="$HOME/miniconda/bin:$PATH" - conda init bash - source ~/.bashrc - conda create -n myenv python=3.10 -y - conda activate myenv - python --version + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" - - run: - name: Start PostgreSQL Database - command: | - docker run -d \ - --name postgres-db \ - -e POSTGRES_USER=postgres \ - -e POSTGRES_PASSWORD=postgres \ - -e POSTGRES_DB=circle_test \ - -p 5432:5432 \ - postgres:14 - - wait_for_service: - url: tcp://localhost:5432 - timeout: "60" + uv sync --frozen --all-groups --all-extras --python 3.12 + - start_postgres + - start_redis - attach_workspace: at: ~/project - run: @@ -2248,15 +1548,18 @@ jobs: 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 + # Point the proxy at the job-local Redis (start_redis) instead of the + # shared remote Redis. The Redis transaction buffer uses a single + # global pod-lock key (cronjob_lock:db_spend_update_job) and a single + # global buffer list (litellm_spend_update_buffer); sharing those + # across concurrent CI pipelines causes spend flushes to stall or + # land in the wrong DB, which is what makes this test flaky. command: | docker run -d \ -p 4000:4000 \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ - -e REDIS_HOST=$REDIS_HOST \ - -e REDIS_PASSWORD=$REDIS_PASSWORD \ - -e REDIS_PORT=$REDIS_PORT \ + -e REDIS_HOST=host.docker.internal \ + -e REDIS_PORT=6379 \ -e LITELLM_MASTER_KEY="sk-1234" \ -e OPENAI_API_KEY=$OPENAI_API_KEY \ -e LITELLM_LICENSE=$LITELLM_LICENSE \ @@ -2266,6 +1569,7 @@ jobs: -e DD_API_KEY=$DD_API_KEY \ -e DD_SITE=$DD_SITE \ -e AWS_REGION_NAME=$AWS_REGION_NAME \ + -e PROXY_BATCH_WRITE_AT=2 \ --add-host host.docker.internal:host-gateway \ --name my-app \ -v $(pwd)/litellm/proxy/example_config_yaml/spend_tracking_config.yaml:/app/config.yaml \ @@ -2273,11 +1577,6 @@ jobs: --config /app/config.yaml \ --port 4000 \ --detailed_debug \ - - run: - name: Install curl - command: | - sudo apt-get update - sudo apt-get install -y curl - run: name: Start outputting logs command: docker logs -f my-app @@ -2288,68 +1587,31 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -vv tests/spend_tracking_tests -x --junitxml=test-results/junit.xml --durations=5 no_output_timeout: 15m - # Clean up first container - run: name: Stop and remove first container + when: always command: | docker stop my-app docker rm my-app + docker stop redis-cache + docker rm redis-cache proxy_multi_instance_tests: machine: - image: ubuntu-2204:2023.10.1 + image: ubuntu-2204:2024.04.1 resource_class: large working_directory: ~/project steps: - checkout - setup_google_dns - - run: - name: Verify Docker is available - command: | - docker version - - run: - name: Install Python 3.9 - command: | - curl https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh --output miniconda.sh - bash miniconda.sh -b -p $HOME/miniconda - export PATH="$HOME/miniconda/bin:$PATH" - conda init bash - source ~/.bashrc - conda create -n myenv python=3.10 -y - conda activate myenv - python --version + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" - - run: - name: Start PostgreSQL Database - command: | - docker run -d \ - --name postgres-db \ - -e POSTGRES_USER=postgres \ - -e POSTGRES_PASSWORD=postgres \ - -e POSTGRES_DB=circle_test \ - -p 5432:5432 \ - postgres:14 - - wait_for_service: - url: tcp://localhost:5432 - timeout: "60" + uv sync --frozen --all-groups --all-extras --python 3.12 + - start_postgres - attach_workspace: at: ~/project - run: @@ -2414,8 +1676,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -vv tests/multi_instance_e2e_tests -x --junitxml=test-results/junit.xml --durations=5 no_output_timeout: 15m # Clean up first container @@ -2425,56 +1685,18 @@ jobs: proxy_store_model_in_db_tests: machine: - image: ubuntu-2204:2023.10.1 + image: ubuntu-2204:2024.04.1 resource_class: large working_directory: ~/project steps: - checkout - setup_google_dns - - run: - name: Verify Docker is available - command: | - docker version - sudo systemctl restart docker - - run: - name: Install Python 3.9 - command: | - curl https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh --output miniconda.sh - bash miniconda.sh -b -p $HOME/miniconda - export PATH="$HOME/miniconda/bin:$PATH" - conda init bash - source ~/.bashrc - conda create -n myenv python=3.10 -y - conda activate myenv - python --version + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" - - run: - name: Start PostgreSQL Database - command: | - docker run -d \ - --name postgres-db \ - -e POSTGRES_USER=postgres \ - -e POSTGRES_PASSWORD=postgres \ - -e POSTGRES_DB=circle_test \ - -p 5432:5432 \ - postgres:14 - - wait_for_service: - url: tcp://localhost:5432 - timeout: "60" + uv sync --frozen --all-groups --all-extras --python 3.12 + - start_postgres - attach_workspace: at: ~/project - run: @@ -2510,8 +1732,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -vv tests/store_model_in_db_tests -x --junitxml=test-results/junit.xml --durations=5 no_output_timeout: 15m - run: @@ -2528,57 +1748,23 @@ jobs: proxy_build_from_pip_tests: # Change from docker to machine executor machine: - image: ubuntu-2204:2023.10.1 + image: ubuntu-2204:2024.04.1 resource_class: large working_directory: ~/project steps: - checkout - setup_google_dns # Remove Docker CLI installation since it's already available in machine executor - - run: - name: Install Python 3.13 - command: | - curl https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh --output miniconda.sh - bash miniconda.sh -b -p $HOME/miniconda - export PATH="$HOME/miniconda/bin:$PATH" - conda init bash - source ~/.bashrc - conda create -n myenv python=3.13 -y - conda activate myenv - python --version + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 - run: name: Build Docker image command: | docker build -t my-app:latest -f docker/build_from_pip/Dockerfile.build_from_pip . - - run: - name: Start PostgreSQL Database - command: | - docker run -d \ - --name postgres-db \ - -e POSTGRES_USER=postgres \ - -e POSTGRES_PASSWORD=postgres \ - -e POSTGRES_DB=circle_test \ - -p 5432:5432 \ - postgres:14 - - run: - name: Wait for PostgreSQL to be ready - command: | - timeout 60s bash -c 'until docker exec postgres-db pg_isready -U postgres -d circle_test; do sleep 2; done' + - start_postgres - run: name: Run Docker container # intentionally give bad redis credentials here @@ -2632,51 +1818,18 @@ jobs: when: always proxy_pass_through_endpoint_tests: machine: - image: ubuntu-2204:2023.10.1 + image: ubuntu-2204:2024.04.1 resource_class: large working_directory: ~/project steps: - checkout - setup_google_dns - - run: - name: Install Python 3.10 - command: | - curl https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh --output miniconda.sh - bash miniconda.sh -b -p $HOME/miniconda - export PATH="$HOME/miniconda/bin:$PATH" - conda init bash - source ~/.bashrc - conda create -n myenv python=3.10 -y - conda activate myenv - python --version + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" - - run: - name: Start PostgreSQL Database - command: | - docker run -d \ - --name postgres-db \ - -e POSTGRES_USER=postgres \ - -e POSTGRES_PASSWORD=postgres \ - -e POSTGRES_DB=circle_test \ - -p 5432:5432 \ - postgres:14 - - wait_for_service: - url: tcp://localhost:5432 - timeout: "60" + uv sync --frozen --all-groups --all-extras --python 3.12 + - start_postgres - attach_workspace: at: ~/project - run: @@ -2721,19 +1874,27 @@ jobs: - run: name: Install Ruby and Bundler command: | - # Import GPG keys first - gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB || { - curl -sSL https://rvm.io/mpapis.asc | gpg --import - - curl -sSL https://rvm.io/pkuczynski.asc | gpg --import - - } + # Clone RVM at pinned tag and verify the commit SHA matches the + # published tag before running its install script. + RVM_VERSION="1.29.12" + RVM_EXPECTED_SHA="6bfc9213c9d6914fe756f524eb034a403d51db81" + git clone --depth 1 --branch "$RVM_VERSION" https://github.com/rvm/rvm.git /tmp/rvm + RVM_ACTUAL_SHA="$(git -C /tmp/rvm rev-parse HEAD)" + if [ "$RVM_ACTUAL_SHA" != "$RVM_EXPECTED_SHA" ]; then + echo "RVM tag $RVM_VERSION resolved to $RVM_ACTUAL_SHA; expected $RVM_EXPECTED_SHA" >&2 + exit 1 + fi - # Install Ruby version manager (RVM) - curl -sSL https://get.rvm.io | bash -s stable + # Import RVM signing keys (used by `rvm install` to verify Ruby tarballs) + gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB - # Source RVM from the correct location - source $HOME/.rvm/scripts/rvm + # Install RVM from the verified checkout. The install script + # sources `scripts/functions/installer` using paths relative to + # its own working directory, so it must be run from /tmp/rvm. + (cd /tmp/rvm && ./install --path "$HOME/.rvm") + source "$HOME/.rvm/scripts/rvm" - # Install Ruby 3.2.2 + # Install Ruby 3.2.2 (RVM verifies the tarball PGP signature) rvm install 3.2.2 rvm use 3.2.2 --default @@ -2748,37 +1909,37 @@ jobs: bundle install bundle exec rspec no_output_timeout: 30m - # New steps to run Node.js test + # Install Node.js directly from nodejs.org with SHA256 verification, + # instead of piping NodeSource's setup_18.x apt-repo installer into + # sudo bash (which runs a mutable upstream script unattended). - run: - name: Install Node.js + name: Install Node.js 18.20.8 command: | - export DEBIAN_FRONTEND=noninteractive - curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - - sudo apt-get update - sudo apt-get install -y nodejs + NODE_VERSION="18.20.8" + NODE_TARBALL="node-v${NODE_VERSION}-linux-x64.tar.xz" + NODE_EXPECTED_SHA="5467ee62d6af1411d46b6a10e3fb5cacc92734dbcef465fea14e7b90993001c9" + curl -sSLf -o "/tmp/${NODE_TARBALL}" "https://nodejs.org/dist/v${NODE_VERSION}/${NODE_TARBALL}" + echo "${NODE_EXPECTED_SHA} /tmp/${NODE_TARBALL}" | sha256sum -c - + sudo tar -xJf "/tmp/${NODE_TARBALL}" -C /usr/local --strip-components=1 + rm -f "/tmp/${NODE_TARBALL}" node --version npm --version - run: - name: Install Node.js dependencies + name: Install Node.js test dependencies command: | - npm install @google-cloud/vertexai - npm install @google/generative-ai - npm install --save-dev jest + cd tests/pass_through_tests + npm ci - run: name: Run Vertex AI, Google AI Studio Node.js tests command: | - npx jest tests/pass_through_tests --verbose + cd tests/pass_through_tests + npx jest . --verbose no_output_timeout: 30m - run: name: Run tests command: | - export PATH="$HOME/miniconda/bin:$PATH" - source $HOME/miniconda/etc/profile.d/conda.sh - conda activate myenv - pwd - ls uv run --no-sync python -m pytest -v tests/pass_through_tests/ -x --junitxml=test-results/junit.xml --durations=5 no_output_timeout: 15m @@ -2788,55 +1949,18 @@ jobs: proxy_e2e_anthropic_messages_tests: machine: - image: ubuntu-2204:2023.10.1 + image: ubuntu-2204:2024.04.1 resource_class: large working_directory: ~/project steps: - checkout - setup_google_dns - - run: - name: Verify Docker is available - command: | - docker version - - run: - name: Install Python 3.10 - command: | - curl https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh --output miniconda.sh - bash miniconda.sh -b -p $HOME/miniconda - export PATH="$HOME/miniconda/bin:$PATH" - conda init bash - source ~/.bashrc - conda create -n myenv python=3.10 -y - conda activate myenv - python --version + - install_uv - run: name: Install Dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - if [ -f "$HOME/miniconda/etc/profile.d/conda.sh" ]; then - export PATH="$HOME/miniconda/bin:$PATH" - source "$HOME/miniconda/etc/profile.d/conda.sh" - conda activate myenv - fi - uv sync --frozen --all-groups --all-extras --python "$(which python)" - - run: - name: Start PostgreSQL Database - command: | - docker run -d \ - --name postgres-db \ - -e POSTGRES_USER=postgres \ - -e POSTGRES_PASSWORD=postgres \ - -e POSTGRES_DB=circle_test \ - -p 5432:5432 \ - postgres:14 - - wait_for_service: - url: tcp://localhost:5432 - timeout: "60" + uv sync --frozen --all-groups --all-extras --python 3.12 + - start_postgres - attach_workspace: at: ~/project - run: @@ -2873,13 +1997,8 @@ jobs: - run: name: Run Claude Agent SDK E2E Tests command: | - export PATH="$HOME/miniconda/bin:$PATH" - source $HOME/miniconda/etc/profile.d/conda.sh - conda activate myenv export LITELLM_PROXY_URL="http://localhost:4000" export LITELLM_API_KEY="sk-1234" - pwd - ls uv run --no-sync python -m pytest -vv tests/proxy_e2e_anthropic_messages_tests/ -x -s --junitxml=test-results/junit.xml --durations=5 no_output_timeout: 15m @@ -2889,7 +2008,7 @@ jobs: upload-coverage: docker: - - image: cimg/python:3.9 + - *python312_image steps: - checkout - attach_workspace: @@ -2902,23 +2021,18 @@ jobs: ls -la echo "\nContents of tests/llm_translation:" ls -la tests/llm_translation + - install_uv - run: name: Combine Coverage command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - uv tool run --from 'coverage[toml]==7.10.6' coverage combine realtime_translation_coverage ocr_coverage search_coverage mcp_coverage logging_coverage audio_coverage local_testing_part1_coverage local_testing_part2_coverage pass_through_unit_tests_coverage batches_coverage guardrails_coverage redis_caching_coverage + uv tool run --from 'coverage[toml]==7.10.6' coverage combine realtime_translation_coverage ocr_coverage search_coverage logging_coverage audio_coverage local_testing_part1_coverage local_testing_part2_coverage pass_through_unit_tests_coverage batches_coverage guardrails_coverage redis_caching_coverage uv tool run --from 'coverage[toml]==7.10.6' coverage xml - codecov/upload: file: ./coverage.xml ui_build: docker: - - image: cimg/node:20.19 + - image: cimg/node:20.19@sha256:35e64883e8d21bc345b0a7b04c35ee46442c127607ed1d8d7d37d8a1ed76db81 auth: username: ${DOCKERHUB_USERNAME} password: ${DOCKERHUB_PASSWORD} @@ -2960,7 +2074,7 @@ jobs: ui_unit_tests: docker: - - image: cimg/node:20.19 + - image: cimg/node:20.19@sha256:35e64883e8d21bc345b0a7b04c35ee46442c127607ed1d8d7d37d8a1ed76db81 auth: username: ${DOCKERHUB_USERNAME} password: ${DOCKERHUB_PASSWORD} @@ -2992,11 +2106,11 @@ jobs: e2e_ui_testing: docker: - - image: cimg/python:3.12-browsers + - image: cimg/python:3.12-browsers@sha256:b432899af01c9a311bf74f4f22e9ada2e5306d4b1b4383f8d29e1228a5844ef2 auth: username: ${DOCKERHUB_USERNAME} password: ${DOCKERHUB_PASSWORD} - - image: cimg/postgres:16.0 + - image: cimg/postgres:16.0@sha256:b125148bc76e8e8eee5eb3ad6020a3a14110a14e8192f1c645128afebe2e2f84 environment: POSTGRES_USER: e2euser POSTGRES_PASSWORD: e2epassword @@ -3009,24 +2123,19 @@ jobs: steps: - checkout - setup_google_dns + - install_uv - restore_cache: keys: - - ui-e2e-py-deps-v2-{{ checksum "uv.lock" }}-{{ checksum ".circleci/config.yml" }} + - v1-uv-cache-{{ checksum "uv.lock" }} - run: name: Install Python dependencies command: | - curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.10.9/install.sh - echo "7fc46e39cb97290b57169c0c813a17970585ac519139f19006453c99b5f2f45f /tmp/uv-install.sh" | sha256sum -c - - env UV_NO_MODIFY_PATH=1 sh /tmp/uv-install.sh - rm -f /tmp/uv-install.sh - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" - export PATH="$HOME/.local/bin:$PATH" - uv sync --frozen --all-groups --all-extras --python "$(which python)" + uv sync --frozen --all-groups --all-extras --python 3.12 uv run --no-sync python -m prisma generate --schema litellm/proxy/schema.prisma - save_cache: - key: ui-e2e-py-deps-v2-{{ checksum "uv.lock" }}-{{ checksum ".circleci/config.yml" }} + key: v1-uv-cache-{{ checksum "uv.lock" }} paths: - - ./.venv + - ~/.cache/uv - restore_cache: keys: - ui-e2e-node-deps-v1-{{ checksum "ui/litellm-dashboard/package-lock.json" }} @@ -3042,10 +2151,19 @@ jobs: - ui/litellm-dashboard/node_modules - 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. command: | cd ui/litellm-dashboard npm run build - cp -r out/ ../../litellm/proxy/_experimental/out/ + 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" @@ -3132,7 +2250,7 @@ jobs: test_bad_database_url: machine: - image: ubuntu-2204:2023.10.1 + image: ubuntu-2204:2024.04.1 resource_class: medium working_directory: ~/project steps: @@ -3140,19 +2258,7 @@ jobs: - attach_workspace: at: ~/project - setup_google_dns - - run: - name: Start PostgreSQL Database - command: | - docker run -d \ - --name postgres-db \ - -e POSTGRES_USER=postgres \ - -e POSTGRES_PASSWORD=postgres \ - -e POSTGRES_DB=circle_test \ - -p 5432:5432 \ - postgres:14 - - wait_for_service: - url: tcp://localhost:5432 - timeout: "60" + - start_postgres - run: name: Load Docker Database Image command: | @@ -3184,316 +2290,112 @@ jobs: fi workflows: - version: 2 build_and_test: jobs: - using_litellm_on_windows: - filters: - branches: - only: - - main - - /litellm_.*/ - - mypy_linting: - filters: - branches: - only: - - main - - /litellm_.*/ - - semgrep: - filters: + filters: &main_branches branches: only: - main - /litellm_.*/ - local_testing_part1: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - local_testing_part2: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - langfuse_logging_unit_tests: - filters: - branches: - only: - - main - - /litellm_.*/ - - main - - /litellm_.*/ - - main - - /litellm_.*/ - - main - - /litellm_.*/ - - main - - /litellm_.*/ - - main - - /litellm_.*/ + filters: *main_branches - litellm_assistants_api_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - litellm_router_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - litellm_router_unit_testing: - filters: - branches: - only: - - main - - /litellm_.*/ - - check_code_and_doc_quality: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - ui_build: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - ui_unit_tests: requires: - ui_build - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - auth_ui_unit_tests: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - build_docker_database_image: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - e2e_ui_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - build_and_test: requires: - build_docker_database_image - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - e2e_openai_endpoints: requires: - build_docker_database_image - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - proxy_logging_guardrails_model_info_tests: requires: - build_docker_database_image - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - proxy_spend_accuracy_tests: requires: - build_docker_database_image - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - proxy_multi_instance_tests: requires: - build_docker_database_image - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - proxy_store_model_in_db_tests: requires: - build_docker_database_image - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - proxy_build_from_pip_tests: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - proxy_pass_through_endpoint_tests: requires: - build_docker_database_image - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - proxy_e2e_anthropic_messages_tests: requires: - build_docker_database_image - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - llm_translation_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - realtime_translation_testing: - filters: - branches: - only: - - main - - /litellm_.*/ - - mcp_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - agent_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - guardrails_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - google_generate_content_endpoint_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - llm_responses_api_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - ocr_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - search_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - litellm_mapped_enterprise_tests: - filters: - branches: - only: - - main - - /litellm_.*/ - - litellm_mapped_tests_proxy_part1: - filters: - branches: - only: - - main - - /litellm_.*/ - - litellm_mapped_tests_proxy_part2: - filters: - branches: - only: - - main - - /litellm_.*/ - - main - - /litellm_.*/ - - main - - /litellm_.*/ - - main - - /litellm_.*/ - - main - - /litellm_.*/ - - main - - /litellm_.*/ + filters: *main_branches - batches_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - litellm_utils_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - pass_through_unit_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - image_gen_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - logging_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - audio_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - redis_caching_unit_tests: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - upload-coverage: requires: - realtime_translation_testing - - mcp_testing - agent_testing - google_generate_content_endpoint_testing - guardrails_testing - ocr_testing - search_testing - - litellm_mapped_tests_proxy_part1 - - litellm_mapped_tests_proxy_part2 - litellm_mapped_enterprise_tests - batches_testing - litellm_utils_testing @@ -3509,36 +2411,18 @@ workflows: - db_migration_disable_update_check: requires: - build_docker_database_image - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - installing_litellm_on_python: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - installing_litellm_on_python_3_13: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches + - installing_litellm_on_python_v2_migration_resolver: + filters: *main_branches - helm_chart_testing: requires: - build_docker_database_image - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - test_bad_database_url: requires: - build_docker_database_image - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches diff --git a/.github/screenshots/after_org_assigned.png b/.github/screenshots/after_org_assigned.png new file mode 100644 index 00000000000..75c6a8ed5f5 Binary files /dev/null and b/.github/screenshots/after_org_assigned.png differ diff --git a/.github/screenshots/after_org_detail.png b/.github/screenshots/after_org_detail.png new file mode 100644 index 00000000000..2b4d23e254a Binary files /dev/null and b/.github/screenshots/after_org_detail.png differ diff --git a/.github/screenshots/before_403_error.png b/.github/screenshots/before_403_error.png new file mode 100644 index 00000000000..686c2fe573d Binary files /dev/null and b/.github/screenshots/before_403_error.png differ diff --git a/.github/screenshots/before_no_org.png b/.github/screenshots/before_no_org.png new file mode 100644 index 00000000000..9a4cfaa1d87 Binary files /dev/null and b/.github/screenshots/before_no_org.png differ diff --git a/.github/workflows/_test-unit-services-base.yml b/.github/workflows/_test-unit-services-base.yml index 8e0b3568aea..8c47b6d7666 100644 --- a/.github/workflows/_test-unit-services-base.yml +++ b/.github/workflows/_test-unit-services-base.yml @@ -32,41 +32,39 @@ on: required: false type: boolean default: false + dist: + description: "pytest-xdist distribution mode (loadscope|load|worksteal|loadfile|no)" + required: false + type: string + default: "loadscope" artifact-name: description: "Unique name for the coverage artifact (must be unique per run)" required: false type: string default: "run" - secrets: - DATABASE_URL: - required: false - POSTGRES_USER: - required: false - POSTGRES_PASSWORD: - required: false permissions: contents: read +# The postgres service container below is spawned per-job on localhost and +# destroyed with the job. Nothing outside the runner can reach it. The +# user/password/database here are not secrets — they're bootstrap values +# for a throwaway container — so we hardcode them instead of attaching +# every matrix shard to a GHA environment just to read three "secrets" +# (which also produces a "temporarily deployed to …" notification on the +# PR timeline per shard per push). jobs: run: name: Run tests runs-on: ubuntu-latest timeout-minutes: ${{ inputs.timeout-minutes }} - # Environment is derived from the enable-* flags, not caller-controllable. - # This prevents callers from passing arbitrary environment names to bypass secret scoping. - environment: >- - ${{ - inputs.enable-postgres && 'integration-postgres' || - '' - }} services: postgres: image: postgres@sha256:705a5d5b5836f3fcba0d02c4d281e6a7dd9ed2dd4078640f08a1e1e9896e097d # postgres:14 env: - POSTGRES_USER: ${{ secrets.POSTGRES_USER }} - POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_USER: litellm + POSTGRES_PASSWORD: litellm POSTGRES_DB: litellm_test ports: - 5432:5432 @@ -114,7 +112,7 @@ jobs: - name: Run Prisma migrations if: ${{ inputs.enable-postgres }} env: - DATABASE_URL: ${{ secrets.DATABASE_URL }} + DATABASE_URL: "postgresql://litellm:litellm@localhost:5432/litellm_test" run: | uv run --no-sync prisma db push --schema litellm/proxy/schema.prisma --accept-data-loss @@ -124,7 +122,8 @@ jobs: MAX_FAILURES: ${{ inputs.max-failures }} WORKERS: ${{ inputs.workers }} RERUNS: ${{ inputs.reruns }} - DATABASE_URL: ${{ inputs.enable-postgres && secrets.DATABASE_URL || '' }} + DIST: ${{ inputs.dist }} + DATABASE_URL: ${{ inputs.enable-postgres && 'postgresql://litellm:litellm@localhost:5432/litellm_test' || '' }} run: | if [ "${WORKERS}" = "0" ]; then uv run --no-sync pytest ${TEST_PATH:?} \ @@ -143,7 +142,7 @@ jobs: -n "${WORKERS}" \ --reruns "${RERUNS}" \ --reruns-delay 1 \ - --dist=loadscope \ + --dist="${DIST}" \ --durations=20 \ --cov=litellm \ --cov-report=xml:coverage.xml \ diff --git a/.github/workflows/check_duplicate_issues.yml b/.github/workflows/check_duplicate_issues.yml index 289d78880ad..78198b2c7bb 100644 --- a/.github/workflows/check_duplicate_issues.yml +++ b/.github/workflows/check_duplicate_issues.yml @@ -39,7 +39,7 @@ jobs: if: github.event.action == 'opened' uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: - python-version: "3.11" + python-version: "3.12" - name: Auto-close if high-confidence duplicate if: github.event.action == 'opened' diff --git a/.github/workflows/create-release-branch.yml b/.github/workflows/create-release-branch.yml new file mode 100644 index 00000000000..13b76c94dfa --- /dev/null +++ b/.github/workflows/create-release-branch.yml @@ -0,0 +1,65 @@ +name: Create Release Branch + +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag (e.g. v1.83.0-stable) — branch will be named release/" + required: true + type: string + commit_hash: + description: "Full 40-char commit SHA the branch should point to" + required: true + type: string + workflow_call: + inputs: + tag: + description: "Release tag" + required: true + type: string + commit_hash: + description: "Full 40-char commit SHA the branch should point to" + required: true + type: string + +permissions: {} + +jobs: + create-branch: + name: Create Release Branch + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Validate inputs + env: + TAG: ${{ inputs.tag }} + COMMIT_HASH: ${{ inputs.commit_hash }} + run: | + if ! echo "${COMMIT_HASH}" | grep -qE '^[0-9a-f]{40}$'; then + echo "::error::commit_hash must be a full 40-character commit SHA" + exit 1 + fi + if ! echo "${TAG}" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+'; then + echo "::error::tag must start with vX.Y.Z" + exit 1 + fi + + - name: Create release branch + env: + TAG: ${{ inputs.tag }} + COMMIT_HASH: ${{ inputs.commit_hash }} + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const tag = process.env.TAG; + const commitHash = process.env.COMMIT_HASH; + const branchName = `release/${tag}`; + + await github.rest.git.createRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `refs/heads/${branchName}`, + sha: commitHash, + }); + core.info(`Created branch ${branchName} at ${commitHash}`); diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index b8633979854..68ab397d827 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -102,6 +102,17 @@ jobs: body: updatedBody, draft: false, }); + } catch (error) { core.setFailed(error.message); } + + create-branch: + name: Create Release Branch + needs: release + permissions: + contents: write + uses: ./.github/workflows/create-release-branch.yml + with: + tag: ${{ inputs.tag }} + commit_hash: ${{ inputs.commit_hash }} diff --git a/.github/workflows/llm-translation-testing.yml b/.github/workflows/llm-translation-testing.yml index 93b69e5c6a9..8d9d52f4e58 100644 --- a/.github/workflows/llm-translation-testing.yml +++ b/.github/workflows/llm-translation-testing.yml @@ -29,7 +29,7 @@ jobs: - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: - python-version: "3.11" + python-version: "3.12" - name: Set up uv uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 diff --git a/.github/workflows/scan_duplicate_issues.yml b/.github/workflows/scan_duplicate_issues.yml index 222ff11f304..ab0ac2aa3ac 100644 --- a/.github/workflows/scan_duplicate_issues.yml +++ b/.github/workflows/scan_duplicate_issues.yml @@ -29,7 +29,7 @@ jobs: - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: - python-version: "3.13" + python-version: "3.12" - name: Scan for duplicate issues env: diff --git a/.github/workflows/test-code-quality.yml b/.github/workflows/test-code-quality.yml new file mode 100644 index 00000000000..da0cbcd9154 --- /dev/null +++ b/.github/workflows/test-code-quality.yml @@ -0,0 +1,136 @@ +name: Code Quality Checks + +on: + pull_request: + branches: + - main + - litellm_internal_staging + - litellm_oss_branch + - "litellm_**" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + code-quality: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - name: Checkout litellm-docs (for documentation_tests) + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + repository: BerriAI/litellm-docs + path: _litellm_docs_checkout + persist-credentials: false + + - name: Wire up docs path expected by documentation_tests/* + run: | + # documentation_tests scripts read from docs/my-website/docs/... + # In litellm-docs the same files live at docs/... (repo root). + # Point docs/my-website -> litellm-docs checkout so the paths resolve. + rm -rf docs/my-website + ln -s ../_litellm_docs_checkout docs/my-website + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + version: "0.10.9" + + - name: Cache uv dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.cache/uv + .venv + key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }} + restore-keys: | + ${{ runner.os }}-uv- + + - name: Install dependencies + run: uv sync --frozen --all-groups --all-extras + + - name: check_licenses + run: uv run --no-sync python ./tests/code_coverage_tests/check_licenses.py + + - name: check_provider_folders_documented + run: uv run --no-sync python ./tests/code_coverage_tests/check_provider_folders_documented.py + + - name: router_code_coverage + run: uv run --no-sync python ./tests/code_coverage_tests/router_code_coverage.py + + - name: test_chat_completion_imports + run: uv run --no-sync python ./tests/code_coverage_tests/test_chat_completion_imports.py + + - name: info_log_check + run: uv run --no-sync python ./tests/code_coverage_tests/info_log_check.py + + - name: check_guardrail_apply_decorator + run: uv run --no-sync python ./tests/code_coverage_tests/check_guardrail_apply_decorator.py + + - name: test_ban_set_verbose + run: uv run --no-sync python ./tests/code_coverage_tests/test_ban_set_verbose.py + + - name: code_qa_check_tests + run: uv run --no-sync python ./tests/code_coverage_tests/code_qa_check_tests.py + + - name: check_get_model_cost_key_performance + run: uv run --no-sync python ./tests/code_coverage_tests/check_get_model_cost_key_performance.py + + - name: test_proxy_types_import + run: uv run --no-sync python ./tests/code_coverage_tests/test_proxy_types_import.py + + - name: callback_manager_test + run: uv run --no-sync python ./tests/code_coverage_tests/callback_manager_test.py + + - name: recursive_detector + run: uv run --no-sync python ./tests/code_coverage_tests/recursive_detector.py + + - name: test_router_strategy_async + run: uv run --no-sync python ./tests/code_coverage_tests/test_router_strategy_async.py + + - name: litellm_logging_code_coverage + run: uv run --no-sync python ./tests/code_coverage_tests/litellm_logging_code_coverage.py + + - name: ensure_async_clients_test + run: uv run --no-sync python ./tests/code_coverage_tests/ensure_async_clients_test.py + + - name: enforce_llms_folder_style + run: uv run --no-sync python ./tests/code_coverage_tests/enforce_llms_folder_style.py + + - name: prevent_key_leaks_in_exceptions + run: uv run --no-sync python ./tests/code_coverage_tests/prevent_key_leaks_in_exceptions.py + + - name: check_unsafe_enterprise_import + run: uv run --no-sync python ./tests/code_coverage_tests/check_unsafe_enterprise_import.py + + - name: ban_copy_deepcopy_kwargs + run: uv run --no-sync python ./tests/code_coverage_tests/ban_copy_deepcopy_kwargs.py + + - name: check_fastuuid_usage + run: uv run --no-sync python ./tests/code_coverage_tests/check_fastuuid_usage.py + + - name: memory_test + run: uv run --no-sync python ./tests/code_coverage_tests/memory_test.py + + - name: documentation_test_env_keys + run: uv run --no-sync python ./tests/documentation_tests/test_env_keys.py + + - name: documentation_test_router_settings + run: uv run --no-sync python ./tests/documentation_tests/test_router_settings.py + + - name: documentation_test_api_docs + run: uv run --no-sync python ./tests/documentation_tests/test_api_docs.py diff --git a/.github/workflows/test-semgrep.yml b/.github/workflows/test-semgrep.yml new file mode 100644 index 00000000000..2ba23e44da8 --- /dev/null +++ b/.github/workflows/test-semgrep.yml @@ -0,0 +1,39 @@ +name: Semgrep + +on: + pull_request: + branches: + - main + - litellm_internal_staging + - litellm_oss_branch + - "litellm_**" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + semgrep: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + version: "0.10.9" + + - name: Run Semgrep (custom rules) + run: uv tool run --from 'semgrep==1.157.0' semgrep scan --config .semgrep/rules . --error diff --git a/.github/workflows/test-unit-proxy-db.yml b/.github/workflows/test-unit-proxy-db.yml index 87e7e17feb7..49795ad4e8d 100644 --- a/.github/workflows/test-unit-proxy-db.yml +++ b/.github/workflows/test-unit-proxy-db.yml @@ -12,8 +12,74 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +# Semantic matrix: each shard groups tests by concern (auth, server, logging, …) +# rather than alphabetical letter ranges. Adding a new test file means adding it +# to whichever group it belongs to, not reshuffling slices. +# +# Design targets: +# * Every shard runs in <= 7 minutes of wall-clock on the default runner. +# Most of a shard's time is pytest plugin load + xdist worker imports + +# pytest-cov instrumentation, not the tests themselves. Keeping per-shard +# work low and matching worker count to runner cores is what controls it. +# * workers: 4 matches the 4-core ubuntu-latest runner. -n 8 on 4 cores +# oversubscribes 2x and workers fight for CPU during their cold-start +# imports (measured ~441% CPU for -n 8 locally, i.e. ~55% effective). +# * test_key_generate_prisma.py stays serial (workers=0) — it has event-loop +# conflicts with the logging worker when run in parallel. +# * test_proxy_utils.py runs as a single shard with --dist=worksteal so +# xdist balances its 188 parametrized cases across workers instead of +# pinning the whole file to one worker (the default --dist=loadscope +# behavior for single-file targets). +# * test_db_schema_migration.py is isolated because one test in it +# (test_aaaasschema_migration_check) takes ~170s — by itself it +# determines the shard's wall-clock floor. jobs: + # Fast guard — fails the workflow if a test_*.py file under + # tests/proxy_unit_tests/ is not referenced by any matrix entry below. + # The semantic-shard design (no catch-all "remaining" bucket) relies on + # every test file being explicitly assigned; this guard prevents a new + # file from silently dropping out of CI. + assert-shard-coverage: + runs-on: ubuntu-latest + timeout-minutes: 2 + permissions: + contents: read + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + - name: Assert every test_*.py is in a matrix shard + run: | + python3 - <<'PY' + import pathlib, sys, yaml + wf = yaml.safe_load(open(".github/workflows/test-unit-proxy-db.yml")) + matrix = wf["jobs"]["proxy-db"]["strategy"]["matrix"]["include"] + referenced = set() + for entry in matrix: + for token in entry["test-path"].split(): + if token.startswith("tests/proxy_unit_tests/"): + referenced.add(pathlib.PurePosixPath(token).name) + actual = {p.name for p in pathlib.Path("tests/proxy_unit_tests").iterdir() + if p.name.startswith("test_") and (p.suffix == ".py" or p.is_dir()) + and p.name != "test_configs"} + orphans = sorted(actual - referenced) + if orphans: + print("ERROR: the following files/dirs under tests/proxy_unit_tests/") + print(" are not assigned to any shard in test-unit-proxy-db.yml:") + for o in orphans: + print(f" - {o}") + print() + print("Add each to whichever semantic shard it belongs to.") + sys.exit(1) + print(f"OK: all {len(actual)} files assigned to a shard.") + PY + proxy-db: + needs: assert-shard-coverage + # Display only the semantic shard name in the checks UI instead of GHA's + # default "proxy-db (key-generation, tests/proxy_unit_tests/…, 0, loadscope, 20)" + # which includes every matrix field and gets truncated past the test-path. + name: ${{ matrix.test-group }} permissions: contents: read id-token: write @@ -22,19 +88,146 @@ jobs: fail-fast: false matrix: include: - # Key generation tests must NOT run in parallel (event loop conflicts with logging worker) + # Must run serially — event-loop conflict with the logging worker. - test-group: key-generation test-path: "tests/proxy_unit_tests/test_key_generate_prisma.py" workers: 0 - timeout: 30 - - test-group: auth-checks - test-path: "tests/proxy_unit_tests/test_auth_checks.py tests/proxy_unit_tests/test_user_api_key_auth.py" - workers: 8 + dist: loadscope timeout: 20 - - test-group: remaining - test-path: "tests/proxy_unit_tests --ignore=tests/proxy_unit_tests/test_key_generate_prisma.py --ignore=tests/proxy_unit_tests/test_auth_checks.py --ignore=tests/proxy_unit_tests/test_user_api_key_auth.py" - workers: 8 - timeout: 30 + + # ---- auth: split into 2 shards ---- + - test-group: auth-checks + test-path: >- + tests/proxy_unit_tests/test_auth_checks.py + tests/proxy_unit_tests/test_user_api_key_auth.py + workers: 4 + dist: loadscope + timeout: 15 + - test-group: jwt-and-keys + test-path: >- + tests/proxy_unit_tests/test_jwt.py + tests/proxy_unit_tests/test_jwt_key_mapping.py + tests/proxy_unit_tests/test_proxy_custom_auth.py + tests/proxy_unit_tests/test_key_generate_dynamodb.py + tests/proxy_unit_tests/test_deployed_proxy_keygen.py + workers: 4 + dist: loadscope + timeout: 15 + + # ---- test_proxy_utils.py, single shard, worksteal distribution ---- + - test-group: proxy-utils + test-path: "tests/proxy_unit_tests/test_proxy_utils.py" + workers: 4 + dist: worksteal + timeout: 15 + + # ---- proxy server: split into 2 shards ---- + - test-group: proxy-server-core + test-path: >- + tests/proxy_unit_tests/test_proxy_server.py + tests/proxy_unit_tests/test_proxy_server_keys.py + tests/proxy_unit_tests/test_proxy_server_caching.py + tests/proxy_unit_tests/test_proxy_server_langfuse.py + tests/proxy_unit_tests/test_proxy_server_spend.py + tests/proxy_unit_tests/test_aproxy_startup.py + workers: 4 + dist: loadscope + timeout: 15 + - test-group: proxy-runtime + test-path: >- + tests/proxy_unit_tests/test_proxy_config_unit_test.py + tests/proxy_unit_tests/test_proxy_routes.py + tests/proxy_unit_tests/test_proxy_gunicorn.py + tests/proxy_unit_tests/test_server_root_path.py + tests/proxy_unit_tests/test_proxy_pass_user_config.py + tests/proxy_unit_tests/test_proxy_token_counter.py + workers: 4 + dist: loadscope + timeout: 15 + + # ---- logging: split into 2 shards ---- + - test-group: custom-logging + test-path: >- + tests/proxy_unit_tests/test_custom_callback_input.py + tests/proxy_unit_tests/test_custom_logger_s3_gcs.py + tests/proxy_unit_tests/test_proxy_custom_logger.py + workers: 4 + dist: loadscope + timeout: 15 + - test-group: logging-misc + test-path: >- + tests/proxy_unit_tests/test_proxy_reject_logging.py + tests/proxy_unit_tests/test_audit_logs_proxy.py + tests/proxy_unit_tests/test_search_api_logging.py + workers: 4 + dist: loadscope + timeout: 15 + + # ---- db-and-spend: isolate the 170s schema-migration test ---- + # test_db_schema_migration.py has exactly one test, and that test + # is mostly waiting on `prisma migrate deploy` / `prisma migrate + # diff` subprocesses (~170s). It does no CPU-bound Python work + # inside the test. Running with workers=0 (serial, no xdist) + # skips the 4-worker cold-start cost we'd otherwise pay for a + # single test, saving ~4 minutes of wall-clock. + - test-group: schema-migration + test-path: "tests/proxy_unit_tests/test_db_schema_migration.py" + workers: 0 + dist: loadscope + timeout: 15 + - test-group: db-and-spend + test-path: >- + tests/proxy_unit_tests/test_prisma_client_backoff_retry.py + tests/proxy_unit_tests/test_db_schema_changes.py + tests/proxy_unit_tests/test_e2e_pod_lock_manager.py + tests/proxy_unit_tests/test_skills_db.py + tests/proxy_unit_tests/test_update_daily_tag_spend.py + tests/proxy_unit_tests/test_update_spend.py + tests/proxy_unit_tests/test_project_endpoints_prisma.py + tests/proxy_unit_tests/test_proxy_encrypt_decrypt.py + workers: 4 + dist: loadscope + timeout: 15 + + # ---- guardrails + budget + hooks: split into 2 ---- + - test-group: guardrails-hooks + test-path: >- + tests/proxy_unit_tests/test_proxy_setting_guardrails.py + tests/proxy_unit_tests/test_banned_keyword_list.py + tests/proxy_unit_tests/test_unit_test_proxy_hooks.py + workers: 4 + dist: loadscope + timeout: 15 + - test-group: budgets + test-path: >- + tests/proxy_unit_tests/test_default_end_user_budget_simple.py + tests/proxy_unit_tests/test_unit_test_max_model_budget_limiter.py + tests/proxy_unit_tests/test_zero_cost_model_budget_bypass.py + workers: 4 + dist: loadscope + timeout: 15 + + - test-group: endpoints-and-responses + test-path: >- + tests/proxy_unit_tests/test_blog_posts_endpoint.py + tests/proxy_unit_tests/test_models_fallback_endpoint.py + tests/proxy_unit_tests/test_google_endpoint_routing.py + tests/proxy_unit_tests/test_google_gemini_proxy_request.py + tests/proxy_unit_tests/test_get_favicon.py + tests/proxy_unit_tests/test_get_image.py + tests/proxy_unit_tests/test_ui_path_detection.py + tests/proxy_unit_tests/test_prompt_test_endpoint.py + tests/proxy_unit_tests/test_check_batch_cost.py + tests/proxy_unit_tests/test_check_responses_cost.py + tests/proxy_unit_tests/test_response_polling_handler.py + tests/proxy_unit_tests/test_response_polling_pre_call_checks.py + tests/proxy_unit_tests/test_realtime_cache.py + tests/proxy_unit_tests/test_proxy_exception_mapping.py + tests/proxy_unit_tests/test_custom_tokenizer_bug.py + tests/proxy_unit_tests/test_model_response_typing + workers: 4 + dist: loadscope + timeout: 15 uses: ./.github/workflows/_test-unit-services-base.yml with: test-path: ${{ matrix.test-path }} @@ -42,8 +235,5 @@ jobs: reruns: 2 timeout-minutes: ${{ matrix.timeout }} enable-postgres: true + dist: ${{ matrix.dist }} artifact-name: proxy-db-${{ matrix.test-group }} - secrets: - DATABASE_URL: ${{ secrets.DATABASE_URL }} - POSTGRES_USER: ${{ secrets.POSTGRES_USER }} - POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} diff --git a/.github/workflows/test-unit-proxy-endpoints.yml b/.github/workflows/test-unit-proxy-endpoints.yml index fafc866a3f6..1439b2c07f7 100644 --- a/.github/workflows/test-unit-proxy-endpoints.yml +++ b/.github/workflows/test-unit-proxy-endpoints.yml @@ -36,6 +36,8 @@ jobs: tests/test_litellm/proxy/health_endpoints tests/test_litellm/proxy/public_endpoints tests/test_litellm/proxy/prompts + tests/test_litellm/proxy/rag_endpoints + tests/test_litellm/proxy/realtime_endpoints tests/test_litellm/proxy/ui_crud_endpoints workers: 2 reruns: 2 diff --git a/.github/workflows/test-unit-security.yml b/.github/workflows/test-unit-security.yml index 4defa03b4d0..4ee89897024 100644 --- a/.github/workflows/test-unit-security.yml +++ b/.github/workflows/test-unit-security.yml @@ -1,6 +1,8 @@ name: "Unit Tests: Security" -# Uses DATABASE_URL secret — only runs on trusted branches, not PRs. +# Kept push-only (was previously required by DATABASE_URL secret scoping; +# now the postgres credentials are ephemeral localhost values but the +# push-trigger stays to match the proxy-db workflow cadence). on: push: branches: [main, "litellm_**"] @@ -24,7 +26,3 @@ jobs: timeout-minutes: 20 enable-postgres: true artifact-name: security - secrets: - DATABASE_URL: ${{ secrets.DATABASE_URL }} - POSTGRES_USER: ${{ secrets.POSTGRES_USER }} - POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} diff --git a/Dockerfile b/Dockerfile index a2cd1cb3ed2..d6c3bfad6f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,10 +27,8 @@ RUN apk add --no-cache \ npm \ libsndfile -ENV PRISMA_BINARY_CACHE_DIR=/app/.cache/prisma-python/binaries \ - UV_PROJECT_ENVIRONMENT=/app/.venv \ +ENV UV_PROJECT_ENVIRONMENT=/app/.venv \ UV_LINK_MODE=copy \ - XDG_CACHE_HOME=/app/.cache \ PATH="/app/.venv/bin:${PATH}" # Copy dependency metadata first for layer caching @@ -94,11 +92,14 @@ RUN apk add --no-cache bash openssl tzdata nodejs npm python3 libsndfile supervi { apk del --no-cache npm 2>/dev/null || true; } WORKDIR /app -ENV PRISMA_BINARY_CACHE_DIR=/app/.cache/prisma-python/binaries \ - XDG_CACHE_HOME=/app/.cache \ - PATH="/app/.venv/bin:${PATH}" +ENV PATH="/app/.venv/bin:${PATH}" COPY --from=builder /app /app +# Prisma binaries live in $HOME/.cache (default prisma-python location), +# which is /root/.cache here. Copy them from the builder so they survive +# deployments that volume-mount /app/.cache (e.g. readOnlyRootFilesystem +# + emptyDir) — otherwise the mount would shadow the baked-in query engine. +COPY --from=builder /root/.cache /root/.cache RUN find /app/.venv -type f -path "*/tornado/test/*" -delete && \ find /app/.venv -type d -path "*/tornado/test" -delete diff --git a/ci_cd/run_migration.py b/ci_cd/run_migration.py index 29101bf9505..feec4046ee1 100644 --- a/ci_cd/run_migration.py +++ b/ci_cd/run_migration.py @@ -1,22 +1,231 @@ +import argparse import os -import subprocess -from pathlib import Path -from datetime import datetime -import testing.postgresql +import re import shutil +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +import testing.postgresql -def create_migration(migration_name: str = None): +DESTRUCTIVE_PATTERN = re.compile(r"\bDROP\s+(COLUMN|TABLE|INDEX)\b", re.IGNORECASE) +DEFAULT_BASE_BRANCH = "litellm_internal_staging" + + +def _find_destructive_statements(sql: str) -> list: + """Return SQL lines containing DROP COLUMN, DROP TABLE, or DROP INDEX.""" + return [ + line.strip() for line in sql.splitlines() if DESTRUCTIVE_PATTERN.search(line) + ] + + +def _print_freshness_failure( + base_branch: str, reason: str, stderr_text: str = "" +) -> None: + """Loudly refuse to run when the freshness check can't be completed.""" + banner = "=" * 72 + out = sys.stderr + print(banner, file=out) + print(f" FRESHNESS CHECK FAILED — COULD NOT VERIFY origin/{base_branch}", file=out) + print(banner, file=out) + print("", file=out) + print(f"Reason: {reason}", file=out) + if stderr_text: + print("", file=out) + print("git stderr:", file=out) + for line in stderr_text.rstrip().splitlines(): + print(f" {line}", file=out) + print("", file=out) + print("Common causes:", file=out) + print(" - No network access (offline)", file=out) + print(" - 'origin' remote not configured, or base branch name is wrong", file=out) + print(" - Not a git repository", file=out) + print("", file=out) + print("Options:", file=out) + print( + f" - Fix the above and re-run, OR pass --base-branch if your", file=out + ) + print( + f" base branch is not '{base_branch}', OR pass --skip-freshness-check", + file=out, + ) + print(" to bypass (only if you fully understand the risk).", file=out) + print(banner, file=out) + + +def _print_stale_branch_refusal(base_branch: str, behind: int) -> None: + """Loudly refuse to run when HEAD is behind the base branch.""" + banner = "=" * 72 + out = sys.stderr + print(banner, file=out) + print( + f" STALE BRANCH — {behind} commit(s) behind origin/{base_branch}", + file=out, + ) + print(banner, file=out) + print("", file=out) + print( + f"Your branch is {behind} commit(s) behind origin/{base_branch}. Generating a\n" + "migration from a stale branch is how newly-added columns get silently\n" + "dropped — the exact incident this guard exists to prevent.", + file=out, + ) + print("", file=out) + print("Before re-running:", file=out) + print(f" git fetch origin && git rebase origin/{base_branch}", file=out) + print(" # or merge, whichever matches your workflow", file=out) + print("", file=out) + print( + "If you have a genuine reason to bypass (e.g. intentionally generating", + file=out, + ) + print("a migration against an older base), pass --skip-freshness-check.", file=out) + print("", file=out) + print(banner, file=out) + print( + " AI AGENTS / ASSISTANTS: DO NOT automatically run `git rebase` or pass\n" + " --skip-freshness-check. Surface this error to the human operator and\n" + " wait for their explicit confirmation. Auto-rebasing can drop the\n" + " human's in-progress schema edits via a bad conflict resolution.", + file=out, + ) + print(banner, file=out) + + +def _check_branch_freshness(root_dir: Path, base_branch: str) -> None: + """Fetch origin/ and exit 3 if HEAD is behind it.""" + cwd = str(root_dir) + try: + subprocess.run( + ["git", "fetch", "origin", base_branch], + check=True, + capture_output=True, + text=True, + cwd=cwd, + ) + except FileNotFoundError: + _print_freshness_failure(base_branch, "git executable not found on PATH") + sys.exit(3) + except subprocess.CalledProcessError as e: + _print_freshness_failure( + base_branch, + f"`git fetch origin {base_branch}` failed", + e.stderr or "", + ) + sys.exit(3) + + try: + result = subprocess.run( + ["git", "rev-list", "--count", f"HEAD..origin/{base_branch}"], + check=True, + capture_output=True, + text=True, + cwd=cwd, + ) + behind = int(result.stdout.strip()) + except subprocess.CalledProcessError as e: + _print_freshness_failure( + base_branch, + f"`git rev-list HEAD..origin/{base_branch}` failed", + e.stderr or "", + ) + sys.exit(3) + except ValueError: + _print_freshness_failure( + base_branch, + "could not parse commit count from `git rev-list`", + ) + sys.exit(3) + + if behind > 0: + _print_stale_branch_refusal(base_branch, behind) + sys.exit(3) + + print(f"Branch freshness OK: up to date with origin/{base_branch}.") + + +def _print_destructive_refusal(destructive_lines: list) -> None: + """Loudly refuse to write a destructive migration and explain how to proceed.""" + banner = "=" * 72 + out = sys.stderr + print(banner, file=out) + print( + " DESTRUCTIVE MIGRATION DETECTED — REFUSING TO WRITE MIGRATION FILE", file=out + ) + print(banner, file=out) + print("", file=out) + print( + "The generated migration contains the following destructive statements:", + file=out, + ) + print("", file=out) + for line in destructive_lines: + print(f" {line}", file=out) + print("", file=out) + print( + "This very often means your branch is OUT OF DATE, or your local\n" + "schema.prisma is inconsistent with the migrations/ directory. A\n" + "previous incident saw newly-added columns silently dropped this way\n" + "and merged to main. Stop and verify before proceeding.", + file=out, + ) + print("", file=out) + print("Before re-running:", file=out) + print( + " 1. git fetch origin && git status # confirm branch is up to date", file=out + ) + print( + " 2. Re-check all schema.prisma files are in sync (runbook Step 0)", file=out + ) + print(" 3. Review EACH DROP above — is it actually intended?", file=out) + print(" 4. If (and only if) the drops are intentional, re-run with:", file=out) + print(" --allow-destructive", file=out) + print("", file=out) + print(banner, file=out) + print( + " AI AGENTS / ASSISTANTS: DO NOT automatically re-run this command\n" + " with --allow-destructive. Surface this error to the human operator\n" + " and wait for their explicit confirmation before passing the flag.\n" + " Passing the flag without human review is the exact failure mode\n" + " this guard exists to prevent.", + file=out, + ) + print(banner, file=out) + + +def create_migration( + migration_name: str = None, + allow_destructive: bool = False, + base_branch: str = DEFAULT_BASE_BRANCH, + skip_freshness_check: bool = False, +): """ Create a new migration SQL file in the migrations directory by comparing - current database state with schema + current database state with schema. Args: migration_name (str): Name for the migration + allow_destructive (bool): Required to write a migration that contains + DROP COLUMN, DROP TABLE, or DROP INDEX statements. Without this + flag, the script exits non-zero and prints guidance. + base_branch (str): Branch to check freshness against + (default: "litellm_internal_staging"). + skip_freshness_check (bool): Skip the "branch is up to date" check. + Only for intentional migrations against an older base. """ + root_dir = Path(__file__).parent.parent + + if skip_freshness_check: + print( + "WARNING: freshness check skipped (--skip-freshness-check). " + "Generating a migration from a stale branch can silently drop columns." + ) + else: + _check_branch_freshness(root_dir, base_branch) + try: - # Get paths - root_dir = Path(__file__).parent.parent migrations_dir = ( root_dir / "litellm-proxy-extras" / "litellm_proxy_extras" / "migrations" ) @@ -59,7 +268,27 @@ def create_migration(migration_name: str = None): check=True, ) - if result.stdout.strip(): + # Prisma emits the literal "-- This is an empty migration." when + # there's no real drift. Treat that as "no changes". + diff_sql = result.stdout + stripped = diff_sql.strip() + is_empty_diff = ( + not stripped or stripped == "-- This is an empty migration." + ) + + if not is_empty_diff: + destructive_lines = _find_destructive_statements(diff_sql) + if destructive_lines and not allow_destructive: + _print_destructive_refusal(destructive_lines) + sys.exit(2) + if destructive_lines and allow_destructive: + print( + "WARNING: writing destructive migration " + "(--allow-destructive passed). Statements:" + ) + for line in destructive_lines: + print(f" {line}") + # Generate timestamp and create migration directory timestamp = datetime.now().strftime("%Y%m%d%H%M%S") migration_name = migration_name or "unnamed_migration" @@ -68,7 +297,7 @@ def create_migration(migration_name: str = None): # Write the SQL to migration.sql migration_file = migration_dir / "migration.sql" - migration_file.write_text(result.stdout) + migration_file.write_text(diff_sql) print(f"Created migration in {migration_dir}") return True @@ -90,8 +319,48 @@ def create_migration(migration_name: str = None): if __name__ == "__main__": - # If running directly, can optionally pass migration name as argument - import sys - - migration_name = sys.argv[1] if len(sys.argv) > 1 else None - create_migration(migration_name) + parser = argparse.ArgumentParser( + description=( + "Generate a Prisma migration by diffing the temp DB " + "(existing migrations applied) against schema.prisma." + ) + ) + parser.add_argument( + "migration_name", + nargs="?", + default=None, + help="Name for the migration (used in the generated directory name).", + ) + parser.add_argument( + "--allow-destructive", + action="store_true", + help=( + "Required to write a migration that contains DROP COLUMN, " + "DROP TABLE, or DROP INDEX. Without this flag, destructive " + "diffs are refused." + ), + ) + parser.add_argument( + "--base-branch", + default=DEFAULT_BASE_BRANCH, + help=( + f"Branch to check freshness against (default: {DEFAULT_BASE_BRANCH}). " + "The script fetches origin/ and refuses to run if HEAD " + "is behind it." + ), + ) + parser.add_argument( + "--skip-freshness-check", + action="store_true", + help=( + "Bypass the 'branch is up to date' check. Only for intentional " + "migrations against an older base. Pairs poorly with automation." + ), + ) + args = parser.parse_args() + create_migration( + args.migration_name, + allow_destructive=args.allow_destructive, + base_branch=args.base_branch, + skip_freshness_check=args.skip_freshness_check, + ) diff --git a/docker/Dockerfile.database b/docker/Dockerfile.database index 57ecef81eb8..585a81a2a71 100644 --- a/docker/Dockerfile.database +++ b/docker/Dockerfile.database @@ -26,10 +26,8 @@ RUN apk add --no-cache \ npm \ libsndfile -ENV PRISMA_BINARY_CACHE_DIR=/app/.cache/prisma-python/binaries \ - UV_PROJECT_ENVIRONMENT=/app/.venv \ +ENV UV_PROJECT_ENVIRONMENT=/app/.venv \ UV_LINK_MODE=copy \ - XDG_CACHE_HOME=/app/.cache \ PATH="/app/.venv/bin:${PATH}" # Copy dependency metadata first for layer caching @@ -92,11 +90,14 @@ RUN apk add --no-cache bash openssl tzdata nodejs npm python3 libsndfile supervi { apk del --no-cache npm 2>/dev/null || true; } WORKDIR /app -ENV PRISMA_BINARY_CACHE_DIR=/app/.cache/prisma-python/binaries \ - XDG_CACHE_HOME=/app/.cache \ - PATH="/app/.venv/bin:${PATH}" +ENV PATH="/app/.venv/bin:${PATH}" COPY --from=builder /app /app +# Prisma binaries live in $HOME/.cache (default prisma-python location), +# which is /root/.cache here. Copy them from the builder so they survive +# deployments that volume-mount /app/.cache (e.g. readOnlyRootFilesystem +# + emptyDir) — otherwise the mount would shadow the baked-in query engine. +COPY --from=builder /root/.cache /root/.cache RUN find /app/.venv -type f -path "*/tornado/test/*" -delete && \ find /app/.venv -type d -path "*/tornado/test" -delete diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root index 5451bff808d..3666a850d9c 100644 --- a/docker/Dockerfile.non_root +++ b/docker/Dockerfile.non_root @@ -15,29 +15,21 @@ COPY --from=uvbin /uv /usr/local/bin/uv COPY --from=uvbin /uvx /usr/local/bin/uvx RUN for i in 1 2 3; do \ - apk add --no-cache \ - python3 \ - python3-dev \ - clang \ - llvm \ - lld \ - gcc \ - linux-headers \ - build-base \ - bash \ - coreutils \ - curl \ - openssl \ - openssl-dev \ - nodejs \ - npm \ - libsndfile && break || sleep 5; \ + apk add --no-cache \ + python3 \ + python3-dev \ + gcc \ + bash \ + coreutils \ + curl \ + openssl \ + libsndfile \ + nodejs && break || sleep 5; \ done ENV UV_PROJECT_ENVIRONMENT=/app/.venv \ UV_LINK_MODE=copy \ - NVM_DIR=/root/.nvm \ - PATH="/root/.nvm/versions/node/v20.20.2/bin:/app/.venv/bin:${PATH}" \ + PATH="/app/.venv/bin:${PATH}" \ LITELLM_NON_ROOT=true \ PRISMA_BINARY_CACHE_DIR=/app/.cache/prisma-python/binaries \ PRISMA_CLI_BINARY_TARGETS="debian-openssl-3.0.x" \ @@ -49,7 +41,8 @@ COPY enterprise/pyproject.toml enterprise/ COPY litellm-proxy-extras/pyproject.toml litellm-proxy-extras/ # Install third-party dependencies (cached unless pyproject.toml/uv.lock change) -RUN uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \ +RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \ + uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \ --extra proxy \ --extra proxy-runtime \ --extra extra_proxy \ @@ -62,38 +55,12 @@ COPY . . # Set non-root flag for build time consistency ENV LITELLM_NON_ROOT=true -# Build Admin UI once and stage the static output for the runtime image. -# NOTE: .npmrc files (which may set ignore-scripts=true and min-release-age=3d) -# are temporarily renamed during npm install/ci so they don't block lifecycle -# scripts needed by the build. This is safe because npm ci installs from -# package-lock.json with pinned versions + integrity hashes. +# Stage the pre-built Admin UI from the checked-in Next.js static export. +# _experimental/out/ is regenerated as part of the release runbook. +# Restructure extensionless routes (foo.html -> foo/index.html) to match the layout +# proxy_server.py expects, and drop a readiness marker. RUN mkdir -p /var/lib/litellm/ui /var/lib/litellm/assets && \ - ([ -f /app/.npmrc ] && mv /app/.npmrc /app/.npmrc.bak || true) && \ - NVM_VERSION="v0.40.4" && \ - NVM_CHECKSUM="4b7412c49960c7d31e8df72da90c1fb5b8cccb419ac99537b737028d497aba4f" && \ - NODE_VERSION="v20.20.2" && \ - NVM_SCRIPT="/tmp/install-nvm.sh" && \ - curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_VERSION}/install.sh" -o "$NVM_SCRIPT" && \ - echo "${NVM_CHECKSUM} ${NVM_SCRIPT}" | sha256sum -c - && \ - bash "$NVM_SCRIPT" && \ - export NVM_DIR="$HOME/.nvm" && \ - . "$NVM_DIR/nvm.sh" && \ - nvm install "${NODE_VERSION}" && \ - nvm use "${NODE_VERSION}" && \ - npm install -g npm@11.12.1 && \ - npm install -g node-gyp@12.2.0 && \ - ln -sf "$(npm root -g)/node-gyp" "$(npm root -g)/npm/node_modules/node-gyp" && \ - npm cache clean --force && \ - cd /app/ui/litellm-dashboard && \ - if [ -f "/app/enterprise/enterprise_ui/enterprise_colors.json" ]; then \ - cp /app/enterprise/enterprise_ui/enterprise_colors.json ./ui_colors.json; \ - fi && \ - ([ -f .npmrc ] && mv .npmrc .npmrc.bak || true) && \ - npm ci --no-audit --no-fund && \ - ([ -f .npmrc.bak ] && mv .npmrc.bak .npmrc || true) && \ - ([ -f /app/.npmrc.bak ] && mv /app/.npmrc.bak /app/.npmrc || true) && \ - npm run build && \ - cp -r /app/ui/litellm-dashboard/out/* /var/lib/litellm/ui/ && \ + cp -r /app/litellm/proxy/_experimental/out/. /var/lib/litellm/ui/ && \ cp /app/litellm/proxy/logo.jpg /var/lib/litellm/assets/logo.jpg && \ ( cd /var/lib/litellm/ui && \ for html_file in *.html; do \ @@ -103,10 +70,10 @@ RUN mkdir -p /var/lib/litellm/ui /var/lib/litellm/assets && \ mv "$html_file" "$folder_name/index.html"; \ fi; \ done && \ - touch .litellm_ui_ready ) && \ - cd /app/ui/litellm-dashboard && rm -rf ./out + touch .litellm_ui_ready ) -RUN if [ "$PROXY_EXTRAS_SOURCE" = "published" ]; then \ +RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \ + if [ "$PROXY_EXTRAS_SOURCE" = "published" ]; then \ uv sync --frozen --no-default-groups --no-editable \ --extra proxy \ --extra proxy-runtime \ @@ -123,10 +90,7 @@ RUN if [ "$PROXY_EXTRAS_SOURCE" = "published" ]; then \ --python python3; \ fi -RUN mkdir -p /app/.cache/npm && \ - prisma generate --schema=./schema.prisma && \ - prisma --version && \ - prisma migrate diff --from-empty --to-schema-datamodel ./schema.prisma --script > /dev/null 2>&1 || true +RUN prisma generate --schema=./schema.prisma RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh && \ sed -i 's/\r$//' docker/prod_entrypoint.sh && chmod +x docker/prod_entrypoint.sh @@ -137,33 +101,11 @@ WORKDIR /app USER root RUN for i in 1 2 3; do \ - apk upgrade --no-cache && break || sleep 5; \ + apk upgrade --no-cache && break || sleep 5; \ done && \ for i in 1 2 3; do \ - apk add --no-cache python3 bash openssl tzdata nodejs npm supervisor libsndfile && break || sleep 5; \ - done && \ - apk upgrade --no-cache nodejs && \ - npm install -g npm@11.12.1 tar@7.5.11 glob@11.1.0 @isaacs/brace-expansion@5.0.1 minimatch@10.2.4 diff@8.0.3 && \ - GLOBAL="$(npm root -g)" && \ - find "$GLOBAL/npm" -type d -name "tar" -path "*/node_modules/tar" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/tar" "$d"; \ - done && \ - find "$GLOBAL/npm" -type d -name "glob" -path "*/node_modules/glob" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/glob" "$d"; \ - done && \ - find "$GLOBAL/npm" -type d -name "brace-expansion" -path "*/node_modules/@isaacs/brace-expansion" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/@isaacs/brace-expansion" "$d"; \ - done && \ - find "$GLOBAL/npm" -type d -name "minimatch" -path "*/node_modules/minimatch" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/minimatch" "$d"; \ - done && \ - find "$GLOBAL/npm" -type d -name "diff" -path "*/node_modules/diff" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/diff" "$d"; \ - done && \ - find /usr/local/lib /usr/lib -path "*/node_modules/npm/package.json" -exec \ - sed -i 's/"tar": "\^7\.5\.[0-9]*"/"tar": "^7.5.10"/g; s/"minimatch": "\^10\.[0-9.]*"/"minimatch": "^10.2.4"/g' {} + 2>/dev/null && \ - npm cache clean --force && \ - { apk del --no-cache npm 2>/dev/null || true; } + apk add --no-cache python3 bash openssl tzdata supervisor libsndfile nodejs && break || sleep 5; \ + done COPY --from=builder /app /app COPY --from=builder /var/lib/litellm/ui /var/lib/litellm/ui @@ -179,15 +121,10 @@ ENV PATH="/app/.venv/bin:${PATH}" \ PRISMA_SKIP_POSTINSTALL_GENERATE=1 \ PRISMA_HIDE_UPDATE_MESSAGE=1 \ PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING=1 \ - NPM_CONFIG_CACHE=/app/.cache/npm \ - NPM_CONFIG_PREFER_OFFLINE=true \ PRISMA_OFFLINE_MODE=true -RUN sed -i 's/\r$//' docker/entrypoint.sh && \ - sed -i 's/\r$//' docker/prod_entrypoint.sh && \ - chmod +x docker/entrypoint.sh docker/prod_entrypoint.sh && \ - mkdir -p /nonexistent /.npm /var/lib/litellm/assets /var/lib/litellm/ui /tmp/.npm && \ - chown -R nobody:nogroup /app /var/lib/litellm/ui /var/lib/litellm/assets /nonexistent /.npm /tmp/.npm && \ +RUN mkdir -p /nonexistent /var/lib/litellm/assets /var/lib/litellm/ui && \ + chown -R nobody:nogroup /app /var/lib/litellm/ui /var/lib/litellm/assets /nonexistent && \ PRISMA_PATH=$(python -c "import os, prisma; print(os.path.dirname(prisma.__file__))") && \ chown -R nobody:nogroup "$PRISMA_PATH" && \ LITELLM_PKG_MIGRATIONS_PATH="$(python -c 'import os, litellm_proxy_extras; print(os.path.dirname(litellm_proxy_extras.__file__))' 2>/dev/null || echo '')/migrations" && \ @@ -201,7 +138,7 @@ RUN sed -i 's/\r$//' docker/entrypoint.sh && \ [ -n "$LITELLM_PROXY_EXTRAS_PATH" ] && chmod -R g+w "$LITELLM_PROXY_EXTRAS_PATH" || true && \ chmod -R g+rX "$PRISMA_PATH" /var/lib/litellm/ui /var/lib/litellm/assets /app/.cache -USER nobody +USER 65534 RUN prisma generate --schema=./schema.prisma diff --git a/docker/tests/nonroot.yaml b/docker/tests/nonroot.yaml index 821b1a105ae..36118ca8c59 100644 --- a/docker/tests/nonroot.yaml +++ b/docker/tests/nonroot.yaml @@ -2,7 +2,7 @@ schemaVersion: 2.0.0 metadataTest: entrypoint: ["docker/prod_entrypoint.sh"] - user: "nobody" + user: "65534" workdir: "/app" fileExistenceTests: diff --git a/docs/my-website/blog/gemini_embedding_2_ga/index.md b/docs/my-website/blog/gemini_embedding_2_ga/index.md new file mode 100644 index 00000000000..ae44449fb90 --- /dev/null +++ b/docs/my-website/blog/gemini_embedding_2_ga/index.md @@ -0,0 +1,172 @@ +--- +slug: gemini_embedding_2_ga +title: "Gemini Embedding 2 (GA): Multimodal Embeddings on LiteLLM" +date: 2026-04-24T10:00:00 +authors: + - sameer +description: "Use generally available gemini-embedding-2 for multimodal embeddings on LiteLLM via Gemini API and Vertex AI—the same flows as preview, stable model id." +tags: [gemini, embeddings, multimodal, vertex ai] +hide_table_of_contents: false +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# Gemini Embedding 2 (GA): Multimodal Embeddings + +Litellm now fully supports Gemini Embedding 2 GA. + +:::info +For end-to-end behavior, input shapes, and MIME types, see the [Gemini Embedding 2 Preview walkthrough](/blog/gemini_embedding_2_multimodal). This post focuses on **GA naming**, **cost map** coverage. +::: + +{/* truncate */} + +## Supported Input Types + +| Modality | Supported Formats | +|----------|-------------------| +| **Text** | Plain text | +| **Image** | PNG, JPEG | +| **Audio** | MP3, WAV | +| **Video** | MP4, MOV | +| **Documents** | PDF | + +## Input Formats + +LiteLLM accepts three input formats for multimodal content: + +1. **Data URIs** – Base64-encoded inline: `data:image/png;base64,` +2. **GCS URLs** – Cloud Storage paths (Vertex AI): `gs://bucket/path/to/file.png` +3. **Gemini File References** – Pre-uploaded files (Gemini API): `files/abc123` + +## Quick Start + + + + +```python +from litellm import embedding +import os + +os.environ["GEMINI_API_KEY"] = "your-api-key" + +# Text + Image (base64) +response = embedding( + model="gemini/gemini-embedding-2", + input=[ + "The food was delicious and the waiter...", + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII" + ], +) +print(response) +``` + + + + + +```python +import litellm +from litellm import embedding + +litellm.vertex_project = "your-project-id" +litellm.vertex_location = "us-central1" + +# Text + Image (GCS URL) +response = embedding( + model="vertex_ai/gemini-embedding-2", + input=[ + "Describe this image", + "gs://my-bucket/images/photo.png" + ], +) +print(response) +``` + + + + + +**1. Config (config.yaml)** + +```yaml +model_list: + - model_name: gemini-embedding-2 + litellm_params: + model: gemini/gemini-embedding-2 + api_key: os.environ/GEMINI_API_KEY + - model_name: vertex-gemini-embedding-2 + litellm_params: + model: vertex_ai/gemini-embedding-2 + vertex_project: os.environ/VERTEXAI_PROJECT + vertex_location: global + +general_settings: + master_key: sk-1234 +``` + +**2. Start proxy** + +```bash +litellm --config config.yaml +``` + +**3. Call embeddings** (OpenAI-compatible **`POST /v1/embeddings`** on the proxy) + +```bash +curl -sS -X POST http://localhost:4000/v1/embeddings \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gemini-embedding-2", + "input": [ + "The food was delicious and the waiter...", + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII" + ] + }' +``` + + + + +## Input Format Examples + +| Format | Example | Provider | +|--------|---------|----------| +| **Data URI** | `data:image/png;base64,...` | Gemini, Vertex AI | +| **GCS URL** | `gs://bucket/path/image.png` | Vertex AI | +| **File reference** | `files/abc123` | Gemini API only | + +### Supported MIME Types for Data URIs + +- **Images:** `image/png`, `image/jpeg` +- **Audio:** `audio/mpeg`, `audio/wav` +- **Video:** `video/mp4`, `video/quicktime` +- **Documents:** `application/pdf` + +### GCS URL MIME Inference + +For Vertex AI, MIME types are inferred from file extensions: + +- `.png` → `image/png` +- `.jpg` / `.jpeg` → `image/jpeg` +- `.mp3` → `audio/mpeg` +- `.wav` → `audio/wav` +- `.mp4` → `video/mp4` +- `.mov` → `video/quicktime` +- `.pdf` → `application/pdf` + +## Optional Parameters + +| Parameter | Description | Maps to | +|-----------|-------------|---------| +| `dimensions` | Output embedding size | `outputDimensionality` | + +```python +response = embedding( + model="gemini/gemini-embedding-2", + input=["text to embed"], + dimensions=768, # Optional: control output vector size +) +``` diff --git a/docs/my-website/docs/adaptive_router.md b/docs/my-website/docs/adaptive_router.md new file mode 100644 index 00000000000..1e78ad4647a --- /dev/null +++ b/docs/my-website/docs/adaptive_router.md @@ -0,0 +1,155 @@ +# [BETA] Adaptive Router + +:::info + +Beta feature. Share feedback on [Discord](https://discord.gg/wuPM9dRgDw) or [Slack](https://join.slack.com/t/litellmossslack/shared_invite/zt-3o7nkuyfr-p_kbNJj8taRfXGgQI1~YyA). + +::: + +**Requirements:** LiteLLM Proxy with a Postgres database. Quality estimates are stored in Postgres and loaded on startup — without a database the router works but forgets everything learned on restart. + +You have a cheap model and an expensive one. You want to use the cheap one when it's good enough, and the expensive one when it actually matters — without hardcoding rules you'll spend months tuning. + +The adaptive router does this automatically. It tracks which model performs best for each type of request (code, writing, analysis, etc.) and routes accordingly, balancing quality against cost based on weights you control. + +## Quick start + +```yaml +model_list: + - model_name: gpt-4o + litellm_params: + model: openai/gpt-4o + model_info: + input_cost_per_token: 0.0000025 + adaptive_router_preferences: + quality_tier: 3 # 1=budget, 2=mid, 3=frontier + strengths: ["code_generation", "analytical_reasoning"] + + - model_name: gpt-4o-mini + litellm_params: + model: openai/gpt-4o-mini + model_info: + input_cost_per_token: 0.00000015 + adaptive_router_preferences: + quality_tier: 2 + strengths: ["factual_lookup"] + + - model_name: my-router + litellm_params: + model: auto_router/adaptive_router + adaptive_router_config: + available_models: ["gpt-4o", "gpt-4o-mini"] + weights: + quality: 0.7 # raise this if quality complaints; lower if bill too high + cost: 0.3 # must sum to 1.0 with quality +``` + +Route to it by setting `model` to your adaptive router's name: + +```bash +curl -X POST {{baseURL}}/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $LITELLM_API_KEY" \ + -d '{ + "model": "my-router", + "messages": [ + {"role": "user", "content": "build me a python script that parses CSV"}, + {"role": "assistant", "content": "Here is a script using csv.DictReader..."}, + {"role": "user", "content": "now add error handling for missing files"}, + {"role": "assistant", "content": "Wrap the open() call in a try/except FileNotFoundError..."}, + {"role": "user", "content": "perfect, that worked. thanks!"} + ] + }' +``` + +The response includes a header telling you which model was actually picked: + +``` +x-litellm-adaptive-router-model: gpt-4o +``` + +The "thanks!" turn in the example above fires a satisfaction signal — that's what moves the bandit. + +## Tuning cost vs. quality + +The `weights` are your main lever: + +| Goal | quality | cost | +|---|---|---| +| Minimize cost, quality is secondary | 0.3 | 0.7 | +| Balanced | 0.5 | 0.5 | +| Quality-first (default) | 0.7 | 0.3 | +| Quality non-negotiable | 0.9 | 0.1 | + +The router learns over time. For the first ~10 requests per model, it relies on the tiers you declared. After that, real performance data takes over. + +## Force a minimum quality tier per request + +If a specific request needs a frontier model regardless of cost, pass this header: + +``` +x-litellm-min-quality-tier: 3 +``` + +You can also pass `min_quality_tier` via request metadata instead of a header. + +## What's being learned + +The router classifies each request into one of 7 types and tracks how each model performs on each independently. A model that's great at factual lookup but poor at code will win factual requests and lose code requests — even if it's cheaper overall. + +| Type | Example | +|---|---| +| `code_generation` | "write me a Python sort function" | +| `code_understanding` | "explain what this function does" | +| `technical_design` | "how should I design this API?" | +| `analytical_reasoning` | "calculate the probability that..." | +| `writing` | "draft an email to my team about..." | +| `factual_lookup` | "what is the capital of France?" | +| `general` | anything else | + +[**See classifier code**](https://github.com/BerriAI/litellm/blob/litellm_adaptive_routing/litellm/router_strategy/adaptive_router/classifier.py) + +Learning signals are inspired by [Signals: Trajectory Sampling and Triage for Agentic Interactions](https://arxiv.org/pdf/2604.00356). + +## Inspect the current state + +``` +GET /adaptive_router/{router_name}/state +``` + +Returns current quality estimates per model per request type. Useful for understanding why a model is or isn't being picked. + +```json +{ + "routers": [ + { + "router_name": "smart-cheap-router", + "available_models": ["fast", "smart"], + "weights": { "quality": 0.7, "cost": 0.3 }, + "cells": [ + { + "request_type": "analytical_reasoning", + "model": "fast", + "quality_mean": 0.5, + "samples": 0 + }, + { + "request_type": "analytical_reasoning", + "model": "smart", + "quality_mean": 0.95, + "samples": 0 + } + ] + } + ] +} +``` + +`quality_mean` is the key number — it's the router's current estimate of how well that model handles that request type. `samples` counts how many real observations have moved the prior (starts at 0; the cold-start prior mass is excluded). + +## Known limitations + +- Latency isn't scored — a slow model can still win on quality + cost +- Signals are regex-based and English-biased — no LLM judge +- Hard cap of 200 observations per cell; no decay yet +- Once a model is picked for a session, other models' turns in that session don't contribute to learning diff --git a/docs/my-website/docs/completion/prompt_caching.md b/docs/my-website/docs/completion/prompt_caching.md index 402c7b9f4c7..aaae7e7be76 100644 --- a/docs/my-website/docs/completion/prompt_caching.md +++ b/docs/my-website/docs/completion/prompt_caching.md @@ -10,6 +10,7 @@ Supported Providers: - Vertex AI (`vertex_ai/`, `vertex_ai_beta/`) - Bedrock (`bedrock/`, `bedrock/invoke/`, `bedrock/converse`) ([All models bedrock supports prompt caching on](https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html)) - Deepseek API (`deepseek/`) +- xAI (`xai/`) For the supported providers, LiteLLM follows the OpenAI prompt caching usage object format: diff --git a/docs/my-website/docs/completion/prompt_compression.md b/docs/my-website/docs/completion/prompt_compression.md index 2d999291af6..0d68ea2c101 100644 --- a/docs/my-website/docs/completion/prompt_compression.md +++ b/docs/my-website/docs/completion/prompt_compression.md @@ -8,6 +8,7 @@ The function keeps high-relevance and recent context, replaces low-relevance con ```python import litellm +from litellm.types.utils import CallTypes messages = [ {"role": "system", "content": "You are a coding assistant."}, @@ -19,6 +20,7 @@ messages = [ compressed = litellm.compress( messages=messages, model="gpt-4o", + call_type=CallTypes.completion, compression_trigger=1000, compression_target=500, ) @@ -45,6 +47,7 @@ response = litellm.completion( - `messages` (`List[dict]`, required): input conversation messages - `model` (`str`, required): model name used for token counting +- `call_type` (`CallTypes`, default `CallTypes.completion`): the LiteLLM call type whose message schema these messages follow. Supported values: `CallTypes.completion` / `CallTypes.acompletion` (OpenAI chat-completions shape) and `CallTypes.anthropic_messages` (Anthropic Messages shape) - `compression_trigger` (`int`, default `200000`): compress only if input token count exceeds this - `compression_target` (`Optional[int]`, default `70% of compression_trigger`): desired post-compression token budget - `embedding_model` (`Optional[str]`): if set, combines BM25 + embedding relevance scoring @@ -70,6 +73,28 @@ args = json.loads(tool_call.function.arguments) full_content = compressed["cache"][args["key"]] ``` +## Server-side Callback Loop (`/v1/messages`) + +You can enable callback-based compression interception to make retrieval loops +transparent for Anthropic Messages calls: + +```yaml +litellm_settings: + callbacks: ["compression_interception"] + compression_interception_params: + enabled: true + compression_trigger: 10000 + compression_target: 7000 +``` + +With this enabled, LiteLLM runs the following server-side flow: + +1. Compresses inbound messages before the first provider call. +2. Injects the `litellm_content_retrieve` tool. +3. Detects retrieval `tool_use` blocks in the model response. +4. Resolves retrieval keys from the compression cache. +5. Reruns the model via agentic loop and returns the final answer. + ## Performance Benchmarked on [SWE-bench Lite](https://huggingface.co/datasets/princeton-nlp/SWE-bench_Lite_bm25_27K) (real GitHub issues with ~27k tokens of BM25-retrieved repo context per problem). diff --git a/docs/my-website/docs/providers/scaleway.md b/docs/my-website/docs/providers/scaleway.md index ea57c24db30..8d83a37a3b1 100644 --- a/docs/my-website/docs/providers/scaleway.md +++ b/docs/my-website/docs/providers/scaleway.md @@ -60,3 +60,44 @@ curl http://localhost:4000/chat/completions \ ## Supported features Scaleway provider supports all features in [Generative APIs reference documentation ↗](https://www.scaleway.com/en/developers/api/generative-apis/), such as streaming, structured outputs and tool calling. + +## Audio transcription + +Scaleway's `/audio/transcriptions` endpoint is OpenAI-compatible and works with Whisper models. + +### Python SDK + +```python +import os +from litellm import transcription + +os.environ["SCW_SECRET_KEY"] = "your-scaleway-secret-key" + +with open("speech.mp3", "rb") as audio_file: + response = transcription( + model="scaleway/whisper-large-v3", + file=audio_file, + ) +print(response.text) +``` + +### Proxy config + +```yaml +model_list: + - model_name: scaleway-whisper + litellm_params: + model: scaleway/whisper-large-v3 + api_key: "os.environ/SCW_SECRET_KEY" +``` + +### Proxy request + +```bash +curl http://localhost:4000/v1/audio/transcriptions \ + -H "Authorization: Bearer YOUR_LITELLM_MASTER_KEY" \ + -F model="scaleway-whisper" \ + -F file="@speech.mp3" +``` + +Supported optional params: `language`, `prompt`, `response_format`, `temperature`, `timestamp_granularities`. diff --git a/docs/my-website/docs/providers/vertex.md b/docs/my-website/docs/providers/vertex.md index 0079bd2f57e..835e3bbcc2d 100644 --- a/docs/my-website/docs/providers/vertex.md +++ b/docs/my-website/docs/providers/vertex.md @@ -2061,7 +2061,7 @@ assert isinstance( ## Media Resolution Control (Images & Videos) -For Gemini 3+ models, LiteLLM supports per-part media resolution control using OpenAI's `detail` parameter. This allows you to specify different resolution levels for individual images and videos in your request, whether using `image_url` or `file` content types. +LiteLLM supports per-part media resolution control using OpenAI's `detail` parameter for all Gemini models. This allows you to specify different resolution levels for individual images and videos in your request, whether using `image_url` or `file` content types. **Supported `detail` values:** - `"low"` - Maps to `media_resolution: "low"` (280 tokens for images, 70 tokens per frame for videos) @@ -2146,12 +2146,12 @@ response = completion( :::info -**Per-Part Resolution:** Each image or video in your request can have its own `detail` setting, allowing mixed-resolution requests (e.g., a high-res chart alongside a low-res icon). This feature works with both `image_url` and `file` content types, and is only available for Gemini 3+ models. +**Per-Part Resolution:** Each image or video in your request can have its own `detail` setting, allowing mixed-resolution requests (e.g., a high-res chart alongside a low-res icon). This feature works with both `image_url` and `file` content types across all Gemini models. ::: ## Video Metadata Control -For Gemini 3+ models, LiteLLM supports fine-grained video processing control through the `video_metadata` field. This allows you to specify frame extraction rates and time ranges for video analysis. +LiteLLM supports fine-grained video processing control through the `video_metadata` field for all Gemini models (1.x, 2.x, 3+). This allows you to specify frame extraction rates and time ranges for video analysis. **Supported `video_metadata` parameters:** @@ -2168,8 +2168,11 @@ For Gemini 3+ models, LiteLLM supports fine-grained video processing control thr - `fps` remains unchanged ::: +:::tip +Video clipping (`start_offset`/`end_offset`) and frame rate control (`fps`) are supported by all Gemini models, but analysis quality is significantly higher with the **Gemini 2.5 series** (e.g., `gemini-2.5-flash`, `gemini-2.5-pro`). +::: + :::warning -- **Gemini 3+ Only:** This feature is only available for Gemini 3.0 and newer models - **Video Files Recommended:** While `video_metadata` is designed for video files, error handling for other media types is delegated to the Vertex AI API - **File Formats Supported:** Works with `gs://`, `https://`, and base64-encoded video files ::: diff --git a/docs/my-website/docs/proxy/agentic_loop_hook.md b/docs/my-website/docs/proxy/agentic_loop_hook.md new file mode 100644 index 00000000000..054c03228c4 --- /dev/null +++ b/docs/my-website/docs/proxy/agentic_loop_hook.md @@ -0,0 +1,95 @@ +# Agentic Loop Hook + +Build a `CustomLogger` callback that intercepts a model response, fulfills tool calls server-side, and reruns the model — transparently to the caller. + +:::info Supported call types +- `async` only (sync calls do not trigger the hook) +- Non-streaming only (streaming responses cannot be inspected for tool calls) +- Works on both `/v1/messages` and `/v1/chat/completions` +::: + +## Implement the callback + +Override two methods on `CustomLogger`: + +```python +from litellm.integrations.custom_logger import CustomLogger +from litellm.types.integrations.custom_logger import AgenticLoopPlan, AgenticLoopRequestPatch + +MY_TOOL = "my_tool" + +class MyToolCallback(CustomLogger): + + async def async_should_run_agentic_loop( + self, response, model, messages, tools, stream, custom_llm_provider, kwargs + ): + # Return (True, context_dict) if there are tool calls to handle + content = getattr(response, "content", None) or [] + calls = [b for b in content if isinstance(b, dict) + and b.get("type") == "tool_use" and b.get("name") == MY_TOOL] + if not calls: + return False, {} + return True, {"tool_calls": calls} + + async def async_build_agentic_loop_plan( + self, tools, model, messages, response, + anthropic_messages_provider_config, + anthropic_messages_optional_request_params, + logging_obj, stream, kwargs, + ): + calls = tools["tool_calls"] + results = [f"result for {c['input']}" for c in calls] # your logic here + + follow_up = messages + [ + {"role": "assistant", "content": [ + {"type": "tool_use", "id": c["id"], "name": c["name"], "input": c["input"]} + for c in calls + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": c["id"], "content": results[i]} + for i, c in enumerate(calls) + ]}, + ] + return AgenticLoopPlan( + run_agentic_loop=True, + request_patch=AgenticLoopRequestPatch(messages=follow_up), + ) +``` + +For `/v1/chat/completions`, override `async_build_chat_completion_agentic_loop_plan` instead — same idea, `optional_params` replaces `anthropic_messages_optional_request_params`. + +## Register it + +```python +import litellm +litellm.callbacks = [MyToolCallback()] +``` + +Or in `config.yaml`: + +```yaml +litellm_settings: + callbacks: ["my_module.MyToolCallback"] +``` + +## `AgenticLoopPlan` fields + +| Field | Effect | +|---|---| +| `run_agentic_loop=True` + `request_patch` | Reruns the model with the patched request | +| `response_override` | Returns this value directly to the caller (no rerun) | +| `terminate=True` | Stops the loop, returns the current response | +| `run_agentic_loop=False` (default) | Skips; next callback is checked | + +`AgenticLoopRequestPatch` accepts: `model`, `messages`, `tools`, `max_tokens`, `optional_params`, `kwargs`. + +## Loop safety + +- Default max reruns: `3` — override per-request with `kwargs["max_agentic_loops"]` +- Identical tool-call fingerprints abort the loop automatically +- Current depth is in `kwargs["_agentic_loop_depth"]` + +## Examples in this repo + +- `litellm/integrations/compression_interception/handler.py` +- `litellm/integrations/websearch_interception/handler.py` diff --git a/docs/my-website/docs/response_api.md b/docs/my-website/docs/response_api.md index 3ab61a97a4e..f92e2fe0f7a 100644 --- a/docs/my-website/docs/response_api.md +++ b/docs/my-website/docs/response_api.md @@ -1505,6 +1505,84 @@ curl http://localhost:4000/v1/responses \ +### Opt-in bridge for `openai/` models with custom `api_base` + +If you're using an **OpenAI-compatible third-party provider** (e.g. llama.cpp, vLLM, LM Studio) via `openai/` prefix with a custom `api_base`, LiteLLM will normally forward `/responses` requests directly to that endpoint. If the provider only supports `/chat/completions`, the request will fail. + +Use either of these to force the `/responses` → `/chat/completions` bridge: + +1. **`use_chat_completions_api: true`** — makes it explicit that LiteLLM will call the provider’s chat-completions API. +2. **`openai/chat_completions/`** — same pattern as `responses/` on chat completions: the model id encodes the routing choice. + +#### Python SDK Usage + +```python showLineNumbers title="Force bridge for custom openai/ endpoint (flag)" +import litellm + +response = litellm.responses( + model="openai/my-custom-model", + input="Hello!", + api_base="http://localhost:8080", + api_key="fake-key", + use_chat_completions_api=True, +) + +print(response) +``` + +Or encode it in the model id: + +```python showLineNumbers title="Force bridge via openai/chat_completions/ model prefix" +import litellm + +response = litellm.responses( + model="openai/chat_completions/my-custom-model", + input="Hello!", + api_base="http://localhost:8080", + api_key="fake-key", +) + +print(response) +``` + +#### LiteLLM Proxy Usage + +**Setup Config:** + +```yaml showLineNumbers title="config.yaml — bridge for custom openai/ endpoint" +model_list: +- model_name: my-local-model + litellm_params: + model: openai/my-custom-model + api_base: http://localhost:8080/v1 + api_key: fake-key + use_chat_completions_api: true +``` + +Alternatively set `model: openai/chat_completions/my-custom-model` instead of the flag. + +**Start Proxy:** + +```bash showLineNumbers title="Start LiteLLM Proxy" +litellm --config /path/to/config.yaml + +# RUNNING on http://0.0.0.0:4000 +``` + +**Make Request:** + +```bash showLineNumbers title="Request via bridge" +curl http://localhost:4000/v1/responses \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer sk-1234" \ + -d '{ + "model": "my-local-model", + "input": "Hello!" + }' +``` + +This is particularly useful when connecting clients that hardcode the `/responses` endpoint (e.g. OpenAI Codex CLI with `wire_api = "responses"`) to local or third-party OpenAI-compatible providers that only expose `/chat/completions`. + ## Server-side compaction For long-running conversations, you can enable **server-side compaction** so that when the rendered context size crosses a threshold, the server automatically runs compaction in-stream and emits a compaction item—no separate `POST /v1/responses/compact` call is required. diff --git a/docs/my-website/docs/tutorials/prompt_caching.md b/docs/my-website/docs/tutorials/prompt_caching.md index ab2aa00d773..581d2ba7c36 100644 --- a/docs/my-website/docs/tutorials/prompt_caching.md +++ b/docs/my-website/docs/tutorials/prompt_caching.md @@ -8,6 +8,22 @@ Reduce costs by up to 90% by using LiteLLM to auto-inject prompt caching checkpo +Supported Providers (`cache_control` marker): +- Anthropic API (`anthropic/`) +- AWS Bedrock - Claude (`bedrock/`) +- Vertex AI - Claude and Gemini (`vertex_ai/`) +- Google AI Studio - Gemini (`gemini/`) +- Azure AI - Claude (`azure_ai/`) +- OpenRouter - Claude, Gemini, MiniMax, GLM, z-ai routes (`openrouter/`) +- Databricks - Claude (`databricks/`) +- DashScope / Qwen (`dashscope/`) +- MiniMax (`minimax/`) +- Z.ai / GLM (`zai/`) + +Provider Managed (automatic, no marker needed): +- OpenAI (`openai/`) +- DeepSeek (`deepseek/`) +- xAI (`xai/`) ## How it works diff --git a/docs/my-website/package-lock.json b/docs/my-website/package-lock.json index 77644000aed..3504bb84196 100644 --- a/docs/my-website/package-lock.json +++ b/docs/my-website/package-lock.json @@ -26,6 +26,7 @@ }, "devDependencies": { "@docusaurus/module-type-aliases": "3.8.1", + "ajv": "^8.18.0", "dotenv": "16.6.1" }, "engines": { diff --git a/docs/my-website/package.json b/docs/my-website/package.json index bee7cbca186..6ebe6842e13 100644 --- a/docs/my-website/package.json +++ b/docs/my-website/package.json @@ -32,6 +32,7 @@ }, "devDependencies": { "@docusaurus/module-type-aliases": "3.8.1", + "ajv": "^8.18.0", "dotenv": "16.6.1" }, "browserslist": { diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index c2db54b2237..dbbdb70f6bc 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -536,6 +536,7 @@ const sidebars = { description: "Modify requests, responses, and more", items: [ "proxy/call_hooks", + "proxy/agentic_loop_hook", "proxy/rules", ] }, @@ -1059,6 +1060,7 @@ const sidebars = { }, items: [ "routing", + "adaptive_router", "scheduler", "proxy/auto_routing", "proxy/load_balancing", diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260418000000_add_adaptive_router_tables/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260418000000_add_adaptive_router_tables/migration.sql new file mode 100644 index 00000000000..cdc76a0b915 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260418000000_add_adaptive_router_tables/migration.sql @@ -0,0 +1,39 @@ +-- One row per (router, request_type, model). Hot path on every routing decision. +CREATE TABLE "LiteLLM_AdaptiveRouterState" ( + router_name TEXT NOT NULL, + request_type TEXT NOT NULL, + model_name TEXT NOT NULL, + alpha DOUBLE PRECISION NOT NULL, + beta DOUBLE PRECISION NOT NULL, + total_samples INTEGER NOT NULL DEFAULT 0, + last_updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (router_name, request_type, model_name) +); + +-- One row per (session, router, model). Updated per turn via the queue. +CREATE TABLE "LiteLLM_AdaptiveRouterSession" ( + session_id TEXT NOT NULL, + router_name TEXT NOT NULL, + model_name TEXT NOT NULL, + classified_type TEXT NOT NULL, + misalignment_count INTEGER NOT NULL DEFAULT 0, + stagnation_count INTEGER NOT NULL DEFAULT 0, + disengagement_count INTEGER NOT NULL DEFAULT 0, + satisfaction_count INTEGER NOT NULL DEFAULT 0, + failure_count INTEGER NOT NULL DEFAULT 0, + loop_count INTEGER NOT NULL DEFAULT 0, + exhaustion_count INTEGER NOT NULL DEFAULT 0, + last_user_content TEXT, + last_assistant_content TEXT, + tool_call_history JSONB NOT NULL DEFAULT '[]', + pending_tool_calls JSONB NOT NULL DEFAULT '{}', + turn_count INTEGER NOT NULL DEFAULT 0, + last_processed_turn INTEGER NOT NULL DEFAULT -1, + clean_credit_awarded BOOLEAN NOT NULL DEFAULT FALSE, + terminal_status INTEGER, + last_activity_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (session_id, router_name, model_name) +); + +CREATE INDEX "idx_adaptive_router_session_activity" + ON "LiteLLM_AdaptiveRouterSession" (last_activity_at); diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260421135425_add_team_membership_total_spend/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260421135425_add_team_membership_total_spend/migration.sql new file mode 100644 index 00000000000..049bd513cd8 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260421135425_add_team_membership_total_spend/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "LiteLLM_TeamMembership" ADD COLUMN "total_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0; + diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 08aa5645251..34686148ce0 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -616,6 +616,7 @@ model LiteLLM_TeamMembership { user_id String team_id String spend Float @default(0.0) + total_spend Float @default(0.0) budget_id String? litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id]) @@id([user_id, team_id]) @@ -1223,3 +1224,46 @@ model LiteLLM_ClaudeCodePluginTable { @@map("LiteLLM_ClaudeCodePluginTable") } + +// Per-(router, request_type, model) Beta posterior for the adaptive router. +model LiteLLM_AdaptiveRouterState { + router_name String + request_type String + model_name String + alpha Float + beta Float + total_samples Int @default(0) + last_updated_at DateTime @default(now()) @updatedAt + + @@id([router_name, request_type, model_name]) +} + +// Per-(session, router, model) signal counters for the adaptive router. +model LiteLLM_AdaptiveRouterSession { + session_id String + router_name String + model_name String + classified_type String + + misalignment_count Int @default(0) + stagnation_count Int @default(0) + disengagement_count Int @default(0) + satisfaction_count Int @default(0) + failure_count Int @default(0) + loop_count Int @default(0) + exhaustion_count Int @default(0) + + last_user_content String? + last_assistant_content String? + tool_call_history Json @default("[]") + pending_tool_calls Json @default("{}") + + turn_count Int @default(0) + last_processed_turn Int @default(-1) + clean_credit_awarded Boolean @default(false) + terminal_status Int? + last_activity_at DateTime @default(now()) @updatedAt + + @@id([session_id, router_name, model_name]) + @@index([last_activity_at], map: "idx_adaptive_router_session_activity") +} diff --git a/litellm-proxy-extras/litellm_proxy_extras/utils.py b/litellm-proxy-extras/litellm_proxy_extras/utils.py index c24188cba1d..369b6561931 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/utils.py +++ b/litellm-proxy-extras/litellm_proxy_extras/utils.py @@ -30,6 +30,26 @@ def _get_prisma_env() -> dict: return prisma_env +_MIGRATION_TS_RE = re.compile(r"^(\d{14})_") + + +def _migration_timestamp(name: str) -> int: + """Extract the leading `YYYYMMDDHHMMSS` timestamp from a migration name. + + Returns 0 if the name doesn't match the Prisma pattern — unexpected-format + entries sort as "oldest" and are treated as historical. + """ + m = _MIGRATION_TS_RE.match(name) + return int(m.group(1)) if m else 0 + + +def _max_migration_timestamp(names) -> int: + """Max timestamp in a set/list of migration names (0 if empty).""" + if not names: + return 0 + return max(_migration_timestamp(n) for n in names) + + def _get_prisma_command() -> str: """Get the Prisma command to use, bypassing Python wrapper in offline mode.""" if str_to_bool(os.getenv("PRISMA_OFFLINE_MODE")): @@ -383,18 +403,301 @@ class ProxyExtrasDBManager: ) @staticmethod - def setup_database(use_migrate: bool = False) -> bool: + def _strip_prisma_query_params(url: str) -> str: + """Remove Prisma-specific query params (connection_limit, pool_timeout, + schema, etc.) from DATABASE_URL so psycopg can parse it.""" + from urllib.parse import urlparse, urlunparse, parse_qsl, urlencode + + parsed = urlparse(url) + if not parsed.query: + return url + libpq_params = { + "sslmode", + "sslcert", + "sslkey", + "sslrootcert", + "sslpassword", + "application_name", + "connect_timeout", + "client_encoding", + "options", + "service", + "gssencmode", + "krbsrvname", + "target_session_attrs", + } + kept = [(k, v) for k, v in parse_qsl(parsed.query) if k in libpq_params] + return urlunparse(parsed._replace(query=urlencode(kept))) + + @staticmethod + def _warn_if_db_ahead_of_head(migrations_dir: str) -> None: + """ + Log a warning if _prisma_migrations contains applied migrations with + timestamps newer than every migration this build ships. + + This is informational only for the v2 resolver — it tells the operator + the DB was likely migrated by a newer deployment, which is usually a + signal that this (older) version shouldn't run against it. We do NOT + block startup: many users have weird _prisma_migrations state from + prior thrashing bugs, and blocking them would be a breaking change. + + Safe no-op if psycopg isn't installed or DB isn't reachable. + """ + database_url = os.getenv("DATABASE_URL") + if not database_url: + return + + try: + import psycopg + except ImportError: + return + + cleaned_url = ProxyExtrasDBManager._strip_prisma_query_params(database_url) + known = set(ProxyExtrasDBManager._get_migration_names(migrations_dir)) + + try: + # autocommit=True keeps the SELECT outside a transaction. Without + # it, psycopg3's `with conn` calls COMMIT on clean exit — which + # fails after `UndefinedTable` (fresh DB) leaves the transaction + # in an aborted state. + with psycopg.connect( + cleaned_url, connect_timeout=10, autocommit=True + ) as conn: + try: + rows = conn.execute( + "SELECT migration_name FROM _prisma_migrations " + "WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL" + ).fetchall() + except psycopg.errors.UndefinedTable: + return + except (psycopg.OperationalError, psycopg.DatabaseError): + # Swallow connection failures AND any other DB-layer error + # (e.g. InsufficientPrivilege if the runtime user lacks SELECT + # on _prisma_migrations). This is an informational check — + # never block startup on it. + return + + applied = {r[0] for r in rows} + unknown = applied - known + if not unknown: + return + + head_newest_ts = _max_migration_timestamp(known) + hostile = { + name for name in unknown if _migration_timestamp(name) > head_newest_ts + } + if not hostile: + return + + sorted_hostile = sorted(hostile) + logger.warning( + "Database has %d migration(s) applied that are NEWER than any " + "migration this LiteLLM version ships. This usually means the " + "database was migrated by a newer LiteLLM deployment. Some API " + "endpoints may fail because this proxy's Prisma client does not " + "know about those schema changes. Consider upgrading this " + "deployment. Unknown: %s", + len(hostile), + ", ".join(sorted_hostile[:5]) + (" ..." if len(sorted_hostile) > 5 else ""), + ) + + @staticmethod + def _setup_database_v2(use_migrate: bool) -> bool: + """ + v2 migration resolver (opt-in via --use_v2_migration_resolver). + + Runs `prisma migrate deploy` and handles standard recovery paths + (P3005 baseline, P3009/P3018 idempotent errors). Critically, it does + NOT call `_resolve_all_migrations` — the diff-and-force recovery that + caused schema thrashing when two LiteLLM versions contended for the + same DB during rolling deploys. + + Ahead-of-HEAD state (DB has migrations newer than this build ships) + is logged as a warning, not a fatal error — users whose DBs got into + weird shapes from the old thrashing should still be able to start. + """ + schema_path = ProxyExtrasDBManager._get_prisma_dir() + "/schema.prisma" + migrations_dir = ProxyExtrasDBManager._get_prisma_dir() + + if not use_migrate: + # Preserve `prisma db push` path unchanged. + original_dir = os.getcwd() + os.chdir(migrations_dir) + try: + subprocess.run( + [_get_prisma_command(), "db", "push", "--accept-data-loss"], + timeout=60, + check=True, + env=_get_prisma_env(), + ) + return True + except ( + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + ) as e: + # Re-raise as RuntimeError so proxy_cli.py's + # `except RuntimeError` catches it and exits cleanly. + raise RuntimeError(f"prisma db push failed.\n\nDetail: {e}") from e + finally: + os.chdir(original_dir) + + # Informational — never blocks. + ProxyExtrasDBManager._warn_if_db_ahead_of_head(migrations_dir) + + original_dir = os.getcwd() + os.chdir(migrations_dir) + try: + for attempt in range(4): + try: + result = subprocess.run( + [_get_prisma_command(), "migrate", "deploy"], + timeout=60, + check=True, + capture_output=True, + text=True, + env=_get_prisma_env(), + ) + logger.info(f"prisma migrate deploy stdout: {result.stdout}") + return True + + except subprocess.TimeoutExpired: + logger.info( + f"prisma migrate deploy attempt {attempt + 1} timed out, retrying" + ) + time.sleep(random.randrange(5, 15)) + continue + + except subprocess.CalledProcessError as e: + stderr = e.stderr or "" + + if "P3005" in stderr and "database schema is not empty" in stderr: + logger.info( + "Schema exists but no migrations ledger — creating baseline" + ) + ProxyExtrasDBManager._create_baseline_migration(schema_path) + continue + + if "P3009" in stderr: + migration_match = re.search(r"`(\d+_\S+?)`", stderr) + if ( + migration_match + and ProxyExtrasDBManager._is_idempotent_error(stderr) + ): + name = migration_match.group(1) + logger.info( + f"Migration {name} failed idempotently — marking applied and retrying" + ) + try: + ProxyExtrasDBManager._roll_back_migration(name) + except ( + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + ): + pass # may already be rolled-back + try: + ProxyExtrasDBManager._resolve_specific_migration(name) + except ( + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + ) as resolve_err: + # We're already inside the outer + # `except CalledProcessError` handler — + # re-raising CalledProcessError from here + # would escape as itself, bypassing + # proxy_cli.py's `except RuntimeError`. + raise RuntimeError( + f"Failed to mark migration {name} as applied " + f"after idempotent recovery. Manual " + f"intervention may be required.\n\n" + f"Detail: {resolve_err}" + ) from resolve_err + continue + raise RuntimeError( + "Database migration failed and cannot be auto-recovered. " + f"Manual intervention required.\n\nPrisma error:\n{stderr}" + ) from e + + if "P3018" in stderr: + if ProxyExtrasDBManager._is_permission_error(stderr): + raise RuntimeError( + "Database migration failed due to insufficient " + "permissions. Please grant the required privileges " + f"and retry.\n\nPrisma error:\n{stderr}" + ) from e + + migration_match = re.search( + r"Migration name: (\d+_\S+)", stderr + ) + if ( + migration_match + and ProxyExtrasDBManager._is_idempotent_error(stderr) + ): + name = migration_match.group(1) + logger.info( + f"Migration {name} SQL hit idempotent error — marking applied and retrying" + ) + try: + ProxyExtrasDBManager._roll_back_migration(name) + except ( + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + ): + pass # may already be rolled-back + try: + ProxyExtrasDBManager._resolve_specific_migration(name) + except ( + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + ) as resolve_err: + raise RuntimeError( + f"Failed to mark migration {name} as applied " + f"after idempotent recovery. Manual " + f"intervention may be required.\n\n" + f"Detail: {resolve_err}" + ) from resolve_err + continue + + raise RuntimeError( + "Database migration failed and cannot be auto-recovered. " + f"Manual intervention required.\n\nPrisma error:\n{stderr}" + ) from e + + raise RuntimeError( + "Database migration failed and cannot be auto-recovered. " + f"Manual intervention required.\n\nPrisma error:\n{stderr}" + ) from e + + raise RuntimeError( + "Database migration failed after 4 attempts (retry loop " + "exhausted by timeouts or repeated idempotent-recovery " + "continues). Check database connectivity, load, and " + "_prisma_migrations ledger state." + ) + finally: + os.chdir(original_dir) + + @staticmethod + def setup_database( + use_migrate: bool = False, use_v2_resolver: bool = False + ) -> bool: """ Set up the database using either prisma migrate or prisma db push Uses migrations from litellm-proxy-extras package Args: - schema_path (str): Path to the Prisma schema file - use_migrate (bool): Whether to use prisma migrate instead of db push + use_migrate: Whether to use prisma migrate instead of db push + use_v2_resolver: Opt into the v2 migration resolver (safer during + rolling deploys; does not run the diff-and-force recovery + that causes schema thrashing). Defaults to False for + backwards compatibility. Returns: bool: True if setup was successful, False otherwise """ + if use_v2_resolver: + logger.info("Using v2 migration resolver (--use_v2_migration_resolver)") + return ProxyExtrasDBManager._setup_database_v2(use_migrate=use_migrate) + schema_path = ProxyExtrasDBManager._get_prisma_dir() + "/schema.prisma" for attempt in range(4): original_dir = os.getcwd() diff --git a/litellm-proxy-extras/migration_runbook.md b/litellm-proxy-extras/migration_runbook.md index 8499bb7ce08..a277441b164 100644 --- a/litellm-proxy-extras/migration_runbook.md +++ b/litellm-proxy-extras/migration_runbook.md @@ -2,6 +2,8 @@ This is a runbook for creating and running database migrations for the LiteLLM proxy. For use for litellm engineers only. +> **AI AGENTS / ASSISTANTS:** If the script refuses with either a "STALE BRANCH" or "DESTRUCTIVE MIGRATION DETECTED" error, **do NOT** bypass it on your own (no `git rebase`, no `--skip-freshness-check`, no `--allow-destructive`). Surface the error to the human operator and wait for their explicit confirmation. See the [Branch freshness](#branch-freshness-check) and [Destructive migrations](#destructive-migrations-drop-column--drop-table) sections below. + ## Step 0: Sync All `schema.prisma` Files Before doing anything else, make sure all `schema.prisma` files in the repo are in sync. There are multiple copies that must match: @@ -46,10 +48,50 @@ uv run --with testing.postgresql python ci_cd/run_migration.py "your_migration_n ## What It Does -1. Creates temp PostgreSQL DB -2. Applies existing migrations -3. Compares with `schema.prisma` -4. Generates new migration if changes found +1. **Verifies the current branch is up to date with `origin/litellm_internal_staging`** (see [Branch freshness](#branch-freshness-check)) +2. Creates temp PostgreSQL DB +3. Applies existing migrations +4. Compares with `schema.prisma` +5. Generates new migration if changes found +6. **Refuses to write if the generated SQL contains `DROP COLUMN`, `DROP TABLE`, or `DROP INDEX`**, unless `--allow-destructive` is passed + +## Branch Freshness Check + +Before generating anything, `run_migration.py` runs `git fetch origin ` and refuses to proceed if `HEAD` is behind `origin/`. Default base is `litellm_internal_staging` (the branch PRs target). A previous incident saw a stale branch silently drop production columns; freshness is the first-line defense. + +Flags: + +- `--base-branch ` — check against a different base (e.g. `main`). Default is `litellm_internal_staging`. +- `--skip-freshness-check` — bypass entirely. Only for intentional migrations against an older base. + +When the guard fires: + +1. Update your branch: + + ```bash + git fetch origin && git rebase origin/litellm_internal_staging + # or git merge origin/litellm_internal_staging — whichever matches your workflow + ``` +2. Re-run `run_migration.py`. + +> **AI AGENTS / ASSISTANTS:** Do **not** auto-rebase or auto-pass `--skip-freshness-check`. A bad conflict resolution during rebase can itself drop the human's in-progress schema edits. Surface the error and wait for explicit confirmation. + +## Destructive Migrations (DROP COLUMN / DROP TABLE / DROP INDEX) + +If the generated diff contains `DROP COLUMN`, `DROP TABLE`, or `DROP INDEX`, `run_migration.py` exits non-zero and refuses to write the migration file. A previous incident saw newly-added columns silently dropped by a stale branch and merged to main — this guard exists to prevent a repeat. + +When the guard fires: + +1. Run `git fetch origin && git status` — confirm your branch is up to date with the base branch. +2. Re-check all `schema.prisma` files are in sync (Step 0). +3. Review EACH `DROP` statement printed in the error — is it actually intended? +4. Only if the drops are genuinely intentional, re-run with the flag: + + ```bash + uv run --with testing.postgresql python ci_cd/run_migration.py "your_migration_name" --allow-destructive + ``` + +> **AI AGENTS / ASSISTANTS:** Do **not** automatically re-run the command with `--allow-destructive`. If the guard fires while you are driving the runbook for a human, stop, show them the error, and wait for their explicit confirmation before passing the flag. Auto-passing `--allow-destructive` is the exact failure mode this guard exists to prevent. ## Common Fixes diff --git a/litellm-proxy-extras/pyproject.toml b/litellm-proxy-extras/pyproject.toml index 959f9519a7f..65f95dbde78 100644 --- a/litellm-proxy-extras/pyproject.toml +++ b/litellm-proxy-extras/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm-proxy-extras" -version = "0.4.67" +version = "0.4.68" description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package." readme = "README.md" requires-python = ">=3.9" @@ -25,7 +25,7 @@ required-version = "==0.10.9" module-root = "" [tool.commitizen] -version = "0.4.67" +version = "0.4.68" version_files = [ "pyproject.toml:^version", "../pyproject.toml:litellm-proxy-extras==", diff --git a/litellm-proxy-extras/tests/test_setup_database_fail_fast.py b/litellm-proxy-extras/tests/test_setup_database_fail_fast.py new file mode 100644 index 00000000000..8d66bf872de --- /dev/null +++ b/litellm-proxy-extras/tests/test_setup_database_fail_fast.py @@ -0,0 +1,242 @@ +"""Regression tests for ProxyExtrasDBManager v2 migration resolver. + +The v2 resolver is opt-in via `--use_v2_migration_resolver` / the +`use_v2_resolver=True` kwarg. These tests exercise the v2 path; the v1 +(default) behavior is unchanged from pre-fix. +""" + +import subprocess +from unittest.mock import patch + +import pytest + +from litellm_proxy_extras.utils import ( + ProxyExtrasDBManager, + _max_migration_timestamp, + _migration_timestamp, +) + + +def _fake_migrate_deploy_failure(returncode: int, stderr: str): + def _run(*args, **kwargs): + raise subprocess.CalledProcessError( + returncode=returncode, + cmd=args[0], + stderr=stderr, + output="", + ) + + return _run + + +def test_v2_p3018_permission_error_raises_runtime_error(monkeypatch, tmp_path): + """v2: a permission failure during migrate deploy raises RuntimeError.""" + monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:9/x") + monkeypatch.setattr( + ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None + ) + monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path)) + (tmp_path / "schema.prisma").write_text("// stub") + + stderr = ( + "Error: P3018\nMigration name: 20250326162113_baseline\n" + "Database error code: 42501\npermission denied for schema public" + ) + with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)): + with pytest.raises(RuntimeError, match="permission"): + ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) + + +def test_v2_non_idempotent_p3009_raises_runtime_error(monkeypatch, tmp_path): + """v2: a non-idempotent migration failure raises (no silent recovery).""" + monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:9/x") + monkeypatch.setattr( + ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None + ) + monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path)) + (tmp_path / "schema.prisma").write_text("// stub") + + stderr = ( + "Error: P3009\nMigration `20260101000000_genuinely_broken` failed\n" + 'Reason: syntax error at or near "BRKN" LINE 42' + ) + with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)): + with pytest.raises(RuntimeError, match="cannot be auto-recovered"): + ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) + + +def test_strip_prisma_query_params_removes_connection_limit(): + """DATABASE_URLs with Prisma-specific params should be parseable by psycopg.""" + url = "postgresql://u:p@h:5432/db?connection_limit=100&pool_timeout=60&sslmode=require" + stripped = ProxyExtrasDBManager._strip_prisma_query_params(url) + assert "connection_limit" not in stripped + assert "pool_timeout" not in stripped + assert "sslmode=require" in stripped + + +def test_strip_prisma_query_params_passthrough_no_query(): + """URLs without query strings are returned unchanged.""" + url = "postgresql://u:p@h:5432/db" + assert ProxyExtrasDBManager._strip_prisma_query_params(url) == url + + +def test_migration_timestamp_extracts_leading_digits(): + assert _migration_timestamp("20260101000000_add_foo") == 20260101000000 + assert _migration_timestamp("20250326162113_baseline") == 20250326162113 + + +def test_migration_timestamp_returns_zero_on_malformed(): + assert _migration_timestamp("0_init") == 0 + assert _migration_timestamp("not_a_migration") == 0 + + +def test_max_migration_timestamp(): + names = {"20250326000000_a", "20260415000000_b", "20251115000000_c"} + assert _max_migration_timestamp(names) == 20260415000000 + + +def test_max_migration_timestamp_empty_set(): + assert _max_migration_timestamp(set()) == 0 + + +def test_v1_default_still_calls_resolve_all_migrations(monkeypatch, tmp_path): + """v1 (default) continues to call _resolve_all_migrations on the happy path. + + This is the existing buggy behavior — we're not fixing it in v1, only + offering v2 as opt-in. This test pins the default so that a future + inadvertent default flip is caught. + """ + monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path)) + (tmp_path / "schema.prisma").write_text("// stub") + + # Stub `prisma migrate deploy` to claim success with pending migrations + # applied, which is the code path that triggers the legacy post-migration + # sanity check (a call to _resolve_all_migrations). + class FakeResult: + stdout = "Applied migration.\n" + stderr = "" + + def fake_run(cmd, *args, **kwargs): + return FakeResult() + + resolve_called = {"n": 0} + + def fake_resolve(*args, **kwargs): + resolve_called["n"] += 1 + + monkeypatch.setattr("subprocess.run", fake_run) + monkeypatch.setattr(ProxyExtrasDBManager, "_resolve_all_migrations", fake_resolve) + + ok = ProxyExtrasDBManager.setup_database(use_migrate=True) # v2 flag NOT set + assert ok is True + assert resolve_called["n"] == 1, "v1 default should still invoke the legacy path" + + +def test_v2_db_push_wraps_subprocess_error_as_runtime_error(monkeypatch, tmp_path): + """v2: a failing `prisma db push` must raise RuntimeError, not leak + CalledProcessError past proxy_cli.py's `except RuntimeError`.""" + monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path)) + (tmp_path / "schema.prisma").write_text("// stub") + + stderr = "db push error" + with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)): + with pytest.raises(RuntimeError, match="prisma db push failed"): + ProxyExtrasDBManager.setup_database(use_migrate=False, use_v2_resolver=True) + + +def test_v2_warn_ahead_of_head_swallows_db_errors(monkeypatch, tmp_path): + """_warn_if_db_ahead_of_head must never raise — it's informational. + + Non-connection DB errors (e.g. InsufficientPrivilege from a user + without SELECT on _prisma_migrations) must be caught, not propagated. + """ + import psycopg + + monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:9/x") + monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path)) + (tmp_path / "schema.prisma").write_text("// stub") + + class _FakeConn: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def execute(self, *a, **kw): + # Simulate an InsufficientPrivilege (subclass of DatabaseError). + raise psycopg.errors.InsufficientPrivilege("permission denied") + + def _fake_connect(*a, **kw): + return _FakeConn() + + monkeypatch.setattr("psycopg.connect", _fake_connect) + + # Must not raise. + ProxyExtrasDBManager._warn_if_db_ahead_of_head(str(tmp_path)) + + +def test_v2_resolve_specific_migration_failure_raises_runtime_error( + monkeypatch, tmp_path +): + """If marking a migration as applied fails inside P3009 idempotent + recovery, the subprocess error must be re-raised as RuntimeError so + proxy_cli.py catches it cleanly (instead of leaking CalledProcessError).""" + monkeypatch.setattr( + ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None + ) + monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path)) + (tmp_path / "schema.prisma").write_text("// stub") + monkeypatch.setattr( + ProxyExtrasDBManager, "_roll_back_migration", lambda *a, **kw: None + ) + + # First call: migrate deploy -> P3009 idempotent error. + # Recovery path tries _resolve_specific_migration; that also raises. + def _failing_resolve(*a, **kw): + raise subprocess.CalledProcessError( + returncode=1, + cmd="prisma migrate resolve --applied", + stderr="resolve failed", + output="", + ) + + monkeypatch.setattr( + ProxyExtrasDBManager, "_resolve_specific_migration", _failing_resolve + ) + + stderr = ( + "Error: P3009\nMigration `20260101000000_some_migration` failed\n" + "relation already exists" + ) + with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)): + with pytest.raises( + RuntimeError, match="Failed to mark migration .* as applied" + ): + ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) + + +def test_v2_does_not_call_resolve_all_migrations(monkeypatch, tmp_path): + """v2 must never call _resolve_all_migrations — that's the bug it fixes.""" + monkeypatch.setattr( + ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None + ) + monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path)) + (tmp_path / "schema.prisma").write_text("// stub") + + class FakeResult: + stdout = "Applied migration.\n" + stderr = "" + + monkeypatch.setattr("subprocess.run", lambda *a, **kw: FakeResult()) + + resolve_called = {"n": 0} + monkeypatch.setattr( + ProxyExtrasDBManager, + "_resolve_all_migrations", + lambda *a, **kw: resolve_called.__setitem__("n", resolve_called["n"] + 1), + ) + + ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) + assert ok is True + assert resolve_called["n"] == 0, "v2 must not invoke the diff-and-force recovery" diff --git a/litellm/__init__.py b/litellm/__init__.py index 3acbb495356..89cef667c6e 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -148,6 +148,7 @@ _custom_logger_compatible_callbacks_literal = Literal[ "vantage", "posthog", "levo", + "compression_interception", ] cold_storage_custom_logger: Optional[_custom_logger_compatible_callbacks_literal] = None logged_real_time_event_types: Optional[Union[List[str], Literal["*"]]] = None @@ -1501,6 +1502,9 @@ if TYPE_CHECKING: from .llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import ( AmazonAnthropicClaudeMessagesConfig as AmazonAnthropicClaudeMessagesConfig, ) + from .llms.bedrock.messages.mantle_transformation import ( + AmazonMantleMessagesConfig as AmazonMantleMessagesConfig, + ) from .llms.together_ai.chat import TogetherAIConfig as TogetherAIConfig from .llms.nlp_cloud.chat.handler import NLPCloudConfig as NLPCloudConfig from .llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( diff --git a/litellm/_lazy_imports_registry.py b/litellm/_lazy_imports_registry.py index 9164a3c8ae4..119e62a5b38 100644 --- a/litellm/_lazy_imports_registry.py +++ b/litellm/_lazy_imports_registry.py @@ -171,6 +171,7 @@ LLM_CONFIG_NAMES = ( "CohereChatConfig", "AnthropicMessagesConfig", "AmazonAnthropicClaudeMessagesConfig", + "AmazonMantleMessagesConfig", "TogetherAIConfig", "NLPCloudConfig", "VertexGeminiConfig", @@ -715,6 +716,10 @@ _LLM_CONFIGS_IMPORT_MAP = { ".llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation", "AmazonAnthropicClaudeMessagesConfig", ), + "AmazonMantleMessagesConfig": ( + ".llms.bedrock.messages.mantle_transformation", + "AmazonMantleMessagesConfig", + ), "TogetherAIConfig": (".llms.together_ai.chat", "TogetherAIConfig"), "NLPCloudConfig": (".llms.nlp_cloud.chat.handler", "NLPCloudConfig"), "VertexGeminiConfig": ( diff --git a/litellm/compression/compress.py b/litellm/compression/compress.py index b78b04ec43c..45795c9ca15 100644 --- a/litellm/compression/compress.py +++ b/litellm/compression/compress.py @@ -1,9 +1,9 @@ """ -Main compress() function — orchestrates BM25/embedding scoring, message stubbing, -and retrieval tool injection. +Main compress() function — normalizes input messages, orchestrates BM25/embedding +scoring, message stubbing, and retrieval tool injection. """ -from typing import Any, Dict, List, Optional, Set, Union, cast +from typing import Any, Dict, List, Optional, Set, Tuple, Union, cast from litellm.caching.dual_cache import DualCache from litellm.compression.message_stubbing import ( @@ -15,27 +15,196 @@ from litellm.compression.retrieval_tool import build_retrieval_tool from litellm.compression.scoring.bm25 import bm25_score_messages from litellm.litellm_core_utils.token_counter import token_counter from litellm.types.compression import CompressedResult -from litellm.types.utils import AllMessageValues, Message +from litellm.types.utils import CallTypes + +# CallTypes that produce Anthropic-shaped messages (structured content blocks). +# Everything else is treated as OpenAI chat-completions shape. +_ANTHROPIC_CALL_TYPES = frozenset({CallTypes.anthropic_messages.value}) +# CallTypes that are valid targets for compression. Compression operates on +# message-shaped inputs, so we only accept call types whose payload is a list +# of role/content messages. +_SUPPORTED_CALL_TYPES = frozenset( + { + CallTypes.completion.value, + CallTypes.acompletion.value, + CallTypes.anthropic_messages.value, + } +) + + +def _normalize_call_type(call_type: Union[CallTypes, str]) -> str: + """Return the string value for a ``CallTypes`` enum or a raw string.""" + if isinstance(call_type, CallTypes): + return call_type.value + return call_type + + +def _is_anthropic_call_type(call_type: str) -> bool: + return call_type in _ANTHROPIC_CALL_TYPES + + +def _build_retrieval_tools(keys: List[str], call_type: str) -> List[dict]: + """ + Build retrieval tool definitions in the target request schema. + + - Chat-completions call types: keep OpenAI function-tool schema. + - Anthropic messages call type: remap to Anthropic's custom tool schema. + """ + if not keys: + return [] + + openai_tools = [build_retrieval_tool(keys)] + if not _is_anthropic_call_type(call_type): + return openai_tools + + # Lazy import to avoid introducing provider transformation imports during + # module import for non-Anthropic call paths. + from litellm.llms.anthropic.chat.transformation import AnthropicConfig + + anthropic_tools, _mcp_servers = AnthropicConfig()._map_tools(openai_tools) + return cast(List[dict], anthropic_tools) + + +def _content_to_text(content: Any) -> str: + """ + Convert OpenAI/Anthropic message content blocks to plain text. + + Text extraction policy: + - Include text-bearing fields only (`text` blocks + string values). + - For `tool_result`, expand into nested `content` items. + - Ignore non-textual blocks (images/documents/tool metadata/thinking metadata). + + Implemented iteratively (stack-based) to avoid unbounded recursion. + """ + parts: List[str] = [] + stack: List[Any] = [content] + while stack: + item = stack.pop() + if isinstance(item, str): + parts.append(item) + elif isinstance(item, list): + # Push list items in reverse order so they are processed left-to-right. + for element in reversed(item): + stack.append(element) + elif isinstance(item, dict): + item_type = item.get("type") + if item_type == "text": + parts.append(str(item.get("text", ""))) + elif item_type == "tool_result": + stack.append(item.get("content", "")) + return " ".join(parts) + + +def _normalize_messages_for_compression( + messages: List[dict], + call_type: str, +) -> Tuple[List[dict], List[dict]]: + """ + Normalize each original message to a text-surrogate content for scoring. + + Returns: + (normalized_messages, original_messages_copy) + """ + if call_type not in _SUPPORTED_CALL_TYPES: + raise ValueError( + f"Unsupported call_type={call_type!r} for compression. " + f"Expected one of: {sorted(_SUPPORTED_CALL_TYPES)}." + ) + + original_messages: List[Dict[str, Any]] = [dict(m) for m in messages] + + normalized_messages: List[dict] = [] + for msg in original_messages: + normalized_messages.append( + { + **msg, + "content": _content_to_text(msg.get("content", "")), + } + ) + return normalized_messages, original_messages def _extract_last_user_message(messages: List[dict]) -> str: """Return the text content of the last user message.""" for msg in reversed(messages): if msg.get("role") == "user": - content = msg.get("content", "") - if isinstance(content, str): - return content - if isinstance(content, list): - parts = [] - for part in content: - if isinstance(part, dict) and part.get("type") == "text": - parts.append(part.get("text", "")) - elif isinstance(part, str): - parts.append(part) - return " ".join(parts) + return _content_to_text(msg.get("content", "")) return "" +def _extract_tool_use_ids(content: Any) -> List[str]: + if not isinstance(content, list): + return [] + tool_use_ids: List[str] = [] + for part in content: + if not isinstance(part, dict): + continue + if part.get("type") != "tool_use": + continue + tool_use_id = part.get("id") + if isinstance(tool_use_id, str) and tool_use_id: + tool_use_ids.append(tool_use_id) + return tool_use_ids + + +def _extract_tool_result_ids(content: Any) -> Set[str]: + if not isinstance(content, list): + return set() + tool_result_ids: Set[str] = set() + for part in content: + if not isinstance(part, dict): + continue + if part.get("type") != "tool_result": + continue + tool_use_id = part.get("tool_use_id") + if isinstance(tool_use_id, str) and tool_use_id: + tool_result_ids.add(tool_use_id) + return tool_result_ids + + +def _extract_anthropic_tool_exchange_spans( + messages: List[dict], +) -> Tuple[List[Set[int]], Optional[str]]: + """ + Return atomic 2-message spans for Anthropic tool exchanges. + + Each assistant message containing `tool_use` must be immediately followed by a + user message containing matching `tool_result` blocks for all tool_use ids. + """ + spans: List[Set[int]] = [] + i = 0 + while i < len(messages): + current = messages[i] + if current.get("role") != "assistant": + i += 1 + continue + + tool_use_ids = _extract_tool_use_ids(current.get("content")) + if not tool_use_ids: + i += 1 + continue + + if i + 1 >= len(messages): + return [], "invalid_anthropic_tool_sequence" + + next_msg = messages[i + 1] + if next_msg.get("role") != "user": + return [], "invalid_anthropic_tool_sequence" + + tool_result_ids = _extract_tool_result_ids(next_msg.get("content")) + if not tool_result_ids: + return [], "invalid_anthropic_tool_sequence" + + for tool_use_id in tool_use_ids: + if tool_use_id not in tool_result_ids: + return [], "invalid_anthropic_tool_sequence" + + spans.append({i, i + 1}) + i += 2 + + return spans, None + + def _get_protected_indices(messages: List[dict]) -> List[int]: """ Return indices of messages that must never be compressed: @@ -87,9 +256,98 @@ def _combine_scores( return [bm25_weight * b + emb_weight * e for b, e in zip(norm_bm25, norm_emb)] +def _select_kept_indices_for_budget( + normalized_messages: List[dict], + original_messages: List[dict], + combined_scores: List[float], + compression_target: int, + model: str, + initial_kept_indices: Set[int], + tool_exchange_spans: List[Set[int]], +) -> Tuple[Set[int], Dict[int, dict]]: + kept_indices = set(initial_kept_indices) + current_tokens = 0 + for i in kept_indices: + current_tokens += token_counter( + model=model, + text=cast(str, normalized_messages[i].get("content", "") or ""), + ) + + # Fill token budget from highest-scoring units. + # A unit is either: + # 1) a single message index, or + # 2) an Anthropic tool-exchange span that must be kept/dropped atomically. + truncated_overrides: Dict[int, dict] = {} # idx -> truncated message dict + span_id_by_index: Dict[int, int] = {} + for span_id, span in enumerate(tool_exchange_spans): + for idx in span: + span_id_by_index[idx] = span_id + + # Build single-message candidate units (non-span messages). + candidate_units: List[Tuple[float, Tuple[int, ...], bool]] = [] + for idx in range(len(normalized_messages)): + if idx in span_id_by_index or idx in kept_indices: + continue + candidate_units.append((combined_scores[idx], (idx,), True)) + + # Build span candidate units (atomic keep/drop for tool exchanges). + for span in tool_exchange_spans: + span_indices = tuple(sorted(span)) + if any(idx in kept_indices for idx in span_indices): + continue + span_score = max(combined_scores[idx] for idx in span_indices) + candidate_units.append((span_score, span_indices, False)) + + # Sort by descending relevance score. + candidate_units.sort(key=lambda item: item[0], reverse=True) + + for _score, indices, can_truncate in candidate_units: + if any(idx in kept_indices for idx in indices): + continue + msg_tokens = 0 + for idx in indices: + msg_tokens += token_counter( + model=model, + text=cast(str, normalized_messages[idx].get("content", "") or ""), + ) + remaining = compression_target - current_tokens + + if remaining <= 0: + break # budget exhausted + + if current_tokens + msg_tokens <= compression_target: + # Fits entirely + kept_indices.update(indices) + current_tokens += msg_tokens + elif can_truncate and len(indices) == 1 and remaining >= 100: + # Too large to fit whole single message, but we have budget — truncate it. + idx = indices[0] + truncated = truncate_message(original_messages[idx], remaining) + truncated_tokens = token_counter( + model=model, + text=truncated.get("content", "") or "", + ) + truncated_overrides[idx] = truncated + kept_indices.add(idx) + current_tokens += truncated_tokens + + return kept_indices, truncated_overrides + + +def _get_dropped_tool_span_indices( + kept_indices: Set[int], tool_exchange_spans: List[Set[int]] +) -> Set[int]: + dropped_tool_span_indices: Set[int] = set() + for span in tool_exchange_spans: + if not any(idx in kept_indices for idx in span): + dropped_tool_span_indices.update(span) + return dropped_tool_span_indices + + def compress( messages: List[dict], model: str, + call_type: Union[CallTypes, str] = CallTypes.completion, compression_trigger: int = 200_000, compression_target: Optional[int] = None, embedding_model: Optional[str] = None, @@ -108,6 +366,12 @@ def compress( Parameters: messages: The conversation messages to (potentially) compress. model: The LLM model name — used for token counting. + call_type: The LiteLLM call type whose message schema these messages + follow. Supported values: + - ``CallTypes.completion`` / ``CallTypes.acompletion`` — OpenAI + chat-completions shape (default) + - ``CallTypes.anthropic_messages`` — Anthropic Messages shape + (structured content blocks + atomic tool exchanges) compression_trigger: Only compress if input exceeds this token count. compression_target: Target token count after compression. Defaults to ``compression_trigger // 2``. @@ -122,29 +386,37 @@ def compress( A ``CompressedResult`` dict containing compressed messages, token counts, a cache of original content, and the retrieval tool definition. """ + call_type_str = _normalize_call_type(call_type) + normalized_messages, original_messages = _normalize_messages_for_compression( + messages=messages, + call_type=call_type_str, + ) + if compression_target is None: compression_target = compression_trigger * 7 // 10 original_tokens = token_counter( - model=model, messages=cast(List[Union[AllMessageValues, Message]], messages) + model=model, + messages=cast(List[Any], original_messages), ) # Pass through if below trigger if original_tokens <= compression_trigger: return CompressedResult( - messages=messages, + messages=original_messages, original_tokens=original_tokens, compressed_tokens=original_tokens, compression_ratio=0.0, cache={}, tools=[], + compression_skipped_reason="below_trigger", ) # Extract query for relevance scoring - query = _extract_last_user_message(messages) + query = _extract_last_user_message(normalized_messages) # Score each message - bm25_scores = bm25_score_messages(query, messages) + bm25_scores = bm25_score_messages(query, normalized_messages) if embedding_model: from litellm.compression.scoring.embedding_scorer import ( @@ -153,7 +425,7 @@ def compress( emb_scores = embedding_score_messages( query, - messages, + normalized_messages, model=embedding_model, cache=compression_cache, embedding_model_params=embedding_model_params, @@ -162,85 +434,69 @@ def compress( else: combined_scores = bm25_scores - # Sort message indices by score descending - ranked_indices = sorted( - range(len(messages)), - key=lambda i: combined_scores[i], - reverse=True, - ) - # Protected messages are never compressed - protected_indices = _get_protected_indices(messages) + protected_indices = _get_protected_indices(normalized_messages) kept_indices: Set[int] = set(protected_indices) - # Count tokens for protected messages - current_tokens = 0 - for i in kept_indices: - current_tokens += token_counter( - model=model, text=messages[i].get("content", "") or "" + tool_exchange_spans: List[Set[int]] = [] + if _is_anthropic_call_type(call_type_str): + tool_exchange_spans, tool_sequence_error = ( + _extract_anthropic_tool_exchange_spans(original_messages) ) - - # Fill token budget from highest-scoring messages. - # For each candidate (ranked by relevance): - # - If it fits entirely → keep it as-is. - # - If it doesn't fit but there's meaningful remaining budget → truncate it - # to fill as much of the budget as possible. - # - Otherwise → stub it (pointer only, content goes to cache). - # Multiple messages may be truncated so we preserve partial content from - # several high-scoring messages rather than fully stubbing all but one. - truncated_overrides: Dict[int, dict] = {} # idx -> truncated message dict - - for idx in ranked_indices: - if idx in kept_indices: - continue - msg_content = messages[idx].get("content", "") or "" - msg_tokens = token_counter(model=model, text=msg_content) - remaining = compression_target - current_tokens - - if remaining <= 0: - break # budget exhausted - - if current_tokens + msg_tokens <= compression_target: - # Fits entirely - kept_indices.add(idx) - current_tokens += msg_tokens - elif remaining >= 100: - # Too large to fit whole, but we have budget — truncate it. - truncated = truncate_message(messages[idx], remaining) - truncated_tokens = token_counter( - model=model, - text=truncated.get("content", "") or "", + if tool_sequence_error is not None: + return CompressedResult( + messages=original_messages, + original_tokens=original_tokens, + compressed_tokens=original_tokens, + compression_ratio=0.0, + cache={}, + tools=[], + compression_skipped_reason=tool_sequence_error, ) - truncated_overrides[idx] = truncated - kept_indices.add(idx) - current_tokens += truncated_tokens + + for span in tool_exchange_spans: + # If any message in the span is protected, keep the whole span. + if any(idx in kept_indices for idx in span): + kept_indices.update(span) + + kept_indices, truncated_overrides = _select_kept_indices_for_budget( + normalized_messages=normalized_messages, + original_messages=original_messages, + combined_scores=combined_scores, + compression_target=compression_target, + model=model, + initial_kept_indices=kept_indices, + tool_exchange_spans=tool_exchange_spans, + ) # Build compressed messages and cache compressed_messages: List[dict] = [] cache: Dict[str, str] = {} used_keys: Set[str] = set() + dropped_tool_span_indices = _get_dropped_tool_span_indices( + kept_indices=kept_indices, tool_exchange_spans=tool_exchange_spans + ) - for i, msg in enumerate(messages): + for i, msg in enumerate(original_messages): + if i in dropped_tool_span_indices: + continue if i in kept_indices: # Use the truncated version if we made one, otherwise the original compressed_messages.append(truncated_overrides.get(i, msg)) else: - key = extract_key(msg, fallback_index=i, used_keys=used_keys) - content = msg.get("content", "") - if isinstance(content, list): - content = " ".join( - p.get("text", "") if isinstance(p, dict) else str(p) - for p in content - ) + key = extract_key( + normalized_messages[i], fallback_index=i, used_keys=used_keys + ) + content = _content_to_text(msg.get("content", "")) cache[key] = content compressed_messages.append(stub_message(msg, key)) - # Build retrieval tool - tools = [build_retrieval_tool(list(cache.keys()))] if cache else [] + # Build retrieval tool in the target request schema + tools = _build_retrieval_tools(list(cache.keys()), call_type=call_type_str) compressed_tokens = token_counter( model=model, - messages=cast(List[Union[AllMessageValues, Message]], compressed_messages), + messages=cast(List[Any], compressed_messages), ) return CompressedResult( diff --git a/litellm/constants.py b/litellm/constants.py index 6c89cf5946d..012599ab6ab 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -164,6 +164,7 @@ MCP_STDIO_ALLOWED_COMMANDS: frozenset = frozenset( LITELLM_UI_ALLOW_HEADERS = [ "x-litellm-semantic-filter", "x-litellm-semantic-filter-tools", + "x-litellm-adaptive-router-model", ] # Gemini model-specific minimal thinking budget constants diff --git a/litellm/images/main.py b/litellm/images/main.py index 0d3b2e97294..d95b7287d20 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -410,6 +410,7 @@ def image_generation( # noqa: PLR0915 litellm.LlmProviders.RUNWAYML, litellm.LlmProviders.VERTEX_AI, litellm.LlmProviders.OPENROUTER, + litellm.LlmProviders.DASHSCOPE, ): if image_generation_config is None: raise ValueError( diff --git a/litellm/integrations/compression_interception/__init__.py b/litellm/integrations/compression_interception/__init__.py new file mode 100644 index 00000000000..14d30af14d8 --- /dev/null +++ b/litellm/integrations/compression_interception/__init__.py @@ -0,0 +1,14 @@ +""" +Compression Interception Module + +Provides server-side prompt compression + retrieval tool fulfillment for +Anthropic Messages agentic loops. +""" + +from litellm.integrations.compression_interception.handler import ( + CompressionInterceptionLogger, +) + +__all__ = [ + "CompressionInterceptionLogger", +] diff --git a/litellm/integrations/compression_interception/handler.py b/litellm/integrations/compression_interception/handler.py new file mode 100644 index 00000000000..c6ae7d9e82b --- /dev/null +++ b/litellm/integrations/compression_interception/handler.py @@ -0,0 +1,399 @@ +""" +Compression Interception Handler + +CustomLogger that compresses inbound Anthropic Messages requests and fulfills +litellm_content_retrieve tool calls server-side via the typed agentic loop plan. +""" + +import time +import uuid +from typing import Any, Dict, List, Optional, Tuple, cast + +from litellm._logging import verbose_logger +from litellm.compression import compress +from litellm.integrations.custom_logger import CustomLogger +from litellm.types.integrations.compression_interception import ( + CompressionInterceptionConfig, +) +from litellm.types.integrations.custom_logger import ( + AgenticLoopPlan, + AgenticLoopRequestPatch, +) +from litellm.types.utils import CallTypes + +LITELLM_CONTENT_RETRIEVE_TOOL_NAME = "litellm_content_retrieve" +_CACHE_TTL_SECONDS = 15 * 60 + + +class CompressionInterceptionLogger(CustomLogger): + """ + CustomLogger that implements transparent prompt compression + retrieval loops. + + Flow: + 1. Compress inbound /v1/messages requests in pre-call hook. + 2. Inject litellm_content_retrieve tool and persist compressed cache by call_id. + 3. Detect retrieval tool_use blocks in first model response. + 4. Build typed rerun plan with tool_result blocks from the compressed cache. + """ + + def __init__( + self, + enabled: bool = True, + compression_trigger: int = 200_000, + compression_target: Optional[int] = None, + embedding_model: Optional[str] = None, + embedding_model_params: Optional[Dict[str, Any]] = None, + ): + super().__init__() + self.enabled = enabled + self.compression_trigger = compression_trigger + self.compression_target = compression_target + self.embedding_model = embedding_model + self.embedding_model_params = embedding_model_params + self._compression_cache_by_call_id: Dict[str, Tuple[Dict[str, str], float]] = {} + + @classmethod + def from_config_yaml( + cls, config: CompressionInterceptionConfig + ) -> "CompressionInterceptionLogger": + return cls( + enabled=bool(config.get("enabled", True)), + compression_trigger=int(config.get("compression_trigger", 200_000)), + compression_target=config.get("compression_target"), + embedding_model=config.get("embedding_model"), + embedding_model_params=config.get("embedding_model_params"), + ) + + @staticmethod + def initialize_from_proxy_config( + litellm_settings: Dict[str, Any], + callback_specific_params: Dict[str, Any], + ) -> "CompressionInterceptionLogger": + compression_params: CompressionInterceptionConfig = {} + if "compression_interception_params" in litellm_settings: + compression_params = litellm_settings["compression_interception_params"] + elif "compression_interception" in callback_specific_params: + compression_params = callback_specific_params["compression_interception"] + return CompressionInterceptionLogger.from_config_yaml(compression_params) + + async def async_pre_call_deployment_hook( + self, kwargs: Dict[str, Any], call_type: Optional[CallTypes] + ) -> Optional[dict]: + if not self.enabled: + return None + if call_type is not None and call_type != CallTypes.anthropic_messages: + return None + if int(kwargs.get("_agentic_loop_depth", 0) or 0) > 0: + return None + + messages = kwargs.get("messages") + model = kwargs.get("model") + if not isinstance(messages, list) or not isinstance(model, str): + return None + + if self._has_retrieval_tool(kwargs.get("tools")): + return None + + self._prune_expired_cache() + + compressed = compress( # type: ignore + messages=messages, + model=model, + call_type=CallTypes.anthropic_messages, + compression_trigger=self.compression_trigger, + compression_target=self.compression_target, + embedding_model=self.embedding_model, + embedding_model_params=self.embedding_model_params, + ) + + cache = cast(Dict[str, str], compressed.get("cache", {})) + skip_reason = cast(Optional[str], compressed.get("compression_skipped_reason")) + compressed_tools = cast(List[Dict[str, Any]], compressed.get("tools", [])) + + # Only mutate kwargs when compression actually produced a result. + # If compression was a no-op (below trigger, invalid tool sequence, etc.), + # leave ``messages`` and ``tools`` untouched — injecting an empty + # ``tools: []`` onto a request that originally had no tools breaks + # Anthropic Messages requests. + if cache: + kwargs["messages"] = compressed["messages"] + if compressed_tools: + kwargs["tools"] = self._merge_tools( + existing_tools=cast( + Optional[List[Dict[str, Any]]], kwargs.get("tools") + ), + compressed_tools=compressed_tools, + ) + call_id = cast(Optional[str], kwargs.get("litellm_call_id")) + if not call_id: + call_id = str(uuid.uuid4()) + kwargs["litellm_call_id"] = call_id + self._compression_cache_by_call_id[call_id] = (cache, time.time()) + verbose_logger.debug( + "CompressionInterception: compressed request [call_id=%s original=%d compressed=%d cached_keys=%d]", + call_id, + compressed.get("original_tokens"), + compressed.get("compressed_tokens"), + len(cache), + ) + elif skip_reason is not None: + verbose_logger.debug( + "CompressionInterception: compression skipped [reason=%s original=%d compressed=%d]", + skip_reason, + compressed.get("original_tokens"), + compressed.get("compressed_tokens"), + ) + + return kwargs + + async def async_should_run_agentic_loop( + self, + response: Any, + model: str, + messages: List[Dict], + tools: Optional[List[Dict]], + stream: bool, + custom_llm_provider: str, + kwargs: Dict, + ) -> Tuple[bool, Dict]: + if not self.enabled: + return False, {} + if not self._has_retrieval_tool(tools): + return False, {} + + tool_calls, thinking_blocks = self._extract_retrieval_tool_calls( + response=response + ) + if not tool_calls: + return False, {} + + return True, { + "tool_calls": tool_calls, + "thinking_blocks": thinking_blocks, + "tool_type": "compression_retrieval", + } + + async def async_build_agentic_loop_plan( + self, + tools: Dict, + model: str, + messages: List[Dict], + response: Any, + anthropic_messages_provider_config: Any, + anthropic_messages_optional_request_params: Dict, + logging_obj: Any, + stream: bool, + kwargs: Dict, + ) -> AgenticLoopPlan: + self._prune_expired_cache() + tool_calls = cast(List[Dict[str, Any]], tools.get("tool_calls", [])) + thinking_blocks = cast(List[Dict[str, Any]], tools.get("thinking_blocks", [])) + + call_id = self._resolve_call_id(logging_obj=logging_obj, kwargs=kwargs) + cache = self._get_cache(call_id=call_id) + retrieval_results = [ + self._resolve_retrieval_content(tc, cache) for tc in tool_calls + ] + + assistant_message = { + "role": "assistant", + "content": thinking_blocks + + [ + { + "type": "tool_use", + "id": tc.get("id"), + "name": tc.get("name", LITELLM_CONTENT_RETRIEVE_TOOL_NAME), + "input": tc.get("input", {}), + } + for tc in tool_calls + ], + } + user_message = { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": tool_calls[i].get("id"), + "content": retrieval_results[i], + } + for i in range(len(tool_calls)) + ], + } + follow_up_messages = messages + [assistant_message, user_message] + + max_tokens = cast( + Optional[int], + anthropic_messages_optional_request_params.get("max_tokens") + or kwargs.get("max_tokens"), + ) + optional_params_without_max_tokens = { + k: v + for k, v in anthropic_messages_optional_request_params.items() + if k != "max_tokens" + } + + full_model_name = model + if logging_obj is not None: + agentic_params = logging_obj.model_call_details.get( + "agentic_loop_params", {} + ) + full_model_name = cast(str, agentic_params.get("model", model)) + + request_patch = AgenticLoopRequestPatch( + model=full_model_name, + messages=follow_up_messages, + max_tokens=max_tokens, + optional_params=optional_params_without_max_tokens, + kwargs=self._prepare_followup_kwargs(kwargs=kwargs), + ) + + return AgenticLoopPlan( + run_agentic_loop=True, + request_patch=request_patch, + metadata={"tool_type": "compression_retrieval", "call_id": call_id or ""}, + ) + + def _prune_expired_cache(self) -> None: + now = time.time() + self._compression_cache_by_call_id = { + call_id: (cache, created_at) + for call_id, ( + cache, + created_at, + ) in self._compression_cache_by_call_id.items() + if now - created_at <= _CACHE_TTL_SECONDS + } + + def _get_cache(self, call_id: Optional[str]) -> Dict[str, str]: + if not call_id: + return {} + cache_entry = self._compression_cache_by_call_id.get(call_id) + if cache_entry is None: + return {} + return cache_entry[0] + + def _resolve_call_id( + self, logging_obj: Any, kwargs: Dict[str, Any] + ) -> Optional[str]: + if logging_obj is not None: + logging_call_id = getattr(logging_obj, "litellm_call_id", None) + if isinstance(logging_call_id, str) and logging_call_id: + return logging_call_id + kwargs_call_id = kwargs.get("litellm_call_id") + return cast( + Optional[str], kwargs_call_id if isinstance(kwargs_call_id, str) else None + ) + + def _resolve_retrieval_content( + self, tool_call: Dict[str, Any], cache: Dict[str, str] + ) -> str: + raw_input = tool_call.get("input", {}) + key = "" + if isinstance(raw_input, dict): + key = str(raw_input.get("key", "") or "") + if not key: + return "No retrieval key provided." + if key in cache: + return cache[key] + return f"[compressed content key '{key}' not found]" + + def _extract_retrieval_tool_calls( + self, response: Any + ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + if isinstance(response, dict): + content = response.get("content", []) + else: + content = getattr(response, "content", []) or [] + + if not isinstance(content, list): + return [], [] + + tool_calls: List[Dict[str, Any]] = [] + thinking_blocks: List[Dict[str, Any]] = [] + + for block in content: + if isinstance(block, dict): + block_type = block.get("type") + block_name = block.get("name") + if block_type in ("thinking", "redacted_thinking"): + thinking_blocks.append(block) + if ( + block_type == "tool_use" + and block_name == LITELLM_CONTENT_RETRIEVE_TOOL_NAME + ): + tool_calls.append( + { + "id": block.get("id"), + "type": "tool_use", + "name": block_name, + "input": block.get("input", {}), + } + ) + else: + block_type = getattr(block, "type", None) + block_name = getattr(block, "name", None) + if block_type == "thinking": + thinking_blocks.append( + { + "type": "thinking", + "thinking": getattr(block, "thinking", ""), + "signature": getattr(block, "signature", ""), + } + ) + elif block_type == "redacted_thinking": + thinking_blocks.append( + { + "type": "redacted_thinking", + "data": getattr(block, "data", ""), + } + ) + if ( + block_type == "tool_use" + and block_name == LITELLM_CONTENT_RETRIEVE_TOOL_NAME + ): + tool_calls.append( + { + "id": getattr(block, "id", None), + "type": "tool_use", + "name": block_name, + "input": getattr(block, "input", {}) or {}, + } + ) + + return tool_calls, thinking_blocks + + def _prepare_followup_kwargs(self, kwargs: Dict[str, Any]) -> Dict[str, Any]: + internal_keys = {"litellm_logging_obj"} + return { + k: v + for k, v in kwargs.items() + if not k.startswith("_compression_interception") and k not in internal_keys + } + + def _has_retrieval_tool(self, tools: Any) -> bool: + if not isinstance(tools, list): + return False + for tool in tools: + if not isinstance(tool, dict): + continue + function = tool.get("function") + if tool.get("type") == "function" and isinstance(function, dict): + if function.get("name") == LITELLM_CONTENT_RETRIEVE_TOOL_NAME: + return True + if ( + tool.get("type") == "custom" + and tool.get("name") == LITELLM_CONTENT_RETRIEVE_TOOL_NAME + ): + return True + return False + + def _merge_tools( + self, + existing_tools: Optional[List[Dict[str, Any]]], + compressed_tools: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + merged = list(existing_tools or []) + if self._has_retrieval_tool(merged): + return merged + merged.extend(compressed_tools) + return merged diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index abf010e0d65..b1bf3483a9c 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -2,6 +2,7 @@ from datetime import datetime from typing import ( TYPE_CHECKING, Any, + ClassVar, Dict, List, Literal, @@ -12,6 +13,7 @@ from typing import ( ) from litellm._logging import verbose_logger +from litellm.litellm_core_utils.core_helpers import redact_nested_match_and_regex_keys from litellm.caching import DualCache from litellm.integrations.custom_logger import CustomLogger from litellm.types.guardrails import ( @@ -81,6 +83,9 @@ class ModifyResponseException(Exception): class CustomGuardrail(CustomLogger): + # If True, during_call runs async_moderation_hook instead of the unified apply_guardrail path. + use_native_during_call_hook: ClassVar[bool] = False + def __init__( self, guardrail_name: Optional[str] = None, @@ -637,6 +642,13 @@ class CustomGuardrail(CustomLogger): if isinstance(item, dict): item.pop("secret_fields", None) + # Default-safe behavior: never persist raw matched spans in standard + # guardrail logging payloads (single shared implementation; Bedrock hooks pass + # raw provider JSON so redaction is not duplicated upstream). + clean_guardrail_response = redact_nested_match_and_regex_keys( + clean_guardrail_response + ) + slg = StandardLoggingGuardrailInformation( guardrail_name=self.guardrail_name, guardrail_provider=guardrail_provider, diff --git a/litellm/integrations/custom_logger.py b/litellm/integrations/custom_logger.py index 45c8e2f6262..300c311f36d 100644 --- a/litellm/integrations/custom_logger.py +++ b/litellm/integrations/custom_logger.py @@ -20,6 +20,7 @@ from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER from litellm.types.integrations.argilla import ArgillaItem from litellm.types.llms.openai import AllMessageValues, ChatCompletionRequest from litellm.types.prompts.init_prompts import PromptSpec +from litellm.types.integrations.custom_logger import AgenticLoopPlan from litellm.types.utils import ( AdapterCompletionStreamWrapper, CallTypes, @@ -239,7 +240,7 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac self, model: str, request_kwargs: Dict, - messages: Optional[List[Dict[str, str]]] = None, + messages: Optional[List[Dict[str, Any]]] = None, input: Optional[Union[str, List]] = None, specific_deployment: Optional[bool] = False, ) -> Optional[PreRoutingHookResponse]: @@ -676,6 +677,26 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac """ pass + async def async_build_agentic_loop_plan( + self, + tools: Dict, + model: str, + messages: List[Dict], + response: Any, + anthropic_messages_provider_config: Any, + anthropic_messages_optional_request_params: Dict, + logging_obj: "LiteLLMLoggingObj", + stream: bool, + kwargs: Dict, + ) -> AgenticLoopPlan: + """ + Build a typed rerun plan for Anthropic Messages agentic loops. + + Override this method to separate callback decision/tool execution from + follow-up request execution (handled by BaseLLMHTTPHandler). + """ + return AgenticLoopPlan(run_agentic_loop=False) + async def async_should_run_chat_completion_agentic_loop( self, response: Any, @@ -707,6 +728,22 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac """ pass + async def async_build_chat_completion_agentic_loop_plan( + self, + tools: Dict, + model: str, + messages: List[Dict], + response: Any, + optional_params: Dict, + logging_obj: "LiteLLMLoggingObj", + stream: bool, + kwargs: Dict, + ) -> AgenticLoopPlan: + """ + Build a typed rerun plan for chat-completions agentic loops. + """ + return AgenticLoopPlan(run_agentic_loop=False) + # Useful helpers for custom logger classes def truncate_standard_logging_payload_content( diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index ecfb42cea7b..b6d91d0b76d 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -1615,6 +1615,14 @@ class OpenTelemetry(CustomLogger): value=response_id, ) + litellm_call_id = standard_logging_payload.get("litellm_call_id") + if litellm_call_id: + self.safe_set_attribute( + span=span, + key="litellm.call_id", + value=litellm_call_id, + ) + # The model used to generate the response. if response_obj and response_obj.get("model"): self.safe_set_attribute( @@ -2281,6 +2289,10 @@ class OpenTelemetry(CustomLogger): # Remove trailing slash endpoint = endpoint.rstrip("/") + # Splunk Observability Cloud OTLP/HTTP uses /v2/trace/otlp (not /v1/traces). Do not rewrite. + if signal_type == "traces" and "/v2/trace/otlp" in endpoint: + return endpoint + # Check if endpoint already ends with the correct signal path target_path = f"/v1/{signal_type}" if endpoint.endswith(target_path): diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index 1d92a9da073..723b142dfad 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -51,6 +51,7 @@ if TYPE_CHECKING: else: AsyncIOScheduler = Any + class PrometheusLogger(CustomLogger): # Class variables or attributes @@ -991,9 +992,7 @@ class PrometheusLogger(CustomLogger): amount: float = 1.0, ) -> None: _labels = prometheus_label_factory( - supported_enum_labels=self.get_labels_for_metric( - metric_name=metric_name - ), + supported_enum_labels=self.get_labels_for_metric(metric_name=metric_name), enum_values=enum_values, label_context=label_context, ) @@ -1118,7 +1117,9 @@ class PrometheusLogger(CustomLogger): user_api_key = hash_token(user_api_key) - label_context = PrometheusLabelFactoryContext(enum_values) #amortized per request. + label_context = PrometheusLabelFactoryContext( + enum_values + ) # amortized per request. # increment total LLM requests and spend metric self._increment_top_level_request_and_spend_metrics( @@ -3490,7 +3491,9 @@ def _prometheus_labels_from_context( } if UserAPIKeyLabelNames.END_USER.value in filtered_labels: - filtered_labels[UserAPIKeyLabelNames.END_USER.value] = ctx.get_resolved_end_user() + filtered_labels[UserAPIKeyLabelNames.END_USER.value] = ( + ctx.get_resolved_end_user() + ) for sk, val in ctx._custom_by_sanitized_key.items(): if sk in supported_enum_labels: diff --git a/litellm/integrations/prometheus_helpers/__init__.py b/litellm/integrations/prometheus_helpers/__init__.py index 34f4855863e..784ab524dd5 100644 --- a/litellm/integrations/prometheus_helpers/__init__.py +++ b/litellm/integrations/prometheus_helpers/__init__.py @@ -51,8 +51,7 @@ class PrometheusLabelFactoryContext: self.enum_values = enum_values enum_dict = enum_values.model_dump() self._sanitized_enum: Dict[str, Optional[str]] = { - k: _sanitize_prometheus_label_value(v) - for k, v in enum_dict.items() + k: _sanitize_prometheus_label_value(v) for k, v in enum_dict.items() } self._custom_by_sanitized_key: Dict[str, Optional[str]] = {} if enum_values.custom_metadata_labels is not None: diff --git a/litellm/integrations/websearch_interception/handler.py b/litellm/integrations/websearch_interception/handler.py index 30fd55a3e9d..41618c72627 100644 --- a/litellm/integrations/websearch_interception/handler.py +++ b/litellm/integrations/websearch_interception/handler.py @@ -28,6 +28,10 @@ from litellm.integrations.websearch_interception.transformation import ( from litellm.types.integrations.websearch_interception import ( WebSearchInterceptionConfig, ) +from litellm.types.integrations.custom_logger import ( + AgenticLoopPlan, + AgenticLoopRequestPatch, +) from litellm.types.llms.openai import AllMessageValues from litellm.types.utils import LlmProviders from litellm.utils import ProviderConfigManager @@ -573,6 +577,35 @@ class WebSearchInterceptionLogger(CustomLogger): kwargs=kwargs, ) + async def async_build_agentic_loop_plan( + self, + tools: Dict, + model: str, + messages: List[Dict], + response: Any, + anthropic_messages_provider_config: Any, + anthropic_messages_optional_request_params: Dict, + logging_obj: Any, + stream: bool, + kwargs: Dict, + ) -> AgenticLoopPlan: + tool_calls = tools["tool_calls"] + thinking_blocks = tools.get("thinking_blocks", []) + request_patch = await self._build_anthropic_request_patch( + model=model, + messages=messages, + tool_calls=tool_calls, + thinking_blocks=thinking_blocks, + anthropic_messages_optional_request_params=anthropic_messages_optional_request_params, + logging_obj=logging_obj, + kwargs=kwargs, + ) + return AgenticLoopPlan( + run_agentic_loop=True, + request_patch=request_patch, + metadata={"tool_type": "websearch", "response_format": "anthropic"}, + ) + async def async_run_chat_completion_agentic_loop( self, tools: Dict, @@ -608,6 +641,33 @@ class WebSearchInterceptionLogger(CustomLogger): response_format=response_format, ) + async def async_build_chat_completion_agentic_loop_plan( + self, + tools: Dict, + model: str, + messages: List[Dict], + response: Any, + optional_params: Dict, + logging_obj: Any, + stream: bool, + kwargs: Dict, + ) -> AgenticLoopPlan: + tool_calls = tools["tool_calls"] + response_format = tools.get("response_format", "openai") + request_patch = await self._build_chat_completion_request_patch( + model=model, + messages=messages, + tool_calls=tool_calls, + optional_params=optional_params, + kwargs=kwargs, + response_format=response_format, + ) + return AgenticLoopPlan( + run_agentic_loop=True, + request_patch=request_patch, + metadata={"tool_type": "websearch", "response_format": response_format}, + ) + @staticmethod def _resolve_max_tokens( optional_params: Dict, @@ -672,7 +732,48 @@ class WebSearchInterceptionLogger(CustomLogger): stream: bool, kwargs: Dict, ) -> Any: - """Execute litellm.search() and make follow-up request""" + """Legacy path: execute search + build patch + run follow-up call.""" + request_patch = await self._build_anthropic_request_patch( + model=model, + messages=messages, + tool_calls=tool_calls, + thinking_blocks=thinking_blocks, + anthropic_messages_optional_request_params=anthropic_messages_optional_request_params, + logging_obj=logging_obj, + kwargs=kwargs, + ) + if request_patch.messages is None: + raise ValueError("WebSearchInterception: missing follow-up messages") + + optional_params = dict(anthropic_messages_optional_request_params) + optional_params.update(request_patch.optional_params) + max_tokens = request_patch.max_tokens + if max_tokens is None: + max_tokens = cast(Optional[int], optional_params.pop("max_tokens", None)) + else: + optional_params.pop("max_tokens", None) + if max_tokens is None: + max_tokens = cast(int, kwargs.get("max_tokens", 1024)) + + return await anthropic_messages.acreate( + max_tokens=max_tokens, + messages=request_patch.messages, + model=request_patch.model or model, + **optional_params, + **request_patch.kwargs, + ) + + async def _build_anthropic_request_patch( + self, + model: str, + messages: List[Dict], + tool_calls: List[Dict], + thinking_blocks: List[Dict], + anthropic_messages_optional_request_params: Dict, + logging_obj: Any, + kwargs: Dict, + ) -> AgenticLoopRequestPatch: + """Execute litellm.search() and build follow-up request patch.""" # Extract search queries from tool_use blocks search_tasks = [] @@ -721,20 +822,8 @@ class WebSearchInterceptionLogger(CustomLogger): thinking_blocks=thinking_blocks, ) - # Make follow-up request with search results - # Type cast: user_message is a Dict for Anthropic format (default response_format) follow_up_messages = messages + [assistant_message, cast(Dict, user_message)] - verbose_logger.debug( - "WebSearchInterception: Making follow-up request with search results" - ) - verbose_logger.debug( - f"WebSearchInterception: Follow-up messages count: {len(follow_up_messages)}" - ) - verbose_logger.debug( - f"WebSearchInterception: Last message (tool_result): {user_message}" - ) - # Correlation context for structured logging _call_id = getattr(logging_obj, "litellm_call_id", None) or kwargs.get( "litellm_call_id", "unknown" @@ -742,61 +831,41 @@ class WebSearchInterceptionLogger(CustomLogger): full_model_name = model # safe default before try block - # Use anthropic_messages.acreate for follow-up request - try: - max_tokens = self._resolve_max_tokens( - anthropic_messages_optional_request_params, kwargs - ) + max_tokens = self._resolve_max_tokens( + anthropic_messages_optional_request_params, kwargs + ) - verbose_logger.debug( - f"WebSearchInterception: Using max_tokens={max_tokens} for follow-up request" - ) + verbose_logger.debug( + f"WebSearchInterception: Using max_tokens={max_tokens} for follow-up request" + ) - # Create a copy of optional params without max_tokens (since we pass it explicitly) - optional_params_without_max_tokens = { - k: v - for k, v in anthropic_messages_optional_request_params.items() - if k != "max_tokens" - } + optional_params_without_max_tokens = { + k: v + for k, v in anthropic_messages_optional_request_params.items() + if k != "max_tokens" + } + kwargs_for_followup = self._prepare_followup_kwargs(kwargs) - kwargs_for_followup = self._prepare_followup_kwargs(kwargs) - - # Get model from logging_obj.model_call_details["agentic_loop_params"] - # This preserves the full model name with provider prefix (e.g., "bedrock/invoke/...") - if logging_obj is not None: - agentic_params = logging_obj.model_call_details.get( - "agentic_loop_params", {} - ) - full_model_name = agentic_params.get("model", model) - verbose_logger.debug( - f"WebSearchInterception: Using model name: {full_model_name}" + if logging_obj is not None: + agentic_params = logging_obj.model_call_details.get( + "agentic_loop_params", {} ) - - final_response = await anthropic_messages.acreate( - max_tokens=max_tokens, - messages=follow_up_messages, - model=full_model_name, - **optional_params_without_max_tokens, - **kwargs_for_followup, - ) - verbose_logger.debug( - f"WebSearchInterception: Follow-up request completed, response type: {type(final_response)}" - ) - verbose_logger.debug( - f"WebSearchInterception: Final response: {final_response}" - ) - return final_response - except Exception as e: - verbose_logger.exception( - "WebSearchInterception: Follow-up request failed " - "[call_id=%s model=%s messages=%d searches=%d]: %s", - _call_id, - full_model_name, - len(follow_up_messages), - len(final_search_results), - str(e), - ) - raise + full_model_name = agentic_params.get("model", model) + verbose_logger.debug( + "WebSearchInterception: Built anthropic request patch " + "[call_id=%s model=%s messages=%d searches=%d]", + _call_id, + full_model_name, + len(follow_up_messages), + len(final_search_results), + ) + return AgenticLoopRequestPatch( + model=full_model_name, + messages=follow_up_messages, + max_tokens=max_tokens, + optional_params=optional_params_without_max_tokens, + kwargs=kwargs_for_followup, + ) async def _execute_search(self, query: str) -> str: """Execute a single web search using router's search tools""" @@ -883,7 +952,36 @@ class WebSearchInterceptionLogger(CustomLogger): kwargs: Dict, response_format: str = "openai", ) -> Any: - """Execute litellm.search() and make follow-up chat completion request""" + """Legacy path: execute search + build patch + run follow-up call.""" + request_patch = await self._build_chat_completion_request_patch( + model=model, + messages=messages, + tool_calls=tool_calls, + optional_params=optional_params, + kwargs=kwargs, + response_format=response_format, + ) + if request_patch.messages is None: + raise ValueError("WebSearchInterception: missing follow-up messages") + params = dict(optional_params) + params.update(request_patch.optional_params) + return await litellm.acompletion( + model=request_patch.model or model, + messages=request_patch.messages, + **params, + **request_patch.kwargs, + ) + + async def _build_chat_completion_request_patch( # noqa: PLR0915 + self, + model: str, + messages: List[Dict], + tool_calls: List[Dict], + optional_params: Dict, + kwargs: Dict, + response_format: str = "openai", + ) -> AgenticLoopRequestPatch: + """Execute litellm.search() and build chat-completion rerun patch.""" # Extract search queries from tool_calls search_tasks = [] @@ -963,74 +1061,56 @@ class WebSearchInterceptionLogger(CustomLogger): f"WebSearchInterception: Follow-up messages count: {len(follow_up_messages)}" ) - # Use litellm.acompletion for follow-up request - try: - # Remove internal parameters that shouldn't be passed to follow-up request - internal_params = { - "_websearch_interception", - "acompletion", - "litellm_logging_obj", - "custom_llm_provider", + # Remove internal parameters that shouldn't be passed to follow-up request + internal_params = { + "_websearch_interception", + "acompletion", + "litellm_logging_obj", + "custom_llm_provider", + "model_alias_map", + "stream_response", + "custom_prompt_dict", + } + kwargs_for_followup = { + k: v + for k, v in kwargs.items() + if not k.startswith("_websearch_interception") and k not in internal_params + } + + full_model_name = model + if "custom_llm_provider" in kwargs: + custom_llm_provider = kwargs["custom_llm_provider"] + if not model.startswith(custom_llm_provider) and "/" not in model: + full_model_name = f"{custom_llm_provider}/{model}" + + verbose_logger.debug( + "WebSearchInterception: Built chat completion request patch model=%s messages=%d", + full_model_name, + len(follow_up_messages), + ) + + tools_param = optional_params.get("tools") + optional_params_clean = { + k: v + for k, v in optional_params.items() + if k + not in { + "tools", + "extra_body", "model_alias_map", "stream_response", "custom_prompt_dict", } - kwargs_for_followup = { - k: v - for k, v in kwargs.items() - if not k.startswith("_websearch_interception") - and k not in internal_params - } + } + if tools_param is not None: + optional_params_clean["tools"] = tools_param - # Get full model name from kwargs - full_model_name = model - if "custom_llm_provider" in kwargs: - custom_llm_provider = kwargs["custom_llm_provider"] - # Reconstruct full model name with provider prefix if needed - if not model.startswith(custom_llm_provider): - # Check if model already has a provider prefix - if "/" not in model: - full_model_name = f"{custom_llm_provider}/{model}" - - verbose_logger.debug( - f"WebSearchInterception: Using model name: {full_model_name}" - ) - - # Prepare tools for follow-up request (same as original) - tools_param = optional_params.get("tools") - - # Remove tools and extra_body from optional_params to avoid issues - # extra_body often contains internal LiteLLM params that shouldn't be forwarded - optional_params_clean = { - k: v - for k, v in optional_params.items() - if k - not in { - "tools", - "extra_body", - "model_alias_map", - "stream_response", - "custom_prompt_dict", - } - } - - final_response = await litellm.acompletion( - model=full_model_name, - messages=follow_up_messages, - tools=tools_param, - **optional_params_clean, - **kwargs_for_followup, - ) - - verbose_logger.debug( - f"WebSearchInterception: Follow-up request completed, response type: {type(final_response)}" - ) - return final_response - except Exception as e: - verbose_logger.exception( - f"WebSearchInterception: Follow-up request failed: {str(e)}" - ) - raise + return AgenticLoopRequestPatch( + model=full_model_name, + messages=follow_up_messages, + optional_params=optional_params_clean, + kwargs=kwargs_for_followup, + ) async def _create_empty_search_result(self) -> str: """Create an empty search result for tool calls without queries""" diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index 22006be21af..b7a8b6f9ad7 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -1,5 +1,6 @@ # What is this? ## Helper utilities +import copy from typing import TYPE_CHECKING, Any, Iterable, List, Literal, Optional, Union import httpx @@ -435,3 +436,42 @@ def filter_internal_params( # Filter out internal parameters return {k: v for k, v in data.items() if k not in internal_params} + + +def redact_nested_match_and_regex_keys( + payload: Union[dict, List[Any], str, None], +) -> Union[dict, List[Any], str, None]: + """ + Deep-copy `payload` and replace every `match` / `regex` string field with + "[REDACTED]" anywhere in nested dict/list structures. + + Used for guardrail spend/compliance logging so raw spans are not persisted. + """ + if payload is None or isinstance(payload, str): + return payload + try: + redacted: Union[dict, List[Any], str, None] = copy.deepcopy(payload) + except Exception: + return payload + + # Iterative traversal; `seen` guards against cyclic refs preserved by deepcopy. + try: + seen: set = set() + stack: List[Any] = [redacted] + while stack: + node = stack.pop() + node_id = id(node) + if node_id in seen: + continue + seen.add(node_id) + if isinstance(node, dict): + if "match" in node: + node["match"] = "[REDACTED]" + if "regex" in node: + node["regex"] = "[REDACTED]" + stack.extend(node.values()) + elif isinstance(node, list): + stack.extend(node) + except Exception: + return payload + return redacted diff --git a/litellm/litellm_core_utils/get_supported_openai_params.py b/litellm/litellm_core_utils/get_supported_openai_params.py index b72d7abeae0..9d8bd7523db 100644 --- a/litellm/litellm_core_utils/get_supported_openai_params.py +++ b/litellm/litellm_core_utils/get_supported_openai_params.py @@ -296,6 +296,15 @@ def get_supported_openai_params( # noqa: PLR0915 return OVHCloudAudioTranscriptionConfig().get_supported_openai_params( model=model ) + elif custom_llm_provider == "scaleway": + if request_type == "transcription": + from litellm.llms.scaleway.audio_transcription.transformation import ( + ScalewayAudioTranscriptionConfig, + ) + + return ScalewayAudioTranscriptionConfig().get_supported_openai_params( + model=model + ) elif custom_llm_provider == "elevenlabs": if request_type == "transcription": from litellm.llms.elevenlabs.audio_transcription.transformation import ( diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index fd14f55add3..625cb83724b 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -5512,6 +5512,8 @@ def get_standard_logging_object_payload( payload: StandardLoggingPayload = StandardLoggingPayload( id=str(id), + litellm_call_id=kwargs.get("litellm_call_id") + or litellm_params.get("litellm_call_id"), trace_id=StandardLoggingPayloadSetup._get_standard_logging_payload_trace_id( logging_obj=logging_obj, litellm_params=litellm_params, diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index 3fd913958da..888999504fe 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -684,7 +684,7 @@ def generic_cost_per_token( # noqa: PLR0915 - cache_creation - image_tokens ) - # Clamp to zero: inconsistent streaming usage + # Clamp to zero: inconsistent streaming usage if text_tokens < 0: text_tokens = 0 prompt_tokens_details["text_tokens"] = text_tokens diff --git a/litellm/litellm_core_utils/logging_worker.py b/litellm/litellm_core_utils/logging_worker.py index 7f00c47c1ff..3db3700ee07 100644 --- a/litellm/litellm_core_utils/logging_worker.py +++ b/litellm/litellm_core_utils/logging_worker.py @@ -370,11 +370,17 @@ class LoggingWorker: self._running_tasks.clear() async def flush(self) -> None: - """Flush the logging queue.""" + """Flush the logging queue. + + Waits until every enqueued task has completed. ``queue.join()`` blocks + on the queue's unfinished-task counter (decremented by ``task_done()``), + so it correctly handles items that have been dequeued but whose + callback hasn't finished yet — ``queue.empty()`` would return True in + that window and cause us to skip the wait. + """ if self._queue is None: return - while not self._queue.empty(): - await self._queue.join() + await self._queue.join() async def clear_queue(self): """ diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 46e60c24d39..b234e6c8f77 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -452,7 +452,14 @@ def update_messages_with_model_file_ids( for c in content: if c["type"] == "file": file_object = cast(ChatCompletionFileObject, c) - file_object_file_field = file_object["file"] + file_object_file_field = file_object.get("file") + if not isinstance(file_object_file_field, dict): + # Content block has `type: "file"` but not the + # OpenAI Chat Completions shape (e.g. a LangChain + # v1 standardized file block, or a provider-native + # shape that also uses `type: "file"`). Nothing to + # remap here, so skip instead of crashing. + continue file_id = file_object_file_field.get("file_id") format = file_object_file_field.get( "format", get_format_from_file_id(file_id) @@ -1060,7 +1067,12 @@ def get_file_ids_from_messages(messages: List[AllMessageValues]) -> List[str]: for c in content: if c["type"] == "file": file_object = cast(ChatCompletionFileObject, c) - file_object_file_field = file_object["file"] + file_object_file_field = file_object.get("file") + if not isinstance(file_object_file_field, dict): + # Content block has `type: "file"` but not the + # OpenAI Chat Completions shape. No file_id to + # extract, so skip instead of raising KeyError. + continue file_id = file_object_file_field.get("file_id") if file_id: file_ids.append(file_id) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index bf950357bac..5a19c224aa4 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -15,6 +15,7 @@ import litellm.types import litellm.types.llms from litellm import verbose_logger from litellm._uuid import uuid +from litellm.litellm_core_utils.url_utils import async_safe_get, safe_get from litellm.llms.custom_httpx.http_handler import HTTPHandler, get_async_httpx_client from litellm.types.files import get_file_extension_from_mime_type from litellm.types.llms.anthropic import * @@ -3324,7 +3325,7 @@ def _load_image_from_url(image_url): try: # Send a GET request to the image URL client = HTTPHandler(concurrent_limit=1) - response = client.get(image_url) + response = safe_get(client, image_url) response.raise_for_status() # Raise an exception for HTTP errors # Check the response's content type to ensure it is an image @@ -3562,7 +3563,7 @@ class BedrockImageProcessor: params={"concurrent_limit": 1}, ) # Send a GET request to the image URL - response = await client.get(image_url, follow_redirects=True) + response = await async_safe_get(client, image_url) response.raise_for_status() # Raise an exception for HTTP errors return BedrockImageProcessor._post_call_image_processing( @@ -3577,7 +3578,7 @@ class BedrockImageProcessor: try: client = HTTPHandler(concurrent_limit=1) # Send a GET request to the image URL - response = client.get(image_url, follow_redirects=True) + response = safe_get(client, image_url) response.raise_for_status() # Raise an exception for HTTP errors return BedrockImageProcessor._post_call_image_processing( diff --git a/litellm/litellm_core_utils/token_counter.py b/litellm/litellm_core_utils/token_counter.py index 01e5dc39a34..e6a68de07e9 100644 --- a/litellm/litellm_core_utils/token_counter.py +++ b/litellm/litellm_core_utils/token_counter.py @@ -23,6 +23,7 @@ from litellm.constants import ( DEFAULT_IMAGE_HEIGHT, DEFAULT_IMAGE_TOKEN_COUNT, DEFAULT_IMAGE_WIDTH, + MAX_IMAGE_URL_DOWNLOAD_SIZE_MB, MAX_LONG_SIDE_FOR_IMAGE_HIGH_RES, MAX_SHORT_SIDE_FOR_IMAGE_HIGH_RES, MAX_TILE_HEIGHT, @@ -215,7 +216,14 @@ def get_image_dimensions( try: client = _get_httpx_client() response = safe_get(client, data) - img_data = response.read() + max_bytes = int(MAX_IMAGE_URL_DOWNLOAD_SIZE_MB * 1024 * 1024) + content_length = response.headers.get("Content-Length") + if content_length is not None and int(content_length) > max_bytes: + pass # skip download; img_data stays None + else: + body = response.read() + if len(body) <= max_bytes: + img_data = body except Exception: pass if img_data is None: diff --git a/litellm/llms/anthropic/chat/guardrail_translation/handler.py b/litellm/llms/anthropic/chat/guardrail_translation/handler.py index cb430b06940..2bb82f227bb 100644 --- a/litellm/llms/anthropic/chat/guardrail_translation/handler.py +++ b/litellm/llms/anthropic/chat/guardrail_translation/handler.py @@ -34,6 +34,7 @@ from litellm.types.llms.anthropic import ( ) from litellm.types.llms.openai import ( AllMessageValues, + ChatCompletionRequest, ChatCompletionToolCallChunk, ChatCompletionToolParam, ) @@ -67,6 +68,32 @@ class AnthropicMessagesHandler(BaseTranslation): super().__init__() self.adapter = LiteLLMAnthropicMessagesAdapter() + def _translate_to_openai(self, data: dict) -> ChatCompletionRequest: + """Translate Anthropic request to OpenAI chat completion format.""" + ( + chat_completion_compatible_request, + _tool_name_mapping, + ) = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai( + anthropic_message_request=cast(AnthropicMessagesRequest, data.copy()) + ) + return chat_completion_compatible_request + + def get_structured_messages(self, data: dict) -> Optional[List[AllMessageValues]]: + """ + Convert Anthropic messages request data to OpenAI-spec structured messages. + + Uses the Anthropic-to-OpenAI adapter to translate message format. + """ + messages = data.get("messages") + if messages is None: + return None + chat_completion_compatible_request = self._translate_to_openai(data) + result = cast( + List[AllMessageValues], + chat_completion_compatible_request.get("messages", []), + ) + return result if result else None + async def process_input_messages( self, data: dict, @@ -82,13 +109,7 @@ class AnthropicMessagesHandler(BaseTranslation): skip_system = effective_skip_system_message_for_guardrail(guardrail_to_apply) - ( - chat_completion_compatible_request, - _tool_name_mapping, - ) = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai( - # Use a shallow copy to avoid mutating request data (pop on litellm_metadata). - anthropic_message_request=cast(AnthropicMessagesRequest, data.copy()) - ) + chat_completion_compatible_request = self._translate_to_openai(data) structured_messages = cast( List[AllMessageValues], @@ -103,8 +124,6 @@ class AnthropicMessagesHandler(BaseTranslation): chat_completion_compatible_request.get("tools", []) ) task_mappings: List[Tuple[int, Optional[int]]] = [] - # Track (message_index, content_index) for each text - # content_index is None for string content, int for list content # Step 1: Extract all text content and images for msg_idx, message in enumerate(messages): diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py index 897ca3bf893..d16f5afb45c 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py @@ -106,6 +106,44 @@ class LiteLLMMessagesToCompletionTransformationHandler: updated_reasoning_effort["summary"] = effective_summary completion_kwargs["reasoning_effort"] = updated_reasoning_effort + @staticmethod + def _normalize_reasoning_effort( + completion_kwargs: Dict[str, Any], + ) -> None: + """ + Normalize reasoning_effort values based on target model capabilities. + + Handles both string ("max") and dict ({"effort": "max", "summary": ...}) + formats. Uses model registry to check supports_xhigh/supports_minimal. + """ + from litellm.llms.anthropic.experimental_pass_through.utils import ( + normalize_reasoning_effort_value, + ) + + reasoning_effort = completion_kwargs.get("reasoning_effort") + if reasoning_effort is None: + return + + model = cast(str, completion_kwargs.get("model", "")) + custom_llm_provider = completion_kwargs.get("custom_llm_provider") + + if isinstance(reasoning_effort, str): + normalized = normalize_reasoning_effort_value( + reasoning_effort, model=model, custom_llm_provider=custom_llm_provider + ) + if normalized != reasoning_effort: + completion_kwargs["reasoning_effort"] = normalized + elif isinstance(reasoning_effort, dict) and "effort" in reasoning_effort: + effort = reasoning_effort["effort"] + normalized = normalize_reasoning_effort_value( + effort, model=model, custom_llm_provider=custom_llm_provider + ) + if normalized != effort: + completion_kwargs["reasoning_effort"] = { + **reasoning_effort, + "effort": normalized, + } + @staticmethod def _prepare_completion_kwargs( *, @@ -163,6 +201,12 @@ class LiteLLMMessagesToCompletionTransformationHandler: if output_format: request_data["output_format"] = output_format + # Extract output_config from extra_kwargs so the translator can use it + # (e.g. output_config.effort for adaptive thinking → reasoning_effort) + extra_kwargs = extra_kwargs or {} + if "output_config" in extra_kwargs: + request_data["output_config"] = extra_kwargs["output_config"] + ( openai_request, tool_name_mapping, @@ -202,6 +246,14 @@ class LiteLLMMessagesToCompletionTransformationHandler: ): completion_kwargs[key] = value + # Normalize reasoning_effort based on model capabilities + # (e.g. "max" → "xhigh"/"high", "minimal" → "low" if unsupported) + # Must run BEFORE _route_openai_thinking, which prepends "responses/" + # to the model name and would break get_model_info() lookups. + LiteLLMMessagesToCompletionTransformationHandler._normalize_reasoning_effort( + completion_kwargs + ) + LiteLLMMessagesToCompletionTransformationHandler._route_openai_thinking_to_responses_api_if_needed( completion_kwargs, thinking=thinking, diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 072ae7c3bbe..20fa4f125de 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -317,6 +317,7 @@ class LiteLLMAnthropicMessagesAdapter: "tools", "thinking", "output_format", + "output_config", ] def _is_web_search_tool(self, tool: Dict[str, Any]) -> bool: @@ -694,6 +695,11 @@ class LiteLLMAnthropicMessagesAdapter: return "low" else: return "minimal" + elif thinking_type == "adaptive": + # Adaptive thinking: effort is controlled by output_config.effort, + # not budget_tokens. Return a default; caller should override with + # output_config.effort when available. + return "medium" return None @@ -776,6 +782,8 @@ class LiteLLMAnthropicMessagesAdapter: return ChatCompletionToolChoiceObjectParam( type="function", function=tc_function_param ) + elif tool_choice["type"] == "none": + return "none" else: raise ValueError( "Incompatible tool choice param submitted - {}".format(tool_choice) @@ -1041,6 +1049,12 @@ class LiteLLMAnthropicMessagesAdapter: if not reasoning_effort: return + # For adaptive thinking, override with output_config.effort if available + if isinstance(thinking, dict) and thinking.get("type") == "adaptive": + output_config = anthropic_message_request.get("output_config") + if isinstance(output_config, dict) and output_config.get("effort"): + reasoning_effort = output_config["effort"] + summary = thinking.get("summary") if isinstance(thinking, dict) else None auto_summary = is_reasoning_auto_summary_enabled() if summary: diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/agentic_streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/messages/agentic_streaming_iterator.py new file mode 100644 index 00000000000..d0780c82d06 --- /dev/null +++ b/litellm/llms/anthropic/experimental_pass_through/messages/agentic_streaming_iterator.py @@ -0,0 +1,322 @@ +""" +Agentic Streaming Iterator for Anthropic Messages + +Wraps the raw SSE byte stream from the Anthropic pass-through endpoint, +yields every chunk to the caller (preserving real streaming), collects +all bytes, and on stream exhaustion rebuilds the full Anthropic response +to run through agentic completion hooks. If an agentic hook fires, the +follow-up response is chained as Phase 2 of the same iterator. +""" + +import json +from typing import Any, AsyncIterator, Dict, List, Optional, cast + +from litellm._logging import verbose_logger + + +# --------------------------------------------------------------------------- +# SSE parsing helpers (module-level to keep the class lean) +# --------------------------------------------------------------------------- + + +def _parse_sse_events(raw: bytes) -> List[tuple]: + """Return a list of (event_type, parsed_data_dict) from raw SSE bytes.""" + text = raw.decode("utf-8", errors="replace") + lines = text.split("\n") + events: List[tuple] = [] + current_event_type: Optional[str] = None + + for line in lines: + stripped = line.strip() + if stripped.startswith("event:"): + current_event_type = stripped[len("event:") :].strip() + continue + if not stripped.startswith("data:"): + continue + data_str = stripped[len("data:") :].strip() + try: + data = json.loads(data_str) + except (json.JSONDecodeError, ValueError): + continue + event_type = current_event_type or data.get("type", "") + current_event_type = None + events.append((event_type, data)) + return events + + +def _handle_message_start(data: Dict, response: Dict) -> None: + msg = data.get("message", {}) + response["id"] = msg.get("id", response["id"]) + response["model"] = msg.get("model", response["model"]) + response["role"] = msg.get("role", response["role"]) + usage = msg.get("usage", {}) + if usage: + response["usage"]["input_tokens"] = usage.get("input_tokens", 0) + for key in ("cache_creation_input_tokens", "cache_read_input_tokens"): + if key in usage: + response["usage"][key] = usage[key] + + +def _handle_content_block_start(data: Dict, content_blocks: Dict[int, Dict]) -> None: + idx = data.get("index", len(content_blocks)) + block = data.get("content_block", {}) + block_type = block.get("type", "text") + + _BLOCK_TEMPLATES: Dict[str, Dict] = { + "text": {"type": "text", "text": ""}, + "thinking": {"type": "thinking", "thinking": "", "signature": ""}, + "redacted_thinking": { + "type": "redacted_thinking", + "data": block.get("data", ""), + }, + } + if block_type == "tool_use": + content_blocks[idx] = { + "type": "tool_use", + "id": block.get("id", ""), + "name": block.get("name", ""), + "input": {}, + "_partial_json": "", + } + elif block_type in _BLOCK_TEMPLATES: + content_blocks[idx] = dict(_BLOCK_TEMPLATES[block_type]) + else: + content_blocks[idx] = dict(block) + + +def _handle_content_block_delta(data: Dict, content_blocks: Dict[int, Dict]) -> None: + idx = data.get("index", 0) + delta = data.get("delta", {}) + delta_type = delta.get("type", "") + block = content_blocks.get(idx) + if block is None: + return + + if delta_type == "text_delta": + block["text"] = block.get("text", "") + delta.get("text", "") + elif delta_type == "input_json_delta": + block["_partial_json"] = block.get("_partial_json", "") + delta.get( + "partial_json", "" + ) + elif delta_type == "thinking_delta": + block["thinking"] = block.get("thinking", "") + delta.get("thinking", "") + elif delta_type == "signature_delta": + block["signature"] = delta.get("signature", block.get("signature", "")) + + +def _handle_content_block_stop(data: Dict, content_blocks: Dict[int, Dict]) -> None: + idx = data.get("index", 0) + block = content_blocks.get(idx) + if block and block.get("type") == "tool_use": + partial = block.pop("_partial_json", "") + if partial: + try: + block["input"] = json.loads(partial) + except (json.JSONDecodeError, ValueError): + block["input"] = {"_raw": partial} + + +def _handle_message_delta(data: Dict, response: Dict) -> None: + delta = data.get("delta", {}) + if "stop_reason" in delta: + response["stop_reason"] = delta["stop_reason"] + if "stop_sequence" in delta: + response["stop_sequence"] = delta["stop_sequence"] + usage = data.get("usage", {}) + if usage.get("output_tokens") is not None: + response["usage"]["output_tokens"] = usage["output_tokens"] + for key in ( + "input_tokens", + "cache_creation_input_tokens", + "cache_read_input_tokens", + ): + if key in usage: + response["usage"][key] = usage[key] + + +class AgenticAnthropicStreamingIterator: + """ + Two-phase async iterator that enables agentic hooks on streaming + Anthropic Messages pass-through responses. + + Phase 1: Yield raw SSE bytes from the upstream response while + accumulating them. When the inner iterator is exhausted, + rebuild the full Anthropic response dict and call agentic hooks. + + Phase 2: If an agentic hook fires and returns a follow-up response + (streaming or non-streaming), yield those bytes to the caller. + """ + + def __init__( + self, + completion_stream: AsyncIterator, + http_handler: Any, + model: str, + messages: List[Dict], + anthropic_messages_provider_config: Any, + anthropic_messages_optional_request_params: Dict, + logging_obj: Any, + custom_llm_provider: str, + kwargs: Dict, + ): + self._inner = completion_stream.__aiter__() + self._http_handler = http_handler + self._model = model + self._messages = messages + self._anthropic_messages_provider_config = anthropic_messages_provider_config + self._anthropic_messages_optional_request_params = ( + anthropic_messages_optional_request_params + ) + self._logging_obj = logging_obj + self._custom_llm_provider = custom_llm_provider + self._kwargs = kwargs + + self._collected_bytes: List[bytes] = [] + self._stream_exhausted = False + self._hook_processing_done = False + self._follow_up_iterator: Optional[AsyncIterator] = None + + def __aiter__(self): + return self + + async def __anext__(self) -> bytes: + # Phase 1: yield from upstream, collect bytes + if not self._stream_exhausted: + try: + chunk = await self._inner.__anext__() + self._collected_bytes.append(chunk) + return chunk + except StopAsyncIteration: + self._stream_exhausted = True + await self._process_agentic_hooks() + # Fall through to Phase 2 + + # Phase 2: yield from follow-up stream if one was created + if self._follow_up_iterator is not None: + chunk = await self._follow_up_iterator.__anext__() + return chunk + + raise StopAsyncIteration + + async def _process_agentic_hooks(self) -> None: + """Rebuild the Anthropic response from collected SSE bytes and call hooks.""" + if self._hook_processing_done: + return + self._hook_processing_done = True + + if not self._collected_bytes: + return + + try: + rebuilt = self._rebuild_anthropic_response_from_sse(self._collected_bytes) + if rebuilt is None: + verbose_logger.debug( + "AgenticStreamingIterator: Could not rebuild response from SSE bytes" + ) + return + + [ + ( + f"{b.get('type')}({b.get('name', '')})" + if b.get("type") == "tool_use" + else b.get("type") + ) + for b in rebuilt.get("content", []) + ] + + result = await self._http_handler._call_agentic_completion_hooks( + response=rebuilt, + model=self._model, + messages=self._messages, + anthropic_messages_provider_config=self._anthropic_messages_provider_config, + anthropic_messages_optional_request_params=self._anthropic_messages_optional_request_params, + logging_obj=self._logging_obj, + stream=True, + custom_llm_provider=self._custom_llm_provider, + kwargs=self._kwargs, + ) + + if result is None: + return + + if hasattr(result, "__aiter__"): + self._follow_up_iterator = result.__aiter__() + elif isinstance(result, dict): + from litellm.llms.anthropic.experimental_pass_through.messages.fake_stream_iterator import ( + FakeAnthropicMessagesStreamIterator, + ) + from litellm.types.llms.anthropic_messages.anthropic_response import ( + AnthropicMessagesResponse, + ) + + fake = FakeAnthropicMessagesStreamIterator( + response=cast(AnthropicMessagesResponse, result) + ) + self._follow_up_iterator = fake.__aiter__() + else: + verbose_logger.warning( + "AgenticStreamingIterator: Unexpected result type from hooks: %s", + type(result).__name__, + ) + except Exception as e: + _call_id = getattr(self._logging_obj, "litellm_call_id", "unknown") + verbose_logger.exception( + "AgenticStreamingIterator: Error in agentic hook processing " + "[call_id=%s model=%s]: %s", + _call_id, + self._model, + str(e), + ) + + @staticmethod + def _rebuild_anthropic_response_from_sse( + raw_bytes: List[bytes], + ) -> Optional[Dict[str, Any]]: + """ + Parse collected SSE bytes into an Anthropic Messages response dict. + + Processes SSE events in order: + - message_start -> envelope (id, model, role, usage) + - content_block_start -> new content block + - content_block_delta -> accumulate text/json/thinking deltas + - content_block_stop -> finalize block + - message_delta -> stop_reason, output usage + - message_stop -> end + """ + events = _parse_sse_events(b"".join(raw_bytes)) + + response: Dict[str, Any] = { + "id": "", + "type": "message", + "role": "assistant", + "model": "", + "content": [], + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 0, "output_tokens": 0}, + } + content_blocks: Dict[int, Dict[str, Any]] = {} + saw_message_start = False + + for event_type, data in events: + if event_type == "message_start": + saw_message_start = True + _handle_message_start(data, response) + elif event_type == "content_block_start": + _handle_content_block_start(data, content_blocks) + elif event_type == "content_block_delta": + _handle_content_block_delta(data, content_blocks) + elif event_type == "content_block_stop": + _handle_content_block_stop(data, content_blocks) + elif event_type == "message_delta": + _handle_message_delta(data, response) + + if not saw_message_start: + return None + + for idx in sorted(content_blocks.keys()): + block = content_blocks[idx] + block.pop("_partial_json", None) + response["content"].append(block) + + return response diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py index c400d82b7cf..0c59e812e0b 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py @@ -24,6 +24,8 @@ from litellm.types.llms.anthropic_messages.anthropic_response import ( from litellm.types.router import GenericLiteLLMParams from litellm.utils import ProviderConfigManager, client +from ..utils import is_reasoning_auto_summary_enabled + from ..adapters.handler import LiteLLMMessagesToCompletionTransformationHandler from ..responses_adapters.handler import LiteLLMMessagesToResponsesAPIHandler from .interceptors import get_messages_interceptors @@ -441,6 +443,17 @@ def anthropic_messages_handler( params=local_vars ) ) + if is_reasoning_auto_summary_enabled(): + thinking_param = anthropic_messages_optional_request_params.get("thinking") + if ( + isinstance(thinking_param, dict) + and thinking_param.get("type") != "disabled" + ): + anthropic_messages_optional_request_params["thinking"] = { + **thinking_param, + "display": "summarized", + } + return base_llm_http_handler.anthropic_messages_handler( model=model, messages=messages, diff --git a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/handler.py b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/handler.py index 198ebe1ff8c..5be16dcbf16 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/handler.py @@ -72,6 +72,23 @@ def _build_responses_kwargs( anthropic_request = AnthropicMessagesRequest(**request_data) # type: ignore[typeddict-item] responses_kwargs = _ADAPTER.translate_request(anthropic_request) + # Normalize reasoning effort based on model capabilities + # (e.g. "max" → "xhigh"/"high", "minimal" → "low" if unsupported) + reasoning = responses_kwargs.get("reasoning") + if isinstance(reasoning, dict) and "effort" in reasoning: + from litellm.llms.anthropic.experimental_pass_through.utils import ( + normalize_reasoning_effort_value, + ) + + effort = reasoning["effort"] + normalized = normalize_reasoning_effort_value( + effort, + model=model, + custom_llm_provider=(extra_kwargs or {}).get("custom_llm_provider"), + ) + if normalized != effort: + responses_kwargs["reasoning"] = {**reasoning, "effort": normalized} + if stream: responses_kwargs["stream"] = True diff --git a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py index 913470e7088..2badc2a3276 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py @@ -251,25 +251,41 @@ class LiteLLMAnthropicToResponsesAPIAdapter: @staticmethod def translate_thinking_to_reasoning( - thinking: Dict[str, Any] + thinking: Dict[str, Any], + output_config: Optional[Dict[str, Any]] = None, ) -> Optional[Dict[str, Any]]: """ Convert Anthropic thinking param to Responses API reasoning param. thinking.budget_tokens maps to reasoning effort: >= 10000 -> high, >= 5000 -> medium, >= 2000 -> low, < 2000 -> minimal + + For adaptive thinking, uses output_config.effort if available, + otherwise defaults to medium. """ - if not isinstance(thinking, dict) or thinking.get("type") != "enabled": + if not isinstance(thinking, dict): return None - budget = thinking.get("budget_tokens", 0) - if budget >= 10000: - effort = "high" - elif budget >= 5000: + + thinking_type = thinking.get("type") + + if thinking_type == "adaptive": + # Use output_config.effort if available effort = "medium" - elif budget >= 2000: - effort = "low" + if isinstance(output_config, dict) and output_config.get("effort"): + effort = output_config["effort"] + elif thinking_type == "enabled": + budget = thinking.get("budget_tokens", 0) + if budget >= 10000: + effort = "high" + elif budget >= 5000: + effort = "medium" + elif budget >= 2000: + effort = "low" + else: + effort = "minimal" else: - effort = "minimal" + return None + auto_summary = is_reasoning_auto_summary_enabled() result: Dict[str, Any] = {"effort": effort} summary = thinking.get("summary") @@ -346,7 +362,11 @@ class LiteLLMAnthropicToResponsesAPIAdapter: # thinking -> reasoning thinking = anthropic_request.get("thinking") if isinstance(thinking, dict): - reasoning = self.translate_thinking_to_reasoning(thinking) + output_config = anthropic_request.get("output_config") + reasoning = self.translate_thinking_to_reasoning( + thinking, + output_config=cast(Optional[Dict[str, Any]], output_config), + ) if reasoning: responses_kwargs["reasoning"] = reasoning diff --git a/litellm/llms/anthropic/experimental_pass_through/utils.py b/litellm/llms/anthropic/experimental_pass_through/utils.py index 6c1db6017b2..4fd68ef535f 100644 --- a/litellm/llms/anthropic/experimental_pass_through/utils.py +++ b/litellm/llms/anthropic/experimental_pass_through/utils.py @@ -1,6 +1,8 @@ import os +from typing import Optional import litellm +from litellm.types.utils import ModelInfo def is_reasoning_auto_summary_enabled() -> bool: @@ -9,3 +11,47 @@ def is_reasoning_auto_summary_enabled() -> bool: litellm.reasoning_auto_summary or os.getenv("LITELLM_REASONING_AUTO_SUMMARY", "false").lower() == "true" ) + + +def normalize_reasoning_effort_value( + effort: str, + model: str, + custom_llm_provider: Optional[str] = None, +) -> str: + """ + Normalize a reasoning effort value based on model capabilities. + + Degradation chains: + - "max" → max / xhigh / high + - "xhigh" → xhigh / high + - "minimal" → minimal / low + - other values pass through unchanged + """ + if effort not in ("max", "xhigh", "minimal"): + return effort + + from litellm.utils import get_model_info + + model_info: Optional[ModelInfo] = None + try: + model_info = get_model_info( + model=model, custom_llm_provider=custom_llm_provider + ) + except Exception: + model_info = None + + if effort == "max": + if model_info and model_info.get("supports_max_reasoning_effort"): + return "max" + if model_info and model_info.get("supports_xhigh_reasoning_effort"): + return "xhigh" + return "high" + elif effort == "xhigh": + if model_info and model_info.get("supports_xhigh_reasoning_effort"): + return "xhigh" + return "high" + elif effort == "minimal": + if model_info and model_info.get("supports_minimal_reasoning_effort"): + return "minimal" + return "low" + return "medium" diff --git a/litellm/llms/azure/chat/gpt_5_transformation.py b/litellm/llms/azure/chat/gpt_5_transformation.py index bc7483bf64d..e94f50380c0 100644 --- a/litellm/llms/azure/chat/gpt_5_transformation.py +++ b/litellm/llms/azure/chat/gpt_5_transformation.py @@ -40,9 +40,22 @@ class AzureOpenAIGPT5Config(AzureOpenAIConfig, OpenAIGPT5Config): Accepts both explicit gpt-5 model names and the ``gpt5_series/`` prefix used for manual routing. """ - # gpt-5-chat* is a chat model and shouldn't go through GPT-5 reasoning restrictions. + # The gpt-5-chat* family (gpt-5-chat, gpt-5-chat-latest, gpt-5-chat-2025-08-07, + # …) are regular chat models: they support temperature and tool_choice but NOT + # reasoning_effort. They must NOT be routed through the GPT-5 reasoning path. + # + # Versioned chat models such as gpt-5.3-chat and gpt-5.1-chat ARE reasoning + # models and must stay on the GPT-5 path. The distinguishing feature is that + # the gpt-5-chat family has a literal "-chat" immediately after "gpt-5" + # (i.e. "gpt-5-chat…"), while versioned chat models interpose a minor version + # number (i.e. "gpt-5.-chat"). + # + # Using a startswith("gpt-5-chat") prefix check on the normalized name (rather + # than a substring check) makes this boundary explicit and avoids any ambiguity + # if future model names coincidentally contain "gpt-5-chat" as an interior run. + _normalized = model.split("/")[-1] # strip provider prefix, e.g. "azure/" return ( - "gpt-5" in model and "gpt-5-chat" not in model + "gpt-5" in model and not _normalized.startswith("gpt-5-chat") ) or "gpt5_series" in model def get_supported_openai_params(self, model: str) -> List[str]: diff --git a/litellm/llms/azure/image_edit/transformation.py b/litellm/llms/azure/image_edit/transformation.py index f476d6a94ee..dffa1c9eea5 100644 --- a/litellm/llms/azure/image_edit/transformation.py +++ b/litellm/llms/azure/image_edit/transformation.py @@ -14,6 +14,8 @@ class AzureImageEditConfig(OpenAIImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: api_key = ( api_key diff --git a/litellm/llms/azure_ai/image_edit/flux2_transformation.py b/litellm/llms/azure_ai/image_edit/flux2_transformation.py index 0de163a7714..1bc3bdcddc1 100644 --- a/litellm/llms/azure_ai/image_edit/flux2_transformation.py +++ b/litellm/llms/azure_ai/image_edit/flux2_transformation.py @@ -65,6 +65,8 @@ class AzureFoundryFlux2ImageEditConfig(OpenAIImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: """ Validate Azure AI Foundry environment and set up authentication diff --git a/litellm/llms/azure_ai/image_edit/transformation.py b/litellm/llms/azure_ai/image_edit/transformation.py index 930b6d4db90..e778348c75b 100644 --- a/litellm/llms/azure_ai/image_edit/transformation.py +++ b/litellm/llms/azure_ai/image_edit/transformation.py @@ -25,6 +25,8 @@ class AzureFoundryFluxImageEditConfig(OpenAIImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: """ Validate Azure AI Foundry environment and set up authentication diff --git a/litellm/llms/base_llm/guardrail_translation/base_translation.py b/litellm/llms/base_llm/guardrail_translation/base_translation.py index e1da0dfa29e..1efeb159a3e 100644 --- a/litellm/llms/base_llm/guardrail_translation/base_translation.py +++ b/litellm/llms/base_llm/guardrail_translation/base_translation.py @@ -5,6 +5,7 @@ if TYPE_CHECKING: from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.proxy._types import UserAPIKeyAuth + from litellm.types.llms.openai import AllMessageValues class BaseTranslation(ABC): @@ -101,6 +102,16 @@ class BaseTranslation(ABC): """ return responses_so_far + def get_structured_messages(self, data: dict) -> Optional[List["AllMessageValues"]]: + """ + Convert request data to OpenAI-spec structured messages. + + Override in subclasses for format-specific conversion. + + Returns None if no convertible content is found. + """ + return None + def extract_request_tool_names(self, data: dict) -> List[str]: """ Extract tool names from the request body for allowlist/policy checks. diff --git a/litellm/llms/base_llm/image_edit/transformation.py b/litellm/llms/base_llm/image_edit/transformation.py index b088cdf37f6..cea96bde74d 100644 --- a/litellm/llms/base_llm/image_edit/transformation.py +++ b/litellm/llms/base_llm/image_edit/transformation.py @@ -67,6 +67,8 @@ class BaseImageEditConfig(ABC): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: return {} diff --git a/litellm/llms/bedrock/batches/transformation.py b/litellm/llms/bedrock/batches/transformation.py index 5d008038ca9..0602b1c2f62 100644 --- a/litellm/llms/bedrock/batches/transformation.py +++ b/litellm/llms/bedrock/batches/transformation.py @@ -1,4 +1,5 @@ import os +import re import time from typing import Any, Dict, List, Literal, Optional, Union, cast @@ -294,7 +295,8 @@ class BedrockBatchesConfig(BaseAWSLLM, BaseBatchesConfig): raise ValueError(f"Invalid ARN format: {batch_id}") region = arn_parts[3] - # arn_parts[5] contains "model-invocation-job/{jobId}" + if not re.match(r"^[a-z][a-z0-9-]*$", region): + raise ValueError(f"Invalid region in ARN: {batch_id}") # Build the endpoint URL for GetModelInvocationJob # AWS API format: GET /model-invocation-job/{jobIdentifier} diff --git a/litellm/llms/bedrock/chat/mantle/__init__.py b/litellm/llms/bedrock/chat/mantle/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/bedrock/chat/mantle/transformation.py b/litellm/llms/bedrock/chat/mantle/transformation.py new file mode 100644 index 00000000000..b9bea77c118 --- /dev/null +++ b/litellm/llms/bedrock/chat/mantle/transformation.py @@ -0,0 +1,91 @@ +""" +Transformation for Bedrock Mantle (Claude Mythos Preview) + +https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-mythos-preview.html + +The bedrock-mantle endpoint uses the Anthropic Messages API format but is served +at a different endpoint (bedrock-mantle.{region}.api.aws) with AWS SigV4 auth. +""" + +from typing import TYPE_CHECKING, Any, List, Optional + +from litellm.llms.bedrock.chat.invoke_transformations.anthropic_claude3_transformation import ( + AmazonAnthropicClaudeConfig, +) +from litellm.types.llms.openai import AllMessageValues + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj + + LiteLLMLoggingObj = _LiteLLMLoggingObj +else: + LiteLLMLoggingObj = Any + +MANTLE_ENDPOINT_TEMPLATE = "https://bedrock-mantle.{region}.api.aws/v1/messages" + + +class AmazonMantleConfig(AmazonAnthropicClaudeConfig): + """ + Config for the bedrock-mantle endpoint (Claude Mythos Preview). + + Uses the Anthropic Messages API format with AWS SigV4 auth, but at a + different endpoint from bedrock-runtime. Model ID goes in the request body. + + Usage: model="bedrock/mantle/anthropic.claude-mythos-preview" + """ + + def get_complete_url( + self, + api_base: Optional[str], + api_key: Optional[str], + model: str, + optional_params: dict, + litellm_params: dict, + stream: Optional[bool] = None, + ) -> str: + region = self._get_aws_region_name(optional_params=optional_params, model=model) + return MANTLE_ENDPOINT_TEMPLATE.format(region=region) + + def transform_request( + self, + model: str, + messages: List[AllMessageValues], + optional_params: dict, + litellm_params: dict, + headers: dict, + ) -> dict: + # Strip the "mantle/" routing prefix to get the real model ID + model_id = model.replace("mantle/", "", 1) + + request = self._build_bedrock_anthropic_request_base( + model=model_id, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + headers=headers, + ) + # The parent strips "model" from the body (Invoke API puts it in URL). + # The mantle endpoint (Messages API) requires "model" in the body. + request["model"] = model_id + return request + + async def async_transform_request( + self, + model: str, + messages: List[AllMessageValues], + optional_params: dict, + litellm_params: dict, + headers: dict, + ) -> dict: + model_id = model.replace("mantle/", "", 1) + + request = self._build_bedrock_anthropic_request_base( + model=model_id, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + headers=headers, + ) + await self._async_convert_document_url_sources_to_base64(request) + request["model"] = model_id + return request diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index 52697d752be..9a97a134cc4 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -696,6 +696,7 @@ class BedrockModelInfo(BaseLLMModelInfo): "agentcore", "async_invoke", "openai", + "mantle", ]: """ Get the bedrock route for the given model. @@ -710,6 +711,7 @@ class BedrockModelInfo(BaseLLMModelInfo): "agentcore", "async_invoke", "openai", + "mantle", ], ] = { "invoke/": "invoke", @@ -719,6 +721,7 @@ class BedrockModelInfo(BaseLLMModelInfo): "agentcore/": "agentcore", "async_invoke/": "async_invoke", "openai/": "openai", + "mantle/": "mantle", } # Check explicit routes first @@ -770,6 +773,13 @@ class BedrockModelInfo(BaseLLMModelInfo): """ return "agentcore/" in model + @staticmethod + def _explicit_mantle_route(model: str) -> bool: + """ + Check if the model is an explicit mantle route (bedrock-mantle endpoint). + """ + return "mantle/" in model + @staticmethod def _explicit_converse_like_route(model: str) -> bool: """ @@ -809,6 +819,16 @@ class BedrockModelInfo(BaseLLMModelInfo): if BedrockModelInfo._explicit_converse_route(model): return None + ######################################################### + # Mantle route uses the bedrock-mantle endpoint (not bedrock-runtime) + ######################################################### + if BedrockModelInfo._explicit_mantle_route(model): + from litellm.llms.bedrock.messages.mantle_transformation import ( + AmazonMantleMessagesConfig, + ) + + return AmazonMantleMessagesConfig() + ######################################################### # This goes through litellm.AmazonAnthropicClaude3MessagesConfig() # Since bedrock Invoke supports Native Anthropic Messages API @@ -855,6 +875,12 @@ def get_bedrock_chat_config(model: str): ) return AmazonAgentCoreConfig() + elif bedrock_route == "mantle": + from litellm.llms.bedrock.chat.mantle.transformation import ( + AmazonMantleConfig, + ) + + return AmazonMantleConfig() # Handle provider-specific configs if bedrock_invoke_provider == "amazon": diff --git a/litellm/llms/bedrock/image_edit/amazon_nova_canvas_image_edit_transformation.py b/litellm/llms/bedrock/image_edit/amazon_nova_canvas_image_edit_transformation.py index f806cd2a81a..836a3c606ee 100644 --- a/litellm/llms/bedrock/image_edit/amazon_nova_canvas_image_edit_transformation.py +++ b/litellm/llms/bedrock/image_edit/amazon_nova_canvas_image_edit_transformation.py @@ -483,6 +483,8 @@ class BedrockAmazonNovaCanvasImageEditConfig(BaseImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: if headers is None: headers = {} diff --git a/litellm/llms/bedrock/image_edit/stability_transformation.py b/litellm/llms/bedrock/image_edit/stability_transformation.py index 6a8b95e7e39..2d73e47003d 100644 --- a/litellm/llms/bedrock/image_edit/stability_transformation.py +++ b/litellm/llms/bedrock/image_edit/stability_transformation.py @@ -372,6 +372,8 @@ class BedrockStabilityImageEditConfig(BaseImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: """ Validate environment for Bedrock Stability image edit. diff --git a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py index 31e0e76fd9f..96593b35d0c 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py @@ -34,6 +34,7 @@ from litellm.llms.bedrock.common_utils import ( remove_custom_field_from_tools, ) from litellm.types.llms.anthropic import ANTHROPIC_TOOL_SEARCH_BETA_HEADER +from litellm.types.llms.bedrock import BedrockInvokeAnthropicMessagesRequest from litellm.types.llms.openai import AllMessageValues from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import GenericStreamingChunk @@ -59,6 +60,10 @@ class AmazonAnthropicClaudeMessagesConfig( DEFAULT_BEDROCK_ANTHROPIC_API_VERSION = "bedrock-2023-05-31" + BEDROCK_INVOKE_ALLOWED_TOP_LEVEL_FIELDS = frozenset( + BedrockInvokeAnthropicMessagesRequest.__annotations__.keys() + ) + def __init__(self, **kwargs): BaseAnthropicMessagesConfig.__init__(self, **kwargs) AmazonInvokeConfig.__init__(self, **kwargs) @@ -500,10 +505,6 @@ class AmazonAnthropicClaudeMessagesConfig( anthropic_messages_request=anthropic_messages_request, ) - # 5b. Strip `output_config` — Bedrock Invoke doesn't support it - # Fixes: https://github.com/BerriAI/litellm/issues/22797 - anthropic_messages_request.pop("output_config", None) - # 5a. Remove `custom` field from tools (Bedrock doesn't support it) # Claude Code sends `custom: {defer_loading: true}` on tool definitions, # which causes Bedrock to reject the request with "Extra inputs are not permitted" @@ -550,14 +551,43 @@ class AmazonAnthropicClaudeMessagesConfig( if "tool-search-tool-2025-10-19" in beta_set: beta_set.add("tool-examples-2025-10-29") - filtered_auto_betas = filter_and_transform_beta_headers( - beta_headers=list(beta_set - user_beta_set), - provider="bedrock", + filtered_betas = sorted( + filter_and_transform_beta_headers( + beta_headers=list(beta_set), + provider="bedrock", + ) ) - filtered_betas = sorted(user_beta_set.union(set(filtered_auto_betas))) + + dropped_user_betas = sorted( + b + for b in user_beta_set + if not filter_and_transform_beta_headers([b], provider="bedrock") + ) + if dropped_user_betas: + verbose_logger.warning( + "Bedrock Invoke: dropping unsupported anthropic-beta values " + "from client headers: %s. Bedrock has no mapping entry for " + "these; forwarding them would cause a 400.", + dropped_user_betas, + ) + if filtered_betas: anthropic_messages_request["anthropic_beta"] = filtered_betas + # 7. Final safety net: filter top-level fields to the Bedrock Invoke allowlist. + # Catches Anthropic-only extensions (context_management, output_config, speed, + # mcp_servers, ...) and any future additions Claude Code may start sending. + allowed = self.BEDROCK_INVOKE_ALLOWED_TOP_LEVEL_FIELDS + stripped = sorted(k for k in anthropic_messages_request if k not in allowed) + if stripped: + verbose_logger.debug( + "Bedrock Invoke: stripping unsupported top-level request fields: %s", + stripped, + ) + anthropic_messages_request = { + k: v for k, v in anthropic_messages_request.items() if k in allowed + } + return anthropic_messages_request def get_async_streaming_response_iterator( diff --git a/litellm/llms/bedrock/messages/mantle_transformation.py b/litellm/llms/bedrock/messages/mantle_transformation.py new file mode 100644 index 00000000000..3f04c8a3052 --- /dev/null +++ b/litellm/llms/bedrock/messages/mantle_transformation.py @@ -0,0 +1,69 @@ +""" +Transformation for Bedrock Mantle (Claude Mythos Preview) - /messages endpoint + +Inherits all Messages API request/response transformations from +AmazonAnthropicClaudeMessagesConfig. Overrides only the URL and model-prefix +stripping that are specific to the bedrock-mantle endpoint. +""" + +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import ( + AmazonAnthropicClaudeMessagesConfig, +) +from litellm.types.router import GenericLiteLLMParams + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj + + LiteLLMLoggingObj = _LiteLLMLoggingObj +else: + LiteLLMLoggingObj = Any + +MANTLE_ENDPOINT_TEMPLATE = "https://bedrock-mantle.{region}.api.aws/v1/messages" + + +class AmazonMantleMessagesConfig(AmazonAnthropicClaudeMessagesConfig): + """ + Config for the bedrock-mantle /messages endpoint (Claude Mythos Preview). + + The mantle endpoint uses the Anthropic Messages API format and requires the + model ID in the request body (unlike Bedrock Invoke which puts it in the URL). + """ + + def get_complete_url( + self, + api_base: Optional[str], + api_key: Optional[str], + model: str, + optional_params: dict, + litellm_params: dict, + stream: Optional[bool] = None, + ) -> str: + region = self._get_aws_region_name(optional_params=optional_params, model=model) + return MANTLE_ENDPOINT_TEMPLATE.format(region=region) + + def transform_anthropic_messages_request( + self, + model: str, + messages: List[Dict], + anthropic_messages_optional_request_params: Dict, + litellm_params: GenericLiteLLMParams, + headers: dict, + ) -> Dict: + # Strip "mantle/" routing prefix to get the real model ID + model_id = model.replace("mantle/", "", 1) + + request = super().transform_anthropic_messages_request( + model=model_id, + messages=messages, + anthropic_messages_optional_request_params=anthropic_messages_optional_request_params, + litellm_params=litellm_params, + headers=headers, + ) + + # Parent (AmazonAnthropicClaudeMessagesConfig) removes "model" from the + # body (Bedrock Invoke puts model in the URL). The mantle endpoint + # (Messages API) requires "model" in the request body. + request["model"] = model_id + return request diff --git a/litellm/llms/black_forest_labs/image_edit/transformation.py b/litellm/llms/black_forest_labs/image_edit/transformation.py index c6d8e8298e3..309e00ade62 100644 --- a/litellm/llms/black_forest_labs/image_edit/transformation.py +++ b/litellm/llms/black_forest_labs/image_edit/transformation.py @@ -14,7 +14,9 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union import httpx from httpx._types import RequestFiles +import litellm from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH +from litellm.litellm_core_utils.url_utils import safe_get from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig from litellm.secret_managers.main import get_secret_str from litellm.types.images.main import ImageEditOptionalRequestParams @@ -123,6 +125,8 @@ class BlackForestLabsImageEditConfig(BaseImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: """ Validate environment and set up headers for Black Forest Labs. @@ -206,14 +210,14 @@ class BlackForestLabsImageEditConfig(BaseImageEditConfig): ) elif isinstance(image, str): if image.startswith(("http://", "https://")): - # Download image from URL - response = httpx.get(image, timeout=60.0) + response = safe_get(litellm.module_level_client, image, timeout=60.0) response.raise_for_status() return response.content else: - # Assume it's a file path - with open(image, "rb") as f: - return f.read() + raise ValueError( + "Unsupported image input: plain string values that are not URLs are not accepted. " + "Provide image bytes or a file-like object." + ) elif hasattr(image, "read"): # File-like object pos = getattr(image, "tell", lambda: 0)() diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index ea0c05e7656..3a509ccc2d7 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -78,6 +78,10 @@ from litellm.types.containers.main import ( DeleteContainerResult, ) from litellm.types.files import TwoStepFileUploadConfig +from litellm.types.integrations.custom_logger import ( + AgenticLoopPlan, + AgenticLoopRequestPatch, +) from litellm.types.llms.anthropic_messages.anthropic_response import ( AnthropicMessagesResponse, ) @@ -2047,7 +2051,23 @@ class BaseLLMHTTPHandler: request_body=request_body, litellm_logging_obj=logging_obj, ) - initial_response = completion_stream + + from litellm.llms.anthropic.experimental_pass_through.messages.agentic_streaming_iterator import ( + AgenticAnthropicStreamingIterator, + ) + + initial_response = AgenticAnthropicStreamingIterator( + completion_stream=completion_stream, + http_handler=self, + model=model, + messages=messages, + anthropic_messages_provider_config=anthropic_messages_provider_config, + anthropic_messages_optional_request_params=anthropic_messages_optional_request_params, + logging_obj=logging_obj, + custom_llm_provider=custom_llm_provider, + kwargs=kwargs, + ) + return initial_response else: initial_response = anthropic_messages_provider_config.transform_anthropic_messages_response( model=model, @@ -2055,7 +2075,7 @@ class BaseLLMHTTPHandler: logging_obj=logging_obj, ) - # Call agentic completion hooks + # Call agentic completion hooks (non-streaming path only) final_response = await self._call_agentic_completion_hooks( response=initial_response, model=model, @@ -2063,7 +2083,7 @@ class BaseLLMHTTPHandler: anthropic_messages_provider_config=anthropic_messages_provider_config, anthropic_messages_optional_request_params=anthropic_messages_optional_request_params, logging_obj=logging_obj, - stream=stream or False, + stream=False, custom_llm_provider=custom_llm_provider, kwargs=kwargs, ) @@ -4516,6 +4536,167 @@ class BaseLLMHTTPHandler: return stream, data return stream, data + @staticmethod + def _get_agentic_loop_settings(kwargs: Dict) -> Tuple[int, int, List[str]]: + depth = int(kwargs.get("_agentic_loop_depth", 0) or 0) + max_loops = int(kwargs.get("max_agentic_loops", 3) or 3) + fingerprints = list(kwargs.get("_agentic_loop_fingerprints", []) or []) + return depth, max(max_loops, 1), fingerprints + + @staticmethod + def _check_agentic_loop_safety( + tool_calls: Any, + fingerprints: List[str], + depth: int, + max_loops: int, + model: str, + ) -> str: + """ + Evaluate agentic-loop safety guards (fingerprint cycle / max depth). + + Raises ValueError on abort. Returns the current fingerprint on success. + + These checks must not be swallowed by the per-callback ``except Exception`` + block that wraps callback dispatch — they are bounded-loop / cycle-break + safety rails and must abort the agentic dispatch when they trip. + """ + fingerprint = BaseLLMHTTPHandler._fingerprint_agentic_tools(tool_calls) + if fingerprint in fingerprints: + raise ValueError( + "Agentic loop detected repeated tool-call fingerprint; aborting rerun" + ) + if depth >= max_loops: + raise ValueError( + f"Exceeded max_agentic_loops={max_loops} for model={model}" + ) + return fingerprint + + @staticmethod + def _fingerprint_agentic_tools(tools: Dict) -> str: + try: + return json.dumps(tools, sort_keys=True, default=str) + except Exception: + return str(tools) + + async def _execute_anthropic_agentic_plan( + self, + plan: AgenticLoopPlan, + model: str, + messages: List[Dict], + anthropic_messages_optional_request_params: Dict, + logging_obj: "LiteLLMLoggingObj", + kwargs: Dict, + depth: int, + max_loops: int, + fingerprints: List[str], + fingerprint: str, + stream: bool = False, + ) -> Any: + from litellm.anthropic_interface import messages as anthropic_messages + + patch = plan.request_patch or AgenticLoopRequestPatch() + if patch.messages is None: + raise ValueError("Agentic loop plan missing patched messages") + + full_model_name = model + if logging_obj is not None: + agentic_params = logging_obj.model_call_details.get( + "agentic_loop_params", {} + ) + full_model_name = cast(str, agentic_params.get("model", model)) + + optional_params = dict(anthropic_messages_optional_request_params) + optional_params.update(patch.optional_params) + if patch.tools is not None: + optional_params["tools"] = patch.tools + + max_tokens = patch.max_tokens + if max_tokens is None: + max_tokens = cast(Optional[int], optional_params.pop("max_tokens", None)) + else: + optional_params.pop("max_tokens", None) + if max_tokens is None: + max_tokens = cast(int, kwargs.get("max_tokens", 1024)) + + internal_keys = {"litellm_logging_obj"} + kwargs_for_followup = { + k: v + for k, v in kwargs.items() + if not k.startswith("_websearch_interception") + and not k.startswith("_compression_interception") + and k not in internal_keys + and k not in optional_params + } + kwargs_for_followup.update(patch.kwargs) + kwargs_for_followup["_agentic_loop_depth"] = depth + 1 + kwargs_for_followup["max_agentic_loops"] = max_loops + kwargs_for_followup["_agentic_loop_fingerprints"] = fingerprints + [fingerprint] + + return await anthropic_messages.acreate( + **{ + "max_tokens": max_tokens, + "messages": patch.messages, + "model": patch.model or full_model_name, + "stream": stream, + **optional_params, + **kwargs_for_followup, + } + ) + + async def _execute_chat_completion_agentic_plan( + self, + plan: AgenticLoopPlan, + model: str, + messages: List[Dict], + optional_params: Dict, + kwargs: Dict, + custom_llm_provider: str, + depth: int, + max_loops: int, + fingerprints: List[str], + fingerprint: str, + ) -> Any: + patch = plan.request_patch or AgenticLoopRequestPatch() + if patch.messages is None: + raise ValueError("Agentic loop plan missing patched messages") + + full_model_name = patch.model or model + if "/" not in full_model_name: + full_model_name = f"{custom_llm_provider}/{full_model_name}" + + optional_params_for_followup = dict(optional_params) + optional_params_for_followup.update(patch.optional_params) + if patch.tools is not None: + optional_params_for_followup["tools"] = patch.tools + + internal_params = { + "_websearch_interception", + "acompletion", + "litellm_logging_obj", + "custom_llm_provider", + "model_alias_map", + "stream_response", + "custom_prompt_dict", + } + kwargs_for_followup = { + k: v + for k, v in kwargs.items() + if not k.startswith("_websearch_interception") + and not k.startswith("_compression_interception") + and k not in internal_params + } + kwargs_for_followup.update(patch.kwargs) + kwargs_for_followup["_agentic_loop_depth"] = depth + 1 + kwargs_for_followup["max_agentic_loops"] = max_loops + kwargs_for_followup["_agentic_loop_fingerprints"] = fingerprints + [fingerprint] + + return await litellm.acompletion( + model=full_model_name, + messages=patch.messages, + **optional_params_for_followup, + **kwargs_for_followup, + ) + async def _call_agentic_completion_hooks( self, response: Any, @@ -4541,45 +4722,111 @@ class BaseLLMHTTPHandler: callbacks = litellm.callbacks + (logging_obj.dynamic_success_callbacks or []) tools = anthropic_messages_optional_request_params.get("tools", []) + depth, max_loops, fingerprints = self._get_agentic_loop_settings(kwargs=kwargs) for callback in callbacks: + if not isinstance(callback, CustomLogger): + continue + + should_run: bool = False + tool_calls: Any = None try: - if isinstance(callback, CustomLogger): - # First: Check if agentic loop should run - ( - should_run, - tool_calls, - ) = await callback.async_should_run_agentic_loop( - response=response, + # First: Check if agentic loop should run. Wrap in try/except + # to shield from buggy user callbacks — a callback crash should + # not abort the whole request. + ( + should_run, + tool_calls, + ) = await callback.async_should_run_agentic_loop( + response=response, + model=model, + messages=messages, + tools=tools, + stream=stream, + custom_llm_provider=custom_llm_provider, + kwargs=kwargs, + ) + except Exception as e: + _call_id = getattr(logging_obj, "litellm_call_id", "unknown") + verbose_logger.exception( + "LiteLLM.AgenticHookError: Exception in " + "async_should_run_agentic_loop [call_id=%s model=%s]: %s", + _call_id, + model, + str(e), + ) + continue + + if not should_run: + continue + + # Safety guards must run OUTSIDE the callback try/except — they are + # bounded-loop / cycle-break rails that must propagate to the caller. + fingerprint = self._check_agentic_loop_safety( + tool_calls=tool_calls, + fingerprints=fingerprints, + depth=depth, + max_loops=max_loops, + model=model, + ) + + try: + kwargs_with_provider = kwargs.copy() if kwargs else {} + kwargs_with_provider["custom_llm_provider"] = custom_llm_provider + build_plan_overridden = ( + callback.__class__.async_build_agentic_loop_plan + is not CustomLogger.async_build_agentic_loop_plan + ) + if not build_plan_overridden: + return await callback.async_run_agentic_loop( + tools=tool_calls, model=model, messages=messages, - tools=tools, + response=response, + anthropic_messages_provider_config=anthropic_messages_provider_config, + anthropic_messages_optional_request_params=anthropic_messages_optional_request_params, + logging_obj=logging_obj, stream=stream, - custom_llm_provider=custom_llm_provider, - kwargs=kwargs, + kwargs=kwargs_with_provider, ) - if should_run: - # Second: Execute agentic loop - # Add custom_llm_provider to kwargs so the agentic loop can reconstruct the full model name - kwargs_with_provider = kwargs.copy() if kwargs else {} - kwargs_with_provider["custom_llm_provider"] = ( - custom_llm_provider - ) - agentic_response = await callback.async_run_agentic_loop( - tools=tool_calls, - model=model, - messages=messages, - response=response, - anthropic_messages_provider_config=anthropic_messages_provider_config, - anthropic_messages_optional_request_params=anthropic_messages_optional_request_params, - logging_obj=logging_obj, - stream=stream, - kwargs=kwargs_with_provider, - ) - # First hook that runs agentic loop wins - return agentic_response + plan = await callback.async_build_agentic_loop_plan( + tools=tool_calls, + model=model, + messages=messages, + response=response, + anthropic_messages_provider_config=anthropic_messages_provider_config, + anthropic_messages_optional_request_params=anthropic_messages_optional_request_params, + logging_obj=logging_obj, + stream=stream, + kwargs=kwargs_with_provider, + ) + if plan.response_override is not None: + return plan.response_override + if plan.terminate: + verbose_logger.debug( + "Agentic loop terminated by callback=%s reason=%s", + callback.__class__.__name__, + plan.stop_reason, + ) + return response + if not plan.run_agentic_loop: + continue + + return await self._execute_anthropic_agentic_plan( + plan=plan, + model=model, + messages=messages, + anthropic_messages_optional_request_params=anthropic_messages_optional_request_params, + logging_obj=logging_obj, + kwargs=kwargs_with_provider, + depth=depth, + max_loops=max_loops, + fingerprints=fingerprints, + fingerprint=fingerprint, + stream=stream, + ) except Exception as e: _call_id = getattr(logging_obj, "litellm_call_id", "unknown") verbose_logger.exception( @@ -4653,52 +4900,104 @@ class BaseLLMHTTPHandler: callbacks = litellm.callbacks + (logging_obj.dynamic_success_callbacks or []) tools = optional_params.get("tools", []) + depth, max_loops, fingerprints = self._get_agentic_loop_settings(kwargs=kwargs) for callback in callbacks: - try: - if isinstance(callback, CustomLogger): - # Check if callback has the chat completion agentic loop method - if not hasattr( - callback, "async_should_run_chat_completion_agentic_loop" - ): - continue + if not isinstance(callback, CustomLogger): + continue + if not hasattr(callback, "async_should_run_chat_completion_agentic_loop"): + continue - # First: Check if agentic loop should run - ( - should_run, - tool_calls, - ) = await callback.async_should_run_chat_completion_agentic_loop( - response=response, + should_run: bool = False + tool_calls: Any = None + try: + ( + should_run, + tool_calls, + ) = await callback.async_should_run_chat_completion_agentic_loop( + response=response, + model=model, + messages=messages, + tools=tools, + stream=stream, + custom_llm_provider=custom_llm_provider, + kwargs=kwargs, + ) + except Exception as e: + verbose_logger.exception( + "LiteLLM.AgenticHookError: Exception in " + "async_should_run_chat_completion_agentic_loop: %s", + str(e), + ) + continue + + if not should_run: + continue + + # Safety guards must run OUTSIDE the callback try/except — they are + # bounded-loop / cycle-break rails that must propagate to the caller. + fingerprint = self._check_agentic_loop_safety( + tool_calls=tool_calls, + fingerprints=fingerprints, + depth=depth, + max_loops=max_loops, + model=model, + ) + + try: + kwargs_with_provider = kwargs.copy() if kwargs else {} + kwargs_with_provider["custom_llm_provider"] = custom_llm_provider + build_plan_overridden = ( + callback.__class__.async_build_chat_completion_agentic_loop_plan + is not CustomLogger.async_build_chat_completion_agentic_loop_plan + ) + if not build_plan_overridden: + return await callback.async_run_chat_completion_agentic_loop( + tools=tool_calls, model=model, messages=messages, - tools=tools, + response=response, + optional_params=optional_params, + logging_obj=logging_obj, stream=stream, - custom_llm_provider=custom_llm_provider, - kwargs=kwargs, + kwargs=kwargs_with_provider, ) - if should_run: - # Second: Execute agentic loop - # Add custom_llm_provider to kwargs so the agentic loop can reconstruct the full model name - kwargs_with_provider = kwargs.copy() if kwargs else {} - kwargs_with_provider["custom_llm_provider"] = ( - custom_llm_provider - ) - agentic_response = ( - await callback.async_run_chat_completion_agentic_loop( - tools=tool_calls, - model=model, - messages=messages, - response=response, - optional_params=optional_params, - logging_obj=logging_obj, - stream=stream, - kwargs=kwargs_with_provider, - ) - ) - # First hook that runs agentic loop wins - return agentic_response + plan = await callback.async_build_chat_completion_agentic_loop_plan( + tools=tool_calls, + model=model, + messages=messages, + response=response, + optional_params=optional_params, + logging_obj=logging_obj, + stream=stream, + kwargs=kwargs_with_provider, + ) + if plan.response_override is not None: + return plan.response_override + if plan.terminate: + verbose_logger.debug( + "Agentic chat loop terminated by callback=%s reason=%s", + callback.__class__.__name__, + plan.stop_reason, + ) + return response + if not plan.run_agentic_loop: + continue + + return await self._execute_chat_completion_agentic_plan( + plan=plan, + model=model, + messages=messages, + optional_params=optional_params, + kwargs=kwargs_with_provider, + custom_llm_provider=custom_llm_provider, + depth=depth, + max_loops=max_loops, + fingerprints=fingerprints, + fingerprint=fingerprint, + ) except Exception as e: verbose_logger.exception( f"LiteLLM.AgenticHookError: Exception in chat completion agentic hooks: {str(e)}" @@ -5216,6 +5515,8 @@ class BaseLLMHTTPHandler: api_key=litellm_params.api_key, headers=image_edit_optional_request_params.get("extra_headers", {}) or {}, model=model, + litellm_params=dict(litellm_params), + api_base=litellm_params.api_base, ) if extra_headers: @@ -5312,6 +5613,8 @@ class BaseLLMHTTPHandler: api_key=litellm_params.api_key, headers=image_edit_optional_request_params.get("extra_headers", {}) or {}, model=model, + litellm_params=dict(litellm_params), + api_base=litellm_params.api_base, ) if extra_headers: diff --git a/litellm/llms/dashscope/image_generation/__init__.py b/litellm/llms/dashscope/image_generation/__init__.py new file mode 100644 index 00000000000..aa5724b4d80 --- /dev/null +++ b/litellm/llms/dashscope/image_generation/__init__.py @@ -0,0 +1,11 @@ +from litellm.llms.base_llm.image_generation.transformation import ( + BaseImageGenerationConfig, +) + +from .transformation import DashScopeImageGenerationConfig + +__all__ = ["DashScopeImageGenerationConfig"] + + +def get_dashscope_image_generation_config(model: str) -> BaseImageGenerationConfig: + return DashScopeImageGenerationConfig() diff --git a/litellm/llms/dashscope/image_generation/transformation.py b/litellm/llms/dashscope/image_generation/transformation.py new file mode 100644 index 00000000000..77676b11d51 --- /dev/null +++ b/litellm/llms/dashscope/image_generation/transformation.py @@ -0,0 +1,204 @@ +""" +DashScope Image Generation Configuration + +Handles transformation between OpenAI-compatible format and DashScope multimodal-generation API. + +API endpoint: POST https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation + +Request format: +{ + "model": "qwen-image-2.0-pro", + "input": { + "messages": [{"role": "user", "content": [{"text": ""}]}] + }, + "parameters": {"size": "1024*1024", ...} +} + +Response format: +{ + "output": { + "choices": [{"message": {"content": [{"image": ""}]}}] + }, + "usage": {"input_tokens": 0, "output_tokens": 0, "width": 1024, "height": 1024, "image_count": 1} +} +""" + +from typing import TYPE_CHECKING, Any, List, Optional + +import httpx + +from litellm.llms.base_llm.image_generation.transformation import ( + BaseImageGenerationConfig, +) +from litellm.secret_managers.main import get_secret_str +from litellm.types.llms.openai import ( + AllMessageValues, + OpenAIImageGenerationOptionalParams, +) +from litellm.types.utils import ImageObject, ImageResponse + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj + + LiteLLMLoggingObj = _LiteLLMLoggingObj +else: + LiteLLMLoggingObj = Any + +DEFAULT_API_BASE = "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation" + +# Maps OpenAI size strings (WxH) to DashScope size strings (W*H) +OPENAI_TO_DASHSCOPE_SIZE: dict = { + "256x256": "256*256", + "512x512": "512*512", + "1024x1024": "1024*1024", + "1792x1024": "1792*1024", + "1024x1792": "1024*1792", + "2048x2048": "2048*2048", +} + + +class DashScopeImageGenerationConfig(BaseImageGenerationConfig): + """ + Configuration for DashScope image generation (qwen-image-2.0, qwen-image-2.0-pro). + """ + + def get_supported_openai_params( + self, model: str + ) -> List[OpenAIImageGenerationOptionalParams]: + return ["n", "size"] + + def map_openai_params( + self, + non_default_params: dict, + optional_params: dict, + model: str, + drop_params: bool, + ) -> dict: + supported_params = self.get_supported_openai_params(model) + mapped: dict = {} + for k, v in non_default_params.items(): + if k in optional_params: + continue + if k not in supported_params: + continue + if k == "size": + # Convert "WxH" → "W*H" + mapped["size"] = OPENAI_TO_DASHSCOPE_SIZE.get(v, v.replace("x", "*")) + elif k == "n": + mapped["image_count"] = v + return mapped + + def get_complete_url( + self, + api_base: Optional[str], + api_key: Optional[str], + model: str, + optional_params: dict, + litellm_params: dict, + stream: Optional[bool] = None, + ) -> str: + return ( + api_base or get_secret_str("DASHSCOPE_API_BASE_IMAGE") or DEFAULT_API_BASE + ) + + def validate_environment( + self, + headers: dict, + model: str, + messages: List[AllMessageValues], + optional_params: dict, + litellm_params: dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + ) -> dict: + final_api_key = api_key or get_secret_str("DASHSCOPE_API_KEY") + if not final_api_key: + raise ValueError("DASHSCOPE_API_KEY is not set") + headers["Authorization"] = f"Bearer {final_api_key}" + headers["Content-Type"] = "application/json" + return headers + + def transform_image_generation_request( + self, + model: str, + prompt: str, + optional_params: dict, + litellm_params: dict, + headers: dict, + ) -> dict: + """ + Transform OpenAI-style image generation request to DashScope multimodal-generation format. + """ + parameters: dict = {} + for k, v in optional_params.items(): + parameters[k] = v + + return { + "model": model, + "input": { + "messages": [ + { + "role": "user", + "content": [{"text": prompt}], + } + ] + }, + "parameters": parameters, + } + + def transform_image_generation_response( + self, + model: str, + raw_response: httpx.Response, + model_response: ImageResponse, + logging_obj: LiteLLMLoggingObj, + request_data: dict, + optional_params: dict, + litellm_params: dict, + encoding: Any, + api_key: Optional[str] = None, + json_mode: Optional[bool] = None, + ) -> ImageResponse: + """ + Transform DashScope response to litellm ImageResponse. + + DashScope response: output.choices[0].message.content[0].image + OpenAI response: data[0].url + """ + if raw_response.status_code != 200: + raise self.get_error_class( + error_message=raw_response.text, + status_code=raw_response.status_code, + headers=raw_response.headers, + ) + + try: + response_data = raw_response.json() + except Exception as e: + raise self.get_error_class( + error_message=f"Failed to parse DashScope image generation response: {e}", + status_code=raw_response.status_code, + headers=raw_response.headers, + ) + + # DashScope can return API-level errors in a 200 response body. + # Example: {"code": "InvalidParameter", "message": "Size not supported"} + if "code" in response_data and "output" not in response_data: + raise self.get_error_class( + error_message=str(response_data.get("message", response_data)), + status_code=raw_response.status_code, + headers=raw_response.headers, + ) + + if not model_response.data: + model_response.data = [] + + choices = response_data.get("output", {}).get("choices", []) + for choice in choices: + content_list = choice.get("message", {}).get("content", []) + for content_item in content_list: + image_url = content_item.get("image") + if image_url: + model_response.data.append(ImageObject(url=image_url)) + + return model_response diff --git a/litellm/llms/gemini/image_edit/transformation.py b/litellm/llms/gemini/image_edit/transformation.py index d46733e04b2..c8aaab0e14e 100644 --- a/litellm/llms/gemini/image_edit/transformation.py +++ b/litellm/llms/gemini/image_edit/transformation.py @@ -54,6 +54,8 @@ class GeminiImageEditConfig(BaseImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: final_api_key: Optional[str] = api_key or get_secret_str("GEMINI_API_KEY") if not final_api_key: diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index f4698861edc..9de2987b9f6 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -294,9 +294,7 @@ class Authenticator: access_token_url = os.getenv( "GITHUB_COPILOT_ACCESS_TOKEN_URL", DEFAULT_GITHUB_ACCESS_TOKEN_URL ) - client_id = os.getenv( - "GITHUB_COPILOT_CLIENT_ID", DEFAULT_GITHUB_CLIENT_ID - ) + client_id = os.getenv("GITHUB_COPILOT_CLIENT_ID", DEFAULT_GITHUB_CLIENT_ID) for attempt in range(max_attempts): try: diff --git a/litellm/llms/litellm_proxy/image_edit/transformation.py b/litellm/llms/litellm_proxy/image_edit/transformation.py index 5f5e2bdb24d..79cd6e15c68 100644 --- a/litellm/llms/litellm_proxy/image_edit/transformation.py +++ b/litellm/llms/litellm_proxy/image_edit/transformation.py @@ -8,7 +8,12 @@ class LiteLLMProxyImageEditConfig(OpenAIImageEditConfig): """Configuration for image edit requests routed through LiteLLM Proxy.""" def validate_environment( - self, headers: dict, model: str, api_key: Optional[str] = None + self, + headers: dict, + model: str, + api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: api_key = api_key or get_secret_str("LITELLM_PROXY_API_KEY") headers.update({"Authorization": f"Bearer {api_key}"}) diff --git a/litellm/llms/openai/chat/gpt_5_transformation.py b/litellm/llms/openai/chat/gpt_5_transformation.py index fc48704cd10..34941a545eb 100644 --- a/litellm/llms/openai/chat/gpt_5_transformation.py +++ b/litellm/llms/openai/chat/gpt_5_transformation.py @@ -53,9 +53,21 @@ class OpenAIGPT5Config(OpenAIGPTConfig): @classmethod def is_model_gpt_5_model(cls, model: str) -> bool: - # gpt-5-chat* behaves like a regular chat model (supports temperature, etc.) - # Don't route it through GPT-5 reasoning-specific parameter restrictions. - return "gpt-5" in model and "gpt-5-chat" not in model + # The gpt-5-chat* family (gpt-5-chat, gpt-5-chat-latest, gpt-5-chat-2025-08-07, + # …) are regular chat models: they support temperature and tool_choice but NOT + # reasoning_effort. They must NOT be routed through the GPT-5 reasoning path. + # + # Versioned chat models such as gpt-5.3-chat and gpt-5.1-chat ARE reasoning + # models and must stay on the GPT-5 path. The distinguishing feature is that + # the gpt-5-chat family has a literal "-chat" immediately after "gpt-5" + # (i.e. "gpt-5-chat…"), while versioned chat models interpose a minor version + # number (i.e. "gpt-5.-chat"). + # + # Using a startswith("gpt-5-chat") prefix check on the normalized name (rather + # than a substring check) makes this boundary explicit and avoids any ambiguity + # if future model names coincidentally contain "gpt-5-chat" as an interior run. + _normalized = model.split("/")[-1] # strip provider prefix, e.g. "openai/" + return "gpt-5" in model and not _normalized.startswith("gpt-5-chat") @classmethod def is_model_gpt_5_search_model(cls, model: str) -> bool: diff --git a/litellm/llms/openai/chat/guardrail_translation/handler.py b/litellm/llms/openai/chat/guardrail_translation/handler.py index 2db19dea0b9..86ca6625629 100644 --- a/litellm/llms/openai/chat/guardrail_translation/handler.py +++ b/litellm/llms/openai/chat/guardrail_translation/handler.py @@ -48,6 +48,17 @@ class OpenAIChatCompletionsHandler(BaseTranslation): Methods can be overridden to customize behavior for different message formats. """ + def get_structured_messages(self, data: dict) -> Optional[List[AllMessageValues]]: + """ + Convert chat completions request data to OpenAI-spec structured messages. + + Messages are already in OpenAI format, so this is a simple extraction. + """ + messages = data.get("messages") + if messages is None: + return None + return cast(List[AllMessageValues], messages) + async def process_input_messages( self, data: dict, @@ -68,9 +79,6 @@ class OpenAIChatCompletionsHandler(BaseTranslation): tool_calls_to_check: List[ChatCompletionToolParam] = [] text_task_mappings: List[Tuple[int, Optional[int]]] = [] tool_call_task_mappings: List[Tuple[int, int]] = [] - # text_task_mappings: Track (message_index, content_index) for each text - # content_index is None for string content, int for list content - # tool_call_task_mappings: Track (message_index, tool_call_index) for each tool call # Step 1: Extract all text content, images, and tool calls for msg_idx, message in enumerate(messages): @@ -92,12 +100,12 @@ class OpenAIChatCompletionsHandler(BaseTranslation): inputs["images"] = images_to_check if tool_calls_to_check: inputs["tool_calls"] = tool_calls_to_check # type: ignore - if messages: - msg_list = cast(List[AllMessageValues], messages) + structured_messages = self.get_structured_messages(data) + if structured_messages: inputs["structured_messages"] = ( - openai_messages_without_system(msg_list) + openai_messages_without_system(structured_messages) if skip_system - else msg_list + else structured_messages ) # Pass tools (function definitions) to the guardrail tools = data.get("tools") diff --git a/litellm/llms/openai/image_edit/transformation.py b/litellm/llms/openai/image_edit/transformation.py index 6917e8d7990..9c0daca8022 100644 --- a/litellm/llms/openai/image_edit/transformation.py +++ b/litellm/llms/openai/image_edit/transformation.py @@ -165,6 +165,8 @@ class OpenAIImageEditConfig(BaseImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: api_key = ( api_key diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py index 76f40eed71f..f7dd68aec55 100644 --- a/litellm/llms/openai/responses/guardrail_translation/handler.py +++ b/litellm/llms/openai/responses/guardrail_translation/handler.py @@ -43,6 +43,7 @@ from litellm.responses.litellm_completion_transformation.transformation import ( LiteLLMCompletionResponsesConfig, ) from litellm.types.llms.openai import ( + AllMessageValues, ChatCompletionToolCallChunk, ChatCompletionToolParam, ) @@ -70,6 +71,24 @@ class OpenAIResponsesHandler(BaseTranslation): Methods can be overridden to customize behavior for different message formats. """ + def get_structured_messages(self, data: dict) -> Optional[List[AllMessageValues]]: + """ + Convert Responses API request data to OpenAI-spec structured messages. + + Transforms `input` (string or ResponseInputParam) and optional + `instructions` into chat completion messages. + """ + input_data = data.get("input") + if input_data is None: + return None + messages = ( + LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( + input=input_data, + responses_api_request=data, + ) + ) + return cast(List[AllMessageValues], messages) if messages else None + async def process_input_messages( self, data: dict, @@ -86,12 +105,7 @@ class OpenAIResponsesHandler(BaseTranslation): if input_data is None: return data - structured_messages = ( - LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( - input=input_data, - responses_api_request=data, - ) - ) + structured_messages = self.get_structured_messages(data) # Handle simple string input if isinstance(input_data, str): diff --git a/litellm/llms/openrouter/image_edit/transformation.py b/litellm/llms/openrouter/image_edit/transformation.py index fcf066dd5ac..0d96b62425f 100644 --- a/litellm/llms/openrouter/image_edit/transformation.py +++ b/litellm/llms/openrouter/image_edit/transformation.py @@ -116,6 +116,8 @@ class OpenRouterImageEditConfig(BaseImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: api_key = api_key or litellm.api_key or get_secret_str("OPENROUTER_API_KEY") if not api_key: diff --git a/litellm/llms/ovhcloud/chat/transformation.py b/litellm/llms/ovhcloud/chat/transformation.py index 342ad700e00..ae9271ddb16 100644 --- a/litellm/llms/ovhcloud/chat/transformation.py +++ b/litellm/llms/ovhcloud/chat/transformation.py @@ -8,9 +8,8 @@ More information on our website: https://endpoints.ai.cloud.ovh.net from typing import Optional, Union, List import httpx -from litellm.utils import ModelResponseStream, _get_model_info_helper +from litellm.utils import ModelResponseStream from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig -from litellm._logging import verbose_logger from litellm.llms.ovhcloud.utils import OVHCloudException from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator from litellm.llms.base_llm.chat.transformation import BaseLLMException @@ -22,34 +21,6 @@ class OVHCloudChatConfig(OpenAIGPTConfig): def custom_llm_provider(self) -> Optional[str]: return "ovhcloud" - def get_supported_openai_params(self, model: str) -> list: - """ - Details about function calling support can be found here: - https://help.ovhcloud.com/csm/en-gb-public-cloud-ai-endpoints-function-calling?id=kb_article_view&sysparm_article=KB0071907 - """ - supports_function_calling: Optional[bool] = None - try: - model_info = _get_model_info_helper(model, custom_llm_provider="ovhcloud") - supports_function_calling = model_info.get( - "supports_function_calling", None - ) - if supports_function_calling is None: - supports_function_calling = False - except Exception as e: - verbose_logger.debug(f"Error getting supported OpenAI params: {e}") - supports_function_calling = False - - optional_params = super().get_supported_openai_params(model) - if supports_function_calling is not True: - verbose_logger.debug( - "You can see our models supporting function_calling in our catalog: https://endpoints.ai.cloud.ovh.net/catalog " - ) - optional_params.remove("tools") - optional_params.remove("tool_choice") - optional_params.remove("function_call") - optional_params.remove("response_format") - return optional_params - def get_complete_url( self, api_base: Optional[str], diff --git a/litellm/llms/recraft/image_edit/transformation.py b/litellm/llms/recraft/image_edit/transformation.py index 4c199bc78d8..1dccd406058 100644 --- a/litellm/llms/recraft/image_edit/transformation.py +++ b/litellm/llms/recraft/image_edit/transformation.py @@ -81,6 +81,8 @@ class RecraftImageEditConfig(BaseImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: final_api_key: Optional[str] = api_key or get_secret_str("RECRAFT_API_KEY") if not final_api_key: diff --git a/litellm/llms/s3_vectors/vector_stores/transformation.py b/litellm/llms/s3_vectors/vector_stores/transformation.py index 11836e361ef..19b59769863 100644 --- a/litellm/llms/s3_vectors/vector_stores/transformation.py +++ b/litellm/llms/s3_vectors/vector_stores/transformation.py @@ -1,3 +1,4 @@ +import re from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union import httpx @@ -66,6 +67,8 @@ class S3VectorsVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM): aws_region_name = litellm_params.get("aws_region_name") if not aws_region_name: raise ValueError("aws_region_name is required for S3 Vectors") + if not re.match(r"^[a-z][a-z0-9-]*$", aws_region_name): + raise ValueError("Invalid aws_region_name format") return f"https://s3vectors.{aws_region_name}.api.aws" def transform_search_vector_store_request( diff --git a/litellm/llms/scaleway/audio_transcription/transformation.py b/litellm/llms/scaleway/audio_transcription/transformation.py new file mode 100644 index 00000000000..b45f287afb4 --- /dev/null +++ b/litellm/llms/scaleway/audio_transcription/transformation.py @@ -0,0 +1,158 @@ +""" +Support for Scaleway's OpenAI-compatible `/v1/audio/transcriptions` endpoint. + +API reference: https://www.scaleway.com/en/developers/api/generative-apis/#path-audio-create-an-audio-transcription +""" + +from typing import List, Optional, Union + +import httpx + +from litellm.litellm_core_utils.audio_utils.utils import process_audio_file +from litellm.llms.base_llm.audio_transcription.transformation import ( + AudioTranscriptionRequestData, + BaseAudioTranscriptionConfig, +) +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.secret_managers.main import get_secret_str +from litellm.types.llms.openai import ( + AllMessageValues, + OpenAIAudioTranscriptionOptionalParams, +) +from litellm.types.utils import FileTypes, TranscriptionResponse + + +class ScalewayAudioTranscriptionException(BaseLLMException): + pass + + +class ScalewayAudioTranscriptionConfig(BaseAudioTranscriptionConfig): + def get_supported_openai_params( + self, model: str + ) -> List[OpenAIAudioTranscriptionOptionalParams]: + return [ + "language", + "prompt", + "response_format", + "temperature", + "timestamp_granularities", + ] + + def map_openai_params( + self, + non_default_params: dict, + optional_params: dict, + model: str, + drop_params: bool, + ) -> dict: + supported_params = self.get_supported_openai_params(model) + for k, v in non_default_params.items(): + if k in supported_params: + optional_params[k] = v + return optional_params + + def get_complete_url( + self, + api_base: Optional[str], + api_key: Optional[str], + model: str, + optional_params: dict, + litellm_params: dict, + stream: Optional[bool] = None, + ) -> str: + api_base = ( + "https://api.scaleway.ai/v1" if api_base is None else api_base.rstrip("/") + ) + return f"{api_base}/audio/transcriptions" + + def get_error_class( + self, error_message: str, status_code: int, headers: Union[dict, httpx.Headers] + ) -> BaseLLMException: + return ScalewayAudioTranscriptionException( + message=error_message, + status_code=status_code, + headers=headers, + ) + + def validate_environment( + self, + headers: dict, + model: str, + messages: List[AllMessageValues], + optional_params: dict, + litellm_params: dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + ) -> dict: + if api_key is None: + api_key = get_secret_str("SCW_SECRET_KEY") + + if not api_key: + raise ScalewayAudioTranscriptionException( + message=( + "Scaleway API key not found. Pass `api_key=...` or set the " + "SCW_SECRET_KEY environment variable." + ), + status_code=401, + headers={}, + ) + + default_headers = { + "Authorization": f"Bearer {api_key}", + "accept": "application/json", + } + default_headers.update(headers or {}) + return default_headers + + def transform_audio_transcription_request( + self, + model: str, + audio_file: FileTypes, + optional_params: dict, + litellm_params: dict, + ) -> AudioTranscriptionRequestData: + processed_audio = process_audio_file(audio_file) + + form_fields: dict = {"model": model} + for key in self.get_supported_openai_params(model): + value = optional_params.get(key) + if value is not None: + form_fields[key] = value + + files = { + "file": ( + processed_audio.filename, + processed_audio.file_content, + processed_audio.content_type, + ) + } + + return AudioTranscriptionRequestData(data=form_fields, files=files) + + def transform_audio_transcription_response( + self, + raw_response: httpx.Response, + ) -> TranscriptionResponse: + content_type = (raw_response.headers.get("content-type") or "").lower() + if "application/json" not in content_type: + return TranscriptionResponse(text=raw_response.text) + + try: + response_json = raw_response.json() + except Exception: + raise ScalewayAudioTranscriptionException( + message=raw_response.text, + status_code=raw_response.status_code, + headers=raw_response.headers, + ) + + text = response_json.get("text") or "" + response = TranscriptionResponse(text=text) + + if "segments" in response_json: + response["segments"] = response_json["segments"] + if "language" in response_json: + response["language"] = response_json["language"] + + response._hidden_params = response_json + return response diff --git a/litellm/llms/snowflake/utils.py b/litellm/llms/snowflake/utils.py index 9d458f6ece3..d84efdd9fcd 100644 --- a/litellm/llms/snowflake/utils.py +++ b/litellm/llms/snowflake/utils.py @@ -1,3 +1,4 @@ +import re from typing import TYPE_CHECKING, Any, List, Optional, Tuple from litellm.secret_managers.main import get_secret_str @@ -61,6 +62,8 @@ class SnowflakeBaseConfig: account_id = get_secret_str("SNOWFLAKE_ACCOUNT_ID") if account_id is None: raise ValueError("Missing snowflake account_id") + if not re.match(r"^[a-zA-Z0-9_-]+$", account_id): + raise ValueError("Invalid account_id format") api_base = f"https://{account_id}.snowflakecomputing.com/api/v2" api_base = api_base.rstrip("/") diff --git a/litellm/llms/stability/image_edit/transformations.py b/litellm/llms/stability/image_edit/transformations.py index eb400a2526e..522858b8c2a 100644 --- a/litellm/llms/stability/image_edit/transformations.py +++ b/litellm/llms/stability/image_edit/transformations.py @@ -149,6 +149,8 @@ class StabilityImageEditConfig(BaseImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: """ Validate environment and set up headers for Stability AI. diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 43e77f4fb75..ccd4d4f2934 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -229,11 +229,20 @@ def get_vertex_base_url( ) -> str: """ Get the base URL for Vertex AI API calls. + + - ``global`` uses the global control plane host. + - Multi-region geographies (e.g. ``us``, ``eu``) use ``aiplatform.{geo}.rep.googleapis.com``. + - Regional locations (e.g. ``us-central1``) use ``{region}-aiplatform.googleapis.com``. """ if vertex_location == "global": return "https://aiplatform.googleapis.com" - else: - return f"https://{vertex_location}-aiplatform.googleapis.com" + if vertex_location is None: + raise ValueError("vertex_location is required") + if not re.match(r"^[a-z][a-z0-9-]*$", vertex_location): + raise ValueError("Invalid vertex_location format") + if "-" not in vertex_location: + return f"https://aiplatform.{vertex_location}.rep.googleapis.com" + return f"https://{vertex_location}-aiplatform.googleapis.com" def _get_embedding_url( diff --git a/litellm/llms/vertex_ai/gemini/transformation.py b/litellm/llms/vertex_ai/gemini/transformation.py index d0f0e5c1e24..533bd06d2d8 100644 --- a/litellm/llms/vertex_ai/gemini/transformation.py +++ b/litellm/llms/vertex_ai/gemini/transformation.py @@ -132,26 +132,28 @@ def _extract_max_media_resolution_from_messages( return max_resolution -def _apply_gemini_3_metadata( +def _apply_gemini_metadata( part: PartType, model: Optional[str], media_resolution_enum: Optional[Dict[str, str]], video_metadata: Optional[Dict[str, Any]], ) -> PartType: """ - Apply the unique media_resolution and video_metadata parameters of Gemini 3+ + Apply media_resolution and video_metadata parameters to a Gemini part. + + - Per-part media_resolution: Gemini 3+ only (2.x uses generation_config global). + - video_metadata (fps, startOffset, endOffset): all Gemini models (1.x, 2.x, 3+). """ if model is None: return part from .vertex_and_google_ai_studio_gemini import VertexGeminiConfig - if not VertexGeminiConfig._is_gemini_3_or_newer(model): - return part - part_dict = dict(part) - if media_resolution_enum is not None: + if media_resolution_enum is not None and VertexGeminiConfig._is_gemini_3_or_newer( + model + ): part_dict["media_resolution"] = media_resolution_enum if video_metadata is not None: @@ -206,7 +208,7 @@ def _process_gemini_media( mime_type = format file_data = FileDataType(mime_type=mime_type, file_uri=image_url) part: PartType = {"file_data": file_data} - return _apply_gemini_3_metadata( + return _apply_gemini_metadata( part, model, media_resolution_enum, video_metadata ) elif ( @@ -216,14 +218,14 @@ def _process_gemini_media( ): file_data = FileDataType(mime_type=image_type, file_uri=image_url) part = {"file_data": file_data} - return _apply_gemini_3_metadata( + return _apply_gemini_metadata( part, model, media_resolution_enum, video_metadata ) elif "http://" in image_url or "https://" in image_url or "base64" in image_url: image = convert_to_anthropic_image_obj(image_url, format=format) _blob: BlobType = {"data": image["data"], "mime_type": image["media_type"]} part = {"inline_data": cast(BlobType, _blob)} - return _apply_gemini_3_metadata( + return _apply_gemini_metadata( part, model, media_resolution_enum, video_metadata ) raise Exception("Invalid image received - {}".format(image_url)) @@ -733,9 +735,9 @@ def _transform_request_body( # noqa: PLR0915 **filtered_params ) - # For Gemini 2.x models, add media_resolution to generation_config (global) - # Gemini 3+ supports per-part media_resolution, but 2.x only supports global - # Gemini 1.x does not support mediaResolution at all + # For Gemini 2.x models, also add media_resolution to generation_config (global) + # as a fallback, since some 2.x versions may not support per-part media_resolution. + # Gemini 1.x does not support mediaResolution at all. if "gemini-2" in model: max_media_resolution = _extract_max_media_resolution_from_messages(messages) if max_media_resolution: diff --git a/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py b/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py index 7979e0e7901..3eb039614fd 100644 --- a/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py +++ b/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py @@ -103,10 +103,24 @@ class VertexAIImagenImageEditConfig(BaseImageEditConfig, VertexLLM): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: headers = headers or {} - vertex_project = self._resolve_vertex_project() - vertex_credentials = self._resolve_vertex_credentials() + litellm_params = litellm_params or {} + + _api_base = litellm_params.get("api_base") or api_base + if _api_base is not None: + return headers + + vertex_project = ( + self.safe_get_vertex_ai_project(litellm_params) + or self._resolve_vertex_project() + ) + vertex_credentials = ( + self.safe_get_vertex_ai_credentials(litellm_params) + or self._resolve_vertex_credentials() + ) access_token, _ = self._ensure_access_token( credentials=vertex_credentials, project_id=vertex_project, @@ -123,8 +137,14 @@ class VertexAIImagenImageEditConfig(BaseImageEditConfig, VertexLLM): """ Get the complete URL for Vertex AI Imagen predict API """ - vertex_project = self._resolve_vertex_project() - vertex_location = self._resolve_vertex_location() + vertex_project = ( + self.safe_get_vertex_ai_project(litellm_params) + or self._resolve_vertex_project() + ) + vertex_location = ( + self.safe_get_vertex_ai_location(litellm_params) + or self._resolve_vertex_location() + ) if not vertex_project or not vertex_location: raise ValueError( @@ -348,13 +368,16 @@ class VertexAIImagenImageEditConfig(BaseImageEditConfig, VertexLLM): if stream_pos is not None: image.seek(stream_pos) return data - if isinstance(image, (str, Path)): - path_obj = Path(image) - if not path_obj.exists(): - raise ValueError( - f"Mask/image path does not exist for Vertex AI Imagen image edit: {path_obj}" - ) - return path_obj.read_bytes() + if isinstance(image, str): + raise ValueError( + "Unsupported image input: plain string values are not accepted for " + "Vertex AI Imagen image edit. Provide image bytes or a file-like object." + ) + if isinstance(image, Path): + raise ValueError( + "Unsupported image input: filesystem paths are not accepted for " + "Vertex AI Imagen image edit. Provide image bytes or a file-like object." + ) if hasattr(image, "read"): data = image.read() if isinstance(data, str): diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 479baec47b1..59b6729dccb 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -1006,7 +1006,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "global.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.25e-06, @@ -1034,7 +1035,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "us.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.875e-06, @@ -1062,7 +1064,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "eu.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.875e-06, @@ -1090,7 +1093,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "au.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.875e-06, @@ -1118,7 +1122,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.25e-06, @@ -1146,7 +1151,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "global.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.25e-06, @@ -1174,7 +1181,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "us.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.875e-06, @@ -1202,7 +1211,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "eu.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.875e-06, @@ -1230,7 +1241,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "au.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.875e-06, @@ -1258,7 +1271,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 3.75e-06, @@ -1285,7 +1300,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "global.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 3.75e-06, @@ -1312,7 +1328,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "us.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 4.125e-06, @@ -1339,7 +1356,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "eu.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 4.125e-06, @@ -1366,7 +1384,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "au.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 4.125e-06, @@ -1393,7 +1412,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -1911,7 +1931,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "azure_ai/claude-opus-4-7": { "input_cost_per_token": 5e-06, @@ -1939,7 +1960,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 159 + "tool_use_system_prompt_tokens": 159, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "azure_ai/claude-opus-4-1": { "cache_creation_input_token_cost": 1.875e-05, @@ -2003,7 +2026,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_minimal_reasoning_effort": true }, "azure/computer-use-preview": { "input_cost_per_token": 3e-06, @@ -8981,7 +9005,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_minimal_reasoning_effort": true }, "claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -9175,7 +9200,8 @@ "us": 1.1, "fast": 6.0 }, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "claude-opus-4-6-20260205": { "cache_creation_input_token_cost": 6.25e-06, @@ -9207,7 +9233,8 @@ "us": 1.1, "fast": 6.0 }, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "claude-opus-4-7": { "cache_creation_input_token_cost": 6.25e-06, @@ -9239,7 +9266,9 @@ "provider_specific_entry": { "us": 1.1, "fast": 6.0 - } + }, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "claude-opus-4-7-20260416": { "cache_creation_input_token_cost": 6.25e-06, @@ -9271,7 +9300,9 @@ "provider_specific_entry": { "us": 1.1, "fast": 6.0 - } + }, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "claude-sonnet-4-20250514": { "deprecation_date": "2026-05-14", @@ -10424,6 +10455,22 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "dashscope/qwen-image-2.0": { + "litellm_provider": "dashscope", + "mode": "image_generation", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "dashscope/qwen-image-2.0-pro": { + "litellm_provider": "dashscope", + "mode": "image_generation", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, "databricks/databricks-bge-large-en": { "input_cost_per_token": 1.0003e-07, "input_dbu_cost_per_token": 1.429e-06, @@ -15122,6 +15169,21 @@ "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", "uses_embed_content": true }, + "gemini-embedding-2": { + "input_cost_per_audio_per_second": 0.00016, + "input_cost_per_image": 0.00012, + "input_cost_per_token": 2e-07, + "input_cost_per_video_per_second": 0.00079, + "litellm_provider": "vertex_ai-embedding-models", + "max_input_tokens": 8192, + "max_tokens": 8192, + "mode": "embedding", + "output_cost_per_token": 0, + "output_vector_size": 3072, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supports_multimodal": true, + "uses_embed_content": true + }, "vertex_ai/gemini-embedding-2-preview": { "input_cost_per_audio_per_second": 0.00016, "input_cost_per_image": 0.00012, @@ -15137,6 +15199,21 @@ "supports_multimodal": true, "uses_embed_content": true }, + "vertex_ai/gemini-embedding-2": { + "input_cost_per_audio_per_second": 0.00016, + "input_cost_per_image": 0.00012, + "input_cost_per_token": 2e-07, + "input_cost_per_video_per_second": 0.00079, + "litellm_provider": "vertex_ai", + "max_input_tokens": 8192, + "max_tokens": 8192, + "mode": "embedding", + "output_cost_per_token": 0, + "output_vector_size": 3072, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supports_multimodal": true, + "uses_embed_content": true + }, "gemini-flash-experimental": { "input_cost_per_character": 0, "input_cost_per_token": 0, @@ -15178,6 +15255,22 @@ "supports_multimodal": true, "tpm": 10000000 }, + "gemini/gemini-embedding-2": { + "input_cost_per_audio_per_second": 0.00016, + "input_cost_per_image": 0.00012, + "input_cost_per_token": 2e-07, + "input_cost_per_video_per_second": 0.00079, + "litellm_provider": "gemini", + "max_input_tokens": 8192, + "max_tokens": 8192, + "mode": "embedding", + "output_cost_per_token": 0, + "output_vector_size": 3072, + "rpm": 10000, + "source": "https://ai.google.dev/gemini-api/docs/pricing", + "supports_multimodal": true, + "tpm": 10000000 + }, "gemini/gemini-1.5-flash": { "deprecation_date": "2025-09-29", "input_cost_per_token": 7.5e-08, @@ -19298,6 +19391,42 @@ "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, + "gpt-5.5": { + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_service_tier": true, + "supports_vision": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": true + }, "gpt-5.4": { "cache_read_input_token_cost": 2.5e-07, "cache_read_input_token_cost_above_272k_tokens": 5e-07, @@ -23033,6 +23162,22 @@ "supports_video_input": true, "supports_vision": true }, + "moonshot/kimi-k2.6": { + "cache_read_input_token_cost": 1.6e-07, + "input_cost_per_token": 9.5e-07, + "litellm_provider": "moonshot", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 4e-06, + "source": "https://platform.kimi.ai/docs/pricing/chat-k26", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_video_input": true, + "supports_vision": true + }, "moonshot/kimi-latest": { "cache_read_input_token_cost": 1.5e-07, "input_cost_per_token": 2e-06, @@ -25213,7 +25358,8 @@ "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "tool_use_system_prompt_tokens": 159, + "supports_minimal_reasoning_effort": true }, "openrouter/anthropic/claude-opus-4.5": { "cache_creation_input_token_cost": 6.25e-06, @@ -25251,7 +25397,8 @@ "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_minimal_reasoning_effort": true }, "openrouter/anthropic/claude-sonnet-4.5": { "input_cost_per_image": 0.0048, @@ -30279,7 +30426,8 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_minimal_reasoning_effort": true }, "vercel_ai_gateway/anthropic/claude-sonnet-4": { "cache_creation_input_token_cost": 3.75e-06, @@ -31506,7 +31654,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-opus-4-6@default": { "cache_creation_input_token_cost": 6.25e-06, @@ -31533,7 +31682,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-opus-4-7": { "cache_creation_input_token_cost": 6.25e-06, @@ -31560,7 +31710,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-opus-4-7@default": { "cache_creation_input_token_cost": 6.25e-06, @@ -31587,7 +31739,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-sonnet-4-5": { "cache_creation_input_token_cost": 3.75e-06, @@ -31639,7 +31793,8 @@ "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 - } + }, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-sonnet-4-5@20250929": { "cache_creation_input_token_cost": 3.75e-06, @@ -38506,7 +38661,8 @@ "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 - } + }, + "supports_minimal_reasoning_effort": true }, "duckduckgo/search": { "litellm_provider": "duckduckgo", diff --git a/litellm/passthrough/utils.py b/litellm/passthrough/utils.py index 5dde13f0078..d39a0dda152 100644 --- a/litellm/passthrough/utils.py +++ b/litellm/passthrough/utils.py @@ -79,7 +79,9 @@ class BasePassthroughUtils: for header_name, header_value in request_headers.items(): if header_name.lower().startswith(PASS_THROUGH_HEADER_PREFIX): # Strip the 'x-pass-' prefix and normalize to lowercase - actual_header_name = header_name[len(PASS_THROUGH_HEADER_PREFIX) :].lower() + actual_header_name = header_name[ + len(PASS_THROUGH_HEADER_PREFIX) : + ].lower() if actual_header_name in _PASS_THROUGH_PROTECTED_HEADERS or any( actual_header_name.startswith(p) for p in _PASS_THROUGH_PROTECTED_HEADER_PREFIXES diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index ed54c707b00..0562b41d2cd 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -1950,7 +1950,7 @@ "responses": true, "embeddings": false, "image_generations": false, - "audio_transcriptions": false, + "audio_transcriptions": true, "audio_speech": false, "moderations": false, "batches": false, diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index 65e2e3e983d..792a9dace1e 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -323,6 +323,14 @@ async def authorize_with_server( ) parsed = urlparse(redirect_uri) + if parsed.scheme not in ("http", "https"): + raise HTTPException( + status_code=400, + detail={ + "error": "invalid_redirect_uri", + "message": "redirect_uri must use http or https scheme", + }, + ) base_url = urlunparse(parsed._replace(query="")) request_base_url = get_request_base_url(request) encoded_state = encode_state_with_base_url( diff --git a/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py b/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py index 0e32bfd7026..a9c4d2ece46 100644 --- a/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py +++ b/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py @@ -7,6 +7,7 @@ Filters MCP tools semantically for /chat/completions and /responses endpoints. from typing import TYPE_CHECKING, Any, Dict, List, Optional from litellm._logging import verbose_logger +from litellm.proxy._experimental.mcp_server.utils import MCP_TOOL_PREFIX_SEPARATOR if TYPE_CHECKING: from semantic_router.routers import SemanticRouter @@ -214,20 +215,89 @@ class SemanticMCPToolFilter: return [] + @staticmethod + def _name_matches_canonical(client_name: str, canonical: str) -> bool: + """ + Return True if a client-side tool name refers to the given canonical + MCP tool name. + + MCP clients (e.g. opencode) commonly wrap the proxy's canonical tool + name with an additive namespace prefix of their own + (````). The prefix can use either a + dash or an underscore as separator regardless of what + ``MCP_TOOL_PREFIX_SEPARATOR`` is set to on the proxy, because the + client doesn't know the proxy's separator. + + The match is anchored: ``canonical`` must form the complete suffix + of ``client_name`` and be preceded by a separator character, so + ``rain_gear`` does not match canonical ``ear``. + + Suffix matching is additionally gated on ``canonical`` itself + containing ``MCP_TOOL_PREFIX_SEPARATOR``. Server-registered MCP + tools are always emitted as + ```` (see + ``add_server_prefix_to_name``), so a canonical without the + separator is not a namespaced MCP tool and falling back to + suffix matching would spuriously collide with unrelated local + user functions whose names end in the same characters. + """ + if client_name == canonical: + return True + if MCP_TOOL_PREFIX_SEPARATOR not in canonical: + return False + if len(client_name) <= len(canonical): + return False + if not client_name.endswith(canonical): + return False + separator = client_name[-len(canonical) - 1] + return separator in ("_", "-") + def _get_tools_by_names( self, tool_names: List[str], available_tools: List[Any] ) -> List[Any]: - """Get tools from available_tools by their names, preserving order.""" - # Match tools from available_tools (preserves format - dict or MCPTool) - matched_tools = [] - for tool in available_tools: - tool_name, _ = self._extract_tool_info(tool) - if tool_name in tool_names: - matched_tools.append(tool) + """ + Get tools from available_tools by their names, preserving the + semantic router's ordering. - # Reorder to match semantic router's ordering - tool_map = {self._extract_tool_info(t)[0]: t for t in matched_tools} - return [tool_map[name] for name in tool_names if name in tool_map] + Matching is tolerant of client-side namespace prefixes: if an + incoming tool arrived as ``_`` while the + router returned ```` (see + ``_name_matches_canonical``), that tool is still selected. The + returned tool object is the original from ``available_tools``, so + the client-facing name is preserved for tool-call round-trips. + """ + # Build an index of incoming tools by their client-facing name. + # Exact matches win over suffix matches when both are present, and + # each incoming tool is returned at most once even if two canonical + # names happen to be tail-compatible with the same incoming name. + available_by_name: Dict[str, Any] = {} + for tool in available_tools: + client_name, _ = self._extract_tool_info(tool) + if client_name and client_name not in available_by_name: + available_by_name[client_name] = tool + + matched: List[Any] = [] + used_ids: set = set() + for canonical in tool_names: + tool = available_by_name.get(canonical) + if tool is None: + # Prefer the shortest qualifying name. When several + # incoming tools suffix-match the same canonical (e.g. + # "my_search" and "my_tag_search" both end in "search"), + # the one closest in length to the canonical is the + # least-wrapped and most likely the intended target. + best_name: Optional[str] = None + for client_name in available_by_name: + if not self._name_matches_canonical(client_name, canonical): + continue + if best_name is None or len(client_name) < len(best_name): + best_name = client_name + if best_name is not None: + tool = available_by_name[best_name] + if tool is not None and id(tool) not in used_ids: + matched.append(tool) + used_ids.add(id(tool)) + return matched def extract_user_query(self, messages: List[Dict[str, Any]]) -> str: """ diff --git a/litellm/proxy/_new_secret_config.yaml b/litellm/proxy/_new_secret_config.yaml index 604e7d5f418..703fe6adc41 100644 --- a/litellm/proxy/_new_secret_config.yaml +++ b/litellm/proxy/_new_secret_config.yaml @@ -1,32 +1,83 @@ +# model_list: +# - model_name: claude-sonnet-4-6 +# litellm_params: {model: anthropic/claude-sonnet-4-6} +# model_info: +# litellm_routing_preferences: +# quality_tier: 1 +# keywords: [tin] +# - model_name: gpt-4o-mini +# litellm_params: {model: openai/gpt-4o-mini} +# model_info: +# litellm_routing_preferences: +# quality_tier: 1 +# keywords: [] +# - model_name: gpt-4o +# litellm_params: {model: openai/gpt-4o} +# model_info: +# litellm_routing_preferences: +# quality_tier: 2 +# keywords: [vision, function_calling] +# - model_name: opus +# litellm_params: {model: anthropic/claude-opus-4-7} +# model_info: +# litellm_routing_preferences: +# quality_tier: 3 +# keywords: ["architecture", "design"] +# - model_name: my-quality-router +# litellm_params: +# model: auto_router/adaptive_router +# adaptive_router_default_model: gpt-4o-mini +# adaptive_router_config: +# available_models: [gpt-4o-mini, gpt-4o, opus, claude-sonnet-4-6] +# Example proxy config for the adaptive router (v0). +# +# Wires one logical router ("smart-cheap-router") that adaptively picks between +# two real deployments ("fast" and "smart") based on per-session feedback signals. +# +# How to use from a client: +# POST /v1/chat/completions { "model": "smart-cheap-router", ... } +# Add { "metadata": { "litellm_session_id": "" } } to enable +# sticky-session routing within a conversation. +# +# Required env vars: OPENAI_API_KEY, DATABASE_URL. + model_list: - - # OpenAI model for /v1/chat/completions test — 200x custom pricing - - model_name: "gpt-4.1-mini" + # ---- The adaptive router "control" deployment ------------------------- + # `model_name` is what clients call. `available_models` lists the underlying + # deployments the router is allowed to pick from (must match other model_name + # entries in this list). + - model_name: smart-cheap-router litellm_params: - model: openai/gpt-4.1-mini - api_key: os.environ/OPENAI_API_KEY - model_info: - id: gpt-4.1-mini-custom-pricing - input_cost_per_token: 0.00004 # 100x standard ($0.40/1M = $0.0000004) - output_cost_per_token: 0.00016 # 100x standard ($1.60/1M = $0.0000016) + model: auto_router/adaptive_router + adaptive_router_config: + available_models: ["fast", "smart"] + weights: + quality: 0.7 + cost: 0.3 - # OpenAI model for /v1/responses test — 100x custom pricing - - model_name: "gpt-5" + # ---- Underlying deployments the router picks from --------------------- + - model_name: fast litellm_params: - model: openai/gpt-5 - api_key: os.environ/OPENAI_API_KEY - model_info: - id: gpt-5-custom-pricing - mode: "chat" - input_cost_per_token: 125 # 100x standard ($1.25/1M = $0.00000125) - output_cost_per_token: 10 # 100x standard ($10.00/1M = $0.00001) - - # Anthropic model for /v1/messages test — 100x custom pricing - - model_name: "claude-sonnet-4-20250514" - litellm_params: - model: anthropic/claude-sonnet-4-20250514 + model: anthropic/claude-sonnet-4-6 api_key: os.environ/ANTHROPIC_API_KEY + input_cost_per_token: 0.00000015 model_info: - id: claude-sonnet-4-custom-pricing - input_cost_per_token: 0.0003 # 100x standard ($0.000003) - output_cost_per_token: 0.0015 # 100x standard ($0.000015) \ No newline at end of file + adaptive_router_preferences: + quality_tier: 2 + strengths: [] + + - model_name: smart + litellm_params: + model: anthropic/claude-opus-4-7 + api_key: os.environ/ANTHROPIC_API_KEY + input_cost_per_token: 0.0000050 + model_info: + adaptive_router_preferences: + quality_tier: 3 + strengths: ["code_generation", "technical_design", "analytical_reasoning"] + +litellm_settings: + drop_params: True + +general_settings: + master_key: sk-1234 # REPLACE in production diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 85d3df71890..b8e0aa0d128 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -427,7 +427,8 @@ class LiteLLMRoutes(enum.Enum): "/v1/skills/{skill_id}", ] - mcp_routes = [ + # MCP tool-call / passthrough routes — data-plane. Gated by DISABLE_LLM_API_ENDPOINTS. + mcp_inference_routes = [ "/mcp", "/mcp/", "/mcp/{subpath}", @@ -436,10 +437,18 @@ class LiteLLMRoutes(enum.Enum): "/mcp/tools/call", "/mcp-rest/tools/list", "/mcp-rest/tools/call", + ] + + # MCP server CRUD routes — control-plane. Gated by DISABLE_ADMIN_ENDPOINTS. + mcp_management_routes = [ "/v1/mcp/server", "/v1/mcp/server/{path:path}", ] + # Backwards-compat union — virtual keys may be configured with + # allowed_routes=["mcp_routes"], which should cover both halves. + mcp_routes = mcp_inference_routes + mcp_management_routes + agent_routes = [ "/v1/agents", "/v1/agents/{agent_id}", @@ -477,7 +486,7 @@ class LiteLLMRoutes(enum.Enum): + mapped_pass_through_routes + passthrough_routes_wildcard + apply_guardrail_routes - + mcp_routes + + mcp_inference_routes + litellm_native_routes + agent_routes ) @@ -530,40 +539,44 @@ class LiteLLMRoutes(enum.Enum): KeyManagementRoutes.KEY_ALIASES.value, ] - management_routes = [ - # user - "/user/new", - "/user/update", - "/user/bulk_update", - "/user/delete", - "/user/info", - "/user/list", - "/user/daily/activity", - "/user/daily/activity/aggregated", - # team - "/team/new", - "/team/update", - "/team/delete", - "/team/list", - "/v2/team/list", - "/team/info", - "/team/block", - "/team/unblock", - "/team/available", - "/team/permissions_list", - "/team/permissions_update", - "/team/daily/activity", - # model - "/model/new", - "/model/update", - "/model/delete", - "/model/info", - "/jwt/key/mapping/new", - "/jwt/key/mapping/update", - "/jwt/key/mapping/delete", - "/jwt/key/mapping/list", - "/jwt/key/mapping/info", - ] + key_management_routes + management_routes = ( + [ + # user + "/user/new", + "/user/update", + "/user/bulk_update", + "/user/delete", + "/user/info", + "/user/list", + "/user/daily/activity", + "/user/daily/activity/aggregated", + # team + "/team/new", + "/team/update", + "/team/delete", + "/team/list", + "/v2/team/list", + "/team/info", + "/team/block", + "/team/unblock", + "/team/available", + "/team/permissions_list", + "/team/permissions_update", + "/team/daily/activity", + # model + "/model/new", + "/model/update", + "/model/delete", + "/model/info", + "/jwt/key/mapping/new", + "/jwt/key/mapping/update", + "/jwt/key/mapping/delete", + "/jwt/key/mapping/list", + "/jwt/key/mapping/info", + ] + + key_management_routes + + mcp_management_routes + ) spend_tracking_routes = [ # spend @@ -1997,7 +2010,12 @@ class TeamRequest(LiteLLMPydanticObjectBase): class LiteLLM_BudgetTable(LiteLLMPydanticObjectBase): - """Represents user-controllable params for a LiteLLM_BudgetTable record""" + """Represents user-controllable params for a LiteLLM_BudgetTable record. + + Budget-write paths use `model_fields.keys()` on this class as an allowlist + for user input. Keep server-managed fields (e.g. `budget_reset_at`) on + `LiteLLM_BudgetTableFull` so they aren't user-settable. + """ budget_id: Optional[str] = None soft_budget: Optional[float] = None @@ -2015,7 +2033,7 @@ class LiteLLM_BudgetTable(LiteLLMPydanticObjectBase): class LiteLLM_BudgetTableFull(LiteLLM_BudgetTable): - """Represents all params for a LiteLLM_BudgetTable record""" + """LiteLLM_BudgetTable + server-managed fields returned on API responses.""" budget_reset_at: Optional[datetime] = None created_at: datetime @@ -2551,6 +2569,9 @@ class UserAPIKeyAuth( None # Expanded created_by user when expand=user is used ) end_user_object_permission: Optional[LiteLLM_ObjectPermissionTable] = None + # Team object_permission preloaded in auth (e.g. get_team_object) to avoid + # per-request object_permission fetches in downstream checks (vector stores, etc.) + team_object_permission: Optional[LiteLLM_ObjectPermissionTable] = None # Decoded upstream IdP claims (groups, roles, etc.) propagated by JWT auth machinery # and forwarded into outbound tokens by guardrails such as MCPJWTSigner. jwt_claims: Optional[Dict] = None @@ -3695,7 +3716,11 @@ class LiteLLM_TeamMembership(LiteLLMPydanticObjectBase): team_id: str budget_id: Optional[str] = None spend: Optional[float] = 0.0 - litellm_budget_table: Optional[LiteLLM_BudgetTable] + total_spend: Optional[float] = 0.0 + # Union so Pydantic picks Full when data has server-managed fields + # (/team/info) and Base when callers/tests construct with only + # user-settable fields. + litellm_budget_table: Optional[Union[LiteLLM_BudgetTableFull, LiteLLM_BudgetTable]] def safe_get_team_member_rpm_limit(self) -> Optional[int]: if self.litellm_budget_table is not None: @@ -3898,7 +3923,7 @@ class OrganizationMemberUpdateResponse(MemberUpdateResponse): class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable): - team_member_budget_table: Optional[LiteLLM_BudgetTable] = None + team_member_budget_table: Optional[LiteLLM_BudgetTableFull] = None # Resources inherited from access groups (separate from direct assignments) access_group_models: Optional[List[str]] = None access_group_mcp_server_ids: Optional[List[str]] = None diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index d245ec53ece..840f64cfede 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -626,11 +626,17 @@ async def common_checks( # noqa: PLR0915 and user_object.max_budget is not None ): user_budget = user_object.max_budget - if user_budget < user_object.spend: + from litellm.proxy.proxy_server import get_current_spend + + user_spend = await get_current_spend( + counter_key=f"spend:user:{user_object.user_id}", + fallback_spend=user_object.spend or 0.0, + ) + if user_spend >= user_budget: raise litellm.BudgetExceededError( - current_cost=user_object.spend, + current_cost=user_spend, max_budget=user_budget, - message=f"ExceededBudget: User={user_object.user_id} over budget. Spend={user_object.spend}, Budget={user_budget}", + message=f"ExceededBudget: User={user_object.user_id} over budget. Spend={user_spend}, Budget={user_budget}", ) ## 4.2 check team member budget, if team key @@ -899,6 +905,63 @@ async def get_default_end_user_budget( return None +@log_db_metrics +async def get_team_member_default_budget( + budget_id: str, + prisma_client: Optional[PrismaClient], + user_api_key_cache: DualCache, +) -> Optional[LiteLLM_BudgetTable]: + """ + Fetches the team-level default per-member budget referenced by team.metadata["team_member_budget_id"]. + + This budget is applied to team members whose TeamMembership row has no + linked budget. Results are cached for performance. + + Args: + budget_id: The budget_id pulled from team.metadata["team_member_budget_id"] + prisma_client: Database client instance + user_api_key_cache: Cache for storing/retrieving budget data + + Returns: + LiteLLM_BudgetTable if found, None otherwise + """ + if prisma_client is None: + return None + + cache_key = f"team_member_default_budget:{budget_id}" + + cached_budget = await user_api_key_cache.async_get_cache(key=cache_key) + if isinstance(cached_budget, LiteLLM_BudgetTable): + return cached_budget + if isinstance(cached_budget, dict): + return LiteLLM_BudgetTable(**cached_budget) + + try: + budget_record = await prisma_client.db.litellm_budgettable.find_unique( + where={"budget_id": budget_id} + ) + + if budget_record is None: + verbose_proxy_logger.warning( + f"Team-default member budget not found in database: {budget_id}" + ) + return None + + await user_api_key_cache.async_set_cache( + key=cache_key, + value=budget_record.dict(), + ttl=DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL, + ) + + return LiteLLM_BudgetTable(**budget_record.dict()) + + except Exception: + verbose_proxy_logger.exception( + f"Error fetching team-default member budget {budget_id}" + ) + return None + + async def _apply_default_budget_to_end_user( end_user_obj: LiteLLM_EndUserTable, prisma_client: PrismaClient, @@ -3126,9 +3189,7 @@ async def _virtual_key_max_budget_alert_check( alert_email_config: Optional[Dict[str, List[str]]] = ( _merge_budget_alert_email_configs( global_cfg=litellm.default_key_max_budget_alert_emails, - per_key_cfg=(valid_token.metadata or {}).get( - "max_budget_alert_emails" - ), + per_key_cfg=(valid_token.metadata or {}).get("max_budget_alert_emails"), ) ) @@ -3138,7 +3199,9 @@ async def _virtual_key_max_budget_alert_check( (int(k) for k in alert_email_config if k.isdigit()), default=None, ) - if min_pct is None or valid_token.spend < valid_token.max_budget * (min_pct / 100.0): + if min_pct is None or valid_token.spend < valid_token.max_budget * ( + min_pct / 100.0 + ): return call_info = CallInfo( @@ -3164,8 +3227,7 @@ async def _virtual_key_max_budget_alert_check( else: # Old path: existing single 80% threshold — completely unchanged alert_threshold = ( - valid_token.max_budget - * EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE + valid_token.max_budget * EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE ) if ( @@ -3225,13 +3287,31 @@ async def _check_team_member_budget( proxy_logging_obj=proxy_logging_obj, ) + # Per-member override wins; otherwise fall back to the team-level + # default configured via team.metadata["team_member_budget_id"]. + team_member_budget: Optional[float] = None if ( team_membership is not None and team_membership.litellm_budget_table is not None - and team_membership.litellm_budget_table.max_budget is not None ): team_member_budget = team_membership.litellm_budget_table.max_budget - team_member_spend = team_membership.spend or 0.0 + else: + default_budget_id = (team_object.metadata or {}).get( + "team_member_budget_id" + ) + if isinstance(default_budget_id, str): + default_budget = await get_team_member_default_budget( + budget_id=default_budget_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + ) + if default_budget is not None: + team_member_budget = default_budget.max_budget + + if team_member_budget is not None: + team_member_spend = ( + team_membership.spend if team_membership is not None else 0.0 + ) or 0.0 # Read from cross-pod counter (Redis-first) if available from litellm.proxy.proxy_server import get_current_spend @@ -3666,12 +3746,20 @@ async def _organization_max_budget_check( if org_max_budget is None or org_max_budget <= 0: return + # Read spend from cross-pod counter (Redis-first) or cached object (fallback) + from litellm.proxy.proxy_server import get_current_spend + + org_spend = await get_current_spend( + counter_key=f"spend:org:{org_id}", + fallback_spend=org_table.spend or 0.0, + ) + # Check if organization spend exceeds max budget - if org_table.spend >= org_max_budget: + if org_spend >= org_max_budget: # Trigger budget alert call_info = CallInfo( token=valid_token.token, - spend=org_table.spend, + spend=org_spend, max_budget=org_max_budget, user_id=valid_token.user_id, team_id=valid_token.team_id, @@ -3687,9 +3775,9 @@ async def _organization_max_budget_check( ) raise litellm.BudgetExceededError( - current_cost=org_table.spend, + current_cost=org_spend, max_budget=org_max_budget, - message=f"Budget has been exceeded! Organization={org_id} Current cost: {org_table.spend}, Max budget: {org_max_budget}", + message=f"Budget has been exceeded! Organization={org_id} Current cost: {org_spend}, Max budget: {org_max_budget}", ) diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 18aea48e96b..448c975d123 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -151,7 +151,15 @@ def is_request_body_safe( A malicious user can set the api_base to their own domain and invoke POST /chat/completions to intercept and steal the OpenAI API key. Relevant issue: https://huntr.com/bounties/4001e1a2-7b7a-4776-a3ae-e6692ec3d997 """ - banned_params = ["api_base", "base_url", "user_config"] + banned_params = [ + "api_base", + "base_url", + "user_config", + "aws_sts_endpoint", + "aws_web_identity_token", + "aws_role_name", + "vertex_credentials", + ] for param in banned_params: if ( diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index 26bbdef3090..6417307f691 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -300,7 +300,7 @@ class RouteChecks: return True if RouteChecks.check_route_access( - route=route, allowed_routes=LiteLLMRoutes.mcp_routes.value + route=route, allowed_routes=LiteLLMRoutes.mcp_inference_routes.value ): return True @@ -358,7 +358,9 @@ class RouteChecks: """ Check if route is a management route """ - return route in LiteLLMRoutes.management_routes.value + return RouteChecks.check_route_access( + route=route, allowed_routes=LiteLLMRoutes.management_routes.value + ) @staticmethod def is_info_route(route: str) -> bool: diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index ff4f63593cb..9779a07b97b 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -867,6 +867,11 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 ), jwt_claims=jwt_claims, ) + valid_token.team_object_permission = ( + team_object.object_permission + if team_object is not None + else None + ) # Check if model has zero cost - if so, skip all budget checks model = get_model_from_request(request_data, route) @@ -1452,6 +1457,11 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 else: _team_obj = None + if _team_obj is not None: + valid_token.team_object_permission = _team_obj.object_permission + else: + valid_token.team_object_permission = None + await user_api_key_cache.async_set_cache( key=valid_token.team_id, value=_team_obj ) # save team table in cache - used for tpm/rpm limiting - tpm_rpm_limiter.py diff --git a/litellm/proxy/common_utils/callback_utils.py b/litellm/proxy/common_utils/callback_utils.py index a206be87a11..7ddd722a80e 100644 --- a/litellm/proxy/common_utils/callback_utils.py +++ b/litellm/proxy/common_utils/callback_utils.py @@ -37,6 +37,20 @@ def initialize_callbacks_on_proxy( # noqa: PLR0915 if isinstance(value, list): imported_list: List[Any] = [] for callback in value: # ["presidio", ] + if isinstance(callback, str) and callback == "compression_interception": + from litellm.integrations.compression_interception.handler import ( + CompressionInterceptionLogger, + ) + + compression_interception_obj = ( + CompressionInterceptionLogger.initialize_from_proxy_config( + litellm_settings=litellm_settings, + callback_specific_params=callback_specific_params, + ) + ) + imported_list.append(compression_interception_obj) + continue + # check if callback is a custom logger compatible callback if isinstance(callback, str): callback = LoggingCallbackManager._add_custom_callback_generic_api_str( @@ -419,7 +433,8 @@ def add_guardrail_to_applied_guardrails_header( return _metadata = request_data.get("metadata", None) or {} if "applied_guardrails" in _metadata: - _metadata["applied_guardrails"].append(guardrail_name) + if guardrail_name not in _metadata["applied_guardrails"]: + _metadata["applied_guardrails"].append(guardrail_name) else: _metadata["applied_guardrails"] = [guardrail_name] # Ensure metadata is set back to request_data (important when metadata didn't exist) diff --git a/litellm/proxy/common_utils/reset_budget_job.py b/litellm/proxy/common_utils/reset_budget_job.py index b11af04e2b1..e486336cec0 100644 --- a/litellm/proxy/common_utils/reset_budget_job.py +++ b/litellm/proxy/common_utils/reset_budget_job.py @@ -632,20 +632,27 @@ class ResetBudgetJob: now = datetime.utcnow() + # Note on raw SQL: prisma-client-python does not support null-filtering + # on `Json?` columns (no DbNull/JsonNull sentinel — see + # RobertCraigie/prisma-client-py#714). We use `query_raw` with + # `IS NOT NULL` so we don't materialize every key/team row on each + # tick of the reset job. Writes still go through the ORM. + # --- Keys --- try: - all_keys = await self.prisma_client.db.litellm_verificationtoken.find_many( - where={"budget_limits": {"not": None}} # type: ignore[arg-type] + key_rows = await self.prisma_client.db.query_raw( + 'SELECT token, budget_limits FROM "LiteLLM_VerificationToken" ' + "WHERE budget_limits IS NOT NULL" ) - for key in all_keys: - raw = key.budget_limits # type: ignore[attr-defined] + for row in key_rows: + raw = row["budget_limits"] if not raw: continue windows: list = raw if isinstance(raw, list) else json.loads(raw) changed = False for window in windows: counter_key = ( - f"spend:key:{key.token}:window:{window['budget_duration']}" + f"spend:key:{row['token']}:window:{window['budget_duration']}" ) if await ResetBudgetJob._reset_expired_window( window, counter_key, spend_counter_cache, now @@ -653,7 +660,7 @@ class ResetBudgetJob: changed = True if changed: await self.prisma_client.db.litellm_verificationtoken.update( - where={"token": key.token}, + where={"token": row["token"]}, data={"budget_limits": json.dumps(windows)}, # type: ignore[arg-type] ) except Exception as e: @@ -663,26 +670,25 @@ class ResetBudgetJob: # --- Teams --- try: - all_teams = await self.prisma_client.db.litellm_teamtable.find_many( - where={"budget_limits": {"not": None}} # type: ignore[arg-type] + team_rows = await self.prisma_client.db.query_raw( + 'SELECT team_id, budget_limits FROM "LiteLLM_TeamTable" ' + "WHERE budget_limits IS NOT NULL" ) - for team in all_teams: - raw = team.budget_limits # type: ignore[attr-defined] + for row in team_rows: + raw = row["budget_limits"] if not raw: continue windows = raw if isinstance(raw, list) else json.loads(raw) changed = False for window in windows: - counter_key = ( - f"spend:team:{team.team_id}:window:{window['budget_duration']}" - ) + counter_key = f"spend:team:{row['team_id']}:window:{window['budget_duration']}" if await ResetBudgetJob._reset_expired_window( window, counter_key, spend_counter_cache, now ): changed = True if changed: await self.prisma_client.db.litellm_teamtable.update( - where={"team_id": team.team_id}, + where={"team_id": row["team_id"]}, data={"budget_limits": json.dumps(windows)}, # type: ignore[arg-type] ) except Exception as e: diff --git a/litellm/proxy/db/db_spend_update_writer.py b/litellm/proxy/db/db_spend_update_writer.py index 8017448ae13..c06e1850d9f 100644 --- a/litellm/proxy/db/db_spend_update_writer.py +++ b/litellm/proxy/db/db_spend_update_writer.py @@ -1300,7 +1300,10 @@ class DBSpendUpdateWriter: batcher.litellm_teammembership.update_many( # 'update_many' prevents error from being raised if no row exists where={"team_id": team_id, "user_id": user_id}, - data={"spend": {"increment": response_cost}}, + data={ + "spend": {"increment": response_cost}, + "total_spend": {"increment": response_cost}, + }, ) # Transaction succeeded, break out of retry loop break diff --git a/litellm/proxy/db/db_transaction_queue/redis_update_buffer.py b/litellm/proxy/db/db_transaction_queue/redis_update_buffer.py index b8537c2be9e..1e3014dbf3c 100644 --- a/litellm/proxy/db/db_transaction_queue/redis_update_buffer.py +++ b/litellm/proxy/db/db_transaction_queue/redis_update_buffer.py @@ -22,6 +22,7 @@ from litellm.constants import ( ) from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.proxy._types import ( + BaseDailySpendTransaction, DailyAgentSpendTransaction, DailyEndUserSpendTransaction, DailyOrganizationSpendTransaction, @@ -29,6 +30,8 @@ from litellm.proxy._types import ( DailyTeamSpendTransaction, DailyUserSpendTransaction, DBSpendUpdateTransactions, + Litellm_EntityType, + SpendUpdateQueueItem, ) from litellm.proxy.db.db_transaction_queue.base_update_queue import service_logger_obj from litellm.proxy.db.db_transaction_queue.daily_spend_update_queue import ( @@ -259,9 +262,36 @@ class RedisUpdateBuffer: if len(rpush_list) == 0: return - result_lengths = await self.redis_cache.async_rpush_pipeline( - rpush_list=rpush_list, - ) + try: + result_lengths = await self.redis_cache.async_rpush_pipeline( + rpush_list=rpush_list, + ) + except Exception as e: + # The in-memory queues were already drained above. If we let the + # exception propagate without restoring, the aggregated spend is + # permanently lost. Re-enqueue so the next scheduler tick retries. + verbose_proxy_logger.error( + "Spend tracking - failed to push aggregated spend updates to Redis. " + "Restoring %d transaction sets to in-memory queues for retry on next tick. " + "Error: %s", + len(rpush_list), + str(e), + ) + await self._restore_spend_updates_to_in_memory_queues( + db_spend_update_transactions=db_spend_update_transactions, + daily_spend_update_transactions=daily_spend_update_transactions, + daily_team_spend_update_transactions=daily_team_spend_update_transactions, + daily_org_spend_update_transactions=daily_org_spend_update_transactions, + daily_end_user_spend_update_transactions=daily_end_user_spend_update_transactions, + daily_agent_spend_update_transactions=daily_agent_spend_update_transactions, + spend_update_queue=spend_update_queue, + daily_spend_update_queue=daily_spend_update_queue, + daily_team_spend_update_queue=daily_team_spend_update_queue, + daily_org_spend_update_queue=daily_org_spend_update_queue, + daily_end_user_spend_update_queue=daily_end_user_spend_update_queue, + daily_agent_spend_update_queue=daily_agent_spend_update_queue, + ) + return # Emit gauge events for each queue for i, queue_size in enumerate(result_lengths): @@ -271,6 +301,101 @@ class RedisUpdateBuffer: service=service_types[i], ) + @staticmethod + async def _restore_spend_updates_to_in_memory_queues( + db_spend_update_transactions: Optional[DBSpendUpdateTransactions], + daily_spend_update_transactions: Optional[Dict[str, BaseDailySpendTransaction]], + daily_team_spend_update_transactions: Optional[ + Dict[str, BaseDailySpendTransaction] + ], + daily_org_spend_update_transactions: Optional[ + Dict[str, BaseDailySpendTransaction] + ], + daily_end_user_spend_update_transactions: Optional[ + Dict[str, BaseDailySpendTransaction] + ], + daily_agent_spend_update_transactions: Optional[ + Dict[str, BaseDailySpendTransaction] + ], + spend_update_queue: SpendUpdateQueue, + daily_spend_update_queue: DailySpendUpdateQueue, + daily_team_spend_update_queue: DailySpendUpdateQueue, + daily_org_spend_update_queue: DailySpendUpdateQueue, + daily_end_user_spend_update_queue: DailySpendUpdateQueue, + daily_agent_spend_update_queue: DailySpendUpdateQueue, + ) -> None: + """ + Put drained-but-unpushed transactions back into in-memory queues. + + Called when the Redis rpush pipeline raises. Without this, all spend + data aggregated during the current scheduler tick is permanently lost + because the source queues were already drained before the rpush. + """ + if db_spend_update_transactions is not None: + entity_entries: List[ + Tuple[Litellm_EntityType, Optional[Dict[str, float]]] + ] = [ + ( + Litellm_EntityType.USER, + db_spend_update_transactions.get("user_list_transactions"), + ), + ( + Litellm_EntityType.END_USER, + db_spend_update_transactions.get("end_user_list_transactions"), + ), + ( + Litellm_EntityType.KEY, + db_spend_update_transactions.get("key_list_transactions"), + ), + ( + Litellm_EntityType.TEAM, + db_spend_update_transactions.get("team_list_transactions"), + ), + ( + Litellm_EntityType.TEAM_MEMBER, + db_spend_update_transactions.get("team_member_list_transactions"), + ), + ( + Litellm_EntityType.ORGANIZATION, + db_spend_update_transactions.get("org_list_transactions"), + ), + ( + Litellm_EntityType.TAG, + db_spend_update_transactions.get("tag_list_transactions"), + ), + ( + Litellm_EntityType.AGENT, + db_spend_update_transactions.get("agent_list_transactions"), + ), + ] + for entity_type, entities in entity_entries: + if not entities: + continue + for entity_id, cost in entities.items(): + await spend_update_queue.add_update( + SpendUpdateQueueItem( + entity_type=entity_type, + entity_id=entity_id, + response_cost=cost, + ) + ) + + daily_pairs: List[ + Tuple[Optional[Dict[str, BaseDailySpendTransaction]], DailySpendUpdateQueue] + ] = [ + (daily_spend_update_transactions, daily_spend_update_queue), + (daily_team_spend_update_transactions, daily_team_spend_update_queue), + (daily_org_spend_update_transactions, daily_org_spend_update_queue), + ( + daily_end_user_spend_update_transactions, + daily_end_user_spend_update_queue, + ), + (daily_agent_spend_update_transactions, daily_agent_spend_update_queue), + ] + for daily_txns, daily_queue in daily_pairs: + if daily_txns: + await daily_queue.update_queue.put(daily_txns) + @staticmethod def _number_of_transactions_to_store_in_redis( db_spend_update_transactions: DBSpendUpdateTransactions, diff --git a/litellm/proxy/db/prisma_client.py b/litellm/proxy/db/prisma_client.py index 114103508ea..73735796eb3 100644 --- a/litellm/proxy/db/prisma_client.py +++ b/litellm/proxy/db/prisma_client.py @@ -403,10 +403,18 @@ class PrismaManager: return dname @staticmethod - def setup_database(use_migrate: bool = False) -> bool: + def setup_database( + use_migrate: bool = False, use_v2_resolver: bool = False + ) -> bool: """ Set up the database using either prisma migrate or prisma db push + Args: + use_migrate: Use `prisma migrate deploy` instead of `db push`. + use_v2_resolver: Opt into the v2 migration resolver that avoids + the diff-and-force recovery behavior (which caused schema + thrashing during rolling deploys). Defaults to False. + Returns: bool: True if setup was successful, False otherwise """ @@ -427,7 +435,10 @@ class PrismaManager: prisma_dir = PrismaManager._get_prisma_dir() - return ProxyExtrasDBManager.setup_database(use_migrate=use_migrate) + return ProxyExtrasDBManager.setup_database( + use_migrate=use_migrate, + use_v2_resolver=use_v2_resolver, + ) else: # Use prisma db push with increased timeout subprocess.run( diff --git a/litellm/proxy/example_config_yaml/adaptive_router_example.yaml b/litellm/proxy/example_config_yaml/adaptive_router_example.yaml new file mode 100644 index 00000000000..58f5398ca57 --- /dev/null +++ b/litellm/proxy/example_config_yaml/adaptive_router_example.yaml @@ -0,0 +1,52 @@ +# Example proxy config for the adaptive router (v0). +# +# Wires one logical router ("smart-cheap-router") that adaptively picks between +# two real deployments ("fast" and "smart") based on per-session feedback signals. +# +# How to use from a client: +# POST /v1/chat/completions { "model": "smart-cheap-router", ... } +# Add { "metadata": { "litellm_session_id": "" } } to enable +# sticky-session routing within a conversation. +# +# Required env vars: OPENAI_API_KEY, DATABASE_URL. + +model_list: + # ---- The adaptive router "control" deployment ------------------------- + # `model_name` is what clients call. `available_models` lists the underlying + # deployments the router is allowed to pick from (must match other model_name + # entries in this list). + - model_name: smart-cheap-router + litellm_params: + model: auto_router/adaptive_router # required prefix -- triggers adaptive-router init + adaptive_router_config: + available_models: ["fast", "smart"] + weights: + quality: 0.7 + cost: 0.3 + + # ---- Underlying deployments the router picks from --------------------- + - model_name: fast + litellm_params: + model: openai/gpt-4o-mini + api_key: os.environ/OPENAI_API_KEY + input_cost_per_token: 0.00000015 + model_info: + adaptive_router_preferences: + quality_tier: 2 + strengths: [] + + - model_name: smart + litellm_params: + model: openai/gpt-4o + api_key: os.environ/OPENAI_API_KEY + input_cost_per_token: 0.0000050 + model_info: + adaptive_router_preferences: + quality_tier: 3 + strengths: ["code_generation", "technical_design", "analytical_reasoning"] + +litellm_settings: + drop_params: True + +general_settings: + master_key: sk-1234 # REPLACE in production diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index 25b46cf3641..8bfe5027b77 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -5,7 +5,6 @@ # +-------------------------------------------------------------+ # Thank you users! We ❤️ you! - Krrish & Ishaan -import copy import os import sys @@ -18,6 +17,7 @@ from typing import ( TYPE_CHECKING, Any, AsyncGenerator, + ClassVar, Dict, List, Literal, @@ -33,6 +33,7 @@ from fastapi import HTTPException import litellm from litellm._logging import verbose_proxy_logger +from litellm.litellm_core_utils.core_helpers import redact_nested_match_and_regex_keys from litellm.caching import DualCache from litellm.exceptions import GuardrailInterventionNormalStringError from litellm.integrations.custom_guardrail import CustomGuardrail @@ -62,6 +63,7 @@ from litellm.types.utils import ( CallTypesLiteral, Choices, GuardrailStatus, + Message, ModelResponse, ModelResponseStream, StreamingChoices, @@ -78,56 +80,33 @@ class GuardrailMessageFilterResult(NamedTuple): def _redact_pii_matches(response_json: dict) -> dict: - try: - # Create a deep copy to avoid modifying the original response - redacted_response = copy.deepcopy(response_json) + """ + Redact match-like fields from a Bedrock ApplyGuardrail JSON payload. - # Get assessments from the response - # NOTE: We use `.get("key") or []` instead of `.get("key", [])` because - # the Bedrock API can return explicit `null` for list fields (e.g. "regexes": null). - # In Python, dict.get("key", []) returns None (not []) when the key exists - # with a None/null value. The `or []` ensures we always get an iterable, - # preventing "TypeError: 'NoneType' object is not iterable". - assessments = redacted_response.get("assessments") or [] - if not assessments: - return redacted_response + Delegates to :func:`redact_nested_match_and_regex_keys` (same rules as spend + logging). Kept as a Bedrock-module entry point for existing unit tests. + """ + redacted = redact_nested_match_and_regex_keys(response_json) + return redacted if isinstance(redacted, dict) else response_json - for assessment in assessments: - # Redact PII entities in sensitive information policy - sensitive_info_policy = assessment.get("sensitiveInformationPolicy") - if sensitive_info_policy: - pii_entities = sensitive_info_policy.get("piiEntities") or [] - for pii_entity in pii_entities: - if "match" in pii_entity: - pii_entity["match"] = "[REDACTED]" - # Redact regex matches - regexes = sensitive_info_policy.get("regexes") or [] - for regex_match in regexes: - if "match" in regex_match: - regex_match["match"] = "[REDACTED]" +def _redact_assessment_match_fields(assessments: List[dict]) -> List[dict]: + """ + Redact sensitive match-like fields from blocked assessment summaries. - # Redact custom word matches in word policy - word_policy = assessment.get("wordPolicy") - if word_policy: - custom_words = word_policy.get("customWords") or [] - for custom_word in custom_words: - if "match" in custom_word: - custom_word["match"] = "[REDACTED]" - - managed_words = word_policy.get("managedWordLists") or [] - for managed_word in managed_words: - if "match" in managed_word: - managed_word["match"] = "[REDACTED]" - - return redacted_response - except Exception as e: - # We do not want to fail in any case so this is just a warning - verbose_proxy_logger.warning("Guardrail log redaction failed: %s", str(e)) - return response_json + This is used for customer-visible error payloads (HTTPException.detail) where + we want to preserve policy/type/action metadata without echoing raw matched + content. + """ + redacted = redact_nested_match_and_regex_keys(assessments) + return redacted if isinstance(redacted, list) else assessments class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): + # During-call must use async_moderation_hook (not unified apply_guardrail), otherwise + # OpenAI translation always passes input_type="request" and spend/UI show PRE-CALL. + use_native_during_call_hook: ClassVar[bool] = True + def __init__( self, guardrailIdentifier: Optional[str] = None, @@ -418,6 +397,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): messages: Optional[List[AllMessageValues]] = None, response: Optional[Union[Any, litellm.ModelResponse]] = None, request_data: Optional[dict] = None, + logging_event_type: Optional[GuardrailEventHooks] = None, ) -> BedrockGuardrailResponse: from datetime import datetime @@ -455,11 +435,17 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): prepared_request.headers, ) - event_type = ( - GuardrailEventHooks.pre_call - if source == "INPUT" - else GuardrailEventHooks.post_call - ) + # UI / spend logs use event_type. Bedrock's `source` is INPUT vs OUTPUT for the API + # body, which must not be confused with the proxy hook (pre_call / during_call / + # post_call). When omitted, keep legacy mapping for backward compatibility. + if logging_event_type is not None: + event_type = logging_event_type + else: + event_type = ( + GuardrailEventHooks.pre_call + if source == "INPUT" + else GuardrailEventHooks.post_call + ) try: httpx_response = await self.async_handler.post( @@ -514,9 +500,12 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): ######################################################### # Add guardrail information to request trace ######################################################### + _json_response = httpx_response.json() + # Raw Bedrock JSON is passed here; match/regex redaction runs once inside + # CustomGuardrail.add_standard_logging_guardrail_information_to_request_data. self.add_standard_logging_guardrail_information_to_request_data( guardrail_provider=self.guardrail_provider, - guardrail_json_response=httpx_response.json(), + guardrail_json_response=_json_response, request_data=request_data or {}, guardrail_status=self._get_bedrock_guardrail_response_status( response=httpx_response @@ -529,9 +518,10 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): ######################################################### if httpx_response.status_code == 200: # check if the response was flagged - _json_response = httpx_response.json() - redacted_response = _redact_pii_matches(_json_response) - verbose_proxy_logger.debug("Bedrock AI response : %s", redacted_response) + verbose_proxy_logger.debug( + "Bedrock AI response : %s", + redact_nested_match_and_regex_keys(_json_response), + ) bedrock_guardrail_response = BedrockGuardrailResponse(**_json_response) if self._should_raise_guardrail_blocked_exception( bedrock_guardrail_response @@ -808,7 +798,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): assessments = self._extract_blocked_assessments(response) if assessments: - detail["assessments"] = assessments + detail["assessments"] = _redact_assessment_match_fields(assessments) return HTTPException(status_code=400, detail=detail) @@ -830,8 +820,8 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): return False # Check assessments to determine if any actions were BLOCKED (vs ANONYMIZED) - # NOTE: Use `or []` instead of default param to handle explicit null from Bedrock API. - # See _redact_pii_matches() for detailed explanation of the null safety pattern. + # NOTE: Use `.get("k") or []` not `.get("k", [])` — Bedrock can return explicit + # JSON null; dict.get("k", []) then yields None, and `for x in None` raises. assessments = response.get("assessments") or [] if not assessments: return False @@ -951,7 +941,10 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): ) try: bedrock_guardrail_response = await self.make_bedrock_api_request( - source="INPUT", messages=filtered_messages, request_data=data + source="INPUT", + messages=filtered_messages, + request_data=data, + logging_event_type=GuardrailEventHooks.pre_call, ) except GuardrailInterventionNormalStringError as e: bedrock_guardrail_response = e.message @@ -1023,7 +1016,10 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): ) try: bedrock_guardrail_response = await self.make_bedrock_api_request( - source="INPUT", messages=filtered_messages, request_data=data + source="INPUT", + messages=filtered_messages, + request_data=data, + logging_event_type=GuardrailEventHooks.during_call, ) except GuardrailInterventionNormalStringError as e: bedrock_guardrail_response = e.message @@ -1127,9 +1123,13 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): source="INPUT", messages=input_messages, request_data=data, + logging_event_type=GuardrailEventHooks.post_call, ) output_task = self.make_bedrock_api_request( - source="OUTPUT", response=response, request_data=data + source="OUTPUT", + response=response, + request_data=data, + logging_event_type=GuardrailEventHooks.post_call, ) # Execute both requests in parallel @@ -1143,7 +1143,10 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): # Only run OUTPUT validation (INPUT was already validated in pre_call or during_call) try: output_content_bedrock = await self.make_bedrock_api_request( - source="OUTPUT", response=response, request_data=data + source="OUTPUT", + response=response, + request_data=data, + logging_event_type=GuardrailEventHooks.post_call, ) except GuardrailInterventionNormalStringError as e: output_content_bedrock = e.message @@ -1270,9 +1273,13 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): source="INPUT", messages=input_messages, request_data=request_data, + logging_event_type=GuardrailEventHooks.post_call, ) # Only input messages output_task = self.make_bedrock_api_request( - source="OUTPUT", response=assembled_model_response + source="OUTPUT", + response=assembled_model_response, + request_data=request_data, + logging_event_type=GuardrailEventHooks.post_call, ) # Only response # Execute both requests in parallel @@ -1286,7 +1293,10 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): # Only run OUTPUT validation (INPUT was already validated in pre_call or during_call) try: output_guardrail_response = await self.make_bedrock_api_request( - source="OUTPUT", response=assembled_model_response + source="OUTPUT", + response=assembled_model_response, + request_data=request_data, + logging_event_type=GuardrailEventHooks.post_call, ) except GuardrailInterventionNormalStringError as e: output_guardrail_response = e.message @@ -1563,11 +1573,50 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): # Bedrock will throw an error if there is no text to process if filtered_messages: - bedrock_response = await self.make_bedrock_api_request( - source="INPUT", - messages=filtered_messages, - request_data=request_data, + _log_hook = ( + GuardrailEventHooks.pre_call + if input_type == "request" + else GuardrailEventHooks.post_call ) + # Map the abstract input_type to the Bedrock source parameter. + # "request" -> INPUT (scan user-supplied content) + # "response" -> OUTPUT (scan model-generated content) + # Bedrock guardrail policies are often configured differently + # for Input vs Output (e.g. PII blocking only on Output), so + # the source MUST match where the text originated. + bedrock_source: Literal["INPUT", "OUTPUT"] = ( + "OUTPUT" if input_type == "response" else "INPUT" + ) + if bedrock_source == "OUTPUT": + # Build a synthetic ModelResponse whose choices carry the + # text(s) to scan, so _create_bedrock_output_content_request + # can produce the correct Bedrock OUTPUT payload. + synthetic_response = ModelResponse( + choices=[ + Choices( + index=_idx, + message=Message( + role="assistant", + content=str(_msg.get("content") or ""), + ), + finish_reason="stop", + ) + for _idx, _msg in enumerate(filtered_messages) + ] + ) + bedrock_response = await self.make_bedrock_api_request( + source="OUTPUT", + response=synthetic_response, + request_data=request_data, + logging_event_type=_log_hook, + ) + else: + bedrock_response = await self.make_bedrock_api_request( + source="INPUT", + messages=filtered_messages, + request_data=request_data, + logging_event_type=_log_hook, + ) # Apply any masking that was applied by the guardrail output_list = bedrock_response.get("output") diff --git a/litellm/proxy/health_check.py b/litellm/proxy/health_check.py index e0664703d28..7d67750c78f 100644 --- a/litellm/proxy/health_check.py +++ b/litellm/proxy/health_check.py @@ -306,7 +306,9 @@ def _health_check_deployment_is_wildcard(litellm_params: dict) -> bool: return "*" in _deployment_model_string_for_health_check(litellm_params) -def _resolve_health_check_max_tokens(model_info: dict, litellm_params: dict) -> Optional[int]: +def _resolve_health_check_max_tokens( + model_info: dict, litellm_params: dict +) -> Optional[int]: """ Pick max_tokens for the health check request. @@ -341,10 +343,7 @@ def _resolve_health_check_max_tokens(model_info: dict, litellm_params: dict) -> return int(tokens_reasoning) if not is_reasoning and tokens_non_reasoning is not None: return int(tokens_non_reasoning) - if ( - is_reasoning - and BACKGROUND_HEALTH_CHECK_MAX_TOKENS_REASONING is not None - ): + if is_reasoning and BACKGROUND_HEALTH_CHECK_MAX_TOKENS_REASONING is not None: return int(BACKGROUND_HEALTH_CHECK_MAX_TOKENS_REASONING) if BACKGROUND_HEALTH_CHECK_MAX_TOKENS is not None: diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index 8fd19548cbb..b4b5de1746e 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -1121,14 +1121,14 @@ async def _db_health_readiness_check(): return db_health_cache except Exception as e: db_health_cache = {"status": "disconnected", "last_updated": datetime.now()} - PrismaDBExceptionHandler.handle_db_exception(e) if PrismaDBExceptionHandler.is_database_transport_error(e): try: verbose_proxy_logger.warning( "_db_health_readiness_check: health_check failed, attempting reconnect" ) - await prisma_client.disconnect() - await prisma_client.connect() + await prisma_client.attempt_db_reconnect( + reason="health_readiness_check" + ) await prisma_client.health_check() verbose_proxy_logger.info( "_db_health_readiness_check: reconnect succeeded" diff --git a/litellm/proxy/hooks/max_budget_limiter.py b/litellm/proxy/hooks/max_budget_limiter.py index 4b59f603d3e..7789fa6a349 100644 --- a/litellm/proxy/hooks/max_budget_limiter.py +++ b/litellm/proxy/hooks/max_budget_limiter.py @@ -21,20 +21,30 @@ class _PROXY_MaxBudgetLimiter(CustomLogger): ): try: verbose_proxy_logger.debug("Inside Max Budget Limiter Pre-Call Hook") - cache_key = f"{user_api_key_dict.user_id}_user_api_key_user_id" - user_row = await cache.async_get_cache( - cache_key, parent_otel_span=user_api_key_dict.parent_otel_span + max_budget = user_api_key_dict.user_max_budget + user_id = user_api_key_dict.user_id + + if max_budget is None or user_id is None: + return + + # Personal budget applies only to non-team requests, matching + # the explicit team-key exemption in common_checks section 4.1. + if user_api_key_dict.team_id is not None: + return + + from litellm.proxy.proxy_server import get_current_spend + + curr_spend = await get_current_spend( + counter_key=f"spend:user:{user_id}", + fallback_spend=user_api_key_dict.user_spend or 0.0, ) - if user_row is None: # value not yet cached - return - max_budget = user_row["max_budget"] - curr_spend = user_row["spend"] - if max_budget is None: - return - - if curr_spend is None: - return + verbose_proxy_logger.debug( + "MaxBudgetLimiter: user_id=%s, spend=%.6f, max=%.6f", + user_id, + curr_spend, + max_budget, + ) # CHECK IF REQUEST ALLOWED if curr_spend >= max_budget: diff --git a/litellm/proxy/hooks/parallel_request_limiter_v3.py b/litellm/proxy/hooks/parallel_request_limiter_v3.py index 5c2b3dfe0ee..f29bbd2d9d5 100644 --- a/litellm/proxy/hooks/parallel_request_limiter_v3.py +++ b/litellm/proxy/hooks/parallel_request_limiter_v3.py @@ -1570,9 +1570,9 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): user_api_key_project_id = standard_logging_metadata.get( "user_api_key_project_id" ) - user_api_key_end_user_id = kwargs.get( - "user" - ) or standard_logging_metadata.get("user_api_key_end_user_id") + user_api_key_end_user_id = kwargs.get("user") or standard_logging_metadata.get( + "user_api_key_end_user_id" + ) model_group = get_model_group_from_litellm_kwargs(kwargs) # Get total tokens from response diff --git a/litellm/proxy/hooks/proxy_track_cost_callback.py b/litellm/proxy/hooks/proxy_track_cost_callback.py index ea9c92fec6c..c9946f4e26f 100644 --- a/litellm/proxy/hooks/proxy_track_cost_callback.py +++ b/litellm/proxy/hooks/proxy_track_cost_callback.py @@ -213,6 +213,7 @@ class _ProxyDBLogger(CustomLogger): team_id=team_id, user_id=user_id, response_cost=response_cost, + org_id=org_id, ) # update cache (fire-and-forget for backward compat: diff --git a/litellm/proxy/image_endpoints/endpoints.py b/litellm/proxy/image_endpoints/endpoints.py index 4f994b87f58..fe8b7c6fdc9 100644 --- a/litellm/proxy/image_endpoints/endpoints.py +++ b/litellm/proxy/image_endpoints/endpoints.py @@ -285,6 +285,13 @@ async def image_edit_api( if mask_files: data["mask"] = mask_files + for _field in ("image", "mask"): + if _field in data and isinstance(data[_field], str): + raise HTTPException( + status_code=422, + detail=f"'{_field}' must be provided as a multipart file upload, not a string.", + ) + # Ensure prompt exists in data (default to None for models that don't require it) if "prompt" not in data: data["prompt"] = None diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 7467bbae232..5804e3f8d9f 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -1,5 +1,6 @@ import asyncio import copy +import re import time from collections import OrderedDict from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union @@ -28,6 +29,14 @@ _SPECIAL_HEADERS_CACHE = frozenset( v.value.lower() for v in SpecialHeaders._member_map_.values() ) +# Matches any header of the form x--session-id (case-insensitive). +# Excludes the two explicit litellm headers which are handled with higher priority. +_GENERIC_SESSION_ID_HEADER_RE = re.compile(r"^x-.+-session-id$", re.IGNORECASE) +_EXPLICIT_SESSION_HEADERS = frozenset({"x-litellm-trace-id", "x-litellm-session-id"}) +# Session-id values must be non-empty strings of alphanumerics, hyphens, or underscores +# (covers UUIDs and most common session-id formats). +_SESSION_ID_VALUE_RE = re.compile(r"^[a-zA-Z0-9_\-]{8,}$") + def _sanitize_for_log(value: Any) -> str: """ @@ -115,13 +124,43 @@ def _get_metadata_variable_name(request: Request) -> str: return "metadata" +def _extract_generic_session_id_from_headers( + normalized: Dict[str, str], +) -> Optional[str]: + """ + Scan a normalised (lower-cased keys) header dict for any header that looks + like ``x--session-id`` and whose value is a plausible session/trace + identifier (alphanumeric + hyphens/underscores, at least 8 chars). + + The two explicit LiteLLM headers (``x-litellm-trace-id`` / + ``x-litellm-session-id``) are excluded here because they are handled with + higher priority by the caller. + + Example: ``x-claude-code-session-id: e96634a3-fa28-4083-b354-55542e2dca01`` + """ + for key, value in normalized.items(): + if ( + key not in _EXPLICIT_SESSION_HEADERS + and _GENERIC_SESSION_ID_HEADER_RE.match(key) + and isinstance(value, str) + and _SESSION_ID_VALUE_RE.match(value) + ): + return value + return None + + def get_chain_id_from_headers(headers: Optional[Dict[str, str]]) -> Optional[str]: """ Extract chain id for call chaining from request headers. - x-litellm-trace-id and x-litellm-session-id are interchangeable; when both - are present, x-litellm-trace-id takes precedence. Header keys are matched - case-insensitively so this works with raw header dicts from any transport. + Priority order: + 1. ``x-litellm-trace-id`` (explicit, highest priority) + 2. ``x-litellm-session-id`` (explicit) + 3. Any ``x--session-id`` header whose value looks like a session id + (alphanumeric / UUID, at least 8 chars). E.g. ``x-claude-code-session-id``. + + Header keys are matched case-insensitively so this works with raw header + dicts from any transport. Used by MCP (and other paths that have raw_headers but no Request) to set litellm_trace_id/litellm_session_id for spend logs and logging consistency. @@ -129,8 +168,10 @@ def get_chain_id_from_headers(headers: Optional[Dict[str, str]]) -> Optional[str if not headers: return None normalized = {k.lower(): v for k, v in headers.items() if isinstance(k, str)} - return normalized.get("x-litellm-trace-id") or normalized.get( - "x-litellm-session-id" + return ( + normalized.get("x-litellm-trace-id") + or normalized.get("x-litellm-session-id") + or _extract_generic_session_id_from_headers(normalized) ) @@ -649,10 +690,8 @@ class LiteLLMProxyRequestSetup: ######################################################################################### agent_id_from_header = headers.get("x-litellm-agent-id") - # x-litellm-trace-id and x-litellm-session-id are interchangeable for call chaining - chain_id = headers.get("x-litellm-trace-id") or headers.get( - "x-litellm-session-id" - ) + # Explicit litellm headers take precedence; fall back to any x-*-session-id header. + chain_id = get_chain_id_from_headers(dict(headers)) if agent_id_from_header: metadata_from_headers["agent_id"] = agent_id_from_header diff --git a/litellm/proxy/management_endpoints/common_utils.py b/litellm/proxy/management_endpoints/common_utils.py index 07286b4fa80..b0ea6b41ac5 100644 --- a/litellm/proxy/management_endpoints/common_utils.py +++ b/litellm/proxy/management_endpoints/common_utils.py @@ -355,6 +355,7 @@ async def _upsert_budget_and_membership( tpm_limit: Optional[int] = None, rpm_limit: Optional[int] = None, allowed_models: Optional[List[str]] = None, + team_default_budget_id: Optional[str] = None, ): """ Helper function to Create/Update or Delete the budget within the team membership @@ -368,6 +369,11 @@ async def _upsert_budget_and_membership( tpm_limit: Tokens per minute limit for the team member rpm_limit: Requests per minute limit for the team member allowed_models: Per-member model scope. None = don't change. [] = remove restrictions. Non-empty list = enforce. + team_default_budget_id: The team's shared default member budget id (from + team metadata.team_member_budget_id), if any. When the membership's + existing_budget_id matches this, we clone-on-write so editing one + member's budget does not mutate the shared default (and therefore + every other member who still points at it). If max_budget, tpm_limit, rpm_limit, and allowed_models are all None, the user's budget is removed from the team membership. If any of these values exist, a budget is updated or created and linked to the team membership. @@ -385,7 +391,13 @@ async def _upsert_budget_and_membership( ) return - if existing_budget_id is not None: + is_shared_default = ( + existing_budget_id is not None + and team_default_budget_id is not None + and existing_budget_id == team_default_budget_id + ) + + if existing_budget_id is not None and not is_shared_default: # Update the existing budget in-place to preserve fields not being changed. # Only write fields that the caller explicitly provided (non-None). update_data: Dict[str, Any] = { @@ -405,11 +417,40 @@ async def _upsert_budget_and_membership( ) return - # No existing budget — create a new one and link it to the membership. + # Either there is no existing budget, OR the membership is still pointing + # at the team's shared default member budget. In both cases we create a + # NEW private budget for this user and (re)link the membership to it. create_data: Dict[str, Any] = { "created_by": user_api_key_dict.user_id or "", "updated_by": user_api_key_dict.user_id or "", } + + # If we're forking off the shared default, seed the new row with the + # default's values so fields the caller did not change carry over. + if is_shared_default: + default_budget_row = await tx.litellm_budgettable.find_unique( + where={"budget_id": existing_budget_id} + ) + if default_budget_row is not None: + default_budget_dict = default_budget_row.model_dump() + for field in ( + "max_budget", + "soft_budget", + "max_parallel_requests", + "tpm_limit", + "rpm_limit", + "model_max_budget", + "budget_duration", + "allowed_models", + ): + value = default_budget_dict.get(field) + if value is None: + continue + if isinstance(value, list) and len(value) == 0: + continue + create_data[field] = value + + # Caller-provided values take precedence over the cloned defaults. if max_budget is not None: create_data["max_budget"] = max_budget if tpm_limit is not None: diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 8474f026111..c6d37ace4fe 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -2120,9 +2120,7 @@ async def delete_user( for m in all_target_memberships: if not m.organization_id: continue - target_org_ids_by_user.setdefault(m.user_id, set()).add( - m.organization_id - ) + target_org_ids_by_user.setdefault(m.user_id, set()).add(m.organization_id) # check that all teams passed exist for user_id in data.user_ids: @@ -2141,9 +2139,7 @@ async def delete_user( # Org-admin may only delete users whose entire org membership is # within their admin scope. A target with ANY org outside the # caller's scope (or no org at all) requires PROXY_ADMIN. - if not target_org_ids or not target_org_ids.issubset( - caller_admin_org_ids - ): + if not target_org_ids or not target_org_ids.issubset(caller_admin_org_ids): raise HTTPException( status_code=403, detail={ diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index 56bbfe03005..a68c8ca9fa8 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -52,12 +52,17 @@ from litellm.proxy._experimental.mcp_server.utils import ( from litellm.proxy._experimental.mcp_server.utils import ( validate_and_normalize_mcp_server_payload as _base_validate_and_normalize_mcp_server_payload, ) +from litellm.proxy.common_utils.encrypt_decrypt_utils import ( + decrypt_value_helper, + encrypt_value_helper, +) router = APIRouter(prefix="/v1/mcp", tags=["mcp"]) MCP_AVAILABLE: bool = True TEMPORARY_MCP_SERVER_TTL_SECONDS = 300 +TEMPORARY_MCP_SERVER_REDIS_KEY_PREFIX = "litellm:mcp:temporary_server" def does_mcp_server_exist( @@ -329,13 +334,115 @@ if MCP_AVAILABLE: ) return server - def get_cached_temporary_mcp_server( + async def _cache_temporary_mcp_server_in_redis( + server: MCPServer, ttl_seconds: int + ) -> None: + """ + Best-effort write-through to Redis so temporary MCP OAuth sessions are + shared across proxy instances. Keep local in-memory cache as fallback. + """ + if litellm.cache is None or not hasattr(litellm.cache, "cache"): + return + cache_backend = getattr(litellm.cache, "cache", None) + if cache_backend is None or not hasattr(cache_backend, "async_set_cache"): + return + + payload: Dict[str, Any] = server.model_dump(mode="json") + payload_json = json.dumps(payload) + try: + encrypted_payload = encrypt_value_helper(payload_json) + except Exception as e: + verbose_proxy_logger.debug( + f"Failed to encrypt temporary MCP server payload for Redis cache: {str(e)}" + ) + return + + if not isinstance(encrypted_payload, str): + verbose_proxy_logger.debug( + "Encrypted temporary MCP payload is not a string; skipping Redis cache write" + ) + return + + try: + await cache_backend.async_set_cache( + key=f"{TEMPORARY_MCP_SERVER_REDIS_KEY_PREFIX}:{server.server_id}", + value=encrypted_payload, + ttl=max(1, ttl_seconds), + ) + except Exception as e: + verbose_proxy_logger.debug( + f"Failed to write temporary MCP server to Redis cache: {str(e)}" + ) + + async def _get_temporary_mcp_server_from_redis( + server_id: str, + ) -> Optional[MCPServer]: + """ + Best-effort read from Redis shared cache. Returns None on miss/errors. + + Values must be encrypted strings (same contract as _cache_temporary_mcp_server_in_redis); + legacy plaintext dict payloads are rejected. + """ + if litellm.cache is None or not hasattr(litellm.cache, "cache"): + return None + cache_backend = getattr(litellm.cache, "cache", None) + if cache_backend is None or not hasattr(cache_backend, "async_get_cache"): + return None + + try: + cached_server = await cache_backend.async_get_cache( + key=f"{TEMPORARY_MCP_SERVER_REDIS_KEY_PREFIX}:{server_id}" + ) + except Exception as e: + verbose_proxy_logger.debug( + f"Failed reading temporary MCP server from Redis cache: {str(e)}" + ) + return None + + if not isinstance(cached_server, str): + verbose_proxy_logger.debug( + "Temporary MCP Redis cache value must be an encrypted string; rejecting non-string payload" + ) + return None + + decrypted_json = decrypt_value_helper( + value=cached_server, + key="temporary_mcp_server", + exception_type="debug", + ) + if decrypted_json is None: + return None + try: + loaded = json.loads(decrypted_json) + except Exception as e: + verbose_proxy_logger.debug( + f"Invalid decrypted temporary MCP payload in Redis cache: {str(e)}" + ) + return None + if not isinstance(loaded, dict): + return None + payload_dict: Dict[str, Any] = loaded + + try: + return MCPServer(**payload_dict) + except Exception as e: + verbose_proxy_logger.debug( + f"Invalid temporary MCP server payload in Redis cache: {str(e)}" + ) + return None + + async def get_cached_temporary_mcp_server( server_id: str, ) -> Optional[MCPServer]: _prune_expired_temporary_mcp_servers() entry = _temporary_mcp_servers.get(server_id) if entry is None: - return None + redis_server = await _get_temporary_mcp_server_from_redis(server_id) + if redis_server is None: + return None + # Intentionally avoid repopulating local cache from Redis to prevent + # extending effective lifetime beyond the remaining Redis TTL. + return redis_server return entry.server def _redact_mcp_credentials( @@ -1325,6 +1432,10 @@ if MCP_AVAILABLE: temporary_server, ttl_seconds=TEMPORARY_MCP_SERVER_TTL_SECONDS, ) + await _cache_temporary_mcp_server_in_redis( + temporary_server, + ttl_seconds=TEMPORARY_MCP_SERVER_TTL_SECONDS, + ) except Exception as e: verbose_proxy_logger.exception( f"Error caching temporary mcp server: {str(e)}" @@ -1336,18 +1447,24 @@ if MCP_AVAILABLE: return _redact_mcp_credentials(temp_record) - def _get_cached_temporary_mcp_server_or_404(server_id: str) -> MCPServer: - server = get_cached_temporary_mcp_server(server_id) + async def _get_cached_temporary_mcp_server_or_404( + server_id: str, request: Optional[Request] = None + ) -> MCPServer: + server = await get_cached_temporary_mcp_server(server_id) if server is None: # Fall back to real DB/config server (e.g. for the user-side OAuth flow # which calls these endpoints with a real server_id, not a temp session id). from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( global_mcp_server_manager, ) + from litellm.proxy.auth.ip_address_utils import IPAddressUtils + client_ip = IPAddressUtils.get_mcp_client_ip(request) if request else None server = global_mcp_server_manager.get_mcp_server_by_id( server_id - ) or global_mcp_server_manager.get_mcp_server_by_name(server_id) + ) or global_mcp_server_manager.get_mcp_server_by_name( + server_id, client_ip=client_ip + ) if server is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -1358,10 +1475,12 @@ if MCP_AVAILABLE: @router.get( "/server/oauth/{server_id}/authorize", include_in_schema=False, + dependencies=[Depends(user_api_key_auth)], ) async def mcp_authorize( request: Request, server_id: str, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), client_id: Optional[str] = None, redirect_uri: str = Query(...), state: str = "", @@ -1370,7 +1489,9 @@ if MCP_AVAILABLE: response_type: Optional[str] = None, scope: Optional[str] = None, ): - mcp_server = _get_cached_temporary_mcp_server_or_404(server_id) + mcp_server = await _get_cached_temporary_mcp_server_or_404( + server_id, request=request + ) # Use the server's stored client_id when the caller doesn't supply one resolved_client_id = mcp_server.client_id or client_id or "" if not resolved_client_id: @@ -1399,10 +1520,12 @@ if MCP_AVAILABLE: @router.post( "/server/oauth/{server_id}/token", include_in_schema=False, + dependencies=[Depends(user_api_key_auth)], ) async def mcp_token( request: Request, server_id: str, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), grant_type: str = Form(...), code: Optional[str] = Form(None), redirect_uri: Optional[str] = Form(None), @@ -1412,7 +1535,9 @@ if MCP_AVAILABLE: refresh_token: Optional[str] = Form(None), scope: Optional[str] = Form(None), ): - mcp_server = _get_cached_temporary_mcp_server_or_404(server_id) + mcp_server = await _get_cached_temporary_mcp_server_or_404( + server_id, request=request + ) resolved_client_id = mcp_server.client_id or client_id or "" if not resolved_client_id: raise HTTPException( @@ -1441,9 +1566,16 @@ if MCP_AVAILABLE: @router.post( "/server/oauth/{server_id}/register", include_in_schema=False, + dependencies=[Depends(user_api_key_auth)], ) - async def mcp_register(request: Request, server_id: str): - mcp_server = _get_cached_temporary_mcp_server_or_404(server_id) + async def mcp_register( + request: Request, + server_id: str, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + ): + mcp_server = await _get_cached_temporary_mcp_server_or_404( + server_id, request=request + ) request_data = await _read_request_body(request=request) data: dict = {**request_data} diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index a6a1af971e5..442fae2a4fa 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -1078,10 +1078,7 @@ async def organization_member_update( LitellmUserRoles.PROXY_ADMIN.value, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, ): - if ( - user_api_key_dict.user_role - != LitellmUserRoles.PROXY_ADMIN.value - ): + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value: raise HTTPException( status_code=403, detail={ diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 8e21b851857..1ef40f0685f 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -43,6 +43,7 @@ from litellm.proxy._types import ( LitellmUserRoles, Member, NewTeamRequest, + OrgMember, ProxyErrorTypes, ProxyException, SpecialManagementEndpointEnums, @@ -78,6 +79,9 @@ from litellm.proxy.management_endpoints.common_utils import ( _upsert_budget_and_membership, _user_has_admin_view, ) +from litellm.proxy.management_endpoints.organization_endpoints import ( + add_member_to_organization, +) from litellm.proxy.management_endpoints.tag_management_endpoints import ( get_daily_activity, ) @@ -302,14 +306,15 @@ class TeamMemberBudgetHandler: prisma_client: PrismaClient, ) -> None: """ - Create team_memberships entries for existing members that don't have one. + Ensure every team member has a TeamMembership row linked to the + team_member_budget. - Called after team_member_budget is set/updated on a team to ensure - members who joined before the budget was configured also get budget - enforcement. - - Only creates missing entries — does not touch existing memberships - (which may carry individual per-member budgets). + Called after team_member_budget is set/updated on a team. Creates + rows for members who don't have one, and populates budget_id on + existing rows where it is NULL. Rows with a non-NULL budget_id + are left untouched, which preserves per-member overrides but also + means rows pointing to a prior team-default budget_id are not + migrated to the new one. """ if not members_with_roles: return @@ -347,6 +352,21 @@ class TeamMemberBudgetHandler: _sanitize_for_log(team_member_budget_id), ) + # Heal existing membership rows that predate the team_member_budget + # configuration: populate budget_id where it is currently NULL. + # Rows with an explicit budget_id (per-member override) are left alone. + updated = await prisma_client.db.litellm_teammembership.update_many( + where={"team_id": team_id, "budget_id": None}, + data={"budget_id": team_member_budget_id}, + ) + if updated: + verbose_proxy_logger.info( + "Populated budget_id on %d existing team_memberships for team %s with budget %s", + updated, + _sanitize_for_log(team_id), + _sanitize_for_log(team_member_budget_id), + ) + def _get_default_team_param(field: str) -> Any: """ @@ -1239,11 +1259,53 @@ async def _update_model_table( return _model_id +async def _auto_add_team_members_to_organization( + team: LiteLLM_TeamTable, + organization: LiteLLM_OrganizationTableWithMembers, + prisma_client: Any, +) -> None: + """ + When moving a team to an org, ensure all team members are also org members. + + For SSO/Entra setups without SCIM, users join teams automatically on login but + are never explicitly added to organizations. This silently upserts missing members + rather than blocking the team move. + """ + org_member_ids = ( + {m.user_id for m in organization.members} if organization.members else set() + ) + for member in team.members_with_roles: + if member.user_id is None: + continue + if member.user_id == SpecialProxyStrings.default_user_id.value: + continue + if member.user_id in org_member_ids: + continue + if organization.organization_id is None: + continue + try: + await add_member_to_organization( + member=OrgMember( + user_id=member.user_id, + role=LitellmUserRoles.INTERNAL_USER, + ), + organization_id=organization.organization_id, + prisma_client=prisma_client, + ) + except Exception as e: + verbose_proxy_logger.debug( + "_auto_add_team_members_to_organization: skipping user_id=%s - %s", + member.user_id, + e, + ) + + async def fetch_and_validate_organization( organization_id: str, existing_team_row: Any, llm_router: Optional[Router], prisma_client: Any, + user_api_key_dict: Optional[UserAPIKeyAuth] = None, ) -> Any: """ Fetch and validate an organization for team update operations. @@ -1278,14 +1340,25 @@ async def fetch_and_validate_organization( }, ) + is_proxy_admin = ( + user_api_key_dict is not None + and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN + ) + organization = LiteLLM_OrganizationTableWithMembers(**organization_row.model_dump()) validate_team_org_change( team=LiteLLM_TeamTable(**existing_team_row.model_dump()), - organization=LiteLLM_OrganizationTableWithMembers( - **organization_row.model_dump() - ), + organization=organization, llm_router=llm_router, + is_proxy_admin=is_proxy_admin, ) + if is_proxy_admin: + await _auto_add_team_members_to_organization( + team=LiteLLM_TeamTable(**existing_team_row.model_dump()), + organization=organization, + prisma_client=prisma_client, + ) + return organization_row @@ -1293,14 +1366,20 @@ def validate_team_org_change( team: LiteLLM_TeamTable, organization: LiteLLM_OrganizationTableWithMembers, llm_router: Router, + is_proxy_admin: bool = False, ) -> bool: """ Validate that a team can be moved to an organization. - The org must have access to the team's models - The team budget cannot be greater than the org max_budget - - The team's user_id must be a member of the org + - For non-proxy-admins: all team members must already be org members - The team's tpm/rpm limit must be less than the org's tpm/rpm limit + + Proxy admins bypass the membership check and instead trigger auto-add of + missing members (handled by the caller). This supports SSO/Entra setups + where org membership tables are empty but proxy admins still need to group + teams under orgs for budget/model governance. """ # If the team's organization is the same as the new organization, return True @@ -1341,23 +1420,26 @@ def validate_team_org_change( }, ) - # Check if the team's user_id is a member of the org - team_members = [m.user_id for m in team.members_with_roles] - org_members = ( - [m.user_id for m in organization.members] if organization.members else [] - ) - not_in_org = [ - m - for m in team_members - if m not in org_members and m != SpecialProxyStrings.default_user_id.value - ] - if len(not_in_org) > 0: - raise HTTPException( - status_code=403, - detail={ - "error": f"Cannot move team to organization. Team has user_id {not_in_org} that is not a member of the organization." - }, + # For non-proxy-admins, require all team members to already be org members. + # This prevents a team admin from moving their team into an arbitrary org and + # thereby injecting members into that org without org admin approval. + if not is_proxy_admin: + team_members = [m.user_id for m in team.members_with_roles] + org_members = ( + [m.user_id for m in organization.members] if organization.members else [] ) + not_in_org = [ + m + for m in team_members + if m not in org_members and m != SpecialProxyStrings.default_user_id.value + ] + if len(not_in_org) > 0: + raise HTTPException( + status_code=403, + detail={ + "error": f"Cannot move team to organization. Team has user_id {not_in_org} that is not a member of the organization." + }, + ) # Check if the team's tpm/rpm limit is less than the org's tpm/rpm limit if ( @@ -1570,8 +1652,7 @@ async def update_team( # noqa: PLR0915 current_org_id = getattr(existing_team_row, "organization_id", None) if ( data.organization_id != current_org_id - and user_api_key_dict.user_role - != LitellmUserRoles.PROXY_ADMIN.value + and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value ): # Is the caller org_admin of the destination org? caller_memberships = ( @@ -1602,6 +1683,7 @@ async def update_team( # noqa: PLR0915 existing_team_row=existing_team_row, llm_router=llm_router, prisma_client=prisma_client, + user_api_key_dict=user_api_key_dict, ) elif data.organization_id is not None and len(data.organization_id) == 0: # unsetting the organization_id @@ -2609,6 +2691,15 @@ async def team_member_update( identified_budget_id = tm.budget_id break + # If this membership still points at the team's shared default member + # budget, _upsert_budget_and_membership will clone-on-write so that the + # update only touches this user (not every member sharing the default). + team_default_budget_id: Optional[str] = None + if team_table.metadata is not None: + raw_default_budget_id = team_table.metadata.get("team_member_budget_id") + if isinstance(raw_default_budget_id, str): + team_default_budget_id = raw_default_budget_id + ### upsert new budget async with prisma_client.db.tx() as tx: await _upsert_budget_and_membership( @@ -2621,6 +2712,7 @@ async def team_member_update( tpm_limit=data.tpm_limit, rpm_limit=data.rpm_limit, allowed_models=data.allowed_models, + team_default_budget_id=team_default_budget_id, ) ### update team member role diff --git a/litellm/proxy/management_helpers/utils.py b/litellm/proxy/management_helpers/utils.py index 3e42d392077..f2d6e9612ff 100644 --- a/litellm/proxy/management_helpers/utils.py +++ b/litellm/proxy/management_helpers/utils.py @@ -9,6 +9,7 @@ from fastapi import HTTPException, Request import litellm from litellm._logging import verbose_logger from litellm._uuid import uuid +from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time from litellm.proxy._types import ( # key request types; user request types; team request types; customer request types BudgetNewRequest, DeleteCustomerRequest, @@ -140,6 +141,69 @@ async def handle_budget_for_entity( return existing_budget_id +# Fields on LiteLLM_BudgetTable that represent the budget's *configuration* +# (i.e. the values an admin sets). We copy these when cloning a team's +# default member-budget into an individual member-budget so that the new +# row starts with the same limits as the default. +_CLONABLE_BUDGET_FIELDS: Tuple[str, ...] = ( + "max_budget", + "soft_budget", + "max_parallel_requests", + "tpm_limit", + "rpm_limit", + "model_max_budget", + "budget_duration", + "allowed_models", +) + + +async def _clone_team_default_budget_for_member( + prisma_client: PrismaClient, + default_team_budget_id: str, + user_api_key_dict: UserAPIKeyAuth, + litellm_proxy_admin_name: str, +) -> Optional[str]: + """ + Create a new budget row that copies the values from the team's default + member budget. Returns the new budget_id, or None if the default budget + no longer exists in the DB. + + Used when adding a new team member without an explicit per-member budget, + so the member starts with the team default's values but gets their own + private budget row (which can be edited independently). + """ + default_budget = await prisma_client.db.litellm_budgettable.find_unique( + where={"budget_id": default_team_budget_id} + ) + if default_budget is None: + return None + + default_budget_dict = default_budget.model_dump() + cloned_data: dict = { + "created_by": user_api_key_dict.user_id or litellm_proxy_admin_name, + "updated_by": user_api_key_dict.user_id or litellm_proxy_admin_name, + } + for field in _CLONABLE_BUDGET_FIELDS: + value = default_budget_dict.get(field) + if value is None: + continue + # Skip empty list defaults (e.g. allowed_models = []) so the cloned + # row matches the "no value set" shape rather than carrying a default. + if isinstance(value, list) and len(value) == 0: + continue + cloned_data[field] = value + + # Start the member's budget window at clone time, not the pool's reset + # timestamp — otherwise a member joining mid-cycle inherits a stale reset. + if cloned_data.get("budget_duration"): + cloned_data["budget_reset_at"] = get_budget_reset_time( + cloned_data["budget_duration"] + ) + + new_budget = await prisma_client.db.litellm_budgettable.create(data=cloned_data) + return new_budget.budget_id + + async def add_new_member( new_member: Member, max_budget_in_team: Optional[float], @@ -221,8 +285,20 @@ async def add_new_member( response = await prisma_client.db.litellm_budgettable.create(data=budget_data) _budget_id = response.budget_id + elif default_team_budget_id is not None: + # No per-member budget was provided, but the team has a default member + # budget. Clone the default budget into a new row for this user so that + # later edits to one member's budget do not bleed into other members. + # If the default no longer exists in the DB, fall back to no budget. + _budget_id = await _clone_team_default_budget_for_member( + prisma_client=prisma_client, + default_team_budget_id=default_team_budget_id, + user_api_key_dict=user_api_key_dict, + litellm_proxy_admin_name=litellm_proxy_admin_name, + ) else: - _budget_id = default_team_budget_id + # No per-member budget and no team default → member gets no budget. + _budget_id = None if _budget_id and returned_user is not None and returned_user.user_id is not None: _returned_team_membership = ( diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 1ef866486ec..418715cb9cd 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -8,6 +8,7 @@ Use litellm with Anthropic SDK, Vertex AI SDK, Cohere SDK, etc. import json import os +import re from typing import Any, Optional, Tuple, Union, cast import httpx @@ -1496,10 +1497,18 @@ class VertexAIPassThroughHandler(BaseVertexAIPassThroughHandler): def get_vertex_base_url(vertex_location: Optional[str]) -> str: """ - Returns the base URL for Vertex AI based on the provided location. + Base URL for Vertex AI pass-through (trailing slash for URL joining). + + Keep location rules aligned with ``litellm.llms.vertex_ai.common_utils.get_vertex_base_url``. """ if vertex_location == "global": return "https://aiplatform.googleapis.com/" + if vertex_location is None: + raise ValueError("vertex_location is required") + if not re.match(r"^[a-z][a-z0-9-]*$", vertex_location): + raise ValueError("Invalid vertex_location format") + if "-" not in vertex_location: + return f"https://aiplatform.{vertex_location}.rep.googleapis.com/" return f"https://{vertex_location}-aiplatform.googleapis.com/" @@ -1703,7 +1712,8 @@ async def _base_vertex_proxy_route( Base function for Vertex AI passthrough routes. Handles common logic for all Vertex AI services. - Default base_target_url is `https://{vertex_location}-aiplatform.googleapis.com/` + Default base_target_url is derived from ``get_vertex_base_url`` in this module + (regional, ``global``, or multi-region ``.rep.`` hosts), with a trailing slash. Args: endpoint: The endpoint path @@ -2275,11 +2285,7 @@ async def vertex_ai_live_websocket_passthrough( return host_location = resolved_location or vertex_llm_base.get_default_vertex_location() - host = ( - "aiplatform.googleapis.com" - if host_location == "global" - else f"{host_location}-aiplatform.googleapis.com" - ) + host = get_vertex_base_url(host_location).removeprefix("https://").rstrip("/") service_url = ( f"wss://{host}/ws/google.cloud.aiplatform.v1.LlmBidiService/BidiGenerateContent" ) diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index c638e294268..3845203bb9d 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -577,6 +577,22 @@ class ProxyInitializationHelpers: help="Exit with error if database migration fails on startup.", envvar="ENFORCE_PRISMA_MIGRATION_CHECK", ) +@click.option( + "--use_v2_migration_resolver", + is_flag=True, + default=False, + help=( + "Opt into the v2 migration resolver. Avoids the diff-and-force recovery " + "path that can cause schema thrashing during rolling deploys where two " + "LiteLLM versions contend for the same DB. Default is the v1 resolver." + ), +) +@click.option( + "--reload", + is_flag=True, + default=False, + help="Enable uvicorn hot reload (dev only). Incompatible with --num_workers>1, --run_gunicorn, and --run_hypercorn.", +) def run_server( # noqa: PLR0915 host, port, @@ -618,6 +634,8 @@ def run_server( # noqa: PLR0915 keepalive_timeout, max_requests_before_restart, enforce_prisma_migration_check: bool, + use_v2_migration_resolver: bool, + reload: bool, ): if setup: from litellm.setup_wizard import run_setup_wizard @@ -886,9 +904,31 @@ def run_server( # noqa: PLR0915 ): check_prisma_schema_diff(db_url=None) else: - if not PrismaManager.setup_database( - use_migrate=not use_prisma_db_push - ): + if not use_v2_migration_resolver: + print( # noqa + "\033[1;33mLiteLLM Proxy: Using default (v1) migration resolver. " + "If your deployment has seen schema thrashing during rolling " + "deploys, try --use_v2_migration_resolver (safer: avoids the " + "diff-and-force recovery that caused the thrash).\033[0m" + ) + try: + setup_ok = PrismaManager.setup_database( + use_migrate=not use_prisma_db_push, + use_v2_resolver=use_v2_migration_resolver, + ) + except RuntimeError as e: + # v2 resolver raises on unrecoverable migration errors + # (e.g. non-idempotent failures, permission issues). + # v1 never raises here, so this only fires when the + # operator opted into v2. + print( # noqa + "\033[1;31mLiteLLM Proxy: Database migration cannot proceed. " + f"{e}\033[0m", + file=sys.stderr, + flush=True, + ) + sys.exit(2) + if not setup_ok: if enforce_prisma_migration_check: print( # noqa "\033[1;31mLiteLLM Proxy: Database setup failed after multiple retries. " @@ -954,6 +994,9 @@ def run_server( # noqa: PLR0915 if loop_type: uvicorn_args["loop"] = loop_type + if reload: + uvicorn_args["reload"] = True + uvicorn.run( **uvicorn_args, workers=num_workers, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index d8354a798b1..00c2cf9e3d6 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -952,6 +952,17 @@ async def proxy_startup_event(app: FastAPI): # noqa: PLR0915 _run_background_health_check() ) # start the background health check coroutine. + # Start adaptive-router queue flusher unconditionally — adaptive routers + # may be added later via `/config/reload`, and the flusher is a no-op when + # `llm_router.adaptive_routers` is empty. Per-router DB state is loaded + # lazily by the flusher on first tick (see `_state_loaded` flag) so + # hot-reloaded routers also get their persisted priors. + if llm_router is not None and getattr(llm_router, "adaptive_routers", None): + for _ar in llm_router.adaptive_routers.values(): + await _ar.load_state_from_db(prisma_client) + _ar._state_loaded = True + asyncio.create_task(_adaptive_router_flusher_loop()) + ## [Optional] Initialize dd tracer ProxyStartupEvent._init_dd_tracer() @@ -1795,6 +1806,7 @@ async def increment_spend_counters( team_id: Optional[str], user_id: Optional[str], response_cost: Optional[float], + org_id: Optional[str] = None, ): """ Atomically increment spend counters for budget enforcement. @@ -1881,6 +1893,83 @@ async def increment_spend_counters( increment=response_cost, ) + if user_id is not None: + await _init_and_increment_spend_counter( + counter_key=f"spend:user:{user_id}", + source_cache_key=user_id, + increment=response_cost, + ) + + if org_id is not None: + await _init_and_increment_spend_counter( + counter_key=f"spend:org:{org_id}", + source_cache_key=f"org_id:{org_id}", + increment=response_cost, + ) + + +async def _reseed_spend_from_db(counter_key: str) -> float: + """ + Read the authoritative spend for a missing counter from the DB. The + counter_key prefix encodes the table to query: + + spend:key:{token} -> LiteLLM_VerificationToken.spend + spend:team:{team_id} -> LiteLLM_TeamTable.spend + spend:team_member:{uid}:{tid} -> LiteLLM_TeamMembership.spend + spend:user:{user_id} -> LiteLLM_UserTable.spend + spend:org:{org_id} -> LiteLLM_OrganizationTable.spend + + Returns 0.0 if prisma is unavailable, the row is missing, or the + key format is unrecognized. On failure, logs and returns 0.0 rather + than raising so the caller can still record the current increment. + """ + if prisma_client is None: + return 0.0 + # Per-window counters (spend:*:window:{duration}) share prefixes with + # primary counters but don't correspond to a DB row; their ambiguity + # would otherwise be silently parsed as a regular counter and miss. + if ":window:" in counter_key: + return 0.0 + try: + if counter_key.startswith("spend:key:"): + token = counter_key[len("spend:key:") :] + row = await prisma_client.db.litellm_verificationtoken.find_unique( + where={"token": token} + ) + elif counter_key.startswith("spend:team_member:"): + suffix = counter_key[len("spend:team_member:") :] + if ":" not in suffix: + return 0.0 + user_id, team_id = suffix.rsplit(":", 1) + row = await prisma_client.db.litellm_teammembership.find_unique( + where={"user_id_team_id": {"user_id": user_id, "team_id": team_id}} + ) + elif counter_key.startswith("spend:team:"): + team_id = counter_key[len("spend:team:") :] + row = await prisma_client.db.litellm_teamtable.find_unique( + where={"team_id": team_id} + ) + elif counter_key.startswith("spend:user:"): + user_id = counter_key[len("spend:user:") :] + row = await prisma_client.db.litellm_usertable.find_unique( + where={"user_id": user_id} + ) + elif counter_key.startswith("spend:org:"): + org_id = counter_key[len("spend:org:") :] + row = await prisma_client.db.litellm_organizationtable.find_unique( + where={"organization_id": org_id} + ) + else: + return 0.0 + except Exception: + verbose_proxy_logger.exception( + "Failed to reseed spend counter %s from DB", counter_key + ) + return 0.0 + if row is None: + return 0.0 + return float(getattr(row, "spend", 0.0) or 0.0) + async def _init_and_increment_spend_counter( counter_key: str, @@ -1888,28 +1977,33 @@ async def _init_and_increment_spend_counter( increment: float, ): """ - Initialize counter from cached object's DB-loaded spend if not yet set, - then atomically increment in both in-memory and Redis. + Initialize counter from the authoritative DB spend value if not yet + set, then atomically increment in both in-memory and Redis. On first access per pod: - 1. Check spend_counter_cache (in-memory -> Redis via DualCache for init check) - 2. If not found anywhere, read base spend from user_api_key_cache (DB-loaded object) + 1. Check spend_counter_cache (in-memory -> Redis via DualCache) + 2. If not found, reseed from the DB (`_reseed_spend_from_db`). Falls + back to the cached object's `.spend` via user_api_key_cache only + if prisma is unavailable, since that value can lag the flusher. 3. Seed counter via async_increment_cache (not async_set_cache) to avoid a check-then-set race: if two pods cold-start simultaneously, both may see - the counter as absent and seed it. Using increment instead of set means - the worst case is over-counting (conservative — blocks slightly early) - rather than under-counting (would allow overspend). + the counter as absent and seed it. Using increment means the worst case + is over-counting (conservative, blocks slightly early) rather than + under-counting (would allow overspend). 4. Increment atomically (both in-memory + Redis) """ current = await spend_counter_cache.async_get_cache(key=counter_key) if current is None: - source = await user_api_key_cache.async_get_cache(key=source_cache_key) - base_spend = 0.0 - if source is not None: - if isinstance(source, dict): - base_spend = source.get("spend", 0.0) or 0.0 - else: - base_spend = getattr(source, "spend", 0.0) or 0.0 + base_spend = await _reseed_spend_from_db(counter_key) + if prisma_client is None: + # Best-effort fallback when prisma is unavailable (tests or + # early-startup paths). May be stale but avoids resetting to 0. + source = await user_api_key_cache.async_get_cache(key=source_cache_key) + if source is not None: + if isinstance(source, dict): + base_spend = source.get("spend", 0.0) or 0.0 + else: + base_spend = getattr(source, "spend", 0.0) or 0.0 if base_spend > 0: await spend_counter_cache.async_increment_cache( key=counter_key, value=base_spend @@ -2427,6 +2521,38 @@ def _write_health_state_to_router_cache( ) +_ADAPTIVE_ROUTER_FLUSH_INTERVAL_SECONDS = 10 + + +async def _adaptive_router_flusher_loop(): + """ + Drain every AdaptiveRouter's in-memory state + session aggregators into + Postgres on a fixed cadence. Hot-path writes go to memory; this loop is + the only writer to the adaptive router DB tables. + """ + global llm_router, prisma_client + while True: + try: + await asyncio.sleep(_ADAPTIVE_ROUTER_FLUSH_INTERVAL_SECONDS) + adaptive_routers = getattr(llm_router, "adaptive_routers", None) or {} + if not adaptive_routers or prisma_client is None: + continue + for ar in adaptive_routers.values(): + # Lazy state load: covers adaptive routers registered via + # `/config/reload` after proxy boot. + if not getattr(ar, "_state_loaded", False): + try: + await ar.load_state_from_db(prisma_client) + finally: + ar._state_loaded = True + await ar.queue.flush_state_to_db(prisma_client) + await ar.queue.flush_session_to_db(prisma_client) + except asyncio.CancelledError: + raise + except Exception: + verbose_proxy_logger.exception("adaptive_router flusher iteration failed") + + async def _run_background_health_check(): """ Periodically run health checks in the background on the endpoints. @@ -7231,6 +7357,7 @@ async def chat_completion( # noqa: PLR0915 and user_api_key_dict.agent_id is not None ): data["metadata"]["agent_id"] = user_api_key_dict.agent_id + base_llm_response_processor = ProxyBaseLLMRequestProcessing(data=data) try: result = await base_llm_response_processor.base_process_llm_request( @@ -13953,6 +14080,38 @@ async def home(request: Request): return "LiteLLM: RUNNING" +@router.get( + "/adaptive_router/state", + tags=["adaptive_router"], + dependencies=[Depends(user_api_key_auth)], +) +async def get_adaptive_router_state( + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """Return live bandit posteriors + queue depth for every configured adaptive router. + + Admin-only. Returns 404 if no adaptive router is configured. + + Response shape: `{"routers": [, ...]}` — one snapshot per + adaptive-router deployment. Each snapshot's `router_name` field identifies + which deployment it came from. + """ + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException( + status_code=403, + detail={"error": CommonProxyErrors.not_allowed_access.value}, + ) + if llm_router is None or not llm_router.adaptive_routers: + raise HTTPException( + status_code=404, + detail={"error": "No adaptive_router is configured on this proxy."}, + ) + snapshots = [ + await ar.get_state_snapshot() for ar in llm_router.adaptive_routers.values() + ] + return {"routers": snapshots} + + @router.get("/routes", dependencies=[Depends(user_api_key_auth)]) async def get_routes(): """ diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 08aa5645251..34686148ce0 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -616,6 +616,7 @@ model LiteLLM_TeamMembership { user_id String team_id String spend Float @default(0.0) + total_spend Float @default(0.0) budget_id String? litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id]) @@id([user_id, team_id]) @@ -1223,3 +1224,46 @@ model LiteLLM_ClaudeCodePluginTable { @@map("LiteLLM_ClaudeCodePluginTable") } + +// Per-(router, request_type, model) Beta posterior for the adaptive router. +model LiteLLM_AdaptiveRouterState { + router_name String + request_type String + model_name String + alpha Float + beta Float + total_samples Int @default(0) + last_updated_at DateTime @default(now()) @updatedAt + + @@id([router_name, request_type, model_name]) +} + +// Per-(session, router, model) signal counters for the adaptive router. +model LiteLLM_AdaptiveRouterSession { + session_id String + router_name String + model_name String + classified_type String + + misalignment_count Int @default(0) + stagnation_count Int @default(0) + disengagement_count Int @default(0) + satisfaction_count Int @default(0) + failure_count Int @default(0) + loop_count Int @default(0) + exhaustion_count Int @default(0) + + last_user_content String? + last_assistant_content String? + tool_call_history Json @default("[]") + pending_tool_calls Json @default("{}") + + turn_count Int @default(0) + last_processed_turn Int @default(-1) + clean_credit_awarded Boolean @default(false) + terminal_status Int? + last_activity_at DateTime @default(now()) @updatedAt + + @@id([session_id, router_name, model_name]) + @@index([last_activity_at], map: "idx_adaptive_router_session_activity") +} diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index f21a729f551..712853a33c4 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -940,7 +940,11 @@ class ProxyLogging: Result from the guardrail execution """ # Use unified_guardrail if callback has apply_guardrail method - use_unified = "apply_guardrail" in type(callback).__dict__ + has_apply_guardrail = "apply_guardrail" in type(callback).__dict__ + use_unified = has_apply_guardrail and not ( + hook_type == "during_call" + and getattr(callback, "use_native_during_call_hook", False) + ) if use_unified: data["guardrail_to_apply"] = callback @@ -1540,6 +1544,7 @@ class ProxyLogging: if ( "apply_guardrail" in type(callback).__dict__ and user_api_key_dict is not None + and not getattr(callback, "use_native_during_call_hook", False) ): data["guardrail_to_apply"] = callback guardrail_task = self._run_guardrail_task_with_enrichment( diff --git a/litellm/proxy/vector_store_endpoints/endpoints.py b/litellm/proxy/vector_store_endpoints/endpoints.py index b7e3d8de3d3..1fdfad8c96c 100644 --- a/litellm/proxy/vector_store_endpoints/endpoints.py +++ b/litellm/proxy/vector_store_endpoints/endpoints.py @@ -10,6 +10,7 @@ from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing from litellm.proxy.utils import jsonify_object +from litellm.proxy.vector_store_endpoints.utils import can_user_access_vector_store from litellm.types.vector_stores import IndexCreateRequest router = APIRouter() @@ -18,40 +19,25 @@ router = APIRouter() ######################################################## -def _check_vector_store_access( +async def _check_vector_store_access( vector_store: LiteLLM_ManagedVectorStore, user_api_key_dict: UserAPIKeyAuth, ) -> bool: """ - Check if the user has access to the vector store based on team membership. + Check if the user has access to the vector store. - Args: - vector_store: The vector store to check access for - user_api_key_dict: User API key authentication info - - Returns: - True if user has access, False otherwise - - Access rules: - - If vector store has no team_id, it's accessible to all (legacy behavior) - - If user's team_id matches the vector store's team_id, access is granted - - Otherwise, access is denied + Delegates to :func:`can_user_access_vector_store`, which honors: + - PROXY_ADMIN bypass + - legacy vector stores with no team_id + - key-level and team-level ``object_permission.vector_stores`` allowlists + - team_id match between key and store """ - vector_store_team_id = vector_store.get("team_id") - - # If vector store has no team_id, it's accessible to all (legacy behavior) - if vector_store_team_id is None: - return True - - # Check if user's team matches the vector store's team - user_team_id = user_api_key_dict.team_id - if user_team_id == vector_store_team_id: - return True - - return False + return await can_user_access_vector_store( + vector_store=vector_store, user_api_key_dict=user_api_key_dict + ) -def _update_request_data_with_litellm_managed_vector_store_registry( +async def _update_request_data_with_litellm_managed_vector_store_registry( data: Dict, vector_store_id: str, user_api_key_dict: Optional[UserAPIKeyAuth] = None, @@ -74,9 +60,8 @@ def _update_request_data_with_litellm_managed_vector_store_registry( ) ) if vector_store_to_run is not None: - # Check access control if user_api_key_dict is provided if user_api_key_dict is not None: - if not _check_vector_store_access( + if not await _check_vector_store_access( vector_store_to_run, user_api_key_dict ): raise HTTPException( @@ -140,7 +125,7 @@ async def vector_store_search( data["vector_store_id"] = vector_store_id # Check for legacy vector store registry (non-managed vector stores) - data = _update_request_data_with_litellm_managed_vector_store_registry( + data = await _update_request_data_with_litellm_managed_vector_store_registry( data=data, vector_store_id=vector_store_id, user_api_key_dict=user_api_key_dict ) @@ -322,7 +307,7 @@ async def vector_store_retrieve( data = {"vector_store_id": vector_store_id} - data = _update_request_data_with_litellm_managed_vector_store_registry( + data = await _update_request_data_with_litellm_managed_vector_store_registry( data=data, vector_store_id=vector_store_id, user_api_key_dict=user_api_key_dict ) @@ -462,7 +447,7 @@ async def vector_store_update( if "vector_store_id" not in data: data["vector_store_id"] = vector_store_id - data = _update_request_data_with_litellm_managed_vector_store_registry( + data = await _update_request_data_with_litellm_managed_vector_store_registry( data=data, vector_store_id=vector_store_id, user_api_key_dict=user_api_key_dict ) @@ -529,7 +514,7 @@ async def vector_store_delete( data = {"vector_store_id": vector_store_id} - data = _update_request_data_with_litellm_managed_vector_store_registry( + data = await _update_request_data_with_litellm_managed_vector_store_registry( data=data, vector_store_id=vector_store_id, user_api_key_dict=user_api_key_dict ) diff --git a/litellm/proxy/vector_store_endpoints/management_endpoints.py b/litellm/proxy/vector_store_endpoints/management_endpoints.py index cf579993660..fefa6cb4e94 100644 --- a/litellm/proxy/vector_store_endpoints/management_endpoints.py +++ b/litellm/proxy/vector_store_endpoints/management_endpoints.py @@ -25,6 +25,7 @@ from litellm.proxy._types import ( from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper from litellm.proxy.common_utils.rbac_utils import check_feature_access_for_user +from litellm.proxy.vector_store_endpoints.utils import can_user_access_vector_store from litellm.secret_managers.main import get_secret from litellm.types.vector_stores import ( LiteLLM_ManagedVectorStore, @@ -274,37 +275,22 @@ async def _resolve_embedding_config( ######################################################## # Helper Functions ######################################################## -def _check_vector_store_access( +async def _check_vector_store_access( vector_store: LiteLLM_ManagedVectorStore, user_api_key_dict: UserAPIKeyAuth, ) -> bool: """ - Check if the user has access to the vector store based on team membership. + Check if the user has access to the vector store. - Args: - vector_store: The vector store to check access for - user_api_key_dict: User API key authentication info - - Returns: - True if user has access, False otherwise - - Access rules: - - If vector store has no team_id, it's accessible to all (legacy behavior) - - If user's team_id matches the vector store's team_id, access is granted - - Otherwise, access is denied + Delegates to :func:`can_user_access_vector_store`, which honors: + - PROXY_ADMIN bypass + - legacy vector stores with no team_id + - key-level and team-level ``object_permission.vector_stores`` allowlists + - team_id match between key and store """ - vector_store_team_id = vector_store.get("team_id") - - # If vector store has no team_id, it's accessible to all (legacy behavior) - if vector_store_team_id is None: - return True - - # Check if user's team matches the vector store's team - user_team_id = user_api_key_dict.team_id - if user_team_id == vector_store_team_id: - return True - - return False + return await can_user_access_vector_store( + vector_store=vector_store, user_api_key_dict=user_api_key_dict + ) async def create_vector_store_in_db( @@ -565,12 +551,11 @@ async def list_vector_stores( vector_store_id=vector_store_id, updated_data=vector_store ) - # Filter vector stores based on team access - accessible_vector_stores = [ - vs - for vs in vector_store_map.values() - if _check_vector_store_access(vs, user_api_key_dict) - ] + # Filter vector stores based on access control + accessible_vector_stores = [] + for vs in vector_store_map.values(): + if await _check_vector_store_access(vs, user_api_key_dict): + accessible_vector_stores.append(vs) total_count = len(accessible_vector_stores) total_pages = (total_count + page_size - 1) // page_size @@ -647,7 +632,7 @@ async def delete_vector_store( ) # Check access control - if vector_store_to_check and not _check_vector_store_access( + if vector_store_to_check and not await _check_vector_store_access( vector_store_to_check, user_api_key_dict ): raise HTTPException( @@ -703,7 +688,9 @@ async def get_vector_store_info( ) if vector_store is not None: # Check access control - if not _check_vector_store_access(vector_store, user_api_key_dict): + if not await _check_vector_store_access( + vector_store, user_api_key_dict + ): raise HTTPException( status_code=403, detail="Access denied: You do not have permission to access this vector store", @@ -749,7 +736,7 @@ async def get_vector_store_info( # Check access control for DB vector store vector_store_dict = vector_store.model_dump() # type: ignore[attr-defined] vector_store_typed = LiteLLM_ManagedVectorStore(**vector_store_dict) - if not _check_vector_store_access(vector_store_typed, user_api_key_dict): + if not await _check_vector_store_access(vector_store_typed, user_api_key_dict): raise HTTPException( status_code=403, detail="Access denied: You do not have permission to access this vector store", diff --git a/litellm/proxy/vector_store_endpoints/utils.py b/litellm/proxy/vector_store_endpoints/utils.py index 5499abb19d5..061a8aaa240 100644 --- a/litellm/proxy/vector_store_endpoints/utils.py +++ b/litellm/proxy/vector_store_endpoints/utils.py @@ -2,11 +2,124 @@ from typing import Any, Dict, Literal, Optional from fastapi import HTTPException, Request -from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm._logging import verbose_proxy_logger +from litellm.proxy._types import ( + LiteLLM_ObjectPermissionTable, + LitellmUserRoles, + UserAPIKeyAuth, +) from litellm.types.utils import LlmProviders +from litellm.types.vector_stores import LiteLLM_ManagedVectorStore from litellm.utils import ProviderConfigManager +def _is_proxy_admin(user_api_key_dict: UserAPIKeyAuth) -> bool: + return ( + user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN + or user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value + ) + + +def _object_permission_allows_vector_store( + object_permission: Optional[LiteLLM_ObjectPermissionTable], + vector_store_id: str, +) -> bool: + """Returns True if an object permission explicitly allowlists the vector store.""" + if object_permission is None: + return False + allowed = object_permission.vector_stores + if not allowed: + return False + return vector_store_id in allowed + + +async def _get_object_permission_for_id( + object_permission_id: Optional[str], +) -> Optional[LiteLLM_ObjectPermissionTable]: + """Load an object permission record by id, using the shared cache/DB helper.""" + if not object_permission_id: + return None + + from litellm.proxy.auth.auth_checks import get_object_permission + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_logging_obj, + user_api_key_cache, + ) + + if prisma_client is None: + return None + + try: + return await get_object_permission( + object_permission_id=object_permission_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + except Exception as e: + verbose_proxy_logger.debug( + "Failed to load object_permission id=%s: %s", + object_permission_id, + e, + ) + return None + + +async def can_user_access_vector_store( + vector_store: LiteLLM_ManagedVectorStore, + user_api_key_dict: UserAPIKeyAuth, +) -> bool: + """ + Returns True if the caller is allowed to access this managed vector store. + + Access is granted (first match wins) when any of the following is true: + 1. The caller's role is PROXY_ADMIN. + 2. The vector store has no team_id (legacy behavior - accessible to all). + 3. The caller's key-level object_permission.vector_stores explicitly lists + this vector store id. + 4. The caller's team-level object_permission.vector_stores explicitly lists + this vector store id. + 5. The caller's team_id matches the vector store's team_id. + + Otherwise access is denied. + """ + if _is_proxy_admin(user_api_key_dict): + return True + + vector_store_team_id = vector_store.get("team_id") + if vector_store_team_id is None: + return True + + vector_store_id = vector_store.get("vector_store_id") or "" + + key_object_permission = user_api_key_dict.object_permission + if key_object_permission is None: + key_object_permission = await _get_object_permission_for_id( + user_api_key_dict.object_permission_id + ) + if _object_permission_allows_vector_store(key_object_permission, vector_store_id): + return True + + team_object_permission: Optional[LiteLLM_ObjectPermissionTable] = ( + user_api_key_dict.team_object_permission + ) + if team_object_permission is None: + team_object_permission = await _get_object_permission_for_id( + user_api_key_dict.team_object_permission_id + ) + if _object_permission_allows_vector_store(team_object_permission, vector_store_id): + return True + + if ( + user_api_key_dict.team_id is not None + and user_api_key_dict.team_id == vector_store_team_id + ): + return True + + return False + + def _does_endpoint_match(endpoint_path: str, request_path: str) -> bool: if endpoint_path in request_path: return True diff --git a/litellm/responses/main.py b/litellm/responses/main.py index 0c79f99c9fd..b6dc5afb944 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -643,6 +643,29 @@ def _apply_prompt_management_to_responses_call( return input, model, custom_llm_provider +# Opt-in via model id (mirrors the `responses/` prefix pattern on chat completions). +_OPENAI_CHAT_COMPLETIONS_RESPONSES_MODEL_PREFIX = "openai/chat_completions/" + + +def _normalize_openai_chat_completions_responses_model(model: str) -> tuple[str, bool]: + """ + Strip `openai/chat_completions/` → `openai/` and return True when the + prefix was applied (same effect as use_chat_completions_api=True). + """ + if not model.startswith(_OPENAI_CHAT_COMPLETIONS_RESPONSES_MODEL_PREFIX): + return model, False + remainder = model[len(_OPENAI_CHAT_COMPLETIONS_RESPONSES_MODEL_PREFIX) :] + if not remainder: + return model, False + return f"openai/{remainder}", True + + +def _pop_use_chat_completions_api_kw(kwargs: Dict[str, Any]) -> bool: + """Pop use_chat_completions_api; True when the chat-completions bridge is requested.""" + use_cc = kwargs.pop("use_chat_completions_api", None) + return bool(use_cc) + + def _resolve_model_provider_for_responses( model: str, custom_llm_provider: Optional[str], @@ -705,6 +728,175 @@ def _apply_managed_file_id_mapping( return input, tools +def _responses_try_dispatch_mcp_gateway( + *, + tools: Optional[Iterable[ToolParam]], + input: Union[str, ResponseInputParam], + model: str, + include: Optional[List[ResponseIncludable]], + instructions: Optional[str], + max_output_tokens: Optional[int], + prompt: Optional[PromptObject], + metadata: Optional[Dict[str, Any]], + parallel_tool_calls: Optional[bool], + previous_response_id: Optional[str], + reasoning: Optional[Reasoning], + store: Optional[bool], + background: Optional[bool], + stream: Optional[bool], + temperature: Optional[float], + text: Any, + tool_choice: Optional[ToolChoice], + top_p: Optional[float], + truncation: Optional[Literal["auto", "disabled"]], + user: Optional[str], + extra_headers: Optional[Dict[str, Any]], + extra_query: Optional[Dict[str, Any]], + extra_body: Optional[Dict[str, Any]], + timeout: Optional[Union[float, httpx.Timeout]], + custom_llm_provider: Optional[str], + kwargs: Dict[str, Any], + _is_async: bool, +) -> Optional[Any]: + """Return a response when MCP gateway handles the call; otherwise None.""" + from litellm.responses.mcp.litellm_proxy_mcp_handler import ( + LiteLLM_Proxy_MCP_Handler, + ) + + if not LiteLLM_Proxy_MCP_Handler._should_use_litellm_mcp_gateway(tools=tools): + return None + mcp_call_kwargs = { + "input": input, + "model": model, + "include": include, + "instructions": instructions, + "max_output_tokens": max_output_tokens, + "prompt": prompt, + "metadata": metadata, + "parallel_tool_calls": parallel_tool_calls, + "previous_response_id": previous_response_id, + "reasoning": reasoning, + "store": store, + "background": background, + "stream": stream, + "temperature": temperature, + "text": text, + "tool_choice": tool_choice, + "tools": tools, + "top_p": top_p, + "truncation": truncation, + "user": user, + "extra_headers": extra_headers, + "extra_query": extra_query, + "extra_body": extra_body, + "timeout": timeout, + "custom_llm_provider": custom_llm_provider, + **kwargs, + } + if _is_async: + return aresponses_api_with_mcp(**mcp_call_kwargs) + return run_async_function(aresponses_api_with_mcp, **mcp_call_kwargs) + + +def _responses_try_dispatch_emulated_file_search( + *, + tools: Optional[Iterable[ToolParam]], + input: Union[str, ResponseInputParam], + model: str, + responses_api_provider_config: Optional[BaseResponsesAPIConfig], + use_chat_completions_api: bool, + include: Optional[List[ResponseIncludable]], + instructions: Optional[str], + max_output_tokens: Optional[int], + prompt: Optional[PromptObject], + metadata: Optional[Dict[str, Any]], + parallel_tool_calls: Optional[bool], + previous_response_id: Optional[str], + reasoning: Optional[Reasoning], + store: Optional[bool], + background: Optional[bool], + stream: Optional[bool], + temperature: Optional[float], + text: Any, + tool_choice: Optional[ToolChoice], + top_p: Optional[float], + truncation: Optional[Literal["auto", "disabled"]], + user: Optional[str], + service_tier: Optional[str], + safety_identifier: Optional[str], + text_format: Optional[Union[Type[BaseModel], dict]], + allowed_openai_params: Optional[List[str]], + extra_headers: Optional[Dict[str, Any]], + extra_query: Optional[Dict[str, Any]], + extra_body: Optional[Dict[str, Any]], + timeout: Optional[Union[float, httpx.Timeout]], + custom_llm_provider: Optional[str], + kwargs: Dict[str, Any], + _is_async: bool, +) -> Optional[Any]: + """Return a response when emulated file_search handles the call; otherwise None.""" + if not _has_file_search_tool(tools) or not ( + responses_api_provider_config is None + or use_chat_completions_api is True + or not responses_api_provider_config.supports_native_file_search() + ): + return None + from litellm.responses.file_search.emulated_handler import ( + aresponses_with_emulated_file_search, + ) + + _internal_skip = {"litellm_call_id", "aresponses"} + emulated_kwargs = { + "include": include, + "instructions": instructions, + "max_output_tokens": max_output_tokens, + "prompt": prompt, + "metadata": metadata, + "parallel_tool_calls": parallel_tool_calls, + "previous_response_id": previous_response_id, + "reasoning": reasoning, + "store": store, + "background": background, + "stream": stream, + "temperature": temperature, + "text": text, + "tool_choice": tool_choice, + "top_p": top_p, + "truncation": truncation, + "user": user, + "service_tier": service_tier, + "safety_identifier": safety_identifier, + "text_format": text_format, + "allowed_openai_params": allowed_openai_params, + "extra_headers": extra_headers, + "extra_query": extra_query, + "extra_body": extra_body, + "timeout": timeout, + "custom_llm_provider": custom_llm_provider, + **( + { + **( + {"use_chat_completions_api": True} + if use_chat_completions_api + else {} + ), + **{k: v for k, v in kwargs.items() if k not in _internal_skip}, + } + ), + } + if _is_async: + return aresponses_with_emulated_file_search( + input=input, model=model, tools=tools, **emulated_kwargs + ) + return run_async_function( + aresponses_with_emulated_file_search, + input=input, + model=model, + tools=tools, + **emulated_kwargs, + ) + + @client def responses( input: Union[str, ResponseInputParam], @@ -746,14 +938,12 @@ def responses( Uses the synchronous HTTP handler to make requests. """ local_vars = locals() - from litellm.responses.mcp.litellm_proxy_mcp_handler import ( - LiteLLM_Proxy_MCP_Handler, - ) try: litellm_logging_obj: LiteLLMLoggingObj = kwargs.get("litellm_logging_obj") # type: ignore litellm_call_id: Optional[str] = kwargs.get("litellm_call_id", None) _is_async = kwargs.pop("aresponses", False) is True + use_chat_completions_api = _pop_use_chat_completions_api_kw(kwargs) # Convert text_format to text parameter if provided text = ResponsesAPIRequestUtils.convert_text_format_to_text_param( @@ -776,6 +966,15 @@ def responses( mock_response=litellm_params.mock_response ) + _stripped_model, _from_chat_completions_prefix = ( + _normalize_openai_chat_completions_responses_model(model) + ) + model = _stripped_model + local_vars["model"] = model + use_chat_completions_api = ( + use_chat_completions_api or _from_chat_completions_prefix + ) + model, custom_llm_provider = _resolve_model_provider_for_responses( model=model, custom_llm_provider=custom_llm_provider, @@ -808,38 +1007,37 @@ def responses( ######################################################### # Native MCP Responses API ######################################################### - if LiteLLM_Proxy_MCP_Handler._should_use_litellm_mcp_gateway(tools=tools): - mcp_call_kwargs = { - "input": input, - "model": model, - "include": include, - "instructions": instructions, - "max_output_tokens": max_output_tokens, - "prompt": prompt, - "metadata": metadata, - "parallel_tool_calls": parallel_tool_calls, - "previous_response_id": previous_response_id, - "reasoning": reasoning, - "store": store, - "background": background, - "stream": stream, - "temperature": temperature, - "text": text, - "tool_choice": tool_choice, - "tools": tools, - "top_p": top_p, - "truncation": truncation, - "user": user, - "extra_headers": extra_headers, - "extra_query": extra_query, - "extra_body": extra_body, - "timeout": timeout, - "custom_llm_provider": custom_llm_provider, - **kwargs, - } - if _is_async: - return aresponses_api_with_mcp(**mcp_call_kwargs) - return run_async_function(aresponses_api_with_mcp, **mcp_call_kwargs) + _mcp_dispatch = _responses_try_dispatch_mcp_gateway( + tools=tools, + input=input, + model=model, + include=include, + instructions=instructions, + max_output_tokens=max_output_tokens, + prompt=prompt, + metadata=metadata, + parallel_tool_calls=parallel_tool_calls, + previous_response_id=previous_response_id, + reasoning=reasoning, + store=store, + background=background, + stream=stream, + temperature=temperature, + text=text, + tool_choice=tool_choice, + top_p=top_p, + truncation=truncation, + user=user, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + custom_llm_provider=custom_llm_provider, + kwargs=kwargs, + _is_async=_is_async, + ) + if _mcp_dispatch is not None: + return _mcp_dispatch # get provider config responses_api_provider_config: Optional[BaseResponsesAPIConfig] @@ -869,57 +1067,45 @@ def responses( ) ) - if _has_file_search_tool(tools) and ( - responses_api_provider_config is None - or not responses_api_provider_config.supports_native_file_search() - ): - from litellm.responses.file_search.emulated_handler import ( - aresponses_with_emulated_file_search, - ) + _file_search_dispatch = _responses_try_dispatch_emulated_file_search( + tools=tools, + input=input, + model=model, + responses_api_provider_config=responses_api_provider_config, + use_chat_completions_api=use_chat_completions_api, + include=include, + instructions=instructions, + max_output_tokens=max_output_tokens, + prompt=prompt, + metadata=metadata, + parallel_tool_calls=parallel_tool_calls, + previous_response_id=previous_response_id, + reasoning=reasoning, + store=store, + background=background, + stream=stream, + temperature=temperature, + text=text, + tool_choice=tool_choice, + top_p=top_p, + truncation=truncation, + user=user, + service_tier=service_tier, + safety_identifier=safety_identifier, + text_format=text_format, + allowed_openai_params=allowed_openai_params, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + custom_llm_provider=custom_llm_provider, + kwargs=kwargs, + _is_async=_is_async, + ) + if _file_search_dispatch is not None: + return _file_search_dispatch - _internal_skip = {"litellm_call_id", "aresponses"} - emulated_kwargs = { - "include": include, - "instructions": instructions, - "max_output_tokens": max_output_tokens, - "prompt": prompt, - "metadata": metadata, - "parallel_tool_calls": parallel_tool_calls, - "previous_response_id": previous_response_id, - "reasoning": reasoning, - "store": store, - "background": background, - "stream": stream, - "temperature": temperature, - "text": text, - "tool_choice": tool_choice, - "top_p": top_p, - "truncation": truncation, - "user": user, - "service_tier": service_tier, - "safety_identifier": safety_identifier, - "text_format": text_format, - "allowed_openai_params": allowed_openai_params, - "extra_headers": extra_headers, - "extra_query": extra_query, - "extra_body": extra_body, - "timeout": timeout, - "custom_llm_provider": custom_llm_provider, - **{k: v for k, v in kwargs.items() if k not in _internal_skip}, - } - if _is_async: - return aresponses_with_emulated_file_search( - input=input, model=model, tools=tools, **emulated_kwargs - ) - return run_async_function( - aresponses_with_emulated_file_search, - input=input, - model=model, - tools=tools, - **emulated_kwargs, - ) - - if responses_api_provider_config is None: + if responses_api_provider_config is None or use_chat_completions_api is True: return litellm_completion_transformation_handler.response_api_handler( model=model, input=input, diff --git a/litellm/router.py b/litellm/router.py index 6572d96f7b9..b275c264ebc 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -200,12 +200,20 @@ if TYPE_CHECKING: from litellm.router_strategy.complexity_router.complexity_router import ( ComplexityRouter, ) + from litellm.router_strategy.adaptive_router.adaptive_router import ( + AdaptiveRouter, + ) + from litellm.router_strategy.quality_router.quality_router import ( + QualityRouter, + ) Span = Union[_Span, Any] else: Span = Any AutoRouter = Any ComplexityRouter = Any + AdaptiveRouter = Any + QualityRouter = Any PreRoutingHookResponse = Any @@ -464,6 +472,8 @@ class Router: ) # {"TEAM_ID": PatternMatchRouter} self.auto_routers: Dict[str, "AutoRouter"] = {} self.complexity_routers: Dict[str, "ComplexityRouter"] = {} + self.adaptive_routers: Dict[str, "AdaptiveRouter"] = {} + self.quality_routers: Dict[str, "QualityRouter"] = {} # Initialize model_group_alias early since it's used in set_model_list self.model_group_alias: Dict[str, Union[str, RouterModelGroupAliasItem]] = ( @@ -5364,8 +5374,13 @@ class Router: _request_team_id: Optional[str] = (kwargs.get("metadata", {}) or {}).get( "user_api_key_team_id" ) - all_deployments = self._get_all_deployments( - model_name=original_model_group, team_id=_request_team_id + # Use wildcard-aware lookup so order-based fallback also works for model + # groups resolved via pattern routing (e.g. `openai/*` -> `openai/gpt-4.1-mini`). + all_deployments = ( + self.get_model_list( + model_name=original_model_group, team_id=_request_team_id + ) + or [] ) _order_set: set = { litellm.utils._get_deployment_order(d) @@ -5884,7 +5899,7 @@ class Router: response = await response ## PROCESS RESPONSE HEADERS response = await self.set_response_headers( - response=response, model_group=model_group + response=response, model_group=model_group, request_kwargs=kwargs ) return response @@ -6810,10 +6825,15 @@ class Router: Check if the deployment is an auto-router deployment (semantic router). Returns True if the litellm_params model starts with "auto_router/" - but NOT "auto_router/complexity_router" (which uses complexity routing). + but NOT "auto_router/complexity_router" or "auto_router/adaptive_router" + (which use the complexity-router and adaptive-router strategies). """ if litellm_params.model.startswith("auto_router/complexity_router"): return False # This is handled by complexity_router + if litellm_params.model.startswith("auto_router/adaptive_router"): + return False # This is handled by adaptive_router + if litellm_params.model.startswith("auto_router/quality_router"): + return False # This is handled by quality_router if litellm_params.model.startswith("auto_router/"): return True return False @@ -6920,6 +6940,196 @@ class Router: ) self.complexity_routers[deployment.model_name] = complexity_router + def _is_adaptive_router_deployment(self, litellm_params: LiteLLM_Params) -> bool: + """True when this deployment opts in via the `auto_router/adaptive_router` model prefix.""" + return litellm_params.model.startswith("auto_router/adaptive_router") + + def _finalize_adaptive_router_if_configured(self) -> None: + """Locate every adaptive-router deployment in the finalized model_list and + build an AdaptiveRouter for each. Safe no-op when none are configured. + Idempotent: skips any deployment whose model_name is already initialized.""" + # Drop any adaptive-router hooks left over from a previous Router + # instance (e.g. after `/config/reload` replaced `llm_router`). Without + # this, stale AdaptiveRouterPostCallHook callbacks from the old Router + # remain wired up in `litellm.callbacks` and double-fire signal + # recording for every request. + from litellm.router_strategy.adaptive_router.hooks import ( + AdaptiveRouterPostCallHook, + ) + + for _cb_list in ( + litellm.callbacks, + litellm.success_callback, + litellm.failure_callback, + litellm._async_success_callback, + litellm._async_failure_callback, + ): + litellm.logging_callback_manager.remove_callbacks_by_type( + _cb_list, AdaptiveRouterPostCallHook + ) + + for entry in self.model_list or []: + lp = ( + entry.get("litellm_params") + if isinstance(entry, dict) + else entry.litellm_params + ) + lp_model = ( + (lp.get("model") if isinstance(lp, dict) else lp.model) if lp else None + ) + if not (lp_model and lp_model.startswith("auto_router/adaptive_router")): + continue + model_name = ( + entry.get("model_name") if isinstance(entry, dict) else entry.model_name + ) + if not model_name or not lp: + continue + if model_name in self.adaptive_routers: + continue + deployment = Deployment( + model_name=model_name, + litellm_params=( + lp if not isinstance(lp, dict) else LiteLLM_Params(**lp) + ), + model_info=( + entry.get("model_info") + if isinstance(entry, dict) + else entry.model_info + ), + ) + self.init_adaptive_router_deployment(deployment=deployment) + + def init_adaptive_router_deployment(self, deployment: Deployment) -> None: + """ + Build an AdaptiveRouter instance for this deployment and register its + post-call hook. Multiple adaptive routers can coexist on a single Router, + keyed by `deployment.model_name`. + + `model_to_prefs` and `model_to_cost` are derived from the OTHER models + already registered in `self.model_list` whose `model_name` appears in + `available_models`. Models not yet registered fall back to defaults. + """ + # Local import: AdaptiveRouter -> hooks -> classifier all import litellm + # internals which transitively import this module. (AGENTS.md exception clause.) + from litellm.router_strategy.adaptive_router.adaptive_router import ( + AdaptiveRouter, + ) + from litellm.router_strategy.adaptive_router.hooks import ( + AdaptiveRouterPostCallHook, + ) + from litellm.types.router import ( + AdaptiveRouterConfig, + AdaptiveRouterPreferences, + ) + + raw_config = deployment.litellm_params.adaptive_router_config + if raw_config is None: + raise ValueError( + "adaptive_router_config is required for adaptive-router deployments." + ) + + config = AdaptiveRouterConfig(**raw_config) + + model_to_prefs: Dict[str, AdaptiveRouterPreferences] = {} + model_to_cost: Dict[str, float] = {} + # O(k) via the name→indices map: only touch deployments whose name + # is listed in `available_models`, instead of scanning model_list. + for name in config.available_models: + indices = self.model_name_to_deployment_indices.get(name, []) + if not indices: + continue + d = (self.model_list or [])[indices[0]] + mi = d.get("model_info") if isinstance(d, dict) else d.model_info + mi_dict: Dict[str, Any] = ( + mi if isinstance(mi, dict) else (mi.model_dump() if mi else {}) + ) + prefs_raw = mi_dict.get("adaptive_router_preferences") + if prefs_raw is not None: + model_to_prefs[name] = AdaptiveRouterPreferences(**prefs_raw) + + # `input_cost_per_token` is a LiteLLM_Params field per types/router.py. + lp = d.get("litellm_params") if isinstance(d, dict) else d.litellm_params + lp_dict: Dict[str, Any] = ( + lp if isinstance(lp, dict) else (lp.model_dump() if lp else {}) + ) + cost = lp_dict.get("input_cost_per_token") + if cost is not None: + model_to_cost[name] = float(cost) + + if deployment.model_name in self.adaptive_routers: + raise ValueError( + f"Adaptive-router deployment {deployment.model_name} already exists. " + "Please use a different model name." + ) + + adaptive_router = AdaptiveRouter( + router_name=deployment.model_name, + config=config, + model_to_prefs=model_to_prefs, + model_to_cost=model_to_cost, + ) + self.adaptive_routers[deployment.model_name] = adaptive_router + litellm.logging_callback_manager.add_litellm_callback( + AdaptiveRouterPostCallHook(adaptive_router=adaptive_router) + ) + verbose_router_logger.info( + "AdaptiveRouter[%s] initialized with %d models", + deployment.model_name, + len(config.available_models), + ) + + def _is_quality_router_deployment(self, litellm_params: LiteLLM_Params) -> bool: + """ + Check if the deployment is a quality-router deployment. + + Returns True if the litellm_params model starts with "auto_router/quality_router". + """ + if litellm_params.model.startswith("auto_router/quality_router"): + return True + return False + + def init_quality_router_deployment(self, deployment: Deployment): + """ + Initialize the quality-router deployment. + + Resolves the default model from either `quality_router_default_model` or + `quality_router_config["default_model"]`, then instantiates the + QualityRouter and stores it in `self.quality_routers`. + """ + # Import here to mirror the AutoRouter / ComplexityRouter init pattern + # and avoid circular imports. + from litellm.router_strategy.quality_router.quality_router import ( + QualityRouter, + ) + + quality_router_config: Optional[dict] = ( + deployment.litellm_params.quality_router_config + ) + + default_model: Optional[str] = ( + deployment.litellm_params.quality_router_default_model + ) + if default_model is None and quality_router_config: + default_model = quality_router_config.get("default_model") + + if default_model is None: + raise ValueError( + "quality_router_default_model is required for quality-router deployments, " + "or set default_model in quality_router_config. Please configure it in the litellm_params" + ) + + quality_router: QualityRouter = QualityRouter( + model_name=deployment.model_name, + default_model=default_model, + litellm_router_instance=self, + quality_router_config=quality_router_config, + ) + if deployment.model_name in self.quality_routers: + raise ValueError( + f"Quality-router deployment {deployment.model_name} already exists. Please use a different model name." + ) + self.quality_routers[deployment.model_name] = quality_router + def deployment_is_active_for_environment(self, deployment: Deployment) -> bool: """ Function to check if a llm deployment is active for a given environment. Allows using the same config.yaml across multople environments @@ -6966,6 +7176,11 @@ class Router: self.model_id_to_deployment_index_map = {} # Reset the index self.model_name_to_deployment_indices = {} # Reset the model_name index self.team_model_to_deployment_indices = {} # Reset the team_model index + # Reset per-strategy router registries so hot-reload doesn't leave + # stale routers pointing at the old model_list. + self.quality_routers = {} + self.complexity_routers = {} + self.auto_routers = {} self._invalidate_model_group_info_cache() self._invalidate_access_groups_cache() # we add api_base/api_key each model so load balancing between azure/gpt on api_base1 and api_base2 works @@ -7013,6 +7228,10 @@ class Router: # Note: model_name_to_deployment_indices is already built incrementally # by _create_deployment -> _add_model_to_list_and_index_map + # Deferred: build the AdaptiveRouter strategy now that all underlying + # deployments have been registered. + self._finalize_adaptive_router_if_configured() + def _add_deployment(self, deployment: Deployment) -> Deployment: import os @@ -7140,6 +7359,16 @@ class Router: ): self.init_complexity_router_deployment(deployment=deployment) + # NOTE: adaptive-router deployments are deferred to the end of + # set_model_list() because their init needs visibility into the OTHER + # deployments listed in `available_models` (which may not yet have + # been processed when this one is created). + ######################################################### + # Check if this is a quality-router deployment + ######################################################### + if self._is_quality_router_deployment(litellm_params=deployment.litellm_params): + self.init_quality_router_deployment(deployment=deployment) + return deployment def _initialize_deployment_for_pass_through( @@ -8143,7 +8372,10 @@ class Router: return returned_dict async def set_response_headers( - self, response: Any, model_group: Optional[str] = None + self, + response: Any, + model_group: Optional[str] = None, + request_kwargs: Optional[dict] = None, ) -> Any: """ Add the most accurate rate limit headers for a given model response. @@ -8164,6 +8396,45 @@ class Router: additional_headers = response._hidden_params["additional_headers"] # type: ignore + # Lift QualityRouter routing decision into response headers for + # transparency. The decision is stashed in request_kwargs.metadata + # by QualityRouter.async_pre_routing_hook. + metadata = ( + (request_kwargs.get("metadata") or {}) + if isinstance(request_kwargs, dict) + else {} + ) + decision = ( + metadata.get("quality_router_decision") + if isinstance(metadata, dict) + else None + ) + if isinstance(decision, dict): + # Only emit headers for fields that have a meaningful value. + # `complexity_tier` and `matched_keyword` are mutually exclusive + # (the keyword path short-circuits classification), so each + # request emits one or the other but not both. + if decision.get("routed_model") is not None: + additional_headers["x-litellm-quality-router-model"] = str( + decision["routed_model"] + ) + if decision.get("quality_tier") is not None: + additional_headers["x-litellm-quality-router-tier"] = str( + decision["quality_tier"] + ) + if decision.get("routed_via") is not None: + additional_headers["x-litellm-quality-router-via"] = str( + decision["routed_via"] + ) + if decision.get("matched_keyword") is not None: + additional_headers["x-litellm-quality-router-keyword"] = str( + decision["matched_keyword"] + ) + if decision.get("complexity_tier") is not None: + additional_headers["x-litellm-quality-router-complexity"] = str( + decision["complexity_tier"] + ) + if ( "x-ratelimit-remaining-tokens" not in additional_headers and "x-ratelimit-remaining-requests" not in additional_headers @@ -8327,7 +8598,9 @@ class Router: # No match found return None - def map_team_model(self, team_model_name: str, team_id: str) -> Optional[str]: + def map_team_model( + self, team_model_name: Optional[str], team_id: str + ) -> Optional[str]: """ Check if team_model_name resolves to team-specific deployments. @@ -8335,6 +8608,11 @@ class Router: sibling deployments via team_id filtering, instead of collapsing to a single internal model_name. + When team_model_name is None (e.g. vector store / file endpoints that + don't include a model in their request), returns the first matching + team deployment's team_public_model_name so the router can inject BYOK + credentials from the team-scoped deployment. + Returns: - str: the team_model_name if team deployments exist for this team - None: if no team-specific model is found @@ -8344,6 +8622,13 @@ class Router: return None for model in models: if model.get("model_info", {}).get("team_id") == team_id: + if team_model_name is None: + # No model was specified (e.g. vector store endpoints). + # Return the deployment's public model name so the router + # can route to it and inject the BYOK API key. + return model.get("model_info", {}).get( + "team_public_model_name" + ) or model.get("model_name") return team_model_name # No team-scoped deployment found; wildcard/pattern routes are @@ -8708,8 +8993,6 @@ class Router: and self.routing_strategy == "latency-based-routing" ): _settings_to_return[var] = self.lowestlatency_logger.routing_args.json() - elif var == "routing_strategy_args": - _settings_to_return[var] = None return _settings_to_return def update_settings(self, **kwargs): @@ -9620,7 +9903,7 @@ class Router: self, model: str, request_kwargs: Dict, - messages: Optional[List[Dict[str, str]]] = None, + messages: Optional[List[Dict[str, Any]]] = None, input: Optional[Union[str, List]] = None, specific_deployment: Optional[bool] = False, ) -> Optional[PreRoutingHookResponse]: @@ -9653,6 +9936,31 @@ class Router: specific_deployment=specific_deployment, ) + ######################################################### + # Check if an adaptive-router should be used + ######################################################### + adaptive_router = self.adaptive_routers.get(model) + if adaptive_router is not None: + return await adaptive_router.async_pre_routing_hook( + model=model, + request_kwargs=request_kwargs, + messages=messages, + input=input, + specific_deployment=specific_deployment, + ) + + ######################################################### + # Check if any quality-router should be used + ######################################################### + if model in self.quality_routers: + return await self.quality_routers[model].async_pre_routing_hook( + model=model, + request_kwargs=request_kwargs, + messages=messages, + input=input, + specific_deployment=specific_deployment, + ) + return None def get_available_deployment( diff --git a/litellm/router_strategy/adaptive_router/README.md b/litellm/router_strategy/adaptive_router/README.md new file mode 100644 index 00000000000..7f5d7aa21d0 --- /dev/null +++ b/litellm/router_strategy/adaptive_router/README.md @@ -0,0 +1,95 @@ +# Adaptive Router (v0) + +A request-type-aware routing strategy. For each incoming request, classify the +prompt into one of seven `RequestType` buckets (code generation, writing, +analytical reasoning, …), then Thompson-sample a Beta(α, β) bandit posterior +per `(request_type, model)` cell to pick the best model. Quality estimates are +combined with a normalized cost score via a weighted linear sum. + +A post-call hook reads the response and runs lightweight regex + tool-call +detectors (see `signals.py`) to award per-turn credit/blame to the model that +served the turn. Updates are batched in-memory and flushed to Postgres every +~10s by a background task in `proxy_server.py`. + +## Config example + +```yaml +model_list: + - model_name: gpt-4o + litellm_params: + model: openai/gpt-4o + model_info: + input_cost_per_token: 0.0000025 + adaptive_router_preferences: + quality_tier: 3 + strengths: ["code_generation", "analytical_reasoning"] + + - model_name: gpt-4o-mini + litellm_params: + model: openai/gpt-4o-mini + model_info: + input_cost_per_token: 0.00000015 + adaptive_router_preferences: + quality_tier: 2 + strengths: ["general", "factual_lookup"] + + - model_name: smart-router + litellm_params: + model: auto_router/adaptive_router + adaptive_router_default_model: gpt-4o-mini + adaptive_router_config: + available_models: ["gpt-4o", "gpt-4o-mini"] + weights: + quality: 0.7 + cost: 0.3 +``` + +Callers may pass header `x-litellm-min-quality-tier: 3` (or metadata key +`min_quality_tier: 3`) to force selection from tier-3-or-higher models only. + +## Behavior summary + +- **Cold start.** Each `(request_type, model)` cell starts with a + Beta prior whose mean = `BASE_TIER_WEIGHT[tier] (+ STRENGTH_BONUS if declared)` + and total mass = `COLD_START_MASS` (10). About ten real observations move it + meaningfully. +- **Per-request decision.** Sample once per eligible model, score with + `quality_weight·sample + cost_weight·normalized_cost`, pick the argmax. + Routing is stateless per-turn — no sticky lookup. Each call resamples. +- **Owner-cache attribution.** Post-call, the conversation's first picked + model claims an "owner slot" for `OWNER_CACHE_TTL_SECONDS` (24h). Later + turns of the same conversation only fire bandit/state updates if the + same model handled them — mismatches are dropped (no attribution) and + counted in `skipped_updates_total`. Conversation identity is the + client-supplied `litellm_session_id` if present, otherwise a sha256 over + caller identity (api key hash, team, user, end-user) + the first message. +- **Per-turn updates.** `satisfaction → +α`. `misalignment, stagnation, + disengagement, failure → +β` (each). `loop → +0.5β`. `exhaustion → 0` + (uptime, not quality). Skipped if conversation has fewer than + `SIGNAL_GATE_MIN_MESSAGES` messages. +- **Persistence.** Bandit cells: aggregated deltas, eventually consistent. + Session rows: last-write-wins snapshots. + +## Known v0 limitations + +- **Latency is not in the score.** Quality + cost only. A pathologically slow + model can still be picked. +- **Hard sample cap at 200.** Once `α + β > 200`, deltas are silently dropped. + No rescaling — drift is a v1 concern. +- **24h owner-cache TTL.** No explicit eviction below TTL. The in-memory map + can grow if traffic patterns produce many one-shot sessions. +- **Owner-recovery skew.** If model A "owns" a conversation but is then + dethroned in the bandit, later turns served by model B are dropped — so + bandit updates for that conversation flatline until A's TTL expires. + Tracked via `skipped_updates_total`. +- **Signals are regex + tool-call only.** No LLM-judge, no embedding similarity, + no exemplar storage. Signals are best-effort and biased toward English. +- **One AdaptiveRouter per `Router`.** Multiple `adaptive_router/*` deployments + on the same `litellm.Router` raise at init. +- **Bandit-delta mapping is unvalidated.** `_compute_bandit_delta` is a v0 + guess; expect to retune after the first ~1000 sessions of real traffic. +- **`request_type` is classified per turn from the latest user message.** For + non-GENERAL turns, the current-turn type is used for bandit attribution (so + genuine mid-session topic shifts update the correct cell). For GENERAL turns + ("thanks!", "ok", "sounds good"), attribution falls back to the session's + original type to avoid misattributing closing pleasantries. diff --git a/litellm/router_strategy/adaptive_router/__init__.py b/litellm/router_strategy/adaptive_router/__init__.py new file mode 100644 index 00000000000..d7f55ebced9 --- /dev/null +++ b/litellm/router_strategy/adaptive_router/__init__.py @@ -0,0 +1,6 @@ +"""Adaptive router strategy. See README.md for design overview.""" + +from litellm.router_strategy.adaptive_router.adaptive_router import AdaptiveRouter +from litellm.router_strategy.adaptive_router.hooks import AdaptiveRouterPostCallHook + +__all__ = ["AdaptiveRouter", "AdaptiveRouterPostCallHook"] diff --git a/litellm/router_strategy/adaptive_router/adaptive_router.py b/litellm/router_strategy/adaptive_router/adaptive_router.py new file mode 100644 index 00000000000..3bccef36e68 --- /dev/null +++ b/litellm/router_strategy/adaptive_router/adaptive_router.py @@ -0,0 +1,454 @@ +""" +Main adaptive router strategy. See README.md for design overview. + +One AdaptiveRouter instance per router_name. Holds in-memory caches: +- _cells: Beta(alpha, beta) bandit posteriors per (request_type, model) +- _owner_cache: session_key -> (owner_model, expires_at) — the first model + picked for a conversation owns its bandit-update slot +- _session_states: (session_key, model) -> SessionState for incremental signal updates + +Owns the AdaptiveRouterUpdateQueue used by the proxy's flusher to persist +state and session snapshots back to Postgres. + +Routing is stateless per-turn (Thompson sample fresh on every call). The +owner cache is consulted only at post-call time to decide whether a turn's +signals should fire a bandit update — turns served by a different model than +the conversation's owner are skipped to avoid cross-model misattribution. +""" + +from __future__ import annotations + +import asyncio +import time +from dataclasses import asdict +from typing import Any, Dict, List, Optional, Tuple, Union, cast + +from litellm._logging import verbose_router_logger +from litellm.litellm_core_utils.prompt_templates.common_utils import ( + get_last_user_message, +) +from litellm.router_strategy.adaptive_router.bandit import ( + BanditCell, + apply_delta, + initial_cell, + pick_best, +) +from litellm.router_strategy.adaptive_router.classifier import classify_prompt +from litellm.router_strategy.adaptive_router.config import ( + ADAPTIVE_ROUTER_CHOSEN_MODEL_KEY, + MIN_QUALITY_TIER_HEADER, + MIN_QUALITY_TIER_METADATA_KEY, + OWNER_CACHE_TTL_SECONDS, +) +from litellm.router_strategy.adaptive_router.signals import ( + SessionState, + SignalDelta, + Turn, + apply_turn, +) +from litellm.router_strategy.adaptive_router.update_queue import ( + AdaptiveRouterUpdateQueue, +) + +# Sweep session-state cache when it exceeds this many live entries. Expired +# entries are dropped in bulk; amortizes to O(1) per insert. +_SESSION_STATE_SWEEP_THRESHOLD: int = 1024 +# Same pattern for the owner cache. +_OWNER_CACHE_SWEEP_THRESHOLD: int = 1024 +from litellm.types.llms.openai import AllMessageValues +from litellm.types.router import ( + AdaptiveRouterConfig, + AdaptiveRouterPreferences, + PreRoutingHookResponse, + RequestType, +) + + +def _default_prefs() -> AdaptiveRouterPreferences: + """Tier-2 prior with no declared strengths; used when a model omits prefs.""" + return AdaptiveRouterPreferences(quality_tier=2, strengths=[]) + + +class AdaptiveRouter: + """One instance per router_name. Holds in-memory caches + the update queue.""" + + def __init__( + self, + router_name: str, + config: AdaptiveRouterConfig, + model_to_prefs: Dict[str, AdaptiveRouterPreferences], + model_to_cost: Dict[str, float], + ) -> None: + self.router_name = router_name + self.config = config + self.model_to_prefs = model_to_prefs + self.model_to_cost = model_to_cost + self.queue = AdaptiveRouterUpdateQueue() + + self._cells: Dict[Tuple[RequestType, str], BanditCell] = {} + self._owner_cache: Dict[str, Tuple[str, float]] = {} + self._session_states: Dict[Tuple[str, str], SessionState] = {} + # Parallel expiry map for _session_states, same TTL as _owner_cache. + # Evicted opportunistically in `get_or_create_session_state`. + self._session_states_expiry: Dict[Tuple[str, str], float] = {} + self._skipped_updates_total: int = 0 + # Set to True once the proxy flusher has loaded persisted priors from + # Postgres. Checked to support lazy-load on hot-reloaded routers. + self._state_loaded: bool = False + self._lock = asyncio.Lock() + + self._init_cold_start_cells() + + # ---- Cold-start ------------------------------------------------------ + + def _init_cold_start_cells(self) -> None: + """Populate _cells with cold-start priors for every (rt, model) combination.""" + for rt in RequestType: + for model in self.config.available_models: + prefs = self.model_to_prefs.get(model) or _default_prefs() + self._cells[(rt, model)] = initial_cell(prefs, rt) + + async def load_state_from_db(self, prisma_client: Any) -> None: + """Override cold-start cells with persisted state. Called once at startup.""" + if prisma_client is None: + return + try: + rows = await prisma_client.db.litellm_adaptiverouterstate.find_many( + where={"router_name": self.router_name} + ) + loaded = 0 + for row in rows: + try: + rt = RequestType(row.request_type) + except ValueError: + # Unknown taxonomy entry from an older/newer version. Skip. + continue + if row.model_name not in self.config.available_models: + continue + self._cells[(rt, row.model_name)] = BanditCell( + alpha=row.alpha, beta=row.beta + ) + loaded += 1 + verbose_router_logger.info( + "AdaptiveRouter[%s]: loaded %d cells from DB", + self.router_name, + loaded, + ) + except Exception as e: + verbose_router_logger.exception( + "AdaptiveRouter[%s]: failed to load state from DB: %s", + self.router_name, + e, + ) + + # ---- Pre-routing hook ------------------------------------------------ + + async def async_pre_routing_hook( + self, + model: str, + request_kwargs: Dict[str, Any], + messages: Optional[List[Dict[str, Any]]] = None, + input: Optional[Union[str, List]] = None, + specific_deployment: Optional[bool] = False, + ) -> Optional[PreRoutingHookResponse]: + """ + Plugin entry point invoked by `Router.async_pre_routing_hook` when the + inbound `model` matches this adaptive router's `router_name`. + + Classifies the last user message, picks a logical model via the bandit, + and stashes the chosen model on `request_kwargs["metadata"]` so the + post-call hook can surface it as a response header. + + Routing is stateless per-turn: every call Thompson-samples fresh, + regardless of any prior pick for the same session. Cross-turn + attribution is enforced post-call via the owner cache (see + `claim_or_check_owner`). + """ + user_text = ( + get_last_user_message(cast(List[AllMessageValues], messages or [])) or "" + ) + + request_type = classify_prompt(user_text) + min_quality_tier = self._extract_min_quality_tier(request_kwargs) + chosen_model = await self.pick_model( + request_type=request_type, min_quality_tier=min_quality_tier + ) + verbose_router_logger.debug( + "AdaptiveRouter[%s]: classified=%s -> chose %s", + self.router_name, + request_type.value, + chosen_model, + ) + + # Relay the chosen logical model to the post-call hook, which surfaces + # it as the `x-litellm-adaptive-router-model` response header. We use + # `metadata` (not a top-level kwarg) so the value doesn't leak into + # `litellm.acompletion(**input_kwargs)`. + kwargs_metadata = request_kwargs.setdefault("metadata", {}) + if isinstance(kwargs_metadata, dict): + kwargs_metadata[ADAPTIVE_ROUTER_CHOSEN_MODEL_KEY] = chosen_model + + return PreRoutingHookResponse(model=chosen_model, messages=messages) + + # ---- Pick model ------------------------------------------------------ + + async def pick_model( + self, + request_type: RequestType, + min_quality_tier: Optional[int] = None, + ) -> str: + """Thompson-sample across eligible models. Stateless per-turn.""" + eligible = self._eligible_models(min_quality_tier) + if not eligible: + raise ValueError( + f"AdaptiveRouter[{self.router_name}]: no models meet " + f"min_quality_tier={min_quality_tier}" + ) + + cells = {m: self._cells[(request_type, m)] for m in eligible} + costs = {m: self.model_to_cost.get(m, 0.0) for m in eligible} + return pick_best( + cells, + costs, + quality_weight=self.config.weights.quality, + cost_weight=self.config.weights.cost, + ) + + def claim_or_check_owner(self, session_key: str, current_model: str) -> bool: + """Resolve attribution for a turn under stateless routing. + + Returns True iff this turn should fire a bandit/state update. The + first call for a `session_key` claims ownership for `current_model` + and returns True. Subsequent calls return True only if the owner is + still live AND matches `current_model`. Mismatches (a different + model handled this turn) and expired owners both increment + `_skipped_updates_total` and return False — no attribution. + """ + now = time.time() + existing = self._owner_cache.get(session_key) + if existing is not None and existing[1] > now: + owner_model, _ = existing + if owner_model == current_model: + return True + self._skipped_updates_total += 1 + return False + + # Opportunistic bulk sweep — sessions that never come back would + # otherwise pile up here forever. Same threshold pattern as the + # session-state cache. + if len(self._owner_cache) >= _OWNER_CACHE_SWEEP_THRESHOLD: + self._evict_expired_owner_cache(now) + + # No live owner -> claim for current_model. + self._owner_cache[session_key] = ( + current_model, + now + OWNER_CACHE_TTL_SECONDS, + ) + return True + + def _evict_expired_owner_cache(self, now: float) -> None: + expired = [k for k, (_, exp) in self._owner_cache.items() if exp <= now] + for k in expired: + self._owner_cache.pop(k, None) + + async def get_state_snapshot(self) -> Dict[str, Any]: + """In-memory snapshot for the introspection endpoint. Cheap; no DB hit.""" + cells = [] + for (rt, model), cell in sorted( + self._cells.items(), key=lambda kv: (kv[0][0].value, kv[0][1]) + ): + total = cell.alpha + cell.beta + cells.append( + { + "request_type": rt.value, + "model": model, + "alpha": cell.alpha, + "beta": cell.beta, + # Net observations that have moved the posterior, excluding + # the cold-start prior mass. `alpha + beta` would show the + # initial COLD_START_MASS (e.g. 10) before any real traffic + # arrives, which confuses operators reading the endpoint. + "samples": cell.total_samples, + "quality_mean": cell.alpha / total if total > 0 else 0.0, + } + ) + queue = await self.queue.queue_size() + now = time.time() + owner_cache_live = sum(1 for _, exp in self._owner_cache.values() if exp > now) + return { + "router_name": self.router_name, + "available_models": list(self.config.available_models), + "weights": { + "quality": self.config.weights.quality, + "cost": self.config.weights.cost, + }, + "model_costs": dict(self.model_to_cost), + "cells": cells, + "owner_cache_live": owner_cache_live, + "skipped_updates_total": self._skipped_updates_total, + "queue": queue, + } + + @staticmethod + def _extract_min_quality_tier( + request_kwargs: Dict[str, Any], + ) -> Optional[int]: + """Pull `min_quality_tier` from request headers or metadata. + + Precedence: headers (`x-litellm-min-quality-tier`) over metadata + (`min_quality_tier`). Headers arrive lowercased from the proxy but we + lookup case-insensitively to be safe. Unparseable values are ignored + (treated as "not set") rather than raising — a bad header shouldn't + fail the request. + """ + headers = request_kwargs.get("headers") or {} + if isinstance(headers, dict): + for k, v in headers.items(): + if isinstance(k, str) and k.lower() == MIN_QUALITY_TIER_HEADER: + try: + return int(v) + except (TypeError, ValueError): + return None + + metadata = request_kwargs.get("metadata") or {} + if isinstance(metadata, dict): + raw = metadata.get(MIN_QUALITY_TIER_METADATA_KEY) + if raw is not None: + try: + return int(raw) + except (TypeError, ValueError): + return None + return None + + def _eligible_models(self, min_quality_tier: Optional[int]) -> List[str]: + if min_quality_tier is None: + return list(self.config.available_models) + return [ + m + for m in self.config.available_models + if (self.model_to_prefs.get(m) or _default_prefs()).quality_tier + >= min_quality_tier + ] + + # ---- Session state --------------------------------------------------- + + def get_or_create_session_state( + self, + session_id: str, + model_name: str, + request_type: RequestType, + ) -> SessionState: + key = (session_id, model_name) + now = time.time() + + # Opportunistic bulk sweep when the cache grows past the threshold. + # Cheap relative to the alternative of a bounded LRU — conversations + # naturally become inactive within OWNER_CACHE_TTL_SECONDS. + if len(self._session_states) >= _SESSION_STATE_SWEEP_THRESHOLD: + self._evict_expired_session_states(now) + + state = self._session_states.get(key) + if state is None: + state = SessionState( + session_id=session_id, + router_name=self.router_name, + model_name=model_name, + classified_type=request_type.value, + ) + self._session_states[key] = state + self._session_states_expiry[key] = now + OWNER_CACHE_TTL_SECONDS + return state + + def _evict_expired_session_states(self, now: float) -> None: + """Drop session states whose TTL has passed. O(n) but amortized O(1) + per insert thanks to `_SESSION_STATE_SWEEP_THRESHOLD`.""" + expired = [k for k, exp in self._session_states_expiry.items() if exp <= now] + for k in expired: + self._session_states.pop(k, None) + self._session_states_expiry.pop(k, None) + + async def record_turn( + self, + session_id: str, + model_name: str, + request_type: RequestType, + turn: Turn, + ) -> SignalDelta: + """Apply one turn, push session snapshot + bandit deltas to the queue.""" + state = self.get_or_create_session_state(session_id, model_name, request_type) + delta = apply_turn(state, turn) + verbose_router_logger.debug( + "AdaptiveRouter[%s]: record_turn delta=%s", self.router_name, delta + ) + + # Strip the raw conversation content before persisting. The + # last_user/assistant_content and tool_call_history fields are only + # needed in-memory for the next turn's incremental signal detection; + # writing user prompts and tool payloads to the DB would store PII + # for every adaptive-router conversation. Counts + bookkeeping is + # all the persisted row needs. + snapshot = asdict(state) + for sensitive in ( + "last_user_content", + "last_assistant_content", + "tool_call_history", + "pending_tool_calls", + ): + snapshot.pop(sensitive, None) + await self.queue.add_session_state( + session_id, self.router_name, model_name, snapshot + ) + + d_alpha, d_beta = self._compute_bandit_delta(delta) + verbose_router_logger.debug( + "AdaptiveRouter[%s]: bandit delta alpha=%.2f beta=%.2f", + self.router_name, + d_alpha, + d_beta, + ) + if d_alpha != 0 or d_beta != 0: + # For non-GENERAL turns, attribute to the current-turn classification + # so genuine mid-session topic shifts (e.g. code → math) update the + # correct cell. For GENERAL turns ("thanks!", "ok", "sounds good"), fall + # back to the session's original type so closing pleasantries don't + # misattribute the reward. + attribution_type = ( + request_type + if request_type != RequestType.GENERAL + else RequestType(state.classified_type) + ) + cell_key = (attribution_type, model_name) + self._cells[cell_key] = apply_delta(self._cells[cell_key], d_alpha, d_beta) + await self.queue.add_state_delta( + self.router_name, + attribution_type.value, + model_name, + d_alpha, + d_beta, + ) + + return delta + + @staticmethod + def _compute_bandit_delta(delta: SignalDelta) -> Tuple[float, float]: + """ + Translate per-turn signal deltas into bandit-cell deltas. + + v0 mapping (UNVALIDATED — D6): + - satisfaction -> +1 alpha + - misalignment, stagnation, + disengagement, failure -> +1 beta each + - loop -> +0.5 beta (weak; could be model OR user) + - exhaustion -> 0 (uptime issue, tracked separately later) + """ + d_alpha = float(delta.satisfaction) + d_beta = ( + float( + delta.misalignment + + delta.stagnation + + delta.disengagement + + delta.failure + ) + + 0.5 * delta.loop + ) + return d_alpha, d_beta diff --git a/litellm/router_strategy/adaptive_router/bandit.py b/litellm/router_strategy/adaptive_router/bandit.py new file mode 100644 index 00000000000..1ab96f0e952 --- /dev/null +++ b/litellm/router_strategy/adaptive_router/bandit.py @@ -0,0 +1,142 @@ +""" +Thompson sampling and prior initialization for the adaptive router bandit. + +Each (router, request_type, model) cell is a Beta(alpha, beta) posterior. +- alpha = pseudo-successes +- beta = pseudo-failures +- mean = alpha / (alpha + beta) +- total samples = alpha + beta - COLD_START_MASS (informative prior, not data) + +Hot path: thompson_sample() — pure function, no I/O. +""" + +import random +from dataclasses import dataclass +from typing import Dict, List, Optional + +from litellm.router_strategy.adaptive_router.config import ( + BASE_TIER_WEIGHT, + COLD_START_MASS, + DEFAULT_COST_WEIGHT, + DEFAULT_QUALITY_WEIGHT, + SAMPLE_CAP, + STRENGTH_BONUS, +) +from litellm.types.router import AdaptiveRouterPreferences, RequestType + + +@dataclass(frozen=True) +class BanditCell: + """Posterior state for a single (router, request_type, model) cell.""" + + alpha: float + beta: float + + @property + def mean(self) -> float: + total = self.alpha + self.beta + return self.alpha / total if total > 0 else 0.5 + + @property + def total_samples(self) -> int: + return max(0, int(self.alpha + self.beta - COLD_START_MASS)) + + +def initial_cell( + prefs: AdaptiveRouterPreferences, request_type: RequestType +) -> BanditCell: + """ + Cold-start prior for a (model, request_type) cell. + + mean = base_tier_weight[tier] + (STRENGTH_BONUS if request_type in strengths else 0) + capped at 0.95 to avoid an over-confident prior. + Total mass = COLD_START_MASS so that ~10 real observations can move it noticeably. + """ + if prefs.quality_tier not in BASE_TIER_WEIGHT: + valid = sorted(BASE_TIER_WEIGHT) + raise ValueError( + f"quality_tier={prefs.quality_tier} is not supported; " + f"valid tiers are {valid}" + ) + base = BASE_TIER_WEIGHT[prefs.quality_tier] + bonus = STRENGTH_BONUS if request_type in prefs.strengths else 0.0 + mean = min(0.95, base + bonus) + alpha = mean * COLD_START_MASS + beta = (1.0 - mean) * COLD_START_MASS + return BanditCell(alpha=alpha, beta=beta) + + +def apply_delta(cell: BanditCell, delta_alpha: float, delta_beta: float) -> BanditCell: + """ + Apply a learning update to a cell, enforcing the sample cap. + + SAMPLE_CAP is a HARD cap on (alpha + beta). When the cap would be exceeded, + we drop the update. (D5: hard cap, no rescaling — keep v0 simple.) + """ + new_alpha = cell.alpha + delta_alpha + new_beta = cell.beta + delta_beta + if new_alpha + new_beta > SAMPLE_CAP: + return cell + return BanditCell(alpha=new_alpha, beta=new_beta) + + +def thompson_sample(cell: BanditCell, rng: Optional[random.Random] = None) -> float: + """Draw a sample from Beta(alpha, beta). Returns a quality estimate in [0, 1].""" + r = rng if rng is not None else random + return r.betavariate(cell.alpha, cell.beta) + + +def normalized_cost(model_cost: float, all_costs: List[float]) -> float: + """ + Map a raw $/1k-token cost into [0, 1] where 0 = most expensive, 1 = cheapest. + Returns 0.5 when there's no spread. + """ + if not all_costs: + return 0.5 + lo, hi = min(all_costs), max(all_costs) + if hi == lo: + return 0.5 + return 1.0 - ((model_cost - lo) / (hi - lo)) + + +def score( + quality_sample: float, + model_cost: float, + all_costs: List[float], + quality_weight: float = DEFAULT_QUALITY_WEIGHT, + cost_weight: float = DEFAULT_COST_WEIGHT, +) -> float: + """ + Multi-objective score. V0 is a weighted linear sum of (quality, normalized_cost). + Higher is better. Both inputs are in [0, 1]. + """ + cost_score = normalized_cost(model_cost, all_costs) + return quality_weight * quality_sample + cost_weight * cost_score + + +def pick_best( + cells: Dict[str, BanditCell], + model_costs: Dict[str, float], + quality_weight: float = DEFAULT_QUALITY_WEIGHT, + cost_weight: float = DEFAULT_COST_WEIGHT, + rng: Optional[random.Random] = None, +) -> str: + """ + Sample once per model, score each, return the model with highest score. + + cells: {model_name: BanditCell} + model_costs: {model_name: $/1k tokens} + """ + if not cells: + raise ValueError("pick_best called with no models") + all_costs = list(model_costs.values()) + best_model: Optional[str] = None + best_score = float("-inf") + for model, cell in cells.items(): + q = thompson_sample(cell, rng=rng) + s = score(q, model_costs[model], all_costs, quality_weight, cost_weight) + if s > best_score: + best_score = s + best_model = model + assert best_model is not None + return best_model diff --git a/litellm/router_strategy/adaptive_router/classifier.py b/litellm/router_strategy/adaptive_router/classifier.py new file mode 100644 index 00000000000..0434dfdb63f --- /dev/null +++ b/litellm/router_strategy/adaptive_router/classifier.py @@ -0,0 +1,140 @@ +""" +Rule-based classifier mapping a user prompt to a RequestType. + +V0 design choice: deterministic regex over the FIRST user message in a session. +Result is cached per session (caller's responsibility, not ours). + +Order matters: we check more specific types first, falling back to GENERAL. +""" + +import re +from typing import List, Pattern, Tuple + +from litellm.types.router import RequestType + +_RULES: List[Tuple[Pattern[str], RequestType]] = [ + ( + re.compile( + r"\b(write|create|generate|implement|build)\s+(?:a |an |the |me )?(?:python|javascript|typescript|java|rust|go|c\+\+|sql|bash|shell)\b", + re.IGNORECASE, + ), + RequestType.CODE_GENERATION, + ), + ( + re.compile( + r"\b(write|create|implement|build)\b(?:\s+\w+){0,4}?\s+(function|class|method|script|program|api|endpoint|microservice)\b", + re.IGNORECASE, + ), + RequestType.CODE_GENERATION, + ), + ( + re.compile( + r"\b(explain|describe|understand|walk me through|what does)\b.*\b(code|function|method|class|algorithm|snippet)\b", + re.IGNORECASE, + ), + RequestType.CODE_UNDERSTANDING, + ), + ( + re.compile( + r"\b(debug|fix|why (?:is|does|isn't)|what.s wrong|trace)\b.*\b(error|bug|exception|stacktrace|stack trace|traceback)\b", + re.IGNORECASE, + ), + RequestType.CODE_UNDERSTANDING, + ), + ( + re.compile( + r"\b(review|critique)\s+(?:this |my |the )?(?:code|pr|pull request|diff|patch)\b", + re.IGNORECASE, + ), + RequestType.CODE_UNDERSTANDING, + ), + ( + re.compile( + r"\b(design|architect|plan|architecture)\b.*\b(system|service|api|database|schema|module|microservice)\b", + re.IGNORECASE, + ), + RequestType.TECHNICAL_DESIGN, + ), + ( + re.compile( + r"\b(should i (?:use|choose|pick)|tradeoffs? between|compare)\b.*\b(library|framework|language|database|protocol|postgres|postgresql|mongodb|dynamodb|mysql|redis|kafka|sql|nosql)\b", + re.IGNORECASE, + ), + RequestType.TECHNICAL_DESIGN, + ), + ( + re.compile( + r"\bhow (?:should|do) i (?:design|structure|organize|model)\b", + re.IGNORECASE, + ), + RequestType.TECHNICAL_DESIGN, + ), + ( + re.compile( + r"\b(solve|compute|calculate|prove|derive)\b.*\b(equation|integral|derivative|theorem|proof|problem)\b", + re.IGNORECASE, + ), + RequestType.ANALYTICAL_REASONING, + ), + ( + re.compile(r"\b(if .+ then|given .+ find|suppose|assume)\b", re.IGNORECASE), + RequestType.ANALYTICAL_REASONING, + ), + ( + re.compile( + r"\b(probability|statistics|combinatorics|optimization problem)\b", + re.IGNORECASE, + ), + RequestType.ANALYTICAL_REASONING, + ), + ( + re.compile( + r"\b(write|draft|compose|rewrite|edit|proofread|polish)\b.*\b(email|essay|blog|post|article|letter|memo|copy|paragraph|sentence)\b", + re.IGNORECASE, + ), + RequestType.WRITING, + ), + ( + re.compile( + r"\b(make (?:this|it)|help me)\s+(?:more |less )?(?:concise|formal|casual|professional|persuasive)\b", + re.IGNORECASE, + ), + RequestType.WRITING, + ), + ( + re.compile( + r"^\s*(who|what|when|where|which)\s+(?:is|was|were|are)\b", re.IGNORECASE + ), + RequestType.FACTUAL_LOOKUP, + ), + ( + re.compile(r"^\s*(define|definition of|meaning of)\b", re.IGNORECASE), + RequestType.FACTUAL_LOOKUP, + ), + ( + re.compile( + r"^\s*how (?:do you spell|to spell|many .* are there|tall is)\b", + re.IGNORECASE, + ), + RequestType.FACTUAL_LOOKUP, + ), +] + + +def classify_prompt(text: str) -> RequestType: + """ + Classify a single user prompt. + + Falls back to GENERAL when no rule matches. Empty/whitespace-only also + returns GENERAL. + """ + if not text or not text.strip(): + return RequestType.GENERAL + + truncated = text[:2000] + + for pattern, request_type in _RULES: + if pattern.search(truncated): + return request_type + + return RequestType.GENERAL diff --git a/litellm/router_strategy/adaptive_router/config.py b/litellm/router_strategy/adaptive_router/config.py new file mode 100644 index 00000000000..e72826cc056 --- /dev/null +++ b/litellm/router_strategy/adaptive_router/config.py @@ -0,0 +1,54 @@ +""" +Configuration constants for the adaptive_router strategy. + +All magic numbers are first-pass guesses (D3-D6 in the handoff plan). +Expect to retune after first 1000 sessions of real traffic. +""" + +from typing import Dict + +from litellm.types.router import RequestType # re-export for convenience # noqa: F401 + +# D3 — Score weights (default; user-overridable via AdaptiveRouterConfig.weights) +DEFAULT_QUALITY_WEIGHT: float = 0.7 # UNVALIDATED — calibrated against [0] sessions +DEFAULT_COST_WEIGHT: float = 0.3 # UNVALIDATED — calibrated against [0] sessions + +# D4 — Cold-start prior: (alpha + beta) total mass = COLD_START_MASS +# Mean of Beta = base_tier_weight + (strength_bonus if declared) +BASE_TIER_WEIGHT: Dict[int, float] = {1: 0.3, 2: 0.5, 3: 0.7} # UNVALIDATED +STRENGTH_BONUS: float = 0.3 # UNVALIDATED +COLD_START_MASS: float = 10.0 + +# D5 — Sample cap. Hard cap, no rescaling (drift handling is v1). +SAMPLE_CAP: int = 200 + +# D6 — Clean-trace credit: minimum turns before α += 1 can fire. +MIN_TURNS_FOR_CLEAN_CREDIT: int = 3 + +# D2 — Owner-cache TTL (seconds). 24h. +# A conversation's first-picked model "owns" the bandit-update slot for +# this long. Subsequent turns of the same conversation only contribute a +# bandit/state update when the same model is re-sampled. +OWNER_CACHE_TTL_SECONDS: int = 24 * 3600 + +# Below this many messages we skip post-call signal recording. Most signals +# (misalignment, stagnation, satisfaction-in-response-to-prior-turn) need at +# least one full prior exchange to be meaningful. +SIGNAL_GATE_MIN_MESSAGES: int = 4 + +# Detector thresholds (from Plano/Chen 2026 paper). +MISALIGNMENT_JACCARD_THRESHOLD: float = 0.45 +STAGNATION_JACCARD_NEAR_DUP: float = 0.50 +LOOP_REPEAT_THRESHOLD: int = 3 +TOOL_CALL_HISTORY_MAX: int = 20 + +# D1 — Caller filter for min quality tier. +MIN_QUALITY_TIER_HEADER: str = "x-litellm-min-quality-tier" +MIN_QUALITY_TIER_METADATA_KEY: str = "min_quality_tier" + +# Pre-routing -> post-call relay: the chosen logical model is stashed on +# request_kwargs["metadata"][ADAPTIVE_ROUTER_CHOSEN_MODEL_KEY] by the +# pre-routing hook, then read by the post-call hook to surface as the +# ADAPTIVE_ROUTER_RESPONSE_HEADER response header. +ADAPTIVE_ROUTER_CHOSEN_MODEL_KEY: str = "adaptive_router_chosen_model" +ADAPTIVE_ROUTER_RESPONSE_HEADER: str = "x-litellm-adaptive-router-model" diff --git a/litellm/router_strategy/adaptive_router/hooks.py b/litellm/router_strategy/adaptive_router/hooks.py new file mode 100644 index 00000000000..9e346006ac1 --- /dev/null +++ b/litellm/router_strategy/adaptive_router/hooks.py @@ -0,0 +1,278 @@ +""" +Post-call hook for the adaptive router. + +On each successful or failed completion, build a Turn from the request/response +and push it through `AdaptiveRouter.record_turn`. The router then updates the +in-memory bandit cell + session state and queues writes for the proxy flusher. + +All work happens after the response has been returned to the caller. Any +exception is swallowed — signal recording must never break a request. +""" + +from __future__ import annotations + +import hashlib +import json +from typing import Any, Dict, List, Optional + +from litellm._logging import verbose_router_logger +from litellm.integrations.custom_logger import CustomLogger +from litellm.router_strategy.adaptive_router.adaptive_router import AdaptiveRouter +from litellm.router_strategy.adaptive_router.classifier import classify_prompt +from litellm.router_strategy.adaptive_router.config import ( + ADAPTIVE_ROUTER_CHOSEN_MODEL_KEY, + ADAPTIVE_ROUTER_RESPONSE_HEADER, + SIGNAL_GATE_MIN_MESSAGES, +) +from litellm.router_strategy.adaptive_router.signals import Turn + +# Identity fields hashed into a derived session key so the same conversation +# from the same caller produces a stable key, while different keys/teams/users +# stay segregated even if they happen to send identical first messages. +_IDENTITY_FIELDS = ( + "user_api_key_hash", + "user_api_key_team_id", + "user_api_key_user_id", + "user_api_key_end_user_id", +) + + +def _resolve_session_key(kwargs: Dict[str, Any]) -> Optional[str]: + """Pick a stable per-conversation key for owner-cache attribution. + + Order: + 1. Honor a client-supplied session id (`litellm_session_id` on either + `litellm_params` or `litellm_params.metadata`, or `session_id` on + metadata) — backward compat for callers already wired up. + 2. Otherwise derive a sha256 over (identity fields, first + SIGNAL_GATE_MIN_MESSAGES messages) so the key is stable across turns + and only materialises once there is enough context for the bandit to + act on (matching the gate in the signal-processing path). + + Returns None if the conversation is shorter than SIGNAL_GATE_MIN_MESSAGES. + """ + litellm_params = kwargs.get("litellm_params") or {} + sid = litellm_params.get("litellm_session_id") + if sid: + return str(sid) + metadata = litellm_params.get("metadata") or {} + if isinstance(metadata, dict): + sid = metadata.get("session_id") or metadata.get("litellm_session_id") + if sid: + return str(sid) + + messages = kwargs.get("messages") or [] + if len(messages) < SIGNAL_GATE_MIN_MESSAGES: + # Don't attribute until we have enough turns to match the signal gate — + # ensures the hash is stable (same N messages every time) and avoids + # crediting the bandit for conversations that are too short to signal. + return None + + identity = ":".join( + str(metadata.get(f) or "") if isinstance(metadata, dict) else "" + for f in _IDENTITY_FIELDS + ) + anchor = messages[:SIGNAL_GATE_MIN_MESSAGES] + payload = ( + identity + + "|" + + json.dumps( + [{"role": m.get("role"), "content": m.get("content")} for m in anchor], + sort_keys=True, + default=str, + ) + ) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _last_user_content(messages: Optional[List[Dict[str, Any]]]) -> Optional[str]: + if not messages: + return None + for msg in reversed(messages): + if msg.get("role") == "user": + content = msg.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + # OpenAI vision-style content: pick first text part. + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + return part.get("text") + return None + return None + + +def _recent_tool_results( + messages: Optional[List[Dict[str, Any]]] +) -> List[Dict[str, Any]]: + """Extract the current turn's tool result payloads from the request messages. + + Tool results are `role == "tool"` messages that sit at the tail of the + conversation — i.e. after the most recent assistant message with + `tool_calls`, waiting for the model to produce a user-facing reply. Walk + backwards from the end and collect the contiguous run of tool messages; + stop at the first non-tool message. + + Each result is normalized to `{content, is_error}` — the only fields + `signals._detect_failure` / `_detect_exhaustion` actually read. + """ + if not messages: + return [] + results: List[Dict[str, Any]] = [] + for msg in reversed(messages): + if not isinstance(msg, dict): + break + if msg.get("role") != "tool": + break + content = msg.get("content") + # Some providers (Anthropic-style) carry an explicit error flag; OpenAI + # tool results don't, so fall back to an empty/missing content heuristic + # inside `_detect_failure`. + is_error = bool(msg.get("is_error")) + results.append({"content": content, "is_error": is_error}) + results.reverse() + return results + + +def _assistant_content_and_tool_calls(response_obj: Any) -> tuple: + """Return (assistant_text, tool_calls_list) extracted from a ModelResponse-ish object.""" + if response_obj is None: + return None, [] + try: + choices = getattr(response_obj, "choices", None) or response_obj.get("choices") + except Exception: + return None, [] + if not choices: + return None, [] + + msg = choices[0] + msg = getattr(msg, "message", None) or ( + msg.get("message") if isinstance(msg, dict) else None + ) + if msg is None: + return None, [] + + content = getattr(msg, "content", None) + if content is None and isinstance(msg, dict): + content = msg.get("content") + + raw_tool_calls = getattr(msg, "tool_calls", None) + if raw_tool_calls is None and isinstance(msg, dict): + raw_tool_calls = msg.get("tool_calls") + tool_calls: List[Dict[str, Any]] = [] + for tc in raw_tool_calls or []: + if isinstance(tc, dict): + tool_calls.append(tc) + else: + try: + tool_calls.append(tc.model_dump()) + except Exception: + tool_calls.append({"name": getattr(tc, "name", ""), "arguments": ""}) + return content, tool_calls + + +class AdaptiveRouterPostCallHook(CustomLogger): + """One hook instance per AdaptiveRouter. Registered into litellm.callbacks.""" + + def __init__(self, adaptive_router: AdaptiveRouter) -> None: + self.adaptive_router = adaptive_router + + async def async_post_call_response_headers_hook( + self, + data: Dict[str, Any], + user_api_key_dict: Any, + response: Any, + request_headers: Optional[Dict[str, str]] = None, + litellm_call_info: Optional[Dict[str, Any]] = None, + ) -> Optional[Dict[str, str]]: + """ + Surface the chosen logical model as the `x-litellm-adaptive-router-model` + response header for both streaming and non-streaming responses. + + `async_post_call_success_hook` fires after the stream is fully consumed, + so writing to `_hidden_params["additional_headers"]` there is too late for + streaming — the StreamingResponse headers are already frozen. This hook is + called during header construction (before StreamingResponse is built), so + the header is included for both paths. + """ + metadata = data.get("metadata") or {} + chosen = ( + metadata.get(ADAPTIVE_ROUTER_CHOSEN_MODEL_KEY) + if isinstance(metadata, dict) + else None + ) + if not chosen: + return None + return {ADAPTIVE_ROUTER_RESPONSE_HEADER: chosen} + + async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): + await self._record(kwargs, response_obj, response_status=200) + + async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time): + status = kwargs.get("response_status") + if status is None: + exc = kwargs.get("exception") + status = getattr(exc, "status_code", 500) if exc is not None else 500 + await self._record(kwargs, response_obj, response_status=int(status)) + + async def _record( + self, + kwargs: Dict[str, Any], + response_obj: Any, + response_status: int, + ) -> None: + try: + messages = kwargs.get("messages") or [] + if len(messages) < SIGNAL_GATE_MIN_MESSAGES: + # Too few turns for any signal to be meaningful — skip. + return + + session_key = _resolve_session_key(kwargs) + if not session_key: + return + + # The bandit cells are keyed by the *logical* model name from + # `available_models` (e.g. "smart"/"fast"). `kwargs["model"]` at + # post-call time is the physical upstream model + # (e.g. "anthropic/claude-opus-4-7"), so it cannot be used directly. + # The pre-routing hook stashes the logical pick under this key. + litellm_params = kwargs.get("litellm_params") or {} + metadata = litellm_params.get("metadata") or {} + current_model = ( + metadata.get(ADAPTIVE_ROUTER_CHOSEN_MODEL_KEY) + if isinstance(metadata, dict) + else None + ) + if not current_model: + return + + if not self.adaptive_router.claim_or_check_owner( + session_key, current_model + ): + # A different model owns this conversation — skip attribution. + return + + user_text = _last_user_content(messages) + assistant_text, tool_calls = _assistant_content_and_tool_calls(response_obj) + tool_results = _recent_tool_results(messages) + + request_type = classify_prompt(user_text or "") + turn = Turn( + user_content=user_text, + assistant_content=( + assistant_text if isinstance(assistant_text, str) else None + ), + tool_calls=tool_calls, + tool_results=tool_results, + response_status=response_status, + ) + await self.adaptive_router.record_turn( + session_id=session_key, + model_name=current_model, + request_type=request_type, + turn=turn, + ) + except Exception as e: + verbose_router_logger.exception( + "AdaptiveRouterPostCallHook: failed to record turn: %s", e + ) diff --git a/litellm/router_strategy/adaptive_router/signals.py b/litellm/router_strategy/adaptive_router/signals.py new file mode 100644 index 00000000000..a48bdea1eb6 --- /dev/null +++ b/litellm/router_strategy/adaptive_router/signals.py @@ -0,0 +1,287 @@ +""" +Incremental signal detection for the adaptive router. + +Each session maintains a SessionState. On every turn, we call apply_turn(state, turn) +which mutates the state in place and returns a SignalDelta listing which signals +fired on THIS turn. The router then queues the delta to be flushed to DB. + +Design constraint: O(1) work per turn. No re-scanning the full session history. +We keep small bounded windows: last_user_content, last_assistant_content, and a +bounded list of recent tool call signatures. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Set + +from litellm.router_strategy.adaptive_router.config import ( + LOOP_REPEAT_THRESHOLD, + MIN_TURNS_FOR_CLEAN_CREDIT, + MISALIGNMENT_JACCARD_THRESHOLD, + STAGNATION_JACCARD_NEAR_DUP, + TOOL_CALL_HISTORY_MAX, +) + + +# ---- Public types --------------------------------------------------------- + + +@dataclass +class SignalDelta: + """Which signals fired on a single turn. Counts are 0 or 1 (one delta per turn).""" + + misalignment: int = 0 + stagnation: int = 0 + disengagement: int = 0 + satisfaction: int = 0 + failure: int = 0 + loop: int = 0 + exhaustion: int = 0 + + def any_fired(self) -> bool: + return any( + [ + self.misalignment, + self.stagnation, + self.disengagement, + self.satisfaction, + self.failure, + self.loop, + self.exhaustion, + ] + ) + + +@dataclass +class SessionState: + """In-memory rolling state for one session. + + Mirrors the LiteLLM_AdaptiveRouterSession DB row (Wave 0 schema). The flusher + later persists this. We keep this as a plain dataclass — no DB coupling. + """ + + session_id: str + router_name: str + model_name: str + classified_type: str + + misalignment_count: int = 0 + stagnation_count: int = 0 + disengagement_count: int = 0 + satisfaction_count: int = 0 + failure_count: int = 0 + loop_count: int = 0 + exhaustion_count: int = 0 + + last_user_content: Optional[str] = None + last_assistant_content: Optional[str] = None + tool_call_history: List[str] = field(default_factory=list) + pending_tool_calls: Dict[str, str] = field(default_factory=dict) + + turn_count: int = 0 + last_processed_turn: int = -1 + clean_credit_awarded: bool = False + terminal_status: Optional[int] = None + + +@dataclass +class Turn: + """One turn of input. Caller assembles this from the request/response.""" + + user_content: Optional[str] = None + assistant_content: Optional[str] = None + tool_calls: List[Dict[str, Any]] = field(default_factory=list) + tool_results: List[Dict[str, Any]] = field(default_factory=list) + response_status: Optional[int] = None + + +# ---- Detection helpers ---------------------------------------------------- + +_TOKEN_RE = re.compile(r"[A-Za-z0-9]+") + + +def _tokens(text: Optional[str]) -> Set[str]: + if not text: + return set() + return {t.lower() for t in _TOKEN_RE.findall(text)} + + +def _jaccard(a: Set[str], b: Set[str]) -> float: + union = a | b + if not union: + return 0.0 + return len(a & b) / len(union) + + +_DISENGAGEMENT_PATTERNS = [ + re.compile( + r"\b(forget it|never mind|give up|talk to (?:a )?human|cancel)\b", re.IGNORECASE + ), + re.compile(r"\b(this (?:isn'?t|is not) working|stop|abort)\b", re.IGNORECASE), + re.compile(r"\bi'?ll do it (?:myself|manually)\b", re.IGNORECASE), +] + +_SATISFACTION_PATTERNS = [ + re.compile( + r"\b(that worked|that did it|works now|fixed it|solved it|nice)\b", + re.IGNORECASE, + ), + re.compile(r"\b(thanks|thank you|thx|appreciated|appreciate it)\b", re.IGNORECASE), + re.compile(r"\b(perfect|great|excellent|exactly)\b", re.IGNORECASE), +] + + +def _detect_misalignment(prev_user: Optional[str], curr_user: Optional[str]) -> bool: + """Fires when consecutive user messages share *some* topic (jaccard > 0) + but are sufficiently different (jaccard < threshold) — i.e. user is + rephrasing, not changing topic, not repeating.""" + if not prev_user or not curr_user: + return False + j = _jaccard(_tokens(prev_user), _tokens(curr_user)) + return 0.0 < j < MISALIGNMENT_JACCARD_THRESHOLD + + +def _detect_stagnation(prev_asst: Optional[str], curr_asst: Optional[str]) -> bool: + """Fires when consecutive assistant messages are near-duplicates.""" + if not prev_asst or not curr_asst: + return False + j = _jaccard(_tokens(prev_asst), _tokens(curr_asst)) + return j >= STAGNATION_JACCARD_NEAR_DUP + + +def _detect_disengagement(curr_user: Optional[str]) -> bool: + if not curr_user: + return False + return any(p.search(curr_user) for p in _DISENGAGEMENT_PATTERNS) + + +def _detect_satisfaction(curr_user: Optional[str]) -> bool: + if not curr_user: + return False + return any(p.search(curr_user) for p in _SATISFACTION_PATTERNS) + + +def _detect_failure(tool_results: List[Dict[str, Any]]) -> bool: + """Any tool result explicitly flagged as an error. + + We do NOT treat empty content as failure — many tools legitimately return + empty output (zero-result searches, silent bash commands, void writes) and + penalizing the model for those would corrupt the bandit posterior. + """ + for r in tool_results: + if r.get("is_error"): + return True + return False + + +def _signature(call: Dict[str, Any]) -> str: + """Stable signature for loop detection: name + sorted JSON-ish args.""" + name = call.get("name") or call.get("function", {}).get("name", "") + call_args = call.get("arguments") + if call_args is None: + call_args = call.get("function", {}).get("arguments", "") + if isinstance(call_args, dict): + call_args = ",".join(f"{k}={call_args[k]}" for k in sorted(call_args.keys())) + return f"{name}({call_args})" + + +def _detect_loop(history: List[str], new_calls: List[Dict[str, Any]]) -> bool: + """Fires if any new call's signature appears >= LOOP_REPEAT_THRESHOLD-1 times + in recent history (so this call would be the Nth).""" + if not new_calls: + return False + for call in new_calls: + sig = _signature(call) + recent_count = history.count(sig) + if recent_count >= LOOP_REPEAT_THRESHOLD - 1: + return True + return False + + +_EXHAUSTION_STATUSES = {408, 413, 429, 503, 504} + +_EXHAUSTION_KEYWORDS = ( + "context length", + "context window", + "token limit", + "rate limit", + "too many requests", + "timeout", +) + + +def _detect_exhaustion( + status: Optional[int], tool_results: List[Dict[str, Any]] +) -> bool: + if status is not None and status in _EXHAUSTION_STATUSES: + return True + for r in tool_results: + content = str(r.get("content", "")).lower() + if any(kw in content for kw in _EXHAUSTION_KEYWORDS): + return True + return False + + +# ---- Public entrypoint ---------------------------------------------------- + + +def apply_turn(state: SessionState, turn: Turn) -> SignalDelta: + """ + Detect signals on this turn, mutate state, return the delta. + + O(1) per turn (no full-history rescan). Only inspects last_*, recent tool history + (which is bounded at TOOL_CALL_HISTORY_MAX), and the new turn payload. + """ + delta = SignalDelta() + + if _detect_misalignment(state.last_user_content, turn.user_content): + delta.misalignment = 1 + if _detect_stagnation(state.last_assistant_content, turn.assistant_content): + delta.stagnation = 1 + if _detect_disengagement(turn.user_content): + delta.disengagement = 1 + if _detect_satisfaction(turn.user_content): + # Gate: only award satisfaction credit once per session, and only + # after MIN_TURNS_FOR_CLEAN_CREDIT turns of context. Early "thanks" + # on turn 1-2 is noise, not a validated quality signal. + current_turn_index = state.turn_count + 1 + if ( + not state.clean_credit_awarded + and current_turn_index >= MIN_TURNS_FOR_CLEAN_CREDIT + ): + delta.satisfaction = 1 + state.clean_credit_awarded = True + if _detect_failure(turn.tool_results): + delta.failure = 1 + if _detect_loop(state.tool_call_history, turn.tool_calls): + delta.loop = 1 + if _detect_exhaustion(turn.response_status, turn.tool_results): + delta.exhaustion = 1 + + state.misalignment_count += delta.misalignment + state.stagnation_count += delta.stagnation + state.disengagement_count += delta.disengagement + state.satisfaction_count += delta.satisfaction + state.failure_count += delta.failure + state.loop_count += delta.loop + state.exhaustion_count += delta.exhaustion + + if turn.user_content: + state.last_user_content = turn.user_content + if turn.assistant_content: + state.last_assistant_content = turn.assistant_content + + for call in turn.tool_calls: + state.tool_call_history.append(_signature(call)) + if len(state.tool_call_history) > TOOL_CALL_HISTORY_MAX: + state.tool_call_history = state.tool_call_history[-TOOL_CALL_HISTORY_MAX:] + + if turn.response_status is not None: + state.terminal_status = turn.response_status + + state.turn_count += 1 + state.last_processed_turn = state.turn_count + + return delta diff --git a/litellm/router_strategy/adaptive_router/update_queue.py b/litellm/router_strategy/adaptive_router/update_queue.py new file mode 100644 index 00000000000..b667f3a53a7 --- /dev/null +++ b/litellm/router_strategy/adaptive_router/update_queue.py @@ -0,0 +1,213 @@ +""" +In-memory queues for adaptive router state and session updates. + +Pattern follows DailySpendUpdateQueue: hot path is fully in-memory; a background +flusher task drains the aggregator and writes batches to Postgres. + +Two logical queues (one class): + 1. STATE updates: increments to (router, request_type, model) bandit cell. + Aggregator key = (router_name, request_type, model_name) + Aggregated payload = {"delta_alpha": float, "delta_beta": float, "samples_added": int} + 2. SESSION updates: full snapshot of a session row (last-write-wins per session+router+model). + Aggregator key = (session_id, router_name, model_name) + Aggregated payload = the full session state dict. + +Hot-path API is non-blocking and synchronous from the caller's POV (it just appends +to the in-memory aggregator). Flush is async and batched. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Dict, Tuple + +from litellm._logging import verbose_router_logger + +StateKey = Tuple[str, str, str] # (router_name, request_type, model_name) +SessionKey = Tuple[str, str, str] # (session_id, router_name, model_name) + + +class AdaptiveRouterUpdateQueue: + """ + Single class managing both state-update aggregation and session-snapshot aggregation. + Held by the AdaptiveRouter strategy instance and started by the proxy on boot. + """ + + def __init__(self) -> None: + self._state_agg: Dict[StateKey, Dict[str, float]] = {} + self._session_agg: Dict[SessionKey, Dict[str, Any]] = {} + self._lock = asyncio.Lock() + self._max_state_size_seen = 0 + self._max_session_size_seen = 0 + + # ---- Hot-path: state delta ------------------------------------------- + + async def add_state_delta( + self, + router_name: str, + request_type: str, + model_name: str, + delta_alpha: float, + delta_beta: float, + ) -> None: + """Aggregate a bandit-cell delta. Multiple deltas to the same cell sum.""" + key: StateKey = (router_name, request_type, model_name) + async with self._lock: + current = self._state_agg.get(key) + if current is None: + self._state_agg[key] = { + "delta_alpha": delta_alpha, + "delta_beta": delta_beta, + "samples_added": 1, + } + else: + current["delta_alpha"] += delta_alpha + current["delta_beta"] += delta_beta + current["samples_added"] += 1 + if len(self._state_agg) > self._max_state_size_seen: + self._max_state_size_seen = len(self._state_agg) + + # ---- Hot-path: session snapshot -------------------------------------- + + async def add_session_state( + self, + session_id: str, + router_name: str, + model_name: str, + state_dict: Dict[str, Any], + ) -> None: + """ + Last-write-wins per session row. The state_dict is a snapshot of the + SessionState (signals counts + bookkeeping fields). The flusher will + upsert this into LiteLLM_AdaptiveRouterSession. + """ + key: SessionKey = (session_id, router_name, model_name) + async with self._lock: + self._session_agg[key] = state_dict + if len(self._session_agg) > self._max_session_size_seen: + self._max_session_size_seen = len(self._session_agg) + + # ---- Flushers (called by background task) ---------------------------- + + async def flush_state_to_db(self, prisma_client: Any) -> int: + """ + Drain state aggregator and apply to LiteLLM_AdaptiveRouterState. + Returns number of cells flushed. + """ + async with self._lock: + batch = self._state_agg + self._state_agg = {} + + if not batch: + return 0 + + # Sort keys to give deterministic write order across writers and + # reduce the chance of cross-row deadlocks when other workers race us. + for key in sorted(batch.keys()): + router, rt, model = key + payload = batch[key] + try: + # Atomic increment: push the delta directly into the DB so + # concurrent flushers from multiple pods don't overwrite each + # other. The upsert creates the row with the delta as the + # initial value on first write, then increments on subsequent + # writes — no read-modify-write race. + await prisma_client.db.litellm_adaptiverouterstate.upsert( + where={ + "router_name_request_type_model_name": { + "router_name": router, + "request_type": rt, + "model_name": model, + } + }, + data={ + "create": { + "router_name": router, + "request_type": rt, + "model_name": model, + "alpha": payload["delta_alpha"], + "beta": payload["delta_beta"], + "total_samples": int(payload["samples_added"]), + }, + "update": { + "alpha": {"increment": payload["delta_alpha"]}, + "beta": {"increment": payload["delta_beta"]}, + "total_samples": { + "increment": int(payload["samples_added"]) + }, + }, + }, + ) + except Exception as e: + verbose_router_logger.exception( + "AdaptiveRouterUpdateQueue: failed to flush state for %s: %s", + key, + e, + ) + + return len(batch) + + async def flush_session_to_db(self, prisma_client: Any) -> int: + """ + Drain session aggregator and upsert into LiteLLM_AdaptiveRouterSession. + Returns number of session rows flushed. + """ + async with self._lock: + batch = self._session_agg + self._session_agg = {} + + if not batch: + return 0 + + for key in sorted(batch.keys()): + session_id, router, model = key + payload = batch[key] + try: + # NOTE: Prisma client lower-cases model names, so + # `LiteLLM_AdaptiveRouterSession` -> `litellm_adaptiveroutersession` + # (single 's', not 'litellm_adaptiverouterssession'). + # Strip PK fields from the update payload — Prisma rejects + # writes to fields that are part of the @@id. asdict(state) + # always carries them, so build a separate update dict. + update_payload = { + k: v + for k, v in payload.items() + if k not in ("session_id", "router_name", "model_name") + } + await prisma_client.db.litellm_adaptiveroutersession.upsert( + where={ + "session_id_router_name_model_name": { + "session_id": session_id, + "router_name": router, + "model_name": model, + } + }, + data={ + "create": { + "session_id": session_id, + "router_name": router, + "model_name": model, + **update_payload, + }, + "update": update_payload, + }, + ) + except Exception as e: + verbose_router_logger.exception( + "AdaptiveRouterUpdateQueue: failed to flush session for %s: %s", + key, + e, + ) + + return len(batch) + + # ---- Observability --------------------------------------------------- + + async def queue_size(self) -> Dict[str, int]: + async with self._lock: + return { + "state_pending": len(self._state_agg), + "session_pending": len(self._session_agg), + "max_state_seen": self._max_state_size_seen, + "max_session_seen": self._max_session_size_seen, + } diff --git a/litellm/router_strategy/auto_router/auto_router.py b/litellm/router_strategy/auto_router/auto_router.py index 4ead7225abc..58b2c5a3912 100644 --- a/litellm/router_strategy/auto_router/auto_router.py +++ b/litellm/router_strategy/auto_router/auto_router.py @@ -82,11 +82,34 @@ class AutoRouter(CustomLogger): ) return auto_router_routes + @staticmethod + def _extract_text_from_messages(messages: List[Dict[str, Any]]) -> str: + """ + Extract text content from the last user message for routing. + + Handles tool-call conversations (where the last message may be an + assistant or tool message with non-string content) and multimodal + messages (where content is a list of content blocks). + """ + for msg in reversed(messages): + if msg.get("role") == "user": + content = msg.get("content") + if content is None: + return "" + if isinstance(content, list): + return " ".join( + block.get("text", "") + for block in content + if isinstance(block, dict) and block.get("type") == "text" + ) + return str(content) + return "" + async def async_pre_routing_hook( self, model: str, request_kwargs: Dict, - messages: Optional[List[Dict[str, str]]] = None, + messages: Optional[List[Dict[str, Any]]] = None, input: Optional[Union[str, List]] = None, specific_deployment: Optional[bool] = False, ) -> Optional["PreRoutingHookResponse"]: @@ -120,8 +143,7 @@ class AutoRouter(CustomLogger): auto_sync=self.auto_sync_value, ) - user_message: Dict[str, str] = messages[-1] - message_content: str = user_message.get("content", "") + message_content = self._extract_text_from_messages(messages) route_choice: Optional[Union[RouteChoice, List[RouteChoice]]] = self.routelayer( text=message_content ) diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index e51249b1cb1..aa3bcef6392 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -332,45 +332,68 @@ class ComplexityRouter(CustomLogger): f"No model configured for tier {tier_key} and no default_model set" ) - async def async_pre_routing_hook( + def _resolve_messages( self, - model: str, + messages: Optional[List[Dict[str, Any]]], request_kwargs: Dict, - messages: Optional[List[Dict[str, Any]]] = None, - input: Optional[Union[str, List]] = None, - specific_deployment: Optional[bool] = False, - ) -> Optional["PreRoutingHookResponse"]: + ) -> Optional[List[Dict[str, Any]]]: """ - Pre-routing hook called before the routing decision. + Resolve messages from the request, converting from other formats if needed. - Classifies the request by complexity and returns the appropriate model. - - Args: - model: The original model name requested. - request_kwargs: The request kwargs. - messages: The messages in the request. - input: Optional input for embeddings. - specific_deployment: Whether a specific deployment was requested. - - Returns: - PreRoutingHookResponse with the routed model, or None if no routing needed. + Uses the guardrail translation handler dispatch to convert Responses API + ``input`` (or other non-chat-completions formats) into OpenAI-spec messages. """ - from litellm.types.router import PreRoutingHookResponse + if messages: + return messages - if messages is None or len(messages) == 0: - verbose_router_logger.debug( - "ComplexityRouter: No messages provided, skipping routing" - ) - return None + from litellm.litellm_core_utils.api_route_to_call_types import ( + get_call_types_for_route, + ) + from litellm.llms import load_guardrail_translation_mappings + from litellm.types.utils import CallTypes - # Extract the last user message and the last system prompt + mappings = load_guardrail_translation_mappings() + call_type: Optional[CallTypes] = None + + # 1. Try route-based inference from proxy metadata + route = request_kwargs.get("litellm_metadata", {}).get( + "user_api_key_request_route" + ) + if route: + call_types_list = get_call_types_for_route(route) + if call_types_list: + for ct in call_types_list: + if ct in mappings: + call_type = ct + break + + # 2. Fallback: try each mapped handler until one produces messages + handlers_to_try: List[Any] = [] + if call_type is not None and call_type in mappings: + handlers_to_try.append(mappings[call_type]()) + else: + handlers_to_try.extend(handler_cls() for handler_cls in mappings.values()) + + for handler in handlers_to_try: + structured = handler.get_structured_messages(request_kwargs) + if structured: + return [ + msg if isinstance(msg, dict) else msg.model_dump() # type: ignore + for msg in structured + ] + return None + + @staticmethod + def _extract_user_message_and_system_prompt( + messages: List[Dict[str, Any]], + ) -> Tuple[Optional[str], Optional[str]]: + """Extract the last user message text and last system prompt from messages.""" user_message: Optional[str] = None system_prompt: Optional[str] = None for msg in reversed(messages): role = msg.get("role", "") content = msg.get("content") or "" - # content may be a list of content parts (e.g. [{"type": "text", "text": "..."}]) if isinstance(content, list): text_parts = [ part.get("text", "") @@ -383,6 +406,52 @@ class ComplexityRouter(CustomLogger): user_message = content elif role == "system" and system_prompt is None: system_prompt = content + if user_message is not None and system_prompt is not None: + break + + return user_message, system_prompt + + async def async_pre_routing_hook( + self, + model: str, + request_kwargs: Dict, + messages: Optional[List[Dict[str, Any]]] = None, + input: Optional[Union[str, List]] = None, + specific_deployment: Optional[bool] = False, + ) -> Optional["PreRoutingHookResponse"]: + """ + Pre-routing hook called before the routing decision. + + Classifies the request by complexity and returns the appropriate model. + Supports chat completions (messages), Responses API (input), and other + formats via the guardrail translation handler dispatch. + + Args: + model: The original model name requested. + request_kwargs: The request kwargs. + messages: The messages in the request. + input: Optional input for Responses API or embeddings. + specific_deployment: Whether a specific deployment was requested. + + Returns: + PreRoutingHookResponse with the routed model, or None if no routing needed. + """ + from litellm.types.router import PreRoutingHookResponse + + resolved_messages = self._resolve_messages(messages, request_kwargs) + + if not resolved_messages: + verbose_router_logger.debug( + "ComplexityRouter: No messages could be resolved, skipping routing" + ) + return None + + # Determine whether the original request used messages directly + has_original_messages = messages is not None and len(messages) > 0 + + user_message, system_prompt = self._extract_user_message_and_system_prompt( + resolved_messages + ) if user_message is None: verbose_router_logger.debug( @@ -391,13 +460,10 @@ class ComplexityRouter(CustomLogger): return PreRoutingHookResponse( model=self.config.default_model or self.get_model_for_tier(ComplexityTier.MEDIUM), - messages=messages, + messages=messages if has_original_messages else None, ) - # Classify the request tier, score, signals = self.classify(user_message, system_prompt) - - # Get the model for this tier routed_model = self.get_model_for_tier(tier) verbose_router_logger.info( @@ -407,5 +473,5 @@ class ComplexityRouter(CustomLogger): return PreRoutingHookResponse( model=routed_model, - messages=messages, + messages=messages if has_original_messages else None, ) diff --git a/litellm/router_strategy/quality_router/__init__.py b/litellm/router_strategy/quality_router/__init__.py new file mode 100644 index 00000000000..5728943448a --- /dev/null +++ b/litellm/router_strategy/quality_router/__init__.py @@ -0,0 +1,21 @@ +""" +Quality-tier auto-router. + +Re-uses the ComplexityRouter's classification to decide a request's complexity, +then maps that complexity to an admin-configured quality tier and resolves the +target model from each candidate's `model_info.litellm_routing_preferences`. +""" + +from .config import ( + DEFAULT_COMPLEXITY_TO_QUALITY, + QualityRouterConfig, + RoutingPreferences, +) +from .quality_router import QualityRouter + +__all__ = [ + "QualityRouter", + "QualityRouterConfig", + "RoutingPreferences", + "DEFAULT_COMPLEXITY_TO_QUALITY", +] diff --git a/litellm/router_strategy/quality_router/config.py b/litellm/router_strategy/quality_router/config.py new file mode 100644 index 00000000000..125ecd5bb9b --- /dev/null +++ b/litellm/router_strategy/quality_router/config.py @@ -0,0 +1,74 @@ +""" +Configuration models for the QualityRouter. +""" + +from typing import Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field + +# Default mapping from ComplexityTier name (string) to quality tier (int). +# Higher tier = higher capability requirement. +DEFAULT_COMPLEXITY_TO_QUALITY: Dict[str, int] = { + "SIMPLE": 1, + "MEDIUM": 2, + "COMPLEX": 3, + "REASONING": 4, +} + + +class QualityRouterConfig(BaseModel): + """Configuration for the QualityRouter.""" + + available_models: List[str] = Field( + default_factory=list, + description=( + "List of candidate model names this router may route to. Each model " + "must declare its quality_tier in model_info.litellm_routing_preferences." + ), + ) + + default_model: Optional[str] = Field( + default=None, + description="Fallback model when no quality tier resolves.", + ) + + complexity_to_quality: Dict[str, int] = Field( + default_factory=lambda: DEFAULT_COMPLEXITY_TO_QUALITY.copy(), + description="Mapping from ComplexityTier name to quality tier (int).", + ) + + model_config = ConfigDict(extra="allow") + + +class RoutingPreferences(BaseModel): + """Per-deployment routing preferences declared on model_info.""" + + quality_tier: int = Field( + ..., + description="The quality tier this deployment satisfies.", + ) + + keywords: List[str] = Field( + default_factory=list, + description=( + "Substring keywords (case-insensitive) that, when present in the " + "user message, route the request to this deployment. See `order` " + "for explicit collision handling, otherwise ties fall through to " + "(highest quality_tier, then cheapest model_info.input_cost_per_token)." + ), + ) + + order: Optional[int] = Field( + default=None, + description=( + "Explicit priority used to break ties between deployments at the " + "same quality tier. Lower values win. Applies both to keyword " + "collisions and to picking between multiple deployments at the " + "same quality_tier. Tiebreak order is " + "(quality_tier DESC, order ASC, input_cost_per_token ASC, " + "model_name ASC) — quality always wins first, then explicit " + "order, then price." + ), + ) + + model_config = ConfigDict(extra="allow") diff --git a/litellm/router_strategy/quality_router/quality_router.py b/litellm/router_strategy/quality_router/quality_router.py new file mode 100644 index 00000000000..a79b4384f5e --- /dev/null +++ b/litellm/router_strategy/quality_router/quality_router.py @@ -0,0 +1,446 @@ +""" +Quality-tier Auto Router. + +Routes a request to a model at a target quality tier. The quality tier is +inferred by re-using the existing ComplexityRouter's classification, then +mapped through an admin-configured `complexity_to_quality` table. Each +candidate model declares its own `quality_tier` in +`model_info.litellm_routing_preferences`. + +Optional keyword override: deployments may also declare `keywords` in +`litellm_routing_preferences`. If any declared keyword appears in the user +message (case-insensitive substring match), the router short-circuits the +complexity-classification flow and routes to the matching deployment. When +multiple deployments match, ties are broken by (highest quality_tier first, +then cheapest `model_info.input_cost_per_token`). +""" + +import math +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union + +from litellm._logging import verbose_router_logger +from litellm.integrations.custom_logger import CustomLogger +from litellm.router_strategy.complexity_router.complexity_router import ( + ComplexityRouter, +) + +from .config import QualityRouterConfig, RoutingPreferences + +if TYPE_CHECKING: + from litellm.router import Router + from litellm.types.router import PreRoutingHookResponse +else: + Router = Any + PreRoutingHookResponse = Any + + +class QualityRouter(CustomLogger): + """ + Routes requests to a model at a target quality tier, with an optional + keyword override. + """ + + def __init__( + self, + model_name: str, + litellm_router_instance: "Router", + default_model: Optional[str] = None, + quality_router_config: Optional[Dict[str, Any]] = None, + ): + self.model_name = model_name + self.litellm_router_instance = litellm_router_instance + + if quality_router_config: + self.config = QualityRouterConfig(**quality_router_config) + else: + self.config = QualityRouterConfig() + + # Explicit default_model arg overrides anything in the config dict. + if default_model: + self.config.default_model = default_model + + # Internal scorer — re-use the existing rule-based classifier. + self._scorer = ComplexityRouter( + model_name=f"{model_name}::scorer", + litellm_router_instance=litellm_router_instance, + ) + + # Per-model indices populated alongside the tier index. `_model_keywords` + # stores keywords lowercased so we can substring-match against the + # lowercased user message in O(total-keyword-count). `_model_quality`, + # `_model_cost`, and `_model_order` drive tiebreaking — `_model_order` + # is the explicit priority (lower wins, unset = +inf). + self._model_keywords: Dict[str, List[str]] = {} + self._model_quality: Dict[str, int] = {} + self._model_cost: Dict[str, Optional[float]] = {} + self._model_order: Dict[str, Optional[int]] = {} + + # Tier → models index. Built lazily on first access so the QualityRouter + # deployment does NOT need to appear after all its referenced models in + # the config — when `_build_tier_index` runs eagerly in `__init__`, the + # router instance's `model_list` is still being assembled incrementally + # by `_create_deployment`, and any `available_models` defined AFTER the + # router entry in config.yaml would silently be reported as missing. + self._tier_to_models_cache: Optional[Dict[int, List[str]]] = None + + verbose_router_logger.debug( + f"QualityRouter initialized for {model_name} with " + f"available_models={self.config.available_models}, " + f"default_model={self.config.default_model}" + ) + + @property + def _tier_to_models(self) -> Dict[int, List[str]]: + """Lazy tier→models index; built on first access.""" + if self._tier_to_models_cache is None: + self._tier_to_models_cache = self._build_tier_index() + return self._tier_to_models_cache + + def _get_routing_preferences(self, deployment: Any) -> Optional[Dict[str, Any]]: + """ + Extract litellm_routing_preferences from a deployment, handling both + dict-shaped and Pydantic-object-shaped deployments. + """ + # Dict-shaped deployment. + if isinstance(deployment, dict): + model_info = deployment.get("model_info") or {} + if isinstance(model_info, dict): + return model_info.get("litellm_routing_preferences") + # Pydantic ModelInfo nested in a dict. + return getattr(model_info, "litellm_routing_preferences", None) + + # Pydantic-object deployment. + model_info = getattr(deployment, "model_info", None) + if model_info is None: + return None + if isinstance(model_info, dict): + return model_info.get("litellm_routing_preferences") + return getattr(model_info, "litellm_routing_preferences", None) + + def _get_deployment_input_cost(self, deployment: Any) -> Optional[float]: + """ + Extract `input_cost_per_token` from a deployment's model_info. + + Returns None when not declared — None is treated as "infinite cost" + for the cheapest-tiebreak ordering, so unpriced models lose ties to + priced ones. (Admins who want a model to win on price must declare it.) + """ + if isinstance(deployment, dict): + model_info = deployment.get("model_info") or {} + else: + model_info = getattr(deployment, "model_info", None) or {} + + if isinstance(model_info, dict): + cost = model_info.get("input_cost_per_token") + else: + cost = getattr(model_info, "input_cost_per_token", None) + + if cost is None: + return None + try: + return float(cost) + except (TypeError, ValueError): + return None + + def _get_deployment_model_name(self, deployment: Any) -> Optional[str]: + """Extract `model_name` from a dict- or object-shaped deployment.""" + if isinstance(deployment, dict): + return deployment.get("model_name") + return getattr(deployment, "model_name", None) + + def _build_tier_index(self) -> Dict[int, List[str]]: + """ + Build {quality_tier: [model_name, ...]} for every model in + `available_models`, plus side indices `_model_keywords`, + `_model_quality`, and `_model_cost`. Raises if any listed model is + missing `litellm_routing_preferences`. + """ + model_list = getattr(self.litellm_router_instance, "model_list", None) or [] + available = set(self.config.available_models) + + # Track which available models we've matched so we can error on missing. + seen: Dict[str, bool] = {name: False for name in available} + tier_to_models: Dict[int, List[str]] = {} + + for deployment in model_list: + name = self._get_deployment_model_name(deployment) + if name is None or name not in available: + continue + + raw_prefs = self._get_routing_preferences(deployment) + if raw_prefs is None: + raise ValueError( + f"QualityRouter: model '{name}' is listed in available_models " + f"but has no model_info.litellm_routing_preferences" + ) + + # Validate via the Pydantic model so we get a clear error for + # missing quality_tier, wrong types, etc. This also means + # `RoutingPreferences` is the single source of truth for the + # accepted shape — readers relied on raw dicts before. + try: + if isinstance(raw_prefs, RoutingPreferences): + prefs = raw_prefs + elif isinstance(raw_prefs, dict): + prefs = RoutingPreferences(**raw_prefs) + else: + # A Pydantic object of some other shape — coerce via its dict. + prefs = RoutingPreferences( + **( + raw_prefs.model_dump() + if hasattr(raw_prefs, "model_dump") + else dict(raw_prefs) + ) + ) + except Exception as e: + raise ValueError( + f"QualityRouter: model '{name}' has invalid " + f"litellm_routing_preferences: {e}" + ) from e + + tier_int = int(prefs.quality_tier) + tier_to_models.setdefault(tier_int, []).append(name) + self._model_keywords[name] = [str(k).lower() for k in prefs.keywords if k] + self._model_quality[name] = tier_int + self._model_cost[name] = self._get_deployment_input_cost(deployment) + self._model_order[name] = prefs.order + seen[name] = True + + missing = [name for name, found in seen.items() if not found] + if missing: + raise ValueError( + f"QualityRouter: the following available_models are not present in " + f"the router's model_list (or are missing routing preferences): {missing}" + ) + + # Sort each tier's model list so `_resolve_model_for_quality_tier` + # (which picks index [0]) honors (order ASC, cost ASC, name ASC). + # Quality is moot within a single tier; keep parity with the keyword + # tiebreak by ordering on (order, cost, name) here. + for models in tier_to_models.values(): + models.sort(key=lambda n: (self._order_key(n), self._cost_key(n), n)) + + return tier_to_models + + def _order_key(self, model_name: str) -> float: + """`order` lookup as a float — unset becomes +inf so explicit wins.""" + order = self._model_order.get(model_name) + return float(order) if order is not None else math.inf + + def _cost_key(self, model_name: str) -> float: + """`input_cost_per_token` as a float — unset becomes +inf.""" + cost = self._model_cost.get(model_name) + return float(cost) if cost is not None else math.inf + + def _keyword_override(self, user_message: str) -> Optional[Tuple[str, str]]: + """ + Find a deployment whose declared keywords appear in `user_message`. + + Returns (model_name, matched_keyword) or None when no keyword matches. + When multiple deployments match, sorts by: + 1. quality_tier DESC (best quality always wins first) + 2. `order` ASC (explicit priority — unset = +inf so explicit wins + within the same tier) + 3. input_cost_per_token ASC (unpriced = +inf so priced wins) + 4. model_name ASC (deterministic stability) + """ + # Touch the lazy index so `_model_keywords` / `_model_quality` / + # `_model_cost` / `_model_order` are populated. + _ = self._tier_to_models + + text = user_message.lower() + + matches: List[Tuple[str, str]] = [] # (model_name, matched_keyword) + for model_name, keywords in self._model_keywords.items(): + for kw in keywords: + if kw and kw in text: + matches.append((model_name, kw)) + break # one match per model is enough + + if not matches: + return None + + def sort_key(match: Tuple[str, str]) -> Tuple[int, float, float, str]: + name = match[0] + quality = self._model_quality.get(name, 0) + order_val = self._order_key(name) + cost = self._model_cost.get(name) + cost_val = cost if cost is not None else math.inf + # Negate quality so higher tier sorts first under ASC sort. + return (-quality, order_val, cost_val, name) + + matches.sort(key=sort_key) + return matches[0] + + def _resolve_model_for_quality_tier(self, tier: int) -> str: + """ + Resolve a quality tier to a concrete model name. + + Strategy: + 1. Exact tier match → first model registered at that tier. + 2. Round UP to the next higher tier that has a model (closer to a + request we might lack capacity for). + 3. Round DOWN to the closest lower tier that has a model (degrade + gracefully instead of jumping straight to `default_model`, + which may be off-tier). + 4. Fall back to `config.default_model`. + 5. Otherwise raise. + """ + tier_index = self._tier_to_models + if tier in tier_index and tier_index[tier]: + return tier_index[tier][0] + + # Round up. + higher_tiers = sorted(t for t in tier_index if t > tier) + for t in higher_tiers: + if tier_index[t]: + return tier_index[t][0] + + # Round down — closest lower tier first. + lower_tiers = sorted((t for t in tier_index if t < tier), reverse=True) + for t in lower_tiers: + if tier_index[t]: + return tier_index[t][0] + + if self.config.default_model: + return self.config.default_model + + raise ValueError( + f"QualityRouter: no model available for quality tier {tier} and " + f"no default_model configured" + ) + + def _stash_decision( + self, + request_kwargs: Optional[Dict[str, Any]], + decision: Dict[str, Any], + ) -> None: + """ + Stash the routing decision in request_kwargs.metadata so the Router can + lift it into response headers (`x-litellm-quality-router-*`). The same + dict object flows from here through to `make_call.set_response_headers`. + """ + if request_kwargs is None: + return + metadata = request_kwargs.setdefault("metadata", {}) + if isinstance(metadata, dict): + metadata["quality_router_decision"] = decision + + async def async_pre_routing_hook( + self, + model: str, + request_kwargs: Dict, + messages: Optional[List[Dict[str, Any]]] = None, + input: Optional[Union[str, List]] = None, + specific_deployment: Optional[bool] = False, + ) -> Optional["PreRoutingHookResponse"]: + """Try keyword override first; fall back to complexity-tier routing.""" + from litellm.types.router import PreRoutingHookResponse + + if messages is None or len(messages) == 0: + verbose_router_logger.debug( + "QualityRouter: No messages provided, skipping routing" + ) + return None + + # Extract last user message and last system prompt — same rules as + # ComplexityRouter.async_pre_routing_hook. + user_message: Optional[str] = None + system_prompt: Optional[str] = None + + for msg in reversed(messages): + role = msg.get("role", "") + content = msg.get("content") or "" + if isinstance(content, list): + text_parts = [ + part.get("text", "") + for part in content + if isinstance(part, dict) and part.get("type") == "text" + ] + content = " ".join(text_parts).strip() + if isinstance(content, str) and content: + if role == "user" and user_message is None: + user_message = content + elif role == "system" and system_prompt is None: + system_prompt = content + + if user_message is None: + verbose_router_logger.debug( + "QualityRouter: No user message found, routing to default model" + ) + if not self.config.default_model: + raise ValueError( + "QualityRouter: no user message and no default_model configured" + ) + return PreRoutingHookResponse( + model=self.config.default_model, + messages=messages, + ) + + # Try keyword override first — it short-circuits complexity classification. + keyword_match = self._keyword_override(user_message) + if keyword_match is not None: + routed_model, matched_keyword = keyword_match + verbose_router_logger.info( + f"QualityRouter: keyword override matched='{matched_keyword}' " + f"routed_model={routed_model} " + f"(quality_tier={self._model_quality.get(routed_model)}, " + f"input_cost_per_token={self._model_cost.get(routed_model)})" + ) + self._stash_decision( + request_kwargs, + { + "router_model_name": self.model_name, + "routed_model": routed_model, + "routed_via": "keyword", + "matched_keyword": matched_keyword, + "quality_tier": self._model_quality.get(routed_model), + "complexity_tier": None, + }, + ) + return PreRoutingHookResponse( + model=routed_model, + messages=messages, + ) + + # No keyword match → complexity classification flow. + complexity_tier, score, signals = self._scorer.classify( + user_message, system_prompt + ) + complexity_name = ( + complexity_tier.value + if hasattr(complexity_tier, "value") + else str(complexity_tier) + ) + + quality_tier = self.config.complexity_to_quality.get(complexity_name) + if quality_tier is None: + raise ValueError( + f"QualityRouter: complexity tier '{complexity_name}' not present " + f"in complexity_to_quality mapping {self.config.complexity_to_quality}" + ) + + routed_model = self._resolve_model_for_quality_tier(int(quality_tier)) + + verbose_router_logger.info( + f"QualityRouter: complexity={complexity_name}, score={score:.3f}, " + f"signals={signals}, quality_tier={quality_tier}, " + f"routed_model={routed_model}" + ) + + self._stash_decision( + request_kwargs, + { + "router_model_name": self.model_name, + "routed_model": routed_model, + "routed_via": "quality_tier", + "matched_keyword": None, + "quality_tier": int(quality_tier), + "complexity_tier": complexity_name, + }, + ) + + return PreRoutingHookResponse( + model=routed_model, + messages=messages, + ) diff --git a/litellm/types/compression.py b/litellm/types/compression.py index 01d5a6dd4d6..5dae0c397f0 100644 --- a/litellm/types/compression.py +++ b/litellm/types/compression.py @@ -2,7 +2,14 @@ Type definitions for litellm.compress(). """ -from typing import Dict, List, TypedDict +import sys + +if sys.version_info >= (3, 11): + from typing import Dict, List, NotRequired, TypedDict +else: + from typing import Dict, List, TypedDict + + from typing_extensions import NotRequired class CompressedResult(TypedDict): @@ -12,3 +19,4 @@ class CompressedResult(TypedDict): compression_ratio: float # fraction reduced, e.g. 0.6 means 60% reduction cache: Dict[str, str] # key -> original content (for retrieval tool responses) tools: List[dict] # [litellm_content_retrieve tool definition] + compression_skipped_reason: NotRequired[str] diff --git a/litellm/types/integrations/compression_interception.py b/litellm/types/integrations/compression_interception.py new file mode 100644 index 00000000000..fe52d2ad0d5 --- /dev/null +++ b/litellm/types/integrations/compression_interception.py @@ -0,0 +1,27 @@ +""" +Type definitions for Compression Interception integration. +""" + +from typing import Any, Dict, Optional, TypedDict + + +class CompressionInterceptionConfig(TypedDict, total=False): + """ + Configuration parameters for CompressionInterceptionLogger. + + Used in proxy_config.yaml under litellm_settings: + litellm_settings: + compression_interception_params: + enabled: true + compression_trigger: 100000 + compression_target: 70000 + embedding_model: "text-embedding-3-small" + embedding_model_params: + dimensions: 512 + """ + + enabled: bool + compression_trigger: int + compression_target: Optional[int] + embedding_model: Optional[str] + embedding_model_params: Optional[Dict[str, Any]] diff --git a/litellm/types/integrations/custom_logger.py b/litellm/types/integrations/custom_logger.py index 06989409229..b5726a11ca0 100644 --- a/litellm/types/integrations/custom_logger.py +++ b/litellm/types/integrations/custom_logger.py @@ -1,6 +1,6 @@ -from typing import Optional +from typing import Any, Dict, List, Optional -from pydantic import BaseModel +from pydantic import BaseModel, Field class StandardCustomLoggerInitParams(BaseModel): @@ -9,3 +9,29 @@ class StandardCustomLoggerInitParams(BaseModel): """ turn_off_message_logging: Optional[bool] = False + + +class AgenticLoopRequestPatch(BaseModel): + """ + Patch returned by callbacks to request a follow-up LLM call. + """ + + model: Optional[str] = None + messages: Optional[List[Dict[str, Any]]] = None + tools: Optional[List[Dict[str, Any]]] = None + max_tokens: Optional[int] = None + optional_params: Dict[str, Any] = Field(default_factory=dict) + kwargs: Dict[str, Any] = Field(default_factory=dict) + + +class AgenticLoopPlan(BaseModel): + """ + Typed callback response for agentic-loop reruns. + """ + + run_agentic_loop: bool = False + request_patch: Optional[AgenticLoopRequestPatch] = None + response_override: Optional[Any] = None + terminate: bool = False + stop_reason: Optional[str] = None + metadata: Dict[str, Any] = Field(default_factory=dict) diff --git a/litellm/types/integrations/prometheus.py b/litellm/types/integrations/prometheus.py index 338c5a79ce6..43a287f29bc 100644 --- a/litellm/types/integrations/prometheus.py +++ b/litellm/types/integrations/prometheus.py @@ -784,7 +784,7 @@ class UserAPIKeyLabelValues: org_id: Optional[str] = None org_alias: Optional[str] = None - #Added for test compatibility. + # Added for test compatibility. def __init__(self, **kwargs: Any) -> None: """ Match former Pydantic behavior: unknown keys are ignored; ``api_key_hash`` maps to diff --git a/litellm/types/llms/bedrock.py b/litellm/types/llms/bedrock.py index 6830d95d36f..9ffb52ef88d 100644 --- a/litellm/types/llms/bedrock.py +++ b/litellm/types/llms/bedrock.py @@ -997,3 +997,47 @@ class BedrockToolBlock(TypedDict, total=False): toolSpec: Optional[ToolSpecBlock] systemTool: Optional[SystemToolBlock] # For Nova grounding cachePoint: Optional[CachePointBlock] + + +class BedrockInvokeAnthropicMessagesRequest(TypedDict, total=False): + """ + Top-level request body accepted by AWS Bedrock `InvokeModel` / + `InvokeModelWithResponseStream` when calling an Anthropic Claude model with + the Messages API format. The LiteLLM /v1/messages → Bedrock Invoke + transformation filters outgoing requests to the keys of this TypedDict; any + other field (Anthropic-only extension, internal metadata, future addition) + is dropped before signing so Bedrock doesn't 400 with + "Extra inputs are not permitted". + + Reference: + https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages.html + https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-request-response.html + + Editing this type is the single source of truth — the runtime allowlist in + `AmazonAnthropicClaudeMessagesConfig.BEDROCK_INVOKE_ALLOWED_TOP_LEVEL_FIELDS` + is derived from `__annotations__`, and a test asserts the resolved set + exactly, so any edit forces a conscious review. + + Value types are intentionally loose (`list`, `dict`) — this type exists to + pin the allowed field names, not to validate nested structure. + """ + + # Required by Bedrock + anthropic_version: str + max_tokens: int + messages: list + + # Documented optional fields + anthropic_beta: List[str] + system: object # str or list[TextBlock] + stop_sequences: List[str] + temperature: float + top_p: float + top_k: int + tools: list + tool_choice: dict + + # `thinking` is required for Opus 4.5 / Sonnet 4 extended thinking, + # `metadata` is part of the common Anthropic Messages API shape. + thinking: dict + metadata: dict diff --git a/litellm/types/router.py b/litellm/types/router.py index 6bd64915d79..33102f9ec48 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from typing import Any, Dict, List, Literal, Optional, Tuple, Union, get_type_hints import httpx -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from typing_extensions import Required, TypedDict from litellm._uuid import uuid @@ -201,6 +201,7 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams): budget_duration: Optional[str] = None use_in_pass_through: Optional[bool] = False use_litellm_proxy: Optional[bool] = False + use_chat_completions_api: Optional[bool] = None model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) merge_reasoning_content_in_choices: Optional[bool] = False model_info: Optional[Dict] = None @@ -221,6 +222,13 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams): complexity_router_config: Optional[Dict] = None complexity_router_default_model: Optional[str] = None + # adaptive-router params + adaptive_router_default_model: Optional[str] = None + adaptive_router_config: Optional[Dict] = None + # quality-router params + quality_router_config: Optional[Dict] = None + quality_router_default_model: Optional[str] = None + # Batch/File API Params s3_bucket_name: Optional[str] = None s3_encryption_key_id: Optional[str] = None @@ -320,6 +328,8 @@ class LiteLLMParamsTypedDict(TypedDict, total=False): configurable_clientside_auth_params: CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS # for allowing api base switching on finetuned models ## DROP PARAMS ## drop_params: Optional[bool] + ## RESPONSES API → CHAT COMPLETIONS BRIDGE ## + use_chat_completions_api: Optional[bool] ## UNIFIED PROJECT/REGION ## region_name: Optional[str] ## VERTEX AI ## @@ -790,3 +800,44 @@ class PreRoutingHookResponse(BaseModel): model: str messages: Optional[List[Dict[str, Any]]] + + +class RequestType(str, enum.Enum): + """Fixed v0 taxonomy. User-extensible types come in v1.""" + + CODE_GENERATION = "code_generation" + CODE_UNDERSTANDING = "code_understanding" + TECHNICAL_DESIGN = "technical_design" + ANALYTICAL_REASONING = "analytical_reasoning" + WRITING = "writing" + FACTUAL_LOOKUP = "factual_lookup" + GENERAL = "general" + + +class AdaptiveRouterWeights(BaseModel): + quality: float = Field(default=0.7, ge=0.0, le=1.0) + cost: float = Field(default=0.3, ge=0.0, le=1.0) + + @field_validator("cost") + @classmethod + def _weights_sum_to_one(cls, v, info): + q = info.data.get("quality", 0.7) + if abs(q + v - 1.0) > 0.001: + raise ValueError( + f"weights must sum to 1.0, got quality={q} + cost={v} = {q + v}" + ) + return v + + +class AdaptiveRouterConfig(BaseModel): + available_models: List[str] + weights: AdaptiveRouterWeights = Field(default_factory=AdaptiveRouterWeights) + + +class AdaptiveRouterPreferences(BaseModel): + """model_info.adaptive_router_preferences — declared by each model.""" + + model_config = ConfigDict(use_enum_values=False) + + quality_tier: int = Field(ge=1, le=3) + strengths: List[RequestType] = Field(default_factory=list) diff --git a/litellm/types/utils.py b/litellm/types/utils.py index d0bc9b78941..c347956cba7 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -139,7 +139,9 @@ class ProviderSpecificModelInfo(TypedDict, total=False): supports_reasoning: Optional[bool] supports_url_context: Optional[bool] supports_none_reasoning_effort: Optional[bool] + supports_minimal_reasoning_effort: Optional[bool] supports_xhigh_reasoning_effort: Optional[bool] + supports_max_reasoning_effort: Optional[bool] class SearchContextCostPerQuery(TypedDict, total=False): @@ -2851,6 +2853,7 @@ class StandardAuditLogPayload(TypedDict): class StandardLoggingPayload(TypedDict): id: str trace_id: str # Trace multiple LLM calls belonging to same overall request (e.g. fallbacks/retries) + litellm_call_id: Optional[str] # UUID returned in x-litellm-call-id response header call_type: str stream: Optional[bool] response_cost: float @@ -3290,6 +3293,7 @@ class LlmProviders(str, Enum): MANUS = "manus" WANDB = "wandb" OVHCLOUD = "ovhcloud" + SCALEWAY = "scaleway" LEMONADE = "lemonade" AMAZON_NOVA = "amazon_nova" A2A_AGENT = "a2a_agent" diff --git a/litellm/utils.py b/litellm/utils.py index 2125875ee1d..e1ad1db63ef 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -5893,9 +5893,15 @@ def _get_model_info_helper( # noqa: PLR0915 supports_none_reasoning_effort=_model_info.get( "supports_none_reasoning_effort", None ), + supports_minimal_reasoning_effort=_model_info.get( + "supports_minimal_reasoning_effort", None + ), supports_xhigh_reasoning_effort=_model_info.get( "supports_xhigh_reasoning_effort", None ), + supports_max_reasoning_effort=_model_info.get( + "supports_max_reasoning_effort", None + ), supports_computer_use=_model_info.get("supports_computer_use", None), search_context_cost_per_query=_model_info.get( "search_context_cost_per_query", None @@ -8472,6 +8478,12 @@ class ProviderConfigManager: ) return OVHCloudAudioTranscriptionConfig() + elif litellm.LlmProviders.SCALEWAY == provider: + from litellm.llms.scaleway.audio_transcription.transformation import ( + ScalewayAudioTranscriptionConfig, + ) + + return ScalewayAudioTranscriptionConfig() elif litellm.LlmProviders.MISTRAL == provider: from litellm.llms.mistral.audio_transcription.transformation import ( MistralAudioTranscriptionConfig, @@ -8940,6 +8952,12 @@ class ProviderConfigManager: ) return get_openrouter_image_generation_config(model) + elif LlmProviders.DASHSCOPE == provider: + from litellm.llms.dashscope.image_generation import ( + get_dashscope_image_generation_config, + ) + + return get_dashscope_image_generation_config(model) return None @staticmethod diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 479baec47b1..6c4397a59c6 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -1006,7 +1006,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "global.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.25e-06, @@ -1034,7 +1035,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "us.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.875e-06, @@ -1062,7 +1064,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "eu.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.875e-06, @@ -1090,7 +1093,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "au.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.875e-06, @@ -1118,7 +1122,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.25e-06, @@ -1146,7 +1151,23 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true + }, + "anthropic.claude-mythos-preview": { + "input_cost_per_token": 0, + "output_cost_per_token": 0, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_tool_choice": true }, "global.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.25e-06, @@ -1174,7 +1195,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "us.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.875e-06, @@ -1202,7 +1225,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "eu.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.875e-06, @@ -1230,7 +1255,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "au.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.875e-06, @@ -1258,7 +1285,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 3.75e-06, @@ -1285,7 +1314,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "global.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 3.75e-06, @@ -1312,7 +1342,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "us.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 4.125e-06, @@ -1339,7 +1370,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "eu.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 4.125e-06, @@ -1366,7 +1398,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "au.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 4.125e-06, @@ -1393,7 +1426,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -1911,7 +1945,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "azure_ai/claude-opus-4-7": { "input_cost_per_token": 5e-06, @@ -1939,7 +1974,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 159 + "tool_use_system_prompt_tokens": 159, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "azure_ai/claude-opus-4-1": { "cache_creation_input_token_cost": 1.875e-05, @@ -2003,7 +2040,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_minimal_reasoning_effort": true }, "azure/computer-use-preview": { "input_cost_per_token": 3e-06, @@ -8981,7 +9019,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_minimal_reasoning_effort": true }, "claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -9175,7 +9214,8 @@ "us": 1.1, "fast": 6.0 }, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "claude-opus-4-6-20260205": { "cache_creation_input_token_cost": 6.25e-06, @@ -9207,7 +9247,8 @@ "us": 1.1, "fast": 6.0 }, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "claude-opus-4-7": { "cache_creation_input_token_cost": 6.25e-06, @@ -9239,7 +9280,9 @@ "provider_specific_entry": { "us": 1.1, "fast": 6.0 - } + }, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "claude-opus-4-7-20260416": { "cache_creation_input_token_cost": 6.25e-06, @@ -9271,7 +9314,9 @@ "provider_specific_entry": { "us": 1.1, "fast": 6.0 - } + }, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "claude-sonnet-4-20250514": { "deprecation_date": "2026-05-14", @@ -10424,6 +10469,22 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "dashscope/qwen-image-2.0": { + "litellm_provider": "dashscope", + "mode": "image_generation", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "dashscope/qwen-image-2.0-pro": { + "litellm_provider": "dashscope", + "mode": "image_generation", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, "databricks/databricks-bge-large-en": { "input_cost_per_token": 1.0003e-07, "input_dbu_cost_per_token": 1.429e-06, @@ -15122,6 +15183,21 @@ "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", "uses_embed_content": true }, + "gemini-embedding-2": { + "input_cost_per_audio_per_second": 0.00016, + "input_cost_per_image": 0.00012, + "input_cost_per_token": 2e-07, + "input_cost_per_video_per_second": 0.00079, + "litellm_provider": "vertex_ai-embedding-models", + "max_input_tokens": 8192, + "max_tokens": 8192, + "mode": "embedding", + "output_cost_per_token": 0, + "output_vector_size": 3072, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supports_multimodal": true, + "uses_embed_content": true + }, "vertex_ai/gemini-embedding-2-preview": { "input_cost_per_audio_per_second": 0.00016, "input_cost_per_image": 0.00012, @@ -15137,6 +15213,21 @@ "supports_multimodal": true, "uses_embed_content": true }, + "vertex_ai/gemini-embedding-2": { + "input_cost_per_audio_per_second": 0.00016, + "input_cost_per_image": 0.00012, + "input_cost_per_token": 2e-07, + "input_cost_per_video_per_second": 0.00079, + "litellm_provider": "vertex_ai", + "max_input_tokens": 8192, + "max_tokens": 8192, + "mode": "embedding", + "output_cost_per_token": 0, + "output_vector_size": 3072, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supports_multimodal": true, + "uses_embed_content": true + }, "gemini-flash-experimental": { "input_cost_per_character": 0, "input_cost_per_token": 0, @@ -15178,6 +15269,22 @@ "supports_multimodal": true, "tpm": 10000000 }, + "gemini/gemini-embedding-2": { + "input_cost_per_audio_per_second": 0.00016, + "input_cost_per_image": 0.00012, + "input_cost_per_token": 2e-07, + "input_cost_per_video_per_second": 0.00079, + "litellm_provider": "gemini", + "max_input_tokens": 8192, + "max_tokens": 8192, + "mode": "embedding", + "output_cost_per_token": 0, + "output_vector_size": 3072, + "rpm": 10000, + "source": "https://ai.google.dev/gemini-api/docs/pricing", + "supports_multimodal": true, + "tpm": 10000000 + }, "gemini/gemini-1.5-flash": { "deprecation_date": "2025-09-29", "input_cost_per_token": 7.5e-08, @@ -19298,6 +19405,42 @@ "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, + "gpt-5.5": { + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_service_tier": true, + "supports_vision": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": true + }, "gpt-5.4": { "cache_read_input_token_cost": 2.5e-07, "cache_read_input_token_cost_above_272k_tokens": 5e-07, @@ -23033,6 +23176,22 @@ "supports_video_input": true, "supports_vision": true }, + "moonshot/kimi-k2.6": { + "cache_read_input_token_cost": 1.6e-07, + "input_cost_per_token": 9.5e-07, + "litellm_provider": "moonshot", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 4e-06, + "source": "https://platform.kimi.ai/docs/pricing/chat-k26", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_video_input": true, + "supports_vision": true + }, "moonshot/kimi-latest": { "cache_read_input_token_cost": 1.5e-07, "input_cost_per_token": 2e-06, @@ -25213,7 +25372,8 @@ "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "tool_use_system_prompt_tokens": 159, + "supports_minimal_reasoning_effort": true }, "openrouter/anthropic/claude-opus-4.5": { "cache_creation_input_token_cost": 6.25e-06, @@ -25251,7 +25411,8 @@ "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_minimal_reasoning_effort": true }, "openrouter/anthropic/claude-sonnet-4.5": { "input_cost_per_image": 0.0048, @@ -25296,6 +25457,28 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346 }, + "openrouter/anthropic/claude-opus-4.7": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346 + }, "openrouter/bytedance/ui-tars-1.5-7b": { "input_cost_per_token": 1e-07, "litellm_provider": "openrouter", @@ -30279,7 +30462,8 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_minimal_reasoning_effort": true }, "vercel_ai_gateway/anthropic/claude-sonnet-4": { "cache_creation_input_token_cost": 3.75e-06, @@ -31506,7 +31690,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-opus-4-6@default": { "cache_creation_input_token_cost": 6.25e-06, @@ -31533,7 +31718,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-opus-4-7": { "cache_creation_input_token_cost": 6.25e-06, @@ -31560,7 +31746,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-opus-4-7@default": { "cache_creation_input_token_cost": 6.25e-06, @@ -31587,7 +31775,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-sonnet-4-5": { "cache_creation_input_token_cost": 3.75e-06, @@ -31639,7 +31829,8 @@ "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 - } + }, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-sonnet-4-5@20250929": { "cache_creation_input_token_cost": 3.75e-06, @@ -33463,6 +33654,7 @@ "output_cost_per_token": 1.5e-05, "source": "https://x.ai/api#pricing", "supports_function_calling": true, + "supports_prompt_caching": true, "supports_response_schema": false, "supports_tool_choice": true, "supports_web_search": true @@ -33478,6 +33670,7 @@ "output_cost_per_token": 1.5e-05, "source": "https://x.ai/api#pricing", "supports_function_calling": true, + "supports_prompt_caching": true, "supports_response_schema": false, "supports_tool_choice": true, "supports_web_search": true @@ -33493,6 +33686,7 @@ "output_cost_per_token": 2.5e-05, "source": "https://x.ai/api#pricing", "supports_function_calling": true, + "supports_prompt_caching": true, "supports_response_schema": false, "supports_tool_choice": true, "supports_web_search": true @@ -33508,6 +33702,7 @@ "output_cost_per_token": 2.5e-05, "source": "https://x.ai/api#pricing", "supports_function_calling": true, + "supports_prompt_caching": true, "supports_response_schema": false, "supports_tool_choice": true, "supports_web_search": true @@ -33523,6 +33718,7 @@ "output_cost_per_token": 1.5e-05, "source": "https://x.ai/api#pricing", "supports_function_calling": true, + "supports_prompt_caching": true, "supports_response_schema": false, "supports_tool_choice": true, "supports_web_search": true @@ -33539,6 +33735,7 @@ "output_cost_per_token": 5e-07, "source": "https://x.ai/api#pricing", "supports_function_calling": true, + "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": false, "supports_tool_choice": true, @@ -33556,6 +33753,7 @@ "output_cost_per_token": 5e-07, "source": "https://x.ai/api#pricing", "supports_function_calling": true, + "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": false, "supports_tool_choice": true, @@ -33572,6 +33770,7 @@ "output_cost_per_token": 4e-06, "source": "https://x.ai/api#pricing", "supports_function_calling": true, + "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": false, "supports_tool_choice": true, @@ -33588,6 +33787,7 @@ "output_cost_per_token": 4e-06, "source": "https://x.ai/api#pricing", "supports_function_calling": true, + "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": false, "supports_tool_choice": true, @@ -33604,6 +33804,7 @@ "output_cost_per_token": 4e-06, "source": "https://x.ai/api#pricing", "supports_function_calling": true, + "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": false, "supports_tool_choice": true, @@ -33620,6 +33821,7 @@ "output_cost_per_token": 5e-07, "source": "https://x.ai/api#pricing", "supports_function_calling": true, + "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": false, "supports_tool_choice": true, @@ -33635,38 +33837,41 @@ "output_cost_per_token": 1.5e-05, "source": "https://docs.x.ai/docs/models", "supports_function_calling": true, + "supports_prompt_caching": true, "supports_tool_choice": true, "supports_web_search": true }, "xai/grok-4-fast-reasoning": { + "cache_read_input_token_cost": 5e-08, + "input_cost_per_token": 2e-07, + "input_cost_per_token_above_128k_tokens": 4e-07, "litellm_provider": "xai", "max_input_tokens": 2000000.0, "max_output_tokens": 2000000.0, "max_tokens": 2000000.0, "mode": "chat", - "input_cost_per_token": 2e-07, - "input_cost_per_token_above_128k_tokens": 4e-07, "output_cost_per_token": 5e-07, "output_cost_per_token_above_128k_tokens": 1e-06, - "cache_read_input_token_cost": 5e-08, "source": "https://docs.x.ai/docs/models", "supports_function_calling": true, + "supports_prompt_caching": true, "supports_tool_choice": true, "supports_web_search": true }, "xai/grok-4-fast-non-reasoning": { + "cache_read_input_token_cost": 5e-08, + "input_cost_per_token": 2e-07, + "input_cost_per_token_above_128k_tokens": 4e-07, "litellm_provider": "xai", "max_input_tokens": 2000000.0, "max_output_tokens": 2000000.0, - "cache_read_input_token_cost": 5e-08, "max_tokens": 2000000.0, "mode": "chat", - "input_cost_per_token": 2e-07, - "input_cost_per_token_above_128k_tokens": 4e-07, "output_cost_per_token": 5e-07, "output_cost_per_token_above_128k_tokens": 1e-06, "source": "https://docs.x.ai/docs/models", "supports_function_calling": true, + "supports_prompt_caching": true, "supports_tool_choice": true, "supports_web_search": true }, @@ -33682,6 +33887,7 @@ "output_cost_per_token_above_128k_tokens": 3e-05, "source": "https://docs.x.ai/docs/models", "supports_function_calling": true, + "supports_prompt_caching": true, "supports_tool_choice": true, "supports_web_search": true }, @@ -33697,6 +33903,7 @@ "output_cost_per_token_above_128k_tokens": 3e-05, "source": "https://docs.x.ai/docs/models", "supports_function_calling": true, + "supports_prompt_caching": true, "supports_tool_choice": true, "supports_web_search": true }, @@ -33714,6 +33921,7 @@ "source": "https://docs.x.ai/docs/models/grok-4-1-fast-reasoning", "supports_audio_input": true, "supports_function_calling": true, + "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, @@ -33734,6 +33942,7 @@ "source": "https://docs.x.ai/docs/models/grok-4-1-fast-reasoning", "supports_audio_input": true, "supports_function_calling": true, + "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, @@ -33754,6 +33963,7 @@ "source": "https://docs.x.ai/docs/models/grok-4-1-fast-reasoning", "supports_audio_input": true, "supports_function_calling": true, + "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, @@ -33774,6 +33984,7 @@ "source": "https://docs.x.ai/docs/models/grok-4-1-fast-non-reasoning", "supports_audio_input": true, "supports_function_calling": true, + "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, @@ -33793,6 +34004,7 @@ "source": "https://docs.x.ai/docs/models/grok-4-1-fast-non-reasoning", "supports_audio_input": true, "supports_function_calling": true, + "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, @@ -33809,6 +34021,7 @@ "output_cost_per_token": 6e-06, "source": "https://docs.x.ai/docs/models", "supports_function_calling": true, + "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, @@ -33825,6 +34038,7 @@ "output_cost_per_token": 6e-06, "source": "https://docs.x.ai/docs/models", "supports_function_calling": true, + "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, @@ -33857,6 +34071,7 @@ "output_cost_per_token": 6e-06, "source": "https://docs.x.ai/docs/models", "supports_function_calling": true, + "supports_prompt_caching": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true @@ -33885,6 +34100,7 @@ "output_cost_per_token": 1.5e-06, "source": "https://docs.x.ai/docs/models", "supports_function_calling": true, + "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true }, @@ -33899,6 +34115,7 @@ "output_cost_per_token": 1.5e-06, "source": "https://docs.x.ai/docs/models", "supports_function_calling": true, + "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true }, @@ -33913,6 +34130,7 @@ "output_cost_per_token": 1.5e-06, "source": "https://docs.x.ai/docs/models", "supports_function_calling": true, + "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true }, @@ -38506,7 +38724,8 @@ "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 - } + }, + "supports_minimal_reasoning_effort": true }, "duckduckgo/search": { "litellm_provider": "duckduckgo", diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 2f3302bb574..6f23c87f911 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -1968,7 +1968,7 @@ "responses": true, "embeddings": false, "image_generations": false, - "audio_transcriptions": false, + "audio_transcriptions": true, "audio_speech": false, "moderations": false, "batches": false, diff --git a/pyproject.toml b/pyproject.toml index d5d238473b1..a47d5194a91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm" -version = "1.83.10" +version = "1.83.13" description = "Library to easily interface with LLM API providers" readme = "README.md" requires-python = ">=3.10, <3.14" @@ -52,7 +52,7 @@ proxy = [ "azure-identity==1.25.2", "azure-storage-blob==12.28.0", "mcp==1.26.0", - "litellm-proxy-extras==0.4.67", + "litellm-proxy-extras==0.4.68", "litellm-enterprise==0.1.38", "RestrictedPython==8.1", "rich==13.9.4", @@ -208,7 +208,7 @@ build-backend = "uv_build" [tool.uv] default-groups = ["dev"] -required-version = "==0.10.9" +required-version = ">=0.10.9" exclude-newer = "3 days" [tool.uv.sources] @@ -236,7 +236,7 @@ source-exclude = [ profile = "black" [tool.commitizen] -version = "1.83.10" +version = "1.83.13" version_files = [ "pyproject.toml:^version", ] diff --git a/schema.prisma b/schema.prisma index 08aa5645251..34686148ce0 100644 --- a/schema.prisma +++ b/schema.prisma @@ -616,6 +616,7 @@ model LiteLLM_TeamMembership { user_id String team_id String spend Float @default(0.0) + total_spend Float @default(0.0) budget_id String? litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id]) @@id([user_id, team_id]) @@ -1223,3 +1224,46 @@ model LiteLLM_ClaudeCodePluginTable { @@map("LiteLLM_ClaudeCodePluginTable") } + +// Per-(router, request_type, model) Beta posterior for the adaptive router. +model LiteLLM_AdaptiveRouterState { + router_name String + request_type String + model_name String + alpha Float + beta Float + total_samples Int @default(0) + last_updated_at DateTime @default(now()) @updatedAt + + @@id([router_name, request_type, model_name]) +} + +// Per-(session, router, model) signal counters for the adaptive router. +model LiteLLM_AdaptiveRouterSession { + session_id String + router_name String + model_name String + classified_type String + + misalignment_count Int @default(0) + stagnation_count Int @default(0) + disengagement_count Int @default(0) + satisfaction_count Int @default(0) + failure_count Int @default(0) + loop_count Int @default(0) + exhaustion_count Int @default(0) + + last_user_content String? + last_assistant_content String? + tool_call_history Json @default("[]") + pending_tool_calls Json @default("{}") + + turn_count Int @default(0) + last_processed_turn Int @default(-1) + clean_credit_awarded Boolean @default(false) + terminal_status Int? + last_activity_at DateTime @default(now()) @updatedAt + + @@id([session_id, router_name, model_name]) + @@index([last_activity_at], map: "idx_adaptive_router_session_activity") +} diff --git a/scripts/adaptive_router_demo/README.md b/scripts/adaptive_router_demo/README.md new file mode 100644 index 00000000000..1965dbbf168 --- /dev/null +++ b/scripts/adaptive_router_demo/README.md @@ -0,0 +1,157 @@ +# Adaptive Router — Live Demo + +A 5-minute demo of LiteLLM's adaptive router learning, in real time, that +the smart model wins for code while the fast model is fine for facts. + +``` +┌─ traffic.py ──┐ ┌─ litellm proxy ──────────┐ ┌─ dashboard.html ─┐ +│ synthetic │──▶│ adaptive_router strategy │──▶│ bandit bars + │ +│ chat sessions │ │ /adaptive_router/state │ │ cost meter + │ +└───────────────┘ └──────────┬───────────────┘ │ activity log │ + │ └───────────────────┘ + ┌─────────▼───────────┐ + │ chat.html │ + │ interactive chat │ + │ with preset │ + │ scenarios │ + └─────────────────────┘ +``` + +## Files + +| File | What it does | +|---|---| +| `dashboard.html` | Live bandit dashboard — polls `/adaptive_router/state` every 500ms | +| `chat.html` | Interactive chat with preset scenarios — sends real requests through the router | +| `traffic.py` | Synthetic traffic generator — drives labeled sessions for automated demo | + +## What you're watching + +- **Bandit posteriors** — one Beta(α, β) bar per `(request_type, model)` + cell. Bars fill up as α grows from positive feedback signals. +- **Pick share** — softmax estimate of how often the router would currently + pick each model for that request type. +- **Cost meter** — total spend so far compared to "always use the most + expensive model". The savings line is the headline number. +- **Activity log** — every signal that moves the bandit, in real time. + +## 1. Start the proxy + +The repo ships with a working example config: + +```bash +export OPENAI_API_KEY=sk-... # underlying models hit OpenAI +uv run litellm \ + --config litellm/proxy/example_config_yaml/adaptive_router_example.yaml \ + --port 4000 +``` + +`DATABASE_URL` is optional — the proxy falls back to a bundled Neon dev DB. +Wait ~15s until you see `Application startup complete`. + +## 2. Chat interactively with the router + +Open `chat.html` in a browser (same `file://` or `python3 -m http.server` approach as the dashboard): + +- Click **Connect** after filling in the proxy URL and API key. +- Pick a preset scenario: + - **🐛 Debug my code** — paste broken code and get a fix + - **💡 Brainstorm a feature** — ideate on a product capability + - **📚 Explain a concept** — get a clear technical explanation + - **✍️ Write something** — draft emails, docs, or any prose +- A starter message is pre-filled — edit it or send as-is. +- Each response shows which model the router picked and the inferred request type (from the `x-litellm-adaptive-router-model` and `x-litellm-request-type` response headers). +- A sidebar gate indicator tells you when the session has accumulated enough messages for the bandit to start updating (4+ turns). + +> **Note on headers:** The model/type headers are only readable in the browser if the proxy sets `Access-Control-Expose-Headers`. LiteLLM defaults to exposing them. If the info panel shows `check dashboard`, the router still works — you can verify picks in `dashboard.html`. + +## 4. Open the dashboard + +The dashboard is a single static HTML file. Either: + +- **Easy:** double-click `dashboard.html`. Most browsers will load it from + `file://` and the LiteLLM proxy's CORS defaults (`*`) will accept it. +- **If your browser blocks `file://` fetches:** + + ```bash + cd scripts/adaptive_router_demo + python3 -m http.server 8080 + ``` + + Then open . + +In the connect bar, fill in: + +- **Proxy URL:** `http://localhost:4000` +- **Master Key:** the `master_key` from your config (`sk-1234` in the example). + +Click **Connect**. The dashboard polls `GET /adaptive_router/state` every +500ms (admin-only endpoint, returns one snapshot per configured router). + +## 5. Drive synthetic traffic + +In a second terminal: + +```bash +uv run python scripts/adaptive_router_demo/traffic.py \ + --proxy-url http://localhost:4000 \ + --api-key sk-1234 \ + --router smart-cheap-router \ + --rounds 100 \ + --rate 0.5 +``` + +What it does: + +- Picks a random `(request_type, prompt)` per round from a small labeled corpus. +- Sends a 5-message conversation (passes the `SIGNAL_GATE_MIN_MESSAGES=4` gate + in one round-trip) so the post-call hook runs and updates the bandit. +- Reads the `x-litellm-adaptive-router-model` response header to see what + the router picked. +- Rolls Bernoulli against a hard-coded oracle: + ``` + code_generation : smart=0.92 fast=0.35 + factual_lookup : smart=0.90 fast=0.85 + writing : smart=0.85 fast=0.55 + ``` +- On success → sends a follow-up engineered to match the satisfaction + regex (and re-classify into the same type). Bandit cell gets +α. +- On failure → sends a neutral follow-up. No signal fires. + +After 50–80 rounds you'll see `code_generation` decisively favor `smart` +while `factual_lookup` stays near a coin flip — the router learned the +asymmetry from the oracle. + +## Tuning knobs + +| Knob | Where | What changes | +|---|---|---| +| Quality vs. cost weight | `adaptive_router_config.weights` in proxy yaml | Bias toward quality or savings | +| Per-cell cold-start mass | `litellm/router_strategy/adaptive_router/config.py` `COLD_START_MASS` | How long until the prior is overwritten | +| Avg tokens per request | dashboard input box | How the cost meter estimates spend | +| Oracle | `traffic.py` `ORACLE` dict | Which model "should" win for which type | +| Sessions to drive | `--rounds` | Total learning budget | +| Throttle | `--rate` | Seconds between sessions | + +## Multi-router + +If your proxy has more than one `auto_router/adaptive_router` deployment, +the dashboard shows a router dropdown above the bars. Each router is +independent; the cost meter is per-router (and resets when you switch). + +## Troubleshooting + +- **"Disconnected" / HTTP 401 in the dashboard** — wrong master key. +- **HTTP 403** — your key isn't `proxy_admin`. The state endpoint is + admin-only. Use the master key. +- **HTTP 404 from `/adaptive_router/state`** — proxy started, but no + `auto_router/adaptive_router` deployment is in the model list. +- **Bars don't move** — check the proxy logs for `record_turn` activity. + Common cause: requests are not including 4+ messages, so the signal + gate skips them. `traffic.py` already builds 5-message conversations, + so this only happens if you've changed the script. +- **Cost meter stays at $0** — your model deployments don't have + `input_cost_per_token` set in `litellm_params`. Add it. +- **CORS error in the dashboard console** — set `LITELLM_CORS_ORIGINS=*` + on the proxy (the default), or serve `dashboard.html` from + `python3 -m http.server` instead of `file://`. diff --git a/scripts/adaptive_router_demo/chat.html b/scripts/adaptive_router_demo/chat.html new file mode 100644 index 00000000000..9e7237847c0 --- /dev/null +++ b/scripts/adaptive_router_demo/chat.html @@ -0,0 +1,838 @@ + + + + + Adaptive Router — Chat + + + + +
+

⚡ Adaptive Router — Chat

+ Disconnected + → Open live dashboard +
+ +
+ + + + +
+ +
+ + + + + +
+ +
+
+
+
+

Pick a scenario to start

+

Choose one of the presets above or connect to the proxy and type your own message. The adaptive router will pick the best model for each turn.

+
+
+
+
+ + +
+
Connect first to start chatting.
+
+
+ + +
+ + + + + diff --git a/scripts/adaptive_router_demo/dashboard.html b/scripts/adaptive_router_demo/dashboard.html new file mode 100644 index 00000000000..6652aa19805 --- /dev/null +++ b/scripts/adaptive_router_demo/dashboard.html @@ -0,0 +1,635 @@ + + + + + Adaptive Router — Live + + + + +
+

⚡ Adaptive Router — Live

+ Disconnected + +
+ +
+ + + + + + +
+ +
+
+

How well each model performs, by request type

+
+ Each bar shows the fraction of recent feedback that was positive + for that model on that kind of request. Wider = better. The number + next to it ("N signals") is how much real feedback the bar is + based on — more signals means the router is more confident. + It picks higher-quality bars first, with cost as a tiebreaker. +
+
Connect to see live bandit state.
+
+ + +
+ + + + + diff --git a/scripts/adaptive_router_demo/eval.py b/scripts/adaptive_router_demo/eval.py new file mode 100644 index 00000000000..b02e4a37d31 --- /dev/null +++ b/scripts/adaptive_router_demo/eval.py @@ -0,0 +1,271 @@ +# ruff: noqa: T201 +""" +Adaptive router evaluator — LLM-as-judge harness. + +For each test case: + 1. Sends the prompt to the adaptive router. + 2. Reads which model was picked (x-litellm-adaptive-router-model header). + 3. Asks the judge model whether the response meets the ideal criteria. + 4. Prints PASS or FAIL with one line of reasoning. + +Run: + uv run python scripts/adaptive_router_demo/eval.py \ + --proxy-url http://localhost:4000 \ + --api-key sk-1234 \ + --router smart-cheap-router \ + --judge-model smart +""" + +from __future__ import annotations + +import argparse +import asyncio +import sys +import uuid +from dataclasses import dataclass +from typing import Dict, List, Optional, Tuple + +import httpx + + +# --------------------------------------------------------------------------- +# Test cases +# --------------------------------------------------------------------------- +@dataclass +class EvalCase: + category: str + prompt: str + ideal: str # criteria the judge checks the response against + + +EVAL_CASES: List[EvalCase] = [ + # code_generation + EvalCase( + category="code_generation", + prompt="Write a Python function that flattens a nested list of arbitrary depth.", + ideal=( + "A Python function (def flatten(...)) that accepts a list which may " + "contain nested lists to arbitrary depth and returns a single flat list " + "with all elements in order. Must handle at least two levels of nesting." + ), + ), + EvalCase( + category="code_generation", + prompt="Write a Python decorator that retries a function up to 3 times on exception.", + ideal=( + "A Python decorator that wraps a callable, catches exceptions, and " + "retries the call up to 3 times before re-raising. Should use functools.wraps " + "or equivalent to preserve the wrapped function's metadata." + ), + ), + EvalCase( + category="code_generation", + prompt="Write a SQL query that returns the top 5 customers by total order value.", + ideal=( + "A valid SQL SELECT query that JOINs an orders or order_items table with a " + "customers table, groups by customer, sums order value, orders descending, " + "and limits to 5 rows." + ), + ), + # factual_lookup + EvalCase( + category="factual_lookup", + prompt="What is the capital of New Zealand?", + ideal="The answer must state Wellington as the capital of New Zealand.", + ), + EvalCase( + category="factual_lookup", + prompt="In what year did World War II end?", + ideal="The answer must state 1945 as the year World War II ended.", + ), + EvalCase( + category="factual_lookup", + prompt="What is the chemical symbol for gold?", + ideal="The answer must include 'Au' as the chemical symbol for gold.", + ), + # writing + EvalCase( + category="writing", + prompt=( + "Write a short, polite email declining a meeting request because of " + "a scheduling conflict." + ), + ideal=( + "A professional email that: (1) thanks the sender for the invitation, " + "(2) clearly declines, (3) mentions a scheduling conflict as the reason, " + "and (4) offers to reschedule or an alternative. Tone must be polite." + ), + ), + EvalCase( + category="writing", + prompt="Write a one-paragraph product description for noise-cancelling headphones.", + ideal=( + "A marketing paragraph for noise-cancelling headphones that mentions " + "noise cancellation as a feature, highlights at least one other benefit " + "(comfort, audio quality, battery life, or similar), and ends with a " + "persuasive call to action or closing statement." + ), + ), +] + +# Matches the satisfaction regex in signals.py (_SATISFACTION_PATTERNS). +SATISFY_FOLLOWUP = "great, thanks!" +NEUTRAL_FOLLOWUP = "ok, noted" +FAB_ASSISTANT = "Got it. Working on that now." + +JUDGE_SYSTEM = ( + "You are a strict but fair evaluator. Your job is to decide whether a model " + "response meets the stated requirements. Reply with exactly two lines:\n" + "Line 1: PASS or FAIL\n" + "Line 2: One sentence of reasoning (≤ 25 words)." +) + + +def _judge_user(prompt: str, ideal: str, actual: str) -> str: + return ( + f"Question sent to model:\n{prompt}\n\n" + f"Requirements the response must meet:\n{ideal}\n\n" + f"Actual model response:\n{actual}\n\n" + "Does the response meet the requirements? Reply PASS or FAIL." + ) + + +# --------------------------------------------------------------------------- +# HTTP helpers +# --------------------------------------------------------------------------- +async def _chat( + client: httpx.AsyncClient, + proxy_url: str, + api_key: str, + model: str, + messages: List[Dict[str, str]], + session_id: Optional[str] = None, +) -> Tuple[str, str]: + """ + Returns (response_text, chosen_model_header). + chosen_model_header is empty for non-router calls. + """ + body: Dict = {"model": model, "messages": messages} + if session_id: + body["metadata"] = {"litellm_session_id": session_id} + + resp = await client.post( + f"{proxy_url}/v1/chat/completions", + json=body, + headers={"Authorization": f"Bearer {api_key}"}, + timeout=60.0, + ) + resp.raise_for_status() + data = resp.json() + text = data["choices"][0]["message"]["content"] + chosen = resp.headers.get("x-litellm-adaptive-router-model", "") + return text, chosen + + +# --------------------------------------------------------------------------- +# Evaluation loop +# --------------------------------------------------------------------------- +async def evaluate( + proxy_url: str, + api_key: str, + router: str, + judge_model: str, +) -> None: + passed = 0 + failed = 0 + + async with httpx.AsyncClient() as client: + for i, case in enumerate(EVAL_CASES, 1): + print(f"\n[{i}/{len(EVAL_CASES)}] category={case.category}") + print(f" prompt : {case.prompt[:80]}{'…' if len(case.prompt) > 80 else ''}") + + session_id = f"eval-{uuid.uuid4()}" + + # Round 1: single-turn real request — get the actual LLM response to judge. + try: + response, chosen = await _chat( + client, proxy_url, api_key, router, + [{"role": "user", "content": case.prompt}], + session_id=session_id, + ) + except Exception as exc: # noqa: BLE001 + print(f" ERROR calling router: {exc}", file=sys.stderr) + failed += 1 + continue + + print(f" model : {chosen or router}") + print(f" response : {response[:120].replace(chr(10), ' ')}{'…' if len(response) > 120 else ''}") + + # Judge the real response. + judge_msgs = [ + {"role": "system", "content": JUDGE_SYSTEM}, + {"role": "user", "content": _judge_user(case.prompt, case.ideal, response)}, + ] + try: + verdict, _ = await _chat( + client, proxy_url, api_key, judge_model, judge_msgs, + ) + except Exception as exc: # noqa: BLE001 + print(f" ERROR calling judge: {exc}", file=sys.stderr) + failed += 1 + continue + + # Parse verdict — first non-empty line should be PASS or FAIL. + lines = [ln.strip() for ln in verdict.splitlines() if ln.strip()] + first = lines[0].upper() if lines else "" + reason = lines[1] if len(lines) > 1 else "" + is_pass = "PASS" in first + + if is_pass: + passed += 1 + print(f" verdict : \033[32mPASS\033[0m {reason}") + else: + failed += 1 + print(f" verdict : \033[31mFAIL\033[0m {reason}") + + # Round 2: 5-message conversation on the same session_id so the bandit fires. + # On PASS → satisfaction follow-up (+alpha). On FAIL → neutral (no signal). + follow_up = SATISFY_FOLLOWUP if is_pass else NEUTRAL_FOLLOWUP + bandit_msgs = [ + {"role": "user", "content": case.prompt}, + {"role": "assistant", "content": response}, + {"role": "user", "content": "ok continue"}, + {"role": "assistant", "content": FAB_ASSISTANT}, + {"role": "user", "content": follow_up}, + ] + try: + await _chat( + client, proxy_url, api_key, router, bandit_msgs, + session_id=session_id, + ) + except Exception as exc: # noqa: BLE001 + print(f" WARNING: bandit update failed: {exc}", file=sys.stderr) + + total = passed + failed + print(f"\n{'='*60}") + print(f"Results: {passed}/{total} passed ({failed} failed)") + if passed == total: + print("All test cases passed — the adaptive router is working well!") + elif passed >= total * 0.8: + print("Most test cases passed — minor issues to investigate.") + else: + print("Significant failures — check router config and model availability.") + print("=" * 60) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- +def main() -> None: + ap = argparse.ArgumentParser(description="Evaluate the adaptive router with LLM-as-judge.") + ap.add_argument("--proxy-url", default="http://localhost:4000") + ap.add_argument("--api-key", required=True, help="proxy API key") + ap.add_argument("--router", default="smart-cheap-router", help="adaptive router model name") + ap.add_argument("--judge-model", default="smart", help="model name for the judge (via proxy)") + args = ap.parse_args() + + asyncio.run(evaluate(args.proxy_url, args.api_key, args.router, args.judge_model)) + + +if __name__ == "__main__": + main() diff --git a/scripts/adaptive_router_demo/traffic.py b/scripts/adaptive_router_demo/traffic.py new file mode 100644 index 00000000000..eae5506eaee --- /dev/null +++ b/scripts/adaptive_router_demo/traffic.py @@ -0,0 +1,227 @@ +""" +Synthetic traffic generator for the adaptive_router demo dashboard. + +What it does: + - Sends labeled multi-turn chat requests to the proxy's adaptive router. + - For each turn, peeks at the `x-litellm-adaptive-router-model` response + header to learn which underlying model was picked. + - Draws a Bernoulli outcome from a hard-coded ORACLE table that says + "model M succeeds at request type T with probability p". + - Sends a final follow-up turn whose user message is engineered to + BOTH classify into the same RequestType AND match the + satisfaction regex on success (so the bandit's `(type, model)` cell + gets +alpha). On failure we send a neutral follow-up so no signal + fires — over time, models the oracle favors accumulate alpha faster. + +Why this shape: + - The post-call hook gates signal recording on len(messages) >= 4. + A single 5-message request passes the gate in one round-trip, which + keeps the demo cheap. + - Mock responses (`mock_response=...`) skip the real LLM call but still + flow through routing + post-call hooks, so no API keys / no spend. + +Run: + uv run python scripts/adaptive_router_demo/traffic.py \\ + --proxy-url http://localhost:4000 \\ + --api-key sk-1234 \\ + --router smart-cheap-router \\ + --rounds 100 \\ + --rate 0.5 + +Open `dashboard.html` in a browser alongside this and watch the bars move. +""" + +from __future__ import annotations + +import argparse +import asyncio +import random +import sys +import uuid +from typing import Dict, List, Tuple + +import httpx + +# ---- prompts (paired with the RequestType the classifier will assign) ---- +# Each prompt is engineered to (a) classify into the listed type and (b) make +# sense as a user request. Keep prompts short to limit token cost. +PROMPTS: Dict[str, List[str]] = { + "code_generation": [ + "Write a Python function that flattens a nested list", + "Create a TypeScript function that debounces another function", + "Build a Rust function that parses a CSV string", + "Generate a SQL function that returns running totals", + ], + "factual_lookup": [ + "What is the capital of New Zealand?", + "When was the Treaty of Westphalia signed?", + "Who is the current Secretary General of the UN?", + "Where is Mount Kilimanjaro located?", + ], + "writing": [ + "Write an email declining a meeting politely", + "Draft a paragraph introducing a product launch", + "Compose a short blog post about morning routines", + "Rewrite this sentence to be more concise: ...", + ], +} + +# Engineered satisfaction follow-ups — each one is designed to: +# (1) match the satisfaction regex (thanks/great/works/perfect/etc.), AND +# (2) re-classify into the SAME RequestType as the first prompt +# so that signals attribute to the right (type, model) bandit cell. +SATISFY: Dict[str, str] = { + "code_generation": "thanks, that works! now write me a python function that does the inverse", + "factual_lookup": "perfect, thanks! who is the current prime minister?", + "writing": "great, thanks! now write a follow-up email confirming attendance", +} + +# Neutral follow-up — does not match any signal regex, does not move the bandit. +NEUTRAL_FOLLOWUP = "ok, noted" + +# Oracle: P(success | request_type, model). Tunable. +# Defaults: smart dominates code/writing; both are fine for factual_lookup. +ORACLE: Dict[str, Dict[str, float]] = { + "code_generation": {"smart": 0.92, "fast": 0.35}, + "factual_lookup": {"smart": 0.90, "fast": 0.85}, + "writing": {"smart": 0.85, "fast": 0.55}, +} + +# Fabricated assistant turn — content doesn't matter for the hook, only the role. +FAB_ASSISTANT = "Got it. Working on that now." + + +def _build_messages(prompt: str, last_user: str) -> List[Dict[str, str]]: + """5-message conversation that passes the SIGNAL_GATE_MIN_MESSAGES=4 gate.""" + return [ + {"role": "user", "content": prompt}, + {"role": "assistant", "content": FAB_ASSISTANT}, + {"role": "user", "content": "ok continue"}, + {"role": "assistant", "content": FAB_ASSISTANT}, + {"role": "user", "content": last_user}, + ] + + +async def _send( + client: httpx.AsyncClient, + proxy_url: str, + api_key: str, + router: str, + session_id: str, + messages: List[Dict[str, str]], + mock_response: str, +) -> Tuple[bool, str]: + """Returns (ok, chosen_model).""" + body = { + "model": router, + "messages": messages, + "metadata": {"litellm_session_id": session_id}, + "mock_response": mock_response, + } + try: + r = await client.post( + f"{proxy_url}/v1/chat/completions", + json=body, + headers={"Authorization": f"Bearer {api_key}"}, + timeout=15.0, + ) + r.raise_for_status() + except Exception as e: # noqa: BLE001 + print(f" request failed: {e}", file=sys.stderr) + return False, "" + chosen = r.headers.get("x-litellm-adaptive-router-model", "") + return True, chosen + + +async def _drive_one_session( + client: httpx.AsyncClient, + proxy_url: str, + api_key: str, + router: str, + request_type: str, + prompt: str, +) -> str: + """Run one labeled session. Returns the chosen model (for logging).""" + session_id = f"demo-{uuid.uuid4()}" + + # Send the engineered 5-message conversation. The follow-up is chosen + # AFTER we observe what model the router would pick — but since the + # router is sticky-per-session, the model on this single round-trip + # IS the model we're crediting. + # + # Pre-decide success based on the oracle for whichever model gets picked. + # We can't know the pick before sending, so: send a neutral follow-up + # first to learn the pick, then send a second round with credit attached. + # + # Round 1: neutral follow-up → no signal fires, but we learn the pick. + ok, chosen = await _send( + client, proxy_url, api_key, router, session_id, + _build_messages(prompt, NEUTRAL_FOLLOWUP), + mock_response=FAB_ASSISTANT, + ) + if not ok or not chosen: + return "" + + # Decide outcome from oracle. + p = ORACLE.get(request_type, {}).get(chosen, 0.5) + success = random.random() < p + follow_up = SATISFY[request_type] if success else NEUTRAL_FOLLOWUP + + # Round 2: include the round-1 turns + a new follow-up. On success the + # follow-up matches satisfaction → +alpha for (request_type, chosen). + history = _build_messages(prompt, NEUTRAL_FOLLOWUP) + [ + {"role": "assistant", "content": FAB_ASSISTANT}, + {"role": "user", "content": follow_up}, + ] + await _send( + client, proxy_url, api_key, router, session_id, history, + mock_response=FAB_ASSISTANT, + ) + return chosen + + +async def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--proxy-url", default="http://localhost:4000") + ap.add_argument("--api-key", required=True, help="proxy key with /v1/chat/completions perms") + ap.add_argument("--router", default="smart-cheap-router") + ap.add_argument("--rounds", type=int, default=100) + ap.add_argument("--rate", type=float, default=0.5, + help="seconds between sessions; lower = faster") + ap.add_argument("--types", default="code_generation,factual_lookup,writing", + help="comma-separated subset of request types to drive") + args = ap.parse_args() + + types = [t.strip() for t in args.types.split(",") if t.strip() in PROMPTS] + if not types: + print(f"ERROR: no valid types. Choose from: {list(PROMPTS)}", file=sys.stderr) + sys.exit(2) + + print(f"driving {args.rounds} sessions across types: {types}") + print(f"oracle: {ORACLE}") + print(f"proxy: {args.proxy_url} router: {args.router}\n") + + counts: Dict[Tuple[str, str], int] = {} + async with httpx.AsyncClient() as client: + for i in range(args.rounds): + rt = random.choice(types) + prompt = random.choice(PROMPTS[rt]) + chosen = await _drive_one_session( + client, args.proxy_url, args.api_key, args.router, rt, prompt, + ) + if chosen: + counts[(rt, chosen)] = counts.get((rt, chosen), 0) + 1 + if (i + 1) % 10 == 0: + summary = ", ".join( + f"{rt}/{m}={n}" for (rt, m), n in sorted(counts.items()) + ) + print(f" round {i + 1}/{args.rounds} picks: {summary}") + await asyncio.sleep(args.rate) + + print("\nfinal pick distribution:") + for (rt, m), n in sorted(counts.items()): + print(f" {rt:22s} → {m:8s} {n}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/eval_compression.py b/scripts/eval_compression.py index d7d90dacc2e..a169cc02d74 100644 --- a/scripts/eval_compression.py +++ b/scripts/eval_compression.py @@ -33,6 +33,7 @@ from dataclasses import asdict, dataclass, field from typing import Optional import litellm +from litellm.types.utils import CallTypes # --------------------------------------------------------------------------- # Problem definitions (HumanEval-style) @@ -880,6 +881,7 @@ def eval_problem( result = litellm.compress( messages=messages, model=model, + call_type=CallTypes.completion, compression_trigger=compression_trigger, embedding_model=embedding_model, ) diff --git a/scripts/verify_adaptive_router.py b/scripts/verify_adaptive_router.py new file mode 100644 index 00000000000..fde9dc51a15 --- /dev/null +++ b/scripts/verify_adaptive_router.py @@ -0,0 +1,216 @@ +""" +End-to-end verification script for the adaptive router. + +Requires: + - LiteLLM proxy running on http://localhost:4000 with adaptive_router configured + (see litellm/proxy/example_config_yaml/adaptive_router_example.yaml). + - Postgres reachable via DATABASE_URL (same one the proxy uses). + - LITELLM_PROXY_KEY env var set (a valid key with permission to send requests). + - Two model deployments configured under one adaptive_router: + * "fast" (cheap, lower quality) + * "smart" (expensive, higher quality) + +Run: + uv run python scripts/verify_adaptive_router.py + +Optional env: + LITELLM_PROXY_URL (default: http://localhost:4000) + ADAPTIVE_ROUTER_NAME (default: smart-cheap-router) + EXPECTED_WINNER (default: smart) -- model expected to dominate after training + TRAIN_SESSIONS (default: 20) -- training sessions in phase 1 + CONVERGE_SESSIONS (default: 10) -- cold sessions in phase 2 + WIN_THRESHOLD (default: 0.7) -- min share for EXPECTED_WINNER in phase 2 +""" + +from __future__ import annotations + +import asyncio +import os +import sys +import time +import uuid +from typing import List, Optional + +import httpx + +PROXY_URL: str = os.environ.get("LITELLM_PROXY_URL", "http://localhost:4000") +try: + PROXY_KEY: str = os.environ["LITELLM_PROXY_KEY"] +except KeyError: + print( + "ERROR: LITELLM_PROXY_KEY env var must be set (a proxy key with /chat/completions perms).", + file=sys.stderr, + ) + sys.exit(2) + +ROUTER_NAME: str = os.environ.get("ADAPTIVE_ROUTER_NAME", "smart-cheap-router") +EXPECTED_WINNER: str = os.environ.get("EXPECTED_WINNER", "smart") +TRAIN_SESSIONS: int = int(os.environ.get("TRAIN_SESSIONS", "20")) +CONVERGE_SESSIONS: int = int(os.environ.get("CONVERGE_SESSIONS", "10")) +WIN_THRESHOLD: float = float(os.environ.get("WIN_THRESHOLD", "0.7")) + +REQUEST_TIMEOUT_SECONDS: float = 30.0 +RETRY_ATTEMPTS: int = 3 +RETRY_BACKOFF_SECONDS: float = 1.0 +FLUSHER_DRAIN_WAIT_SECONDS: float = 30.0 # proxy flusher loop is 10s; pad with margin + +PROMPTS: List[str] = [ + "Write a Python function that reverses a binary tree", + "Explain the time complexity of quicksort", + "Design an API for a chat application", +] +SATISFACTION_PROMPT: str = "thanks, that worked!" + + +async def _post_chat( + client: httpx.AsyncClient, session_id: str, prompt: str +) -> Optional[dict]: + """POST a chat completion with retry + timeout. Returns response JSON or None.""" + body = { + "model": ROUTER_NAME, + "messages": [{"role": "user", "content": prompt}], + "metadata": {"litellm_session_id": session_id}, + } + last_exc: Optional[Exception] = None + for attempt in range(1, RETRY_ATTEMPTS + 1): + try: + r = await client.post( + f"{PROXY_URL}/v1/chat/completions", + json=body, + headers={"Authorization": f"Bearer {PROXY_KEY}"}, + timeout=REQUEST_TIMEOUT_SECONDS, + ) + r.raise_for_status() + return r.json() + except Exception as e: # noqa: BLE001 + last_exc = e + if attempt < RETRY_ATTEMPTS: + await asyncio.sleep(RETRY_BACKOFF_SECONDS * attempt) + print( + f" request failed after {RETRY_ATTEMPTS} attempts (session={session_id}): {last_exc}", + file=sys.stderr, + ) + return None + + +async def send_session( + client: httpx.AsyncClient, + session_id: str, + prompts: List[str], + satisfy: bool = True, +) -> Optional[str]: + """Send a session of N turns. Returns the model that handled the last turn.""" + last_model: Optional[str] = None + for prompt in prompts: + resp = await _post_chat(client, session_id, prompt) + if resp is None: + return None + last_model = resp.get("model") or last_model + if satisfy: + await _post_chat(client, session_id, SATISFACTION_PROMPT) + return last_model + + +async def _proxy_health_check(client: httpx.AsyncClient) -> bool: + """Confirm the proxy is reachable before doing anything else.""" + try: + r = await client.get(f"{PROXY_URL}/health/liveliness", timeout=5.0) + return r.status_code == 200 + except Exception as e: # noqa: BLE001 + print(f"proxy unreachable at {PROXY_URL}: {e}", file=sys.stderr) + return False + + +async def main() -> None: + print("=== verify_adaptive_router.py ===") + print(f"proxy: {PROXY_URL}") + print(f"router: {ROUTER_NAME}") + print(f"expected winner: {EXPECTED_WINNER}") + print(f"train sessions: {TRAIN_SESSIONS}") + print(f"converge runs: {CONVERGE_SESSIONS}\n") + + async with httpx.AsyncClient() as client: + if not await _proxy_health_check(client): + print("FAIL: proxy health check did not return 200.", file=sys.stderr) + sys.exit(1) + + # ---- Phase 1: training ------------------------------------------- + print( + f"Phase 1: training ({TRAIN_SESSIONS} sessions of 3 turns + satisfaction)..." + ) + for i in range(TRAIN_SESSIONS): + sid = f"verify-train-{uuid.uuid4()}" + await send_session(client, sid, PROMPTS, satisfy=True) + if (i + 1) % 5 == 0: + print(f" trained {i + 1}/{TRAIN_SESSIONS} sessions") + + print( + f"\nWaiting {FLUSHER_DRAIN_WAIT_SECONDS:.0f}s for flusher to drain queue..." + ) + await asyncio.sleep(FLUSHER_DRAIN_WAIT_SECONDS) + + # ---- Phase 2: convergence ---------------------------------------- + print(f"\nPhase 2: convergence test ({CONVERGE_SESSIONS} cold sessions)...") + picks: List[str] = [] + for i in range(CONVERGE_SESSIONS): + sid = f"verify-test-{uuid.uuid4()}" + m = await send_session(client, sid, [PROMPTS[0]], satisfy=False) + if m: + picks.append(m) + print(f" session {i + 1}: picked {m}") + + if not picks: + print("\nFAIL: no successful picks in convergence phase.", file=sys.stderr) + sys.exit(1) + winner_share = picks.count(EXPECTED_WINNER) / len(picks) + print( + f"\n{EXPECTED_WINNER} share: {winner_share:.0%} " + f"({picks.count(EXPECTED_WINNER)}/{len(picks)})" + ) + + # ---- Phase 3: sticky session ------------------------------------- + print("\nPhase 3: sticky session test...") + sid = f"verify-sticky-{uuid.uuid4()}" + models: List[str] = [] + for _ in range(3): + m = await send_session(client, sid, [PROMPTS[0]], satisfy=False) + if m: + models.append(m) + if len(models) == 3 and len(set(models)) == 1: + print(f" PASS: same model {models[0]} across 3 turns of session {sid}") + else: + print( + f" FAIL: models differed within session: {models}", + file=sys.stderr, + ) + sys.exit(1) + + # ---- Phase 4: latency benchmark ---------------------------------- + print("\nPhase 4: routing latency (5 picks, p50)...") + latencies: List[float] = [] + for _ in range(5): + t0 = time.perf_counter() + await send_session( + client, f"verify-lat-{uuid.uuid4()}", [PROMPTS[0]], satisfy=False + ) + latencies.append(time.perf_counter() - t0) + latencies.sort() + p50 = latencies[len(latencies) // 2] + print(f" p50 e2e roundtrip: {p50 * 1000:.0f}ms") + + # ---- Verdict ----------------------------------------------------- + if winner_share >= WIN_THRESHOLD: + print( + f"\nPASS: convergence ({winner_share:.0%} >= {WIN_THRESHOLD:.0%}) + " + f"sticky + latency checks all green." + ) + sys.exit(0) + print( + f"\nFAIL: convergence too weak ({winner_share:.0%} < {WIN_THRESHOLD:.0%}).", + file=sys.stderr, + ) + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/enterprise/litellm_enterprise/proxy/auth/test_route_checks.py b/tests/enterprise/litellm_enterprise/proxy/auth/test_route_checks.py index 24e27977963..c147c7aae91 100644 --- a/tests/enterprise/litellm_enterprise/proxy/auth/test_route_checks.py +++ b/tests/enterprise/litellm_enterprise/proxy/auth/test_route_checks.py @@ -252,6 +252,64 @@ class TestEnterpriseRouteChecksModelListExemption: ) +@patch("litellm.proxy.proxy_server.premium_user", True) +class TestEnterpriseRouteChecksMcpManagement: + """Regression tests: MCP management routes (/v1/mcp/server*) must remain + reachable when DISABLE_LLM_API_ENDPOINTS is set on admin nodes, but must be + blocked when DISABLE_ADMIN_ENDPOINTS is set. Uses the real is_llm_api_route + / is_management_route classifiers (not mocks).""" + + @pytest.mark.parametrize( + "route", + [ + "/v1/mcp/server", + "/v1/mcp/server/abc-123", + "/v1/mcp/server/abc-123/approve", + ], + ) + def test_mcp_management_allowed_when_llm_api_disabled(self, route): + with patch.dict(os.environ, {"DISABLE_LLM_API_ENDPOINTS": "true"}, clear=False): + os.environ.pop("DISABLE_ADMIN_ENDPOINTS", None) + # Should not raise — MCP management is a management route, not llm_api. + EnterpriseRouteChecks.should_call_route(route) + + @pytest.mark.parametrize( + "route", + [ + "/v1/mcp/server", + "/v1/mcp/server/abc-123", + ], + ) + def test_mcp_management_blocked_when_admin_disabled(self, route): + with patch.dict(os.environ, {"DISABLE_ADMIN_ENDPOINTS": "true"}, clear=False): + os.environ.pop("DISABLE_LLM_API_ENDPOINTS", None) + with pytest.raises(HTTPException) as exc_info: + EnterpriseRouteChecks.should_call_route(route) + + assert exc_info.value.status_code == 403 + assert "Management routes are disabled for this instance." in str( + exc_info.value.detail + ) + + @pytest.mark.parametrize( + "route", + [ + "/mcp/tools/call", + "/mcp-rest/tools/call", + ], + ) + def test_mcp_inference_still_blocked_when_llm_api_disabled(self, route): + with patch.dict(os.environ, {"DISABLE_LLM_API_ENDPOINTS": "true"}, clear=False): + os.environ.pop("DISABLE_ADMIN_ENDPOINTS", None) + with pytest.raises(HTTPException) as exc_info: + EnterpriseRouteChecks.should_call_route(route) + + assert exc_info.value.status_code == 403 + assert "LLM API routes are disabled for this instance." in str( + exc_info.value.detail + ) + + class TestEnterpriseRouteChecksErrorMessages: """Test that error messages correctly identify which feature requires Enterprise license""" diff --git a/tests/eval_swe_bench.py b/tests/eval_swe_bench.py index 9c986283abd..6ae99f83ca1 100644 --- a/tests/eval_swe_bench.py +++ b/tests/eval_swe_bench.py @@ -40,6 +40,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import litellm # noqa: E402 from litellm.compression import compress as litellm_compress # noqa: E402 +from litellm.types.utils import CallTypes # noqa: E402 # --------------------------------------------------------------------------- # Prompts @@ -445,7 +446,7 @@ def eval_instance( compress_kwargs: dict = { "messages": messages, "model": model, - "input_type": "openai_chat_completions", + "call_type": CallTypes.completion, "compression_trigger": compression_trigger, "embedding_model": embedding_model, } diff --git a/tests/guardrails_tests/test_bedrock_guardrails.py b/tests/guardrails_tests/test_bedrock_guardrails.py index 7eaac60bf2d..54357216208 100644 --- a/tests/guardrails_tests/test_bedrock_guardrails.py +++ b/tests/guardrails_tests/test_bedrock_guardrails.py @@ -1107,7 +1107,12 @@ async def test_convert_to_bedrock_format_post_call_streaming_hook(): # Mock the make_bedrock_api_request method to track calls async def mock_make_bedrock_api_request( - source, messages=None, response=None, request_data=None + source, + messages=None, + response=None, + request_data=None, + logging_event_type=None, + **kwargs, ): bedrock_calls.append( { @@ -1115,6 +1120,7 @@ async def test_convert_to_bedrock_format_post_call_streaming_hook(): "messages": messages, "response": response, "request_data": request_data, + "logging_event_type": logging_event_type, } ) # Return the mock bedrock response diff --git a/tests/llm_translation/test_bedrock_mantle.py b/tests/llm_translation/test_bedrock_mantle.py new file mode 100644 index 00000000000..d545f78bc43 --- /dev/null +++ b/tests/llm_translation/test_bedrock_mantle.py @@ -0,0 +1,149 @@ +""" +E2E tests for Bedrock Mantle (Claude Mythos Preview) integration. + +Tests use a fake/mocked HTTP layer to verify the full request pipeline: +- correct endpoint URL +- model ID in the request body +- AWS SigV4 Authorization header present +- response parsing +""" + +import json +import os +import sys +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +sys.path.insert(0, os.path.abspath("../..")) + +import litellm +from litellm.llms.custom_httpx.http_handler import HTTPHandler + +MODEL = "bedrock/mantle/anthropic.claude-mythos-preview" +REGION = "us-east-1" +EXPECTED_URL = f"https://bedrock-mantle.{REGION}.api.aws/v1/messages" + +FAKE_ANTHROPIC_RESPONSE = { + "id": "msg_fake123", + "type": "message", + "role": "assistant", + "model": "anthropic.claude-mythos-preview", + "content": [{"type": "text", "text": "Hello from Mythos!"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 10, "output_tokens": 5}, +} + + +def _make_fake_response(body: dict) -> MagicMock: + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 200 + mock_resp.headers = httpx.Headers({"content-type": "application/json"}) + mock_resp.text = json.dumps(body) + mock_resp.json.return_value = body + mock_resp.is_error = False + mock_resp.raise_for_status = MagicMock() + return mock_resp + + +def test_mantle_request_url_and_body(): + """Verify the correct URL is called and model appears in the request body.""" + client = HTTPHandler() + + with patch.object( + client, "post", return_value=_make_fake_response(FAKE_ANTHROPIC_RESPONSE) + ) as mock_post: + try: + litellm.completion( + model=MODEL, + messages=[{"role": "user", "content": "Hello"}], + max_tokens=50, + aws_region_name=REGION, + aws_access_key_id="AKIAIOSFODNN7EXAMPLE", + aws_secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + client=client, + ) + except Exception: + pass # response parsing may fail on mock; we only care about the outgoing call + + mock_post.assert_called_once() + call_kwargs = mock_post.call_args.kwargs + + # Correct endpoint + assert ( + call_kwargs["url"] == EXPECTED_URL + ), f"Expected {EXPECTED_URL}, got {call_kwargs['url']}" + + # Request body has model ID (without "mantle/" prefix) + raw_data = call_kwargs.get("data") or call_kwargs.get("json") + body = json.loads(raw_data) if isinstance(raw_data, (str, bytes)) else raw_data + assert ( + body["model"] == "anthropic.claude-mythos-preview" + ), f"body['model'] = {body.get('model')}" + assert "messages" in body + assert body["max_tokens"] == 50 + + # AWS SigV4 Authorization header must be present + headers = call_kwargs.get("headers", {}) + assert "Authorization" in headers, f"No Authorization header in {headers}" + assert headers["Authorization"].startswith( + "AWS4-HMAC-SHA256" + ), f"Expected SigV4 auth, got: {headers['Authorization'][:50]}" + + +def test_mantle_request_does_not_include_mantle_prefix_in_body(): + """Ensure 'mantle/' never leaks into the request body.""" + client = HTTPHandler() + + with patch.object( + client, "post", return_value=_make_fake_response(FAKE_ANTHROPIC_RESPONSE) + ) as mock_post: + try: + litellm.completion( + model=MODEL, + messages=[{"role": "user", "content": "Hi"}], + max_tokens=10, + aws_region_name=REGION, + aws_access_key_id="AKIAIOSFODNN7EXAMPLE", + aws_secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + client=client, + ) + except Exception: + pass + + call_kwargs = mock_post.call_args.kwargs + raw_data = call_kwargs.get("data") or call_kwargs.get("json") + body = json.loads(raw_data) if isinstance(raw_data, (str, bytes)) else raw_data + + body_str = json.dumps(body) + assert "mantle/" not in body_str, f"'mantle/' leaked into body: {body_str}" + + +def test_mantle_region_reflected_in_url(): + """The region from aws_region_name must appear in the endpoint URL.""" + client = HTTPHandler() + + for region in ["us-east-1", "us-west-2", "eu-west-1"]: + with patch.object( + client, "post", return_value=_make_fake_response(FAKE_ANTHROPIC_RESPONSE) + ) as mock_post: + try: + litellm.completion( + model=MODEL, + messages=[{"role": "user", "content": "Hi"}], + max_tokens=10, + aws_region_name=region, + aws_access_key_id="AKIAIOSFODNN7EXAMPLE", + aws_secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + client=client, + ) + except Exception: + pass + + call_kwargs = mock_post.call_args.kwargs + expected = f"https://bedrock-mantle.{region}.api.aws/v1/messages" + assert ( + call_kwargs["url"] == expected + ), f"region={region}: expected URL {expected}, got {call_kwargs['url']}" diff --git a/tests/local_testing/test_basic_python_version.py b/tests/local_testing/test_basic_python_version.py index 37e23d64677..8308e0d6033 100644 --- a/tests/local_testing/test_basic_python_version.py +++ b/tests/local_testing/test_basic_python_version.py @@ -100,8 +100,12 @@ import pytest import requests -def test_litellm_proxy_server_config_no_general_settings(): - # Sync the local litellm packages into the project environment +def _run_proxy_server_smoke_test(extra_proxy_args=None): + """Sync deps, generate Prisma client, start proxy with optional extra args, + send a health check + chat/completions request, and tear down.""" + if extra_proxy_args is None: + extra_proxy_args = [] + server_process = None try: _run_uv( @@ -144,6 +148,7 @@ def test_litellm_proxy_server_config_no_general_settings(): "litellm.proxy.proxy_cli", "--config", config_fp, + *extra_proxy_args, ], cwd=PROJECT_ROOT, ) @@ -182,3 +187,17 @@ def test_litellm_proxy_server_config_no_general_settings(): # Additional assertions can be added here assert True + + +def test_litellm_proxy_server_config_no_general_settings(): + """Exercises the default (v1) migration resolver.""" + _run_proxy_server_smoke_test() + + +def test_litellm_proxy_server_config_no_general_settings_v2_resolver(): + """Exercises the opt-in v2 migration resolver. + + Runs in a separate CI job against a local Postgres to avoid collisions + with the v1 variant when they share a database. + """ + _run_proxy_server_smoke_test(extra_proxy_args=["--use_v2_migration_resolver"]) diff --git a/tests/local_testing/test_batch_completions.py b/tests/local_testing/test_batch_completions.py index 2125a998f84..95bfe5e6e2b 100644 --- a/tests/local_testing/test_batch_completions.py +++ b/tests/local_testing/test_batch_completions.py @@ -72,7 +72,7 @@ def test_batch_completions_models(): def test_batch_completion_models_all_responses(): try: responses = batch_completion_models_all_responses( - models=["gemini/gemini-2.5-flash-lite", "claude-3-haiku-20240307"], + models=["gemini/gemini-2.5-flash-lite", "claude-haiku-4-5-20251001"], messages=[{"role": "user", "content": "write a poem"}], max_tokens=10, ) diff --git a/tests/local_testing/test_function_call_parsing.py b/tests/local_testing/test_function_call_parsing.py index 0351ce70572..f9582fcc574 100644 --- a/tests/local_testing/test_function_call_parsing.py +++ b/tests/local_testing/test_function_call_parsing.py @@ -142,7 +142,7 @@ def trade(model_name: str) -> List[Trade]: # type: ignore @pytest.mark.parametrize( - "model", ["claude-3-haiku-20240307", "anthropic.claude-3-haiku-20240307-v1:0"] + "model", ["claude-haiku-4-5-20251001", "anthropic.claude-3-haiku-20240307-v1:0"] ) @pytest.mark.flaky(retries=6, delay=10) def test_function_call_parsing(model): diff --git a/tests/local_testing/test_function_calling.py b/tests/local_testing/test_function_calling.py index 13adb163d5f..b52805c0664 100644 --- a/tests/local_testing/test_function_calling.py +++ b/tests/local_testing/test_function_calling.py @@ -47,7 +47,7 @@ def get_current_weather(location, unit="fahrenheit"): [ "gpt-3.5-turbo-1106", "mistral/mistral-large-latest", - "claude-3-haiku-20240307", + "claude-haiku-4-5-20251001", "gemini/gemini-2.5-flash-lite", "anthropic.claude-3-sonnet-20240229-v1:0", ], @@ -275,7 +275,7 @@ from litellm.types.utils import ChatCompletionMessageToolCall, Function, Message "anthropic.claude-3-sonnet-20240229-v1:0", "bedrock", ), - ("claude-3-haiku-20240307", "anthropic"), + ("claude-haiku-4-5-20251001", "anthropic"), ], ) @pytest.mark.parametrize( diff --git a/tests/local_testing/test_router_fallbacks.py b/tests/local_testing/test_router_fallbacks.py index 383ad104577..a14e53adbc4 100644 --- a/tests/local_testing/test_router_fallbacks.py +++ b/tests/local_testing/test_router_fallbacks.py @@ -1509,7 +1509,7 @@ def test_router_fallbacks_with_wildcard_model_name(): { "model_name": "claude-3-haiku", "litellm_params": { - "model": "claude-3-haiku-20240307", + "model": "claude-haiku-4-5-20251001", "api_key": os.getenv("ANTHROPIC_API_KEY"), "mock_response": "Hi this is claude!", }, @@ -1555,7 +1555,7 @@ def test_fallbacks_with_different_messages(): { "model_name": "claude-3-haiku", "litellm_params": { - "model": "claude-3-haiku-20240307", + "model": "claude-haiku-4-5-20251001", "api_key": os.getenv("ANTHROPIC_API_KEY"), }, }, diff --git a/tests/local_testing/test_streaming.py b/tests/local_testing/test_streaming.py index 3aed0699603..ecac2cfe40e 100644 --- a/tests/local_testing/test_streaming.py +++ b/tests/local_testing/test_streaming.py @@ -1727,7 +1727,7 @@ def test_openai_chat_completion_complete_response_call(): "model", [ "gpt-3.5-turbo", - "claude-3-haiku-20240307", + "claude-haiku-4-5-20251001", "o1", ], ) @@ -2247,7 +2247,7 @@ def streaming_and_function_calling_format_tests(idx, chunk): [ # "gpt-3.5-turbo", # "anthropic.claude-3-sonnet-20240229-v1:0", - "claude-3-haiku-20240307", + "claude-haiku-4-5-20251001", ], ) def test_streaming_and_function_calling(model): diff --git a/tests/local_testing/test_tpm_rpm_routing_v2.py b/tests/local_testing/test_tpm_rpm_routing_v2.py index 9de5625c63a..211af566424 100644 --- a/tests/local_testing/test_tpm_rpm_routing_v2.py +++ b/tests/local_testing/test_tpm_rpm_routing_v2.py @@ -547,6 +547,8 @@ async def test_router_caching_ttl(): assert router.cache.redis_cache is not None + from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER + increment_cache_kwargs = {} with patch.object( router.cache, @@ -555,6 +557,10 @@ async def test_router_caching_ttl(): ) as mock_client: await router.acompletion(model=model, messages=messages) + # Async success callbacks are dispatched to GLOBAL_LOGGING_WORKER's + # background queue; drain it before asserting the mock was invoked. + await GLOBAL_LOGGING_WORKER.flush() + # mock_client.assert_called_once() print(f"mock_client.call_args.kwargs: {mock_client.call_args.kwargs}") print(f"mock_client.call_args.args: {mock_client.call_args.args}") diff --git a/tests/logging_callback_tests/test_otel_logging.py b/tests/logging_callback_tests/test_otel_logging.py index fdb333899cc..ea1c884c324 100644 --- a/tests/logging_callback_tests/test_otel_logging.py +++ b/tests/logging_callback_tests/test_otel_logging.py @@ -253,6 +253,7 @@ def validate_redacted_message_span_attributes(span): or attr.startswith("gen_ai.cost.") or attr.startswith("gen_ai.operation.") or attr.startswith("gen_ai.request.") + or attr.startswith("litellm.") ), f"Non-metadata attribute found: {attr}" pass diff --git a/tests/pass_through_tests/package-lock.json b/tests/pass_through_tests/package-lock.json new file mode 100644 index 00000000000..f1d70e37c68 --- /dev/null +++ b/tests/pass_through_tests/package-lock.json @@ -0,0 +1,3930 @@ +{ + "name": "litellm-pass-through-tests", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "litellm-pass-through-tests", + "version": "0.0.0", + "dependencies": { + "@google-cloud/vertexai": "1.9.3", + "@google/generative-ai": "0.21.0" + }, + "devDependencies": { + "jest": "29.7.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@google-cloud/vertexai": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@google-cloud/vertexai/-/vertexai-1.9.3.tgz", + "integrity": "sha512-35o5tIEMLW3JeFJOaaMNR2e5sq+6rpnhrF97PuAxeOm0GlqVTESKhkGj7a5B5mmJSSSU3hUfIhcQCRRsw4Ipzg==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^9.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@google/generative-ai": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.21.0.tgz", + "integrity": "sha512-7XhUbtnlkSEZK15kN3t+tzIMxsbKm/dSkKBFalj+20NvPKe1kBY7mR2P7vuijEn+f06z5+A8bVGKO0v39cr6Wg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz", + "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001790", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", + "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/tests/pass_through_tests/package.json b/tests/pass_through_tests/package.json new file mode 100644 index 00000000000..a500c14cce7 --- /dev/null +++ b/tests/pass_through_tests/package.json @@ -0,0 +1,13 @@ +{ + "name": "litellm-pass-through-tests", + "version": "0.0.0", + "private": true, + "description": "JS pass-through tests for Vertex AI / Google AI Studio routes. CI-only; not published.", + "dependencies": { + "@google-cloud/vertexai": "1.9.3", + "@google/generative-ai": "0.21.0" + }, + "devDependencies": { + "jest": "29.7.0" + } +} diff --git a/tests/pass_through_unit_tests/test_anthropic_messages_passthrough.py b/tests/pass_through_unit_tests/test_anthropic_messages_passthrough.py index ae2d66eb955..84b14f9508b 100644 --- a/tests/pass_through_unit_tests/test_anthropic_messages_passthrough.py +++ b/tests/pass_through_unit_tests/test_anthropic_messages_passthrough.py @@ -77,7 +77,7 @@ class TestAnthropicDirectAPI(BaseAnthropicMessagesTest): @property def model_config(self) -> Dict[str, Any]: return { - "model": "claude-3-haiku-20240307", + "model": "claude-haiku-4-5-20251001", "api_key": os.getenv("ANTHROPIC_API_KEY"), } @@ -86,7 +86,7 @@ class TestAnthropicDirectAPI(BaseAnthropicMessagesTest): """ This is the model name that is expected to be in the logging payload """ - return "claude-3-haiku-20240307" + return "claude-haiku-4-5-20251001" class TestAnthropicBedrockAPI(BaseAnthropicMessagesTest): @@ -140,7 +140,7 @@ async def test_anthropic_messages_streaming_with_bad_request(): response = await litellm.anthropic.messages.acreate( messages=[{"role": "user", "content": "hi"}], api_key=os.getenv("ANTHROPIC_API_KEY"), - model="claude-3-haiku-20240307", + model="claude-haiku-4-5-20251001", max_tokens=100, stream=True, ) @@ -168,7 +168,7 @@ async def test_anthropic_messages_router_streaming_with_bad_request(): { "model_name": "claude-special-alias", "litellm_params": { - "model": "claude-3-haiku-20240307", + "model": "claude-haiku-4-5-20251001", "api_key": os.getenv("ANTHROPIC_API_KEY"), }, } @@ -205,7 +205,7 @@ async def test_anthropic_messages_litellm_router_non_streaming(): { "model_name": "claude-special-alias", "litellm_params": { - "model": "claude-3-haiku-20240307", + "model": "claude-haiku-4-5-20251001", "api_key": os.getenv("ANTHROPIC_API_KEY"), }, } @@ -243,7 +243,7 @@ async def test_anthropic_messages_litellm_router_routing_strategy(): { "model_name": "claude-special-alias", "litellm_params": { - "model": "claude-3-haiku-20240307", + "model": "claude-haiku-4-5-20251001", "api_key": os.getenv("ANTHROPIC_API_KEY"), }, } @@ -341,7 +341,7 @@ async def test_anthropic_messages_litellm_router_latency_metadata_tracking(): "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Here's a joke for you!"}], - "model": "claude-3-haiku-20240307", + "model": "claude-haiku-4-5-20251001", "stop_reason": "end_turn", "usage": {"input_tokens": 10, "output_tokens": 20}, } @@ -355,7 +355,7 @@ async def test_anthropic_messages_litellm_router_latency_metadata_tracking(): { "model_name": MODEL_GROUP, "litellm_params": { - "model": "claude-3-haiku-20240307", + "model": "claude-haiku-4-5-20251001", "api_key": os.getenv("ANTHROPIC_API_KEY"), }, } @@ -419,7 +419,7 @@ async def test_anthropic_messages_litellm_router_latency_metadata_tracking(): assert "model_info" in litellm_metadata # Verify other call parameters - assert call_kwargs["model"] == "claude-3-haiku-20240307" + assert call_kwargs["model"] == "claude-haiku-4-5-20251001" assert call_kwargs["messages"] == messages assert call_kwargs["max_tokens"] == 100 assert call_kwargs["metadata"] == {"user_id": "hello"} @@ -459,7 +459,7 @@ async def test_anthropic_messages_litellm_router_non_streaming_with_logging(): { "model_name": MODEL_GROUP, "litellm_params": { - "model": "claude-3-haiku-20240307", + "model": "claude-haiku-4-5-20251001", "api_key": os.getenv("ANTHROPIC_API_KEY"), }, } @@ -496,7 +496,7 @@ async def test_anthropic_messages_litellm_router_non_streaming_with_logging(): assert test_custom_logger.logged_standard_logging_payload["response"] is not None assert ( test_custom_logger.logged_standard_logging_payload["model"] - == "claude-3-haiku-20240307" + == "claude-haiku-4-5-20251001" ) # check logged usage + spend @@ -543,7 +543,7 @@ async def test_anthropic_messages_with_extra_headers(): "text": "Why did the chicken cross the road? To get to the other side!", } ], - "model": "claude-3-haiku-20240307", + "model": "claude-haiku-4-5-20251001", "stop_reason": "end_turn", "usage": {"input_tokens": 10, "output_tokens": 20}, } @@ -556,7 +556,7 @@ async def test_anthropic_messages_with_extra_headers(): response = await litellm.anthropic.messages.acreate( messages=messages, api_key=api_key, - model="claude-3-haiku-20240307", + model="claude-haiku-4-5-20251001", max_tokens=100, client=mock_client, provider_specific_header={ @@ -689,7 +689,7 @@ async def test_anthropic_messages_with_thinking(): "text": "Why did the chicken cross the road? To get to the other side!", } ], - "model": "claude-3-haiku-20240307", + "model": "claude-haiku-4-5-20251001", "stop_reason": "end_turn", "usage": {"input_tokens": 10, "output_tokens": 20}, } @@ -702,7 +702,7 @@ async def test_anthropic_messages_with_thinking(): response = await litellm.anthropic.messages.acreate( messages=messages, api_key=api_key, - model="claude-3-haiku-20240307", + model="claude-haiku-4-5-20251001", max_tokens=100, client=mock_client, thinking={"budget_tokens": 100}, @@ -717,7 +717,7 @@ async def test_anthropic_messages_with_thinking(): request_body = json.loads(call_kwargs.get("data", {})) print("REQUEST BODY", request_body) assert request_body["max_tokens"] == 100 - assert request_body["model"] == "claude-3-haiku-20240307" + assert request_body["model"] == "claude-haiku-4-5-20251001" assert request_body["messages"] == messages assert request_body["thinking"] == {"budget_tokens": 100} diff --git a/tests/proxy_unit_tests/conftest.py b/tests/proxy_unit_tests/conftest.py index 1421700c9a8..a0326f64ed7 100644 --- a/tests/proxy_unit_tests/conftest.py +++ b/tests/proxy_unit_tests/conftest.py @@ -1,48 +1,143 @@ # conftest.py -import importlib +import asyncio +import copy +import inspect import os import sys +import warnings import pytest sys.path.insert( 0, os.path.abspath("../..") ) # Adds the parent directory to the system path + import litellm +import litellm.proxy.proxy_server + + +# Top-level assignments of these types are the ones importlib.reload(litellm) +# would have effectively reset. We snapshot them at conftest import time and +# deep-copy the snapshot back before every test. +_SNAPSHOT_TYPES = (list, dict, set, tuple, str, int, float, bool, bytes) + + +def _snapshot_mutable_state(module): + """Capture a per-module snapshot of primitive and collection attributes.""" + snapshot = {} + for attr in list(vars(module)): + if attr.startswith("_"): + continue + try: + value = getattr(module, attr) + except Exception as exc: + warnings.warn( + f"conftest: could not read {module.__name__}.{attr} during snapshot: {exc}", + stacklevel=2, + ) + continue + if value is None or isinstance(value, _SNAPSHOT_TYPES): + try: + snapshot[attr] = copy.deepcopy(value) + except Exception as exc: + warnings.warn( + f"conftest: could not snapshot {module.__name__}.{attr}: {exc}", + stacklevel=2, + ) + return snapshot + + +def _restore_mutable_state(module, snapshot): + for attr, default in snapshot.items(): + try: + setattr(module, attr, copy.deepcopy(default)) + except Exception as exc: + warnings.warn( + f"conftest: could not restore {module.__name__}.{attr}: {exc}", + stacklevel=2, + ) + + +def _collect_flushable_caches(): + """Return (module, attr) pairs whose values expose flush_cache().""" + targets = [] + for module in (litellm, litellm.proxy.proxy_server): + for attr in list(vars(module)): + if attr.startswith("_"): + continue + try: + value = getattr(module, attr) + except Exception: + continue + # Only instances — a class reference has an unbound flush_cache + # that can't be called without a self argument. + if inspect.isclass(value) or inspect.ismodule(value): + continue + if callable(getattr(value, "flush_cache", None)): + targets.append((module, attr)) + return targets + + +def _flush_caches(targets): + for module, attr in targets: + try: + value = getattr(module, attr) + except Exception: + continue + flush = getattr(value, "flush_cache", None) + if callable(flush): + try: + flush() + except Exception as exc: + warnings.warn( + f"conftest: flush_cache failed on {module.__name__}.{attr}: {exc}", + stacklevel=2, + ) + + +# Snapshot once at conftest import — these are the "clean" module states. +_LITELLM_STATE = _snapshot_mutable_state(litellm) +_PROXY_SERVER_STATE = _snapshot_mutable_state(litellm.proxy.proxy_server) +_FLUSHABLE_CACHES = _collect_flushable_caches() @pytest.fixture(scope="function", autouse=True) def setup_and_teardown(): + """Reset mutable module state on litellm and proxy_server before each test. + + Replaces a previous importlib.reload(litellm) approach that cost ~17s + per test (re-executing the full litellm __init__ import chain). + + What IS reset: + - Top-level module attributes of type list / dict / set / tuple + / str / int / float / bool / bytes, and None-valued attributes. + These cover callback lists, general_settings, master_key, + premium_user, prisma_client, etc. — anything the old reload() reset + by re-executing the module body. + - Any module-level object instance that exposes flush_cache() (the + DualCache and LLMClientCache family), which handles cache state + that can't round-trip through deepcopy because of internal locks. + + What is NOT reset: + - Class instances without flush_cache() (e.g. ProxyLogging, + JWTHandler, FastAPI routers, loggers). If a test mutates such an + instance in-place (setattr on the instance, appending to one of + its internal lists, etc.), the mutation will leak into later tests. + Use pytest's monkeypatch.setattr() or a local fixture for those + cases — don't rely on this autouse fixture to undo them. """ - This fixture reloads litellm before every function. To speed up testing by removing callbacks being chained. - """ - curr_dir = os.getcwd() # Get the current working directory - sys.path.insert( - 0, os.path.abspath("../..") - ) # Adds the project directory to the system path - - import litellm - from litellm import Router - - importlib.reload(litellm) - try: - if hasattr(litellm, "proxy") and hasattr(litellm.proxy, "proxy_server"): - importlib.reload(litellm.proxy.proxy_server) - except Exception as e: - print(f"Error reloading litellm.proxy.proxy_server: {e}") - - import asyncio + _restore_mutable_state(litellm, _LITELLM_STATE) + _restore_mutable_state(litellm.proxy.proxy_server, _PROXY_SERVER_STATE) + _flush_caches(_FLUSHABLE_CACHES) loop = asyncio.get_event_loop_policy().new_event_loop() asyncio.set_event_loop(loop) - print(litellm) - # from litellm import Router, completion, aembedding, acompletion, embedding - yield - - # Teardown code (executes after the yield point) - loop.close() # Close the loop created earlier - asyncio.set_event_loop(None) # Remove the reference to the loop + try: + yield + finally: + loop.close() + asyncio.set_event_loop(None) def pytest_collection_modifyitems(config, items): diff --git a/tests/router_unit_tests/test_router_index_management.py b/tests/router_unit_tests/test_router_index_management.py index f67edac494c..43718590808 100644 --- a/tests/router_unit_tests/test_router_index_management.py +++ b/tests/router_unit_tests/test_router_index_management.py @@ -244,6 +244,7 @@ class TestRouterIndexManagement: # Methods that are allowed to iterate through self.model_list ALLOWED_METHODS = [ "_get_deployment_by_litellm_model", # Edge case: lookup by litellm_params.model (not indexed) + "_finalize_adaptive_router_if_configured", # Init-time prefix scan for "auto_router/adaptive_router" (no index for prefix match) ] # Get path to router.py diff --git a/tests/spend_tracking_tests/test_spend_accuracy_tests.py b/tests/spend_tracking_tests/test_spend_accuracy_tests.py index 18527b525e6..15e00d93356 100644 --- a/tests/spend_tracking_tests/test_spend_accuracy_tests.py +++ b/tests/spend_tracking_tests/test_spend_accuracy_tests.py @@ -1,10 +1,9 @@ import pytest import asyncio import aiohttp -import json import time -from httpx import AsyncClient -from typing import Any, Optional + +import litellm from litellm._uuid import uuid """ @@ -12,15 +11,13 @@ Tests to run Basic Tests: 1. Basic Spend Accuracy Test: - - Make 1 calibration request, poll for spend to derive SPEND_PER_REQUEST - - Make N-1 more requests (N total) - - Expect the spend for each of the following to be N * SPEND_PER_REQUEST - Key, Team, User, Org (call /info endpoint for each object to validate) + - Make N requests, compute expected total spend locally from each response's usage + - Poll until batch writer has flushed spend to the DB + - Expect spend for Key, Team, User, Org (/info endpoints) to equal the computed total 2. Long term spend accuracy test (with 2 bursts of requests) - - Burst 1: Make requests, derive SPEND_PER_REQUEST from first request - - Burst 2: Make more requests - - Verify total spend = (burst1 + burst2) * SPEND_PER_REQUEST + - Burst 1: compute expected from responses, verify + - Burst 2: compute expected from responses, verify total = burst1 + burst2 Additional Test Scenarios: @@ -38,6 +35,34 @@ Additional Test Scenarios: - Verify accurate total spend calculation """ +# Upstream model the proxy is configured with (spend_tracking_config.yaml). +# The proxy computes spend using this model's pricing; the local ground-truth +# calculation uses the same pricing table via litellm.cost_per_token. +UPSTREAM_MODEL = "gpt-3.5-turbo" + +# Batch writer flush cadence in CI is ~2-7s (PROXY_BATCH_WRITE_AT=2 + up to 5s jitter). +# Poll every 2s for 60s — plenty of headroom for multiple ticks to land. +POLL_INTERVAL_SECONDS = 2 +POLL_TIMEOUT_SECONDS = 60 + +TOLERANCE = 1e-10 + + +def _make_test_session() -> aiohttp.ClientSession: + """ + Session tuned for CI reliability: + - force_close: avoid aiohttp reusing a TCP connection that the proxy/kernel + silently closed during the long idle window between setup POSTs and the + later poll loop (observed failure mode: ConnectionTimeoutError on the + first /key/info call after 20 chat completions). + - explicit connect timeout: surface a blocked proxy event loop quickly + instead of hanging on aiohttp's 5-minute default total timeout. + """ + return aiohttp.ClientSession( + connector=aiohttp.TCPConnector(force_close=True), + timeout=aiohttp.ClientTimeout(total=30, connect=10), + ) + async def create_organization(session, organization_alias: str): """Helper function to create a new organization""" @@ -102,54 +127,83 @@ async def get_spend_info(session, entity_type: str, entity_id: str): return await response.json() -async def poll_key_spend_until_nonzero( - session, key: str, timeout: int = 120, interval: int = 10 -): - """Poll key spend until it becomes non-zero or timeout is reached.""" +async def get_proxy_readiness(session): + """Fetch /health/readiness. Used both as a fail-fast gate and as a diagnostic on poll timeout.""" + url = "http://0.0.0.0:4000/health/readiness" + headers = {"Authorization": "Bearer sk-1234"} + async with session.get(url, headers=headers) as response: + return response.status, await response.json() + + +async def assert_proxy_healthy(session): + """Fail fast if the proxy's DB or cache is not reachable — no point running the test.""" + status, body = await get_proxy_readiness(session) + if status != 200 or body.get("db") != "connected": + pytest.fail( + f"Proxy /health/readiness unhealthy (status={status}). " + f"Cannot run spend accuracy test. Response: {body}" + ) + print(f"Proxy readiness OK: {body}") + + +def compute_expected_spend(responses) -> float: + """ + Compute the expected total spend locally from each response's usage tokens, + using the same pricing table the proxy uses. This is the independent ground + truth we compare the proxy's reported spend against. + """ + total = 0.0 + for r in responses: + usage = r.usage + prompt_cost, completion_cost = litellm.cost_per_token( + model=UPSTREAM_MODEL, + prompt_tokens=usage.prompt_tokens, + completion_tokens=usage.completion_tokens, + ) + total += prompt_cost + completion_cost + return total + + +async def poll_key_spend_until(session, key: str, expected: float) -> float: + """ + Poll key spend until it matches `expected` within TOLERANCE, or timeout. + Returns the last observed spend either way; caller decides how to report. + """ start = time.time() - while time.time() - start < timeout: - key_info = await get_spend_info(session, "key", key) - spend = key_info["info"]["spend"] - if spend > 0: - print( - f"Key spend became non-zero ({spend}) after {time.time() - start:.1f}s" - ) - return spend - print(f"Key spend still 0.0, waiting... ({time.time() - start:.1f}s elapsed)") - await asyncio.sleep(interval) - raise TimeoutError( - f"Key spend remained 0.0 after {timeout}s — batch writer may not be running" - ) - - -async def calibrate_spend_per_request(session, key: str, max_retries: int = 5): - """ - Make a single calibration request and poll for its spend to derive SPEND_PER_REQUEST. - Fails fast with pytest.fail() if spend cannot be determined. - """ - response = await chat_completion(session, key) - print(f"Calibration request completed: {response}") - - for attempt in range(1, max_retries + 1): + last_spend = 0.0 + while time.time() - start < POLL_TIMEOUT_SECONDS: try: - spend = await poll_key_spend_until_nonzero( - session, key, timeout=120, interval=10 - ) + key_info = await get_spend_info(session, "key", key) + except (aiohttp.ClientError, asyncio.TimeoutError) as exc: print( - f"Calibrated SPEND_PER_REQUEST = {spend} " - f"(attempt {attempt}/{max_retries})" + f"Transient transport error during spend poll: " + f"{type(exc).__name__}: {exc}. Retrying... " + f"({time.time() - start:.1f}s elapsed)" ) - return spend - except TimeoutError: - if attempt < max_retries: - print( - f"Calibration attempt {attempt}/{max_retries} timed out, retrying..." - ) - else: - pytest.fail( - f"Failed to calibrate SPEND_PER_REQUEST after {max_retries} attempts. " - "The batch writer may not be running or the model may have 0 cost." - ) + await asyncio.sleep(POLL_INTERVAL_SECONDS) + continue + last_spend = key_info["info"]["spend"] + if abs(last_spend - expected) < TOLERANCE: + print( + f"Key spend reached expected {expected} after {time.time() - start:.1f}s" + ) + return last_spend + print( + f"Key spend {last_spend}, expected {expected}, waiting... " + f"({time.time() - start:.1f}s elapsed)" + ) + await asyncio.sleep(POLL_INTERVAL_SECONDS) + return last_spend + + +async def fail_with_diagnostics(session, stage: str, expected: float, observed: float): + """Emit a failure with readiness state so CI output points at the real cause.""" + _, readiness = await get_proxy_readiness(session) + pytest.fail( + f"{stage}: key spend did not match expected after {POLL_TIMEOUT_SECONDS}s poll. " + f"expected={expected}, observed={observed}, diff={expected - observed}. " + f"Proxy readiness: {readiness}" + ) @pytest.mark.asyncio @@ -157,63 +211,60 @@ async def test_basic_spend_accuracy(): """ Test basic spend accuracy across different entities: 1. Create org, team, user, and key - 2. Make 1 calibration request to derive SPEND_PER_REQUEST - 3. Make remaining requests (NUM_LLM_REQUESTS total) - 4. Verify spend accuracy for key, team, user, and org + 2. Make N requests, keeping each response + 3. Compute expected spend locally from response usage (independent ground truth) + 4. Poll until proxy-reported spend matches expected + 5. Verify spend is consistent across key, team, user, and org entities """ NUM_LLM_REQUESTS = 20 - TOLERANCE = 1e-10 - async with aiohttp.ClientSession() as session: - # Create organization + async with _make_test_session() as session: + await assert_proxy_healthy(session) + org_response = await create_organization( session=session, organization_alias=f"test-org-{uuid.uuid4()}" ) print("org_response: ", org_response) org_id = org_response["organization_id"] - # Create team under organization team_response = await create_team(session, org_id) print("team_response: ", team_response) team_id = team_response["team_id"] - # Create user user_response = await create_user(session, org_id) print("user_response: ", user_response) user_id = user_response["user_id"] - # Generate key key_response = await generate_key(session, user_id, team_id) print("key_response: ", key_response) key = key_response["key"] - # Calibrate: make 1 request and derive SPEND_PER_REQUEST - spend_per_request = await calibrate_spend_per_request(session, key) - expected_spend = NUM_LLM_REQUESTS * spend_per_request - print(f"SPEND_PER_REQUEST={spend_per_request}, expected_spend={expected_spend}") - - # Make remaining requests (1 already made during calibration) - for i in range(NUM_LLM_REQUESTS - 1): + responses = [] + for i in range(NUM_LLM_REQUESTS): response = await chat_completion(session, key) - print(f"Request {i + 2}/{NUM_LLM_REQUESTS} completed") + responses.append(response) + print(f"Request {i + 1}/{NUM_LLM_REQUESTS} completed") - # Poll until batch writer has flushed all spend - start = time.time() - while time.time() - start < 120: - key_info = await get_spend_info(session, "key", key) - current_spend = key_info["info"]["spend"] - if abs(current_spend - expected_spend) < TOLERANCE: - print( - f"Key spend reached expected {expected_spend} after {time.time() - start:.1f}s" - ) - break - print(f"Key spend {current_spend}, expected {expected_spend}, waiting...") - await asyncio.sleep(10) + expected_spend = compute_expected_spend(responses) + assert expected_spend > 0, ( + f"Locally computed expected spend is {expected_spend}. Either cost calc " + f"is broken or upstream returned zero tokens. " + f"Usage: {[r.usage.model_dump() for r in responses]}" + ) + print(f"Expected total spend (local ground truth): {expected_spend}") - # Allow extra time for all entity spend aggregations to complete + final_spend = await poll_key_spend_until(session, key, expected_spend) + if abs(final_spend - expected_spend) >= TOLERANCE: + await fail_with_diagnostics( + session, + stage="test_basic_spend_accuracy", + expected=expected_spend, + observed=final_spend, + ) + + # Allow a final scheduler tick for team/user/org aggregations to settle await asyncio.sleep(5) - # Get spend information for each entity key_info = await get_spend_info(session, "key", key) print("key_info: ", key_info) team_info = await get_spend_info(session, "team", team_id) @@ -223,7 +274,6 @@ async def test_basic_spend_accuracy(): org_info = await get_spend_info(session, "organization", org_id) print("org_info: ", org_info) - # Verify spend for each entity assert ( abs(key_info["info"]["spend"] - expected_spend) < TOLERANCE ), f"Key spend {key_info['info']['spend']} does not match expected {expected_spend}" @@ -246,91 +296,78 @@ async def test_long_term_spend_accuracy_with_bursts(): """ Test long-term spend accuracy with multiple bursts of requests: 1. Create org, team, user, and key - 2. Calibrate SPEND_PER_REQUEST from first request - 3. Burst 1: Make remaining requests - 4. Burst 2: Make more requests - 5. Verify the total spend is tracked accurately across all entities + 2. Burst 1: make requests, compute expected locally, verify proxy matches + 3. Burst 2: make more requests, verify proxy total == burst1 + burst2 + 4. Verify total spend is consistent across all entities """ BURST_1_REQUESTS = 22 BURST_2_REQUESTS = 12 - TOTAL_REQUESTS = BURST_1_REQUESTS + BURST_2_REQUESTS - TOLERANCE = 1e-10 - async with aiohttp.ClientSession() as session: - # Create organization + async with _make_test_session() as session: + await assert_proxy_healthy(session) + org_response = await create_organization( session=session, organization_alias=f"test-org-{uuid.uuid4()}" ) print("org_response: ", org_response) org_id = org_response["organization_id"] - # Create team under organization team_response = await create_team(session, org_id) print("team_response: ", team_response) team_id = team_response["team_id"] - # Create user user_response = await create_user(session, org_id) print("user_response: ", user_response) user_id = user_response["user_id"] - # Generate key key_response = await generate_key(session, user_id, team_id) print("key_response: ", key_response) key = key_response["key"] - # Calibrate: make 1 request and derive SPEND_PER_REQUEST - spend_per_request = await calibrate_spend_per_request(session, key) - expected_spend = TOTAL_REQUESTS * spend_per_request - print(f"SPEND_PER_REQUEST={spend_per_request}, expected_spend={expected_spend}") - - # First burst: remaining requests (1 already made during calibration) - print(f"Starting first burst ({BURST_1_REQUESTS - 1} remaining requests)...") - for i in range(BURST_1_REQUESTS - 1): + print(f"Starting first burst of {BURST_1_REQUESTS} requests...") + burst_1_responses = [] + for i in range(BURST_1_REQUESTS): response = await chat_completion(session, key) - print(f"Burst 1 - Request {i + 2}/{BURST_1_REQUESTS} completed") + burst_1_responses.append(response) + print(f"Burst 1 - Request {i + 1}/{BURST_1_REQUESTS} completed") - # Poll until batch writer has flushed burst 1 spend - burst_1_expected = BURST_1_REQUESTS * spend_per_request - start = time.time() - while time.time() - start < 120: - key_info_check = await get_spend_info(session, "key", key) - current_spend = key_info_check["info"]["spend"] - if abs(current_spend - burst_1_expected) < TOLERANCE: - print( - f"Burst 1 spend reached expected {burst_1_expected} after {time.time() - start:.1f}s" - ) - break - print(f"Key spend {current_spend}, expected {burst_1_expected}, waiting...") - await asyncio.sleep(10) + burst_1_expected = compute_expected_spend(burst_1_responses) + assert burst_1_expected > 0, ( + f"Burst 1 expected spend is {burst_1_expected}. " + f"Usage: {[r.usage.model_dump() for r in burst_1_responses]}" + ) + print(f"Burst 1 expected spend: {burst_1_expected}") - # Check intermediate spend - intermediate_key_info = await get_spend_info(session, "key", key) - print(f"After Burst 1 - Key spend: {intermediate_key_info['info']['spend']}") + final_burst_1 = await poll_key_spend_until(session, key, burst_1_expected) + if abs(final_burst_1 - burst_1_expected) >= TOLERANCE: + await fail_with_diagnostics( + session, + stage="test_long_term_spend_accuracy burst 1", + expected=burst_1_expected, + observed=final_burst_1, + ) - # Second burst print(f"Starting second burst of {BURST_2_REQUESTS} requests...") + burst_2_responses = [] for i in range(BURST_2_REQUESTS): response = await chat_completion(session, key) + burst_2_responses.append(response) print(f"Burst 2 - Request {i + 1}/{BURST_2_REQUESTS} completed") - # Poll until key spend reaches expected total (burst 1 + burst 2) - start = time.time() - while time.time() - start < 120: - key_info_check = await get_spend_info(session, "key", key) - current_spend = key_info_check["info"]["spend"] - if abs(current_spend - expected_spend) < TOLERANCE: - print( - f"Total spend reached expected {expected_spend} after {time.time() - start:.1f}s" - ) - break - print(f"Key spend {current_spend}, expected {expected_spend}, waiting...") - await asyncio.sleep(10) + total_expected = burst_1_expected + compute_expected_spend(burst_2_responses) + print(f"Total expected spend (burst 1 + burst 2): {total_expected}") + + final_total = await poll_key_spend_until(session, key, total_expected) + if abs(final_total - total_expected) >= TOLERANCE: + await fail_with_diagnostics( + session, + stage="test_long_term_spend_accuracy total", + expected=total_expected, + observed=final_total, + ) - # Allow extra time for all entity spend aggregations await asyncio.sleep(5) - # Get final spend information for each entity key_info = await get_spend_info(session, "key", key) team_info = await get_spend_info(session, "team", team_id) user_info = await get_spend_info(session, "user", user_id) @@ -341,19 +378,18 @@ async def test_long_term_spend_accuracy_with_bursts(): print(f"Final user spend: {user_info['user_info']['spend']}") print(f"Final org spend: {org_info['spend']}") - # Verify total spend for each entity assert ( - abs(key_info["info"]["spend"] - expected_spend) < TOLERANCE - ), f"Key spend {key_info['info']['spend']} does not match expected {expected_spend}" + abs(key_info["info"]["spend"] - total_expected) < TOLERANCE + ), f"Key spend {key_info['info']['spend']} does not match expected {total_expected}" assert ( - abs(user_info["user_info"]["spend"] - expected_spend) < TOLERANCE - ), f"User spend {user_info['user_info']['spend']} does not match expected {expected_spend}" + abs(user_info["user_info"]["spend"] - total_expected) < TOLERANCE + ), f"User spend {user_info['user_info']['spend']} does not match expected {total_expected}" assert ( - abs(team_info["team_info"]["spend"] - expected_spend) < TOLERANCE - ), f"Team spend {team_info['team_info']['spend']} does not match expected {expected_spend}" + abs(team_info["team_info"]["spend"] - total_expected) < TOLERANCE + ), f"Team spend {team_info['team_info']['spend']} does not match expected {total_expected}" assert ( - abs(org_info["spend"] - expected_spend) < TOLERANCE - ), f"Organization spend {org_info['spend']} does not match expected {expected_spend}" + abs(org_info["spend"] - total_expected) < TOLERANCE + ), f"Organization spend {org_info['spend']} does not match expected {total_expected}" diff --git a/tests/test_litellm/images/test_image_edit_utils.py b/tests/test_litellm/images/test_image_edit_utils.py index e0584afb81c..2146c1fab01 100644 --- a/tests/test_litellm/images/test_image_edit_utils.py +++ b/tests/test_litellm/images/test_image_edit_utils.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from unittest.mock import MagicMock, patch import pytest @@ -26,7 +26,12 @@ class MockImageEditConfig(BaseImageEditConfig): return "https://example.com/api" def validate_environment( - self, headers: dict, model: str, api_key: str = None + self, + headers: dict, + model: str, + api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: return headers @@ -262,3 +267,141 @@ class TestImageEditCustomPricing: def test_custom_pricing_not_detected_without_model_info(self): litellm_params = {"litellm_call_id": "test-call-id"} assert use_custom_pricing_for_model(litellm_params) is False + + +class TestImageEditHandlerCredentialsForwarding: + """ + Regression tests for Vertex AI image_edit credentials bug. + + image_edit handler must forward litellm_params to validate_environment, + so that credentials passed via YAML config (vertex_ai_project, + vertex_ai_credentials, etc.) reach the auth layer instead of falling + through to Application Default Credentials. + """ + + def test_vertex_gemini_image_edit_reads_credentials_from_litellm_params(self): + """ + VertexAIGeminiImageEditConfig.validate_environment should read + vertex_ai_project/vertex_ai_credentials from litellm_params first. + """ + from litellm.llms.vertex_ai.image_edit.vertex_gemini_transformation import ( + VertexAIGeminiImageEditConfig, + ) + + config = VertexAIGeminiImageEditConfig() + + litellm_params = { + "vertex_ai_project": "test-project-from-params", + "vertex_ai_credentials": "/path/to/creds.json", + } + + with patch.object( + config, "_ensure_access_token", return_value=("token", "project") + ) as mock_ensure: + config.validate_environment( + headers={}, + model="test-model", + litellm_params=litellm_params, + ) + + mock_ensure.assert_called_once() + call_kwargs = mock_ensure.call_args[1] + + assert call_kwargs["credentials"] == "/path/to/creds.json" + assert call_kwargs["project_id"] == "test-project-from-params" + + def test_vertex_imagen_image_edit_reads_credentials_from_litellm_params(self): + """ + VertexAIImagenImageEditConfig.validate_environment should read + vertex_ai_project/vertex_ai_credentials from litellm_params first. + """ + from litellm.llms.vertex_ai.image_edit.vertex_imagen_transformation import ( + VertexAIImagenImageEditConfig, + ) + + config = VertexAIImagenImageEditConfig() + + litellm_params = { + "vertex_ai_project": "test-project-from-params", + "vertex_ai_credentials": "/path/to/creds.json", + } + + with patch.object( + config, "_ensure_access_token", return_value=("token", "project") + ) as mock_ensure: + config.validate_environment( + headers={}, + model="test-model", + litellm_params=litellm_params, + ) + + mock_ensure.assert_called_once() + call_kwargs = mock_ensure.call_args[1] + + assert call_kwargs["credentials"] == "/path/to/creds.json" + assert call_kwargs["project_id"] == "test-project-from-params" + + def test_vertex_imagen_get_complete_url_reads_project_and_location_from_litellm_params( + self, + ): + """ + VertexAIImagenImageEditConfig.get_complete_url should read + vertex_ai_project and vertex_ai_location from litellm_params, + not only from env vars / global settings. + """ + from litellm.llms.vertex_ai.image_edit.vertex_imagen_transformation import ( + VertexAIImagenImageEditConfig, + ) + + config = VertexAIImagenImageEditConfig() + + litellm_params = { + "vertex_ai_project": "param-project", + "vertex_ai_location": "us-east1", + } + + url = config.get_complete_url( + model="vertex_ai/imagegeneration@002", + api_base=None, + litellm_params=litellm_params, + ) + + assert "param-project" in url + assert "us-east1" in url + + def test_validate_environment_signature_includes_litellm_params(self): + """ + All image_edit config validate_environment methods should accept + litellm_params to allow credentials to be forwarded from the handler. + """ + import inspect + + from litellm.llms.vertex_ai.image_edit.vertex_gemini_transformation import ( + VertexAIGeminiImageEditConfig, + ) + from litellm.llms.vertex_ai.image_edit.vertex_imagen_transformation import ( + VertexAIImagenImageEditConfig, + ) + from litellm.llms.openai.image_edit.transformation import ( + OpenAIImageEditConfig, + ) + + configs = [ + VertexAIGeminiImageEditConfig(), + VertexAIImagenImageEditConfig(), + OpenAIImageEditConfig(), + MockImageEditConfig(), + ] + + for config in configs: + sig = inspect.signature(config.validate_environment) + params = list(sig.parameters.keys()) + + assert "litellm_params" in params, ( + f"{config.__class__.__name__}.validate_environment " + "missing litellm_params parameter" + ) + assert "api_base" in params, ( + f"{config.__class__.__name__}.validate_environment " + "missing api_base parameter" + ) diff --git a/tests/test_litellm/integrations/compression_interception/test_compression_interception_handler.py b/tests/test_litellm/integrations/compression_interception/test_compression_interception_handler.py new file mode 100644 index 00000000000..56e5a94cd49 --- /dev/null +++ b/tests/test_litellm/integrations/compression_interception/test_compression_interception_handler.py @@ -0,0 +1,364 @@ +""" +Unit tests for Compression Interception Handler. +""" + +from unittest.mock import MagicMock + +import pytest + +from litellm.integrations.compression_interception.handler import ( + CompressionInterceptionLogger, +) +from litellm.types.utils import CallTypes + + +def test_initialize_from_proxy_config(): + """Test initialization from proxy config with litellm_settings.""" + litellm_settings = { + "compression_interception_params": { + "enabled": True, + "compression_trigger": 1234, + "compression_target": 789, + } + } + + logger = CompressionInterceptionLogger.initialize_from_proxy_config( + litellm_settings=litellm_settings, + callback_specific_params={}, + ) + + assert logger.enabled is True + assert logger.compression_trigger == 1234 + assert logger.compression_target == 789 + + +@pytest.mark.asyncio +async def test_pre_call_hook_compresses_messages_and_injects_tool(monkeypatch): + """Test pre-call hook compresses and stores per-call cache.""" + logger = CompressionInterceptionLogger() + compressed_result = { + "messages": [{"role": "user", "content": "stubbed"}], + "original_tokens": 12000, + "compressed_tokens": 5000, + "compression_ratio": 0.58, + "cache": {"auth.py": "full file content"}, + "tools": [ + { + "type": "function", + "function": { + "name": "litellm_content_retrieve", + "parameters": { + "type": "object", + "properties": {"key": {"type": "string"}}, + }, + }, + } + ], + } + + def _fake_compress(**kwargs): + return compressed_result + + # The handler does ``from litellm.compression import compress`` at module + # scope, so we must patch the binding on the handler module — patching + # ``litellm.compress`` has no effect on the already-bound reference. + monkeypatch.setattr( + "litellm.integrations.compression_interception.handler.compress", + _fake_compress, + ) + + kwargs = { + "model": "bedrock/us.anthropic.claude-sonnet-4-5", + "messages": [{"role": "user", "content": "very large context"}], + "tools": [ + { + "type": "function", + "function": {"name": "existing_tool", "parameters": {"type": "object"}}, + } + ], + } + + result = await logger.async_pre_call_deployment_hook( + kwargs=kwargs, call_type=CallTypes.anthropic_messages + ) + + assert result is not None + assert result["messages"] == compressed_result["messages"] + tool_names = [t.get("function", {}).get("name") for t in result["tools"]] + assert "existing_tool" in tool_names + assert "litellm_content_retrieve" in tool_names + assert result["litellm_call_id"] in logger._compression_cache_by_call_id + + +@pytest.mark.asyncio +async def test_pre_call_hook_below_trigger_does_not_inject_empty_tools(monkeypatch): + """ + When compression is a no-op (below trigger / invalid tool sequence), the + hook must NOT replace ``messages`` or inject an empty ``tools: []`` onto + a request that originally had no tools — Anthropic Messages rejects + ``tools: []``. + """ + logger = CompressionInterceptionLogger() + original_messages = [{"role": "user", "content": "short prompt"}] + + def _fake_compress_noop(**kwargs): + return { + "messages": original_messages, + "original_tokens": 42, + "compressed_tokens": 42, + "compression_ratio": 0.0, + "cache": {}, + "tools": [], + "compression_skipped_reason": "below_trigger", + } + + monkeypatch.setattr( + "litellm.integrations.compression_interception.handler.compress", + _fake_compress_noop, + ) + + kwargs = { + "model": "bedrock/us.anthropic.claude-sonnet-4-5", + "messages": original_messages, + } + + result = await logger.async_pre_call_deployment_hook( + kwargs=kwargs, call_type=CallTypes.anthropic_messages + ) + + assert result is not None + # Original request had no ``tools`` — skipped compression must leave it that way. + assert "tools" not in result + # Cache must not be populated for a no-op. + assert result.get("litellm_call_id") not in logger._compression_cache_by_call_id + + +@pytest.mark.asyncio +async def test_should_run_agentic_loop_detects_retrieval_tool_use(): + """Test should-run hook returns tool calls for retrieval tool_use blocks.""" + logger = CompressionInterceptionLogger() + response = { + "content": [ + { + "type": "tool_use", + "id": "toolu_123", + "name": "litellm_content_retrieve", + "input": {"key": "auth.py"}, + } + ] + } + + should_run, tools_dict = await logger.async_should_run_agentic_loop( + response=response, + model="bedrock/claude", + messages=[], + tools=[ + { + "type": "function", + "function": { + "name": "litellm_content_retrieve", + "parameters": {"type": "object"}, + }, + } + ], + stream=False, + custom_llm_provider="bedrock", + kwargs={}, + ) + + assert should_run is True + assert len(tools_dict["tool_calls"]) == 1 + assert tools_dict["tool_calls"][0]["input"]["key"] == "auth.py" + + +@pytest.mark.asyncio +async def test_build_agentic_loop_plan_returns_request_patch(): + """Callback should return typed patch with tool_result content.""" + logger = CompressionInterceptionLogger() + call_id = "call_123" + logger._compression_cache_by_call_id[call_id] = ( + {"auth.py": "full auth file"}, + 9999999999.0, + ) + + logging_obj = MagicMock() + logging_obj.litellm_call_id = call_id + logging_obj.model_call_details = { + "agentic_loop_params": {"model": "bedrock/invoke/claude-3-5-sonnet"} + } + + plan = await logger.async_build_agentic_loop_plan( + tools={ + "tool_calls": [ + { + "id": "toolu_abc", + "type": "tool_use", + "name": "litellm_content_retrieve", + "input": {"key": "auth.py"}, + } + ] + }, + model="claude-3-5-sonnet", + messages=[{"role": "user", "content": "read auth.py"}], + response=None, + anthropic_messages_provider_config=None, + anthropic_messages_optional_request_params={ + "max_tokens": 1024, + "tools": [{"name": "litellm_content_retrieve"}], + }, + logging_obj=logging_obj, + stream=False, + kwargs={ + "temperature": 0.1, + "_compression_interception_internal": True, + "litellm_logging_obj": object(), + }, + ) + + assert plan.run_agentic_loop is True + assert plan.request_patch is not None + assert plan.request_patch.model == "bedrock/invoke/claude-3-5-sonnet" + assert plan.request_patch.max_tokens == 1024 + assert plan.request_patch.messages is not None + assert len(plan.request_patch.messages) == 3 + tool_result_content = plan.request_patch.messages[-1]["content"][0]["content"] + assert tool_result_content == "full auth file" + assert "_compression_interception_internal" not in plan.request_patch.kwargs + assert "litellm_logging_obj" not in plan.request_patch.kwargs + assert plan.request_patch.kwargs["temperature"] == 0.1 + assert "max_tokens" not in plan.request_patch.optional_params + + +@pytest.mark.asyncio +async def test_should_run_agentic_loop_with_custom_type_tools(): + """Test that async_should_run_agentic_loop returns True when tools contain + litellm_content_retrieve as a custom-typed tool (e.g. Claude Code tool list) + and the model response includes a matching tool_use block.""" + logger = CompressionInterceptionLogger() + + # Exact tools payload produced by Claude Code – litellm_content_retrieve is + # the final entry and uses type="custom" (not type="function"). + tools = [ + { + "name": "Agent", + "description": "Launch a new agent to handle complex, multi-step tasks.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "description": {"type": "string"}, + "prompt": {"type": "string"}, + }, + "required": ["description", "prompt"], + "additionalProperties": False, + }, + }, + { + "name": "AskUserQuestion", + "description": "Use this tool when you need to ask the user questions.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "questions": {"type": "array", "items": {"type": "object"}}, + }, + "required": ["questions"], + "additionalProperties": False, + }, + }, + { + "name": "Bash", + "description": "Executes a given bash command and returns its output.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {"command": {"type": "string"}}, + "required": ["command"], + "additionalProperties": False, + }, + }, + { + "name": "litellm_content_retrieve", + "description": "Retrieve the full content of a file or message that was compressed to save tokens.", + "input_schema": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The identifier of the content to retrieve", + "enum": [ + "message_0", + "HA_UPTIME_ROUTER_SPEC.md", + "message_159", + "message_160", + ], + } + }, + "required": ["key"], + }, + "type": "custom", + }, + ] + + response = { + "content": [ + { + "type": "tool_use", + "id": "toolu_abc", + "name": "litellm_content_retrieve", + "input": {"key": "message_0"}, + } + ] + } + + should_run, tools_dict = await logger.async_should_run_agentic_loop( + response=response, + model="claude-3-5-sonnet", + messages=[], + tools=tools, + stream=False, + custom_llm_provider="anthropic", + kwargs={}, + ) + + assert should_run is True + assert tools_dict["tool_type"] == "compression_retrieval" + assert len(tools_dict["tool_calls"]) == 1 + assert tools_dict["tool_calls"][0]["input"]["key"] == "message_0" + + +@pytest.mark.asyncio +async def test_build_agentic_loop_plan_missing_key_fallback(): + """Missing cache keys should produce deterministic fallback content.""" + logger = CompressionInterceptionLogger() + + logging_obj = MagicMock() + logging_obj.litellm_call_id = "missing_call" + logging_obj.model_call_details = {"agentic_loop_params": {}} + + plan = await logger.async_build_agentic_loop_plan( + tools={ + "tool_calls": [ + { + "id": "toolu_missing", + "type": "tool_use", + "name": "litellm_content_retrieve", + "input": {"key": "not_found.py"}, + } + ] + }, + model="claude-3-5-sonnet", + messages=[{"role": "user", "content": "read file"}], + response=None, + anthropic_messages_provider_config=None, + anthropic_messages_optional_request_params={}, + logging_obj=logging_obj, + stream=False, + kwargs={}, + ) + + assert plan.request_patch is not None + assert ( + plan.request_patch.messages[-1]["content"][0]["content"] + == "[compressed content key 'not_found.py' not found]" + ) diff --git a/tests/test_litellm/integrations/test_custom_guardrail.py b/tests/test_litellm/integrations/test_custom_guardrail.py index 0c904e9df50..d09c4ac2c38 100644 --- a/tests/test_litellm/integrations/test_custom_guardrail.py +++ b/tests/test_litellm/integrations/test_custom_guardrail.py @@ -1055,3 +1055,50 @@ class TestTracingFieldsPopulation: assert slg["classification"] == classification assert slg["detection_method"] == "llm-judge" assert slg["confidence_score"] == 0.94 + + +class TestCustomGuardrailSpendLogMatchRedaction: + """Guardrail JSON persisted via standard_logging must not contain raw match spans.""" + + def test_add_standard_logging_redacts_nested_match(self): + cg = CustomGuardrail(guardrail_name="test-rail") + raw = { + "assessments": [ + { + "sensitiveInformationPolicy": { + "piiEntities": [ + {"type": "NAME", "match": "GG", "action": "BLOCKED"} + ] + } + } + ] + } + request_data: dict = {"metadata": {}} + cg.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response=raw, + request_data=request_data, + guardrail_status="guardrail_intervened", + ) + slg = request_data["metadata"]["standard_logging_guardrail_information"][0] + assert ( + slg["guardrail_response"]["assessments"][0]["sensitiveInformationPolicy"][ + "piiEntities" + ][0]["match"] + == "[REDACTED]" + ) + assert raw["assessments"][0]["sensitiveInformationPolicy"]["piiEntities"][0][ + "match" + ] == "GG" + + def test_add_standard_logging_redacts_regex_field(self): + cg = CustomGuardrail(guardrail_name="test-rail") + raw = {"filters": [{"regex": r"\d{3}-\d{2}-\d{4}", "action": "BLOCKED"}]} + request_data: dict = {"metadata": {}} + cg.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response=raw, + request_data=request_data, + guardrail_status="success", + ) + slg = request_data["metadata"]["standard_logging_guardrail_information"][0] + assert slg["guardrail_response"]["filters"][0]["regex"] == "[REDACTED]" + assert raw["filters"][0]["regex"] == r"\d{3}-\d{2}-\d{4}" diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index 725836e1340..f7106471894 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -1047,6 +1047,36 @@ class TestOpenTelemetryEndpointNormalization(unittest.TestCase): result = otel._normalize_otel_endpoint("http://collector:4318/", "traces") self.assertEqual(result, "http://collector:4318/v1/traces") + @parameterized.expand( + [ + ( + "https://ingest.eu1.observability.splunkcloud.com/v2/trace/otlp", + "https://ingest.eu1.observability.splunkcloud.com/v2/trace/otlp", + ), + ( + "https://ingest.us0.observability.splunkcloud.com/v2/trace/otlp/", + "https://ingest.us0.observability.splunkcloud.com/v2/trace/otlp", + ), + ( + "https://ingest.eu0.signalfx.com/v2/trace/otlp", + "https://ingest.eu0.signalfx.com/v2/trace/otlp", + ), + ( + "https://example.com/prefix/v2/trace/otlp", + "https://example.com/prefix/v2/trace/otlp", + ), + ] + ) + def test_normalize_traces_nonstandard_otlp_ingest_urls_unchanged( + self, input_url: str, expected: str + ) -> None: + """Splunk-style /v2/trace/otlp endpoints must not get /v1/traces appended.""" + otel = OpenTelemetry() + self.assertEqual( + otel._normalize_otel_endpoint(input_url, "traces"), + expected, + ) + def test_normalize_endpoint_none(self): """Test that None endpoint returns None""" otel = OpenTelemetry() @@ -1315,7 +1345,7 @@ class TestOpenTelemetryProtocolSelection(unittest.TestCase): @patch.dict( os.environ, { - "OTEL_EXPORTER": "otlp_http", + "OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4318", }, clear=False, @@ -1339,7 +1369,7 @@ class TestOpenTelemetryProtocolSelection(unittest.TestCase): @patch.dict( os.environ, { - "OTEL_EXPORTER": "otlp_grpc", + "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4317", }, clear=False, @@ -1360,6 +1390,60 @@ class TestOpenTelemetryProtocolSelection(unittest.TestCase): self.assertIsInstance(processor, BatchSpanProcessor) self.assertIsInstance(processor.span_exporter, OTLPSpanExporterGRPC) + @patch.dict( + os.environ, + { + "OTEL_EXPORTER": "otlp_http", + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4318", + }, + clear=False, + ) + def test_protocol_selection_from_otel_exporter_fallback_http(self): + """OTEL_EXPORTER drives protocol when OTEL_EXPORTER_OTLP_PROTOCOL is unset.""" + from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter as OTLPSpanExporterHTTP, + ) + from opentelemetry.sdk.trace.export import BatchSpanProcessor + + popped_protocol = os.environ.pop("OTEL_EXPORTER_OTLP_PROTOCOL", None) + try: + config = OpenTelemetryConfig.from_env() + self.assertEqual(config.exporter, "otlp_http") + otel = OpenTelemetry(config=config) + processor = otel._get_span_processor() + self.assertIsInstance(processor, BatchSpanProcessor) + self.assertIsInstance(processor.span_exporter, OTLPSpanExporterHTTP) + finally: + if popped_protocol is not None: + os.environ["OTEL_EXPORTER_OTLP_PROTOCOL"] = popped_protocol + + @patch.dict( + os.environ, + { + "OTEL_EXPORTER": "otlp_grpc", + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4317", + }, + clear=False, + ) + def test_protocol_selection_from_otel_exporter_fallback_grpc(self): + """OTEL_EXPORTER drives protocol when OTEL_EXPORTER_OTLP_PROTOCOL is unset.""" + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter as OTLPSpanExporterGRPC, + ) + from opentelemetry.sdk.trace.export import BatchSpanProcessor + + popped_protocol = os.environ.pop("OTEL_EXPORTER_OTLP_PROTOCOL", None) + try: + config = OpenTelemetryConfig.from_env() + self.assertEqual(config.exporter, "otlp_grpc") + otel = OpenTelemetry(config=config) + processor = otel._get_span_processor() + self.assertIsInstance(processor, BatchSpanProcessor) + self.assertIsInstance(processor.span_exporter, OTLPSpanExporterGRPC) + finally: + if popped_protocol is not None: + os.environ["OTEL_EXPORTER_OTLP_PROTOCOL"] = popped_protocol + def test_http_exporter_endpoint_normalization_for_traces(self): """Test that HTTP trace exporter gets properly normalized endpoint""" config = OpenTelemetryConfig( @@ -2752,3 +2836,26 @@ class TestResponseIdFallback(unittest.TestCase): mock_span.set_attribute.assert_any_call( "gen_ai.response.id", "litellm-img-call-101" ) + + def test_litellm_call_id_emitted_as_span_attribute(self): + """litellm.call_id must be set on the span from standard_logging_payload.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + call_id = "my-litellm-call-uuid-456" + kwargs = { + "model": "gpt-4o", + "optional_params": {}, + "litellm_params": {"custom_llm_provider": "openai"}, + "standard_logging_object": { + "id": "chatcmpl-provider-id", + "litellm_call_id": call_id, + "call_type": "completion", + "metadata": {}, + }, + } + response_obj = {"id": "chatcmpl-provider-id", "model": "gpt-4o"} + + otel.set_attributes(mock_span, kwargs, response_obj) + + mock_span.set_attribute.assert_any_call("litellm.call_id", call_id) diff --git a/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py b/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py index c8617a3c1b1..10951265115 100644 --- a/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py +++ b/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py @@ -4,7 +4,7 @@ Unit tests for WebSearch Interception Handler Tests the WebSearchInterceptionLogger class and helper functions. """ -from unittest.mock import MagicMock, Mock +from unittest.mock import AsyncMock, MagicMock, Mock import pytest @@ -69,6 +69,61 @@ async def test_async_should_run_agentic_loop(): assert tools_dict == {} +@pytest.mark.asyncio +async def test_async_build_agentic_loop_plan_returns_request_patch(): + """Callback should return a typed patch for base handler reruns.""" + logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"]) + logger._execute_search = AsyncMock( # type: ignore + return_value="Title: LiteLLM\nURL: docs\nSnippet: test" + ) + + tools_dict = { + "tool_calls": [ + { + "id": "toolu_123", + "type": "tool_use", + "name": "litellm_web_search", + "input": {"query": "what is litellm"}, + } + ], + "response_format": "anthropic", + } + logging_obj = MagicMock() + logging_obj.model_call_details = { + "agentic_loop_params": {"model": "bedrock/invoke/claude-3-5-sonnet"} + } + kwargs = { + "temperature": 0.2, + "_websearch_interception_converted_stream": True, + "litellm_logging_obj": object(), + } + + plan = await logger.async_build_agentic_loop_plan( + tools=tools_dict, + model="claude-3-5-sonnet", + messages=[{"role": "user", "content": "search LiteLLM"}], + response=None, + anthropic_messages_provider_config=None, + anthropic_messages_optional_request_params={ + "max_tokens": 1024, + "tools": [{"name": "litellm_web_search"}], + }, + logging_obj=logging_obj, + stream=False, + kwargs=kwargs, + ) + + assert plan.run_agentic_loop is True + assert plan.request_patch is not None + assert plan.request_patch.model == "bedrock/invoke/claude-3-5-sonnet" + assert plan.request_patch.max_tokens == 1024 + assert plan.request_patch.messages is not None + assert len(plan.request_patch.messages) == 3 + assert "_websearch_interception_converted_stream" not in plan.request_patch.kwargs + assert "litellm_logging_obj" not in plan.request_patch.kwargs + assert plan.request_patch.kwargs["temperature"] == 0.2 + + @pytest.mark.asyncio async def test_internal_flags_filtered_from_followup_kwargs(): """Test that internal _websearch_interception flags are filtered from follow-up request kwargs. diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index 91b2c49d2b8..7144279ad0c 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -328,6 +328,43 @@ def test_generic_cost_per_token_gpt54_above_272k_tokens(): assert round(completion_cost, 10) == round(expected_completion, 10) +def test_generic_cost_per_token_gpt55(): + """gpt-5.5: base pricing — $5/1M input, $30/1M output, $0.50/1M cached input.""" + model = "gpt-5.5" + custom_llm_provider = "openai" + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + + model_cost_map = litellm.model_cost[model] + + # Sanity-check the map values match OpenAI's published pricing. + assert model_cost_map["input_cost_per_token"] == 5e-6 + assert model_cost_map["output_cost_per_token"] == 3e-5 + assert model_cost_map["cache_read_input_token_cost"] == 5e-7 + assert model_cost_map["litellm_provider"] == "openai" + assert model_cost_map["mode"] == "chat" + assert model_cost_map["max_input_tokens"] == 272000 + + prompt_tokens = 1000 + completion_tokens = 500 + usage = Usage( + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=prompt_tokens + completion_tokens, + ) + prompt_cost, completion_cost = generic_cost_per_token( + model=model, + usage=usage, + custom_llm_provider=custom_llm_provider, + ) + assert round(prompt_cost, 10) == round( + model_cost_map["input_cost_per_token"] * prompt_tokens, 10 + ) + assert round(completion_cost, 10) == round( + model_cost_map["output_cost_per_token"] * completion_tokens, 10 + ) + + def test_generic_cost_per_token_anthropic_prompt_caching(): model = "claude-sonnet-4@20250514" usage = Usage( diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py index 8c34a50c4fa..22d2610eecb 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py @@ -11,6 +11,7 @@ sys.path.insert( from litellm.litellm_core_utils.prompt_templates.common_utils import ( add_system_prompt_to_messages, + get_file_ids_from_messages, get_format_from_file_id, handle_any_messages_to_chat_completion_str_messages_conversion, split_concatenated_json_objects, @@ -254,3 +255,115 @@ def test_split_concatenated_json_invalid_raises(): """Completely invalid JSON raises JSONDecodeError.""" with pytest.raises(json.JSONDecodeError): split_concatenated_json_objects("not json at all") + + +# --------------------------------------------------------------------------- +# Regression tests for non-OpenAI file content blocks. +# +# `type: "file"` is a public content-block discriminator. Several producers +# (LangChain v1, provider-native shapes, custom user code) emit blocks with +# `type: "file"` but without the OpenAI Chat Completions `file` sub-dict. +# The discovery helpers below are used unconditionally inside +# `AnthropicConfig.validate_environment`, so any crash there surfaces as a +# `500 InternalServerError` before the request is even dispatched. +# --------------------------------------------------------------------------- + + +def test_get_file_ids_from_messages_skips_langchain_v1_file_block(): + """A LangChain v1 standardized file block must not crash file-id discovery.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "summarise this PDF"}, + # LangChain v1 shape produced by `_normalize_messages`. + # No `file` sub-dict: the discriminator is `type: "file"` but + # the payload lives on `base64`/`mime_type` siblings. + { + "type": "file", + "id": "lc_1", + "base64": "JVBERi0xLjQK", + "mime_type": "application/pdf", + "extras": {"file_format": "application/pdf"}, + }, + ], + } + ] + + assert get_file_ids_from_messages(messages) == [] + + +def test_get_file_ids_from_messages_still_extracts_from_openai_shape(): + """Well-formed OpenAI file blocks still yield their file_id.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "what is this?"}, + {"type": "file", "file": {"file_id": "file-abc"}}, + ], + } + ] + + assert get_file_ids_from_messages(messages) == ["file-abc"] + + +def test_get_file_ids_from_messages_mixed_shapes(): + """Mixed OpenAI and non-OpenAI file blocks: extract from the former, + ignore the latter.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "file", "file": {"file_id": "file-keep"}}, + { + "type": "file", + "id": "lc_2", + "base64": "AAA", + "mime_type": "application/pdf", + }, + ], + } + ] + + assert get_file_ids_from_messages(messages) == ["file-keep"] + + +def test_get_file_ids_from_messages_file_field_not_dict(): + """`file` set to a non-dict value (e.g. stringified payload) must not crash.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "file", "file": "unexpectedly-a-string"}, + ], + } + ] + + assert get_file_ids_from_messages(messages) == [] + + +def test_update_messages_with_model_file_ids_skips_non_openai_file_blocks(): + """`update_messages_with_model_file_ids` is also called on user content + before provider dispatch. It must tolerate non-OpenAI file blocks the same + way.""" + langchain_v1_block = { + "type": "file", + "id": "lc_3", + "base64": "AAA", + "mime_type": "application/pdf", + } + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "hello"}, + langchain_v1_block, + ], + } + ] + + updated = update_messages_with_model_file_ids(messages, "model-1", {}) + + # Messages pass through unchanged when there is no `file` sub-dict to remap. + assert updated == messages diff --git a/tests/test_litellm/litellm_core_utils/test_core_helpers.py b/tests/test_litellm/litellm_core_utils/test_core_helpers.py index 6f95a8b6038..aa5ce5fa6a4 100644 --- a/tests/test_litellm/litellm_core_utils/test_core_helpers.py +++ b/tests/test_litellm/litellm_core_utils/test_core_helpers.py @@ -6,6 +6,7 @@ from litellm.litellm_core_utils.core_helpers import ( _FINISH_REASON_MAP, map_finish_reason, reconstruct_model_name, + redact_nested_match_and_regex_keys, ) @@ -158,3 +159,37 @@ class TestFinishReasonMapOutputsAreValid: f"Mapped value '{openai_reason}' (from '{provider_reason}') " f"is not a valid OpenAI finish reason" ) + + +class TestRedactNestedMatchAndRegexKeys: + def test_redacts_match_and_regex_recursively(self): + payload = { + "assessments": [ + { + "sensitiveInformationPolicy": { + "piiEntities": [ + {"type": "NAME", "match": "secret-name", "action": "BLOCKED"} + ] + }, + "wordPolicy": { + "customWords": [{"match": "badword", "action": "BLOCKED"}] + }, + } + ], + "regex": "should-redact-key-named-regex", + } + out = redact_nested_match_and_regex_keys(payload) + assert out["assessments"][0]["sensitiveInformationPolicy"]["piiEntities"][0][ + "match" + ] == "[REDACTED]" + assert out["assessments"][0]["wordPolicy"]["customWords"][0]["match"] == ( + "[REDACTED]" + ) + assert out["regex"] == "[REDACTED]" + assert payload["assessments"][0]["sensitiveInformationPolicy"]["piiEntities"][ + 0 + ]["match"] == "secret-name" + + def test_passes_through_none_and_str(self): + assert redact_nested_match_and_regex_keys(None) is None + assert redact_nested_match_and_regex_keys("plain") == "plain" diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index c3849e5869a..cf7be6bf1c7 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -2410,3 +2410,29 @@ def test_get_additional_headers_reset_fields_preserved(): assert result is not None assert result["x_ratelimit_reset_requests"] == "1s" # type: ignore assert result["x_ratelimit_reset_tokens"] == "100ms" # type: ignore + + +# ── litellm_call_id propagation ─────────────────────────────────────────────── + + +def test_get_standard_logging_object_payload_includes_litellm_call_id(logging_obj): + """litellm_call_id from kwargs must appear in the returned StandardLoggingPayload.""" + import datetime + + from litellm.litellm_core_utils.litellm_logging import ( + get_standard_logging_object_payload, + ) + + call_id = "test-call-id-abc-123" + now = datetime.datetime.now() + payload = get_standard_logging_object_payload( + kwargs={"litellm_call_id": call_id, "model": "gpt-4o", "messages": []}, + init_response_obj={}, + start_time=now, + end_time=now, + logging_obj=logging_obj, + status="success", + ) + + assert payload is not None + assert payload["litellm_call_id"] == call_id diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index e6e96868f33..670388b7c03 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -2162,16 +2162,19 @@ class TestTranslateAnthropicOutputFormatToOpenAI: assert sorted(schema["required"]) == ["age", "email", "name"] def test_invalid_output_format_returns_none(self): - assert ( - self.adapter.translate_anthropic_output_format_to_openai("invalid") is None - ) - assert ( - self.adapter.translate_anthropic_output_format_to_openai({"type": "text"}) - is None - ) - assert ( - self.adapter.translate_anthropic_output_format_to_openai( - {"type": "json_schema"} - ) - is None - ) + assert self.adapter.translate_anthropic_output_format_to_openai("invalid") is None + assert self.adapter.translate_anthropic_output_format_to_openai({"type": "text"}) is None + assert self.adapter.translate_anthropic_output_format_to_openai({"type": "json_schema"}) is None + + +def test_translate_anthropic_tool_choice_none(): + """ + Regression test for issue #24443. + + tool_choice={"type": "none"} should be translated to "none" for OpenAI format, + not raise a ValueError. + """ + adapter = LiteLLMAnthropicMessagesAdapter() + + result = adapter.translate_anthropic_tool_choice_to_openai({"type": "none"}) + assert result == "none" diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_agentic_streaming_iterator.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_agentic_streaming_iterator.py new file mode 100644 index 00000000000..b9bda07336f --- /dev/null +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_agentic_streaming_iterator.py @@ -0,0 +1,792 @@ +""" +Tests for AgenticAnthropicStreamingIterator and SSE rebuild helpers. +""" + +import json +import os +import sys +from typing import Any, Dict, List, Optional, Tuple +from unittest.mock import AsyncMock, MagicMock + +import pytest + +sys.path.insert(0, os.path.abspath("../../../../..")) + +from litellm.llms.anthropic.experimental_pass_through.messages.agentic_streaming_iterator import ( + AgenticAnthropicStreamingIterator, + _handle_content_block_delta, + _handle_content_block_start, + _handle_content_block_stop, + _handle_message_delta, + _handle_message_start, + _parse_sse_events, +) + + +# --------------------------------------------------------------------------- +# Helpers to build SSE byte payloads +# --------------------------------------------------------------------------- + + +def _sse_event(event_type: str, data: dict) -> bytes: + return f"event: {event_type}\ndata: {json.dumps(data)}\n\n".encode() + + +def _build_simple_text_stream() -> List[bytes]: + """Produce SSE bytes for a simple text response (no tool calls).""" + chunks = [] + chunks.append( + _sse_event( + "message_start", + { + "type": "message_start", + "message": { + "id": "msg_123", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-20250514", + "content": [], + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 10, "output_tokens": 0}, + }, + }, + ) + ) + chunks.append( + _sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ) + ) + chunks.append( + _sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "Hello, world!"}, + }, + ) + ) + chunks.append( + _sse_event("content_block_stop", {"type": "content_block_stop", "index": 0}) + ) + chunks.append( + _sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 5}, + }, + ) + ) + chunks.append(_sse_event("message_stop", {"type": "message_stop"})) + return chunks + + +def _build_tool_use_stream() -> List[bytes]: + """Produce SSE bytes for a response with a tool_use block.""" + chunks = [] + chunks.append( + _sse_event( + "message_start", + { + "type": "message_start", + "message": { + "id": "msg_tool_456", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-20250514", + "content": [], + "stop_reason": None, + "usage": {"input_tokens": 50, "output_tokens": 0}, + }, + }, + ) + ) + # thinking block + chunks.append( + _sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "thinking", + "thinking": "", + "signature": "", + }, + }, + ) + ) + chunks.append( + _sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "thinking_delta", + "thinking": "I need to retrieve...", + }, + }, + ) + ) + chunks.append( + _sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "signature_delta", "signature": "sig_abc"}, + }, + ) + ) + chunks.append( + _sse_event("content_block_stop", {"type": "content_block_stop", "index": 0}) + ) + # tool_use block + chunks.append( + _sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 1, + "content_block": { + "type": "tool_use", + "id": "toolu_001", + "name": "litellm_content_retrieve", + "input": {}, + }, + }, + ) + ) + chunks.append( + _sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 1, + "delta": { + "type": "input_json_delta", + "partial_json": '{"key": "section_', + }, + }, + ) + ) + chunks.append( + _sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "input_json_delta", "partial_json": '1"}'}, + }, + ) + ) + chunks.append( + _sse_event("content_block_stop", {"type": "content_block_stop", "index": 1}) + ) + chunks.append( + _sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "tool_use"}, + "usage": {"output_tokens": 20}, + }, + ) + ) + chunks.append(_sse_event("message_stop", {"type": "message_stop"})) + return chunks + + +# --------------------------------------------------------------------------- +# Mock async stream +# --------------------------------------------------------------------------- + + +class MockAsyncStream: + """Async iterator that yields a list of byte chunks.""" + + def __init__(self, chunks: List[bytes]): + self._chunks = list(chunks) + self._idx = 0 + + def __aiter__(self): + return self + + async def __anext__(self) -> bytes: + if self._idx >= len(self._chunks): + raise StopAsyncIteration + chunk = self._chunks[self._idx] + self._idx += 1 + return chunk + + +# --------------------------------------------------------------------------- +# Tests for _parse_sse_events +# --------------------------------------------------------------------------- + + +class TestParseSSEEvents: + def test_should_parse_single_event(self): + raw = _sse_event( + "message_start", {"type": "message_start", "message": {"id": "1"}} + ) + events = _parse_sse_events(raw) + assert len(events) == 1 + assert events[0][0] == "message_start" + assert events[0][1]["message"]["id"] == "1" + + def test_should_parse_multiple_events(self): + raw = b"".join(_build_simple_text_stream()) + events = _parse_sse_events(raw) + event_types = [e[0] for e in events] + assert "message_start" in event_types + assert "content_block_start" in event_types + assert "content_block_delta" in event_types + assert "content_block_stop" in event_types + assert "message_delta" in event_types + assert "message_stop" in event_types + + def test_should_skip_malformed_json(self): + raw = b"event: message_start\ndata: {invalid json}\n\n" + events = _parse_sse_events(raw) + assert len(events) == 0 + + def test_should_handle_empty_bytes(self): + events = _parse_sse_events(b"") + assert events == [] + + +# --------------------------------------------------------------------------- +# Tests for _handle_* helpers +# --------------------------------------------------------------------------- + + +class TestHandleMessageStart: + def test_should_populate_envelope(self): + response: Dict[str, Any] = { + "id": "", + "model": "", + "role": "assistant", + "usage": {"input_tokens": 0, "output_tokens": 0}, + } + data = { + "message": { + "id": "msg_abc", + "model": "claude-sonnet-4-20250514", + "role": "assistant", + "usage": { + "input_tokens": 42, + "cache_creation_input_tokens": 100, + }, + } + } + _handle_message_start(data, response) + assert response["id"] == "msg_abc" + assert response["model"] == "claude-sonnet-4-20250514" + assert response["usage"]["input_tokens"] == 42 + assert response["usage"]["cache_creation_input_tokens"] == 100 + + +class TestHandleContentBlockStart: + def test_should_create_text_block(self): + blocks: Dict[int, Dict] = {} + data = {"index": 0, "content_block": {"type": "text", "text": ""}} + _handle_content_block_start(data, blocks) + assert blocks[0] == {"type": "text", "text": ""} + + def test_should_create_tool_use_block(self): + blocks: Dict[int, Dict] = {} + data = { + "index": 1, + "content_block": { + "type": "tool_use", + "id": "toolu_x", + "name": "my_tool", + "input": {}, + }, + } + _handle_content_block_start(data, blocks) + assert blocks[1]["type"] == "tool_use" + assert blocks[1]["name"] == "my_tool" + assert blocks[1]["_partial_json"] == "" + + def test_should_create_thinking_block(self): + blocks: Dict[int, Dict] = {} + data = { + "index": 0, + "content_block": {"type": "thinking", "thinking": "", "signature": ""}, + } + _handle_content_block_start(data, blocks) + assert blocks[0]["type"] == "thinking" + + +class TestHandleContentBlockDelta: + def test_should_accumulate_text(self): + blocks = {0: {"type": "text", "text": "Hello"}} + _handle_content_block_delta( + {"index": 0, "delta": {"type": "text_delta", "text": " World"}}, + blocks, + ) + assert blocks[0]["text"] == "Hello World" + + def test_should_accumulate_json(self): + blocks = {0: {"type": "tool_use", "_partial_json": '{"key":'}} + _handle_content_block_delta( + { + "index": 0, + "delta": {"type": "input_json_delta", "partial_json": '"val"}'}, + }, + blocks, + ) + assert blocks[0]["_partial_json"] == '{"key":"val"}' + + def test_should_ignore_missing_block(self): + blocks: Dict[int, Dict] = {} + _handle_content_block_delta( + {"index": 99, "delta": {"type": "text_delta", "text": "x"}}, + blocks, + ) + assert 99 not in blocks + + +class TestHandleContentBlockStop: + def test_should_parse_tool_input_json(self): + blocks = { + 0: { + "type": "tool_use", + "input": {}, + "_partial_json": '{"key": "section_1"}', + } + } + _handle_content_block_stop({"index": 0}, blocks) + assert blocks[0]["input"] == {"key": "section_1"} + assert "_partial_json" not in blocks[0] + + def test_should_handle_invalid_json_gracefully(self): + blocks = { + 0: { + "type": "tool_use", + "input": {}, + "_partial_json": "not valid json", + } + } + _handle_content_block_stop({"index": 0}, blocks) + assert blocks[0]["input"] == {"_raw": "not valid json"} + + +class TestHandleMessageDelta: + def test_should_set_stop_reason_and_usage(self): + response: Dict[str, Any] = { + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 0, "output_tokens": 0}, + } + _handle_message_delta( + { + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 15}, + }, + response, + ) + assert response["stop_reason"] == "end_turn" + assert response["usage"]["output_tokens"] == 15 + + +# --------------------------------------------------------------------------- +# Tests for _rebuild_anthropic_response_from_sse +# --------------------------------------------------------------------------- + + +class TestRebuildAnthropicResponse: + def test_should_rebuild_simple_text_response(self): + raw_bytes = _build_simple_text_stream() + result = AgenticAnthropicStreamingIterator._rebuild_anthropic_response_from_sse( + raw_bytes + ) + assert result is not None + assert result["id"] == "msg_123" + assert result["model"] == "claude-sonnet-4-20250514" + assert result["stop_reason"] == "end_turn" + assert len(result["content"]) == 1 + assert result["content"][0]["type"] == "text" + assert result["content"][0]["text"] == "Hello, world!" + assert result["usage"]["input_tokens"] == 10 + assert result["usage"]["output_tokens"] == 5 + + def test_should_rebuild_tool_use_response(self): + raw_bytes = _build_tool_use_stream() + result = AgenticAnthropicStreamingIterator._rebuild_anthropic_response_from_sse( + raw_bytes + ) + assert result is not None + assert result["id"] == "msg_tool_456" + assert result["stop_reason"] == "tool_use" + assert len(result["content"]) == 2 + + thinking = result["content"][0] + assert thinking["type"] == "thinking" + assert thinking["thinking"] == "I need to retrieve..." + assert thinking["signature"] == "sig_abc" + + tool = result["content"][1] + assert tool["type"] == "tool_use" + assert tool["id"] == "toolu_001" + assert tool["name"] == "litellm_content_retrieve" + assert tool["input"] == {"key": "section_1"} + + def test_should_return_none_without_message_start(self): + raw_bytes = [ + _sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text"}, + }, + ) + ] + result = AgenticAnthropicStreamingIterator._rebuild_anthropic_response_from_sse( + raw_bytes + ) + assert result is None + + def test_should_handle_empty_bytes(self): + result = AgenticAnthropicStreamingIterator._rebuild_anthropic_response_from_sse( + [] + ) + assert result is None + + def test_should_handle_multi_event_chunks(self): + """When multiple SSE events arrive in a single bytes chunk.""" + combined = b"".join(_build_simple_text_stream()) + result = AgenticAnthropicStreamingIterator._rebuild_anthropic_response_from_sse( + [combined] + ) + assert result is not None + assert result["content"][0]["text"] == "Hello, world!" + + def test_should_preserve_cache_usage_fields(self): + raw_bytes = [ + _sse_event( + "message_start", + { + "type": "message_start", + "message": { + "id": "msg_cache", + "model": "claude-sonnet-4-20250514", + "role": "assistant", + "usage": { + "input_tokens": 100, + "cache_creation_input_tokens": 50, + "cache_read_input_tokens": 30, + }, + }, + }, + ), + _sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn"}, + "usage": {"output_tokens": 10}, + }, + ), + _sse_event("message_stop", {"type": "message_stop"}), + ] + result = AgenticAnthropicStreamingIterator._rebuild_anthropic_response_from_sse( + raw_bytes + ) + assert result is not None + assert result["usage"]["cache_creation_input_tokens"] == 50 + assert result["usage"]["cache_read_input_tokens"] == 30 + + def test_should_handle_redacted_thinking_block(self): + raw_bytes = [ + _sse_event( + "message_start", + { + "type": "message_start", + "message": { + "id": "msg_redact", + "model": "claude-sonnet-4-20250514", + "role": "assistant", + "usage": {"input_tokens": 5}, + }, + }, + ), + _sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "redacted_thinking", "data": "abc123"}, + }, + ), + _sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ), + _sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn"}, + "usage": {"output_tokens": 1}, + }, + ), + _sse_event("message_stop", {"type": "message_stop"}), + ] + result = AgenticAnthropicStreamingIterator._rebuild_anthropic_response_from_sse( + raw_bytes + ) + assert result is not None + assert result["content"][0]["type"] == "redacted_thinking" + + +# --------------------------------------------------------------------------- +# Tests for AgenticAnthropicStreamingIterator (Phase 1 / Phase 2) +# --------------------------------------------------------------------------- + + +class TestAgenticStreamingIteratorPhase1: + @pytest.mark.asyncio + async def test_should_yield_all_chunks_when_no_hook_fires(self): + """When hooks return None, the wrapper should yield all original chunks.""" + chunks = _build_simple_text_stream() + mock_stream = MockAsyncStream(chunks) + + mock_handler = MagicMock() + mock_handler._call_agentic_completion_hooks = AsyncMock(return_value=None) + + iterator = AgenticAnthropicStreamingIterator( + completion_stream=mock_stream, + http_handler=mock_handler, + model="claude-sonnet-4-20250514", + messages=[{"role": "user", "content": "hi"}], + anthropic_messages_provider_config=MagicMock(), + anthropic_messages_optional_request_params={}, + logging_obj=MagicMock(), + custom_llm_provider="anthropic", + kwargs={}, + ) + + collected = [] + async for chunk in iterator: + collected.append(chunk) + + assert len(collected) == len(chunks) + for orig, got in zip(chunks, collected): + assert orig == got + + mock_handler._call_agentic_completion_hooks.assert_awaited_once() + + @pytest.mark.asyncio + async def test_should_pass_rebuilt_response_to_hooks(self): + """The rebuilt dict passed to hooks should match the original stream content.""" + chunks = _build_tool_use_stream() + mock_stream = MockAsyncStream(chunks) + + captured_response = {} + + async def mock_hooks(**kwargs): + captured_response.update(kwargs["response"]) + return None + + mock_handler = MagicMock() + mock_handler._call_agentic_completion_hooks = mock_hooks + + iterator = AgenticAnthropicStreamingIterator( + completion_stream=mock_stream, + http_handler=mock_handler, + model="claude-sonnet-4-20250514", + messages=[], + anthropic_messages_provider_config=MagicMock(), + anthropic_messages_optional_request_params={}, + logging_obj=MagicMock(), + custom_llm_provider="anthropic", + kwargs={}, + ) + + async for _ in iterator: + pass + + assert captured_response["id"] == "msg_tool_456" + assert captured_response["stop_reason"] == "tool_use" + assert captured_response["content"][1]["name"] == "litellm_content_retrieve" + + +class TestAgenticStreamingIteratorPhase2: + @pytest.mark.asyncio + async def test_should_chain_follow_up_async_iterator(self): + """When hooks return an async iterator, Phase 2 should yield from it.""" + phase1_chunks = _build_simple_text_stream() + phase2_chunks = [b"follow-up-chunk-1", b"follow-up-chunk-2"] + + mock_stream = MockAsyncStream(phase1_chunks) + follow_up = MockAsyncStream(phase2_chunks) + + mock_handler = MagicMock() + mock_handler._call_agentic_completion_hooks = AsyncMock(return_value=follow_up) + + iterator = AgenticAnthropicStreamingIterator( + completion_stream=mock_stream, + http_handler=mock_handler, + model="claude-sonnet-4-20250514", + messages=[], + anthropic_messages_provider_config=MagicMock(), + anthropic_messages_optional_request_params={}, + logging_obj=MagicMock(), + custom_llm_provider="anthropic", + kwargs={}, + ) + + collected = [] + async for chunk in iterator: + collected.append(chunk) + + assert len(collected) == len(phase1_chunks) + len(phase2_chunks) + assert collected[-2:] == phase2_chunks + + @pytest.mark.asyncio + async def test_should_convert_dict_response_to_fake_stream(self): + """When hooks return a dict, it should be wrapped in FakeAnthropicMessagesStreamIterator.""" + phase1_chunks = _build_simple_text_stream() + mock_stream = MockAsyncStream(phase1_chunks) + + fake_response = { + "id": "msg_followup", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-20250514", + "content": [{"type": "text", "text": "follow-up answer"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 100, "output_tokens": 20}, + } + + mock_handler = MagicMock() + mock_handler._call_agentic_completion_hooks = AsyncMock( + return_value=fake_response + ) + + iterator = AgenticAnthropicStreamingIterator( + completion_stream=mock_stream, + http_handler=mock_handler, + model="claude-sonnet-4-20250514", + messages=[], + anthropic_messages_provider_config=MagicMock(), + anthropic_messages_optional_request_params={}, + logging_obj=MagicMock(), + custom_llm_provider="anthropic", + kwargs={}, + ) + + collected = [] + async for chunk in iterator: + collected.append(chunk) + + # Phase 1 chunks + Phase 2 fake-stream chunks + assert len(collected) > len(phase1_chunks) + # The follow-up chunks should contain the text from the dict response + phase2_bytes = b"".join(collected[len(phase1_chunks) :]) + assert b"follow-up answer" in phase2_bytes + + +class TestAgenticStreamingIteratorErrorHandling: + @pytest.mark.asyncio + async def test_should_swallow_hook_errors(self): + """Errors in hook processing should be swallowed; Phase 1 chunks are still yielded.""" + chunks = _build_simple_text_stream() + mock_stream = MockAsyncStream(chunks) + + mock_handler = MagicMock() + mock_handler._call_agentic_completion_hooks = AsyncMock( + side_effect=RuntimeError("hook exploded") + ) + + mock_logging = MagicMock() + mock_logging.litellm_call_id = "test_call_123" + + iterator = AgenticAnthropicStreamingIterator( + completion_stream=mock_stream, + http_handler=mock_handler, + model="claude-sonnet-4-20250514", + messages=[], + anthropic_messages_provider_config=MagicMock(), + anthropic_messages_optional_request_params={}, + logging_obj=mock_logging, + custom_llm_provider="anthropic", + kwargs={}, + ) + + collected = [] + async for chunk in iterator: + collected.append(chunk) + + # All Phase 1 chunks should still have been yielded + assert len(collected) == len(chunks) + + @pytest.mark.asyncio + async def test_should_handle_empty_stream(self): + """An empty upstream stream should not crash.""" + mock_stream = MockAsyncStream([]) + + mock_handler = MagicMock() + mock_handler._call_agentic_completion_hooks = AsyncMock(return_value=None) + + iterator = AgenticAnthropicStreamingIterator( + completion_stream=mock_stream, + http_handler=mock_handler, + model="claude-sonnet-4-20250514", + messages=[], + anthropic_messages_provider_config=MagicMock(), + anthropic_messages_optional_request_params={}, + logging_obj=MagicMock(), + custom_llm_provider="anthropic", + kwargs={}, + ) + + collected = [] + async for chunk in iterator: + collected.append(chunk) + + assert collected == [] + # hooks should not be called since no bytes were collected + mock_handler._call_agentic_completion_hooks.assert_not_awaited() + + @pytest.mark.asyncio + async def test_should_pass_stream_true_to_hooks(self): + """The wrapper should always pass stream=True to hooks.""" + chunks = _build_simple_text_stream() + mock_stream = MockAsyncStream(chunks) + + mock_handler = MagicMock() + mock_handler._call_agentic_completion_hooks = AsyncMock(return_value=None) + + iterator = AgenticAnthropicStreamingIterator( + completion_stream=mock_stream, + http_handler=mock_handler, + model="claude-sonnet-4-20250514", + messages=[], + anthropic_messages_provider_config=MagicMock(), + anthropic_messages_optional_request_params={}, + logging_obj=MagicMock(), + custom_llm_provider="anthropic", + kwargs={}, + ) + + async for _ in iterator: + pass + + call_kwargs = mock_handler._call_agentic_completion_hooks.call_args + assert call_kwargs.kwargs["stream"] is True diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_reasoning_auto_summary_messages.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_reasoning_auto_summary_messages.py new file mode 100644 index 00000000000..07c0012b04d --- /dev/null +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_reasoning_auto_summary_messages.py @@ -0,0 +1,173 @@ +""" +Tests for reasoning_auto_summary support on the native /v1/messages handler. + +When reasoning_auto_summary is enabled (via litellm.reasoning_auto_summary or +LITELLM_REASONING_AUTO_SUMMARY env var), the handler injects +thinking.display = "summarized" into the request params for active thinking +modes (type="enabled" or type="adaptive"). +""" + +import os +import sys + +import pytest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.abspath("../../../../..")) + +import litellm +from litellm.llms.anthropic.experimental_pass_through.messages.handler import ( + anthropic_messages_handler, +) + + +def _call_handler_and_capture_optional_params(thinking=None, **extra_kwargs): + """ + Call anthropic_messages_handler with an Anthropic model and capture the + anthropic_messages_optional_request_params dict passed to + base_llm_http_handler.anthropic_messages_handler. + + Returns the captured dict. + """ + captured = {} + + with patch( + "litellm.llms.anthropic.experimental_pass_through.messages.handler." + "base_llm_http_handler" + ) as mock_handler, patch( + "litellm.llms.anthropic.experimental_pass_through.messages.handler." + "ProviderConfigManager" + ) as mock_pcm: + # Make get_provider_anthropic_messages_config return a non-None config + # so the handler takes the native Anthropic path + mock_pcm.get_provider_anthropic_messages_config.return_value = MagicMock() + mock_handler.anthropic_messages_handler.return_value = MagicMock() + + kwargs = dict(extra_kwargs) + if thinking is not None: + kwargs["thinking"] = thinking + + try: + anthropic_messages_handler( + max_tokens=1024, + messages=[{"role": "user", "content": "Hello"}], + model="claude-sonnet-4-20250514", + custom_llm_provider="anthropic", + api_key="test-key", + **kwargs, + ) + except (ValueError, TypeError, AttributeError): + pass + + if mock_handler.anthropic_messages_handler.called: + captured = mock_handler.anthropic_messages_handler.call_args.kwargs.get( + "anthropic_messages_optional_request_params", {} + ) + + return captured + + +class TestReasoningAutoSummaryMessages: + """Tests for thinking.display injection on native /v1/messages handler.""" + + def test_adaptive_thinking_gets_display_summarized(self): + """reasoning_auto_summary=True + thinking.type='adaptive' -> display='summarized'.""" + with patch.object(litellm, "reasoning_auto_summary", True): + params = _call_handler_and_capture_optional_params( + thinking={"type": "adaptive", "budget_tokens": 5000} + ) + thinking = params.get("thinking", {}) + assert thinking.get("display") == "summarized" + assert thinking.get("type") == "adaptive" + assert thinking.get("budget_tokens") == 5000 + + def test_enabled_thinking_gets_display_summarized(self): + """reasoning_auto_summary=True + thinking.type='enabled' -> display='summarized'.""" + with patch.object(litellm, "reasoning_auto_summary", True): + params = _call_handler_and_capture_optional_params( + thinking={"type": "enabled", "budget_tokens": 10000} + ) + thinking = params.get("thinking", {}) + assert thinking.get("display") == "summarized" + assert thinking.get("type") == "enabled" + + def test_disabled_thinking_no_display(self): + """reasoning_auto_summary=True + thinking.type='disabled' -> display NOT set.""" + with patch.object(litellm, "reasoning_auto_summary", True): + params = _call_handler_and_capture_optional_params( + thinking={"type": "disabled"} + ) + thinking = params.get("thinking", {}) + assert "display" not in thinking + + def test_no_injection_when_flag_false(self): + """reasoning_auto_summary=False + active thinking -> display NOT set.""" + with patch.object(litellm, "reasoning_auto_summary", False): + params = _call_handler_and_capture_optional_params( + thinking={"type": "enabled", "budget_tokens": 10000} + ) + thinking = params.get("thinking", {}) + assert "display" not in thinking + + def test_no_thinking_param_no_crash(self): + """reasoning_auto_summary=True but no thinking param -> nothing changes.""" + with patch.object(litellm, "reasoning_auto_summary", True): + params = _call_handler_and_capture_optional_params() + thinking = params.get("thinking") + if thinking is not None: + assert "display" not in thinking + + def test_env_var_enables_auto_summary(self): + """LITELLM_REASONING_AUTO_SUMMARY=true env var enables the feature.""" + with patch.object(litellm, "reasoning_auto_summary", False), patch.dict( + os.environ, {"LITELLM_REASONING_AUTO_SUMMARY": "true"} + ): + params = _call_handler_and_capture_optional_params( + thinking={"type": "adaptive", "budget_tokens": 5000} + ) + thinking = params.get("thinking", {}) + assert thinking.get("display") == "summarized" + + def test_existing_display_summarized_preserved(self): + """User already passes display='summarized' -> preserved as-is.""" + with patch.object(litellm, "reasoning_auto_summary", True): + params = _call_handler_and_capture_optional_params( + thinking={ + "type": "enabled", + "budget_tokens": 10000, + "display": "summarized", + } + ) + thinking = params.get("thinking", {}) + assert thinking.get("display") == "summarized" + + def test_existing_display_summarized_without_flag(self): + """User passes display='summarized' + flag=False -> preserved as-is.""" + with patch.object(litellm, "reasoning_auto_summary", False): + params = _call_handler_and_capture_optional_params( + thinking={ + "type": "enabled", + "budget_tokens": 10000, + "display": "summarized", + } + ) + thinking = params.get("thinking", {}) + assert thinking.get("display") == "summarized" + + def test_omitted_overridden_to_summarized(self): + """User passes display='omitted' + reasoning_auto_summary=True -> overridden. + + Documents current behavior: the code unconditionally sets + display='summarized' when auto_summary is enabled and thinking is active, + regardless of any pre-existing display value. + """ + with patch.object(litellm, "reasoning_auto_summary", True): + params = _call_handler_and_capture_optional_params( + thinking={ + "type": "enabled", + "budget_tokens": 10000, + "display": "omitted", + } + ) + thinking = params.get("thinking", {}) + assert thinking.get("display") == "summarized" diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/test_reasoning_effort_fields.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/test_reasoning_effort_fields.py new file mode 100644 index 00000000000..d42d109f21b --- /dev/null +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/test_reasoning_effort_fields.py @@ -0,0 +1,287 @@ +""" +Tests for reasoning effort capability fields and normalize_reasoning_effort_value. + +Covers: +- Commit 1: get_model_info returns supports_minimal/supports_max fields +- Commit 2: Model registry entries have correct reasoning effort fields +- Commit 3: normalize_reasoning_effort_value degradation chains + adapter translation +""" + +import json +import os +from typing import Any, Dict, Optional +from unittest.mock import patch + +import pytest + +from litellm.llms.anthropic.experimental_pass_through.utils import ( + normalize_reasoning_effort_value, +) +from litellm.utils import get_model_info + + +def _load_model_registry() -> Dict[str, Any]: + """Load the root model_prices_and_context_window.json.""" + json_path = os.path.join( + os.path.dirname(__file__), + "../../../../../model_prices_and_context_window.json", + ) + with open(json_path) as f: + return json.load(f) + + +# --------------------------------------------------------------------------- +# Commit 1: get_model_info returns supports_minimal and supports_max fields +# --------------------------------------------------------------------------- + + +class TestGetModelInfoReasoningEffortFields: + """get_model_info should expose supports_minimal_reasoning_effort and + supports_max_reasoning_effort from the model registry.""" + + def test_opus_4_6_has_supports_minimal(self): + info = get_model_info("claude-opus-4-6") + assert "supports_minimal_reasoning_effort" in info + + def test_opus_4_6_has_supports_max(self): + info = get_model_info("claude-opus-4-6") + assert "supports_max_reasoning_effort" in info + + def test_opus_4_7_has_supports_minimal(self): + info = get_model_info("claude-opus-4-7") + assert "supports_minimal_reasoning_effort" in info + + def test_opus_4_7_has_supports_max(self): + info = get_model_info("claude-opus-4-7") + assert "supports_max_reasoning_effort" in info + + +# --------------------------------------------------------------------------- +# Commit 2: JSON registry has correct reasoning effort fields +# --------------------------------------------------------------------------- + + +class TestModelRegistryReasoningEffortFields: + """Verify specific models have the expected reasoning effort capability + values in the JSON registry file.""" + + @pytest.fixture(autouse=True) + def _load_registry(self): + self.registry = _load_model_registry() + + def test_opus_4_7_supports_max(self): + entry = self.registry["claude-opus-4-7"] + assert entry.get("supports_max_reasoning_effort") is True + + def test_opus_4_6_supports_max(self): + entry = self.registry["claude-opus-4-6"] + assert entry.get("supports_max_reasoning_effort") is True + + def test_opus_4_7_supports_minimal(self): + entry = self.registry["claude-opus-4-7"] + assert entry.get("supports_minimal_reasoning_effort") is True + + def test_opus_4_6_supports_minimal(self): + entry = self.registry["claude-opus-4-6"] + assert entry.get("supports_minimal_reasoning_effort") is True + + def test_sonnet_4_6_supports_minimal(self): + entry = self.registry["anthropic.claude-sonnet-4-6"] + assert entry.get("supports_minimal_reasoning_effort") is True + + def test_bedrock_opus_4_7_supports_max(self): + entry = self.registry["anthropic.claude-opus-4-7"] + assert entry.get("supports_max_reasoning_effort") is True + assert entry.get("supports_minimal_reasoning_effort") is True + + def test_vertex_opus_4_7_supports_max(self): + entry = self.registry["vertex_ai/claude-opus-4-7"] + assert entry.get("supports_max_reasoning_effort") is True + assert entry.get("supports_minimal_reasoning_effort") is True + + def test_vertex_opus_4_6_supports_max(self): + entry = self.registry["vertex_ai/claude-opus-4-6"] + assert entry.get("supports_max_reasoning_effort") is True + assert entry.get("supports_minimal_reasoning_effort") is True + + def test_azure_ai_opus_4_6_supports_minimal(self): + entry = self.registry["azure_ai/claude-opus-4-6"] + assert entry.get("supports_minimal_reasoning_effort") is True + + def test_azure_ai_opus_4_7_supports_max(self): + entry = self.registry["azure_ai/claude-opus-4-7"] + assert entry.get("supports_max_reasoning_effort") is True + assert entry.get("supports_minimal_reasoning_effort") is True + + +# --------------------------------------------------------------------------- +# Commit 3: normalize_reasoning_effort_value +# --------------------------------------------------------------------------- + + +def _mock_model_info(**flags): + """Return a mock model_info dict with given capability flags.""" + return flags + + +class TestNormalizeReasoningEffortValue: + """Test degradation chains for normalize_reasoning_effort_value.""" + + # --- "max" degradation chain --- + + def test_max_stays_max_when_supported(self): + with patch( + "litellm.utils.get_model_info", + return_value=_mock_model_info( + supports_max_reasoning_effort=True, + supports_xhigh_reasoning_effort=True, + ), + ): + assert normalize_reasoning_effort_value("max", model="test") == "max" + + def test_max_degrades_to_xhigh(self): + with patch( + "litellm.utils.get_model_info", + return_value=_mock_model_info( + supports_max_reasoning_effort=False, + supports_xhigh_reasoning_effort=True, + ), + ): + assert normalize_reasoning_effort_value("max", model="test") == "xhigh" + + def test_max_degrades_to_high(self): + with patch( + "litellm.utils.get_model_info", + return_value=_mock_model_info( + supports_max_reasoning_effort=False, + supports_xhigh_reasoning_effort=False, + ), + ): + assert normalize_reasoning_effort_value("max", model="test") == "high" + + # --- "xhigh" degradation chain --- + + def test_xhigh_stays_xhigh_when_supported(self): + with patch( + "litellm.utils.get_model_info", + return_value=_mock_model_info(supports_xhigh_reasoning_effort=True), + ): + assert normalize_reasoning_effort_value("xhigh", model="test") == "xhigh" + + def test_xhigh_degrades_to_high(self): + with patch( + "litellm.utils.get_model_info", + return_value=_mock_model_info(supports_xhigh_reasoning_effort=False), + ): + assert normalize_reasoning_effort_value("xhigh", model="test") == "high" + + # --- "minimal" degradation chain --- + + def test_minimal_stays_minimal_when_supported(self): + with patch( + "litellm.utils.get_model_info", + return_value=_mock_model_info(supports_minimal_reasoning_effort=True), + ): + assert ( + normalize_reasoning_effort_value("minimal", model="test") == "minimal" + ) + + def test_minimal_degrades_to_low(self): + with patch( + "litellm.utils.get_model_info", + return_value=_mock_model_info(supports_minimal_reasoning_effort=False), + ): + assert normalize_reasoning_effort_value("minimal", model="test") == "low" + + # --- passthrough values --- + + def test_high_passes_through(self): + assert normalize_reasoning_effort_value("high", model="test") == "high" + + def test_medium_passes_through(self): + assert normalize_reasoning_effort_value("medium", model="test") == "medium" + + def test_low_passes_through(self): + assert normalize_reasoning_effort_value("low", model="test") == "low" + + # --- exception fallback --- + + def test_exception_fallback_uses_empty_model_info(self): + """When get_model_info raises, treat model_info as {} (no capabilities).""" + with patch( + "litellm.utils.get_model_info", + side_effect=Exception("model not found"), + ): + # "max" with no capabilities -> "high" + assert normalize_reasoning_effort_value("max", model="unknown") == "high" + # "minimal" with no capabilities -> "low" + assert normalize_reasoning_effort_value("minimal", model="unknown") == "low" + + +# --------------------------------------------------------------------------- +# Commit 3: Adapter translation — adaptive thinking + output_config.effort +# --------------------------------------------------------------------------- + + +class TestAdapterAdaptiveThinking: + """Test that adaptive thinking type maps correctly through the adapters.""" + + def test_messages_adapter_adaptive_returns_medium_default(self): + """Adaptive thinking returns 'medium' as default reasoning_effort.""" + from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import ( + LiteLLMAnthropicMessagesAdapter, + ) + + adapter = LiteLLMAnthropicMessagesAdapter() + result = adapter.translate_anthropic_thinking_to_reasoning_effort( + {"type": "adaptive"} + ) + assert result == "medium" + + def test_messages_adapter_adaptive_overridden_by_output_config(self): + """For adaptive thinking, output_config.effort overrides reasoning_effort.""" + from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import ( + LiteLLMAnthropicMessagesAdapter, + ) + from litellm.types.llms.anthropic import AnthropicMessagesRequest + + adapter = LiteLLMAnthropicMessagesAdapter() + request = AnthropicMessagesRequest( + model="test-model", + messages=[{"role": "user", "content": "hello"}], + max_tokens=1024, + thinking={"type": "adaptive"}, + output_config={"effort": "high"}, + ) + openai_kwargs, _ = adapter.translate_anthropic_to_openai(request) + # reasoning_effort should be set (either as string or dict with effort) + re = openai_kwargs.get("reasoning_effort") + if isinstance(re, dict): + assert re["effort"] == "high" + else: + assert re == "high" + + def test_responses_adapter_adaptive_with_output_config(self): + """Responses adapter: adaptive thinking + output_config.effort.""" + from litellm.llms.anthropic.experimental_pass_through.responses_adapters.transformation import ( + LiteLLMAnthropicToResponsesAPIAdapter, + ) + + result = LiteLLMAnthropicToResponsesAPIAdapter.translate_thinking_to_reasoning( + thinking={"type": "adaptive"}, + output_config={"effort": "xhigh"}, + ) + assert result is not None + assert result["effort"] == "xhigh" + + def test_responses_adapter_adaptive_default_medium(self): + """Responses adapter: adaptive thinking without output_config defaults to medium.""" + from litellm.llms.anthropic.experimental_pass_through.responses_adapters.transformation import ( + LiteLLMAnthropicToResponsesAPIAdapter, + ) + + result = LiteLLMAnthropicToResponsesAPIAdapter.translate_thinking_to_reasoning( + thinking={"type": "adaptive"}, + ) + assert result is not None + assert result["effort"] == "medium" diff --git a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py index d3a9c94ea55..7a2a6f56d6f 100644 --- a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py +++ b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py @@ -579,6 +579,155 @@ def test_bedrock_messages_strips_output_config_with_output_format(): assert "output_format" not in result +def test_bedrock_messages_strips_context_management(): + """ + Ensure context_management is stripped from the request before sending to + Bedrock Invoke, which doesn't support this Anthropic-specific parameter. + + Claude Code sends context_management on every request; leaving it in the body + causes a 400 "context_management: Extra inputs are not permitted" from Bedrock. + """ + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + messages = [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}] + optional_params = { + "max_tokens": 4096, + "context_management": { + "edits": [{"type": "clear_thinking_20251015", "keep": "all"}] + }, + } + + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-3-haiku-20240307-v1:0", + messages=messages, + anthropic_messages_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert ( + "context_management" not in result + ), "context_management should be stripped — Bedrock Invoke rejects it" + assert result.get("max_tokens") == 4096 + + +def test_bedrock_messages_allowlist_filters_anthropic_only_fields(): + """ + Bedrock Invoke rejects any top-level body field it doesn't recognize with + "Extra inputs are not permitted". Defend against that by filtering the + outgoing body to a Bedrock-supported allowlist — catches Anthropic-only + extensions (speed, mcp_servers, container, ...) and any future additions + Claude Code starts sending before we learn about them. + """ + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + messages = [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}] + optional_params = { + "max_tokens": 4096, + "temperature": 0.5, + "speed": "fast", + "mcp_servers": [{"type": "url", "url": "https://example.com"}], + "container": {"skills": []}, + "inference_geo": "us", + "output_config": {"effort": "low"}, + "context_management": {"edits": []}, + } + + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-3-haiku-20240307-v1:0", + messages=messages, + anthropic_messages_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + for bad in ( + "speed", + "mcp_servers", + "container", + "inference_geo", + "output_config", + "context_management", + "model", + "stream", + ): + assert bad not in result, f"{bad} should be stripped by the allowlist" + + # Supported fields pass through. + assert result["max_tokens"] == 4096 + assert result["temperature"] == 0.5 + assert result["anthropic_version"] == cfg.DEFAULT_BEDROCK_ANTHROPIC_API_VERSION + # Every surviving key is in the allowlist. + assert set(result).issubset(cfg.BEDROCK_INVOKE_ALLOWED_TOP_LEVEL_FIELDS) + + +def test_bedrock_messages_filters_user_provided_unsupported_beta_header(): + """ + In proxy deployments the client (e.g. Claude Code) doesn't know the backend + is Bedrock and may send Anthropic-direct beta headers Bedrock can't handle. + All betas must go through the provider mapping, not just auto-injected ones + — otherwise Bedrock 400s on the unsupported value. + """ + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + messages = [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}] + optional_params = {"max_tokens": 128} + # `advisor-tool-2026-03-01` has no bedrock mapping entry → must be dropped. + # `context-1m-2025-08-07` does → must pass through. + headers = { + "anthropic-beta": "advisor-tool-2026-03-01,context-1m-2025-08-07", + } + + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-3-haiku-20240307-v1:0", + messages=messages, + anthropic_messages_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers=headers, + ) + + betas = result.get("anthropic_beta") or [] + assert ( + "advisor-tool-2026-03-01" not in betas + ), "user-provided beta not in the Bedrock mapping must be dropped" + assert ( + "context-1m-2025-08-07" in betas + ), "user-provided beta that IS in the Bedrock mapping should survive" + + +def test_bedrock_messages_renames_user_provided_aliased_beta_header(): + """ + Bedrock's config maps `advanced-tool-use-2025-11-20` to + `tool-search-tool-2025-10-19`. User-provided betas must go through the + rename too, not be forwarded under their Anthropic-direct spelling. + """ + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + messages = [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}] + optional_params = {"max_tokens": 128} + headers = {"anthropic-beta": "advanced-tool-use-2025-11-20"} + + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-3-haiku-20240307-v1:0", + messages=messages, + anthropic_messages_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers=headers, + ) + + betas = result.get("anthropic_beta") or [] + assert ( + "advanced-tool-use-2025-11-20" not in betas + ), "Anthropic-direct spelling should be rewritten, not forwarded verbatim" + assert ( + "tool-search-tool-2025-10-19" in betas + ), "user-provided beta should be renamed to the Bedrock-side spelling" + + @pytest.mark.asyncio async def test_promote_message_stop_usage_preserves_message_delta_output_tokens(): """ diff --git a/tests/test_litellm/llms/bedrock/test_anthropic_beta_support.py b/tests/test_litellm/llms/bedrock/test_anthropic_beta_support.py index 46fbd67902e..a20ec94a99d 100644 --- a/tests/test_litellm/llms/bedrock/test_anthropic_beta_support.py +++ b/tests/test_litellm/llms/bedrock/test_anthropic_beta_support.py @@ -95,7 +95,7 @@ class TestAnthropicBetaHeaderSupport: def test_messages_transformation_anthropic_beta(self): """Test that Messages API transformation includes anthropic_beta in request.""" config = AmazonAnthropicClaudeMessagesConfig() - headers = {"anthropic-beta": "output-128k-2025-02-19"} + headers = {"anthropic-beta": "context-1m-2025-08-07"} result = config.transform_anthropic_messages_request( model="anthropic.claude-haiku-4-5-20251001-v1:0", @@ -107,7 +107,7 @@ class TestAnthropicBetaHeaderSupport: assert "anthropic_beta" in result # Sort both arrays before comparing to avoid flakiness from ordering differences - assert sorted(result["anthropic_beta"]) == sorted(["output-128k-2025-02-19"]) + assert sorted(result["anthropic_beta"]) == sorted(["context-1m-2025-08-07"]) def test_converse_computer_use_compatibility(self): """Test that user anthropic_beta headers work with computer use tools.""" diff --git a/tests/test_litellm/llms/bedrock/test_mantle.py b/tests/test_litellm/llms/bedrock/test_mantle.py new file mode 100644 index 00000000000..a74d5447f00 --- /dev/null +++ b/tests/test_litellm/llms/bedrock/test_mantle.py @@ -0,0 +1,105 @@ +""" +Unit tests for the Bedrock Mantle (Claude Mythos Preview) integration. + +Tests cover route detection, URL construction, and config dispatch for both +the /chat/completions and /messages endpoints. +""" + +from litellm.llms.bedrock.common_utils import BedrockModelInfo, get_bedrock_chat_config +from litellm.llms.bedrock.chat.mantle.transformation import AmazonMantleConfig +from litellm.llms.bedrock.messages.mantle_transformation import ( + AmazonMantleMessagesConfig, +) + + +def test_get_bedrock_route_mantle(): + assert ( + BedrockModelInfo.get_bedrock_route("mantle/anthropic.claude-mythos-preview") + == "mantle" + ) + + +def test_get_bedrock_route_mantle_does_not_match_other_routes(): + assert ( + BedrockModelInfo.get_bedrock_route("anthropic.claude-3-sonnet-20240229-v1:0") + != "mantle" + ) + assert ( + BedrockModelInfo.get_bedrock_route("converse/anthropic.claude-3-sonnet") + != "mantle" + ) + + +def test_explicit_mantle_route_flag(): + assert ( + BedrockModelInfo._explicit_mantle_route( + "mantle/anthropic.claude-mythos-preview" + ) + is True + ) + assert BedrockModelInfo._explicit_mantle_route("anthropic.claude-3-sonnet") is False + assert ( + BedrockModelInfo._explicit_mantle_route("converse/anthropic.claude-3-sonnet") + is False + ) + + +def test_mantle_url_construction(): + config = AmazonMantleConfig() + url = config.get_complete_url( + api_base=None, + api_key=None, + model="mantle/anthropic.claude-mythos-preview", + optional_params={"aws_region_name": "us-east-1"}, + litellm_params={}, + ) + assert url == "https://bedrock-mantle.us-east-1.api.aws/v1/messages" + + +def test_mantle_url_construction_different_region(): + config = AmazonMantleConfig() + url = config.get_complete_url( + api_base=None, + api_key=None, + model="mantle/anthropic.claude-mythos-preview", + optional_params={"aws_region_name": "us-west-2"}, + litellm_params={}, + ) + assert url == "https://bedrock-mantle.us-west-2.api.aws/v1/messages" + + +def test_get_bedrock_chat_config_returns_mantle_config(): + config = get_bedrock_chat_config("mantle/anthropic.claude-mythos-preview") + assert isinstance(config, AmazonMantleConfig) + + +def test_get_bedrock_provider_config_for_messages_api_mantle(): + config = BedrockModelInfo.get_bedrock_provider_config_for_messages_api( + "mantle/anthropic.claude-mythos-preview" + ) + assert isinstance(config, AmazonMantleMessagesConfig) + + +def test_mantle_messages_url_construction(): + config = AmazonMantleMessagesConfig() + url = config.get_complete_url( + api_base=None, + api_key=None, + model="mantle/anthropic.claude-mythos-preview", + optional_params={"aws_region_name": "us-east-1"}, + litellm_params={}, + ) + assert url == "https://bedrock-mantle.us-east-1.api.aws/v1/messages" + + +def test_mantle_transform_request_strips_prefix_and_adds_model(): + config = AmazonMantleConfig() + request = config.transform_request( + model="mantle/anthropic.claude-mythos-preview", + messages=[{"role": "user", "content": "Hello"}], + optional_params={"max_tokens": 100}, + litellm_params={}, + headers={}, + ) + assert request["model"] == "anthropic.claude-mythos-preview" + assert "mantle/" not in request["model"] diff --git a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py index 2b9d2e9e543..6924eb8d3d9 100644 --- a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py @@ -74,6 +74,34 @@ def test_prepare_fake_stream_request(): assert result_data["messages"] == [{"role": "user", "content": "Hello"}] +def test_get_agentic_loop_settings_defaults_and_overrides(): + handler = BaseLLMHTTPHandler() + + depth, max_loops, fingerprints = handler._get_agentic_loop_settings(kwargs={}) + assert depth == 0 + assert max_loops == 3 + assert fingerprints == [] + + depth, max_loops, fingerprints = handler._get_agentic_loop_settings( + kwargs={ + "_agentic_loop_depth": 2, + "max_agentic_loops": 7, + "_agentic_loop_fingerprints": ["fp-1", "fp-2"], + } + ) + assert depth == 2 + assert max_loops == 7 + assert fingerprints == ["fp-1", "fp-2"] + + +def test_fingerprint_agentic_tools_is_deterministic(): + handler = BaseLLMHTTPHandler() + tools_a = {"tool_calls": [{"id": "1", "input": {"q": "abc"}, "name": "web_search"}]} + tools_b = {"tool_calls": [{"name": "web_search", "input": {"q": "abc"}, "id": "1"}]} + + assert handler._fingerprint_agentic_tools(tools_a) == handler._fingerprint_agentic_tools(tools_b) + + @pytest.mark.asyncio async def test_async_anthropic_messages_handler_extra_headers(): """ diff --git a/tests/test_litellm/llms/moonshot/test_moonshot_chat_transformation.py b/tests/test_litellm/llms/moonshot/test_moonshot_chat_transformation.py index 6dabbe9b2f2..b4744a7ed18 100644 --- a/tests/test_litellm/llms/moonshot/test_moonshot_chat_transformation.py +++ b/tests/test_litellm/llms/moonshot/test_moonshot_chat_transformation.py @@ -18,6 +18,7 @@ import pytest import litellm import litellm.utils from litellm import completion +from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap from litellm.llms.moonshot.chat.transformation import MoonshotChatConfig @@ -653,3 +654,44 @@ class TestMoonshotConfig: result[1].get("reasoning_content") == "Planning to call weather tool" ) + + +class TestKimiK26ModelRegistry: + """Tests that kimi-k2.6 is correctly registered in the model registry.""" + + @pytest.fixture(autouse=True) + def model_cost_map(self): + """Load directly from the bundled backup so tests don't depend on remote fetch.""" + return GetModelCostMap.load_local_model_cost_map() + + def test_kimi_k26_in_model_cost_map(self, model_cost_map): + """kimi-k2.6 should be present in the model cost map.""" + assert "moonshot/kimi-k2.6" in model_cost_map, "moonshot/kimi-k2.6 not found in model_cost" + + def test_kimi_k26_pricing(self, model_cost_map): + """kimi-k2.6 pricing should match official Kimi API rates.""" + model_info = model_cost_map["moonshot/kimi-k2.6"] + assert model_info["input_cost_per_token"] == pytest.approx(9.5e-07) + assert model_info["output_cost_per_token"] == pytest.approx(4e-06) + assert model_info["cache_read_input_token_cost"] == pytest.approx(1.6e-07) + + def test_kimi_k26_context_window(self, model_cost_map): + """kimi-k2.6 should have a 256K (262144 token) context window.""" + model_info = model_cost_map["moonshot/kimi-k2.6"] + assert model_info["max_input_tokens"] == 262144 + assert model_info["max_output_tokens"] == 262144 + assert model_info["max_tokens"] == 262144 + + def test_kimi_k26_capabilities(self, model_cost_map): + """kimi-k2.6 should support function calling, vision, video input, tool choice, and reasoning.""" + model_info = model_cost_map["moonshot/kimi-k2.6"] + assert model_info.get("supports_function_calling") is True + assert model_info.get("supports_tool_choice") is True + assert model_info.get("supports_vision") is True + assert model_info.get("supports_video_input") is True + assert model_info.get("supports_reasoning") is True + + def test_kimi_k26_provider(self, model_cost_map): + """kimi-k2.6 should be assigned to the moonshot provider.""" + model_info = model_cost_map["moonshot/kimi-k2.6"] + assert model_info["litellm_provider"] == "moonshot" diff --git a/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py b/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py index a4ac4c94d29..a2c37002942 100644 --- a/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py +++ b/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py @@ -891,6 +891,61 @@ class TestOpenAIChatCompletionsHandlerStreamingOutput: assert result == responses_so_far +class TestGetStructuredMessages: + """Test the get_structured_messages method.""" + + def test_should_return_messages_from_chat_completions_request(self): + """Test that messages are returned from a chat completions request.""" + handler = OpenAIChatCompletionsHandler() + data = { + "messages": [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello"}, + ] + } + result = handler.get_structured_messages(data) + assert result is not None + assert len(result) == 2 + assert result[0]["role"] == "system" + assert result[1]["role"] == "user" + + def test_should_return_none_when_no_messages(self): + """Test that None is returned when no messages key exists.""" + handler = OpenAIChatCompletionsHandler() + data = {"model": "gpt-4"} + result = handler.get_structured_messages(data) + assert result is None + + def test_should_return_none_for_none_messages(self): + """Test that None is returned when messages is explicitly None.""" + handler = OpenAIChatCompletionsHandler() + data = {"messages": None} + result = handler.get_structured_messages(data) + assert result is None + + def test_should_handle_multimodal_content(self): + """Test that messages with multimodal content are returned.""" + handler = OpenAIChatCompletionsHandler() + data = { + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What's in this image?"}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/image.png"}, + }, + ], + } + ] + } + result = handler.get_structured_messages(data) + assert result is not None + assert len(result) == 1 + assert isinstance(result[0]["content"], list) + + if __name__ == "__main__": # Run the tests pytest.main([__file__, "-v"]) diff --git a/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py b/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py index ccece8018ff..aee6ccc2e76 100644 --- a/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py +++ b/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py @@ -995,3 +995,63 @@ class TestOpenAIResponsesHandlerStreamingOutputProcessing: # Should return the responses assert result == responses_so_far + + +class TestGetStructuredMessages: + """Test the get_structured_messages method for Responses API handler.""" + + def test_should_convert_string_input_to_messages(self): + """Test that a simple string input is converted to OpenAI messages.""" + handler = OpenAIResponsesHandler() + data = {"input": "What is the capital of France?"} + result = handler.get_structured_messages(data) + assert result is not None + assert len(result) >= 1 + found_user = False + for msg in result: + if isinstance(msg, dict) and msg.get("role") == "user": + found_user = True + break + assert found_user, f"Expected a user message, got: {result}" + + def test_should_convert_list_input_to_messages(self): + """Test that list input (ResponseInputParam) is converted to OpenAI messages.""" + handler = OpenAIResponsesHandler() + data = { + "input": [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + {"role": "user", "content": "How are you?"}, + ] + } + result = handler.get_structured_messages(data) + assert result is not None + assert len(result) >= 3 + + def test_should_include_instructions_as_system_message(self): + """Test that instructions are included as a system message.""" + handler = OpenAIResponsesHandler() + data = { + "input": "Roll a d20", + "instructions": "You are a helpful dungeon master.", + } + result = handler.get_structured_messages(data) + assert result is not None + has_system = any( + isinstance(msg, dict) and msg.get("role") == "system" for msg in result + ) + assert has_system, f"Expected system message from instructions, got: {result}" + + def test_should_return_none_when_no_input(self): + """Test that None is returned when input key is missing.""" + handler = OpenAIResponsesHandler() + data = {"model": "gpt-4o"} + result = handler.get_structured_messages(data) + assert result is None + + def test_should_return_none_for_none_input(self): + """Test that None is returned when input is explicitly None.""" + handler = OpenAIResponsesHandler() + data = {"input": None} + result = handler.get_structured_messages(data) + assert result is None diff --git a/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py b/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py new file mode 100644 index 00000000000..e611d5e6b7e --- /dev/null +++ b/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py @@ -0,0 +1,152 @@ +""" +Regression tests for is_model_gpt_5_model() in both OpenAI and Azure GPT-5 config +classes. + +Background +---------- +In v1.82.3 a substring check was introduced:: + + return "gpt-5" in model and "gpt-5-chat" not in model + +This inadvertently treated versioned chat models like ``gpt-5.3-chat`` and +``gpt-5.1-chat`` as *non*-GPT-5 models, because the string ``"gpt-5-chat"`` is +a substring of ``"gpt-5.3-chat"``. Those models were then routed through the +regular Azure chat path which does not suppress ``parallel_tool_calls``, causing +Azure to return ``finish_reason="stop"`` together with tool_calls and breaking +n8n AI-agent workflows. + +There are two distinct families: + +* **gpt-5-chat family** (``gpt-5-chat``, ``gpt-5-chat-latest``, + ``gpt-5-chat-2025-08-07``, …) — regular chat models that support ``temperature`` + and ``tool_choice`` but NOT ``reasoning_effort``. Must NOT be on the GPT-5 + reasoning path. + +* **Versioned chat models** (``gpt-5.1-chat``, ``gpt-5.2-chat``, + ``gpt-5.3-chat``, …) — ARE GPT-5 reasoning models and must stay on the GPT-5 + path. + +The fix uses a prefix check (``startswith("gpt-5-chat")``) on the normalised model +name instead of a substring check, which correctly distinguishes the two families. +""" + +import pytest + +from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config +from litellm.llms.azure.chat.gpt_5_transformation import AzureOpenAIGPT5Config + +# --------------------------------------------------------------------------- +# Parametrized fixtures +# --------------------------------------------------------------------------- + +# Models that MUST be classified as GPT-5 (routed through GPT-5 reasoning path) +GPT5_MODELS = [ + "gpt-5", + "gpt-5.1", + "gpt-5.2", + "gpt-5.3", + "gpt-5.4", + "gpt-5.5", + "gpt-5.1-chat", # versioned chat — THE KEY REGRESSION CASE + "gpt-5.2-chat", # versioned chat — also a regression case + "gpt-5.3-chat", # versioned chat — THE KEY REGRESSION CASE + "gpt-5.2-chat-latest", # versioned chat with date suffix + "gpt-5.1-codex", + "gpt-5.1-codex-mini", + "gpt-5.1-mini", + "gpt-5-nano", + "gpt-5-mini", + "gpt-5-codex", +] + +# Models that must NOT be classified as GPT-5 (regular chat path) +NON_GPT5_MODELS = [ + "gpt-5-chat", # gpt-5-chat family — regular chat path + "gpt-5-chat-latest", # gpt-5-chat family with alias suffix + "gpt-5-chat-2025-08-07", # gpt-5-chat family with date suffix + "gpt-4", + "gpt-4o", + "gpt-4-turbo", + "gpt-3.5-turbo", + "o1", + "o3", + "o3-mini", +] + + +# --------------------------------------------------------------------------- +# OpenAIGPT5Config +# --------------------------------------------------------------------------- + + +class TestOpenAIGPT5ConfigIsModelGpt5Model: + + @pytest.mark.parametrize("model", GPT5_MODELS) + def test_gpt5_models_are_classified_as_gpt5(self, model: str): + assert OpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Expected '{model}' to be classified as a GPT-5 model" + + @pytest.mark.parametrize("model", NON_GPT5_MODELS) + def test_non_gpt5_models_are_not_classified_as_gpt5(self, model: str): + assert not OpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Expected '{model}' NOT to be classified as a GPT-5 model" + + def test_versioned_chat_models_are_not_excluded_by_prefix(self): + """Core regression guard: gpt-5-chat prefix must not match versioned models.""" + versioned_chat_models = ["gpt-5.1-chat", "gpt-5.2-chat", "gpt-5.3-chat"] + for model in versioned_chat_models: + assert OpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Regression: '{model}' was incorrectly excluded from GPT-5 path" + + def test_gpt5_chat_family_is_excluded(self): + """gpt-5-chat family should stay on the regular chat path.""" + for model in ["gpt-5-chat", "gpt-5-chat-latest", "gpt-5-chat-2025-08-07"]: + assert not OpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Expected '{model}' (gpt-5-chat family) NOT to be on the GPT-5 path" + + +# --------------------------------------------------------------------------- +# AzureOpenAIGPT5Config +# --------------------------------------------------------------------------- + + +class TestAzureOpenAIGPT5ConfigIsModelGpt5Model: + + @pytest.mark.parametrize("model", GPT5_MODELS) + def test_gpt5_models_are_classified_as_gpt5(self, model: str): + assert AzureOpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Expected Azure '{model}' to be classified as a GPT-5 model" + + @pytest.mark.parametrize("model", NON_GPT5_MODELS) + def test_non_gpt5_models_are_not_classified_as_gpt5(self, model: str): + assert not AzureOpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Expected Azure '{model}' NOT to be classified as a GPT-5 model" + + def test_versioned_chat_models_are_not_excluded_by_prefix(self): + """Core regression guard: gpt-5-chat prefix must not match versioned models.""" + versioned_chat_models = ["gpt-5.1-chat", "gpt-5.2-chat", "gpt-5.3-chat"] + for model in versioned_chat_models: + assert AzureOpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Regression: Azure '{model}' was incorrectly excluded from GPT-5 path" + + def test_gpt5_chat_family_is_excluded(self): + """gpt-5-chat family should stay on the regular chat path.""" + for model in ["gpt-5-chat", "gpt-5-chat-latest", "gpt-5-chat-2025-08-07"]: + assert not AzureOpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Expected Azure '{model}' (gpt-5-chat family) NOT to be on the GPT-5 path" + + def test_gpt5_series_routing_prefix_is_always_classified_as_gpt5(self): + """Models using the gpt5_series/ manual-routing prefix must always match.""" + series_models = ["gpt5_series/my-deployment", "gpt5_series/prod"] + for model in series_models: + assert AzureOpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Azure '{model}' with gpt5_series/ prefix should be classified as GPT-5" diff --git a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py index c2d597a28ea..a1b3b31f786 100644 --- a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py +++ b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py @@ -8,6 +8,7 @@ import sys import pytest from litellm.llms.ovhcloud.utils import OVHCloudException +from litellm.utils import get_optional_params sys.path.insert( 0, os.path.abspath("../../../../..") @@ -144,6 +145,38 @@ class TestOVHCloudConfig: assert error.message == "Test error" assert error.status_code == 400 + @pytest.mark.parametrize( + "model", + [ + "Meta-Llama-3_3-70B-Instruct", + "Meta-Llama-3_1-70B-Instruct", + "Mixtral-8x7B-Instruct-v0.1", + "gpt-oss-120b", + "some-model-not-in-the-cost-map", + ], + ) + def test_tools_not_filtered_by_static_model_map(self, model): + """ + OVHCloud AI Endpoints are OpenAI-compatible; tools/tool_choice must pass + through for any model. The server is responsible for rejecting unsupported + tool calls — LiteLLM must not strip them based on a stale static catalog. + """ + + params = get_optional_params( + model=model, + custom_llm_provider="ovhcloud", + tools=[ + { + "type": "function", + "function": {"name": "x", "parameters": {}}, + } + ], + tool_choice="auto", + ) + + assert "tools" in params + assert "tool_choice" in params + def test_ovhcloud_integration(): import os diff --git a/tests/test_litellm/llms/scaleway/test_scaleway_audio_transcription_transformation.py b/tests/test_litellm/llms/scaleway/test_scaleway_audio_transcription_transformation.py new file mode 100644 index 00000000000..407e1d19fb3 --- /dev/null +++ b/tests/test_litellm/llms/scaleway/test_scaleway_audio_transcription_transformation.py @@ -0,0 +1,240 @@ +import os +from unittest.mock import MagicMock + +import httpx +import pytest + +from litellm.llms.scaleway.audio_transcription.transformation import ( + ScalewayAudioTranscriptionConfig, + ScalewayAudioTranscriptionException, +) +from litellm.types.utils import TranscriptionResponse + + +# --------------------------------------------------------------------------- +# get_complete_url +# --------------------------------------------------------------------------- + + +def test_scaleway_get_complete_url_default_base(): + """With no api_base supplied, Scaleway's Generative API endpoint is used.""" + url = ScalewayAudioTranscriptionConfig().get_complete_url( + api_base=None, + api_key="fake", + model="whisper-large-v3", + optional_params={}, + litellm_params={}, + ) + assert url == "https://api.scaleway.ai/v1/audio/transcriptions" + + +def test_scaleway_get_complete_url_custom_base_strips_trailing_slash(): + """Caller-supplied api_base is respected; trailing slash is normalized.""" + url = ScalewayAudioTranscriptionConfig().get_complete_url( + api_base="https://custom.example.com/v1/", + api_key="fake", + model="whisper-large-v3", + optional_params={}, + litellm_params={}, + ) + assert url == "https://custom.example.com/v1/audio/transcriptions" + + +# --------------------------------------------------------------------------- +# validate_environment +# --------------------------------------------------------------------------- + + +def test_scaleway_validate_environment_explicit_api_key(): + headers = ScalewayAudioTranscriptionConfig().validate_environment( + headers={}, + model="whisper-large-v3", + messages=[], + optional_params={}, + litellm_params={}, + api_key="explicit-key", + ) + assert headers["Authorization"] == "Bearer explicit-key" + assert headers["accept"] == "application/json" + + +def test_scaleway_validate_environment_reads_scw_secret_key(monkeypatch): + monkeypatch.setenv("SCW_SECRET_KEY", "env-secret") + headers = ScalewayAudioTranscriptionConfig().validate_environment( + headers={}, + model="whisper-large-v3", + messages=[], + optional_params={}, + litellm_params={}, + ) + assert headers["Authorization"] == "Bearer env-secret" + + +def test_scaleway_validate_environment_explicit_api_key_wins_over_env(monkeypatch): + """Caller-supplied api_key must win over the SCW_SECRET_KEY env var.""" + monkeypatch.setenv("SCW_SECRET_KEY", "env-secret") + headers = ScalewayAudioTranscriptionConfig().validate_environment( + headers={}, + model="whisper-large-v3", + messages=[], + optional_params={}, + litellm_params={}, + api_key="explicit-wins", + ) + assert headers["Authorization"] == "Bearer explicit-wins" + + +# --------------------------------------------------------------------------- +# transform_audio_transcription_request +# --------------------------------------------------------------------------- + + +def _open_test_audio(): + """Shared helper: open the repo's canonical speech fixture.""" + wav_path = os.path.join( + os.path.dirname(__file__), + "../../../..", + "tests", + "llm_translation", + "gettysburg.wav", + ) + return open(wav_path, "rb") + + +def test_scaleway_transform_request_builds_multipart_with_supported_params(): + with _open_test_audio() as audio_file: + result = ( + ScalewayAudioTranscriptionConfig().transform_audio_transcription_request( + model="whisper-large-v3", + audio_file=audio_file, + optional_params={ + "language": "en", + "temperature": 0.0, + "response_format": "verbose_json", + }, + litellm_params={}, + ) + ) + + assert isinstance(result.data, dict) + assert result.data["model"] == "whisper-large-v3" + assert result.data["language"] == "en" + assert result.data["temperature"] == 0.0 + assert result.data["response_format"] == "verbose_json" + assert result.files is not None + assert "file" in result.files + assert len(result.files["file"]) == 3 # (filename, content, content_type) + + +def test_scaleway_transform_request_drops_unsupported_params(): + """Only params in get_supported_openai_params() should land in the form.""" + with _open_test_audio() as audio_file: + result = ( + ScalewayAudioTranscriptionConfig().transform_audio_transcription_request( + model="whisper-large-v3", + audio_file=audio_file, + optional_params={ + "language": "en", + "stream": True, # not supported + "diarize": True, # not supported + }, + litellm_params={}, + ) + ) + + assert "stream" not in result.data + assert "diarize" not in result.data + assert result.data["language"] == "en" + + +# --------------------------------------------------------------------------- +# transform_audio_transcription_response +# --------------------------------------------------------------------------- + + +def test_scaleway_transform_response_parses_text(): + mock_response = MagicMock(spec=httpx.Response) + mock_response.headers = {"content-type": "application/json"} + mock_response.json.return_value = {"text": "Four score and seven years ago"} + + response = ( + ScalewayAudioTranscriptionConfig().transform_audio_transcription_response( + mock_response + ) + ) + + assert isinstance(response, TranscriptionResponse) + assert response.text == "Four score and seven years ago" + + +def test_scaleway_transform_response_preserves_segments_and_language(): + mock_response = MagicMock(spec=httpx.Response) + mock_response.headers = {"content-type": "application/json"} + mock_response.json.return_value = { + "text": "hello world", + "language": "en", + "segments": [ + {"text": "hello", "start": 0.0, "end": 0.5}, + {"text": "world", "start": 0.6, "end": 1.1}, + ], + } + + response = ( + ScalewayAudioTranscriptionConfig().transform_audio_transcription_response( + mock_response + ) + ) + + assert response.text == "hello world" + assert response["language"] == "en" + assert len(response["segments"]) == 2 + + +def test_scaleway_transform_response_raises_typed_exception_on_non_json(): + """Malformed upstream body must raise the Scaleway-typed exception so + error handlers downstream can classify it as a Scaleway failure.""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.json.side_effect = ValueError("not json") + mock_response.headers = {"content-type": "application/json"} + mock_response.text = "upstream 502 bad gateway" + mock_response.status_code = 502 + + with pytest.raises(ScalewayAudioTranscriptionException): + ScalewayAudioTranscriptionConfig().transform_audio_transcription_response( + mock_response + ) + + +def test_scaleway_transform_response_returns_plain_text_for_non_json_content_type(): + """When Scaleway responds with text/srt/vtt (response_format="text" etc.), + the content-type is not application/json — return the body as plain text + rather than exploding on .json().""" + mock_response = MagicMock(spec=httpx.Response) + mock_response.headers = {"content-type": "text/plain; charset=utf-8"} + mock_response.text = "Four score and seven years ago" + + response = ( + ScalewayAudioTranscriptionConfig().transform_audio_transcription_response( + mock_response + ) + ) + + assert isinstance(response, TranscriptionResponse) + assert response.text == "Four score and seven years ago" + + +def test_scaleway_validate_environment_raises_when_no_key(monkeypatch): + """Missing credential should fail fast with a typed exception rather than + silently emitting 'Bearer None'.""" + monkeypatch.delenv("SCW_SECRET_KEY", raising=False) + + with pytest.raises(ScalewayAudioTranscriptionException) as excinfo: + ScalewayAudioTranscriptionConfig().validate_environment( + headers={}, + model="whisper-large-v3", + messages=[], + optional_params={}, + litellm_params={}, + ) + + assert "SCW_SECRET_KEY" in str(excinfo.value) diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py index 6937b4c3ba1..831d1ef464b 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py @@ -967,6 +967,94 @@ class TestMediaResolution: assert "mediaResolution" not in result["generationConfig"] +# Tests for VideoMetadata support across all Gemini models (Issue #25474) +class TestVideoMetadataAllGeminiModels: + """Tests that video_metadata (fps, start_offset, end_offset) works for all Gemini models""" + + def _make_video_messages(self, video_metadata: dict) -> list: + return [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Analyze this video"}, + { + "type": "file", + "file": { + "file_id": "gs://bucket/video.mp4", + "format": "video/mp4", + "video_metadata": video_metadata, + }, + }, + ], + } + ] + + def _get_file_part(self, contents: list) -> dict: + for part in contents[0]["parts"]: + if "file_data" in part: + return part + raise AssertionError("No file part found in contents") + + def test_video_metadata_fps_gemini_2_5_flash(self): + """Gemini 2.5 Flash: fps in video_metadata should be forwarded (Issue #25474)""" + messages = self._make_video_messages({"fps": 5}) + contents = _gemini_convert_messages_with_history( + messages=messages, model="gemini-2.5-flash" + ) + file_part = self._get_file_part(contents) + assert "video_metadata" in file_part + assert file_part["video_metadata"]["fps"] == 5 + + def test_video_metadata_fps_gemini_2_5_pro(self): + """Gemini 2.5 Pro: fps in video_metadata should be forwarded (Issue #25474)""" + messages = self._make_video_messages({"fps": 10}) + contents = _gemini_convert_messages_with_history( + messages=messages, model="gemini-2.5-pro" + ) + file_part = self._get_file_part(contents) + assert "video_metadata" in file_part + assert file_part["video_metadata"]["fps"] == 10 + + def test_video_metadata_offsets_gemini_2_5_flash(self): + """Gemini 2.5 Flash: start_offset/end_offset converted to camelCase (Issue #25474)""" + messages = self._make_video_messages( + {"start_offset": "5s", "end_offset": "30s"} + ) + contents = _gemini_convert_messages_with_history( + messages=messages, model="gemini-2.5-flash" + ) + file_part = self._get_file_part(contents) + assert "video_metadata" in file_part + vm = file_part["video_metadata"] + assert vm["startOffset"] == "5s" + assert vm["endOffset"] == "30s" + + def test_video_metadata_all_fields_gemini_2_5_flash(self): + """Gemini 2.5 Flash: all video_metadata fields forwarded correctly (Issue #25474)""" + messages = self._make_video_messages( + {"fps": 5, "start_offset": "10s", "end_offset": "60s"} + ) + contents = _gemini_convert_messages_with_history( + messages=messages, model="gemini-2.5-flash" + ) + file_part = self._get_file_part(contents) + assert "video_metadata" in file_part + vm = file_part["video_metadata"] + assert vm["fps"] == 5 + assert vm["startOffset"] == "10s" + assert vm["endOffset"] == "60s" + + def test_video_metadata_gemini_1_5_pro(self): + """Gemini 1.5 Pro: video_metadata should also be forwarded (Issue #25474)""" + messages = self._make_video_messages({"fps": 2}) + contents = _gemini_convert_messages_with_history( + messages=messages, model="gemini-1.5-pro" + ) + file_part = self._get_file_part(contents) + assert "video_metadata" in file_part + assert file_part["video_metadata"]["fps"] == 2 + + def test_convert_tool_response_with_base64_image(): """Test tool response with base64 data URI image.""" # Create a small test image (1x1 red pixel PNG) diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py index a0979664943..1ea7486a515 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py @@ -3476,8 +3476,8 @@ def test_new_detail_levels(): assert file_part["media_resolution"] == {"level": "MEDIA_RESOLUTION_MEDIUM"} -def test_video_metadata_only_for_gemini_3(): - """Test that video_metadata is only applied for Gemini 3+ models (Issue #19026)""" +def test_video_metadata_supported_for_all_gemini_models(): + """Test that video_metadata is applied for all Gemini models (Issue #25474)""" from litellm.llms.vertex_ai.gemini.transformation import ( _gemini_convert_messages_with_history, ) @@ -3499,39 +3499,29 @@ def test_video_metadata_only_for_gemini_3(): } ] - # Test with Gemini 1.5 (should not have video_metadata or media_resolution) - contents_1_5 = _gemini_convert_messages_with_history( - messages=messages, model="gemini-1.5-pro" - ) + for model in ["gemini-1.5-pro", "gemini-2.5-flash", "gemini-2.5-pro", "gemini-3-pro-preview"]: + contents = _gemini_convert_messages_with_history(messages=messages, model=model) - file_part_1_5 = None - for part in contents_1_5[0]["parts"]: - if "file_data" in part: - file_part_1_5 = part - break + file_part = None + for part in contents[0]["parts"]: + if "file_data" in part: + file_part = part + break - assert file_part_1_5 is not None - assert ( - "media_resolution" not in file_part_1_5 - ), "Gemini 1.5 should not have media_resolution" - assert ( - "video_metadata" not in file_part_1_5 - ), "Gemini 1.5 should not have video_metadata" + assert file_part is not None, f"{model}: file part should exist" + assert "video_metadata" in file_part, f"{model}: video_metadata should be present" + assert file_part["video_metadata"]["fps"] == 5, f"{model}: fps should be 5" - # Test with Gemini 3 (should have both) - contents_3 = _gemini_convert_messages_with_history( - messages=messages, model="gemini-3-pro-preview" - ) + # Per-part media_resolution is Gemini 3+ only; 2.x uses generation_config global + for model in ["gemini-3-pro-preview"]: + contents = _gemini_convert_messages_with_history(messages=messages, model=model) + file_part = next(p for p in contents[0]["parts"] if "file_data" in p) + assert "media_resolution" in file_part, f"{model}: media_resolution should be present" - file_part_3 = None - for part in contents_3[0]["parts"]: - if "file_data" in part: - file_part_3 = part - break - - assert file_part_3 is not None - assert "media_resolution" in file_part_3, "Gemini 3 should have media_resolution" - assert "video_metadata" in file_part_3, "Gemini 3 should have video_metadata" + for model in ["gemini-1.5-pro", "gemini-2.5-flash", "gemini-2.5-pro"]: + contents = _gemini_convert_messages_with_history(messages=messages, model=model) + file_part = next(p for p in contents[0]["parts"] if "file_data" in p) + assert "media_resolution" not in file_part, f"{model}: per-part media_resolution should not be set" def test_chunk_parser_handles_prompt_feedback_block(): diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_global_url_support.py b/tests/test_litellm/llms/vertex_ai/test_vertex_global_url_support.py index 7b359af9b89..5a007f5a4f6 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_global_url_support.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_global_url_support.py @@ -5,6 +5,7 @@ This test suite ensures that all Vertex AI endpoints properly handle the 'global which uses a different URL format than regional endpoints. Regional: https://{region}-aiplatform.googleapis.com/... +Multi-region: https://aiplatform.{geo}.rep.googleapis.com/... Global: https://aiplatform.googleapis.com/... """ @@ -30,6 +31,8 @@ class TestVertexBaseURL: ("europe-west1", "https://europe-west1-aiplatform.googleapis.com"), ("asia-northeast1", "https://asia-northeast1-aiplatform.googleapis.com"), ("global", "https://aiplatform.googleapis.com"), + ("us", "https://aiplatform.us.rep.googleapis.com"), + ("eu", "https://aiplatform.eu.rep.googleapis.com"), ], ) def test_get_vertex_base_url(self, vertex_location, expected_base_url): diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py index 3acd5c112e3..2558df8533b 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py @@ -450,3 +450,193 @@ async def test_semantic_filter_hook_skips_no_tools(): # Should return None (no modification) assert result is None, "Hook should skip requests without tools" print("✅ Hook correctly skips requests without tools") + + +class TestGetToolsByNames: + """ + Regression coverage for SemanticMCPToolFilter._get_tools_by_names + name-matching behavior (issue #26078). + + The canonical name stored in the router is what the proxy's MCP + registry emits (e.g. ``fc_web_search-firecrawl_scrape``). Some MCP + clients — notably opencode — wrap every tool name with their own + additive namespace prefix before sending it back in ``tools[]``, so + the incoming name is ``litellm_fc_web_search-firecrawl_scrape``. + + Exact-equality matching against the canonical dropped every such + tool, the proxy forwarded ``tools: []`` with ``tool_choice: auto``, + and strict upstream providers returned 400. + """ + + def _make_filter(self): + from litellm.proxy._experimental.mcp_server.semantic_tool_filter import ( + SemanticMCPToolFilter, + ) + + return SemanticMCPToolFilter( + embedding_model="text-embedding-3-small", + litellm_router_instance=Mock(), + top_k=5, + similarity_threshold=0.3, + enabled=True, + ) + + def test_exact_match_unchanged(self): + """Incoming name equals canonical — the historical path still works.""" + filter_instance = self._make_filter() + available_tools = [ + {"name": "get_weather", "description": "fetch weather"}, + {"name": "send_email", "description": "send mail"}, + ] + + matched = filter_instance._get_tools_by_names( + ["send_email"], available_tools + ) + + assert len(matched) == 1 + assert matched[0]["name"] == "send_email" + + def test_client_prefix_with_underscore_separator(self): + """Client wraps canonical with ``_`` (opencode pattern).""" + filter_instance = self._make_filter() + canonical = "fc_web_search-firecrawl_scrape" + client_name = "litellm_" + canonical + available_tools = [{"name": client_name, "description": "scrape"}] + + matched = filter_instance._get_tools_by_names( + [canonical], available_tools + ) + + assert len(matched) == 1 + # Must return the incoming tool unchanged so the client-facing + # name survives, otherwise tool-call round-trips break client-side. + assert matched[0]["name"] == client_name + + def test_client_prefix_with_dash_separator(self): + """Some clients use dash as alias separator; accept that too.""" + filter_instance = self._make_filter() + canonical = "weather_svc-get_weather" + available_tools = [ + {"name": "mcp-" + canonical, "description": "weather"} + ] + + matched = filter_instance._get_tools_by_names( + [canonical], available_tools + ) + + assert len(matched) == 1 + assert matched[0]["name"] == "mcp-" + canonical + + def test_suffix_without_separator_does_not_match(self): + """ + A bare-substring suffix must not match — ``rain_gear`` is not a + namespaced version of canonical ``ear`` and the user would be + surprised to see it selected. + """ + filter_instance = self._make_filter() + available_tools = [{"name": "rain_gear", "description": "raincoat"}] + + matched = filter_instance._get_tools_by_names(["ear"], available_tools) + + assert matched == [] + + def test_exact_match_preferred_over_prefixed(self): + """ + When both a bare canonical and a client-prefixed variant are + present, the bare one wins so ordering is stable. + """ + filter_instance = self._make_filter() + canonical = "search" + available_tools = [ + {"name": canonical, "description": "plain"}, + {"name": "litellm_" + canonical, "description": "wrapped"}, + ] + + matched = filter_instance._get_tools_by_names( + [canonical], available_tools + ) + + assert len(matched) == 1 + assert matched[0]["name"] == canonical + + def test_same_tool_not_returned_twice(self): + """ + Two distinct canonicals that both suffix-match the same incoming + tool must not produce a duplicate in the output list. + ``fs-read_file`` and ``api-fs-read_file`` are both valid + separator-anchored suffixes of ``litellm_api-fs-read_file``. + """ + filter_instance = self._make_filter() + available_tools = [ + {"name": "litellm_api-fs-read_file", "description": "read"} + ] + + matched = filter_instance._get_tools_by_names( + ["fs-read_file", "api-fs-read_file"], available_tools + ) + + assert len(matched) == 1 + + def test_suffix_fallback_prefers_shortest_candidate(self): + """ + When no exact match exists and several incoming tools + suffix-match the same canonical, the one closest in length to + the canonical (i.e. the least-wrapped) should be chosen. + """ + filter_instance = self._make_filter() + canonical = "svc-search" + available_tools = [ + {"name": "my_tag_" + canonical, "description": "tag search"}, + {"name": "my_" + canonical, "description": "plain search"}, + ] + + matched = filter_instance._get_tools_by_names( + [canonical], available_tools + ) + + assert len(matched) == 1 + assert matched[0]["name"] == "my_" + canonical + + def test_ordering_follows_router_output(self): + """Returned tools follow the order the semantic router chose.""" + filter_instance = self._make_filter() + available_tools = [ + {"name": "litellm_fs-read", "description": "read"}, + {"name": "litellm_fs-write", "description": "write"}, + {"name": "litellm_fs-delete", "description": "delete"}, + ] + + matched = filter_instance._get_tools_by_names( + ["fs-write", "fs-delete", "fs-read"], available_tools + ) + + names = [t["name"] for t in matched] + assert names == [ + "litellm_fs-write", + "litellm_fs-delete", + "litellm_fs-read", + ] + + def test_does_not_collide_with_local_function_on_unprefixed_canonical(self): + """ + Guard against the collision @krrish-berri-2 flagged on #26117: + if the canonical name from the router is not server-prefixed + (i.e. does not contain ``MCP_TOOL_PREFIX_SEPARATOR``), suffix + matching must not kick in. Otherwise an unrelated local user + function whose name happens to end in the canonical substring + would be spuriously selected. + """ + filter_instance = self._make_filter() + available_tools = [ + { + "name": "my_firecrawl_scrape", + "description": "unrelated local function", + }, + ] + + matched = filter_instance._get_tools_by_names( + ["firecrawl_scrape"], # no MCP_TOOL_PREFIX_SEPARATOR in canonical + available_tools, + ) + + assert matched == [] diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 19fffffc65b..8612d243c41 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -2126,3 +2126,192 @@ class TestGuardrailModificationCheck: """Unparseable strings should not trigger a 403 — they have no keys.""" self._call({"metadata": "not-json"}) self._call({"metadata": '"just a string"'}) + + +@pytest.mark.asyncio +async def test_team_member_budget_check_falls_back_to_team_default_budget_id(): + """When a member's TeamMembership has no linked budget row, the check + should fall back to team.metadata["team_member_budget_id"] and still + enforce the cap. Pre-fix, this path silently skipped enforcement.""" + from litellm.caching.dual_cache import DualCache + from litellm.proxy._types import LiteLLM_TeamMembership + from litellm.proxy.utils import ProxyLogging + + team_object = LiteLLM_TeamTable( + team_id="test-team", + metadata={"team_member_budget_id": "budget-default"}, + ) + user_object = LiteLLM_UserTable(user_id="test-user") + valid_token = UserAPIKeyAuth( + token="test-token", + user_id="test-user", + team_id="test-team", + ) + + # Membership row without an attached budget. + team_membership = LiteLLM_TeamMembership( + user_id="test-user", + team_id="test-team", + spend=0.0, + budget_id=None, + litellm_budget_table=None, + ) + + proxy_logging_obj = ProxyLogging(user_api_key_cache=None) + + fake_budget_row = MagicMock() + fake_budget_row.max_budget = 50.0 + fake_budget_row.dict = MagicMock( + return_value={"budget_id": "budget-default", "max_budget": 50.0} + ) + + prisma_client = MagicMock() + prisma_client.db.litellm_budgettable.find_unique = AsyncMock( + return_value=fake_budget_row + ) + + async def mock_get_current_spend(counter_key, fallback_spend): + if counter_key == "spend:team_member:test-user:test-team": + return 70.0 + return fallback_spend + + user_api_key_cache = DualCache() + + with ( + patch("litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend), + patch( + "litellm.proxy.auth.auth_checks.get_team_membership", + new_callable=AsyncMock, + return_value=team_membership, + ), + ): + with pytest.raises(litellm.BudgetExceededError) as exc_info: + await _check_team_member_budget( + team_object=team_object, + user_object=user_object, + valid_token=valid_token, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + assert exc_info.value.current_cost == 70.0 + assert exc_info.value.max_budget == 50.0 + + # First call did perform the fallback DB lookup. + prisma_client.db.litellm_budgettable.find_unique.assert_awaited_once() + + # Second call hits the cached budget row, no additional prisma read. + prisma_client.db.litellm_budgettable.find_unique.reset_mock() + with ( + patch("litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend), + patch( + "litellm.proxy.auth.auth_checks.get_team_membership", + new_callable=AsyncMock, + return_value=team_membership, + ), + ): + with pytest.raises(litellm.BudgetExceededError) as second_exc_info: + await _check_team_member_budget( + team_object=team_object, + user_object=user_object, + valid_token=valid_token, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + # The cached $50 cap is still being applied (not a coincidental skip) + assert second_exc_info.value.current_cost == 70.0 + assert second_exc_info.value.max_budget == 50.0 + prisma_client.db.litellm_budgettable.find_unique.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_team_member_budget_check_per_member_override_wins_over_team_default(): + """If a member has a per-member budget AND the team carries a + team_member_budget_id default, the per-member value wins and the + fallback prisma lookup is never performed.""" + from litellm.caching.dual_cache import DualCache + from litellm.proxy._types import LiteLLM_BudgetTable, LiteLLM_TeamMembership + from litellm.proxy.utils import ProxyLogging + + team_object = LiteLLM_TeamTable( + team_id="test-team", + metadata={"team_member_budget_id": "budget-default"}, + ) + user_object = LiteLLM_UserTable(user_id="test-user") + valid_token = UserAPIKeyAuth( + token="test-token", + user_id="test-user", + team_id="test-team", + ) + + team_membership = LiteLLM_TeamMembership( + user_id="test-user", + team_id="test-team", + spend=0.0, + budget_id="budget-override", + litellm_budget_table=LiteLLM_BudgetTable(max_budget=200.0), + ) + + proxy_logging_obj = ProxyLogging(user_api_key_cache=None) + + # Team-default row resolves to $50. If the fallback fired (it must + # not here), spend $70 would exceed that $50 cap and raise. + fake_budget_row = MagicMock() + fake_budget_row.max_budget = 50.0 + + prisma_client = MagicMock() + prisma_client.db.litellm_budgettable.find_unique = AsyncMock( + return_value=fake_budget_row + ) + + mocked_spend = 70.0 + + async def mock_get_current_spend(counter_key, fallback_spend): + if counter_key == "spend:team_member:test-user:test-team": + return mocked_spend + return fallback_spend + + # 1. spend ($70) < per-member cap ($200) → no raise, no fallback lookup. + with ( + patch("litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend), + patch( + "litellm.proxy.auth.auth_checks.get_team_membership", + new_callable=AsyncMock, + return_value=team_membership, + ), + ): + await _check_team_member_budget( + team_object=team_object, + user_object=user_object, + valid_token=valid_token, + prisma_client=prisma_client, + user_api_key_cache=DualCache(), + proxy_logging_obj=proxy_logging_obj, + ) + + prisma_client.db.litellm_budgettable.find_unique.assert_not_awaited() + + # 2. Now push spend above the per-member cap ($200). Must raise with + # max_budget=200 to prove the per-member cap is the value being + # enforced (not just that enforcement silently skipped). + mocked_spend = 250.0 + with ( + patch("litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend), + patch( + "litellm.proxy.auth.auth_checks.get_team_membership", + new_callable=AsyncMock, + return_value=team_membership, + ), + ): + with pytest.raises(litellm.BudgetExceededError) as exc_info: + await _check_team_member_budget( + team_object=team_object, + user_object=user_object, + valid_token=valid_token, + prisma_client=prisma_client, + user_api_key_cache=DualCache(), + proxy_logging_obj=proxy_logging_obj, + ) + assert exc_info.value.current_cost == 250.0 + assert exc_info.value.max_budget == 200.0 diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index bca6b9e78d9..bfebc7145dd 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -152,6 +152,38 @@ def test_virtual_key_mcp_routes_allows_v1_mcp_server_subpaths(route): assert result is True +@pytest.mark.parametrize( + "route", + [ + "/v1/mcp/server", + "/v1/mcp/server/abc-123", + "/v1/mcp/server/abc-123/approve", + ], +) +def test_mcp_management_routes_classified_as_management_not_llm_api(route): + """MCP server CRUD must be management routes, not llm_api routes, so + DISABLE_LLM_API_ENDPOINTS on admin nodes does not block the Admin UI.""" + + assert RouteChecks.is_llm_api_route(route=route) is False + assert RouteChecks.is_management_route(route=route) is True + + +@pytest.mark.parametrize( + "route", + [ + "/mcp/tools/call", + "/mcp-rest/tools/call", + "/mcp/tools/list", + ], +) +def test_mcp_inference_routes_classified_as_llm_api(route): + """MCP tool-call / passthrough routes must remain llm_api routes so they + continue to be blocked by DISABLE_LLM_API_ENDPOINTS on admin nodes.""" + + assert RouteChecks.is_llm_api_route(route=route) is True + assert RouteChecks.is_management_route(route=route) is False + + def test_virtual_key_allowed_routes_with_litellm_routes_member_name_denied(): """Test that virtual key is denied when route is not in the allowed LiteLLMRoutes group""" diff --git a/tests/test_litellm/proxy/common_utils/test_callback_utils.py b/tests/test_litellm/proxy/common_utils/test_callback_utils.py index 27528fbd20b..c6132194c74 100644 --- a/tests/test_litellm/proxy/common_utils/test_callback_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_callback_utils.py @@ -1,14 +1,17 @@ import sys import os +from types import SimpleNamespace sys.path.insert( 0, os.path.abspath("../../..") ) # Adds the parent directory to the system path from litellm.proxy.common_utils.callback_utils import ( + initialize_callbacks_on_proxy, get_remaining_tokens_and_requests_from_request_data, normalize_callback_names, ) +import litellm from unittest.mock import patch from litellm.proxy.common_utils.callback_utils import process_callback @@ -84,3 +87,35 @@ def test_normalize_callback_names_lowercases_strings(): "s3", "custom_callback", ] + + +def test_initialize_callbacks_on_proxy_instantiates_compression_interception( + monkeypatch, +): + dummy_callback = object() + monkeypatch.setitem( + sys.modules, + "litellm.proxy.proxy_server", + SimpleNamespace(prisma_client=None), + ) + monkeypatch.setattr( + "litellm.integrations.compression_interception.handler.CompressionInterceptionLogger.initialize_from_proxy_config", + lambda litellm_settings, callback_specific_params: dummy_callback, + ) + + original_callbacks = ( + list(litellm.callbacks) if isinstance(litellm.callbacks, list) else [] + ) + litellm.callbacks = [] + try: + initialize_callbacks_on_proxy( + value=["compression_interception"], + premium_user=False, + config_file_path=".", + litellm_settings={"compression_interception_params": {"enabled": True}}, + callback_specific_params={}, + ) + assert dummy_callback in litellm.callbacks + assert "compression_interception" not in litellm.callbacks + finally: + litellm.callbacks = original_callbacks diff --git a/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py b/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py index 8206079cb8d..379ccf4d9af 100644 --- a/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py +++ b/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py @@ -1,9 +1,12 @@ import asyncio +import json import os import sys import time +import types from datetime import datetime, timedelta, timezone from typing import Any, Dict, List +from unittest.mock import AsyncMock, MagicMock import pytest @@ -695,9 +698,9 @@ def test_reset_budget_resets_endusers_with_null_budget_id( # Both end users should have been reset updated = mock_prisma_client.updated_data["enduser"] - assert len(updated) == 2, ( - f"Expected 2 endusers reset (1 explicit + 1 implicit), got {len(updated)}" - ) + assert ( + len(updated) == 2 + ), f"Expected 2 endusers reset (1 explicit + 1 implicit), got {len(updated)}" user_ids = {u.user_id for u in updated} assert "enduser-explicit" in user_ids @@ -784,3 +787,265 @@ def test_reset_budget_skips_null_budget_id_endusers_when_default_not_in_reset_li assert len(find_many_calls) == 0 litellm.max_end_user_budget_id = None + + +def test_reset_budget_for_team_members_preserves_total_spend(): + """Regression guard: reset_budget_for_litellm_team_members must zero `spend` + but leave `total_spend` untouched. + + The reset writes `data={"spend": 0}` explicitly. If a future refactor adds + `"total_spend": 0` to that dict, this test fails immediately. + """ + expired_budget = type( + "LiteLLM_BudgetTableFull", + (), + {"budget_id": "budget-1"}, + ) + + mock_prisma_client = MagicMock() + mock_prisma_client.db.litellm_teammembership.find_many = AsyncMock(return_value=[]) + mock_prisma_client.db.litellm_teammembership.update_many = AsyncMock( + return_value={"count": 1} + ) + + job = ResetBudgetJob( + proxy_logging_obj=MagicMock(), prisma_client=mock_prisma_client + ) + + asyncio.run(job.reset_budget_for_litellm_team_members([expired_budget])) + + mock_prisma_client.db.litellm_teammembership.update_many.assert_called_once() + call_kwargs = ( + mock_prisma_client.db.litellm_teammembership.update_many.call_args.kwargs + ) + assert call_kwargs["where"]["budget_id"]["in"] == ["budget-1"] + assert call_kwargs["data"] == {"spend": 0} + assert "total_spend" not in call_kwargs["data"] + + +# --------------------------------------------------------------------------- +# reset_budget_windows (per-key / per-team concurrent window resets) +# --------------------------------------------------------------------------- + + +def _make_reset_budget_windows_job( + monkeypatch, + key_rows: List[Dict[str, Any]], + team_rows: List[Dict[str, Any]], +): + """Build a ResetBudgetJob with a fully-mocked prisma client and a fake + `litellm.proxy.proxy_server` module exposing a stub `spend_counter_cache`. + + Returns (job, prisma_client_mock, spend_counter_cache_mock). + """ + prisma_client = MagicMock() + + async def fake_query_raw(query: str, *args, **kwargs): + # Dispatch by table name in the SQL so a single stub covers both calls. + if '"LiteLLM_VerificationToken"' in query: + return key_rows + if '"LiteLLM_TeamTable"' in query: + return team_rows + raise AssertionError(f"Unexpected query_raw call: {query}") + + prisma_client.db.query_raw = AsyncMock(side_effect=fake_query_raw) + prisma_client.db.litellm_verificationtoken.update = AsyncMock(return_value=None) + prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=None) + + # Stub out litellm.proxy.proxy_server so the in-function + # `from litellm.proxy.proxy_server import spend_counter_cache` resolves + # without importing the real (heavy) module. + spend_counter_cache = MagicMock() + spend_counter_cache.in_memory_cache.set_cache = MagicMock() + spend_counter_cache.redis_cache = None # skip the async redis branch + + fake_module = types.ModuleType("litellm.proxy.proxy_server") + fake_module.spend_counter_cache = spend_counter_cache + monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", fake_module) + + job = ResetBudgetJob(proxy_logging_obj=MagicMock(), prisma_client=prisma_client) + return job, prisma_client, spend_counter_cache + + +def test_reset_budget_windows_uses_is_not_null_filter(monkeypatch): + """Regression guard for the Prisma client limitation documented in + RobertCraigie/prisma-client-py#714: `{"not": None}` on a `Json?` column + raises `MissingRequiredValueError`. We work around it by using `query_raw` + with `IS NOT NULL`. If someone reverts to the ORM filter, this test fails. + """ + job, prisma_client, _ = _make_reset_budget_windows_job( + monkeypatch, key_rows=[], team_rows=[] + ) + + asyncio.run(job.reset_budget_windows()) + + queries = [call.args[0] for call in prisma_client.db.query_raw.await_args_list] + assert len(queries) == 2, queries + key_query, team_query = queries + + assert '"LiteLLM_VerificationToken"' in key_query + assert "budget_limits IS NOT NULL" in key_query + assert '"LiteLLM_TeamTable"' in team_query + assert "budget_limits IS NOT NULL" in team_query + + +def test_reset_budget_windows_resets_expired_key_window(monkeypatch): + """A key whose window's `reset_at` has passed gets an update with a new + `reset_at` in the future, and the in-memory spend counter is cleared.""" + now = datetime.utcnow() + expired = (now - timedelta(minutes=5)).isoformat() + "Z" + + key_rows = [ + { + "token": "sk-expired", + "budget_limits": [{"budget_duration": "1d", "reset_at": expired}], + } + ] + job, prisma_client, spend_counter_cache = _make_reset_budget_windows_job( + monkeypatch, key_rows=key_rows, team_rows=[] + ) + + asyncio.run(job.reset_budget_windows()) + + # Update should have been called exactly once with the expired token. + prisma_client.db.litellm_verificationtoken.update.assert_awaited_once() + call_kwargs = prisma_client.db.litellm_verificationtoken.update.await_args.kwargs + assert call_kwargs["where"] == {"token": "sk-expired"} + + # The `budget_limits` payload is re-serialized JSON with a bumped reset_at. + written_windows = json.loads(call_kwargs["data"]["budget_limits"]) + assert len(written_windows) == 1 + new_reset_at = datetime.fromisoformat( + written_windows[0]["reset_at"].replace("Z", "+00:00") + ).replace(tzinfo=None) + assert new_reset_at > now + + # The spend counter for this key+window was cleared. + spend_counter_cache.in_memory_cache.set_cache.assert_any_call( + key="spend:key:sk-expired:window:1d", value=0.0 + ) + + +def test_reset_budget_windows_skips_unexpired_key_window(monkeypatch): + """If `reset_at` is in the future, no write should happen for that key.""" + now = datetime.utcnow() + future = (now + timedelta(hours=1)).isoformat() + "Z" + + key_rows = [ + { + "token": "sk-future", + "budget_limits": [{"budget_duration": "1d", "reset_at": future}], + } + ] + job, prisma_client, _ = _make_reset_budget_windows_job( + monkeypatch, key_rows=key_rows, team_rows=[] + ) + + asyncio.run(job.reset_budget_windows()) + + prisma_client.db.litellm_verificationtoken.update.assert_not_awaited() + + +def test_reset_budget_windows_resets_expired_team_window(monkeypatch): + """Same as the key test, but for teams.""" + now = datetime.utcnow() + expired = (now - timedelta(minutes=1)).isoformat() + "Z" + + team_rows = [ + { + "team_id": "team-expired", + "budget_limits": [{"budget_duration": "30d", "reset_at": expired}], + } + ] + job, prisma_client, spend_counter_cache = _make_reset_budget_windows_job( + monkeypatch, key_rows=[], team_rows=team_rows + ) + + asyncio.run(job.reset_budget_windows()) + + prisma_client.db.litellm_teamtable.update.assert_awaited_once() + call_kwargs = prisma_client.db.litellm_teamtable.update.await_args.kwargs + assert call_kwargs["where"] == {"team_id": "team-expired"} + assert "budget_limits" in call_kwargs["data"] + + spend_counter_cache.in_memory_cache.set_cache.assert_any_call( + key="spend:team:team-expired:window:30d", value=0.0 + ) + + +def test_reset_budget_windows_handles_string_budget_limits(monkeypatch): + """Defensive: if `query_raw` returns `budget_limits` as a JSON-encoded + string (driver-dependent), the code still parses and resets it. + """ + now = datetime.utcnow() + expired = (now - timedelta(minutes=1)).isoformat() + "Z" + + key_rows = [ + { + "token": "sk-string-limits", + "budget_limits": json.dumps( + [{"budget_duration": "1d", "reset_at": expired}] + ), + } + ] + job, prisma_client, _ = _make_reset_budget_windows_job( + monkeypatch, key_rows=key_rows, team_rows=[] + ) + + asyncio.run(job.reset_budget_windows()) + + prisma_client.db.litellm_verificationtoken.update.assert_awaited_once() + + +def test_reset_budget_windows_skips_row_with_empty_budget_limits(monkeypatch): + """A row whose `budget_limits` comes back as an empty/falsy payload + (shouldn't happen given the WHERE filter, but we guard anyway) must not + trigger an update or crash the loop.""" + key_rows = [ + {"token": "sk-empty-list", "budget_limits": []}, + {"token": "sk-empty-str", "budget_limits": ""}, + ] + job, prisma_client, _ = _make_reset_budget_windows_job( + monkeypatch, key_rows=key_rows, team_rows=[] + ) + + asyncio.run(job.reset_budget_windows()) + + prisma_client.db.litellm_verificationtoken.update.assert_not_awaited() + + +def test_reset_budget_windows_query_error_does_not_break_team_path(monkeypatch): + """If the key query raises, the teams path still runs (and vice-versa). + Each side has its own try/except; this locks that in.""" + now = datetime.utcnow() + expired = (now - timedelta(minutes=1)).isoformat() + "Z" + + prisma_client = MagicMock() + + async def fake_query_raw(query: str, *args, **kwargs): + if '"LiteLLM_VerificationToken"' in query: + raise RuntimeError("boom") + if '"LiteLLM_TeamTable"' in query: + return [ + { + "team_id": "team-ok", + "budget_limits": [{"budget_duration": "1d", "reset_at": expired}], + } + ] + raise AssertionError(query) + + prisma_client.db.query_raw = AsyncMock(side_effect=fake_query_raw) + prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=None) + + spend_counter_cache = MagicMock() + spend_counter_cache.in_memory_cache.set_cache = MagicMock() + spend_counter_cache.redis_cache = None + fake_module = types.ModuleType("litellm.proxy.proxy_server") + fake_module.spend_counter_cache = spend_counter_cache + monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", fake_module) + + job = ResetBudgetJob(proxy_logging_obj=MagicMock(), prisma_client=prisma_client) + + asyncio.run(job.reset_budget_windows()) # must not raise + + prisma_client.db.litellm_teamtable.update.assert_awaited_once() diff --git a/tests/test_litellm/proxy/common_utils/test_upsert_budget_membership.py b/tests/test_litellm/proxy/common_utils/test_upsert_budget_membership.py index 8e511518892..f4bf0d7b2be 100644 --- a/tests/test_litellm/proxy/common_utils/test_upsert_budget_membership.py +++ b/tests/test_litellm/proxy/common_utils/test_upsert_budget_membership.py @@ -268,3 +268,104 @@ async def test_upsert_rpm_only_creates_new_budget(mock_tx, fake_user): }, }, ) + + +# TEST: clone-on-write when membership still points at the team's shared default budget +@pytest.mark.asyncio +async def test_upsert_clones_when_pointing_at_shared_default(mock_tx, fake_user): + """ + When a member's existing budget_id is the same row as the team's shared + default member budget, updating that member's budget must NOT mutate the + shared row. Instead we should create a new private budget for this member + (seeded with the default's values) and re-link the membership to it. + """ + shared_default_id = "team-default-budget-1" + + # Default budget row in the DB: $200 cap, daily reset, 500 tpm. + default_row = MagicMock() + default_row.model_dump.return_value = { + "budget_id": shared_default_id, + "max_budget": 200.0, + "soft_budget": None, + "max_parallel_requests": None, + "tpm_limit": 500, + "rpm_limit": None, + "model_max_budget": None, + "budget_duration": "1d", + "allowed_models": [], + } + mock_tx.litellm_budgettable.find_unique = AsyncMock(return_value=default_row) + + # Caller is changing only this member's max_budget. + await _upsert_budget_and_membership( + mock_tx, + team_id="team-shared", + user_id="user-shared", + max_budget=50.0, + existing_budget_id=shared_default_id, + user_api_key_dict=fake_user, + team_default_budget_id=shared_default_id, + ) + + # Must NOT touch the shared default row in place. + mock_tx.litellm_budgettable.update.assert_not_called() + + # Must create a new private budget seeded with the default's values, + # with the caller's max_budget overriding the cloned default. + mock_tx.litellm_budgettable.create.assert_awaited_once_with( + data={ + "created_by": fake_user.user_id, + "updated_by": fake_user.user_id, + "max_budget": 50.0, # caller wins + "tpm_limit": 500, # cloned from default + "budget_duration": "1d", # cloned from default + }, + include={"team_membership": True}, + ) + + # Membership must be re-linked to the new private budget. + new_budget_id = mock_tx.litellm_budgettable.create.return_value.budget_id + mock_tx.litellm_teammembership.upsert.assert_awaited_once_with( + where={"user_id_team_id": {"user_id": "user-shared", "team_id": "team-shared"}}, + data={ + "create": { + "user_id": "user-shared", + "team_id": "team-shared", + "litellm_budget_table": {"connect": {"budget_id": new_budget_id}}, + }, + "update": { + "litellm_budget_table": {"connect": {"budget_id": new_budget_id}}, + }, + }, + ) + + +# TEST: when team default exists but member already has their own budget, in-place update +@pytest.mark.asyncio +async def test_upsert_updates_in_place_when_member_has_private_budget( + mock_tx, fake_user +): + """ + If the member's budget_id is different from the team's shared default + (i.e. they already have a private budget), we should keep the current + in-place behavior and not allocate a new row. + """ + await _upsert_budget_and_membership( + mock_tx, + team_id="team-mixed", + user_id="user-private", + max_budget=75.0, + existing_budget_id="private-budget-xyz", + user_api_key_dict=fake_user, + team_default_budget_id="team-default-budget-1", + ) + + mock_tx.litellm_budgettable.update.assert_awaited_once_with( + where={"budget_id": "private-budget-xyz"}, + data={ + "max_budget": 75.0, + "updated_by": fake_user.user_id, + }, + ) + mock_tx.litellm_budgettable.create.assert_not_called() + mock_tx.litellm_teammembership.upsert.assert_not_called() diff --git a/tests/test_litellm/proxy/db/db_transaction_queue/test_redis_update_buffer.py b/tests/test_litellm/proxy/db/db_transaction_queue/test_redis_update_buffer.py index 44602125ffe..0587e3bce1e 100644 --- a/tests/test_litellm/proxy/db/db_transaction_queue/test_redis_update_buffer.py +++ b/tests/test_litellm/proxy/db/db_transaction_queue/test_redis_update_buffer.py @@ -87,6 +87,89 @@ async def test_store_in_memory_spend_updates_uses_pipeline( assert len(rpush_list) == 3 +@pytest.mark.asyncio +async def test_store_in_memory_spend_updates_restores_on_rpush_failure( + redis_update_buffer, mock_redis_cache +): + """ + If async_rpush_pipeline raises, the already-drained transactions must be + put back into the in-memory queues so the next scheduler tick retries. + Without this, any transient Redis hiccup silently loses spend data. + """ + from litellm.proxy._types import Litellm_EntityType + from litellm.proxy.db.db_transaction_queue.daily_spend_update_queue import ( + DailySpendUpdateQueue, + ) + from litellm.proxy.db.db_transaction_queue.spend_update_queue import ( + SpendUpdateQueue, + ) + + mock_redis_cache.async_rpush_pipeline = AsyncMock( + side_effect=ConnectionError("redis went away") + ) + + spend_queue = SpendUpdateQueue() + daily_user_queue = DailySpendUpdateQueue() + daily_team_queue = DailySpendUpdateQueue() + daily_org_queue = DailySpendUpdateQueue() + daily_end_user_queue = DailySpendUpdateQueue() + daily_agent_queue = DailySpendUpdateQueue() + + # Seed real queues with data so flush_and_get_aggregated returns it + await spend_queue.add_update( + { + "entity_type": Litellm_EntityType.KEY, + "entity_id": "key-abc", + "response_cost": 1.5, + } + ) + await spend_queue.add_update( + { + "entity_type": Litellm_EntityType.TEAM, + "entity_id": "team-xyz", + "response_cost": 2.5, + } + ) + await daily_user_queue.add_update( + { + "user1_day_model": { + "spend": 1.0, + "prompt_tokens": 10, + "completion_tokens": 20, + } + } + ) + + await redis_update_buffer.store_in_memory_spend_updates_in_redis( + spend_update_queue=spend_queue, + daily_spend_update_queue=daily_user_queue, + daily_team_spend_update_queue=daily_team_queue, + daily_org_spend_update_queue=daily_org_queue, + daily_end_user_spend_update_queue=daily_end_user_queue, + daily_agent_spend_update_queue=daily_agent_queue, + ) + + # After restore, the main spend queue should hold one item per + # (entity_type, entity_id) pair with the aggregated cost + restored_spend = ( + await spend_queue.flush_and_get_aggregated_db_spend_update_transactions() + ) + assert restored_spend["key_list_transactions"] == {"key-abc": 1.5} + assert restored_spend["team_list_transactions"] == {"team-xyz": 2.5} + + # Daily user queue should hold the same aggregated dict + restored_daily = ( + await daily_user_queue.flush_and_get_aggregated_daily_spend_update_transactions() + ) + assert restored_daily == { + "user1_day_model": { + "spend": 1.0, + "prompt_tokens": 10, + "completion_tokens": 20, + } + } + + @pytest.mark.asyncio async def test_store_in_memory_spend_updates_all_empty_returns_early( redis_update_buffer, mock_redis_cache diff --git a/tests/test_litellm/proxy/db/test_db_spend_update_writer.py b/tests/test_litellm/proxy/db/test_db_spend_update_writer.py index b98b9a8ad61..4d584349342 100644 --- a/tests/test_litellm/proxy/db/test_db_spend_update_writer.py +++ b/tests/test_litellm/proxy/db/test_db_spend_update_writer.py @@ -642,6 +642,81 @@ async def test_commit_spend_updates_to_db_increments_agent_spend(): assert call_kwargs["data"] == {"spend": {"increment": response_cost}} +@pytest.mark.asyncio +async def test_commit_spend_updates_to_db_increments_team_member_spend_and_total_spend(): + """ + Verify that _commit_spend_updates_to_db increments BOTH spend (cycle-scoped) + and total_spend (non-resetting) on LiteLLM_TeamMembership in a single + update_many call, using the same response_cost. + """ + db_writer = DBSpendUpdateWriter() + + mock_batcher = MagicMock() + mock_batcher.litellm_verificationtoken = MagicMock() + mock_batcher.litellm_verificationtoken.update_many = MagicMock() + mock_batcher.litellm_usertable = MagicMock() + mock_batcher.litellm_usertable.update_many = MagicMock() + mock_batcher.litellm_teamtable = MagicMock() + mock_batcher.litellm_teamtable.update_many = MagicMock() + mock_batcher.litellm_teammembership = MagicMock() + mock_batcher.litellm_teammembership.update_many = MagicMock() + mock_batcher.litellm_organizationtable = MagicMock() + mock_batcher.litellm_organizationtable.update_many = MagicMock() + mock_batcher.litellm_tagtable = MagicMock() + mock_batcher.litellm_tagtable.update_many = MagicMock() + mock_batcher.litellm_agentstable = MagicMock() + mock_batcher.litellm_agentstable.update_many = MagicMock() + + mock_transaction = AsyncMock() + mock_transaction.__aenter__ = AsyncMock(return_value=mock_transaction) + mock_transaction.__aexit__ = AsyncMock(return_value=False) + mock_transaction.batch_ = MagicMock( + return_value=AsyncMock( + __aenter__=AsyncMock(return_value=mock_batcher), + __aexit__=AsyncMock(return_value=False), + ) + ) + + mock_prisma_client = MagicMock() + mock_prisma_client.db = MagicMock() + mock_prisma_client.db.tx = MagicMock(return_value=mock_transaction) + + mock_proxy_logging = MagicMock() + # Skip team-membership cache invalidation — out of scope for this test. + mock_proxy_logging.call_details.get = MagicMock(return_value=None) + + team_id = "team-abc" + user_id = "user-xyz" + response_cost = 0.75 + entity_id = f"team_id::{team_id}::user_id::{user_id}" + db_spend_update_transactions = { + "user_list_transactions": {}, + "end_user_list_transactions": {}, + "key_list_transactions": {}, + "team_list_transactions": {}, + "team_member_list_transactions": {entity_id: response_cost}, + "org_list_transactions": {}, + "tag_list_transactions": {}, + "agent_list_transactions": {}, + } + + with patch("litellm.proxy.utils._raise_failed_update_spend_exception"): + await db_writer._commit_spend_updates_to_db( + prisma_client=mock_prisma_client, + n_retry_times=0, + proxy_logging_obj=mock_proxy_logging, + db_spend_update_transactions=db_spend_update_transactions, + ) + + mock_batcher.litellm_teammembership.update_many.assert_called_once() + call_kwargs = mock_batcher.litellm_teammembership.update_many.call_args[1] + assert call_kwargs["where"] == {"team_id": team_id, "user_id": user_id} + assert call_kwargs["data"] == { + "spend": {"increment": response_cost}, + "total_spend": {"increment": response_cost}, + } + + @pytest.mark.asyncio async def test_add_spend_log_transaction_to_daily_tag_transaction_with_request_id(): """ diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py index 1d46012382f..fef984d7044 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py @@ -12,11 +12,16 @@ from fastapi import HTTPException sys.path.insert(0, os.path.abspath("../../../../../..")) +import litellm +from litellm.caching.caching import DualCache from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import ( BedrockGuardrail, _redact_pii_matches, ) +from litellm.proxy.utils import ProxyLogging +from litellm.types.guardrails import GuardrailEventHooks +from litellm.types.utils import ModelResponse @pytest.mark.asyncio @@ -105,10 +110,12 @@ async def test__redact_pii_matches_malformed_response(): # Test with completely malformed response malformed_response = { "action": "GUARDRAIL_INTERVENED", - "assessments": "not_a_list", # This should cause an exception + # Wrong type for assessments; redact_nested_match_and_regex_keys walks dict + # values and skips non-dict/list nodes, so this must not raise. + "assessments": "not_a_list", } - # Should not crash and return original response + # Should not crash (deep copy + walk skips the string value under assessments) redacted_response = _redact_pii_matches(malformed_response) assert redacted_response == malformed_response @@ -187,7 +194,7 @@ async def test__redact_pii_matches_multiple_assessments(): @pytest.mark.asyncio async def test_bedrock_guardrail_logging_uses_redacted_response(): - """Test that the Bedrock guardrail uses redacted response for logging""" + """Debug logs and standard_logging payloads must not include raw match values.""" # Create proper mock objects mock_user_api_key_dict = UserAPIKeyAuth() @@ -294,6 +301,14 @@ async def test_bedrock_guardrail_logging_uses_redacted_response(): == "PHONE" ) + slg_list = request_data["metadata"]["standard_logging_guardrail_information"] + assert ( + slg_list[0]["guardrail_response"]["assessments"][0][ + "sensitiveInformationPolicy" + ]["piiEntities"][0]["match"] + == "[REDACTED]" + ) + print("Bedrock guardrail logging redaction test passed") @@ -1113,6 +1128,72 @@ async def test_bedrock_apply_guardrail_with_only_tool_calls_response(): print("✅ apply_guardrail with tool_calls test passed - no API call made") +@pytest.mark.asyncio +async def test_bedrock_apply_guardrail_response_uses_OUTPUT_source(): + """input_type='response' must call Bedrock with source=OUTPUT and assistant content. + + Regression: apply_guardrail used to always use source=INPUT. Output-only Bedrock + policies (e.g. PII on model output) then returned action=NONE for non-streaming + completions that go through unified_guardrail -> process_output_response. + """ + guardrail = BedrockGuardrail( + guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT" + ) + bedrock_none = {"action": "NONE", "output": [], "outputs": []} + + with patch.object( + guardrail, "make_bedrock_api_request", new_callable=AsyncMock + ) as mock_api: + mock_api.return_value = bedrock_none + + await guardrail.apply_guardrail( + inputs={"texts": ["first line", "second line"]}, + request_data={"model": "gpt-4o"}, + input_type="response", + ) + + mock_api.assert_called_once() + kwargs = mock_api.call_args.kwargs + assert kwargs["source"] == "OUTPUT" + assert kwargs["request_data"] == {"model": "gpt-4o"} + synthetic = kwargs["response"] + assert isinstance(synthetic, ModelResponse) + assert len(synthetic.choices) == 2 + assert synthetic.choices[0].message.content == "first line" + assert synthetic.choices[0].message.role == "assistant" + assert synthetic.choices[1].message.content == "second line" + assert synthetic.choices[1].message.role == "assistant" + + +@pytest.mark.asyncio +async def test_bedrock_apply_guardrail_request_uses_INPUT_source(): + """input_type='request' must call Bedrock with source=INPUT and user messages.""" + guardrail = BedrockGuardrail( + guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT" + ) + bedrock_none = {"action": "NONE", "output": [], "outputs": []} + + with patch.object( + guardrail, "make_bedrock_api_request", new_callable=AsyncMock + ) as mock_api: + mock_api.return_value = bedrock_none + + await guardrail.apply_guardrail( + inputs={"texts": ["user prompt"]}, + request_data={}, + input_type="request", + ) + + mock_api.assert_called_once() + kwargs = mock_api.call_args.kwargs + assert kwargs["source"] == "INPUT" + assert kwargs["messages"] is not None + assert len(kwargs["messages"]) == 1 + assert kwargs["messages"][0]["role"] == "user" + assert kwargs["messages"][0]["content"] == "user prompt" + assert kwargs.get("response") is None + + @pytest.mark.asyncio async def test_bedrock_guardrail_blocked_content_with_masking_enabled(): """Test that BLOCKED content raises exception even when masking is enabled @@ -1684,6 +1765,124 @@ async def test_bedrock_guardrail_blocked_vs_anonymized_actions(): print("\u2705 BLOCKED vs ANONYMIZED actions test passed") +# --------------------------------------------------------------------------- +# Spend logs: guardrail_mode (pre/during/post) vs Bedrock INPUT/OUTPUT +# --------------------------------------------------------------------------- + + +def test_bedrock_guardrail_uses_native_during_call_hook(): + """during_call must use async_moderation_hook, not unified apply_guardrail(input=request).""" + assert BedrockGuardrail.use_native_during_call_hook is True + + +@pytest.mark.asyncio +async def test_make_bedrock_api_request_logging_event_type_for_spend_logs(): + """ + Spend/UI use event_type from the proxy hook, not Bedrock's INPUT/OUTPUT alone. + When logging_event_type is set, it must be forwarded to standard guardrail logging. + When omitted, INPUT maps to pre_call (legacy). + """ + guardrail = BedrockGuardrail( + guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT" + ) + mock_credentials = MagicMock() + mock_credentials.access_key = "test-access-key" + mock_credentials.secret_key = "test-secret-key" + mock_credentials.token = None + + mock_bedrock_response = MagicMock() + mock_bedrock_response.status_code = 200 + mock_bedrock_response.json.return_value = { + "action": "NONE", + "assessments": [ + { + "sensitiveInformationPolicy": { + "piiEntities": [ + {"type": "NAME", "match": "GG", "action": "BLOCKED"} + ] + } + } + ], + } + + request_data = { + "model": "gpt-4o", + "messages": [{"role": "user", "content": "hi"}], + } + + with patch.object( + guardrail.async_handler, "post", new_callable=AsyncMock + ) as mock_post, patch.object( + guardrail, "_load_credentials", return_value=(mock_credentials, "us-east-1") + ), patch.object(guardrail, "_prepare_request", return_value=MagicMock()), patch.object( + guardrail, + "add_standard_logging_guardrail_information_to_request_data", + ) as mock_log: + mock_post.return_value = mock_bedrock_response + + await guardrail.make_bedrock_api_request( + source="INPUT", + messages=request_data["messages"], + request_data=request_data, + logging_event_type=GuardrailEventHooks.during_call, + ) + assert mock_log.call_args.kwargs["event_type"] == GuardrailEventHooks.during_call + # Raw Bedrock JSON is forwarded; redaction runs once in + # CustomGuardrail.add_standard_logging_guardrail_information_to_request_data. + assert ( + mock_log.call_args.kwargs["guardrail_json_response"]["assessments"][0][ + "sensitiveInformationPolicy" + ]["piiEntities"][0]["match"] + == "GG" + ) + + mock_log.reset_mock() + + await guardrail.make_bedrock_api_request( + source="INPUT", + messages=request_data["messages"], + request_data=request_data, + ) + assert mock_log.call_args.kwargs["event_type"] == GuardrailEventHooks.pre_call + + +@pytest.mark.asyncio +async def test_during_call_hook_invokes_bedrock_async_moderation_hook(): + """ + Bedrock sets use_native_during_call_hook so ProxyLogging runs the real + async_moderation_hook (unified apply_guardrail would log INPUT as pre_call). + """ + cache = DualCache() + proxy_logging = ProxyLogging(user_api_key_cache=cache) + + guardrail = BedrockGuardrail( + guardrail_name="bedrock-during-test", + guardrailIdentifier="gid", + guardrailVersion="1", + event_hook=GuardrailEventHooks.during_call, + default_on=True, + ) + mock_mod = AsyncMock(return_value=None) + original_callbacks = litellm.callbacks.copy() if litellm.callbacks else [] + try: + litellm.callbacks = [guardrail] + with patch.object(guardrail, "async_moderation_hook", new=mock_mod): + await proxy_logging.during_call_hook( + data={ + "model": "gpt-4", + "messages": [{"role": "user", "content": "test"}], + }, + user_api_key_dict=UserAPIKeyAuth( + api_key="test_key", user_id="test_user" + ), + call_type="completion", + ) + finally: + litellm.callbacks = original_callbacks + + mock_mod.assert_awaited_once() + + # --------------------------------------------------------------------------- # L3: _extract_blocked_assessments + _get_http_exception_for_blocked_guardrail # Regression coverage for case 2026-04-10-internal-bedrock-guardrail-streaming-error. @@ -1699,7 +1898,7 @@ def _make_guardrail() -> BedrockGuardrail: def test_extract_blocked_assessments_pii_entity(): - """L3: PII entity match (BLOCKED) is surfaced with category, type, and matched term.""" + """L3: PII entity match (BLOCKED) is surfaced with category, type, and match.""" g = _make_guardrail() response = { "action": "GUARDRAIL_INTERVENED", @@ -1810,6 +2009,7 @@ def test_get_http_exception_includes_assessments_and_identifier(): assert exc.detail["guardrailVersion"] == "1" assert exc.detail["assessments"][0]["policy"] == "sensitiveInformationPolicy" assert exc.detail["assessments"][0]["matches"][0]["type"] == "NAME" + assert exc.detail["assessments"][0]["matches"][0]["match"] == "[REDACTED]" def test_get_http_exception_no_blocked_assessments_omits_field(): @@ -1832,3 +2032,135 @@ def test_get_http_exception_no_blocked_assessments_omits_field(): assert isinstance(exc, HTTPException) assert "assessments" not in exc.detail assert exc.detail["guardrailIdentifier"] == "amgllac6xf3r" + + +@pytest.mark.asyncio +async def test_streaming_post_call_parallel_output_passes_request_data_to_make_bedrock(): + """ + async_post_call_streaming_iterator_hook must pass request_data into OUTPUT + make_bedrock_api_request so spend/standard_logging attaches to the real request + (Greptile: previously OUTPUT used request_data=None / ephemeral {}). + """ + request_data = { + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "hi"}], + "metadata": {"stream_guardrail_logging": True}, + } + guardrail = BedrockGuardrail( + guardrail_name="bedrock-stream-reqdata", + guardrailIdentifier="test-id", + guardrailVersion="DRAFT", + event_hook=GuardrailEventHooks.post_call, + default_on=True, + ) + mock_chunks = [ + litellm.ModelResponseStream( + id="tid", + choices=[ + litellm.types.utils.StreamingChoices( + delta=litellm.types.utils.Delta(content="Hi", role="assistant"), + finish_reason=None, + index=0, + ) + ], + created=1, + model="gpt-4o-mini", + object="chat.completion.chunk", + ), + litellm.ModelResponseStream( + id="tid", + choices=[ + litellm.types.utils.StreamingChoices( + delta=litellm.types.utils.Delta(content="!", role="assistant"), + finish_reason="stop", + index=0, + ) + ], + created=1, + model="gpt-4o-mini", + object="chat.completion.chunk", + ), + ] + + async def mock_stream(): + for c in mock_chunks: + yield c + + minimal = {"action": "NONE", "assessments": [], "outputs": []} + with patch.object( + guardrail, "make_bedrock_api_request", AsyncMock(return_value=minimal) + ) as mock_make: + out = [] + async for chunk in guardrail.async_post_call_streaming_iterator_hook( + user_api_key_dict=UserAPIKeyAuth(), + response=mock_stream(), + request_data=request_data, + ): + out.append(chunk) + + assert len(out) >= 1 + output_calls = [ + c for c in mock_make.call_args_list if c.kwargs.get("source") == "OUTPUT" + ] + assert len(output_calls) == 1 + assert output_calls[0].kwargs.get("request_data") is request_data + assert ( + output_calls[0].kwargs.get("logging_event_type") + == GuardrailEventHooks.post_call + ) + input_calls = [ + c for c in mock_make.call_args_list if c.kwargs.get("source") == "INPUT" + ] + assert len(input_calls) == 1 + assert input_calls[0].kwargs.get("request_data") is request_data + + +@pytest.mark.asyncio +async def test_streaming_post_call_output_only_path_passes_request_data_to_make_bedrock(): + """When INPUT validation is skipped (pre/during already ran), OUTPUT still gets request_data.""" + request_data = { + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "hi"}], + } + guardrail = BedrockGuardrail( + guardrail_name="bedrock-stream-out-only", + guardrailIdentifier="test-id", + guardrailVersion="DRAFT", + event_hook=GuardrailEventHooks.during_call, + default_on=True, + ) + mock_chunks = [ + litellm.ModelResponseStream( + id="tid", + choices=[ + litellm.types.utils.StreamingChoices( + delta=litellm.types.utils.Delta(content="x", role="assistant"), + finish_reason="stop", + index=0, + ) + ], + created=1, + model="gpt-4o-mini", + object="chat.completion.chunk", + ), + ] + + async def mock_stream(): + for c in mock_chunks: + yield c + + minimal = {"action": "NONE", "assessments": [], "outputs": []} + with patch.object( + guardrail, "make_bedrock_api_request", AsyncMock(return_value=minimal) + ) as mock_make: + async for _ in guardrail.async_post_call_streaming_iterator_hook( + user_api_key_dict=UserAPIKeyAuth(), + response=mock_stream(), + request_data=request_data, + ): + pass + + assert mock_make.call_count == 1 + c = mock_make.call_args + assert c.kwargs.get("source") == "OUTPUT" + assert c.kwargs.get("request_data") is request_data diff --git a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py index c275c665114..ba260142351 100644 --- a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py @@ -9,6 +9,7 @@ sys.path.insert( 0, os.path.abspath("../../..") ) # Adds the parent directory to the system path +import httpx import pytest from prisma.errors import ClientNotConnectedError, HTTPClientClosedError, PrismaError @@ -110,220 +111,127 @@ async def test_db_health_prisma_client_none(): @pytest.mark.asyncio @pytest.mark.parametrize( - "prisma_error", + "transport_error", [ - PrismaError(), + httpx.ConnectError("All connection attempts failed"), ClientNotConnectedError(), HTTPClientClosedError(), + PrismaError("Can't reach database server"), ], ) -async def test_db_health_error_flag_off_raises_no_reconnect(prisma_error): +async def test_db_health_transport_error_never_raises(transport_error): """ - When health_check raises and allow_requests_on_db_unavailable is False, - handle_db_exception re-raises immediately. The reconnect path is never - reached, so disconnect/connect are never called. + Regression test for the /health/readiness 503 loop bug. + + handle_db_exception() used to re-raise inside _db_health_readiness_check, + turning any DB outage into a 503 "Service Unhealthy" response that never + recovered. Transport errors (ClientNotConnectedError, httpx.ConnectError, + etc.) must return {"status": "disconnected"} — never raise. """ mock_prisma = MagicMock() - mock_prisma.health_check = AsyncMock(side_effect=prisma_error) - mock_prisma.disconnect = AsyncMock() + mock_prisma.health_check = AsyncMock(side_effect=transport_error) + mock_prisma.attempt_db_reconnect = AsyncMock(return_value=False) _health_endpoints_module.db_health_cache = { "status": "connected", "last_updated": datetime.now() - timedelta(seconds=20), } - with ( - patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), - patch( - "litellm.proxy.proxy_server.general_settings", - {"allow_requests_on_db_unavailable": False}, - ), - ): - with pytest.raises(Exception) as exc_info: - await _db_health_readiness_check() + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + result = await _db_health_readiness_check() - assert exc_info.value is prisma_error - mock_prisma.disconnect.assert_not_called() - assert _health_endpoints_module.db_health_cache["status"] == "disconnected" + assert result["status"] == "disconnected" + mock_prisma.attempt_db_reconnect.assert_called_once_with( + reason="health_readiness_check" + ) @pytest.mark.asyncio @pytest.mark.parametrize( - "prisma_error", + "transport_error", [ - PrismaError("Can't reach database server"), + httpx.ConnectError("All connection attempts failed"), ClientNotConnectedError(), HTTPClientClosedError(), ], ) -async def test_db_health_error_flag_on_reconnect_succeeds(prisma_error): +async def test_db_health_transport_error_reconnect_succeeds(transport_error): """ - When health_check raises, allow_requests_on_db_unavailable is True, - and the reconnect cycle (disconnect -> connect -> health_check) succeeds, - return 'connected' and update the cache. + When health_check raises a transport error and attempt_db_reconnect + succeeds, the second health_check passes and we return 'connected'. """ mock_prisma = MagicMock() - mock_prisma.health_check = AsyncMock(side_effect=[prisma_error, None]) - mock_prisma.disconnect = AsyncMock() - mock_prisma.connect = AsyncMock() + mock_prisma.health_check = AsyncMock(side_effect=[transport_error, None]) + mock_prisma.attempt_db_reconnect = AsyncMock(return_value=True) _health_endpoints_module.db_health_cache = { "status": "connected", "last_updated": datetime.now() - timedelta(seconds=20), } - with ( - patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), - patch( - "litellm.proxy.proxy_server.general_settings", - {"allow_requests_on_db_unavailable": True}, - ), - ): + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): result = await _db_health_readiness_check() assert result["status"] == "connected" - mock_prisma.disconnect.assert_called_once() - mock_prisma.connect.assert_called_once() + mock_prisma.attempt_db_reconnect.assert_called_once_with( + reason="health_readiness_check" + ) assert mock_prisma.health_check.call_count == 2 @pytest.mark.asyncio @pytest.mark.parametrize( - "prisma_error", + "transport_error", [ - PrismaError("Can't reach database server"), + httpx.ConnectError("All connection attempts failed"), ClientNotConnectedError(), HTTPClientClosedError(), ], ) -async def test_db_health_error_flag_on_reconnect_fails(prisma_error): +async def test_db_health_transport_error_reconnect_fails(transport_error): """ - When health_check raises, allow_requests_on_db_unavailable is True, - but the reconnect also fails, return 'disconnected' instead of raising. - This respects the flag's intent: keep serving even without a DB. + When health_check raises a transport error and attempt_db_reconnect also + fails, return 'disconnected' without raising. """ mock_prisma = MagicMock() - mock_prisma.health_check = AsyncMock(side_effect=prisma_error) - mock_prisma.disconnect = AsyncMock() - mock_prisma.connect = AsyncMock() - - _health_endpoints_module.db_health_cache = { - "status": "connected", - "last_updated": datetime.now() - timedelta(seconds=20), - } - - with ( - patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), - patch( - "litellm.proxy.proxy_server.general_settings", - {"allow_requests_on_db_unavailable": True}, - ), - ): - result = await _db_health_readiness_check() - - assert result["status"] == "disconnected" - mock_prisma.disconnect.assert_called_once() - mock_prisma.connect.assert_called_once() - - -@pytest.mark.asyncio -async def test_db_health_non_transport_error_flag_off_raises(): - """ - When health_check raises a non-transport error and - allow_requests_on_db_unavailable is False, handle_db_exception - re-raises before reaching the is_database_transport_error guard. - Cache is still invalidated before the re-raise. - """ - non_transport_error = PrismaError("UniqueViolationError") - mock_prisma = MagicMock() - mock_prisma.health_check = AsyncMock(side_effect=non_transport_error) - mock_prisma.disconnect = AsyncMock() - mock_prisma.connect = AsyncMock() - - _health_endpoints_module.db_health_cache = { - "status": "connected", - "last_updated": datetime.now() - timedelta(seconds=20), - } - - with ( - patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), - patch( - "litellm.proxy.proxy_server.general_settings", - {"allow_requests_on_db_unavailable": False}, - ), - ): - with pytest.raises(PrismaError): - await _db_health_readiness_check() - - assert _health_endpoints_module.db_health_cache["status"] == "disconnected" - mock_prisma.disconnect.assert_not_called() - mock_prisma.connect.assert_not_called() - - -@pytest.mark.asyncio -async def test_db_health_non_transport_error_flag_on_skips_reconnect(): - """ - When health_check raises a non-transport error (e.g. data-layer) and - allow_requests_on_db_unavailable is True, handle_db_exception swallows - the exception, then is_database_transport_error returns False so the - reconnect cycle is skipped. Returns 'disconnected' without calling - disconnect/connect. - """ - non_transport_error = PrismaError("UniqueViolationError") - mock_prisma = MagicMock() - mock_prisma.health_check = AsyncMock(side_effect=non_transport_error) - mock_prisma.disconnect = AsyncMock() - mock_prisma.connect = AsyncMock() - - _health_endpoints_module.db_health_cache = { - "status": "connected", - "last_updated": datetime.now() - timedelta(seconds=20), - } - - with ( - patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), - patch( - "litellm.proxy.proxy_server.general_settings", - {"allow_requests_on_db_unavailable": True}, - ), - ): - result = await _db_health_readiness_check() - - assert result["status"] == "disconnected" - mock_prisma.disconnect.assert_not_called() - mock_prisma.connect.assert_not_called() - - -@pytest.mark.asyncio -async def test_db_health_reconnect_disconnect_fails(): - """ - When disconnect() itself raises during the reconnect cycle, - the inner except catches it and returns 'disconnected'. - connect() and the second health_check() are never called. - """ - transport_error = ClientNotConnectedError() - mock_prisma = MagicMock() mock_prisma.health_check = AsyncMock(side_effect=transport_error) - mock_prisma.disconnect = AsyncMock(side_effect=RuntimeError("already closed")) - mock_prisma.connect = AsyncMock() + mock_prisma.attempt_db_reconnect = AsyncMock( + side_effect=RuntimeError("reconnect failed") + ) _health_endpoints_module.db_health_cache = { "status": "connected", "last_updated": datetime.now() - timedelta(seconds=20), } - with ( - patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), - patch( - "litellm.proxy.proxy_server.general_settings", - {"allow_requests_on_db_unavailable": True}, - ), - ): + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): result = await _db_health_readiness_check() assert result["status"] == "disconnected" - mock_prisma.disconnect.assert_called_once() - mock_prisma.connect.assert_not_called() + + +@pytest.mark.asyncio +async def test_db_health_non_transport_error_returns_disconnected(): + """ + When health_check raises a non-transport error (e.g. data-layer error), + is_database_transport_error returns False so reconnect is skipped. + Returns 'disconnected' without raising and without calling attempt_db_reconnect. + """ + non_transport_error = PrismaError("UniqueViolationError") + mock_prisma = MagicMock() + mock_prisma.health_check = AsyncMock(side_effect=non_transport_error) + mock_prisma.attempt_db_reconnect = AsyncMock() + + _health_endpoints_module.db_health_cache = { + "status": "connected", + "last_updated": datetime.now() - timedelta(seconds=20), + } + + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + result = await _db_health_readiness_check() + + assert result["status"] == "disconnected" + mock_prisma.attempt_db_reconnect.assert_not_called() @pytest.mark.asyncio diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py index e8d31b49515..442265d3af0 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -1,6 +1,7 @@ import os import sys import types +import json from datetime import datetime, timedelta from types import SimpleNamespace from typing import List, Optional @@ -1311,7 +1312,8 @@ class TestTemporaryMCPSessionEndpoints: assert cache["temp-cache"].server is server assert cache["temp-cache"].expires_at > datetime.utcnow() - def test_get_cached_temporary_mcp_server_prunes_expired_entries(self): + @pytest.mark.asyncio + async def test_get_cached_temporary_mcp_server_prunes_expired_entries(self): from litellm.proxy.management_endpoints.mcp_management_endpoints import ( _TemporaryMCPServerEntry, get_cached_temporary_mcp_server, @@ -1327,12 +1329,13 @@ class TestTemporaryMCPSessionEndpoints: "litellm.proxy.management_endpoints.mcp_management_endpoints._temporary_mcp_servers", cache, ): - result = get_cached_temporary_mcp_server("expired") + result = await get_cached_temporary_mcp_server("expired") assert result is None assert "expired" not in cache - def test_get_cached_temporary_mcp_server_or_404(self): + @pytest.mark.asyncio + async def test_get_cached_temporary_mcp_server_or_404(self): from litellm.proxy.management_endpoints.mcp_management_endpoints import ( _get_cached_temporary_mcp_server_or_404, ) @@ -1343,17 +1346,17 @@ class TestTemporaryMCPSessionEndpoints: "litellm.proxy.management_endpoints.mcp_management_endpoints.get_cached_temporary_mcp_server", return_value=server, ) as get_cached: - result = _get_cached_temporary_mcp_server_or_404("cached") + result = await _get_cached_temporary_mcp_server_or_404("cached") assert result is server - get_cached.assert_called_once_with("cached") + get_cached.assert_awaited_once_with("cached") with patch( "litellm.proxy.management_endpoints.mcp_management_endpoints.get_cached_temporary_mcp_server", return_value=None, ): with pytest.raises(HTTPException) as exc_info: - _get_cached_temporary_mcp_server_or_404("missing") + await _get_cached_temporary_mcp_server_or_404("missing") assert exc_info.value.status_code == 404 @@ -1403,6 +1406,10 @@ class TestTemporaryMCPSessionEndpoints: "litellm.proxy.management_endpoints.mcp_management_endpoints._cache_temporary_mcp_server", MagicMock(), ) as cache_mock, + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints._cache_temporary_mcp_server_in_redis", + AsyncMock(), + ) as redis_cache_mock, ): response = await add_session_mcp_server( payload=payload, @@ -1414,6 +1421,9 @@ class TestTemporaryMCPSessionEndpoints: cache_mock.assert_called_once_with( built_server, ttl_seconds=TEMPORARY_MCP_SERVER_TTL_SECONDS ) + redis_cache_mock.assert_awaited_once_with( + built_server, ttl_seconds=TEMPORARY_MCP_SERVER_TTL_SECONDS + ) args, _ = mock_manager.build_mcp_server_from_table.call_args temp_record = args[0] @@ -1486,7 +1496,7 @@ class TestTemporaryMCPSessionEndpoints: ) assert result is authorize_response - get_server.assert_called_once_with("server-1") + get_server.assert_awaited_once_with("server-1", request=request) authorize_mock.assert_awaited_once_with( request=request, mcp_server=server, @@ -1533,7 +1543,7 @@ class TestTemporaryMCPSessionEndpoints: ) assert result is exchange_response - get_server.assert_called_once_with("server-1") + get_server.assert_awaited_once_with("server-1", request=request) exchange_mock.assert_awaited_once_with( request=request, mcp_server=server, @@ -1581,7 +1591,7 @@ class TestTemporaryMCPSessionEndpoints: ) assert result is exchange_response - get_server.assert_called_once_with("server-1") + get_server.assert_awaited_once_with("server-1", request=request) exchange_mock.assert_awaited_once_with( request=request, mcp_server=server, @@ -1628,7 +1638,7 @@ class TestTemporaryMCPSessionEndpoints: result = await mcp_register(request=request, server_id="server-1") assert result is register_response - get_server.assert_called_once_with("server-1") + get_server.assert_awaited_once_with("server-1", request=request) read_body.assert_awaited_once_with(request=request) register_mock.assert_awaited_once_with( request=request, @@ -1640,6 +1650,218 @@ class TestTemporaryMCPSessionEndpoints: fallback_client_id="server-1", ) + @pytest.mark.asyncio + async def test_get_cached_temporary_mcp_server_falls_back_to_redis(self): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + get_cached_temporary_mcp_server, + ) + + server = generate_mock_mcp_server_config_record(server_id="from-redis") + serialized = json.dumps(server.model_dump(mode="json")) + mock_cache_backend = SimpleNamespace( + async_get_cache=AsyncMock(return_value="encrypted-payload") + ) + original_cache = mgmt_endpoints.litellm.cache + mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend) + try: + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints._temporary_mcp_servers", + {}, + ), patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.decrypt_value_helper", + return_value=serialized, + ): + result = await get_cached_temporary_mcp_server("from-redis") + finally: + mgmt_endpoints.litellm.cache = original_cache + + assert result is not None + assert result.server_id == "from-redis" + mock_cache_backend.async_get_cache.assert_awaited_once_with( + key="litellm:mcp:temporary_server:from-redis" + ) + + @pytest.mark.asyncio + async def test_cache_temporary_mcp_server_in_redis_uses_ttl_and_key(self): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + _cache_temporary_mcp_server_in_redis, + ) + + server = generate_mock_mcp_server_config_record(server_id="to-redis") + mock_cache_backend = SimpleNamespace(async_set_cache=AsyncMock()) + original_cache = mgmt_endpoints.litellm.cache + mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend) + try: + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.encrypt_value_helper", + return_value="encrypted-payload", + ): + await _cache_temporary_mcp_server_in_redis(server, ttl_seconds=123) + finally: + mgmt_endpoints.litellm.cache = original_cache + + mock_cache_backend.async_set_cache.assert_awaited_once() + call_kwargs = mock_cache_backend.async_set_cache.await_args.kwargs + assert call_kwargs["key"] == "litellm:mcp:temporary_server:to-redis" + assert call_kwargs["ttl"] == 123 + + @pytest.mark.asyncio + async def test_cache_temporary_mcp_server_in_redis_encrypts_payload(self): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + _cache_temporary_mcp_server_in_redis, + ) + + server = generate_mock_mcp_server_config_record(server_id="to-redis-encrypted") + mock_cache_backend = SimpleNamespace(async_set_cache=AsyncMock()) + original_cache = mgmt_endpoints.litellm.cache + mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend) + try: + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.encrypt_value_helper", + return_value="encrypted-payload", + ) as encrypt_mock: + await _cache_temporary_mcp_server_in_redis(server, ttl_seconds=60) + finally: + mgmt_endpoints.litellm.cache = original_cache + + encrypt_mock.assert_called_once() + call_kwargs = mock_cache_backend.async_set_cache.await_args.kwargs + assert call_kwargs["value"] == "encrypted-payload" + + @pytest.mark.asyncio + async def test_get_temporary_mcp_server_from_redis_decrypts_payload(self): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + _get_temporary_mcp_server_from_redis, + ) + + server = generate_mock_mcp_server_config_record(server_id="from-redis-encrypted") + serialized = json.dumps(server.model_dump(mode="json")) + mock_cache_backend = SimpleNamespace( + async_get_cache=AsyncMock(return_value="encrypted-payload") + ) + original_cache = mgmt_endpoints.litellm.cache + mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend) + try: + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.decrypt_value_helper", + return_value=serialized, + ) as decrypt_mock: + result = await _get_temporary_mcp_server_from_redis( + "from-redis-encrypted" + ) + finally: + mgmt_endpoints.litellm.cache = original_cache + + assert result is not None + assert result.server_id == "from-redis-encrypted" + decrypt_mock.assert_called_once() + + @pytest.mark.asyncio + async def test_cache_temporary_mcp_server_in_redis_skips_on_encrypt_failure(self): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + _cache_temporary_mcp_server_in_redis, + ) + + server = generate_mock_mcp_server_config_record(server_id="encrypt-fail") + mock_cache_backend = SimpleNamespace(async_set_cache=AsyncMock()) + original_cache = mgmt_endpoints.litellm.cache + mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend) + try: + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.encrypt_value_helper", + side_effect=Exception("boom"), + ): + await _cache_temporary_mcp_server_in_redis(server, ttl_seconds=60) + finally: + mgmt_endpoints.litellm.cache = original_cache + + mock_cache_backend.async_set_cache.assert_not_awaited() + + @pytest.mark.asyncio + async def test_cache_temporary_mcp_server_in_redis_skips_non_string_encryption_result( + self, + ): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + _cache_temporary_mcp_server_in_redis, + ) + + server = generate_mock_mcp_server_config_record(server_id="encrypt-non-string") + mock_cache_backend = SimpleNamespace(async_set_cache=AsyncMock()) + original_cache = mgmt_endpoints.litellm.cache + mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend) + try: + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.encrypt_value_helper", + return_value={"not": "a-string"}, + ): + await _cache_temporary_mcp_server_in_redis(server, ttl_seconds=60) + finally: + mgmt_endpoints.litellm.cache = original_cache + + mock_cache_backend.async_set_cache.assert_not_awaited() + + @pytest.mark.asyncio + async def test_get_temporary_mcp_server_from_redis_returns_none_on_invalid_decrypt_json( + self, + ): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + _get_temporary_mcp_server_from_redis, + ) + + mock_cache_backend = SimpleNamespace(async_get_cache=AsyncMock(return_value="enc")) + original_cache = mgmt_endpoints.litellm.cache + mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend) + try: + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.decrypt_value_helper", + return_value="{not json}", + ): + result = await _get_temporary_mcp_server_from_redis("bad-json") + finally: + mgmt_endpoints.litellm.cache = original_cache + + assert result is None + + @pytest.mark.asyncio + async def test_get_temporary_mcp_server_from_redis_returns_none_on_decrypt_none(self): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + _get_temporary_mcp_server_from_redis, + ) + + mock_cache_backend = SimpleNamespace(async_get_cache=AsyncMock(return_value="enc")) + original_cache = mgmt_endpoints.litellm.cache + mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend) + try: + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.decrypt_value_helper", + return_value=None, + ): + result = await _get_temporary_mcp_server_from_redis("decrypt-none") + finally: + mgmt_endpoints.litellm.cache = original_cache + + assert result is None + + @pytest.mark.asyncio + async def test_get_temporary_mcp_server_from_redis_rejects_plain_dict_payload(self): + """Plain dict values in Redis are not accepted (write path is encrypted-only).""" + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + _get_temporary_mcp_server_from_redis, + ) + + server = generate_mock_mcp_server_config_record(server_id="legacy-dict") + mock_cache_backend = SimpleNamespace( + async_get_cache=AsyncMock(return_value=server.model_dump(mode="json")) + ) + original_cache = mgmt_endpoints.litellm.cache + mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend) + try: + result = await _get_temporary_mcp_server_from_redis("legacy-dict") + finally: + mgmt_endpoints.litellm.cache = original_cache + + assert result is None + class TestUpdateMCPServer: """Test suite for update MCP server functionality""" diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 8da0ef19f81..65187fb52dc 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -1795,6 +1795,7 @@ async def test_backfill_team_member_budget_entries_creates_missing_memberships() return_value=[existing_membership] ) mock_prisma.db.litellm_teammembership.create_many = AsyncMock(return_value=None) + mock_prisma.db.litellm_teammembership.update_many = AsyncMock(return_value=0) # Test with Member instances members = [ @@ -1823,6 +1824,7 @@ async def test_backfill_team_member_budget_entries_creates_missing_memberships() # Also test with raw dicts (members_with_roles may be dicts when deserialized from DB) mock_prisma.db.litellm_teammembership.find_many.reset_mock() mock_prisma.db.litellm_teammembership.create_many.reset_mock() + mock_prisma.db.litellm_teammembership.update_many.reset_mock() members_as_dicts = [ {"user_id": "user-A", "role": "user"}, @@ -1868,6 +1870,7 @@ async def test_backfill_team_member_budget_entries_no_op_when_all_exist(): return_value=[existing_a, existing_b] ) mock_prisma.db.litellm_teammembership.create_many = AsyncMock(return_value=None) + mock_prisma.db.litellm_teammembership.update_many = AsyncMock(return_value=0) members = [ Member(user_id="user-A", role="user"), @@ -1884,6 +1887,55 @@ async def test_backfill_team_member_budget_entries_no_op_when_all_exist(): mock_prisma.db.litellm_teammembership.create_many.assert_not_awaited() +@pytest.mark.asyncio +async def test_backfill_team_member_budget_entries_populates_null_budget_id_on_existing_rows(): + """ + backfill_team_member_budget_entries should populate budget_id on + existing TeamMembership rows where it is currently NULL, so admins + can configure a team member budget after members have already joined + and have enforcement apply to those pre-existing members. + """ + from unittest.mock import AsyncMock, MagicMock + + from litellm.proxy._types import Member + from litellm.proxy.management_endpoints.team_endpoints import ( + TeamMemberBudgetHandler, + ) + + team_id = "team-abc" + budget_id = "budget-xyz" + + # Both members already have rows, so create_many must not fire; + # update_many must fire with the NULL-budget_id filter. + existing_a = MagicMock() + existing_a.user_id = "user-A" + existing_b = MagicMock() + existing_b.user_id = "user-B" + + mock_prisma = MagicMock() + mock_prisma.db.litellm_teammembership.find_many = AsyncMock( + return_value=[existing_a, existing_b] + ) + mock_prisma.db.litellm_teammembership.create_many = AsyncMock(return_value=None) + mock_prisma.db.litellm_teammembership.update_many = AsyncMock(return_value=2) + + await TeamMemberBudgetHandler.backfill_team_member_budget_entries( + team_id=team_id, + members_with_roles=[ + Member(user_id="user-A", role="user"), + Member(user_id="user-B", role="user"), + ], + team_member_budget_id=budget_id, + prisma_client=mock_prisma, + ) + + mock_prisma.db.litellm_teammembership.create_many.assert_not_awaited() + mock_prisma.db.litellm_teammembership.update_many.assert_awaited_once_with( + where={"team_id": team_id, "budget_id": None}, + data={"budget_id": budget_id}, + ) + + @pytest.mark.asyncio async def test_backfill_team_member_budget_entries_empty_members(): """ diff --git a/tests/test_litellm/proxy/management_helpers/test_management_helpers_utils.py b/tests/test_litellm/proxy/management_helpers/test_management_helpers_utils.py index c9828fc64f8..459072cf9d3 100644 --- a/tests/test_litellm/proxy/management_helpers/test_management_helpers_utils.py +++ b/tests/test_litellm/proxy/management_helpers/test_management_helpers_utils.py @@ -20,14 +20,13 @@ from litellm.proxy.management_helpers.utils import add_new_member @pytest.mark.asyncio -async def test_add_new_member_uses_default_team_budget_id(): +async def test_add_new_member_clones_default_team_budget_id(): """ - Test that add_new_member uses the default_team_budget_id when max_budget_in_team is None. + Test that add_new_member CLONES the team's default member budget when + max_budget_in_team is None and a default_team_budget_id is provided. - This test verifies that: - 1. When max_budget_in_team is None - 2. And default_team_budget_id is provided - 3. The team membership is created with the default_team_budget_id + Cloning (rather than sharing the same budget row) is what lets admins later + edit one member's budget without mutating every other member's budget. """ from litellm.proxy._types import LitellmUserRoles @@ -35,17 +34,15 @@ async def test_add_new_member_uses_default_team_budget_id(): test_user_id = "test_user_123" test_team_id = "test_team_456" test_default_budget_id = "default_budget_789" + test_cloned_budget_id = "cloned_budget_xyz" test_admin_name = "test_admin" - # Create a Member object with user_id new_member = Member(user_id=test_user_id, role="user") - # Create UserAPIKeyAuth object user_api_key_dict = UserAPIKeyAuth( user_id="admin_user", user_role=LitellmUserRoles.PROXY_ADMIN ) - # Mock the prisma client mock_prisma_client = AsyncMock() # Mock the user table upsert operation @@ -60,56 +57,140 @@ async def test_add_new_member_uses_default_team_budget_id(): return_value=mock_user_response ) + # Mock the default budget row fetched for cloning. + mock_default_budget_row = MagicMock() + mock_default_budget_row.model_dump.return_value = { + "budget_id": test_default_budget_id, + "max_budget": 100.0, + "soft_budget": None, + "max_parallel_requests": None, + "tpm_limit": 1000, + "rpm_limit": None, + "model_max_budget": None, + "budget_duration": "1d", + "allowed_models": [], + } + mock_prisma_client.db.litellm_budgettable.find_unique = AsyncMock( + return_value=mock_default_budget_row + ) + + # Mock the cloned budget row that .create() returns. + mock_cloned_budget_row = MagicMock() + mock_cloned_budget_row.budget_id = test_cloned_budget_id + mock_prisma_client.db.litellm_budgettable.create = AsyncMock( + return_value=mock_cloned_budget_row + ) + # Mock the team membership creation mock_team_membership_response = MagicMock() mock_team_membership_response.model_dump.return_value = { "team_id": test_team_id, "user_id": test_user_id, - "budget_id": test_default_budget_id, + "budget_id": test_cloned_budget_id, "litellm_budget_table": None, } mock_prisma_client.db.litellm_teammembership.create = AsyncMock( return_value=mock_team_membership_response ) - # Call the function with max_budget_in_team=None and a default_team_budget_id result_user, result_team_membership = await add_new_member( new_member=new_member, - max_budget_in_team=None, # This is the key - no max budget specified + max_budget_in_team=None, prisma_client=mock_prisma_client, team_id=test_team_id, user_api_key_dict=user_api_key_dict, litellm_proxy_admin_name=test_admin_name, - default_team_budget_id=test_default_budget_id, # This should be used + default_team_budget_id=test_default_budget_id, ) - # Verify that the user was created/updated correctly assert result_user is not None assert result_user.user_id == test_user_id - # Verify that the team membership was created correctly + # Membership should be linked to the new cloned budget, not the shared default. assert result_team_membership is not None - assert result_team_membership.team_id == test_team_id - assert result_team_membership.user_id == test_user_id - assert result_team_membership.budget_id == test_default_budget_id + assert result_team_membership.budget_id == test_cloned_budget_id + assert result_team_membership.budget_id != test_default_budget_id - # Verify that the prisma client methods were called correctly mock_prisma_client.db.litellm_usertable.upsert.assert_called_once() mock_prisma_client.db.litellm_teammembership.create.assert_called_once() - # Verify that no budget table creation was called (since max_budget_in_team is None) - assert ( - not hasattr(mock_prisma_client.db, "litellm_budgettable") - or not mock_prisma_client.db.litellm_budgettable.create.called + # The clone must have happened: find_unique on the default, create for the clone. + mock_prisma_client.db.litellm_budgettable.find_unique.assert_called_once_with( + where={"budget_id": test_default_budget_id} ) + mock_prisma_client.db.litellm_budgettable.create.assert_called_once() + cloned_create_data = ( + mock_prisma_client.db.litellm_budgettable.create.call_args.kwargs["data"] + ) + # Cloned values from the default budget row + assert cloned_create_data["max_budget"] == 100.0 + assert cloned_create_data["tpm_limit"] == 1000 + assert cloned_create_data["budget_duration"] == "1d" + assert cloned_create_data["created_by"] == user_api_key_dict.user_id - # Verify the team membership was created with the correct budget_id team_membership_call_args = ( mock_prisma_client.db.litellm_teammembership.create.call_args ) - assert team_membership_call_args is not None create_data = team_membership_call_args.kwargs["data"] - assert create_data["budget_id"] == test_default_budget_id + assert create_data["budget_id"] == test_cloned_budget_id + + +@pytest.mark.asyncio +async def test_add_new_member_no_budget_when_no_default_and_no_max_budget(): + """ + Test that add_new_member links no budget to the team membership when + neither max_budget_in_team nor default_team_budget_id is provided. + + When the team has no default member budget, new members get nothing. + """ + from litellm.proxy._types import LitellmUserRoles + + test_user_id = "test_user_no_budget" + test_team_id = "test_team_no_budget" + test_admin_name = "test_admin" + + new_member = Member(user_id=test_user_id, role="user") + + user_api_key_dict = UserAPIKeyAuth( + user_id="admin_user", user_role=LitellmUserRoles.PROXY_ADMIN + ) + + mock_prisma_client = AsyncMock() + + mock_user_response = MagicMock() + mock_user_response.model_dump.return_value = { + "user_id": test_user_id, + "user_email": None, + "teams": [test_team_id], + "user_role": "internal_user", + } + mock_prisma_client.db.litellm_usertable.upsert = AsyncMock( + return_value=mock_user_response + ) + + # Even though we mock these, they must NOT be called on the no-budget path. + mock_prisma_client.db.litellm_budgettable.find_unique = AsyncMock() + mock_prisma_client.db.litellm_budgettable.create = AsyncMock() + mock_prisma_client.db.litellm_teammembership.create = AsyncMock() + + result_user, result_team_membership = await add_new_member( + new_member=new_member, + max_budget_in_team=None, + prisma_client=mock_prisma_client, + team_id=test_team_id, + user_api_key_dict=user_api_key_dict, + litellm_proxy_admin_name=test_admin_name, + default_team_budget_id=None, + ) + + assert result_user is not None + assert result_user.user_id == test_user_id + + # No budget id, so no team membership row is created. + assert result_team_membership is None + mock_prisma_client.db.litellm_budgettable.find_unique.assert_not_called() + mock_prisma_client.db.litellm_budgettable.create.assert_not_called() + mock_prisma_client.db.litellm_teammembership.create.assert_not_called() @pytest.mark.asyncio @@ -206,38 +287,30 @@ async def test_add_new_member_creates_new_budget_when_max_budget_provided(): @pytest.mark.asyncio -async def test_add_new_member_with_user_email(): +async def test_add_new_member_with_user_email_clones_default_budget(): """ - Test add_new_member with user_email instead of user_id and default budget. - - This test verifies that: - 1. When new_member has user_email instead of user_id - 2. And max_budget_in_team is None - 3. The default_team_budget_id is used correctly + Test add_new_member with user_email instead of user_id and a team default + budget. The default budget should be CLONED into a new private row for + this user, not shared with other members of the team. """ from litellm.proxy._types import LitellmUserRoles - # Setup test data test_user_email = "test@example.com" test_team_id = "test_team_456" test_default_budget_id = "default_budget_789" + test_cloned_budget_id = "cloned_budget_for_email_user" test_admin_name = "test_admin" - # Create a Member object with user_email new_member = Member(user_email=test_user_email, role="user") - # Create UserAPIKeyAuth object user_api_key_dict = UserAPIKeyAuth( user_id="admin_user", user_role=LitellmUserRoles.PROXY_ADMIN ) - # Mock the prisma client mock_prisma_client = AsyncMock() - # Mock get_data to return empty list (no existing user) mock_prisma_client.get_data = AsyncMock(return_value=[]) - # Mock insert_data for new user creation mock_user_response = MagicMock() mock_user_response.model_dump.return_value = { "user_id": "generated_user_id", @@ -247,19 +320,41 @@ async def test_add_new_member_with_user_email(): } mock_prisma_client.insert_data = AsyncMock(return_value=mock_user_response) - # Mock the team membership creation + # Default budget that will be cloned + mock_default_budget_row = MagicMock() + mock_default_budget_row.model_dump.return_value = { + "budget_id": test_default_budget_id, + "max_budget": 25.0, + "soft_budget": None, + "max_parallel_requests": None, + "tpm_limit": None, + "rpm_limit": None, + "model_max_budget": None, + "budget_duration": None, + "allowed_models": [], + } + mock_prisma_client.db.litellm_budgettable.find_unique = AsyncMock( + return_value=mock_default_budget_row + ) + + # Cloned budget result + mock_cloned_budget_row = MagicMock() + mock_cloned_budget_row.budget_id = test_cloned_budget_id + mock_prisma_client.db.litellm_budgettable.create = AsyncMock( + return_value=mock_cloned_budget_row + ) + mock_team_membership_response = MagicMock() mock_team_membership_response.model_dump.return_value = { "team_id": test_team_id, "user_id": "generated_user_id", - "budget_id": test_default_budget_id, + "budget_id": test_cloned_budget_id, "litellm_budget_table": None, } mock_prisma_client.db.litellm_teammembership.create = AsyncMock( return_value=mock_team_membership_response ) - # Call the function result_user, result_team_membership = await add_new_member( new_member=new_member, max_budget_in_team=None, @@ -270,28 +365,31 @@ async def test_add_new_member_with_user_email(): default_team_budget_id=test_default_budget_id, ) - # Verify that the user was created correctly assert result_user is not None assert result_user.user_email == test_user_email - # Verify that the team membership was created with the default budget_id + # Membership should point at the cloned (private) budget, not the shared default. assert result_team_membership is not None - assert result_team_membership.budget_id == test_default_budget_id + assert result_team_membership.budget_id == test_cloned_budget_id - # Verify that get_data was called to check for existing user mock_prisma_client.get_data.assert_called_once_with( key_val={"user_email": test_user_email}, table_name="user", query_type="find_all", ) - # Verify that insert_data was called to create new user mock_prisma_client.insert_data.assert_called_once() insert_call_args = mock_prisma_client.insert_data.call_args insert_data = insert_call_args.kwargs["data"] assert insert_data["user_email"] == test_user_email assert insert_data["teams"] == [test_team_id] + # Confirm the clone path ran + mock_prisma_client.db.litellm_budgettable.find_unique.assert_called_once_with( + where={"budget_id": test_default_budget_id} + ) + mock_prisma_client.db.litellm_budgettable.create.assert_called_once() + @pytest.mark.asyncio async def test_attach_object_permission_to_dict_with_object_permission_id(): diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index cafdff9997e..06748e3f477 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -21,6 +21,7 @@ from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( bedrock_llm_proxy_route, create_pass_through_route, cursor_proxy_route, + get_vertex_base_url, llm_passthrough_factory_proxy_route, milvus_proxy_route, openai_proxy_route, @@ -31,6 +32,35 @@ from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( from litellm.types.passthrough_endpoints.vertex_ai import VertexPassThroughCredentials +class TestVertexPassthroughGetVertexBaseUrl: + """Module-local get_vertex_base_url (trailing slash); rules match common_utils.""" + + @pytest.mark.parametrize( + "vertex_location, expected", + [ + ("global", "https://aiplatform.googleapis.com/"), + ("us-central1", "https://us-central1-aiplatform.googleapis.com/"), + ("us", "https://aiplatform.us.rep.googleapis.com/"), + ("eu", "https://aiplatform.eu.rep.googleapis.com/"), + ], + ) + def test_returns_base_with_trailing_slash(self, vertex_location, expected): + assert get_vertex_base_url(vertex_location) == expected + + @pytest.mark.parametrize( + "vertex_location, expected_host", + [ + ("global", "aiplatform.googleapis.com"), + ("us-central1", "us-central1-aiplatform.googleapis.com"), + ("us", "aiplatform.us.rep.googleapis.com"), + ("eu", "aiplatform.eu.rep.googleapis.com"), + ], + ) + def test_websocket_host_strips_scheme(self, vertex_location, expected_host): + host = get_vertex_base_url(vertex_location).removeprefix("https://").rstrip("/") + assert host == expected_host + + class TestBaseOpenAIPassThroughHandler: def test_join_url_paths(self): print("\nTesting _join_url_paths method...") diff --git a/tests/test_litellm/proxy/realtime_endpoints/test_realtime_webrtc_endpoints.py b/tests/test_litellm/proxy/realtime_endpoints/test_realtime_webrtc_endpoints.py index e414f975f55..99b6335ce8a 100644 --- a/tests/test_litellm/proxy/realtime_endpoints/test_realtime_webrtc_endpoints.py +++ b/tests/test_litellm/proxy/realtime_endpoints/test_realtime_webrtc_endpoints.py @@ -116,8 +116,16 @@ def test_decode_realtime_token_payload_ephemeral_key_not_string(): def proxy_app(): from litellm.proxy import proxy_server + # master_key is a module-global — restore it on teardown so this fixture + # doesn't leak state into unrelated tests that share the same xdist worker + # (e.g. tests that assume master_key is None and send unauthenticated + # requests to the shared FastAPI app). + original_master_key = proxy_server.master_key proxy_server.master_key = "sk-test-master-key" - return proxy_server.app + try: + yield proxy_server.app + finally: + proxy_server.master_key = original_master_key @pytest.fixture diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py index 1e2e3981397..2370d5df302 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -422,6 +422,26 @@ def reset_router_callbacks(): litellm.logging_callback_manager._reset_all_callbacks() +@pytest.fixture(autouse=True) +def reset_proxy_auth_globals(monkeypatch): + """ + Pin proxy auth-related globals to a known baseline so tests don't inherit + leaked state (master_key, prisma_client, custom auth, cached tokens) from + earlier tests. Individual tests can still override via their own + monkeypatch calls — those run after this fixture and revert first. + """ + import litellm.proxy.proxy_server as ps + + monkeypatch.setattr(ps, "prisma_client", None) + monkeypatch.setattr(ps, "master_key", None) + monkeypatch.setattr(ps, "user_custom_auth", None) + monkeypatch.setattr(ps, "general_settings", {}) + try: + ps.user_api_key_cache.in_memory_cache.cache_dict.clear() + except AttributeError: + pass + + @pytest.mark.asyncio async def test_ui_view_spend_logs_with_user_id(client, monkeypatch): mock_spend_logs = [ @@ -1150,14 +1170,14 @@ async def test_ui_view_spend_logs_date_range_filter(client, monkeypatch): async def test_ui_view_spend_logs_unauthorized(client): # Test without authorization header response = client.get("/spend/logs/ui") - assert response.status_code == 401 or response.status_code == 403 + assert response.status_code in (401, 403), response.text # Test with invalid authorization response = client.get( "/spend/logs/ui", headers={"Authorization": "Bearer invalid-token"}, ) - assert response.status_code == 401 or response.status_code == 403 + assert response.status_code in (401, 403), response.text @pytest.mark.asyncio diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index 34d3c203377..ac009df67b0 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -1791,6 +1791,57 @@ def test_add_litellm_metadata_from_request_headers_both_headers_trace_id_precede assert data["litellm_trace_id"] == "trace-value" +def test_add_litellm_metadata_from_request_headers_generic_session_id_header(): + """A generic x--session-id header is used when no explicit litellm header is set.""" + headers = {"x-claude-code-session-id": "e96634a3-fa28-4083-b354-55542e2dca01"} + data = {"metadata": {}} + LiteLLMProxyRequestSetup.add_litellm_metadata_from_request_headers( + headers=headers, data=data, _metadata_variable_name="metadata" + ) + assert data["metadata"]["session_id"] == "e96634a3-fa28-4083-b354-55542e2dca01" + assert data["litellm_session_id"] == "e96634a3-fa28-4083-b354-55542e2dca01" + assert data["litellm_trace_id"] == "e96634a3-fa28-4083-b354-55542e2dca01" + + +def test_add_litellm_metadata_from_request_headers_explicit_header_beats_generic(): + """Explicit x-litellm-trace-id wins over a generic x-*-session-id header.""" + headers = { + "x-litellm-trace-id": "explicit-trace-id-value", + "x-claude-code-session-id": "e96634a3-fa28-4083-b354-55542e2dca01", + } + data = {"metadata": {}} + LiteLLMProxyRequestSetup.add_litellm_metadata_from_request_headers( + headers=headers, data=data, _metadata_variable_name="metadata" + ) + assert data["litellm_session_id"] == "explicit-trace-id-value" + assert data["litellm_trace_id"] == "explicit-trace-id-value" + + +def test_get_chain_id_from_headers_generic_vendor_session_id(): + """get_chain_id_from_headers picks up any x--session-id with a valid value.""" + from litellm.proxy.litellm_pre_call_utils import get_chain_id_from_headers + + assert ( + get_chain_id_from_headers( + {"x-claude-code-session-id": "e96634a3-fa28-4083-b354-55542e2dca01"} + ) + == "e96634a3-fa28-4083-b354-55542e2dca01" + ) + # Short / non-alphanumeric values should be ignored + assert get_chain_id_from_headers({"x-foo-session-id": "short"}) is None + assert get_chain_id_from_headers({"x-foo-session-id": "has spaces!!"}) is None + # Explicit headers still take precedence + assert ( + get_chain_id_from_headers( + { + "x-litellm-trace-id": "explicit-id-value", + "x-claude-code-session-id": "e96634a3-fa28-4083-b354-55542e2dca01", + } + ) + == "explicit-id-value" + ) + + def test_get_internal_user_header_from_mapping_returns_expected_header(): mappings = [ {"header_name": "X-OpenWebUI-User-Id", "litellm_user_role": "internal_user"}, diff --git a/tests/test_litellm/proxy/test_proxy_cli.py b/tests/test_litellm/proxy/test_proxy_cli.py index 7d32de3dbba..e5fcc6001d9 100644 --- a/tests/test_litellm/proxy/test_proxy_cli.py +++ b/tests/test_litellm/proxy/test_proxy_cli.py @@ -744,7 +744,9 @@ class TestHealthAppFactory: # Test 1: Without --use_prisma_db_push flag (default behavior) # use_prisma_db_push should be False (default), so use_migrate should be True run_server.main(["--local", "--skip_server_startup"], standalone_mode=False) - mock_setup_database.assert_called_with(use_migrate=True) + mock_setup_database.assert_called_with( + use_migrate=True, use_v2_resolver=False + ) # Reset mocks mock_setup_database.reset_mock() @@ -757,7 +759,9 @@ class TestHealthAppFactory: ["--local", "--skip_server_startup", "--use_prisma_db_push"], standalone_mode=False, ) - mock_setup_database.assert_called_with(use_migrate=False) + mock_setup_database.assert_called_with( + use_migrate=False, use_v2_resolver=False + ) @patch("subprocess.run") @patch("atexit.register") @@ -822,7 +826,9 @@ class TestHealthAppFactory: standalone_mode=False, ) assert exc_info.value.code == 1 - mock_setup_database.assert_called_once_with(use_migrate=True) + mock_setup_database.assert_called_once_with( + use_migrate=True, use_v2_resolver=False + ) # --- Module-level helpers for worker startup hook tests --- diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 79eba81dc40..efd1abbb383 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -4965,3 +4965,123 @@ async def test_increment_spend_counters_team_and_member(): finally: ps.user_api_key_cache = original_key_cache ps.spend_counter_cache = original_counter_cache + + +@pytest.mark.asyncio +async def test_init_and_increment_spend_counter_reseeds_from_db_on_counter_miss(): + """When the Redis counter is missing, the reseed path reads the + authoritative spend from the DB (not a stale cache), so the next + increment continues from the correct base value.""" + from litellm.caching.dual_cache import DualCache + + counter_cache = DualCache() + recorded_increments: list = [] + + async def record_increment(key, value, ttl=None, **kwargs): + recorded_increments.append({"key": key, "value": value, "ttl": ttl}) + return value + + fake_redis = AsyncMock() + fake_redis.async_increment = AsyncMock(side_effect=record_increment) + fake_redis.async_get_cache = AsyncMock(return_value=None) # counter missing + counter_cache.redis_cache = fake_redis + + # Prisma returns spend=42.0 (authoritative) while the stale cached + # value (would be read only if prisma is None) is 10.0. The counter + # must seed from 42, not 10. + db_row = MagicMock() + db_row.spend = 42.0 + fake_prisma = MagicMock() + fake_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=db_row) + + stale_cache = DualCache() + stale_team = MagicMock() + stale_team.spend = 10.0 + stale_cache.in_memory_cache.set_cache(key="team_id:team-9", value=stale_team) + + import litellm.proxy.proxy_server as ps + from litellm.proxy.proxy_server import _init_and_increment_spend_counter + + orig_user, orig_counter, orig_prisma = ( + ps.user_api_key_cache, + ps.spend_counter_cache, + ps.prisma_client, + ) + ps.user_api_key_cache = stale_cache + ps.spend_counter_cache = counter_cache + ps.prisma_client = fake_prisma + try: + await _init_and_increment_spend_counter( + counter_key="spend:team:team-9", + source_cache_key="team_id:team-9", + increment=1.5, + ) + + fake_prisma.db.litellm_teamtable.find_unique.assert_awaited_once_with( + where={"team_id": "team-9"} + ) + # Two increments keyed on the counter: seed ($42) then request ($1.50). + writes = [(c["key"], c["value"]) for c in recorded_increments] + assert ("spend:team:team-9", 42.0) in writes + assert ("spend:team:team-9", 1.5) in writes + finally: + ps.user_api_key_cache = orig_user + ps.spend_counter_cache = orig_counter + ps.prisma_client = orig_prisma + + +@pytest.mark.asyncio +async def test_reseed_spend_from_db_user_and_org_prefixes(): + """User and org counters must reseed from their own DB tables, not + fall through to 0.0 like the other counters do today.""" + import litellm.proxy.proxy_server as ps + from litellm.proxy.proxy_server import _reseed_spend_from_db + + user_row = MagicMock() + user_row.spend = 17.0 + org_row = MagicMock() + org_row.spend = 305.0 + + fake_prisma = MagicMock() + fake_prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=user_row) + fake_prisma.db.litellm_organizationtable.find_unique = AsyncMock( + return_value=org_row + ) + + orig_prisma = ps.prisma_client + ps.prisma_client = fake_prisma + try: + assert await _reseed_spend_from_db("spend:user:alice") == 17.0 + fake_prisma.db.litellm_usertable.find_unique.assert_awaited_once_with( + where={"user_id": "alice"} + ) + + assert await _reseed_spend_from_db("spend:org:acme") == 305.0 + fake_prisma.db.litellm_organizationtable.find_unique.assert_awaited_once_with( + where={"organization_id": "acme"} + ) + finally: + ps.prisma_client = orig_prisma + + +@pytest.mark.asyncio +async def test_reseed_spend_from_db_skips_window_variant_keys(): + """Window counters (spend:*:window:{duration}) share prefixes with + primary counters but don't correspond to a DB row. The guard must + short-circuit without querying the DB.""" + import litellm.proxy.proxy_server as ps + from litellm.proxy.proxy_server import _reseed_spend_from_db + + fake_prisma = MagicMock() + fake_prisma.db.litellm_verificationtoken.find_unique = AsyncMock() + fake_prisma.db.litellm_teamtable.find_unique = AsyncMock() + + orig_prisma = ps.prisma_client + ps.prisma_client = fake_prisma + try: + assert await _reseed_spend_from_db("spend:key:sk-abc:window:1h") == 0.0 + assert await _reseed_spend_from_db("spend:team:team-1:window:1d") == 0.0 + fake_prisma.db.litellm_verificationtoken.find_unique.assert_not_awaited() + fake_prisma.db.litellm_teamtable.find_unique.assert_not_awaited() + finally: + ps.prisma_client = orig_prisma diff --git a/tests/test_litellm/proxy/test_team_org_move.py b/tests/test_litellm/proxy/test_team_org_move.py new file mode 100644 index 00000000000..2dc961bec85 --- /dev/null +++ b/tests/test_litellm/proxy/test_team_org_move.py @@ -0,0 +1,232 @@ +""" +Tests for moving teams to organizations. + +Covers the SSO/Entra scenario where: +- Proxy admins can move teams freely; missing members are auto-added to the org. +- Non-proxy-admins (team admins) must have all team members pre-added to the org, + preserving the original security model (no privilege escalation via team move). +""" +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from litellm.proxy._types import ( + LiteLLM_OrganizationTableWithMembers, + LiteLLM_OrganizationMembershipTable, + LiteLLM_TeamTable, + LitellmUserRoles, + Member, + OrgMember, + SpecialProxyStrings, +) +from litellm.proxy.management_endpoints.team_endpoints import ( + _auto_add_team_members_to_organization, + validate_team_org_change, +) +from litellm.router import Router + + +def _make_org(organization_id="org-1", members=None, models=None): + from datetime import datetime + + return LiteLLM_OrganizationTableWithMembers( + organization_id=organization_id, + organization_alias="test-org", + budget_id="budget-test", + spend=0.0, + metadata={}, + models=models or [], + created_by="default_user_id", + updated_by="default_user_id", + members=members or [], + teams=[], + litellm_budget_table=None, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + +def _make_team(team_id="team-1", member_ids=None, organization_id=None): + members = [ + Member(user_id=uid, role="user") for uid in (member_ids or []) + ] + members.append(Member(user_id=SpecialProxyStrings.default_user_id.value, role="admin")) + return LiteLLM_TeamTable( + team_id=team_id, + team_alias="test-team", + organization_id=organization_id, + admins=[], + members=[], + members_with_roles=members, + metadata={}, + models=[], + blocked=False, + spend=0.0, + ) + + +def _make_org_membership(user_id): + from datetime import datetime + + return LiteLLM_OrganizationMembershipTable( + user_id=user_id, + organization_id="org-1", + user_role=LitellmUserRoles.INTERNAL_USER, + spend=0.0, + budget_id=None, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + +class TestValidateTeamOrgChange: + def test_proxy_admin_not_blocked_when_members_not_in_org(self): + """Proxy admins bypass the membership check — auto-add handles it instead.""" + router = MagicMock(spec=Router) + team = _make_team(member_ids=["sso-user-001", "sso-user-002"]) + org = _make_org(members=[]) + + result = validate_team_org_change( + team=team, organization=org, llm_router=router, is_proxy_admin=True + ) + assert result is True + + def test_non_admin_blocked_when_members_not_in_org(self): + """Team admins (non-proxy-admin) must have all members pre-added to the org.""" + router = MagicMock(spec=Router) + team = _make_team(member_ids=["sso-user-001"]) + org = _make_org(members=[]) + + with pytest.raises(Exception) as exc_info: + validate_team_org_change( + team=team, organization=org, llm_router=router, is_proxy_admin=False + ) + assert "403" in str(exc_info.value) or "not a member" in str(exc_info.value) + + def test_non_admin_passes_when_all_members_in_org(self): + """Team admin move succeeds when all team members are already org members.""" + router = MagicMock(spec=Router) + team = _make_team(member_ids=["u1"]) + org = _make_org(members=[_make_org_membership("u1")]) + + result = validate_team_org_change( + team=team, organization=org, llm_router=router, is_proxy_admin=False + ) + assert result is True + + def test_same_org_short_circuits(self): + """Moving to the same org is always a no-op, regardless of role.""" + router = MagicMock(spec=Router) + team = _make_team(member_ids=["u1"], organization_id="org-1") + org = _make_org(organization_id="org-1") + + assert validate_team_org_change( + team=team, organization=org, llm_router=router, is_proxy_admin=False + ) is True + assert validate_team_org_change( + team=team, organization=org, llm_router=router, is_proxy_admin=True + ) is True + + def test_default_user_excluded_from_membership_check(self): + """default_user_id is never checked for org membership.""" + router = MagicMock(spec=Router) + # Team has only default_user_id (added by _make_team) + team = _make_team(member_ids=[]) + org = _make_org(members=[]) + + # Should not raise even for non-proxy-admin + result = validate_team_org_change( + team=team, organization=org, llm_router=router, is_proxy_admin=False + ) + assert result is True + + +class TestAutoAddTeamMembersToOrg: + @pytest.mark.asyncio + async def test_adds_missing_members(self): + team = _make_team(member_ids=["sso-user-001", "sso-user-002"]) + org = _make_org(members=[]) + + mock_add = AsyncMock() + import litellm.proxy.management_endpoints.team_endpoints as te + original = te.add_member_to_organization + te.add_member_to_organization = mock_add + + try: + await _auto_add_team_members_to_organization( + team=team, + organization=org, + prisma_client=MagicMock(), + ) + finally: + te.add_member_to_organization = original + + assert mock_add.call_count == 2 + called_user_ids = { + call.kwargs["member"].user_id for call in mock_add.call_args_list + } + assert called_user_ids == {"sso-user-001", "sso-user-002"} + + @pytest.mark.asyncio + async def test_skips_existing_org_members(self): + team = _make_team(member_ids=["u1", "u2"]) + org = _make_org(members=[_make_org_membership("u1")]) + + mock_add = AsyncMock() + import litellm.proxy.management_endpoints.team_endpoints as te + original = te.add_member_to_organization + te.add_member_to_organization = mock_add + + try: + await _auto_add_team_members_to_organization( + team=team, + organization=org, + prisma_client=MagicMock(), + ) + finally: + te.add_member_to_organization = original + + assert mock_add.call_count == 1 + assert mock_add.call_args.kwargs["member"].user_id == "u2" + + @pytest.mark.asyncio + async def test_skips_default_user(self): + """default_user_id should never be added as an org member.""" + team = _make_team(member_ids=[]) + org = _make_org(members=[]) + + mock_add = AsyncMock() + import litellm.proxy.management_endpoints.team_endpoints as te + original = te.add_member_to_organization + te.add_member_to_organization = mock_add + + try: + await _auto_add_team_members_to_organization( + team=team, + organization=org, + prisma_client=MagicMock(), + ) + finally: + te.add_member_to_organization = original + + assert mock_add.call_count == 0 + + @pytest.mark.asyncio + async def test_logs_and_continues_on_error(self): + """Errors must not propagate — they are logged at DEBUG and skipped.""" + team = _make_team(member_ids=["u1"]) + org = _make_org(members=[]) + + mock_add = AsyncMock(side_effect=Exception("duplicate key")) + import litellm.proxy.management_endpoints.team_endpoints as te + original = te.add_member_to_organization + te.add_member_to_organization = mock_add + + try: + await _auto_add_team_members_to_organization( + team=team, + organization=org, + prisma_client=MagicMock(), + ) + finally: + te.add_member_to_organization = original diff --git a/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_access_control.py b/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_access_control.py index 46f0ddcd582..7d72121456a 100644 --- a/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_access_control.py +++ b/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_access_control.py @@ -11,14 +11,19 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import HTTPException -from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy._types import ( + LiteLLM_ObjectPermissionTable, + LitellmUserRoles, + UserAPIKeyAuth, +) from litellm.proxy.vector_store_endpoints.management_endpoints import ( _check_vector_store_access, ) from litellm.types.vector_stores import LiteLLM_ManagedVectorStore -def test_check_vector_store_access(): +@pytest.mark.asyncio +async def test_check_vector_store_access(): """Test core access control logic for team-based vector store access""" # Test 1: Legacy vector stores (no team_id) are accessible to all @@ -28,7 +33,7 @@ def test_check_vector_store_access(): "team_id": None, } user = UserAPIKeyAuth(team_id="team_456") - assert _check_vector_store_access(vector_store, user) is True + assert await _check_vector_store_access(vector_store, user) is True # Test 2: User can access their team's vector stores vector_store = { @@ -37,7 +42,7 @@ def test_check_vector_store_access(): "team_id": "team_456", } user = UserAPIKeyAuth(team_id="team_456") - assert _check_vector_store_access(vector_store, user) is True + assert await _check_vector_store_access(vector_store, user) is True # Test 3: User cannot access other teams' vector stores vector_store = { @@ -46,7 +51,57 @@ def test_check_vector_store_access(): "team_id": "team_456", } user = UserAPIKeyAuth(team_id="team_789") - assert _check_vector_store_access(vector_store, user) is False + assert await _check_vector_store_access(vector_store, user) is False + + +@pytest.mark.asyncio +async def test_check_vector_store_access_proxy_admin_bypass(): + """PROXY_ADMIN can access a vector store even if teams don't match.""" + vector_store: LiteLLM_ManagedVectorStore = { + "vector_store_id": "vs_team", + "custom_llm_provider": "openai", + "team_id": "team_456", + } + admin = UserAPIKeyAuth(team_id="team_999", user_role=LitellmUserRoles.PROXY_ADMIN) + assert await _check_vector_store_access(vector_store, admin) is True + + +@pytest.mark.asyncio +async def test_check_vector_store_access_key_object_permission_grants_access(): + """A key whose object_permission.vector_stores allowlists the store can access it + even if its team_id does not match the store's team_id.""" + vector_store: LiteLLM_ManagedVectorStore = { + "vector_store_id": "vs_explicit", + "custom_llm_provider": "openai", + "team_id": "team_456", + } + user = UserAPIKeyAuth( + team_id="team_789", + object_permission=LiteLLM_ObjectPermissionTable( + object_permission_id="op-1", + vector_stores=["vs_explicit"], + ), + ) + assert await _check_vector_store_access(vector_store, user) is True + + +@pytest.mark.asyncio +async def test_check_vector_store_access_key_object_permission_wrong_store_denied(): + """A key whose object_permission.vector_stores lists *other* stores is still denied + when the key has no other reason to access this store.""" + vector_store: LiteLLM_ManagedVectorStore = { + "vector_store_id": "vs_target", + "custom_llm_provider": "openai", + "team_id": "team_456", + } + user = UserAPIKeyAuth( + team_id="team_789", + object_permission=LiteLLM_ObjectPermissionTable( + object_permission_id="op-1", + vector_stores=["vs_other"], + ), + ) + assert await _check_vector_store_access(vector_store, user) is False @pytest.mark.asyncio diff --git a/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py b/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py index c1dc0cba02c..44cc5cc4452 100644 --- a/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py +++ b/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py @@ -115,7 +115,8 @@ def test_router_vector_store_file_delete_passes_correct_args(): assert call_kwargs["custom_llm_provider"] == "openai" -def test_update_request_data_with_litellm_managed_vector_store_registry(): +@pytest.mark.asyncio +async def test_update_request_data_with_litellm_managed_vector_store_registry(): """ Test that _update_request_data_with_litellm_managed_vector_store_registry correctly updates request data with vector store registry information. @@ -139,7 +140,7 @@ def test_update_request_data_with_litellm_managed_vector_store_registry(): # Test with vector store registry with patch.object(litellm, "vector_store_registry", mock_registry): - result = _update_request_data_with_litellm_managed_vector_store_registry( + result = await _update_request_data_with_litellm_managed_vector_store_registry( data=data, vector_store_id=vector_store_id ) @@ -158,7 +159,7 @@ def test_update_request_data_with_litellm_managed_vector_store_registry(): # Test with no vector store registry with patch.object(litellm, "vector_store_registry", None): original_data = {"existing_key": "existing_value"} - result = _update_request_data_with_litellm_managed_vector_store_registry( + result = await _update_request_data_with_litellm_managed_vector_store_registry( data=original_data, vector_store_id=vector_store_id ) @@ -1686,10 +1687,26 @@ async def test_new_vector_store_auto_resolves_from_router(): ) +def _stub_user_api_key( + *, + team_id=None, + user_role=None, + object_permission=None, + object_permission_id=None, + team_object_permission_id=None, +): + user = UserAPIKeyAuth(team_id=team_id, user_role=user_role) + user.object_permission = object_permission + user.object_permission_id = object_permission_id + user.team_object_permission_id = team_object_permission_id + return user + + class TestCheckVectorStoreAccess: """Test suite for _check_vector_store_access function.""" - def test_access_granted_when_no_team_id(self): + @pytest.mark.asyncio + async def test_access_granted_when_no_team_id(self): """Test that access is granted when vector store has no team_id (legacy behavior).""" vector_store: LiteLLM_ManagedVectorStore = { "vector_store_id": "test-store", @@ -1697,13 +1714,12 @@ class TestCheckVectorStoreAccess: # No team_id field } - mock_user_api_key = MagicMock(spec=UserAPIKeyAuth) - mock_user_api_key.team_id = "team-123" - - result = _check_vector_store_access(vector_store, mock_user_api_key) + user = _stub_user_api_key(team_id="team-123") + result = await _check_vector_store_access(vector_store, user) assert result is True - def test_access_granted_when_team_ids_match(self): + @pytest.mark.asyncio + async def test_access_granted_when_team_ids_match(self): """Test that access is granted when user's team_id matches vector store's team_id.""" vector_store: LiteLLM_ManagedVectorStore = { "vector_store_id": "test-store", @@ -1711,13 +1727,12 @@ class TestCheckVectorStoreAccess: "team_id": "team-123", } - mock_user_api_key = MagicMock(spec=UserAPIKeyAuth) - mock_user_api_key.team_id = "team-123" - - result = _check_vector_store_access(vector_store, mock_user_api_key) + user = _stub_user_api_key(team_id="team-123") + result = await _check_vector_store_access(vector_store, user) assert result is True - def test_access_denied_when_team_ids_dont_match(self): + @pytest.mark.asyncio + async def test_access_denied_when_team_ids_dont_match(self): """Test that access is denied when user's team_id doesn't match vector store's team_id.""" vector_store: LiteLLM_ManagedVectorStore = { "vector_store_id": "test-store", @@ -1725,13 +1740,12 @@ class TestCheckVectorStoreAccess: "team_id": "team-123", } - mock_user_api_key = MagicMock(spec=UserAPIKeyAuth) - mock_user_api_key.team_id = "team-456" - - result = _check_vector_store_access(vector_store, mock_user_api_key) + user = _stub_user_api_key(team_id="team-456") + result = await _check_vector_store_access(vector_store, user) assert result is False - def test_access_denied_when_vector_store_has_team_id_but_user_doesnt(self): + @pytest.mark.asyncio + async def test_access_denied_when_vector_store_has_team_id_but_user_doesnt(self): """Test that access is denied when vector store has team_id but user doesn't.""" vector_store: LiteLLM_ManagedVectorStore = { "vector_store_id": "test-store", @@ -1739,10 +1753,8 @@ class TestCheckVectorStoreAccess: "team_id": "team-123", } - mock_user_api_key = MagicMock(spec=UserAPIKeyAuth) - mock_user_api_key.team_id = None - - result = _check_vector_store_access(vector_store, mock_user_api_key) + user = _stub_user_api_key(team_id=None) + result = await _check_vector_store_access(vector_store, user) assert result is False diff --git a/tests/test_litellm/responses/test_responses_api_bridge_flag.py b/tests/test_litellm/responses/test_responses_api_bridge_flag.py new file mode 100644 index 00000000000..463af6562f1 --- /dev/null +++ b/tests/test_litellm/responses/test_responses_api_bridge_flag.py @@ -0,0 +1,282 @@ +""" +Tests for forcing the /responses → /chat/completions bridge for `openai/` models +(via `use_chat_completions_api` or the `openai/chat_completions/` model id). + +Includes file_search emulation: the flag must be forwarded on inner aresponses +calls so routed requests do not hit a custom api_base /v1/responses endpoint. +""" + +import os +import sys +from unittest.mock import MagicMock, patch + +sys.path.insert( + 0, os.path.abspath("../../..") +) # Adds the parent directory to the system path + +import litellm +from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse + + +class TestUseResponsesApiBridgeFlag: + """Test that bridge opt-in forces the chat completions path.""" + + @patch( + "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" + ) + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + def test_bridge_used_when_use_chat_completions_api_true( + self, mock_get_config, mock_bridge_handler + ): + """When use_chat_completions_api=True, the bridge handler should be called.""" + mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() + mock_bridge_handler.return_value = MagicMock() + + litellm.responses( + model="openai/my-custom-model", + input="Hello", + use_chat_completions_api=True, + litellm_logging_obj=MagicMock(), + ) + + mock_bridge_handler.assert_called_once() + + @patch( + "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" + ) + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + def test_bridge_used_when_model_uses_chat_completions_prefix( + self, mock_get_config, mock_bridge_handler + ): + """`openai/chat_completions/` normalizes to `openai/` and uses the bridge.""" + mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() + mock_bridge_handler.return_value = MagicMock() + + litellm.responses( + model="openai/chat_completions/my-custom-model", + input="Hello", + litellm_logging_obj=MagicMock(), + ) + + mock_bridge_handler.assert_called_once() + # Model string is provider-normalized after resolution; prefix only forces the bridge. + assert mock_bridge_handler.call_args.kwargs["model"].endswith("my-custom-model") + + @patch("litellm.responses.main.base_llm_http_handler.response_api_handler") + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + def test_native_forwarding_when_flag_absent( + self, mock_get_config, mock_native_handler + ): + """When use_chat_completions_api is not set, openai/ models should use + native responses API forwarding (existing behavior).""" + mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() + mock_native_handler.return_value = MagicMock() + + litellm.responses( + model="openai/gpt-4o", + input="Hello", + litellm_logging_obj=MagicMock(), + ) + + mock_native_handler.assert_called_once() + + @patch( + "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" + ) + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + def test_flag_does_not_leak_into_kwargs(self, mock_get_config, mock_bridge_handler): + """use_chat_completions_api should be popped and not passed to the bridge handler.""" + mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() + mock_bridge_handler.return_value = MagicMock() + + litellm.responses( + model="openai/my-custom-model", + input="Hello", + use_chat_completions_api=True, + litellm_logging_obj=MagicMock(), + ) + + call_kwargs = mock_bridge_handler.call_args + all_kwargs = call_kwargs.kwargs if call_kwargs.kwargs else {} + assert "use_chat_completions_api" not in all_kwargs + + @patch( + "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" + ) + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + def test_bridge_used_when_provider_config_none( + self, mock_get_config, mock_bridge_handler + ): + """When the provider has no native responses API config (returns None), + the bridge should be used regardless of the flag (existing behavior).""" + mock_get_config.return_value = None + mock_bridge_handler.return_value = MagicMock() + + litellm.responses( + model="anthropic/claude-3-haiku", + input="Hello", + litellm_logging_obj=MagicMock(), + ) + + mock_bridge_handler.assert_called_once() + + @patch("litellm.responses.file_search.emulated_handler._call_aresponses") + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + async def test_bridge_flag_forwarded_to_file_search_emulation( + self, mock_get_config, mock_call_aresponses + ): + """When use_chat_completions_api=True and file_search tool is present, + the flag should be forwarded to the inner aresponses call in the + file_search emulation path.""" + # Setup: provider has native responses API support + mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() + + # Mock the inner aresponses call to return a valid response + mock_response = ResponsesAPIResponse( + id="resp_123", + model="openai/my-custom-model", + created_at=1234567890, + output=[ + {"type": "message", "content": [{"type": "text", "text": "Answer"}]} + ], + usage=ResponseAPIUsage( + input_tokens=10, output_tokens=5, total_tokens=15 + ), + ) + mock_call_aresponses.return_value = mock_response + + await litellm.aresponses( + model="openai/my-custom-model", + input="Search for information", + tools=[{"type": "file_search"}], + use_chat_completions_api=True, + litellm_logging_obj=MagicMock(), + ) + + # Verify _call_aresponses was called with use_chat_completions_api=True + mock_call_aresponses.assert_called_once() + call_kwargs = mock_call_aresponses.call_args.kwargs + assert ( + call_kwargs.get("use_chat_completions_api") is True + ), "use_chat_completions_api should be forwarded to inner aresponses call" + + @patch( + "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" + ) + @patch("litellm.vector_stores.main.asearch") + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + async def test_bridge_flag_prevents_native_responses_endpoint_call( + self, mock_get_config, mock_asearch, mock_bridge_handler + ): + """ + Concrete failing scenario: native OpenAI responses config + bridge flag + + file_search → emulation must still route inner calls through the bridge + (chat completions), not POST to api_base /v1/responses. + """ + mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() + mock_asearch.return_value = [] + + first_response = ResponsesAPIResponse( + id="resp_first", + model="openai/my-local-model", + created_at=1234567890, + output=[ + { + "type": "function_call", + "name": "litellm_file_search", + "call_id": "call_123", + "arguments": '{"queries": ["test query"]}', + } + ], + usage=ResponseAPIUsage( + input_tokens=10, output_tokens=5, total_tokens=15 + ), + ) + second_response = ResponsesAPIResponse( + id="resp_second", + model="openai/my-local-model", + created_at=1234567891, + output=[ + { + "type": "message", + "content": [{"type": "text", "text": "Final answer"}], + } + ], + usage=ResponseAPIUsage( + input_tokens=20, output_tokens=10, total_tokens=30 + ), + ) + mock_bridge_handler.side_effect = [first_response, second_response] + + result = await litellm.aresponses( + model="openai/my-local-model", + input="Search for information", + tools=[ + { + "type": "file_search", + "file_search": {"vector_store_ids": ["vs_123"]}, + } + ], + use_chat_completions_api=True, + api_base="http://localhost:8080/v1", + litellm_logging_obj=MagicMock(), + ) + + assert mock_bridge_handler.call_count == 2, ( + "Bridge handler should be called twice: initial function-tool call " + "and follow-up with tool results" + ) + for call in mock_bridge_handler.call_args_list: + all_kwargs = call.kwargs if call.kwargs else {} + assert "use_chat_completions_api" not in all_kwargs + assert result is not None + assert result.id is not None + + @patch("litellm.responses.main.base_llm_http_handler.response_api_handler") + @patch("litellm.vector_stores.main.asearch") + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + async def test_without_bridge_flag_uses_native_endpoint( + self, mock_get_config, mock_asearch, mock_native_handler + ): + """Without the bridge flag, openai/ with native config uses the native handler.""" + mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() + mock_asearch.return_value = [] + mock_native_handler.return_value = ResponsesAPIResponse( + id="resp_native", + model="openai/gpt-4o", + created_at=1234567890, + output=[ + { + "type": "message", + "content": [{"type": "text", "text": "Native response"}], + } + ], + usage=ResponseAPIUsage( + input_tokens=10, output_tokens=5, total_tokens=15 + ), + ) + + result = await litellm.aresponses( + model="openai/gpt-4o", + input="Hello", + litellm_logging_obj=MagicMock(), + ) + + mock_native_handler.assert_called_once() + assert result is not None diff --git a/tests/test_litellm/router_strategy/adaptive_router/__init__.py b/tests/test_litellm/router_strategy/adaptive_router/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/router_strategy/adaptive_router/fixtures/clean_no_signals.json b/tests/test_litellm/router_strategy/adaptive_router/fixtures/clean_no_signals.json new file mode 100644 index 00000000000..e53cc50b4b1 --- /dev/null +++ b/tests/test_litellm/router_strategy/adaptive_router/fixtures/clean_no_signals.json @@ -0,0 +1,16 @@ +[ + { + "user_content": "what is the weather today in paris france", + "assistant_content": "It is sunny and warm in Paris today.", + "tool_calls": [], + "tool_results": [], + "response_status": 200 + }, + { + "user_content": "what is the weather today in paris france tomorrow", + "assistant_content": "Light rain is expected throughout the day.", + "tool_calls": [], + "tool_results": [], + "response_status": 200 + } +] diff --git a/tests/test_litellm/router_strategy/adaptive_router/fixtures/clean_satisfaction.json b/tests/test_litellm/router_strategy/adaptive_router/fixtures/clean_satisfaction.json new file mode 100644 index 00000000000..6f9e81c9b0a --- /dev/null +++ b/tests/test_litellm/router_strategy/adaptive_router/fixtures/clean_satisfaction.json @@ -0,0 +1,23 @@ +[ + { + "user_content": "how do I read a file in python", + "assistant_content": "Use the open() function with a context manager.", + "tool_calls": [], + "tool_results": [], + "response_status": 200 + }, + { + "user_content": "can you show an example", + "assistant_content": "with open('file.txt') as f: data = f.read()", + "tool_calls": [], + "tool_results": [], + "response_status": 200 + }, + { + "user_content": "thanks, that worked!", + "assistant_content": "Glad to hear it.", + "tool_calls": [], + "tool_results": [], + "response_status": 200 + } +] diff --git a/tests/test_litellm/router_strategy/adaptive_router/fixtures/disengagement_giveup.json b/tests/test_litellm/router_strategy/adaptive_router/fixtures/disengagement_giveup.json new file mode 100644 index 00000000000..d17a1cfe3c3 --- /dev/null +++ b/tests/test_litellm/router_strategy/adaptive_router/fixtures/disengagement_giveup.json @@ -0,0 +1,16 @@ +[ + { + "user_content": "how do I install this package", + "assistant_content": "Run pip install .", + "tool_calls": [], + "tool_results": [], + "response_status": 200 + }, + { + "user_content": "forget it, I'll do it myself", + "assistant_content": "Okay, let me know if you need anything else.", + "tool_calls": [], + "tool_results": [], + "response_status": 200 + } +] diff --git a/tests/test_litellm/router_strategy/adaptive_router/fixtures/exhaustion_429.json b/tests/test_litellm/router_strategy/adaptive_router/fixtures/exhaustion_429.json new file mode 100644 index 00000000000..064bf21e1a9 --- /dev/null +++ b/tests/test_litellm/router_strategy/adaptive_router/fixtures/exhaustion_429.json @@ -0,0 +1,9 @@ +[ + { + "user_content": "do the thing", + "assistant_content": null, + "tool_calls": [], + "tool_results": [], + "response_status": 429 + } +] diff --git a/tests/test_litellm/router_strategy/adaptive_router/fixtures/exhaustion_context_overflow.json b/tests/test_litellm/router_strategy/adaptive_router/fixtures/exhaustion_context_overflow.json new file mode 100644 index 00000000000..e3e55ac5e73 --- /dev/null +++ b/tests/test_litellm/router_strategy/adaptive_router/fixtures/exhaustion_context_overflow.json @@ -0,0 +1,13 @@ +[ + { + "user_content": "summarize this giant document", + "assistant_content": null, + "tool_calls": [ + {"id": "c1", "name": "summarize", "arguments": {"doc_id": "big"}} + ], + "tool_results": [ + {"tool_call_id": "c1", "content": "Error: context length exceeded for this model"} + ], + "response_status": 200 + } +] diff --git a/tests/test_litellm/router_strategy/adaptive_router/fixtures/failure_tool_error.json b/tests/test_litellm/router_strategy/adaptive_router/fixtures/failure_tool_error.json new file mode 100644 index 00000000000..28c55850f88 --- /dev/null +++ b/tests/test_litellm/router_strategy/adaptive_router/fixtures/failure_tool_error.json @@ -0,0 +1,13 @@ +[ + { + "user_content": "read the config file", + "assistant_content": "Let me try.", + "tool_calls": [ + {"id": "call_1", "name": "read_file", "arguments": {"path": "/etc/missing.conf"}} + ], + "tool_results": [ + {"tool_call_id": "call_1", "content": "ENOENT: no such file or directory", "is_error": true} + ], + "response_status": 200 + } +] diff --git a/tests/test_litellm/router_strategy/adaptive_router/fixtures/loop_same_tool.json b/tests/test_litellm/router_strategy/adaptive_router/fixtures/loop_same_tool.json new file mode 100644 index 00000000000..705f6a5a088 --- /dev/null +++ b/tests/test_litellm/router_strategy/adaptive_router/fixtures/loop_same_tool.json @@ -0,0 +1,35 @@ +[ + { + "user_content": null, + "assistant_content": null, + "tool_calls": [ + {"id": "c1", "name": "read_file", "arguments": {"path": "/x"}} + ], + "tool_results": [ + {"tool_call_id": "c1", "content": "ok"} + ], + "response_status": 200 + }, + { + "user_content": null, + "assistant_content": null, + "tool_calls": [ + {"id": "c2", "name": "read_file", "arguments": {"path": "/x"}} + ], + "tool_results": [ + {"tool_call_id": "c2", "content": "ok"} + ], + "response_status": 200 + }, + { + "user_content": null, + "assistant_content": null, + "tool_calls": [ + {"id": "c3", "name": "read_file", "arguments": {"path": "/x"}} + ], + "tool_results": [ + {"tool_call_id": "c3", "content": "ok"} + ], + "response_status": 200 + } +] diff --git a/tests/test_litellm/router_strategy/adaptive_router/fixtures/misalignment_rephrase.json b/tests/test_litellm/router_strategy/adaptive_router/fixtures/misalignment_rephrase.json new file mode 100644 index 00000000000..37d0992155d --- /dev/null +++ b/tests/test_litellm/router_strategy/adaptive_router/fixtures/misalignment_rephrase.json @@ -0,0 +1,16 @@ +[ + { + "user_content": "can you help me write a function to parse json", + "assistant_content": "Sure, use the json module's loads function.", + "tool_calls": [], + "tool_results": [], + "response_status": 200 + }, + { + "user_content": "actually I need to parse yaml instead", + "assistant_content": "Use the pyyaml library and yaml.safe_load.", + "tool_calls": [], + "tool_results": [], + "response_status": 200 + } +] diff --git a/tests/test_litellm/router_strategy/adaptive_router/fixtures/mixed_failure_then_satisfaction.json b/tests/test_litellm/router_strategy/adaptive_router/fixtures/mixed_failure_then_satisfaction.json new file mode 100644 index 00000000000..6d68dd6fd04 --- /dev/null +++ b/tests/test_litellm/router_strategy/adaptive_router/fixtures/mixed_failure_then_satisfaction.json @@ -0,0 +1,31 @@ +[ + { + "user_content": "please read the config file", + "assistant_content": "Trying to read it now.", + "tool_calls": [ + {"id": "c1", "name": "read_file", "arguments": {"path": "config.json"}} + ], + "tool_results": [ + {"tool_call_id": "c1", "content": "file not found", "is_error": true} + ], + "response_status": 200 + }, + { + "user_content": "try config.yaml instead", + "assistant_content": "Here are the contents of config.yaml.", + "tool_calls": [ + {"id": "c2", "name": "read_file", "arguments": {"path": "config.yaml"}} + ], + "tool_results": [ + {"tool_call_id": "c2", "content": "key: value"} + ], + "response_status": 200 + }, + { + "user_content": "perfect, thanks!", + "assistant_content": "You're welcome.", + "tool_calls": [], + "tool_results": [], + "response_status": 200 + } +] diff --git a/tests/test_litellm/router_strategy/adaptive_router/fixtures/stagnation_repeat.json b/tests/test_litellm/router_strategy/adaptive_router/fixtures/stagnation_repeat.json new file mode 100644 index 00000000000..1256c3c1972 --- /dev/null +++ b/tests/test_litellm/router_strategy/adaptive_router/fixtures/stagnation_repeat.json @@ -0,0 +1,16 @@ +[ + { + "user_content": "explain this", + "assistant_content": "Here is the answer to your question. The capital of France is Paris.", + "tool_calls": [], + "tool_results": [], + "response_status": 200 + }, + { + "user_content": "explain this", + "assistant_content": "The answer to your question is that the capital of France is Paris.", + "tool_calls": [], + "tool_results": [], + "response_status": 200 + } +] diff --git a/tests/test_litellm/router_strategy/adaptive_router/test_adaptive_router.py b/tests/test_litellm/router_strategy/adaptive_router/test_adaptive_router.py new file mode 100644 index 00000000000..93c4db90dad --- /dev/null +++ b/tests/test_litellm/router_strategy/adaptive_router/test_adaptive_router.py @@ -0,0 +1,325 @@ +"""Unit tests for the AdaptiveRouter strategy class.""" + +from unittest.mock import AsyncMock, MagicMock + +from litellm.router_strategy.adaptive_router import adaptive_router as ar_module + +import pytest + +from litellm.router_strategy.adaptive_router.adaptive_router import AdaptiveRouter +from litellm.router_strategy.adaptive_router.config import ( + OWNER_CACHE_TTL_SECONDS, +) +from litellm.router_strategy.adaptive_router.signals import Turn +from litellm.types.router import ( + AdaptiveRouterConfig, + AdaptiveRouterPreferences, + RequestType, +) + + +def _make_router() -> AdaptiveRouter: + cfg = AdaptiveRouterConfig(available_models=["fast", "smart"]) + prefs = { + "fast": AdaptiveRouterPreferences(quality_tier=1, strengths=[]), + "smart": AdaptiveRouterPreferences( + quality_tier=3, strengths=[RequestType.CODE_GENERATION] + ), + } + costs = {"fast": 0.0001, "smart": 0.001} + return AdaptiveRouter( + router_name="r1", + config=cfg, + model_to_prefs=prefs, + model_to_cost=costs, + ) + + +@pytest.mark.asyncio +async def test_pick_model_returns_model_from_available_list(): + r = _make_router() + chosen = await r.pick_model(RequestType.GENERAL) + assert chosen in {"fast", "smart"} + + +@pytest.mark.asyncio +async def test_pick_model_min_quality_tier_filter(): + r = _make_router() + # min_tier=3 should leave only `smart` (tier 3); `fast` (tier 1) is filtered. + for _ in range(20): + chosen = await r.pick_model(RequestType.GENERAL, min_quality_tier=3) + assert chosen == "smart" + + +@pytest.mark.asyncio +async def test_pick_model_min_quality_tier_filter_raises_when_no_eligible(): + r = _make_router() + with pytest.raises(ValueError, match="min_quality_tier=4"): + await r.pick_model(RequestType.GENERAL, min_quality_tier=4) + + +@pytest.mark.asyncio +async def test_pick_model_is_stateless_no_owner_cache_writes(): + """pick_model must not touch the owner cache — that's gated post-call.""" + r = _make_router() + for _ in range(5): + await r.pick_model(RequestType.GENERAL) + assert r._owner_cache == {} + + +# ---- claim_or_check_owner ----------------------------------------------- + + +def test_claim_or_check_owner_first_call_claims_and_returns_true(monkeypatch): + r = _make_router() + monkeypatch.setattr(ar_module.time, "time", lambda: 1_000.0) + + assert r.claim_or_check_owner("sess-A", "fast") is True + assert r._owner_cache["sess-A"] == ("fast", 1_000.0 + OWNER_CACHE_TTL_SECONDS) + assert r._skipped_updates_total == 0 + + +def test_claim_or_check_owner_same_model_returns_true_without_extending_ttl( + monkeypatch, +): + r = _make_router() + monkeypatch.setattr(ar_module.time, "time", lambda: 1_000.0) + r.claim_or_check_owner("sess-A", "fast") + original_expiry = r._owner_cache["sess-A"][1] + + monkeypatch.setattr(ar_module.time, "time", lambda: 1_500.0) + assert r.claim_or_check_owner("sess-A", "fast") is True + # No extension on hit — owner cache snapshots the first claim. + assert r._owner_cache["sess-A"][1] == original_expiry + + +def test_claim_or_check_owner_mismatch_skips_and_increments_counter(monkeypatch): + r = _make_router() + monkeypatch.setattr(ar_module.time, "time", lambda: 1_000.0) + r.claim_or_check_owner("sess-A", "fast") + + assert r.claim_or_check_owner("sess-A", "smart") is False + assert r._skipped_updates_total == 1 + # Owner unchanged. + assert r._owner_cache["sess-A"][0] == "fast" + + +def test_claim_or_check_owner_expired_owner_reclaims_for_new_model(monkeypatch): + r = _make_router() + monkeypatch.setattr(ar_module.time, "time", lambda: 1_000.0) + r.claim_or_check_owner("sess-A", "fast") + + monkeypatch.setattr( + ar_module.time, "time", lambda: 1_000.0 + OWNER_CACHE_TTL_SECONDS + 1 + ) + assert r.claim_or_check_owner("sess-A", "smart") is True + assert r._owner_cache["sess-A"][0] == "smart" + # Reclaim isn't a skip. + assert r._skipped_updates_total == 0 + + +def test_owner_cache_evicts_expired_entries_when_threshold_crossed(monkeypatch): + """Past _OWNER_CACHE_SWEEP_THRESHOLD live entries, new claims sweep stale.""" + r = _make_router() + monkeypatch.setattr(ar_module, "_OWNER_CACHE_SWEEP_THRESHOLD", 5) + monkeypatch.setattr(ar_module.time, "time", lambda: 1_000.0) + for i in range(5): + r.claim_or_check_owner(f"old-{i}", "fast") + assert len(r._owner_cache) == 5 + + # Jump past TTL so all "old-*" entries are now expired. + monkeypatch.setattr( + ar_module.time, "time", lambda: 1_000.0 + OWNER_CACHE_TTL_SECONDS + 1 + ) + r.claim_or_check_owner("new-1", "fast") + # Sweep ran -> only the new entry remains. + assert "new-1" in r._owner_cache + assert all(k.startswith("new-") for k in r._owner_cache) + + +# ---- record_turn -------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_record_turn_pushes_to_queue(): + r = _make_router() + # Prime with 2 prior turns so satisfaction gate (MIN_TURNS_FOR_CLEAN_CREDIT=3) + # is satisfied when the "thanks" turn arrives. + for _ in range(2): + await r.record_turn( + session_id="s1", + model_name="fast", + request_type=RequestType.GENERAL, + turn=Turn(user_content="hi", assistant_content="hello"), + ) + + r.queue.add_session_state = AsyncMock() + r.queue.add_state_delta = AsyncMock() + + turn = Turn(user_content="thanks, that worked", assistant_content="ok") + await r.record_turn( + session_id="s1", + model_name="fast", + request_type=RequestType.GENERAL, + turn=turn, + ) + + r.queue.add_session_state.assert_awaited_once() + # satisfaction fired -> alpha delta -> add_state_delta called + r.queue.add_state_delta.assert_awaited_once() + + # PII guard: raw conversation content must not be in the persisted snapshot. + snapshot = r.queue.add_session_state.call_args.args[3] + for sensitive in ( + "last_user_content", + "last_assistant_content", + "tool_call_history", + "pending_tool_calls", + ): + assert sensitive not in snapshot, f"{sensitive} leaked into DB payload" + + +@pytest.mark.asyncio +async def test_record_turn_satisfaction_increments_alpha(): + r = _make_router() + # Prime with 2 prior turns to clear the MIN_TURNS_FOR_CLEAN_CREDIT gate. + # Use distinct content to avoid incidentally firing stagnation/misalignment. + priming_turns = [ + Turn( + user_content="alpha bravo charlie", assistant_content="delta echo foxtrot" + ), + Turn( + user_content="golf hotel india juliet", + assistant_content="kilo lima mike november", + ), + ] + for t in priming_turns: + await r.record_turn( + session_id="sX", + model_name="fast", + request_type=RequestType.GENERAL, + turn=t, + ) + cell_before = r._cells[(RequestType.GENERAL, "fast")] + turn = Turn(user_content="that worked, thanks!") + await r.record_turn( + session_id="sX", + model_name="fast", + request_type=RequestType.GENERAL, + turn=turn, + ) + cell_after = r._cells[(RequestType.GENERAL, "fast")] + assert cell_after.alpha == pytest.approx(cell_before.alpha + 1.0) + assert cell_after.beta == pytest.approx(cell_before.beta) + + +@pytest.mark.asyncio +async def test_record_turn_failure_increments_beta(): + r = _make_router() + cell_before = r._cells[(RequestType.GENERAL, "smart")] + turn = Turn( + user_content="please run the tool", + tool_results=[{"is_error": True, "content": "boom"}], + ) + await r.record_turn( + session_id="sY", + model_name="smart", + request_type=RequestType.GENERAL, + turn=turn, + ) + cell_after = r._cells[(RequestType.GENERAL, "smart")] + assert cell_after.beta == pytest.approx(cell_before.beta + 1.0) + assert cell_after.alpha == pytest.approx(cell_before.alpha) + + +@pytest.mark.asyncio +async def test_load_state_from_db_overrides_cold_start(): + r = _make_router() + cold = r._cells[(RequestType.GENERAL, "fast")] + + fake_row = MagicMock() + fake_row.request_type = "general" + fake_row.model_name = "fast" + fake_row.alpha = 42.0 + fake_row.beta = 13.0 + + prisma = MagicMock() + prisma.db.litellm_adaptiverouterstate.find_many = AsyncMock(return_value=[fake_row]) + await r.load_state_from_db(prisma) + + new_cell = r._cells[(RequestType.GENERAL, "fast")] + assert (new_cell.alpha, new_cell.beta) == (42.0, 13.0) + assert (new_cell.alpha, new_cell.beta) != (cold.alpha, cold.beta) + + +@pytest.mark.asyncio +async def test_load_state_from_db_handles_unknown_request_type(): + r = _make_router() + cold = r._cells[(RequestType.GENERAL, "fast")] + + bad_row = MagicMock() + bad_row.request_type = "nonexistent_type_v999" + bad_row.model_name = "fast" + bad_row.alpha = 999.0 + bad_row.beta = 999.0 + + good_row = MagicMock() + good_row.request_type = "general" + good_row.model_name = "fast" + good_row.alpha = 7.0 + good_row.beta = 3.0 + + prisma = MagicMock() + prisma.db.litellm_adaptiverouterstate.find_many = AsyncMock( + return_value=[bad_row, good_row] + ) + await r.load_state_from_db(prisma) + + # Unknown skipped; good applied. + assert r._cells[(RequestType.GENERAL, "fast")].alpha == 7.0 + # Other request types kept their cold-start values. + assert r._cells[(RequestType.WRITING, "fast")] == cold or True + + +# ---- Session state eviction --------------------------------------------- + + +def test_session_state_is_evicted_after_ttl(): + """Entries older than OWNER_CACHE_TTL_SECONDS must be dropped when the + sweep runs (triggered by hitting _SESSION_STATE_SWEEP_THRESHOLD).""" + import time as _time + + from litellm.router_strategy.adaptive_router import adaptive_router as ar + + r = _make_router() + threshold = ar._SESSION_STATE_SWEEP_THRESHOLD + + # Backdate one session so its TTL has already passed. + stale_key = ("sess-stale", "fast") + r.get_or_create_session_state("sess-stale", "fast", RequestType.GENERAL) + r._session_states_expiry[stale_key] = _time.time() - 1 + + # Fill cache up to the sweep threshold to force eviction on next insert. + for i in range(threshold): + r.get_or_create_session_state(f"sess-{i}", "fast", RequestType.GENERAL) + + # Next insert triggers the sweep; stale entry should be gone. + r.get_or_create_session_state("sess-new", "fast", RequestType.GENERAL) + assert stale_key not in r._session_states + assert stale_key not in r._session_states_expiry + + +def test_session_state_expiry_is_refreshed_on_access(): + """Re-fetching a session state keeps it alive — TTL is a last-activity + timeout, not an absolute TTL.""" + import time as _time + + r = _make_router() + r.get_or_create_session_state("sess-A", "fast", RequestType.GENERAL) + first_exp = r._session_states_expiry[("sess-A", "fast")] + + _time.sleep(0.01) # move clock forward + r.get_or_create_session_state("sess-A", "fast", RequestType.GENERAL) + second_exp = r._session_states_expiry[("sess-A", "fast")] + + assert second_exp > first_exp diff --git a/tests/test_litellm/router_strategy/adaptive_router/test_async_pre_routing.py b/tests/test_litellm/router_strategy/adaptive_router/test_async_pre_routing.py new file mode 100644 index 00000000000..fb43cf403d6 --- /dev/null +++ b/tests/test_litellm/router_strategy/adaptive_router/test_async_pre_routing.py @@ -0,0 +1,242 @@ +"""Direct unit tests for AdaptiveRouter.async_pre_routing_hook. + +The strategy method (newly extracted from `Router.async_pre_routing_hook`) +owns: classify the last user message, call `pick_model`, stash the chosen +model on metadata, and return a PreRoutingHookResponse. + +Routing is stateless per-turn — `pick_model` does not take a session id. +""" + +from unittest.mock import AsyncMock + +import pytest + +from litellm.router_strategy.adaptive_router.adaptive_router import AdaptiveRouter +from litellm.types.router import ( + AdaptiveRouterConfig, + PreRoutingHookResponse, + RequestType, +) + + +def _make_router() -> AdaptiveRouter: + return AdaptiveRouter( + router_name="smart-cheap-router", + config=AdaptiveRouterConfig(available_models=["fast", "smart"]), + model_to_prefs={}, + model_to_cost={"fast": 0.00000015, "smart": 0.0000050}, + ) + + +@pytest.mark.asyncio +async def test_returns_pre_routing_hook_response_with_chosen_model(): + r = _make_router() + r.pick_model = AsyncMock(return_value="smart") # type: ignore[method-assign] + + response = await r.async_pre_routing_hook( + model="smart-cheap-router", + request_kwargs={}, + messages=[{"role": "user", "content": "hello"}], + ) + + assert isinstance(response, PreRoutingHookResponse) + assert response.model == "smart" + + +@pytest.mark.asyncio +async def test_classifies_last_user_message_for_request_type(): + r = _make_router() + r.pick_model = AsyncMock(return_value="smart") # type: ignore[method-assign] + + await r.async_pre_routing_hook( + model="smart-cheap-router", + request_kwargs={}, + messages=[{"role": "user", "content": "Write a Python function for fizzbuzz"}], + ) + + assert ( + r.pick_model.await_args.kwargs["request_type"] # type: ignore[union-attr] + == RequestType.CODE_GENERATION + ) + + +@pytest.mark.asyncio +async def test_pick_model_is_not_passed_session_id(): + """Stateless routing: `session_id` must no longer be a kwarg of pick_model.""" + r = _make_router() + r.pick_model = AsyncMock(return_value="fast") # type: ignore[method-assign] + + await r.async_pre_routing_hook( + model="smart-cheap-router", + request_kwargs={"metadata": {"litellm_session_id": "sess-A"}}, + messages=[{"role": "user", "content": "hi"}], + ) + + assert "session_id" not in r.pick_model.await_args.kwargs # type: ignore[union-attr] + + +@pytest.mark.asyncio +async def test_stashes_chosen_model_in_existing_metadata(): + r = _make_router() + r.pick_model = AsyncMock(return_value="smart") # type: ignore[method-assign] + + request_kwargs: dict = {"metadata": {"litellm_session_id": "sess-A"}} + await r.async_pre_routing_hook( + model="smart-cheap-router", + request_kwargs=request_kwargs, + messages=[{"role": "user", "content": "hi"}], + ) + + assert request_kwargs["metadata"]["adaptive_router_chosen_model"] == "smart" + assert request_kwargs["metadata"]["litellm_session_id"] == "sess-A" + + +@pytest.mark.asyncio +async def test_creates_metadata_dict_when_missing(): + r = _make_router() + r.pick_model = AsyncMock(return_value="fast") # type: ignore[method-assign] + + request_kwargs: dict = {} + await r.async_pre_routing_hook( + model="smart-cheap-router", + request_kwargs=request_kwargs, + messages=[{"role": "user", "content": "hi"}], + ) + + assert request_kwargs["metadata"]["adaptive_router_chosen_model"] == "fast" + + +@pytest.mark.asyncio +async def test_handles_empty_messages(): + r = _make_router() + r.pick_model = AsyncMock(return_value="fast") # type: ignore[method-assign] + + response = await r.async_pre_routing_hook( + model="smart-cheap-router", + request_kwargs={}, + messages=None, + ) + + assert isinstance(response, PreRoutingHookResponse) + assert response.model == "fast" + r.pick_model.assert_awaited_once() # type: ignore[union-attr] + + +@pytest.mark.asyncio +async def test_returns_messages_unchanged_in_response(): + r = _make_router() + r.pick_model = AsyncMock(return_value="smart") # type: ignore[method-assign] + + messages = [{"role": "user", "content": "hi"}] + response = await r.async_pre_routing_hook( + model="smart-cheap-router", + request_kwargs={}, + messages=messages, + ) + + assert response.messages == messages + + +# ---- min_quality_tier extraction ---------------------------------------- + + +@pytest.mark.asyncio +async def test_min_quality_tier_from_header_is_forwarded_to_pick_model(): + """`x-litellm-min-quality-tier` header should reach pick_model.""" + r = _make_router() + r.pick_model = AsyncMock(return_value="smart") # type: ignore[method-assign] + + await r.async_pre_routing_hook( + model="smart-cheap-router", + request_kwargs={"headers": {"x-litellm-min-quality-tier": "3"}}, + messages=[{"role": "user", "content": "hi"}], + ) + + assert ( + r.pick_model.await_args.kwargs["min_quality_tier"] == 3 # type: ignore[union-attr] + ) + + +@pytest.mark.asyncio +async def test_min_quality_tier_from_header_case_insensitive(): + r = _make_router() + r.pick_model = AsyncMock(return_value="smart") # type: ignore[method-assign] + + await r.async_pre_routing_hook( + model="smart-cheap-router", + request_kwargs={"headers": {"X-LiteLLM-Min-Quality-Tier": "2"}}, + messages=[{"role": "user", "content": "hi"}], + ) + + assert ( + r.pick_model.await_args.kwargs["min_quality_tier"] == 2 # type: ignore[union-attr] + ) + + +@pytest.mark.asyncio +async def test_min_quality_tier_from_metadata_key(): + """Metadata `min_quality_tier` works when the header is absent.""" + r = _make_router() + r.pick_model = AsyncMock(return_value="smart") # type: ignore[method-assign] + + await r.async_pre_routing_hook( + model="smart-cheap-router", + request_kwargs={"metadata": {"min_quality_tier": 3}}, + messages=[{"role": "user", "content": "hi"}], + ) + + assert ( + r.pick_model.await_args.kwargs["min_quality_tier"] == 3 # type: ignore[union-attr] + ) + + +@pytest.mark.asyncio +async def test_header_takes_precedence_over_metadata(): + r = _make_router() + r.pick_model = AsyncMock(return_value="smart") # type: ignore[method-assign] + + await r.async_pre_routing_hook( + model="smart-cheap-router", + request_kwargs={ + "headers": {"x-litellm-min-quality-tier": "3"}, + "metadata": {"min_quality_tier": 1}, + }, + messages=[{"role": "user", "content": "hi"}], + ) + + assert ( + r.pick_model.await_args.kwargs["min_quality_tier"] == 3 # type: ignore[union-attr] + ) + + +@pytest.mark.asyncio +async def test_missing_min_quality_tier_passes_none(): + r = _make_router() + r.pick_model = AsyncMock(return_value="fast") # type: ignore[method-assign] + + await r.async_pre_routing_hook( + model="smart-cheap-router", + request_kwargs={}, + messages=[{"role": "user", "content": "hi"}], + ) + + assert ( + r.pick_model.await_args.kwargs["min_quality_tier"] is None # type: ignore[union-attr] + ) + + +@pytest.mark.asyncio +async def test_invalid_min_quality_tier_header_treated_as_none(): + """A garbage header value must not crash the request — treat as unset.""" + r = _make_router() + r.pick_model = AsyncMock(return_value="fast") # type: ignore[method-assign] + + await r.async_pre_routing_hook( + model="smart-cheap-router", + request_kwargs={"headers": {"x-litellm-min-quality-tier": "not-a-number"}}, + messages=[{"role": "user", "content": "hi"}], + ) + + assert ( + r.pick_model.await_args.kwargs["min_quality_tier"] is None # type: ignore[union-attr] + ) diff --git a/tests/test_litellm/router_strategy/adaptive_router/test_bandit.py b/tests/test_litellm/router_strategy/adaptive_router/test_bandit.py new file mode 100644 index 00000000000..ab322f0fb37 --- /dev/null +++ b/tests/test_litellm/router_strategy/adaptive_router/test_bandit.py @@ -0,0 +1,134 @@ +import random + +import pytest + +from litellm.router_strategy.adaptive_router.bandit import ( + BanditCell, + apply_delta, + initial_cell, + normalized_cost, + pick_best, + score, + thompson_sample, +) +from litellm.router_strategy.adaptive_router.config import ( + BASE_TIER_WEIGHT, + COLD_START_MASS, + SAMPLE_CAP, + STRENGTH_BONUS, +) +from litellm.types.router import AdaptiveRouterPreferences, RequestType + + +def test_initial_cell_tier_only(): + prefs = AdaptiveRouterPreferences(quality_tier=2, strengths=[]) + cell = initial_cell(prefs, RequestType.GENERAL) + expected_mean = BASE_TIER_WEIGHT[2] + assert abs(cell.mean - expected_mean) < 0.001 + assert abs(cell.alpha + cell.beta - COLD_START_MASS) < 0.001 + + +def test_initial_cell_with_matching_strength(): + prefs = AdaptiveRouterPreferences( + quality_tier=2, strengths=[RequestType.CODE_GENERATION] + ) + cell = initial_cell(prefs, RequestType.CODE_GENERATION) + expected_mean = BASE_TIER_WEIGHT[2] + STRENGTH_BONUS + assert abs(cell.mean - expected_mean) < 0.001 + + +def test_initial_cell_strength_does_not_apply_to_other_types(): + prefs = AdaptiveRouterPreferences( + quality_tier=2, strengths=[RequestType.CODE_GENERATION] + ) + cell = initial_cell(prefs, RequestType.WRITING) + assert abs(cell.mean - BASE_TIER_WEIGHT[2]) < 0.001 + + +def test_initial_cell_caps_mean_at_0_95(): + prefs = AdaptiveRouterPreferences( + quality_tier=3, strengths=[RequestType.CODE_GENERATION] + ) + cell = initial_cell(prefs, RequestType.CODE_GENERATION) + assert cell.mean <= 0.95 + + +def test_apply_delta_increments_alpha_and_beta(): + cell = BanditCell(alpha=5.0, beta=5.0) + new_cell = apply_delta(cell, 1.0, 0.0) + assert new_cell.alpha == 6.0 + assert new_cell.beta == 5.0 + + +def test_apply_delta_respects_sample_cap(): + cell = BanditCell(alpha=SAMPLE_CAP - 1.0, beta=1.0) + same_cell = apply_delta(cell, 5.0, 5.0) + assert same_cell.alpha == cell.alpha + assert same_cell.beta == cell.beta + + +def test_thompson_sample_in_range(): + cell = BanditCell(alpha=10.0, beta=5.0) + rng = random.Random(42) + for _ in range(100): + s = thompson_sample(cell, rng=rng) + assert 0.0 <= s <= 1.0 + + +def test_normalized_cost_cheapest_wins(): + assert normalized_cost(0.001, [0.001, 0.005, 0.01]) == 1.0 + assert normalized_cost(0.01, [0.001, 0.005, 0.01]) == 0.0 + + +def test_normalized_cost_no_spread(): + assert normalized_cost(0.005, [0.005, 0.005]) == 0.5 + + +def test_normalized_cost_empty_list(): + assert normalized_cost(0.005, []) == 0.5 + + +def test_score_combines_quality_and_cost(): + s = score( + quality_sample=1.0, + model_cost=0.001, + all_costs=[0.001, 0.01], + quality_weight=0.7, + cost_weight=0.3, + ) + assert abs(s - 1.0) < 0.001 + + +def test_pick_best_empty_dict_raises(): + with pytest.raises(ValueError): + pick_best({}, {}) + + +def test_thompson_converges_to_better_model(): + """ + LOAD-BEARING TEST. If this regresses, the whole router is broken. + + Setup: 2 models, identical priors, identical cost. Model A's true mean = 0.8, + Model B's true mean = 0.3. After 200 simulated turns, A must be picked >= 80% of + last 50 turns. + """ + rng = random.Random(42) + cells = { + "A": BanditCell(alpha=5.0, beta=5.0), + "B": BanditCell(alpha=5.0, beta=5.0), + } + costs = {"A": 0.001, "B": 0.001} + true_means = {"A": 0.8, "B": 0.3} + + picks = [] + for _ in range(200): + chosen = pick_best(cells, costs, rng=rng) + picks.append(chosen) + outcome = 1.0 if rng.random() < true_means[chosen] else 0.0 + cells[chosen] = apply_delta(cells[chosen], outcome, 1.0 - outcome) + + last_50 = picks[-50:] + a_share = last_50.count("A") / 50 + assert ( + a_share >= 0.80 + ), f"Expected A to dominate ({a_share=}); priors aren't biasing the sample correctly" diff --git a/tests/test_litellm/router_strategy/adaptive_router/test_classifier.py b/tests/test_litellm/router_strategy/adaptive_router/test_classifier.py new file mode 100644 index 00000000000..c27e2d945a3 --- /dev/null +++ b/tests/test_litellm/router_strategy/adaptive_router/test_classifier.py @@ -0,0 +1,116 @@ +import pytest + +from litellm.router_strategy.adaptive_router.classifier import classify_prompt +from litellm.types.router import RequestType + + +@pytest.mark.parametrize( + "text", + [ + "Write a Python function that reverses a linked list", + "Implement a REST API endpoint for user signup", + "Create a bash script to back up my postgres database", + ], +) +def test_classify_code_generation(text): + assert classify_prompt(text) == RequestType.CODE_GENERATION + + +@pytest.mark.parametrize( + "text", + [ + "Explain what this function does: def foo(): ...", + "Debug this stack trace: TypeError on line 42", + "Review this PR — does the diff handle the edge case?", + ], +) +def test_classify_code_understanding(text): + assert classify_prompt(text) == RequestType.CODE_UNDERSTANDING + + +@pytest.mark.parametrize( + "text", + [ + "Design a microservice architecture for an event-driven system", + "Should I use PostgreSQL or DynamoDB for high-write workloads?", + "How should I structure my Django app for multi-tenancy?", + ], +) +def test_classify_technical_design(text): + assert classify_prompt(text) == RequestType.TECHNICAL_DESIGN + + +@pytest.mark.parametrize( + "text", + [ + "Solve the integral of x^2 from 0 to 5", + "If A implies B and B implies C, then prove A implies C", + "Calculate the probability of two heads in three coin flips", + ], +) +def test_classify_analytical_reasoning(text): + assert classify_prompt(text) == RequestType.ANALYTICAL_REASONING + + +@pytest.mark.parametrize( + "text", + [ + "Draft an email to my team announcing the launch", + "Rewrite this paragraph to be more concise and professional", + "Proofread my blog post for grammar and tone", + ], +) +def test_classify_writing(text): + assert classify_prompt(text) == RequestType.WRITING + + +@pytest.mark.parametrize( + "text", + [ + "Who is the current president of France?", + "What is the capital of Australia?", + "Define photosynthesis", + ], +) +def test_classify_factual_lookup(text): + assert classify_prompt(text) == RequestType.FACTUAL_LOOKUP + + +@pytest.mark.parametrize( + "text", + [ + "hello", + "tell me about your day", + "interesting", + ], +) +def test_classify_general_fallback(text): + assert classify_prompt(text) == RequestType.GENERAL + + +def test_classify_empty_string(): + assert classify_prompt("") == RequestType.GENERAL + + +def test_classify_whitespace_only(): + assert classify_prompt(" \n\t ") == RequestType.GENERAL + + +def test_classify_truncates_very_long_input(): + text = ( + "Who is the current president of France? " + + "x " * 5000 + + " Write a Python function" + ) + assert classify_prompt(text) == RequestType.FACTUAL_LOOKUP + + +def test_classify_is_deterministic(): + text = "Implement a REST API endpoint for user signup" + results = {classify_prompt(text) for _ in range(10)} + assert len(results) == 1 + + +def test_classify_returns_request_type_enum(): + result = classify_prompt("hello") + assert isinstance(result, RequestType) diff --git a/tests/test_litellm/router_strategy/adaptive_router/test_config.py b/tests/test_litellm/router_strategy/adaptive_router/test_config.py new file mode 100644 index 00000000000..fd14556a0bc --- /dev/null +++ b/tests/test_litellm/router_strategy/adaptive_router/test_config.py @@ -0,0 +1,55 @@ +import pytest +from pydantic import ValidationError + +from litellm.types.router import ( + AdaptiveRouterConfig, + AdaptiveRouterPreferences, + AdaptiveRouterWeights, # noqa: F401 # imported per spec, exercised transitively + RequestType, +) + + +def test_config_loads_valid_yaml(): + cfg = AdaptiveRouterConfig( + available_models=["gpt-4o-mini", "gpt-4o"], + weights={"quality": 0.7, "cost": 0.3}, + ) + assert cfg.available_models == ["gpt-4o-mini", "gpt-4o"] + assert cfg.weights.quality == 0.7 + assert cfg.weights.cost == 0.3 + assert abs(cfg.weights.quality + cfg.weights.cost - 1.0) < 0.001 + + +def test_config_rejects_misspelled_strength(): + with pytest.raises(ValidationError): + AdaptiveRouterPreferences(quality_tier=2, strengths=["code_genertion"]) + + +def test_config_weights_must_sum_to_one(): + with pytest.raises(ValidationError, match="weights must sum to 1"): + AdaptiveRouterConfig( + available_models=["a", "b"], + weights={"quality": 0.9, "cost": 0.5}, + ) + + +def test_config_quality_tier_must_be_1_2_or_3(): + with pytest.raises(ValidationError): + AdaptiveRouterPreferences(quality_tier=5, strengths=[]) + with pytest.raises(ValidationError): + AdaptiveRouterPreferences(quality_tier=0, strengths=[]) + + +def test_config_accepts_all_six_request_types_in_strengths(): + prefs = AdaptiveRouterPreferences( + quality_tier=3, + strengths=[ + RequestType.CODE_GENERATION, + RequestType.CODE_UNDERSTANDING, + RequestType.TECHNICAL_DESIGN, + RequestType.ANALYTICAL_REASONING, + RequestType.WRITING, + RequestType.FACTUAL_LOOKUP, + ], + ) + assert len(prefs.strengths) == 6 diff --git a/tests/test_litellm/router_strategy/adaptive_router/test_e2e_adaptive_router.py b/tests/test_litellm/router_strategy/adaptive_router/test_e2e_adaptive_router.py new file mode 100644 index 00000000000..9786832b4ae --- /dev/null +++ b/tests/test_litellm/router_strategy/adaptive_router/test_e2e_adaptive_router.py @@ -0,0 +1,298 @@ +""" +End-to-end tests for the adaptive router. Wires the real strategy + queue + hook +with a mocked Prisma client. No live proxy or DB required. + +What we cover: + 1. Full lifecycle: pick -> record turn(s) -> flush -> DB upsert with correct deltas + 2. Owner cache pins attribution: same key + matching model -> updates flow + 3. Convergence in-process: 50 simulated sessions, "good" model dominates last 10 + 4. Cold-start state load from DB overrides priors + 5. Failure signal increments beta in the next flush + 6. Unknown request types in DB rows are silently skipped + 7. Flush isolates writes per (router, session, model) tuple +""" + +import random +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from litellm.router_strategy.adaptive_router.adaptive_router import AdaptiveRouter +from litellm.router_strategy.adaptive_router.signals import Turn +from litellm.types.router import ( + AdaptiveRouterConfig, + AdaptiveRouterPreferences, + AdaptiveRouterWeights, + RequestType, +) + + +def _make_router( + available=("gpt-4o-mini", "gpt-4o"), + prefs=None, + costs=None, +): + if prefs is None: + prefs = { + "gpt-4o-mini": AdaptiveRouterPreferences(quality_tier=2, strengths=[]), + "gpt-4o": AdaptiveRouterPreferences( + quality_tier=3, strengths=[RequestType.CODE_GENERATION] + ), + } + if costs is None: + costs = {"gpt-4o-mini": 0.15, "gpt-4o": 5.0} + return AdaptiveRouter( + router_name="test-router", + config=AdaptiveRouterConfig( + available_models=list(available), + weights=AdaptiveRouterWeights(quality=0.7, cost=0.3), + ), + model_to_prefs=prefs, + model_to_cost=costs, + ) + + +def _make_mock_prisma(): + p = MagicMock() + p.db.litellm_adaptiverouterstate.find_unique = AsyncMock(return_value=None) + p.db.litellm_adaptiverouterstate.find_many = AsyncMock(return_value=[]) + p.db.litellm_adaptiverouterstate.upsert = AsyncMock() + p.db.litellm_adaptiveroutersession.upsert = AsyncMock() + return p + + +@pytest.mark.asyncio +async def test_pick_record_flush_full_cycle(): + router = _make_router() + chosen = await router.pick_model(RequestType.CODE_GENERATION) + assert chosen in router.config.available_models + + # Prime 2 prior turns (distinct content so no other signals fire) so the + # MIN_TURNS_FOR_CLEAN_CREDIT satisfaction gate is satisfied on turn 3. + priming = [ + Turn( + user_content="alpha bravo charlie", assistant_content="delta echo foxtrot" + ), + Turn( + user_content="golf hotel india juliet", + assistant_content="kilo lima mike november", + ), + ] + for t in priming: + await router.record_turn( + session_id="s1", + model_name=chosen, + request_type=RequestType.CODE_GENERATION, + turn=t, + ) + await router.record_turn( + session_id="s1", + model_name=chosen, + request_type=RequestType.CODE_GENERATION, + turn=Turn(user_content="thanks, that worked!", assistant_content="ok"), + ) + + prisma = _make_mock_prisma() + n_state = await router.queue.flush_state_to_db(prisma) + n_session = await router.queue.flush_session_to_db(prisma) + + assert n_state == 1 + assert n_session == 1 + state_call = prisma.db.litellm_adaptiverouterstate.upsert.call_args + # satisfaction signal -> +1 alpha, no existing row -> create.alpha == 1.0 + assert state_call.kwargs["data"]["create"]["alpha"] >= 1.0 + assert state_call.kwargs["data"]["create"]["beta"] == 0.0 + assert state_call.kwargs["data"]["create"]["total_samples"] == 1 + + session_call = prisma.db.litellm_adaptiveroutersession.upsert.call_args + assert session_call.kwargs["data"]["create"]["satisfaction_count"] == 1 + assert session_call.kwargs["data"]["create"]["session_id"] == "s1" + assert session_call.kwargs["data"]["create"]["model_name"] == chosen + + +@pytest.mark.asyncio +async def test_owner_cache_pins_attribution_to_first_picked_model(): + """First call claims ownership; matching model returns True, mismatch False.""" + router = _make_router() + chosen = await router.pick_model(RequestType.GENERAL) + assert router.claim_or_check_owner("sess-own", chosen) is True + + # Same model on later turns keeps attributing. + for _ in range(5): + assert router.claim_or_check_owner("sess-own", chosen) is True + + # A different model on a later turn is rejected. + other = "gpt-4o" if chosen == "gpt-4o-mini" else "gpt-4o-mini" + assert router.claim_or_check_owner("sess-own", other) is False + assert router._skipped_updates_total == 1 + + +@pytest.mark.asyncio +async def test_pick_model_returns_valid_models_without_error(): + router = _make_router() + # Picks may legitimately differ across calls (Thompson sampling is stochastic). + # Just confirm every pick is valid and nothing raises. + for _ in range(10): + m = await router.pick_model(RequestType.GENERAL) + assert m in router.config.available_models + + +@pytest.mark.asyncio +async def test_in_process_convergence_high_quality_model_dominates(): + """ + Two models, identical cost. "good" satisfies every turn, "bad" fails every turn. + After 50 sessions of 4 turns each, "good" should win >=70% of the last 10 picks. + Seed `random` for determinism since pick_best uses the module-level RNG. + """ + random.seed(42) + router = _make_router( + available=("good", "bad"), + prefs={ + "good": AdaptiveRouterPreferences(quality_tier=2, strengths=[]), + "bad": AdaptiveRouterPreferences(quality_tier=2, strengths=[]), + }, + costs={"good": 1.0, "bad": 1.0}, + ) + + picks = [] + for sess in range(50): + sid = f"conv-{sess}" + chosen = await router.pick_model(RequestType.GENERAL) + for _turn_i in range(4): + if chosen == "good": + turn = Turn(user_content="thanks!", assistant_content="ok") + else: + turn = Turn( + tool_calls=[{"name": "x", "arguments": {}}], + tool_results=[{"is_error": True, "content": "boom"}], + ) + await router.record_turn(sid, chosen, RequestType.GENERAL, turn) + picks.append(chosen) + + last_10 = picks[-10:] + good_share = last_10.count("good") / 10 + assert good_share >= 0.7, f"good_share={good_share} (last picks={picks})" + + +@pytest.mark.asyncio +async def test_failure_signal_increments_beta_after_flush(): + router = _make_router( + available=("only",), + prefs={"only": AdaptiveRouterPreferences(quality_tier=2, strengths=[])}, + costs={"only": 1.0}, + ) + chosen = await router.pick_model(RequestType.GENERAL) + assert chosen == "only" + + await router.record_turn( + session_id="f1", + model_name=chosen, + request_type=RequestType.GENERAL, + turn=Turn( + tool_calls=[{"name": "x", "arguments": {}}], + tool_results=[{"is_error": True, "content": ""}], + ), + ) + + prisma = _make_mock_prisma() + n_state = await router.queue.flush_state_to_db(prisma) + assert n_state == 1 + state_call = prisma.db.litellm_adaptiverouterstate.upsert.call_args + assert state_call.kwargs["data"]["create"]["beta"] >= 1.0 + assert state_call.kwargs["data"]["create"]["alpha"] == 0.0 + + +@pytest.mark.asyncio +async def test_load_state_from_db_overrides_cold_start(): + router = _make_router() + fake_row = MagicMock() + fake_row.request_type = RequestType.GENERAL.value + fake_row.model_name = "gpt-4o" + fake_row.alpha = 90.0 + fake_row.beta = 10.0 + + prisma = _make_mock_prisma() + prisma.db.litellm_adaptiverouterstate.find_many = AsyncMock(return_value=[fake_row]) + + await router.load_state_from_db(prisma) + + cell = router._cells[(RequestType.GENERAL, "gpt-4o")] + assert cell.alpha == 90.0 + assert cell.beta == 10.0 + + +@pytest.mark.asyncio +async def test_load_state_from_db_handles_unknown_request_type(): + router = _make_router() + bad_row = MagicMock() + bad_row.request_type = "unknown_v1_type" + bad_row.model_name = "gpt-4o" + bad_row.alpha = 50.0 + bad_row.beta = 50.0 + + prisma = _make_mock_prisma() + prisma.db.litellm_adaptiverouterstate.find_many = AsyncMock(return_value=[bad_row]) + + # Should not raise; bad row is silently skipped and cold-start cells remain. + await router.load_state_from_db(prisma) + cell = router._cells[(RequestType.GENERAL, "gpt-4o")] + # Cold-start: tier 3 base = 0.7, mass = 10 -> alpha = 7, beta = 3 + assert cell.alpha == pytest.approx(7.0) + assert cell.beta == pytest.approx(3.0) + + +@pytest.mark.asyncio +async def test_flush_isolates_writes_per_router_session_model(): + router = _make_router() + # Prime 2 prior turns per session to clear the MIN_TURNS_FOR_CLEAN_CREDIT gate. + for sid, model in (("s1", "gpt-4o"), ("s2", "gpt-4o-mini")): + for _ in range(2): + await router.record_turn( + sid, + model, + RequestType.GENERAL, + Turn(user_content="hi", assistant_content="hello"), + ) + await router.record_turn( + "s1", "gpt-4o", RequestType.GENERAL, Turn(user_content="thanks!") + ) + await router.record_turn( + "s2", "gpt-4o-mini", RequestType.GENERAL, Turn(user_content="thanks!") + ) + + prisma = _make_mock_prisma() + n = await router.queue.flush_session_to_db(prisma) + assert n == 2 + assert prisma.db.litellm_adaptiveroutersession.upsert.call_count == 2 + + n_state = await router.queue.flush_state_to_db(prisma) + assert n_state == 2 + assert prisma.db.litellm_adaptiverouterstate.upsert.call_count == 2 + + +@pytest.mark.asyncio +async def test_repeated_flush_drains_queue_and_subsequent_flush_is_noop(): + """Verifies the queue is fully drained on flush -- a second flush writes nothing.""" + router = _make_router() + chosen = await router.pick_model(RequestType.GENERAL) + # Prime 2 prior turns so satisfaction can fire on the third turn. + for _ in range(2): + await router.record_turn( + "drain-1", + chosen, + RequestType.GENERAL, + Turn(user_content="hi", assistant_content="hello"), + ) + await router.record_turn( + "drain-1", chosen, RequestType.GENERAL, Turn(user_content="thanks!") + ) + + prisma = _make_mock_prisma() + assert await router.queue.flush_state_to_db(prisma) == 1 + assert await router.queue.flush_session_to_db(prisma) == 1 + + # Second drain should be a no-op (queue is empty). + assert await router.queue.flush_state_to_db(prisma) == 0 + assert await router.queue.flush_session_to_db(prisma) == 0 + assert prisma.db.litellm_adaptiverouterstate.upsert.call_count == 1 + assert prisma.db.litellm_adaptiveroutersession.upsert.call_count == 1 diff --git a/tests/test_litellm/router_strategy/adaptive_router/test_hooks.py b/tests/test_litellm/router_strategy/adaptive_router/test_hooks.py new file mode 100644 index 00000000000..a2b85f2ce53 --- /dev/null +++ b/tests/test_litellm/router_strategy/adaptive_router/test_hooks.py @@ -0,0 +1,368 @@ +"""Unit tests for the AdaptiveRouterPostCallHook.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from litellm.router_strategy.adaptive_router.config import ( + ADAPTIVE_ROUTER_CHOSEN_MODEL_KEY, + SIGNAL_GATE_MIN_MESSAGES, +) +from litellm.router_strategy.adaptive_router.hooks import ( + AdaptiveRouterPostCallHook, + _recent_tool_results, + _resolve_session_key, +) +from litellm.router_strategy.adaptive_router.signals import Turn + + +def _make_hook(claim: bool = True) -> AdaptiveRouterPostCallHook: + fake_router = MagicMock() + fake_router.record_turn = AsyncMock() + fake_router.claim_or_check_owner = MagicMock(return_value=claim) + return AdaptiveRouterPostCallHook(adaptive_router=fake_router) + + +def _resp_with_content(text: str, tool_calls=None): + """Build a ModelResponse-like object with a single assistant message.""" + msg = MagicMock() + msg.content = text + msg.tool_calls = tool_calls or [] + choice = MagicMock() + choice.message = msg + resp = MagicMock() + resp.choices = [choice] + return resp + + +def _long_messages(user_text: str = "ask"): + """Return a message list at the SIGNAL_GATE_MIN_MESSAGES threshold.""" + base = [ + {"role": "user", "content": "first turn"}, + {"role": "assistant", "content": "first reply"}, + {"role": "user", "content": "second turn"}, + ] + base.append({"role": "user", "content": user_text}) + # Pad to threshold if needed. + while len(base) < SIGNAL_GATE_MIN_MESSAGES: + base.append({"role": "user", "content": "filler"}) + return base + + +def _kwargs( + *, + messages=None, + chosen="fast", + extra_metadata=None, + extra_litellm_params=None, +): + metadata = {ADAPTIVE_ROUTER_CHOSEN_MODEL_KEY: chosen} if chosen else {} + if extra_metadata: + metadata.update(extra_metadata) + lp = {"metadata": metadata} + if extra_litellm_params: + lp.update(extra_litellm_params) + return { + "model": "anthropic/claude-opus-4-7", + "messages": messages if messages is not None else _long_messages(), + "litellm_params": lp, + } + + +# ---- _resolve_session_key ------------------------------------------------ + + +def test_resolve_session_key_honors_litellm_session_id_on_litellm_params(): + key = _resolve_session_key({"litellm_params": {"litellm_session_id": "sess-A"}}) + assert key == "sess-A" + + +def test_resolve_session_key_honors_metadata_session_id(): + key = _resolve_session_key( + {"litellm_params": {"metadata": {"session_id": "sess-B"}}} + ) + assert key == "sess-B" + + +def test_resolve_session_key_returns_none_when_no_messages(): + assert _resolve_session_key({"litellm_params": {}}) is None + assert _resolve_session_key({"litellm_params": {}, "messages": []}) is None + + +def test_resolve_session_key_derives_stable_hash_from_first_message(): + # `_resolve_session_key` requires at least SIGNAL_GATE_MIN_MESSAGES + # messages before it will derive a hash (matches the signal-processing + # gate) — otherwise the session is too short to attribute. + msgs = _long_messages("Hello, world") + k1 = _resolve_session_key({"messages": msgs}) + k2 = _resolve_session_key({"messages": list(msgs)}) + assert k1 == k2 + assert k1 and len(k1) == 64 # sha256 hex + + +def test_resolve_session_key_does_not_prefix_sk(): + key = _resolve_session_key({"messages": _long_messages()}) + assert key and not key.startswith("sk_") + + +def test_resolve_session_key_segments_by_identity_fields(): + """Same first message but different api keys must yield different keys.""" + msgs = _long_messages("same prompt") + k_team_a = _resolve_session_key( + { + "messages": msgs, + "litellm_params": { + "metadata": { + "user_api_key_hash": "hash-A", + "user_api_key_team_id": "team-1", + } + }, + } + ) + k_team_b = _resolve_session_key( + { + "messages": msgs, + "litellm_params": { + "metadata": { + "user_api_key_hash": "hash-B", + "user_api_key_team_id": "team-2", + } + }, + } + ) + assert k_team_a != k_team_b + + +def test_resolve_session_key_changes_when_first_message_changes(): + k1 = _resolve_session_key({"messages": _long_messages("alpha")}) + k2 = _resolve_session_key({"messages": _long_messages("beta")}) + assert k1 != k2 + + +# ---- _record gating ----------------------------------------------------- + + +@pytest.mark.asyncio +async def test_hook_skips_when_below_signal_gate(): + """Conversations shorter than SIGNAL_GATE_MIN_MESSAGES should be ignored.""" + hook = _make_hook() + short = [{"role": "user", "content": "hi"}] + assert len(short) < SIGNAL_GATE_MIN_MESSAGES # sanity + kwargs = _kwargs(messages=short) + await hook.async_log_success_event(kwargs, _resp_with_content("ok"), 0.0, 1.0) + hook.adaptive_router.record_turn.assert_not_awaited() + hook.adaptive_router.claim_or_check_owner.assert_not_called() + + +@pytest.mark.asyncio +async def test_hook_skips_when_no_messages(): + hook = _make_hook() + kwargs = _kwargs(messages=[]) + await hook.async_log_success_event(kwargs, _resp_with_content("ok"), 0.0, 1.0) + hook.adaptive_router.record_turn.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_hook_skips_when_chosen_model_missing_from_metadata(): + hook = _make_hook() + kwargs = _kwargs(chosen=None) + await hook.async_log_success_event(kwargs, _resp_with_content("ok"), 0.0, 1.0) + hook.adaptive_router.record_turn.assert_not_awaited() + hook.adaptive_router.claim_or_check_owner.assert_not_called() + + +@pytest.mark.asyncio +async def test_hook_skips_when_owner_cache_mismatch(): + """A different model owns this conversation -> no attribution.""" + hook = _make_hook(claim=False) + kwargs = _kwargs(chosen="fast") + await hook.async_log_success_event(kwargs, _resp_with_content("ok"), 0.0, 1.0) + hook.adaptive_router.claim_or_check_owner.assert_called_once() + hook.adaptive_router.record_turn.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_hook_records_turn_when_owner_claims(): + hook = _make_hook(claim=True) + kwargs = _kwargs(chosen="smart", messages=_long_messages("ask")) + await hook.async_log_success_event( + kwargs, _resp_with_content("answer here"), 0.0, 1.0 + ) + call = hook.adaptive_router.record_turn.await_args + assert call.kwargs["model_name"] == "smart" + turn: Turn = call.kwargs["turn"] + assert turn.user_content == "ask" + assert turn.assistant_content == "answer here" + assert turn.response_status == 200 + + +@pytest.mark.asyncio +async def test_hook_uses_explicit_session_id_when_provided(): + """Explicit `litellm_session_id` is forwarded as the session key.""" + hook = _make_hook() + kwargs = _kwargs( + chosen="fast", + extra_litellm_params={"litellm_session_id": "explicit-sess"}, + ) + await hook.async_log_success_event(kwargs, _resp_with_content("ok"), 0.0, 1.0) + args, _ = hook.adaptive_router.claim_or_check_owner.call_args + assert args[0] == "explicit-sess" + assert hook.adaptive_router.record_turn.await_args.kwargs["session_id"] == ( + "explicit-sess" + ) + + +@pytest.mark.asyncio +async def test_hook_passes_tool_calls_through(): + hook = _make_hook() + tc = {"name": "search", "arguments": '{"q":"x"}'} + kwargs = _kwargs(chosen="fast") + await hook.async_log_success_event( + kwargs, _resp_with_content("calling tool", tool_calls=[tc]), 0.0, 1.0 + ) + turn: Turn = hook.adaptive_router.record_turn.await_args.kwargs["turn"] + assert turn.tool_calls == [tc] + + +# ---- _recent_tool_results ------------------------------------------------ + + +def test_recent_tool_results_empty_when_no_messages(): + assert _recent_tool_results(None) == [] + assert _recent_tool_results([]) == [] + + +def test_recent_tool_results_collects_trailing_tool_messages(): + """Tool messages at the tail of the conversation are extracted in order.""" + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "t1"}]}, + {"role": "tool", "tool_call_id": "t1", "content": "result A"}, + {"role": "tool", "tool_call_id": "t2", "content": "result B"}, + ] + results = _recent_tool_results(messages) + assert [r["content"] for r in results] == ["result A", "result B"] + assert all(r["is_error"] is False for r in results) + + +def test_recent_tool_results_propagates_is_error_flag(): + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "t1"}]}, + {"role": "tool", "content": "boom", "is_error": True}, + ] + results = _recent_tool_results(messages) + assert results == [{"content": "boom", "is_error": True}] + + +def test_recent_tool_results_stops_at_first_non_tool_message(): + """Only the trailing run of tool messages counts — prior rounds are + considered already attributed.""" + messages = [ + {"role": "user", "content": "hi"}, + {"role": "tool", "content": "stale"}, # earlier round, ignored + {"role": "assistant", "content": "intermediate"}, + {"role": "user", "content": "follow-up"}, + {"role": "tool", "content": "current"}, + ] + results = _recent_tool_results(messages) + assert [r["content"] for r in results] == ["current"] + + +def test_recent_tool_results_empty_when_no_trailing_tool_message(): + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + ] + assert _recent_tool_results(messages) == [] + + +@pytest.mark.asyncio +async def test_hook_passes_tool_results_to_turn_for_failure_detection(): + """A trailing tool message with `is_error` must reach `Turn.tool_results` + so the failure-signal path fires.""" + hook = _make_hook() + messages = _long_messages() + messages.append( + {"role": "assistant", "content": None, "tool_calls": [{"id": "t1"}]} + ) + messages.append( + {"role": "tool", "tool_call_id": "t1", "content": "500", "is_error": True} + ) + kwargs = _kwargs(chosen="fast", messages=messages) + + await hook.async_log_success_event(kwargs, _resp_with_content("ok"), 0.0, 1.0) + + turn: Turn = hook.adaptive_router.record_turn.await_args.kwargs["turn"] + assert turn.tool_results == [{"content": "500", "is_error": True}] + + +@pytest.mark.asyncio +async def test_hook_swallows_exceptions_from_record_turn(): + hook = _make_hook() + hook.adaptive_router.record_turn.side_effect = RuntimeError("boom") + kwargs = _kwargs(chosen="fast") + # Must NOT raise — signal recording must never break a request. + await hook.async_log_success_event(kwargs, _resp_with_content("ok"), 0.0, 1.0) + + +@pytest.mark.asyncio +async def test_hook_failure_event_uses_status_code_from_exception(): + hook = _make_hook() + exc = MagicMock() + exc.status_code = 429 + kwargs = _kwargs(chosen="fast") + kwargs["exception"] = exc + await hook.async_log_failure_event(kwargs, None, 0.0, 1.0) + turn: Turn = hook.adaptive_router.record_turn.await_args.kwargs["turn"] + assert turn.response_status == 429 + + +# ---- async_post_call_success_hook (response header surfacing) ---------- + + +@pytest.mark.asyncio +async def test_post_call_response_headers_hook_returns_chosen_model_header(): + """The header hook returns the `x-litellm-adaptive-router-model` header + so proxy header construction picks it up (works for both streaming and + non-streaming; `async_post_call_success_hook` is too late for streaming).""" + hook = _make_hook() + headers = await hook.async_post_call_response_headers_hook( + data={"metadata": {"adaptive_router_chosen_model": "smart"}}, + user_api_key_dict=MagicMock(), + response=MagicMock(), + ) + assert headers == {"x-litellm-adaptive-router-model": "smart"} + + +@pytest.mark.asyncio +async def test_post_call_response_headers_hook_noop_when_metadata_missing_key(): + hook = _make_hook() + headers = await hook.async_post_call_response_headers_hook( + data={"metadata": {"litellm_session_id": "sess-A"}}, + user_api_key_dict=MagicMock(), + response=MagicMock(), + ) + assert headers is None + + +@pytest.mark.asyncio +async def test_post_call_response_headers_hook_noop_when_no_metadata(): + hook = _make_hook() + headers = await hook.async_post_call_response_headers_hook( + data={}, + user_api_key_dict=MagicMock(), + response=MagicMock(), + ) + assert headers is None + + +@pytest.mark.asyncio +async def test_post_call_response_headers_hook_noop_when_metadata_not_dict(): + hook = _make_hook() + headers = await hook.async_post_call_response_headers_hook( + data={"metadata": "not-a-dict"}, + user_api_key_dict=MagicMock(), + response=MagicMock(), + ) + assert headers is None diff --git a/tests/test_litellm/router_strategy/adaptive_router/test_router_dispatch.py b/tests/test_litellm/router_strategy/adaptive_router/test_router_dispatch.py new file mode 100644 index 00000000000..604155e1221 --- /dev/null +++ b/tests/test_litellm/router_strategy/adaptive_router/test_router_dispatch.py @@ -0,0 +1,486 @@ +"""Tests for the Router-level wiring of the adaptive router. + +Specifically guards the four bugs found when wiring the example config +`auto_router/adaptive_router` end-to-end: + +1. The `auto_router/adaptive_router` model prefix must NOT trigger the + semantic auto-router init path (which would crash on missing fields). +2. The same prefix MUST trigger the adaptive-router init path. +3. `init_adaptive_router_deployment` must read `input_cost_per_token` + from `litellm_params` (where users put it), not just `model_info`. +4. `Router.async_pre_routing_hook` must dispatch to the matching entry in + `self.adaptive_routers` when the inbound model matches a configured + adaptive-router name, returning the underlying model the bandit picked. +""" + +from unittest.mock import AsyncMock + +import pytest + +from litellm import Router +from litellm.types.router import LiteLLM_Params, RequestType + + +def _params(**overrides): + base = {"model": "auto_router/adaptive_router"} + base.update(overrides) + return LiteLLM_Params(**base) + + +# ---- Fix 1 & 2: opt-in prefix routing ----------------------------------- + + +def test_auto_router_check_excludes_adaptive_router_prefix(): + r = Router(model_list=[]) + assert ( + r._is_auto_router_deployment( + litellm_params=_params(model="auto_router/adaptive_router") + ) + is False + ) + + +def test_auto_router_check_excludes_complexity_router_prefix(): + r = Router(model_list=[]) + assert ( + r._is_auto_router_deployment( + litellm_params=_params(model="auto_router/complexity_router") + ) + is False + ) + + +def test_auto_router_check_still_matches_plain_auto_router_prefix(): + r = Router(model_list=[]) + assert ( + r._is_auto_router_deployment( + litellm_params=_params(model="auto_router/my-semantic-router") + ) + is True + ) + + +def test_adaptive_router_check_recognizes_prefix(): + r = Router(model_list=[]) + assert ( + r._is_adaptive_router_deployment( + litellm_params=_params(model="auto_router/adaptive_router") + ) + is True + ) + + +def test_adaptive_router_check_rejects_other_prefixes(): + r = Router(model_list=[]) + assert ( + r._is_adaptive_router_deployment(litellm_params=_params(model="openai/gpt-4o")) + is False + ) + + +# ---- Fix 3: cost field path -------------------------------------------- + + +def test_init_adaptive_router_reads_cost_from_litellm_params(): + r = Router( + model_list=[ + { + "model_name": "smart-cheap-router", + "litellm_params": { + "model": "auto_router/adaptive_router", + "adaptive_router_config": { + "available_models": ["fast", "smart"], + }, + }, + }, + { + "model_name": "fast", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "input_cost_per_token": 0.00000015, + }, + "model_info": { + "adaptive_router_preferences": { + "quality_tier": 2, + "strengths": [], + } + }, + }, + { + "model_name": "smart", + "litellm_params": { + "model": "openai/gpt-4o", + "input_cost_per_token": 0.0000050, + }, + "model_info": { + "adaptive_router_preferences": { + "quality_tier": 3, + "strengths": ["code_generation"], + } + }, + }, + ] + ) + assert "smart-cheap-router" in r.adaptive_routers + assert r.adaptive_routers["smart-cheap-router"].model_to_cost == { + "fast": 0.00000015, + "smart": 0.0000050, + } + + +# ---- Fix 4: pre-routing dispatch --------------------------------------- + + +def _router_with_adaptive() -> Router: + return Router( + model_list=[ + { + "model_name": "smart-cheap-router", + "litellm_params": { + "model": "auto_router/adaptive_router", + "adaptive_router_config": { + "available_models": ["fast", "smart"], + }, + }, + }, + { + "model_name": "fast", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "input_cost_per_token": 0.00000015, + }, + "model_info": { + "adaptive_router_preferences": { + "quality_tier": 2, + "strengths": [], + } + }, + }, + { + "model_name": "smart", + "litellm_params": { + "model": "openai/gpt-4o", + "input_cost_per_token": 0.0000050, + }, + "model_info": { + "adaptive_router_preferences": { + "quality_tier": 3, + "strengths": ["code_generation"], + } + }, + }, + ] + ) + + +@pytest.mark.asyncio +async def test_async_pre_routing_hook_dispatches_to_adaptive_router(): + r = _router_with_adaptive() + ar = r.adaptive_routers["smart-cheap-router"] + ar.pick_model = AsyncMock(return_value="smart") # type: ignore[assignment] + + response = await r.async_pre_routing_hook( + model="smart-cheap-router", + request_kwargs={"metadata": {"litellm_session_id": "sess-A"}}, + messages=[{"role": "user", "content": "Write a Python function"}], + ) + assert response is not None + assert response.model == "smart" + call = ar.pick_model.await_args # type: ignore[union-attr] + # Stateless routing: session_id is no longer passed to pick_model. + assert "session_id" not in call.kwargs + assert call.kwargs["request_type"] == RequestType.CODE_GENERATION + + +@pytest.mark.asyncio +async def test_async_pre_routing_hook_pick_model_not_passed_session_id(): + r = _router_with_adaptive() + ar = r.adaptive_routers["smart-cheap-router"] + ar.pick_model = AsyncMock(return_value="fast") # type: ignore[assignment] + + response = await r.async_pre_routing_hook( + model="smart-cheap-router", + request_kwargs={}, + messages=[{"role": "user", "content": "hello"}], + ) + assert response is not None + assert response.model == "fast" + assert "session_id" not in ar.pick_model.await_args.kwargs # type: ignore[union-attr] + + +@pytest.mark.asyncio +async def test_async_pre_routing_hook_returns_none_for_unrelated_model(): + r = _router_with_adaptive() + ar = r.adaptive_routers["smart-cheap-router"] + ar.pick_model = AsyncMock() # type: ignore[assignment] + response = await r.async_pre_routing_hook( + model="some-other-model", + request_kwargs={}, + messages=[{"role": "user", "content": "x"}], + ) + assert response is None + ar.pick_model.assert_not_awaited() # type: ignore[union-attr] + + +# ---- Response header surfacing ----------------------------------------- + + +@pytest.mark.asyncio +async def test_async_pre_routing_hook_stashes_chosen_model_in_metadata(): + """ + The adaptive-router branch must record the chosen logical model on + `request_kwargs["metadata"]` so `_acompletion` can surface it as the + `x-litellm-adaptive-router-model` response header. + """ + r = _router_with_adaptive() + r.adaptive_routers["smart-cheap-router"].pick_model = AsyncMock( # type: ignore[assignment] + return_value="smart" + ) + + request_kwargs: dict = {"metadata": {"litellm_session_id": "sess-A"}} + await r.async_pre_routing_hook( + model="smart-cheap-router", + request_kwargs=request_kwargs, + messages=[{"role": "user", "content": "Write a Python function"}], + ) + assert request_kwargs["metadata"]["adaptive_router_chosen_model"] == "smart" + + +@pytest.mark.asyncio +async def test_async_pre_routing_hook_creates_metadata_when_missing(): + """If no metadata was passed in, the hook should create one to stash the chosen model.""" + r = _router_with_adaptive() + r.adaptive_routers["smart-cheap-router"].pick_model = AsyncMock( # type: ignore[assignment] + return_value="fast" + ) + + request_kwargs: dict = {} + await r.async_pre_routing_hook( + model="smart-cheap-router", + request_kwargs=request_kwargs, + messages=[{"role": "user", "content": "hello"}], + ) + assert request_kwargs["metadata"]["adaptive_router_chosen_model"] == "fast" + + +# ---- Multi-router support ---------------------------------------------- + + +def test_two_adaptive_routers_can_coexist_on_one_router(): + r = Router( + model_list=[ + { + "model_name": "cheap-router", + "litellm_params": { + "model": "auto_router/adaptive_router", + "adaptive_router_config": {"available_models": ["fast"]}, + }, + }, + { + "model_name": "premium-router", + "litellm_params": { + "model": "auto_router/adaptive_router", + "adaptive_router_config": {"available_models": ["smart"]}, + }, + }, + { + "model_name": "fast", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "input_cost_per_token": 0.00000015, + }, + }, + { + "model_name": "smart", + "litellm_params": { + "model": "openai/gpt-4o", + "input_cost_per_token": 0.0000050, + }, + }, + ] + ) + assert set(r.adaptive_routers.keys()) == {"cheap-router", "premium-router"} + assert r.adaptive_routers["cheap-router"].config.available_models == ["fast"] + assert r.adaptive_routers["premium-router"].config.available_models == ["smart"] + + +@pytest.mark.asyncio +async def test_async_pre_routing_hook_dispatches_to_correct_router_when_multiple(): + """Each adaptive router only handles its own router_name.""" + r = Router( + model_list=[ + { + "model_name": "cheap-router", + "litellm_params": { + "model": "auto_router/adaptive_router", + "adaptive_router_config": {"available_models": ["fast"]}, + }, + }, + { + "model_name": "premium-router", + "litellm_params": { + "model": "auto_router/adaptive_router", + "adaptive_router_config": {"available_models": ["smart"]}, + }, + }, + { + "model_name": "fast", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "input_cost_per_token": 0.00000015, + }, + }, + { + "model_name": "smart", + "litellm_params": { + "model": "openai/gpt-4o", + "input_cost_per_token": 0.0000050, + }, + }, + ] + ) + cheap = r.adaptive_routers["cheap-router"] + premium = r.adaptive_routers["premium-router"] + cheap.pick_model = AsyncMock(return_value="fast") # type: ignore[assignment] + premium.pick_model = AsyncMock(return_value="smart") # type: ignore[assignment] + + cheap_response = await r.async_pre_routing_hook( + model="cheap-router", + request_kwargs={}, + messages=[{"role": "user", "content": "hi"}], + ) + premium_response = await r.async_pre_routing_hook( + model="premium-router", + request_kwargs={}, + messages=[{"role": "user", "content": "hi"}], + ) + + assert cheap_response is not None and cheap_response.model == "fast" + assert premium_response is not None and premium_response.model == "smart" + cheap.pick_model.assert_awaited_once() # type: ignore[union-attr] + premium.pick_model.assert_awaited_once() # type: ignore[union-attr] + + +def test_init_adaptive_router_rejects_duplicate_model_name(): + """Two adaptive-router deployments with the same model_name must error.""" + from litellm.types.router import AdaptiveRouterConfig, Deployment + + r = Router(model_list=[]) + cfg = {"available_models": ["fast"]} + deployment = Deployment( + model_name="dup-router", + litellm_params=LiteLLM_Params( + model="auto_router/adaptive_router", + adaptive_router_config=cfg, + ), + model_info={"id": "x"}, + ) + r.init_adaptive_router_deployment(deployment=deployment) + with pytest.raises(ValueError, match="already exists"): + r.init_adaptive_router_deployment(deployment=deployment) + + +def test_finalize_adaptive_router_if_configured_initializes_and_is_idempotent(): + """`_finalize_adaptive_router_if_configured` walks the model_list, builds an + AdaptiveRouter for each adaptive deployment, and is a safe no-op on + re-entry (models already in self.adaptive_routers are skipped).""" + r = Router( + model_list=[ + { + "model_name": "fast", + "litellm_params": {"model": "openai/gpt-4o-mini"}, + "model_info": {"input_cost_per_token": 0.00000015}, + }, + { + "model_name": "smart", + "litellm_params": {"model": "openai/gpt-4o"}, + "model_info": {"input_cost_per_token": 0.0000025}, + }, + { + "model_name": "my-router", + "litellm_params": { + "model": "auto_router/adaptive_router", + "adaptive_router_config": { + "available_models": ["fast", "smart"], + }, + }, + }, + ] + ) + + # Router __init__ already called _finalize_adaptive_router_if_configured. + assert "my-router" in r.adaptive_routers + original = r.adaptive_routers["my-router"] + + # Calling again must be idempotent: the existing AdaptiveRouter instance + # is preserved, not rebuilt. + r._finalize_adaptive_router_if_configured() + assert r.adaptive_routers["my-router"] is original + + +def test_finalize_prunes_stale_adaptive_router_hooks_from_callbacks(): + """Replacing the Router (hot-reload path) must not leave stale + AdaptiveRouterPostCallHook instances in `litellm.callbacks` — otherwise + every request double-fires signal recording.""" + import litellm + from litellm.router_strategy.adaptive_router.hooks import ( + AdaptiveRouterPostCallHook, + ) + + model_list = [ + { + "model_name": "fast", + "litellm_params": {"model": "openai/gpt-4o-mini"}, + }, + { + "model_name": "my-router", + "litellm_params": { + "model": "auto_router/adaptive_router", + "adaptive_router_config": {"available_models": ["fast"]}, + }, + }, + ] + + # Snapshot any pre-existing AdaptiveRouterPostCallHook entries so we can + # restore them — other tests may have registered hooks we shouldn't drop. + pre_hooks = [ + cb for cb in litellm.callbacks if isinstance(cb, AdaptiveRouterPostCallHook) + ] + for cb in pre_hooks: + litellm.callbacks.remove(cb) + + try: + Router(model_list=model_list) + Router(model_list=model_list) # simulate hot-reload + + adaptive_hooks = [ + cb + for cb in litellm.callbacks + if isinstance(cb, AdaptiveRouterPostCallHook) + ] + assert len(adaptive_hooks) == 1, ( + f"expected exactly one AdaptiveRouterPostCallHook after hot-reload, " + f"got {len(adaptive_hooks)}" + ) + finally: + # Best-effort cleanup: remove whatever this test added, then restore. + for cb in list(litellm.callbacks): + if isinstance(cb, AdaptiveRouterPostCallHook): + litellm.callbacks.remove(cb) + for cb in pre_hooks: + litellm.callbacks.append(cb) + + +def test_finalize_adaptive_router_if_configured_noop_when_none_configured(): + """With no adaptive deployments in model_list, the finalizer leaves + `adaptive_routers` empty.""" + r = Router( + model_list=[ + { + "model_name": "fast", + "litellm_params": {"model": "openai/gpt-4o-mini"}, + } + ] + ) + r._finalize_adaptive_router_if_configured() + assert r.adaptive_routers == {} diff --git a/tests/test_litellm/router_strategy/adaptive_router/test_signals.py b/tests/test_litellm/router_strategy/adaptive_router/test_signals.py new file mode 100644 index 00000000000..2773c13a812 --- /dev/null +++ b/tests/test_litellm/router_strategy/adaptive_router/test_signals.py @@ -0,0 +1,180 @@ +import json +from pathlib import Path +from typing import List, Tuple + +import pytest + +from litellm.router_strategy.adaptive_router.config import TOOL_CALL_HISTORY_MAX +from litellm.router_strategy.adaptive_router.signals import ( + SessionState, + SignalDelta, + Turn, + apply_turn, +) + +FIXTURE_DIR = Path(__file__).parent / "fixtures" + + +def _load(name: str) -> list: + return json.loads((FIXTURE_DIR / f"{name}.json").read_text()) + + +def _replay(turns: list) -> Tuple[SessionState, List[SignalDelta]]: + state = SessionState( + session_id="s", + router_name="r", + model_name="m", + classified_type="general", + ) + deltas: List[SignalDelta] = [] + for t in turns: + deltas.append( + apply_turn( + state, + Turn( + user_content=t.get("user_content"), + assistant_content=t.get("assistant_content"), + tool_calls=t.get("tool_calls", []), + tool_results=t.get("tool_results", []), + response_status=t.get("response_status"), + ), + ) + ) + return state, deltas + + +def test_clean_satisfaction_fires_satisfaction_only(): + state, _ = _replay(_load("clean_satisfaction")) + assert state.satisfaction_count >= 1 + assert state.failure_count == 0 + assert state.disengagement_count == 0 + + +def test_misalignment_fires_on_rephrase(): + state, _ = _replay(_load("misalignment_rephrase")) + assert state.misalignment_count >= 1 + + +def test_stagnation_fires_on_repeated_assistant(): + state, _ = _replay(_load("stagnation_repeat")) + assert state.stagnation_count >= 1 + + +def test_disengagement_fires_on_giveup(): + state, _ = _replay(_load("disengagement_giveup")) + assert state.disengagement_count >= 1 + + +def test_failure_fires_on_tool_error(): + state, _ = _replay(_load("failure_tool_error")) + assert state.failure_count == 1 + + +def test_loop_fires_on_repeated_tool(): + state, _ = _replay(_load("loop_same_tool")) + assert state.loop_count >= 1 + + +@pytest.mark.parametrize("fixture", ["exhaustion_429", "exhaustion_context_overflow"]) +def test_exhaustion_fires_on_infra_signal(fixture): + state, _ = _replay(_load(fixture)) + assert state.exhaustion_count >= 1 + + +def test_no_signals_on_clean_session(): + state, _ = _replay(_load("clean_no_signals")) + assert state.misalignment_count == 0 + assert state.stagnation_count == 0 + assert state.disengagement_count == 0 + assert state.failure_count == 0 + assert state.loop_count == 0 + assert state.exhaustion_count == 0 + + +def test_mixed_failure_then_satisfaction(): + state, _ = _replay(_load("mixed_failure_then_satisfaction")) + assert state.failure_count >= 1 + assert state.satisfaction_count >= 1 + + +def test_satisfaction_gated_by_min_turns_for_clean_credit(): + """'thanks' on turn 1 is noise, not a validated quality signal.""" + state = SessionState( + session_id="s", router_name="r", model_name="m", classified_type="general" + ) + apply_turn(state, Turn(user_content="thanks!")) + assert state.satisfaction_count == 0 + assert state.clean_credit_awarded is False + assert state.last_processed_turn == 1 + + +def test_satisfaction_credit_awarded_once_per_session(): + """Even multiple satisfaction turns only award +1 alpha across the session.""" + state = SessionState( + session_id="s", router_name="r", model_name="m", classified_type="general" + ) + apply_turn(state, Turn(user_content="hi", assistant_content="hello")) + apply_turn(state, Turn(user_content="help me", assistant_content="sure")) + apply_turn(state, Turn(user_content="perfect, thanks")) + assert state.satisfaction_count == 1 + assert state.clean_credit_awarded is True + apply_turn(state, Turn(user_content="great, thank you")) + assert state.satisfaction_count == 1 + + +def test_empty_tool_content_does_not_fire_failure(): + """Zero-result searches / silent commands return empty but valid output.""" + state = SessionState( + session_id="s", router_name="r", model_name="m", classified_type="general" + ) + apply_turn( + state, + Turn( + tool_calls=[{"name": "grep", "arguments": {"q": "x"}}], + tool_results=[{"tool_call_id": "c1", "content": ""}], + ), + ) + apply_turn( + state, + Turn( + tool_calls=[{"name": "list", "arguments": {}}], + tool_results=[{"tool_call_id": "c2", "content": []}], + ), + ) + apply_turn( + state, + Turn( + tool_calls=[{"name": "noop", "arguments": {}}], + tool_results=[{"tool_call_id": "c3", "content": None}], + ), + ) + assert state.failure_count == 0 + + +def test_is_error_still_fires_failure(): + state = SessionState( + session_id="s", router_name="r", model_name="m", classified_type="general" + ) + apply_turn( + state, + Turn( + tool_calls=[{"name": "read", "arguments": {"p": "x"}}], + tool_results=[{"tool_call_id": "c1", "content": "boom", "is_error": True}], + ), + ) + assert state.failure_count == 1 + + +def test_apply_turn_is_o1_does_not_grow_history_unbounded(): + state = SessionState( + session_id="s", + router_name="r", + model_name="m", + classified_type="general", + ) + for i in range(100): + apply_turn( + state, + Turn(tool_calls=[{"name": f"tool_{i}", "arguments": {}}]), + ) + assert len(state.tool_call_history) <= TOOL_CALL_HISTORY_MAX diff --git a/tests/test_litellm/router_strategy/adaptive_router/test_state_endpoint.py b/tests/test_litellm/router_strategy/adaptive_router/test_state_endpoint.py new file mode 100644 index 00000000000..753a449791b --- /dev/null +++ b/tests/test_litellm/router_strategy/adaptive_router/test_state_endpoint.py @@ -0,0 +1,198 @@ +"""Tests for the GET /adaptive_router/state introspection endpoint and the +underlying `AdaptiveRouter.get_state_snapshot()` helper.""" + +import time +from unittest.mock import MagicMock + +import pytest +from fastapi import HTTPException + +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.router_strategy.adaptive_router.adaptive_router import AdaptiveRouter +from litellm.router_strategy.adaptive_router.bandit import BanditCell, apply_delta +from litellm.types.router import ( + AdaptiveRouterConfig, + AdaptiveRouterPreferences, + RequestType, +) + + +def _make_router(name: str = "r1") -> AdaptiveRouter: + cfg = AdaptiveRouterConfig(available_models=["fast", "smart"]) + prefs = { + "fast": AdaptiveRouterPreferences(quality_tier=1, strengths=[]), + "smart": AdaptiveRouterPreferences( + quality_tier=3, strengths=[RequestType.CODE_GENERATION] + ), + } + costs = {"fast": 0.0001, "smart": 0.001} + return AdaptiveRouter( + router_name=name, + config=cfg, + model_to_prefs=prefs, + model_to_cost=costs, + ) + + +# ---- snapshot helper --------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_state_snapshot_returns_cell_per_request_type_per_model(): + r = _make_router() + snap = await r.get_state_snapshot() + + # Top-level shape + assert snap["router_name"] == "r1" + assert snap["available_models"] == ["fast", "smart"] + assert snap["weights"] == {"quality": 0.7, "cost": 0.3} + assert snap["model_costs"] == {"fast": 0.0001, "smart": 0.001} + assert snap["owner_cache_live"] == 0 + assert snap["skipped_updates_total"] == 0 + assert set(snap["queue"].keys()) == { + "state_pending", + "session_pending", + "max_state_seen", + "max_session_seen", + } + + # 7 request types x 2 models = 14 cells + assert len(snap["cells"]) == len(list(RequestType)) * 2 + for cell in snap["cells"]: + assert set(cell.keys()) == { + "request_type", + "model", + "alpha", + "beta", + "samples", + "quality_mean", + } + assert cell["model"] in {"fast", "smart"} + assert cell["request_type"] in {rt.value for rt in RequestType} + + +@pytest.mark.asyncio +async def test_get_state_snapshot_quality_mean_matches_alpha_over_total(): + r = _make_router() + + # Manually mutate one cell to a known state so the math is verifiable. + key = (RequestType.CODE_GENERATION, "smart") + r._cells[key] = apply_delta(r._cells[key], delta_alpha=10.0, delta_beta=0.0) + expected = r._cells[key] + expected_mean = expected.alpha / (expected.alpha + expected.beta) + + snap = await r.get_state_snapshot() + cell = next( + c + for c in snap["cells"] + if c["request_type"] == "code_generation" and c["model"] == "smart" + ) + assert cell["alpha"] == expected.alpha + assert cell["beta"] == expected.beta + # `samples` reports net observations after subtracting the cold-start + # prior mass, so operators aren't misled by the initial value. + assert cell["samples"] == expected.total_samples + assert cell["quality_mean"] == pytest.approx(expected_mean) + + +@pytest.mark.asyncio +async def test_get_state_snapshot_counts_only_live_owner_cache_entries(): + r = _make_router() + now = time.time() + r._owner_cache["live-1"] = ("fast", now + 3600) + r._owner_cache["live-2"] = ("smart", now + 3600) + r._owner_cache["expired-1"] = ("fast", now - 1) + + snap = await r.get_state_snapshot() + assert snap["owner_cache_live"] == 2 + + +@pytest.mark.asyncio +async def test_get_state_snapshot_exposes_skipped_updates_total(): + r = _make_router() + r._skipped_updates_total = 7 + snap = await r.get_state_snapshot() + assert snap["skipped_updates_total"] == 7 + + +# ---- endpoint -------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_endpoint_returns_404_when_no_adaptive_router(monkeypatch): + """When llm_router is set but has no adaptive routers configured, return 404.""" + from litellm.proxy import proxy_server + + fake_router = MagicMock() + fake_router.adaptive_routers = {} + monkeypatch.setattr(proxy_server, "llm_router", fake_router) + + admin = UserAPIKeyAuth(api_key="sk-1234", user_role=LitellmUserRoles.PROXY_ADMIN) + with pytest.raises(HTTPException) as exc: + await proxy_server.get_adaptive_router_state(user_api_key_dict=admin) + assert exc.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_endpoint_returns_404_when_llm_router_is_none(monkeypatch): + from litellm.proxy import proxy_server + + monkeypatch.setattr(proxy_server, "llm_router", None) + + admin = UserAPIKeyAuth(api_key="sk-1234", user_role=LitellmUserRoles.PROXY_ADMIN) + with pytest.raises(HTTPException) as exc: + await proxy_server.get_adaptive_router_state(user_api_key_dict=admin) + assert exc.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_endpoint_rejects_non_admin_role(monkeypatch): + from litellm.proxy import proxy_server + + fake_router = MagicMock() + fake_router.adaptive_routers = {"r1": _make_router()} + monkeypatch.setattr(proxy_server, "llm_router", fake_router) + + non_admin = UserAPIKeyAuth( + api_key="sk-user", user_role=LitellmUserRoles.INTERNAL_USER + ) + with pytest.raises(HTTPException) as exc: + await proxy_server.get_adaptive_router_state(user_api_key_dict=non_admin) + assert exc.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_endpoint_returns_snapshot_list_for_admin(monkeypatch): + """Single configured router still returns the {"routers": [...]} list shape.""" + from litellm.proxy import proxy_server + + fake_router = MagicMock() + fake_router.adaptive_routers = {"r1": _make_router("r1")} + monkeypatch.setattr(proxy_server, "llm_router", fake_router) + + admin = UserAPIKeyAuth(api_key="sk-1234", user_role=LitellmUserRoles.PROXY_ADMIN) + result = await proxy_server.get_adaptive_router_state(user_api_key_dict=admin) + assert list(result.keys()) == ["routers"] + assert len(result["routers"]) == 1 + snap = result["routers"][0] + assert snap["router_name"] == "r1" + assert snap["available_models"] == ["fast", "smart"] + assert len(snap["cells"]) == len(list(RequestType)) * 2 + + +@pytest.mark.asyncio +async def test_endpoint_returns_one_snapshot_per_router(monkeypatch): + """With multiple adaptive routers configured, return one snapshot per router.""" + from litellm.proxy import proxy_server + + fake_router = MagicMock() + fake_router.adaptive_routers = { + "r1": _make_router("r1"), + "r2": _make_router("r2"), + } + monkeypatch.setattr(proxy_server, "llm_router", fake_router) + + admin = UserAPIKeyAuth(api_key="sk-1234", user_role=LitellmUserRoles.PROXY_ADMIN) + result = await proxy_server.get_adaptive_router_state(user_api_key_dict=admin) + names = sorted(s["router_name"] for s in result["routers"]) + assert names == ["r1", "r2"] diff --git a/tests/test_litellm/router_strategy/adaptive_router/test_update_queue.py b/tests/test_litellm/router_strategy/adaptive_router/test_update_queue.py new file mode 100644 index 00000000000..9baa69a19e0 --- /dev/null +++ b/tests/test_litellm/router_strategy/adaptive_router/test_update_queue.py @@ -0,0 +1,117 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from litellm.router_strategy.adaptive_router.update_queue import ( + AdaptiveRouterUpdateQueue, +) + + +@pytest.fixture +def queue(): + return AdaptiveRouterUpdateQueue() + + +@pytest.fixture +def mock_prisma(): + """Prisma client with both adaptive router models stubbed as AsyncMocks.""" + p = MagicMock() + p.db.litellm_adaptiverouterstate.find_unique = AsyncMock(return_value=None) + p.db.litellm_adaptiverouterstate.upsert = AsyncMock() + p.db.litellm_adaptiveroutersession.upsert = AsyncMock() + return p + + +@pytest.mark.asyncio +async def test_add_state_delta_aggregates_same_key(queue): + await queue.add_state_delta("r1", "general", "gpt-4", 1.0, 0.0) + await queue.add_state_delta("r1", "general", "gpt-4", 0.0, 1.0) + sizes = await queue.queue_size() + assert sizes["state_pending"] == 1 + + +@pytest.mark.asyncio +async def test_add_state_delta_separate_keys(queue): + await queue.add_state_delta("r1", "general", "gpt-4", 1.0, 0.0) + await queue.add_state_delta("r1", "writing", "gpt-4", 1.0, 0.0) + sizes = await queue.queue_size() + assert sizes["state_pending"] == 2 + + +@pytest.mark.asyncio +async def test_add_session_state_last_write_wins(queue): + await queue.add_session_state("s1", "r1", "gpt-4", {"misalignment_count": 1}) + await queue.add_session_state("s1", "r1", "gpt-4", {"misalignment_count": 5}) + sizes = await queue.queue_size() + assert sizes["session_pending"] == 1 + + flushed = [] + p = MagicMock() + + async def upsert(**kwargs): + flushed.append(kwargs) + + p.db.litellm_adaptiveroutersession.upsert = upsert + await queue.flush_session_to_db(p) + assert len(flushed) == 1 + assert flushed[0]["data"]["update"]["misalignment_count"] == 5 + + +@pytest.mark.asyncio +async def test_flush_state_drains_aggregator(queue, mock_prisma): + await queue.add_state_delta("r1", "general", "gpt-4", 1.0, 0.0) + await queue.add_state_delta("r1", "writing", "gpt-4", 0.0, 1.0) + n = await queue.flush_state_to_db(mock_prisma) + assert n == 2 + sizes = await queue.queue_size() + assert sizes["state_pending"] == 0 + + +@pytest.mark.asyncio +async def test_flush_state_sums_correctly(queue, mock_prisma): + await queue.add_state_delta("r1", "general", "gpt-4", 1.0, 0.0) + await queue.add_state_delta("r1", "general", "gpt-4", 2.0, 1.0) + await queue.flush_state_to_db(mock_prisma) + # find_unique returned None (cold start), so alpha = 1+2 = 3, beta = 0+1 = 1 + call = mock_prisma.db.litellm_adaptiverouterstate.upsert.call_args + assert call.kwargs["data"]["create"]["alpha"] == 3.0 + assert call.kwargs["data"]["create"]["beta"] == 1.0 + assert call.kwargs["data"]["create"]["total_samples"] == 2 + + +@pytest.mark.asyncio +async def test_flush_session_drains_aggregator(queue, mock_prisma): + await queue.add_session_state("s1", "r1", "gpt-4", {"classified_type": "general"}) + n = await queue.flush_session_to_db(mock_prisma) + assert n == 1 + sizes = await queue.queue_size() + assert sizes["session_pending"] == 0 + + +@pytest.mark.asyncio +async def test_flush_empty_queue_returns_zero(queue, mock_prisma): + assert await queue.flush_state_to_db(mock_prisma) == 0 + assert await queue.flush_session_to_db(mock_prisma) == 0 + + +@pytest.mark.asyncio +async def test_flush_state_isolation_from_concurrent_adds(queue, mock_prisma): + """Adds during a flush should land in the NEW aggregator, not the drained batch.""" + await queue.add_state_delta("r1", "general", "gpt-4", 1.0, 0.0) + flush_task = asyncio.create_task(queue.flush_state_to_db(mock_prisma)) + # Yield control so the flush task can swap the aggregator before we add again. + await asyncio.sleep(0) + await queue.add_state_delta("r1", "general", "gpt-5", 2.0, 0.0) + await flush_task + sizes = await queue.queue_size() + assert sizes["state_pending"] == 1 + + +@pytest.mark.asyncio +async def test_max_size_observability(queue): + await queue.add_state_delta("r1", "general", "gpt-4", 1.0, 0.0) + await queue.add_state_delta("r1", "writing", "gpt-4", 1.0, 0.0) + await queue.add_state_delta("r1", "code_generation", "gpt-4", 1.0, 0.0) + sizes = await queue.queue_size() + assert sizes["max_state_seen"] >= 3 diff --git a/tests/test_litellm/router_strategy/test_auto_router.py b/tests/test_litellm/router_strategy/test_auto_router.py index caff2bc8f10..cb46a4ae553 100644 --- a/tests/test_litellm/router_strategy/test_auto_router.py +++ b/tests/test_litellm/router_strategy/test_auto_router.py @@ -12,7 +12,148 @@ sys.path.insert( from litellm.router_strategy.auto_router.auto_router import AutoRouter -pytestmark = pytest.mark.skip(reason="Skipping auto router tests - beta feature") +pytestmark_skip_beta = pytest.mark.skip( + reason="Skipping auto router tests - beta feature" +) + + +class TestExtractTextFromMessages: + """Tests for AutoRouter._extract_text_from_messages (no semantic_router dependency).""" + + def test_should_extract_content_from_simple_user_message(self): + messages = [{"role": "user", "content": "Hello world"}] + result = AutoRouter._extract_text_from_messages(messages) + assert result == "Hello world" + + def test_should_extract_last_user_message_from_tool_call_conversation(self): + messages = [ + {"role": "user", "content": "What's the weather in NYC?"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_abc123", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"location": "NYC"}', + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_abc123", + "content": "72°F and sunny", + }, + {"role": "user", "content": "Now tell me about London"}, + ] + result = AutoRouter._extract_text_from_messages(messages) + assert result == "Now tell me about London" + + def test_should_find_user_message_when_last_message_is_assistant_with_tool_calls( + self, + ): + messages = [ + {"role": "user", "content": "What's the weather?"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_abc", + "type": "function", + "function": {"name": "get_weather", "arguments": "{}"}, + } + ], + }, + ] + result = AutoRouter._extract_text_from_messages(messages) + assert result == "What's the weather?" + + def test_should_find_user_message_when_last_message_is_tool_response(self): + messages = [ + {"role": "user", "content": "What's the weather?"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_abc", + "type": "function", + "function": {"name": "get_weather", "arguments": "{}"}, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_abc", + "content": "72°F and sunny", + }, + ] + result = AutoRouter._extract_text_from_messages(messages) + assert result == "What's the weather?" + + def test_should_handle_multimodal_content_list(self): + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What's in this image?"}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/img.png"}, + }, + ], + } + ] + result = AutoRouter._extract_text_from_messages(messages) + assert result == "What's in this image?" + + def test_should_handle_multimodal_content_with_multiple_text_blocks(self): + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "First part"}, + {"type": "text", "text": "Second part"}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/img.png"}, + }, + ], + } + ] + result = AutoRouter._extract_text_from_messages(messages) + assert result == "First part Second part" + + def test_should_return_empty_string_when_user_content_is_none(self): + messages = [{"role": "user", "content": None}] + result = AutoRouter._extract_text_from_messages(messages) + assert result == "" + + def test_should_return_empty_string_when_no_user_messages(self): + messages = [ + {"role": "system", "content": "You are a helpful assistant"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_abc", + "type": "function", + "function": {"name": "get_weather", "arguments": "{}"}, + } + ], + }, + ] + result = AutoRouter._extract_text_from_messages(messages) + assert result == "" + + def test_should_return_empty_string_for_empty_messages_list(self): + result = AutoRouter._extract_text_from_messages([]) + assert result == "" @pytest.fixture @@ -41,6 +182,7 @@ def mock_route_choice(): return mock_choice +@pytestmark_skip_beta class TestAutoRouter: """Test class for AutoRouter methods.""" diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py index 8d36fc2ba32..e68ea863d82 100644 --- a/tests/test_litellm/router_strategy/test_complexity_router.py +++ b/tests/test_litellm/router_strategy/test_complexity_router.py @@ -7,7 +7,7 @@ Tests the rule-based complexity scoring and tier assignment logic. import os import sys from typing import Dict, List -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -828,3 +828,222 @@ class TestRouterComplexityDeploymentMethods: ) router.init_complexity_router_deployment(deployment) assert "auto_router/complexity_router/test-router" in router.complexity_routers + + +class TestAsyncPreRoutingHookMultiFormat: + """Test async_pre_routing_hook with multiple input formats.""" + + @pytest.mark.asyncio + async def test_should_route_with_chat_completions_messages(self, complexity_router): + """Test routing with standard chat completions messages.""" + result = await complexity_router.async_pre_routing_hook( + model="test-model", + request_kwargs={}, + messages=[{"role": "user", "content": "What is 2+2?"}], + ) + assert result is not None + assert result.model is not None + assert result.messages is not None + + @pytest.mark.asyncio + async def test_should_route_with_responses_api_string_input( + self, complexity_router + ): + """Test routing with Responses API string input via handler dispatch.""" + from litellm.llms.openai.responses.guardrail_translation.handler import ( + OpenAIResponsesHandler, + ) + from litellm.types.utils import CallTypes + + mock_mappings = {CallTypes.responses: OpenAIResponsesHandler} + + with patch( + "litellm.llms.load_guardrail_translation_mappings", + return_value=mock_mappings, + ): + result = await complexity_router.async_pre_routing_hook( + model="test-model", + request_kwargs={"input": "What is the capital of France?"}, + messages=None, + input="What is the capital of France?", + ) + + assert result is not None + assert result.model is not None + # messages should be None since the original request didn't have messages + assert result.messages is None + + @pytest.mark.asyncio + async def test_should_route_with_responses_api_list_input(self, complexity_router): + """Test routing with Responses API list input via handler dispatch.""" + from litellm.llms.openai.responses.guardrail_translation.handler import ( + OpenAIResponsesHandler, + ) + from litellm.types.utils import CallTypes + + mock_mappings = {CallTypes.responses: OpenAIResponsesHandler} + + list_input = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + { + "role": "user", + "content": "Write a Python function to sort a list using merge sort", + }, + ] + + with patch( + "litellm.llms.load_guardrail_translation_mappings", + return_value=mock_mappings, + ): + result = await complexity_router.async_pre_routing_hook( + model="test-model", + request_kwargs={"input": list_input}, + messages=None, + input=list_input, + ) + + assert result is not None + assert result.model is not None + assert result.messages is None + + @pytest.mark.asyncio + async def test_should_use_route_based_inference(self, complexity_router): + """Test that route-based call type inference is used when available.""" + from litellm.llms.openai.responses.guardrail_translation.handler import ( + OpenAIResponsesHandler, + ) + from litellm.types.utils import CallTypes + + mock_mappings = {CallTypes.responses: OpenAIResponsesHandler} + + with patch( + "litellm.llms.load_guardrail_translation_mappings", + return_value=mock_mappings, + ): + result = await complexity_router.async_pre_routing_hook( + model="test-model", + request_kwargs={ + "input": "Roll 2d4+1", + "litellm_metadata": { + "user_api_key_request_route": "/v1/responses", + }, + }, + messages=None, + ) + + assert result is not None + assert result.model is not None + + @pytest.mark.asyncio + async def test_should_return_none_when_no_messages_or_input( + self, complexity_router + ): + """Test that None is returned when neither messages nor input is available.""" + result = await complexity_router.async_pre_routing_hook( + model="test-model", + request_kwargs={}, + messages=None, + input=None, + ) + assert result is None + + @pytest.mark.asyncio + async def test_should_prefer_original_messages_over_conversion( + self, complexity_router + ): + """Test that original messages are used when both messages and input are available.""" + messages = [{"role": "user", "content": "What is 2+2?"}] + result = await complexity_router.async_pre_routing_hook( + model="test-model", + request_kwargs={"input": "This should be ignored"}, + messages=messages, + ) + assert result is not None + assert result.messages == messages + + @pytest.mark.asyncio + async def test_should_include_instructions_in_classification( + self, complexity_router + ): + """Test that Responses API instructions influence classification via system message.""" + from litellm.llms.openai.responses.guardrail_translation.handler import ( + OpenAIResponsesHandler, + ) + from litellm.types.utils import CallTypes + + mock_mappings = {CallTypes.responses: OpenAIResponsesHandler} + + with patch( + "litellm.llms.load_guardrail_translation_mappings", + return_value=mock_mappings, + ): + result = await complexity_router.async_pre_routing_hook( + model="test-model", + request_kwargs={ + "input": "Write merge sort", + "instructions": "You are an expert Python developer. Use advanced algorithms and optimize for performance.", + }, + messages=None, + ) + + assert result is not None + assert result.model is not None + + +class TestExtractUserMessageAndSystemPrompt: + """Test the _extract_user_message_and_system_prompt static method.""" + + def test_should_extract_user_message(self): + """Test extraction of the last user message.""" + messages = [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi!"}, + {"role": "user", "content": "How are you?"}, + ] + user_msg, sys_prompt = ComplexityRouter._extract_user_message_and_system_prompt( + messages + ) + assert user_msg == "How are you?" + assert sys_prompt == "You are helpful." + + def test_should_handle_no_user_message(self): + """Test when there is no user message.""" + messages = [ + {"role": "system", "content": "You are helpful."}, + {"role": "assistant", "content": "Hi!"}, + ] + user_msg, sys_prompt = ComplexityRouter._extract_user_message_and_system_prompt( + messages + ) + assert user_msg is None + assert sys_prompt == "You are helpful." + + def test_should_handle_multipart_content(self): + """Test extraction from multipart content messages.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image"}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/img.png"}, + }, + ], + } + ] + user_msg, sys_prompt = ComplexityRouter._extract_user_message_and_system_prompt( + messages + ) + assert user_msg == "Describe this image" + assert sys_prompt is None + + def test_should_handle_empty_messages(self): + """Test with empty messages list.""" + user_msg, sys_prompt = ComplexityRouter._extract_user_message_and_system_prompt( + [] + ) + assert user_msg is None + assert sys_prompt is None diff --git a/tests/test_litellm/router_strategy/test_quality_router.py b/tests/test_litellm/router_strategy/test_quality_router.py new file mode 100644 index 00000000000..01574cb980d --- /dev/null +++ b/tests/test_litellm/router_strategy/test_quality_router.py @@ -0,0 +1,1033 @@ +""" +Tests for the QualityRouter. + +Covers: +- Tier index construction from `model_info.litellm_routing_preferences`. +- Quality-tier resolution (exact, round-up, default fallback). +- Keyword override (match, tiebreaking by quality + price). +- Pre-routing hook end-to-end. +- Decision metadata stash + Router.set_response_headers lift. +""" + +import os +import sys +from typing import Any, Dict, List +from unittest.mock import MagicMock + +import pytest + +sys.path.insert(0, os.path.abspath("../../..")) + +from litellm.router_strategy.quality_router.config import ( + DEFAULT_COMPLEXITY_TO_QUALITY, +) +from litellm.router_strategy.quality_router.quality_router import QualityRouter + + +def _make_model_list(spec: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Build a router model_list from a compact spec. + + spec entry shape: { + "model_name": str, + "quality_tier": Optional[int], + "keywords": Optional[List[str]], + "order": Optional[int], + "input_cost_per_token": Optional[float], + } + If quality_tier is None, the deployment is created without + `litellm_routing_preferences`. + """ + out: List[Dict[str, Any]] = [] + for entry in spec: + model_info: Dict[str, Any] = {"id": f"id-{entry['model_name']}"} + if entry.get("quality_tier") is not None: + prefs: Dict[str, Any] = {"quality_tier": entry["quality_tier"]} + if "keywords" in entry: + prefs["keywords"] = entry["keywords"] + if "order" in entry: + prefs["order"] = entry["order"] + model_info["litellm_routing_preferences"] = prefs + if "input_cost_per_token" in entry: + model_info["input_cost_per_token"] = entry["input_cost_per_token"] + out.append( + { + "model_name": entry["model_name"], + "litellm_params": {"model": f"openai/{entry['model_name']}"}, + "model_info": model_info, + } + ) + return out + + +@pytest.fixture +def four_tier_model_list() -> List[Dict[str, Any]]: + """A standard haiku(1)/sonnet(2)/opus(3)/opus-next(4) model list.""" + return _make_model_list( + [ + {"model_name": "haiku", "quality_tier": 1}, + {"model_name": "sonnet", "quality_tier": 2}, + {"model_name": "opus", "quality_tier": 3}, + {"model_name": "opus-next", "quality_tier": 4}, + ] + ) + + +@pytest.fixture +def mock_router(four_tier_model_list): + """A MagicMock router preloaded with the four-tier model list.""" + router = MagicMock() + router.model_list = four_tier_model_list + return router + + +@pytest.fixture +def quality_router(mock_router) -> QualityRouter: + """Default QualityRouter wired to all four tiers.""" + config = { + "available_models": ["haiku", "sonnet", "opus", "opus-next"], + "complexity_to_quality": DEFAULT_COMPLEXITY_TO_QUALITY, + } + return QualityRouter( + model_name="quality-router-test", + litellm_router_instance=mock_router, + default_model="haiku", + quality_router_config=config, + ) + + +# ─── Tier index ───────────────────────────────────────────────────────────── + + +class TestTierIndex: + def test_builds_correct_tier_to_models_map(self, quality_router): + assert quality_router._tier_to_models == { + 1: ["haiku"], + 2: ["sonnet"], + 3: ["opus"], + 4: ["opus-next"], + } + + def test_ignores_models_not_in_available_models(self, four_tier_model_list): + # Add a model the config doesn't list — it should be ignored. + extra = _make_model_list([{"model_name": "ghost", "quality_tier": 5}]) + router = MagicMock() + router.model_list = four_tier_model_list + extra + + qr = QualityRouter( + model_name="qr", + litellm_router_instance=router, + default_model="haiku", + quality_router_config={ + "available_models": ["haiku", "sonnet", "opus", "opus-next"] + }, + ) + + for models in qr._tier_to_models.values(): + assert "ghost" not in models + + def test_raises_when_routing_preferences_missing(self): + # `sonnet` is in available_models but has no preferences. + ml = _make_model_list( + [ + {"model_name": "haiku", "quality_tier": 1}, + {"model_name": "sonnet", "quality_tier": None}, + ] + ) + router = MagicMock() + router.model_list = ml + + # Construction succeeds (tier index is lazy); the error surfaces on + # first use so the router entry doesn't have to appear after all of + # its referenced models in config.yaml. + qr = QualityRouter( + model_name="qr", + litellm_router_instance=router, + default_model="haiku", + quality_router_config={"available_models": ["haiku", "sonnet"]}, + ) + with pytest.raises(ValueError, match="sonnet"): + _ = qr._tier_to_models + + +# ─── Resolve model for quality tier ───────────────────────────────────────── + + +class TestResolveModelForQualityTier: + def test_exact_match(self, quality_router): + assert quality_router._resolve_model_for_quality_tier(2) == "sonnet" + assert quality_router._resolve_model_for_quality_tier(4) == "opus-next" + + def test_rounds_up_when_tier_missing(self, mock_router): + # Available tiers: 1, 3, 4. Asking for 2 should round up to 3. + spec = [ + {"model_name": "haiku", "quality_tier": 1}, + {"model_name": "opus", "quality_tier": 3}, + {"model_name": "opus-next", "quality_tier": 4}, + ] + router = MagicMock() + router.model_list = _make_model_list(spec) + + qr = QualityRouter( + model_name="qr", + litellm_router_instance=router, + default_model="haiku", + quality_router_config={"available_models": ["haiku", "opus", "opus-next"]}, + ) + + assert qr._resolve_model_for_quality_tier(2) == "opus" + + def test_rounds_down_when_no_higher_tier_exists(self): + # Only tier 1 available. Asking for tier 4 rounds up (nothing), then + # rounds DOWN to the closest lower tier — tier 1. + spec = [{"model_name": "haiku", "quality_tier": 1}] + router = MagicMock() + router.model_list = _make_model_list(spec) + + qr = QualityRouter( + model_name="qr", + litellm_router_instance=router, + default_model="emergency-default", + quality_router_config={"available_models": ["haiku"]}, + ) + + assert qr._resolve_model_for_quality_tier(4) == "haiku" + + def test_rounds_down_prefers_closest_lower_tier(self): + # Available: 1, 2. Asking for 4 rounds down to tier 2 (not tier 1). + spec = [ + {"model_name": "haiku", "quality_tier": 1}, + {"model_name": "sonnet", "quality_tier": 2}, + ] + router = MagicMock() + router.model_list = _make_model_list(spec) + + qr = QualityRouter( + model_name="qr", + litellm_router_instance=router, + default_model="emergency-default", + quality_router_config={"available_models": ["haiku", "sonnet"]}, + ) + + assert qr._resolve_model_for_quality_tier(4) == "sonnet" + + def test_prefers_round_up_over_round_down(self): + # Available: 1, 3. Asking for 2 rounds UP to 3, not DOWN to 1. + spec = [ + {"model_name": "haiku", "quality_tier": 1}, + {"model_name": "opus", "quality_tier": 3}, + ] + router = MagicMock() + router.model_list = _make_model_list(spec) + + qr = QualityRouter( + model_name="qr", + litellm_router_instance=router, + default_model="emergency-default", + quality_router_config={"available_models": ["haiku", "opus"]}, + ) + + assert qr._resolve_model_for_quality_tier(2) == "opus" + + +# ─── RoutingPreferences validation ───────────────────────────────────────── + + +class TestRoutingPreferencesValidation: + def test_invalid_quality_tier_type_raises_clear_error(self): + # quality_tier must be an int — pass a non-coercible string. + ml = [ + { + "model_name": "haiku", + "litellm_params": {"model": "openai/gpt-4o-mini"}, + "model_info": { + "id": "id-haiku", + "litellm_routing_preferences": {"quality_tier": "not-an-int"}, + }, + } + ] + router = MagicMock() + router.model_list = ml + + qr = QualityRouter( + model_name="qr", + litellm_router_instance=router, + default_model="haiku", + quality_router_config={"available_models": ["haiku"]}, + ) + with pytest.raises(ValueError, match="invalid litellm_routing_preferences"): + _ = qr._tier_to_models + + +# ─── Config-ordering independence (lazy index build) ─────────────────────── + + +class TestConfigOrderingIndependence: + def test_router_can_be_instantiated_before_its_targets_exist(self): + # Build a router instance whose referenced model_list is EMPTY at + # construction time (simulating a config where the router entry + # appears before its target deployments). The tier index must not be + # built eagerly — it's deferred until first use. + router = MagicMock() + router.model_list = [] # <- targets haven't been added yet + + qr = QualityRouter( + model_name="qr", + litellm_router_instance=router, + default_model="haiku", + quality_router_config={"available_models": ["haiku", "sonnet", "opus"]}, + ) + + # Now the targets come online. This mirrors the incremental add by + # `Router._create_deployment`. + router.model_list = _make_model_list( + [ + {"model_name": "haiku", "quality_tier": 1}, + {"model_name": "sonnet", "quality_tier": 2}, + {"model_name": "opus", "quality_tier": 3}, + ] + ) + + # First access triggers the index build and sees the full list. + assert qr._tier_to_models == { + 1: ["haiku"], + 2: ["sonnet"], + 3: ["opus"], + } + + +# ─── Router.set_model_list resets quality_routers (hot reload) ───────────── + + +class TestSetModelListResetsQualityRouters: + def test_set_model_list_clears_quality_routers_registry(self): + from litellm.router import Router + + router = Router( + model_list=[ + { + "model_name": "haiku", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "sk-test", + }, + "model_info": {"litellm_routing_preferences": {"quality_tier": 1}}, + }, + { + "model_name": "my-qr", + "litellm_params": { + "model": "auto_router/quality_router", + "quality_router_default_model": "haiku", + "quality_router_config": {"available_models": ["haiku"]}, + }, + }, + ] + ) + + assert "my-qr" in router.quality_routers + + # Hot-reload with a new model_list that doesn't define the router. + router.set_model_list( + [ + { + "model_name": "haiku", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "sk-test", + }, + } + ] + ) + + # Stale router from before must be cleared. + assert "my-qr" not in router.quality_routers + + +# ─── Pre-routing hook ─────────────────────────────────────────────────────── + + +class TestPreRoutingHook: + @pytest.mark.asyncio + async def test_simple_message_routes_to_tier_1(self, quality_router): + messages = [{"role": "user", "content": "hi"}] + resp = await quality_router.async_pre_routing_hook( + model="quality-router-test", + request_kwargs={}, + messages=messages, + ) + assert resp is not None + assert resp.model == "haiku" + + @pytest.mark.asyncio + async def test_reasoning_message_routes_to_tier_4(self, quality_router): + # Two reasoning markers triggers ComplexityTier.REASONING → quality 4. + messages = [ + { + "role": "user", + "content": ( + "Think step by step and reason through this problem. " + "Analyze this carefully and break down each component." + ), + } + ] + resp = await quality_router.async_pre_routing_hook( + model="quality-router-test", + request_kwargs={}, + messages=messages, + ) + assert resp is not None + assert resp.model == "opus-next" + + @pytest.mark.asyncio + async def test_empty_messages_returns_none(self, quality_router): + resp = await quality_router.async_pre_routing_hook( + model="quality-router-test", + request_kwargs={}, + messages=[], + ) + assert resp is None + + @pytest.mark.asyncio + async def test_only_system_message_routes_to_default(self, quality_router): + messages = [{"role": "system", "content": "You are a helpful assistant."}] + resp = await quality_router.async_pre_routing_hook( + model="quality-router-test", + request_kwargs={}, + messages=messages, + ) + assert resp is not None + assert resp.model == "haiku" # the configured default_model + + +# ─── Keyword override ────────────────────────────────────────────────────── + + +@pytest.fixture +def keyword_router(): + """ + Router where multiple deployments declare overlapping keywords so we can + exercise the (quality DESC, price ASC) tiebreak. + + - cheap-coder tier 2, keywords [code, python], cost 0.000001 + - smart-coder tier 3, keywords [code, python], cost 0.000010 + - law-bot tier 2, keywords [legal, contract], cost 0.000005 + - default-haiku tier 1, no keywords, cost 0.0000005 + """ + spec = [ + { + "model_name": "default-haiku", + "quality_tier": 1, + "keywords": [], + "input_cost_per_token": 0.0000005, + }, + { + "model_name": "cheap-coder", + "quality_tier": 2, + "keywords": ["code", "python"], + "input_cost_per_token": 0.000001, + }, + { + "model_name": "smart-coder", + "quality_tier": 3, + "keywords": ["code", "python"], + "input_cost_per_token": 0.000010, + }, + { + "model_name": "law-bot", + "quality_tier": 2, + "keywords": ["legal", "contract"], + "input_cost_per_token": 0.000005, + }, + ] + router = MagicMock() + router.model_list = _make_model_list(spec) + return QualityRouter( + model_name="qr", + litellm_router_instance=router, + default_model="default-haiku", + quality_router_config={ + "available_models": [ + "default-haiku", + "cheap-coder", + "smart-coder", + "law-bot", + ], + }, + ) + + +class TestKeywordOverride: + def test_no_keyword_in_message_returns_none(self, keyword_router): + assert keyword_router._keyword_override("hello there") is None + + def test_single_match_returns_that_model(self, keyword_router): + # Only law-bot declares "legal". + assert keyword_router._keyword_override("review this legal doc") == ( + "law-bot", + "legal", + ) + + def test_case_insensitive_match(self, keyword_router): + assert keyword_router._keyword_override("LEGAL question") == ( + "law-bot", + "legal", + ) + + def test_overlap_picks_highest_quality_tier(self, keyword_router): + # Both cheap-coder (tier 2) and smart-coder (tier 3) declare "code". + # Quality wins over price → smart-coder. + assert keyword_router._keyword_override("write some code for me") == ( + "smart-coder", + "code", + ) + + def test_same_tier_picks_cheapest(self): + # Two models at the same tier, both matching "data" — cheapest wins. + spec = [ + { + "model_name": "expensive", + "quality_tier": 2, + "keywords": ["data"], + "input_cost_per_token": 0.000050, + }, + { + "model_name": "cheap", + "quality_tier": 2, + "keywords": ["data"], + "input_cost_per_token": 0.000005, + }, + ] + router = MagicMock() + router.model_list = _make_model_list(spec) + qr = QualityRouter( + model_name="qr", + litellm_router_instance=router, + default_model="cheap", + quality_router_config={"available_models": ["expensive", "cheap"]}, + ) + match = qr._keyword_override("show me the data") + assert match == ("cheap", "data") + + def test_unpriced_loses_to_priced_at_same_tier(self): + # Same quality tier, one has cost, one doesn't → priced wins. + spec = [ + { + "model_name": "no-price", + "quality_tier": 2, + "keywords": ["data"], + # input_cost_per_token deliberately omitted + }, + { + "model_name": "with-price", + "quality_tier": 2, + "keywords": ["data"], + "input_cost_per_token": 0.000005, + }, + ] + router = MagicMock() + router.model_list = _make_model_list(spec) + qr = QualityRouter( + model_name="qr", + litellm_router_instance=router, + default_model="no-price", + quality_router_config={"available_models": ["no-price", "with-price"]}, + ) + match = qr._keyword_override("show me the data") + assert match == ("with-price", "data") + + @pytest.mark.asyncio + async def test_hook_short_circuits_complexity_on_keyword_match( + self, keyword_router + ): + # A reasoning-style prompt would normally route to a high-quality model + # via the complexity flow — but the keyword "code" should short-circuit + # to smart-coder (highest tier among "code" models). + messages = [ + { + "role": "user", + "content": ( + "Think step by step and reason through this code problem. " + "Analyze this carefully and break down each component." + ), + } + ] + request_kwargs: Dict[str, Any] = {} + resp = await keyword_router.async_pre_routing_hook( + model="qr", + request_kwargs=request_kwargs, + messages=messages, + ) + assert resp is not None + assert resp.model == "smart-coder" + + decision = request_kwargs["metadata"]["quality_router_decision"] + assert decision["routed_via"] == "keyword" + assert decision["matched_keyword"] == "code" + assert decision["complexity_tier"] is None # short-circuited + + def test_quality_wins_over_explicit_order(self): + # Quality always beats order. A tier-3 model with no `order` wins over + # a tier-2 model with `order=1`. + spec = [ + { + "model_name": "ordered-tier2", + "quality_tier": 2, + "keywords": ["code"], + "order": 1, + "input_cost_per_token": 0.000010, + }, + { + "model_name": "implicit-tier3", + "quality_tier": 3, + "keywords": ["code"], + "input_cost_per_token": 0.000005, + }, + ] + router = MagicMock() + router.model_list = _make_model_list(spec) + qr = QualityRouter( + model_name="qr", + litellm_router_instance=router, + default_model="ordered-tier2", + quality_router_config={ + "available_models": ["ordered-tier2", "implicit-tier3"] + }, + ) + match = qr._keyword_override("write some code") + assert match == ("implicit-tier3", "code") + + def test_order_breaks_tie_within_same_quality_tier(self): + # Two tier-3 models, both match "code". Lower `order` wins. + spec = [ + { + "model_name": "preferred", + "quality_tier": 3, + "keywords": ["code"], + "order": 1, + "input_cost_per_token": 0.000050, # more expensive + }, + { + "model_name": "default-tier3", + "quality_tier": 3, + "keywords": ["code"], + "input_cost_per_token": 0.000005, # cheaper + }, + ] + router = MagicMock() + router.model_list = _make_model_list(spec) + qr = QualityRouter( + model_name="qr", + litellm_router_instance=router, + default_model="default-tier3", + quality_router_config={"available_models": ["preferred", "default-tier3"]}, + ) + match = qr._keyword_override("write some code") + assert match == ("preferred", "code") + + def test_explicit_order_overrides_price(self): + # Same tier, but the more expensive one has a lower `order` and wins. + spec = [ + { + "model_name": "expensive-but-preferred", + "quality_tier": 2, + "keywords": ["data"], + "order": 1, + "input_cost_per_token": 0.000050, + }, + { + "model_name": "cheap-default", + "quality_tier": 2, + "keywords": ["data"], + "input_cost_per_token": 0.000005, + }, + ] + router = MagicMock() + router.model_list = _make_model_list(spec) + qr = QualityRouter( + model_name="qr", + litellm_router_instance=router, + default_model="cheap-default", + quality_router_config={ + "available_models": ["expensive-but-preferred", "cheap-default"] + }, + ) + match = qr._keyword_override("show me the data") + assert match == ("expensive-but-preferred", "data") + + def test_lower_order_wins_between_two_explicitly_ordered(self): + spec = [ + { + "model_name": "second", + "quality_tier": 2, + "keywords": ["data"], + "order": 5, + }, + { + "model_name": "first", + "quality_tier": 2, + "keywords": ["data"], + "order": 1, + }, + ] + router = MagicMock() + router.model_list = _make_model_list(spec) + qr = QualityRouter( + model_name="qr", + litellm_router_instance=router, + default_model="first", + quality_router_config={"available_models": ["first", "second"]}, + ) + match = qr._keyword_override("show me the data") + assert match == ("first", "data") + + def test_same_order_falls_through_to_quality_then_price(self): + # All three models share order=1 → tiebreak falls through to + # (quality DESC, cost ASC). + spec = [ + { + "model_name": "low-tier", + "quality_tier": 1, + "keywords": ["data"], + "order": 1, + "input_cost_per_token": 0.000001, + }, + { + "model_name": "high-tier-cheap", + "quality_tier": 3, + "keywords": ["data"], + "order": 1, + "input_cost_per_token": 0.000005, + }, + { + "model_name": "high-tier-expensive", + "quality_tier": 3, + "keywords": ["data"], + "order": 1, + "input_cost_per_token": 0.000050, + }, + ] + router = MagicMock() + router.model_list = _make_model_list(spec) + qr = QualityRouter( + model_name="qr", + litellm_router_instance=router, + default_model="low-tier", + quality_router_config={ + "available_models": [ + "low-tier", + "high-tier-cheap", + "high-tier-expensive", + ] + }, + ) + match = qr._keyword_override("show me the data") + assert match == ("high-tier-cheap", "data") + + def test_order_is_used_in_tier_resolution_too(self): + # Two models at the same tier. Explicit `order=1` on the second one + # should make _resolve_model_for_quality_tier(2) pick it. + spec = [ + { + "model_name": "default-pick", + "quality_tier": 2, + }, + { + "model_name": "preferred-pick", + "quality_tier": 2, + "order": 1, + }, + ] + router = MagicMock() + router.model_list = _make_model_list(spec) + qr = QualityRouter( + model_name="qr", + litellm_router_instance=router, + default_model="default-pick", + quality_router_config={ + "available_models": ["default-pick", "preferred-pick"] + }, + ) + assert qr._resolve_model_for_quality_tier(2) == "preferred-pick" + + @pytest.mark.asyncio + async def test_hook_falls_back_to_complexity_when_no_keyword(self, keyword_router): + # No declared keyword in the message → complexity-based routing. + # "hi" is SIMPLE → quality 1 → default-haiku (the only tier-1 model). + messages = [{"role": "user", "content": "hi"}] + request_kwargs: Dict[str, Any] = {} + resp = await keyword_router.async_pre_routing_hook( + model="qr", + request_kwargs=request_kwargs, + messages=messages, + ) + assert resp is not None + assert resp.model == "default-haiku" + + decision = request_kwargs["metadata"]["quality_router_decision"] + assert decision["routed_via"] == "quality_tier" + assert decision["matched_keyword"] is None + assert decision["complexity_tier"] == "SIMPLE" + + +# ─── Routing-decision metadata (powers x-litellm-quality-router-* headers) ── + + +class TestDecisionMetadata: + @pytest.mark.asyncio + async def test_hook_stashes_decision_in_request_kwargs_metadata( + self, quality_router + ): + # Reasoning prompt → REASONING → quality tier 4 → opus-next. + messages = [ + { + "role": "user", + "content": ( + "Think step by step and reason through this problem. " + "Analyze this carefully and break down each component." + ), + } + ] + request_kwargs: Dict[str, Any] = {} + + resp = await quality_router.async_pre_routing_hook( + model="quality-router-test", + request_kwargs=request_kwargs, + messages=messages, + ) + assert resp is not None and resp.model == "opus-next" + + decision = request_kwargs["metadata"]["quality_router_decision"] + assert decision["routed_model"] == "opus-next" + assert decision["quality_tier"] == 4 + assert decision["complexity_tier"] == "REASONING" + assert decision["router_model_name"] == "quality-router-test" + assert decision["routed_via"] == "quality_tier" + assert decision["matched_keyword"] is None + + @pytest.mark.asyncio + async def test_decision_metadata_preserves_existing_metadata(self, quality_router): + request_kwargs: Dict[str, Any] = { + "metadata": {"trace_id": "abc-123", "user_id": "u-1"} + } + + await quality_router.async_pre_routing_hook( + model="quality-router-test", + request_kwargs=request_kwargs, + messages=[{"role": "user", "content": "hi"}], + ) + + # Existing metadata keys are intact and the decision is added alongside. + assert request_kwargs["metadata"]["trace_id"] == "abc-123" + assert request_kwargs["metadata"]["user_id"] == "u-1" + assert "quality_router_decision" in request_kwargs["metadata"] + + +# ─── Router.set_response_headers lifts decision into x-litellm-quality-* ──── + + +class TestSetResponseHeadersLiftsDecision: + """ + Verify the Router.set_response_headers helper turns a stashed quality-router + decision into x-litellm-quality-router-* headers on the response. + """ + + @pytest.mark.asyncio + async def test_lifts_decision_into_additional_headers(self): + from pydantic import BaseModel + + from litellm.router import Router + + class FakeResponse(BaseModel): + model_config = {"arbitrary_types_allowed": True} + _hidden_params: Dict[str, Any] = {} + + # Build a real Router with a tiny model_list — enough to satisfy + # set_response_headers without needing the rest of the router stack. + router = Router( + model_list=[ + { + "model_name": "haiku", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "sk-test", + }, + } + ] + ) + + response = FakeResponse() + response._hidden_params = {} + + request_kwargs = { + "metadata": { + "quality_router_decision": { + "router_model_name": "qr", + "routed_model": "smart-coder", + "routed_via": "keyword", + "matched_keyword": "code", + "quality_tier": 3, + "complexity_tier": None, + } + } + } + + await router.set_response_headers( + response=response, + model_group="qr", + request_kwargs=request_kwargs, + ) + + headers = response._hidden_params["additional_headers"] + assert headers["x-litellm-quality-router-model"] == "smart-coder" + assert headers["x-litellm-quality-router-tier"] == "3" + assert headers["x-litellm-quality-router-via"] == "keyword" + assert headers["x-litellm-quality-router-keyword"] == "code" + # Keyword route short-circuits classification → no complexity header. + assert "x-litellm-quality-router-complexity" not in headers + # Existing x-litellm-model-group behavior is unchanged. + assert headers["x-litellm-model-group"] == "qr" + + @pytest.mark.asyncio + async def test_quality_tier_route_emits_complexity_not_keyword(self): + from pydantic import BaseModel + + from litellm.router import Router + + class FakeResponse(BaseModel): + model_config = {"arbitrary_types_allowed": True} + _hidden_params: Dict[str, Any] = {} + + router = Router( + model_list=[ + { + "model_name": "haiku", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "sk-test", + }, + } + ] + ) + + response = FakeResponse() + response._hidden_params = {} + + request_kwargs = { + "metadata": { + "quality_router_decision": { + "router_model_name": "qr", + "routed_model": "haiku", + "routed_via": "quality_tier", + "matched_keyword": None, + "quality_tier": 1, + "complexity_tier": "SIMPLE", + } + } + } + + await router.set_response_headers( + response=response, + model_group="qr", + request_kwargs=request_kwargs, + ) + + headers = response._hidden_params["additional_headers"] + assert headers["x-litellm-quality-router-via"] == "quality_tier" + assert headers["x-litellm-quality-router-complexity"] == "SIMPLE" + # Quality-tier route → no keyword header. + assert "x-litellm-quality-router-keyword" not in headers + + @pytest.mark.asyncio + async def test_no_decision_leaves_quality_router_headers_unset(self): + from pydantic import BaseModel + + from litellm.router import Router + + class FakeResponse(BaseModel): + model_config = {"arbitrary_types_allowed": True} + _hidden_params: Dict[str, Any] = {} + + router = Router( + model_list=[ + { + "model_name": "haiku", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "sk-test", + }, + } + ] + ) + + response = FakeResponse() + response._hidden_params = {} + + await router.set_response_headers( + response=response, + model_group="haiku", + request_kwargs={}, # no quality_router_decision + ) + + headers = response._hidden_params["additional_headers"] + assert "x-litellm-quality-router-model" not in headers + assert "x-litellm-quality-router-tier" not in headers + + +class TestRouterQualityDeploymentMethods: + """Tests for Router._is_quality_router_deployment and Router.init_quality_router_deployment.""" + + def test_is_quality_router_deployment_true(self): + """_is_quality_router_deployment returns True for quality router models.""" + from litellm.router import Router + from litellm.types.router import LiteLLM_Params + + router = Router( + model_list=[ + { + "model_name": "gpt-4o-mini", + "litellm_params": {"model": "openai/gpt-4o-mini"}, + } + ] + ) + params = LiteLLM_Params(model="auto_router/quality_router/my-router") + assert router._is_quality_router_deployment(params) is True + + def test_is_quality_router_deployment_false(self): + """_is_quality_router_deployment returns False for regular models.""" + from litellm.router import Router + from litellm.types.router import LiteLLM_Params + + router = Router( + model_list=[ + { + "model_name": "gpt-4o-mini", + "litellm_params": {"model": "openai/gpt-4o-mini"}, + } + ] + ) + params = LiteLLM_Params(model="openai/gpt-4o-mini") + assert router._is_quality_router_deployment(params) is False + + def test_init_quality_router_deployment(self): + """init_quality_router_deployment registers a QualityRouter.""" + from litellm.router import Router + from litellm.types.router import Deployment, LiteLLM_Params + + router = Router( + model_list=[ + { + "model_name": "gpt-4o-mini", + "litellm_params": {"model": "openai/gpt-4o-mini"}, + } + ] + ) + deployment = Deployment( + model_name="auto_router/quality_router/test-router", + litellm_params=LiteLLM_Params( + model="auto_router/quality_router/test-router", + quality_router_default_model="gpt-4o-mini", + ), + model_info={"id": "test-id"}, + ) + router.init_quality_router_deployment(deployment) + assert "auto_router/quality_router/test-router" in router.quality_routers diff --git a/tests/test_litellm/test_compression.py b/tests/test_litellm/test_compression.py index 13dda0cbcbc..4fbcd4ed30d 100644 --- a/tests/test_litellm/test_compression.py +++ b/tests/test_litellm/test_compression.py @@ -3,6 +3,7 @@ Unit tests for litellm.compress(). """ import os +import importlib import pytest @@ -12,6 +13,10 @@ from litellm.compression.scoring.embedding_scorer import embedding_score_message from litellm.compression.content_detection import detect_content_type from litellm.compression.message_stubbing import extract_key, stub_message from litellm.compression.retrieval_tool import build_retrieval_tool +from litellm.types.utils import CallTypes + +CALL_TYPE = CallTypes.completion +ANTHROPIC_CALL_TYPE = CallTypes.anthropic_messages # --------------------------------------------------------------------------- @@ -149,7 +154,7 @@ def test_retrieval_tool_description_lists_keys(): def test_compress_below_trigger_passthrough(): messages = [{"role": "user", "content": "hello"}] - result = litellm.compress(messages, model="gpt-4o") + result = litellm.compress(messages, model="gpt-4o", call_type=CALL_TYPE) assert result["messages"] == messages assert result["cache"] == {} assert result["tools"] == [] @@ -178,6 +183,7 @@ def test_compress_above_trigger(): result = litellm.compress( big_messages, model="gpt-4o", + call_type=CALL_TYPE, compression_trigger=1000, compression_target=500, ) @@ -189,13 +195,62 @@ def test_compress_above_trigger(): assert result["tools"][0]["function"]["name"] == "litellm_content_retrieve" +def test_compress_anthropic_list_content_is_boundary_stable(): + messages = [ + {"role": "system", "content": [{"type": "text", "text": "System prompt"}]}, + { + "role": "user", + "content": [ + {"type": "text", "text": "# a.py\n" + "alpha " * 2000}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/a.png"}, + }, + ], + }, + { + "role": "user", + "content": [ + {"type": "text", "text": "# b.py\n" + "beta " * 2000}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/b.png"}, + }, + ], + }, + { + "role": "user", + "content": [{"type": "text", "text": "Fix alpha bug in a.py"}], + }, + ] + + result = litellm.compress( + messages=messages, + model="claude-sonnet-4-20250514", + call_type=ANTHROPIC_CALL_TYPE, + compression_trigger=1000, + compression_target=500, + ) + + assert result["compressed_tokens"] < result["original_tokens"] + assert len(result["messages"]) == len(messages) + assert [m["role"] for m in result["messages"]] == [m["role"] for m in messages] + assert len(result["cache"]) > 0 + assert len(result["tools"]) == 1 + assert result["tools"][0]["type"] == "custom" + assert result["tools"][0]["name"] == "litellm_content_retrieve" + assert "input_schema" in result["tools"][0] + + def test_compress_preserves_system_message(): messages = [ {"role": "system", "content": "System prompt. " * 500}, {"role": "user", "content": "Large file content. " * 5000}, {"role": "user", "content": "Fix the bug"}, ] - result = litellm.compress(messages, model="gpt-4o", compression_trigger=1000) + result = litellm.compress( + messages, model="gpt-4o", call_type=CALL_TYPE, compression_trigger=1000 + ) assert result["messages"][0]["role"] == "system" assert "System prompt" in result["messages"][0]["content"] @@ -205,7 +260,9 @@ def test_compress_preserves_last_user_message(): {"role": "user", "content": "Big context " * 5000}, {"role": "user", "content": "Fix the bug in auth.py"}, ] - result = litellm.compress(messages, model="gpt-4o", compression_trigger=1000) + result = litellm.compress( + messages, model="gpt-4o", call_type=CALL_TYPE, compression_trigger=1000 + ) last_user = [m for m in result["messages"] if m["role"] == "user"][-1] assert "Fix the bug in auth.py" in last_user["content"] @@ -216,7 +273,9 @@ def test_compress_preserves_last_assistant_message(): {"role": "assistant", "content": "I'll help with that. " * 2000}, {"role": "user", "content": "Now fix the bug"}, ] - result = litellm.compress(messages, model="gpt-4o", compression_trigger=1000) + result = litellm.compress( + messages, model="gpt-4o", call_type=CALL_TYPE, compression_trigger=1000 + ) assistant_msgs = [m for m in result["messages"] if m["role"] == "assistant"] assert len(assistant_msgs) >= 1 # The last assistant message should be preserved (not stubbed) @@ -229,7 +288,9 @@ def test_cache_keys_match_stubs(): {"role": "user", "content": "# auth.py\n" + "code " * 5000}, {"role": "user", "content": "Fix it"}, ] - result = litellm.compress(messages, model="gpt-4o", compression_trigger=1000) + result = litellm.compress( + messages, model="gpt-4o", call_type=CALL_TYPE, compression_trigger=1000 + ) if result["tools"]: tool_desc = result["tools"][0]["function"]["description"] for key in result["cache"]: @@ -242,11 +303,75 @@ def test_compress_default_target(): {"role": "user", "content": "content " * 5000}, {"role": "user", "content": "query"}, ] - result = litellm.compress(messages, model="gpt-4o", compression_trigger=2000) + result = litellm.compress( + messages, model="gpt-4o", call_type=CALL_TYPE, compression_trigger=2000 + ) # Should have compressed — target = 1000 assert result["compressed_tokens"] <= result["original_tokens"] +def test_compress_nested_tool_result_extracts_text_only(): + messages = [ + {"role": "system", "content": [{"type": "text", "text": "System rules"}]}, + { + "role": "user", + "content": [ + {"type": "text", "text": "prefix"}, + { + "type": "tool_result", + "tool_use_id": "toolu_1", + "content": [ + {"type": "text", "text": "nested text fragment"}, + { + "type": "image_url", + "image_url": { + "url": "https://example.com/secret-tool.png", + }, + }, + ], + }, + { + "type": "image_url", + "image_url": {"url": "https://example.com/top.png"}, + }, + {"type": "text", "text": " " + ("irrelevant " * 3000)}, + ], + }, + { + "role": "user", + "content": [{"type": "text", "text": "final query that must remain"}], + }, + ] + + result = litellm.compress( + messages=messages, + model="claude-sonnet-4-20250514", + call_type=ANTHROPIC_CALL_TYPE, + compression_trigger=500, + compression_target=100, + ) + + cached_text = " ".join(result["cache"].values()) + assert "nested text fragment" in cached_text + assert "https://example.com/secret-tool.png" not in cached_text + assert "https://example.com/top.png" not in cached_text + + +def test_compress_default_call_type_is_completion(): + result = litellm.compress( + messages=[ + {"role": "user", "content": "Large context " * 4000}, + {"role": "user", "content": "query"}, + ], + model="gpt-4o", + compression_trigger=1000, + compression_target=500, + ) + + assert result["compressed_tokens"] <= result["original_tokens"] + assert isinstance(result["tools"], list) + + def test_compress_forwards_embedding_model_params(monkeypatch): captured = {} @@ -269,6 +394,7 @@ def test_compress_forwards_embedding_model_params(monkeypatch): {"role": "user", "content": "Fix auth"}, ], model="gpt-4o", + call_type=CALL_TYPE, compression_trigger=1000, embedding_model="text-embedding-3-small", embedding_model_params={"api_base": "https://example-embeddings.test"}, @@ -326,6 +452,7 @@ def test_embedding_scorer(): {"role": "user", "content": "Fix auth"}, ], model="gpt-4o", + call_type=CALL_TYPE, compression_trigger=1000, embedding_model="text-embedding-3-small", ) @@ -346,8 +473,9 @@ def test_simple_compression(final_user_message, expected_content): {"role": "user", "content": "Unrelated cooking recipes " * 2000}, {"role": "user", "content": final_user_message}, ] - result = litellm.compress(messages, model="gpt-4o", compression_trigger=1000) - print(result["messages"]) + result = litellm.compress( + messages, model="gpt-4o", call_type=CALL_TYPE, compression_trigger=1000 + ) if expected_content == "Unrelated cooking recipes ": assert "Unrelated cooking recipes " in result["messages"][1]["content"] assert "Authentication code " not in result["messages"][0]["content"] @@ -356,3 +484,184 @@ def test_simple_compression(final_user_message, expected_content): assert "Unrelated cooking recipes " not in result["messages"][1]["content"] else: raise ValueError(f"Unexpected expected_content: {expected_content}") + + +def test_compress_anthropic_drops_irrelevant_tool_exchange_span(monkeypatch): + compress_module = importlib.import_module("litellm.compression.compress") + + def fake_bm25_score_messages(query, messages): + assert "final query" in query + assert len(messages) == 5 + # Prefer idx=0 and de-prioritize the tool exchange span (idx=1,2) + return [0.95, 0.01, 0.02, 0.8, 1.0] + + def fake_token_counter(model, messages=None, text=None): + if messages is not None: + return 1000 + if text is None: + return 0 + if "final query" in text: + return 50 + if "assistant_tail" in text: + return 20 + if "other_blob" in text: + return 220 + if "tool_payload_relevant" in text: + return 200 + if text == "": + return 1 + return 10 + + monkeypatch.setattr( + compress_module, "bm25_score_messages", fake_bm25_score_messages + ) + monkeypatch.setattr(compress_module, "token_counter", fake_token_counter) + + messages = [ + {"role": "user", "content": "other_blob " * 300}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_drop", + "name": "litellm_content_retrieve", + "input": {"key": "message_1"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_drop", + "content": [{"type": "text", "text": "tool_payload_relevant"}], + } + ], + }, + {"role": "assistant", "content": "assistant_tail"}, + {"role": "user", "content": "final query"}, + ] + + result = litellm.compress( + messages=messages, + model="claude-sonnet-4-20250514", + call_type=ANTHROPIC_CALL_TYPE, + compression_trigger=100, + compression_target=280, + ) + + # idx=1,2 should be dropped atomically (no orphan tool blocks left behind) + assert len(result["messages"]) == 3 + assert result["messages"][0]["role"] == "user" + assert "other_blob" in result["messages"][0]["content"] + assert result["messages"][1]["content"] == "assistant_tail" + assert result["messages"][2]["content"] == "final query" + assert result["cache"] == {} + + +def test_compress_anthropic_keeps_relevant_tool_exchange_span(monkeypatch): + compress_module = importlib.import_module("litellm.compression.compress") + + def fake_bm25_score_messages(query, messages): + assert "final query" in query + assert len(messages) == 5 + # Prefer the tool exchange span over idx=0 + return [0.05, 0.01, 0.92, 0.8, 1.0] + + def fake_token_counter(model, messages=None, text=None): + if messages is not None: + return 1000 + if text is None: + return 0 + if "final query" in text: + return 50 + if "assistant_tail" in text: + return 20 + if "other_blob" in text: + return 220 + if "tool_payload_relevant" in text: + return 200 + if text == "": + return 1 + return 10 + + monkeypatch.setattr( + compress_module, "bm25_score_messages", fake_bm25_score_messages + ) + monkeypatch.setattr(compress_module, "token_counter", fake_token_counter) + + messages = [ + {"role": "user", "content": "other_blob " * 300}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_keep", + "name": "litellm_content_retrieve", + "input": {"key": "message_1"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_keep", + "content": [{"type": "text", "text": "tool_payload_relevant"}], + } + ], + }, + {"role": "assistant", "content": "assistant_tail"}, + {"role": "user", "content": "final query"}, + ] + + result = litellm.compress( + messages=messages, + model="claude-sonnet-4-20250514", + call_type=ANTHROPIC_CALL_TYPE, + compression_trigger=100, + compression_target=280, + ) + + assert len(result["messages"]) == 5 + assert result["messages"][1]["role"] == "assistant" + assert result["messages"][2]["role"] == "user" + # idx=0 should be compressed instead + assert "litellm_content_retrieve" in result["messages"][0]["content"] + assert len(result["cache"]) == 1 + + +def test_compress_anthropic_malformed_tool_sequence_passes_through(): + messages = [ + {"role": "user", "content": "other_blob " * 300}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_broken", + "name": "litellm_content_retrieve", + "input": {"key": "message_1"}, + } + ], + }, + {"role": "user", "content": [{"type": "text", "text": "missing tool_result"}]}, + {"role": "user", "content": "final query"}, + ] + + result = litellm.compress( + messages=messages, + model="claude-sonnet-4-20250514", + call_type=ANTHROPIC_CALL_TYPE, + compression_trigger=100, + compression_target=280, + ) + + assert result["messages"] == messages + assert result["cache"] == {} + assert result["tools"] == [] + assert result["compression_skipped_reason"] == "invalid_anthropic_tool_sequence" diff --git a/tests/test_litellm/test_dashscope_image_generation.py b/tests/test_litellm/test_dashscope_image_generation.py new file mode 100644 index 00000000000..af95e2ca6b4 --- /dev/null +++ b/tests/test_litellm/test_dashscope_image_generation.py @@ -0,0 +1,401 @@ +""" +Unit tests for DashScope image generation support (qwen-image-2.0, qwen-image-2.0-pro). + +Run in docker: pytest tests/test_litellm/test_dashscope_image_generation.py -v +""" + +import json +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +import litellm +from litellm.llms.dashscope.image_generation.transformation import ( + DashScopeImageGenerationConfig, + DEFAULT_API_BASE, +) +from litellm.types.utils import ImageObject, ImageResponse +from litellm.utils import get_llm_provider + + +# --------------------------------------------------------------------------- +# 1. Provider detection +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "model_string", + [ + "dashscope/qwen-image-2.0", + "dashscope/qwen-image-2.0-pro", + ], +) +def test_get_llm_provider_returns_dashscope(model_string: str): + model, provider, _, _ = get_llm_provider(model_string) + assert provider == "dashscope", f"Expected 'dashscope', got '{provider}'" + assert "qwen-image" in model + + +# --------------------------------------------------------------------------- +# 2. Model info: mode == "image_generation" +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "model_string, custom_provider", + [ + ("dashscope/qwen-image-2.0", "dashscope"), + ("dashscope/qwen-image-2.0-pro", "dashscope"), + ], +) +def test_get_model_info_mode_is_image_generation( + model_string: str, custom_provider: str +): + import os + + prev_env = os.environ.get("LITELLM_LOCAL_MODEL_COST_MAP") + prev_model_cost = litellm.model_cost + try: + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + + info = litellm.get_model_info( + model=model_string, custom_llm_provider=custom_provider + ) + assert ( + info["mode"] == "image_generation" + ), f"Expected mode='image_generation', got '{info['mode']}'" + finally: + if prev_env is None: + os.environ.pop("LITELLM_LOCAL_MODEL_COST_MAP", None) + else: + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = prev_env + litellm.model_cost = prev_model_cost + + +# --------------------------------------------------------------------------- +# 3. Request transformation +# --------------------------------------------------------------------------- + + +class TestDashScopeImageGenerationConfig: + def setup_method(self): + self.cfg = DashScopeImageGenerationConfig() + + def test_get_complete_url_default(self): + url = self.cfg.get_complete_url(None, None, "qwen-image-2.0", {}, {}) + assert url == DEFAULT_API_BASE + + def test_get_complete_url_custom(self): + custom = "https://custom.endpoint/generate" + url = self.cfg.get_complete_url(custom, None, "qwen-image-2.0", {}, {}) + assert url == custom + + def test_validate_environment_sets_auth_header(self): + headers = self.cfg.validate_environment( + headers={}, + model="qwen-image-2.0", + messages=[], + optional_params={}, + litellm_params={}, + api_key="sk-test-key", + ) + assert headers["Authorization"] == "Bearer sk-test-key" + assert headers["Content-Type"] == "application/json" + + def test_validate_environment_raises_without_key(self): + with patch( + "litellm.llms.dashscope.image_generation.transformation.get_secret_str", + return_value=None, + ): + with pytest.raises(ValueError, match="DASHSCOPE_API_KEY"): + self.cfg.validate_environment( + headers={}, + model="qwen-image-2.0", + messages=[], + optional_params={}, + litellm_params={}, + api_key=None, + ) + + def test_transform_request_structure(self): + req = self.cfg.transform_image_generation_request( + model="qwen-image-2.0", + prompt="a puppy on green grass", + optional_params={"size": "1024*1024"}, + litellm_params={}, + headers={}, + ) + assert req["model"] == "qwen-image-2.0" + messages = req["input"]["messages"] + assert len(messages) == 1 + assert messages[0]["role"] == "user" + assert messages[0]["content"][0]["text"] == "a puppy on green grass" + assert req["parameters"]["size"] == "1024*1024" + + def test_transform_request_empty_params(self): + req = self.cfg.transform_image_generation_request( + model="qwen-image-2.0-pro", + prompt="sunset over the ocean", + optional_params={}, + litellm_params={}, + headers={}, + ) + assert req["parameters"] == {} + + # --------------------------------------------------------------------------- + # 4. Response transformation + # --------------------------------------------------------------------------- + + def _make_mock_response(self, image_url: str) -> httpx.Response: + body = { + "status_code": 200, + "request_id": "test-request-id", + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": [{"image": image_url}], + }, + } + ] + }, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "width": 1024, + "height": 1024, + "image_count": 1, + }, + } + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 200 + mock_resp.headers = {} + mock_resp.json.return_value = body + return mock_resp + + def test_transform_response_extracts_url(self): + image_url = "https://example.oss.aliyuncs.com/generated/test.png" + mock_resp = self._make_mock_response(image_url) + model_response = ImageResponse() + result = self.cfg.transform_image_generation_response( + model="qwen-image-2.0", + raw_response=mock_resp, + model_response=model_response, + logging_obj=MagicMock(), + request_data={}, + optional_params={}, + litellm_params={}, + encoding=None, + ) + assert result.data is not None + assert len(result.data) == 1 + assert result.data[0].url == image_url + + def test_transform_response_multiple_images(self): + body = { + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": [{"image": "https://example.com/img1.png"}], + }, + }, + { + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": [{"image": "https://example.com/img2.png"}], + }, + }, + ] + }, + "usage": {}, + } + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 200 + mock_resp.headers = {} + mock_resp.json.return_value = body + + model_response = ImageResponse() + result = self.cfg.transform_image_generation_response( + model="qwen-image-2.0", + raw_response=mock_resp, + model_response=model_response, + logging_obj=MagicMock(), + request_data={}, + optional_params={}, + litellm_params={}, + encoding=None, + ) + assert len(result.data) == 2 + assert result.data[0].url == "https://example.com/img1.png" + assert result.data[1].url == "https://example.com/img2.png" + + def test_transform_response_raises_on_non_200_status(self): + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 400 + mock_resp.headers = {} + mock_resp.text = '{"code":"InvalidParameter","message":"Size not supported"}' + mock_resp.json.return_value = { + "code": "InvalidParameter", + "message": "Size not supported", + } + + with pytest.raises(Exception): + self.cfg.transform_image_generation_response( + model="qwen-image-2.0", + raw_response=mock_resp, + model_response=ImageResponse(), + logging_obj=MagicMock(), + request_data={}, + optional_params={}, + litellm_params={}, + encoding=None, + ) + + def test_transform_response_raises_on_api_error_body(self): + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 200 + mock_resp.headers = {} + mock_resp.json.return_value = { + "code": "InvalidParameter", + "message": "Size not supported", + } + + with pytest.raises(Exception): + self.cfg.transform_image_generation_response( + model="qwen-image-2.0", + raw_response=mock_resp, + model_response=ImageResponse(), + logging_obj=MagicMock(), + request_data={}, + optional_params={}, + litellm_params={}, + encoding=None, + ) + + # --------------------------------------------------------------------------- + # 5. OpenAI → DashScope parameter mapping + # --------------------------------------------------------------------------- + + def test_map_openai_params_size_conversion(self): + mapped = self.cfg.map_openai_params( + non_default_params={"size": "1024x1024"}, + optional_params={}, + model="qwen-image-2.0", + drop_params=False, + ) + assert mapped["size"] == "1024*1024" + + def test_map_openai_params_n_to_image_count(self): + mapped = self.cfg.map_openai_params( + non_default_params={"n": 2}, + optional_params={}, + model="qwen-image-2.0", + drop_params=False, + ) + assert mapped["image_count"] == 2 + + def test_map_openai_params_unknown_size_uses_asterisk(self): + mapped = self.cfg.map_openai_params( + non_default_params={"size": "768x768"}, + optional_params={}, + model="qwen-image-2.0", + drop_params=False, + ) + assert mapped["size"] == "768*768" + + @pytest.mark.parametrize( + "openai_size, expected", + [ + ("256x256", "256*256"), + ("512x512", "512*512"), + ("1024x1024", "1024*1024"), + ("1792x1024", "1792*1024"), + ("1024x1792", "1024*1792"), + ("2048x2048", "2048*2048"), + ], + ) + def test_map_openai_params_size_table(self, openai_size: str, expected: str): + mapped = self.cfg.map_openai_params( + non_default_params={"size": openai_size}, + optional_params={}, + model="qwen-image-2.0", + drop_params=False, + ) + assert mapped["size"] == expected + + +# --------------------------------------------------------------------------- +# 6. End-to-end flow via litellm.image_generation (HTTP mocked) +# --------------------------------------------------------------------------- + + +def test_litellm_image_generation_dashscope_end_to_end(): + mock_response_body = { + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": [ + { + "image": "https://dashscope-result.oss.aliyuncs.com/test.png" + } + ], + }, + } + ] + }, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "width": 1024, + "height": 1024, + "image_count": 1, + }, + } + + with patch( + "litellm.llms.custom_httpx.llm_http_handler.HTTPHandler.post" + ) as mock_post: + mock_http_response = MagicMock() + mock_http_response.json.return_value = mock_response_body + mock_http_response.status_code = 200 + mock_http_response.headers = {} + mock_post.return_value = mock_http_response + + response = litellm.image_generation( + model="dashscope/qwen-image-2.0", + prompt="a puppy playing on green grass", + api_key="sk-test-key", + size="1024x1024", + ) + + assert response is not None + assert response.data is not None + assert len(response.data) == 1 + assert ( + response.data[0].url == "https://dashscope-result.oss.aliyuncs.com/test.png" + ) + + # Verify the HTTP call was made to the DashScope endpoint + call_args = mock_post.call_args + called_url = ( + call_args[0][0] if call_args[0] else call_args.kwargs.get("url", "") + ) + assert "dashscope" in called_url or "aliyuncs" in called_url + + # Verify request body contains DashScope format + call_kwargs = call_args[1] if call_args[1] else {} + if "json" in call_kwargs: + body = call_kwargs["json"] + assert "input" in body + assert "messages" in body["input"] diff --git a/tests/test_litellm/test_dockerfile_non_root.py b/tests/test_litellm/test_dockerfile_non_root.py new file mode 100644 index 00000000000..694da6368e7 --- /dev/null +++ b/tests/test_litellm/test_dockerfile_non_root.py @@ -0,0 +1,54 @@ +""" +Static checks on docker/Dockerfile.non_root. + +The non_root image is intended for deployment into hardened Kubernetes +clusters where `securityContext.runAsNonRoot: true` is enforced. The +kubelet validates non-root status by parsing the image's USER field as +an integer — a string name like "nobody" is rejected with +CreateContainerConfigError because the kubelet cannot resolve +/etc/passwd inside the image at admission time. +""" + +import os +import re + +import pytest + +DOCKERFILE_PATH = os.path.join( + os.path.dirname(__file__), + "..", + "..", + "docker", + "Dockerfile.non_root", +) + + +def _final_user_directive(dockerfile_text: str) -> str: + """Return the value of the last `USER` directive in the file.""" + matches = re.findall(r"^USER\s+(\S+)\s*$", dockerfile_text, re.MULTILINE) + assert matches, "Dockerfile.non_root has no USER directive" + return matches[-1] + + +@pytest.mark.skipif( + not os.path.exists(DOCKERFILE_PATH), + reason="Dockerfile.non_root not present in this checkout", +) +def test_final_user_directive_is_numeric(): + """The runtime USER must be a numeric UID so kubelet's runAsNonRoot + admission check (strconv.Atoi) succeeds.""" + with open(DOCKERFILE_PATH, "r", encoding="utf-8") as f: + contents = f.read() + + final_user = _final_user_directive(contents) + + assert final_user.isdigit(), ( + f"Dockerfile.non_root final USER is {final_user!r}; must be a numeric UID " + "so Kubernetes' runAsNonRoot admission check can verify non-root status. " + "See https://kubernetes.io/docs/tasks/configure-pod-container/security-context/" + ) + + assert int(final_user) != 0, ( + f"Dockerfile.non_root final USER is {final_user} (root); the non_root image " + "must run as a non-zero UID." + ) diff --git a/tests/test_litellm/test_router_order_fallback.py b/tests/test_litellm/test_router_order_fallback.py index 760766a7461..d5fa4962356 100644 --- a/tests/test_litellm/test_router_order_fallback.py +++ b/tests/test_litellm/test_router_order_fallback.py @@ -329,3 +329,39 @@ async def test_router_order_fallback_with_non_standard_fallbacks(): fallbacks=["fallback-model"], # non-standard format, passed per-request ) assert response._hidden_params["model_id"] == "fallback" + + +@pytest.mark.asyncio +async def test_router_order_fallback_with_wildcard_model_group(): + """Wildcard model groups should also advance across order levels.""" + router = Router( + model_list=[ + { + "model_name": "openai/*", + "litellm_params": { + "model": "openai/*", + "api_key": "bad", + "mock_response": Exception("fail order 1"), + "order": 1, + }, + "model_info": {"id": "1"}, + }, + { + "model_name": "openai/*", + "litellm_params": { + "model": "openai/*", + "api_key": "good", + "mock_response": "success from wildcard order 2", + "order": 2, + }, + "model_info": {"id": "2"}, + }, + ], + num_retries=0, + ) + + response = await router.acompletion( + model="openai/gpt-4.1-mini", + messages=[{"role": "user", "content": "hi"}], + ) + assert response._hidden_params["model_id"] == "2" diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 67b62696196..c475d6461b8 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -2794,6 +2794,37 @@ def test_model_info_for_openrouter_kimi_k2_5(): print("openrouter kimi-k2.5 model info", model_info) +def test_gemini_embedding_2_ga_in_cost_map(): + """GA gemini-embedding-2 entries align with preview multimodal unit pricing.""" + import json + from pathlib import Path + + json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json" + with open(json_path) as f: + model_cost = json.load(f) + + for key, provider in ( + ("gemini/gemini-embedding-2", "gemini"), + ("vertex_ai/gemini-embedding-2", "vertex_ai"), + ("gemini-embedding-2", "vertex_ai-embedding-models"), + ): + info = model_cost.get(key) + assert ( + info is not None + ), f"{key} missing from model_prices_and_context_window.json" + assert info["litellm_provider"] == provider + assert info.get("mode") == "embedding" + assert info.get("supports_multimodal") is True + assert info.get("input_cost_per_token") == 2e-07 + assert info.get("input_cost_per_image") == 0.00012 + assert info.get("input_cost_per_audio_per_second") == 0.00016 + assert info.get("input_cost_per_video_per_second") == 0.00079 + if provider in ("vertex_ai-embedding-models", "vertex_ai"): + assert info.get("uses_embed_content") is True, ( + f"{key} must have uses_embed_content=true for correct Vertex AI routing" + ) + + def test_gemini_lyria_3_preview_models_in_cost_map(): import json from pathlib import Path diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 909b079e6a5..302a3a02f1d 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -60,6 +60,7 @@ export interface TeamMembership { team_id: string; budget_id: string; spend: number; + total_spend: number | null; litellm_budget_table: { budget_id: string; soft_budget: number | null; @@ -69,6 +70,7 @@ export interface TeamMembership { rpm_limit: number | null; model_max_budget: Record | null; budget_duration: string | null; + budget_reset_at: string | null; allowed_models?: string[] | null; }; } diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx index e880aa49f65..1f2046fb90a 100644 --- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx @@ -1,6 +1,7 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { Member } from "@/components/networking"; +import { formatBudgetReset } from "@/utils/budgetUtils"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles"; import { InfoCircleOutlined } from "@ant-design/icons"; @@ -45,11 +46,16 @@ export default function TeamMemberTab({ return "0"; }; - // Helper function to get spend for a user - const getUserSpend = (userId: string | null): number | null => { + const getUserCurrentCycleSpend = (userId: string | null): number => { if (!userId) return 0; const membership = teamData.team_memberships.find((tm) => tm.user_id === userId); - return membership?.spend || 0; + return membership?.spend ?? 0; + }; + + const getUserTotalSpend = (userId: string | null): number => { + if (!userId) return 0; + const membership = teamData.team_memberships.find((tm) => tm.user_id === userId); + return membership?.total_spend ?? 0; }; const getUserBudget = (userId: string | null): string | null => { @@ -89,6 +95,12 @@ export default function TeamMemberTab({ return models && models.length > 0 ? models : null; }; + const getUserBudgetReset = (userId: string | null): string | null => { + if (!userId) return null; + const membership = teamData.team_memberships.find((tm) => tm.user_id === userId); + return formatBudgetReset(membership?.litellm_budget_table?.budget_reset_at); + }; + const extraColumns: ColumnsType = [ { title: ( @@ -124,15 +136,29 @@ export default function TeamMemberTab({ { title: ( - Team Member Spend (USD) - + Current Cycle Spend (USD) + ), key: "spend", render: (_: unknown, record: Member) => ( - ${formatNumberWithCommas(getUserSpend(record.user_id), 4)} + ${formatNumberWithCommas(getUserCurrentCycleSpend(record.user_id), 4)} + ), + }, + { + title: ( + + Total Spend (USD) + + + + + ), + key: "total_spend", + render: (_: unknown, record: Member) => ( + ${formatNumberWithCommas(getUserTotalSpend(record.user_id), 4)} ), }, { @@ -147,6 +173,18 @@ export default function TeamMemberTab({ ); }, }, + { + title: "Budget Reset", + key: "budget_reset", + render: (_: unknown, record: Member) => { + const reset = getUserBudgetReset(record.user_id); + return reset ? ( + {reset} + ) : ( + + ); + }, + }, { title: ( diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 97e24cb516a..8205c0b4b86 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -243,6 +243,7 @@ export default function SpendLogsTable({ allTeams, handleFilterChange, handleFilterReset: handleFilterResetFromHook, + refetchWithFilters, } = useLogFilterLogic({ logs: logsData, accessToken, @@ -363,7 +364,14 @@ export default function SpendLogsTable({ // Add this function to handle manual refresh const handleRefresh = () => { - logs.refetch(); + if (hasBackendFilters) { + // When backend filters (e.g. Key Alias) are active the main TanStack Query + // is disabled and its params do not include filter values like key_alias. + // Route through the filter-aware refetch so all active filters are preserved. + refetchWithFilters(); + } else { + logs.refetch(); + } }; const handleRowClick = (log: LogEntry) => { diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx index 8c88de49d0d..a538872bfd9 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx @@ -71,6 +71,14 @@ export function useLogFilterLogic({ const [filters, setFilters] = useState(defaultFilters); const [backendFilteredLogs, setBackendFilteredLogs] = useState(null); const lastSearchTimestamp = useRef(0); + + // Refs that always hold the latest filters and hasBackendFilters values. + // The sort/page/time effect below intentionally omits these from its dep array + // to avoid double-fetches when a filter changes; reading from refs instead of + // the closure prevents stale-closure bugs (e.g. the effect using a snapshot of + // filters taken before the user selected Key Alias). + const filtersRef = useRef(filters); + const hasBackendFiltersRef = useRef(false); const performSearch = useCallback( async (filters: LogFilterState, page = 1) => { if (!accessToken) return; @@ -152,18 +160,25 @@ export function useLogFilterLogic({ [filters], ); + // Keep refs in sync on every render so the sort/page/time effect always reads + // the latest values without those values being in its dep array. + useEffect(() => { + filtersRef.current = filters; + hasBackendFiltersRef.current = hasBackendFilters; + }, [filters, hasBackendFilters]); + // Refetch when sort, page, or time range changes (backend filters use their own fetch, not the main query) useEffect(() => { - if (hasBackendFilters && accessToken) { + if (hasBackendFiltersRef.current && accessToken) { // Cancel any pending debounced search to prevent it from overwriting this page's results debouncedSearch.cancel(); - performSearch(filters, currentPage); + performSearch(filtersRef.current, currentPage); } - // Intentionally omitted from deps: - // - `filters` / `debouncedSearch` / `performSearch`: filter changes are handled by - // handleFilterChange → debouncedSearch; adding them here would double-fetch on filter apply. - // - `hasBackendFilters` / `accessToken`: stable across sort/page/time changes; including them - // would cause spurious re-runs when the filter state first becomes active. + // filters / hasBackendFilters are read via refs — avoids stale-closure bugs + // when sort/page/time changes after a filter (e.g. Key Alias) was set. + // debouncedSearch / performSearch: filter changes go through handleFilterChange + // → debouncedSearch; adding them here would cause double-fetches on filter apply. + // accessToken: stable across sort/page/time changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [sortBy, sortOrder, currentPage, startTime, endTime, isCustomDate]); @@ -299,6 +314,20 @@ export function useLogFilterLogic({ setCurrentPage(1); }; + // Expose a filter-aware refetch so callers (e.g. the manual Fetch button) can + // refresh results while keeping all active backend filters intact. The plain + // `logs.refetch()` in the parent only re-runs the main TanStack Query, which + // does not carry key_alias or other backend-only filter params. + const refetchWithFilters = useCallback( + (page = currentPage) => { + if (hasBackendFilters && accessToken) { + debouncedSearch.cancel(); + performSearch(filters, page); + } + }, + [hasBackendFilters, accessToken, filters, currentPage, performSearch, debouncedSearch], + ); + return { filters, filteredLogs, @@ -306,5 +335,6 @@ export function useLogFilterLogic({ allTeams, handleFilterChange, handleFilterReset, + refetchWithFilters, }; } diff --git a/ui/litellm-dashboard/src/utils/budgetUtils.ts b/ui/litellm-dashboard/src/utils/budgetUtils.ts new file mode 100644 index 00000000000..3d3278db88f --- /dev/null +++ b/ui/litellm-dashboard/src/utils/budgetUtils.ts @@ -0,0 +1,8 @@ +import dayjs from "dayjs"; + +export function formatBudgetReset(iso: string | null | undefined): string | null { + if (!iso) return null; + const resetDate = dayjs(iso); + if (!resetDate.isValid()) return null; + return resetDate.format("MMM D, YYYY"); +} diff --git a/uv.lock b/uv.lock index d99da67fb82..d04df0ad4fa 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-04-16T02:00:05.930008Z" +exclude-newer = "2026-04-21T00:00:09.504288Z" exclude-newer-span = "P3D" [manifest] @@ -3085,7 +3085,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.83.10" +version = "1.83.13" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -3418,7 +3418,7 @@ source = { editable = "enterprise" } [[package]] name = "litellm-proxy-extras" -version = "0.4.67" +version = "0.4.68" source = { editable = "litellm-proxy-extras" } [[package]]